chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { createEnvMock, workflowAuthzMockFns } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
ensureWorkflowAccessMock,
|
||||
setWorkflowVariablesMock,
|
||||
recordAuditMock,
|
||||
executeWorkflowMock,
|
||||
getExecutionStateForWorkflowMock,
|
||||
getLatestExecutionStateWithExecutionIdMock,
|
||||
} = vi.hoisted(() => ({
|
||||
ensureWorkflowAccessMock: vi.fn(),
|
||||
setWorkflowVariablesMock: vi.fn(),
|
||||
recordAuditMock: vi.fn(),
|
||||
executeWorkflowMock: vi.fn(),
|
||||
getExecutionStateForWorkflowMock: vi.fn(),
|
||||
getLatestExecutionStateWithExecutionIdMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/audit', () => ({
|
||||
AuditAction: { WORKFLOW_VARIABLES_UPDATED: 'WORKFLOW_VARIABLES_UPDATED' },
|
||||
AuditResourceType: { WORKFLOW: 'WORKFLOW' },
|
||||
recordAudit: recordAuditMock,
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {},
|
||||
workflow: {},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-key/orchestration', () => ({
|
||||
performCreateWorkspaceApiKey: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/env', () => createEnvMock({ INTERNAL_API_SECRET: 'secret' }))
|
||||
|
||||
vi.mock('@/lib/core/utils/request', () => ({
|
||||
generateRequestId: () => 'request-1',
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/utils/urls', () => ({
|
||||
getSocketServerUrl: () => 'http://socket.test',
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/executor/execute-workflow', () => ({
|
||||
executeWorkflow: executeWorkflowMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/executor/execution-state', () => ({
|
||||
getExecutionStateForWorkflow: getExecutionStateForWorkflowMock,
|
||||
getLatestExecutionStateWithExecutionId: getLatestExecutionStateWithExecutionIdMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/orchestration', () => ({
|
||||
performCreateFolder: vi.fn(),
|
||||
performCreateWorkflow: vi.fn(),
|
||||
performDeleteFolder: vi.fn(),
|
||||
performDeleteWorkflow: vi.fn(),
|
||||
performUpdateFolder: vi.fn(),
|
||||
performUpdateWorkflow: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/persistence/utils', () => ({
|
||||
loadWorkflowFromNormalizedTables: vi.fn(),
|
||||
saveWorkflowToNormalizedTables: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/sanitization/json-sanitizer', () => ({
|
||||
sanitizeForCopilot: vi.fn((state) => state),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/utils', () => ({
|
||||
listFolders: vi.fn(),
|
||||
setWorkflowVariables: setWorkflowVariablesMock,
|
||||
verifyFolderWorkspace: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/executor/utils/errors', () => ({
|
||||
hasExecutionResult: vi.fn(() => false),
|
||||
}))
|
||||
|
||||
vi.mock('../access', () => ({
|
||||
ensureWorkflowAccess: ensureWorkflowAccessMock,
|
||||
ensureWorkspaceAccess: vi.fn(),
|
||||
getDefaultWorkspaceId: vi.fn(),
|
||||
}))
|
||||
|
||||
import { performUpdateWorkflow } from '@/lib/workflows/orchestration'
|
||||
import { verifyFolderWorkspace } from '@/lib/workflows/utils'
|
||||
import {
|
||||
executeMoveWorkflow,
|
||||
executeRunFromBlock,
|
||||
executeSetGlobalWorkflowVariables,
|
||||
} from './mutations'
|
||||
|
||||
const performUpdateWorkflowMock = vi.mocked(performUpdateWorkflow)
|
||||
const verifyFolderWorkspaceMock = vi.mocked(verifyFolderWorkspace)
|
||||
|
||||
describe('executeSetGlobalWorkflowVariables', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
global.fetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })) as typeof fetch
|
||||
ensureWorkflowAccessMock.mockResolvedValue({
|
||||
workflow: {
|
||||
id: 'workflow-1',
|
||||
variables: {},
|
||||
},
|
||||
})
|
||||
setWorkflowVariablesMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('persists variable changes and notifies clients that workflow state changed', async () => {
|
||||
const result = await executeSetGlobalWorkflowVariables(
|
||||
{
|
||||
workflowId: 'workflow-1',
|
||||
operations: [{ operation: 'add', name: 'threshold', type: 'number', value: '5' }],
|
||||
},
|
||||
{ userId: 'user-1' } as any
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const [, variables] = setWorkflowVariablesMock.mock.calls[0]
|
||||
expect(Object.values(variables)).toEqual([
|
||||
expect.objectContaining({
|
||||
workflowId: 'workflow-1',
|
||||
name: 'threshold',
|
||||
type: 'number',
|
||||
value: 5,
|
||||
}),
|
||||
])
|
||||
expect(global.fetch).toHaveBeenCalledWith('http://socket.test/api/workflow-updated', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': 'secret',
|
||||
},
|
||||
body: JSON.stringify({ workflowId: 'workflow-1' }),
|
||||
})
|
||||
expect(recordAuditMock).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('lock enforcement', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
global.fetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })) as typeof fetch
|
||||
workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined)
|
||||
workflowAuthzMockFns.mockAssertFolderMutable.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('does not persist variable changes when the workflow is locked', async () => {
|
||||
ensureWorkflowAccessMock.mockResolvedValue({
|
||||
workflow: { id: 'workflow-1', variables: {} },
|
||||
})
|
||||
workflowAuthzMockFns.mockAssertWorkflowMutable.mockRejectedValueOnce(
|
||||
new Error('Workflow is locked')
|
||||
)
|
||||
|
||||
const result = await executeSetGlobalWorkflowVariables(
|
||||
{
|
||||
workflowId: 'workflow-1',
|
||||
operations: [{ operation: 'add', name: 'threshold', type: 'number', value: '5' }],
|
||||
},
|
||||
{ userId: 'user-1' } as any
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Workflow is locked')
|
||||
expect(setWorkflowVariablesMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not move a workflow into a locked target folder', async () => {
|
||||
ensureWorkflowAccessMock.mockResolvedValue({
|
||||
workspaceId: 'workspace-1',
|
||||
workflow: { id: 'workflow-1', name: 'WF', folderId: null },
|
||||
})
|
||||
verifyFolderWorkspaceMock.mockResolvedValue(true)
|
||||
workflowAuthzMockFns.mockAssertFolderMutable.mockRejectedValueOnce(
|
||||
new Error('Folder is locked')
|
||||
)
|
||||
|
||||
const result = await executeMoveWorkflow(
|
||||
{ workflowIds: ['workflow-1'], folderId: 'locked-folder' },
|
||||
{ userId: 'user-1' } as any
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Folder is locked')
|
||||
expect(performUpdateWorkflowMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('executeRunFromBlock', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
ensureWorkflowAccessMock.mockResolvedValue({
|
||||
workflow: {
|
||||
id: 'workflow-1',
|
||||
userId: 'owner-1',
|
||||
workspaceId: 'workspace-1',
|
||||
variables: {},
|
||||
},
|
||||
})
|
||||
executeWorkflowMock.mockResolvedValue({
|
||||
success: true,
|
||||
output: {},
|
||||
logs: [],
|
||||
metadata: { executionId: 'new-execution-1' },
|
||||
})
|
||||
})
|
||||
|
||||
it('passes source execution lineage for stored run-from-block snapshots', async () => {
|
||||
const sourceSnapshot = {
|
||||
blockStates: {
|
||||
upstream: {
|
||||
output: {
|
||||
__simLargeValueRef: true,
|
||||
version: 1,
|
||||
id: 'lv_ABCDEFGHIJKL',
|
||||
kind: 'object',
|
||||
size: 10,
|
||||
key: 'execution/workspace-1/workflow-1/source-execution-1/large-value-lv_ABCDEFGHIJKL.json',
|
||||
executionId: 'source-execution-1',
|
||||
},
|
||||
},
|
||||
},
|
||||
executedBlocks: [],
|
||||
blockLogs: [],
|
||||
decisions: {},
|
||||
completedLoops: [],
|
||||
activeExecutionPath: [],
|
||||
}
|
||||
getExecutionStateForWorkflowMock.mockResolvedValue(sourceSnapshot)
|
||||
|
||||
const result = await executeRunFromBlock(
|
||||
{
|
||||
workflowId: 'workflow-1',
|
||||
startBlockId: 'agent-1',
|
||||
executionId: 'source-execution-1',
|
||||
},
|
||||
{ userId: 'user-1' } as any
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(executeWorkflowMock).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
'request-1',
|
||||
undefined,
|
||||
'user-1',
|
||||
expect.objectContaining({
|
||||
runFromBlock: {
|
||||
startBlockId: 'agent-1',
|
||||
sourceSnapshot,
|
||||
sourceExecutionId: 'source-execution-1',
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
workflowsPersistenceUtilsMock,
|
||||
workflowsPersistenceUtilsMockFns,
|
||||
workflowsUtilsMock,
|
||||
workflowsUtilsMockFns,
|
||||
} from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
ensureWorkflowAccessMock,
|
||||
getEffectiveBlockOutputPathsMock,
|
||||
hasTriggerCapabilityMock,
|
||||
getBlockMock,
|
||||
} = vi.hoisted(() => ({
|
||||
ensureWorkflowAccessMock: vi.fn(),
|
||||
getEffectiveBlockOutputPathsMock: vi.fn(),
|
||||
hasTriggerCapabilityMock: vi.fn(),
|
||||
getBlockMock: vi.fn(),
|
||||
}))
|
||||
|
||||
const loadWorkflowFromNormalizedTablesMock =
|
||||
workflowsPersistenceUtilsMockFns.mockLoadWorkflowFromNormalizedTables
|
||||
const getWorkflowByIdMock = workflowsUtilsMockFns.mockGetWorkflowById
|
||||
|
||||
vi.mock('../access', () => ({
|
||||
ensureWorkflowAccess: ensureWorkflowAccessMock,
|
||||
ensureWorkspaceAccess: vi.fn(),
|
||||
getDefaultWorkspaceId: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock)
|
||||
|
||||
vi.mock('@/lib/workflows/blocks/block-outputs', () => ({
|
||||
getEffectiveBlockOutputPaths: getEffectiveBlockOutputPathsMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/triggers/trigger-utils', () => ({
|
||||
hasTriggerCapability: hasTriggerCapabilityMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/blocks/registry', () => ({
|
||||
getBlock: getBlockMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
|
||||
|
||||
import { executeGetBlockOutputs } from './queries'
|
||||
|
||||
describe('executeGetBlockOutputs', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
ensureWorkflowAccessMock.mockResolvedValue({
|
||||
workflow: { id: 'wf-1', userId: 'user-1', workspaceId: 'ws-1' },
|
||||
})
|
||||
getWorkflowByIdMock.mockResolvedValue({ variables: {} })
|
||||
getBlockMock.mockReturnValue({ category: 'core' })
|
||||
hasTriggerCapabilityMock.mockReturnValue(false)
|
||||
getEffectiveBlockOutputPathsMock.mockReturnValue(['content'])
|
||||
})
|
||||
|
||||
it('returns display outputs and block-relative outputs for chat deployment', async () => {
|
||||
loadWorkflowFromNormalizedTablesMock.mockResolvedValue({
|
||||
blocks: {
|
||||
'agent-1': {
|
||||
type: 'agent',
|
||||
name: 'Support Agent',
|
||||
subBlocks: {},
|
||||
},
|
||||
'loop-1': {
|
||||
type: 'loop',
|
||||
name: 'Items Loop',
|
||||
},
|
||||
},
|
||||
loops: {
|
||||
'loop-1': {
|
||||
loopType: 'forEach',
|
||||
},
|
||||
},
|
||||
parallels: {},
|
||||
})
|
||||
|
||||
const result = await executeGetBlockOutputs({ blockIds: ['agent-1', 'loop-1'] }, {
|
||||
workflowId: 'wf-1',
|
||||
userId: 'user-1',
|
||||
} as any)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output).toEqual({
|
||||
blocks: [
|
||||
{
|
||||
blockId: 'agent-1',
|
||||
blockName: 'Support Agent',
|
||||
blockType: 'agent',
|
||||
outputs: ['supportagent.content'],
|
||||
relativeOutputs: ['content'],
|
||||
triggerMode: undefined,
|
||||
},
|
||||
{
|
||||
blockId: 'loop-1',
|
||||
blockName: 'Items Loop',
|
||||
blockType: 'loop',
|
||||
outputs: [],
|
||||
relativeOutputs: [],
|
||||
insideSubflowOutputs: ['itemsloop.index', 'itemsloop.currentItem', 'itemsloop.items'],
|
||||
outsideSubflowOutputs: ['itemsloop.results'],
|
||||
relativeInsideSubflowOutputs: ['index', 'currentItem', 'items'],
|
||||
relativeOutsideSubflowOutputs: ['results'],
|
||||
triggerMode: undefined,
|
||||
},
|
||||
],
|
||||
variables: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,540 @@
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks'
|
||||
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
|
||||
import { formatNormalizedWorkflowForCopilot } from '@/lib/copilot/tools/shared/workflow-utils'
|
||||
import { mcpService } from '@/lib/mcp/service'
|
||||
import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace'
|
||||
import { getEffectiveBlockOutputPaths } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator'
|
||||
import { getBlockReferenceTags } from '@/lib/workflows/blocks/block-reference-tags'
|
||||
import { listCustomTools } from '@/lib/workflows/custom-tools/operations'
|
||||
import {
|
||||
loadDeployedWorkflowState,
|
||||
loadWorkflowFromNormalizedTables,
|
||||
} from '@/lib/workflows/persistence/utils'
|
||||
import { resolveTriggerRunOptions, toPublicRunOption } from '@/lib/workflows/triggers/run-options'
|
||||
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { getWorkflowById } from '@/lib/workflows/utils'
|
||||
import { listUserWorkspaces } from '@/lib/workspaces/utils'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import type { Loop, Parallel } from '@/stores/workflows/workflow/types'
|
||||
import { ensureWorkflowAccess } from '../access'
|
||||
import type {
|
||||
GetBlockOutputsParams,
|
||||
GetBlockUpstreamReferencesParams,
|
||||
GetDeployedWorkflowStateParams,
|
||||
GetWorkflowDataParams,
|
||||
GetWorkflowRunOptionsParams,
|
||||
} from '../param-types'
|
||||
|
||||
export async function executeListUserWorkspaces(
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
try {
|
||||
const workspaces = await listUserWorkspaces(context.userId)
|
||||
|
||||
return { success: true, output: { workspaces } }
|
||||
} catch (error) {
|
||||
return { success: false, error: toError(error).message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeGetWorkflowRunOptions(
|
||||
params: GetWorkflowRunOptionsParams,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
try {
|
||||
const workflowId = params.workflowId || context.workflowId
|
||||
if (!workflowId) {
|
||||
return { success: false, error: 'workflowId is required' }
|
||||
}
|
||||
|
||||
await ensureWorkflowAccess(workflowId, context.userId)
|
||||
|
||||
const normalized = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
if (!normalized) {
|
||||
return { success: false, error: `Workflow ${workflowId} has no saved state` }
|
||||
}
|
||||
|
||||
const merged = mergeSubblockStateWithValues(normalized.blocks)
|
||||
const options = resolveTriggerRunOptions(merged, normalized.edges)
|
||||
|
||||
if (options.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
workflowId,
|
||||
default: null,
|
||||
triggers: [],
|
||||
message:
|
||||
'No runnable trigger blocks found. Add a Start/API/Input/Chat trigger or an external (webhook/integration) trigger before running.',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const guidanceFor = (kind: string): string => {
|
||||
switch (kind) {
|
||||
case 'fields':
|
||||
return 'Build workflow_input matching inputSchema. Copy mockPayload only if you have no better values.'
|
||||
case 'event_payload':
|
||||
return 'Construct an event payload matching inputSchema, or run with useMockPayload: true if you cannot build one.'
|
||||
case 'chat':
|
||||
return 'Provide workflow_input shaped like { "input": "<message>" }.'
|
||||
default:
|
||||
return 'No input required.'
|
||||
}
|
||||
}
|
||||
|
||||
const triggers = options.map((option) => {
|
||||
const pub = toPublicRunOption(option)
|
||||
const callExample =
|
||||
pub.inputKind === 'none'
|
||||
? { triggerBlockId: pub.triggerBlockId }
|
||||
: { triggerBlockId: pub.triggerBlockId, workflow_input: pub.mockPayload }
|
||||
return { ...pub, guidance: guidanceFor(pub.inputKind), callExample }
|
||||
})
|
||||
|
||||
const defaultOption = options.find((option) => option.isDefault)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
workflowId,
|
||||
default: defaultOption
|
||||
? {
|
||||
triggerBlockId: defaultOption.triggerBlockId,
|
||||
reason: `Highest-priority trigger (${defaultOption.blockName})`,
|
||||
}
|
||||
: null,
|
||||
triggers,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: toError(error).message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeGetWorkflowData(
|
||||
params: GetWorkflowDataParams,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
try {
|
||||
const workflowId = params.workflowId || context.workflowId
|
||||
const dataType = params.data_type || params.dataType || ''
|
||||
if (!workflowId) {
|
||||
return { success: false, error: 'workflowId is required' }
|
||||
}
|
||||
if (!dataType) {
|
||||
return { success: false, error: 'data_type is required' }
|
||||
}
|
||||
|
||||
const { workflow: workflowRecord, workspaceId } = await ensureWorkflowAccess(
|
||||
workflowId,
|
||||
context.userId
|
||||
)
|
||||
|
||||
if (dataType === 'global_variables') {
|
||||
const variablesRecord = (workflowRecord.variables as Record<string, unknown>) || {}
|
||||
const variables = Object.values(variablesRecord).map((v) => {
|
||||
const variable = v as Record<string, unknown> | null
|
||||
return {
|
||||
id: String(variable?.id || ''),
|
||||
name: String(variable?.name || ''),
|
||||
value: variable?.value,
|
||||
}
|
||||
})
|
||||
return { success: true, output: { variables } }
|
||||
}
|
||||
|
||||
if (dataType === 'custom_tools') {
|
||||
if (!workspaceId) {
|
||||
return { success: false, error: 'workspaceId is required' }
|
||||
}
|
||||
const toolsRows = await listCustomTools({
|
||||
userId: context.userId,
|
||||
workspaceId,
|
||||
})
|
||||
|
||||
const customToolsData = toolsRows.map((tool) => {
|
||||
const schema = tool.schema as Record<string, unknown> | null
|
||||
const fn = (schema?.function ?? {}) as Record<string, unknown>
|
||||
return {
|
||||
id: String(tool.id || ''),
|
||||
title: String(tool.title || ''),
|
||||
functionName: String(fn.name || ''),
|
||||
description: String(fn.description || ''),
|
||||
parameters: fn.parameters,
|
||||
}
|
||||
})
|
||||
|
||||
return { success: true, output: { customTools: customToolsData } }
|
||||
}
|
||||
|
||||
if (dataType === 'mcp_tools') {
|
||||
if (!workspaceId) {
|
||||
return { success: false, error: 'workspaceId is required' }
|
||||
}
|
||||
const tools = await mcpService.discoverTools(context.userId, workspaceId, false)
|
||||
const mcpTools = tools.map((tool) => ({
|
||||
name: String(tool.name || ''),
|
||||
serverId: String(tool.serverId || ''),
|
||||
serverName: String(tool.serverName || ''),
|
||||
description: String(tool.description || ''),
|
||||
inputSchema: tool.inputSchema,
|
||||
}))
|
||||
return { success: true, output: { mcpTools } }
|
||||
}
|
||||
|
||||
if (dataType === 'files') {
|
||||
if (!workspaceId) {
|
||||
return { success: false, error: 'workspaceId is required' }
|
||||
}
|
||||
const files = await listWorkspaceFiles(workspaceId)
|
||||
const fileResults = files.map((file) => ({
|
||||
id: String(file.id || ''),
|
||||
name: String(file.name || ''),
|
||||
key: String(file.key || ''),
|
||||
path: String(file.path || ''),
|
||||
size: Number(file.size || 0),
|
||||
type: String(file.type || ''),
|
||||
uploadedAt: String(file.uploadedAt || ''),
|
||||
}))
|
||||
return { success: true, output: { files: fileResults } }
|
||||
}
|
||||
|
||||
return { success: false, error: `Unknown data_type: ${dataType}` }
|
||||
} catch (error) {
|
||||
return { success: false, error: toError(error).message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeGetBlockOutputs(
|
||||
params: GetBlockOutputsParams,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
try {
|
||||
const workflowId = params.workflowId || context.workflowId
|
||||
if (!workflowId) {
|
||||
return { success: false, error: 'workflowId is required' }
|
||||
}
|
||||
await ensureWorkflowAccess(workflowId, context.userId)
|
||||
|
||||
const normalized = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
if (!normalized) {
|
||||
return { success: false, error: 'Workflow has no normalized data' }
|
||||
}
|
||||
|
||||
const blocks = normalized.blocks || {}
|
||||
const loops = normalized.loops || {}
|
||||
const parallels = normalized.parallels || {}
|
||||
const blockIds =
|
||||
Array.isArray(params.blockIds) && params.blockIds.length > 0
|
||||
? params.blockIds
|
||||
: Object.keys(blocks)
|
||||
|
||||
const results: Array<{
|
||||
blockId: string
|
||||
blockName: string
|
||||
blockType: string
|
||||
outputs: string[]
|
||||
relativeOutputs?: string[]
|
||||
insideSubflowOutputs?: string[]
|
||||
outsideSubflowOutputs?: string[]
|
||||
relativeInsideSubflowOutputs?: string[]
|
||||
relativeOutsideSubflowOutputs?: string[]
|
||||
triggerMode?: boolean
|
||||
}> = []
|
||||
|
||||
for (const blockId of blockIds) {
|
||||
const block = blocks[blockId]
|
||||
if (!block?.type) continue
|
||||
const blockName = block.name || block.type
|
||||
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
const insidePaths = getSubflowInsidePaths(block.type, blockId, loops, parallels)
|
||||
results.push({
|
||||
blockId,
|
||||
blockName,
|
||||
blockType: block.type,
|
||||
outputs: [],
|
||||
relativeOutputs: [],
|
||||
insideSubflowOutputs: formatOutputsForDisplay(insidePaths, blockName),
|
||||
outsideSubflowOutputs: formatOutputsForDisplay(['results'], blockName),
|
||||
relativeInsideSubflowOutputs: insidePaths,
|
||||
relativeOutsideSubflowOutputs: ['results'],
|
||||
triggerMode: block.triggerMode,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const blockConfig = getBlock(block.type)
|
||||
const isTriggerCapable = blockConfig ? hasTriggerCapability(blockConfig) : false
|
||||
const triggerMode = Boolean(block.triggerMode && isTriggerCapable)
|
||||
const outputs = getEffectiveBlockOutputPaths(block.type, block.subBlocks, {
|
||||
triggerMode,
|
||||
preferToolOutputs: !triggerMode,
|
||||
})
|
||||
results.push({
|
||||
blockId,
|
||||
blockName,
|
||||
blockType: block.type,
|
||||
outputs: formatOutputsForDisplay(outputs, blockName),
|
||||
relativeOutputs: outputs,
|
||||
triggerMode: block.triggerMode,
|
||||
})
|
||||
}
|
||||
|
||||
const variables = await getWorkflowVariablesForTool(workflowId)
|
||||
|
||||
const payload = { blocks: results, variables }
|
||||
return { success: true, output: payload }
|
||||
} catch (error) {
|
||||
return { success: false, error: toError(error).message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeGetBlockUpstreamReferences(
|
||||
params: GetBlockUpstreamReferencesParams,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
try {
|
||||
const workflowId = params.workflowId || context.workflowId
|
||||
if (!workflowId) {
|
||||
return { success: false, error: 'workflowId is required' }
|
||||
}
|
||||
if (!Array.isArray(params.blockIds) || params.blockIds.length === 0) {
|
||||
return { success: false, error: 'blockIds array is required' }
|
||||
}
|
||||
await ensureWorkflowAccess(workflowId, context.userId)
|
||||
|
||||
const normalized = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
if (!normalized) {
|
||||
return { success: false, error: 'Workflow has no normalized data' }
|
||||
}
|
||||
|
||||
const blocks = normalized.blocks || {}
|
||||
const edges = normalized.edges || []
|
||||
const loops = normalized.loops || {}
|
||||
const parallels = normalized.parallels || {}
|
||||
|
||||
const graphEdges = edges.map((edge) => ({ source: edge.source, target: edge.target }))
|
||||
const variableOutputs = await getWorkflowVariablesForTool(workflowId)
|
||||
|
||||
interface AccessibleBlockEntry {
|
||||
blockId: string
|
||||
blockName: string
|
||||
blockType: string
|
||||
outputs: string[]
|
||||
triggerMode?: boolean
|
||||
accessContext?: 'inside' | 'outside'
|
||||
}
|
||||
|
||||
interface UpstreamReferenceResult {
|
||||
blockId: string
|
||||
blockName: string
|
||||
blockType: string
|
||||
accessibleBlocks: AccessibleBlockEntry[]
|
||||
insideSubflows: Array<{ blockId: string; blockName: string; blockType: string }>
|
||||
variables: Array<{ id: string; name: string; type: string; tag: string }>
|
||||
}
|
||||
|
||||
const results: UpstreamReferenceResult[] = []
|
||||
|
||||
for (const blockId of params.blockIds) {
|
||||
const targetBlock = blocks[blockId]
|
||||
if (!targetBlock) continue
|
||||
|
||||
const insideSubflows: Array<{ blockId: string; blockName: string; blockType: string }> = []
|
||||
const containingLoopIds = new Set<string>()
|
||||
const containingParallelIds = new Set<string>()
|
||||
|
||||
Object.values(loops).forEach((loop) => {
|
||||
if (loop?.nodes?.includes(blockId)) {
|
||||
containingLoopIds.add(loop.id)
|
||||
const loopBlock = blocks[loop.id]
|
||||
if (loopBlock) {
|
||||
insideSubflows.push({
|
||||
blockId: loop.id,
|
||||
blockName: loopBlock.name || loopBlock.type,
|
||||
blockType: 'loop',
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Object.values(parallels).forEach((parallel) => {
|
||||
if (parallel?.nodes?.includes(blockId)) {
|
||||
containingParallelIds.add(parallel.id)
|
||||
const parallelBlock = blocks[parallel.id]
|
||||
if (parallelBlock) {
|
||||
insideSubflows.push({
|
||||
blockId: parallel.id,
|
||||
blockName: parallelBlock.name || parallelBlock.type,
|
||||
blockType: 'parallel',
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const ancestorIds = BlockPathCalculator.findAllPathNodes(graphEdges, blockId)
|
||||
const accessibleIds = new Set<string>(ancestorIds)
|
||||
accessibleIds.add(blockId)
|
||||
|
||||
containingLoopIds.forEach((loopId) => accessibleIds.add(loopId))
|
||||
|
||||
containingParallelIds.forEach((parallelId) => accessibleIds.add(parallelId))
|
||||
|
||||
const accessibleBlocks: AccessibleBlockEntry[] = []
|
||||
|
||||
for (const accessibleBlockId of accessibleIds) {
|
||||
const block = blocks[accessibleBlockId]
|
||||
if (!block?.type) continue
|
||||
const canSelfReference = block.type === 'approval' || block.type === 'human_in_the_loop'
|
||||
if (accessibleBlockId === blockId && !canSelfReference) continue
|
||||
|
||||
const blockName = block.name || block.type
|
||||
let accessContext: 'inside' | 'outside' | undefined
|
||||
|
||||
let formattedOutputs: string[]
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
const isInside =
|
||||
(block.type === 'loop' && containingLoopIds.has(accessibleBlockId)) ||
|
||||
(block.type === 'parallel' && containingParallelIds.has(accessibleBlockId))
|
||||
accessContext = isInside ? 'inside' : 'outside'
|
||||
const outputPaths = isInside
|
||||
? getSubflowInsidePaths(block.type, accessibleBlockId, loops, parallels)
|
||||
: ['results']
|
||||
formattedOutputs = formatOutputsForDisplay(outputPaths, blockName)
|
||||
} else {
|
||||
formattedOutputs = getBlockReferenceTags({
|
||||
block: {
|
||||
id: accessibleBlockId,
|
||||
type: block.type,
|
||||
name: block.name,
|
||||
triggerMode: block.triggerMode,
|
||||
subBlocks: block.subBlocks,
|
||||
},
|
||||
currentBlockId: blockId,
|
||||
})
|
||||
}
|
||||
const entry: AccessibleBlockEntry = {
|
||||
blockId: accessibleBlockId,
|
||||
blockName,
|
||||
blockType: block.type,
|
||||
outputs: formattedOutputs,
|
||||
...(block.triggerMode ? { triggerMode: true } : {}),
|
||||
...(accessContext ? { accessContext } : {}),
|
||||
}
|
||||
accessibleBlocks.push(entry)
|
||||
}
|
||||
|
||||
results.push({
|
||||
blockId,
|
||||
blockName: targetBlock.name || targetBlock.type,
|
||||
blockType: targetBlock.type,
|
||||
accessibleBlocks,
|
||||
insideSubflows,
|
||||
variables: variableOutputs,
|
||||
})
|
||||
}
|
||||
|
||||
const payload = { results }
|
||||
return { success: true, output: payload }
|
||||
} catch (error) {
|
||||
return { success: false, error: toError(error).message }
|
||||
}
|
||||
}
|
||||
|
||||
async function getWorkflowVariablesForTool(
|
||||
workflowId: string
|
||||
): Promise<Array<{ id: string; name: string; type: string; tag: string }>> {
|
||||
const workflowRecord = await getWorkflowById(workflowId)
|
||||
|
||||
const variablesRecord = (workflowRecord?.variables as Record<string, unknown>) || {}
|
||||
return Object.values(variablesRecord)
|
||||
.filter((v): v is Record<string, unknown> => {
|
||||
if (!v || typeof v !== 'object') return false
|
||||
const variable = v as Record<string, unknown>
|
||||
return !!variable.name && String(variable.name).trim() !== ''
|
||||
})
|
||||
.map((v) => ({
|
||||
id: String(v.id || ''),
|
||||
name: String(v.name || ''),
|
||||
type: String(v.type || 'plain'),
|
||||
tag: `variable.${normalizeName(String(v.name || ''))}`,
|
||||
}))
|
||||
}
|
||||
|
||||
function getSubflowInsidePaths(
|
||||
blockType: 'loop' | 'parallel',
|
||||
blockId: string,
|
||||
loops: Record<string, Loop>,
|
||||
parallels: Record<string, Parallel>
|
||||
): string[] {
|
||||
const paths = ['index']
|
||||
if (blockType === 'loop') {
|
||||
const loopType = loops[blockId]?.loopType || 'for'
|
||||
if (loopType === 'forEach') {
|
||||
paths.push('currentItem', 'items')
|
||||
}
|
||||
} else {
|
||||
const parallelType = parallels[blockId]?.parallelType || 'count'
|
||||
if (parallelType === 'collection') {
|
||||
paths.push('currentItem', 'items')
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
function formatOutputsForDisplay(paths: string[], blockName: string): string[] {
|
||||
const normalizedName = normalizeName(blockName)
|
||||
return paths.map((path) => `${normalizedName}.${path}`)
|
||||
}
|
||||
|
||||
export async function executeGetDeployedWorkflowState(
|
||||
params: GetDeployedWorkflowStateParams,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
try {
|
||||
const workflowId = params.workflowId || context.workflowId
|
||||
if (!workflowId) {
|
||||
return { success: false, error: 'workflowId is required' }
|
||||
}
|
||||
|
||||
const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId)
|
||||
|
||||
try {
|
||||
const deployedState = await loadDeployedWorkflowState(workflowId)
|
||||
const formatted = formatNormalizedWorkflowForCopilot({
|
||||
blocks: deployedState.blocks,
|
||||
edges: deployedState.edges,
|
||||
loops: deployedState.loops as Record<string, Loop>,
|
||||
parallels: deployedState.parallels as Record<string, Parallel>,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
workflowId,
|
||||
workflowName: workflowRecord.name || '',
|
||||
isDeployed: true,
|
||||
deploymentVersionId: deployedState.deploymentVersionId,
|
||||
deployedState: formatted,
|
||||
},
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
workflowId,
|
||||
workflowName: workflowRecord.name || '',
|
||||
isDeployed: false,
|
||||
message: 'Workflow has not been deployed yet.',
|
||||
},
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: toError(error).message }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user