first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as run from './run.operation';
|
||||
|
||||
export { run };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['agent'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Run',
|
||||
value: 'run',
|
||||
description: 'Run an Airtop agent',
|
||||
action: 'Run an agent',
|
||||
},
|
||||
],
|
||||
default: 'run',
|
||||
},
|
||||
...run.description,
|
||||
];
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { IDataObject, ResourceMapperField } from 'n8n-workflow';
|
||||
|
||||
export interface AgentSchemaProperty {
|
||||
description?: string;
|
||||
type: string;
|
||||
default?: unknown;
|
||||
}
|
||||
|
||||
export interface AirtopAgentSchema {
|
||||
$schema?: string;
|
||||
additionalProperties?: boolean;
|
||||
properties?: Record<string, AgentSchemaProperty>;
|
||||
required?: string[];
|
||||
type?: 'string' | 'number' | 'boolean' | 'object' | 'array';
|
||||
}
|
||||
|
||||
export interface AirtopAgentResponse extends IDataObject {
|
||||
agent: {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
versionData: {
|
||||
configVarsSchema?: AirtopAgentSchema;
|
||||
resultSchema?: AirtopAgentSchema;
|
||||
};
|
||||
webhookId: string;
|
||||
}
|
||||
|
||||
export interface AgentsListResponse extends IDataObject {
|
||||
agents: Array<AirtopAgentResponse['agent']>;
|
||||
}
|
||||
|
||||
export interface AgentParametersInput {
|
||||
value?: IDataObject;
|
||||
schema: ResourceMapperField[];
|
||||
}
|
||||
|
||||
export interface AgentInvocationResponse extends IDataObject {
|
||||
invocationId: string;
|
||||
}
|
||||
|
||||
export interface AgentResultResponse extends IDataObject {
|
||||
status: 'Completed' | 'Running' | 'Failed' | 'Unknown';
|
||||
output?: IDataObject;
|
||||
error?: string;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { IDataObject, IExecuteFunctions, ILoadOptionsFunctions, INode } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import type { AgentParametersInput, AgentResultResponse, AirtopAgentResponse } from './agent.types';
|
||||
import { AIRTOP_HOOKS_BASE_URL, BASE_URL_V2, ERROR_MESSAGES } from '../../constants';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
/**
|
||||
* Gets the agent input parameters schema.
|
||||
*/
|
||||
export async function getAgentDetails(
|
||||
this: IExecuteFunctions | ILoadOptionsFunctions,
|
||||
agentId: string,
|
||||
): Promise<AirtopAgentResponse> {
|
||||
return await apiRequest.call<
|
||||
IExecuteFunctions | ILoadOptionsFunctions,
|
||||
['GET', string],
|
||||
Promise<AirtopAgentResponse>
|
||||
>(this, 'GET', `${BASE_URL_V2}/agents/${agentId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the agent parameters with the schema.
|
||||
*/
|
||||
export function validateAgentParameters(
|
||||
this: IExecuteFunctions,
|
||||
params: AgentParametersInput,
|
||||
): IDataObject {
|
||||
const inputParameters = params?.value ?? {};
|
||||
const requiredParameters = (params?.schema ?? [])
|
||||
.filter((field) => field.required)
|
||||
.map((field) => field.id);
|
||||
// check for empty values on required fields
|
||||
const missingRequiredParameters = requiredParameters.filter((reqParam) => {
|
||||
return (
|
||||
inputParameters[reqParam] === undefined ||
|
||||
inputParameters[reqParam] === null ||
|
||||
inputParameters[reqParam] === ''
|
||||
);
|
||||
});
|
||||
if (missingRequiredParameters.length) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Missing required parameters: ${missingRequiredParameters.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { configVars: inputParameters };
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the agent status
|
||||
*/
|
||||
export async function getAgentStatus(
|
||||
this: IExecuteFunctions,
|
||||
agentId: string,
|
||||
invocationId: string,
|
||||
): Promise<AgentResultResponse> {
|
||||
const resultUrl = `${AIRTOP_HOOKS_BASE_URL}/agents/${agentId}/invocations/${invocationId}/result`;
|
||||
return await apiRequest.call<IExecuteFunctions, ['GET', string], Promise<AgentResultResponse>>(
|
||||
this,
|
||||
'GET',
|
||||
resultUrl,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls the agent execution status until it's completed or fails.
|
||||
*/
|
||||
export async function pollAgentStatus(
|
||||
this: IExecuteFunctions,
|
||||
agentId: string,
|
||||
invocationId: string,
|
||||
timeoutSeconds: number,
|
||||
): Promise<AgentResultResponse | undefined> {
|
||||
const airtopNode = this.getNode();
|
||||
const startTime = Date.now();
|
||||
const timeoutMs = timeoutSeconds * 1000;
|
||||
let response: AgentResultResponse | undefined;
|
||||
|
||||
this.logger.info(`[${airtopNode.name}] Polling agent status for invocationId: ${invocationId}`);
|
||||
|
||||
while (true) {
|
||||
const elapsed = Date.now() - startTime;
|
||||
throwOperationErrorIf(elapsed >= timeoutMs, ERROR_MESSAGES.TIMEOUT_REACHED, airtopNode);
|
||||
|
||||
response = await getAgentStatus.call(this, agentId, invocationId);
|
||||
|
||||
if (response?.output || response?.error) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// Wait one second before next poll
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws an operation error if the statement is true.
|
||||
*/
|
||||
export function throwOperationErrorIf(statement: boolean, message: string, node: INode) {
|
||||
if (statement) {
|
||||
throw new NodeOperationError(node, message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import type { AgentInvocationResponse, AgentParametersInput } from './agent.types';
|
||||
import {
|
||||
getAgentDetails,
|
||||
pollAgentStatus,
|
||||
throwOperationErrorIf,
|
||||
validateAgentParameters,
|
||||
} from './agent.utils';
|
||||
import { AGENT_MIN_TIMEOUT_SECONDS, AIRTOP_HOOKS_BASE_URL, ERROR_MESSAGES } from '../../constants';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['agent'],
|
||||
operation: ['run'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Agent',
|
||||
name: 'agentId',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: '' },
|
||||
required: true,
|
||||
description:
|
||||
'The Airtop agent to run. Visit <a href="https://portal.airtop.ai/agents" target="_blank">Airtop Agents</a> for more information.',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['agent'],
|
||||
operation: ['run'],
|
||||
},
|
||||
},
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
placeholder: 'Select an Agent...',
|
||||
typeOptions: {
|
||||
searchListMethod: 'listSearchAgents',
|
||||
searchFilterRequired: false,
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'By ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. agent_abc123',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '.+',
|
||||
errorMessage: 'Agent ID cannot be empty',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Agent Parameters',
|
||||
name: 'agentParameters',
|
||||
type: 'resourceMapper',
|
||||
noDataExpression: true,
|
||||
default: {
|
||||
mappingMode: 'defineBelow',
|
||||
value: null,
|
||||
},
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['agentId.value'],
|
||||
resourceMapper: {
|
||||
resourceMapperMethod: 'agentsResourceMapping',
|
||||
mode: 'map',
|
||||
supportAutoMap: false,
|
||||
addAllFields: true,
|
||||
noFieldsError: 'No input parameters found for the selected agent',
|
||||
multiKeyMatch: false,
|
||||
allowEmptyValues: true,
|
||||
fieldWords: {
|
||||
singular: 'parameter',
|
||||
plural: 'parameters',
|
||||
},
|
||||
},
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['agent'],
|
||||
operation: ['run'],
|
||||
},
|
||||
hide: {
|
||||
agentId: [''],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Await Agent',
|
||||
name: 'awaitExecution',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Whether to wait for the agent to complete its execution',
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
displayName: 'Timeout',
|
||||
name: 'timeout',
|
||||
type: 'number',
|
||||
default: 600,
|
||||
description: 'Timeout in seconds to wait for the agent to finish',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['agent'],
|
||||
operation: ['run'],
|
||||
awaitExecution: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const airtopNode = this.getNode();
|
||||
const agentId = this.getNodeParameter('agentId', index, '', {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const agentParameters = this.getNodeParameter(
|
||||
'agentParameters',
|
||||
index,
|
||||
{},
|
||||
) as AgentParametersInput;
|
||||
|
||||
const awaitExecution = this.getNodeParameter('awaitExecution', index, true) as boolean;
|
||||
|
||||
const timeout = this.getNodeParameter('timeout', index, 600) as number;
|
||||
|
||||
// Validate timeout
|
||||
throwOperationErrorIf(
|
||||
timeout < AGENT_MIN_TIMEOUT_SECONDS,
|
||||
ERROR_MESSAGES.AGENT_TIMEOUT_INVALID,
|
||||
airtopNode,
|
||||
);
|
||||
|
||||
// Convert fixedCollection parameters to API format
|
||||
const validatedAgentParameters = validateAgentParameters.call(this, agentParameters);
|
||||
|
||||
const { webhookId } = await getAgentDetails.call(this, agentId);
|
||||
const invokeUrl = `${AIRTOP_HOOKS_BASE_URL}/agents/${agentId}/webhooks/${webhookId}`;
|
||||
|
||||
const invocationResponse = await apiRequest.call<
|
||||
IExecuteFunctions,
|
||||
['POST', string, IDataObject],
|
||||
Promise<AgentInvocationResponse>
|
||||
>(this, 'POST', invokeUrl, validatedAgentParameters);
|
||||
|
||||
const invocationId = invocationResponse.invocationId;
|
||||
throwOperationErrorIf(
|
||||
!invocationId,
|
||||
"No 'invocationId' received from agent webhook response",
|
||||
airtopNode,
|
||||
);
|
||||
|
||||
if (!awaitExecution) {
|
||||
return this.helpers.returnJsonArray({
|
||||
invocationId,
|
||||
});
|
||||
}
|
||||
|
||||
// Poll for agent's execution status
|
||||
const result = await pollAgentStatus.call(this, agentId, invocationId, timeout);
|
||||
throwOperationErrorIf(
|
||||
Boolean(result?.error),
|
||||
`${result?.error ?? 'Unknown error'}. Agent Invocation ID: ${invocationId}`,
|
||||
airtopNode,
|
||||
);
|
||||
|
||||
return this.helpers.returnJsonArray({
|
||||
invocationId,
|
||||
status: result?.status ?? 'Unknown',
|
||||
output: result?.output ?? {},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user