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
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:
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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'],
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -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 block’s 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user