chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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

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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,228 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { MEMORY } from '@/executor/constants'
import { Memory } from '@/executor/handlers/agent/memory'
import type { Message } from '@/executor/handlers/agent/types'
vi.mock('@/lib/tokenization/estimators', () => ({
getAccurateTokenCount: vi.fn((text: string) => {
return Math.ceil(text.length / 4)
}),
}))
describe('Memory', () => {
let memoryService: Memory
beforeEach(() => {
memoryService = new Memory()
})
describe('applyWindow (message-based)', () => {
it('should keep last N messages', () => {
const messages: Message[] = [
{ role: 'user', content: 'Message 1' },
{ role: 'assistant', content: 'Response 1' },
{ role: 'user', content: 'Message 2' },
{ role: 'assistant', content: 'Response 2' },
{ role: 'user', content: 'Message 3' },
{ role: 'assistant', content: 'Response 3' },
]
const result = (memoryService as any).applyWindow(messages, 4)
expect(result.length).toBe(4)
expect(result[0].content).toBe('Message 2')
expect(result[3].content).toBe('Response 3')
})
it('should return all messages if limit exceeds array length', () => {
const messages: Message[] = [
{ role: 'user', content: 'Test' },
{ role: 'assistant', content: 'Response' },
]
const result = (memoryService as any).applyWindow(messages, 10)
expect(result.length).toBe(2)
})
it('should handle invalid window size', () => {
const messages: Message[] = [{ role: 'user', content: 'Test' }]
const result = (memoryService as any).applyWindow(messages, Number.NaN)
expect(result).toEqual(messages)
})
it('should handle zero limit', () => {
const messages: Message[] = [{ role: 'user', content: 'Test' }]
const result = (memoryService as any).applyWindow(messages, 0)
expect(result).toEqual(messages)
})
})
describe('applyTokenWindow (token-based)', () => {
it('should keep messages within token limit', () => {
const messages: Message[] = [
{ role: 'user', content: 'Short' },
{ role: 'assistant', content: 'This is a longer response message' },
{ role: 'user', content: 'Another user message here' },
{ role: 'assistant', content: 'Final response' },
]
const result = (memoryService as any).applyTokenWindow(messages, 15, 'gpt-4o')
expect(result.length).toBeGreaterThan(0)
expect(result.length).toBeLessThan(messages.length)
expect(result[result.length - 1].content).toBe('Final response')
})
it('should include at least 1 message even if it exceeds limit', () => {
const messages: Message[] = [
{
role: 'user',
content:
'This is a very long message that definitely exceeds our small token limit of just 5 tokens',
},
]
const result = (memoryService as any).applyTokenWindow(messages, 5, 'gpt-4o')
expect(result.length).toBe(1)
expect(result[0].content).toBe(messages[0].content)
})
it('should process messages from newest to oldest', () => {
const messages: Message[] = [
{ role: 'user', content: 'Old message' },
{ role: 'assistant', content: 'Old response' },
{ role: 'user', content: 'New message' },
{ role: 'assistant', content: 'New response' },
]
const result = (memoryService as any).applyTokenWindow(messages, 10, 'gpt-4o')
expect(result[result.length - 1].content).toBe('New response')
})
it('should handle invalid token limit', () => {
const messages: Message[] = [{ role: 'user', content: 'Test' }]
const result = (memoryService as any).applyTokenWindow(messages, Number.NaN, 'gpt-4o')
expect(result).toEqual(messages)
})
it('should handle zero or negative token limit', () => {
const messages: Message[] = [{ role: 'user', content: 'Test' }]
const result1 = (memoryService as any).applyTokenWindow(messages, 0, 'gpt-4o')
expect(result1).toEqual(messages)
const result2 = (memoryService as any).applyTokenWindow(messages, -5, 'gpt-4o')
expect(result2).toEqual(messages)
})
it('should work without model specified', () => {
const messages: Message[] = [{ role: 'user', content: 'Test message' }]
const result = (memoryService as any).applyTokenWindow(messages, 100, undefined)
expect(result.length).toBe(1)
})
it('should handle empty messages array', () => {
const messages: Message[] = []
const result = (memoryService as any).applyTokenWindow(messages, 100, 'gpt-4o')
expect(result).toEqual([])
})
})
describe('validateConversationId', () => {
it('should throw error for missing conversationId', () => {
expect(() => {
;(memoryService as any).validateConversationId(undefined)
}).toThrow('Conversation ID is required')
})
it('should throw error for empty conversationId', () => {
expect(() => {
;(memoryService as any).validateConversationId(' ')
}).toThrow('Conversation ID is required')
})
it('should throw error for too long conversationId', () => {
const longId = 'a'.repeat(MEMORY.MAX_CONVERSATION_ID_LENGTH + 1)
expect(() => {
;(memoryService as any).validateConversationId(longId)
}).toThrow('Conversation ID too long')
})
it('should accept valid conversationId', () => {
expect(() => {
;(memoryService as any).validateConversationId('user-123')
}).not.toThrow()
})
})
describe('validateContent', () => {
it('should throw error for content exceeding max size', () => {
const largeContent = 'x'.repeat(MEMORY.MAX_MESSAGE_CONTENT_BYTES + 1)
expect(() => {
;(memoryService as any).validateContent(largeContent)
}).toThrow('Message content too large')
})
it('should accept content within limit', () => {
const content = 'Normal sized content'
expect(() => {
;(memoryService as any).validateContent(content)
}).not.toThrow()
})
})
describe('sanitizeMessageForStorage', () => {
it('should strip file payloads but preserve tool-call fields before memory persistence', () => {
const message: Message = {
role: 'user',
content: 'Analyze this file',
executionId: 'exec-1',
files: [
{
id: 'file-1',
key: 'workspace/ws-1/example.png',
name: 'example.png',
url: '/api/files/serve/workspace%2Fws-1%2Fexample.png?context=workspace',
size: 128,
type: 'image/png',
base64: 'iVBORw0KGgo=',
},
],
tool_calls: [{ id: 'call-1' }],
}
expect((memoryService as any).sanitizeMessageForStorage(message)).toEqual({
role: 'user',
content: 'Analyze this file',
executionId: 'exec-1',
tool_calls: [{ id: 'call-1' }],
})
})
})
describe('Token-based vs Message-based comparison', () => {
it('should produce different results for same limit concept', () => {
const messages: Message[] = [
{ role: 'user', content: 'A' },
{
role: 'assistant',
content: 'This is a much longer response that takes many more tokens',
},
{ role: 'user', content: 'B' },
]
const messageResult = (memoryService as any).applyWindow(messages, 2)
expect(messageResult.length).toBe(2)
const tokenResult = (memoryService as any).applyTokenWindow(messages, 10, 'gpt-4o')
expect(tokenResult.length).toBeGreaterThanOrEqual(1)
})
})
})
+300
View File
@@ -0,0 +1,300 @@
import { db } from '@sim/db'
import { memory } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, eq, sql } from 'drizzle-orm'
import { redactObjectStrings } from '@/lib/logs/execution/pii-redaction'
import { getAccurateTokenCount } from '@/lib/tokenization/estimators'
import { MEMORY } from '@/executor/constants'
import type { AgentInputs, Message } from '@/executor/handlers/agent/types'
import type { ExecutionContext } from '@/executor/types'
import { PROVIDER_DEFINITIONS } from '@/providers/models'
const logger = createLogger('Memory')
export class Memory {
async fetchMemoryMessages(ctx: ExecutionContext, inputs: AgentInputs): Promise<Message[]> {
if (!inputs.memoryType || inputs.memoryType === 'none') {
return []
}
const workspaceId = this.requireWorkspaceId(ctx)
this.validateConversationId(inputs.conversationId)
const messages = await this.fetchMemory(workspaceId, inputs.conversationId!)
switch (inputs.memoryType) {
case 'conversation':
return this.applyContextWindowLimit(messages, inputs.model)
case 'sliding_window': {
const limit = this.parsePositiveInt(
inputs.slidingWindowSize,
MEMORY.DEFAULT_SLIDING_WINDOW_SIZE
)
return this.applyWindow(messages, limit)
}
case 'sliding_window_tokens': {
const maxTokens = this.parsePositiveInt(
inputs.slidingWindowTokens,
MEMORY.DEFAULT_SLIDING_WINDOW_TOKENS
)
return this.applyTokenWindow(messages, maxTokens, inputs.model)
}
default:
return messages
}
}
async appendToMemory(
ctx: ExecutionContext,
inputs: AgentInputs,
message: Message
): Promise<void> {
if (!inputs.memoryType || inputs.memoryType === 'none') {
return
}
const workspaceId = this.requireWorkspaceId(ctx)
this.validateConversationId(inputs.conversationId)
message = await this.maskContentForStorage(ctx, message)
this.validateContent(message.content)
const key = inputs.conversationId!
await this.appendMessage(workspaceId, key, message)
logger.debug('Appended message to memory', {
workspaceId,
key,
role: message.role,
})
}
async seedMemory(ctx: ExecutionContext, inputs: AgentInputs, messages: Message[]): Promise<void> {
if (!inputs.memoryType || inputs.memoryType === 'none') {
return
}
const workspaceId = this.requireWorkspaceId(ctx)
const conversationMessages = messages.filter((m) => m.role !== 'system')
if (conversationMessages.length === 0) {
return
}
this.validateConversationId(inputs.conversationId)
const key = inputs.conversationId!
let messagesToStore = conversationMessages
if (inputs.memoryType === 'sliding_window') {
const limit = this.parsePositiveInt(
inputs.slidingWindowSize,
MEMORY.DEFAULT_SLIDING_WINDOW_SIZE
)
messagesToStore = this.applyWindow(conversationMessages, limit)
} else if (inputs.memoryType === 'sliding_window_tokens') {
const maxTokens = this.parsePositiveInt(
inputs.slidingWindowTokens,
MEMORY.DEFAULT_SLIDING_WINDOW_TOKENS
)
messagesToStore = this.applyTokenWindow(conversationMessages, maxTokens, inputs.model)
}
messagesToStore = await Promise.all(
messagesToStore.map((message) => this.maskContentForStorage(ctx, message))
)
await this.seedMemoryRecord(workspaceId, key, messagesToStore)
logger.debug('Seeded memory', {
workspaceId,
key,
count: messagesToStore.length,
})
}
/**
* Handlers persist messages to memory before the executor redacts block
* output, so mask content here too when the block-output stage is enabled —
* otherwise raw PII is stored in the memory table and read back on later runs.
* `onFailure: 'throw'` aborts rather than persisting unredacted content.
*/
private async maskContentForStorage(ctx: ExecutionContext, message: Message): Promise<Message> {
if (!ctx.piiBlockOutputRedaction?.enabled || !message.content) {
return message
}
return {
...message,
content: await redactObjectStrings(message.content, {
entityTypes: ctx.piiBlockOutputRedaction.entityTypes,
language: ctx.piiBlockOutputRedaction.language,
onFailure: 'throw',
}),
}
}
private requireWorkspaceId(ctx: ExecutionContext): string {
if (!ctx.workspaceId) {
throw new Error('workspaceId is required for memory operations')
}
return ctx.workspaceId
}
private applyWindow(messages: Message[], limit: number): Message[] {
return messages.slice(-limit)
}
private sanitizeMessageForStorage(message: Message): Message {
const { files: _files, ...messageWithoutFiles } = message
return messageWithoutFiles
}
private applyTokenWindow(messages: Message[], maxTokens: number, model?: string): Message[] {
const result: Message[] = []
let tokenCount = 0
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
const msgTokens = getAccurateTokenCount(msg.content, model)
if (tokenCount + msgTokens <= maxTokens) {
result.unshift(msg)
tokenCount += msgTokens
} else if (result.length === 0) {
result.unshift(msg)
break
} else {
break
}
}
return result
}
private applyContextWindowLimit(messages: Message[], model?: string): Message[] {
if (!model) return messages
for (const provider of Object.values(PROVIDER_DEFINITIONS)) {
if (provider.contextInformationAvailable === false) continue
const matchesPattern = provider.modelPatterns?.some((p) => p.test(model))
const matchesModel = provider.models.some((m) => m.id === model)
if (matchesPattern || matchesModel) {
const modelDef = provider.models.find((m) => m.id === model)
if (modelDef?.contextWindow) {
const maxTokens = Math.floor(modelDef.contextWindow * MEMORY.CONTEXT_WINDOW_UTILIZATION)
return this.applyTokenWindow(messages, maxTokens, model)
}
}
}
return messages
}
private async fetchMemory(workspaceId: string, key: string): Promise<Message[]> {
const result = await db
.select({ data: memory.data })
.from(memory)
.where(and(eq(memory.workspaceId, workspaceId), eq(memory.key, key)))
.limit(1)
if (result.length === 0) return []
const data = result[0].data
if (!Array.isArray(data)) return []
return data
.filter(
(msg): msg is Message =>
msg &&
typeof msg === 'object' &&
'role' in msg &&
'content' in msg &&
['system', 'user', 'assistant'].includes(msg.role) &&
typeof msg.content === 'string'
)
.map((msg) => this.sanitizeMessageForStorage(msg))
}
private async seedMemoryRecord(
workspaceId: string,
key: string,
messages: Message[]
): Promise<void> {
const now = new Date()
const sanitizedMessages = messages.map((message) => this.sanitizeMessageForStorage(message))
await db
.insert(memory)
.values({
id: generateId(),
workspaceId,
key,
data: sanitizedMessages,
createdAt: now,
updatedAt: now,
})
.onConflictDoNothing()
}
private async appendMessage(workspaceId: string, key: string, message: Message): Promise<void> {
const now = new Date()
const sanitizedMessage = this.sanitizeMessageForStorage(message)
await db
.insert(memory)
.values({
id: generateId(),
workspaceId,
key,
data: [sanitizedMessage],
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: [memory.workspaceId, memory.key],
set: {
data: sql`${memory.data} || ${JSON.stringify([sanitizedMessage])}::jsonb`,
updatedAt: now,
},
})
}
private parsePositiveInt(value: string | undefined, defaultValue: number): number {
if (!value) return defaultValue
const parsed = Number.parseInt(value, 10)
if (Number.isNaN(parsed) || parsed <= 0) return defaultValue
return parsed
}
private validateConversationId(conversationId?: string): void {
if (!conversationId || conversationId.trim() === '') {
throw new Error('Conversation ID is required')
}
if (conversationId.length > MEMORY.MAX_CONVERSATION_ID_LENGTH) {
throw new Error(
`Conversation ID too long (max ${MEMORY.MAX_CONVERSATION_ID_LENGTH} characters)`
)
}
}
private validateContent(content: string): void {
const size = Buffer.byteLength(content, 'utf8')
if (size > MEMORY.MAX_MESSAGE_CONTENT_BYTES) {
throw new Error(
`Message content too large (${size} bytes, max ${MEMORY.MAX_MESSAGE_CONTENT_BYTES})`
)
}
}
}
export const memoryService = new Memory()
@@ -0,0 +1,50 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { limitMock } = vi.hoisted(() => ({ limitMock: vi.fn() }))
vi.mock('@sim/db', () => ({
db: { select: () => ({ from: () => ({ where: () => ({ limit: limitMock }) }) }) },
skill: { workspaceId: 'workspaceId', name: 'name', content: 'content' },
}))
vi.mock('@sim/logger', () => ({
createLogger: () => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }),
}))
vi.mock('drizzle-orm', () => ({
and: vi.fn(() => ({})),
eq: vi.fn(() => ({})),
inArray: vi.fn(() => ({})),
}))
import { resolveSkillContent } from './skills-resolver'
// resolveSkillContent is the shared resolver invoked when the mothership calls
// load_user_skill (and when a workflow agent block calls load_skill).
describe('resolveSkillContent', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns null without a skill name or workspace', async () => {
expect(await resolveSkillContent('', 'ws-1')).toBeNull()
expect(await resolveSkillContent('x', '')).toBeNull()
})
it('resolves builtin skills without touching the database', async () => {
const content = await resolveSkillContent('research', 'ws-1')
expect(content).toBeTruthy()
expect(limitMock).not.toHaveBeenCalled()
})
it('resolves a workspace user skill by name', async () => {
limitMock.mockResolvedValue([{ content: '# Playbook', name: 'posthog-playbook' }])
expect(await resolveSkillContent('posthog-playbook', 'ws-1')).toBe('# Playbook')
})
it('returns null when the user skill is not found', async () => {
limitMock.mockResolvedValue([])
expect(await resolveSkillContent('missing', 'ws-1')).toBeNull()
})
})
@@ -0,0 +1,165 @@
import { db } from '@sim/db'
import { skill } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import { getBuiltinSkillById, getBuiltinSkillByName } from '@/lib/workflows/skills/builtin-skills'
import type { SkillInput } from '@/executor/handlers/agent/types'
const logger = createLogger('SkillsResolver')
function escapeXml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
interface SkillMetadata {
name: string
description: string
}
/**
* Fetch skill metadata (name + description) for system prompt injection.
* Only returns lightweight data so the LLM knows what skills are available.
*/
export async function resolveSkillMetadata(
skillInputs: SkillInput[],
workspaceId: string
): Promise<SkillMetadata[]> {
if (!skillInputs.length || !workspaceId) return []
const metadata: SkillMetadata[] = []
const dbSkillIds: string[] = []
for (const input of skillInputs) {
const builtin = getBuiltinSkillById(input.skillId)
if (builtin) {
metadata.push({ name: builtin.name, description: builtin.description })
} else {
dbSkillIds.push(input.skillId)
}
}
if (dbSkillIds.length === 0) return metadata
try {
const rows = await db
.select({ name: skill.name, description: skill.description })
.from(skill)
.where(and(eq(skill.workspaceId, workspaceId), inArray(skill.id, dbSkillIds)))
return [...metadata, ...rows]
} catch (error) {
logger.error('Failed to resolve skill metadata', { error, dbSkillIds, workspaceId })
return metadata
}
}
/**
* Fetch full skill content for a load_skill tool response.
* Called when the LLM decides a skill is relevant and invokes load_skill.
*/
export async function resolveSkillContent(
skillName: string,
workspaceId: string
): Promise<string | null> {
if (!skillName || !workspaceId) return null
const builtin = getBuiltinSkillByName(skillName)
if (builtin) return builtin.content
try {
const rows = await db
.select({ content: skill.content, name: skill.name })
.from(skill)
.where(and(eq(skill.workspaceId, workspaceId), eq(skill.name, skillName)))
.limit(1)
if (rows.length === 0) {
logger.warn('Skill not found', { skillName, workspaceId })
return null
}
return rows[0].content
} catch (error) {
logger.error('Failed to resolve skill content', { error, skillName, workspaceId })
return null
}
}
export async function resolveSkillContentById(
skillId: string,
workspaceId: string
): Promise<{ name: string; content: string } | null> {
if (!skillId || !workspaceId) return null
const builtin = getBuiltinSkillById(skillId)
if (builtin) return { name: builtin.name, content: builtin.content }
try {
const rows = await db
.select({ content: skill.content, name: skill.name })
.from(skill)
.where(and(eq(skill.workspaceId, workspaceId), eq(skill.id, skillId)))
.limit(1)
if (rows.length === 0) {
logger.warn('Skill not found', { skillId, workspaceId })
return null
}
return rows[0]
} catch (error) {
logger.error('Failed to resolve skill content', { error, skillId, workspaceId })
return null
}
}
/**
* Build the system prompt section that lists available skills.
* Uses XML format per the agentskills.io integration guide.
*/
export function buildSkillsSystemPromptSection(skills: SkillMetadata[]): string {
if (!skills.length) return ''
const skillEntries = skills
.map(
(s) =>
` <skill name="${escapeXml(s.name)}">\n <description>${escapeXml(s.description)}</description>\n </skill>`
)
.join('\n')
return [
'',
'You have access to the following skills. Use the load_skill tool to activate a skill when relevant.',
'',
'<available_skills>',
skillEntries,
'</available_skills>',
].join('\n')
}
/**
* Build the load_skill tool definition for injection into the tools array.
* Returns a ProviderToolConfig-compatible object so all providers can process it.
*/
export function buildLoadSkillTool(skillNames: string[]) {
return {
id: 'load_skill',
name: 'load_skill',
description: `Load a skill to get specialized instructions. Available skills: ${skillNames.join(', ')}`,
params: {},
parameters: {
type: 'object',
properties: {
skill_name: {
type: 'string',
description: 'Name of the skill to load',
enum: skillNames,
},
},
required: ['skill_name'],
},
}
}
+82
View File
@@ -0,0 +1,82 @@
import type { UserFile } from '@/executor/types'
export interface SkillInput {
skillId: string
name?: string
description?: string
}
export interface AgentInputs {
model?: string
responseFormat?: string | object
tools?: ToolInput[]
skills?: SkillInput[]
// Legacy inputs (backward compatible)
systemPrompt?: string
userPrompt?: string | object
memories?: any // Legacy memory block output
// New message array input (from messages-input subblock)
messages?: Message[]
// Memory configuration
memoryType?: 'none' | 'conversation' | 'sliding_window' | 'sliding_window_tokens'
conversationId?: string // Required for all non-none memory types
slidingWindowSize?: string // For message-based sliding window
slidingWindowTokens?: string // For token-based sliding window
// Deep research multi-turn
previousInteractionId?: string // Interactions API previous interaction reference
// LLM parameters
temperature?: string
maxTokens?: string
apiKey?: string
azureEndpoint?: string
azureApiVersion?: string
vertexProject?: string
vertexLocation?: string
vertexCredential?: string
bedrockAccessKeyId?: string
bedrockSecretKey?: string
bedrockRegion?: string
reasoningEffort?: string
verbosity?: string
thinkingLevel?: string
files?: unknown
}
/**
* Represents a tool input for the agent block.
*
* @remarks
* Valid types include:
* - Standard block types (e.g., 'api', 'search', 'function')
* - 'custom-tool': User-defined tools with custom code
* - 'mcp': Individual MCP tool from a connected server
*/
export interface ToolInput {
/** Tool type identifier */
type?: string
schema?: any
title?: string
code?: string
/** Tool parameters */
params?: Record<string, any>
timeout?: number
usageControl?: 'auto' | 'force' | 'none'
operation?: string
/** Database ID for custom tools (new reference format) */
customToolId?: string
}
export interface Message {
role: 'system' | 'user' | 'assistant'
content: string
files?: UserFile[]
executionId?: string
function_call?: any
tool_calls?: any[]
}
export interface StreamingConfig {
shouldUseStreaming: boolean
isBlockSelectedForOutput: boolean
hasOutgoingConnections: boolean
}
@@ -0,0 +1,263 @@
import '@sim/testing/mocks/executor'
import { inputValidationMock, inputValidationMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import { BlockType } from '@/executor/constants'
import { ApiBlockHandler } from '@/executor/handlers/api/api-handler'
import type { ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
import type { ToolConfig } from '@/tools/types'
import { getTool } from '@/tools/utils'
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
const mockGetTool = vi.mocked(getTool)
const mockExecuteTool = executeTool as Mock
const mockValidateUrlWithDNS = inputValidationMockFns.mockValidateUrlWithDNS
describe('ApiBlockHandler', () => {
let handler: ApiBlockHandler
let mockBlock: SerializedBlock
let mockContext: ExecutionContext
let mockApiTool: ToolConfig
beforeEach(() => {
handler = new ApiBlockHandler()
mockBlock = {
id: 'api-block-1',
metadata: { id: BlockType.API, name: 'Test API Block' },
position: { x: 10, y: 10 },
config: { tool: 'http_request', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
mockContext = {
workflowId: 'test-workflow-id',
blockStates: new Map(),
blockLogs: [],
metadata: { duration: 0 },
environmentVariables: {},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
completedLoops: new Set(),
}
mockApiTool = {
id: 'http_request',
name: 'HTTP Request Tool',
description: 'Makes an HTTP request',
version: '1.0',
params: {
url: { type: 'string', required: true },
method: { type: 'string', default: 'GET' },
headers: { type: 'object' },
body: { type: 'any' },
},
request: {
url: 'https://example.com/api',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => params,
},
}
// Reset mocks using vi
vi.clearAllMocks()
mockValidateUrlWithDNS.mockResolvedValue({
isValid: true,
resolvedIP: '93.184.216.34',
originalHostname: 'example.com',
})
// Set up mockGetTool to return the mockApiTool
mockGetTool.mockImplementation((toolId) => {
if (toolId === 'http_request') {
return mockApiTool
}
return undefined
})
// Default mock implementations
mockExecuteTool.mockResolvedValue({ success: true, output: { data: 'Success' } })
})
it('should handle api blocks', () => {
expect(handler.canHandle(mockBlock)).toBe(true)
const nonApiBlock: SerializedBlock = {
...mockBlock,
metadata: { id: 'other-block' },
}
expect(handler.canHandle(nonApiBlock)).toBe(false)
})
it('should execute api block correctly with valid inputs', async () => {
const inputs = {
url: 'https://example.com/api',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: 'value' }),
}
const expectedOutput = { data: 'Success' }
mockExecuteTool.mockResolvedValue({ success: true, output: { data: 'Success' } })
const result = await handler.execute(mockContext, mockBlock, inputs)
expect(mockGetTool).toHaveBeenCalledWith('http_request')
expect(mockExecuteTool).toHaveBeenCalledWith(
'http_request',
{
...inputs,
body: { key: 'value' }, // Expect parsed body
_context: { workflowId: 'test-workflow-id' },
},
{ executionContext: mockContext }
)
expect(result).toEqual(expectedOutput)
})
it('should handle missing URL gracefully (empty success response)', async () => {
const inputs = {
url: '', // Empty URL
method: 'GET',
}
const expectedOutput = { data: null, status: 200, headers: {} }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect(mockGetTool).toHaveBeenCalledWith('http_request')
expect(mockExecuteTool).not.toHaveBeenCalled()
expect(result).toEqual(expectedOutput)
})
it('should throw error for invalid URL format (no protocol)', async () => {
const inputs = { url: 'example.com/api' }
mockValidateUrlWithDNS.mockResolvedValueOnce({
isValid: false,
error: 'url must be a valid URL',
})
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'url must be a valid URL'
)
expect(mockExecuteTool).not.toHaveBeenCalled()
})
it('should throw error for generally invalid URL format', async () => {
const inputs = { url: 'htp:/invalid-url' }
mockValidateUrlWithDNS.mockResolvedValueOnce({
isValid: false,
error: 'url must use https:// protocol',
})
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'url must use https:// protocol'
)
expect(mockExecuteTool).not.toHaveBeenCalled()
})
it('should parse JSON string body correctly', async () => {
const inputs = {
url: 'https://example.com/api',
body: ' { "key": "value", "nested": { "num": 1 } } ', // With extra whitespace
}
const expectedParsedBody = { key: 'value', nested: { num: 1 } }
await handler.execute(mockContext, mockBlock, inputs)
expect(mockExecuteTool).toHaveBeenCalledWith(
'http_request',
expect.objectContaining({ body: expectedParsedBody }),
{ executionContext: mockContext }
)
})
it('should keep non-JSON string body as string', async () => {
const inputs = {
url: 'https://example.com/api',
body: 'This is plain text',
}
await handler.execute(mockContext, mockBlock, inputs)
expect(mockExecuteTool).toHaveBeenCalledWith(
'http_request',
expect.objectContaining({ body: 'This is plain text' }),
{ executionContext: mockContext }
)
})
it('should handle null body by converting to undefined', async () => {
const inputs = {
url: 'https://example.com/api',
body: null,
}
await handler.execute(mockContext, mockBlock, inputs)
expect(mockExecuteTool).toHaveBeenCalledWith(
'http_request',
expect.objectContaining({ body: undefined }),
{ executionContext: mockContext }
)
})
it('should handle API errors correctly and format message', async () => {
const inputs = {
url: 'https://example.com/notfound',
method: 'GET',
}
const errorOutput = { status: 404, statusText: 'Not Found' }
mockExecuteTool.mockResolvedValue({
success: false,
output: errorOutput,
error: 'Resource not found',
})
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'HTTP Request failed: URL: https://example.com/notfound | Method: GET | Error: Resource not found | Status: 404 | Status text: Not Found - The requested resource was not found'
)
expect(mockExecuteTool).toHaveBeenCalled()
})
it('should throw error if tool is not found', async () => {
const inputs = { url: 'https://example.com' }
// Override mock to return undefined for this test
mockGetTool.mockImplementation(() => undefined)
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'Tool not found: http_request'
)
expect(mockExecuteTool).not.toHaveBeenCalled()
})
it('should handle CORS error suggestion', async () => {
const inputs = { url: 'https://example.com/cors-issue' }
mockExecuteTool.mockResolvedValue({
success: false,
error: 'Request failed due to CORS policy',
})
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
/CORS policy prevented the request, try using a proxy or server-side request/
)
})
it('should handle generic fetch error suggestion', async () => {
const inputs = { url: 'https://unreachable.local' }
mockExecuteTool.mockResolvedValue({ success: false, error: 'Failed to fetch' })
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
/Network error, check if the URL is accessible and if you have internet connectivity/
)
})
})
@@ -0,0 +1,166 @@
import { createLogger } from '@sim/logger'
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
import { BlockType, HTTP } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
import { getTool } from '@/tools/utils'
const logger = createLogger('ApiBlockHandler')
/**
* Handler for API blocks that make external HTTP requests.
*/
export class ApiBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === BlockType.API
}
async execute(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<any> {
const tool = getTool(block.config.tool)
if (!tool) {
throw new Error(`Tool not found: ${block.config.tool}`)
}
if (tool.name?.includes('HTTP') && (!inputs.url || inputs.url.trim() === '')) {
return { data: null, status: HTTP.STATUS.OK, headers: {} }
}
if (tool.name?.includes('HTTP') && inputs.url) {
let urlToValidate = inputs.url
if (typeof urlToValidate === 'string') {
if (
(urlToValidate.startsWith('"') && urlToValidate.endsWith('"')) ||
(urlToValidate.startsWith("'") && urlToValidate.endsWith("'"))
) {
urlToValidate = urlToValidate.slice(1, -1)
inputs.url = urlToValidate
}
}
const urlValidation = await validateUrlWithDNS(urlToValidate, 'url')
if (!urlValidation.isValid) {
throw new Error(urlValidation.error)
}
}
try {
const processedInputs = { ...inputs }
if (processedInputs.body !== undefined) {
if (typeof processedInputs.body === 'string') {
try {
const trimmedBody = processedInputs.body.trim()
if (trimmedBody.startsWith('{') || trimmedBody.startsWith('[')) {
processedInputs.body = JSON.parse(trimmedBody)
}
} catch (e) {}
} else if (processedInputs.body === null) {
processedInputs.body = undefined
}
}
const result = await executeTool(
block.config.tool,
{
...processedInputs,
_context: {
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
executionId: ctx.executionId,
userId: ctx.userId,
isDeployedContext: ctx.isDeployedContext,
enforceCredentialAccess: ctx.enforceCredentialAccess,
callChain: ctx.callChain,
},
},
{ executionContext: ctx }
)
if (!result.success) {
const errorDetails = []
if (inputs.url) errorDetails.push(`URL: ${inputs.url}`)
if (inputs.method) errorDetails.push(`Method: ${inputs.method}`)
if (result.error) errorDetails.push(`Error: ${result.error}`)
if (result.output?.status) errorDetails.push(`Status: ${result.output.status}`)
if (result.output?.statusText) errorDetails.push(`Status text: ${result.output.statusText}`)
let suggestion = ''
if (result.output?.status === HTTP.STATUS.FORBIDDEN) {
suggestion = ' - This may be due to CORS restrictions or authorization issues'
} else if (result.output?.status === HTTP.STATUS.NOT_FOUND) {
suggestion = ' - The requested resource was not found'
} else if (result.output?.status === HTTP.STATUS.TOO_MANY_REQUESTS) {
suggestion = ' - Too many requests, you may need to implement rate limiting'
} else if (result.output?.status >= HTTP.STATUS.SERVER_ERROR) {
suggestion = ' - Server error, the target server is experiencing issues'
} else if (result.error?.includes('CORS')) {
suggestion =
' - CORS policy prevented the request, try using a proxy or server-side request'
} else if (result.error?.includes('Failed to fetch')) {
suggestion =
' - Network error, check if the URL is accessible and if you have internet connectivity'
}
const errorMessage =
errorDetails.length > 0
? `HTTP Request failed: ${errorDetails.join(' | ')}${suggestion}`
: `API request to ${tool.name || block.config.tool} failed with no error message`
const error = new Error(errorMessage)
Object.assign(error, {
toolId: block.config.tool,
toolName: tool.name || 'Unknown tool',
blockId: block.id,
blockName: block.metadata?.name || 'Unnamed Block',
output: result.output || {},
status: result.output?.status || null,
request: {
url: inputs.url,
method: inputs.method || 'GET',
},
timestamp: new Date().toISOString(),
})
throw error
}
return result.output
} catch (error: any) {
if (!error.message || error.message === 'undefined (undefined)') {
let errorMessage = `API request to ${tool.name || block.config.tool} failed`
if (inputs.url) errorMessage += `: ${inputs.url}`
if (error.status) errorMessage += ` (Status: ${error.status})`
if (error.statusText) errorMessage += ` - ${error.statusText}`
if (errorMessage === `API request to ${tool.name || block.config.tool} failed`) {
errorMessage += ` - ${block.metadata?.name || 'Unknown error'}`
}
error.message = errorMessage
}
if (typeof error === 'object' && error !== null) {
if (!error.toolId) error.toolId = block.config.tool
if (!error.blockName) error.blockName = block.metadata?.name || 'Unnamed Block'
if (inputs && !error.request) {
error.request = {
url: inputs.url,
method: inputs.method || 'GET',
}
}
}
throw error
}
}
}
@@ -0,0 +1,987 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { BlockType } from '@/executor/constants'
import { ConditionBlockHandler } from '@/executor/handlers/condition/condition-handler'
import type { BlockState, ExecutionContext } from '@/executor/types'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
vi.mock('@/tools', () => ({
executeTool: vi.fn(),
}))
vi.mock('@/executor/utils/block-data', () => ({
collectBlockData: vi.fn(() => ({
blockData: { 'source-block-1': { value: 10, text: 'hello' } },
blockNameMapping: { sourceblock: 'source-block-1' },
})),
}))
import { collectBlockData } from '@/executor/utils/block-data'
import { executeTool } from '@/tools'
const mockExecuteTool = executeTool as ReturnType<typeof vi.fn>
const mockCollectBlockData = collectBlockData as ReturnType<typeof vi.fn>
describe('ConditionBlockHandler', () => {
let handler: ConditionBlockHandler
let mockBlock: SerializedBlock
let mockContext: ExecutionContext
let mockWorkflow: Partial<SerializedWorkflow>
let mockSourceBlock: SerializedBlock
let mockTargetBlock1: SerializedBlock
let mockTargetBlock2: SerializedBlock
beforeEach(() => {
mockSourceBlock = {
id: 'source-block-1',
metadata: { id: 'source', name: 'Source Block' },
position: { x: 10, y: 10 },
config: { tool: 'source_tool', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
mockBlock = {
id: 'cond-block-1',
metadata: { id: BlockType.CONDITION, name: 'Test Condition' },
position: { x: 50, y: 50 },
config: { tool: BlockType.CONDITION, params: {} },
inputs: { conditions: 'json' },
outputs: {},
enabled: true,
}
mockTargetBlock1 = {
id: 'target-block-1',
metadata: { id: 'target', name: 'Target Block 1' },
position: { x: 100, y: 100 },
config: { tool: 'target_tool_1', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
mockTargetBlock2 = {
id: 'target-block-2',
metadata: { id: 'target', name: 'Target Block 2' },
position: { x: 100, y: 150 },
config: { tool: 'target_tool_2', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
mockWorkflow = {
blocks: [mockSourceBlock, mockBlock, mockTargetBlock1, mockTargetBlock2],
connections: [
{ source: mockSourceBlock.id, target: mockBlock.id },
{
source: mockBlock.id,
target: mockTargetBlock1.id,
sourceHandle: 'condition-cond1',
},
{
source: mockBlock.id,
target: mockTargetBlock2.id,
sourceHandle: 'condition-else1',
},
],
}
handler = new ConditionBlockHandler()
mockContext = {
workflowId: 'test-workflow-id',
workspaceId: 'test-workspace-id',
blockStates: new Map<string, BlockState>([
[
mockSourceBlock.id,
{
output: { value: 10, text: 'hello' },
executed: true,
executionTime: 100,
},
],
]),
blockLogs: [],
metadata: { duration: 0 },
environmentVariables: { API_KEY: 'test-key' },
workflowVariables: { userName: { name: 'userName', value: 'john', type: 'plain' } },
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
executedBlocks: new Set([mockSourceBlock.id]),
activeExecutionPath: new Set(),
workflow: mockWorkflow as SerializedWorkflow,
completedLoops: new Set(),
}
vi.clearAllMocks()
// Default: condition evaluates to false (else path). Individual tests override with mockResolvedValueOnce.
mockExecuteTool.mockResolvedValue({ success: true, output: { result: false } })
})
it('should handle condition blocks', () => {
expect(handler.canHandle(mockBlock)).toBe(true)
const nonCondBlock: SerializedBlock = { ...mockBlock, metadata: { id: 'other' } }
expect(handler.canHandle(nonCondBlock)).toBe(false)
})
it('should execute condition block correctly and select first path', async () => {
// Mock executeTool to return true for the condition
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value > 5' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const expectedOutput = {
value: 10,
text: 'hello',
conditionResult: true,
selectedPath: {
blockId: mockTargetBlock1.id,
blockType: 'target',
blockTitle: 'Target Block 1',
},
selectedOption: 'cond1',
}
const result = await handler.execute(mockContext, mockBlock, inputs)
expect(result).toEqual(expectedOutput)
expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('cond1')
})
it('should pass correct parameters to function_execute tool', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value > 5' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
await handler.execute(mockContext, mockBlock, inputs)
expect(mockExecuteTool).toHaveBeenCalledWith(
'function_execute',
expect.objectContaining({
code: expect.stringContaining('context.value > 5'),
timeout: 5000,
envVars: mockContext.environmentVariables,
workflowVariables: mockContext.workflowVariables,
blockData: { 'source-block-1': { value: 10, text: 'hello' } },
blockNameMapping: { sourceblock: 'source-block-1' },
_context: {
workflowId: 'test-workflow-id',
workspaceId: 'test-workspace-id',
},
}),
{ executionContext: mockContext }
)
})
it('should select the else path if other conditions fail', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } })
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value < 0' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const expectedOutput = {
value: 10,
text: 'hello',
conditionResult: true,
selectedPath: {
blockId: mockTargetBlock2.id,
blockType: 'target',
blockTitle: 'Target Block 2',
},
selectedOption: 'else1',
}
const result = await handler.execute(mockContext, mockBlock, inputs)
expect(result).toEqual(expectedOutput)
expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('else1')
})
it('should handle invalid conditions JSON format', async () => {
const inputs = { conditions: '{ "invalid json ' }
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
/^Invalid conditions format:/
)
})
it('should handle evaluation errors gracefully', async () => {
mockExecuteTool.mockResolvedValueOnce({
success: false,
error: 'Cannot read property "doSomething" of undefined',
})
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.nonExistentProperty.doSomething()' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
/Evaluation error in condition "if"/
)
})
it('should handle missing source block output gracefully', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
const conditions = [{ id: 'cond1', title: 'if', value: 'true' }]
const inputs = { conditions: JSON.stringify(conditions) }
const contextWithoutSource = {
...mockContext,
blockStates: new Map<string, BlockState>(),
}
const result = await handler.execute(contextWithoutSource, mockBlock, inputs)
expect(result).toHaveProperty('conditionResult', true)
expect(result).toHaveProperty('selectedOption', 'cond1')
})
it('should throw error if target block is missing', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
const conditions = [{ id: 'cond1', title: 'if', value: 'true' }]
const inputs = { conditions: JSON.stringify(conditions) }
mockContext.workflow!.blocks = [mockSourceBlock, mockBlock, mockTargetBlock2]
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
`Target block ${mockTargetBlock1.id} not found`
)
})
it('should return no-match result if no condition matches and no else exists', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } })
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } })
const conditions = [
{ id: 'cond1', title: 'if', value: 'false' },
{ id: 'cond2', title: 'else if', value: 'context.value === 99' },
]
const inputs = { conditions: JSON.stringify(conditions) }
mockContext.workflow!.connections = [
{ source: mockSourceBlock.id, target: mockBlock.id },
{
source: mockBlock.id,
target: mockTargetBlock1.id,
sourceHandle: 'condition-cond1',
},
]
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).conditionResult).toBe(false)
expect((result as any).selectedPath).toBeNull()
expect((result as any).selectedOption).toBeNull()
expect(mockContext.decisions.condition.has(mockBlock.id)).toBe(false)
})
it('falls back to else path when loop context data is unavailable', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } })
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.item === "apple"' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('else1')
expect((result as any).selectedOption).toBe('else1')
})
it('should use collectBlockData to gather block state', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
const conditions = [
{ id: 'cond1', title: 'if', value: 'true' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
await handler.execute(mockContext, mockBlock, inputs)
expect(mockCollectBlockData).toHaveBeenCalledWith(mockContext, mockBlock.id)
})
it('should handle function_execute tool failure', async () => {
mockExecuteTool.mockResolvedValueOnce({
success: false,
error: 'Execution timeout',
})
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value > 5' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
/Evaluation error in condition "if".*Execution timeout/
)
})
describe('Multiple branches to same target', () => {
it('should handle if and else pointing to same target', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value > 5' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
mockContext.workflow!.connections = [
{ source: mockSourceBlock.id, target: mockBlock.id },
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-cond1' },
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-else1' },
]
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).conditionResult).toBe(true)
expect((result as any).selectedOption).toBe('cond1')
expect((result as any).selectedPath).toEqual({
blockId: mockTargetBlock1.id,
blockType: 'target',
blockTitle: 'Target Block 1',
})
})
it('should select else branch to same target when if fails', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } })
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value < 0' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
mockContext.workflow!.connections = [
{ source: mockSourceBlock.id, target: mockBlock.id },
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-cond1' },
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-else1' },
]
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).conditionResult).toBe(true)
expect((result as any).selectedOption).toBe('else1')
expect((result as any).selectedPath).toEqual({
blockId: mockTargetBlock1.id,
blockType: 'target',
blockTitle: 'Target Block 1',
})
})
it('should handle if→A, elseif→B, else→A pattern', async () => {
// First condition (cond1): false
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } })
// Second condition (cond2): false
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } })
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value === 1' },
{ id: 'cond2', title: 'else if', value: 'context.value === 2' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
mockContext.workflow!.connections = [
{ source: mockSourceBlock.id, target: mockBlock.id },
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-cond1' },
{ source: mockBlock.id, target: mockTargetBlock2.id, sourceHandle: 'condition-cond2' },
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-else1' },
]
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).conditionResult).toBe(true)
expect((result as any).selectedOption).toBe('else1')
expect((result as any).selectedPath?.blockId).toBe(mockTargetBlock1.id)
})
})
describe('Condition evaluation with different data types', () => {
it('should evaluate string comparison conditions', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
output: { name: 'test', status: 'active' },
executed: true,
executionTime: 100,
})
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.status === "active"' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).selectedOption).toBe('cond1')
})
it('should evaluate boolean conditions', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
output: { isEnabled: true, count: 5 },
executed: true,
executionTime: 100,
})
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.isEnabled' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).selectedOption).toBe('cond1')
})
it('should evaluate array length conditions', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
output: { items: [1, 2, 3, 4, 5] },
executed: true,
executionTime: 100,
})
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.items.length > 3' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).selectedOption).toBe('cond1')
})
it('should evaluate null/undefined check conditions', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
output: { data: null },
executed: true,
executionTime: 100,
})
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.data === null' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).selectedOption).toBe('cond1')
})
})
describe('Multiple else-if conditions', () => {
it('should evaluate multiple else-if conditions in order', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } })
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
output: { score: 75 },
executed: true,
executionTime: 100,
})
const mockTargetBlock3: SerializedBlock = {
id: 'target-block-3',
metadata: { id: 'target', name: 'Target Block 3' },
position: { x: 100, y: 200 },
config: { tool: 'target_tool_3', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
mockContext.workflow!.blocks!.push(mockTargetBlock3)
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.score >= 90' },
{ id: 'cond2', title: 'else if', value: 'context.score >= 70' },
{ id: 'cond3', title: 'else if', value: 'context.score >= 50' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
mockContext.workflow!.connections = [
{ source: mockSourceBlock.id, target: mockBlock.id },
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-cond1' },
{ source: mockBlock.id, target: mockTargetBlock2.id, sourceHandle: 'condition-cond2' },
{ source: mockBlock.id, target: mockTargetBlock3.id, sourceHandle: 'condition-cond3' },
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-else1' },
]
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).selectedOption).toBe('cond2')
expect((result as any).selectedPath?.blockId).toBe(mockTargetBlock2.id)
})
it('should skip to else when all else-if fail', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } })
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } })
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } })
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
output: { score: 30 },
executed: true,
executionTime: 100,
})
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.score >= 90' },
{ id: 'cond2', title: 'else if', value: 'context.score >= 70' },
{ id: 'cond3', title: 'else if', value: 'context.score >= 50' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).selectedOption).toBe('else1')
})
})
describe('Condition with no outgoing edge', () => {
it('should set selectedOption when condition matches but has no edge', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
const conditions = [
{ id: 'cond1', title: 'if', value: 'true' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
mockContext.workflow!.connections = [
{ source: mockSourceBlock.id, target: mockBlock.id },
{ source: mockBlock.id, target: mockTargetBlock2.id, sourceHandle: 'condition-else1' },
]
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).conditionResult).toBe(true)
expect((result as any).selectedPath).toBeNull()
expect((result as any).selectedOption).toBe('cond1')
expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('cond1')
})
it('should set selectedOption when else is selected but has no edge', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } })
const conditions = [
{ id: 'cond1', title: 'if', value: 'false' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
mockContext.workflow!.connections = [
{ source: mockSourceBlock.id, target: mockBlock.id },
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-cond1' },
]
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).conditionResult).toBe(true)
expect((result as any).selectedPath).toBeNull()
expect((result as any).selectedOption).toBe('else1')
expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('else1')
})
it('should deactivate if-path when else is selected with no edge', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } })
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value > 100' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
mockContext.workflow!.connections = [
{ source: mockSourceBlock.id, target: mockBlock.id },
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-cond1' },
]
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).selectedOption).toBe('else1')
expect((result as any).conditionResult).toBe(true)
})
})
describe('Empty conditions handling', () => {
it('should handle empty conditions array', async () => {
const conditions: unknown[] = []
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).conditionResult).toBe(false)
expect((result as any).selectedPath).toBeNull()
expect((result as any).selectedOption).toBeNull()
})
it('should handle conditions passed as array directly', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
const conditions = [
{ id: 'cond1', title: 'if', value: 'true' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).selectedOption).toBe('cond1')
})
})
describe('Source output filtering', () => {
it('should not propagate error field from source block output', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
output: { value: 10, text: 'hello', error: 'upstream block failed' },
executed: true,
executionTime: 100,
})
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value > 5' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).conditionResult).toBe(true)
expect((result as any).selectedOption).toBe('cond1')
expect(result).not.toHaveProperty('error')
})
it('should not propagate _pauseMetadata from source block output', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
output: { value: 10, _pauseMetadata: { contextId: 'abc' } },
executed: true,
executionTime: 100,
})
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value > 5' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).conditionResult).toBe(true)
expect(result).not.toHaveProperty('_pauseMetadata')
})
it('should still pass through non-control fields from source output', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
output: { value: 10, text: 'hello', customData: { nested: true } },
executed: true,
executionTime: 100,
})
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value > 5' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).value).toBe(10)
expect((result as any).text).toBe('hello')
expect((result as any).customData).toEqual({ nested: true })
})
})
describe('Virtual block ID handling', () => {
it('should use currentVirtualBlockId for decision key when available', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
mockContext.currentVirtualBlockId = 'virtual-block-123'
const conditions = [
{ id: 'cond1', title: 'if', value: 'true' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
await handler.execute(mockContext, mockBlock, inputs)
expect(mockContext.decisions.condition.get('virtual-block-123')).toBe('cond1')
expect(mockContext.decisions.condition.has(mockBlock.id)).toBe(false)
})
})
describe('Parallel branch handling', () => {
it('should resolve connections and block data correctly when inside a parallel branch', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
const parallelConditionBlock: SerializedBlock = {
id: 'cond-block-1₍0₎',
metadata: { id: 'condition', name: 'Condition' },
position: { x: 0, y: 0 },
config: {},
}
const sourceBlockVirtualId = 'agent-block-1₍0₎'
const parallelWorkflow: SerializedWorkflow = {
blocks: [
{
id: 'agent-block-1',
metadata: { id: 'agent', name: 'Agent' },
position: { x: 0, y: 0 },
config: {},
},
{
id: 'cond-block-1',
metadata: { id: 'condition', name: 'Condition' },
position: { x: 100, y: 0 },
config: {},
},
{
id: 'target-block-1',
metadata: { id: 'api', name: 'Target' },
position: { x: 200, y: 0 },
config: {},
},
],
connections: [
{ source: 'agent-block-1', target: 'cond-block-1' },
{ source: 'cond-block-1', target: 'target-block-1', sourceHandle: 'condition-cond1' },
],
loops: [],
parallels: [],
}
const parallelBlockStates = new Map<string, BlockState>([
[
sourceBlockVirtualId,
{ output: { response: 'hello from branch 0', success: true }, executed: true },
],
])
const parallelContext: ExecutionContext = {
workflowId: 'test-workflow-id',
workspaceId: 'test-workspace-id',
workflow: parallelWorkflow,
blockStates: parallelBlockStates,
blockLogs: [],
completedBlocks: new Set(),
decisions: {
router: new Map(),
condition: new Map(),
},
environmentVariables: {},
workflowVariables: {},
}
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.response === "hello from branch 0"' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(parallelContext, parallelConditionBlock, inputs)
expect((result as any).conditionResult).toBe(true)
expect((result as any).selectedOption).toBe('cond1')
expect((result as any).selectedPath).toEqual({
blockId: 'target-block-1',
blockType: 'api',
blockTitle: 'Target',
})
})
it('should find correct source block output in parallel branch context', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: true } })
const parallelConditionBlock: SerializedBlock = {
id: 'cond-block-1₍1₎',
metadata: { id: 'condition', name: 'Condition' },
position: { x: 0, y: 0 },
config: {},
}
const parallelWorkflow: SerializedWorkflow = {
blocks: [
{
id: 'agent-block-1',
metadata: { id: 'agent', name: 'Agent' },
position: { x: 0, y: 0 },
config: {},
},
{
id: 'cond-block-1',
metadata: { id: 'condition', name: 'Condition' },
position: { x: 100, y: 0 },
config: {},
},
{
id: 'target-block-1',
metadata: { id: 'api', name: 'Target' },
position: { x: 200, y: 0 },
config: {},
},
],
connections: [
{ source: 'agent-block-1', target: 'cond-block-1' },
{ source: 'cond-block-1', target: 'target-block-1', sourceHandle: 'condition-cond1' },
],
loops: [],
parallels: [],
}
const parallelBlockStates = new Map<string, BlockState>([
['agent-block-1₍0₎', { output: { value: 10 }, executed: true }],
['agent-block-1₍1₎', { output: { value: 25 }, executed: true }],
['agent-block-1₍2₎', { output: { value: 5 }, executed: true }],
])
const parallelContext: ExecutionContext = {
workflowId: 'test-workflow-id',
workspaceId: 'test-workspace-id',
workflow: parallelWorkflow,
blockStates: parallelBlockStates,
blockLogs: [],
completedBlocks: new Set(),
decisions: {
router: new Map(),
condition: new Map(),
},
environmentVariables: {},
workflowVariables: {},
}
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value > 20' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(parallelContext, parallelConditionBlock, inputs)
expect((result as any).conditionResult).toBe(true)
expect((result as any).selectedOption).toBe('cond1')
})
it('should fall back to else when condition is false in parallel branch', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } })
const parallelConditionBlock: SerializedBlock = {
id: 'cond-block-1₍2₎',
metadata: { id: 'condition', name: 'Condition' },
position: { x: 0, y: 0 },
config: {},
}
const parallelWorkflow: SerializedWorkflow = {
blocks: [
{
id: 'agent-block-1',
metadata: { id: 'agent', name: 'Agent' },
position: { x: 0, y: 0 },
config: {},
},
{
id: 'cond-block-1',
metadata: { id: 'condition', name: 'Condition' },
position: { x: 100, y: 0 },
config: {},
},
{
id: 'target-true',
metadata: { id: 'api', name: 'True Path' },
position: { x: 200, y: 0 },
config: {},
},
{
id: 'target-false',
metadata: { id: 'api', name: 'False Path' },
position: { x: 200, y: 100 },
config: {},
},
],
connections: [
{ source: 'agent-block-1', target: 'cond-block-1' },
{ source: 'cond-block-1', target: 'target-true', sourceHandle: 'condition-cond1' },
{ source: 'cond-block-1', target: 'target-false', sourceHandle: 'condition-else1' },
],
loops: [],
parallels: [],
}
const parallelBlockStates = new Map<string, BlockState>([
['agent-block-1₍0₎', { output: { value: 100 }, executed: true }],
['agent-block-1₍1₎', { output: { value: 50 }, executed: true }],
['agent-block-1₍2₎', { output: { value: 5 }, executed: true }],
])
const parallelContext: ExecutionContext = {
workflowId: 'test-workflow-id',
workspaceId: 'test-workspace-id',
workflow: parallelWorkflow,
blockStates: parallelBlockStates,
blockLogs: [],
completedBlocks: new Set(),
decisions: {
router: new Map(),
condition: new Map(),
},
environmentVariables: {},
workflowVariables: {},
}
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value > 20' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(parallelContext, parallelConditionBlock, inputs)
expect((result as any).conditionResult).toBe(true)
expect((result as any).selectedOption).toBe('else1')
expect((result as any).selectedPath.blockId).toBe('target-false')
})
})
})
@@ -0,0 +1,264 @@
import { createLogger } from '@sim/logger'
import { normalizeStringRecord, normalizeWorkflowVariables } from '@/lib/core/utils/records'
import type { BlockOutput } from '@/blocks/types'
import { BlockType, CONDITION, DEFAULTS, EDGE } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { collectBlockData } from '@/executor/utils/block-data'
import {
buildBranchNodeId,
extractBaseBlockId,
extractBranchIndex,
isBranchNodeId,
} from '@/executor/utils/subflow-utils'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
const logger = createLogger('ConditionBlockHandler')
const CONDITION_TIMEOUT_MS = 5000
/**
* Evaluates a single condition expression.
* Variable resolution is handled consistently with the function block via the function_execute tool.
* Returns true if condition is met, false otherwise.
*/
async function evaluateConditionExpression(
ctx: ExecutionContext,
conditionExpression: string,
providedEvalContext?: Record<string, any>,
currentNodeId?: string
): Promise<boolean> {
const evalContext = providedEvalContext || {}
try {
const contextSetup = `const context = ${JSON.stringify(evalContext)};`
const code = `${contextSetup}\nreturn Boolean(${conditionExpression})`
const { blockData, blockNameMapping, blockOutputSchemas } = collectBlockData(ctx, currentNodeId)
const result = await executeTool(
'function_execute',
{
code,
timeout: CONDITION_TIMEOUT_MS,
envVars: normalizeStringRecord(ctx.environmentVariables),
workflowVariables: normalizeWorkflowVariables(ctx.workflowVariables),
blockData,
blockNameMapping,
blockOutputSchemas,
_context: {
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
userId: ctx.userId,
isDeployedContext: ctx.isDeployedContext,
enforceCredentialAccess: ctx.enforceCredentialAccess,
},
},
{ executionContext: ctx }
)
if (!result.success) {
logger.error(`Failed to evaluate condition: ${result.error}`, {
originalCondition: conditionExpression,
evalContext,
error: result.error,
})
throw new Error(`Evaluation error in condition: ${result.error}`)
}
return Boolean(result.output?.result)
} catch (evalError: any) {
logger.error(`Failed to evaluate condition: ${evalError.message}`, {
originalCondition: conditionExpression,
evalContext,
evalError,
})
throw new Error(`Evaluation error in condition: ${evalError.message}`)
}
}
/**
* Handler for Condition blocks that evaluate expressions to determine execution paths.
*/
export class ConditionBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === BlockType.CONDITION
}
async execute(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<BlockOutput> {
const conditions = this.parseConditions(inputs.conditions)
const baseBlockId = extractBaseBlockId(block.id)
const branchIndex = isBranchNodeId(block.id) ? extractBranchIndex(block.id) : null
const sourceConnection = ctx.workflow?.connections.find((conn) => conn.target === baseBlockId)
let sourceBlockId = sourceConnection?.source
if (sourceBlockId && branchIndex !== null) {
const virtualSourceId = buildBranchNodeId(sourceBlockId, branchIndex)
if (ctx.blockStates.has(virtualSourceId)) {
sourceBlockId = virtualSourceId
}
}
const evalContext = this.buildEvaluationContext(ctx, sourceBlockId)
const rawSourceOutput = sourceBlockId ? ctx.blockStates.get(sourceBlockId)?.output : null
const sourceOutput = this.filterSourceOutput(rawSourceOutput)
const outgoingConnections = ctx.workflow?.connections.filter(
(conn) => conn.source === baseBlockId
)
const { selectedConnection, selectedCondition } = await this.evaluateConditions(
conditions,
outgoingConnections || [],
evalContext,
ctx,
block.id
)
if (!selectedCondition) {
return {
...((sourceOutput as any) || {}),
conditionResult: false,
selectedPath: null,
selectedOption: null,
}
}
if (!selectedConnection) {
const decisionKey = ctx.currentVirtualBlockId || block.id
ctx.decisions.condition.set(decisionKey, selectedCondition.id)
return {
...((sourceOutput as any) || {}),
conditionResult: true,
selectedPath: null,
selectedOption: selectedCondition.id,
}
}
const targetBlock = ctx.workflow?.blocks.find((b) => b.id === selectedConnection?.target)
if (!targetBlock) {
throw new Error(`Target block ${selectedConnection?.target} not found`)
}
const decisionKey = ctx.currentVirtualBlockId || block.id
ctx.decisions.condition.set(decisionKey, selectedCondition.id)
return {
...((sourceOutput as any) || {}),
conditionResult: true,
selectedPath: {
blockId: targetBlock.id,
blockType: targetBlock.metadata?.id || DEFAULTS.BLOCK_TYPE,
blockTitle: targetBlock.metadata?.name || DEFAULTS.BLOCK_TITLE,
},
selectedOption: selectedCondition.id,
}
}
private filterSourceOutput(output: any): any {
if (!output || typeof output !== 'object') {
return output
}
const { _pauseMetadata, error, providerTiming, tokens, toolCalls, model, cost, ...rest } =
output
return rest
}
private parseConditions(input: any): Array<{ id: string; title: string; value: string }> {
try {
const conditions = Array.isArray(input) ? input : JSON.parse(input || '[]')
return conditions
} catch (error: any) {
logger.error('Failed to parse conditions:', { input, error })
throw new Error(`Invalid conditions format: ${error.message}`)
}
}
private buildEvaluationContext(
ctx: ExecutionContext,
sourceBlockId?: string
): Record<string, any> {
let evalContext: Record<string, any> = {}
if (sourceBlockId) {
const sourceOutput = ctx.blockStates.get(sourceBlockId)?.output
if (sourceOutput && typeof sourceOutput === 'object' && sourceOutput !== null) {
evalContext = {
...evalContext,
...sourceOutput,
}
}
}
return evalContext
}
private async evaluateConditions(
conditions: Array<{ id: string; title: string; value: string }>,
outgoingConnections: Array<{ source: string; target: string; sourceHandle?: string }>,
evalContext: Record<string, any>,
ctx: ExecutionContext,
currentNodeId?: string
): Promise<{
selectedConnection: { target: string; sourceHandle?: string } | null
selectedCondition: { id: string; title: string; value: string } | null
}> {
for (const condition of conditions) {
if (condition.title === CONDITION.ELSE_TITLE) {
const connection = this.findConnectionForCondition(outgoingConnections, condition.id)
if (connection) {
return { selectedConnection: connection, selectedCondition: condition }
}
continue
}
const conditionValueString = String(condition.value || '')
try {
const conditionMet = await evaluateConditionExpression(
ctx,
conditionValueString,
evalContext,
currentNodeId
)
if (conditionMet) {
const connection = this.findConnectionForCondition(outgoingConnections, condition.id)
if (connection) {
return { selectedConnection: connection, selectedCondition: condition }
}
return { selectedConnection: null, selectedCondition: condition }
}
} catch (error: any) {
logger.error(`Failed to evaluate condition "${condition.title}": ${error.message}`)
throw new Error(`Evaluation error in condition "${condition.title}": ${error.message}`)
}
}
const elseCondition = conditions.find((c) => c.title === CONDITION.ELSE_TITLE)
if (elseCondition) {
const elseConnection = this.findConnectionForCondition(outgoingConnections, elseCondition.id)
if (elseConnection) {
return { selectedConnection: elseConnection, selectedCondition: elseCondition }
}
return { selectedConnection: null, selectedCondition: elseCondition }
}
return { selectedConnection: null, selectedCondition: null }
}
private findConnectionForCondition(
connections: Array<{ source: string; target: string; sourceHandle?: string }>,
conditionId: string
): { target: string; sourceHandle?: string } | undefined {
return connections.find(
(conn) => conn.sourceHandle === `${EDGE.CONDITION_PREFIX}${conditionId}`
)
}
}
@@ -0,0 +1,111 @@
import { db } from '@sim/db'
import { credential } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, asc, eq, inArray } from 'drizzle-orm'
import type { BlockOutput } from '@/blocks/types'
import { BlockType } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('CredentialBlockHandler')
export class CredentialBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === BlockType.CREDENTIAL
}
async execute(
ctx: ExecutionContext,
_block: SerializedBlock,
inputs: Record<string, unknown>
): Promise<BlockOutput> {
if (!ctx.workspaceId) {
throw new Error('workspaceId is required for credential resolution')
}
const operation = typeof inputs.operation === 'string' ? inputs.operation : 'select'
if (operation === 'list') {
return this.listCredentials(ctx.workspaceId, inputs)
}
return this.selectCredential(ctx.workspaceId, inputs)
}
private async selectCredential(
workspaceId: string,
inputs: Record<string, unknown>
): Promise<BlockOutput> {
const credentialId = typeof inputs.credentialId === 'string' ? inputs.credentialId.trim() : ''
if (!credentialId) {
throw new Error('No credential selected')
}
const record = await db.query.credential.findFirst({
where: and(
eq(credential.id, credentialId),
eq(credential.workspaceId, workspaceId),
eq(credential.type, 'oauth')
),
columns: {
id: true,
displayName: true,
providerId: true,
},
})
if (!record) {
throw new Error(`Credential not found: ${credentialId}`)
}
logger.info('Credential block resolved', { credentialId: record.id })
return {
credentialId: record.id,
displayName: record.displayName,
providerId: record.providerId ?? '',
}
}
private async listCredentials(
workspaceId: string,
inputs: Record<string, unknown>
): Promise<BlockOutput> {
const providerFilter = Array.isArray(inputs.providerFilter)
? (inputs.providerFilter as string[]).filter(Boolean)
: []
const conditions = [eq(credential.workspaceId, workspaceId), eq(credential.type, 'oauth')]
if (providerFilter.length > 0) {
conditions.push(inArray(credential.providerId, providerFilter))
}
const records = await db.query.credential.findMany({
where: and(...conditions),
columns: {
id: true,
displayName: true,
providerId: true,
},
orderBy: [asc(credential.displayName)],
})
const credentials = records.map((r) => ({
credentialId: r.id,
displayName: r.displayName,
providerId: r.providerId ?? '',
}))
logger.info('Credential block listed credentials', {
count: credentials.length,
providerFilter: providerFilter.length > 0 ? providerFilter : undefined,
})
return {
credentials,
count: credentials.length,
}
}
}
@@ -0,0 +1,499 @@
import '@sim/testing/mocks/executor'
import { authOAuthUtilsMock, authOAuthUtilsMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock)
vi.mock('@/lib/credentials/access', () => ({
getCredentialActorContext: vi.fn().mockResolvedValue({
credential: {
id: 'test-vertex-credential-id',
type: 'oauth',
workspaceId: 'test-workspace',
accountId: 'test-vertex-credential-id',
},
member: { role: 'admin', status: 'active' },
hasWorkspaceAccess: true,
canWriteWorkspace: true,
isAdmin: true,
}),
}))
import { BlockType } from '@/executor/constants'
import { EvaluatorBlockHandler } from '@/executor/handlers/evaluator/evaluator-handler'
import type { ExecutionContext } from '@/executor/types'
import { getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types'
const mockGetProviderFromModel = getProviderFromModel as Mock
const mockFetch = global.fetch as unknown as Mock
describe('EvaluatorBlockHandler', () => {
let handler: EvaluatorBlockHandler
let mockBlock: SerializedBlock
let mockContext: ExecutionContext
beforeEach(() => {
handler = new EvaluatorBlockHandler()
mockBlock = {
id: 'eval-block-1',
metadata: { id: BlockType.EVALUATOR, name: 'Test Evaluator' },
position: { x: 20, y: 20 },
config: { tool: BlockType.EVALUATOR, params: {} },
inputs: {
content: 'string',
metrics: 'json',
model: 'string',
temperature: 'number',
}, // Using ParamType strings
outputs: {},
enabled: true,
}
mockContext = {
workflowId: 'test-workflow-id',
userId: 'test-user',
blockStates: new Map(),
blockLogs: [],
metadata: { duration: 0 },
environmentVariables: {},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
completedLoops: new Set(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
}
// Reset mocks using vi
vi.clearAllMocks()
// Default mock implementations
authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValue({
accountId: 'test-vertex-credential-id',
usedCredentialTable: false,
})
authOAuthUtilsMockFns.mockRefreshTokenIfNeeded.mockResolvedValue({
accessToken: 'mock-access-token',
refreshed: false,
})
mockGetProviderFromModel.mockReturnValue('openai')
// Set up fetch mock to return a successful response
mockFetch.mockImplementation(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: JSON.stringify({ score1: 5, score2: 8 }),
model: 'mock-model',
tokens: { input: 50, output: 10, total: 60 },
cost: 0.002,
timing: { total: 200 },
}),
})
})
})
it('should handle evaluator blocks', () => {
expect(handler.canHandle(mockBlock)).toBe(true)
const nonEvalBlock: SerializedBlock = { ...mockBlock, metadata: { id: 'other' } }
expect(handler.canHandle(nonEvalBlock)).toBe(false)
})
it('should execute evaluator block correctly with basic inputs', async () => {
const inputs = {
content: 'This is the content to evaluate.',
metrics: [
{ name: 'score1', description: 'First score', range: { min: 0, max: 10 } },
{ name: 'score2', description: 'Second score', range: { min: 0, max: 10 } },
],
model: 'gpt-4o',
apiKey: 'test-api-key',
temperature: 0.1,
}
const result = await handler.execute(mockContext, mockBlock, inputs)
expect(mockGetProviderFromModel).toHaveBeenCalledWith('gpt-4o')
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
method: 'POST',
headers: expect.any(Object),
body: expect.any(String),
})
)
const fetchCallArgs = mockFetch.mock.calls[0]
const requestBody = JSON.parse(fetchCallArgs[1].body)
expect(requestBody).toMatchObject({
provider: 'openai',
model: 'gpt-4o',
systemPrompt: expect.stringContaining(inputs.content),
responseFormat: expect.objectContaining({
schema: {
type: 'object',
properties: {
score1: { type: 'number' },
score2: { type: 'number' },
},
required: ['score1', 'score2'],
additionalProperties: false,
},
}),
temperature: 0.1,
})
expect(result).toEqual({
content: 'This is the content to evaluate.',
model: 'mock-model',
tokens: { input: 50, output: 10, total: 60 },
cost: {
input: 0,
output: 0,
total: 0,
},
score1: 5,
score2: 8,
})
})
it('should process JSON string content correctly', async () => {
const contentObj = { text: 'Evaluate this JSON.', value: 42 }
const inputs = {
content: JSON.stringify(contentObj),
metrics: [{ name: 'clarity', description: 'Clarity score', range: { min: 1, max: 5 } }],
apiKey: 'test-api-key',
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: JSON.stringify({ clarity: 4 }),
model: 'm',
tokens: {},
cost: 0,
timing: {},
}),
})
})
await handler.execute(mockContext, mockBlock, inputs)
const fetchCallArgs = mockFetch.mock.calls[0]
const requestBody = JSON.parse(fetchCallArgs[1].body)
expect(requestBody).toMatchObject({
systemPrompt: expect.stringContaining(JSON.stringify(contentObj, null, 2)),
})
})
it('should process object content correctly', async () => {
const contentObj = { data: [1, 2, 3], status: 'ok' }
const inputs = {
content: contentObj,
metrics: [
{ name: 'completeness', description: 'Data completeness', range: { min: 0, max: 1 } },
],
apiKey: 'test-api-key',
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: JSON.stringify({ completeness: 1 }),
model: 'm',
tokens: {},
cost: 0,
timing: {},
}),
})
})
await handler.execute(mockContext, mockBlock, inputs)
const fetchCallArgs = mockFetch.mock.calls[0]
const requestBody = JSON.parse(fetchCallArgs[1].body)
expect(requestBody).toMatchObject({
systemPrompt: expect.stringContaining(JSON.stringify(contentObj, null, 2)),
})
})
it('should parse valid JSON response correctly', async () => {
const inputs = {
content: 'Test content',
metrics: [{ name: 'quality', description: 'Quality score', range: { min: 1, max: 10 } }],
apiKey: 'test-api-key',
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: '```json\n{ "quality": 9 }\n```',
model: 'm',
tokens: {},
cost: 0,
timing: {},
}),
})
})
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).quality).toBe(9)
})
it('should handle invalid/non-JSON response gracefully (scores = 0)', async () => {
const inputs = {
content: 'Test content',
metrics: [{ name: 'score', description: 'Score', range: { min: 0, max: 5 } }],
apiKey: 'test-api-key',
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: 'Sorry, I cannot provide a score.',
model: 'm',
tokens: {},
cost: 0,
timing: {},
}),
})
})
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).score).toBe(0)
})
it('should handle partially valid JSON response (extracts what it can)', async () => {
const inputs = {
content: 'Test content',
metrics: [
{ name: 'accuracy', description: 'Acc', range: { min: 0, max: 1 } },
{ name: 'fluency', description: 'Flu', range: { min: 0, max: 1 } },
],
apiKey: 'test-api-key',
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: '{ "accuracy": 1, "fluency": invalid }',
model: 'm',
tokens: {},
cost: 0,
timing: {},
}),
})
})
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).accuracy).toBe(0)
expect((result as any).fluency).toBe(0)
})
it('should extract metric scores ignoring case', async () => {
const inputs = {
content: 'Test',
metrics: [{ name: 'CamelCaseScore', description: 'Desc', range: { min: 0, max: 10 } }],
apiKey: 'test-api-key',
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: JSON.stringify({ camelcasescore: 7 }),
model: 'm',
tokens: {},
cost: 0,
timing: {},
}),
})
})
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).camelcasescore).toBe(7)
})
it('should handle missing metrics in response (score = 0)', async () => {
const inputs = {
content: 'Test',
metrics: [
{ name: 'presentScore', description: 'Desc1', range: { min: 0, max: 5 } },
{ name: 'missingScore', description: 'Desc2', range: { min: 0, max: 5 } },
],
apiKey: 'test-api-key',
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: JSON.stringify({ presentScore: 4 }),
model: 'm',
tokens: {},
cost: 0,
timing: {},
}),
})
})
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).presentscore).toBe(4)
expect((result as any).missingscore).toBe(0)
})
it('should handle server error responses', async () => {
const inputs = { content: 'Test error handling.', apiKey: 'test-api-key' }
// Override fetch mock to return an error
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: false,
status: 500,
json: () => Promise.resolve({ error: 'Server error' }),
})
})
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow('Server error')
})
it('should handle Azure OpenAI models with endpoint and API version', async () => {
const inputs = {
content: 'Test content to evaluate',
metrics: [{ name: 'quality', description: 'Quality score', range: { min: 1, max: 10 } }],
model: 'gpt-4o',
apiKey: 'test-azure-key',
azureEndpoint: 'https://test.openai.azure.com',
azureApiVersion: '2024-07-01-preview',
}
mockGetProviderFromModel.mockReturnValue('azure-openai')
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: JSON.stringify({ quality: 8 }),
model: 'gpt-4o',
tokens: {},
cost: 0,
timing: {},
}),
})
})
await handler.execute(mockContext, mockBlock, inputs)
const fetchCallArgs = mockFetch.mock.calls[0]
const requestBody = JSON.parse(fetchCallArgs[1].body)
expect(requestBody).toMatchObject({
provider: 'azure-openai',
model: 'gpt-4o',
apiKey: 'test-azure-key',
azureEndpoint: 'https://test.openai.azure.com',
azureApiVersion: '2024-07-01-preview',
})
})
it('should handle Vertex AI models with OAuth credential', async () => {
const inputs = {
content: 'Test content to evaluate',
metrics: [{ name: 'quality', description: 'Quality score', range: { min: 1, max: 10 } }],
model: 'gemini-2.0-flash-exp',
vertexCredential: 'test-vertex-credential-id',
vertexProject: 'test-gcp-project',
vertexLocation: 'us-central1',
}
mockGetProviderFromModel.mockReturnValue('vertex')
// Mock the database query for Vertex credential
const mockDb = await import('@sim/db')
const mockAccount = {
id: 'test-vertex-credential-id',
accessToken: 'mock-access-token',
refreshToken: 'mock-refresh-token',
expiresAt: new Date(Date.now() + 3600000), // 1 hour from now
}
;(mockDb.db.query as any).account = { findFirst: vi.fn() }
vi.spyOn(mockDb.db.query.account, 'findFirst').mockResolvedValue(mockAccount as any)
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: JSON.stringify({ quality: 9 }),
model: 'gemini-2.0-flash-exp',
tokens: {},
cost: 0,
timing: {},
}),
})
})
await handler.execute(mockContext, mockBlock, inputs)
const fetchCallArgs = mockFetch.mock.calls[0]
const requestBody = JSON.parse(fetchCallArgs[1].body)
expect(requestBody).toMatchObject({
provider: 'vertex',
model: 'gemini-2.0-flash-exp',
vertexProject: 'test-gcp-project',
vertexLocation: 'us-central1',
})
expect(requestBody.apiKey).toBe('mock-access-token')
})
it('should use default model when not provided', async () => {
const inputs = {
content: 'Test content',
metrics: [{ name: 'score', description: 'Score', range: { min: 0, max: 10 } }],
apiKey: 'test-api-key',
// No model provided - should use default
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: JSON.stringify({ score: 7 }),
model: 'claude-sonnet-5',
tokens: {},
cost: 0,
timing: {},
}),
})
})
await handler.execute(mockContext, mockBlock, inputs)
const fetchCallArgs = mockFetch.mock.calls[0]
const requestBody = JSON.parse(fetchCallArgs[1].body)
expect(requestBody.model).toBe('claude-sonnet-5')
})
})
@@ -0,0 +1,279 @@
import { createLogger } from '@sim/logger'
import type { BlockOutput } from '@/blocks/types'
import { validateModelProvider } from '@/ee/access-control/utils/permission-check'
import { BlockType, DEFAULTS, EVALUATOR } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http'
import { isJSONString, parseJSON, stringifyJSON } from '@/executor/utils/json'
import { resolveVertexCredential } from '@/executor/utils/vertex-credential'
import { calculateCost, getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('EvaluatorBlockHandler')
/**
* Handler for Evaluator blocks that assess content against criteria.
*/
export class EvaluatorBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === BlockType.EVALUATOR
}
async execute(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<BlockOutput> {
const evaluatorConfig = {
model: inputs.model || EVALUATOR.DEFAULT_MODEL,
apiKey: inputs.apiKey,
vertexProject: inputs.vertexProject,
vertexLocation: inputs.vertexLocation,
vertexCredential: inputs.vertexCredential,
bedrockAccessKeyId: inputs.bedrockAccessKeyId,
bedrockSecretKey: inputs.bedrockSecretKey,
bedrockRegion: inputs.bedrockRegion,
}
await validateModelProvider(ctx.userId, ctx.workspaceId, evaluatorConfig.model, ctx)
const providerId = getProviderFromModel(evaluatorConfig.model)
let finalApiKey: string | undefined = evaluatorConfig.apiKey
if (providerId === 'vertex' && evaluatorConfig.vertexCredential) {
finalApiKey = await resolveVertexCredential(
evaluatorConfig.vertexCredential,
ctx.userId,
'vertex-evaluator'
)
}
const processedContent = this.processContent(inputs.content)
let systemPromptObj: { systemPrompt: string; responseFormat: any } = {
systemPrompt: '',
responseFormat: null,
}
logger.info('Inputs for evaluator:', inputs)
let metrics: any[]
if (Array.isArray(inputs.metrics)) {
metrics = inputs.metrics
} else {
metrics = []
}
logger.info('Metrics for evaluator:', metrics)
const metricDescriptions = metrics
.filter((m: any) => m?.name && m.range)
.map((m: any) => `"${m.name}" (${m.range.min}-${m.range.max}): ${m.description || ''}`)
.join('\n')
const responseProperties: Record<string, any> = {}
metrics.forEach((m: any) => {
if (m?.name) {
responseProperties[m.name.toLowerCase()] = { type: 'number' }
} else {
logger.warn('Skipping invalid metric entry during response format generation:', m)
}
})
systemPromptObj = {
systemPrompt: `You are an evaluation agent. Analyze this content against the metrics and provide scores.
Metrics:
${metricDescriptions}
Content:
${processedContent}
Return a JSON object with each metric name as a key and a numeric score as the value. No explanations, only scores.`,
responseFormat: {
name: EVALUATOR.RESPONSE_SCHEMA_NAME,
schema: {
type: 'object',
properties: responseProperties,
required: metrics.filter((m: any) => m?.name).map((m: any) => m.name.toLowerCase()),
additionalProperties: false,
},
strict: true,
},
}
if (!systemPromptObj.systemPrompt) {
systemPromptObj.systemPrompt =
'Evaluate the content and provide scores for each metric as JSON.'
}
try {
const url = buildAPIUrl('/api/providers', ctx.userId ? { userId: ctx.userId } : {})
const providerRequest: Record<string, any> = {
provider: providerId,
model: evaluatorConfig.model,
systemPrompt: systemPromptObj.systemPrompt,
responseFormat: systemPromptObj.responseFormat,
context: stringifyJSON([
{
role: 'user',
content:
'Please evaluate the content provided in the system prompt. Return ONLY a valid JSON with metric scores.',
},
]),
temperature: EVALUATOR.DEFAULT_TEMPERATURE,
apiKey: finalApiKey,
azureEndpoint: inputs.azureEndpoint,
azureApiVersion: inputs.azureApiVersion,
vertexProject: evaluatorConfig.vertexProject,
vertexLocation: evaluatorConfig.vertexLocation,
bedrockAccessKeyId: evaluatorConfig.bedrockAccessKeyId,
bedrockSecretKey: evaluatorConfig.bedrockSecretKey,
bedrockRegion: evaluatorConfig.bedrockRegion,
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
}
const response = await fetch(url.toString(), {
method: 'POST',
headers: await buildAuthHeaders(ctx.userId),
body: stringifyJSON(providerRequest),
})
if (!response.ok) {
const errorMessage = await extractAPIErrorMessage(response)
throw new Error(errorMessage)
}
const result = await response.json()
const parsedContent = this.extractJSONFromResponse(result.content)
const metricScores = this.extractMetricScores(parsedContent, inputs.metrics)
const inputTokens = result.tokens?.input || result.tokens?.prompt || DEFAULTS.TOKENS.PROMPT
const outputTokens =
result.tokens?.output || result.tokens?.completion || DEFAULTS.TOKENS.COMPLETION
const costCalculation = calculateCost(result.model, inputTokens, outputTokens, false)
return {
content: inputs.content,
model: result.model,
tokens: {
input: inputTokens,
output: outputTokens,
total: result.tokens?.total || DEFAULTS.TOKENS.TOTAL,
},
cost: {
input: costCalculation.input,
output: costCalculation.output,
total: costCalculation.total,
},
...metricScores,
}
} catch (error) {
logger.error('Evaluator execution failed:', error)
throw error
}
}
private processContent(content: any): string {
if (typeof content === 'string') {
if (isJSONString(content)) {
const parsed = parseJSON(content, null)
if (parsed) {
return stringifyJSON(parsed)
}
return content
}
return content
}
if (typeof content === 'object') {
return stringifyJSON(content)
}
return String(content || '')
}
private extractJSONFromResponse(responseContent: string): Record<string, any> {
try {
const contentStr = responseContent.trim()
const fullMatch = contentStr.match(/(\{[\s\S]*\})/)
if (fullMatch) {
return parseJSON(fullMatch[0], {})
}
if (contentStr.includes('{') && contentStr.includes('}')) {
const startIdx = contentStr.indexOf('{')
const endIdx = contentStr.lastIndexOf('}') + 1
const jsonStr = contentStr.substring(startIdx, endIdx)
return parseJSON(jsonStr, {})
}
return parseJSON(contentStr, {})
} catch (error) {
logger.error('Error parsing evaluator response:', error)
logger.error('Raw response content:', responseContent)
return {}
}
}
private extractMetricScores(
parsedContent: Record<string, any>,
metrics: any
): Record<string, number> {
const metricScores: Record<string, number> = {}
let validMetrics: any[]
if (Array.isArray(metrics)) {
validMetrics = metrics
} else {
validMetrics = []
}
if (Object.keys(parsedContent).length === 0) {
validMetrics.forEach((metric: any) => {
if (metric?.name) {
metricScores[metric.name.toLowerCase()] = 0
}
})
return metricScores
}
validMetrics.forEach((metric: any) => {
if (!metric?.name) {
logger.warn('Skipping invalid metric entry:', metric)
return
}
const score = this.findMetricScore(parsedContent, metric.name)
metricScores[metric.name.toLowerCase()] = score
})
return metricScores
}
private findMetricScore(parsedContent: Record<string, any>, metricName: string): number {
const lowerMetricName = metricName.toLowerCase()
if (parsedContent[metricName] !== undefined) {
return Number(parsedContent[metricName])
}
if (parsedContent[lowerMetricName] !== undefined) {
return Number(parsedContent[lowerMetricName])
}
const matchingKey = Object.keys(parsedContent).find((key) => {
return typeof key === 'string' && key.toLowerCase() === lowerMetricName
})
if (matchingKey) {
return Number(parsedContent[matchingKey])
}
logger.warn(`Metric "${metricName}" not found in LLM response`)
return 0
}
}
@@ -0,0 +1,244 @@
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants'
import { BlockType } from '@/executor/constants'
import { FunctionBlockHandler } from '@/executor/handlers/function/function-handler'
import type { ExecutionContext } from '@/executor/types'
import {
FUNCTION_BLOCK_CONTEXT_VARS_KEY,
FUNCTION_BLOCK_DISPLAY_CODE_KEY,
} from '@/executor/variables/resolver'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
vi.mock('@/tools', () => ({
executeTool: vi.fn(),
}))
const mockExecuteTool = executeTool as Mock
describe('FunctionBlockHandler', () => {
let handler: FunctionBlockHandler
let mockBlock: SerializedBlock
let mockContext: ExecutionContext
beforeEach(() => {
handler = new FunctionBlockHandler()
mockBlock = {
id: 'func-block-1',
metadata: { id: BlockType.FUNCTION, name: 'Test Function' },
position: { x: 30, y: 30 },
config: { tool: BlockType.FUNCTION, params: {} },
inputs: { code: 'string', timeout: 'number' }, // Using ParamType strings
outputs: {},
enabled: true,
}
mockContext = {
workflowId: 'test-workflow-id',
blockStates: new Map(),
blockLogs: [],
metadata: { duration: 0 },
environmentVariables: {},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
completedLoops: new Set(),
}
// Reset mocks using vi
vi.clearAllMocks()
// Default mock implementation for executeTool
mockExecuteTool.mockResolvedValue({ success: true, output: { result: 'Success' } })
})
it('should handle function blocks', () => {
expect(handler.canHandle(mockBlock)).toBe(true)
const nonFuncBlock: SerializedBlock = { ...mockBlock, metadata: { id: 'other' } }
expect(handler.canHandle(nonFuncBlock)).toBe(false)
})
it('should execute function block with string code', async () => {
const inputs = {
code: 'console.log("Hello"); return 1 + 1;',
timeout: 10000,
envVars: {},
isCustomTool: false,
workflowId: undefined,
}
const expectedToolParams = {
code: inputs.code,
language: 'javascript',
timeout: inputs.timeout,
envVars: {},
workflowVariables: {},
blockData: {},
blockNameMapping: {},
blockOutputSchemas: {},
contextVariables: {},
_context: {
workflowId: mockContext.workflowId,
workspaceId: mockContext.workspaceId,
executionId: mockContext.executionId,
userId: mockContext.userId,
isDeployedContext: mockContext.isDeployedContext,
enforceCredentialAccess: mockContext.enforceCredentialAccess,
},
}
const expectedOutput: any = { result: 'Success' }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect(mockExecuteTool).toHaveBeenCalledWith('function_execute', expectedToolParams, {
executionContext: mockContext,
})
expect(result).toEqual(expectedOutput)
})
it('should execute function block with array code', async () => {
const inputs = {
code: [{ content: 'const x = 5;' }, { content: 'return x * 2;' }],
timeout: 5000,
envVars: {},
isCustomTool: false,
workflowId: undefined,
}
const expectedCode = 'const x = 5;\nreturn x * 2;'
const expectedToolParams = {
code: expectedCode,
language: 'javascript',
timeout: inputs.timeout,
envVars: {},
workflowVariables: {},
blockData: {},
blockNameMapping: {},
blockOutputSchemas: {},
contextVariables: {},
_context: {
workflowId: mockContext.workflowId,
workspaceId: mockContext.workspaceId,
executionId: mockContext.executionId,
userId: mockContext.userId,
isDeployedContext: mockContext.isDeployedContext,
enforceCredentialAccess: mockContext.enforceCredentialAccess,
},
}
const expectedOutput: any = { result: 'Success' }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect(mockExecuteTool).toHaveBeenCalledWith('function_execute', expectedToolParams, {
executionContext: mockContext,
})
expect(result).toEqual(expectedOutput)
})
it('should use default timeout if not provided', async () => {
const inputs = { code: 'return true;' }
const expectedToolParams = {
code: inputs.code,
language: 'javascript',
timeout: DEFAULT_EXECUTION_TIMEOUT_MS,
envVars: {},
workflowVariables: {},
blockData: {},
blockNameMapping: {},
blockOutputSchemas: {},
contextVariables: {},
_context: {
workflowId: mockContext.workflowId,
workspaceId: mockContext.workspaceId,
executionId: mockContext.executionId,
userId: mockContext.userId,
isDeployedContext: mockContext.isDeployedContext,
enforceCredentialAccess: mockContext.enforceCredentialAccess,
},
}
await handler.execute(mockContext, mockBlock, inputs)
expect(mockExecuteTool).toHaveBeenCalledWith('function_execute', expectedToolParams, {
executionContext: mockContext,
})
})
it('should handle execution errors from the tool', async () => {
const inputs = { code: 'throw new Error("Code failed");' }
const errorResult = { success: false, error: 'Function execution failed: Code failed' }
mockExecuteTool.mockResolvedValue(errorResult)
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'Function execution failed: Code failed'
)
expect(mockExecuteTool).toHaveBeenCalled()
})
it('should pass runtime context variables to function_execute', async () => {
const contextVariables = { __blockRef_0: { result: 'from-block' } }
await handler.execute(mockContext, mockBlock, {
code: 'return globalThis["__blockRef_0"]',
[FUNCTION_BLOCK_CONTEXT_VARS_KEY]: contextVariables,
})
expect(mockExecuteTool).toHaveBeenCalledWith(
'function_execute',
expect.objectContaining({
contextVariables,
}),
{ executionContext: mockContext }
)
})
it('should pass display-resolved function code for error display', async () => {
mockBlock.config.params = { code: 'retur <start.reqerror>' }
await handler.execute(mockContext, mockBlock, {
code: 'retur globalThis["__blockRef_0"]',
[FUNCTION_BLOCK_DISPLAY_CODE_KEY]: 'retur "value"',
[FUNCTION_BLOCK_CONTEXT_VARS_KEY]: { __blockRef_0: 'value' },
})
expect(mockExecuteTool).toHaveBeenCalledWith(
'function_execute',
expect.objectContaining({
code: 'retur globalThis["__blockRef_0"]',
sourceCode: 'retur "value"',
}),
{ executionContext: mockContext }
)
})
it('should normalize malformed execution context records before calling function_execute', async () => {
const legacyVariable = { id: 'var-1', name: 'brand', type: 'plain', value: 'myfitness' }
mockContext.workflowVariables = [legacyVariable] as unknown as Record<string, any>
mockContext.environmentVariables = ['invalid-env'] as unknown as Record<string, string>
await handler.execute(mockContext, mockBlock, {
code: 'return "myfitness"',
[FUNCTION_BLOCK_CONTEXT_VARS_KEY]: ['invalid-context'],
})
expect(mockExecuteTool).toHaveBeenCalledWith(
'function_execute',
expect.objectContaining({
envVars: {},
workflowVariables: { 'var-1': legacyVariable },
contextVariables: {},
}),
{ executionContext: mockContext }
)
})
it('should handle tool error with no specific message', async () => {
const inputs = { code: 'some code' }
const errorResult = { success: false }
mockExecuteTool.mockResolvedValue(errorResult)
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'Function execution failed'
)
})
})
@@ -0,0 +1,93 @@
import {
normalizeRecord,
normalizeStringRecord,
normalizeWorkflowVariables,
} from '@/lib/core/utils/records'
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants'
import { DEFAULT_CODE_LANGUAGE } from '@/lib/execution/languages'
import { mergeFileKeys, mergeLargeValueKeys } from '@/lib/execution/payloads/access-keys'
import { BlockType } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { collectBlockData } from '@/executor/utils/block-data'
import {
FUNCTION_BLOCK_CONTEXT_VARS_KEY,
FUNCTION_BLOCK_DISPLAY_CODE_KEY,
} from '@/executor/variables/resolver'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
function readCodeContent(value: unknown): string | undefined {
if (typeof value === 'string') {
return value
}
if (Array.isArray(value)) {
return value
.map((entry) =>
entry && typeof entry === 'object' && typeof entry.content === 'string' ? entry.content : ''
)
.join('\n')
}
return undefined
}
/**
* Handler for Function blocks that execute custom code.
*/
export class FunctionBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === BlockType.FUNCTION
}
async execute(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<any> {
const codeContent = readCodeContent(inputs.code) ?? inputs.code
const sourceCode =
readCodeContent(inputs[FUNCTION_BLOCK_DISPLAY_CODE_KEY]) ??
readCodeContent((block.config?.params as Record<string, unknown> | undefined)?.code)
const { blockNameMapping, blockOutputSchemas } = collectBlockData(ctx)
const contextVariables = normalizeRecord(inputs[FUNCTION_BLOCK_CONTEXT_VARS_KEY])
const toolParams = {
code: codeContent,
...(sourceCode ? { sourceCode } : {}),
language: inputs.language || DEFAULT_CODE_LANGUAGE,
timeout: inputs.timeout || DEFAULT_EXECUTION_TIMEOUT_MS,
envVars: normalizeStringRecord(ctx.environmentVariables),
workflowVariables: normalizeWorkflowVariables(ctx.workflowVariables),
blockData: {},
blockNameMapping,
blockOutputSchemas,
contextVariables,
_context: {
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
executionId: ctx.executionId,
largeValueExecutionIds: ctx.largeValueExecutionIds,
largeValueKeys: ctx.largeValueKeys,
fileKeys: ctx.fileKeys,
allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
userId: ctx.userId,
isDeployedContext: ctx.isDeployedContext,
enforceCredentialAccess: ctx.enforceCredentialAccess,
},
}
const result = await executeTool('function_execute', toolParams, { executionContext: ctx })
if (!result.success) {
throw new Error(result.error || 'Function execution failed')
}
mergeLargeValueKeys(ctx, result.largeValueKeys ?? [])
mergeFileKeys(ctx, result.fileKeys ?? [])
return result.output
}
}
@@ -0,0 +1,147 @@
import '@sim/testing/mocks/executor'
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import { BlockType } from '@/executor/constants'
import { GenericBlockHandler } from '@/executor/handlers/generic/generic-handler'
import type { ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
import type { ToolConfig } from '@/tools/types'
import { getTool } from '@/tools/utils'
const mockGetTool = vi.mocked(getTool)
const mockExecuteTool = executeTool as Mock
describe('GenericBlockHandler', () => {
let handler: GenericBlockHandler
let mockBlock: SerializedBlock
let mockContext: ExecutionContext
let mockTool: ToolConfig
beforeEach(() => {
handler = new GenericBlockHandler()
mockBlock = {
id: 'generic-block-1',
metadata: { id: 'custom-type', name: 'Test Generic Block' },
position: { x: 40, y: 40 },
config: { tool: 'some_custom_tool', params: { param1: 'value1' } },
inputs: { param1: 'string' }, // Using ParamType strings
outputs: {},
enabled: true,
}
mockContext = {
workflowId: 'test-workflow-id',
blockStates: new Map(),
blockLogs: [],
metadata: { duration: 0 },
environmentVariables: {},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
completedLoops: new Set(),
}
mockTool = {
id: 'some_custom_tool',
name: 'Some Custom Tool',
description: 'Does something custom',
version: '1.0',
params: { param1: { type: 'string' } },
request: {
url: 'https://example.com/api',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => params,
},
}
// Reset mocks using vi
vi.clearAllMocks()
// Set up mockGetTool to return mockTool
mockGetTool.mockImplementation((toolId) => {
if (toolId === 'some_custom_tool') {
return mockTool
}
return undefined
})
// Default mock implementations
mockExecuteTool.mockResolvedValue({ success: true, output: { customResult: 'OK' } })
})
it.concurrent('should always handle any block type', () => {
const agentBlock: SerializedBlock = { ...mockBlock, metadata: { id: BlockType.AGENT } }
expect(handler.canHandle(agentBlock)).toBe(true)
expect(handler.canHandle(mockBlock)).toBe(true)
const noMetaIdBlock: SerializedBlock = { ...mockBlock, metadata: undefined }
expect(handler.canHandle(noMetaIdBlock)).toBe(true)
})
it.concurrent('should execute generic block by calling its associated tool', async () => {
const inputs = { param1: 'resolvedValue1' }
const expectedToolParams = {
...inputs,
_context: { workflowId: mockContext.workflowId },
}
const expectedOutput: any = { customResult: 'OK' }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect(mockGetTool).toHaveBeenCalledWith('some_custom_tool')
expect(mockExecuteTool).toHaveBeenCalledWith('some_custom_tool', expectedToolParams, {
executionContext: mockContext,
})
expect(result).toEqual(expectedOutput)
})
it('should throw error if the associated tool is not found', async () => {
const inputs = { param1: 'value' }
// Override mock to return undefined for this test
mockGetTool.mockImplementation(() => undefined)
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'Tool not found: some_custom_tool'
)
expect(mockExecuteTool).not.toHaveBeenCalled()
})
it('should handle tool execution errors correctly', async () => {
const inputs = { param1: 'value' }
const errorResult = {
success: false,
error: 'Custom tool failed',
output: { detail: 'error detail' },
}
mockExecuteTool.mockResolvedValue(errorResult)
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'Custom tool failed'
)
// Re-execute to check error properties after catching
try {
await handler.execute(mockContext, mockBlock, inputs)
} catch (e: any) {
expect(e.toolId).toBe('some_custom_tool')
expect(e.blockName).toBe('Test Generic Block')
expect(e.output).toEqual({ detail: 'error detail' })
}
expect(mockExecuteTool).toHaveBeenCalledTimes(2) // Called twice now
})
it.concurrent('should handle tool execution errors with no specific message', async () => {
const inputs = { param1: 'value' }
const errorResult = { success: false, output: {} }
mockExecuteTool.mockResolvedValue(errorResult)
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'Block execution of Some Custom Tool failed with no error message'
)
})
})
@@ -0,0 +1,125 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { getBlock } from '@/blocks/index'
import { isMcpTool } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
import { getTool } from '@/tools/utils'
const logger = createLogger('GenericBlockHandler')
export class GenericBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return true
}
async execute(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<any> {
const isMcp = block.config.tool ? isMcpTool(block.config.tool) : false
let tool = null
if (!isMcp) {
tool = getTool(block.config.tool)
if (!tool) {
throw new Error(`Tool not found: ${block.config.tool}`)
}
}
let finalInputs = { ...inputs }
const blockType = block.metadata?.id
if (blockType) {
const blockConfig = getBlock(blockType)
if (blockConfig?.tools?.config?.params) {
const transformedParams = blockConfig.tools.config.params(inputs)
finalInputs = { ...inputs, ...transformedParams }
}
if (blockConfig?.inputs) {
for (const [key, inputSchema] of Object.entries(blockConfig.inputs)) {
const value = finalInputs[key]
if (typeof value === 'string' && value.trim().length > 0) {
const inputType = typeof inputSchema === 'object' ? inputSchema.type : inputSchema
if (inputType === 'json' || inputType === 'array') {
try {
finalInputs[key] = JSON.parse(value.trim())
} catch (error) {
logger.warn(`Failed to parse ${inputType} field "${key}":`, {
error: toError(error).message,
})
}
}
}
}
}
}
try {
const result = await executeTool(
block.config.tool,
{
...finalInputs,
_context: {
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
executionId: ctx.executionId,
userId: ctx.userId,
isDeployedContext: ctx.isDeployedContext,
enforceCredentialAccess: ctx.enforceCredentialAccess,
},
},
{ executionContext: ctx }
)
if (!result.success) {
const errorDetails = []
if (result.error) errorDetails.push(result.error)
const errorMessage =
errorDetails.length > 0
? errorDetails.join(' - ')
: `Block execution of ${tool?.name || block.config.tool} failed with no error message`
const error = new Error(errorMessage)
Object.assign(error, {
toolId: block.config.tool,
toolName: tool?.name || 'Unknown tool',
blockId: block.id,
blockName: block.metadata?.name || 'Unnamed Block',
output: result.output || {},
timestamp: new Date().toISOString(),
})
throw error
}
return result.output
} catch (error: any) {
if (!error.message || error.message === 'undefined (undefined)') {
let errorMessage = `Block execution of ${tool?.name || block.config.tool} failed`
if (block.metadata?.name) {
errorMessage += `: ${block.metadata.name}`
}
if (error.status) {
errorMessage += ` (Status: ${error.status})`
}
error.message = errorMessage
}
if (typeof error === 'object' && error !== null) {
if (!error.toolId) error.toolId = block.config.tool
if (!error.blockName) error.blockName = block.metadata?.name || 'Unnamed Block'
}
throw error
}
}
}
@@ -0,0 +1,520 @@
import { createLogger } from '@sim/logger'
import { getBaseUrl } from '@/lib/core/utils/urls'
import type { BlockOutput } from '@/blocks/types'
import {
BlockType,
buildResumeApiUrl,
buildResumeUiUrl,
type FieldType,
HTTP,
normalizeName,
PAUSE_RESUME,
} from '@/executor/constants'
import {
generatePauseContextId,
mapNodeMetadataToPauseScopes,
} from '@/executor/human-in-the-loop/utils'
import type { BlockHandler, ExecutionContext, PauseMetadata } from '@/executor/types'
import { collectBlockData } from '@/executor/utils/block-data'
import { convertBuilderDataToJson, convertPropertyValue } from '@/executor/utils/builder-data'
import { parseObjectStrings } from '@/executor/utils/json'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
const logger = createLogger('HumanInTheLoopBlockHandler')
interface JSONProperty {
id: string
name: string
type: FieldType
value: any
collapsed?: boolean
}
interface ResponseStructureEntry {
name: string
type: string
value: any
}
interface NormalizedInputField {
id: string
name: string
label: string
type: string
description?: string
placeholder?: string
value?: any
required?: boolean
options?: any[]
}
interface NotificationToolResult {
toolId: string
title?: string
operation?: string
success: boolean
durationMs?: number
}
export class HumanInTheLoopBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === BlockType.HUMAN_IN_THE_LOOP
}
async execute(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<BlockOutput> {
return this.executeWithNode(ctx, block, inputs, {
nodeId: block.id,
})
}
async executeWithNode(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>,
nodeMetadata: {
nodeId: string
loopId?: string
parallelId?: string
branchIndex?: number
branchTotal?: number
}
): Promise<BlockOutput> {
try {
const operation = inputs.operation ?? PAUSE_RESUME.OPERATION.HUMAN
const { parallelScope, loopScope } = mapNodeMetadataToPauseScopes(ctx, nodeMetadata)
const contextId = generatePauseContextId(block.id, nodeMetadata, loopScope)
const timestamp = new Date().toISOString()
const executionId = ctx.executionId ?? ctx.metadata?.executionId
const workflowId = ctx.workflowId
let resumeLinks: typeof pauseMetadata.resumeLinks | undefined
if (executionId && workflowId) {
try {
const baseUrl = getBaseUrl()
resumeLinks = {
apiUrl: buildResumeApiUrl(baseUrl, workflowId, executionId, contextId),
uiUrl: buildResumeUiUrl(baseUrl, workflowId, executionId),
contextId,
executionId,
workflowId,
}
} catch (error) {
logger.warn('Failed to get base URL, using relative paths', { error })
resumeLinks = {
apiUrl: buildResumeApiUrl(undefined, workflowId, executionId, contextId),
uiUrl: buildResumeUiUrl(undefined, workflowId, executionId),
contextId,
executionId,
workflowId,
}
}
}
const normalizedInputFormat = this.normalizeInputFormat(inputs.inputFormat)
const responseStructure = this.normalizeResponseStructure(inputs.builderData)
let responseData: any
let statusCode: number
let responseHeaders: Record<string, string>
if (operation === PAUSE_RESUME.OPERATION.API) {
const parsed = this.parseResponseData(inputs)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
responseData = {
...parsed,
operation,
responseStructure:
parsed.responseStructure && Array.isArray(parsed.responseStructure)
? parsed.responseStructure
: responseStructure,
}
} else {
responseData = parsed
}
statusCode = this.parseStatus(inputs.status)
responseHeaders = this.parseHeaders(inputs.headers)
} else {
responseData = {
operation,
responseStructure,
inputFormat: normalizedInputFormat,
submission: null,
}
statusCode = HTTP.STATUS.OK
responseHeaders = { 'Content-Type': HTTP.CONTENT_TYPE.JSON }
}
let notificationResults: NotificationToolResult[] | undefined
if (
operation === PAUSE_RESUME.OPERATION.HUMAN &&
inputs.notification &&
Array.isArray(inputs.notification)
) {
notificationResults = await this.executeNotificationTools(ctx, block, inputs.notification, {
resumeLinks,
executionId,
workflowId,
inputFormat: normalizedInputFormat,
responseStructure,
operation,
})
}
const responseDataWithResume =
resumeLinks &&
responseData &&
typeof responseData === 'object' &&
!Array.isArray(responseData)
? { ...responseData, _resume: resumeLinks }
: responseData
const pauseMetadata: PauseMetadata = {
contextId,
blockId: nodeMetadata.nodeId,
response: {
data: responseDataWithResume,
status: statusCode,
headers: responseHeaders,
},
timestamp,
parallelScope,
loopScope,
resumeLinks,
pauseKind: 'human',
}
const responseOutput: Record<string, any> = {
data: responseDataWithResume,
status: statusCode,
headers: responseHeaders,
operation,
}
if (operation === PAUSE_RESUME.OPERATION.HUMAN) {
responseOutput.responseStructure = responseStructure
responseOutput.inputFormat = normalizedInputFormat
responseOutput.submission = null
}
if (resumeLinks) {
responseOutput.resume = resumeLinks
}
const structuredFields: Record<string, any> = {}
if (operation === PAUSE_RESUME.OPERATION.HUMAN) {
for (const field of normalizedInputFormat) {
if (field.name) {
structuredFields[field.name] = field.value !== undefined ? field.value : null
}
}
}
const output: Record<string, any> = {
...structuredFields,
response: responseOutput,
_pauseMetadata: pauseMetadata,
}
if (notificationResults && notificationResults.length > 0) {
output.notificationResults = notificationResults
}
if (resumeLinks) {
output.url = resumeLinks.uiUrl
output.resumeEndpoint = resumeLinks.apiUrl
}
return output
} catch (error: any) {
logger.error('Pause resume block execution failed:', error)
return {
response: {
data: {
error: 'Pause resume block execution failed',
message: error.message || 'Unknown error',
},
status: HTTP.STATUS.SERVER_ERROR,
headers: { 'Content-Type': HTTP.CONTENT_TYPE.JSON },
},
}
}
}
private parseResponseData(inputs: Record<string, any>): any {
const dataMode = inputs.dataMode || 'structured'
if (dataMode === 'json' && inputs.data) {
if (typeof inputs.data === 'string') {
try {
return JSON.parse(inputs.data)
} catch (error) {
logger.warn('Failed to parse JSON data, returning as string:', error)
return inputs.data
}
} else if (typeof inputs.data === 'object' && inputs.data !== null) {
return inputs.data
}
return inputs.data
}
if (dataMode === 'structured' && inputs.builderData) {
const convertedData = convertBuilderDataToJson(inputs.builderData)
return parseObjectStrings(convertedData)
}
return inputs.data || {}
}
private normalizeResponseStructure(
builderData?: JSONProperty[],
prefix = ''
): ResponseStructureEntry[] {
if (!Array.isArray(builderData)) {
return []
}
const entries: ResponseStructureEntry[] = []
for (const prop of builderData) {
const fieldName = typeof prop.name === 'string' ? prop.name.trim() : ''
if (!fieldName) continue
const path = prefix ? `${prefix}.${fieldName}` : fieldName
if (prop.type === 'object' && Array.isArray(prop.value)) {
const nested = this.normalizeResponseStructure(prop.value, path)
if (nested.length > 0) {
entries.push(...nested)
continue
}
}
const value = convertPropertyValue(prop)
entries.push({
name: path,
type: prop.type,
value,
})
}
return entries
}
private normalizeInputFormat(inputFormat: any): NormalizedInputField[] {
if (!Array.isArray(inputFormat)) {
return []
}
return inputFormat
.map((field: any, index: number) => {
const name = typeof field?.name === 'string' ? field.name.trim() : ''
if (!name) return null
const id =
typeof field?.id === 'string' && field.id.length > 0 ? field.id : `field_${index}`
const label =
typeof field?.label === 'string' && field.label.trim().length > 0
? field.label.trim()
: name
const type =
typeof field?.type === 'string' && field.type.trim().length > 0 ? field.type : 'string'
const description =
typeof field?.description === 'string' && field.description.trim().length > 0
? field.description.trim()
: undefined
const placeholder =
typeof field?.placeholder === 'string' && field.placeholder.trim().length > 0
? field.placeholder.trim()
: undefined
const required = field?.required === true
const options = Array.isArray(field?.options) ? field.options : undefined
return {
id,
name,
label,
type,
description,
placeholder,
value: field?.value,
required,
options,
} as NormalizedInputField
})
.filter((field): field is NormalizedInputField => field !== null)
}
private parseStatus(status?: string): number {
if (!status) return HTTP.STATUS.OK
const parsed = Number(status)
if (Number.isNaN(parsed) || parsed < 100 || parsed > 599) {
return HTTP.STATUS.OK
}
return parsed
}
private parseHeaders(
headers: {
id: string
cells: { Key: string; Value: string }
}[]
): Record<string, string> {
const defaultHeaders = { 'Content-Type': HTTP.CONTENT_TYPE.JSON }
if (!headers) return defaultHeaders
const headerObj = headers.reduce((acc: Record<string, string>, header) => {
if (header?.cells?.Key && header?.cells?.Value) {
acc[header.cells.Key] = header.cells.Value
}
return acc
}, {})
return { ...defaultHeaders, ...headerObj }
}
private async executeNotificationTools(
ctx: ExecutionContext,
block: SerializedBlock,
tools: any[],
context: {
resumeLinks?: {
apiUrl: string
uiUrl: string
contextId: string
executionId: string
workflowId: string
}
executionId?: string
workflowId?: string
inputFormat?: NormalizedInputField[]
responseStructure?: ResponseStructureEntry[]
operation?: string
}
): Promise<NotificationToolResult[]> {
if (!tools || tools.length === 0) {
return []
}
const { blockData: collectedBlockData, blockNameMapping: collectedBlockNameMapping } =
collectBlockData(ctx)
const blockDataWithPause: Record<string, any> = { ...collectedBlockData }
const blockNameMappingWithPause: Record<string, string> = { ...collectedBlockNameMapping }
const pauseBlockId = block.id
const pauseBlockName = block.metadata?.name
const pauseOutput: Record<string, any> = {
...(blockDataWithPause[pauseBlockId] || {}),
}
if (context.resumeLinks) {
if (context.resumeLinks.uiUrl) {
pauseOutput.url = context.resumeLinks.uiUrl
}
if (context.resumeLinks.apiUrl) {
pauseOutput.resumeEndpoint = context.resumeLinks.apiUrl
}
}
if (Array.isArray(context.inputFormat)) {
for (const field of context.inputFormat) {
if (field?.name) {
const fieldName = field.name.trim()
if (fieldName.length > 0 && !(fieldName in pauseOutput)) {
pauseOutput[fieldName] = field.value !== undefined ? field.value : null
}
}
}
}
blockDataWithPause[pauseBlockId] = pauseOutput
if (pauseBlockName) {
blockNameMappingWithPause[normalizeName(pauseBlockName)] = pauseBlockId
}
const notificationPromises = tools.map<Promise<NotificationToolResult>>(async (toolConfig) => {
const startTime = Date.now()
try {
const toolId = toolConfig.toolId
if (!toolId) {
logger.warn('Notification tool missing toolId', { toolConfig })
return {
toolId: 'unknown',
title: toolConfig.title,
operation: toolConfig.operation,
success: false,
}
}
const toolParams = {
...toolConfig.params,
_pauseContext: {
resumeApiUrl: context.resumeLinks?.apiUrl,
resumeUiUrl: context.resumeLinks?.uiUrl,
executionId: context.executionId,
workflowId: context.workflowId,
contextId: context.resumeLinks?.contextId,
inputFormat: context.inputFormat,
responseStructure: context.responseStructure,
operation: context.operation,
},
_context: {
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
userId: ctx.userId,
isDeployedContext: ctx.isDeployedContext,
enforceCredentialAccess: ctx.enforceCredentialAccess,
},
blockData: blockDataWithPause,
blockNameMapping: blockNameMappingWithPause,
}
const result = await executeTool(toolId, toolParams, { executionContext: ctx })
const durationMs = Date.now() - startTime
if (!result.success) {
logger.warn('Notification tool execution failed', {
toolId,
error: result.error,
})
return {
toolId,
title: toolConfig.title,
operation: toolConfig.operation,
success: false,
durationMs,
}
}
return {
toolId,
title: toolConfig.title,
operation: toolConfig.operation,
success: true,
durationMs,
}
} catch (error) {
logger.error('Error executing notification tool', { error, toolConfig })
return {
toolId: toolConfig.toolId || 'unknown',
title: toolConfig.title,
operation: toolConfig.operation,
success: false,
}
}
})
return Promise.all(notificationPromises)
}
}
@@ -0,0 +1,599 @@
import '@sim/testing/mocks/executor'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { BlockType } from '@/executor/constants'
import { MothershipBlockHandler } from '@/executor/handlers/mothership/mothership-handler'
import type { ExecutionContext, StreamingExecution } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
const {
mockBuildAuthHeaders,
mockBuildAPIUrl,
mockExtractAPIErrorMessage,
mockGenerateId,
mockIsExecutionCancelled,
mockIsRedisCancellationEnabled,
mockReadUserFileContent,
} = vi.hoisted(() => ({
mockBuildAuthHeaders: vi.fn(),
mockBuildAPIUrl: vi.fn(),
mockExtractAPIErrorMessage: vi.fn(),
mockGenerateId: vi.fn(),
mockIsExecutionCancelled: vi.fn(),
mockIsRedisCancellationEnabled: vi.fn(),
mockReadUserFileContent: vi.fn(),
}))
vi.mock('@/executor/utils/http', () => ({
buildAuthHeaders: mockBuildAuthHeaders,
buildAPIUrl: mockBuildAPIUrl,
extractAPIErrorMessage: mockExtractAPIErrorMessage,
}))
vi.mock('@sim/utils/id', () => ({
generateId: mockGenerateId,
}))
vi.mock('@/lib/execution/cancellation', () => ({
isExecutionCancelled: mockIsExecutionCancelled,
isRedisCancellationEnabled: mockIsRedisCancellationEnabled,
}))
vi.mock('@/lib/execution/payloads/materialization.server', () => ({
readUserFileContent: mockReadUserFileContent,
}))
function createAbortError(): Error {
const error = new Error('The operation was aborted')
error.name = 'AbortError'
return error
}
function createAbortableFetchPromise(signal?: AbortSignal): Promise<Response> {
return new Promise((_resolve, reject) => {
if (signal?.aborted) {
reject(createAbortError())
return
}
signal?.addEventListener(
'abort',
() => {
reject(createAbortError())
},
{ once: true }
)
})
}
async function readStreamText(stream: ReadableStream): Promise<string> {
const reader = stream.getReader()
const decoder = new TextDecoder()
let text = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
text += decoder.decode(value, { stream: true })
}
text += decoder.decode()
reader.releaseLock()
return text
}
describe('MothershipBlockHandler', () => {
let handler: MothershipBlockHandler
let block: SerializedBlock
let context: ExecutionContext
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
handler = new MothershipBlockHandler()
fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
mockBuildAuthHeaders.mockResolvedValue({ Authorization: 'Bearer internal' })
mockBuildAPIUrl.mockReturnValue(new URL('/api/mothership/execute', 'http://localhost:3000'))
mockExtractAPIErrorMessage.mockResolvedValue('boom')
mockGenerateId.mockReset()
mockIsExecutionCancelled.mockReset()
mockIsRedisCancellationEnabled.mockReset()
mockIsRedisCancellationEnabled.mockReturnValue(false)
mockReadUserFileContent.mockReset()
block = {
id: 'mothership-block-1',
metadata: { id: BlockType.MOTHERSHIP, name: 'Mothership' },
position: { x: 0, y: 0 },
config: { tool: BlockType.MOTHERSHIP, params: {} },
inputs: { prompt: 'string', conversationId: 'string', files: 'file[]' },
outputs: {},
enabled: true,
} as SerializedBlock
context = {
workflowId: 'workflow-1',
executionId: 'execution-1',
workspaceId: 'workspace-1',
userId: 'user-1',
blockStates: new Map(),
blockLogs: [],
metadata: { duration: 0 },
environmentVariables: {},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
completedLoops: new Set(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
} as ExecutionContext
})
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
vi.unstubAllGlobals()
})
function createNdjsonResponse(events: unknown[]): Response {
const encoder = new TextEncoder()
return new Response(
new ReadableStream({
start(controller) {
for (const event of events) {
controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`))
}
controller.close()
},
}),
{
status: 200,
headers: { 'Content-Type': 'application/x-ndjson; charset=utf-8' },
}
)
}
it('forwards workflow and execution metadata with generated UUID ids', async () => {
mockGenerateId.mockReturnValueOnce('chat-uuid')
mockGenerateId.mockReturnValueOnce('message-uuid')
mockGenerateId.mockReturnValueOnce('request-uuid')
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
content: 'done',
model: 'mothership',
conversationId: 'chat-uuid',
tokens: { total: 5 },
toolCalls: [],
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
)
)
const result = await handler.execute(context, block, { prompt: 'Hello from workflow' })
expect(result).toEqual({
content: 'done',
model: 'mothership',
conversationId: 'chat-uuid',
tokens: { total: 5 },
toolCalls: { list: [], count: 0 },
cost: undefined,
})
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toBe('http://localhost:3000/api/mothership/execute')
expect(options.method).toBe('POST')
expect(options.signal).toBeInstanceOf(AbortSignal)
expect(options.headers).toMatchObject({
Accept: 'application/x-ndjson',
'X-Mothership-Execute-Stream': 'ndjson',
})
const body = JSON.parse(String(options.body))
expect(body).toEqual({
messages: [{ role: 'user', content: 'Hello from workflow' }],
workspaceId: 'workspace-1',
userId: 'user-1',
chatId: 'chat-uuid',
messageId: 'message-uuid',
requestId: 'request-uuid',
workflowId: 'workflow-1',
executionId: 'execution-1',
})
})
it('uses a provided conversation ID as the mothership chat ID', async () => {
mockGenerateId.mockReturnValueOnce('message-uuid')
mockGenerateId.mockReturnValueOnce('request-uuid')
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
content: 'continued',
model: 'mothership',
conversationId: 'existing-chat-id',
tokens: {},
toolCalls: [],
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
)
)
const result = await handler.execute(context, block, {
prompt: 'Continue this thread',
conversationId: ' existing-chat-id ',
})
expect(result).toEqual({
content: 'continued',
model: 'mothership',
conversationId: 'existing-chat-id',
tokens: {},
toolCalls: { list: [], count: 0 },
cost: undefined,
})
const [, options] = fetchMock.mock.calls[0] as [string, RequestInit]
const body = JSON.parse(String(options.body))
expect(body).toEqual({
messages: [{ role: 'user', content: 'Continue this thread' }],
workspaceId: 'workspace-1',
userId: 'user-1',
chatId: 'existing-chat-id',
messageId: 'message-uuid',
requestId: 'request-uuid',
workflowId: 'workflow-1',
executionId: 'execution-1',
})
expect(mockGenerateId).toHaveBeenCalledTimes(2)
})
it('consumes mothership execute heartbeat streams until the final result', async () => {
mockGenerateId.mockReturnValueOnce('chat-uuid')
mockGenerateId.mockReturnValueOnce('message-uuid')
mockGenerateId.mockReturnValueOnce('request-uuid')
fetchMock.mockResolvedValue(
createNdjsonResponse([
{ type: 'heartbeat', timestamp: '2026-05-15T18:13:48.000Z' },
{
type: 'final',
data: {
content: 'streamed done',
model: 'mothership',
conversationId: 'chat-uuid',
tokens: { total: 7 },
toolCalls: [{ name: 'tool_a', params: { a: 1 }, result: 'ok', durationMs: 42 }],
cost: { total: 0.1 },
},
},
])
)
const result = await handler.execute(context, block, { prompt: 'Hello from workflow' })
expect(result).toEqual({
content: 'streamed done',
model: 'mothership',
conversationId: 'chat-uuid',
tokens: { total: 7 },
toolCalls: {
list: [
{
name: 'tool_a',
arguments: { a: 1 },
result: 'ok',
error: undefined,
duration: 42,
},
],
count: 1,
},
cost: { total: 0.1 },
})
})
it('preserves failed tool calls as output metadata without throwing', async () => {
mockGenerateId.mockReturnValueOnce('chat-uuid')
mockGenerateId.mockReturnValueOnce('message-uuid')
mockGenerateId.mockReturnValueOnce('request-uuid')
fetchMock.mockResolvedValue(
createNdjsonResponse([
{
type: 'final',
data: {
content: 'The lookup failed, so I could not use that result.',
model: 'mothership',
conversationId: 'chat-uuid',
tokens: { total: 7 },
toolCalls: [
{
name: 'lookup_customer',
status: 'error',
params: { email: 'missing@example.com' },
result: { success: false, error: 'Customer not found' },
error: 'Customer not found',
durationMs: 42,
},
],
},
},
])
)
const result = await handler.execute(context, block, { prompt: 'Hello from workflow' })
expect(result).toEqual({
content: 'The lookup failed, so I could not use that result.',
model: 'mothership',
conversationId: 'chat-uuid',
tokens: { total: 7 },
toolCalls: {
list: [
expect.objectContaining({
name: 'lookup_customer',
status: 'error',
arguments: { email: 'missing@example.com' },
result: { success: false, error: 'Customer not found' },
error: 'Customer not found',
duration: 42,
}),
],
count: 1,
},
cost: undefined,
})
})
it('surfaces mothership execute stream errors', async () => {
mockGenerateId.mockReturnValueOnce('chat-uuid')
mockGenerateId.mockReturnValueOnce('message-uuid')
mockGenerateId.mockReturnValueOnce('request-uuid')
fetchMock.mockResolvedValue(
createNdjsonResponse([
{ type: 'heartbeat', timestamp: '2026-05-15T18:13:48.000Z' },
{ type: 'error', error: 'Mothership execution aborted' },
])
)
await expect(
handler.execute(context, block, { prompt: 'Hello from workflow' })
).rejects.toThrow('Sim execution failed: Mothership execution aborted')
})
it('streams mothership assistant chunks and preserves final metadata', async () => {
context.stream = true
context.selectedOutputs = [`${block.id}_content`]
mockGenerateId.mockReturnValueOnce('chat-uuid')
mockGenerateId.mockReturnValueOnce('message-uuid')
mockGenerateId.mockReturnValueOnce('request-uuid')
fetchMock.mockResolvedValue(
createNdjsonResponse([
{ type: 'heartbeat', timestamp: '2026-05-15T18:13:48.000Z' },
{ type: 'chunk', content: 'Hello' },
{ type: 'heartbeat', timestamp: '2026-05-15T18:14:03.000Z' },
{ type: 'chunk', content: ' world' },
{
type: 'final',
data: {
content: 'Hello world',
model: 'mothership',
conversationId: 'chat-uuid',
tokens: { total: 7 },
toolCalls: [{ name: 'tool_a', params: { a: 1 }, result: 'ok', durationMs: 42 }],
cost: { total: 0.1 },
},
},
])
)
const result = await handler.execute(context, block, { prompt: 'Hello from workflow' })
expect(result).toHaveProperty('stream')
const streamingExecution = result as StreamingExecution
await expect(readStreamText(streamingExecution.stream)).resolves.toBe('Hello world')
expect(streamingExecution.execution.output).toEqual({
content: 'Hello world',
model: 'mothership',
conversationId: 'chat-uuid',
tokens: { total: 7 },
toolCalls: {
list: [
{
name: 'tool_a',
arguments: { a: 1 },
result: 'ok',
error: undefined,
duration: 42,
},
],
count: 1,
},
cost: { total: 0.1 },
})
})
it('surfaces mothership streaming errors while streaming selected content', async () => {
context.stream = true
context.selectedOutputs = [`${block.id}_content`]
mockGenerateId.mockReturnValueOnce('chat-uuid')
mockGenerateId.mockReturnValueOnce('message-uuid')
mockGenerateId.mockReturnValueOnce('request-uuid')
fetchMock.mockResolvedValue(
createNdjsonResponse([
{ type: 'chunk', content: 'partial' },
{ type: 'error', error: 'Mothership execution aborted' },
])
)
const result = (await handler.execute(context, block, {
prompt: 'Hello from workflow',
})) as StreamingExecution
await expect(readStreamText(result.stream)).rejects.toThrow(
'Sim execution failed: Mothership execution aborted'
)
})
it('embeds attached files for the mothership execute request', async () => {
const fileContent = Buffer.from('hello mothership', 'utf8').toString('base64')
mockGenerateId.mockReturnValueOnce('chat-uuid')
mockGenerateId.mockReturnValueOnce('message-uuid')
mockGenerateId.mockReturnValueOnce('request-uuid')
mockReadUserFileContent.mockResolvedValueOnce(fileContent)
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
content: 'analyzed',
model: 'mothership',
conversationId: 'chat-uuid',
tokens: {},
toolCalls: [],
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
)
)
const result = await handler.execute(context, block, {
prompt: 'Analyze this file',
files: [
{
name: 'notes.txt',
key: 'workspace/workspace-1/notes.txt',
size: 16,
type: 'text/plain',
},
],
})
expect(result).toMatchObject({
content: 'analyzed',
model: 'mothership',
conversationId: 'chat-uuid',
})
expect(mockReadUserFileContent).toHaveBeenCalledWith(
expect.objectContaining({
id: expect.stringMatching(/^file-/),
key: 'workspace/workspace-1/notes.txt',
name: 'notes.txt',
url: '',
size: 16,
type: 'text/plain',
}),
expect.objectContaining({
encoding: 'base64',
userId: 'user-1',
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
requestId: 'request-uuid',
})
)
const [, options] = fetchMock.mock.calls[0] as [string, RequestInit]
const body = JSON.parse(String(options.body))
expect(body.fileAttachments).toEqual([
{
type: 'document',
source: {
type: 'base64',
media_type: 'text/plain',
data: fileContent,
},
filename: 'notes.txt',
},
])
})
it('propagates local aborts to the mothership request', async () => {
const abortController = new AbortController()
context.abortSignal = abortController.signal
mockGenerateId.mockReturnValueOnce('chat-uuid')
mockGenerateId.mockReturnValueOnce('message-uuid')
mockGenerateId.mockReturnValueOnce('request-uuid')
fetchMock.mockImplementation((_url: string, options?: RequestInit) =>
createAbortableFetchPromise(options?.signal as AbortSignal | undefined)
)
const executionPromise = handler.execute(context, block, { prompt: 'Abort me' })
const abortedExecution = executionPromise.catch((error) => error)
abortController.abort()
await expect(abortedExecution).resolves.toMatchObject({ name: 'AbortError' })
})
it('propagates durable workflow cancellation to the mothership request', async () => {
vi.useFakeTimers()
mockGenerateId.mockReturnValueOnce('chat-uuid')
mockGenerateId.mockReturnValueOnce('message-uuid')
mockGenerateId.mockReturnValueOnce('request-uuid')
mockIsRedisCancellationEnabled.mockReturnValue(true)
mockIsExecutionCancelled.mockResolvedValueOnce(false).mockResolvedValueOnce(true)
fetchMock.mockImplementation((_url: string, options?: RequestInit) =>
createAbortableFetchPromise(options?.signal as AbortSignal | undefined)
)
const executionPromise = handler.execute(context, block, { prompt: 'Cancel me durably' })
const abortedExecution = executionPromise.catch((error) => error)
await vi.advanceTimersByTimeAsync(1000)
await expect(abortedExecution).resolves.toMatchObject({ name: 'AbortError' })
expect(mockIsExecutionCancelled).toHaveBeenCalledWith('execution-1')
})
it('aborts the mothership request when selected-output streaming is cancelled', async () => {
context.stream = true
context.selectedOutputs = [`${block.id}_content`]
mockGenerateId.mockReturnValueOnce('chat-uuid')
mockGenerateId.mockReturnValueOnce('message-uuid')
mockGenerateId.mockReturnValueOnce('request-uuid')
let fetchSignal: AbortSignal | undefined
fetchMock.mockImplementation((_url: string, options?: RequestInit) => {
fetchSignal = options?.signal as AbortSignal | undefined
return Promise.resolve(
new Response(
new ReadableStream({
start() {},
}),
{
status: 200,
headers: { 'Content-Type': 'application/x-ndjson; charset=utf-8' },
}
)
)
})
const result = (await handler.execute(context, block, { prompt: 'Cancel stream' })) as
| StreamingExecution
| undefined
await result?.stream.cancel('client_cancelled')
expect(fetchSignal?.aborted).toBe(true)
})
})
@@ -0,0 +1,459 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { isExecutionCancelled, isRedisCancellationEnabled } from '@/lib/execution/cancellation'
import { readUserFileContent } from '@/lib/execution/payloads/materialization.server'
import {
createFileContentFromBase64,
type MessageContent,
processSingleFileToUserFile,
type RawFileInput,
} from '@/lib/uploads/utils/file-utils'
import type { BlockOutput } from '@/blocks/types'
import { normalizeFileInput } from '@/blocks/utils'
import { BlockType } from '@/executor/constants'
import type {
BlockHandler,
ExecutionContext,
NormalizedBlockOutput,
StreamingExecution,
} from '@/executor/types'
import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http'
import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('MothershipBlockHandler')
const CANCELLATION_CHECK_INTERVAL_MS = 500
const MAX_MOTHERSHIP_ATTACHMENT_BYTES = 10 * 1024 * 1024
const MOTHERSHIP_EXECUTE_STREAM_HEADER = 'X-Mothership-Execute-Stream'
const MOTHERSHIP_EXECUTE_STREAM_VALUE = 'ndjson'
type MothershipFileAttachment = MessageContent & {
filename?: string
}
type MothershipExecuteResult = {
content?: string
model?: string
conversationId?: string
tokens?: Record<string, unknown>
toolCalls?: Array<Record<string, unknown>>
cost?: unknown
}
type MothershipExecuteStreamEvent =
| { type: 'heartbeat'; timestamp?: string }
| { type: 'chunk'; content?: string }
| { type: 'final'; data: MothershipExecuteResult }
| { type: 'error'; error?: string }
function parseMothershipExecuteStreamLine(line: string): MothershipExecuteStreamEvent | undefined {
const trimmed = line.trim()
if (!trimmed) return undefined
try {
return JSON.parse(trimmed) as MothershipExecuteStreamEvent
} catch {
throw new Error('Sim execution stream returned malformed data')
}
}
function formatMothershipBlockOutput(
result: MothershipExecuteResult,
fallbackChatId: string
): NormalizedBlockOutput {
const formattedList = (result.toolCalls || []).map((tc: Record<string, unknown>) => ({
name: typeof tc.name === 'string' ? tc.name : String(tc.name ?? ''),
...(typeof tc.status === 'string' ? { status: tc.status } : {}),
arguments: (tc.arguments || tc.params || tc.input || {}) as Record<string, unknown>,
result: (tc.result ?? tc.output) as any,
error: typeof tc.error === 'string' ? tc.error : undefined,
duration: typeof tc.durationMs === 'number' ? tc.durationMs : 0,
}))
const toolCalls: NormalizedBlockOutput['toolCalls'] = {
list: formattedList,
count: formattedList.length,
}
return {
content: result.content || '',
model: result.model || 'mothership',
conversationId: result.conversationId || fallbackChatId,
tokens: (result.tokens || {}) as NormalizedBlockOutput['tokens'],
toolCalls,
cost: result.cost as NormalizedBlockOutput['cost'] | undefined,
}
}
function isContentSelectedForStreaming(ctx: ExecutionContext, block: SerializedBlock): boolean {
if (!ctx.stream) return false
return (
ctx.selectedOutputs?.some((outputId) => {
if (outputId === block.id) return true
return outputId === `${block.id}.content` || outputId === `${block.id}_content`
}) ?? false
)
}
async function readMothershipExecuteResponse(response: Response): Promise<MothershipExecuteResult> {
const contentType = response.headers.get('content-type') || ''
if (!contentType.includes('application/x-ndjson')) {
return response.json()
}
if (!response.body) {
throw new Error('Sim execution stream ended without a response body')
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let finalResult: MothershipExecuteResult | undefined
const processLine = (line: string) => {
const event = parseMothershipExecuteStreamLine(line)
if (!event) return
if (event.type === 'heartbeat' || event.type === 'chunk') {
return
}
if (event.type === 'error') {
throw new Error(`Sim execution failed: ${event.error || 'Unknown error'}`)
}
if (event.type === 'final') {
finalResult = event.data
return
}
throw new Error('Sim execution stream returned an unknown event')
}
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
processLine(line)
}
}
buffer += decoder.decode()
processLine(buffer)
if (!finalResult) {
throw new Error('Sim execution stream ended without a final result')
}
return finalResult
} finally {
reader.releaseLock()
}
}
function createMothershipStreamingExecution(
response: Response,
fallbackChatId: string,
blockId: string,
options: {
onCancel?: (reason?: unknown) => void
onDone?: () => void
} = {}
): StreamingExecution {
if (!response.body) {
throw new Error('Sim execution stream ended without a response body')
}
const output = formatMothershipBlockOutput({}, fallbackChatId)
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
let cancelled = false
let cleanedUp = false
const cleanup = () => {
if (cleanedUp) return
cleanedUp = true
options.onDone?.()
}
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
reader = response.body!.getReader()
const decoder = new TextDecoder()
const encoder = new TextEncoder()
let buffer = ''
let sawFinal = false
const processLine = (line: string) => {
const event = parseMothershipExecuteStreamLine(line)
if (!event) return
if (event.type === 'heartbeat') {
return
}
if (event.type === 'chunk') {
if (event.content) {
controller.enqueue(encoder.encode(event.content))
}
return
}
if (event.type === 'error') {
throw new Error(`Sim execution failed: ${event.error || 'Unknown error'}`)
}
if (event.type === 'final') {
sawFinal = true
Object.assign(output, formatMothershipBlockOutput(event.data, fallbackChatId))
return
}
throw new Error('Sim execution stream returned an unknown event')
}
try {
while (true) {
const { done, value } = await reader.read()
if (cancelled) return
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
processLine(line)
}
}
buffer += decoder.decode()
processLine(buffer)
if (!sawFinal) {
throw new Error('Sim execution stream ended without a final result')
}
if (!cancelled) {
controller.close()
}
} catch (error) {
if (!cancelled) {
controller.error(error)
}
} finally {
cleanup()
reader?.releaseLock()
}
},
cancel(reason) {
cancelled = true
options.onCancel?.(reason)
cleanup()
return reader?.cancel(reason)
},
})
return {
stream,
execution: {
success: true,
output,
blockId,
logs: [],
metadata: {
duration: 0,
startTime: new Date().toISOString(),
},
isStreaming: true,
} as StreamingExecution['execution'] & { blockId: string },
}
}
async function buildMothershipFileAttachments(
filesInput: unknown,
ctx: ExecutionContext,
requestId: string
): Promise<MothershipFileAttachment[] | undefined> {
const files = normalizeFileInput(filesInput)
if (!files || files.length === 0) {
return undefined
}
if (!ctx.userId) {
throw new Error('Mothership file attachments require an authenticated user.')
}
const attachments: MothershipFileAttachment[] = []
for (const file of files) {
const userFile = processSingleFileToUserFile(file as RawFileInput, requestId, logger)
const base64 = await readUserFileContent(userFile, {
encoding: 'base64',
userId: ctx.userId,
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
executionId: ctx.executionId,
largeValueExecutionIds: ctx.largeValueExecutionIds,
largeValueKeys: ctx.largeValueKeys,
fileKeys: ctx.fileKeys,
allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
requestId,
logger,
maxBytes: MAX_MOTHERSHIP_ATTACHMENT_BYTES,
maxSourceBytes: MAX_MOTHERSHIP_ATTACHMENT_BYTES,
})
const content = createFileContentFromBase64(base64, userFile.type)
if (!content) {
throw new Error(`File type is not supported for Mothership attachments: ${userFile.name}`)
}
attachments.push({ ...content, filename: userFile.name })
}
return attachments
}
/**
* Handler for Mothership blocks that proxy requests to the Mothership AI agent.
*
* Unlike the Agent block (which calls LLM providers directly), the Mothership
* block delegates to the full Mothership infrastructure: main agent, subagents,
* integration tools, memory, and workspace context.
*/
export class MothershipBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === BlockType.MOTHERSHIP
}
async execute(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<BlockOutput | StreamingExecution> {
const prompt = inputs.prompt
if (!prompt || typeof prompt !== 'string') {
throw new Error('Prompt input is required')
}
const messages = [{ role: 'user' as const, content: prompt }]
const providedConversationId =
typeof inputs.conversationId === 'string' ? inputs.conversationId.trim() : ''
const chatId = providedConversationId || generateId()
const messageId = generateId()
const requestId = generateId()
const fileAttachments = await buildMothershipFileAttachments(inputs.files, ctx, requestId)
const url = buildAPIUrl('/api/mothership/execute')
const headers = await buildAuthHeaders(ctx.userId)
headers.Accept = 'application/x-ndjson'
headers[MOTHERSHIP_EXECUTE_STREAM_HEADER] = MOTHERSHIP_EXECUTE_STREAM_VALUE
const body: Record<string, unknown> = {
messages,
workspaceId: ctx.workspaceId || '',
userId: ctx.userId || '',
chatId,
messageId,
requestId,
...(fileAttachments && { fileAttachments }),
...(ctx.workflowId ? { workflowId: ctx.workflowId } : {}),
...(ctx.executionId ? { executionId: ctx.executionId } : {}),
}
logger.info('Executing Mothership block', {
blockId: block.id,
messageId,
requestId,
workflowId: ctx.workflowId,
executionId: ctx.executionId,
chatId,
fileAttachmentCount: fileAttachments?.length ?? 0,
})
const abortController = new AbortController()
const onAbort = () => {
if (!abortController.signal.aborted) {
abortController.abort(ctx.abortSignal?.reason ?? 'workflow_abort')
}
}
if (ctx.abortSignal?.aborted) {
onAbort()
} else {
ctx.abortSignal?.addEventListener('abort', onAbort, { once: true })
}
const executionId = ctx.executionId
const useRedisCancellation = isRedisCancellationEnabled() && !!executionId
let pollInFlight = false
const cancellationPoller =
useRedisCancellation && executionId
? setInterval(() => {
if (pollInFlight || abortController.signal.aborted) {
return
}
pollInFlight = true
void isExecutionCancelled(executionId)
.then((cancelled) => {
if (cancelled && !abortController.signal.aborted) {
abortController.abort('workflow_execution_cancelled')
}
})
.catch((error) => {
logger.warn('Failed to poll workflow cancellation for Mothership block', {
blockId: block.id,
executionId,
error: toError(error).message,
})
})
.finally(() => {
pollInFlight = false
})
}, CANCELLATION_CHECK_INTERVAL_MS)
: undefined
const cleanupAbortListeners = () => {
if (cancellationPoller) {
clearInterval(cancellationPoller)
}
ctx.abortSignal?.removeEventListener('abort', onAbort)
}
let response: Response
let cleanupImmediately = true
try {
response = await fetch(url.toString(), {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: abortController.signal,
})
if (!response.ok) {
const errorMsg = await extractAPIErrorMessage(response)
throw new Error(`Sim execution failed: ${errorMsg}`)
}
if (isContentSelectedForStreaming(ctx, block)) {
const streamingExecution = createMothershipStreamingExecution(response, chatId, block.id, {
onCancel: (reason) => {
if (!abortController.signal.aborted) {
abortController.abort(reason ?? 'mothership_stream_cancelled')
}
},
onDone: cleanupAbortListeners,
})
cleanupImmediately = false
return streamingExecution
}
const result = await readMothershipExecuteResponse(response)
return formatMothershipBlockOutput(result, chatId)
} finally {
if (cleanupImmediately) {
cleanupAbortListeners()
}
}
}
}
+99
View File
@@ -0,0 +1,99 @@
/**
* The seam between the Pi handler and its execution environments. The handler
* resolves keys, skills, memory, and tools, then hands a {@link PiRunParams} to
* one backend ({@link PiBackendRun}) selected by `mode`. Backends own only the
* environment-specific execution (SSH vs E2B) and report progress through
* {@link PiRunContext.onEvent}.
*/
import type { SSHConnectionConfig } from '@/app/api/tools/ssh/utils'
import type { Message } from '@/executor/handlers/agent/types'
import type { PiEvent, PiRunTotals } from '@/executor/handlers/pi/events'
/** A conversation message seeded into the Pi run (subset of the Agent block's message). */
export type PiMessage = Pick<Message, 'role' | 'content'>
/** A resolved skill (name + full content) made available to Pi. */
export interface PiSkill {
name: string
content: string
}
/** SSH connection parameters for local mode (subset of the shared SSH config). */
export type PiSshConnection = Pick<
SSHConnectionConfig,
'host' | 'port' | 'username' | 'password' | 'privateKey' | 'passphrase'
>
/** Result of invoking a tool Pi called. */
export interface PiToolResult {
text: string
isError: boolean
}
/**
* A tool exposed to Pi in a backend-neutral shape (the SSH file/bash tools and
* adapted Sim tools both use it). The local backend converts these into Pi
* `customTools`; keeping them Pi-SDK-free keeps this seam typed.
*/
export interface PiToolSpec {
name: string
description: string
parameters: Record<string, unknown>
execute: (args: Record<string, unknown>) => Promise<PiToolResult>
}
interface PiRunBaseParams {
model: string
providerId: string
apiKey: string
isBYOK: boolean
task: string
thinkingLevel?: string
skills: PiSkill[]
initialMessages: PiMessage[]
}
/** Parameters for a local (SSH) Pi run. */
export interface PiLocalRunParams extends PiRunBaseParams {
mode: 'local'
ssh: PiSshConnection
repoPath: string
tools: PiToolSpec[]
}
/** Parameters for a cloud (E2B) Pi run. */
export interface PiCloudRunParams extends PiRunBaseParams {
mode: 'cloud'
owner: string
repo: string
githubToken: string
baseBranch?: string
branchName?: string
draft: boolean
prTitle?: string
prBody?: string
}
export type PiRunParams = PiLocalRunParams | PiCloudRunParams
/** Progress callbacks and cancellation passed into a backend run. */
export interface PiRunContext {
onEvent: (event: PiEvent) => void
signal?: AbortSignal
}
/** Final result of a Pi run. */
export interface PiRunResult {
totals: PiRunTotals
changedFiles?: string[]
diff?: string
prUrl?: string
branch?: string
}
/** A Pi execution backend. Implemented by the local (SSH) and cloud (E2B) runners. */
export type PiBackendRun<P extends PiRunParams = PiRunParams> = (
params: P,
context: PiRunContext
) => Promise<PiRunResult>
@@ -0,0 +1,281 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockRun, mockReadFile, mockWriteFile, mockExecuteTool, mockProviderEnvVar } = vi.hoisted(
() => ({
mockRun: vi.fn(),
mockReadFile: vi.fn(),
mockWriteFile: vi.fn(),
mockExecuteTool: vi.fn(),
mockProviderEnvVar: vi.fn(),
})
)
vi.mock('@/lib/execution/e2b', () => ({
withPiSandbox: (fn: (runner: unknown) => unknown) =>
fn({ run: mockRun, readFile: mockReadFile, writeFile: mockWriteFile }),
}))
vi.mock('@/tools', () => ({ executeTool: mockExecuteTool }))
vi.mock('@/executor/handlers/pi/keys', () => ({
providerApiKeyEnvVar: mockProviderEnvVar,
mapThinkingLevel: () => 'medium',
}))
vi.mock('@/executor/handlers/pi/context', () => ({ buildPiPrompt: () => 'PROMPT' }))
import type { PiCloudRunParams } from '@/executor/handlers/pi/backend'
import { runCloudPi } from '@/executor/handlers/pi/cloud-backend'
function baseParams(overrides: Partial<PiCloudRunParams> = {}): PiCloudRunParams {
return {
mode: 'cloud',
model: 'claude',
providerId: 'anthropic',
apiKey: 'sk-byok',
isBYOK: true,
task: 'do it',
skills: [],
initialMessages: [],
owner: 'octo',
repo: 'demo',
githubToken: 'ghp_secret',
branchName: 'feature-x',
draft: true,
...overrides,
}
}
describe('runCloudPi', () => {
beforeEach(() => {
vi.clearAllMocks()
mockProviderEnvVar.mockReturnValue('ANTHROPIC_API_KEY')
mockReadFile.mockResolvedValue('diff content')
mockExecuteTool.mockResolvedValue({
success: true,
output: { metadata: { html_url: 'https://github.com/octo/demo/pull/1' } },
})
mockRun.mockImplementation(
(command: string, options: { onStdout?: (chunk: string) => void }) => {
if (command.includes('git clone')) {
return Promise.resolve({
stdout: '__BASE_SHA__=abc123\n__DEFAULT_BRANCH__=main',
stderr: '',
exitCode: 0,
})
}
if (command.includes('pi -p')) {
options.onStdout?.(
'{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"done"}}\n'
)
return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 })
}
if (command.includes('push')) {
return Promise.resolve({ stdout: '__PUSHED__=1', stderr: '', exitCode: 0 })
}
return Promise.resolve({
stdout: '__CHANGED__=src/x.ts\n__NEEDS_PUSH__=1',
stderr: '',
exitCode: 0,
})
}
)
})
it('isolates secrets per command: token only in clone/push, model key only in the Pi loop', async () => {
const onEvent = vi.fn()
await runCloudPi(baseParams(), { onEvent })
const [cloneCmd, cloneOpts] = mockRun.mock.calls[0]
const [piCmd, piOpts] = mockRun.mock.calls[1]
const [prepareCmd, prepareOpts] = mockRun.mock.calls[2]
const [pushCmd, pushOpts] = mockRun.mock.calls[3]
expect(cloneCmd).toContain('git clone')
expect(cloneOpts.envs.GITHUB_TOKEN).toBe('ghp_secret')
expect(cloneOpts.envs.ANTHROPIC_API_KEY).toBeUndefined()
expect(piCmd).toContain('pi -p')
expect(piCmd).toContain('--provider')
expect(piOpts.envs.ANTHROPIC_API_KEY).toBe('sk-byok')
expect(piOpts.envs.GITHUB_TOKEN).toBeUndefined()
expect(piOpts.envs.PI_MODEL).toBe('claude')
expect(piOpts.envs.PI_PROVIDER).toBe('anthropic')
// PREPARE (add/commit/diff) must NOT carry the token: a repo-config-driven
// program the agent may have planted (clean filter, fsmonitor, textconv) runs
// on these commands and `core.hooksPath` does not stop it, so the credential
// must simply be absent.
expect(prepareCmd).toContain('add -A')
expect(prepareCmd).toContain('core.hooksPath=/dev/null')
expect(prepareOpts.envs.GITHUB_TOKEN).toBeUndefined()
expect(prepareOpts.envs.ANTHROPIC_API_KEY).toBeUndefined()
// PUSH is the only token-bearing command, hardened against planted git-config
// program execution (hooks, credential.helper, fsmonitor).
expect(pushCmd).toContain('push')
expect(pushCmd).toContain('core.hooksPath=/dev/null')
expect(pushCmd).toContain('credential.helper=')
expect(pushCmd).toContain('core.fsmonitor=')
expect(pushOpts.envs.GITHUB_TOKEN).toBe('ghp_secret')
expect(pushOpts.envs.ANTHROPIC_API_KEY).toBeUndefined()
expect(onEvent).toHaveBeenCalledWith({ type: 'text', text: 'done' })
})
it('delivers the prompt and commit message via files, never the command line', async () => {
await runCloudPi(baseParams(), { onEvent: vi.fn() })
// Untrusted text is written through the sandbox FS API, not interpolated into a shell command.
expect(mockWriteFile).toHaveBeenCalledWith('/workspace/pi-prompt.txt', 'PROMPT')
expect(mockWriteFile).toHaveBeenCalledWith('/workspace/pi-commit.txt', 'Pi: do it')
const [piCmd, piOpts] = mockRun.mock.calls[1]
// Prompt arrives on stdin from a fixed path; never a CLI arg or env value.
expect(piCmd).toContain('< /workspace/pi-prompt.txt')
expect(piCmd).not.toContain('PROMPT')
expect(piOpts.envs.PI_TASK).toBeUndefined()
const [prepareCmd, prepareOpts] = mockRun.mock.calls[2]
// Commit message is read from a file, not passed as -m "...".
expect(prepareCmd).toContain('commit -F /workspace/pi-commit.txt')
expect(prepareCmd).not.toContain('commit -m')
expect(prepareOpts.envs.COMMIT_MSG).toBeUndefined()
})
it('opens a PR from the pushed branch and returns its URL', async () => {
const result = await runCloudPi(baseParams(), { onEvent: vi.fn() })
expect(mockExecuteTool).toHaveBeenCalledWith(
'github_create_pr',
expect.objectContaining({
owner: 'octo',
repo: 'demo',
head: 'feature-x',
base: 'main',
draft: true,
apiKey: 'ghp_secret',
})
)
expect(result.prUrl).toBe('https://github.com/octo/demo/pull/1')
expect(result.branch).toBe('feature-x')
expect(result.changedFiles).toEqual(['src/x.ts'])
expect(result.diff).toBe('diff content')
})
it('skips the PR when nothing was pushed', async () => {
mockRun.mockImplementation((command: string) => {
if (command.includes('git clone')) {
return Promise.resolve({ stdout: '__BASE_SHA__=abc', stderr: '', exitCode: 0 })
}
if (command.includes('pi -p')) {
return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 })
}
return Promise.resolve({ stdout: '__NO_CHANGES__=1', stderr: '', exitCode: 0 })
})
const result = await runCloudPi(baseParams(), { onEvent: vi.fn() })
expect(mockExecuteTool).not.toHaveBeenCalled()
expect(result.prUrl).toBeUndefined()
// No changes => the token-bearing push command must never run.
expect(mockRun.mock.calls.some(([cmd]: [string]) => cmd.includes('push'))).toBe(false)
})
it('rejects a non-BYOK key (no Sim-owned key in the sandbox)', async () => {
await expect(runCloudPi(baseParams({ isBYOK: false }), { onEvent: vi.fn() })).rejects.toThrow(
/BYOK/
)
})
it('rejects providers that cannot run via a single key', async () => {
mockProviderEnvVar.mockReturnValue(null)
await expect(runCloudPi(baseParams(), { onEvent: vi.fn() })).rejects.toThrow(/not supported/)
})
it('fails when the Pi CLI exits non-zero (no PR opened)', async () => {
mockRun.mockImplementation((command: string) => {
if (command.includes('git clone')) {
return Promise.resolve({ stdout: '__BASE_SHA__=abc', stderr: '', exitCode: 0 })
}
if (command.includes('pi -p')) {
return Promise.resolve({ stdout: '', stderr: 'model not found', exitCode: 1 })
}
return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 })
})
await expect(runCloudPi(baseParams(), { onEvent: vi.fn() })).rejects.toThrow(/Pi agent failed/)
expect(mockExecuteTool).not.toHaveBeenCalled()
})
it('does not commit, push, or open a PR when the run reports an error on a zero exit', async () => {
mockRun.mockImplementation(
(command: string, options: { onStdout?: (chunk: string) => void }) => {
if (command.includes('git clone')) {
return Promise.resolve({ stdout: '__BASE_SHA__=abc', stderr: '', exitCode: 0 })
}
if (command.includes('pi -p')) {
options.onStdout?.('{"type":"error","error":"model exploded"}\n')
return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 })
}
return Promise.resolve({
stdout: '__CHANGED__=src/x.ts\n__NEEDS_PUSH__=1',
stderr: '',
exitCode: 0,
})
}
)
await expect(runCloudPi(baseParams(), { onEvent: vi.fn() })).rejects.toThrow(/model exploded/)
expect(mockExecuteTool).not.toHaveBeenCalled()
expect(mockRun.mock.calls.some(([cmd]: [string]) => cmd.includes('push'))).toBe(false)
})
it('fails (no PR) when finalize reports neither no-changes nor a push', async () => {
mockRun.mockImplementation((command: string) => {
if (command.includes('git clone')) {
return Promise.resolve({ stdout: '__BASE_SHA__=abc', stderr: '', exitCode: 0 })
}
if (command.includes('pi -p')) {
return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 })
}
// PREPARE aborted before emitting a marker (e.g. the repo dir vanished).
return Promise.resolve({
stdout: '',
stderr: 'cd: /workspace/repo: No such file or directory',
exitCode: 1,
})
})
await expect(runCloudPi(baseParams(), { onEvent: vi.fn() })).rejects.toThrow(/finalize failed/)
expect(mockExecuteTool).not.toHaveBeenCalled()
expect(mockRun.mock.calls.some(([cmd]: [string]) => cmd.includes('push'))).toBe(false)
})
it('surfaces the real git push error when the push fails, with the token scrubbed', async () => {
mockRun.mockImplementation((command: string) => {
if (command.includes('git clone')) {
return Promise.resolve({ stdout: '__BASE_SHA__=abc', stderr: '', exitCode: 0 })
}
if (command.includes('pi -p')) {
return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 })
}
if (command.includes('push')) {
return Promise.resolve({ stdout: '', stderr: '', exitCode: 1 })
}
return Promise.resolve({
stdout: '__CHANGED__=src/x.ts\n__NEEDS_PUSH__=1',
stderr: '',
exitCode: 0,
})
})
// The push step writes its stderr to a file; the backend reads + scrubs it.
mockReadFile.mockResolvedValue(
"remote: Permission to octo/demo.git denied.\nfatal: unable to access 'https://x-access-token:ghp_secret@github.com/octo/demo.git/': 403"
)
const error = (await runCloudPi(baseParams(), { onEvent: vi.fn() }).catch((e) => e)) as Error
expect(error.message).toMatch(/git push failed/)
expect(error.message).toMatch(/Permission to octo\/demo\.git denied/)
expect(error.message).not.toContain('ghp_secret')
expect(mockExecuteTool).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,353 @@
/**
* Cloud-mode backend: runs the Pi CLI inside an E2B sandbox against a cloned
* GitHub repo, then pushes a branch and opens a PR. Secrets are isolated per
* command (S2/KTD10): the GitHub token is present only for the clone and push
* commands (and stripped from the cloned remote), while the Pi loop runs with a
* BYOK model key only. The model key is never a Sim-owned hosted key (S1).
*
* Untrusted text (the assembled prompt, which folds in workspace-shared skills
* and memory, and the commit message) is never placed on a shell command line.
* It is written into sandbox files via the E2B filesystem API and read back from
* fixed paths (Pi's prompt on stdin, `git commit -F <file>`), so a collaborator-
* authored skill cannot inject shell into the Pi step where the model key lives.
*/
import { createLogger } from '@sim/logger'
import { generateShortId } from '@sim/utils/id'
import { truncate } from '@sim/utils/string'
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
import { withPiSandbox } from '@/lib/execution/e2b'
import type { PiBackendRun, PiCloudRunParams } from '@/executor/handlers/pi/backend'
import { buildPiPrompt } from '@/executor/handlers/pi/context'
import {
applyPiEvent,
createPiTotals,
type PiRunTotals,
parseJsonLine,
} from '@/executor/handlers/pi/events'
import { mapThinkingLevel, providerApiKeyEnvVar } from '@/executor/handlers/pi/keys'
import { executeTool } from '@/tools'
const logger = createLogger('PiCloudBackend')
const REPO_DIR = '/workspace/repo'
const DIFF_PATH = '/workspace/pi.diff'
const PROMPT_PATH = '/workspace/pi-prompt.txt'
const COMMIT_MSG_PATH = '/workspace/pi-commit.txt'
const PUSH_ERR_PATH = '/workspace/pi-push-err.txt'
const CLONE_TIMEOUT_MS = 10 * 60 * 1000
const PI_TIMEOUT_MS = getMaxExecutionTimeout()
const FINALIZE_TIMEOUT_MS = 10 * 60 * 1000
const MAX_DIFF_BYTES = 200_000
const COMMIT_TITLE_MAX = 72
const PR_SUMMARY_MAX = 2000
const PUSH_ERROR_MAX = 1000
// The agent only edits files; Sim commits, pushes, and opens the PR after the run.
// Without this, the coding agent tries to git push / open a PR / run the test
// toolchain itself and fails — the sandbox has no GitHub auth (the token is
// stripped from the remote after clone) and may lack the project's tooling.
const CLOUD_GUIDANCE =
'You are running inside an automated sandbox. Make only the file changes needed to complete the task. ' +
'Do not run git commands (commit, push, branch, remote), do not configure git credentials or authenticate ' +
'with GitHub, and do not open a pull request — after you finish, Sim automatically commits your changes, ' +
"pushes the branch, and opens the pull request. The project's package manager and test tooling may not be " +
'installed, so do not block on running the full build or test suite; focus on correct, minimal edits.'
const CLONE_SCRIPT = `set -e
rm -rf ${REPO_DIR}
git clone "https://x-access-token:$GITHUB_TOKEN@github.com/$REPO_OWNER/$REPO_NAME.git" ${REPO_DIR}
cd ${REPO_DIR}
if [ -n "$BASE_BRANCH" ]; then git checkout "$BASE_BRANCH"; fi
git rev-parse HEAD | sed "s/^/__BASE_SHA__=/"
DEFAULT_BRANCH=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed "s#^origin/##" || true)
echo "__DEFAULT_BRANCH__=$DEFAULT_BRANCH"
git checkout -b "$BRANCH"
git remote set-url origin "https://github.com/$REPO_OWNER/$REPO_NAME.git"`
const PI_SCRIPT = `cd ${REPO_DIR}
pi -p --mode json --provider "$PI_PROVIDER" --model "$PI_MODEL" --thinking "$PI_THINKING" < ${PROMPT_PATH}`
// Finalize is split so the GitHub token is in scope for ONLY the push. `git add`,
// `commit`, and `diff` run repo-config-driven programs that `core.hooksPath` does
// NOT disable — gitattributes clean/smudge filters (on add), `core.fsmonitor`
// (on add/diff), and `diff.external`/textconv (on diff). The untrusted Pi loop can
// plant `.gitattributes` + `.git/config` to run code during these. Keeping the
// token out of PREPARE's env means a planted program has no credential to steal;
// hooks are disabled too as defense-in-depth. Commit runs unconditionally
// (`|| true` tolerates an empty commit); the push decision is gated on HEAD
// advancing past base, so commits the agent made itself are still pushed.
const PREPARE_SCRIPT = `set -e
cd ${REPO_DIR}
git -c core.hooksPath=/dev/null add -A
git -c core.hooksPath=/dev/null -c user.email="pi@sim.ai" -c user.name="Sim Pi Agent" commit -F ${COMMIT_MSG_PATH} >/dev/null 2>&1 || true
git diff --name-only "$BASE_SHA" HEAD | sed "s/^/__CHANGED__=/"
git diff "$BASE_SHA" HEAD > ${DIFF_PATH} 2>/dev/null || true
if git diff --quiet "$BASE_SHA" HEAD; then echo "__NO_CHANGES__=1"; else echo "__NEEDS_PUSH__=1"; fi`
// The only token-bearing command. The agent-planted `.git/config` is still active,
// so neutralize every config key that could run a program during push: hooks
// (pre-push), `credential.helper` (runs during auth), and `core.fsmonitor`.
// Filters/textconv don't run on push (no checkout/add/diff here).
const PUSH_SCRIPT = `cd ${REPO_DIR}
git -c core.hooksPath=/dev/null -c credential.helper= -c core.fsmonitor= push "https://x-access-token:$GITHUB_TOKEN@github.com/$REPO_OWNER/$REPO_NAME.git" "$BRANCH" >/dev/null 2>${PUSH_ERR_PATH} && echo "__PUSHED__=1"`
function raceAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
if (!signal) return promise
if (signal.aborted) return Promise.reject(new Error('Pi run aborted'))
return new Promise<T>((resolve, reject) => {
const onAbort = () => reject(new Error('Pi run aborted'))
signal.addEventListener('abort', onAbort, { once: true })
promise.then(
(value) => {
signal.removeEventListener('abort', onAbort)
resolve(value)
},
(error) => {
signal.removeEventListener('abort', onAbort)
reject(error)
}
)
})
}
function extractMarkerValues(stdout: string, prefix: string): string[] {
return stdout
.split('\n')
.filter((line) => line.startsWith(prefix))
.map((line) => line.slice(prefix.length).trim())
.filter(Boolean)
}
/**
* Redacts the GitHub token from git output before it is surfaced in an error.
* Removes the literal token and any URL userinfo (`//user:token@`), so a failure
* message can quote git's real stderr without leaking the credential.
*/
function scrubGitSecrets(text: string, token: string): string {
const withoutToken = token ? text.split(token).join('***') : text
return withoutToken.replace(/\/\/[^/@\s]+@/g, '//***@')
}
function buildPrBody(task: string, finalText: string): string {
const summary = finalText.trim()
? truncate(finalText.trim(), PR_SUMMARY_MAX)
: 'Automated changes by the Pi Coding Agent.'
return `## Task\n\n${task}\n\n## Summary\n\n${summary}`
}
/** The commit message and PR title share one default, derived from the PR title or task. */
function defaultTitle(params: PiCloudRunParams): string {
return params.prTitle?.trim() || truncate(`Pi: ${params.task}`, COMMIT_TITLE_MAX)
}
async function openPullRequest(
params: PiCloudRunParams,
branch: string,
detectedBase: string | undefined,
totals: PiRunTotals
): Promise<string | undefined> {
const base = params.baseBranch?.trim() || detectedBase
if (!base) {
throw new Error(
`Branch ${branch} pushed, but the base branch could not be determined — set "Base Branch" on the block and re-run.`
)
}
const title = defaultTitle(params)
const body = params.prBody?.trim() || buildPrBody(params.task, totals.finalText)
const result = await executeTool('github_create_pr', {
owner: params.owner,
repo: params.repo,
title,
head: branch,
base,
body,
draft: params.draft,
apiKey: params.githubToken,
})
if (!result.success) {
throw new Error(
`Branch ${branch} pushed but PR creation failed: ${result.error ?? 'unknown error'}`
)
}
const output = result.output as { metadata?: { html_url?: string } } | undefined
return output?.metadata?.html_url
}
export const runCloudPi: PiBackendRun<PiCloudRunParams> = async (params, context) => {
if (!params.isBYOK) {
throw new Error(
'Cloud mode requires your own provider API key (BYOK). Set one in Settings > BYOK.'
)
}
const keyEnvVar = providerApiKeyEnvVar(params.providerId)
if (!keyEnvVar) {
throw new Error(
`Provider "${params.providerId}" is not supported in cloud mode. Use a key-based provider or run in local mode.`
)
}
const branch = params.branchName?.trim() || `pi/${generateShortId(8)}`
const commitMessage = defaultTitle(params)
const prompt = buildPiPrompt({
skills: params.skills,
initialMessages: params.initialMessages,
task: params.task,
guidance: CLOUD_GUIDANCE,
})
const totals = createPiTotals()
const thinking = mapThinkingLevel(params.thinkingLevel) ?? 'medium'
return withPiSandbox(async (runner) => {
try {
const clone = await raceAbort(
runner.run(CLONE_SCRIPT, {
envs: {
GITHUB_TOKEN: params.githubToken,
REPO_OWNER: params.owner,
REPO_NAME: params.repo,
BASE_BRANCH: params.baseBranch?.trim() ?? '',
BRANCH: branch,
},
timeoutMs: CLONE_TIMEOUT_MS,
}),
context.signal
)
if (clone.exitCode !== 0) {
throw new Error(
`git clone failed: ${scrubGitSecrets(clone.stderr || clone.stdout || 'unknown error', params.githubToken)}`
)
}
const baseSha = extractMarkerValues(clone.stdout, '__BASE_SHA__=')[0]
if (!baseSha) {
throw new Error('Clone did not report a base commit')
}
const detectedBase = extractMarkerValues(clone.stdout, '__DEFAULT_BRANCH__=')[0]
// Deliver the prompt as a file (read back on Pi's stdin), not a CLI
// arg/env, so its skill/memory content can't be parsed by the shell that
// launches the Pi loop.
await runner.writeFile(PROMPT_PATH, prompt)
let buffer = ''
const handleChunk = (chunk: string) => {
buffer += chunk
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
const event = parseJsonLine(line)
if (!event) continue
applyPiEvent(totals, event)
context.onEvent(event)
}
}
const piRun = await raceAbort(
runner.run(PI_SCRIPT, {
envs: {
[keyEnvVar]: params.apiKey,
PI_PROVIDER: params.providerId,
PI_MODEL: params.model,
PI_THINKING: thinking,
},
timeoutMs: PI_TIMEOUT_MS,
onStdout: handleChunk,
}),
context.signal
)
const remaining = buffer.trim() ? parseJsonLine(buffer) : null
if (remaining) {
applyPiEvent(totals, remaining)
context.onEvent(remaining)
}
if (piRun.exitCode !== 0) {
throw new Error(
`Pi agent failed (exit ${piRun.exitCode}): ${piRun.stderr || piRun.stdout}`.trim()
)
}
if (totals.errorMessage) {
throw new Error(`Pi agent failed: ${totals.errorMessage}`)
}
// Same rationale as the prompt: keep the commit message off the command line.
await runner.writeFile(COMMIT_MSG_PATH, commitMessage)
// PREPARE stages, commits, and diffs WITHOUT the GitHub token in scope, so a
// repo-config-driven program the agent may have planted can't exfiltrate it.
const prepare = await raceAbort(
runner.run(PREPARE_SCRIPT, {
envs: { BASE_SHA: baseSha },
timeoutMs: FINALIZE_TIMEOUT_MS,
}),
context.signal
)
const changedFiles = extractMarkerValues(prepare.stdout, '__CHANGED__=')
const noChanges = prepare.stdout.includes('__NO_CHANGES__=1')
const needsPush = prepare.stdout.includes('__NEEDS_PUSH__=1')
// PREPARE (`set -e`) emits exactly one of the two markers on success. Neither
// means the finalize step itself failed (e.g. the repo dir vanished mid-run) —
// surface that rather than silently reporting success with no push.
if (!noChanges && !needsPush) {
const reason = (prepare.stderr || prepare.stdout || 'no status reported').trim()
throw new Error(`Pi finalize failed: ${truncate(reason, PUSH_ERROR_MAX)}`)
}
let diff: string | undefined
try {
const raw = await runner.readFile(DIFF_PATH)
diff =
raw.length > MAX_DIFF_BYTES ? `${raw.slice(0, MAX_DIFF_BYTES)}\n[diff truncated]` : raw
} catch {
diff = undefined
}
if (noChanges) {
logger.info('Pi cloud run produced no changes to push', {
owner: params.owner,
repo: params.repo,
})
return { totals, changedFiles, diff }
}
// PUSH is the only command that carries the token, hardened against any
// git-config program execution the agent may have planted.
const push = await raceAbort(
runner.run(PUSH_SCRIPT, {
envs: {
GITHUB_TOKEN: params.githubToken,
REPO_OWNER: params.owner,
REPO_NAME: params.repo,
BRANCH: branch,
},
timeoutMs: FINALIZE_TIMEOUT_MS,
}),
context.signal
)
if (!push.stdout.includes('__PUSHED__=1')) {
let reason = push.stderr?.trim()
try {
const pushErr = (await runner.readFile(PUSH_ERR_PATH)).trim()
if (pushErr) reason = pushErr
} catch {}
const scrubbed = scrubGitSecrets(reason || 'unknown error', params.githubToken)
throw new Error(`git push failed: ${truncate(scrubbed, PUSH_ERROR_MAX)}`)
}
const prUrl = await openPullRequest(params, branch, detectedBase, totals)
return { totals, changedFiles, diff, prUrl, branch }
} catch (error) {
// Aborts propagate as errors so a cancelled/timed-out run is not reported as
// success and no partial memory turn is persisted (local mode mirrors this).
if (context.signal?.aborted) {
logger.info('Pi cloud run aborted', { owner: params.owner, repo: params.repo })
}
throw error
}
})
}
+118
View File
@@ -0,0 +1,118 @@
/**
* Reuses the Agent block's skills and memory subsystems for Pi runs. Skills
* resolve to full `{ name, content }` entries (so a backend can surface them as
* Pi skills), and multi-turn memory goes through the shared `memoryService`
* keyed by `memoryType`/`conversationId` — seeding the run and persisting the
* user task plus the agent's final message.
*/
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { memoryService } from '@/executor/handlers/agent/memory'
import { resolveSkillContentById } from '@/executor/handlers/agent/skills-resolver'
import type { AgentInputs, Message, SkillInput } from '@/executor/handlers/agent/types'
import type { PiMessage, PiSkill } from '@/executor/handlers/pi/backend'
import type { ExecutionContext } from '@/executor/types'
const logger = createLogger('PiContext')
/** Memory configuration — the Agent block's memory input fields, reused as-is. */
export type PiMemoryConfig = Pick<
AgentInputs,
'memoryType' | 'conversationId' | 'slidingWindowSize' | 'slidingWindowTokens' | 'model'
>
function isMemoryEnabled(config: PiMemoryConfig): boolean {
return !!config.memoryType && config.memoryType !== 'none'
}
/** Resolves selected skill inputs to full `{ name, content }` entries for Pi. */
export async function resolvePiSkills(
skillInputs: unknown,
workspaceId: string | undefined
): Promise<PiSkill[]> {
if (!Array.isArray(skillInputs) || !workspaceId) return []
const skills: PiSkill[] = []
for (const input of skillInputs as SkillInput[]) {
if (!input?.skillId) continue
try {
const resolved = await resolveSkillContentById(input.skillId, workspaceId)
if (resolved) skills.push({ name: resolved.name, content: resolved.content })
} catch (error) {
logger.warn('Failed to resolve skill for Pi', {
skillId: input.skillId,
error: getErrorMessage(error),
})
}
}
return skills
}
/** Loads prior conversation messages to seed the Pi run. */
export async function loadPiMemory(
ctx: ExecutionContext,
config: PiMemoryConfig
): Promise<PiMessage[]> {
if (!isMemoryEnabled(config)) return []
try {
const messages = await memoryService.fetchMemoryMessages(ctx, config)
return messages.map((message: Message) => ({ role: message.role, content: message.content }))
} catch (error) {
logger.warn('Failed to load Pi memory', { error: getErrorMessage(error) })
return []
}
}
/**
* Builds the prompt: optional operating `guidance` (mode-specific constraints),
* then skills, prior memory, and the task.
*/
export function buildPiPrompt(input: {
skills: PiSkill[]
initialMessages: PiMessage[]
task: string
guidance?: string
}): string {
const parts: string[] = []
if (input.guidance) {
parts.push(`# Operating instructions\n${input.guidance}`)
}
if (input.skills.length > 0) {
parts.push('# Available skills')
for (const skill of input.skills) {
parts.push(`## ${skill.name}\n${skill.content}`)
}
}
if (input.initialMessages.length > 0) {
parts.push('# Prior conversation')
for (const message of input.initialMessages) {
parts.push(`${message.role}: ${message.content}`)
}
}
parts.push('# Task')
parts.push(input.task)
return parts.join('\n\n')
}
/** Persists the user task and the agent's final message to memory. */
export async function appendPiMemory(
ctx: ExecutionContext,
config: PiMemoryConfig,
task: string,
finalText: string
): Promise<void> {
if (!isMemoryEnabled(config)) return
try {
await memoryService.appendToMemory(ctx, config, { role: 'user', content: task })
if (finalText) {
await memoryService.appendToMemory(ctx, config, { role: 'assistant', content: finalText })
}
} catch (error) {
logger.warn('Failed to append Pi memory', { error: getErrorMessage(error) })
}
}
@@ -0,0 +1,116 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
applyPiEvent,
createPiTotals,
normalizePiEvent,
parseJsonLine,
streamTextForEvent,
} from '@/executor/handlers/pi/events'
describe('normalizePiEvent', () => {
it('maps a text_delta message_update to a text event', () => {
expect(
normalizePiEvent({
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: 'hello' },
})
).toEqual({ type: 'text', text: 'hello' })
})
it('maps a thinking_delta message_update to a thinking event', () => {
expect(
normalizePiEvent({
type: 'message_update',
assistantMessageEvent: { type: 'thinking_delta', delta: 'hmm' },
})
).toEqual({ type: 'thinking', text: 'hmm' })
})
it('maps tool execution start and end', () => {
expect(normalizePiEvent({ type: 'tool_execution_start', toolName: 'bash' })).toEqual({
type: 'tool_start',
toolName: 'bash',
})
expect(
normalizePiEvent({ type: 'tool_execution_end', toolName: 'bash', isError: true })
).toEqual({
type: 'tool_end',
toolName: 'bash',
isError: true,
})
})
it('extracts usage from turn_end via message.usage and direct usage', () => {
expect(
normalizePiEvent({ type: 'turn_end', message: { usage: { input: 5, output: 7 } } })
).toEqual({ type: 'usage', inputTokens: 5, outputTokens: 7 })
expect(
normalizePiEvent({ type: 'turn_end', usage: { prompt_tokens: 3, completion_tokens: 2 } })
).toEqual({ type: 'usage', inputTokens: 3, outputTokens: 2 })
})
it('maps agent_end to final and error to error', () => {
expect(normalizePiEvent({ type: 'agent_end' })).toEqual({ type: 'final' })
expect(normalizePiEvent({ type: 'error', error: 'boom' })).toEqual({
type: 'error',
message: 'boom',
})
})
it('returns other for unknown types and null for non-objects', () => {
expect(normalizePiEvent({ type: 'queue_update' })).toEqual({ type: 'other' })
expect(normalizePiEvent('nope')).toBeNull()
expect(normalizePiEvent(null)).toBeNull()
})
})
describe('parseJsonLine', () => {
it('parses a valid json line', () => {
expect(parseJsonLine('{"type":"agent_end"}')).toEqual({ type: 'final' })
})
it('returns null for blank or malformed lines', () => {
expect(parseJsonLine(' ')).toBeNull()
expect(parseJsonLine('{not json')).toBeNull()
})
})
describe('applyPiEvent', () => {
it('accumulates text, sums usage, records tool calls and errors', () => {
const totals = createPiTotals()
applyPiEvent(totals, { type: 'text', text: 'a' })
applyPiEvent(totals, { type: 'text', text: 'b' })
applyPiEvent(totals, { type: 'usage', inputTokens: 3, outputTokens: 4 })
applyPiEvent(totals, { type: 'usage', inputTokens: 1, outputTokens: 1 })
applyPiEvent(totals, { type: 'tool_end', toolName: 'read', isError: false })
applyPiEvent(totals, { type: 'error', message: 'boom' })
expect(totals.finalText).toBe('ab')
expect(totals.inputTokens).toBe(4)
expect(totals.outputTokens).toBe(5)
expect(totals.toolCalls).toEqual([{ name: 'read', isError: false }])
expect(totals.errorMessage).toBe('boom')
})
it('uses final text only when no streamed text was seen', () => {
const empty = createPiTotals()
applyPiEvent(empty, { type: 'final', text: 'fallback' })
expect(empty.finalText).toBe('fallback')
const streamed = createPiTotals()
applyPiEvent(streamed, { type: 'text', text: 'streamed' })
applyPiEvent(streamed, { type: 'final', text: 'fallback' })
expect(streamed.finalText).toBe('streamed')
})
})
describe('streamTextForEvent', () => {
it('returns text for text events and null otherwise', () => {
expect(streamTextForEvent({ type: 'text', text: 'x' })).toBe('x')
expect(streamTextForEvent({ type: 'thinking', text: 'x' })).toBeNull()
expect(streamTextForEvent({ type: 'final' })).toBeNull()
})
})
+160
View File
@@ -0,0 +1,160 @@
/**
* Normalization layer for the Pi agent event stream. Both backends produce the
* same logical events — the local backend via the SDK `session.subscribe`
* callback, the cloud backend via `pi --mode json` stdout lines — so this module
* maps either source into a single {@link PiEvent} union and accumulates the
* run totals (final text, token usage, tool calls) the handler reports.
*/
/** A single normalized event emitted during a Pi run. */
export type PiEvent =
| { type: 'text'; text: string }
| { type: 'thinking'; text: string }
| { type: 'tool_start'; toolName: string }
| { type: 'tool_end'; toolName: string; isError: boolean }
| { type: 'usage'; inputTokens: number; outputTokens: number }
| { type: 'final'; text?: string }
| { type: 'error'; message: string }
| { type: 'other' }
/** A tool invocation observed during the run. */
export interface PiToolCallRecord {
name: string
isError?: boolean
}
/** Running totals accumulated across a Pi run. */
export interface PiRunTotals {
finalText: string
inputTokens: number
outputTokens: number
toolCalls: PiToolCallRecord[]
errorMessage?: string
}
/** Creates an empty totals accumulator. */
export function createPiTotals(): PiRunTotals {
return { finalText: '', inputTokens: 0, outputTokens: 0, toolCalls: [] }
}
/**
* Folds a normalized event into the totals. Text deltas accumulate into
* `finalText`; usage events sum (Pi reports per-turn usage on `turn_end`).
*/
export function applyPiEvent(totals: PiRunTotals, event: PiEvent): void {
switch (event.type) {
case 'text':
totals.finalText += event.text
break
case 'final':
if (event.text && totals.finalText.length === 0) {
totals.finalText = event.text
}
break
case 'usage':
totals.inputTokens += event.inputTokens
totals.outputTokens += event.outputTokens
break
case 'tool_end':
totals.toolCalls.push({ name: event.toolName, isError: event.isError })
break
case 'error':
totals.errorMessage = event.message
break
default:
break
}
}
/** Returns the text to enqueue onto the content stream for an event, if any. */
export function streamTextForEvent(event: PiEvent): string | null {
return event.type === 'text' ? event.text : null
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
}
function asString(value: unknown): string {
return typeof value === 'string' ? value : ''
}
function asNumber(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? value : 0
}
/**
* Extracts token usage from an event, tolerating the field names Pi and common
* provider payloads use (`input`/`output`, `inputTokens`/`outputTokens`,
* `prompt_tokens`/`completion_tokens`), checked on the event and on a nested
* `message`/`usage` object.
*/
function extractUsage(
ev: Record<string, unknown>
): { inputTokens: number; outputTokens: number } | null {
const candidates: Array<Record<string, unknown>> = []
const direct = asRecord(ev.usage)
if (direct) candidates.push(direct)
const message = asRecord(ev.message)
if (message) {
const messageUsage = asRecord(message.usage)
if (messageUsage) candidates.push(messageUsage)
}
for (const usage of candidates) {
const input =
asNumber(usage.input) || asNumber(usage.inputTokens) || asNumber(usage.prompt_tokens)
const output =
asNumber(usage.output) || asNumber(usage.outputTokens) || asNumber(usage.completion_tokens)
if (input > 0 || output > 0) {
return { inputTokens: input, outputTokens: output }
}
}
return null
}
/** Normalizes a raw Pi/SDK event object into a {@link PiEvent}. */
export function normalizePiEvent(raw: unknown): PiEvent | null {
const ev = asRecord(raw)
if (!ev) return null
switch (asString(ev.type)) {
case 'message_update': {
const assistantEvent = asRecord(ev.assistantMessageEvent)
const deltaType = assistantEvent ? asString(assistantEvent.type) : ''
const delta = assistantEvent ? asString(assistantEvent.delta) : ''
if (deltaType === 'text_delta') return { type: 'text', text: delta }
if (deltaType === 'thinking_delta') return { type: 'thinking', text: delta }
return { type: 'other' }
}
case 'tool_execution_start':
return { type: 'tool_start', toolName: asString(ev.toolName) }
case 'tool_execution_end':
return { type: 'tool_end', toolName: asString(ev.toolName), isError: ev.isError === true }
case 'turn_end': {
const usage = extractUsage(ev)
return usage ? { type: 'usage', ...usage } : { type: 'other' }
}
case 'agent_end':
return { type: 'final' }
case 'error':
return {
type: 'error',
message: asString(ev.error) || asString(ev.message) || 'Pi run failed',
}
default:
return { type: 'other' }
}
}
/** Parses one `pi --mode json` stdout line into a {@link PiEvent}. */
export function parseJsonLine(line: string): PiEvent | null {
const trimmed = line.trim()
if (!trimmed) return null
try {
return normalizePiEvent(JSON.parse(trimmed))
} catch {
return null
}
}
+161
View File
@@ -0,0 +1,161 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockGetApiKeyWithBYOK,
mockGetBYOKKey,
mockGetProviderFromModel,
mockCalculateCost,
mockShouldBill,
mockResolveVertex,
} = vi.hoisted(() => ({
mockGetApiKeyWithBYOK: vi.fn(),
mockGetBYOKKey: vi.fn(),
mockGetProviderFromModel: vi.fn(),
mockCalculateCost: vi.fn(),
mockShouldBill: vi.fn(),
mockResolveVertex: vi.fn(),
}))
vi.mock('@/lib/api-key/byok', () => ({
getApiKeyWithBYOK: mockGetApiKeyWithBYOK,
getBYOKKey: mockGetBYOKKey,
}))
vi.mock('@/providers/utils', () => ({
getProviderFromModel: mockGetProviderFromModel,
calculateCost: mockCalculateCost,
shouldBillModelUsage: mockShouldBill,
}))
vi.mock('@/executor/utils/vertex-credential', () => ({
resolveVertexCredential: mockResolveVertex,
}))
vi.mock('@/lib/core/config/env-flags', () => ({ getCostMultiplier: () => 2 }))
import { computePiCost, providerApiKeyEnvVar, resolvePiModelKey } from '@/executor/handlers/pi/keys'
describe('providerApiKeyEnvVar', () => {
it('maps key-based providers and rejects unsupported ones', () => {
expect(providerApiKeyEnvVar('anthropic')).toBe('ANTHROPIC_API_KEY')
expect(providerApiKeyEnvVar('openai')).toBe('OPENAI_API_KEY')
expect(providerApiKeyEnvVar('vertex')).toBeNull()
expect(providerApiKeyEnvVar('bedrock')).toBeNull()
expect(providerApiKeyEnvVar('something-else')).toBeNull()
})
})
describe('computePiCost', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns zero cost for BYOK keys without billing', () => {
expect(computePiCost('claude', 100, 200, true)).toEqual({ input: 0, output: 0, total: 0 })
expect(mockCalculateCost).not.toHaveBeenCalled()
})
it('returns zero cost for non-billable models', () => {
mockShouldBill.mockReturnValue(false)
expect(computePiCost('local-model', 100, 200, false)).toEqual({ input: 0, output: 0, total: 0 })
expect(mockCalculateCost).not.toHaveBeenCalled()
})
it('computes billed cost with the cost multiplier', () => {
mockShouldBill.mockReturnValue(true)
mockCalculateCost.mockReturnValue({ input: 1, output: 2, total: 3 })
expect(computePiCost('claude', 10, 20, false)).toEqual({ input: 1, output: 2, total: 3 })
expect(mockCalculateCost).toHaveBeenCalledWith('claude', 10, 20, false, 2, 2)
})
})
describe('resolvePiModelKey', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('resolves Vertex credentials when the provider is vertex', async () => {
mockGetProviderFromModel.mockReturnValue('vertex')
mockResolveVertex.mockResolvedValue('vertex-token')
const result = await resolvePiModelKey({
model: 'gemini-pro',
mode: 'local',
userId: 'user-1',
vertexCredential: 'cred-1',
})
expect(result).toEqual({ providerId: 'vertex', apiKey: 'vertex-token', isBYOK: true })
expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled()
})
it('local mode resolves keys through getApiKeyWithBYOK (hosted keys allowed)', async () => {
mockGetProviderFromModel.mockReturnValue('anthropic')
mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'sk-test', isBYOK: false })
const result = await resolvePiModelKey({
model: 'claude',
mode: 'local',
workspaceId: 'ws-1',
apiKey: 'sk-test',
})
expect(result).toEqual({ providerId: 'anthropic', apiKey: 'sk-test', isBYOK: false })
expect(mockGetApiKeyWithBYOK).toHaveBeenCalledWith('anthropic', 'claude', 'ws-1', 'sk-test')
})
it('cloud mode uses the block API Key field directly as a BYOK key', async () => {
mockGetProviderFromModel.mockReturnValue('anthropic')
const result = await resolvePiModelKey({
model: 'claude',
mode: 'cloud',
workspaceId: 'ws-1',
apiKey: 'sk-user',
})
expect(result).toEqual({ providerId: 'anthropic', apiKey: 'sk-user', isBYOK: true })
expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled()
expect(mockGetBYOKKey).not.toHaveBeenCalled()
})
it('cloud mode falls back to a stored workspace key when the field is empty', async () => {
mockGetProviderFromModel.mockReturnValue('openai')
mockGetBYOKKey.mockResolvedValue({ apiKey: 'sk-workspace', isBYOK: true })
const result = await resolvePiModelKey({
model: 'gpt-5',
mode: 'cloud',
workspaceId: 'ws-1',
})
expect(result).toEqual({ providerId: 'openai', apiKey: 'sk-workspace', isBYOK: true })
expect(mockGetBYOKKey).toHaveBeenCalledWith('ws-1', 'openai')
expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled()
})
it('cloud mode falls back to a stored workspace key for xAI', async () => {
mockGetProviderFromModel.mockReturnValue('xai')
mockGetBYOKKey.mockResolvedValue({ apiKey: 'xai-workspace-key', isBYOK: true })
const result = await resolvePiModelKey({
model: 'grok-4.5',
mode: 'cloud',
workspaceId: 'ws-1',
})
expect(result).toEqual({ providerId: 'xai', apiKey: 'xai-workspace-key', isBYOK: true })
expect(mockGetBYOKKey).toHaveBeenCalledWith('ws-1', 'xai')
expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled()
})
it('cloud mode rejects when no user key is available (never a hosted key)', async () => {
mockGetProviderFromModel.mockReturnValue('anthropic')
mockGetBYOKKey.mockResolvedValue(null)
await expect(
resolvePiModelKey({ model: 'claude', mode: 'cloud', workspaceId: 'ws-1' })
).rejects.toThrow(/your own provider API key/)
expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled()
})
})
+133
View File
@@ -0,0 +1,133 @@
/**
* Model, provider-key, and cost resolution shared by both Pi backends. Local
* mode mirrors the Agent block — keys resolve through `getApiKeyWithBYOK`, so a
* Sim-hosted key may be used and billed. Cloud mode requires the user's own key
* (the block's API Key field, or a stored workspace BYOK key) and never a hosted
* key, since the key is handed to an untrusted sandbox. Vertex resolves through
* `resolveVertexCredential`; cost uses the billing multiplier and is zeroed for
* BYOK / non-billable models.
*/
import type { CreateAgentSessionOptions } from '@earendil-works/pi-coding-agent'
import { getApiKeyWithBYOK, getBYOKKey } from '@/lib/api-key/byok'
import { getCostMultiplier } from '@/lib/core/config/env-flags'
import { resolveVertexCredential } from '@/executor/utils/vertex-credential'
import { isPiSupportedProvider, type PiSupportedProvider } from '@/providers/pi-providers'
import { calculateCost, getProviderFromModel, shouldBillModelUsage } from '@/providers/utils'
import type { BYOKProviderId } from '@/tools/types'
/** Resolved provider, key, and BYOK flag for a Pi run. */
export interface PiKeyResolution {
providerId: string
apiKey: string
isBYOK: boolean
}
interface ResolvePiModelKeyParams {
model: string
mode: 'cloud' | 'local'
workspaceId?: string
userId?: string
apiKey?: string
vertexCredential?: string
}
/** Providers whose key Sim can store as a workspace BYOK key (read back for cloud). */
const WORKSPACE_BYOK_PROVIDERS = new Set<string>([
'anthropic',
'openai',
'google',
'mistral',
'xai',
])
/** Resolves the provider and a usable API key for the selected model. */
export async function resolvePiModelKey(params: ResolvePiModelKeyParams): Promise<PiKeyResolution> {
const providerId = getProviderFromModel(params.model)
if (providerId === 'vertex' && params.vertexCredential) {
const apiKey = await resolveVertexCredential(
params.vertexCredential,
params.userId,
'vertex-pi'
)
return { providerId, apiKey, isBYOK: true }
}
// Cloud hands the model key to an untrusted sandbox, so it must be the user's
// own key — never a Sim-hosted/rotating key. Prefer the block's API Key field,
// then a stored workspace BYOK key; refuse to fall back to a hosted key.
if (params.mode === 'cloud') {
if (params.apiKey) {
return { providerId, apiKey: params.apiKey, isBYOK: true }
}
if (params.workspaceId && WORKSPACE_BYOK_PROVIDERS.has(providerId)) {
const byok = await getBYOKKey(params.workspaceId, providerId as BYOKProviderId)
if (byok) {
return { providerId, apiKey: byok.apiKey, isBYOK: true }
}
}
throw new Error(
WORKSPACE_BYOK_PROVIDERS.has(providerId)
? 'Cloud mode requires your own provider API key (BYOK). Enter it in the API Key field, or store one in Settings > BYOK.'
: 'Cloud mode requires your own provider API key (BYOK). Enter it in the API Key field.'
)
}
const { apiKey, isBYOK } = await getApiKeyWithBYOK(
providerId,
params.model,
params.workspaceId,
params.apiKey
)
return { providerId, apiKey, isBYOK }
}
/** Run cost, zeroed for BYOK keys and models Sim does not bill. */
export function computePiCost(
model: string,
inputTokens: number,
outputTokens: number,
isBYOK: boolean
) {
if (isBYOK || !shouldBillModelUsage(model)) {
return { input: 0, output: 0, total: 0 }
}
const multiplier = getCostMultiplier()
return calculateCost(model, inputTokens, outputTokens, false, multiplier, multiplier)
}
/**
* Env var the Pi CLI reads each provider's key from in the cloud sandbox. Keyed
* by {@link PiSupportedProvider}, so this map and the shared support set (which
* also drives the block's model dropdown) cannot drift — adding a provider to the
* set forces adding its env var here.
*/
const PROVIDER_API_KEY_ENV_VARS: Record<PiSupportedProvider, string> = {
anthropic: 'ANTHROPIC_API_KEY',
openai: 'OPENAI_API_KEY',
google: 'GEMINI_API_KEY',
xai: 'XAI_API_KEY',
deepseek: 'DEEPSEEK_API_KEY',
mistral: 'MISTRAL_API_KEY',
groq: 'GROQ_API_KEY',
cerebras: 'CEREBRAS_API_KEY',
openrouter: 'OPENROUTER_API_KEY',
}
/**
* Env var name a provider's API key is exposed under for the Pi CLI in the cloud
* sandbox, or `null` when Pi cannot run the provider via a single key. The cloud
* backend rejects `null` providers with a clear error rather than guessing.
*/
export function providerApiKeyEnvVar(providerId: string): string | null {
return isPiSupportedProvider(providerId) ? PROVIDER_API_KEY_ENV_VARS[providerId] : null
}
/** Maps a Sim thinking level to Pi's `ThinkingLevel` (shared by both backends). */
export function mapThinkingLevel(level?: string): CreateAgentSessionOptions['thinkingLevel'] {
if (!level || level === 'none') return 'off'
if (level === 'max') return 'xhigh'
if (level === 'low' || level === 'medium' || level === 'high') return level
return undefined
}
@@ -0,0 +1,204 @@
/**
* Local-mode backend: runs the Pi harness embedded in Sim with its built-in
* tools disabled and replaced by SSH-backed file/bash tools (plus any adapted
* Sim tools), all over a single reused SSH connection. The provider key stays in
* Sim's process (injected via `authStorage.setRuntimeApiKey`); only file/bash
* operations cross to the target machine.
*
* The Pi SDK is imported dynamically and externalized from the bundle, mirroring
* how `@e2b/code-interpreter` is loaded, so the package is resolved at runtime.
*/
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { ModelRegistry, ToolDefinition } from '@earendil-works/pi-coding-agent'
import { createLogger } from '@sim/logger'
import type { PiBackendRun, PiLocalRunParams, PiToolSpec } from '@/executor/handlers/pi/backend'
import { buildPiPrompt } from '@/executor/handlers/pi/context'
import { applyPiEvent, createPiTotals, normalizePiEvent } from '@/executor/handlers/pi/events'
import { mapThinkingLevel } from '@/executor/handlers/pi/keys'
import {
buildSshToolSpecs,
captureRepoChanges,
openSshSession,
} from '@/executor/handlers/pi/ssh-tools'
const logger = createLogger('PiLocalBackend')
const MAX_DIFF_BYTES = 200_000
// Local mode edits in place and reports the working-tree diff. The agent must not
// commit (a commit would hide the changes from `git diff HEAD`) or push/open a PR.
const LOCAL_GUIDANCE =
'Use the provided read/write/edit/bash tools to make the file changes needed to complete the task; they ' +
'operate on the target repository. Do not commit, push, or open a pull request — leave your changes in the ' +
'working tree; Sim reports them after you finish.'
/** The Pi SDK module, loaded dynamically so it stays externalized from the bundle. */
type PiSdk = typeof import('@earendil-works/pi-coding-agent')
let sdkPromise: Promise<PiSdk> | undefined
function loadPiSdk(): Promise<PiSdk> {
if (!sdkPromise) {
// A static specifier (not a variable) is required so Next's dependency tracer
// copies the package + its transitive deps into the standalone Docker output,
// the same way `@e2b/code-interpreter` is handled. Clear the cache on failure
// so a transient import error doesn't permanently break later local runs.
sdkPromise = import('@earendil-works/pi-coding-agent').catch((error) => {
sdkPromise = undefined
throw error
})
}
return sdkPromise
}
function toPiTool(sdk: PiSdk, spec: PiToolSpec): ToolDefinition {
return sdk.defineTool({
name: spec.name,
label: spec.name,
description: spec.description,
// double-cast-allowed: Pi accepts a plain JSON Schema at runtime (pi-ai validation.js coerceWithJsonSchema); the static type requires a TypeBox TSchema
parameters: spec.parameters as unknown as ToolDefinition['parameters'],
execute: async (_toolCallId, params) => {
const result = await spec.execute(params as Record<string, unknown>)
return {
content: [{ type: 'text', text: result.text }],
details: { isError: result.isError },
}
},
})
}
/**
* Builds a model definition for a provider Pi supports but whose bundled catalog
* doesn't list this exact id (e.g. a newer model Pi wires to a different
* provider). Mirrors the cloud CLI's passthrough: clone one of the provider's
* models as a template, swap in the requested id, and force reasoning when a
* thinking level is requested. Returns undefined only when the provider has no
* models at all, so even passthrough can't route it.
*/
function buildPiFallbackModel(
modelRegistry: ModelRegistry,
provider: string,
modelId: string,
thinkingLevel: ReturnType<typeof mapThinkingLevel>
) {
const providerModels = modelRegistry.getAll().filter((m) => m.provider === provider)
if (providerModels.length === 0) return undefined
const fallback = { ...providerModels[0], id: modelId, name: modelId }
return thinkingLevel && thinkingLevel !== 'off' ? { ...fallback, reasoning: true } : fallback
}
export const runLocalPi: PiBackendRun<PiLocalRunParams> = async (params, context) => {
// Isolate Pi resource discovery: an empty cwd/agentDir keeps DefaultResourceLoader
// from loading the Sim server's own .agents/skills, AGENTS.md, extensions, or settings.
const isolatedDir = await mkdtemp(join(tmpdir(), 'sim-pi-'))
// Clean up the scratch dir if the SSH connection fails — the try/finally below
// is only entered once the session is open, so an early handshake failure would
// otherwise orphan the directory.
const session = await openSshSession(params.ssh).catch(async (error) => {
await rm(isolatedDir, { recursive: true, force: true }).catch(() => {})
throw error
})
try {
const sdk = await loadPiSdk()
const authStorage = sdk.AuthStorage.create()
authStorage.setRuntimeApiKey(params.providerId, params.apiKey)
const modelRegistry = sdk.ModelRegistry.create(authStorage)
const thinkingLevel = mapThinkingLevel(params.thinkingLevel)
// Parity with cloud: when the model isn't in Pi's bundled catalog under the
// resolved provider, pass it through on that provider instead of failing.
const model =
modelRegistry.find(params.providerId, params.model) ??
buildPiFallbackModel(modelRegistry, params.providerId, params.model, thinkingLevel)
if (!model) {
throw new Error(
`Pi has no models for provider "${params.providerId}" (cannot run ${params.model})`
)
}
const specs = [...buildSshToolSpecs(session, params.repoPath), ...params.tools]
const customTools = specs.map((spec) => toPiTool(sdk, spec))
const { session: agentSession } = await sdk.createAgentSession({
cwd: isolatedDir,
agentDir: isolatedDir,
model,
thinkingLevel,
noTools: 'builtin',
customTools,
authStorage,
modelRegistry,
sessionManager: sdk.SessionManager.inMemory(isolatedDir),
})
const totals = createPiTotals()
const unsubscribe = agentSession.subscribe((raw) => {
const event = normalizePiEvent(raw)
if (!event) return
applyPiEvent(totals, event)
context.onEvent(event)
})
const onAbort = () => {
void agentSession.abort()
}
if (context.signal?.aborted) {
onAbort()
} else {
context.signal?.addEventListener('abort', onAbort, { once: true })
}
let runErrorMessage: string | undefined
try {
await agentSession.prompt(
buildPiPrompt({
skills: params.skills,
initialMessages: params.initialMessages,
task: params.task,
guidance: LOCAL_GUIDANCE,
})
)
// Pi has no error event; a failed run surfaces on the agent state. Capture
// it before `dispose()` so the failure can't be missed by a later read.
runErrorMessage = agentSession.agent.state.errorMessage
} finally {
unsubscribe()
context.signal?.removeEventListener('abort', onAbort)
try {
agentSession.dispose()
} catch (error) {
logger.warn('Failed to dispose Pi session', { error })
}
}
// Aborts propagate as errors so a cancelled/timed-out run is not reported as
// success and no partial memory turn is persisted (cloud mode mirrors this).
// Pi resolves `prompt()` on abort rather than rejecting, so check explicitly.
if (context.signal?.aborted) {
throw new Error('Pi run aborted')
}
if (runErrorMessage) {
totals.errorMessage = runErrorMessage
return { totals }
}
// Local mode edits in place (no PR), so report what changed via the repo's
// working-tree diff over the same SSH session.
const { changedFiles, diff } = await captureRepoChanges(
session,
params.repoPath,
MAX_DIFF_BYTES
)
return { totals, changedFiles, diff }
} finally {
session.close()
await rm(isolatedDir, { recursive: true, force: true }).catch(() => {})
}
}
@@ -0,0 +1,153 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockRunLocal, mockRunCloud, mockResolveKey } = vi.hoisted(() => ({
mockRunLocal: vi.fn(),
mockRunCloud: vi.fn(),
mockResolveKey: vi.fn(),
}))
vi.mock('@/executor/handlers/pi/keys', () => ({
resolvePiModelKey: mockResolveKey,
computePiCost: () => ({ input: 0, output: 0, total: 0 }),
}))
vi.mock('@/executor/handlers/pi/context', () => ({
resolvePiSkills: vi.fn().mockResolvedValue([]),
loadPiMemory: vi.fn().mockResolvedValue([]),
appendPiMemory: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/executor/handlers/pi/sim-tools', () => ({
buildSimToolSpecs: vi.fn().mockResolvedValue([]),
}))
vi.mock('@/executor/handlers/pi/local-backend', () => ({ runLocalPi: mockRunLocal }))
vi.mock('@/executor/handlers/pi/cloud-backend', () => ({ runCloudPi: mockRunCloud }))
vi.mock('@/blocks/utils', () => ({
parseOptionalNumberInput: (value: unknown) => {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : undefined
},
}))
import { PiBlockHandler } from '@/executor/handlers/pi/pi-handler'
import type { ExecutionContext, StreamingExecution } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
const block = { id: 'blk', metadata: { id: 'pi' } } as unknown as SerializedBlock
function ctx(overrides: Partial<ExecutionContext> = {}): ExecutionContext {
return {
workflowId: 'wf',
workspaceId: 'ws',
userId: 'user',
...overrides,
} as ExecutionContext
}
function localInputs(extra: Record<string, unknown> = {}) {
return {
mode: 'local',
task: 'do the thing',
model: 'claude',
host: 'box.example.com',
username: 'deploy',
authMethod: 'password',
password: 'pw',
repoPath: '/srv/repo',
...extra,
}
}
describe('PiBlockHandler', () => {
const handler = new PiBlockHandler()
beforeEach(() => {
vi.clearAllMocks()
mockResolveKey.mockResolvedValue({ providerId: 'anthropic', apiKey: 'k', isBYOK: true })
mockRunLocal.mockResolvedValue({
totals: { finalText: 'hi', inputTokens: 1, outputTokens: 2, toolCalls: [] },
})
mockRunCloud.mockResolvedValue({
totals: { finalText: 'done', inputTokens: 0, outputTokens: 0, toolCalls: [] },
prUrl: 'https://github.com/o/r/pull/1',
branch: 'pi/abc',
changedFiles: ['a.ts'],
diff: 'diff',
})
})
it('canHandle matches the pi block type', () => {
expect(handler.canHandle(block)).toBe(true)
expect(
handler.canHandle({ id: 'x', metadata: { id: 'agent' } } as unknown as SerializedBlock)
).toBe(false)
})
it('throws when the task is missing', async () => {
await expect(handler.execute(ctx(), block, { mode: 'local', task: '' })).rejects.toThrow(/Task/)
})
it('routes local mode to the local backend with SSH params', async () => {
const output = await handler.execute(ctx(), block, localInputs())
expect(mockRunLocal).toHaveBeenCalledTimes(1)
expect(mockRunCloud).not.toHaveBeenCalled()
const params = mockRunLocal.mock.calls[0][0]
expect(params.mode).toBe('local')
expect(params.ssh.host).toBe('box.example.com')
expect(params.repoPath).toBe('/srv/repo')
expect((output as Record<string, unknown>).content).toBe('hi')
})
it('routes cloud mode to the cloud backend and surfaces PR output', async () => {
const output = (await handler.execute(ctx(), block, {
mode: 'cloud',
task: 'do it',
model: 'claude',
owner: 'o',
repo: 'r',
githubToken: 'ghp',
})) as Record<string, unknown>
expect(mockRunCloud).toHaveBeenCalledTimes(1)
expect(output.prUrl).toBe('https://github.com/o/r/pull/1')
expect(output.branch).toBe('pi/abc')
})
it('requires SSH fields in local mode', async () => {
await expect(
handler.execute(ctx(), block, { mode: 'local', task: 'x', model: 'claude', host: 'h' })
).rejects.toThrow(/Local mode requires/)
})
it('requires repo + token in cloud mode', async () => {
await expect(
handler.execute(ctx(), block, { mode: 'cloud', task: 'x', model: 'claude', owner: 'o' })
).rejects.toThrow(/Cloud mode requires/)
})
it('streams text when the block is selected for streaming output', async () => {
mockRunLocal.mockImplementation(async (_params, runCtx) => {
runCtx.onEvent({ type: 'text', text: 'streamed' })
return { totals: { finalText: 'streamed', inputTokens: 0, outputTokens: 0, toolCalls: [] } }
})
const result = (await handler.execute(
ctx({ stream: true, selectedOutputs: ['blk'] }),
block,
localInputs()
)) as StreamingExecution
expect('stream' in result).toBe(true)
const reader = result.stream.getReader()
const decoder = new TextDecoder()
let text = ''
for (;;) {
const { done, value } = await reader.read()
if (done) break
text += decoder.decode(value)
}
expect(text).toContain('streamed')
expect(result.execution.output.content).toBe('streamed')
})
})
+262
View File
@@ -0,0 +1,262 @@
/**
* Executor handler for the Pi Coding Agent block. Resolves the model key,
* skills, and memory, selects a backend by `mode`, and runs it — streaming the
* agent's text to the client when the block is selected for streaming output,
* otherwise returning a plain block output. The handler depends only on the
* {@link PiBackendRun} seam and never reaches into backend internals.
*/
import { createLogger } from '@sim/logger'
import type { BlockOutput } from '@/blocks/types'
import { parseOptionalNumberInput } from '@/blocks/utils'
import { BlockType } from '@/executor/constants'
import type {
PiBackendRun,
PiCloudRunParams,
PiLocalRunParams,
PiRunParams,
PiRunResult,
} from '@/executor/handlers/pi/backend'
import { runCloudPi } from '@/executor/handlers/pi/cloud-backend'
import {
appendPiMemory,
loadPiMemory,
type PiMemoryConfig,
resolvePiSkills,
} from '@/executor/handlers/pi/context'
import { streamTextForEvent } from '@/executor/handlers/pi/events'
import { computePiCost, resolvePiModelKey } from '@/executor/handlers/pi/keys'
import { runLocalPi } from '@/executor/handlers/pi/local-backend'
import { buildSimToolSpecs } from '@/executor/handlers/pi/sim-tools'
import type {
BlockHandler,
ExecutionContext,
NormalizedBlockOutput,
StreamingExecution,
} from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('PiBlockHandler')
const DEFAULT_MODEL = 'claude-sonnet-5'
function asOptString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
return trimmed ? trimmed : undefined
}
function asRawString(value: unknown): string | undefined {
return typeof value === 'string' && value !== '' ? value : undefined
}
export class PiBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === BlockType.PI
}
async execute(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<BlockOutput | StreamingExecution> {
const task = asOptString(inputs.task)
if (!task) throw new Error('Task is required')
const model = asOptString(inputs.model) ?? DEFAULT_MODEL
// Validate the mode up front so an invalid value reports a mode error rather
// than a misattributed credential error from key resolution below.
if (inputs.mode !== 'cloud' && inputs.mode !== 'local') {
throw new Error(`Invalid Pi mode: ${String(inputs.mode)}`)
}
const mode: 'cloud' | 'local' = inputs.mode
const { providerId, apiKey, isBYOK } = await resolvePiModelKey({
model,
mode,
workspaceId: ctx.workspaceId,
userId: ctx.userId,
apiKey: asRawString(inputs.apiKey),
vertexCredential: asOptString(inputs.vertexCredential),
})
const skills = await resolvePiSkills(inputs.skills, ctx.workspaceId)
const memoryConfig: PiMemoryConfig = {
memoryType: asOptString(inputs.memoryType) as PiMemoryConfig['memoryType'],
conversationId: asOptString(inputs.conversationId),
slidingWindowSize: asOptString(inputs.slidingWindowSize),
slidingWindowTokens: asOptString(inputs.slidingWindowTokens),
model,
}
const initialMessages = await loadPiMemory(ctx, memoryConfig)
const base = {
model,
providerId,
apiKey,
isBYOK,
task,
thinkingLevel: asOptString(inputs.thinkingLevel),
skills,
initialMessages,
}
if (mode === 'local') {
const host = asOptString(inputs.host)
const username = asOptString(inputs.username)
const repoPath = asOptString(inputs.repoPath)
if (!host || !username || !repoPath) {
throw new Error('Local mode requires host, username, and repository path')
}
const usePrivateKey = inputs.authMethod === 'privateKey'
const port = parseOptionalNumberInput(inputs.port, 'port', { integer: true, min: 1 }) ?? 22
const tools = await buildSimToolSpecs(ctx, inputs.tools)
const params: PiLocalRunParams = {
...base,
mode: 'local',
repoPath,
tools,
ssh: {
host,
port,
username,
password: usePrivateKey ? undefined : asRawString(inputs.password),
privateKey: usePrivateKey ? asRawString(inputs.privateKey) : undefined,
passphrase: usePrivateKey ? asRawString(inputs.passphrase) : undefined,
},
}
return this.runPi(ctx, block, runLocalPi, params, memoryConfig)
}
if (mode === 'cloud') {
const owner = asOptString(inputs.owner)
const repo = asOptString(inputs.repo)
const githubToken = asRawString(inputs.githubToken)
if (!owner || !repo || !githubToken) {
throw new Error('Cloud mode requires repository owner, name, and a GitHub token')
}
const params: PiCloudRunParams = {
...base,
mode: 'cloud',
owner,
repo,
githubToken,
baseBranch: asOptString(inputs.baseBranch),
branchName: asOptString(inputs.branchName),
draft: inputs.draft !== false,
prTitle: asOptString(inputs.prTitle),
prBody: asOptString(inputs.prBody),
}
return this.runPi(ctx, block, runCloudPi, params, memoryConfig)
}
throw new Error(`Invalid Pi mode: ${String(inputs.mode)}`)
}
private isContentSelectedForStreaming(ctx: ExecutionContext, block: SerializedBlock): boolean {
if (!ctx.stream) return false
return (
ctx.selectedOutputs?.some((outputId) => {
if (outputId === block.id) return true
return outputId === `${block.id}.content` || outputId === `${block.id}_content`
}) ?? false
)
}
private buildOutput(
result: PiRunResult,
model: string,
isBYOK: boolean,
startTime: number,
startTimeISO: string
): NormalizedBlockOutput {
const { totals } = result
const endTime = Date.now()
return {
content: totals.finalText,
model,
changedFiles: result.changedFiles ?? [],
diff: result.diff ?? '',
...(result.prUrl ? { prUrl: result.prUrl } : {}),
...(result.branch ? { branch: result.branch } : {}),
tokens: {
input: totals.inputTokens,
output: totals.outputTokens,
total: totals.inputTokens + totals.outputTokens,
},
cost: computePiCost(model, totals.inputTokens, totals.outputTokens, isBYOK),
providerTiming: {
startTime: startTimeISO,
endTime: new Date(endTime).toISOString(),
duration: endTime - startTime,
},
}
}
private async runPi<P extends PiRunParams>(
ctx: ExecutionContext,
block: SerializedBlock,
backend: PiBackendRun<P>,
params: P,
memoryConfig: PiMemoryConfig
): Promise<BlockOutput | StreamingExecution> {
const startTime = Date.now()
const startTimeISO = new Date(startTime).toISOString()
logger.info('Executing Pi block', {
blockId: block.id,
mode: params.mode,
model: params.model,
workflowId: ctx.workflowId,
executionId: ctx.executionId,
})
if (this.isContentSelectedForStreaming(ctx, block)) {
const output: NormalizedBlockOutput = { content: '', model: params.model }
const stream = new ReadableStream<Uint8Array>({
start: async (controller) => {
const encoder = new TextEncoder()
try {
const result = await backend(params, {
onEvent: (event) => {
const text = streamTextForEvent(event)
if (text) controller.enqueue(encoder.encode(text))
},
signal: ctx.abortSignal,
})
if (result.totals.errorMessage) {
controller.error(new Error(result.totals.errorMessage))
return
}
Object.assign(
output,
this.buildOutput(result, params.model, params.isBYOK, startTime, startTimeISO)
)
await appendPiMemory(ctx, memoryConfig, params.task, result.totals.finalText)
controller.close()
} catch (error) {
controller.error(error)
}
},
})
return {
stream,
execution: {
success: true,
output,
blockId: block.id,
logs: [],
metadata: { startTime: startTimeISO, duration: 0 },
isStreaming: true,
} as StreamingExecution['execution'] & { blockId: string },
}
}
const result = await backend(params, { onEvent: () => {}, signal: ctx.abortSignal })
if (result.totals.errorMessage) {
throw new Error(result.totals.errorMessage)
}
await appendPiMemory(ctx, memoryConfig, params.task, result.totals.finalText)
return this.buildOutput(result, params.model, params.isBYOK, startTime, startTimeISO)
}
}
@@ -0,0 +1,84 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockTransformBlockTool, mockExecuteTool } = vi.hoisted(() => ({
mockTransformBlockTool: vi.fn(),
mockExecuteTool: vi.fn(),
}))
vi.mock('@/providers/utils', () => ({ transformBlockTool: mockTransformBlockTool }))
vi.mock('@/tools', () => ({ executeTool: mockExecuteTool }))
vi.mock('@/tools/utils', () => ({ getTool: vi.fn() }))
vi.mock('@/tools/utils.server', () => ({ getToolAsync: vi.fn() }))
import { buildSimToolSpecs } from '@/executor/handlers/pi/sim-tools'
import type { ExecutionContext } from '@/executor/types'
const ctx = { workspaceId: 'ws-1' } as ExecutionContext
describe('buildSimToolSpecs', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('names the Pi tool with the snake_case tool id, not the human label', async () => {
// transformBlockTool returns a human label with a space, which the model
// provider rejects (tool names must match /^[a-zA-Z0-9_-]{1,128}$/).
mockTransformBlockTool.mockResolvedValue({
id: 'exa_search',
name: 'Exa Search',
description: 'Search the web',
params: {},
parameters: { type: 'object', properties: {} },
})
const specs = await buildSimToolSpecs(ctx, [
{ type: 'exa', operation: 'exa_search', usageControl: 'auto' },
])
expect(specs).toHaveLength(1)
expect(specs[0].name).toBe('exa_search')
expect(specs[0].name).toMatch(/^[a-zA-Z0-9_-]{1,128}$/)
})
it('skips mcp, custom, and usage-none tools without adapting them', async () => {
const specs = await buildSimToolSpecs(ctx, [
{ type: 'mcp', usageControl: 'auto' },
{ type: 'custom-tool', usageControl: 'auto' },
{ type: 'exa', usageControl: 'none' },
])
expect(specs).toHaveLength(0)
expect(mockTransformBlockTool).not.toHaveBeenCalled()
})
it('forwards a trusted _context that an LLM-supplied _context cannot override', async () => {
mockTransformBlockTool.mockResolvedValue({
id: 'exa_search',
name: 'Exa Search',
description: 'Search the web',
params: { apiKey: 'k' },
parameters: { type: 'object', properties: {} },
})
mockExecuteTool.mockResolvedValue({ success: true, output: 'ok' })
const trustedCtx = {
workspaceId: 'ws-1',
workflowId: 'wf-1',
userId: 'user-1',
} as ExecutionContext
const [spec] = await buildSimToolSpecs(trustedCtx, [
{ type: 'exa', operation: 'exa_search', usageControl: 'auto' },
])
// An attacker-influenced tool arg tries to spoof the execution context.
await spec.execute({ query: 'cats', _context: { userId: 'attacker', workspaceId: 'evil' } })
const [toolId, callParams] = mockExecuteTool.mock.calls[0]
expect(toolId).toBe('exa_search')
expect(callParams._context.userId).toBe('user-1')
expect(callParams._context.workspaceId).toBe('ws-1')
expect(callParams._context.workflowId).toBe('wf-1')
})
})
+107
View File
@@ -0,0 +1,107 @@
/**
* Adapts user-selected Sim tools into backend-neutral {@link PiToolSpec}s that
* Pi can call in local mode. Each spec carries the tool's JSON-schema parameters
* and an `execute` that runs the real Sim tool through `executeTool`, so the
* agent's calls go through the same credential-access checks as any block.
*
* MCP and custom tools are skipped in v1; block/integration tools are supported.
*/
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { getAllBlocks } from '@/blocks/registry'
import type { ToolInput } from '@/executor/handlers/agent/types'
import type { PiToolResult, PiToolSpec } from '@/executor/handlers/pi/backend'
import type { ExecutionContext } from '@/executor/types'
import { transformBlockTool } from '@/providers/utils'
import { executeTool } from '@/tools'
import type { ToolResponse } from '@/tools/types'
import { getTool } from '@/tools/utils'
import { getToolAsync } from '@/tools/utils.server'
const logger = createLogger('PiSimTools')
function toToolResult(result: ToolResponse): PiToolResult {
if (result.success) {
const text =
typeof result.output === 'string' ? result.output : JSON.stringify(result.output ?? {})
return { text, isError: false }
}
return { text: result.error || 'Tool execution failed', isError: true }
}
/**
* Builds the Sim tool specs exposed to Pi for a local run. Only tools the user
* added to the block are included, and `usageControl: 'none'` tools are dropped.
*/
export async function buildSimToolSpecs(
ctx: ExecutionContext,
inputTools: unknown
): Promise<PiToolSpec[]> {
if (!Array.isArray(inputTools)) return []
const specs: PiToolSpec[] = []
for (const tool of inputTools as ToolInput[]) {
if ((tool.usageControl || 'auto') === 'none') continue
if (!tool.type || tool.type === 'mcp' || tool.type === 'custom-tool') continue
try {
const provider = await transformBlockTool(tool, {
selectedOperation: tool.operation,
getAllBlocks,
getTool,
getToolAsync,
})
if (!provider?.id) continue
const toolId = provider.id
const preseededParams = provider.params || {}
specs.push({
name: toolId,
description: provider.description || '',
parameters: (provider.parameters as Record<string, unknown>) || {
type: 'object',
properties: {},
},
execute: async (args) => {
try {
const result = await executeTool(
toolId,
{
...preseededParams,
...args,
// Trusted execution context, spread last so an LLM-supplied
// `_context` arg can't override it. executeTool reads this directly
// for OAuth-credential resolution and internal-route identity, the
// same way the Agent block's tool calls do.
_context: {
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
executionId: ctx.executionId,
userId: ctx.userId,
isDeployedContext: ctx.isDeployedContext,
enforceCredentialAccess: ctx.enforceCredentialAccess,
callChain: ctx.callChain,
},
},
{ executionContext: ctx }
)
return toToolResult(result)
} catch (error) {
return { text: getErrorMessage(error, 'Tool execution failed'), isError: true }
}
},
})
} catch (error) {
logger.warn('Failed to adapt Sim tool for Pi', {
type: tool.type,
error: getErrorMessage(error),
})
}
}
return specs
}
@@ -0,0 +1,106 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockExecuteSSHCommand } = vi.hoisted(() => ({
mockExecuteSSHCommand: vi.fn(),
}))
vi.mock('@/app/api/tools/ssh/utils', () => ({
createSSHConnection: vi.fn(),
executeSSHCommand: mockExecuteSSHCommand,
escapeShellArg: (value: string) => value.replace(/'/g, "'\\''"),
sanitizeCommand: (value: string) => value,
sanitizePath: (value: string) => {
if (value.split(/[/\\]/).includes('..')) {
throw new Error('Path contains invalid path traversal sequences')
}
return value.trim()
},
}))
import type { PiSshSession } from '@/executor/handlers/pi/ssh-tools'
import { buildSshToolSpecs } from '@/executor/handlers/pi/ssh-tools'
function createSession(files: Record<string, string>): PiSshSession {
const sftp = {
readFile: (path: string, cb: (err: Error | undefined, data: Buffer) => void) => {
if (!(path in files)) {
cb(new Error(`no such file: ${path}`), Buffer.from(''))
return
}
cb(undefined, Buffer.from(files[path]))
},
writeFile: (path: string, data: string, cb: (err?: Error) => void) => {
files[path] = data
cb(undefined)
},
}
return {
client: {} as PiSshSession['client'],
sftp: sftp as unknown as PiSshSession['sftp'],
close: vi.fn(),
}
}
function getTool(repoPath: string, files: Record<string, string>, name: string) {
const tools = buildSshToolSpecs(createSession(files), repoPath)
const tool = tools.find((t) => t.name === name)
if (!tool) throw new Error(`tool not found: ${name}`)
return tool
}
describe('buildSshToolSpecs', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('reads a file resolved against repoPath', async () => {
const read = getTool('/repo', { '/repo/a.txt': 'contents' }, 'read')
expect(await read.execute({ path: 'a.txt' })).toEqual({ text: 'contents', isError: false })
})
it('writes a file resolved against repoPath', async () => {
const files: Record<string, string> = {}
const write = getTool('/repo', files, 'write')
const result = await write.execute({ path: 'b.txt', content: 'hello' })
expect(result.isError).toBe(false)
expect(files['/repo/b.txt']).toBe('hello')
})
it('edits the first occurrence of old_string', async () => {
const files = { '/repo/c.txt': 'foo bar foo' }
const edit = getTool('/repo', files, 'edit')
const result = await edit.execute({ path: 'c.txt', old_string: 'foo', new_string: 'baz' })
expect(result.isError).toBe(false)
expect(files['/repo/c.txt']).toBe('baz bar foo')
})
it('reports an error when old_string is absent', async () => {
const edit = getTool('/repo', { '/repo/c.txt': 'nothing here' }, 'edit')
const result = await edit.execute({ path: 'c.txt', old_string: 'missing', new_string: 'x' })
expect(result.isError).toBe(true)
})
it('runs bash scoped to the repo directory', async () => {
mockExecuteSSHCommand.mockResolvedValue({ stdout: 'out', stderr: '', exitCode: 0 })
const bash = getTool('/repo', {}, 'bash')
const result = await bash.execute({ command: 'ls -la' })
expect(result).toEqual({ text: 'out', isError: false })
expect(mockExecuteSSHCommand).toHaveBeenCalledWith(expect.anything(), "cd '/repo' && ls -la")
})
it('marks a non-zero bash exit as an error', async () => {
mockExecuteSSHCommand.mockResolvedValue({ stdout: '', stderr: 'boom', exitCode: 2 })
const bash = getTool('/repo', {}, 'bash')
const result = await bash.execute({ command: 'false' })
expect(result.isError).toBe(true)
})
it('rejects path traversal and paths outside the repo', async () => {
const read = getTool('/repo', {}, 'read')
expect((await read.execute({ path: '../etc/passwd' })).isError).toBe(true)
expect((await read.execute({ path: '/outside/repo' })).isError).toBe(true)
})
})
+229
View File
@@ -0,0 +1,229 @@
/**
* SSH-backed file and shell tools for local-mode Pi runs. A single `ssh2`
* connection is opened per run and reused across every tool call: `read`/`write`/
* `edit` go over SFTP, `bash` over a shell exec scoped to the repo directory.
* All paths are sanitized and confined to the configured `repoPath` (S4).
*/
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import type { Client, SFTPWrapper } from 'ssh2'
import {
createSSHConnection,
escapeShellArg,
executeSSHCommand,
sanitizeCommand,
sanitizePath,
} from '@/app/api/tools/ssh/utils'
import type { PiSshConnection, PiToolResult, PiToolSpec } from '@/executor/handlers/pi/backend'
const logger = createLogger('PiSshTools')
/** An open SSH session reused for the duration of a local Pi run. */
export interface PiSshSession {
client: Client
sftp: SFTPWrapper
close: () => void
}
/** Opens one SSH connection plus an SFTP channel for the run. */
export async function openSshSession(connection: PiSshConnection): Promise<PiSshSession> {
const client = await createSSHConnection({
host: connection.host,
port: connection.port,
username: connection.username,
password: connection.password ?? null,
privateKey: connection.privateKey ?? null,
passphrase: connection.passphrase ?? null,
})
const close = () => {
try {
client.end()
} catch (error) {
logger.warn('Failed to close SSH session', { error: getErrorMessage(error) })
}
}
// The TCP/SSH connection is already open here, so close it if opening the SFTP
// channel fails (e.g. the server has the SFTP subsystem disabled) — otherwise
// the connection is orphaned when this function throws.
try {
const sftp = await new Promise<SFTPWrapper>((resolve, reject) => {
client.sftp((err, channel) => (err ? reject(err) : resolve(channel)))
})
return { client, sftp, close }
} catch (error) {
close()
throw error
}
}
function readRemoteFile(sftp: SFTPWrapper, path: string): Promise<string> {
return new Promise((resolve, reject) => {
sftp.readFile(path, (err, data) => (err ? reject(err) : resolve(data.toString('utf-8'))))
})
}
function writeRemoteFile(sftp: SFTPWrapper, path: string, content: string): Promise<void> {
return new Promise((resolve, reject) => {
sftp.writeFile(path, content, (err) => (err ? reject(err) : resolve()))
})
}
/** Resolves a tool-supplied path against `repoPath`, rejecting traversal/escape. */
function resolveRepoPath(repoPath: string, candidate: string): string {
const clean = sanitizePath(candidate)
const root = repoPath.replace(/\/+$/, '')
if (clean.startsWith('/')) {
if (clean !== root && !clean.startsWith(`${root}/`)) {
throw new Error(`Path is outside the repository: ${candidate}`)
}
return clean
}
return `${root}/${clean}`
}
function asString(value: unknown): string {
return typeof value === 'string' ? value : ''
}
async function guard(run: () => Promise<PiToolResult>): Promise<PiToolResult> {
try {
return await run()
} catch (error) {
return { text: getErrorMessage(error, 'SSH tool failed'), isError: true }
}
}
/**
* Best-effort working-tree snapshot of the repo over the run's SSH session, for
* the block's `changedFiles`/`diff` outputs — Local mode edits in place rather
* than opening a PR. `changedFiles` covers both tracked modifications and untracked
* (newly created) files so files the agent created are reported; `diff` reflects
* tracked changes against HEAD. Returns empty on any failure (not a git repo, git
* missing, non-zero exit).
*/
export async function captureRepoChanges(
session: PiSshSession,
repoPath: string,
maxDiffBytes: number
): Promise<{ changedFiles: string[]; diff: string }> {
const scoped = `cd '${escapeShellArg(repoPath)}'`
try {
const tracked = await executeSSHCommand(
session.client,
`${scoped} && git diff --name-only HEAD`
)
const untracked = await executeSSHCommand(
session.client,
`${scoped} && git ls-files --others --exclude-standard`
)
const fileSet = new Set<string>()
for (const result of [tracked, untracked]) {
if (result.exitCode !== 0) continue
for (const line of result.stdout.split('\n')) {
const file = line.trim()
if (file) fileSet.add(file)
}
}
const raw = await executeSSHCommand(session.client, `${scoped} && git diff HEAD`)
const out = raw.exitCode === 0 ? raw.stdout : ''
const diff = out.length > maxDiffBytes ? `${out.slice(0, maxDiffBytes)}\n[diff truncated]` : out
return { changedFiles: [...fileSet], diff }
} catch {
return { changedFiles: [], diff: '' }
}
}
/** Builds the SSH-backed `read`/`write`/`edit`/`bash` tools scoped to `repoPath`. */
export function buildSshToolSpecs(session: PiSshSession, repoPath: string): PiToolSpec[] {
const { client, sftp } = session
return [
{
name: 'read',
description: 'Read the full contents of a file in the repository.',
parameters: {
type: 'object',
properties: { path: { type: 'string', description: 'File path within the repository' } },
required: ['path'],
},
execute: (args) =>
guard(async () => {
const path = asString(args.path)
if (!path) return { text: 'path is required', isError: true }
const content = await readRemoteFile(sftp, resolveRepoPath(repoPath, path))
return { text: content, isError: false }
}),
},
{
name: 'write',
description: 'Write (create or overwrite) a file in the repository.',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: 'File path within the repository' },
content: { type: 'string', description: 'Full file contents to write' },
},
required: ['path', 'content'],
},
execute: (args) =>
guard(async () => {
const path = asString(args.path)
if (!path) return { text: 'path is required', isError: true }
const resolved = resolveRepoPath(repoPath, path)
await writeRemoteFile(sftp, resolved, asString(args.content))
return { text: `Wrote ${resolved}`, isError: false }
}),
},
{
name: 'edit',
description: 'Replace the first occurrence of old_string with new_string in a file.',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: 'File path within the repository' },
old_string: { type: 'string', description: 'Exact text to replace' },
new_string: { type: 'string', description: 'Replacement text' },
},
required: ['path', 'old_string', 'new_string'],
},
execute: (args) =>
guard(async () => {
const path = asString(args.path)
if (!path) return { text: 'path is required', isError: true }
const oldString = asString(args.old_string)
const resolved = resolveRepoPath(repoPath, path)
const current = await readRemoteFile(sftp, resolved)
if (!current.includes(oldString)) {
return { text: `old_string not found in ${resolved}`, isError: true }
}
const updated = current.replace(oldString, asString(args.new_string))
await writeRemoteFile(sftp, resolved, updated)
return { text: `Edited ${resolved}`, isError: false }
}),
},
{
name: 'bash',
description: 'Run a shell command in the repository directory and return its output.',
parameters: {
type: 'object',
properties: { command: { type: 'string', description: 'Shell command to run' } },
required: ['command'],
},
execute: (args) =>
guard(async () => {
const command = asString(args.command)
if (!command) return { text: 'command is required', isError: true }
const scoped = `cd '${escapeShellArg(repoPath)}' && ${sanitizeCommand(command)}`
const result = await executeSSHCommand(client, scoped)
const text = [result.stdout, result.stderr].filter(Boolean).join('\n')
return {
text: text || `Exited with code ${result.exitCode}`,
isError: result.exitCode !== 0,
}
}),
},
]
}
+51
View File
@@ -0,0 +1,51 @@
/**
* Handler Registry
*
* Central registry for all block handlers.
* Creates handlers for real user blocks (not infrastructure like sentinels).
*/
import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler'
import { ApiBlockHandler } from '@/executor/handlers/api/api-handler'
import { ConditionBlockHandler } from '@/executor/handlers/condition/condition-handler'
import { CredentialBlockHandler } from '@/executor/handlers/credential/credential-handler'
import { EvaluatorBlockHandler } from '@/executor/handlers/evaluator/evaluator-handler'
import { FunctionBlockHandler } from '@/executor/handlers/function/function-handler'
import { GenericBlockHandler } from '@/executor/handlers/generic/generic-handler'
import { HumanInTheLoopBlockHandler } from '@/executor/handlers/human-in-the-loop/human-in-the-loop-handler'
import { MothershipBlockHandler } from '@/executor/handlers/mothership/mothership-handler'
import { PiBlockHandler } from '@/executor/handlers/pi/pi-handler'
import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler'
import { RouterBlockHandler } from '@/executor/handlers/router/router-handler'
import { TriggerBlockHandler } from '@/executor/handlers/trigger/trigger-handler'
import { VariablesBlockHandler } from '@/executor/handlers/variables/variables-handler'
import { WaitBlockHandler } from '@/executor/handlers/wait/wait-handler'
import { WorkflowBlockHandler } from '@/executor/handlers/workflow/workflow-handler'
import type { BlockHandler } from '@/executor/types'
/**
* Create all block handlers
*
* Note: Sentinels are NOT included here - they're infrastructure handled
* by NodeExecutionOrchestrator, not user blocks.
*/
export function createBlockHandlers(): BlockHandler[] {
return [
new TriggerBlockHandler(),
new FunctionBlockHandler(),
new ApiBlockHandler(),
new ConditionBlockHandler(),
new RouterBlockHandler(),
new ResponseBlockHandler(),
new HumanInTheLoopBlockHandler(),
new AgentBlockHandler(),
new MothershipBlockHandler(),
new PiBlockHandler(),
new VariablesBlockHandler(),
new WorkflowBlockHandler(),
new WaitBlockHandler(),
new EvaluatorBlockHandler(),
new CredentialBlockHandler(),
new GenericBlockHandler(),
]
}
@@ -0,0 +1,110 @@
import { createLogger } from '@sim/logger'
import { BlockType, HTTP } from '@/executor/constants'
import type { BlockHandler, ExecutionContext, NormalizedBlockOutput } from '@/executor/types'
import {
convertBuilderDataToJson,
convertBuilderDataToJsonString,
} from '@/executor/utils/builder-data'
import { parseObjectStrings } from '@/executor/utils/json'
import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('ResponseBlockHandler')
export class ResponseBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === BlockType.RESPONSE
}
async execute(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<NormalizedBlockOutput> {
logger.info(`Executing response block: ${block.id}`)
try {
const responseData = this.parseResponseData(inputs)
const statusCode = this.parseStatus(inputs.status)
const responseHeaders = this.parseHeaders(inputs.headers)
logger.info('Response prepared', {
status: statusCode,
dataKeys: Object.keys(responseData),
headerKeys: Object.keys(responseHeaders),
})
return {
data: responseData,
status: statusCode,
headers: responseHeaders,
}
} catch (error: any) {
logger.error('Response block execution failed:', error)
return {
data: {
error: 'Response block execution failed',
message: error.message || 'Unknown error',
},
status: HTTP.STATUS.SERVER_ERROR,
headers: { 'Content-Type': HTTP.CONTENT_TYPE.JSON },
}
}
}
private parseResponseData(inputs: Record<string, any>): any {
const dataMode = inputs.dataMode || 'structured'
if (dataMode === 'json' && inputs.data) {
if (typeof inputs.data === 'string') {
try {
return JSON.parse(inputs.data)
} catch (error) {
logger.warn('Failed to parse JSON data, returning as string:', error)
return inputs.data
}
} else if (typeof inputs.data === 'object' && inputs.data !== null) {
return inputs.data
}
return inputs.data
}
if (dataMode === 'structured' && inputs.builderData) {
const convertedData = convertBuilderDataToJson(inputs.builderData)
return parseObjectStrings(convertedData)
}
return inputs.data || {}
}
static convertBuilderDataToJsonString(builderData: any[]): string {
return convertBuilderDataToJsonString(builderData)
}
private parseStatus(status?: string): number {
if (!status) return HTTP.STATUS.OK
const parsed = Number(status)
if (Number.isNaN(parsed) || parsed < 100 || parsed > 599) {
return HTTP.STATUS.OK
}
return parsed
}
private parseHeaders(
headers: {
id: string
cells: { Key: string; Value: string }
}[]
): Record<string, string> {
const defaultHeaders = { 'Content-Type': HTTP.CONTENT_TYPE.JSON }
if (!headers) return defaultHeaders
const headerObj = headers.reduce((acc: Record<string, string>, header) => {
if (header?.cells?.Key && header?.cells?.Value) {
acc[header.cells.Key] = header.cells.Value
}
return acc
}, {})
return { ...defaultHeaders, ...headerObj }
}
}
@@ -0,0 +1,617 @@
import '@sim/testing/mocks/executor'
import { authOAuthUtilsMock, authOAuthUtilsMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock)
vi.mock('@/lib/credentials/access', () => ({
getCredentialActorContext: vi.fn().mockResolvedValue({
credential: {
id: 'test-vertex-credential',
type: 'oauth',
workspaceId: 'test-workspace',
accountId: 'test-vertex-credential-id',
},
member: { role: 'admin', status: 'active' },
hasWorkspaceAccess: true,
canWriteWorkspace: true,
isAdmin: true,
}),
}))
import { generateRouterPrompt, generateRouterV2Prompt } from '@/blocks/blocks/router'
import { BlockType } from '@/executor/constants'
import { RouterBlockHandler } from '@/executor/handlers/router/router-handler'
import type { ExecutionContext } from '@/executor/types'
import { getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
const mockGenerateRouterPrompt = generateRouterPrompt as Mock
const mockGenerateRouterV2Prompt = generateRouterV2Prompt as Mock
const mockGetProviderFromModel = getProviderFromModel as Mock
const mockFetch = global.fetch as unknown as Mock
describe('RouterBlockHandler', () => {
let handler: RouterBlockHandler
let mockBlock: SerializedBlock
let mockContext: ExecutionContext
let mockWorkflow: Partial<SerializedWorkflow>
let mockTargetBlock1: SerializedBlock
let mockTargetBlock2: SerializedBlock
beforeEach(() => {
mockTargetBlock1 = {
id: 'target-block-1',
metadata: { id: 'target', name: 'Option A', description: 'Choose A' },
position: { x: 100, y: 100 },
config: { tool: 'tool_a', params: { p: 'a' } },
inputs: {},
outputs: {},
enabled: true,
}
mockTargetBlock2 = {
id: 'target-block-2',
metadata: { id: 'target', name: 'Option B', description: 'Choose B' },
position: { x: 100, y: 150 },
config: { tool: 'tool_b', params: { p: 'b' } },
inputs: {},
outputs: {},
enabled: true,
}
mockBlock = {
id: 'router-block-1',
metadata: { id: BlockType.ROUTER, name: 'Test Router' },
position: { x: 50, y: 50 },
config: { tool: BlockType.ROUTER, params: {} },
inputs: { prompt: 'string', model: 'string' },
outputs: {},
enabled: true,
}
mockWorkflow = {
blocks: [mockBlock, mockTargetBlock1, mockTargetBlock2],
connections: [
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-then1' },
{ source: mockBlock.id, target: mockTargetBlock2.id, sourceHandle: 'condition-else1' },
],
}
handler = new RouterBlockHandler({})
mockContext = {
workflowId: 'test-workflow-id',
userId: 'test-user',
blockStates: new Map(),
blockLogs: [],
metadata: { duration: 0 },
environmentVariables: {},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
completedLoops: new Set(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
workflow: mockWorkflow as SerializedWorkflow,
}
vi.clearAllMocks()
authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValue({
accountId: 'test-vertex-credential-id',
usedCredentialTable: false,
})
authOAuthUtilsMockFns.mockRefreshTokenIfNeeded.mockResolvedValue({
accessToken: 'mock-access-token',
refreshed: false,
})
mockGetProviderFromModel.mockReturnValue('openai')
mockGenerateRouterPrompt.mockReturnValue('Generated System Prompt')
mockFetch.mockImplementation(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: 'target-block-1',
model: 'mock-model',
tokens: { input: 100, output: 5, total: 105 },
cost: 0.003,
timing: { total: 300 },
}),
})
})
})
it('should handle router blocks', () => {
expect(handler.canHandle(mockBlock)).toBe(true)
const nonRouterBlock: SerializedBlock = { ...mockBlock, metadata: { id: 'other' } }
expect(handler.canHandle(nonRouterBlock)).toBe(false)
})
it('should execute router block correctly and select a path', async () => {
const inputs = {
prompt: 'Choose the best option.',
model: 'gpt-4o',
apiKey: 'test-api-key',
temperature: 0.1,
}
const expectedTargetBlocks = [
{
id: 'target-block-1',
type: 'target',
title: 'Option A',
description: 'Choose A',
subBlocks: {
p: 'a',
systemPrompt: '',
},
currentState: undefined,
},
{
id: 'target-block-2',
type: 'target',
title: 'Option B',
description: 'Choose B',
subBlocks: {
p: 'b',
systemPrompt: '',
},
currentState: undefined,
},
]
const result = await handler.execute(mockContext, mockBlock, inputs)
expect(mockGenerateRouterPrompt).toHaveBeenCalledWith(inputs.prompt, expectedTargetBlocks)
expect(mockGetProviderFromModel).toHaveBeenCalledWith('gpt-4o')
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
method: 'POST',
headers: expect.any(Object),
body: expect.any(String),
})
)
const fetchCallArgs = mockFetch.mock.calls[0]
const requestBody = JSON.parse(fetchCallArgs[1].body)
expect(requestBody).toMatchObject({
provider: 'openai',
model: 'gpt-4o',
systemPrompt: 'Generated System Prompt',
context: JSON.stringify([{ role: 'user', content: 'Choose the best option.' }]),
temperature: 0.1,
})
expect(result).toEqual({
prompt: 'Choose the best option.',
model: 'mock-model',
tokens: { input: 100, output: 5, total: 105 },
cost: {
input: 0,
output: 0,
total: 0,
},
selectedPath: {
blockId: 'target-block-1',
blockType: 'target',
blockTitle: 'Option A',
},
selectedRoute: 'target-block-1',
})
})
it('should throw error if target block is missing', async () => {
const inputs = { prompt: 'Test' }
mockContext.workflow!.blocks = [mockBlock, mockTargetBlock2]
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'Target block target-block-1 not found'
)
expect(mockFetch).not.toHaveBeenCalled()
})
it('should throw error if LLM response is not a valid target block ID', async () => {
const inputs = { prompt: 'Test', apiKey: 'test-api-key' }
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: 'invalid-block-id',
model: 'mock-model',
tokens: {},
cost: 0,
timing: {},
}),
})
})
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'Invalid routing decision: invalid-block-id'
)
})
it('should use default model and temperature if not provided', async () => {
const inputs = { prompt: 'Choose.', apiKey: 'test-api-key' }
await handler.execute(mockContext, mockBlock, inputs)
expect(mockGetProviderFromModel).toHaveBeenCalledWith('claude-sonnet-5')
const fetchCallArgs = mockFetch.mock.calls[0]
const requestBody = JSON.parse(fetchCallArgs[1].body)
expect(requestBody).toMatchObject({
model: 'claude-sonnet-5',
temperature: 0.1,
})
})
it('should handle server error responses', async () => {
const inputs = { prompt: 'Test error handling.', apiKey: 'test-api-key' }
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: false,
status: 500,
json: () => Promise.resolve({ error: 'Server error' }),
})
})
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow('Server error')
})
it('should handle Azure OpenAI models with endpoint and API version', async () => {
const inputs = {
prompt: 'Choose the best option.',
model: 'gpt-4o',
apiKey: 'test-azure-key',
azureEndpoint: 'https://test.openai.azure.com',
azureApiVersion: '2024-07-01-preview',
}
mockGetProviderFromModel.mockReturnValue('azure-openai')
await handler.execute(mockContext, mockBlock, inputs)
const fetchCallArgs = mockFetch.mock.calls[0]
const requestBody = JSON.parse(fetchCallArgs[1].body)
expect(requestBody).toMatchObject({
provider: 'azure-openai',
model: 'gpt-4o',
apiKey: 'test-azure-key',
azureEndpoint: 'https://test.openai.azure.com',
azureApiVersion: '2024-07-01-preview',
})
})
it('should handle Vertex AI models with OAuth credential', async () => {
const inputs = {
prompt: 'Choose the best option.',
model: 'gemini-2.0-flash-exp',
vertexCredential: 'test-vertex-credential-id',
vertexProject: 'test-gcp-project',
vertexLocation: 'us-central1',
}
mockGetProviderFromModel.mockReturnValue('vertex')
const mockDb = await import('@sim/db')
const mockAccount = {
id: 'test-vertex-credential-id',
accessToken: 'mock-access-token',
refreshToken: 'mock-refresh-token',
expiresAt: new Date(Date.now() + 3600000),
}
;(mockDb.db.query as any).account = { findFirst: vi.fn() }
vi.spyOn(mockDb.db.query.account, 'findFirst').mockResolvedValue(mockAccount as any)
await handler.execute(mockContext, mockBlock, inputs)
const fetchCallArgs = mockFetch.mock.calls[0]
const requestBody = JSON.parse(fetchCallArgs[1].body)
expect(requestBody).toMatchObject({
provider: 'vertex',
model: 'gemini-2.0-flash-exp',
vertexProject: 'test-gcp-project',
vertexLocation: 'us-central1',
})
expect(requestBody.apiKey).toBe('mock-access-token')
})
})
describe('RouterBlockHandler V2', () => {
let handler: RouterBlockHandler
let mockRouterV2Block: SerializedBlock
let mockContext: ExecutionContext
let mockWorkflow: Partial<SerializedWorkflow>
let mockTargetBlock1: SerializedBlock
let mockTargetBlock2: SerializedBlock
beforeEach(() => {
mockTargetBlock1 = {
id: 'target-block-1',
metadata: { id: 'agent', name: 'Support Agent' },
position: { x: 100, y: 100 },
config: { tool: 'agent', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
mockTargetBlock2 = {
id: 'target-block-2',
metadata: { id: 'agent', name: 'Sales Agent' },
position: { x: 100, y: 150 },
config: { tool: 'agent', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
mockRouterV2Block = {
id: 'router-v2-block-1',
metadata: { id: BlockType.ROUTER_V2, name: 'Test Router V2' },
position: { x: 50, y: 50 },
config: { tool: BlockType.ROUTER_V2, params: {} },
inputs: {},
outputs: {},
enabled: true,
}
mockWorkflow = {
blocks: [mockRouterV2Block, mockTargetBlock1, mockTargetBlock2],
connections: [
{
source: mockRouterV2Block.id,
target: mockTargetBlock1.id,
sourceHandle: 'router-route-support',
},
{
source: mockRouterV2Block.id,
target: mockTargetBlock2.id,
sourceHandle: 'router-route-sales',
},
],
}
handler = new RouterBlockHandler({})
mockContext = {
workflowId: 'test-workflow-id',
userId: 'test-user',
blockStates: new Map(),
blockLogs: [],
metadata: { duration: 0 },
environmentVariables: {},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
completedLoops: new Set(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
workflow: mockWorkflow as SerializedWorkflow,
}
vi.clearAllMocks()
authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValue({
accountId: 'test-vertex-credential-id',
usedCredentialTable: false,
})
authOAuthUtilsMockFns.mockRefreshTokenIfNeeded.mockResolvedValue({
accessToken: 'mock-access-token',
refreshed: false,
})
mockGetProviderFromModel.mockReturnValue('openai')
mockGenerateRouterV2Prompt.mockReturnValue('Generated V2 System Prompt')
})
it('should handle router_v2 blocks', () => {
expect(handler.canHandle(mockRouterV2Block)).toBe(true)
})
it('should execute router V2 and return reasoning', async () => {
const inputs = {
context: 'I need help with a billing issue',
model: 'gpt-4o',
apiKey: 'test-api-key',
routes: JSON.stringify([
{ id: 'route-support', title: 'Support', value: 'Customer support inquiries' },
{ id: 'route-sales', title: 'Sales', value: 'Sales and pricing questions' },
]),
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: JSON.stringify({
route: 'route-support',
reasoning: 'The user mentioned a billing issue which is a customer support matter.',
}),
model: 'gpt-4o',
tokens: { input: 150, output: 25, total: 175 },
}),
})
})
const result = await handler.execute(mockContext, mockRouterV2Block, inputs)
expect(result).toMatchObject({
context: 'I need help with a billing issue',
model: 'gpt-4o',
selectedRoute: 'route-support',
reasoning: 'The user mentioned a billing issue which is a customer support matter.',
selectedPath: {
blockId: 'target-block-1',
blockType: 'agent',
blockTitle: 'Support Agent',
},
})
})
it('should include responseFormat in provider request', async () => {
const inputs = {
context: 'Test context',
model: 'gpt-4o',
apiKey: 'test-api-key',
routes: JSON.stringify([{ id: 'route-1', title: 'Route 1', value: 'Description 1' }]),
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: JSON.stringify({ route: 'route-1', reasoning: 'Test reasoning' }),
model: 'gpt-4o',
tokens: { input: 100, output: 20, total: 120 },
}),
})
})
await handler.execute(mockContext, mockRouterV2Block, inputs)
const fetchCallArgs = mockFetch.mock.calls[0]
const requestBody = JSON.parse(fetchCallArgs[1].body)
expect(requestBody.responseFormat).toEqual({
name: 'router_response',
schema: {
type: 'object',
properties: {
route: {
type: 'string',
description: 'The selected route ID or NO_MATCH',
},
reasoning: {
type: 'string',
description: 'Brief explanation of why this route was chosen',
},
},
required: ['route', 'reasoning'],
additionalProperties: false,
},
strict: true,
})
})
it('should handle NO_MATCH response with reasoning', async () => {
const inputs = {
context: 'Random unrelated query',
model: 'gpt-4o',
apiKey: 'test-api-key',
routes: JSON.stringify([{ id: 'route-1', title: 'Route 1', value: 'Specific topic' }]),
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: JSON.stringify({
route: 'NO_MATCH',
reasoning: 'The query does not relate to any available route.',
}),
model: 'gpt-4o',
tokens: { input: 100, output: 20, total: 120 },
}),
})
})
await expect(handler.execute(mockContext, mockRouterV2Block, inputs)).rejects.toThrow(
'Router could not determine a matching route: The query does not relate to any available route.'
)
})
it('should throw error for invalid route ID in response', async () => {
const inputs = {
context: 'Test context',
model: 'gpt-4o',
apiKey: 'test-api-key',
routes: JSON.stringify([{ id: 'route-1', title: 'Route 1', value: 'Description' }]),
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: JSON.stringify({ route: 'invalid-route', reasoning: 'Some reasoning' }),
model: 'gpt-4o',
tokens: { input: 100, output: 20, total: 120 },
}),
})
})
await expect(handler.execute(mockContext, mockRouterV2Block, inputs)).rejects.toThrow(
/Router could not determine a valid route/
)
})
it('should handle routes passed as array instead of JSON string', async () => {
const inputs = {
context: 'Test context',
model: 'gpt-4o',
apiKey: 'test-api-key',
routes: [{ id: 'route-1', title: 'Route 1', value: 'Description' }],
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: JSON.stringify({ route: 'route-1', reasoning: 'Matched route 1' }),
model: 'gpt-4o',
tokens: { input: 100, output: 20, total: 120 },
}),
})
})
const result = await handler.execute(mockContext, mockRouterV2Block, inputs)
expect(result.selectedRoute).toBe('route-1')
expect(result.reasoning).toBe('Matched route 1')
})
it('should throw error when no routes are defined', async () => {
const inputs = {
context: 'Test context',
model: 'gpt-4o',
apiKey: 'test-api-key',
routes: '[]',
}
await expect(handler.execute(mockContext, mockRouterV2Block, inputs)).rejects.toThrow(
'No routes defined for router'
)
})
it('should handle fallback when JSON parsing fails', async () => {
const inputs = {
context: 'Test context',
model: 'gpt-4o',
apiKey: 'test-api-key',
routes: JSON.stringify([{ id: 'route-1', title: 'Route 1', value: 'Description' }]),
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: 'route-1',
model: 'gpt-4o',
tokens: { input: 100, output: 5, total: 105 },
}),
})
})
const result = await handler.execute(mockContext, mockRouterV2Block, inputs)
expect(result.selectedRoute).toBe('route-1')
expect(result.reasoning).toBe('')
})
})
@@ -0,0 +1,424 @@
import { createLogger } from '@sim/logger'
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { generateRouterPrompt, generateRouterV2Prompt } from '@/blocks/blocks/router'
import type { BlockOutput } from '@/blocks/types'
import { validateModelProvider } from '@/ee/access-control/utils/permission-check'
import {
BlockType,
DEFAULTS,
isAgentBlockType,
isRouterV2BlockType,
ROUTER,
} from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { buildAuthHeaders } from '@/executor/utils/http'
import { resolveVertexCredential } from '@/executor/utils/vertex-credential'
import { calculateCost, getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('RouterBlockHandler')
interface RouteDefinition {
id: string
title: string
value: string
}
/**
* Handler for Router blocks that dynamically select execution paths.
* Supports both legacy router (block-based) and router_v2 (port-based).
*/
export class RouterBlockHandler implements BlockHandler {
constructor(private pathTracker?: any) {}
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === BlockType.ROUTER || block.metadata?.id === BlockType.ROUTER_V2
}
async execute(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<BlockOutput> {
const isV2 = isRouterV2BlockType(block.metadata?.id)
if (isV2) {
return this.executeV2(ctx, block, inputs)
}
return this.executeLegacy(ctx, block, inputs)
}
/**
* Execute legacy router (block-based routing).
*/
private async executeLegacy(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<BlockOutput> {
const targetBlocks = this.getTargetBlocks(ctx, block)
const routerConfig = {
prompt: inputs.prompt,
model: inputs.model || ROUTER.DEFAULT_MODEL,
apiKey: inputs.apiKey,
vertexProject: inputs.vertexProject,
vertexLocation: inputs.vertexLocation,
vertexCredential: inputs.vertexCredential,
bedrockAccessKeyId: inputs.bedrockAccessKeyId,
bedrockSecretKey: inputs.bedrockSecretKey,
bedrockRegion: inputs.bedrockRegion,
}
await validateModelProvider(ctx.userId, ctx.workspaceId, routerConfig.model, ctx)
const providerId = getProviderFromModel(routerConfig.model)
try {
const url = new URL('/api/providers', getInternalApiBaseUrl())
if (ctx.userId) url.searchParams.set('userId', ctx.userId)
const messages = [{ role: 'user', content: routerConfig.prompt }]
const systemPrompt = generateRouterPrompt(routerConfig.prompt, targetBlocks)
let finalApiKey: string | undefined = routerConfig.apiKey
if (providerId === 'vertex' && routerConfig.vertexCredential) {
finalApiKey = await resolveVertexCredential(
routerConfig.vertexCredential,
ctx.userId,
'vertex-router'
)
}
const providerRequest: Record<string, any> = {
provider: providerId,
model: routerConfig.model,
systemPrompt: systemPrompt,
context: JSON.stringify(messages),
temperature: ROUTER.INFERENCE_TEMPERATURE,
apiKey: finalApiKey,
azureEndpoint: inputs.azureEndpoint,
azureApiVersion: inputs.azureApiVersion,
vertexProject: routerConfig.vertexProject,
vertexLocation: routerConfig.vertexLocation,
bedrockAccessKeyId: routerConfig.bedrockAccessKeyId,
bedrockSecretKey: routerConfig.bedrockSecretKey,
bedrockRegion: routerConfig.bedrockRegion,
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
}
const response = await fetch(url.toString(), {
method: 'POST',
headers: await buildAuthHeaders(ctx.userId),
body: JSON.stringify(providerRequest),
})
if (!response.ok) {
let errorMessage = `Provider API request failed with status ${response.status}`
try {
const errorData = await response.json()
if (errorData.error) {
errorMessage = errorData.error
}
} catch (_e) {}
throw new Error(errorMessage)
}
const result = await response.json()
const chosenBlockId = result.content.trim().toLowerCase()
const chosenBlock = targetBlocks?.find((b) => b.id === chosenBlockId)
if (!chosenBlock) {
logger.error(
`Invalid routing decision. Response content: "${result.content}", available blocks:`,
targetBlocks?.map((b) => ({ id: b.id, title: b.title })) || []
)
throw new Error(`Invalid routing decision: ${chosenBlockId}`)
}
const tokens = result.tokens || {
input: DEFAULTS.TOKENS.PROMPT,
output: DEFAULTS.TOKENS.COMPLETION,
total: DEFAULTS.TOKENS.TOTAL,
}
const cost = calculateCost(
result.model,
tokens.input || DEFAULTS.TOKENS.PROMPT,
tokens.output || DEFAULTS.TOKENS.COMPLETION,
false
)
return {
prompt: inputs.prompt,
model: result.model,
tokens: {
input: tokens.input || DEFAULTS.TOKENS.PROMPT,
output: tokens.output || DEFAULTS.TOKENS.COMPLETION,
total: tokens.total || DEFAULTS.TOKENS.TOTAL,
},
cost: {
input: cost.input,
output: cost.output,
total: cost.total,
},
selectedPath: {
blockId: chosenBlock.id,
blockType: chosenBlock.type || DEFAULTS.BLOCK_TYPE,
blockTitle: chosenBlock.title || DEFAULTS.BLOCK_TITLE,
},
selectedRoute: String(chosenBlock.id),
} as BlockOutput
} catch (error) {
logger.error('Router execution failed:', error)
throw error
}
}
/**
* Execute router v2 (port-based routing).
* Uses route definitions with descriptions instead of downstream block names.
*/
private async executeV2(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<BlockOutput> {
const routes = this.parseRoutes(inputs.routes)
if (routes.length === 0) {
throw new Error('No routes defined for router')
}
const routerConfig = {
context: inputs.context,
model: inputs.model || ROUTER.DEFAULT_MODEL,
apiKey: inputs.apiKey,
vertexProject: inputs.vertexProject,
vertexLocation: inputs.vertexLocation,
vertexCredential: inputs.vertexCredential,
bedrockAccessKeyId: inputs.bedrockAccessKeyId,
bedrockSecretKey: inputs.bedrockSecretKey,
bedrockRegion: inputs.bedrockRegion,
}
await validateModelProvider(ctx.userId, ctx.workspaceId, routerConfig.model, ctx)
const providerId = getProviderFromModel(routerConfig.model)
try {
const url = new URL('/api/providers', getInternalApiBaseUrl())
if (ctx.userId) url.searchParams.set('userId', ctx.userId)
const messages = [{ role: 'user', content: routerConfig.context }]
const systemPrompt = generateRouterV2Prompt(routerConfig.context, routes)
let finalApiKey: string | undefined = routerConfig.apiKey
if (providerId === 'vertex' && routerConfig.vertexCredential) {
finalApiKey = await resolveVertexCredential(
routerConfig.vertexCredential,
ctx.userId,
'vertex-router'
)
}
const providerRequest: Record<string, any> = {
provider: providerId,
model: routerConfig.model,
systemPrompt: systemPrompt,
context: JSON.stringify(messages),
temperature: ROUTER.INFERENCE_TEMPERATURE,
apiKey: finalApiKey,
azureEndpoint: inputs.azureEndpoint,
azureApiVersion: inputs.azureApiVersion,
vertexProject: routerConfig.vertexProject,
vertexLocation: routerConfig.vertexLocation,
bedrockAccessKeyId: routerConfig.bedrockAccessKeyId,
bedrockSecretKey: routerConfig.bedrockSecretKey,
bedrockRegion: routerConfig.bedrockRegion,
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
responseFormat: {
name: 'router_response',
schema: {
type: 'object',
properties: {
route: {
type: 'string',
description: 'The selected route ID or NO_MATCH',
},
reasoning: {
type: 'string',
description: 'Brief explanation of why this route was chosen',
},
},
required: ['route', 'reasoning'],
additionalProperties: false,
},
strict: true,
},
}
const response = await fetch(url.toString(), {
method: 'POST',
headers: await buildAuthHeaders(ctx.userId),
body: JSON.stringify(providerRequest),
})
if (!response.ok) {
let errorMessage = `Provider API request failed with status ${response.status}`
try {
const errorData = await response.json()
if (errorData.error) {
errorMessage = errorData.error
}
} catch (_e) {}
throw new Error(errorMessage)
}
const result = await response.json()
let chosenRouteId: string
let reasoning = ''
try {
const parsedResponse = JSON.parse(result.content)
chosenRouteId = parsedResponse.route?.trim() || ''
reasoning = parsedResponse.reasoning || ''
} catch (_parseError) {
logger.error('Router response was not valid JSON despite responseFormat', {
content: result.content,
})
chosenRouteId = result.content.trim()
}
if (chosenRouteId === 'NO_MATCH' || chosenRouteId.toUpperCase() === 'NO_MATCH') {
logger.info('Router determined no route matches the context, routing to error path')
throw new Error(
reasoning
? `Router could not determine a matching route: ${reasoning}`
: 'Router could not determine a matching route for the given context'
)
}
const chosenRoute = routes.find((r) => r.id === chosenRouteId)
if (!chosenRoute) {
const availableRoutes = routes.map((r) => ({ id: r.id, title: r.title }))
logger.error(
`Invalid routing decision. Response content: "${result.content}". Available routes:`,
availableRoutes
)
throw new Error(
`Router could not determine a valid route. LLM response: "${result.content}". Available route IDs: ${routes.map((r) => r.id).join(', ')}`
)
}
const connection = ctx.workflow?.connections.find(
(conn) => conn.source === block.id && conn.sourceHandle === `router-${chosenRoute.id}`
)
const targetBlock = connection
? ctx.workflow?.blocks.find((b) => b.id === connection.target)
: null
const tokens = result.tokens || {
input: DEFAULTS.TOKENS.PROMPT,
output: DEFAULTS.TOKENS.COMPLETION,
total: DEFAULTS.TOKENS.TOTAL,
}
const cost = calculateCost(
result.model,
tokens.input || DEFAULTS.TOKENS.PROMPT,
tokens.output || DEFAULTS.TOKENS.COMPLETION,
false
)
return {
context: inputs.context,
model: result.model,
tokens: {
input: tokens.input || DEFAULTS.TOKENS.PROMPT,
output: tokens.output || DEFAULTS.TOKENS.COMPLETION,
total: tokens.total || DEFAULTS.TOKENS.TOTAL,
},
cost: {
input: cost.input,
output: cost.output,
total: cost.total,
},
selectedRoute: chosenRoute.id,
reasoning,
selectedPath: targetBlock
? {
blockId: targetBlock.id,
blockType: targetBlock.metadata?.id || DEFAULTS.BLOCK_TYPE,
blockTitle: targetBlock.metadata?.name || DEFAULTS.BLOCK_TITLE,
}
: {
blockId: '',
blockType: DEFAULTS.BLOCK_TYPE,
blockTitle: chosenRoute.title,
},
} as BlockOutput
} catch (error) {
logger.error('Router V2 execution failed:', error)
throw error
}
}
/**
* Parse routes from input (can be JSON string or array)
*/
private parseRoutes(input: any): RouteDefinition[] {
try {
if (typeof input === 'string') {
return JSON.parse(input)
}
if (Array.isArray(input)) {
return input
}
return []
} catch (error) {
logger.error('Failed to parse routes:', { input, error })
return []
}
}
private getTargetBlocks(ctx: ExecutionContext, block: SerializedBlock) {
return ctx.workflow?.connections
.filter((conn) => conn.source === block.id)
.map((conn) => {
const targetBlock = ctx.workflow?.blocks.find((b) => b.id === conn.target)
if (!targetBlock) {
throw new Error(`Target block ${conn.target} not found`)
}
let systemPrompt = ''
if (isAgentBlockType(targetBlock.metadata?.id)) {
const paramsPrompt = targetBlock.config?.params?.systemPrompt
const inputsPrompt = targetBlock.inputs?.systemPrompt
systemPrompt =
(typeof paramsPrompt === 'string' ? paramsPrompt : '') ||
(typeof inputsPrompt === 'string' ? inputsPrompt : '') ||
''
}
return {
id: targetBlock.id,
type: targetBlock.metadata?.id,
title: targetBlock.metadata?.name,
description: targetBlock.metadata?.description,
subBlocks: {
...targetBlock.config.params,
systemPrompt: systemPrompt,
},
currentState: ctx.blockStates.get(targetBlock.id)?.output,
}
})
}
}
@@ -0,0 +1,107 @@
import { createLogger } from '@sim/logger'
import type { BlockOutput } from '@/blocks/types'
import { REFERENCE } from '@/executor/constants'
const logger = createLogger('SharedResponseFormat')
/**
* Parse a raw responseFormat value (string or object) into a usable schema.
*
* Handles:
* - Empty / falsy → undefined
* - Already an object → wraps bare schemas with `{ name, schema, strict }`
* - JSON string → parsed, then same wrapping logic
* - Unresolved block references (`<block.field>`) → undefined
*/
export function parseResponseFormat(responseFormat?: string | object): any {
if (!responseFormat || responseFormat === '') return undefined
if (typeof responseFormat === 'object' && responseFormat !== null) {
const formatObj = responseFormat as any
if (!formatObj.schema && !formatObj.name) {
return { name: 'response_schema', schema: responseFormat, strict: true }
}
return responseFormat
}
if (typeof responseFormat === 'string') {
const trimmed = responseFormat.trim()
if (!trimmed) return undefined
if (trimmed.startsWith(REFERENCE.START) && trimmed.includes(REFERENCE.END)) {
return undefined
}
try {
const parsed = JSON.parse(trimmed)
if (parsed && typeof parsed === 'object' && !parsed.schema && !parsed.name) {
return { name: 'response_schema', schema: parsed, strict: true }
}
return parsed
} catch (error: any) {
logger.warn('Failed to parse response format as JSON', {
error: error.message,
preview: trimmed.slice(0, 100),
})
return undefined
}
}
return undefined
}
/**
* Validate and extract messages from a raw input value.
*
* Accepts a JSON string or an array. Each entry must have
* `role` (string) and `content` (string).
*/
export function resolveMessages(raw: unknown): Array<{ role: string; content: string }> {
if (!raw) {
throw new Error('Messages input is required')
}
let messages: unknown[]
if (typeof raw === 'string') {
try {
messages = JSON.parse(raw)
} catch {
throw new Error('Messages must be a valid JSON array')
}
} else if (Array.isArray(raw)) {
messages = raw
} else {
throw new Error('Messages must be an array of {role, content} objects')
}
return messages.map((msg: any, i: number) => {
if (!msg.role || typeof msg.content !== 'string') {
throw new Error(`Message at index ${i} must have "role" (string) and "content" (string)`)
}
return { role: String(msg.role), content: msg.content }
})
}
/**
* Try to parse the LLM response content as structured JSON and spread
* the fields into the block output. Falls back to returning raw content.
*/
export function processStructuredResponse(
result: { content?: string; model?: string; tokens?: any },
defaultModel: string
): BlockOutput {
const content = result.content ?? ''
try {
const parsed = JSON.parse(content.trim())
return {
...parsed,
model: result.model || defaultModel,
tokens: result.tokens || {},
}
} catch {
logger.warn('Failed to parse structured response, returning raw content')
return {
content,
model: result.model || defaultModel,
tokens: result.tokens || {},
}
}
}
@@ -0,0 +1,314 @@
import '@sim/testing/mocks/executor'
import { beforeEach, describe, expect, it } from 'vitest'
import { TriggerBlockHandler } from '@/executor/handlers/trigger/trigger-handler'
import type { ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
describe('TriggerBlockHandler', () => {
let handler: TriggerBlockHandler
let mockContext: ExecutionContext
beforeEach(() => {
handler = new TriggerBlockHandler()
mockContext = {
workflowId: 'test-workflow-id',
blockStates: new Map(),
blockLogs: [],
metadata: { duration: 0 },
environmentVariables: {},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
completedLoops: new Set(),
}
})
describe('canHandle', () => {
it.concurrent('should handle blocks with triggers category', () => {
const triggerBlock: SerializedBlock = {
id: 'trigger-1',
metadata: { id: 'schedule', name: 'Schedule Block', category: 'triggers' },
position: { x: 0, y: 0 },
config: { tool: 'schedule', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
expect(handler.canHandle(triggerBlock)).toBe(true)
})
it.concurrent('should handle blocks with triggerMode enabled', () => {
const gmailTriggerBlock: SerializedBlock = {
id: 'gmail-1',
metadata: { id: 'gmail', name: 'Gmail Block', category: 'tools' },
position: { x: 0, y: 0 },
config: { tool: 'gmail', params: { triggerMode: true } },
inputs: {},
outputs: {},
enabled: true,
}
expect(handler.canHandle(gmailTriggerBlock)).toBe(true)
})
it.concurrent('should not handle regular tool blocks without triggerMode', () => {
const toolBlock: SerializedBlock = {
id: 'tool-1',
metadata: { id: 'gmail', name: 'Gmail Block', category: 'tools' },
position: { x: 0, y: 0 },
config: { tool: 'gmail', params: { triggerMode: false } },
inputs: {},
outputs: {},
enabled: true,
}
expect(handler.canHandle(toolBlock)).toBe(false)
})
it.concurrent('should not handle blocks without trigger indicators', () => {
const regularBlock: SerializedBlock = {
id: 'regular-1',
metadata: { id: 'api', name: 'API Block', category: 'tools' },
position: { x: 0, y: 0 },
config: { tool: 'api', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
expect(handler.canHandle(regularBlock)).toBe(false)
})
it.concurrent('should handle generic webhook blocks', () => {
const webhookBlock: SerializedBlock = {
id: 'webhook-1',
metadata: { id: 'generic_webhook', name: 'Generic Webhook', category: 'triggers' },
position: { x: 0, y: 0 },
config: { tool: 'generic_webhook', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
expect(handler.canHandle(webhookBlock)).toBe(true)
})
})
describe('execute', () => {
it.concurrent('should return inputs directly when provided', async () => {
const triggerBlock: SerializedBlock = {
id: 'trigger-1',
metadata: { id: 'gmail', name: 'Gmail Trigger', category: 'triggers' },
position: { x: 0, y: 0 },
config: { tool: 'gmail', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
const triggerInputs = {
email: {
id: '12345',
subject: 'Test Email',
from: 'test@example.com',
body: 'Hello world',
},
timestamp: '2023-01-01T12:00:00Z',
}
const result = await handler.execute(mockContext, triggerBlock, triggerInputs)
expect(result).toEqual(triggerInputs)
})
it.concurrent('should return empty object when no inputs provided', async () => {
const triggerBlock: SerializedBlock = {
id: 'trigger-1',
metadata: { id: 'schedule', name: 'Schedule Trigger', category: 'triggers' },
position: { x: 0, y: 0 },
config: { tool: 'schedule', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
const result = await handler.execute(mockContext, triggerBlock, {})
expect(result).toEqual({})
})
it.concurrent('should handle webhook payload inputs', async () => {
const webhookBlock: SerializedBlock = {
id: 'webhook-1',
metadata: { id: 'generic_webhook', name: 'Generic Webhook', category: 'triggers' },
position: { x: 0, y: 0 },
config: { tool: 'generic_webhook', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
const webhookInputs = {
webhook: {
data: {
provider: 'github',
payload: { event: 'push', repo: 'test-repo' },
},
},
}
const result = await handler.execute(mockContext, webhookBlock, webhookInputs)
expect(result).toEqual(webhookInputs)
})
it.concurrent('should handle Outlook trigger inputs', async () => {
const outlookBlock: SerializedBlock = {
id: 'outlook-1',
metadata: { id: 'outlook', name: 'Outlook Block', category: 'tools' },
position: { x: 0, y: 0 },
config: { tool: 'outlook', params: { triggerMode: true } },
inputs: {},
outputs: {},
enabled: true,
}
const outlookInputs = {
email: {
id: 'outlook123',
subject: 'Meeting Invitation',
from: 'colleague@company.com',
bodyPreview: 'Join us for the quarterly review...',
},
timestamp: '2023-01-01T14:30:00Z',
}
const result = await handler.execute(mockContext, outlookBlock, outlookInputs)
expect(result).toEqual(outlookInputs)
})
it.concurrent('should handle schedule trigger with no inputs', async () => {
const scheduleBlock: SerializedBlock = {
id: 'schedule-1',
metadata: { id: 'schedule', name: 'Daily Schedule', category: 'triggers' },
position: { x: 0, y: 0 },
config: { tool: 'schedule', params: { scheduleType: 'daily' } },
inputs: {},
outputs: {},
enabled: true,
}
const result = await handler.execute(mockContext, scheduleBlock, {})
expect(result).toEqual({})
})
it.concurrent('should handle complex nested trigger data', async () => {
const triggerBlock: SerializedBlock = {
id: 'complex-trigger-1',
metadata: { id: 'webhook', name: 'Complex Webhook', category: 'triggers' },
position: { x: 0, y: 0 },
config: { tool: 'webhook', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
const complexInputs = {
webhook: {
data: {
provider: 'github',
payload: {
action: 'opened',
pull_request: {
id: 123,
title: 'Fix bug in authentication',
user: { login: 'developer' },
base: { ref: 'main' },
head: { ref: 'fix-auth-bug' },
},
},
headers: { 'x-github-event': 'pull_request' },
},
},
timestamp: '2023-01-01T15:45:00Z',
}
const result = await handler.execute(mockContext, triggerBlock, complexInputs)
expect(result).toEqual(complexInputs)
})
})
describe('integration scenarios', () => {
it.concurrent('should work with different trigger block types', () => {
const testCases = [
{
name: 'Gmail in trigger mode',
block: {
id: 'gmail-trigger',
metadata: { id: 'gmail', category: 'tools' },
config: { tool: 'gmail', params: { triggerMode: true } },
},
shouldHandle: true,
},
{
name: 'Generic webhook',
block: {
id: 'webhook-trigger',
metadata: { id: 'generic_webhook', category: 'triggers' },
config: { tool: 'generic_webhook', params: {} },
},
shouldHandle: true,
},
{
name: 'Schedule block',
block: {
id: 'schedule-trigger',
metadata: { id: 'schedule', category: 'triggers' },
config: { tool: 'schedule', params: {} },
},
shouldHandle: true,
},
{
name: 'Regular API block',
block: {
id: 'api-block',
metadata: { id: 'api', category: 'tools' },
config: { tool: 'api', params: {} },
},
shouldHandle: false,
},
{
name: 'Gmail in tool mode',
block: {
id: 'gmail-tool',
metadata: { id: 'gmail', category: 'tools' },
config: { tool: 'gmail', params: { triggerMode: false } },
},
shouldHandle: false,
},
]
testCases.forEach(({ name, block, shouldHandle }) => {
const serializedBlock: SerializedBlock = {
...block,
position: { x: 0, y: 0 },
inputs: {},
outputs: {},
enabled: true,
} as SerializedBlock
expect(
handler.canHandle(serializedBlock),
`${name} should ${shouldHandle ? '' : 'not '}be handled`
).toBe(shouldHandle)
})
})
})
})
@@ -0,0 +1,78 @@
import { createLogger } from '@sim/logger'
import { BlockType, isTriggerBehavior, isTriggerInternalKey } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('TriggerBlockHandler')
export class TriggerBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return isTriggerBehavior(block)
}
async execute(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<any> {
logger.info(`Executing trigger block: ${block.id} (Type: ${block.metadata?.id})`)
if (block.metadata?.id === BlockType.STARTER) {
return this.executeStarterBlock(ctx, block, inputs)
}
const existingState = ctx.blockStates.get(block.id)
if (existingState?.output) {
return existingState.output
}
const starterBlock = ctx.workflow?.blocks?.find((b) => b.metadata?.id === 'starter')
if (starterBlock) {
const starterState = ctx.blockStates.get(starterBlock.id)
if (starterState?.output && Object.keys(starterState.output).length > 0) {
const starterOutput = starterState.output
if (starterOutput.webhook?.data) {
const cleanOutput: Record<string, unknown> = {}
for (const [key, value] of Object.entries(starterOutput)) {
if (!isTriggerInternalKey(key)) {
cleanOutput[key] = value
}
}
return cleanOutput
}
return starterOutput
}
}
if (inputs && Object.keys(inputs).length > 0) {
return inputs
}
return {}
}
private executeStarterBlock(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): any {
logger.info(`Executing starter block: ${block.id}`, {
blockName: block.metadata?.name,
})
const existingState = ctx.blockStates.get(block.id)
if (existingState?.output && Object.keys(existingState.output).length > 0) {
return existingState.output
}
logger.warn('Starter block output not found in context, returning empty output', {
blockId: block.id,
})
return {
input: inputs.input || '',
}
}
}
@@ -0,0 +1,296 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache'
import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata'
import { BlockType } from '@/executor/constants'
import { VariablesBlockHandler } from '@/executor/handlers/variables/variables-handler'
import type { ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
const { mockUploadFile } = vi.hoisted(() => ({
mockUploadFile: vi.fn(),
}))
vi.mock('@/lib/uploads', () => ({
StorageService: {
uploadFile: mockUploadFile,
},
}))
function createContext(overrides: Partial<ExecutionContext> = {}): ExecutionContext {
return {
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
blockStates: new Map(),
blockLogs: [],
metadata: { duration: 0 },
environmentVariables: {},
workflowVariables: {
'var-1': { id: 'var-1', name: 'issues', type: 'array', value: [] },
},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
completedLoops: new Set(),
...overrides,
}
}
function createBlock(): SerializedBlock {
return {
id: 'variables-block-1',
metadata: { id: BlockType.VARIABLES, name: 'Variables' },
position: { x: 0, y: 0 },
config: { tool: BlockType.VARIABLES, params: {} },
inputs: {},
outputs: {},
enabled: true,
}
}
describe('VariablesBlockHandler', () => {
beforeEach(() => {
vi.clearAllMocks()
clearLargeValueCacheForTests()
mockUploadFile.mockImplementation(async ({ customKey }) => ({ key: customKey }))
})
it('preserves small assignments inline', async () => {
const handler = new VariablesBlockHandler()
const ctx = createContext()
const value = [{ key: 'SIM-1', summary: 'Small issue' }]
const output = await handler.execute(ctx, createBlock(), {
variables: [
{
variableId: 'var-1',
variableName: 'issues',
type: 'array',
value,
},
],
})
expect(ctx.workflowVariables?.['var-1'].value).toEqual(value)
expect(output).toEqual({ issues: value })
expect(mockUploadFile).not.toHaveBeenCalled()
})
it('includes unmatched assignments in block output without mutating workflow variables', async () => {
const handler = new VariablesBlockHandler()
const ctx = createContext()
const value = [{ key: 'SIM-1', summary: 'Transient issue' }]
const output = await handler.execute(ctx, createBlock(), {
variables: [
{
variableName: 'transientIssues',
type: 'array',
value,
},
],
})
expect(ctx.workflowVariables).not.toHaveProperty('transientIssues')
expect(output).toEqual({ transientIssues: value })
})
it('keeps special unmatched assignment names as own output fields', async () => {
const handler = new VariablesBlockHandler()
const ctx = createContext()
const value = { polluted: true }
const output = await handler.execute(ctx, createBlock(), {
variables: [
{
variableName: '__proto__',
type: 'object',
value,
},
],
})
expect(Object.hasOwn(output, '__proto__')).toBe(true)
expect(output.__proto__).toEqual(value)
expect(Object.getPrototypeOf(output)).toBe(Object.prototype)
})
it('does not treat inherited prototype keys as existing workflow variable IDs', async () => {
const handler = new VariablesBlockHandler()
const ctx = createContext()
const value = { safe: true }
const originalPrototype = Object.getPrototypeOf(ctx.workflowVariables)
const output = await handler.execute(ctx, createBlock(), {
variables: [
{
variableId: '__proto__',
variableName: 'prototypeAssignment',
type: 'object',
value,
},
],
})
expect(Object.getPrototypeOf(ctx.workflowVariables)).toBe(originalPrototype)
expect(ctx.workflowVariables).not.toHaveProperty('__proto__')
expect(output).toEqual({ prototypeAssignment: value })
})
it('stores oversized array assignments as durable manifests in variables and block output', async () => {
const handler = new VariablesBlockHandler()
const ctx = createContext()
const value = Array.from({ length: 120_000 }, (_, index) => ({
key: `SIM-${index}`,
summary: 'Issue summary that keeps each item small',
}))
const output = await handler.execute(ctx, createBlock(), {
variables: [
{
variableId: 'var-1',
variableName: 'issues',
type: 'array',
value,
},
],
})
const storedValue = ctx.workflowVariables?.['var-1'].value
expect(isLargeArrayManifest(storedValue)).toBe(true)
expect(output.issues).toBe(storedValue)
expect(storedValue).toMatchObject({
__simLargeArrayManifest: true,
kind: 'array',
totalCount: value.length,
})
expect(storedValue.chunkCount).toBeGreaterThan(1)
expect(mockUploadFile).toHaveBeenCalledWith(
expect.objectContaining({
context: 'execution',
preserveKey: true,
customKey: expect.stringContaining('/execution-1/large-value-'),
})
)
})
it('fails clearly when durable context is missing for oversized assignments', async () => {
const handler = new VariablesBlockHandler()
const ctx = createContext({ workspaceId: undefined, executionId: undefined })
const value = Array.from({ length: 120_000 }, (_, index) => ({
key: `SIM-${index}`,
summary: 'Issue summary that keeps each item small',
}))
await expect(
handler.execute(ctx, createBlock(), {
variables: [
{
variableId: 'var-1',
variableName: 'issues',
type: 'array',
value,
},
],
})
).rejects.toThrow(
'Cannot persist large execution value without workspace, workflow, and execution IDs'
)
expect(mockUploadFile).not.toHaveBeenCalled()
})
it('preserves whole large refs before scalar type coercion', async () => {
const handler = new VariablesBlockHandler()
const ref = {
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'object',
size: 12 * 1024 * 1024,
executionId: 'execution-1',
}
const ctx = createContext({
workflowVariables: {
stringVar: { id: 'stringVar', name: 'stringRef', type: 'string', value: '' },
plainVar: { id: 'plainVar', name: 'plainRef', type: 'plain', value: '' },
numberVar: { id: 'numberVar', name: 'numberRef', type: 'number', value: 0 },
booleanVar: { id: 'booleanVar', name: 'booleanRef', type: 'boolean', value: false },
},
})
await handler.execute(ctx, createBlock(), {
variables: [
{
variableId: 'stringVar',
variableName: 'stringRef',
type: 'string',
value: JSON.stringify(ref),
},
{
variableId: 'plainVar',
variableName: 'plainRef',
type: 'plain',
value: JSON.stringify(ref),
},
{
variableId: 'numberVar',
variableName: 'numberRef',
type: 'number',
value: JSON.stringify(ref),
},
{
variableId: 'booleanVar',
variableName: 'booleanRef',
type: 'boolean',
value: JSON.stringify(ref),
},
],
})
expect(ctx.workflowVariables?.stringVar.value).toEqual(ref)
expect(ctx.workflowVariables?.plainVar.value).toEqual(ref)
expect(ctx.workflowVariables?.numberVar.value).toEqual(ref)
expect(ctx.workflowVariables?.booleanVar.value).toEqual(ref)
})
it('preserves existing variable metadata when compacting reassignment', async () => {
const handler = new VariablesBlockHandler()
const ctx = createContext({
workflowVariables: {
'var-1': {
id: 'var-1',
name: 'issues',
type: 'array',
value: [],
isExisting: true,
},
},
})
const value = [{ key: 'SIM-1', summary: 'Updated' }]
await handler.execute(ctx, createBlock(), {
variables: [
{
variableId: 'var-1',
variableName: 'issues',
type: 'array',
value,
},
],
})
expect(ctx.workflowVariables?.['var-1']).toEqual({
id: 'var-1',
name: 'issues',
type: 'array',
value,
isExisting: true,
})
})
})
@@ -0,0 +1,207 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { parseLargeExecutionValue } from '@/lib/execution/payloads/large-execution-value'
import { compactWorkflowVariableValue } from '@/lib/execution/payloads/serializer'
import type { BlockOutput } from '@/blocks/types'
import { BlockType } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('VariablesBlockHandler')
function setOutputValue(output: Record<string, any>, key: string, value: any): void {
Object.defineProperty(output, key, {
value,
enumerable: true,
configurable: true,
writable: true,
})
}
function getWorkflowVariableEntry(
workflowVariables: Record<string, any>,
variableId: string | undefined
): [string, any] | undefined {
if (!variableId || !Object.hasOwn(workflowVariables, variableId)) {
return undefined
}
return [variableId, workflowVariables[variableId]]
}
function setWorkflowVariableEntry(
workflowVariables: Record<string, any>,
id: string,
value: any
): void {
Object.defineProperty(workflowVariables, id, {
value,
enumerable: true,
configurable: true,
writable: true,
})
}
export class VariablesBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
const canHandle = block.metadata?.id === BlockType.VARIABLES
return canHandle
}
async execute(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<BlockOutput> {
try {
if (!ctx.workflowVariables) {
ctx.workflowVariables = {}
}
const assignments = this.parseAssignments(inputs.variables)
const output: Record<string, any> = {}
for (const assignment of assignments) {
const existingEntry =
getWorkflowVariableEntry(ctx.workflowVariables, assignment.variableId) ??
Object.entries(ctx.workflowVariables).find(([_, v]) => v.name === assignment.variableName)
const value = await this.compactAssignmentValue(ctx, assignment.value)
if (existingEntry?.[1]) {
const [id, variable] = existingEntry
setWorkflowVariableEntry(ctx.workflowVariables, id, {
...variable,
value,
})
} else {
logger.warn(`Variable "${assignment.variableName}" not found in workflow variables`)
}
setOutputValue(output, assignment.variableName, value)
}
return output
} catch (error) {
const normalizedError = toError(error)
logger.error('Variables block execution failed:', normalizedError)
throw new Error(`Variables block execution failed: ${normalizedError.message}`)
}
}
private async compactAssignmentValue(ctx: ExecutionContext, value: any): Promise<any> {
return compactWorkflowVariableValue(value, {
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
executionId: ctx.executionId,
userId: ctx.userId,
largeValueExecutionIds: ctx.largeValueExecutionIds,
largeValueKeys: ctx.largeValueKeys,
allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
})
}
private parseAssignments(
assignmentsInput: any
): Array<{ variableId?: string; variableName: string; type: string; value: any }> {
const result: Array<{ variableId?: string; variableName: string; type: string; value: any }> =
[]
if (!assignmentsInput || !Array.isArray(assignmentsInput)) {
return result
}
for (const assignment of assignmentsInput) {
if (assignment?.variableName?.trim()) {
const name = assignment.variableName.trim()
const type = assignment.type || 'string'
const value = this.parseValueByType(assignment.value, type, name)
result.push({
variableId: assignment.variableId,
variableName: name,
type,
value,
})
}
}
return result
}
private parseValueByType(value: any, type: string, variableName?: string): any {
const refValue = parseLargeExecutionValue(value)
if (refValue !== undefined) {
return refValue
}
if (value === null || value === undefined || value === '') {
if (type === 'number') return 0
if (type === 'boolean') return false
if (type === 'array') return []
if (type === 'object') return {}
return ''
}
if (type === 'string' || type === 'plain') {
return typeof value === 'string' ? value : String(value)
}
if (type === 'number') {
if (typeof value === 'number') return value
if (typeof value === 'string') {
const trimmed = value.trim()
if (trimmed === '') return 0
const num = Number(trimmed)
if (Number.isNaN(num)) {
throw new Error(
`Invalid number value for variable "${variableName || 'unknown'}": "${value}". Expected a valid number.`
)
}
return num
}
throw new Error(
`Invalid type for variable "${variableName || 'unknown'}": expected number, got ${typeof value}`
)
}
if (type === 'boolean') {
if (typeof value === 'boolean') return value
if (typeof value === 'string') {
const lower = value.toLowerCase().trim()
if (lower === 'true') return true
if (lower === 'false') return false
throw new Error(
`Invalid boolean value for variable "${variableName || 'unknown'}": "${value}". Expected "true" or "false".`
)
}
return Boolean(value)
}
if (type === 'object' || type === 'array') {
// If value is already an object or array, accept it as-is
// The type hint is for UI purposes and string parsing, not runtime validation
if (typeof value === 'object' && value !== null) {
return value
}
// If it's a string, try to parse it as JSON
if (typeof value === 'string' && value.trim()) {
try {
const parsed = JSON.parse(value)
// Accept any valid JSON object or array
if (typeof parsed === 'object' && parsed !== null) {
return parsed
}
throw new Error(
`Invalid JSON for variable "${variableName || 'unknown'}": parsed value is not an object or array`
)
} catch (error: any) {
throw new Error(
`Invalid JSON for variable "${variableName || 'unknown'}": ${error.message}`
)
}
}
return type === 'array' ? [] : {}
}
return value
}
}
@@ -0,0 +1,331 @@
/**
* @vitest-environment node
*/
import '@sim/testing/mocks/executor'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { BlockType } from '@/executor/constants'
import { WaitBlockHandler } from '@/executor/handlers/wait/wait-handler'
import type { ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
describe('WaitBlockHandler', () => {
let handler: WaitBlockHandler
let mockBlock: SerializedBlock
let mockContext: ExecutionContext
beforeEach(() => {
vi.useFakeTimers()
handler = new WaitBlockHandler()
mockBlock = {
id: 'wait-block-1',
metadata: { id: BlockType.WAIT, name: 'Test Wait' },
position: { x: 50, y: 50 },
config: { tool: BlockType.WAIT, params: {} },
inputs: { timeValue: 'string', timeUnit: 'string' },
outputs: {},
enabled: true,
}
mockContext = {
workflowId: 'test-workflow-id',
blockStates: new Map(),
blockLogs: [],
metadata: { duration: 0 },
environmentVariables: {},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
completedLoops: new Set(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
}
})
afterEach(() => {
vi.useRealTimers()
})
it('should handle wait blocks', () => {
expect(handler.canHandle(mockBlock)).toBe(true)
const nonWaitBlock: SerializedBlock = { ...mockBlock, metadata: { id: 'other' } }
expect(handler.canHandle(nonWaitBlock)).toBe(false)
})
it('should wait in-process for short waits in seconds', async () => {
const inputs = { timeValue: '5', timeUnit: 'seconds' }
const executePromise = handler.execute(mockContext, mockBlock, inputs)
await vi.advanceTimersByTimeAsync(5000)
const result = await executePromise
expect(result).toEqual({
waitDuration: 5000,
status: 'completed',
})
})
it('should wait in-process for short waits in minutes', async () => {
const inputs = { timeValue: '2', timeUnit: 'minutes' }
const executePromise = handler.execute(mockContext, mockBlock, inputs)
await vi.advanceTimersByTimeAsync(120_000)
const result = await executePromise
expect(result).toEqual({
waitDuration: 120_000,
status: 'completed',
})
})
it('should default to 10 seconds when inputs are not provided', async () => {
const executePromise = handler.execute(mockContext, mockBlock, {})
await vi.advanceTimersByTimeAsync(10_000)
const result = await executePromise
expect(result).toEqual({
waitDuration: 10_000,
status: 'completed',
})
})
it('should reject negative wait amounts', async () => {
await expect(
handler.execute(mockContext, mockBlock, { timeValue: '-5', timeUnit: 'seconds' })
).rejects.toThrow('Wait amount must be a positive number')
})
it('should reject zero wait amounts', async () => {
await expect(
handler.execute(mockContext, mockBlock, { timeValue: '0', timeUnit: 'seconds' })
).rejects.toThrow('Wait amount must be a positive number')
})
it('should reject non-numeric wait amounts', async () => {
await expect(
handler.execute(mockContext, mockBlock, { timeValue: 'abc', timeUnit: 'seconds' })
).rejects.toThrow('Wait amount must be a positive number')
})
it('should reject unknown wait units', async () => {
await expect(
handler.execute(mockContext, mockBlock, { timeValue: '5', timeUnit: 'fortnights' })
).rejects.toThrow('Unknown wait unit: fortnights')
})
it('should reject async waits longer than the 30-day ceiling', async () => {
await expect(
handler.execute(mockContext, mockBlock, {
async: true,
timeValue: '31',
timeUnitLong: 'days',
})
).rejects.toThrow('Wait time exceeds maximum of 30 days')
})
it('should reject synchronous waits longer than 5 minutes', async () => {
await expect(
handler.execute(mockContext, mockBlock, { timeValue: '10', timeUnit: 'minutes' })
).rejects.toThrow('Wait time exceeds maximum of 5 minutes')
})
it('should default the async unit to minutes when timeUnitLong is missing', async () => {
vi.setSystemTime(new Date('2026-04-28T00:00:00.000Z'))
const result = (await handler.execute(mockContext, mockBlock, {
async: true,
timeValue: '3',
})) as Record<string, any>
const waitMs = 3 * 60 * 1000
expect(result.waitDuration).toBe(waitMs)
expect(result.status).toBe('waiting')
})
it('should reject seconds as a unit in async mode', async () => {
await expect(
handler.execute(mockContext, mockBlock, {
async: true,
timeValue: '30',
timeUnitLong: 'seconds',
})
).rejects.toThrow('Seconds are not allowed in async mode')
})
it('should still execute in-process at the 5-minute boundary', async () => {
const inputs = { timeValue: '5', timeUnit: 'minutes' }
const executePromise = handler.execute(mockContext, mockBlock, inputs)
await vi.advanceTimersByTimeAsync(5 * 60 * 1000)
const result = await executePromise
expect(result).toEqual({
waitDuration: 5 * 60 * 1000,
status: 'completed',
})
})
it('should suspend the workflow when wait exceeds the in-process threshold', async () => {
vi.setSystemTime(new Date('2026-04-28T00:00:00.000Z'))
const inputs = { async: true, timeValue: '10', timeUnitLong: 'minutes' }
const result = (await handler.execute(mockContext, mockBlock, inputs)) as Record<string, any>
const waitMs = 10 * 60 * 1000
const expectedResumeAt = new Date(Date.now() + waitMs).toISOString()
expect(result.status).toBe('waiting')
expect(result.waitDuration).toBe(waitMs)
expect(result.resumeAt).toBe(expectedResumeAt)
const pauseMetadata = result._pauseMetadata
expect(pauseMetadata).toBeDefined()
expect(pauseMetadata.pauseKind).toBe('time')
expect(pauseMetadata.resumeAt).toBe(expectedResumeAt)
expect(pauseMetadata.contextId).toBe('wait-block-1')
expect(pauseMetadata.blockId).toBe('wait-block-1')
expect(pauseMetadata.response).toEqual({ waitDuration: waitMs, resumeAt: expectedResumeAt })
})
it('should suspend the workflow for multi-day waits', async () => {
vi.setSystemTime(new Date('2026-04-28T00:00:00.000Z'))
const inputs = { async: true, timeValue: '2', timeUnitLong: 'days' }
const result = (await handler.execute(mockContext, mockBlock, inputs)) as Record<string, any>
const waitMs = 2 * 24 * 60 * 60 * 1000
const expectedResumeAt = new Date(Date.now() + waitMs).toISOString()
expect(result.status).toBe('waiting')
expect(result.waitDuration).toBe(waitMs)
expect(result.resumeAt).toBe(expectedResumeAt)
expect(result._pauseMetadata.pauseKind).toBe('time')
expect(result._pauseMetadata.resumeAt).toBe(expectedResumeAt)
})
it('should accept hours and convert to milliseconds', async () => {
vi.setSystemTime(new Date('2026-04-28T00:00:00.000Z'))
const result = (await handler.execute(mockContext, mockBlock, {
async: true,
timeValue: '3',
timeUnitLong: 'hours',
})) as Record<string, any>
const waitMs = 3 * 60 * 60 * 1000
expect(result.waitDuration).toBe(waitMs)
expect(result.status).toBe('waiting')
expect(result._pauseMetadata.pauseKind).toBe('time')
})
it('should handle cancellation via AbortSignal', async () => {
const abortController = new AbortController()
mockContext.abortSignal = abortController.signal
const inputs = { timeValue: '30', timeUnit: 'seconds' }
const executePromise = handler.execute(mockContext, mockBlock, inputs)
await vi.advanceTimersByTimeAsync(10000)
abortController.abort()
await vi.advanceTimersByTimeAsync(1)
const result = await executePromise
expect(result).toEqual({
waitDuration: 30000,
status: 'cancelled',
})
})
it('should return cancelled immediately if signal is already aborted', async () => {
const abortController = new AbortController()
abortController.abort()
mockContext.abortSignal = abortController.signal
const inputs = { timeValue: '10', timeUnit: 'seconds' }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect(result).toEqual({
waitDuration: 10000,
status: 'cancelled',
})
})
it('should not invoke the in-process sleep when suspending; AbortSignal is irrelevant for long waits', async () => {
vi.setSystemTime(new Date('2026-04-28T00:00:00.000Z'))
const abortController = new AbortController()
abortController.abort()
mockContext.abortSignal = abortController.signal
const result = (await handler.execute(mockContext, mockBlock, {
async: true,
timeValue: '1',
timeUnitLong: 'hours',
})) as Record<string, any>
expect(result.status).toBe('waiting')
expect(result._pauseMetadata.pauseKind).toBe('time')
})
it('should preserve fractional time values for larger units', async () => {
const inputs = { timeValue: '5.5', timeUnit: 'seconds' }
const executePromise = handler.execute(mockContext, mockBlock, inputs)
await vi.advanceTimersByTimeAsync(5500)
const result = await executePromise
expect(result).toEqual({
waitDuration: 5500,
status: 'completed',
})
})
it('should suspend a 1.5-day wait without truncating', async () => {
vi.setSystemTime(new Date('2026-04-28T00:00:00.000Z'))
const result = (await handler.execute(mockContext, mockBlock, {
async: true,
timeValue: '1.5',
timeUnitLong: 'days',
})) as Record<string, any>
const waitMs = 1.5 * 24 * 60 * 60 * 1000
expect(result.waitDuration).toBe(waitMs)
expect(result.status).toBe('waiting')
expect(result._pauseMetadata.pauseKind).toBe('time')
})
it('should always suspend when async is enabled, even for short waits', async () => {
vi.setSystemTime(new Date('2026-04-28T00:00:00.000Z'))
const result = (await handler.execute(mockContext, mockBlock, {
async: true,
timeValue: '2',
timeUnitLong: 'minutes',
})) as Record<string, any>
const waitMs = 2 * 60 * 1000
const expectedResumeAt = new Date(Date.now() + waitMs).toISOString()
expect(result.status).toBe('waiting')
expect(result.waitDuration).toBe(waitMs)
expect(result.resumeAt).toBe(expectedResumeAt)
expect(result._pauseMetadata.pauseKind).toBe('time')
expect(result._pauseMetadata.resumeAt).toBe(expectedResumeAt)
})
})
@@ -0,0 +1,197 @@
import { isExecutionCancelled, isRedisCancellationEnabled } from '@/lib/execution/cancellation'
import type { BlockOutput } from '@/blocks/types'
import { BlockType } from '@/executor/constants'
import {
generatePauseContextId,
mapNodeMetadataToPauseScopes,
} from '@/executor/human-in-the-loop/utils'
import type { BlockHandler, ExecutionContext, PauseMetadata } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
const CANCELLATION_CHECK_INTERVAL_MS = 500
/** Hard ceiling for in-process (synchronous) waits. */
const MAX_INPROCESS_WAIT_MS = 5 * 60 * 1000
/** Hard ceiling for async waits. */
const MAX_ASYNC_WAIT_MS = 30 * 24 * 60 * 60 * 1000
interface SleepOptions {
signal?: AbortSignal
executionId?: string
}
const sleep = async (ms: number, options: SleepOptions = {}): Promise<boolean> => {
const { signal, executionId } = options
const useRedis = isRedisCancellationEnabled() && !!executionId
if (signal?.aborted) {
return false
}
return new Promise((resolve) => {
// biome-ignore lint/style/useConst: needs to be declared before cleanup() but assigned later
let mainTimeoutId: NodeJS.Timeout | undefined
let checkIntervalId: NodeJS.Timeout | undefined
let resolved = false
const cleanup = () => {
if (mainTimeoutId) clearTimeout(mainTimeoutId)
if (checkIntervalId) clearInterval(checkIntervalId)
if (signal) signal.removeEventListener('abort', onAbort)
}
const onAbort = () => {
if (resolved) return
resolved = true
cleanup()
resolve(false)
}
if (signal) {
signal.addEventListener('abort', onAbort, { once: true })
}
if (useRedis) {
checkIntervalId = setInterval(async () => {
if (resolved) return
try {
const cancelled = await isExecutionCancelled(executionId!)
if (cancelled) {
resolved = true
cleanup()
resolve(false)
}
} catch {}
}, CANCELLATION_CHECK_INTERVAL_MS)
}
mainTimeoutId = setTimeout(() => {
if (resolved) return
resolved = true
cleanup()
resolve(true)
}, ms)
})
}
const UNIT_TO_MS = {
seconds: 1000,
minutes: 60 * 1000,
hours: 60 * 60 * 1000,
days: 24 * 60 * 60 * 1000,
} as const satisfies Record<string, number>
type WaitUnit = keyof typeof UNIT_TO_MS
function isWaitUnit(value: string): value is WaitUnit {
return value in UNIT_TO_MS
}
/**
* Handler for Wait blocks that pause workflow execution for a time delay.
*
* Default (async=false) waits are held in-process via an interruptible sleep and capped at 5 minutes.
* When async=true is set, the workflow is always suspended by returning {@link PauseMetadata} with
* `pauseKind: 'time'`; the cron-driven resume poller (see `/api/resume/poll`) picks the execution back
* up once `resumeAt` is reached. Async caps at 30 days.
*/
export class WaitBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === BlockType.WAIT
}
async execute(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<BlockOutput> {
return this.executeWithNode(ctx, block, inputs, { nodeId: block.id })
}
async executeWithNode(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>,
nodeMetadata: {
nodeId: string
loopId?: string
parallelId?: string
branchIndex?: number
branchTotal?: number
originalBlockId?: string
isLoopNode?: boolean
executionOrder?: number
}
): Promise<BlockOutput> {
const isAsync = inputs.async === true || inputs.async === 'true'
const timeValue = Number.parseFloat(inputs.timeValue || '10')
const timeUnit = isAsync ? inputs.timeUnitLong || 'minutes' : inputs.timeUnit || 'seconds'
if (!Number.isFinite(timeValue) || timeValue <= 0) {
throw new Error('Wait amount must be a positive number')
}
if (!isWaitUnit(timeUnit)) {
throw new Error(`Unknown wait unit: ${timeUnit}`)
}
if (isAsync && timeUnit === 'seconds') {
throw new Error('Seconds are not allowed in async mode')
}
const waitMs = Math.round(timeValue * UNIT_TO_MS[timeUnit])
if (isAsync) {
if (waitMs > MAX_ASYNC_WAIT_MS) {
throw new Error('Wait time exceeds maximum of 30 days')
}
} else if (waitMs > MAX_INPROCESS_WAIT_MS) {
throw new Error(
'Wait time exceeds maximum of 5 minutes; enable async mode to wait up to 30 days'
)
}
if (!isAsync) {
const completed = await sleep(waitMs, {
signal: ctx.abortSignal,
executionId: ctx.executionId,
})
if (!completed) {
return {
waitDuration: waitMs,
status: 'cancelled',
}
}
return {
waitDuration: waitMs,
status: 'completed',
}
}
const { parallelScope, loopScope } = mapNodeMetadataToPauseScopes(ctx, nodeMetadata)
const contextId = generatePauseContextId(block.id, nodeMetadata, loopScope)
const now = new Date()
const resumeAt = new Date(now.getTime() + waitMs).toISOString()
const pauseMetadata: PauseMetadata = {
contextId,
blockId: nodeMetadata.nodeId,
response: { waitDuration: waitMs, resumeAt },
timestamp: now.toISOString(),
parallelScope,
loopScope,
pauseKind: 'time',
resumeAt,
}
return {
waitDuration: waitMs,
status: 'waiting',
resumeAt,
_pauseMetadata: pauseMetadata,
}
}
}
@@ -0,0 +1,499 @@
import { setupGlobalFetchMock } from '@sim/testing'
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import { BlockType } from '@/executor/constants'
import {
findMissingRequiredCustomBlockInputs,
remapCustomBlockInputKeys,
WorkflowBlockHandler,
} from '@/executor/handlers/workflow/workflow-handler'
import type { ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
const { mockExecutorExecute, mockCreateSnapshot } = vi.hoisted(() => ({
mockExecutorExecute: vi.fn(),
mockCreateSnapshot: vi.fn(),
}))
vi.mock('@/executor', () => ({
Executor: class {
execute = mockExecutorExecute
},
}))
vi.mock('@/lib/logs/execution/snapshot/service', () => ({
snapshotService: { createSnapshotWithDeduplication: mockCreateSnapshot },
}))
vi.mock('@/lib/auth/internal', () => ({
generateInternalToken: vi.fn().mockResolvedValue('test-token'),
}))
vi.mock('@/executor/utils/http', () => ({
buildAuthHeaders: vi.fn().mockResolvedValue({ 'Content-Type': 'application/json' }),
buildAPIUrl: vi.fn((path: string) => new URL(path, 'http://localhost:3000')),
extractAPIErrorMessage: vi.fn(async (response: Response) => {
const defaultMessage = `API request failed with status ${response.status}`
try {
const errorData = await response.json()
return errorData.error || defaultMessage
} catch {
return defaultMessage
}
}),
}))
// Mock fetch globally
setupGlobalFetchMock()
describe('WorkflowBlockHandler', () => {
let handler: WorkflowBlockHandler
let mockBlock: SerializedBlock
let mockContext: ExecutionContext
let mockFetch: Mock
beforeEach(() => {
// Mock window.location.origin for getBaseUrl()
;(global as any).window = {
location: {
origin: 'http://localhost:3000',
},
}
handler = new WorkflowBlockHandler()
mockFetch = global.fetch as Mock
mockBlock = {
id: 'workflow-block-1',
metadata: { id: BlockType.WORKFLOW, name: 'Test Workflow Block' },
position: { x: 0, y: 0 },
config: { tool: BlockType.WORKFLOW, params: {} },
inputs: { workflowId: 'string' },
outputs: {},
enabled: true,
}
mockContext = {
workflowId: 'parent-workflow-id',
blockStates: new Map(),
blockLogs: [],
metadata: { duration: 0 },
environmentVariables: {},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
completedLoops: new Set(),
workflow: {
version: '1.0',
blocks: [],
connections: [],
loops: {},
},
}
// Reset all mocks
vi.clearAllMocks()
// Setup default fetch mock
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: {
name: 'Child Workflow',
state: {
blocks: [
{
id: 'starter',
metadata: { id: BlockType.STARTER, name: 'Starter' },
position: { x: 0, y: 0 },
config: { tool: BlockType.STARTER, params: {} },
inputs: {},
outputs: {},
enabled: true,
},
],
edges: [],
loops: {},
parallels: {},
},
},
}),
})
})
describe('canHandle', () => {
it('should handle workflow blocks', () => {
expect(handler.canHandle(mockBlock)).toBe(true)
})
it('should not handle non-workflow blocks', () => {
const nonWorkflowBlock = { ...mockBlock, metadata: { id: BlockType.FUNCTION } }
expect(handler.canHandle(nonWorkflowBlock)).toBe(false)
})
})
describe('execute', () => {
it('should throw error when no workflowId is provided', async () => {
const inputs = {}
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'No workflow selected for execution'
)
})
it('should enforce maximum call chain depth limit', async () => {
const inputs = { workflowId: 'child-workflow-id' }
const deepContext = {
...mockContext,
callChain: Array.from({ length: 25 }, (_, i) => `wf-${i}`),
}
await expect(handler.execute(deepContext, mockBlock, inputs)).rejects.toThrow(
'Maximum workflow call chain depth (25) exceeded'
)
})
it('should handle child workflow not found', async () => {
const inputs = { workflowId: 'non-existent-workflow' }
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found',
text: () => Promise.resolve(''),
})
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'"non-existent-workflow" failed: Child workflow non-existent-workflow not found'
)
})
it('should handle fetch errors gracefully', async () => {
const inputs = { workflowId: 'child-workflow-id' }
mockFetch.mockRejectedValueOnce(new Error('Network error'))
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'"child-workflow-id" failed: Network error'
)
})
})
describe('workspace containment', () => {
const inputs = { workflowId: 'child-workflow-id' }
it('should fail a cross-workspace child in the draft loader path', async () => {
const ctx = { ...mockContext, workspaceId: 'workspace-parent' }
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: {
name: 'Foreign Workflow',
workspaceId: 'workspace-other',
state: { blocks: {}, edges: [], loops: {}, parallels: {} },
},
}),
})
await expect(handler.execute(ctx, mockBlock, inputs)).rejects.toThrow(
'Child workflow child-workflow-id belongs to a different workspace and cannot be executed'
)
expect(mockCreateSnapshot).not.toHaveBeenCalled()
expect(mockExecutorExecute).not.toHaveBeenCalled()
})
it('should fail a cross-workspace child in the deployed loader path', async () => {
const ctx = {
...mockContext,
workspaceId: 'workspace-parent',
isDeployedContext: true,
}
mockFetch.mockImplementation(async (url: unknown) => {
if (String(url).includes('/deployed')) {
return {
ok: true,
json: () =>
Promise.resolve({
data: {
deployedState: { blocks: {}, edges: [], loops: {}, parallels: {} },
},
}),
}
}
return {
ok: true,
json: () =>
Promise.resolve({
data: {
name: 'Foreign Workflow',
workspaceId: 'workspace-other',
variables: {},
},
}),
}
})
await expect(handler.execute(ctx, mockBlock, inputs)).rejects.toThrow(
'Child workflow child-workflow-id belongs to a different workspace and cannot be executed'
)
expect(mockCreateSnapshot).not.toHaveBeenCalled()
expect(mockExecutorExecute).not.toHaveBeenCalled()
})
it('should execute a same-workspace child as before', async () => {
const ctx = { ...mockContext, workspaceId: 'workspace-parent' }
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: {
name: 'Child Workflow',
workspaceId: 'workspace-parent',
state: { blocks: {}, edges: [], loops: {}, parallels: {} },
},
}),
})
mockCreateSnapshot.mockResolvedValue({ snapshot: { id: 'snapshot-1' } })
mockExecutorExecute.mockResolvedValue({ success: true, output: { data: 'ok' } })
const result = await handler.execute(ctx, mockBlock, inputs)
expect(result).toMatchObject({
success: true,
childWorkflowId: 'child-workflow-id',
childWorkflowName: 'Child Workflow',
childWorkflowSnapshotId: 'snapshot-1',
result: { data: 'ok' },
})
expect(mockExecutorExecute).toHaveBeenCalledWith('child-workflow-id')
})
it('should fail closed when the executing context has no workspace', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: {
name: 'Child Workflow',
workspaceId: 'workspace-parent',
state: { blocks: {}, edges: [], loops: {}, parallels: {} },
},
}),
})
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'Cannot execute child workflow child-workflow-id: executing context has no workspace'
)
expect(mockExecutorExecute).not.toHaveBeenCalled()
})
})
describe('loadChildWorkflow', () => {
it('should return null for 404 responses', async () => {
const workflowId = 'non-existent-workflow'
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found',
text: () => Promise.resolve(''),
})
const result = await (handler as any).loadChildWorkflow(workflowId)
expect(result).toBeNull()
})
it('should handle invalid workflow state', async () => {
const workflowId = 'invalid-workflow'
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: {
name: 'Invalid Workflow',
state: null, // Invalid state
},
}),
})
await expect((handler as any).loadChildWorkflow(workflowId)).rejects.toThrow(
'Child workflow invalid-workflow has invalid state'
)
})
})
describe('mapChildOutputToParent', () => {
it('should map successful child output correctly', () => {
const childResult = {
success: true,
output: { data: 'test result' },
}
const result = (handler as any).mapChildOutputToParent(
childResult,
'child-id',
'Child Workflow',
100
)
expect(result).toEqual({
success: true,
childWorkflowId: 'child-id',
childWorkflowName: 'Child Workflow',
result: { data: 'test result' },
childTraceSpans: [],
})
})
it('should throw error for failed child output so BlockExecutor can check error port', () => {
const childResult = {
success: false,
error: 'Child workflow failed',
}
expect(() =>
(handler as any).mapChildOutputToParent(childResult, 'child-id', 'Child Workflow', 100)
).toThrow('"Child Workflow" failed: Child workflow failed')
try {
;(handler as any).mapChildOutputToParent(childResult, 'child-id', 'Child Workflow', 100)
} catch (error: any) {
expect(error.childTraceSpans).toEqual([])
}
})
it('should handle nested response structures', () => {
const childResult = {
output: { nested: 'data' },
}
const result = (handler as any).mapChildOutputToParent(
childResult,
'child-id',
'Child Workflow',
100
)
expect(result).toEqual({
success: true,
childWorkflowId: 'child-id',
childWorkflowName: 'Child Workflow',
result: { nested: 'data' },
childTraceSpans: [],
})
})
})
})
describe('remapCustomBlockInputKeys', () => {
const childBlocks = {
start: {
type: 'start_trigger',
subBlocks: {
inputFormat: {
value: [
{ id: 'f1', name: 'firstName', type: 'string' },
{ id: 'f2', name: 'payload', type: 'object' },
],
},
},
},
}
it('maps field ids to current names and drops keys with no matching field', () => {
const out = remapCustomBlockInputKeys(
{ f1: 'Theodore', removed: 'stale' },
childBlocks as Record<string, unknown>
)
expect(out).toEqual({ firstName: 'Theodore' })
expect('removed' in out).toBe(false)
})
it('decodes an object/array input from its JSON-string value (no double-encoding)', () => {
const out = remapCustomBlockInputKeys(
{ f1: 'Theodore', f2: '"hello"' },
childBlocks as Record<string, unknown>
)
expect(out).toEqual({ firstName: 'Theodore', payload: 'hello' })
})
it('parses a real object value and leaves invalid JSON as a raw string', () => {
expect(
remapCustomBlockInputKeys({ f2: '{"a":1}' }, childBlocks as Record<string, unknown>)
).toEqual({ payload: { a: 1 } })
expect(
remapCustomBlockInputKeys({ f2: 'not json' }, childBlocks as Record<string, unknown>)
).toEqual({ payload: 'not json' })
})
})
describe('findMissingRequiredCustomBlockInputs', () => {
const childBlocks = {
start: {
type: 'start_trigger',
subBlocks: {
inputFormat: {
value: [
{ id: 'f1', name: 'firstName', type: 'string' },
{ id: 'f2', name: 'payload', type: 'object' },
{ name: 'legacyField', type: 'string' },
],
},
},
},
} as Record<string, unknown>
it('flags a required field left empty and reports its display name', () => {
expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, {})).toEqual(['firstName'])
expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: '' })).toEqual([
'firstName',
])
expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: null })).toEqual([
'firstName',
])
})
it('passes when the required field has a value', () => {
expect(
findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: 'Theodore' })
).toEqual([])
expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: 0 })).toEqual([])
expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: false })).toEqual(
[]
)
})
it('ignores a stale required override whose field was removed from the Start', () => {
expect(findMissingRequiredCustomBlockInputs(['removed-field'], childBlocks, {})).toEqual([])
})
it('treats fields without an override as optional', () => {
expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: 'x' })).toEqual(
[]
)
expect(findMissingRequiredCustomBlockInputs([], childBlocks, {})).toEqual([])
})
it('keys legacy fields without a stable id by name', () => {
expect(findMissingRequiredCustomBlockInputs(['legacyField'], childBlocks, {})).toEqual([
'legacyField',
])
expect(
findMissingRequiredCustomBlockInputs(['legacyField'], childBlocks, { legacyField: 'v' })
).toEqual([])
})
it('reports every missing required field at once', () => {
expect(findMissingRequiredCustomBlockInputs(['f1', 'f2'], childBlocks, {})).toEqual([
'firstName',
'payload',
])
})
})
@@ -0,0 +1,920 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
import { buildNextCallChain, validateCallChain } from '@/lib/execution/call-chain'
import { calculateCostSummary } from '@/lib/logs/execution/logging-factory'
import { snapshotService } from '@/lib/logs/execution/snapshot/service'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
import type { TraceSpan } from '@/lib/logs/types'
import { getCustomBlockAuthority } from '@/lib/workflows/custom-blocks/operations'
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
import { type CustomBlockOutput, isCustomBlockType } from '@/blocks/custom/build-config'
import type { BlockOutput } from '@/blocks/types'
import { Executor } from '@/executor'
import { BlockType, DEFAULTS, HTTP } from '@/executor/constants'
import { ChildWorkflowError } from '@/executor/errors/child-workflow-error'
import type { WorkflowNodeMetadata } from '@/executor/execution/types'
import type {
BlockHandler,
ExecutionContext,
ExecutionResult,
StreamingExecution,
} from '@/executor/types'
import { hasExecutionResult } from '@/executor/utils/errors'
import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http'
import { getIterationContext } from '@/executor/utils/iteration-context'
import { parseJSON } from '@/executor/utils/json'
import { lazyCleanupInputMapping } from '@/executor/utils/lazy-cleanup'
import { Serializer } from '@/serializer'
import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('WorkflowBlockHandler')
/** Read a dot-path (e.g. `content.text`) out of a block output object. */
function getValueAtPath(source: unknown, path: string): unknown {
return path.split('.').reduce<unknown>((acc, key) => {
if (acc && typeof acc === 'object') return (acc as Record<string, unknown>)[key]
return undefined
}, source)
}
/**
* Remap a custom block's resolved input mapping from source-field ids to the
* child workflow's current field names. The consumer's sub-block values are keyed
* by the stable field id (so renames don't cook them); the child is addressed by
* name. Legacy fields without an id are keyed by name and pass through unchanged.
* Keys that match no current field are dropped.
*/
/**
* A consumer left a publisher-required custom block input empty. The message is
* consumer-safe (it names only the block's own input labels), so the catch's
* custom-block sanitizer rethrows it verbatim instead of the generic failure.
*/
export class CustomBlockMissingInputsError extends Error {
constructor(message: string) {
super(message)
this.name = 'CustomBlockMissingInputsError'
}
}
/**
* Names of publisher-required custom block inputs the consumer left empty, checked
* against the child's LIVE deployed Start fields — a required override whose field
* was removed is inert, and a field added after publish has no override, so schema
* drift can never block a run. `childWorkflowInput` is the post-remap mapping
* (keyed by field name). Same empty semantics as the serializer's required check.
*/
export function findMissingRequiredCustomBlockInputs(
requiredInputIds: string[],
childBlocks: Record<string, unknown>,
childWorkflowInput: Record<string, unknown>
): string[] {
if (requiredInputIds.length === 0) return []
const requiredIds = new Set(requiredInputIds)
return extractInputFieldsFromBlocks(childBlocks)
.filter((field) => requiredIds.has(field.id ?? field.name))
.filter((field) => {
const value = childWorkflowInput[field.name]
return value === undefined || value === null || value === ''
})
.map((field) => field.name)
}
export function remapCustomBlockInputKeys(
mapping: Record<string, unknown>,
childBlocks: Record<string, unknown>
): Record<string, unknown> {
const fields = extractInputFieldsFromBlocks(childBlocks)
const remapped: Record<string, unknown> = {}
for (const field of fields) {
const key =
field.id && field.id in mapping ? field.id : field.name in mapping ? field.name : null
if (key === null) continue
let value = mapping[key]
// object/array inputs are authored in a JSON code editor, so their value is a
// JSON *string*. Decode it against the child's real Start field type so the
// child receives the actual object/array (or primitive) — not the string
// re-encoded by the mapping's `JSON.stringify` (`"Theodore"` → `\"Theodore\"`).
if ((field.type === 'object' || field.type === 'array') && typeof value === 'string') {
try {
value = JSON.parse(value)
} catch {
// Not valid JSON — pass the raw string through unchanged.
}
}
remapped[field.name] = value
}
return remapped
}
/**
* Canonical hosted-key spend of a child run: the model/tool cost the way the
* parent bills it (recursing nested/iteration spans and de-duping model
* breakdowns), minus the base execution charge the parent applies once itself.
* A naive top-level `cost.total` sum undercounts when spend sits on nested children.
*/
function aggregateChildCost(childTraceSpans: TraceSpan[]): number {
if (childTraceSpans.length === 0) return 0
const summary = calculateCostSummary(childTraceSpans)
return Math.max(0, summary.totalCost - summary.baseExecutionCharge)
}
/**
* A single cost-only span so a FAILED custom block still bills the hosted-key spend
* its child already consumed (`block-executor` bills `error.childTraceSpans`),
* without exposing any of the source workflow's internal spans. Empty when free.
*/
function buildCostCarrierSpans(childCost: number, blockName: string, type: string): TraceSpan[] {
if (childCost <= 0) return []
const now = new Date().toISOString()
return [
{
id: generateId(),
name: blockName,
type,
duration: 0,
startTime: now,
endTime: now,
status: 'error',
cost: { total: childCost },
},
]
}
type WorkflowTraceSpan = TraceSpan & {
metadata?: Record<string, unknown>
children?: WorkflowTraceSpan[]
output?: (Record<string, unknown> & { childTraceSpans?: WorkflowTraceSpan[] }) | null
}
/**
* Handler for workflow blocks that execute other workflows inline.
* Creates sub-execution contexts and manages data flow between parent and child workflows.
*/
export class WorkflowBlockHandler implements BlockHandler {
private serializer = new Serializer()
canHandle(block: SerializedBlock): boolean {
const id = block.metadata?.id
return id === BlockType.WORKFLOW || id === BlockType.WORKFLOW_INPUT || isCustomBlockType(id)
}
async execute(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<BlockOutput | StreamingExecution> {
return this.executeCore(ctx, block, inputs)
}
async executeWithNode(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>,
nodeMetadata: WorkflowNodeMetadata
): Promise<BlockOutput | StreamingExecution> {
return this.executeCore(ctx, block, inputs, nodeMetadata)
}
private async executeCore(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>,
nodeMetadata?: WorkflowNodeMetadata
): Promise<BlockOutput | StreamingExecution> {
logger.info(`Executing workflow block: ${block.id}`)
const blockTypeId = block.metadata?.id
const isCustomBlock = isCustomBlockType(blockTypeId)
// Custom (deploy-as-block) blocks are an invocation boundary: resolve the bound
// workflow + authority from the DB (never trust the serialized value) and run the
// source workflow's LATEST deployment under its OWNER's authority — the same
// identity a normal deployed API/schedule/webhook run uses — so a cross-workspace
// consumer needs no permission on the source workflow. Owner deletion cascade-
// deletes the workflow → the custom_block row, so the block never orphans.
let workflowId = inputs.workflowId
let loadUserId = ctx.userId
let exposedOutputs: CustomBlockOutput[] = []
let requiredInputIds: string[] = []
if (isCustomBlock) {
const authority = await getCustomBlockAuthority(blockTypeId as string, ctx.workspaceId)
if (!authority) {
throw new Error('This custom block is no longer available')
}
workflowId = authority.workflowId
loadUserId = authority.ownerUserId
exposedOutputs = authority.exposedOutputs
requiredInputIds = authority.requiredInputIds
}
if (!workflowId) {
throw new Error('No workflow selected for execution')
}
// Always run the latest deployment for custom blocks, even from a draft-context parent run.
const useDeployed = isCustomBlock || ctx.isDeployedContext
let childWorkflowName = workflowId
// Unique ID per invocation — used to correlate child block events with this specific
// workflow block execution, preventing cross-iteration child mixing in loop contexts.
const instanceId = generateId()
const childCallChain = buildNextCallChain(ctx.callChain || [], workflowId)
const depthError = validateCallChain(childCallChain)
if (depthError) {
throw new ChildWorkflowError({
message: depthError,
childWorkflowName,
})
}
// A custom block runs the source's latest deployment; if the source has been
// undeployed there's nothing to run. Check + throw a clear, consumer-safe
// reason BEFORE the try so the catch's generic sanitizer doesn't mask it (the
// message names no source internals). The block still renders (its schema comes
// from stored curated inputs), so this is the only failure mode to surface.
if (isCustomBlock) {
const deployed = await this.checkChildDeployment(workflowId, loadUserId)
if (!deployed) {
throw new Error('This blocks workflow is not deployed. Redeploy it to use this block.')
}
}
let childWorkflowSnapshotId: string | undefined
try {
if (useDeployed && !isCustomBlock) {
const hasActiveDeployment = await this.checkChildDeployment(workflowId, loadUserId)
if (!hasActiveDeployment) {
throw new Error(
`Child workflow is not deployed. Please deploy the workflow before invoking it.`
)
}
}
const childWorkflow = useDeployed
? await this.loadChildWorkflowDeployed(workflowId, loadUserId)
: await this.loadChildWorkflow(workflowId, ctx.userId)
if (!childWorkflow) {
throw new Error(`Child workflow ${workflowId} not found`)
}
// Custom blocks are org-scoped and deliberately cross-workspace: the source
// workflow lives in the publisher's workspace, not the consumer's. Their
// boundary is the org overlay + `getCustomBlockAuthority`, so the
// same-workspace assert (which guards regular workflow blocks) must be
// skipped or every custom-block invocation from another workspace throws.
if (!isCustomBlock) {
this.assertChildWorkflowInWorkspace(workflowId, childWorkflow.workspaceId, ctx.workspaceId)
}
childWorkflowName = childWorkflow.name || 'Unknown Workflow'
logger.info(
`Executing child workflow: ${childWorkflowName} (${workflowId}), call chain depth ${ctx.callChain?.length || 0}`
)
let childWorkflowInput: Record<string, any> = {}
if (inputs.inputMapping !== undefined && inputs.inputMapping !== null) {
const normalized = parseJSON(inputs.inputMapping, inputs.inputMapping)
if (normalized && typeof normalized === 'object' && !Array.isArray(normalized)) {
// Custom blocks key their mapping by the source field's stable id so a
// rename never orphans the consumer's value; remap id → current name
// before the child (which is addressed by name) receives it.
const remapped = isCustomBlock
? remapCustomBlockInputKeys(
normalized as Record<string, unknown>,
childWorkflow.rawBlocks || {}
)
: (normalized as Record<string, unknown>)
const cleanedMapping = await lazyCleanupInputMapping(
ctx.workflowId || 'unknown',
block.id,
remapped,
childWorkflow.rawBlocks || {}
)
childWorkflowInput = cleanedMapping as Record<string, any>
} else {
childWorkflowInput = {}
}
} else if (inputs.input !== undefined) {
childWorkflowInput = inputs.input
}
if (isCustomBlock) {
const missing = findMissingRequiredCustomBlockInputs(
requiredInputIds,
childWorkflow.rawBlocks || {},
childWorkflowInput
)
if (missing.length > 0) {
throw new CustomBlockMissingInputsError(
`${block.metadata?.name || 'Custom block'} is missing required fields: ${missing.join(', ')}`
)
}
}
const childSnapshotResult = await snapshotService.createSnapshotWithDeduplication(
workflowId,
childWorkflow.workflowState
)
childWorkflowSnapshotId = childSnapshotResult.snapshot.id
const childDepth = (ctx.childWorkflowContext?.depth ?? 0) + 1
const shouldPropagateCallbacks = childDepth <= DEFAULTS.MAX_SSE_CHILD_DEPTH
if (!shouldPropagateCallbacks) {
logger.info('Dropping SSE callbacks beyond max child depth', {
childDepth,
maxDepth: DEFAULTS.MAX_SSE_CHILD_DEPTH,
childWorkflowName,
})
}
if (shouldPropagateCallbacks) {
const effectiveBlockId = nodeMetadata
? (nodeMetadata.originalBlockId ?? nodeMetadata.nodeId)
: block.id
const iterationContext = nodeMetadata ? getIterationContext(ctx, nodeMetadata) : undefined
await ctx.onChildWorkflowInstanceReady?.(
effectiveBlockId,
instanceId,
iterationContext,
nodeMetadata?.executionOrder,
ctx.childWorkflowContext
)
}
// A custom block is an invocation boundary: the child runs under the SOURCE
// workflow owner's identity, workspace, and environment — not the consumer's —
// so it resolves credentials/integrations/env exactly as published and the
// consumer needs no access to any of them. (Billing still lands on the
// consumer's org, aggregated onto the block above.) Regular workflow blocks
// keep running in the parent's context.
let childUserId = ctx.userId
let childWorkspaceId = ctx.workspaceId
let childEnvVarValues = ctx.environmentVariables
if (isCustomBlock) {
if (!loadUserId) {
throw new Error('Custom block source workflow has no owner')
}
if (!childWorkflow.workspaceId) {
throw new Error('Custom block source workflow has no workspace')
}
childUserId = loadUserId
childWorkspaceId = childWorkflow.workspaceId
const ownerEnv = await getPersonalAndWorkspaceEnv(loadUserId, childWorkflow.workspaceId)
childEnvVarValues = { ...ownerEnv.personalDecrypted, ...ownerEnv.workspaceDecrypted }
}
const subExecutor = new Executor({
workflow: childWorkflow.serializedState,
workflowInput: childWorkflowInput,
envVarValues: childEnvVarValues,
workflowVariables: childWorkflow.variables || {},
contextExtensions: {
isChildExecution: true,
// Custom blocks always run the source's latest deployment, so the child
// context must be deployed too — otherwise its metadata treats the
// deployed graph as draft. `useDeployed` folds in the custom-block case.
isDeployedContext: useDeployed,
enforceCredentialAccess: ctx.enforceCredentialAccess,
workspaceId: childWorkspaceId,
userId: childUserId,
executionId: ctx.executionId,
abortSignal: ctx.abortSignal,
// Propagate in-flight block-output redaction into child workflows so
// nested blocks mask outputs too (recurses: each child forwards it).
piiBlockOutputRedaction: ctx.piiBlockOutputRedaction,
callChain: childCallChain,
...(shouldPropagateCallbacks && {
onBlockStart: ctx.onBlockStart,
onBlockComplete: ctx.onBlockComplete,
onStream: ctx.onStream,
onChildWorkflowInstanceReady: ctx.onChildWorkflowInstanceReady,
childWorkflowContext: {
parentBlockId: instanceId,
workflowName: childWorkflowName,
workflowId,
depth: childDepth,
},
}),
},
})
const startTime = performance.now()
const result = await subExecutor.execute(workflowId)
const executionResult = this.toExecutionResult(result)
const duration = performance.now() - startTime
logger.info(`Child workflow ${childWorkflowName} completed in ${Math.round(duration)}ms`, {
success: executionResult.success,
hasLogs: (executionResult.logs?.length ?? 0) > 0,
})
const childTraceSpans = this.captureChildWorkflowLogs(executionResult, childWorkflowName, ctx)
const mappedResult = this.mapChildOutputToParent(
executionResult,
workflowId,
childWorkflowName,
duration,
instanceId,
childTraceSpans,
childWorkflowSnapshotId
)
// Custom blocks expose only curated outputs — never the child workflow id,
// name, or trace spans. `mapChildOutputToParent` above still runs so failures
// surface identically; we just reshape the successful output.
if (isCustomBlock) {
// The child's spans are stripped for privacy, but they're the only carrier
// of the run's cost into billing — so roll their aggregate cost onto the
// block itself. Custom blocks are org-scoped, so this bills the same org the
// source workflow would bill if run directly, exactly as if it ran the key.
const childCost = aggregateChildCost(childTraceSpans)
return this.projectCustomBlockOutput(executionResult, exposedOutputs, childCost)
}
return mappedResult
} catch (error: unknown) {
logger.error(`Error executing child workflow ${workflowId}:`, error)
// Custom blocks are an invocation boundary: on failure the consumer must not
// see the source workflow's name, nested error text (which names internal
// blocks), trace spans, or execution result — the success path hides all of
// these too. The real error is logged above for the publisher/ops; the
// consumer gets only a generic failure attributed to the block they placed.
// But a child that failed AFTER consuming hosted keys still owes that spend,
// so capture the child's spans server-side, distill to the aggregate cost, and
// carry only that (no internals) so `block-executor` still bills it.
if (isCustomBlock) {
// Missing-required-inputs is the consumer's own mistake and its message
// names only the block's input labels — surface it instead of the generic
// failure so they can actually fix it. The child never ran: no spend.
if (error instanceof CustomBlockMissingInputsError) {
throw new ChildWorkflowError({
message: error.message,
childWorkflowName: block.metadata?.name || 'Custom block',
childWorkflowInstanceId: instanceId,
})
}
let failedChildSpans: WorkflowTraceSpan[] = []
if (hasExecutionResult(error) && error.executionResult.logs) {
failedChildSpans = this.captureChildWorkflowLogs(
error.executionResult,
childWorkflowName,
ctx
)
} else if (ChildWorkflowError.isChildWorkflowError(error)) {
failedChildSpans = error.childTraceSpans
}
const blockName = block.metadata?.name || 'Custom block'
throw new ChildWorkflowError({
message: 'Custom block execution failed',
childWorkflowName: blockName,
childTraceSpans: buildCostCarrierSpans(
aggregateChildCost(failedChildSpans),
blockName,
block.metadata?.id ?? 'custom_block'
),
childWorkflowInstanceId: instanceId,
})
}
let childTraceSpans: WorkflowTraceSpan[] = []
let executionResult: ExecutionResult | undefined
if (hasExecutionResult(error) && error.executionResult.logs) {
executionResult = error.executionResult
logger.info(`Extracting child trace spans from error.executionResult`, {
hasLogs: (executionResult.logs?.length ?? 0) > 0,
logCount: executionResult.logs?.length ?? 0,
})
childTraceSpans = this.captureChildWorkflowLogs(executionResult, childWorkflowName, ctx)
logger.info(`Captured ${childTraceSpans.length} child trace spans from failed execution`)
} else if (ChildWorkflowError.isChildWorkflowError(error)) {
childTraceSpans = error.childTraceSpans
}
// Build a cleaner error message for nested workflow errors
const errorMessage = this.buildNestedWorkflowErrorMessage(childWorkflowName, error)
throw new ChildWorkflowError({
message: errorMessage,
childWorkflowName,
childTraceSpans,
executionResult,
childWorkflowSnapshotId,
childWorkflowInstanceId: instanceId,
cause: error instanceof Error ? error : undefined,
})
}
}
/**
* Builds a cleaner error message for nested workflow errors.
* Parses nested error messages to extract workflow chain and root error.
*/
private buildNestedWorkflowErrorMessage(childWorkflowName: string, error: unknown): string {
const originalError = getErrorMessage(error, 'Unknown error')
// Extract any nested workflow names from the error message
const { chain, rootError } = this.parseNestedWorkflowError(originalError)
// Add current workflow to the beginning of the chain
chain.unshift(childWorkflowName)
// If we have a chain (nested workflows), format nicely
if (chain.length > 1) {
return `Workflow chain: ${chain.join(' → ')} | ${rootError}`
}
// Single workflow failure
return `"${childWorkflowName}" failed: ${rootError}`
}
/**
* Parses a potentially nested workflow error message to extract:
* - The chain of workflow names
* - The actual root error message (preserving the block name prefix for the failing block)
*
* Handles formats like:
* - "workflow-name" failed: error
* - Block Name: "workflow-name" failed: error
* - Workflow chain: A → B | error
*/
private parseNestedWorkflowError(message: string): { chain: string[]; rootError: string } {
const chain: string[] = []
const remaining = message
// First, check if it's already in chain format
const chainMatch = remaining.match(/^Workflow chain: (.+?) \| (.+)$/)
if (chainMatch) {
const chainPart = chainMatch[1]
const errorPart = chainMatch[2]
chain.push(...chainPart.split(' → ').map((s) => s.trim()))
return { chain, rootError: errorPart }
}
// Extract workflow names from patterns like:
// - "workflow-name" failed:
// - Block Name: "workflow-name" failed:
const workflowPattern = /(?:\[[^\]]+\]\s*)?(?:[^:]+:\s*)?"([^"]+)"\s*failed:\s*/g
let match: RegExpExecArray | null
let lastIndex = 0
match = workflowPattern.exec(remaining)
while (match !== null) {
chain.push(match[1])
lastIndex = match.index + match[0].length
match = workflowPattern.exec(remaining)
}
// The root error is everything after the last match
// Keep the block name prefix (e.g., Function 1:) so we know which block failed
const rootError = lastIndex > 0 ? remaining.slice(lastIndex) : remaining
return { chain, rootError: rootError.trim() || 'Unknown error' }
}
/**
* Ensures the child workflow belongs to the same workspace as the executing
* context before any child execution starts. Blocks silent cross-workspace
* execution (e.g. a manual workflow id still pointing at the source
* workspace after a fork), which would otherwise run the foreign workflow
* with the parent workspace's environment and billing. Fails closed when the
* executing context carries no workspace id: every server execution path
* populates it via execution-core, so a missing value indicates a context
* that must not silently bypass the check. The error message intentionally
* omits the foreign workspace id.
*/
private assertChildWorkflowInWorkspace(
childWorkflowId: string,
childWorkspaceId: string | null | undefined,
parentWorkspaceId: string | undefined
): void {
if (!parentWorkspaceId) {
throw new Error(
`Cannot execute child workflow ${childWorkflowId}: executing context has no workspace`
)
}
if (childWorkspaceId !== parentWorkspaceId) {
throw new Error(
`Child workflow ${childWorkflowId} belongs to a different workspace and cannot be executed`
)
}
}
private async loadChildWorkflow(workflowId: string, userId?: string) {
const headers = await buildAuthHeaders(userId)
const url = buildAPIUrl(`/api/workflows/${workflowId}`)
const response = await fetch(url.toString(), { headers })
if (!response.ok) {
await response.text().catch(() => {})
if (response.status === HTTP.STATUS.NOT_FOUND) {
logger.warn(`Child workflow ${workflowId} not found`)
return null
}
throw new Error(`Failed to fetch workflow: ${response.status} ${response.statusText}`)
}
const { data: workflowData } = await response.json()
if (!workflowData) {
throw new Error(`Child workflow ${workflowId} returned empty data`)
}
logger.info(`Loaded child workflow: ${workflowData.name} (${workflowId})`)
const workflowState = workflowData.state
if (!workflowState || !workflowState.blocks) {
throw new Error(`Child workflow ${workflowId} has invalid state`)
}
const serializedWorkflow = this.serializer.serializeWorkflow(
workflowState.blocks,
workflowState.edges || [],
workflowState.loops || {},
workflowState.parallels || {},
true
)
const workflowVariables = (workflowData.variables as Record<string, any>) || {}
const workflowStateWithVariables = {
...workflowState,
variables: workflowVariables,
metadata: {
...(workflowState.metadata || {}),
name: workflowData.name || DEFAULTS.WORKFLOW_NAME,
},
}
if (Object.keys(workflowVariables).length > 0) {
logger.info(
`Loaded ${Object.keys(workflowVariables).length} variables for child workflow: ${workflowId}`
)
}
return {
name: workflowData.name,
workspaceId: (workflowData.workspaceId ?? null) as string | null,
serializedState: serializedWorkflow,
variables: workflowVariables,
workflowState: workflowStateWithVariables,
rawBlocks: workflowState.blocks,
}
}
private async checkChildDeployment(workflowId: string, userId?: string): Promise<boolean> {
try {
const headers = await buildAuthHeaders(userId)
const url = buildAPIUrl(`/api/workflows/${workflowId}/deployed`)
const response = await fetch(url.toString(), {
headers,
cache: 'no-store',
})
if (!response.ok) return false
const json = await response.json()
return !!json?.data?.deployedState || !!json?.deployedState
} catch (e) {
logger.error(`Failed to check child deployment for ${workflowId}:`, e)
return false
}
}
private async loadChildWorkflowDeployed(workflowId: string, userId?: string) {
const headers = await buildAuthHeaders(userId)
const deployedUrl = buildAPIUrl(`/api/workflows/${workflowId}/deployed`)
const deployedRes = await fetch(deployedUrl.toString(), {
headers,
cache: 'no-store',
})
if (!deployedRes.ok) {
if (deployedRes.status === HTTP.STATUS.NOT_FOUND) {
return null
}
throw new Error(
`Failed to fetch deployed workflow: ${deployedRes.status} ${deployedRes.statusText}`
)
}
const deployedJson = await deployedRes.json()
const deployedState = deployedJson?.data?.deployedState || deployedJson?.deployedState
if (!deployedState || !deployedState.blocks) {
throw new Error(`Deployed state missing or invalid for child workflow ${workflowId}`)
}
const metaUrl = buildAPIUrl(`/api/workflows/${workflowId}`)
const metaRes = await fetch(metaUrl.toString(), {
headers,
cache: 'no-store',
})
if (!metaRes.ok) {
throw new Error(`Failed to fetch workflow metadata: ${metaRes.status} ${metaRes.statusText}`)
}
const metaJson = await metaRes.json()
const wfData = metaJson?.data
const serializedWorkflow = this.serializer.serializeWorkflow(
deployedState.blocks,
deployedState.edges || [],
deployedState.loops || {},
deployedState.parallels || {},
true
)
const workflowVariables = (wfData?.variables as Record<string, any>) || {}
const childName = wfData?.name || DEFAULTS.WORKFLOW_NAME
const workflowStateWithVariables = {
...deployedState,
variables: workflowVariables,
metadata: {
...(deployedState.metadata || {}),
name: childName,
},
}
return {
name: childName,
workspaceId: (wfData?.workspaceId ?? null) as string | null,
serializedState: serializedWorkflow,
variables: workflowVariables,
workflowState: workflowStateWithVariables,
rawBlocks: deployedState.blocks,
}
}
/**
* Captures and transforms child workflow logs into trace spans
*/
private captureChildWorkflowLogs(
childResult: ExecutionResult,
childWorkflowName: string,
parentContext: ExecutionContext
): WorkflowTraceSpan[] {
try {
if (!childResult.logs || !Array.isArray(childResult.logs)) {
return []
}
const { traceSpans } = buildTraceSpans(childResult)
if (!traceSpans || traceSpans.length === 0) {
return []
}
const processedSpans = this.processChildWorkflowSpans(traceSpans)
if (processedSpans.length === 0) {
return []
}
const transformedSpans = processedSpans.map((span) =>
this.transformSpanForChildWorkflow(span, childWorkflowName)
)
return transformedSpans
} catch (error) {
logger.error(`Error capturing child workflow logs for ${childWorkflowName}:`, error)
return []
}
}
private transformSpanForChildWorkflow(
span: WorkflowTraceSpan,
childWorkflowName: string
): WorkflowTraceSpan {
const metadata: Record<string, unknown> = {
...(span.metadata ?? {}),
isFromChildWorkflow: true,
childWorkflowName,
}
const transformedChildren = Array.isArray(span.children)
? span.children.map((childSpan) =>
this.transformSpanForChildWorkflow(childSpan, childWorkflowName)
)
: undefined
return {
...span,
metadata,
...(transformedChildren ? { children: transformedChildren } : {}),
}
}
private processChildWorkflowSpans(spans: TraceSpan[]): WorkflowTraceSpan[] {
const processed: WorkflowTraceSpan[] = []
spans.forEach((span) => {
if (this.isSyntheticWorkflowWrapper(span)) {
if (span.children && Array.isArray(span.children)) {
processed.push(...this.processChildWorkflowSpans(span.children))
}
return
}
const workflowSpan: WorkflowTraceSpan = {
...span,
}
if (Array.isArray(workflowSpan.children)) {
workflowSpan.children = this.processChildWorkflowSpans(workflowSpan.children as TraceSpan[])
}
processed.push(workflowSpan)
})
return processed
}
private toExecutionResult(result: ExecutionResult | StreamingExecution): ExecutionResult {
return 'execution' in result ? result.execution : result
}
private isSyntheticWorkflowWrapper(span: TraceSpan | undefined): boolean {
if (!span || span.type !== 'workflow') return false
return !span.blockId
}
/**
* Shape a custom block's successful output. With curated `exposedOutputs`, each
* maps a child block output (blockId + dot-path, read from the child's per-block
* logs) to a named top-level field. With none, exposes the child's whole
* `result`. Never leaks child workflow id/name/trace spans.
*/
private projectCustomBlockOutput(
executionResult: ExecutionResult,
exposedOutputs: CustomBlockOutput[],
childCost: number
): BlockOutput {
// Aggregate child cost only (never the child's spans/model breakdown) so the
// run is billed while the source workflow's internals stay hidden.
const cost = childCost > 0 ? { cost: { total: childCost } } : {}
if (exposedOutputs.length === 0) {
return { success: true, result: executionResult.output ?? {}, ...cost }
}
const logs = executionResult.logs ?? []
const output: Record<string, unknown> = { success: true, ...cost }
for (const { blockId, path, name } of exposedOutputs) {
const log =
[...logs].reverse().find((l) => l.blockId === blockId && l.success) ??
[...logs].reverse().find((l) => l.blockId === blockId)
output[name] = log ? getValueAtPath(log.output, path) : undefined
}
return output as BlockOutput
}
private mapChildOutputToParent(
childResult: ExecutionResult,
childWorkflowId: string,
childWorkflowName: string,
duration: number,
instanceId: string,
childTraceSpans?: WorkflowTraceSpan[],
childWorkflowSnapshotId?: string
): BlockOutput {
const success = childResult.success !== false
const result = childResult.output || {}
if (!success) {
logger.warn(`Child workflow ${childWorkflowName} failed`)
throw new ChildWorkflowError({
message: `"${childWorkflowName}" failed: ${childResult.error || 'Child workflow execution failed'}`,
childWorkflowName,
childTraceSpans: childTraceSpans || [],
childWorkflowSnapshotId,
childWorkflowInstanceId: instanceId,
})
}
const output: BlockOutput = {
success: true,
childWorkflowName,
childWorkflowId,
...(childWorkflowSnapshotId ? { childWorkflowSnapshotId } : {}),
result,
childTraceSpans: childTraceSpans || [],
_childWorkflowInstanceId: instanceId,
}
return output
}
}