Files
wehub-resource-sync d25d482dc2
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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

521 lines
15 KiB
TypeScript

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)
}
}