mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-21 12:51:16 +08:00
fix(core): Cancel Instance AI workspace tools promptly on stop (#34551)
Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Robin Braumann <50590409+bjorger@users.noreply.github.com>
This commit is contained in:
co-authored by
Cursor
Robin Braumann
parent
f191548586
commit
267fd853cd
@@ -1,9 +1,12 @@
|
||||
import { BaseFilesystem } from '../../workspace/filesystem/base-filesystem';
|
||||
import type { BaseFilesystemOptions } from '../../workspace/filesystem/base-filesystem';
|
||||
import type {
|
||||
AbortableOptions,
|
||||
AppendOptions,
|
||||
FileContent,
|
||||
FileStat,
|
||||
FileEntry,
|
||||
MkdirOptions,
|
||||
ReadOptions,
|
||||
WriteOptions,
|
||||
ListOptions,
|
||||
@@ -43,7 +46,7 @@ class TestFilesystem extends BaseFilesystem {
|
||||
await this.ensureReady();
|
||||
}
|
||||
|
||||
async appendFile(_path: string, _content: FileContent): Promise<void> {
|
||||
async appendFile(_path: string, _content: FileContent, _options?: AppendOptions): Promise<void> {
|
||||
await this.ensureReady();
|
||||
}
|
||||
|
||||
@@ -59,7 +62,7 @@ class TestFilesystem extends BaseFilesystem {
|
||||
await this.ensureReady();
|
||||
}
|
||||
|
||||
async mkdir(_path: string, _options?: { recursive?: boolean }): Promise<void> {
|
||||
async mkdir(_path: string, _options?: MkdirOptions): Promise<void> {
|
||||
await this.ensureReady();
|
||||
}
|
||||
|
||||
@@ -72,12 +75,12 @@ class TestFilesystem extends BaseFilesystem {
|
||||
return [];
|
||||
}
|
||||
|
||||
async exists(_path: string): Promise<boolean> {
|
||||
async exists(_path: string, _options?: AbortableOptions): Promise<boolean> {
|
||||
await this.ensureReady();
|
||||
return false;
|
||||
}
|
||||
|
||||
async stat(_path: string): Promise<FileStat> {
|
||||
async stat(_path: string, _options?: AbortableOptions): Promise<FileStat> {
|
||||
await this.ensureReady();
|
||||
return {
|
||||
name: 'test',
|
||||
|
||||
@@ -538,6 +538,34 @@ describe('DaytonaSandbox (remote sandbox gone during refetch)', () => {
|
||||
await expect(sandbox.executeCommand('echo', ['hi'])).rejects.toThrow(/create failed/i);
|
||||
});
|
||||
|
||||
it('executeCommand() rejects without starting work when already aborted', async () => {
|
||||
const sandbox = new DaytonaSandbox({ name: 'thread-1', apiKey: 'key' });
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
await expect(
|
||||
sandbox.executeCommand('echo', ['hi'], { abortSignal: controller.signal }),
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
expect(clientLog).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('executeCommand() does not recover from AbortError', async () => {
|
||||
const failing = makeMockSandbox('sb-abort', 'started');
|
||||
const abortError = new Error('This operation was aborted');
|
||||
abortError.name = 'AbortError';
|
||||
failing.process.executeCommand = vi.fn().mockRejectedValue(abortError);
|
||||
queuedGetResults.push(failing);
|
||||
|
||||
const sandbox = new DaytonaSandbox({ name: 'thread-1', apiKey: 'key' });
|
||||
|
||||
await expect(sandbox.executeCommand('echo', ['hi'])).rejects.toMatchObject({
|
||||
name: 'AbortError',
|
||||
});
|
||||
expect(clientLog.every((c) => c.create.mock.calls.length === 0)).toBe(true);
|
||||
// Only the initial start get — no isRecoverable probe get after AbortError.
|
||||
expect(clientLog.reduce((n, c) => n + c.get.mock.calls.length, 0)).toBe(1);
|
||||
});
|
||||
|
||||
it('DaytonaFilesystem reuses the same recovery when the remote was deleted', async () => {
|
||||
const sandbox = await startAndStageRemoteGone();
|
||||
// findExistingSandbox() lookup also 404s during recovery → create a fresh sandbox.
|
||||
|
||||
@@ -2,6 +2,8 @@ import { BaseFilesystem } from '../../workspace/filesystem/base-filesystem';
|
||||
import { BaseSandbox } from '../../workspace/sandbox/base-sandbox';
|
||||
import { ProcessHandle, SandboxProcessManager } from '../../workspace/types';
|
||||
import type {
|
||||
AbortableOptions,
|
||||
AppendOptions,
|
||||
CommandResult,
|
||||
FileContent,
|
||||
FileEntry,
|
||||
@@ -73,7 +75,11 @@ export class InMemoryFilesystem extends BaseFilesystem {
|
||||
this.files.set(p, Buffer.from(content));
|
||||
}
|
||||
|
||||
async appendFile(filePath: string, content: FileContent): Promise<void> {
|
||||
async appendFile(
|
||||
filePath: string,
|
||||
content: FileContent,
|
||||
_options?: AppendOptions,
|
||||
): Promise<void> {
|
||||
await this.ensureReady();
|
||||
const p = this.normalizePath(filePath);
|
||||
const existing = this.files.get(p) ?? Buffer.alloc(0);
|
||||
@@ -169,13 +175,13 @@ export class InMemoryFilesystem extends BaseFilesystem {
|
||||
return entries;
|
||||
}
|
||||
|
||||
async exists(filePath: string): Promise<boolean> {
|
||||
async exists(filePath: string, _options?: AbortableOptions): Promise<boolean> {
|
||||
await this.ensureReady();
|
||||
const p = this.normalizePath(filePath);
|
||||
return this.files.has(p) || this.dirs.has(p);
|
||||
}
|
||||
|
||||
async stat(filePath: string): Promise<FileStat> {
|
||||
async stat(filePath: string, _options?: AbortableOptions): Promise<FileStat> {
|
||||
await this.ensureReady();
|
||||
const p = this.normalizePath(filePath);
|
||||
if (this.dirs.has(p)) {
|
||||
|
||||
@@ -114,7 +114,10 @@ describe('createWorkspaceTools', () => {
|
||||
|
||||
const result = await readTool.handler!({ path: '/test.txt', encoding: 'utf-8' }, {} as never);
|
||||
|
||||
expect(fs.readFile).toHaveBeenCalledWith('/test.txt', { encoding: 'utf-8' });
|
||||
expect(fs.readFile).toHaveBeenCalledWith('/test.txt', {
|
||||
encoding: 'utf-8',
|
||||
abortSignal: undefined,
|
||||
});
|
||||
expect(result).toEqual({ content: 'file content' });
|
||||
});
|
||||
|
||||
@@ -147,6 +150,7 @@ describe('createWorkspaceTools', () => {
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledWith('/test.txt', 'first\nchanged', {
|
||||
overwrite: true,
|
||||
abortSignal: undefined,
|
||||
});
|
||||
expect(result).toEqual({ success: true, result: 'Edit applied successfully.' });
|
||||
});
|
||||
@@ -174,6 +178,28 @@ describe('createWorkspaceTools', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('str_replace_file handler rethrows abort errors instead of soft-failing', async () => {
|
||||
const abortError = new Error('This operation was aborted');
|
||||
abortError.name = 'AbortError';
|
||||
const fs = makeFakeFilesystem({
|
||||
readFile: vi.fn().mockRejectedValue(abortError),
|
||||
});
|
||||
const tools = createWorkspaceTools({ filesystem: fs });
|
||||
const strReplaceTool = tools.find((t) => t.name === 'workspace_str_replace_file')!;
|
||||
|
||||
await expect(
|
||||
strReplaceTool.handler!(
|
||||
{
|
||||
path: '/test.txt',
|
||||
old_str: 'a',
|
||||
new_str: 'b',
|
||||
},
|
||||
{} as never,
|
||||
),
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('batch_str_replace_file handler applies all replacements atomically', async () => {
|
||||
const fs = makeFakeFilesystem({
|
||||
readFile: vi.fn().mockResolvedValue('const a = 1;\nconst b = 2;'),
|
||||
@@ -194,6 +220,7 @@ describe('createWorkspaceTools', () => {
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledWith('/test.ts', 'const a = 10;\nconst b = 20;', {
|
||||
overwrite: true,
|
||||
abortSignal: undefined,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
@@ -240,13 +267,17 @@ describe('createWorkspaceTools', () => {
|
||||
const fs = makeFakeFilesystem();
|
||||
const tools = createWorkspaceTools({ filesystem: fs });
|
||||
const writeTool = tools.find((t) => t.name === 'workspace_write_file')!;
|
||||
const abortController = new AbortController();
|
||||
|
||||
const result = await writeTool.handler!(
|
||||
{ path: '/out.txt', content: 'hello', recursive: true },
|
||||
{} as never,
|
||||
{ abortSignal: abortController.signal } as never,
|
||||
);
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledWith('/out.txt', 'hello', { recursive: true });
|
||||
expect(fs.writeFile).toHaveBeenCalledWith('/out.txt', 'hello', {
|
||||
recursive: true,
|
||||
abortSignal: abortController.signal,
|
||||
});
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
@@ -264,16 +295,18 @@ describe('createWorkspaceTools', () => {
|
||||
});
|
||||
const tools = createWorkspaceTools({ sandbox });
|
||||
const commandTool = tools.find((t) => t.name === 'workspace_execute_command')!;
|
||||
const abortController = new AbortController();
|
||||
|
||||
const result = await commandTool.handler!(
|
||||
{ command: 'node script.mjs', cwd: '/home/daytona/workspace' },
|
||||
{} as never,
|
||||
{ abortSignal: abortController.signal } as never,
|
||||
);
|
||||
|
||||
expect(executeCommand).toHaveBeenCalledWith('node script.mjs', undefined, {
|
||||
cwd: '/home/daytona/workspace',
|
||||
env: { CUSTOM_ENV: 'enabled' },
|
||||
timeout: undefined,
|
||||
abortSignal: abortController.signal,
|
||||
});
|
||||
expect(result).toMatchObject({ success: true, stdout: 'ok' });
|
||||
});
|
||||
@@ -285,7 +318,10 @@ describe('createWorkspaceTools', () => {
|
||||
|
||||
const result = await listTool.handler!({ path: '/', recursive: false }, {} as never);
|
||||
|
||||
expect(fs.readdir).toHaveBeenCalledWith('/', { recursive: false });
|
||||
expect(fs.readdir).toHaveBeenCalledWith('/', {
|
||||
recursive: false,
|
||||
abortSignal: undefined,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
entries: [
|
||||
{ name: 'file1.txt', type: 'file' },
|
||||
@@ -301,7 +337,7 @@ describe('createWorkspaceTools', () => {
|
||||
|
||||
const result = await statTool.handler!({ path: '/test.txt' }, {} as never);
|
||||
|
||||
expect(fs.stat).toHaveBeenCalledWith('/test.txt');
|
||||
expect(fs.stat).toHaveBeenCalledWith('/test.txt', { abortSignal: undefined });
|
||||
expect(result).toEqual({
|
||||
name: 'test.txt',
|
||||
path: '/test.txt',
|
||||
@@ -319,7 +355,10 @@ describe('createWorkspaceTools', () => {
|
||||
|
||||
const result = await mkdirTool.handler!({ path: '/new-dir', recursive: true }, {} as never);
|
||||
|
||||
expect(fs.mkdir).toHaveBeenCalledWith('/new-dir', { recursive: true });
|
||||
expect(fs.mkdir).toHaveBeenCalledWith('/new-dir', {
|
||||
recursive: true,
|
||||
abortSignal: undefined,
|
||||
});
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
@@ -333,7 +372,11 @@ describe('createWorkspaceTools', () => {
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(fs.deleteFile).toHaveBeenCalledWith('/old.txt', { recursive: false, force: true });
|
||||
expect(fs.deleteFile).toHaveBeenCalledWith('/old.txt', {
|
||||
recursive: false,
|
||||
force: true,
|
||||
abortSignal: undefined,
|
||||
});
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
@@ -347,7 +390,9 @@ describe('createWorkspaceTools', () => {
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(fs.appendFile).toHaveBeenCalledWith('/log.txt', 'new line');
|
||||
expect(fs.appendFile).toHaveBeenCalledWith('/log.txt', 'new line', {
|
||||
abortSignal: undefined,
|
||||
});
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
@@ -361,7 +406,10 @@ describe('createWorkspaceTools', () => {
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(fs.copyFile).toHaveBeenCalledWith('/a.txt', '/b.txt', { overwrite: true });
|
||||
expect(fs.copyFile).toHaveBeenCalledWith('/a.txt', '/b.txt', {
|
||||
overwrite: true,
|
||||
abortSignal: undefined,
|
||||
});
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
@@ -375,7 +423,10 @@ describe('createWorkspaceTools', () => {
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(fs.moveFile).toHaveBeenCalledWith('/old.txt', '/new.txt', { overwrite: false });
|
||||
expect(fs.moveFile).toHaveBeenCalledWith('/old.txt', '/new.txt', {
|
||||
overwrite: false,
|
||||
abortSignal: undefined,
|
||||
});
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
@@ -389,7 +440,11 @@ describe('createWorkspaceTools', () => {
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(fs.rmdir).toHaveBeenCalledWith('/old-dir', { recursive: true, force: false });
|
||||
expect(fs.rmdir).toHaveBeenCalledWith('/old-dir', {
|
||||
recursive: true,
|
||||
force: false,
|
||||
abortSignal: undefined,
|
||||
});
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
@@ -406,6 +461,7 @@ describe('createWorkspaceTools', () => {
|
||||
expect(sb.executeCommand).toHaveBeenCalledWith('echo hello', undefined, {
|
||||
cwd: '/tmp',
|
||||
timeout: 5000,
|
||||
abortSignal: undefined,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
|
||||
@@ -107,6 +107,12 @@ export {
|
||||
|
||||
export { createCancellation, isCancellation, CANCELLATION_TYPE } from './sdk/cancellation';
|
||||
export type { Cancellation } from './sdk/cancellation';
|
||||
export {
|
||||
createAbortError,
|
||||
isAbortError,
|
||||
raceWithAbort,
|
||||
throwIfAborted,
|
||||
} from './sdk/abort';
|
||||
export { Tool, wrapToolForApproval, sanitizeToolName } from './sdk/tool';
|
||||
export { Memory } from './sdk/memory';
|
||||
export { VectorStore } from './sdk/vector-store';
|
||||
@@ -396,11 +402,14 @@ export type {
|
||||
FileContent,
|
||||
FileStat,
|
||||
FileEntry,
|
||||
AbortableOptions,
|
||||
AppendOptions,
|
||||
ReadOptions,
|
||||
WriteOptions,
|
||||
ListOptions,
|
||||
RemoveOptions,
|
||||
CopyOptions,
|
||||
MkdirOptions,
|
||||
ProviderStatus,
|
||||
SandboxInfo,
|
||||
LocalFilesystemOptions,
|
||||
|
||||
@@ -180,13 +180,18 @@ export class McpConnection {
|
||||
return false;
|
||||
}
|
||||
|
||||
async callTool(name: string, args: Record<string, unknown>): Promise<McpCallToolResult> {
|
||||
async callTool(
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
options?: { abortSignal?: AbortSignal },
|
||||
): Promise<McpCallToolResult> {
|
||||
if (!this.client) throw new Error('MCP client not initialized; connect() must be called first');
|
||||
const { CallToolResultSchema } = await loadMcpSdk();
|
||||
try {
|
||||
const result = (await this.client.callTool(
|
||||
{ name, arguments: args },
|
||||
CallToolResultSchema,
|
||||
options?.abortSignal ? { signal: options.abortSignal } : undefined,
|
||||
)) as McpCallToolResult;
|
||||
await this.notifyToolCallSettled({ toolName: name, success: result.isError !== true });
|
||||
return result;
|
||||
|
||||
@@ -23,10 +23,12 @@ export class McpToolResolver {
|
||||
|
||||
const handler = async (
|
||||
input: unknown,
|
||||
_ctx: ToolContext | InterruptibleToolContext,
|
||||
ctx: ToolContext | InterruptibleToolContext,
|
||||
): Promise<unknown> => {
|
||||
const args = (input ?? {}) as Record<string, unknown>;
|
||||
return await connection.callTool(originalName, args);
|
||||
return await connection.callTool(originalName, args, {
|
||||
abortSignal: ctx.abortSignal,
|
||||
});
|
||||
};
|
||||
|
||||
const toMessage = (output: unknown): AgentMessage | undefined => {
|
||||
|
||||
@@ -188,11 +188,12 @@ export function createRecallMemoryTool(opts: {
|
||||
.systemInstruction(normalized.recallToolInstruction)
|
||||
.input(RecallMemoryInputSchema)
|
||||
.output(RecallMemoryOutputSchema)
|
||||
.handler(async ({ query }): Promise<RecallMemoryOutput> => {
|
||||
.handler(async ({ query }, ctx): Promise<RecallMemoryOutput> => {
|
||||
const { embed } = await import('ai');
|
||||
const { embedding: queryEmbedding, usage } = await embed({
|
||||
model: normalized.embedder,
|
||||
value: query,
|
||||
abortSignal: ctx.abortSignal,
|
||||
});
|
||||
incrementTokenCountFromUsage(opts.executionCounter, usage);
|
||||
const entries = await opts.memory.episodic.searchEntries(opts.scope, query, {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { toJsonValue } from '../json-value';
|
||||
import { DEFAULT_SUB_AGENT_MAX_CHILDREN } from './sub-agent-task-path';
|
||||
import { executeTool, isSuspendedToolResult, type SuspendedToolResult } from './tool-adapter';
|
||||
import { isAbortError, raceWithAbort } from '../../sdk/abort';
|
||||
import { isCancellation } from '../../sdk/cancellation';
|
||||
import { isLlmMessage } from '../../sdk/message';
|
||||
import type {
|
||||
@@ -145,12 +146,6 @@ function isDeniedApprovalResumeData(value: unknown): boolean {
|
||||
return value !== null && typeof value === 'object' && Reflect.get(value, 'approved') === false;
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
if (error.name === 'AbortError') return true;
|
||||
return error.message === 'Aborted' || error.message === 'This operation was aborted';
|
||||
}
|
||||
|
||||
function shouldEmitToolExecutionStart(tool: BuiltTool, resumeData: unknown): boolean {
|
||||
if (!tool.approval) return true;
|
||||
if (!tool.approval.required && tool.approval.conditional !== true) return true;
|
||||
@@ -800,14 +795,18 @@ export class ToolCallExecutor {
|
||||
input,
|
||||
resolvedTelemetry,
|
||||
async () =>
|
||||
await executeTool(input, builtTool, resumeData, resolvedTelemetry, toolCallId, {
|
||||
runId,
|
||||
persistence,
|
||||
emitEvent: (event) => this.eventBus.emit(event),
|
||||
await raceWithAbort(
|
||||
async () =>
|
||||
await executeTool(input, builtTool, resumeData, resolvedTelemetry, toolCallId, {
|
||||
runId,
|
||||
persistence,
|
||||
emitEvent: (event) => this.eventBus.emit(event),
|
||||
abortSignal,
|
||||
executionCounter,
|
||||
suspendPayload,
|
||||
}),
|
||||
abortSignal,
|
||||
executionCounter,
|
||||
suspendPayload,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createAbortError, isAbortError, raceWithAbort, throwIfAborted } from '../abort';
|
||||
|
||||
describe('abort helpers', () => {
|
||||
describe('isAbortError', () => {
|
||||
it('detects AbortError by name', () => {
|
||||
const error = new Error('stopped');
|
||||
error.name = 'AbortError';
|
||||
expect(isAbortError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('detects known abort messages', () => {
|
||||
expect(isAbortError(new Error('Aborted'))).toBe(true);
|
||||
expect(isAbortError(new Error('This operation was aborted'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects unrelated errors', () => {
|
||||
expect(isAbortError(new Error('disk full'))).toBe(false);
|
||||
expect(isAbortError('Aborted')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('throwIfAborted', () => {
|
||||
it('throws when the signal is already aborted', () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
expect(() => throwIfAborted(controller.signal)).toThrowError(
|
||||
expect.objectContaining({ name: 'AbortError' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('no-ops when the signal is still open', () => {
|
||||
expect(() => throwIfAborted(new AbortController().signal)).not.toThrow();
|
||||
expect(() => throwIfAborted(undefined)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('raceWithAbort', () => {
|
||||
it('resolves the work promise when no signal is provided', async () => {
|
||||
await expect(raceWithAbort(Promise.resolve('ok'))).resolves.toBe('ok');
|
||||
});
|
||||
|
||||
it('rejects immediately when the signal is already aborted', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
await expect(
|
||||
raceWithAbort(new Promise(() => undefined), controller.signal),
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
});
|
||||
|
||||
it('rejects promptly when the signal aborts during execution', async () => {
|
||||
const controller = new AbortController();
|
||||
const pending = raceWithAbort(new Promise(() => undefined), controller.signal);
|
||||
controller.abort('Agent run was aborted');
|
||||
|
||||
await expect(pending).rejects.toMatchObject({
|
||||
name: 'AbortError',
|
||||
message: 'Agent run was aborted',
|
||||
});
|
||||
});
|
||||
|
||||
it('removes the abort listener when work wins the race', async () => {
|
||||
const controller = new AbortController();
|
||||
const removeSpy = vi.spyOn(controller.signal, 'removeEventListener');
|
||||
|
||||
for (let i = 0; i < 12; i++) {
|
||||
await expect(raceWithAbort(Promise.resolve(i), controller.signal)).resolves.toBe(i);
|
||||
}
|
||||
|
||||
expect(removeSpy).toHaveBeenCalledTimes(12);
|
||||
expect(removeSpy.mock.calls.every(([type]) => type === 'abort')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not start factory work when the signal is already aborted', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const work = vi.fn().mockResolvedValue('started');
|
||||
|
||||
await expect(raceWithAbort(work, controller.signal)).rejects.toMatchObject({
|
||||
name: 'AbortError',
|
||||
});
|
||||
expect(work).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns createAbortError for string reasons', () => {
|
||||
const error = createAbortError('stopped by user');
|
||||
expect(error).toMatchObject({ name: 'AbortError', message: 'stopped by user' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Abort helpers for agent runs and long-running tool / sandbox work.
|
||||
* Stop should unblock the executor even when underlying I/O does not cancel.
|
||||
*/
|
||||
|
||||
export function isAbortError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
if (error.name === 'AbortError') return true;
|
||||
return error.message === 'Aborted' || error.message === 'This operation was aborted';
|
||||
}
|
||||
|
||||
export function createAbortError(reason?: unknown): Error {
|
||||
if (reason instanceof Error) return reason;
|
||||
const error = new Error(typeof reason === 'string' ? reason : 'This operation was aborted');
|
||||
error.name = 'AbortError';
|
||||
return error;
|
||||
}
|
||||
|
||||
/** Throw if the given signal has already fired. */
|
||||
export function throwIfAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) {
|
||||
throw createAbortError(signal.reason);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Race work against an abort signal so Stop settles promptly even when the
|
||||
* underlying work ignores cancellation. Pass a factory when work must not start
|
||||
* until after the abort check (e.g. sandbox recover/retry). Cooperative callers
|
||||
* should still forward `abortSignal` into I/O where the provider supports it.
|
||||
*
|
||||
* The abort listener is always removed when the race settles so run-scoped
|
||||
* signals do not accumulate listeners across nested tool calls.
|
||||
*/
|
||||
export async function raceWithAbort<T>(
|
||||
work: Promise<T> | (() => Promise<T>),
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const run = typeof work === 'function' ? work : async () => await work;
|
||||
if (!signal) {
|
||||
return await run();
|
||||
}
|
||||
throwIfAborted(signal);
|
||||
|
||||
let onAbort!: () => void;
|
||||
const rejection = new Promise<never>((_, reject) => {
|
||||
onAbort = () => {
|
||||
reject(createAbortError(signal.reason));
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([run(), rejection]);
|
||||
} finally {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,14 @@ import type {
|
||||
FileContent,
|
||||
FileStat,
|
||||
FileEntry,
|
||||
AbortableOptions,
|
||||
AppendOptions,
|
||||
ReadOptions,
|
||||
WriteOptions,
|
||||
ListOptions,
|
||||
RemoveOptions,
|
||||
CopyOptions,
|
||||
MkdirOptions,
|
||||
} from '../types';
|
||||
|
||||
export type FilesystemLifecycleHook = (args: {
|
||||
@@ -139,13 +142,13 @@ export abstract class BaseFilesystem implements WorkspaceFilesystem {
|
||||
|
||||
abstract readFile(path: string, options?: ReadOptions): Promise<string | Buffer>;
|
||||
abstract writeFile(path: string, content: FileContent, options?: WriteOptions): Promise<void>;
|
||||
abstract appendFile(path: string, content: FileContent): Promise<void>;
|
||||
abstract appendFile(path: string, content: FileContent, options?: AppendOptions): Promise<void>;
|
||||
abstract deleteFile(path: string, options?: RemoveOptions): Promise<void>;
|
||||
abstract copyFile(src: string, dest: string, options?: CopyOptions): Promise<void>;
|
||||
abstract moveFile(src: string, dest: string, options?: CopyOptions): Promise<void>;
|
||||
abstract mkdir(path: string, options?: { recursive?: boolean }): Promise<void>;
|
||||
abstract mkdir(path: string, options?: MkdirOptions): Promise<void>;
|
||||
abstract rmdir(path: string, options?: RemoveOptions): Promise<void>;
|
||||
abstract readdir(path: string, options?: ListOptions): Promise<FileEntry[]>;
|
||||
abstract exists(path: string): Promise<boolean>;
|
||||
abstract stat(path: string): Promise<FileStat>;
|
||||
abstract exists(path: string, options?: AbortableOptions): Promise<boolean>;
|
||||
abstract stat(path: string, options?: AbortableOptions): Promise<FileStat>;
|
||||
}
|
||||
|
||||
@@ -8,11 +8,14 @@
|
||||
* Without this adapter, Daytona workspaces only get sandbox tools (execute_command).
|
||||
*/
|
||||
import type {
|
||||
AbortableOptions,
|
||||
AppendOptions,
|
||||
CopyOptions,
|
||||
FileContent,
|
||||
FileEntry,
|
||||
FileStat,
|
||||
ListOptions,
|
||||
MkdirOptions,
|
||||
ProviderStatus,
|
||||
ReadOptions,
|
||||
RemoveOptions,
|
||||
@@ -43,9 +46,12 @@ export class DaytonaFilesystem extends BaseFilesystem {
|
||||
* sandbox is running with fresh auth and recovers once if the remote was stopped or
|
||||
* deleted while idle, so callers never touch a stale `fs` handle directly.
|
||||
*/
|
||||
private async withFs<T>(op: (fs: DaytonaFsHandle) => Promise<T>): Promise<T> {
|
||||
private async withFs<T>(
|
||||
op: (fs: DaytonaFsHandle) => Promise<T>,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
await this.ensureReady();
|
||||
return await this.sandbox.withFilesystem(op);
|
||||
return await this.sandbox.withFilesystem(op, { abortSignal });
|
||||
}
|
||||
|
||||
async readFile(path: string, options?: ReadOptions): Promise<string | Buffer> {
|
||||
@@ -55,7 +61,7 @@ export class DaytonaFilesystem extends BaseFilesystem {
|
||||
return buffer.toString(options.encoding);
|
||||
}
|
||||
return buffer;
|
||||
});
|
||||
}, options?.abortSignal);
|
||||
}
|
||||
|
||||
async writeFile(path: string, content: FileContent, options?: WriteOptions): Promise<void> {
|
||||
@@ -69,10 +75,10 @@ export class DaytonaFilesystem extends BaseFilesystem {
|
||||
const buffer =
|
||||
typeof content === 'string' ? Buffer.from(content, 'utf-8') : Buffer.from(content);
|
||||
await fs.uploadFile(buffer, path);
|
||||
});
|
||||
}, options?.abortSignal);
|
||||
}
|
||||
|
||||
async appendFile(path: string, content: FileContent): Promise<void> {
|
||||
async appendFile(path: string, content: FileContent, options?: AppendOptions): Promise<void> {
|
||||
await this.withFs(async (fs) => {
|
||||
let existing: Buffer;
|
||||
try {
|
||||
@@ -86,34 +92,40 @@ export class DaytonaFilesystem extends BaseFilesystem {
|
||||
const append =
|
||||
typeof content === 'string' ? Buffer.from(content, 'utf-8') : Buffer.from(content);
|
||||
await fs.uploadFile(Buffer.concat([existing, append]), path);
|
||||
});
|
||||
}, options?.abortSignal);
|
||||
}
|
||||
|
||||
async deleteFile(path: string, options?: RemoveOptions): Promise<void> {
|
||||
await this.withFs(async (fs) => await fs.deleteFile(path, options?.recursive));
|
||||
await this.withFs(
|
||||
async (fs) => await fs.deleteFile(path, options?.recursive),
|
||||
options?.abortSignal,
|
||||
);
|
||||
}
|
||||
|
||||
async copyFile(src: string, dest: string, _options?: CopyOptions): Promise<void> {
|
||||
async copyFile(src: string, dest: string, options?: CopyOptions): Promise<void> {
|
||||
await this.withFs(async (fs) => {
|
||||
const content = await fs.downloadFile(src);
|
||||
await fs.uploadFile(content, dest);
|
||||
});
|
||||
}, options?.abortSignal);
|
||||
}
|
||||
|
||||
async moveFile(src: string, dest: string, _options?: CopyOptions): Promise<void> {
|
||||
await this.withFs(async (fs) => await fs.moveFiles(src, dest));
|
||||
async moveFile(src: string, dest: string, options?: CopyOptions): Promise<void> {
|
||||
await this.withFs(async (fs) => await fs.moveFiles(src, dest), options?.abortSignal);
|
||||
}
|
||||
|
||||
async mkdir(path: string, _options?: { recursive?: boolean }): Promise<void> {
|
||||
async mkdir(path: string, options?: MkdirOptions): Promise<void> {
|
||||
// createFolder with mode '755' creates intermediate dirs
|
||||
await this.withFs(async (fs) => await fs.createFolder(path, '755'));
|
||||
await this.withFs(async (fs) => await fs.createFolder(path, '755'), options?.abortSignal);
|
||||
}
|
||||
|
||||
async rmdir(path: string, options?: RemoveOptions): Promise<void> {
|
||||
await this.withFs(async (fs) => await fs.deleteFile(path, options?.recursive ?? false));
|
||||
await this.withFs(
|
||||
async (fs) => await fs.deleteFile(path, options?.recursive ?? false),
|
||||
options?.abortSignal,
|
||||
);
|
||||
}
|
||||
|
||||
async readdir(path: string, _options?: ListOptions): Promise<FileEntry[]> {
|
||||
async readdir(path: string, options?: ListOptions): Promise<FileEntry[]> {
|
||||
return await this.withFs(async (fs) => {
|
||||
const files = await fs.listFiles(path);
|
||||
return files.map((f) => ({
|
||||
@@ -121,10 +133,10 @@ export class DaytonaFilesystem extends BaseFilesystem {
|
||||
type: f.isDir ? ('directory' as const) : ('file' as const),
|
||||
size: f.size,
|
||||
}));
|
||||
});
|
||||
}, options?.abortSignal);
|
||||
}
|
||||
|
||||
async exists(path: string): Promise<boolean> {
|
||||
async exists(path: string, options?: AbortableOptions): Promise<boolean> {
|
||||
return await this.withFs(async (fs) => {
|
||||
try {
|
||||
await fs.getFileDetails(path);
|
||||
@@ -136,10 +148,10 @@ export class DaytonaFilesystem extends BaseFilesystem {
|
||||
if (isDaytona404(error)) return false;
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}, options?.abortSignal);
|
||||
}
|
||||
|
||||
async stat(path: string): Promise<FileStat> {
|
||||
async stat(path: string, options?: AbortableOptions): Promise<FileStat> {
|
||||
return await this.withFs(async (fs) => {
|
||||
let info;
|
||||
try {
|
||||
@@ -158,7 +170,7 @@ export class DaytonaFilesystem extends BaseFilesystem {
|
||||
createdAt: new Date(info.modTime ?? 0),
|
||||
modifiedAt: new Date(info.modTime ?? 0),
|
||||
};
|
||||
});
|
||||
}, options?.abortSignal);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { SandboxServiceError } from '@n8n/sandbox-client';
|
||||
import { dirname } from 'node:path/posix';
|
||||
|
||||
import { raceWithAbort } from '../../sdk/abort';
|
||||
import type {
|
||||
AbortableOptions,
|
||||
AppendOptions,
|
||||
CopyOptions,
|
||||
FileContent,
|
||||
FileEntry,
|
||||
FileStat,
|
||||
ListOptions,
|
||||
MkdirOptions,
|
||||
ProviderStatus,
|
||||
ReadOptions,
|
||||
RemoveOptions,
|
||||
@@ -35,98 +39,110 @@ export class N8nSandboxFilesystem extends BaseFilesystem {
|
||||
this.id = `n8n-sandbox-fs-${sandbox.id}`;
|
||||
}
|
||||
|
||||
private async getClientAndSandboxId() {
|
||||
await this.sandbox.ensureRunning();
|
||||
private async getClientAndSandboxId(abortSignal?: AbortSignal) {
|
||||
await this.sandbox.ensureRunning({ abortSignal });
|
||||
return {
|
||||
client: this.sandbox.getClient(),
|
||||
sandboxId: this.sandbox.id,
|
||||
};
|
||||
}
|
||||
|
||||
async readFile(path: string, options?: ReadOptions): Promise<string | Buffer> {
|
||||
private async withSandbox<T>(
|
||||
abortSignal: AbortSignal | undefined,
|
||||
op: (
|
||||
client: ReturnType<N8nSandboxServiceSandbox['getClient']>,
|
||||
sandboxId: string,
|
||||
) => Promise<T>,
|
||||
): Promise<T> {
|
||||
await this.ensureReady();
|
||||
const { client, sandboxId } = await this.getClientAndSandboxId();
|
||||
const content = await client.readFile(sandboxId, path);
|
||||
if (options?.encoding) {
|
||||
return content.toString(options.encoding);
|
||||
}
|
||||
return content;
|
||||
return await raceWithAbort(async () => {
|
||||
const { client, sandboxId } = await this.getClientAndSandboxId(abortSignal);
|
||||
return await op(client, sandboxId);
|
||||
}, abortSignal);
|
||||
}
|
||||
|
||||
async readFile(path: string, options?: ReadOptions): Promise<string | Buffer> {
|
||||
return await this.withSandbox(options?.abortSignal, async (client, sandboxId) => {
|
||||
const content = await client.readFile(sandboxId, path);
|
||||
if (options?.encoding) {
|
||||
return content.toString(options.encoding);
|
||||
}
|
||||
return content;
|
||||
});
|
||||
}
|
||||
|
||||
async writeFile(path: string, content: FileContent, options?: WriteOptions): Promise<void> {
|
||||
await this.ensureReady();
|
||||
const { client, sandboxId } = await this.getClientAndSandboxId();
|
||||
if (options?.recursive) {
|
||||
const parent = getParentDirectory(path);
|
||||
if (parent) {
|
||||
await client.mkdir(sandboxId, parent, true);
|
||||
await this.withSandbox(options?.abortSignal, async (client, sandboxId) => {
|
||||
if (options?.recursive) {
|
||||
const parent = getParentDirectory(path);
|
||||
if (parent) {
|
||||
await client.mkdir(sandboxId, parent, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
await client.writeFile(sandboxId, path, content, options?.overwrite ?? true);
|
||||
await client.writeFile(sandboxId, path, content, options?.overwrite ?? true);
|
||||
});
|
||||
}
|
||||
|
||||
async appendFile(path: string, content: FileContent): Promise<void> {
|
||||
await this.ensureReady();
|
||||
const { client, sandboxId } = await this.getClientAndSandboxId();
|
||||
await client.appendFile(sandboxId, path, content);
|
||||
async appendFile(path: string, content: FileContent, options?: AppendOptions): Promise<void> {
|
||||
await this.withSandbox(options?.abortSignal, async (client, sandboxId) => {
|
||||
await client.appendFile(sandboxId, path, content);
|
||||
});
|
||||
}
|
||||
|
||||
async deleteFile(path: string, options?: RemoveOptions): Promise<void> {
|
||||
await this.ensureReady();
|
||||
const { client, sandboxId } = await this.getClientAndSandboxId();
|
||||
await client.deleteFile(sandboxId, path, {
|
||||
recursive: options?.recursive,
|
||||
force: options?.force,
|
||||
await this.withSandbox(options?.abortSignal, async (client, sandboxId) => {
|
||||
await client.deleteFile(sandboxId, path, {
|
||||
recursive: options?.recursive,
|
||||
force: options?.force,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async copyFile(src: string, dest: string, options?: CopyOptions): Promise<void> {
|
||||
await this.ensureReady();
|
||||
const { client, sandboxId } = await this.getClientAndSandboxId();
|
||||
await client.copyFile(sandboxId, {
|
||||
src,
|
||||
dest,
|
||||
recursive: options?.recursive,
|
||||
overwrite: options?.overwrite,
|
||||
await this.withSandbox(options?.abortSignal, async (client, sandboxId) => {
|
||||
await client.copyFile(sandboxId, {
|
||||
src,
|
||||
dest,
|
||||
recursive: options?.recursive,
|
||||
overwrite: options?.overwrite,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async moveFile(src: string, dest: string, options?: CopyOptions): Promise<void> {
|
||||
await this.ensureReady();
|
||||
const { client, sandboxId } = await this.getClientAndSandboxId();
|
||||
await client.moveFile(sandboxId, {
|
||||
src,
|
||||
dest,
|
||||
overwrite: options?.overwrite,
|
||||
await this.withSandbox(options?.abortSignal, async (client, sandboxId) => {
|
||||
await client.moveFile(sandboxId, {
|
||||
src,
|
||||
dest,
|
||||
overwrite: options?.overwrite,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async mkdir(path: string, options?: { recursive?: boolean }): Promise<void> {
|
||||
await this.ensureReady();
|
||||
const { client, sandboxId } = await this.getClientAndSandboxId();
|
||||
await client.mkdir(sandboxId, path, options?.recursive ?? false);
|
||||
async mkdir(path: string, options?: MkdirOptions): Promise<void> {
|
||||
await this.withSandbox(options?.abortSignal, async (client, sandboxId) => {
|
||||
await client.mkdir(sandboxId, path, options?.recursive ?? false);
|
||||
});
|
||||
}
|
||||
|
||||
async rmdir(path: string, options?: RemoveOptions): Promise<void> {
|
||||
await this.deleteFile(path, options);
|
||||
}
|
||||
|
||||
async readdir(path: string, _options?: ListOptions): Promise<FileEntry[]> {
|
||||
await this.ensureReady();
|
||||
const { client, sandboxId } = await this.getClientAndSandboxId();
|
||||
const files = await client.listFiles(sandboxId, { path });
|
||||
return files.map((entry) => ({
|
||||
name: entry.name,
|
||||
type: entry.isDir ? 'directory' : 'file',
|
||||
size: entry.size,
|
||||
}));
|
||||
async readdir(path: string, options?: ListOptions): Promise<FileEntry[]> {
|
||||
return await this.withSandbox(options?.abortSignal, async (client, sandboxId) => {
|
||||
const files = await client.listFiles(sandboxId, { path });
|
||||
return files.map((entry) => ({
|
||||
name: entry.name,
|
||||
type: entry.isDir ? 'directory' : 'file',
|
||||
size: entry.size,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async exists(path: string): Promise<boolean> {
|
||||
await this.ensureReady();
|
||||
async exists(path: string, options?: AbortableOptions): Promise<boolean> {
|
||||
try {
|
||||
const { client, sandboxId } = await this.getClientAndSandboxId();
|
||||
await client.stat(sandboxId, path);
|
||||
await this.stat(path, options);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof SandboxServiceError && error.status === 404) {
|
||||
@@ -136,17 +152,17 @@ export class N8nSandboxFilesystem extends BaseFilesystem {
|
||||
}
|
||||
}
|
||||
|
||||
async stat(path: string): Promise<FileStat> {
|
||||
await this.ensureReady();
|
||||
const { client, sandboxId } = await this.getClientAndSandboxId();
|
||||
const stat = await client.stat(sandboxId, path);
|
||||
return {
|
||||
name: stat.name,
|
||||
path: stat.path,
|
||||
type: stat.type,
|
||||
size: stat.size,
|
||||
createdAt: new Date(stat.createdAt),
|
||||
modifiedAt: new Date(stat.modifiedAt),
|
||||
};
|
||||
async stat(path: string, options?: AbortableOptions): Promise<FileStat> {
|
||||
return await this.withSandbox(options?.abortSignal, async (client, sandboxId) => {
|
||||
const stat = await client.stat(sandboxId, path);
|
||||
return {
|
||||
name: stat.name,
|
||||
path: stat.path,
|
||||
type: stat.type,
|
||||
size: stat.size,
|
||||
createdAt: new Date(stat.createdAt),
|
||||
modifiedAt: new Date(stat.modifiedAt),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,11 +19,14 @@ export type {
|
||||
FileContent,
|
||||
FileStat,
|
||||
FileEntry,
|
||||
AbortableOptions,
|
||||
AppendOptions,
|
||||
ReadOptions,
|
||||
WriteOptions,
|
||||
ListOptions,
|
||||
RemoveOptions,
|
||||
CopyOptions,
|
||||
MkdirOptions,
|
||||
ProviderStatus,
|
||||
SandboxInfo,
|
||||
LocalFilesystemOptions,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { raceWithAbort } from '../../sdk/abort';
|
||||
import type {
|
||||
AbortableOptions,
|
||||
ProviderStatus,
|
||||
WorkspaceSandbox,
|
||||
BaseSandboxOptions,
|
||||
@@ -118,7 +120,7 @@ export abstract class BaseSandbox implements WorkspaceSandbox {
|
||||
this.status = 'pending';
|
||||
}
|
||||
|
||||
async ensureRunning(): Promise<void> {
|
||||
async ensureRunning(options?: AbortableOptions): Promise<void> {
|
||||
if (this.status === 'destroyed') {
|
||||
throw new Error(`Sandbox "${this.name}" has been destroyed`);
|
||||
}
|
||||
@@ -130,7 +132,7 @@ export abstract class BaseSandbox implements WorkspaceSandbox {
|
||||
if (this.stopPromise) await this.stopPromise.catch(() => {});
|
||||
}
|
||||
if (this.status !== 'running') {
|
||||
await this._start();
|
||||
await raceWithAbort(async () => await this._start(), options?.abortSignal);
|
||||
}
|
||||
if (this.status !== 'running') {
|
||||
throw new Error(`Sandbox "${this.name}" failed to start (status: ${this.status})`);
|
||||
@@ -142,16 +144,20 @@ export abstract class BaseSandbox implements WorkspaceSandbox {
|
||||
args?: string[],
|
||||
options?: ExecuteCommandOptions,
|
||||
): Promise<CommandResult> {
|
||||
await this.ensureRunning();
|
||||
await this.ensureRunning({ abortSignal: options?.abortSignal });
|
||||
if (!this.processes) {
|
||||
throw new Error(`Sandbox "${this.name}" has no process manager`);
|
||||
}
|
||||
const fullCommand = args?.length ? `${command} ${args.map(shellQuote).join(' ')}` : command;
|
||||
const handle = await this.processes.spawn(fullCommand, options);
|
||||
return await handle.wait({
|
||||
onStdout: options?.onStdout,
|
||||
onStderr: options?.onStderr,
|
||||
});
|
||||
return await raceWithAbort(
|
||||
async () =>
|
||||
await handle.wait({
|
||||
onStdout: options?.onStdout,
|
||||
onStderr: options?.onStderr,
|
||||
}),
|
||||
options?.abortSignal,
|
||||
);
|
||||
}
|
||||
|
||||
getInstructions(): string {
|
||||
|
||||
@@ -10,7 +10,14 @@ import type {
|
||||
} from '@daytona/sdk';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type { CommandResult, ExecuteCommandOptions, ProviderStatus, SandboxInfo } from '../types';
|
||||
import { isAbortError, raceWithAbort } from '../../sdk/abort';
|
||||
import type {
|
||||
AbortableOptions,
|
||||
CommandResult,
|
||||
ExecuteCommandOptions,
|
||||
ProviderStatus,
|
||||
SandboxInfo,
|
||||
} from '../types';
|
||||
import { BaseSandbox } from './base-sandbox';
|
||||
import { DaytonaAuthManager } from './daytona-auth-manager';
|
||||
import { loadDaytona } from './lazy-daytona';
|
||||
@@ -195,30 +202,32 @@ export class DaytonaSandbox extends BaseSandbox {
|
||||
args: string[] = [],
|
||||
options?: ExecuteCommandOptions,
|
||||
): Promise<CommandResult> {
|
||||
return await this.recoverAndRetry(async () => {
|
||||
await this.ensureRunning();
|
||||
await this.ensureAuthFresh();
|
||||
const startedAt = Date.now();
|
||||
const fullCommand = toShellCommand(command, args);
|
||||
const result = await this.instance.process.executeCommand(
|
||||
fullCommand,
|
||||
options?.cwd,
|
||||
this.compactEnv(options?.env),
|
||||
Math.ceil((options?.timeout ?? this.timeout) / 1000),
|
||||
);
|
||||
const stdout = result.artifacts?.stdout ?? result.result ?? '';
|
||||
if (stdout) options?.onStdout?.(stdout);
|
||||
return await raceWithAbort(async () => {
|
||||
return await this.recoverAndRetry(async () => {
|
||||
await this.ensureRunning({ abortSignal: options?.abortSignal });
|
||||
await this.ensureAuthFresh();
|
||||
const startedAt = Date.now();
|
||||
const fullCommand = toShellCommand(command, args);
|
||||
const result = await this.instance.process.executeCommand(
|
||||
fullCommand,
|
||||
options?.cwd,
|
||||
this.compactEnv(options?.env),
|
||||
Math.ceil((options?.timeout ?? this.timeout) / 1000),
|
||||
);
|
||||
const stdout = result.artifacts?.stdout ?? result.result ?? '';
|
||||
if (stdout) options?.onStdout?.(stdout);
|
||||
|
||||
return {
|
||||
command,
|
||||
args,
|
||||
success: result.exitCode === 0,
|
||||
exitCode: result.exitCode,
|
||||
stdout,
|
||||
stderr: '',
|
||||
executionTimeMs: Date.now() - startedAt,
|
||||
};
|
||||
});
|
||||
return {
|
||||
command,
|
||||
args,
|
||||
success: result.exitCode === 0,
|
||||
exitCode: result.exitCode,
|
||||
stdout,
|
||||
stderr: '',
|
||||
executionTimeMs: Date.now() - startedAt,
|
||||
};
|
||||
});
|
||||
}, options?.abortSignal);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -227,12 +236,17 @@ export class DaytonaSandbox extends BaseSandbox {
|
||||
* stopped/deleted while idle. Lets `DaytonaFilesystem` reuse the same recovery as
|
||||
* `executeCommand` without reaching into private state.
|
||||
*/
|
||||
async withFilesystem<T>(op: (fs: Sandbox['fs']) => Promise<T>): Promise<T> {
|
||||
return await this.recoverAndRetry(async () => {
|
||||
await this.ensureRunning();
|
||||
await this.ensureAuthFresh();
|
||||
return await op(this.instance.fs);
|
||||
});
|
||||
async withFilesystem<T>(
|
||||
op: (fs: Sandbox['fs']) => Promise<T>,
|
||||
options?: AbortableOptions,
|
||||
): Promise<T> {
|
||||
return await raceWithAbort(async () => {
|
||||
return await this.recoverAndRetry(async () => {
|
||||
await this.ensureRunning({ abortSignal: options?.abortSignal });
|
||||
await this.ensureAuthFresh();
|
||||
return await op(this.instance.fs);
|
||||
});
|
||||
}, options?.abortSignal);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -349,6 +363,7 @@ export class DaytonaSandbox extends BaseSandbox {
|
||||
try {
|
||||
return await op();
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) throw error;
|
||||
if (!(await this.isRecoverable(error))) throw error;
|
||||
await this.recover();
|
||||
return await op();
|
||||
|
||||
@@ -14,7 +14,11 @@ export {
|
||||
getWorkspaceRoot,
|
||||
type SandboxWorkspace,
|
||||
} from './workspace-root';
|
||||
export { runInSandbox, type SandboxCommandTarget } from './run-in-sandbox';
|
||||
export {
|
||||
runInSandbox,
|
||||
type RunInSandboxOptions,
|
||||
type SandboxCommandTarget,
|
||||
} from './run-in-sandbox';
|
||||
export { loadDaytona } from './lazy-daytona';
|
||||
export { createFilesystem, createSandbox } from './create-workspace';
|
||||
export type {
|
||||
|
||||
@@ -117,7 +117,7 @@ export class N8nSandboxServiceSandbox extends BaseSandbox {
|
||||
args: string[] = [],
|
||||
options?: ExecuteCommandOptions,
|
||||
): Promise<CommandResult> {
|
||||
await this.ensureRunning();
|
||||
await this.ensureRunning({ abortSignal: options?.abortSignal });
|
||||
const result = await this.client.exec(this.requireSandboxId(), {
|
||||
command: toShellCommand(command, args),
|
||||
env: this.compactEnv(options?.env),
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
import { raceWithAbort } from '../../sdk/abort';
|
||||
|
||||
interface SandboxCommandResult {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export interface RunInSandboxOptions {
|
||||
cwd?: string;
|
||||
abortSignal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface SandboxCommandTarget {
|
||||
sandbox?: {
|
||||
provider?: string;
|
||||
executeCommand?: (
|
||||
command: string,
|
||||
args?: string[],
|
||||
options?: { cwd?: string },
|
||||
options?: { cwd?: string; abortSignal?: AbortSignal },
|
||||
) => Promise<SandboxCommandResult>;
|
||||
processes?: {
|
||||
spawn: (
|
||||
command: string,
|
||||
options?: { cwd?: string },
|
||||
options?: { cwd?: string; abortSignal?: AbortSignal },
|
||||
) => Promise<{ wait: () => Promise<SandboxCommandResult> }>;
|
||||
};
|
||||
};
|
||||
@@ -24,23 +31,33 @@ export interface SandboxCommandTarget {
|
||||
/**
|
||||
* Execute a shell command in the sandbox and wait for completion.
|
||||
* Tries `executeCommand` first, falls back to `processes.spawn` + wait.
|
||||
*
|
||||
* The third argument may be a cwd string (legacy) or {@link RunInSandboxOptions}.
|
||||
*/
|
||||
export async function runInSandbox(
|
||||
workspace: SandboxCommandTarget,
|
||||
command: string,
|
||||
cwd?: string,
|
||||
cwdOrOptions?: string | RunInSandboxOptions,
|
||||
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||||
const options: RunInSandboxOptions =
|
||||
typeof cwdOrOptions === 'string' ? { cwd: cwdOrOptions } : (cwdOrOptions ?? {});
|
||||
const sandbox = workspace.sandbox;
|
||||
if (!sandbox) throw new Error('Workspace has no sandbox');
|
||||
|
||||
if (sandbox.executeCommand) {
|
||||
const result = await sandbox.executeCommand(command, [], { cwd });
|
||||
const result = await sandbox.executeCommand(command, [], {
|
||||
cwd: options.cwd,
|
||||
abortSignal: options.abortSignal,
|
||||
});
|
||||
return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr };
|
||||
}
|
||||
|
||||
if (sandbox.processes) {
|
||||
const handle = await sandbox.processes.spawn(command, { cwd });
|
||||
const result = await handle.wait();
|
||||
const handle = await sandbox.processes.spawn(command, {
|
||||
cwd: options.cwd,
|
||||
abortSignal: options.abortSignal,
|
||||
});
|
||||
const result = await raceWithAbort(async () => await handle.wait(), options.abortSignal);
|
||||
return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr };
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,10 @@ export function createAppendFileTool(filesystem: WorkspaceFilesystem): BuiltTool
|
||||
success: z.boolean().describe('Whether the append was successful'),
|
||||
}),
|
||||
)
|
||||
.handler(async (input) => {
|
||||
await filesystem.appendFile(input.path, input.content);
|
||||
.handler(async (input, ctx) => {
|
||||
await filesystem.appendFile(input.path, input.content, {
|
||||
abortSignal: ctx.abortSignal,
|
||||
});
|
||||
return { success: true };
|
||||
})
|
||||
.build();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TextEditorDocument, type BatchReplaceResult } from '@n8n/ai-utilities/generic-text-editor';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { isAbortError } from '../../sdk/abort';
|
||||
import { Tool } from '../../sdk/tool';
|
||||
import type { BuiltTool } from '../../types/sdk/tool';
|
||||
import type { WorkspaceFilesystem } from '../types';
|
||||
@@ -56,9 +57,12 @@ export function createBatchStrReplaceFileTool(filesystem: WorkspaceFilesystem):
|
||||
)
|
||||
.input(inputSchema)
|
||||
.output(outputSchema)
|
||||
.handler(async (input) => {
|
||||
.handler(async (input, ctx) => {
|
||||
try {
|
||||
const content = await filesystem.readFile(input.path, { encoding: 'utf-8' });
|
||||
const content = await filesystem.readFile(input.path, {
|
||||
encoding: 'utf-8',
|
||||
abortSignal: ctx.abortSignal,
|
||||
});
|
||||
const editor = new TextEditorDocument({ initialText: content.toString() });
|
||||
const result = editor.executeBatch(input.replacements);
|
||||
|
||||
@@ -71,9 +75,13 @@ export function createBatchStrReplaceFileTool(filesystem: WorkspaceFilesystem):
|
||||
throw new Error(`File "${input.path}" is not loaded.`);
|
||||
}
|
||||
|
||||
await filesystem.writeFile(input.path, editedContent, { overwrite: true });
|
||||
await filesystem.writeFile(input.path, editedContent, {
|
||||
overwrite: true,
|
||||
abortSignal: ctx.abortSignal,
|
||||
});
|
||||
return { success: true, result };
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) throw error;
|
||||
return createErrorOutput(error);
|
||||
}
|
||||
})
|
||||
|
||||
@@ -22,8 +22,11 @@ export function createCopyFileTool(filesystem: WorkspaceFilesystem): BuiltTool {
|
||||
success: z.boolean().describe('Whether the copy was successful'),
|
||||
}),
|
||||
)
|
||||
.handler(async (input) => {
|
||||
await filesystem.copyFile(input.src, input.dest, { overwrite: input.overwrite });
|
||||
.handler(async (input, ctx) => {
|
||||
await filesystem.copyFile(input.src, input.dest, {
|
||||
overwrite: input.overwrite,
|
||||
abortSignal: ctx.abortSignal,
|
||||
});
|
||||
return { success: true };
|
||||
})
|
||||
.build();
|
||||
|
||||
@@ -22,8 +22,12 @@ export function createDeleteFileTool(filesystem: WorkspaceFilesystem): BuiltTool
|
||||
success: z.boolean().describe('Whether the deletion was successful'),
|
||||
}),
|
||||
)
|
||||
.handler(async (input) => {
|
||||
await filesystem.deleteFile(input.path, { recursive: input.recursive, force: input.force });
|
||||
.handler(async (input, ctx) => {
|
||||
await filesystem.deleteFile(input.path, {
|
||||
recursive: input.recursive,
|
||||
force: input.force,
|
||||
abortSignal: ctx.abortSignal,
|
||||
});
|
||||
return { success: true };
|
||||
})
|
||||
.build();
|
||||
|
||||
@@ -23,7 +23,7 @@ export function createExecuteCommandTool(sandbox: WorkspaceSandbox): BuiltTool {
|
||||
executionTimeMs: z.number(),
|
||||
}),
|
||||
)
|
||||
.handler(async (input) => {
|
||||
.handler(async (input, ctx) => {
|
||||
if (!sandbox.executeCommand) {
|
||||
throw new Error('Sandbox does not support command execution');
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export function createExecuteCommandTool(sandbox: WorkspaceSandbox): BuiltTool {
|
||||
cwd: input.cwd,
|
||||
...(env ? { env } : {}),
|
||||
timeout: input.timeout,
|
||||
abortSignal: ctx.abortSignal,
|
||||
});
|
||||
return {
|
||||
success: result.success,
|
||||
|
||||
@@ -22,8 +22,10 @@ export function createFileStatTool(filesystem: WorkspaceFilesystem): BuiltTool {
|
||||
modifiedAt: z.string(),
|
||||
}),
|
||||
)
|
||||
.handler(async (input) => {
|
||||
const stat = await filesystem.stat(input.path);
|
||||
.handler(async (input, ctx) => {
|
||||
const stat = await filesystem.stat(input.path, {
|
||||
abortSignal: ctx.abortSignal,
|
||||
});
|
||||
return {
|
||||
name: stat.name,
|
||||
path: stat.path,
|
||||
|
||||
@@ -26,8 +26,11 @@ export function createListFilesTool(filesystem: WorkspaceFilesystem): BuiltTool
|
||||
.describe('List of file entries'),
|
||||
}),
|
||||
)
|
||||
.handler(async (input) => {
|
||||
const entries = await filesystem.readdir(input.path, { recursive: input.recursive });
|
||||
.handler(async (input, ctx) => {
|
||||
const entries = await filesystem.readdir(input.path, {
|
||||
recursive: input.recursive,
|
||||
abortSignal: ctx.abortSignal,
|
||||
});
|
||||
return { entries };
|
||||
})
|
||||
.build();
|
||||
|
||||
@@ -18,8 +18,11 @@ export function createMkdirTool(filesystem: WorkspaceFilesystem): BuiltTool {
|
||||
success: z.boolean().describe('Whether the directory was created'),
|
||||
}),
|
||||
)
|
||||
.handler(async (input) => {
|
||||
await filesystem.mkdir(input.path, { recursive: input.recursive });
|
||||
.handler(async (input, ctx) => {
|
||||
await filesystem.mkdir(input.path, {
|
||||
recursive: input.recursive,
|
||||
abortSignal: ctx.abortSignal,
|
||||
});
|
||||
return { success: true };
|
||||
})
|
||||
.build();
|
||||
|
||||
@@ -22,8 +22,11 @@ export function createMoveFileTool(filesystem: WorkspaceFilesystem): BuiltTool {
|
||||
success: z.boolean().describe('Whether the move was successful'),
|
||||
}),
|
||||
)
|
||||
.handler(async (input) => {
|
||||
await filesystem.moveFile(input.src, input.dest, { overwrite: input.overwrite });
|
||||
.handler(async (input, ctx) => {
|
||||
await filesystem.moveFile(input.src, input.dest, {
|
||||
overwrite: input.overwrite,
|
||||
abortSignal: ctx.abortSignal,
|
||||
});
|
||||
return { success: true };
|
||||
})
|
||||
.build();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { raceWithAbort } from '../../sdk/abort';
|
||||
import { Tool } from '../../sdk/tool';
|
||||
import type { BuiltTool } from '../../types/sdk/tool';
|
||||
import type { SandboxProcessManager } from '../types';
|
||||
@@ -19,8 +20,8 @@ export function createListProcessesTool(processes: SandboxProcessManager): Built
|
||||
),
|
||||
}),
|
||||
)
|
||||
.handler(async () => {
|
||||
const list = await processes.list();
|
||||
.handler(async (_input, ctx) => {
|
||||
const list = await raceWithAbort(async () => await processes.list(), ctx.abortSignal);
|
||||
return { processes: list };
|
||||
})
|
||||
.build();
|
||||
@@ -35,8 +36,11 @@ export function createKillProcessTool(processes: SandboxProcessManager): BuiltTo
|
||||
}),
|
||||
)
|
||||
.output(z.object({ killed: z.boolean() }))
|
||||
.handler(async (input) => {
|
||||
const killed = await processes.kill(input.pid);
|
||||
.handler(async (input, ctx) => {
|
||||
const killed = await raceWithAbort(
|
||||
async () => await processes.kill(input.pid),
|
||||
ctx.abortSignal,
|
||||
);
|
||||
return { killed };
|
||||
})
|
||||
.build();
|
||||
|
||||
@@ -18,9 +18,10 @@ export function createReadFileTool(filesystem: WorkspaceFilesystem): BuiltTool {
|
||||
content: z.string().describe('File content'),
|
||||
}),
|
||||
)
|
||||
.handler(async (input) => {
|
||||
.handler(async (input, ctx) => {
|
||||
const content = await filesystem.readFile(input.path, {
|
||||
encoding: (input.encoding ?? 'utf-8') as BufferEncoding,
|
||||
abortSignal: ctx.abortSignal,
|
||||
});
|
||||
return { content: content.toString() };
|
||||
})
|
||||
|
||||
@@ -22,8 +22,12 @@ export function createRmdirTool(filesystem: WorkspaceFilesystem): BuiltTool {
|
||||
success: z.boolean().describe('Whether the directory was removed'),
|
||||
}),
|
||||
)
|
||||
.handler(async (input) => {
|
||||
await filesystem.rmdir(input.path, { recursive: input.recursive, force: input.force });
|
||||
.handler(async (input, ctx) => {
|
||||
await filesystem.rmdir(input.path, {
|
||||
recursive: input.recursive,
|
||||
force: input.force,
|
||||
abortSignal: ctx.abortSignal,
|
||||
});
|
||||
return { success: true };
|
||||
})
|
||||
.build();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TextEditorDocument } from '@n8n/ai-utilities/generic-text-editor';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { isAbortError } from '../../sdk/abort';
|
||||
import { Tool } from '../../sdk/tool';
|
||||
import type { BuiltTool } from '../../types/sdk/tool';
|
||||
import type { WorkspaceFilesystem } from '../types';
|
||||
@@ -33,9 +34,12 @@ export function createStrReplaceFileTool(filesystem: WorkspaceFilesystem): Built
|
||||
)
|
||||
.input(inputSchema)
|
||||
.output(outputSchema)
|
||||
.handler(async (input) => {
|
||||
.handler(async (input, ctx) => {
|
||||
try {
|
||||
const content = await filesystem.readFile(input.path, { encoding: 'utf-8' });
|
||||
const content = await filesystem.readFile(input.path, {
|
||||
encoding: 'utf-8',
|
||||
abortSignal: ctx.abortSignal,
|
||||
});
|
||||
const editor = new TextEditorDocument({ initialText: content.toString() });
|
||||
const result = editor.execute({
|
||||
command: 'str_replace',
|
||||
@@ -48,9 +52,13 @@ export function createStrReplaceFileTool(filesystem: WorkspaceFilesystem): Built
|
||||
throw new Error(`File "${input.path}" is not loaded.`);
|
||||
}
|
||||
|
||||
await filesystem.writeFile(input.path, editedContent, { overwrite: true });
|
||||
await filesystem.writeFile(input.path, editedContent, {
|
||||
overwrite: true,
|
||||
abortSignal: ctx.abortSignal,
|
||||
});
|
||||
return { success: true, result };
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) throw error;
|
||||
return createErrorOutput(error);
|
||||
}
|
||||
})
|
||||
|
||||
@@ -22,8 +22,11 @@ export function createWriteFileTool(filesystem: WorkspaceFilesystem): BuiltTool
|
||||
success: z.boolean().describe('Whether the write was successful'),
|
||||
}),
|
||||
)
|
||||
.handler(async (input) => {
|
||||
await filesystem.writeFile(input.path, input.content, { recursive: input.recursive });
|
||||
.handler(async (input, ctx) => {
|
||||
await filesystem.writeFile(input.path, input.content, {
|
||||
recursive: input.recursive,
|
||||
abortSignal: ctx.abortSignal,
|
||||
});
|
||||
return { success: true };
|
||||
})
|
||||
.build();
|
||||
|
||||
@@ -29,30 +29,42 @@ export interface FileStat {
|
||||
modifiedAt: Date;
|
||||
}
|
||||
|
||||
export interface ReadOptions {
|
||||
/** Shared abort option for workspace filesystem / sandbox ops. */
|
||||
export interface AbortableOptions {
|
||||
/** When aborted, in-flight workspace work should settle promptly. */
|
||||
abortSignal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ReadOptions extends AbortableOptions {
|
||||
encoding?: BufferEncoding;
|
||||
}
|
||||
|
||||
export interface WriteOptions {
|
||||
export interface WriteOptions extends AbortableOptions {
|
||||
recursive?: boolean;
|
||||
overwrite?: boolean;
|
||||
}
|
||||
|
||||
export interface ListOptions {
|
||||
export interface ListOptions extends AbortableOptions {
|
||||
recursive?: boolean;
|
||||
extension?: string;
|
||||
}
|
||||
|
||||
export interface RemoveOptions {
|
||||
export interface RemoveOptions extends AbortableOptions {
|
||||
recursive?: boolean;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface CopyOptions {
|
||||
export interface CopyOptions extends AbortableOptions {
|
||||
overwrite?: boolean;
|
||||
recursive?: boolean;
|
||||
}
|
||||
|
||||
export interface MkdirOptions extends AbortableOptions {
|
||||
recursive?: boolean;
|
||||
}
|
||||
|
||||
export type AppendOptions = AbortableOptions;
|
||||
|
||||
export interface MountConfig {
|
||||
type: 'local';
|
||||
basePath: string;
|
||||
@@ -68,15 +80,15 @@ export interface WorkspaceFilesystem {
|
||||
|
||||
readFile(path: string, options?: ReadOptions): Promise<string | Buffer>;
|
||||
writeFile(path: string, content: FileContent, options?: WriteOptions): Promise<void>;
|
||||
appendFile(path: string, content: FileContent): Promise<void>;
|
||||
appendFile(path: string, content: FileContent, options?: AppendOptions): Promise<void>;
|
||||
deleteFile(path: string, options?: RemoveOptions): Promise<void>;
|
||||
copyFile(src: string, dest: string, options?: CopyOptions): Promise<void>;
|
||||
moveFile(src: string, dest: string, options?: CopyOptions): Promise<void>;
|
||||
mkdir(path: string, options?: { recursive?: boolean }): Promise<void>;
|
||||
mkdir(path: string, options?: MkdirOptions): Promise<void>;
|
||||
rmdir(path: string, options?: RemoveOptions): Promise<void>;
|
||||
readdir(path: string, options?: ListOptions): Promise<FileEntry[]>;
|
||||
exists(path: string): Promise<boolean>;
|
||||
stat(path: string): Promise<FileStat>;
|
||||
exists(path: string, options?: AbortableOptions): Promise<boolean>;
|
||||
stat(path: string, options?: AbortableOptions): Promise<FileStat>;
|
||||
|
||||
init?(): Promise<void>;
|
||||
destroy?(): Promise<void>;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { isSafeObjectKey } from '@n8n/api-types';
|
||||
|
||||
import { makeToolAbortable } from '../tools/shared/abortable-tool';
|
||||
import type { InstanceAiToolRegistry } from '../types';
|
||||
|
||||
type McpToolRegistry = InstanceAiToolRegistry;
|
||||
@@ -74,7 +73,7 @@ export function addSafeMcpTools(
|
||||
);
|
||||
}
|
||||
options.claimedToolNames.set(normalizedName, name);
|
||||
target.set(name, makeToolAbortable(tool));
|
||||
target.set(name, tool);
|
||||
} catch (error) {
|
||||
if (error instanceof McpToolNameValidationError) {
|
||||
options.warn?.(error);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { BuiltTool } from '@n8n/agents';
|
||||
|
||||
import { makeToolAbortable } from './tools/shared/abortable-tool';
|
||||
import type { InstanceAiToolRegistry } from './types';
|
||||
|
||||
export function createToolRegistry(
|
||||
@@ -8,7 +7,7 @@ export function createToolRegistry(
|
||||
): InstanceAiToolRegistry {
|
||||
const registry = new Map<string, BuiltTool>();
|
||||
for (const [name, tool] of entries) {
|
||||
registry.set(name, makeToolAbortable(tool));
|
||||
registry.set(name, tool);
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
@@ -16,7 +15,7 @@ export function createToolRegistry(
|
||||
export function createToolRegistryFromTools(tools: Iterable<BuiltTool>): InstanceAiToolRegistry {
|
||||
const registry = createToolRegistry();
|
||||
for (const tool of tools) {
|
||||
registry.set(tool.name, makeToolAbortable(tool));
|
||||
registry.set(tool.name, tool);
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
@@ -28,7 +27,7 @@ export function mergeToolRegistries(
|
||||
for (const registry of registries) {
|
||||
if (!registry) continue;
|
||||
for (const [name, tool] of registry) {
|
||||
merged.set(name, makeToolAbortable(tool));
|
||||
merged.set(name, tool);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
|
||||
@@ -202,6 +202,7 @@ async function handleRun(
|
||||
input: Extract<Input, { action: 'run' }>,
|
||||
resumeData: z.infer<typeof resumeSchema> | undefined,
|
||||
suspend: (payload: z.infer<typeof suspendSchema>) => Promise<never>,
|
||||
abortSignal?: AbortSignal,
|
||||
) {
|
||||
if (context.permissions?.runWorkflow === 'blocked') {
|
||||
return {
|
||||
@@ -292,6 +293,7 @@ async function handleRun(
|
||||
// Approved or always_allow — execute
|
||||
return await context.executionService.run(workflowId, input.inputData, {
|
||||
timeout: input.timeout,
|
||||
abortSignal,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -347,7 +349,7 @@ export function createExecutionsTool(context: InstanceAiContext) {
|
||||
case 'get':
|
||||
return await handleGet(context, input);
|
||||
case 'run': {
|
||||
return await handleRun(context, input, ctx.resumeData, ctx.suspend);
|
||||
return await handleRun(context, input, ctx.resumeData, ctx.suspend, ctx.abortSignal);
|
||||
}
|
||||
case 'debug':
|
||||
return await handleDebug(context, input);
|
||||
|
||||
+21
-12
@@ -337,10 +337,13 @@ describe('createToolsFromLocalMcpServer', () => {
|
||||
const result = await execute({ filePath: 'test.ts' }, makeCtx({}));
|
||||
|
||||
expect(result).toEqual(SUCCESS_RESULT);
|
||||
expect(server.callTool).toHaveBeenCalledWith({
|
||||
name: 'write_file',
|
||||
arguments: { filePath: 'test.ts' },
|
||||
});
|
||||
expect(server.callTool).toHaveBeenCalledWith(
|
||||
{
|
||||
name: 'write_file',
|
||||
arguments: { filePath: 'test.ts' },
|
||||
},
|
||||
{ abortSignal: undefined },
|
||||
);
|
||||
});
|
||||
|
||||
it('strips _confirmation from LLM-provided args on the first-call path', async () => {
|
||||
@@ -350,10 +353,13 @@ describe('createToolsFromLocalMcpServer', () => {
|
||||
|
||||
await execute({ filePath: 'test.ts', _confirmation: 'injected-token' }, makeCtx({}));
|
||||
|
||||
expect(server.callTool).toHaveBeenCalledWith({
|
||||
name: 'write_file',
|
||||
arguments: { filePath: 'test.ts' },
|
||||
});
|
||||
expect(server.callTool).toHaveBeenCalledWith(
|
||||
{
|
||||
name: 'write_file',
|
||||
arguments: { filePath: 'test.ts' },
|
||||
},
|
||||
{ abortSignal: undefined },
|
||||
);
|
||||
});
|
||||
|
||||
it('passes through a generic error result unchanged', async () => {
|
||||
@@ -444,10 +450,13 @@ describe('createToolsFromLocalMcpServer', () => {
|
||||
);
|
||||
|
||||
expect(result).toEqual(SUCCESS_RESULT);
|
||||
expect(server.callTool).toHaveBeenCalledWith({
|
||||
name: 'write_file',
|
||||
arguments: { filePath: 'test.ts', _confirmation: 'allowForSession' },
|
||||
});
|
||||
expect(server.callTool).toHaveBeenCalledWith(
|
||||
{
|
||||
name: 'write_file',
|
||||
arguments: { filePath: 'test.ts', _confirmation: 'allowForSession' },
|
||||
},
|
||||
{ abortSignal: undefined },
|
||||
);
|
||||
});
|
||||
|
||||
it('returns access-denied error when resumeData has no token (user denied)', async () => {
|
||||
|
||||
@@ -338,16 +338,22 @@ export function createToolsFromLocalMcpServer(
|
||||
};
|
||||
}
|
||||
// Re-call the daemon with the user's decision
|
||||
return await server.callTool({
|
||||
name: toolName,
|
||||
arguments: { ...args, _confirmation: resumeData.resourceDecision },
|
||||
});
|
||||
return await server.callTool(
|
||||
{
|
||||
name: toolName,
|
||||
arguments: { ...args, _confirmation: resumeData.resourceDecision },
|
||||
},
|
||||
{ abortSignal: ctx.abortSignal },
|
||||
);
|
||||
}
|
||||
|
||||
// First-call path: strip any LLM-provided _confirmation key so the agent
|
||||
// cannot bypass the human confirmation flow by supplying its own token.
|
||||
const { _confirmation: _stripped, ...safeArgs } = args;
|
||||
const result = await server.callTool({ name: toolName, arguments: safeArgs });
|
||||
const result = await server.callTool(
|
||||
{ name: toolName, arguments: safeArgs },
|
||||
{ abortSignal: ctx.abortSignal },
|
||||
);
|
||||
|
||||
// If the daemon requires a resource-access confirmation, suspend the agent
|
||||
if (result.isError) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Tool } from '@n8n/agents';
|
||||
import { isAbortError, Tool } from '@n8n/agents';
|
||||
|
||||
import type { InstanceAiContext } from '../types';
|
||||
import {
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
type N8nDocsReadInput,
|
||||
type N8nDocsSearchInput,
|
||||
} from './n8n-docs/schemas';
|
||||
import { isAbortError } from './shared/abortable-tool';
|
||||
import { N8N_DOCS_TOOL_ID } from './tool-ids';
|
||||
|
||||
export { N8N_DOCS_TOOL_ID };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { isAbortError } from '@n8n/agents';
|
||||
|
||||
import type { Logger } from '../../logger';
|
||||
import { isAbortError } from '../shared/abortable-tool';
|
||||
import { sanitizeWebContent, wrapUntrustedData } from '../web-research/sanitize-web-content';
|
||||
|
||||
const N8N_DOCS_ORIGIN = 'https://docs.n8n.io';
|
||||
|
||||
@@ -162,6 +162,7 @@ export function createVerifyBuiltWorkflowTool(context: OrchestrationContext) {
|
||||
{
|
||||
timeout: resolvedInput.timeout,
|
||||
verificationPinData: prepared.verificationPinData,
|
||||
abortSignal: context.abortSignal,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import type { BuiltTool, ToolContext } from '@n8n/agents';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
createAbortError,
|
||||
isAbortError,
|
||||
makeToolAbortable,
|
||||
throwIfAborted,
|
||||
withAbortableToolHandler,
|
||||
} from '../abortable-tool';
|
||||
|
||||
function makeCtx(signal?: AbortSignal): ToolContext {
|
||||
return { abortSignal: signal };
|
||||
}
|
||||
|
||||
describe('abortable-tool', () => {
|
||||
describe('isAbortError', () => {
|
||||
it('detects AbortError by name', () => {
|
||||
expect(isAbortError(createAbortError())).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects unrelated errors', () => {
|
||||
expect(isAbortError(new Error('boom'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('throwIfAborted', () => {
|
||||
it('throws when the signal is already aborted', () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
expect(() => throwIfAborted(makeCtx(controller.signal))).toThrowError(
|
||||
expect.objectContaining({ name: 'AbortError' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('no-ops when the signal is still open', () => {
|
||||
expect(() => throwIfAborted(makeCtx(new AbortController().signal))).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('withAbortableToolHandler', () => {
|
||||
it('returns the handler result when not aborted', async () => {
|
||||
const handler = withAbortableToolHandler(async () => await Promise.resolve({ ok: true }));
|
||||
await expect(handler({}, makeCtx(new AbortController().signal))).resolves.toEqual({
|
||||
ok: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects promptly when the signal aborts during execution', async () => {
|
||||
const controller = new AbortController();
|
||||
let release!: () => void;
|
||||
const hang = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
|
||||
const handler = withAbortableToolHandler(async () => {
|
||||
await hang;
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const pending = handler({}, makeCtx(controller.signal));
|
||||
controller.abort();
|
||||
|
||||
await expect(pending).rejects.toMatchObject({ name: 'AbortError' });
|
||||
release();
|
||||
});
|
||||
|
||||
it('rejects before starting when already aborted', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const inner = vi.fn(async () => await Promise.resolve({ ok: true }));
|
||||
const handler = withAbortableToolHandler(inner);
|
||||
|
||||
await expect(handler({}, makeCtx(controller.signal))).rejects.toMatchObject({
|
||||
name: 'AbortError',
|
||||
});
|
||||
expect(inner).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes through when no abort signal is provided', async () => {
|
||||
const handler = withAbortableToolHandler(async () => await Promise.resolve({ ok: true }));
|
||||
await expect(handler({}, {})).resolves.toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeToolAbortable', () => {
|
||||
it('wraps the handler and is idempotent', async () => {
|
||||
const controller = new AbortController();
|
||||
let release!: () => void;
|
||||
const hang = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
|
||||
const tool: BuiltTool = {
|
||||
name: 'slow',
|
||||
description: 'slow tool',
|
||||
handler: async () => {
|
||||
await hang;
|
||||
return { done: true };
|
||||
},
|
||||
};
|
||||
|
||||
const once = makeToolAbortable(tool);
|
||||
const twice = makeToolAbortable(once);
|
||||
expect(twice).toBe(once);
|
||||
expect(once.metadata?.abortableWrapped).toBe(true);
|
||||
|
||||
const pending = once.handler?.({}, makeCtx(controller.signal));
|
||||
controller.abort();
|
||||
await expect(pending).rejects.toMatchObject({ name: 'AbortError' });
|
||||
release();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
import type { BuiltTool, InterruptibleToolContext, ToolContext } from '@n8n/agents';
|
||||
|
||||
const ABORTABLE_WRAPPED_KEY = 'abortableWrapped';
|
||||
|
||||
type ToolHandler = NonNullable<BuiltTool['handler']>;
|
||||
|
||||
export function isAbortError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
if (error.name === 'AbortError') return true;
|
||||
return error.message === 'Aborted' || error.message === 'This operation was aborted';
|
||||
}
|
||||
|
||||
export function createAbortError(reason?: unknown): Error {
|
||||
if (reason instanceof Error) return reason;
|
||||
const error = new Error(typeof reason === 'string' ? reason : 'This operation was aborted');
|
||||
error.name = 'AbortError';
|
||||
return error;
|
||||
}
|
||||
|
||||
/** Throw if the run abort signal has already fired. */
|
||||
export function throwIfAborted(ctx: Pick<ToolContext, 'abortSignal'>): void {
|
||||
const signal = ctx.abortSignal;
|
||||
if (signal?.aborted) {
|
||||
throw createAbortError(signal.reason);
|
||||
}
|
||||
}
|
||||
|
||||
async function abortRejection(signal: AbortSignal): Promise<never> {
|
||||
return await new Promise((_, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(createAbortError(signal.reason));
|
||||
return;
|
||||
}
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
reject(createAbortError(signal.reason));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Race a tool handler against the run abort signal so Stop unblocks the
|
||||
* executor even when the underlying work does not cooperate. Cooperative
|
||||
* tools should still forward `ctx.abortSignal` into I/O to stop real work.
|
||||
*/
|
||||
export function withAbortableToolHandler(handler: ToolHandler): ToolHandler {
|
||||
return async (input, ctx) => {
|
||||
const signal = ctx.abortSignal;
|
||||
if (!signal) {
|
||||
return await handler(input, ctx);
|
||||
}
|
||||
throwIfAborted(ctx);
|
||||
return await Promise.race([handler(input, ctx), abortRejection(signal)]);
|
||||
};
|
||||
}
|
||||
|
||||
function isAlreadyWrapped(tool: BuiltTool): boolean {
|
||||
return tool.metadata?.[ABORTABLE_WRAPPED_KEY] === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a BuiltTool so its handler settles promptly when the run abort signal
|
||||
* fires. Idempotent — safe to call on already-wrapped tools.
|
||||
*/
|
||||
export function makeToolAbortable(tool: BuiltTool): BuiltTool {
|
||||
if (!tool.handler || isAlreadyWrapped(tool)) {
|
||||
return tool;
|
||||
}
|
||||
|
||||
const wrappedHandler = withAbortableToolHandler(tool.handler);
|
||||
|
||||
return {
|
||||
...tool,
|
||||
handler: async (input, ctx: ToolContext | InterruptibleToolContext) =>
|
||||
await wrappedHandler(input, ctx),
|
||||
metadata: {
|
||||
...tool.metadata,
|
||||
[ABORTABLE_WRAPPED_KEY]: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -244,7 +244,7 @@ describe('createBuildWorkflowTool', () => {
|
||||
expect(result.postBuildFlow?.guidance).toContain(
|
||||
'Do not replace the error-workflow opt-in with a generic add-anything',
|
||||
);
|
||||
expect(compileWorkflowSource).toHaveBeenCalledWith(context, filePath, source);
|
||||
expect(compileWorkflowSource).toHaveBeenCalledWith(context, filePath, source, undefined);
|
||||
expect(context.workflowService.createFromWorkflowJSON).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'Daily Weather to Slack' }),
|
||||
{ markAsAiTemporary: true },
|
||||
@@ -669,7 +669,7 @@ describe('createBuildWorkflowTool', () => {
|
||||
workflowId: 'wf-existing',
|
||||
workflowName: 'Daily Slack Channel Digest',
|
||||
});
|
||||
expect(compileWorkflowSource).toHaveBeenCalledWith(context, filePath, source);
|
||||
expect(compileWorkflowSource).toHaveBeenCalledWith(context, filePath, source, undefined);
|
||||
expect(context.workflowService.updateFromWorkflowJSON).toHaveBeenCalledWith(
|
||||
'wf-existing',
|
||||
workflowJson,
|
||||
|
||||
+36
-1
@@ -113,7 +113,7 @@ describe('compileWorkflowSource', () => {
|
||||
expect(runInSandbox).toHaveBeenCalledWith(
|
||||
context.workspace,
|
||||
"node --import tsx build.mjs '/home/daytona/workspace/src/workflows/main.workflow.ts'",
|
||||
'/home/daytona/workspace',
|
||||
{ cwd: '/home/daytona/workspace', abortSignal: undefined },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
@@ -165,6 +165,41 @@ describe('compileWorkflowSource', () => {
|
||||
});
|
||||
expect(runInSandbox).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rethrows AbortError from sandbox execution instead of converting to a build failure', async () => {
|
||||
const abortError = new Error('This operation was aborted');
|
||||
abortError.name = 'AbortError';
|
||||
vi.mocked(runInSandbox).mockRejectedValue(abortError);
|
||||
|
||||
await expect(
|
||||
compileWorkflowSource(makeContext(), 'src/workflows/main.workflow.ts', 'workflow source'),
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
});
|
||||
|
||||
it('forwards abortSignal into the sandbox runner', async () => {
|
||||
const controller = new AbortController();
|
||||
vi.mocked(runInSandbox).mockResolvedValue({
|
||||
exitCode: 0,
|
||||
stdout: JSON.stringify({
|
||||
success: true,
|
||||
workflow: { name: 'TS workflow', nodes: [], connections: {} },
|
||||
warnings: [],
|
||||
}),
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
await compileWorkflowSource(
|
||||
makeContext(),
|
||||
'src/workflows/main.workflow.ts',
|
||||
'workflow source',
|
||||
controller.signal,
|
||||
);
|
||||
|
||||
expect(runInSandbox).toHaveBeenCalledWith(expect.anything(), expect.any(String), {
|
||||
cwd: '/home/daytona/workspace',
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('compileWorkflowSource credential resolution', () => {
|
||||
|
||||
+3
@@ -72,6 +72,7 @@ describe('createWriteSandboxFileTool', () => {
|
||||
workspace,
|
||||
'/home/user/workspace/src/workflow.ts',
|
||||
'export default {}',
|
||||
{ abortSignal: undefined },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -98,6 +99,7 @@ describe('createWriteSandboxFileTool', () => {
|
||||
workspace,
|
||||
'/home/user/workspace/src/index.ts',
|
||||
'console.log("hello")',
|
||||
{ abortSignal: undefined },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -195,6 +197,7 @@ describe('createWriteSandboxFileTool', () => {
|
||||
workspace,
|
||||
'/home/user/workspace/chunks/helper.ts',
|
||||
'export const x = 1;',
|
||||
{ abortSignal: undefined },
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ interface BuildCtx {
|
||||
toolCallId?: string;
|
||||
resumeData?: z.infer<typeof confirmationResumeSchema>;
|
||||
suspend?: (payload: z.infer<typeof confirmationSuspendSchema>) => Promise<never>;
|
||||
abortSignal?: AbortSignal;
|
||||
}
|
||||
|
||||
export const buildWorkflowInputSchema = z
|
||||
@@ -453,6 +454,7 @@ export function createBuildWorkflowTool(context: InstanceAiContext) {
|
||||
await writeWorkspaceFile(context.workspace, filePath, input.sourceCode, {
|
||||
logger: context.logger,
|
||||
resourceLabel: 'Workflow source file',
|
||||
abortSignal: ctx.abortSignal,
|
||||
});
|
||||
} catch (error) {
|
||||
const remediation = createCodeFixableRemediation({
|
||||
@@ -482,7 +484,11 @@ export function createBuildWorkflowTool(context: InstanceAiContext) {
|
||||
let sourceCode: string;
|
||||
let sourceHash: string;
|
||||
try {
|
||||
({ source: sourceCode, sourceHash } = await readWorkflowSourceFile(context, filePath));
|
||||
({ source: sourceCode, sourceHash } = await readWorkflowSourceFile(
|
||||
context,
|
||||
filePath,
|
||||
ctx.abortSignal,
|
||||
));
|
||||
} catch (error) {
|
||||
const remediation = createCodeFixableRemediation({
|
||||
reason: 'workflow_source_read_failed',
|
||||
@@ -545,7 +551,7 @@ export function createBuildWorkflowTool(context: InstanceAiContext) {
|
||||
|
||||
let informational: ValidationWarning[] = [];
|
||||
|
||||
let compiled = await compileWorkflowSource(context, filePath, sourceCode);
|
||||
let compiled = await compileWorkflowSource(context, filePath, sourceCode, ctx.abortSignal);
|
||||
if (
|
||||
!compiled.success &&
|
||||
compiled.reason === 'workflow_source_build_failed' &&
|
||||
@@ -558,8 +564,14 @@ export function createBuildWorkflowTool(context: InstanceAiContext) {
|
||||
await writeWorkspaceFile(context.workspace, filePath, recovery.source, {
|
||||
logger: context.logger,
|
||||
resourceLabel: 'Workflow source file',
|
||||
abortSignal: ctx.abortSignal,
|
||||
});
|
||||
const retried = await compileWorkflowSource(context, filePath, recovery.source);
|
||||
const retried = await compileWorkflowSource(
|
||||
context,
|
||||
filePath,
|
||||
recovery.source,
|
||||
ctx.abortSignal,
|
||||
);
|
||||
// The corrected source is on disk; keep reported errors/hash in sync with it.
|
||||
sourceCode = recovery.source;
|
||||
sourceHash = hashWorkflowSource(recovery.source);
|
||||
|
||||
@@ -74,7 +74,7 @@ export function createMaterializeNodeTypeTool(
|
||||
),
|
||||
}),
|
||||
)
|
||||
.handler(async ({ nodeIds }: z.infer<typeof materializeNodeTypeInputSchema>) => {
|
||||
.handler(async ({ nodeIds }: z.infer<typeof materializeNodeTypeInputSchema>, ctx) => {
|
||||
if (!context.nodeService.getNodeTypeDefinition) {
|
||||
return {
|
||||
definitions: nodeIds.map((req: z.infer<typeof nodeRequestSchema>) => ({
|
||||
@@ -133,7 +133,9 @@ export function createMaterializeNodeTypeTool(
|
||||
|
||||
const script = lines.join('\n');
|
||||
const scriptB64 = Buffer.from(script, 'utf-8').toString('base64');
|
||||
const result = await runInSandbox(workspace, `echo '${scriptB64}' | base64 -d | bash`);
|
||||
const result = await runInSandbox(workspace, `echo '${scriptB64}' | base64 -d | bash`, {
|
||||
abortSignal: ctx.abortSignal,
|
||||
});
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
// Mark all as failed but still return content (useful for the agent)
|
||||
|
||||
@@ -176,6 +176,7 @@ export async function refreshWorkflowSourceFileBindingFromSave(
|
||||
export async function readWorkflowSourceFile(
|
||||
context: InstanceAiContext,
|
||||
filePath: string,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<{ source: string; sourceHash: string }> {
|
||||
if (!context.workspace) {
|
||||
throw new Error('Runtime workspace is required for workflow source files.');
|
||||
@@ -185,6 +186,7 @@ export async function readWorkflowSourceFile(
|
||||
const source = await readWorkspaceFile(context.workspace, normalizedFilePath, {
|
||||
logger: context.logger,
|
||||
resourceLabel: 'Workflow source file',
|
||||
abortSignal,
|
||||
});
|
||||
|
||||
if (source === null) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAbortError, isAbortError } from '@n8n/agents';
|
||||
import { getWorkspaceRoot } from '@n8n/agents/sandbox';
|
||||
import { isRecord } from '@n8n/utils/is-record';
|
||||
import { validateWorkflow, type WorkflowJSON } from '@n8n/workflow-sdk';
|
||||
@@ -221,6 +222,7 @@ function enhanceBuildErrors(errors: string[]): string[] {
|
||||
async function compileTypeScriptWorkflowSource(
|
||||
context: InstanceAiContext,
|
||||
filePath: string,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<WorkflowSourceCompileResult> {
|
||||
if (!context.workspace) {
|
||||
return {
|
||||
@@ -242,9 +244,12 @@ async function compileTypeScriptWorkflowSource(
|
||||
buildResult = await runInSandbox(
|
||||
context.workspace,
|
||||
`node --import tsx build.mjs '${escapeSingleQuotes(sandboxFilePath)}'`,
|
||||
root,
|
||||
{ cwd: root, abortSignal },
|
||||
);
|
||||
} catch (error) {
|
||||
// Preserve Stop/cancel so callers do not record a sandbox build failure.
|
||||
if (isAbortError(error)) throw error;
|
||||
if (abortSignal?.aborted) throw createAbortError(abortSignal.reason);
|
||||
return {
|
||||
success: false,
|
||||
reason: 'workflow_source_sandbox_unavailable',
|
||||
@@ -336,12 +341,13 @@ export async function compileWorkflowSource(
|
||||
context: InstanceAiContext,
|
||||
filePath: string,
|
||||
source: string,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<WorkflowSourceCompileResult> {
|
||||
let result: WorkflowSourceCompileResult;
|
||||
if (isWorkflowJsonSourceFile(filePath)) {
|
||||
result = parseWorkflowJsonSource(source);
|
||||
} else if (isTypeScriptWorkflowSource(filePath)) {
|
||||
result = await compileTypeScriptWorkflowSource(context, filePath);
|
||||
result = await compileTypeScriptWorkflowSource(context, filePath, abortSignal);
|
||||
} else {
|
||||
result = {
|
||||
success: false,
|
||||
|
||||
@@ -34,7 +34,7 @@ export function createWriteSandboxFileTool(workspace: SandboxWorkspace) {
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.handler(async ({ filePath, content }: z.infer<typeof writeSandboxFileInputSchema>) => {
|
||||
.handler(async ({ filePath, content }: z.infer<typeof writeSandboxFileInputSchema>, ctx) => {
|
||||
try {
|
||||
const root = await getWorkspaceRoot(workspace);
|
||||
|
||||
@@ -51,7 +51,9 @@ export function createWriteSandboxFileTool(workspace: SandboxWorkspace) {
|
||||
};
|
||||
}
|
||||
|
||||
await writeFileViaSandbox(workspace, normalized, content);
|
||||
await writeFileViaSandbox(workspace, normalized, content, {
|
||||
abortSignal: ctx.abortSignal,
|
||||
});
|
||||
return { success: true, path: normalized };
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -386,6 +386,7 @@ export interface InstanceAiExecutionService {
|
||||
verificationPinData?: Record<string, unknown[]>;
|
||||
/** When set, execute this specific trigger node instead of auto-detecting. */
|
||||
triggerNodeName?: string;
|
||||
abortSignal?: AbortSignal;
|
||||
},
|
||||
): Promise<ExecutionResult>;
|
||||
getStatus(executionId: string): Promise<ExecutionResult>;
|
||||
@@ -790,7 +791,10 @@ export interface LocalMcpServer {
|
||||
getAvailableTools(): McpTool[];
|
||||
/** Return tools that belong to the given category (based on annotations.category). */
|
||||
getToolsByCategory(category: string): McpTool[];
|
||||
callTool(req: McpToolCallRequest): Promise<McpToolCallResult>;
|
||||
callTool(
|
||||
req: McpToolCallRequest,
|
||||
options?: { abortSignal?: AbortSignal },
|
||||
): Promise<McpToolCallResult>;
|
||||
}
|
||||
|
||||
// ── Workspace shapes ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -215,4 +215,25 @@ describe('createLazyRuntimeWorkspace', () => {
|
||||
expect(lazyWorkspace.filesystem?.status).toBe('destroyed');
|
||||
expect(lazyWorkspace.sandbox?.status).toBe('destroyed');
|
||||
});
|
||||
|
||||
it('aborts writeFile while lazy workspace bring-up is still pending', async () => {
|
||||
const abortController = new AbortController();
|
||||
const ensureWorkspace = vi.fn(
|
||||
async () =>
|
||||
await new Promise<Workspace>(() => {
|
||||
// Never resolves — simulates Daytona create/start hanging.
|
||||
}),
|
||||
);
|
||||
const lazyWorkspace = createLazyRuntimeWorkspace({ ensureWorkspace });
|
||||
const writePromise = lazyWorkspace.filesystem!.writeFile('/workflow.ts', 'export {}', {
|
||||
abortSignal: abortController.signal,
|
||||
});
|
||||
|
||||
abortController.abort('Agent run was aborted');
|
||||
|
||||
await expect(writePromise).rejects.toMatchObject({
|
||||
name: 'AbortError',
|
||||
message: 'Agent run was aborted',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,9 @@ import {
|
||||
BaseFilesystem,
|
||||
BaseSandbox,
|
||||
Workspace,
|
||||
raceWithAbort,
|
||||
type AbortableOptions,
|
||||
type AppendOptions,
|
||||
type CommandResult,
|
||||
type CopyOptions,
|
||||
type ExecuteCommandOptions,
|
||||
@@ -9,6 +12,7 @@ import {
|
||||
type FileEntry,
|
||||
type FileStat,
|
||||
type ListOptions,
|
||||
type MkdirOptions,
|
||||
type ProviderStatus,
|
||||
type ReadOptions,
|
||||
type RemoveOptions,
|
||||
@@ -222,51 +226,54 @@ class LazyRuntimeFilesystem extends BaseFilesystem {
|
||||
}
|
||||
|
||||
async readFile(path: string, options?: ReadOptions): Promise<string | Buffer> {
|
||||
return await (await this.getFilesystem()).readFile(path, options);
|
||||
return await (await this.getFilesystem(options?.abortSignal)).readFile(path, options);
|
||||
}
|
||||
|
||||
async writeFile(path: string, content: FileContent, options?: WriteOptions): Promise<void> {
|
||||
await (await this.getFilesystem()).writeFile(path, content, options);
|
||||
await (await this.getFilesystem(options?.abortSignal)).writeFile(path, content, options);
|
||||
}
|
||||
|
||||
async appendFile(path: string, content: FileContent): Promise<void> {
|
||||
await (await this.getFilesystem()).appendFile(path, content);
|
||||
async appendFile(path: string, content: FileContent, options?: AppendOptions): Promise<void> {
|
||||
await (await this.getFilesystem(options?.abortSignal)).appendFile(path, content, options);
|
||||
}
|
||||
|
||||
async deleteFile(path: string, options?: RemoveOptions): Promise<void> {
|
||||
await (await this.getFilesystem()).deleteFile(path, options);
|
||||
await (await this.getFilesystem(options?.abortSignal)).deleteFile(path, options);
|
||||
}
|
||||
|
||||
async copyFile(src: string, dest: string, options?: CopyOptions): Promise<void> {
|
||||
await (await this.getFilesystem()).copyFile(src, dest, options);
|
||||
await (await this.getFilesystem(options?.abortSignal)).copyFile(src, dest, options);
|
||||
}
|
||||
|
||||
async moveFile(src: string, dest: string, options?: CopyOptions): Promise<void> {
|
||||
await (await this.getFilesystem()).moveFile(src, dest, options);
|
||||
await (await this.getFilesystem(options?.abortSignal)).moveFile(src, dest, options);
|
||||
}
|
||||
|
||||
async mkdir(path: string, options?: { recursive?: boolean }): Promise<void> {
|
||||
await (await this.getFilesystem()).mkdir(path, options);
|
||||
async mkdir(path: string, options?: MkdirOptions): Promise<void> {
|
||||
await (await this.getFilesystem(options?.abortSignal)).mkdir(path, options);
|
||||
}
|
||||
|
||||
async rmdir(path: string, options?: RemoveOptions): Promise<void> {
|
||||
await (await this.getFilesystem()).rmdir(path, options);
|
||||
await (await this.getFilesystem(options?.abortSignal)).rmdir(path, options);
|
||||
}
|
||||
|
||||
async readdir(path: string, options?: ListOptions): Promise<FileEntry[]> {
|
||||
return await (await this.getFilesystem()).readdir(path, options);
|
||||
return await (await this.getFilesystem(options?.abortSignal)).readdir(path, options);
|
||||
}
|
||||
|
||||
async exists(path: string): Promise<boolean> {
|
||||
return await (await this.getFilesystem()).exists(path);
|
||||
async exists(path: string, options?: AbortableOptions): Promise<boolean> {
|
||||
return await (await this.getFilesystem(options?.abortSignal)).exists(path, options);
|
||||
}
|
||||
|
||||
async stat(path: string): Promise<FileStat> {
|
||||
return await (await this.getFilesystem()).stat(path);
|
||||
async stat(path: string, options?: AbortableOptions): Promise<FileStat> {
|
||||
return await (await this.getFilesystem(options?.abortSignal)).stat(path, options);
|
||||
}
|
||||
|
||||
private async getFilesystem(): Promise<WorkspaceFilesystem> {
|
||||
const filesystem = await this.resolver.getFilesystem();
|
||||
private async getFilesystem(abortSignal?: AbortSignal): Promise<WorkspaceFilesystem> {
|
||||
const filesystem = await raceWithAbort(
|
||||
async () => await this.resolver.getFilesystem(),
|
||||
abortSignal,
|
||||
);
|
||||
this.syncStatus(filesystem);
|
||||
return filesystem;
|
||||
}
|
||||
@@ -321,7 +328,7 @@ class LazyRuntimeSandbox extends BaseSandbox {
|
||||
args: string[] = [],
|
||||
options?: ExecuteCommandOptions,
|
||||
): Promise<CommandResult> {
|
||||
const sandbox = await this.getSandbox();
|
||||
const sandbox = await this.getSandbox(options?.abortSignal);
|
||||
if (!sandbox.executeCommand) {
|
||||
throw new Error('Instance AI runtime sandbox does not support command execution.');
|
||||
}
|
||||
@@ -351,8 +358,8 @@ class LazyRuntimeSandbox extends BaseSandbox {
|
||||
return 'Workspace command tools are available and create the runtime sandbox on first use.';
|
||||
}
|
||||
|
||||
private async getSandbox(): Promise<WorkspaceSandbox> {
|
||||
const sandbox = await this.resolver.getSandbox();
|
||||
private async getSandbox(abortSignal?: AbortSignal): Promise<WorkspaceSandbox> {
|
||||
const sandbox = await raceWithAbort(async () => await this.resolver.getSandbox(), abortSignal);
|
||||
this.syncStatus(sandbox);
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
* command fallback keeps setup compatible with command-only providers.
|
||||
*/
|
||||
|
||||
import { createAbortError, throwIfAborted } from '@n8n/agents';
|
||||
import {
|
||||
runInSandbox as runInSharedSandbox,
|
||||
type RunInSandboxOptions,
|
||||
type SandboxCommandTarget,
|
||||
type SandboxWorkspace as SharedSandboxWorkspace,
|
||||
} from '@n8n/agents/sandbox';
|
||||
@@ -23,13 +25,19 @@ export interface SandboxWorkspace extends SharedSandboxWorkspace {
|
||||
provider?: string;
|
||||
basePath?: string;
|
||||
init?: () => Promise<void>;
|
||||
readFile?: (path: string, options?: { encoding?: BufferEncoding }) => Promise<string | Buffer>;
|
||||
readFile?: (
|
||||
path: string,
|
||||
options?: { encoding?: BufferEncoding; abortSignal?: AbortSignal },
|
||||
) => Promise<string | Buffer>;
|
||||
writeFile: (
|
||||
path: string,
|
||||
content: string | Buffer,
|
||||
options?: { recursive?: boolean },
|
||||
options?: { recursive?: boolean; abortSignal?: AbortSignal },
|
||||
) => Promise<void>;
|
||||
mkdir: (
|
||||
path: string,
|
||||
options?: { recursive?: boolean; abortSignal?: AbortSignal },
|
||||
) => Promise<void>;
|
||||
mkdir: (path: string, options?: { recursive?: boolean }) => Promise<void>;
|
||||
} & NonNullable<SharedSandboxWorkspace['filesystem']>;
|
||||
}
|
||||
|
||||
@@ -42,14 +50,26 @@ export interface SandboxIoRetryOptions {
|
||||
logger?: Pick<Logger, 'warn'>;
|
||||
resourceLabel?: string;
|
||||
retryBackoffBaseMs?: number;
|
||||
abortSignal?: AbortSignal;
|
||||
}
|
||||
|
||||
function ioResourceLabel(options?: SandboxIoRetryOptions): string {
|
||||
return options?.resourceLabel ?? 'Sandbox file';
|
||||
}
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
async function sleep(ms: number, abortSignal?: AbortSignal): Promise<void> {
|
||||
throwIfAborted(abortSignal);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
abortSignal?.removeEventListener('abort', onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
reject(createAbortError(abortSignal?.reason));
|
||||
};
|
||||
abortSignal?.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
// Daytona surfaces upstream gateway failures (e.g. Cloudflare 502/524) as errors with a numeric status.
|
||||
@@ -66,6 +86,7 @@ export async function retryTransientSandboxIo<T>(
|
||||
): Promise<T> {
|
||||
const baseMs = options?.retryBackoffBaseMs ?? DEFAULT_SANDBOX_IO_RETRY_BACKOFF_BASE_MS;
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
throwIfAborted(options?.abortSignal);
|
||||
try {
|
||||
return await op();
|
||||
} catch (error) {
|
||||
@@ -75,7 +96,10 @@ export async function retryTransientSandboxIo<T>(
|
||||
attempt,
|
||||
error: formatErrorForLog(error),
|
||||
});
|
||||
await sleep(Math.min(baseMs * 2 ** (attempt - 1), SANDBOX_IO_RETRY_BACKOFF_CAP_MS));
|
||||
await sleep(
|
||||
Math.min(baseMs * 2 ** (attempt - 1), SANDBOX_IO_RETRY_BACKOFF_CAP_MS),
|
||||
options?.abortSignal,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,9 +115,9 @@ export async function retryTransientSandboxIo<T>(
|
||||
export async function runInSandbox(
|
||||
workspace: SandboxCommandTarget,
|
||||
command: string,
|
||||
cwd?: string,
|
||||
cwdOrOptions?: string | RunInSandboxOptions,
|
||||
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||||
const result = await runInSharedSandbox(workspace, command, cwd);
|
||||
const result = await runInSharedSandbox(workspace, command, cwdOrOptions);
|
||||
|
||||
const session = getTemplateTelemetrySession(workspace);
|
||||
if (session) {
|
||||
@@ -122,7 +146,9 @@ export async function writeFileViaSandbox(
|
||||
await retryTransientSandboxIo(
|
||||
async () => {
|
||||
const runWriteCommand = async (command: string) => {
|
||||
const result = await runInSandbox(workspace, command);
|
||||
const result = await runInSandbox(workspace, command, {
|
||||
abortSignal: options?.abortSignal,
|
||||
});
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`Failed to write file ${filePath}: ${result.stderr}`);
|
||||
}
|
||||
@@ -173,7 +199,10 @@ export async function readFileViaSandbox(
|
||||
options?: SandboxIoRetryOptions,
|
||||
): Promise<string | null> {
|
||||
const result = await retryTransientSandboxIo(
|
||||
async () => await runInSandbox(workspace, `cat '${escapeSingleQuotes(filePath)}' 2>/dev/null`),
|
||||
async () =>
|
||||
await runInSandbox(workspace, `cat '${escapeSingleQuotes(filePath)}' 2>/dev/null`, {
|
||||
abortSignal: options?.abortSignal,
|
||||
}),
|
||||
filePath,
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import {
|
||||
Workspace,
|
||||
type AbortableOptions,
|
||||
type AppendOptions,
|
||||
type CopyOptions,
|
||||
type FileContent,
|
||||
type FileEntry,
|
||||
type FileStat,
|
||||
type ListOptions,
|
||||
type MkdirOptions,
|
||||
type ProviderStatus,
|
||||
type ReadOptions,
|
||||
type RemoveOptions,
|
||||
@@ -79,8 +82,8 @@ class ScopedFilesystem implements WorkspaceFilesystem {
|
||||
await this.filesystem.writeFile(resolvePath(this.root, path), content, options);
|
||||
}
|
||||
|
||||
async appendFile(path: string, content: FileContent): Promise<void> {
|
||||
await this.filesystem.appendFile(resolvePath(this.root, path), content);
|
||||
async appendFile(path: string, content: FileContent, options?: AppendOptions): Promise<void> {
|
||||
await this.filesystem.appendFile(resolvePath(this.root, path), content, options);
|
||||
}
|
||||
|
||||
async deleteFile(path: string, options?: RemoveOptions): Promise<void> {
|
||||
@@ -103,7 +106,7 @@ class ScopedFilesystem implements WorkspaceFilesystem {
|
||||
);
|
||||
}
|
||||
|
||||
async mkdir(path: string, options?: { recursive?: boolean }): Promise<void> {
|
||||
async mkdir(path: string, options?: MkdirOptions): Promise<void> {
|
||||
await this.filesystem.mkdir(resolvePath(this.root, path), options);
|
||||
}
|
||||
|
||||
@@ -115,12 +118,12 @@ class ScopedFilesystem implements WorkspaceFilesystem {
|
||||
return await this.filesystem.readdir(resolvePath(this.root, path), options);
|
||||
}
|
||||
|
||||
async exists(path: string): Promise<boolean> {
|
||||
return await this.filesystem.exists(resolvePath(this.root, path));
|
||||
async exists(path: string, options?: AbortableOptions): Promise<boolean> {
|
||||
return await this.filesystem.exists(resolvePath(this.root, path), options);
|
||||
}
|
||||
|
||||
async stat(path: string): Promise<FileStat> {
|
||||
return await this.filesystem.stat(resolvePath(this.root, path));
|
||||
async stat(path: string, options?: AbortableOptions): Promise<FileStat> {
|
||||
return await this.filesystem.stat(resolvePath(this.root, path), options);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,11 +10,14 @@ import {
|
||||
|
||||
export interface WorkspaceFileTarget {
|
||||
filesystem?: {
|
||||
readFile?: (path: string, options?: { encoding?: 'utf-8' }) => Promise<string | Buffer>;
|
||||
readFile?: (
|
||||
path: string,
|
||||
options?: { encoding?: 'utf-8'; abortSignal?: AbortSignal },
|
||||
) => Promise<string | Buffer>;
|
||||
writeFile: (
|
||||
path: string,
|
||||
content: string | Buffer,
|
||||
options?: { recursive?: boolean },
|
||||
options?: { recursive?: boolean; abortSignal?: AbortSignal },
|
||||
) => Promise<void>;
|
||||
};
|
||||
sandbox?: SandboxWorkspace['sandbox'];
|
||||
@@ -26,6 +29,7 @@ export interface WorkspaceFileOptions {
|
||||
resourceLabel?: string;
|
||||
/** Base for the exponential retry backoff on transient write errors. Default 1s. */
|
||||
retryBackoffBaseMs?: number;
|
||||
abortSignal?: AbortSignal;
|
||||
}
|
||||
|
||||
function resourceLabel(options?: WorkspaceFileOptions): string {
|
||||
@@ -52,7 +56,11 @@ export async function readWorkspaceFile(
|
||||
return decodeWorkspaceFileContent(
|
||||
await retryTransientSandboxIo(
|
||||
// .call preserves the provider's `this` binding (e.g. LazyRuntimeFilesystem).
|
||||
async () => await readFile.call(filesystem, filePath, { encoding: 'utf-8' }),
|
||||
async () =>
|
||||
await readFile.call(filesystem, filePath, {
|
||||
encoding: 'utf-8',
|
||||
abortSignal: options?.abortSignal,
|
||||
}),
|
||||
filePath,
|
||||
options,
|
||||
),
|
||||
@@ -106,7 +114,11 @@ export async function writeWorkspaceFile(
|
||||
if (filesystem) {
|
||||
try {
|
||||
await retryTransientSandboxIo(
|
||||
async () => await filesystem.writeFile(filePath, content, { recursive: true }),
|
||||
async () =>
|
||||
await filesystem.writeFile(filePath, content, {
|
||||
recursive: true,
|
||||
abortSignal: options?.abortSignal,
|
||||
}),
|
||||
filePath,
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -20,7 +20,9 @@ function ok(text: string): McpToolCallResult {
|
||||
}
|
||||
|
||||
function fakeServer(tools: McpTool[], result: McpToolCallResult) {
|
||||
const callTool = vi.fn(async (_req: McpToolCallRequest) => result);
|
||||
const callTool = vi.fn(
|
||||
async (_req: McpToolCallRequest, _options?: { abortSignal?: AbortSignal }) => result,
|
||||
);
|
||||
const getToolsByCategory = vi.fn((category: string) =>
|
||||
tools.filter((t) => t.annotations?.category === category),
|
||||
);
|
||||
@@ -71,6 +73,16 @@ describe('CompositeLocalMcpServer', () => {
|
||||
expect(a.callTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards abortSignal options to the owning server', async () => {
|
||||
const a = fakeServer([tool('x')], ok('from-a'));
|
||||
const composite = new CompositeLocalMcpServer([a.server]);
|
||||
const abortSignal = new AbortController().signal;
|
||||
|
||||
await composite.callTool({ name: 'x', arguments: {} }, { abortSignal });
|
||||
|
||||
expect(a.callTool).toHaveBeenCalledWith({ name: 'x', arguments: {} }, { abortSignal });
|
||||
});
|
||||
|
||||
it('routes a duplicated tool name to the last server that declared it', async () => {
|
||||
const a = fakeServer([tool('y')], ok('from-a'));
|
||||
const b = fakeServer([tool('y')], ok('from-b'));
|
||||
|
||||
@@ -3134,7 +3134,10 @@ describe('createExecutionAdapter run()', () => {
|
||||
status: 'error',
|
||||
});
|
||||
|
||||
expect(mockActiveExecutions.stopExecution).toHaveBeenCalled();
|
||||
expect(mockActiveExecutions.stopExecution).toHaveBeenCalledWith(
|
||||
'exec-1',
|
||||
expect.objectContaining({ name: 'TimeoutExecutionCancelledError' }),
|
||||
);
|
||||
expect(mockTelemetry.track).toHaveBeenCalledWith(
|
||||
'Builder executed workflow',
|
||||
expect.objectContaining({
|
||||
@@ -3145,6 +3148,45 @@ describe('createExecutionAdapter run()', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('tracks abort cancellation as a manual cancel, not a timeout', async () => {
|
||||
const { adapter, mockActiveExecutions, mockTelemetry } = createRunAdapterForTests(
|
||||
{
|
||||
id: 'wf-1',
|
||||
nodes: [],
|
||||
},
|
||||
{
|
||||
activeExecution: true,
|
||||
postExecutePromise: new Promise(() => {}),
|
||||
threadId: 'thread-1',
|
||||
},
|
||||
);
|
||||
const abortController = new AbortController();
|
||||
|
||||
const runPromise = adapter.run('wf-1', undefined, {
|
||||
timeout: 60_000,
|
||||
abortSignal: abortController.signal,
|
||||
});
|
||||
abortController.abort();
|
||||
|
||||
await expect(runPromise).resolves.toMatchObject({
|
||||
status: 'error',
|
||||
error: 'Execution was cancelled',
|
||||
});
|
||||
|
||||
expect(mockActiveExecutions.stopExecution).toHaveBeenCalledWith(
|
||||
'exec-1',
|
||||
expect.objectContaining({ name: 'ManualExecutionCancelledError' }),
|
||||
);
|
||||
expect(mockTelemetry.track).toHaveBeenCalledWith(
|
||||
'Builder executed workflow',
|
||||
expect.objectContaining({
|
||||
workflow_id: 'wf-1',
|
||||
status: 'error',
|
||||
error: 'Execution was cancelled',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('tracks error status when an execution fails to launch', async () => {
|
||||
const { adapter, mockTelemetry, mockWorkflowRunner } = createRunAdapterForTests(
|
||||
{
|
||||
|
||||
@@ -177,6 +177,54 @@ describe('LocalGateway', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should reject immediately when abortSignal is already aborted', async () => {
|
||||
gateway.init(EMPTY_CAPABILITIES);
|
||||
const controller = new AbortController();
|
||||
controller.abort('stopped by user');
|
||||
|
||||
const events: LocalGatewayRequestEvent[] = [];
|
||||
gateway.onRequest((event) => events.push(event));
|
||||
|
||||
await expect(
|
||||
gateway.callTool(
|
||||
{ name: 'read_file', arguments: { filePath: 'test.ts' } },
|
||||
{ abortSignal: controller.signal },
|
||||
),
|
||||
).rejects.toMatchObject({ name: 'AbortError', message: 'stopped by user' });
|
||||
|
||||
expect(events).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should reject with AbortError when abortSignal fires mid-request and ignore later responses', async () => {
|
||||
gateway.init(EMPTY_CAPABILITIES);
|
||||
const controller = new AbortController();
|
||||
|
||||
const events: LocalGatewayRequestEvent[] = [];
|
||||
gateway.onRequest((event) => events.push(event));
|
||||
|
||||
const callPromise = gateway.callTool(
|
||||
{ name: 'read_file', arguments: { filePath: 'test.ts' } },
|
||||
{ abortSignal: controller.signal },
|
||||
);
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
const requestId = events[0].payload.requestId;
|
||||
|
||||
controller.abort('stopped by user');
|
||||
|
||||
await expect(callPromise).rejects.toMatchObject({
|
||||
name: 'AbortError',
|
||||
message: 'stopped by user',
|
||||
});
|
||||
|
||||
// A late gateway response must not settle a cleaned-up pending request.
|
||||
expect(
|
||||
gateway.resolveRequest(requestId, {
|
||||
content: [{ type: 'text', text: 'late response' }],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should dispatch different tool names correctly', async () => {
|
||||
gateway.init(EMPTY_CAPABILITIES);
|
||||
|
||||
|
||||
@@ -40,7 +40,10 @@ export class CompositeLocalMcpServer implements LocalMcpServer {
|
||||
return this.unwrapTools(this.availableToolsByCategory.get(category)!);
|
||||
}
|
||||
|
||||
async callTool(req: McpToolCallRequest): Promise<McpToolCallResult> {
|
||||
async callTool(
|
||||
req: McpToolCallRequest,
|
||||
options?: { abortSignal?: AbortSignal },
|
||||
): Promise<McpToolCallResult> {
|
||||
const serverTool = this.availableTools.get(req.name);
|
||||
if (!serverTool) {
|
||||
return {
|
||||
@@ -49,7 +52,7 @@ export class CompositeLocalMcpServer implements LocalMcpServer {
|
||||
};
|
||||
}
|
||||
|
||||
return await serverTool.server.callTool(req);
|
||||
return await serverTool.server.callTool(req, options);
|
||||
}
|
||||
|
||||
private unwrapTools(tools: CompositeLocalMcpServerToolMap) {
|
||||
|
||||
@@ -185,9 +185,12 @@ export class LocalGateway {
|
||||
|
||||
/**
|
||||
* Dispatch an MCP tool call to the remote client and await its result.
|
||||
* Throws if not connected or if the request times out.
|
||||
* Throws if not connected, if the request times out, or if aborted.
|
||||
*/
|
||||
async callTool(toolCall: McpToolCallRequest): Promise<McpToolCallResult> {
|
||||
async callTool(
|
||||
toolCall: McpToolCallRequest,
|
||||
options?: { abortSignal?: AbortSignal },
|
||||
): Promise<McpToolCallResult> {
|
||||
if (!this._connected) {
|
||||
throw new Error('Local gateway is not connected');
|
||||
}
|
||||
@@ -200,15 +203,63 @@ export class LocalGateway {
|
||||
};
|
||||
}
|
||||
|
||||
const abortSignal = options?.abortSignal;
|
||||
if (abortSignal?.aborted) {
|
||||
const error = new Error(
|
||||
typeof abortSignal.reason === 'string' ? abortSignal.reason : 'This operation was aborted',
|
||||
);
|
||||
error.name = 'AbortError';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const requestId = `gw_${nanoid()}`;
|
||||
|
||||
return await new Promise<McpToolCallResult>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
let settled = false;
|
||||
|
||||
const settle = (action: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
abortSignal?.removeEventListener('abort', onAbort);
|
||||
this.pendingRequests.delete(requestId);
|
||||
reject(new Error(`Local gateway request timed out after ${REQUEST_TIMEOUT_MS}ms`));
|
||||
action();
|
||||
};
|
||||
|
||||
const onAbort = () => {
|
||||
settle(() => {
|
||||
const error = new Error(
|
||||
typeof abortSignal?.reason === 'string'
|
||||
? abortSignal.reason
|
||||
: 'This operation was aborted',
|
||||
);
|
||||
error.name = 'AbortError';
|
||||
reject(error);
|
||||
});
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
settle(() => {
|
||||
reject(new Error(`Local gateway request timed out after ${REQUEST_TIMEOUT_MS}ms`));
|
||||
});
|
||||
}, REQUEST_TIMEOUT_MS);
|
||||
|
||||
this.pendingRequests.set(requestId, { resolve, reject, timer, toolCall });
|
||||
abortSignal?.addEventListener('abort', onAbort, { once: true });
|
||||
|
||||
this.pendingRequests.set(requestId, {
|
||||
resolve: (result) => {
|
||||
settle(() => {
|
||||
resolve(result);
|
||||
});
|
||||
},
|
||||
reject: (error) => {
|
||||
settle(() => {
|
||||
reject(error);
|
||||
});
|
||||
},
|
||||
timer,
|
||||
toolCall,
|
||||
});
|
||||
|
||||
this.emitter.emit('filesystem-request', {
|
||||
type: 'filesystem-request',
|
||||
|
||||
@@ -110,6 +110,7 @@ import {
|
||||
FORM_TRIGGER_NODE_TYPE,
|
||||
WEBHOOK_NODE_TYPE,
|
||||
SCHEDULE_TRIGGER_NODE_TYPE,
|
||||
ManualExecutionCancelledError,
|
||||
TimeoutExecutionCancelledError,
|
||||
UnexpectedError,
|
||||
UserError,
|
||||
@@ -1251,8 +1252,9 @@ export class InstanceAiAdapterService {
|
||||
}
|
||||
};
|
||||
|
||||
// Wait for completion with timeout protection
|
||||
// Wait for completion with timeout / abort protection
|
||||
const timeoutMs = Math.min(options?.timeout ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
|
||||
const abortSignal = options?.abortSignal;
|
||||
|
||||
if (activeExecutions.has(executionId)) {
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
@@ -1262,28 +1264,60 @@ export class InstanceAiAdapterService {
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
let onAbort: (() => void) | undefined;
|
||||
const abortPromise =
|
||||
abortSignal === undefined
|
||||
? undefined
|
||||
: new Promise<never>((_, reject) => {
|
||||
onAbort = () => {
|
||||
const error = new Error(
|
||||
typeof abortSignal.reason === 'string'
|
||||
? abortSignal.reason
|
||||
: 'This operation was aborted',
|
||||
);
|
||||
error.name = 'AbortError';
|
||||
reject(error);
|
||||
};
|
||||
if (abortSignal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
abortSignal.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
activeExecutions.getPostExecutePromise(executionId),
|
||||
timeoutPromise,
|
||||
...(abortPromise ? [abortPromise] : []),
|
||||
]);
|
||||
clearTimeout(timeoutId);
|
||||
if (onAbort) abortSignal?.removeEventListener('abort', onAbort);
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
// On timeout, cancel the execution
|
||||
if (error instanceof Error && error.message.includes('timed out')) {
|
||||
if (onAbort) abortSignal?.removeEventListener('abort', onAbort);
|
||||
const isTimeout = error instanceof Error && error.message.includes('timed out');
|
||||
const isAbort =
|
||||
error instanceof Error &&
|
||||
(error.name === 'AbortError' || abortSignal?.aborted === true);
|
||||
// On timeout or abort, cancel the execution with the matching reason
|
||||
if (isTimeout || isAbort) {
|
||||
try {
|
||||
activeExecutions.stopExecution(
|
||||
executionId,
|
||||
new TimeoutExecutionCancelledError(executionId),
|
||||
isAbort
|
||||
? new ManualExecutionCancelledError(executionId)
|
||||
: new TimeoutExecutionCancelledError(executionId),
|
||||
);
|
||||
} catch {
|
||||
// Execution may have completed between timeout and cancel
|
||||
// Execution may have completed between timeout/abort and cancel
|
||||
}
|
||||
const result = {
|
||||
executionId,
|
||||
status: 'error',
|
||||
error: `Execution timed out after ${timeoutMs}ms and was cancelled`,
|
||||
error: isAbort
|
||||
? 'Execution was cancelled'
|
||||
: `Execution timed out after ${timeoutMs}ms and was cancelled`,
|
||||
} satisfies ExecutionResult;
|
||||
await pruneVerificationPins();
|
||||
trackBuilderExecutedWorkflow(result.status, result.error);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"noUncheckedIndexedAccess": false,
|
||||
"types": ["vite/client", "vitest/globals"],
|
||||
"paths": {
|
||||
"n8n-workflow": ["./src/index.ts"],
|
||||
"esprima-next": ["./node_modules/esprima-next/dist/esm/esprima"]
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user