Appearance
Ação executeScript — documentação completa
Este documento descreve a ação executeScript de ponta a ponta: tipo TypeScript, sandbox de execução, integração no engine, editor no frontend e API de templates.
Visão geral
A ação executeScript permite que o construtor de fluxos escreva código JavaScript que roda durante a execução de um bloco. O resultado do script pode ser salvo em uma variável de contexto da sessão para uso em condições, interpolações ou ações subsequentes.
O código é executado em um V8 Isolate completamente separado do processo do engine, sem acesso a módulos do host, variáveis de ambiente ou qualquer recurso de I/O.
A primitiva de sandbox vive em packages/flow-ai-runtime (pacote compartilhado) e é invocada tanto pelas actions do engine (flow-ai-engine) quanto pelo executor de Tools publicadas (executeTool, mini-flow). Ambos os serviços que dependem do runtime (flow-ai-engine e flow-ai-agent) reutilizam a mesma executeInSandbox.
Tipo TypeScript (flow-ai-types)
ts
type ExecuteScriptAction = ActionBase & {
type: "executeScript"
/** Código JS do script — arquivo completo ou apenas o corpo de `execute`. */
source: string
/** Mapeamentos nome→valor: `name` vira parâmetro da função; `value` é
interpolado ({{...}}) antes de entrar no sandbox. */
inputVariables: Array<{ name: string; value: string }>
/** Variável de sessão onde o resultado de sucesso é gravado. */
outputVariable: string
/** Variável de sessão onde a mensagem de erro é gravada (apenas onError:"continue"). */
errorVariable?: string
/** "blocking" → congela a sessão / recupera via defaultException.
"continue" → registra o erro e segue o fluxo. */
onError: "blocking" | "continue"
/** Mensagem interpolável enviada ao contato quando onError=blocking. Sem valor usa a default. */
errorMessage?: string
/** Em onError=blocking: true/ausente faz handoff para humano;
false envia errorMessage e recupera no defaultExceptionBlockId. */
errorHandoff?: boolean
}Schema Zod (flow-schema.ts)
ts
const ExecuteScriptActionSchema = ActionBaseSchema.extend({
type: z.literal("executeScript"),
source: z.string().min(1),
inputVariables: z.array(z.object({ name: z.string().min(1), value: z.string() })),
outputVariable: z.string().min(1),
errorVariable: z.string().optional(),
onError: z.enum(["blocking", "continue"]),
})Nota: o schema Zod valida apenas os campos acima.
errorMessageeerrorHandoffexistem no tipo TypeScript (flow-definition.ts) mas não fazem parte doExecuteScriptActionSchema(flow-schema.ts).
Sandbox de execução (isolated-vm)
Por que isolated-vm e não vm ou worker_threads + vm
| Abordagem | Escape possível | Motivo |
|---|---|---|
vm.runInNewContext() | Sim | vm não é uma barreira de segurança (doc oficial do Node). ({}).constructor.constructor('return process')() funciona dentro do contexto. |
worker_threads + vm | Sim | O worker vive no mesmo processo do engine, com process.env contendo credenciais Redis/Postgres. Escape do vm chega ao worker. |
isolated-vm | Não | V8 Isolate com heap completamente separado. Sem ponteiros para o heap do host. Sem caminho de acesso a require, process ou qualquer global do Node. |
Arquivo: packages/flow-ai-runtime/src/sandbox.ts
Exportado como executeInSandbox em packages/flow-ai-runtime/src/index.ts.
executeInSandbox(
source: string,
inputs: Record<string, string>,
timeoutMs?: number // padrão: SANDBOX_TIMEOUT_MS (default 5000ms)
): Promise<string>Globals disponíveis no contexto do isolate (built-ins do próprio V8 — o sandbox não injeta nada além de expor globalThis):
Math — todos os métodos (abs, floor, ceil, round, max, min, pow, sqrt, random, PI...)
JSON — parse, stringify
Date — now, new Date()
parseInt / parseFloat / isNaN / isFinite
Object — keys, values, entries, assign, fromEntries
Array — isArray
String / Number / Boolean / RegExpAusentes (sem acesso): require, process, global, fetch, Buffer, console, __dirname, setTimeout, setInterval — nenhum global do host Node é exposto ao isolate.
Semáforo de concorrência: contador activeSandboxes limitado a MAX_PARALLEL (configurável via SANDBOX_MAX_PARALLEL, default 8). Exceder o limite lança imediatamente Error: Limite de {MAX_PARALLEL} scripts simultâneos atingido. Tente novamente em instantes.
Timeout: configurável via SANDBOX_TIMEOUT_MS (default 5000ms).
Limite de memória: SANDBOX_MEMORY_MB fixo em 32MB por isolate.
Assíncrono não é suportado: não há APIs assíncronas no isolate, e uma executeasync (ou que devolva uma Promise) falha com Script assíncrono não é suportado em vez de gravar {} em outputVariable.
Formato do source: arquivo completo ou corpo (#461)
Dois formatos convivem, sem flag no schema e sem migração de flow publicado:
| Formato | Como é escrito | Onde aparece |
|---|---|---|
| arquivo completo | o autor declara function execute(...) e pode declarar funções auxiliares, constantes e classes fora dela | formato oferecido pelo editor desde #461 |
| corpo (legado) | apenas o corpo de execute — o editor impunha a assinatura como cabeçalho fixo, não editável | todo script salvo antes de #461 |
O montador do programa (packages/flow-ai-runtime/src/sandbox-program.ts, exportado como buildSandboxProgram) serve os dois com o mesmo texto, e não decide por inspeção do código — regex em código de usuário erra (function execute dentro de comentário, de string, de um helper aninhado). Quem decide é o escopo do JS:
- O
sourceentra no corpo de umaexecutegerada, cujos parâmetros são asinputVariables. Um source no formato corpo funciona porque é literalmente o que sempre foi — inclusivereturnno topo e recursão chamandoexecute(...), que resolve para a própria função gerada. - Um source no formato arquivo completo declara a sua
execute. A declaração é hasteada no escopo do corpo da função gerada e sombreia o nome da gerada ali dentro. - A cauda apendada roda só quando o source não retornou nada — ou seja, no formato arquivo completo, onde só houve declarações. Ela compara
executecom a referência da gerada: se são diferentes, a do autor sombreou e é ela que deve ser chamada; se são iguais, ninguém declarou nada e não há o que chamar (era um corpo que caiu fora semreturn, caso que sempre gravou"").
Consequências práticas:
- As
inputVariablesficam visíveis por nome em todo o arquivo — inclusive dentro de funções auxiliares e mesmo que aexecutedo autor não as declare como parâmetro. É o que mantém verdadeiro odeclare const <input>: stringque o editor injeta no Monaco. - Os argumentos são passados por posição, na ordem das
inputVariables. - Um
const/letno topo do arquivo com o mesmo nome de umainputVariableé SyntaxError de redeclaração de parâmetro (já era assim no formato corpo).
O "Testar" do builder de tools (services/flow-ai-core/src/services/tool-block-tester.ts) usa o mesmo buildSandboxProgram — só o motor difere (node:vm no core, V8 Isolate no engine), para que o teste aceite exatamente o que a produção aceita.
Tratamento de erros
Quando a action roda como parte de um flow (engine), onError tem dois caminhos. Em blocking, o comportamento padrão depende de errorHandoff:
blocking(default —errorHandoffausente outrue): lançaRuntimeUserError, congela a sessão e faz handoff para atendimento humano (aerrorMessagecustom, se houver, acompanha o freeze).blocking+errorHandoff: false: envia aerrorMessage(ou a default) ao contato e redireciona para odefaultExceptionBlockIddo flow; a sessão permanece emmode: "flow".continue: gravaoutputVariable = ""e, se definido,errorVariable = mensagem; o fluxo segue normalmente.
Em ambos os caminhos de blocking, se errorVariable estiver definido a mensagem também é gravada nele (para que {{errorVariable}} resolva).
| Situação | onError: "blocking" | onError: "continue" |
|---|---|---|
| Script lança exceção | Freeze + handoff (default) / redireciona para defaultExceptionBlockId se errorHandoff:false | outputVariable = "", errorVariable = mensagem |
| Timeout (loop infinito) | idem acima (mensagem do isolate: Script execution timed out) | outputVariable = "", errorVariable = mensagem do timeout |
| Limite de scripts simultâneos atingido | idem acima | outputVariable = "", errorVariable = mensagem de limite |
| Memória excedida (>32MB) | idem acima | outputVariable = "", errorVariable = mensagem do isolate |
| Sucesso | outputVariable = resultado | outputVariable = resultado |
Nota: quando o
executeScriptroda dentro de uma Tool publicada (mini-flow, viaexecuteTool), o caminhoblockingé mais simples — o erro é apenas relançado (throw) e propaga para quem chamou a Tool; não há freeze/handoff nesse nível. Emcontinue, gravaerrorVariable/outputVariablenas variáveis da Tool.
Templates de script
Templates são globais da plataforma — não têm workspaceId.
Rotas backend (flow-ai-core)
Todas as rotas exigem autenticação (authenticate). O prefixo global FLOW_AI_CORE_BASE_PATH (default /api) é aplicado no boot, então os paths efetivos são /api/script-templates.
| Método | Path | Comportamento |
|---|---|---|
GET | /api/script-templates | Lista todos os templates ordenados por createdAt desc. |
POST | /api/script-templates | Cria template. Body: { name, description?, source }. Responde 201. |
DELETE | /api/script-templates/:id | Remove template. Responde 204. |
Guia de sintaxe para o usuário
O sandbox chama execute(...). Escreva o arquivo inteiro: declare function execute(...) e o que mais precisar fora dela. Cada item de inputVariables vira um argumento posicional (o name é o nome no escopo); seu value é interpolado ({{...}}) e injetado como string. Use return para produzir o valor salvo em outputVariable.
js
// inputVariables: [{ name: 'cpf', value: '{{cpf}}' }]
function execute(cpf) {
const digits = digitos(cpf)
return valido(digits) ? 'valido' : 'invalido'
}
function digitos(valor) {
return String(valor).replace(/\D/g, '')
}
function valido(digits) {
return digits.length === 11
}js
// inputVariables: [{ name: 'nome', value: '{{nome}}' }]
function execute(nome) {
return nome.trim().split(' ').map(capitaliza).join(' ')
}
function capitaliza(parte) {
return parte[0].toUpperCase() + parte.slice(1)
}Objetos e arrays retornados são serializados com JSON.stringify. Scripts que não fazem return (ou retornam undefined/null) gravam "" em outputVariable.
Formato antigo (só o corpo) continua valendo
Scripts salvos antes de #461 são apenas o corpo de execute e rodam sem alteração — não há migração a fazer. O editor identifica o formato e oferece um botão para envolver o código em function execute(...) quando você quiser passar a declarar funções auxiliares.
js
// inputVariables: [{ name: 'vencimento', value: '{{vencimento}}' }]
const diasRestantes = Math.ceil((new Date(vencimento) - Date.now()) / 86400000)
return String(diasRestantes)