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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,72 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
import { createBlockFromParams } from './builders'
const agentBlockConfig = {
type: 'agent',
name: 'Agent',
outputs: {
content: { type: 'string', description: 'Default content output' },
},
subBlocks: [{ id: 'responseFormat', type: 'response-format' }],
}
const conditionBlockConfig = {
type: 'condition',
name: 'Condition',
outputs: {},
subBlocks: [{ id: 'conditions', type: 'condition-input' }],
}
vi.mock('@/blocks/registry', () => ({
getAllBlocks: () => [agentBlockConfig, conditionBlockConfig],
getBlock: (type: string) =>
type === 'agent' ? agentBlockConfig : type === 'condition' ? conditionBlockConfig : undefined,
}))
describe('createBlockFromParams', () => {
it('derives agent outputs from responseFormat when outputs are not provided', () => {
const block = createBlockFromParams('b-agent', {
type: 'agent',
name: 'Agent',
inputs: {
responseFormat: {
type: 'object',
properties: {
answer: {
type: 'string',
description: 'Structured answer text',
},
},
required: ['answer'],
},
},
triggerMode: false,
})
expect(block.outputs.answer).toBeDefined()
expect(block.outputs.answer.type).toBe('string')
})
it('preserves configured subblock types and normalizes condition branch ids', () => {
const block = createBlockFromParams('condition-1', {
type: 'condition',
name: 'Condition 1',
inputs: {
conditions: JSON.stringify([
{ id: 'arbitrary-if', title: 'if', value: 'true' },
{ id: 'arbitrary-else', title: 'else', value: '' },
]),
},
triggerMode: false,
})
expect(block.subBlocks.conditions.type).toBe('condition-input')
const parsed = JSON.parse(block.subBlocks.conditions.value)
expect(parsed[0].id).toBe('condition-1-if')
expect(parsed[1].id).toBe('condition-1-else')
})
})
@@ -0,0 +1,765 @@
import { createLogger } from '@sim/logger'
import { generateId, isValidUuid } from '@sim/utils/id'
import { sortObjectKeysDeep } from '@sim/utils/object'
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import {
buildCanonicalIndex,
buildDefaultCanonicalModes,
isCanonicalPair,
} from '@/lib/workflows/subblocks/visibility'
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
import { getAllBlocks } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
import type { EditWorkflowOperation, SkippedItem, ValidationError } from './types'
import { logSkippedItem } from './types'
import {
validateInputsForBlock,
validateSourceHandleForBlock,
validateTargetHandle,
} from './validation'
/**
* Helper to create a block state from operation params
*/
export function createBlockFromParams(
blockId: string,
params: any,
parentId?: string,
errorsCollector?: ValidationError[],
permissionConfig?: PermissionGroupConfig | null,
skippedItems?: SkippedItem[]
): any {
const blockConfig = getAllBlocks().find((b) => b.type === params.type)
// Validate inputs against block configuration
let validatedInputs: Record<string, any> | undefined
if (params.inputs) {
const result = validateInputsForBlock(params.type, params.inputs, blockId)
validatedInputs = result.validInputs
if (errorsCollector && result.errors.length > 0) {
errorsCollector.push(...result.errors)
}
}
// Determine outputs based on trigger mode
const triggerMode = params.triggerMode || false
const isTriggerCapable = blockConfig ? hasTriggerCapability(blockConfig) : false
const effectiveTriggerMode = Boolean(triggerMode && isTriggerCapable)
let outputs: Record<string, any>
if (params.outputs) {
outputs = params.outputs
} else if (blockConfig) {
const subBlocks: Record<string, any> = {}
if (validatedInputs) {
Object.entries(validatedInputs).forEach(([key, value]) => {
// Skip runtime subblock IDs when computing outputs
if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(key)) {
return
}
subBlocks[key] = { id: key, type: 'short-input', value: value }
})
}
outputs = getEffectiveBlockOutputs(params.type, subBlocks, {
triggerMode: effectiveTriggerMode,
preferToolOutputs: !effectiveTriggerMode,
})
} else {
outputs = {}
}
const blockState: any = {
id: blockId,
type: params.type,
name: params.name,
position: { x: 0, y: 0 },
enabled: params.enabled !== undefined ? params.enabled : true,
horizontalHandles: true,
advancedMode: params.advancedMode || false,
height: 0,
triggerMode: triggerMode,
subBlocks: {},
outputs: outputs,
data: parentId ? { parentId, extent: 'parent' as const } : {},
locked: false,
}
// Add validated inputs as subBlocks
if (validatedInputs) {
Object.entries(validatedInputs).forEach(([key, value]) => {
if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(key)) {
return
}
let sanitizedValue = value
// Normalize array subblocks with id fields (inputFormat, table rows, etc.)
if (shouldNormalizeArrayIds(key)) {
sanitizedValue = normalizeArrayWithIds(value)
if (JSON_STRING_SUBBLOCK_KEYS.has(key)) {
sanitizedValue = JSON.stringify(sanitizedValue)
}
}
sanitizedValue = normalizeConditionRouterIds(blockId, key, sanitizedValue)
// Special handling for tools - normalize and filter disallowed
if (key === 'tools' && Array.isArray(value)) {
sanitizedValue = filterDisallowedTools(
normalizeTools(value),
permissionConfig ?? null,
blockId,
skippedItems ?? []
)
}
// Special handling for responseFormat - normalize to ensure consistent format
if (key === 'responseFormat' && value) {
sanitizedValue = normalizeResponseFormat(value)
}
const subBlockDef = blockConfig?.subBlocks.find((subBlock) => subBlock.id === key)
blockState.subBlocks[key] = {
id: key,
type: subBlockDef?.type || 'short-input',
value: sanitizedValue,
}
})
}
// Set up subBlocks from block configuration
if (blockConfig) {
blockConfig.subBlocks.forEach((subBlock) => {
if (!blockState.subBlocks[subBlock.id]) {
blockState.subBlocks[subBlock.id] = {
id: subBlock.id,
type: subBlock.type,
value: null,
}
} else {
blockState.subBlocks[subBlock.id].type = subBlock.type
}
})
const defaultModes = buildDefaultCanonicalModes(blockConfig.subBlocks)
if (Object.keys(defaultModes).length > 0) {
if (!blockState.data) blockState.data = {}
blockState.data.canonicalModes = defaultModes
}
if (validatedInputs) {
updateCanonicalModesForInputs(blockState, Object.keys(validatedInputs), blockConfig)
}
}
// Initialize default conditions/routes so edge handle validation works.
// The UI does this in the React component; we need to mirror it here.
if (params.type === 'condition' && !blockState.subBlocks.conditions?.value) {
blockState.subBlocks.conditions = {
id: 'conditions',
type: 'condition-input',
value: JSON.stringify([
{ id: generateId(), title: 'If', value: '' },
{ id: generateId(), title: 'Else', value: '' },
]),
}
} else if (params.type === 'router_v2' && !blockState.subBlocks.routes?.value) {
blockState.subBlocks.routes = {
id: 'routes',
type: 'router-input',
value: JSON.stringify([{ id: generateId(), title: 'Route 1', value: '' }]),
}
}
return blockState
}
export function updateCanonicalModesForInputs(
block: { data?: { canonicalModes?: Record<string, 'basic' | 'advanced'> } },
inputKeys: string[],
blockConfig: BlockConfig
): void {
if (!blockConfig.subBlocks?.length) return
const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks)
const canonicalModeUpdates: Record<string, 'basic' | 'advanced'> = {}
for (const inputKey of inputKeys) {
const canonicalId = canonicalIndex.canonicalIdBySubBlockId[inputKey]
if (!canonicalId) continue
const group = canonicalIndex.groupsById[canonicalId]
if (!group || !isCanonicalPair(group)) continue
const isAdvanced = group.advancedIds.includes(inputKey)
const existingMode = canonicalModeUpdates[canonicalId]
if (!existingMode || isAdvanced) {
canonicalModeUpdates[canonicalId] = isAdvanced ? 'advanced' : 'basic'
}
}
if (Object.keys(canonicalModeUpdates).length > 0) {
if (!block.data) block.data = {}
if (!block.data.canonicalModes) block.data.canonicalModes = {}
Object.assign(block.data.canonicalModes, canonicalModeUpdates)
}
}
/**
* Normalize tools array by adding back fields that were sanitized for training
*/
export function normalizeTools(tools: any[]): any[] {
return tools.map((tool) => {
if (tool.type === 'custom-tool') {
// New reference format: minimal fields only
if (tool.customToolId && !tool.schema && !tool.code) {
return {
type: tool.type,
customToolId: tool.customToolId,
usageControl: tool.usageControl || 'auto',
isExpanded: tool.isExpanded ?? true,
}
}
// Legacy inline format: include all fields
const normalized: any = {
...tool,
params: tool.params || {},
isExpanded: tool.isExpanded ?? true,
}
// Ensure schema has proper structure (for inline format)
if (normalized.schema?.function) {
normalized.schema = {
type: 'function',
function: {
name: normalized.schema.function.name || tool.title, // Preserve name or derive from title
description: normalized.schema.function.description,
parameters: normalized.schema.function.parameters,
},
}
}
return normalized
}
// For other tool types, just ensure isExpanded exists
return {
...tool,
isExpanded: tool.isExpanded ?? true,
}
})
}
/**
* Subblock types that store arrays of objects with `id` fields.
* The LLM may generate arbitrary IDs which need to be converted to proper UUIDs.
*/
const ARRAY_WITH_ID_SUBBLOCK_TYPES = new Set([
'inputFormat', // input-format: Fields with id, name, type, value, collapsed
'headers', // table: Rows with id, cells (used for HTTP headers)
'params', // table: Rows with id, cells (used for query params)
'variables', // table or variables-input: Rows/assignments with id
'tagFilters', // knowledge-tag-filters: Filters with id, tagName, etc.
'documentTags', // document-tag-entry: Tags with id, tagName, etc.
'metrics', // eval-input: Metrics with id, name, description, range
'conditions', // condition-input: Condition branches with id, title, value
'routes', // router-input: Router routes with id, title, value
])
/**
* Subblock keys whose UI components expect a JSON string, not a raw array.
* After normalizeArrayWithIds returns an array, these must be re-stringified.
*/
export const JSON_STRING_SUBBLOCK_KEYS = new Set(['conditions', 'routes'])
/**
* Normalizes array subblock values by ensuring each item has a valid UUID.
* The LLM may generate arbitrary IDs like "input-desc-001" or "row-1" which need
* to be converted to proper UUIDs for consistency with UI-created items.
*/
export function normalizeArrayWithIds(value: unknown): any[] {
let arr: any[]
if (Array.isArray(value)) {
arr = value
} else if (typeof value === 'string') {
try {
const parsed = JSON.parse(value)
if (!Array.isArray(parsed)) return []
arr = parsed
} catch {
return []
}
} else {
return []
}
return arr.map((item: any) => {
if (!item || typeof item !== 'object') {
return item
}
const hasValidUUID = typeof item.id === 'string' && isValidUuid(item.id)
if (!hasValidUUID) {
return { ...item, id: generateId() }
}
return item
})
}
/**
* Checks if a subblock key should have its array items normalized with UUIDs.
*/
export function shouldNormalizeArrayIds(key: string): boolean {
return ARRAY_WITH_ID_SUBBLOCK_TYPES.has(key)
}
/**
* Normalizes condition/router branch IDs to use canonical block-scoped format.
* The LLM provides branch structure (if/else-if/else or routes) but should not
* have to generate the internal IDs -- we assign them based on the block ID.
*/
export function normalizeConditionRouterIds(blockId: string, key: string, value: unknown): unknown {
if (key !== 'conditions' && key !== 'routes') return value
let parsed: any[]
if (typeof value === 'string') {
try {
parsed = JSON.parse(value)
if (!Array.isArray(parsed)) return value
} catch {
return value
}
} else if (Array.isArray(value)) {
parsed = value
} else {
return value
}
let elseIfCounter = 0
const normalized = parsed.map((item, index) => {
if (!item || typeof item !== 'object') return item
let canonicalId: string
if (key === 'conditions') {
if (index === 0) {
canonicalId = `${blockId}-if`
} else if (index === parsed.length - 1) {
canonicalId = `${blockId}-else`
} else {
canonicalId = `${blockId}-else-if-${elseIfCounter}`
elseIfCounter++
}
} else {
canonicalId = `${blockId}-route${index + 1}`
}
return { ...item, id: canonicalId }
})
return typeof value === 'string' ? JSON.stringify(normalized) : normalized
}
/**
* Normalize responseFormat to ensure consistent storage
* Handles both string (JSON) and object formats
* Returns pretty-printed JSON for better UI readability
*/
export function normalizeResponseFormat(value: any): string {
try {
let obj = value
// If it's already a string, parse it first
if (typeof value === 'string') {
const trimmed = value.trim()
if (!trimmed) {
return ''
}
obj = JSON.parse(trimmed)
}
// If it's an object, stringify it with consistent formatting
if (obj && typeof obj === 'object') {
// Return pretty-printed with 2-space indentation for UI readability
// The sanitizer will normalize it to minified format for comparison
return JSON.stringify(sortObjectKeysDeep(obj), null, 2)
}
return String(value)
} catch {
// If parsing fails, return the original value as string
return String(value)
}
}
/**
* Creates a validated edge between two blocks.
* Returns true if edge was created, false if skipped due to validation errors.
*/
export function createValidatedEdge(
modifiedState: any,
sourceBlockId: string,
targetBlockId: string,
sourceHandle: string,
targetHandle: string,
operationType: string,
logger: ReturnType<typeof createLogger>,
skippedItems?: SkippedItem[]
): boolean {
if (!modifiedState.blocks[targetBlockId]) {
// The target doesn't exist yet. It may be created by a later operation in
// this batch or by a future edit_workflow call. Record the connection as
// pending on the source block (persisted in block.data) so it is resolved
// automatically once the target appears, instead of being silently dropped.
const pendingSource = modifiedState.blocks[sourceBlockId]
if (pendingSource) {
if (!pendingSource.data) pendingSource.data = {}
if (!pendingSource.data.pendingConnections) pendingSource.data.pendingConnections = {}
const pending = pendingSource.data.pendingConnections as Record<
string,
Array<{ target: string; targetHandle: string }>
>
if (!pending[sourceHandle]) pending[sourceHandle] = []
if (
!pending[sourceHandle].some(
(p) => p.target === targetBlockId && p.targetHandle === targetHandle
)
) {
pending[sourceHandle].push({ target: targetBlockId, targetHandle })
}
}
logger.warn(`Target block "${targetBlockId}" not found. Connection deferred until it exists.`, {
sourceBlockId,
targetBlockId,
sourceHandle,
})
skippedItems?.push({
type: 'invalid_edge_target',
operationType,
blockId: sourceBlockId,
reason: `Edge from "${sourceBlockId}" to "${targetBlockId}" deferred until the target block "${targetBlockId}" exists - if it is created later (in this or a following edit) the engine wires this edge automatically; if you did not intend to create "${targetBlockId}", fix the target id.`,
details: { sourceHandle, targetHandle, targetId: targetBlockId },
})
return false
}
const sourceBlock = modifiedState.blocks[sourceBlockId]
if (!sourceBlock) {
logger.warn(`Source block "${sourceBlockId}" not found. Edge skipped.`, {
sourceBlockId,
targetBlockId,
})
skippedItems?.push({
type: 'invalid_edge_source',
operationType,
blockId: sourceBlockId,
reason: `Edge from "${sourceBlockId}" to "${targetBlockId}" skipped - source block does not exist`,
details: { sourceHandle, targetHandle, targetId: targetBlockId },
})
return false
}
const sourceBlockType = sourceBlock.type
if (!sourceBlockType) {
logger.warn(`Source block "${sourceBlockId}" has no type. Edge skipped.`, {
sourceBlockId,
targetBlockId,
})
skippedItems?.push({
type: 'invalid_edge_source',
operationType,
blockId: sourceBlockId,
reason: `Edge from "${sourceBlockId}" to "${targetBlockId}" skipped - source block has no type`,
details: { sourceHandle, targetHandle, targetId: targetBlockId },
})
return false
}
const sourceValidation = validateSourceHandleForBlock(sourceHandle, sourceBlockType, sourceBlock)
if (!sourceValidation.valid) {
logger.warn(`Invalid source handle. Edge skipped.`, {
sourceBlockId,
targetBlockId,
sourceHandle,
error: sourceValidation.error,
})
skippedItems?.push({
type: 'invalid_source_handle',
operationType,
blockId: sourceBlockId,
reason: sourceValidation.error || `Invalid source handle "${sourceHandle}"`,
details: { sourceHandle, targetHandle, targetId: targetBlockId },
})
return false
}
const targetValidation = validateTargetHandle(targetHandle)
if (!targetValidation.valid) {
logger.warn(`Invalid target handle. Edge skipped.`, {
sourceBlockId,
targetBlockId,
targetHandle,
error: targetValidation.error,
})
skippedItems?.push({
type: 'invalid_target_handle',
operationType,
blockId: sourceBlockId,
reason: targetValidation.error || `Invalid target handle "${targetHandle}"`,
details: { sourceHandle, targetHandle, targetId: targetBlockId },
})
return false
}
// Use normalized handle if available (e.g., 'if' -> 'condition-{uuid}')
const finalSourceHandle = sourceValidation.normalizedHandle || sourceHandle
// Avoid creating duplicate edges (e.g., when a pending connection resolves to
// the same edge a later operation already created).
const edgeExists = (modifiedState.edges || []).some(
(e: any) =>
e.source === sourceBlockId &&
e.sourceHandle === finalSourceHandle &&
e.target === targetBlockId &&
e.targetHandle === targetHandle
)
if (edgeExists) return true
modifiedState.edges.push({
id: generateId(),
source: sourceBlockId,
sourceHandle: finalSourceHandle,
target: targetBlockId,
targetHandle,
type: 'default',
})
return true
}
/**
* Adds connections as edges for a block.
* Supports multiple target formats:
* - String: "target-block-id"
* - Object: { block: "target-block-id", handle?: "custom-target-handle" }
* - Array of strings or objects
*/
export function addConnectionsAsEdges(
modifiedState: any,
blockId: string,
connections: Record<string, any>,
logger: ReturnType<typeof createLogger>,
skippedItems?: SkippedItem[]
): void {
const normalizeHandle = (handle: string): string => {
if (handle === 'success') return 'source'
return handle
}
Object.entries(connections).forEach(([rawHandle, targets]) => {
if (targets === null) return
const sourceHandle = normalizeHandle(rawHandle)
const addEdgeForTarget = (targetBlock: string, targetHandle?: string) => {
createValidatedEdge(
modifiedState,
blockId,
targetBlock,
sourceHandle,
targetHandle || 'target',
'add_edge',
logger,
skippedItems
)
}
if (typeof targets === 'string') {
addEdgeForTarget(targets)
} else if (Array.isArray(targets)) {
targets.forEach((target: any) => {
if (typeof target === 'string') {
addEdgeForTarget(target)
} else if (target?.block) {
addEdgeForTarget(target.block, target.handle)
}
})
} else if (typeof targets === 'object' && targets?.block) {
addEdgeForTarget(targets.block, targets.handle)
}
})
}
export function applyTriggerConfigToBlockSubblocks(block: any, triggerConfig: Record<string, any>) {
if (!block?.subBlocks || !triggerConfig || typeof triggerConfig !== 'object') {
return
}
Object.entries(triggerConfig).forEach(([configKey, configValue]) => {
const existingSubblock = block.subBlocks[configKey]
if (existingSubblock) {
const existingValue = existingSubblock.value
const valuesEqual =
typeof existingValue === 'object' || typeof configValue === 'object'
? JSON.stringify(existingValue) === JSON.stringify(configValue)
: existingValue === configValue
if (valuesEqual) {
return
}
block.subBlocks[configKey] = {
...existingSubblock,
value: configValue,
}
} else {
block.subBlocks[configKey] = {
id: configKey,
type: 'short-input',
value: configValue,
}
}
})
}
/**
* Filters out tools that are not allowed by the permission group config
* Returns both the allowed tools and any skipped tool items for logging
*/
export function filterDisallowedTools(
tools: any[],
permissionConfig: PermissionGroupConfig | null,
blockId: string,
skippedItems: SkippedItem[]
): any[] {
if (!permissionConfig) {
return tools
}
const allowedTools: any[] = []
for (const tool of tools) {
if (tool.type === 'custom-tool' && permissionConfig.disableCustomTools) {
logSkippedItem(skippedItems, {
type: 'tool_not_allowed',
operationType: 'add',
blockId,
reason: `Custom tool "${tool.title || tool.customToolId || 'unknown'}" is not allowed by permission group - tool not added`,
details: { toolType: 'custom-tool', toolId: tool.customToolId },
})
continue
}
if (tool.type === 'mcp' && permissionConfig.disableMcpTools) {
logSkippedItem(skippedItems, {
type: 'tool_not_allowed',
operationType: 'add',
blockId,
reason: `MCP tool "${tool.title || 'unknown'}" is not allowed by permission group - tool not added`,
details: { toolType: 'mcp', serverId: tool.params?.serverId },
})
continue
}
allowedTools.push(tool)
}
return allowedTools
}
/**
* Normalizes block IDs in operations to ensure they are valid UUIDs.
* The LLM may generate human-readable IDs like "web_search" or "research_agent"
* which need to be converted to proper UUIDs for database compatibility.
*
* Returns the normalized operations and a mapping from old IDs to new UUIDs.
*/
export function normalizeBlockIdsInOperations(operations: EditWorkflowOperation[]): {
normalizedOperations: EditWorkflowOperation[]
idMapping: Map<string, string>
} {
const logger = createLogger('EditWorkflowServerTool')
const idMapping = new Map<string, string>()
// First pass: collect all non-UUID block_ids from add/insert operations
for (const op of operations) {
if (op.operation_type === 'add' || op.operation_type === 'insert_into_subflow') {
if (op.block_id && !isValidUuid(op.block_id)) {
const newId = generateId()
idMapping.set(op.block_id, newId)
logger.debug('Normalizing block ID', { oldId: op.block_id, newId })
}
}
}
if (idMapping.size === 0) {
return { normalizedOperations: operations, idMapping }
}
logger.info('Normalizing block IDs in operations', {
normalizedCount: idMapping.size,
mappings: Object.fromEntries(idMapping),
})
// Helper to replace an ID if it's in the mapping
const replaceId = (id: string | undefined): string | undefined => {
if (!id) return id
return idMapping.get(id) ?? id
}
// Second pass: update all references to use new UUIDs
const normalizedOperations = operations.map((op) => {
const normalized: EditWorkflowOperation = {
...op,
block_id: replaceId(op.block_id) ?? op.block_id,
}
if (op.params) {
normalized.params = { ...op.params }
// Update subflowId references (for insert_into_subflow)
if (normalized.params.subflowId) {
normalized.params.subflowId = replaceId(normalized.params.subflowId)
}
// Update connection references
if (normalized.params.connections) {
const normalizedConnections: Record<string, any> = {}
for (const [handle, targets] of Object.entries(normalized.params.connections)) {
if (typeof targets === 'string') {
normalizedConnections[handle] = replaceId(targets)
} else if (Array.isArray(targets)) {
normalizedConnections[handle] = targets.map((t) => {
if (typeof t === 'string') return replaceId(t)
if (t && typeof t === 'object' && t.block) {
return { ...t, block: replaceId(t.block) }
}
return t
})
} else if (targets && typeof targets === 'object' && (targets as any).block) {
normalizedConnections[handle] = { ...targets, block: replaceId((targets as any).block) }
} else {
normalizedConnections[handle] = targets
}
}
normalized.params.connections = normalizedConnections
}
// Update nestedNodes block IDs
if (normalized.params.nestedNodes) {
const normalizedNestedNodes: Record<string, any> = {}
for (const [childId, childBlock] of Object.entries(normalized.params.nestedNodes)) {
const newChildId = replaceId(childId) ?? childId
normalizedNestedNodes[newChildId] = childBlock
}
normalized.params.nestedNodes = normalizedNestedNodes
}
}
return normalized
})
return { normalizedOperations, idMapping }
}
@@ -0,0 +1,379 @@
import { createLogger } from '@sim/logger'
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
import { isValidKey } from '@/lib/workflows/sanitization/key-validation'
import { validateEdges } from '@/stores/workflows/workflow/edge-validation'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
import {
addConnectionsAsEdges,
createValidatedEdge,
normalizeBlockIdsInOperations,
} from './builders'
import {
handleAddOperation,
handleDeleteOperation,
handleEditOperation,
handleExtractFromSubflowOperation,
handleInsertIntoSubflowOperation,
} from './operations'
import type {
ApplyOperationsResult,
EditWorkflowOperation,
OperationContext,
ValidationError,
} from './types'
import { logSkippedItem, type SkippedItem } from './types'
const logger = createLogger('EditWorkflowServerTool')
type OperationHandler = (op: EditWorkflowOperation, ctx: OperationContext) => void
const OPERATION_HANDLERS: Record<EditWorkflowOperation['operation_type'], OperationHandler> = {
delete: handleDeleteOperation,
extract_from_subflow: handleExtractFromSubflowOperation,
add: handleAddOperation,
insert_into_subflow: handleInsertIntoSubflowOperation,
edit: handleEditOperation,
}
/**
* Topologically sort insert operations to ensure parents are created before children
* Returns sorted array where parent inserts always come before child inserts
*/
export function topologicalSortInserts(
inserts: EditWorkflowOperation[],
adds: EditWorkflowOperation[]
): EditWorkflowOperation[] {
if (inserts.length === 0) return []
// Build a map of blockId -> operation for quick lookup
const insertMap = new Map<string, EditWorkflowOperation>()
inserts.forEach((op) => insertMap.set(op.block_id, op))
// Build a set of blocks being added (potential parents)
const addedBlocks = new Set(adds.map((op) => op.block_id))
// Build dependency graph: block -> blocks that depend on it
const dependents = new Map<string, Set<string>>()
const dependencies = new Map<string, Set<string>>()
inserts.forEach((op) => {
const blockId = op.block_id
const parentId = op.params?.subflowId
dependencies.set(blockId, new Set())
if (parentId) {
// Track dependency if parent is being inserted OR being added
// This ensures children wait for parents regardless of operation type
const parentBeingCreated = insertMap.has(parentId) || addedBlocks.has(parentId)
if (parentBeingCreated) {
// Only add dependency if parent is also being inserted (not added)
// Because adds run before inserts, added parents are already created
if (insertMap.has(parentId)) {
dependencies.get(blockId)!.add(parentId)
if (!dependents.has(parentId)) {
dependents.set(parentId, new Set())
}
dependents.get(parentId)!.add(blockId)
}
}
}
})
// Topological sort using Kahn's algorithm
const sorted: EditWorkflowOperation[] = []
const queue: string[] = []
// Start with nodes that have no dependencies (or depend only on added blocks)
inserts.forEach((op) => {
const deps = dependencies.get(op.block_id)!
if (deps.size === 0) {
queue.push(op.block_id)
}
})
while (queue.length > 0) {
const blockId = queue.shift()!
const op = insertMap.get(blockId)
if (op) {
sorted.push(op)
}
// Remove this node from dependencies of others
const children = dependents.get(blockId)
if (children) {
children.forEach((childId) => {
const childDeps = dependencies.get(childId)!
childDeps.delete(blockId)
if (childDeps.size === 0) {
queue.push(childId)
}
})
}
}
// If sorted length doesn't match input, there's a cycle (shouldn't happen with valid operations)
// Just append remaining operations
if (sorted.length < inserts.length) {
inserts.forEach((op) => {
if (!sorted.includes(op)) {
sorted.push(op)
}
})
}
return sorted
}
function orderOperations(operations: EditWorkflowOperation[]): EditWorkflowOperation[] {
/**
* Reorder operations to ensure correct execution sequence:
* 1. delete - Remove blocks first to free up IDs and clean state
* 2. extract_from_subflow - Extract blocks from subflows before modifications
* 3. add - Create new blocks (sorted by connection dependencies)
* 4. insert_into_subflow - Insert blocks into subflows (sorted by parent dependency)
* 5. edit - Edit existing blocks last, so connections to newly added blocks work
*/
const deletes = operations.filter((op) => op.operation_type === 'delete')
const extracts = operations.filter((op) => op.operation_type === 'extract_from_subflow')
const adds = operations.filter((op) => op.operation_type === 'add')
const inserts = operations.filter((op) => op.operation_type === 'insert_into_subflow')
const edits = operations.filter((op) => op.operation_type === 'edit')
// Sort insert operations to ensure parents are inserted before children
const sortedInserts = topologicalSortInserts(inserts, adds)
return [...deletes, ...extracts, ...adds, ...sortedInserts, ...edits]
}
/**
* Apply operations directly to the workflow JSON state
*/
export function applyOperationsToWorkflowState(
workflowState: Record<string, unknown>,
operations: EditWorkflowOperation[],
permissionConfig: PermissionGroupConfig | null = null
): ApplyOperationsResult {
// Deep clone the workflow state to avoid mutations
const modifiedState = structuredClone(workflowState)
// Collect validation errors across all operations
const validationErrors: ValidationError[] = []
// Collect skipped items across all operations
const skippedItems: SkippedItem[] = []
// Normalize block IDs to UUIDs before processing
const { normalizedOperations } = normalizeBlockIdsInOperations(operations)
// Order operations for deterministic application
const orderedOperations = orderOperations(normalizedOperations)
logger.info('Applying operations to workflow:', {
totalOperations: orderedOperations.length,
operationTypes: orderedOperations.reduce((acc: Record<string, number>, op) => {
acc[op.operation_type] = (acc[op.operation_type] || 0) + 1
return acc
}, {}),
initialBlockCount: Object.keys((modifiedState as any).blocks || {}).length,
})
const ctx: OperationContext = {
modifiedState,
skippedItems,
validationErrors,
permissionConfig,
deferredConnections: [],
}
for (const operation of orderedOperations) {
const { operation_type, block_id } = operation
// CRITICAL: Validate block_id is a valid string and not "undefined"
// This prevents undefined keys from being set in the workflow state
if (!isValidKey(block_id)) {
logSkippedItem(skippedItems, {
type: 'missing_required_params',
operationType: operation_type,
blockId: String(block_id || 'invalid'),
reason: `Invalid block_id "${block_id}" (type: ${typeof block_id}) - operation skipped. Block IDs must be valid non-empty strings.`,
})
logger.error('Invalid block_id detected in operation', {
operation_type,
block_id,
block_id_type: typeof block_id,
})
continue
}
const handler = OPERATION_HANDLERS[operation_type]
if (!handler) continue
logger.debug(`Executing operation: ${operation_type} for block ${block_id}`, {
params: operation.params ? Object.keys(operation.params) : [],
currentBlockCount: Object.keys((modifiedState as any).blocks || {}).length,
})
handler(operation, ctx)
}
// Pass 2: Create all edges from deferred connections
// All blocks exist at this point, so forward references resolve correctly
if (ctx.deferredConnections.length > 0) {
logger.info('Processing deferred connections from add/insert operations', {
deferredConnectionCount: ctx.deferredConnections.length,
totalBlocks: Object.keys((modifiedState as any).blocks || {}).length,
})
for (const { blockId, connections } of ctx.deferredConnections) {
// Verify the source block still exists (it might have been deleted by a later operation)
if (!(modifiedState as any).blocks[blockId]) {
logger.warn('Source block no longer exists for deferred connection', {
blockId,
availableBlocks: Object.keys((modifiedState as any).blocks || {}),
})
continue
}
addConnectionsAsEdges(modifiedState, blockId, connections, logger, skippedItems)
}
logger.info('Finished processing deferred connections', {
totalEdges: (modifiedState as any).edges?.length,
})
}
// Pass 3: resolve pending connections whose target block now exists. These are
// forward-reference connections that were recorded (on block.data) when their
// target didn't exist yet — possibly in an earlier edit_workflow call. Now
// that this batch's blocks are all created, create the edges that resolve.
resolvePendingConnections(modifiedState, skippedItems)
// Remove edges that cross scope boundaries. This runs after all operations
// and deferred connections are applied so that every block has its final
// parentId. Running it per-operation would incorrectly drop edges between
// blocks that are both being moved into the same subflow in one batch.
removeInvalidScopeEdges(modifiedState, skippedItems)
// Regenerate loops and parallels after modifications
;(modifiedState as any).loops = generateLoopBlocks((modifiedState as any).blocks)
;(modifiedState as any).parallels = generateParallelBlocks((modifiedState as any).blocks)
// Validate all blocks have types before returning
const blocksWithoutType = Object.entries((modifiedState as any).blocks || {})
.filter(([_, block]: [string, any]) => !block.type || block.type === undefined)
.map(([id, block]: [string, any]) => ({ id, block }))
if (blocksWithoutType.length > 0) {
logger.error('Blocks without type after operations:', {
blocksWithoutType: blocksWithoutType.map(({ id, block }) => ({
id,
type: block.type,
name: block.name,
keys: Object.keys(block),
})),
})
// Attempt to fix by removing type-less blocks
blocksWithoutType.forEach(({ id }) => {
delete (modifiedState as any).blocks[id]
})
// Remove edges connected to removed blocks
const removedIds = new Set(blocksWithoutType.map(({ id }) => id))
;(modifiedState as any).edges = ((modifiedState as any).edges || []).filter(
(edge: any) => !removedIds.has(edge.source) && !removedIds.has(edge.target)
)
}
return { state: modifiedState, validationErrors, skippedItems }
}
/**
* Resolves pending forward-reference connections recorded on block.data.
*
* When a connection references a target block that does not exist yet, the edge
* is recorded as pending on the source block instead of being dropped. This runs
* after all blocks for the current batch exist (and picks up pending entries
* persisted from earlier edit_workflow calls), creating any edges whose target
* now exists and leaving still-unresolved entries pending.
*/
function resolvePendingConnections(modifiedState: any, skippedItems: SkippedItem[]): void {
const blocks = modifiedState.blocks || {}
for (const [sourceId, block] of Object.entries(blocks) as [string, any][]) {
const pending = block?.data?.pendingConnections as
| Record<string, Array<{ target: string; targetHandle: string }>>
| undefined
if (!pending) continue
for (const [handle, targets] of Object.entries(pending)) {
const stillPending: Array<{ target: string; targetHandle: string }> = []
for (const { target, targetHandle } of targets) {
if (blocks[target]) {
createValidatedEdge(
modifiedState,
sourceId,
target,
handle,
targetHandle || 'target',
'resolve_pending_connection',
logger,
skippedItems
)
} else {
stillPending.push({ target, targetHandle })
}
}
if (stillPending.length > 0) {
pending[handle] = stillPending
} else {
delete pending[handle]
}
}
if (Object.keys(pending).length === 0) {
block.data.pendingConnections = undefined
}
}
}
/**
* Removes edges that cross scope boundaries after all operations are applied.
* An edge is invalid if:
* - Either endpoint no longer exists (dangling reference)
* - The source and target are in incompatible scopes
* - A child block connects to its own parent container (non-handle edge)
*
* Valid scope relationships:
* - Same scope: both blocks share the same parentId
* - Container→child: source is the parent container of the target (start handles)
* - Child→container: target is the parent container of the source (end handles)
*/
function removeInvalidScopeEdges(modifiedState: any, skippedItems: SkippedItem[]): void {
const { valid, dropped } = validateEdges(modifiedState.edges || [], modifiedState.blocks || {})
modifiedState.edges = valid
if (dropped.length > 0) {
for (const { edge, reason } of dropped) {
logSkippedItem(skippedItems, {
type: 'invalid_edge_scope',
operationType: 'add_edge',
blockId: edge.source,
reason: `Edge from "${edge.source}" to "${edge.target}" skipped - ${reason}`,
details: {
edgeId: edge.id,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
targetId: edge.target,
},
})
}
logger.info('Removed invalid workflow edges', {
removed: dropped.length,
reasons: dropped.map(({ reason }) => reason),
})
}
}
@@ -0,0 +1,417 @@
import { db } from '@sim/db'
import { workflow as workflowTable } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import {
assertWorkflowMutable,
authorizeWorkflowByWorkspacePermission,
} from '@sim/platform-authz/workflow'
import { toError } from '@sim/utils/errors'
import { eq } from 'drizzle-orm'
import { EditWorkflow } from '@/lib/copilot/generated/tool-catalog-v1'
import {
assertServerToolNotAborted,
type BaseServerTool,
type ServerToolContext,
} from '@/lib/copilot/tools/server/base-tool'
import { env } from '@/lib/core/config/env'
import { getSocketServerUrl } from '@/lib/core/utils/urls'
import {
applyTargetedLayout,
getTargetedLayoutImpact,
transferBlockHeights,
} from '@/lib/workflows/autolayout'
import {
DEFAULT_HORIZONTAL_SPACING,
DEFAULT_VERTICAL_SPACING,
} from '@/lib/workflows/autolayout/constants'
import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence'
import {
loadWorkflowFromNormalizedTables,
saveWorkflowToNormalizedTables,
} from '@/lib/workflows/persistence/utils'
import { validateWorkflowState } from '@/lib/workflows/sanitization/validation'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
import { normalizeWorkflowState } from '@/stores/workflows/workflow/validation'
import { applyOperationsToWorkflowState } from './engine'
import {
collectWorkflowFieldIssues,
formatWorkflowLintMessage,
hasWorkflowLintIssues,
lintEditedWorkflowState,
type WorkflowLintReport,
type WorkflowLintUnresolvedReference,
} from './lint'
import { type EditWorkflowParams, isDeferredSkippedItem, type ValidationError } from './types'
import {
collectUnresolvedAgentToolReferences,
collectUnresolvedReferences,
preValidateCredentialInputs,
UNRESOLVABLE_AT_LINT_NOTE,
} from './validation'
async function getCurrentWorkflowStateFromDb(
workflowId: string
): Promise<{ workflowState: any; subBlockValues: Record<string, Record<string, any>> }> {
const logger = createLogger('EditWorkflowServerTool')
const [workflowRecord] = await db
.select()
.from(workflowTable)
.where(eq(workflowTable.id, workflowId))
.limit(1)
if (!workflowRecord) throw new Error(`Workflow ${workflowId} not found in database`)
const normalized = await loadWorkflowFromNormalizedTables(workflowId)
if (!normalized) throw new Error('Workflow has no normalized data')
const { state: validatedState, warnings } = normalizeWorkflowState({
blocks: normalized.blocks,
edges: normalized.edges,
loops: normalized.loops || {},
parallels: normalized.parallels || {},
})
if (warnings.length > 0) {
logger.warn('Normalized workflow state loaded from DB for copilot', {
workflowId,
warningCount: warnings.length,
warnings,
})
}
const subBlockValues: Record<string, Record<string, any>> = {}
Object.entries(validatedState.blocks).forEach(([blockId, block]) => {
subBlockValues[blockId] = {}
Object.entries((block as any).subBlocks || {}).forEach(([subId, sub]) => {
if ((sub as any).value !== undefined) subBlockValues[blockId][subId] = (sub as any).value
})
})
return { workflowState: validatedState, subBlockValues }
}
export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, unknown> = {
name: EditWorkflow.id,
async execute(params: EditWorkflowParams, context?: ServerToolContext): Promise<unknown> {
const logger = createLogger('EditWorkflowServerTool')
const { operations, workflowId, currentUserWorkflow } = params
if (!Array.isArray(operations) || operations.length === 0) {
throw new Error('operations are required and must be an array')
}
if (!workflowId) throw new Error('workflowId is required')
if (!context?.userId) {
throw new Error('Unauthorized workflow access')
}
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId: context.userId,
action: 'write',
})
if (!authorization.allowed) {
throw new Error(authorization.message || 'Unauthorized workflow access')
}
await assertWorkflowMutable(workflowId)
const workspaceId = authorization.workflow?.workspaceId ?? undefined
const workflowName = authorization.workflow?.name ?? undefined
logger.info('Executing edit_workflow', {
operationCount: operations.length,
workflowId,
hasCurrentUserWorkflow: !!currentUserWorkflow,
chatId: context.chatId,
})
assertServerToolNotAborted(context)
let workflowState: any
if (currentUserWorkflow) {
try {
workflowState = JSON.parse(currentUserWorkflow)
} catch (error) {
logger.error('Failed to parse currentUserWorkflow', error)
throw new Error('Invalid currentUserWorkflow format')
}
} else {
const fromDb = await getCurrentWorkflowStateFromDb(workflowId)
workflowState = fromDb.workflowState
}
const permissionConfig =
context?.userId && workspaceId
? await getUserPermissionConfig(context.userId, workspaceId)
: null
// Pre-validate credential and apiKey inputs before applying operations
// This filters out invalid credentials and apiKeys for hosted models
let operationsToApply = operations
const credentialErrors: ValidationError[] = []
if (context?.userId) {
const { filteredOperations, errors: credErrors } = await preValidateCredentialInputs(
operations,
{ userId: context.userId, workspaceId },
workflowState
)
operationsToApply = filteredOperations
credentialErrors.push(...credErrors)
}
// Apply operations directly to the workflow state
const {
state: modifiedWorkflowState,
validationErrors,
skippedItems,
} = applyOperationsToWorkflowState(workflowState, operationsToApply, permissionConfig)
// Add credential validation errors
validationErrors.push(...credentialErrors)
// Resolve credential/resource references against the workspace (Tier 2).
// Includes oauth-input credentials and only the active canonical member, so a
// credential "set in basic mode but unresolved in the dropdown" is caught.
let unresolvedReferences: WorkflowLintUnresolvedReference[] = []
if (context?.userId) {
try {
unresolvedReferences = await collectUnresolvedReferences(modifiedWorkflowState, {
userId: context.userId,
workspaceId,
})
// Back-compat: also surface unresolved references through the input-validation channel.
validationErrors.push(
...unresolvedReferences.map((ref) => ({
blockId: ref.blockId,
blockType: ref.blockType ?? 'unknown',
field: ref.field,
value: ref.value,
error: ref.reason,
}))
)
} catch (error) {
logger.warn('Selector ID validation failed', {
error: toError(error).message,
})
}
// Resolve agent-block tool/skill references (custom tools, MCP servers,
// skills). A well-shaped entry whose id does not resolve is dropped at
// runtime, so the agent silently loses the tool/skill - surface it through
// the same lint + input-validation channels as credential/resource refs.
try {
const toolReferences = await collectUnresolvedAgentToolReferences(modifiedWorkflowState, {
userId: context.userId,
workspaceId,
})
unresolvedReferences.push(...toolReferences)
validationErrors.push(
...toolReferences.map((ref) => ({
blockId: ref.blockId,
blockType: ref.blockType ?? 'agent',
field: ref.field,
value: ref.value,
error: ref.reason,
}))
)
} catch (error) {
logger.warn('Agent tool/skill reference validation failed', {
error: toError(error).message,
})
}
}
// Validate the workflow state
const validation = validateWorkflowState(modifiedWorkflowState, { sanitize: true })
if (!validation.valid) {
logger.error('Edited workflow state is invalid', {
errors: validation.errors,
warnings: validation.warnings,
})
throw new Error(`Invalid edited workflow: ${validation.errors.join('; ')}`)
}
if (validation.warnings.length > 0) {
logger.warn('Edited workflow validation warnings', {
warnings: validation.warnings,
})
}
// Extract and persist custom tools to database (reuse workspaceId from selector validation)
if (context?.userId && workspaceId) {
try {
assertServerToolNotAborted(context)
const finalWorkflowState = validation.sanitizedState || modifiedWorkflowState
const { saved, errors } = await extractAndPersistCustomTools(
finalWorkflowState,
workspaceId,
context.userId
)
if (saved > 0) {
logger.info(`Persisted ${saved} custom tool(s) to database`, { workflowId })
}
if (errors.length > 0) {
logger.warn('Some custom tools failed to persist', { errors, workflowId })
}
} catch (error) {
logger.error('Failed to persist custom tools', { error, workflowId })
}
} else if (context?.userId && !workspaceId) {
logger.warn('Workflow has no workspaceId, skipping custom tools persistence', {
workflowId,
})
} else {
logger.warn('No userId in context - skipping custom tools persistence', { workflowId })
}
logger.info('edit_workflow successfully applied operations', {
operationCount: operations.length,
blocksCount: Object.keys(modifiedWorkflowState.blocks).length,
edgesCount: modifiedWorkflowState.edges.length,
inputValidationErrors: validationErrors.length,
skippedItemsCount: skippedItems.length,
schemaValidationErrors: validation.errors.length,
validationWarnings: validation.warnings.length,
})
// Format validation errors for LLM feedback
const inputErrors =
validationErrors.length > 0
? validationErrors.map((e) => `Block "${e.blockId}" (${e.blockType}): ${e.error}`)
: undefined
// Split engine skipped items into genuine failures vs benign, self-healing
// deferrals. A deferred forward-reference edge (invalid_edge_target) is NOT
// a failure: the engine wires it automatically once its target block exists
// (this call or a later one) via pendingConnections. Surfacing it through
// the same "skipped" failure channel as real skips makes a literal model
// thrash (re-issuing a self-healing op). Keep them in separate result fields
// and preserve item.type/details so the prompt can branch on a
// machine-readable category instead of pattern-matching prose.
const mapSkippedItem = (item: (typeof skippedItems)[number]) => ({
type: item.type,
operationType: item.operationType,
blockId: item.blockId,
reason: item.reason,
...(item.details && { details: item.details }),
})
const genuineSkippedItems = skippedItems.filter((item) => !isDeferredSkippedItem(item))
const deferredItems = skippedItems.filter((item) => isDeferredSkippedItem(item))
const skippedDetails =
genuineSkippedItems.length > 0 ? genuineSkippedItems.map(mapSkippedItem) : undefined
const deferredDetails = deferredItems.length > 0 ? deferredItems.map(mapSkippedItem) : undefined
// Persist the workflow state to the database
const finalWorkflowState = validation.sanitizedState || modifiedWorkflowState
const { layoutBlockIds, resizedBlockIds, shiftSourceBlockIds } = getTargetedLayoutImpact({
before: workflowState,
after: finalWorkflowState,
})
let layoutedBlocks = finalWorkflowState.blocks
if (layoutBlockIds.length > 0 || resizedBlockIds.length > 0 || shiftSourceBlockIds.length > 0) {
try {
transferBlockHeights(workflowState.blocks, finalWorkflowState.blocks)
layoutedBlocks = applyTargetedLayout(finalWorkflowState.blocks, finalWorkflowState.edges, {
changedBlockIds: layoutBlockIds,
resizedBlockIds,
shiftSourceBlockIds,
horizontalSpacing: DEFAULT_HORIZONTAL_SPACING,
verticalSpacing: DEFAULT_VERTICAL_SPACING,
})
} catch (error) {
logger.warn('Targeted autolayout failed, using default positions', {
workflowId,
error: toError(error).message,
})
}
}
const workflowStateForDb = {
blocks: layoutedBlocks,
edges: finalWorkflowState.edges,
loops: generateLoopBlocks(layoutedBlocks as any),
parallels: generateParallelBlocks(layoutedBlocks as any),
lastSaved: Date.now(),
isDeployed: false,
}
// Aggregate lint report: graph (sources/sinks/orphans/ports) + Tier-1 config
// (required + canonical-mode) + Tier-2 resolution (credential/resource IDs).
const graphLint = lintEditedWorkflowState(workflowStateForDb as any)
const fieldIssues = collectWorkflowFieldIssues(workflowStateForDb.blocks as any)
const workflowLint: WorkflowLintReport = {
...graphLint,
fieldIssues,
unresolvedReferences,
notes: unresolvedReferences.length > 0 ? [UNRESOLVABLE_AT_LINT_NOTE] : [],
}
const workflowLintMessage = hasWorkflowLintIssues(workflowLint)
? formatWorkflowLintMessage(workflowLint)
: undefined
assertServerToolNotAborted(context)
const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowStateForDb as any)
if (!saveResult.success) {
logger.error('Failed to persist workflow state to database', {
workflowId,
error: saveResult.error,
})
throw new Error(`Failed to save workflow: ${saveResult.error}`)
}
// Update workflow's lastSynced timestamp
assertServerToolNotAborted(context)
await db
.update(workflowTable)
.set({
lastSynced: new Date(),
updatedAt: new Date(),
})
.where(eq(workflowTable.id, workflowId))
logger.info('Workflow state persisted to database', { workflowId })
fetch(`${getSocketServerUrl()}/api/workflow-updated`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': env.INTERNAL_API_SECRET,
},
body: JSON.stringify({ workflowId }),
}).catch((error) => {
logger.warn('Failed to notify socket server of workflow update', { workflowId, error })
})
const sanitizationWarnings = validation.warnings.length > 0 ? validation.warnings : undefined
return {
success: true,
workflowId,
workflowName: workflowName ?? 'Workflow',
workflowState: { ...finalWorkflowState, blocks: layoutedBlocks },
workflowLint,
...(workflowLintMessage && { workflowLintMessage }),
...(inputErrors && {
inputValidationErrors: inputErrors,
inputValidationMessage: `${inputErrors.length} input(s) were rejected due to validation errors. The workflow was still updated with valid inputs only. Errors: ${inputErrors.join('; ')}`,
}),
...(skippedDetails && {
skippedItems: skippedDetails,
skippedItemsMessage: `${skippedDetails.length} operation(s) were skipped (not applied) and need attention. Each item includes a machine-readable "type" (e.g. block_not_found, block_locked, duplicate_block_name, invalid_block_type, invalid_source_handle, invalid_target_handle, invalid_edge_scope). Details: ${skippedDetails.map((item) => item.reason).join('; ')}`,
}),
...(deferredDetails && {
deferredConnections: deferredDetails,
deferredMessage: `${deferredDetails.length} edge(s) were deferred because their target block does not exist yet. This is NOT a failure and does NOT need fixing: the engine wires these edges automatically once the target block exists (in this edit or a later one). Do not re-issue them. Only act on a deferred edge if its target id was a typo or hallucination that you do not intend to create. Details: ${deferredDetails.map((item) => item.reason).join('; ')}`,
}),
...(sanitizationWarnings && {
sanitizationWarnings,
sanitizationMessage: `${sanitizationWarnings.length} field(s) were automatically sanitized: ${sanitizationWarnings.join('; ')}`,
}),
}
},
}
@@ -0,0 +1,320 @@
import { describe, expect, it } from 'vitest'
import { hasWorkflowLintIssues, lintEditedWorkflowState } from './lint'
function baseBlock(id: string, type: string, name: string, subBlocks: Record<string, any> = {}) {
return {
id,
type,
name,
enabled: true,
position: { x: 0, y: 0 },
subBlocks,
outputs: {},
}
}
describe('lintEditedWorkflowState', () => {
it('reports orphan blocks but allows unconnected condition/router branches', () => {
const workflowState = {
blocks: {
start: baseBlock('start', 'starter', 'Start'),
condition: baseBlock('condition', 'condition', 'Condition', {
conditions: {
value: JSON.stringify([
{ id: 'condition-if', title: 'if', value: 'true' },
{ id: 'condition-else', title: 'else', value: '' },
]),
},
}),
router: baseBlock('router', 'router_v2', 'Router', {
routes: {
value: [
{ id: 'route-1', title: 'Route 1', value: 'support' },
{ id: 'route-2', title: 'Route 2', value: 'sales' },
],
},
}),
agent: baseBlock('agent', 'agent', 'Agent'),
function: baseBlock('function', 'function', 'Orphan Function'),
note: baseBlock('note', 'note', 'Note'),
},
edges: [
{
id: 'edge-start-condition',
source: 'start',
sourceHandle: 'source',
target: 'condition',
targetHandle: 'target',
},
{
id: 'edge-start-router',
source: 'start',
sourceHandle: 'source',
target: 'router',
targetHandle: 'target',
},
{
id: 'edge-condition-agent',
source: 'condition',
sourceHandle: 'if',
target: 'agent',
targetHandle: 'target',
},
],
}
const lint = lintEditedWorkflowState(workflowState as any)
expect(lint.orphanBlocks).toEqual([
{ blockId: 'function', blockName: 'Orphan Function', blockType: 'function' },
])
expect(lint.emptyOutgoingPorts).toEqual([])
expect(lint.invalidBranchPorts).toEqual([])
expect(hasWorkflowLintIssues(lint)).toBe(true)
})
it('reports invalid branch handles and missing connection targets', () => {
const workflowState = {
blocks: {
start: baseBlock('start', 'starter', 'Start'),
condition: baseBlock('condition', 'condition', 'Condition', {
conditions: {
value: [{ id: 'condition-if', title: 'if', value: 'true' }],
},
}),
agent: baseBlock('agent', 'agent', 'Agent'),
},
edges: [
{
id: 'edge-start-condition',
source: 'start',
sourceHandle: 'source',
target: 'condition',
targetHandle: 'target',
},
{
id: 'edge-condition-agent',
source: 'condition',
sourceHandle: 'else',
target: 'agent',
targetHandle: 'target',
},
{
id: 'edge-agent-missing',
source: 'agent',
sourceHandle: 'source',
target: 'missing',
targetHandle: 'target',
},
],
}
const lint = lintEditedWorkflowState(workflowState as any)
expect(lint.invalidBranchPorts).toEqual([
expect.objectContaining({
blockId: 'condition',
sourceHandle: 'else',
}),
])
expect(lint.invalidConnectionTargets).toEqual([
expect.objectContaining({
sourceBlockId: 'agent',
targetBlockId: 'missing',
reason: 'Connection target block does not exist',
}),
])
expect(hasWorkflowLintIssues(lint)).toBe(true)
})
it('returns clean result when every active block and dynamic port is connected', () => {
const workflowState = {
blocks: {
start: baseBlock('start', 'starter', 'Start'),
router: baseBlock('router', 'router_v2', 'Router', {
routes: {
value: [{ id: 'route-1', title: 'Route 1', value: 'support' }],
},
}),
agent: baseBlock('agent', 'agent', 'Agent'),
},
edges: [
{
id: 'edge-start-router',
source: 'start',
sourceHandle: 'source',
target: 'router',
targetHandle: 'target',
},
{
id: 'edge-router-agent',
source: 'router',
sourceHandle: 'route-0',
target: 'agent',
targetHandle: 'target',
},
],
}
const lint = lintEditedWorkflowState(workflowState as any)
expect(lint).toEqual({
sources: [{ blockId: 'start', blockName: 'Start', blockType: 'starter' }],
sinks: [{ blockId: 'agent', blockName: 'Agent', blockType: 'agent' }],
orphanBlocks: [],
emptyOutgoingPorts: [],
invalidBranchPorts: [],
invalidConnectionTargets: [],
})
expect(hasWorkflowLintIssues(lint)).toBe(false)
})
it('objectively reports multiple sources without turning disconnected islands into an issue', () => {
const workflowState = {
blocks: {
start: baseBlock('start', 'starter', 'Start'),
fetch: baseBlock('fetch', 'function', 'FetchCurrentFiles'),
normalize: baseBlock('normalize', 'function', 'NormalizeFiles'),
slack: { ...baseBlock('slack', 'slack', 'SlackTrigger'), triggerMode: true },
filter: baseBlock('filter', 'condition', 'MessageFilter'),
},
edges: [
{
id: 'e1',
source: 'start',
sourceHandle: 'source',
target: 'fetch',
targetHandle: 'target',
},
{
id: 'e2',
source: 'fetch',
sourceHandle: 'source',
target: 'normalize',
targetHandle: 'target',
},
{
id: 'e3',
source: 'slack',
sourceHandle: 'source',
target: 'filter',
targetHandle: 'target',
},
],
}
const lint = lintEditedWorkflowState(workflowState as any)
expect(lint.sources.map((b) => b.blockId).sort()).toEqual(['slack', 'start'])
expect(lint.orphanBlocks).toEqual([])
expect(lint.emptyOutgoingPorts).toEqual([])
expect(hasWorkflowLintIssues(lint)).toBe(false)
})
it('reports sources and sinks (triggers are sources, terminals are sinks, notes excluded)', () => {
const workflowState = {
blocks: {
start: baseBlock('start', 'starter', 'Start'),
agent: baseBlock('agent', 'agent', 'Agent'),
end: baseBlock('end', 'function', 'End'),
note: baseBlock('note', 'note', 'Note'),
},
edges: [
{
id: 'e1',
source: 'start',
sourceHandle: 'source',
target: 'agent',
targetHandle: 'target',
},
{
id: 'e2',
source: 'agent',
sourceHandle: 'source',
target: 'end',
targetHandle: 'target',
},
],
}
const lint = lintEditedWorkflowState(workflowState as any)
// 'start' has no incoming edge -> a source, even though it is NOT an orphan (trigger).
expect(lint.sources).toEqual([{ blockId: 'start', blockName: 'Start', blockType: 'starter' }])
expect(lint.orphanBlocks).toEqual([])
// 'end' has no outgoing edge -> a sink.
expect(lint.sinks).toEqual([{ blockId: 'end', blockName: 'End', blockType: 'function' }])
// 'agent' has both in and out edges -> neither source nor sink.
expect(lint.sources.map((b) => b.blockId)).not.toContain('agent')
expect(lint.sinks.map((b) => b.blockId)).not.toContain('agent')
// 'note' is excluded from both even though it has no edges.
expect(lint.sources.map((b) => b.blockId)).not.toContain('note')
expect(lint.sinks.map((b) => b.blockId)).not.toContain('note')
})
it('warns when loop/parallel start ports are empty', () => {
const workflowState = {
blocks: {
start: baseBlock('start', 'starter', 'Start'),
loop: baseBlock('loop', 'loop', 'Loop'),
parallel: baseBlock('parallel', 'parallel', 'Parallel'),
},
edges: [
{
id: 'e1',
source: 'start',
sourceHandle: 'source',
target: 'loop',
targetHandle: 'target',
},
{
id: 'e2',
source: 'loop',
sourceHandle: 'loop-end-source',
target: 'parallel',
targetHandle: 'target',
},
],
}
const lint = lintEditedWorkflowState(workflowState as any)
expect(lint.emptyOutgoingPorts.map((port) => `${port.blockName}.${port.handle}`)).toEqual([
'Loop.loop-start-source',
'Parallel.parallel-start-source',
])
expect(hasWorkflowLintIssues(lint)).toBe(true)
})
it('treats loop/parallel start edges as internal so containers can still be sinks', () => {
const workflowState = {
blocks: {
start: baseBlock('start', 'starter', 'Start'),
loop: baseBlock('loop', 'loop', 'Loop'),
child: baseBlock('child', 'function', 'Loop Child'),
},
edges: [
{
id: 'e1',
source: 'start',
sourceHandle: 'source',
target: 'loop',
targetHandle: 'target',
},
{
id: 'e2',
source: 'loop',
sourceHandle: 'loop-start-source',
target: 'child',
targetHandle: 'target',
},
],
}
const lint = lintEditedWorkflowState(workflowState as any)
expect(lint.emptyOutgoingPorts).toEqual([])
expect(lint.sinks.map((block) => block.blockId).sort()).toEqual(['child', 'loop'])
expect(hasWorkflowLintIssues(lint)).toBe(false)
})
})
@@ -0,0 +1,377 @@
import { getBlock } from '@/blocks'
import { isTriggerBlockType } from '@/executor/constants'
import {
collectBlockFieldIssues,
extractBlockParams,
type InactiveModeValue,
} from '@/serializer/index'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import { validateConditionHandle, validateRouterHandle } from './validation'
type BlockState = {
id?: string
type?: string
name?: string
triggerMode?: boolean
subBlocks?: Record<string, { value?: unknown } | undefined>
}
type EdgeState = {
source?: string | null
sourceHandle?: string | null
target?: string | null
}
export interface WorkflowLintBlockRef {
blockId: string
blockName?: string
blockType?: string
}
export interface WorkflowLintEmptyOutgoingPort extends WorkflowLintBlockRef {
handle: string
label: string
}
export interface WorkflowLintInvalidBranchPort extends WorkflowLintBlockRef {
sourceHandle: string
reason: string
}
export interface WorkflowLintInvalidConnectionTarget {
sourceBlockId: string
sourceBlockName?: string
sourceHandle?: string
targetBlockId: string
reason: string
}
export interface WorkflowLintResult {
/** Every non-note block with no incoming edge (trigger blocks are naturally sources). */
sources: WorkflowLintBlockRef[]
/** Every non-note block with no outgoing edge. */
sinks: WorkflowLintBlockRef[]
orphanBlocks: WorkflowLintBlockRef[]
emptyOutgoingPorts: WorkflowLintEmptyOutgoingPort[]
invalidBranchPorts: WorkflowLintInvalidBranchPort[]
invalidConnectionTargets: WorkflowLintInvalidConnectionTarget[]
}
/** Tier-1 (sync, config) field issues for a single block. */
export interface WorkflowLintFieldIssue extends WorkflowLintBlockRef {
/** Required fields that resolve empty in the active mode. */
missingRequiredFields: string[]
/** Canonical pairs whose value is stranded on the inactive member (silently dropped). */
inactiveModeValues: InactiveModeValue[]
}
/** Tier-2 (async, DB) reference that does not resolve to an accessible entity. */
export interface WorkflowLintUnresolvedReference extends WorkflowLintBlockRef {
field: string
value: string | string[]
kind: 'credential' | 'resource' | 'custom-tool' | 'mcp-tool' | 'skill'
reason: string
}
/**
* Aggregate lint report: the graph lint plus the config (Tier 1) and resolution
* (Tier 2) checks. Returned in the edit_workflow result and written to lint.json.
*/
export interface WorkflowLintReport extends WorkflowLintResult {
fieldIssues: WorkflowLintFieldIssue[]
unresolvedReferences: WorkflowLintUnresolvedReference[]
notes: string[]
}
function blockRef(blockId: string, block: BlockState): WorkflowLintBlockRef {
return {
blockId,
blockName: block.name,
blockType: block.type,
}
}
function isWorkflowEntryBlock(block: BlockState) {
return Boolean(block.triggerMode) || isTriggerBlockType(block.type)
}
function requiredSubflowStartPort(block: BlockState) {
if (block.type === 'loop') {
return { handle: 'loop-start-source', label: 'loop-start-source' }
}
if (block.type === 'parallel') {
return { handle: 'parallel-start-source', label: 'parallel-start-source' }
}
return null
}
function countsAsExternalOutgoing(block: BlockState, sourceHandle?: string | null) {
if (block.type === 'loop') {
return sourceHandle !== 'loop-start-source'
}
if (block.type === 'parallel') {
return sourceHandle !== 'parallel-start-source'
}
return true
}
export function lintEditedWorkflowState(workflowState: Pick<WorkflowState, 'blocks' | 'edges'>) {
const blocks = (workflowState.blocks || {}) as Record<string, BlockState>
const edges = Array.isArray(workflowState.edges)
? (workflowState.edges as EdgeState[])
: ([] as EdgeState[])
const incomingEdgesByTarget = new Map<string, number>()
const outgoingEdgesBySource = new Set<string>()
const connectedDynamicHandles = new Map<string, Set<string>>()
const invalidBranchPorts: WorkflowLintInvalidBranchPort[] = []
const invalidConnectionTargets: WorkflowLintInvalidConnectionTarget[] = []
for (const edge of edges) {
const sourceBlockId = edge?.source || ''
const targetBlockId = edge?.target || ''
const sourceBlock = blocks[sourceBlockId]
const targetBlock = blocks[targetBlockId]
if (!sourceBlock || !targetBlock) {
invalidConnectionTargets.push({
sourceBlockId: sourceBlockId || 'unknown',
sourceBlockName: sourceBlock?.name,
sourceHandle: edge?.sourceHandle ?? undefined,
targetBlockId: targetBlockId || 'unknown',
reason: !sourceBlock
? 'Connection source block does not exist'
: 'Connection target block does not exist',
})
continue
}
incomingEdgesByTarget.set(targetBlockId, (incomingEdgesByTarget.get(targetBlockId) || 0) + 1)
if (countsAsExternalOutgoing(sourceBlock, edge?.sourceHandle)) {
outgoingEdgesBySource.add(sourceBlockId)
}
const sourceHandle = edge?.sourceHandle
if (!sourceHandle || sourceHandle === 'error') continue
if (sourceBlock.type === 'condition' || sourceBlock.type === 'router_v2') {
const validation =
sourceBlock.type === 'condition'
? validateConditionHandle(
sourceHandle,
sourceBlockId,
sourceBlock.subBlocks?.conditions?.value as string | any[]
)
: validateRouterHandle(
sourceHandle,
sourceBlockId,
sourceBlock.subBlocks?.routes?.value as string | any[]
)
if (!validation.valid) {
invalidBranchPorts.push({
...blockRef(sourceBlockId, sourceBlock),
sourceHandle,
reason: validation.error || `Invalid branch handle "${sourceHandle}"`,
})
continue
}
const normalizedHandle = validation.normalizedHandle || sourceHandle
const handles = connectedDynamicHandles.get(sourceBlockId) || new Set<string>()
handles.add(normalizedHandle)
connectedDynamicHandles.set(sourceBlockId, handles)
continue
}
const handles = connectedDynamicHandles.get(sourceBlockId) || new Set<string>()
handles.add(sourceHandle)
connectedDynamicHandles.set(sourceBlockId, handles)
}
const orphanBlocks = Object.entries(blocks)
.filter(([, block]) => block.type !== 'note' && !isWorkflowEntryBlock(block))
.filter(([blockId]) => !incomingEdgesByTarget.has(blockId))
.map(([blockId, block]) => blockRef(blockId, block))
// Structural descriptors (advisory, not "issues"): sources have no incoming
// edge (trigger blocks are naturally sources), sinks have no outgoing edge.
const sources = Object.entries(blocks)
.filter(([, block]) => block.type !== 'note')
.filter(([blockId]) => !incomingEdgesByTarget.has(blockId))
.map(([blockId, block]) => blockRef(blockId, block))
const sinks = Object.entries(blocks)
.filter(([, block]) => block.type !== 'note')
.filter(([blockId]) => !outgoingEdgesBySource.has(blockId))
.map(([blockId, block]) => blockRef(blockId, block))
const emptyOutgoingPorts = Object.entries(blocks).flatMap(([blockId, block]) => {
const handles = connectedDynamicHandles.get(blockId) || new Set<string>()
const requiredPort = requiredSubflowStartPort(block)
const ports = requiredPort ? [requiredPort] : []
return ports
.filter((port) => !handles.has(port.handle))
.map((port) => ({
...blockRef(blockId, block),
handle: port.handle,
label: port.label,
}))
})
return {
sources,
sinks,
orphanBlocks,
emptyOutgoingPorts,
invalidBranchPorts,
invalidConnectionTargets,
} satisfies WorkflowLintResult
}
/**
* Tier-1 config lint: per-block required-field and canonical-mode (inactive
* member) issues. Pure/sync. Uses the shared collector so results match the
* runtime serializer's required-field semantics. Skips notes and subflow
* containers, and blocks with no registry config.
*/
export function collectWorkflowFieldIssues(
blocks: WorkflowState['blocks'] | Record<string, unknown> | undefined
): WorkflowLintFieldIssue[] {
const results: WorkflowLintFieldIssue[] = []
for (const [blockId, block] of Object.entries(blocks || {})) {
const type = (block as { type?: string })?.type
if (!type || type === 'note' || type === 'loop' || type === 'parallel') continue
const blockConfig = getBlock(type)
if (!blockConfig) continue
let params: Record<string, any>
try {
params = extractBlockParams(block as any)
} catch {
continue
}
const { missingRequiredFields, inactiveModeValues } = collectBlockFieldIssues(
block as any,
blockConfig,
params
)
if (missingRequiredFields.length > 0 || inactiveModeValues.length > 0) {
results.push({
...blockRef(blockId, block as BlockState),
missingRequiredFields,
inactiveModeValues,
})
}
}
return results
}
type WorkflowLintIssueView = WorkflowLintResult & {
fieldIssues?: WorkflowLintFieldIssue[]
unresolvedReferences?: WorkflowLintUnresolvedReference[]
}
export function hasWorkflowLintIssues(lint: WorkflowLintIssueView) {
return (
lint.orphanBlocks.length > 0 ||
lint.emptyOutgoingPorts.length > 0 ||
lint.invalidBranchPorts.length > 0 ||
lint.invalidConnectionTargets.length > 0 ||
(lint.fieldIssues?.length ?? 0) > 0 ||
(lint.unresolvedReferences?.length ?? 0) > 0
)
}
export function formatWorkflowLintMessage(lint: WorkflowLintIssueView) {
const parts: string[] = []
if (lint.orphanBlocks.length > 0) {
parts.push(
`Blocks with no incoming edge: ${lint.orphanBlocks
.map((block) => `"${block.blockName || block.blockId}" (${block.blockType || 'unknown'})`)
.join(', ')}`
)
}
if (lint.emptyOutgoingPorts.length > 0) {
parts.push(
`Unconnected required subflow start ports: ${lint.emptyOutgoingPorts
.map((port) => `"${port.blockName || port.blockId}".${port.label}`)
.join(', ')}`
)
}
if (lint.invalidBranchPorts.length > 0) {
parts.push(
`Invalid condition/router branch handles: ${lint.invalidBranchPorts
.map((port) => `"${port.blockName || port.blockId}" uses "${port.sourceHandle}"`)
.join(', ')}`
)
}
if (lint.invalidConnectionTargets.length > 0) {
parts.push(
`Connections pointing at missing blocks: ${lint.invalidConnectionTargets
.map((edge) => `${edge.sourceBlockId} -> ${edge.targetBlockId}`)
.join(', ')}`
)
}
const fieldIssues = lint.fieldIssues ?? []
const missing = fieldIssues.filter((issue) => issue.missingRequiredFields.length > 0)
if (missing.length > 0) {
parts.push(
`Blocks missing required fields: ${missing
.map(
(issue) =>
`"${issue.blockName || issue.blockId}" (${issue.missingRequiredFields.join(', ')})`
)
.join(', ')}`
)
}
const inactive = fieldIssues.filter((issue) => issue.inactiveModeValues.length > 0)
if (inactive.length > 0) {
parts.push(
`Values set on the inactive field mode (they will not resolve): ${inactive
.map(
(issue) =>
`"${issue.blockName || issue.blockId}" (${issue.inactiveModeValues
.map(
(v) =>
`${v.inactiveMemberId}: move the value to "${v.activeMemberId ?? v.canonicalId}"`
)
.join('; ')})`
)
.join(', ')}`
)
}
const unresolved = lint.unresolvedReferences ?? []
const credResourceRefs = unresolved.filter(
(ref) => ref.kind === 'credential' || ref.kind === 'resource'
)
if (credResourceRefs.length > 0) {
parts.push(
`Credential/resource references that do not resolve: ${credResourceRefs
.map((ref) => `"${ref.blockName || ref.blockId}".${ref.field} (${ref.reason})`)
.join(', ')}`
)
}
const toolSkillRefs = unresolved.filter(
(ref) => ref.kind === 'custom-tool' || ref.kind === 'mcp-tool' || ref.kind === 'skill'
)
if (toolSkillRefs.length > 0) {
parts.push(
`Agent tool/skill references that do not resolve (they will not attach at runtime): ${toolSkillRefs
.map((ref) => `"${ref.blockName || ref.blockId}".${ref.field} (${ref.reason})`)
.join(', ')}`
)
}
return `Workflow lint found issues. Fix these before continuing: ${parts.join('; ')}`
}
@@ -0,0 +1,530 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
import { applyOperationsToWorkflowState } from './engine'
vi.mock('@/blocks/registry', () => ({
getAllBlocks: () => [
{
type: 'condition',
name: 'Condition',
subBlocks: [{ id: 'conditions', type: 'condition-input' }],
},
{
type: 'agent',
name: 'Agent',
subBlocks: [
{ id: 'systemPrompt', type: 'long-input' },
{ id: 'model', type: 'combobox' },
],
},
{
type: 'function',
name: 'Function',
subBlocks: [
{ id: 'code', type: 'code' },
{ id: 'language', type: 'dropdown' },
],
},
],
getBlock: (type: string) => {
const blocks: Record<string, any> = {
condition: {
type: 'condition',
name: 'Condition',
subBlocks: [{ id: 'conditions', type: 'condition-input' }],
},
agent: {
type: 'agent',
name: 'Agent',
subBlocks: [
{ id: 'systemPrompt', type: 'long-input' },
{ id: 'model', type: 'combobox' },
],
},
function: {
type: 'function',
name: 'Function',
subBlocks: [
{ id: 'code', type: 'code' },
{ id: 'language', type: 'dropdown' },
],
},
}
return blocks[type] || undefined
},
}))
function makeLoopWorkflow() {
return {
blocks: {
'loop-1': {
id: 'loop-1',
type: 'loop',
name: 'Loop 1',
position: { x: 0, y: 0 },
enabled: true,
subBlocks: {},
outputs: {},
data: { loopType: 'for', count: 5 },
},
'condition-1': {
id: 'condition-1',
type: 'condition',
name: 'Condition 1',
position: { x: 100, y: 100 },
enabled: true,
subBlocks: {
conditions: {
id: 'conditions',
type: 'condition-input',
value: JSON.stringify([
{ id: 'condition-1-if', title: 'if', value: 'true' },
{ id: 'condition-1-else', title: 'else', value: '' },
]),
},
},
outputs: {},
data: { parentId: 'loop-1', extent: 'parent' },
},
'agent-1': {
id: 'agent-1',
type: 'agent',
name: 'Agent 1',
position: { x: 300, y: 100 },
enabled: true,
subBlocks: {
systemPrompt: { id: 'systemPrompt', type: 'long-input', value: 'You are helpful' },
model: { id: 'model', type: 'combobox', value: 'gpt-4o' },
},
outputs: {},
data: { parentId: 'loop-1', extent: 'parent' },
},
},
edges: [
{
id: 'edge-1',
source: 'loop-1',
sourceHandle: 'loop-start-source',
target: 'condition-1',
targetHandle: 'target',
type: 'default',
},
{
id: 'edge-2',
source: 'condition-1',
sourceHandle: 'condition-condition-1-if',
target: 'agent-1',
targetHandle: 'target',
type: 'default',
},
],
loops: {},
parallels: {},
}
}
function makeNestedLoopWorkflow() {
return {
blocks: {
'outer-loop': {
id: 'outer-loop',
type: 'loop',
name: 'Outer Loop',
position: { x: 0, y: 0 },
enabled: true,
subBlocks: {},
outputs: {},
data: { loopType: 'for', count: 2 },
},
'inner-loop': {
id: 'inner-loop',
type: 'loop',
name: 'Inner Loop',
position: { x: 120, y: 80 },
enabled: true,
subBlocks: {},
outputs: {},
data: { parentId: 'outer-loop', extent: 'parent', loopType: 'for', count: 3 },
},
'inner-agent': {
id: 'inner-agent',
type: 'agent',
name: 'Inner Agent',
position: { x: 240, y: 120 },
enabled: true,
subBlocks: {
systemPrompt: { id: 'systemPrompt', type: 'long-input', value: 'Original prompt' },
model: { id: 'model', type: 'combobox', value: 'gpt-4o' },
},
outputs: {},
data: { parentId: 'inner-loop', extent: 'parent' },
},
},
edges: [
{
id: 'edge-outer-inner',
source: 'outer-loop',
sourceHandle: 'loop-start-source',
target: 'inner-loop',
targetHandle: 'target',
type: 'default',
},
{
id: 'edge-inner-agent',
source: 'inner-loop',
sourceHandle: 'loop-start-source',
target: 'inner-agent',
targetHandle: 'target',
type: 'default',
},
],
loops: {},
parallels: {},
}
}
describe('handleEditOperation nestedNodes merge', () => {
it('preserves existing child block IDs when editing a loop with nestedNodes', () => {
const workflow = makeLoopWorkflow()
const { state } = applyOperationsToWorkflowState(workflow, [
{
operation_type: 'edit',
block_id: 'loop-1',
params: {
nestedNodes: {
'new-condition': {
type: 'condition',
name: 'Condition 1',
inputs: {
conditions: [
{ id: 'x', title: 'if', value: 'x > 1' },
{ id: 'y', title: 'else', value: '' },
],
},
},
'new-agent': {
type: 'agent',
name: 'Agent 1',
inputs: { systemPrompt: 'Updated prompt' },
},
},
},
},
])
expect(state.blocks['condition-1']).toBeDefined()
expect(state.blocks['agent-1']).toBeDefined()
expect(state.blocks['new-condition']).toBeUndefined()
expect(state.blocks['new-agent']).toBeUndefined()
})
it('preserves edges for matched children when connections are not provided', () => {
const workflow = makeLoopWorkflow()
const { state } = applyOperationsToWorkflowState(workflow, [
{
operation_type: 'edit',
block_id: 'loop-1',
params: {
nestedNodes: {
x: { type: 'condition', name: 'Condition 1' },
y: { type: 'agent', name: 'Agent 1' },
},
},
},
])
const conditionEdge = state.edges.find((e: any) => e.source === 'condition-1')
expect(conditionEdge).toBeDefined()
})
it('removes children not present in incoming nestedNodes', () => {
const workflow = makeLoopWorkflow()
const { state } = applyOperationsToWorkflowState(workflow, [
{
operation_type: 'edit',
block_id: 'loop-1',
params: {
nestedNodes: {
x: { type: 'condition', name: 'Condition 1' },
},
},
},
])
expect(state.blocks['condition-1']).toBeDefined()
expect(state.blocks['agent-1']).toBeUndefined()
const agentEdges = state.edges.filter(
(e: any) => e.source === 'agent-1' || e.target === 'agent-1'
)
expect(agentEdges).toHaveLength(0)
})
it('creates new children that do not match existing ones', () => {
const workflow = makeLoopWorkflow()
const { state } = applyOperationsToWorkflowState(workflow, [
{
operation_type: 'edit',
block_id: 'loop-1',
params: {
nestedNodes: {
x: { type: 'condition', name: 'Condition 1' },
y: { type: 'agent', name: 'Agent 1' },
'new-func': { type: 'function', name: 'Function 1', inputs: { code: 'return 1' } },
},
},
},
])
expect(state.blocks['condition-1']).toBeDefined()
expect(state.blocks['agent-1']).toBeDefined()
const funcBlock = Object.values(state.blocks).find((b: any) => b.name === 'Function 1')
expect(funcBlock).toBeDefined()
expect((funcBlock as any).data?.parentId).toBe('loop-1')
})
it('updates inputs on matched children without changing their ID', () => {
const workflow = makeLoopWorkflow()
const { state } = applyOperationsToWorkflowState(workflow, [
{
operation_type: 'edit',
block_id: 'loop-1',
params: {
nestedNodes: {
x: {
type: 'agent',
name: 'Agent 1',
inputs: { systemPrompt: 'New prompt' },
},
y: { type: 'condition', name: 'Condition 1' },
},
},
},
])
const agent = state.blocks['agent-1']
expect(agent).toBeDefined()
expect(agent.subBlocks.systemPrompt.value).toBe('New prompt')
})
it('recursively updates an existing nested loop and preserves grandchild IDs', () => {
const workflow = makeNestedLoopWorkflow()
const { state } = applyOperationsToWorkflowState(workflow, [
{
operation_type: 'edit',
block_id: 'outer-loop',
params: {
nestedNodes: {
'new-inner-loop': {
type: 'loop',
name: 'Inner Loop',
inputs: {
loopType: 'forEach',
collection: '<start.input.items>',
},
nestedNodes: {
'new-inner-agent': {
type: 'agent',
name: 'Inner Agent',
inputs: { systemPrompt: 'Updated prompt' },
},
'new-helper': {
type: 'function',
name: 'Helper',
inputs: { code: 'return 1' },
},
},
},
},
},
},
])
expect(state.blocks['inner-loop']).toBeDefined()
expect(state.blocks['new-inner-loop']).toBeUndefined()
expect(state.blocks['inner-loop'].data.loopType).toBe('forEach')
expect(state.blocks['inner-loop'].data.collection).toBe('<start.input.items>')
expect(state.blocks['inner-agent']).toBeDefined()
expect(state.blocks['new-inner-agent']).toBeUndefined()
expect(state.blocks['inner-agent'].subBlocks.systemPrompt.value).toBe('Updated prompt')
const helperBlock = Object.values(state.blocks).find((block: any) => block.name === 'Helper') as
| any
| undefined
expect(helperBlock).toBeDefined()
expect(helperBlock?.data?.parentId).toBe('inner-loop')
})
it('removes grandchildren omitted from an existing nested loop update', () => {
const workflow = makeNestedLoopWorkflow()
const { state } = applyOperationsToWorkflowState(workflow, [
{
operation_type: 'edit',
block_id: 'outer-loop',
params: {
nestedNodes: {
'new-inner-loop': {
type: 'loop',
name: 'Inner Loop',
nestedNodes: {
'new-helper': {
type: 'function',
name: 'Helper',
inputs: { code: 'return 1' },
},
},
},
},
},
},
])
expect(state.blocks['inner-loop']).toBeDefined()
expect(state.blocks['inner-agent']).toBeUndefined()
expect(
state.edges.some(
(edge: any) => edge.source === 'inner-agent' || edge.target === 'inner-agent'
)
).toBe(false)
const helperBlock = Object.values(state.blocks).find((block: any) => block.name === 'Helper')
expect(helperBlock).toBeDefined()
})
it('removes an unmatched nested container with all descendants and edges', () => {
const workflow = makeNestedLoopWorkflow()
const { state } = applyOperationsToWorkflowState(workflow, [
{
operation_type: 'edit',
block_id: 'outer-loop',
params: {
nestedNodes: {
replacement: {
type: 'function',
name: 'Replacement',
inputs: { code: 'return 2' },
},
},
},
},
])
expect(state.blocks['inner-loop']).toBeUndefined()
expect(state.blocks['inner-agent']).toBeUndefined()
expect(
state.edges.some(
(edge: any) =>
edge.source === 'inner-loop' ||
edge.target === 'inner-loop' ||
edge.source === 'inner-agent' ||
edge.target === 'inner-agent'
)
).toBe(false)
const replacementBlock = Object.values(state.blocks).find(
(block: any) => block.name === 'Replacement'
) as any
expect(replacementBlock).toBeDefined()
expect(replacementBlock.data?.parentId).toBe('outer-loop')
})
})
describe('forward-reference connections (pending resolution)', () => {
function makeMinimalWorkflow() {
return {
blocks: {
'start-1': {
id: 'start-1',
type: 'function',
name: 'Start',
position: { x: 0, y: 0 },
enabled: true,
subBlocks: {},
outputs: {},
data: {},
},
},
edges: [] as any[],
loops: {},
parallels: {},
}
}
// Valid UUIDs so block_ids are not normalized/remapped on add.
const BLOCK_A = '11111111-1111-4111-8111-111111111111'
const BLOCK_B = '22222222-2222-4222-8222-222222222222'
it('defers a connection to a not-yet-created block and resolves it on a later apply', () => {
const workflow = makeMinimalWorkflow()
// First apply: add block A connecting to block B, which does not exist yet.
const first = applyOperationsToWorkflowState(workflow, [
{
operation_type: 'add',
block_id: BLOCK_A,
params: {
type: 'function',
name: 'Block A',
inputs: { code: 'return 1' },
connections: { source: BLOCK_B },
},
},
])
// No edge created yet; the connection is recorded as pending on block A.
expect(first.state.edges.some((e: any) => e.target === BLOCK_B)).toBe(false)
expect(first.state.blocks[BLOCK_A].data.pendingConnections.source).toEqual([
{ target: BLOCK_B, targetHandle: 'target' },
])
// Second apply (simulating a later edit_workflow call): add block B.
const second = applyOperationsToWorkflowState(first.state, [
{
operation_type: 'add',
block_id: BLOCK_B,
params: { type: 'function', name: 'Block B', inputs: { code: 'return 2' } },
},
])
// The pending edge is now created and the pending record cleared.
const edge = second.state.edges.find((e: any) => e.source === BLOCK_A && e.target === BLOCK_B)
expect(edge).toBeDefined()
expect(second.state.blocks[BLOCK_A].data?.pendingConnections).toBeUndefined()
})
it('resolves a forward-reference connection within a single apply regardless of operation order', () => {
const workflow = makeMinimalWorkflow()
const { state } = applyOperationsToWorkflowState(workflow, [
{
operation_type: 'add',
block_id: BLOCK_A,
params: {
type: 'function',
name: 'Block A',
inputs: { code: 'return 1' },
connections: { source: BLOCK_B },
},
},
{
operation_type: 'add',
block_id: BLOCK_B,
params: { type: 'function', name: 'Block B', inputs: { code: 'return 2' } },
},
])
const edge = state.edges.find((e: any) => e.source === BLOCK_A && e.target === BLOCK_B)
expect(edge).toBeDefined()
expect(state.blocks[BLOCK_A].data?.pendingConnections).toBeUndefined()
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,151 @@
import { createLogger } from '@sim/logger'
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
/** Selector subblock types that can be validated */
export const SELECTOR_TYPES = new Set([
'oauth-input',
'knowledge-base-selector',
'document-selector',
'file-selector',
'project-selector',
'channel-selector',
'folder-selector',
'mcp-server-selector',
'mcp-tool-selector',
'workflow-selector',
])
const validationLogger = createLogger('EditWorkflowValidation')
/**
* Validation error for a specific field
*/
export interface ValidationError {
blockId: string
blockType: string
field: string
value: any
error: string
}
/**
* Types of items that can be skipped during operation application
*/
export type SkippedItemType =
| 'block_not_found'
| 'invalid_block_type'
| 'block_not_allowed'
| 'block_locked'
| 'tool_not_allowed'
| 'invalid_edge_target'
| 'invalid_edge_source'
| 'invalid_edge_scope'
| 'invalid_source_handle'
| 'invalid_target_handle'
| 'invalid_subblock_field'
| 'missing_required_params'
| 'invalid_subflow_parent'
| 'nested_subflow_not_allowed'
| 'duplicate_block_name'
| 'reserved_block_name'
| 'duplicate_trigger'
| 'duplicate_single_instance_block'
/**
* Represents an item that was skipped during operation application
*/
export interface SkippedItem {
type: SkippedItemType
operationType: string
blockId: string
reason: string
details?: Record<string, any>
}
/**
* Skipped-item types that represent benign, SELF-HEALING deferrals rather than
* failures. A deferred forward-reference edge (`invalid_edge_target`) is
* recorded as a pending connection and wired automatically once its target
* block exists -- possibly on a later edit_workflow call. It must be surfaced to
* the model as informational, NOT through the "skipped/failed operation"
* channel; otherwise a literal model re-issues the self-healing operation in a
* loop. See `createValidatedEdge` (builders.ts) and `resolvePendingConnections`
* (engine.ts).
*/
export const DEFERRED_SKIPPED_ITEM_TYPES: ReadonlySet<SkippedItemType> = new Set([
'invalid_edge_target',
])
/** Whether a skipped item is a benign deferral (see DEFERRED_SKIPPED_ITEM_TYPES). */
export function isDeferredSkippedItem(item: SkippedItem): boolean {
return DEFERRED_SKIPPED_ITEM_TYPES.has(item.type)
}
/**
* Logs and records a skipped item
*/
export function logSkippedItem(skippedItems: SkippedItem[], item: SkippedItem): void {
validationLogger.warn(`Skipped ${item.operationType} operation: ${item.reason}`, {
type: item.type,
operationType: item.operationType,
blockId: item.blockId,
...(item.details && { details: item.details }),
})
skippedItems.push(item)
}
/**
* Result of input validation
*/
export interface ValidationResult {
validInputs: Record<string, any>
errors: ValidationError[]
}
/**
* Result of validating a single value
*/
export interface ValueValidationResult {
valid: boolean
value?: any
error?: ValidationError
}
export interface EditWorkflowOperation {
operation_type: 'add' | 'edit' | 'delete' | 'insert_into_subflow' | 'extract_from_subflow'
block_id: string
params?: Record<string, any>
}
export interface EditWorkflowParams {
operations: EditWorkflowOperation[]
workflowId: string
currentUserWorkflow?: string
}
export interface EdgeHandleValidationResult {
valid: boolean
error?: string
/** The normalized handle to use (e.g., simple 'if' normalized to 'condition-{uuid}') */
normalizedHandle?: string
}
/**
* Result of applying operations to workflow state
*/
export interface ApplyOperationsResult {
state: any
validationErrors: ValidationError[]
skippedItems: SkippedItem[]
}
export interface OperationContext {
modifiedState: any
skippedItems: SkippedItem[]
validationErrors: ValidationError[]
permissionConfig: PermissionGroupConfig | null
deferredConnections: Array<{
blockId: string
connections: Record<string, any>
}>
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,156 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { listLogsMock, fetchLogDetailMock, toOverviewMock, toFullMock, grepSpansMock } = vi.hoisted(
() => ({
listLogsMock: vi.fn(),
fetchLogDetailMock: vi.fn(),
toOverviewMock: vi.fn(),
toFullMock: vi.fn(),
grepSpansMock: vi.fn(),
})
)
vi.mock('@/lib/logs/list-logs', () => ({ listLogs: listLogsMock }))
vi.mock('@/lib/logs/fetch-log-detail', () => ({ fetchLogDetail: fetchLogDetailMock }))
vi.mock('@/lib/logs/log-views', () => ({
toOverview: toOverviewMock,
toFull: toFullMock,
grepSpans: grepSpansMock,
}))
vi.mock('@/lib/execution/payloads/large-execution-value', () => ({
collectLargeValueExecutionIds: vi.fn(() => []),
collectLargeValueKeys: vi.fn(() => []),
}))
import { queryLogsServerTool } from './query-logs'
const ctx = { userId: 'user-1', workspaceId: 'ws-1' }
function detail(overrides: Record<string, unknown> = {}) {
return {
executionId: 'exec-1',
workflowId: 'wf-1',
status: 'success',
trigger: 'manual',
cost: { total: 0.1 },
executionData: { totalDuration: 1234, traceSpans: [{ id: 's1' }] },
...overrides,
}
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('queryLogsServerTool', () => {
it('list view delegates to listLogs with workspaceId and no view field', async () => {
listLogsMock.mockResolvedValue({ data: [{ id: 'log-1' }], nextCursor: null })
const result = await queryLogsServerTool.execute(
{ view: 'list', sortBy: 'date', sortOrder: 'desc', limit: 100 } as any,
ctx
)
expect(listLogsMock).toHaveBeenCalledTimes(1)
const [params, userId] = listLogsMock.mock.calls[0]
expect(userId).toBe('user-1')
expect(params.workspaceId).toBe('ws-1')
expect(params).not.toHaveProperty('view')
expect(result).toEqual({ data: [{ id: 'log-1' }], nextCursor: null })
})
it('overview view returns the projected span tree', async () => {
fetchLogDetailMock.mockResolvedValue(detail())
toOverviewMock.mockReturnValue([{ id: 's1', name: 'A' }])
const result: any = await queryLogsServerTool.execute(
{ view: 'overview', executionId: 'exec-1' } as any,
ctx
)
expect(result.executionId).toBe('exec-1')
expect(result.durationMs).toBe(1234)
expect(result.spans).toEqual([{ id: 's1', name: 'A' }])
expect(toFullMock).not.toHaveBeenCalled()
})
it('full view returns materialized spans', async () => {
fetchLogDetailMock.mockResolvedValue(detail())
toFullMock.mockResolvedValue([{ id: 's1', input: { a: 1 } }])
const result: any = await queryLogsServerTool.execute(
{ view: 'full', executionId: 'exec-1', blockId: 'blk-1' } as any,
ctx
)
expect(toFullMock).toHaveBeenCalledWith(expect.anything(), expect.anything(), {
blockId: 'blk-1',
blockName: undefined,
})
expect(result.spans).toEqual([{ id: 's1', input: { a: 1 } }])
expect(result.truncated).toBe(false)
})
it('full view falls back to overview when the result is too large', async () => {
fetchLogDetailMock.mockResolvedValue(detail())
const huge = 'x'.repeat(600 * 1024)
toFullMock.mockResolvedValue([{ id: 's1', output: huge }])
toOverviewMock.mockReturnValue([{ id: 's1', name: 'A' }])
const result: any = await queryLogsServerTool.execute(
{ view: 'full', executionId: 'exec-1' } as any,
ctx
)
expect(result.truncated).toBe(true)
expect(result.note).toContain('too large')
expect(result.spans).toEqual([{ id: 's1', name: 'A' }])
})
it('pattern runs grepSpans and returns matches', async () => {
fetchLogDetailMock.mockResolvedValue(detail())
grepSpansMock.mockResolvedValue({
matches: [{ spanId: 's1', name: 'A', field: 'output', snippet: '…timeout…' }],
truncated: false,
})
const result: any = await queryLogsServerTool.execute(
{ view: 'full', executionId: 'exec-1', pattern: 'timeout' } as any,
ctx
)
expect(grepSpansMock).toHaveBeenCalledTimes(1)
expect(result.pattern).toBe('timeout')
expect(result.matches).toHaveLength(1)
expect(toFullMock).not.toHaveBeenCalled()
})
it('returns not-found for an unknown executionId', async () => {
fetchLogDetailMock.mockResolvedValue(null)
const result: any = await queryLogsServerTool.execute(
{ view: 'overview', executionId: 'missing' } as any,
ctx
)
expect(result.ok).toBe(false)
expect(result.error).toContain('missing')
})
it('throws when unauthenticated', async () => {
await expect(
queryLogsServerTool.execute({ view: 'overview', executionId: 'exec-1' } as any, {} as any)
).rejects.toThrow('Unauthorized')
})
it('rejects overview/full without executionId via inputSchema', () => {
const schema = queryLogsServerTool.inputSchema!
expect(schema.safeParse({ view: 'overview', workspaceId: 'ws-1' }).success).toBe(false)
expect(schema.safeParse({ view: 'full', workspaceId: 'ws-1' }).success).toBe(false)
expect(
schema.safeParse({ view: 'overview', workspaceId: 'ws-1', executionId: 'e1' }).success
).toBe(true)
})
})
@@ -0,0 +1,199 @@
import { createLogger } from '@sim/logger'
import { z } from 'zod'
import { QueryLogs } from '@/lib/copilot/generated/tool-catalog-v1'
import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool'
import {
collectLargeValueExecutionIds,
collectLargeValueKeys,
} from '@/lib/execution/payloads/large-execution-value'
import { fetchLogDetail } from '@/lib/logs/fetch-log-detail'
import { type ListLogsParams, listLogs } from '@/lib/logs/list-logs'
import { grepSpans, type LogViewContext, toFull, toOverview } from '@/lib/logs/log-views'
import type { TraceSpan } from '@/lib/logs/types'
const logger = createLogger('QueryLogsServerTool')
/**
* Max serialized size for a `full` view result before falling back to the
* compact overview. Keeps a single tool result inline-able.
*/
const MAX_FULL_RESULT_BYTES = 512 * 1024
const comparisonOperator = z.enum(['=', '>', '<', '>=', '<=', '!='])
const listArgsSchema = z.object({
view: z.literal('list'),
workspaceId: z.string().optional(),
level: z.string().optional(),
workflowIds: z.string().optional(),
folderIds: z.string().optional(),
triggers: z.string().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
search: z.string().optional(),
workflowName: z.string().optional(),
folderName: z.string().optional(),
executionId: z.string().optional(),
costOperator: comparisonOperator.optional(),
costValue: z.coerce.number().optional(),
durationOperator: comparisonOperator.optional(),
durationValue: z.coerce.number().optional(),
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(200).optional().default(100),
sortBy: z.enum(['date', 'duration', 'cost', 'status']).optional().default('date'),
sortOrder: z.enum(['asc', 'desc']).optional().default('desc'),
})
const overviewArgsSchema = z.object({
view: z.literal('overview'),
workspaceId: z.string().optional(),
executionId: z.string(),
pattern: z.string().optional(),
})
const fullArgsSchema = z.object({
view: z.literal('full'),
workspaceId: z.string().optional(),
executionId: z.string(),
blockId: z.string().optional(),
blockName: z.string().optional(),
pattern: z.string().optional(),
})
const queryLogsArgsSchema = z.discriminatedUnion('view', [
listArgsSchema,
overviewArgsSchema,
fullArgsSchema,
])
type QueryLogsArgs = z.infer<typeof queryLogsArgsSchema>
function resolveWorkspaceId(args: QueryLogsArgs, context?: ServerToolContext): string {
const workspaceId = args.workspaceId ?? context?.workspaceId
if (!workspaceId) {
throw new Error('workspaceId is required')
}
return workspaceId
}
function buildLogViewContext(
detail: {
workflowId: string | null
executionId: string
executionData?: unknown
},
workspaceId: string,
userId: string
): LogViewContext {
return {
workspaceId,
workflowId: detail.workflowId ?? undefined,
executionId: detail.executionId,
userId,
largeValueExecutionIds: collectLargeValueExecutionIds(detail.executionData),
largeValueKeys: collectLargeValueKeys(detail.executionData),
allowLargeValueWorkflowScope: true,
}
}
/**
* Consolidated execution/log read tool.
*
* - `view: "list"` — paginated execution summaries with the full Logs-UI filter
* set (reuses `listLogs`).
* - `view: "overview"` — a single execution's trace-span tree (timing + cost,
* no input/output).
* - `view: "full"` — a single execution's trace spans with materialized
* input/output, optionally scoped to one block via `blockId`/`blockName`.
* - `pattern` (with `overview`/`full`) — grep that execution's trace spans,
* streaming large values chunk-by-chunk.
*/
export const queryLogsServerTool: BaseServerTool<QueryLogsArgs, unknown> = {
name: QueryLogs.id,
inputSchema: queryLogsArgsSchema,
outputSchema: z.unknown(),
async execute(args: QueryLogsArgs, context?: ServerToolContext): Promise<unknown> {
if (!context?.userId) {
throw new Error('Unauthorized access')
}
const userId = context.userId
const workspaceId = resolveWorkspaceId(args, context)
if (args.view === 'list') {
const { view: _view, ...rest } = args
const params = { ...rest, workspaceId } as ListLogsParams
logger.info('query_logs list', { workspaceId, sortBy: params.sortBy })
return listLogs(params, userId)
}
// overview / full / grep — single execution by id
const detail = await fetchLogDetail({
userId,
workspaceId,
lookupColumn: 'executionId',
lookupValue: args.executionId,
})
if (!detail) {
return { ok: false, error: `Execution not found: ${args.executionId}` }
}
const execData = detail.executionData as
| { traceSpans?: TraceSpan[]; totalDuration?: number | null }
| undefined
const traceSpans = (execData?.traceSpans ?? []) as TraceSpan[]
const viewCtx = buildLogViewContext(detail, workspaceId, userId)
if (args.pattern) {
logger.info('query_logs grep', { workspaceId, executionId: args.executionId })
const { matches, truncated } = await grepSpans(traceSpans, args.pattern, viewCtx)
return {
executionId: detail.executionId,
workflowId: detail.workflowId,
status: detail.status,
pattern: args.pattern,
matches,
truncated,
}
}
if (args.view === 'overview') {
return {
executionId: detail.executionId,
workflowId: detail.workflowId,
status: detail.status,
trigger: detail.trigger,
durationMs: execData?.totalDuration ?? null,
cost: detail.cost ?? null,
spans: toOverview(traceSpans),
}
}
// full
const spans = await toFull(traceSpans, viewCtx, {
blockId: args.blockId,
blockName: args.blockName,
})
const result = {
executionId: detail.executionId,
workflowId: detail.workflowId,
status: detail.status,
trigger: detail.trigger,
cost: detail.cost ?? null,
spans,
truncated: false,
}
if (JSON.stringify(result).length > MAX_FULL_RESULT_BYTES) {
return {
executionId: detail.executionId,
workflowId: detail.workflowId,
status: detail.status,
truncated: true,
note: 'Full result too large; returning the compact overview. Scope with blockId/blockName, or use pattern to grep.',
spans: toOverview(traceSpans),
}
}
return result
},
}