n8n/packages/@n8n/instance-ai/src/tools/executions.tool.ts

281 lines
9.4 KiB
TypeScript

/**
* Consolidated executions tool — list, get, run, debug, get-node-output,
* get-resolved-node-parameters, stop.
*/
import { Tool } from '@n8n/agents';
import { instanceAiConfirmationSeveritySchema } from '@n8n/api-types';
import { nanoid } from 'nanoid';
import { z } from 'zod';
import { sanitizeInputSchema } from '../agent/sanitize-mcp-schemas';
import type { InstanceAiContext } from '../types';
// ── Constants ──────────────────────────────────────────────────────────────
const MAX_TIMEOUT_MS = 600_000;
// ── Action schemas ─────────────────────────────────────────────────────────
const listAction = z.object({
action: z.literal('list').describe('List recent workflow executions'),
workflowId: z.string().optional().describe('Workflow ID'),
status: z
.string()
.optional()
.describe('Filter by status (e.g. "success", "error", "running", "waiting")'),
limit: z
.number()
.int()
.positive()
.max(100)
.optional()
.describe('Max results to return (default 20)'),
});
const getAction = z.object({
action: z.literal('get').describe('Get execution status without blocking (poll running ones)'),
executionId: z.string().describe('Execution ID'),
});
const runAction = z.object({
action: z.literal('run').describe('Execute a workflow and wait for completion'),
workflowId: z.string().describe('Workflow ID'),
inputData: z
.record(z.unknown())
.optional()
.describe(
'Input data passed to the workflow trigger. Works for ANY trigger type — ' +
'the system injects inputData as the trigger node output, bypassing the need for a real event. ' +
'For webhook triggers, inputData is the request body (do NOT wrap in { body: ... }). ' +
'For event-based triggers (e.g. Linear, GitHub, Slack), pass inputData matching ' +
'the shape the trigger would emit (e.g. { action: "create", data: { ... } }).',
),
timeout: z
.number()
.int()
.min(1000)
.max(MAX_TIMEOUT_MS)
.optional()
.describe('Max wait time in milliseconds (default 300000, max 600000)'),
});
const debugAction = z.object({
action: z
.literal('debug')
.describe(
'Analyze a failed execution with structured diagnostics. When a node failed, ' +
"`failedNode.resolvedParameters` includes that node's raw parameters, the same " +
'tree with expressions substituted, and lists of expressions that threw or resolved to empty values',
),
executionId: z.string().describe('Execution ID'),
});
const getNodeOutputAction = z.object({
action: z
.literal('get-node-output')
.describe('Retrieve raw output of a specific node from an execution'),
executionId: z.string().describe('Execution ID'),
nodeName: z.string().describe("Name of the node (must exist in the execution's workflow)"),
startIndex: z.number().int().min(0).optional().describe('Item index to start from (default 0)'),
maxItems: z
.number()
.int()
.min(1)
.max(50)
.optional()
.describe('Maximum number of items to return (default 10, max 50)'),
});
const getResolvedNodeParametersAction = z.object({
action: z
.literal('get-resolved-node-parameters')
.describe(
"Replay expression resolution for a node's parameters against a past execution. " +
'Returns the raw `parameters` (with expressions intact), the `resolved` tree (same ' +
'shape, expressions substituted), `failedExpressions` (those that threw), and ' +
'`emptyResolutions` (those that resolved to `null`/`undefined`/`""` — the common ' +
'silent cause of empty downstream fields). Use this when debugging why a node ' +
'received an unexpected value or failed because of a parameter — far more precise ' +
'than guessing from raw expression strings or input data.',
),
executionId: z.string().describe('Execution ID'),
nodeName: z.string().describe("Name of the node (must exist in the execution's workflow)"),
itemIndex: z
.number()
.int()
.min(0)
.optional()
.describe('Input item index to resolve against (default 0)'),
runIndex: z
.number()
.int()
.min(0)
.optional()
.describe('Which run of the node to use, if it ran multiple times (default: last run)'),
});
const stopAction = z.object({
action: z.literal('stop').describe('Cancel a running workflow execution'),
executionId: z.string().describe('Execution ID'),
});
const inputSchema = sanitizeInputSchema(
z.discriminatedUnion('action', [
listAction,
getAction,
runAction,
debugAction,
getNodeOutputAction,
getResolvedNodeParametersAction,
stopAction,
]),
);
type Input = z.infer<typeof inputSchema>;
// ── Suspend / resume schemas (used by `run`) ───────────────────────────────
const suspendSchema = z.object({
requestId: z.string(),
message: z.string(),
severity: instanceAiConfirmationSeveritySchema,
});
const resumeSchema = z.object({
approved: z.boolean(),
});
// ── Handlers ───────────────────────────────────────────────────────────────
async function handleList(context: InstanceAiContext, input: Extract<Input, { action: 'list' }>) {
const executions = await context.executionService.list({
workflowId: input.workflowId,
status: input.status,
limit: input.limit,
});
return { executions };
}
async function handleGet(context: InstanceAiContext, input: Extract<Input, { action: 'get' }>) {
return await context.executionService.getStatus(input.executionId);
}
async function handleRun(
context: InstanceAiContext,
input: Extract<Input, { action: 'run' }>,
resumeData: z.infer<typeof resumeSchema> | undefined,
suspend: (payload: z.infer<typeof suspendSchema>) => Promise<never>,
) {
if (context.permissions?.runWorkflow === 'blocked') {
return {
executionId: '',
status: 'error' as const,
denied: true,
reason: 'Action blocked by admin',
};
}
// `always_allow` is only honored for the workflow IDs the caller pre-authorized
// (e.g. checkpoint follow-ups scope the override to the workflows the checkpoint
// is verifying). When the allow-list is unset, `always_allow` applies broadly,
// matching the legacy behavior.
const allowList = context.allowedRunWorkflowIds;
const allowedByScope =
context.permissions?.runWorkflow === 'always_allow' &&
(allowList === undefined || allowList.has(input.workflowId));
const needsApproval = !allowedByScope;
// If approval is required and this is the first call, suspend for confirmation
if (needsApproval && (resumeData === undefined || resumeData === null)) {
const workflowName = await context.workflowService
.get(input.workflowId)
.then((wf) => wf.name)
.catch(() => input.workflowId);
return await suspend({
requestId: nanoid(),
message: `Execute ${workflowName} (ID: ${input.workflowId})`,
severity: 'warning' as const,
});
}
// If resumed with denial
if (resumeData !== undefined && resumeData !== null && !resumeData.approved) {
return {
executionId: '',
status: 'error' as const,
denied: true,
reason: 'User denied the action',
};
}
// Approved or always_allow — execute
return await context.executionService.run(input.workflowId, input.inputData, {
timeout: input.timeout,
});
}
async function handleDebug(context: InstanceAiContext, input: Extract<Input, { action: 'debug' }>) {
return await context.executionService.getDebugInfo(input.executionId);
}
async function handleGetNodeOutput(
context: InstanceAiContext,
input: Extract<Input, { action: 'get-node-output' }>,
) {
return await context.executionService.getNodeOutput(input.executionId, input.nodeName, {
startIndex: input.startIndex,
maxItems: input.maxItems,
});
}
async function handleGetResolvedNodeParameters(
context: InstanceAiContext,
input: Extract<Input, { action: 'get-resolved-node-parameters' }>,
) {
return await context.executionService.getResolvedNodeParameters(
input.executionId,
input.nodeName,
{
itemIndex: input.itemIndex,
runIndex: input.runIndex,
},
);
}
async function handleStop(context: InstanceAiContext, input: Extract<Input, { action: 'stop' }>) {
return await context.executionService.stop(input.executionId);
}
// ── Tool factory ───────────────────────────────────────────────────────────
export function createExecutionsTool(context: InstanceAiContext) {
return new Tool('executions')
.description(
'Manage workflow executions — list, inspect, run, debug, get node output, ' +
'get resolved node parameters for a past run, and stop.',
)
.input(inputSchema)
.suspend(suspendSchema)
.resume(resumeSchema)
.handler(async (input: Input, ctx) => {
switch (input.action) {
case 'list':
return await handleList(context, input);
case 'get':
return await handleGet(context, input);
case 'run': {
return await handleRun(context, input, ctx.resumeData, ctx.suspend);
}
case 'debug':
return await handleDebug(context, input);
case 'get-node-output':
return await handleGetNodeOutput(context, input);
case 'get-resolved-node-parameters':
return await handleGetResolvedNodeParameters(context, input);
case 'stop':
return await handleStop(context, input);
}
})
.build();
}