From eba7d056c5c8045a19ebb100cc607edce23d38e6 Mon Sep 17 00:00:00 2001 From: Albert Alises Date: Fri, 22 May 2026 13:39:00 +0200 Subject: [PATCH] feat(core): Make sandbox thread-scoped and lazy-initialize it on Instance AI (#30904) --- .../workspace/workspace-tools.test.ts | 28 + .../src/workspace/tools/execute-command.ts | 2 + packages/@n8n/agents/src/workspace/types.ts | 1 + .../instance-ai/src/agent/instance-agent.ts | 5 +- packages/@n8n/instance-ai/src/index.ts | 3 + .../build-workflow-agent.tool.test.ts | 40 +- .../credential-guardrails.prompt.test.ts | 27 + .../build-workflow-agent.prompt.ts | 69 +- .../build-workflow-agent.tool.ts | 1080 ++++++++--------- .../submit-workflow-identity.test.ts | 20 + .../workflows/submit-workflow-identity.ts | 18 +- .../tools/workflows/submit-workflow.tool.ts | 2 +- packages/@n8n/instance-ai/src/types.ts | 15 +- .../__tests__/create-workspace.test.ts | 4 + .../__tests__/lazy-runtime-workspace.test.ts | 187 +++ .../workspace/__tests__/sandbox-setup.test.ts | 236 +++- .../src/workspace/create-workspace.ts | 4 + .../src/workspace/lazy-runtime-workspace.ts | 317 +++++ .../instance-ai/src/workspace/sandbox-fs.ts | 8 +- .../src/workspace/sandbox-setup.ts | 259 +++- .../__tests__/credit-counting.test.ts | 4 +- .../__tests__/instance-ai.service.test.ts | 346 +++++- .../instance-ai.service.threadPushRef.test.ts | 6 +- .../instance-ai/instance-ai.service.ts | 181 ++- 24 files changed, 2160 insertions(+), 702 deletions(-) create mode 100644 packages/@n8n/instance-ai/src/workspace/__tests__/lazy-runtime-workspace.test.ts create mode 100644 packages/@n8n/instance-ai/src/workspace/lazy-runtime-workspace.ts diff --git a/packages/@n8n/agents/src/__tests__/workspace/workspace-tools.test.ts b/packages/@n8n/agents/src/__tests__/workspace/workspace-tools.test.ts index 0316bc47ec7..b25cfb599c8 100644 --- a/packages/@n8n/agents/src/__tests__/workspace/workspace-tools.test.ts +++ b/packages/@n8n/agents/src/__tests__/workspace/workspace-tools.test.ts @@ -250,6 +250,34 @@ describe('createWorkspaceTools', () => { expect(result).toEqual({ success: true }); }); + it('execute_command handler includes sandbox default command environment', async () => { + const executeCommand = jest.fn().mockResolvedValue({ + success: true, + exitCode: 0, + stdout: 'ok', + stderr: '', + executionTimeMs: 5, + }); + const sandbox = makeFakeSandbox({ + executeCommand, + getDefaultCommandEnv: () => ({ CUSTOM_ENV: 'enabled' }), + }); + const tools = createWorkspaceTools({ sandbox }); + const commandTool = tools.find((t) => t.name === 'workspace_execute_command')!; + + const result = await commandTool.handler!( + { command: 'node script.mjs', cwd: '/home/daytona/workspace' }, + {} as never, + ); + + expect(executeCommand).toHaveBeenCalledWith('node script.mjs', undefined, { + cwd: '/home/daytona/workspace', + env: { CUSTOM_ENV: 'enabled' }, + timeout: undefined, + }); + expect(result).toMatchObject({ success: true, stdout: 'ok' }); + }); + it('list_files handler calls filesystem.readdir', async () => { const fs = makeFakeFilesystem(); const tools = createWorkspaceTools({ filesystem: fs }); diff --git a/packages/@n8n/agents/src/workspace/tools/execute-command.ts b/packages/@n8n/agents/src/workspace/tools/execute-command.ts index 6a73f46ba4d..690677eb90a 100644 --- a/packages/@n8n/agents/src/workspace/tools/execute-command.ts +++ b/packages/@n8n/agents/src/workspace/tools/execute-command.ts @@ -27,8 +27,10 @@ export function createExecuteCommandTool(sandbox: WorkspaceSandbox): BuiltTool { if (!sandbox.executeCommand) { throw new Error('Sandbox does not support command execution'); } + const env = sandbox.getDefaultCommandEnv?.(); const result = await sandbox.executeCommand(input.command, undefined, { cwd: input.cwd, + ...(env ? { env } : {}), timeout: input.timeout, }); return { diff --git a/packages/@n8n/agents/src/workspace/types.ts b/packages/@n8n/agents/src/workspace/types.ts index 6808f168e08..d7529209553 100644 --- a/packages/@n8n/agents/src/workspace/types.ts +++ b/packages/@n8n/agents/src/workspace/types.ts @@ -128,6 +128,7 @@ export interface WorkspaceSandbox { readonly provider: string; status: ProviderStatus; getInstructions?(): string; + getDefaultCommandEnv?(): NodeJS.ProcessEnv; executeCommand?( command: string, args?: string[], diff --git a/packages/@n8n/instance-ai/src/agent/instance-agent.ts b/packages/@n8n/instance-ai/src/agent/instance-agent.ts index 523f3917728..bd921e98c5b 100644 --- a/packages/@n8n/instance-ai/src/agent/instance-agent.ts +++ b/packages/@n8n/instance-ai/src/agent/instance-agent.ts @@ -158,13 +158,13 @@ export async function createInstanceAgent(options: CreateInstanceAgentOptions): branchReadOnly: context.branchReadOnly, }); - // The orchestrator intentionally does not receive a workspace. Sandbox access - // is scoped to the workflow-builder subagent via `builderSandboxFactory`. const telemetry = orchestrationContext?.tracing?.getTelemetry?.({ agentRole: 'orchestrator', functionId: 'instance-ai.orchestrator', executionMode: 'foreground', }); + // The orchestrator agent itself does not receive workspace tools. Sandbox access + // stays scoped to tools and sub-agents that request orchestrationContext.workspace. const agent = new Agent('n8n-instance-agent') .model(modelId) .instructions(systemPrompt, { @@ -195,7 +195,6 @@ export async function createInstanceAgent(options: CreateInstanceAgentOptions): : {}), }); } - mergeTraceRunInputs( orchestrationContext?.tracing?.actorRun, buildAgentTraceInputs({ diff --git a/packages/@n8n/instance-ai/src/index.ts b/packages/@n8n/instance-ai/src/index.ts index 738b8d3832f..66ed8236e7c 100644 --- a/packages/@n8n/instance-ai/src/index.ts +++ b/packages/@n8n/instance-ai/src/index.ts @@ -234,6 +234,9 @@ defineLazyExport('HAIKU_MODEL', () => loadEvalAgents().HAIKU_MODEL); export type { SuspensionInfo, Resumable } from './utils/stream-helpers'; export { buildAgentTreeFromEvents, findAgentNodeInTree } from './utils/agent-tree'; export type { SandboxConfig } from './workspace/create-workspace'; +export { createLazyRuntimeWorkspace } from './workspace/lazy-runtime-workspace'; +export type { RuntimeWorkspaceResolver } from './workspace/lazy-runtime-workspace'; +export { getWorkspaceRoot, setupSandboxWorkspace } from './workspace/sandbox-setup'; export type { BuilderWorkspace } from './workspace/builder-sandbox-factory'; export type BuilderSandboxFactory = BuilderSandboxFactoryMod.BuilderSandboxFactory; export const createSandbox: typeof CreateWorkspaceMod.createSandbox = lazyFunction( diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/build-workflow-agent.tool.test.ts b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/build-workflow-agent.tool.test.ts index 9d771e4682b..355d249b711 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/build-workflow-agent.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/build-workflow-agent.tool.test.ts @@ -7,7 +7,6 @@ import { import { UserError } from 'n8n-workflow'; import { executeTool } from '../../../__tests__/tool-test-utils'; -import type { BuilderSandboxSession } from '../../../runtime/builder-sandbox-session-registry'; import { createToolRegistry } from '../../../tool-registry'; import type { OrchestrationContext, InstanceAiContext } from '../../../types'; import { createRemediation } from '../../../workflow-loop'; @@ -32,6 +31,7 @@ const { determineSetupRequirement, determineVerificationReadiness, getBuilderSessionMemory, + builderWorkflowWorkspaceLayout, mergeLatestVerificationIntoOutcome, settleMissingMainWorkflowSubmit, supportingWorkflowIdsFromSubmitAttempts, @@ -107,6 +107,32 @@ function createSpawnableContext( const MAIN_PATH = '/home/daytona/workspace/src/workflow.ts'; +describe('builderWorkflowWorkspaceLayout', () => { + it('gives parallel work items isolated main workflow files in the shared workspace', () => { + const root = '/home/daytona/workspace'; + const first = builderWorkflowWorkspaceLayout(root, 'wi_fetch_customers'); + const second = builderWorkflowWorkspaceLayout(root, 'wi_send_report'); + + expect(first.mainWorkflowPath).toBe(`${first.workItemRoot}/src/workflow.ts`); + expect(second.mainWorkflowPath).toBe(`${second.workItemRoot}/src/workflow.ts`); + expect(first.mainWorkflowPath).not.toBe(second.mainWorkflowPath); + expect(first.chunksDir).not.toBe(second.chunksDir); + expect(first.tsconfigPath).toBe(`${first.workItemRoot}/tsconfig.json`); + expect(first.relativeMainWorkflowPath).not.toContain('..'); + }); + + it('sanitizes work item ids before using them in workspace paths', () => { + const layout = builderWorkflowWorkspaceLayout( + '/home/daytona/workspace', + 'run:one/../../workflow', + ); + + expect(layout.relativeMainWorkflowPath).toMatch( + /^builder-work-items\/run-one-workflow-[a-f0-9]{8}\/src\/workflow\.ts$/, + ); + }); +}); + describe('buildWarmBuilderFollowUp', () => { it('keeps the detached builder verification contract in warm follow-ups', () => { const briefing = buildWarmBuilderFollowUp({ @@ -127,22 +153,20 @@ describe('buildWarmBuilderFollowUp', () => { }); describe('getBuilderSessionMemory', () => { - const session = { sessionId: 'builder-session-1' } as BuilderSandboxSession; - - it('uses memory for retained builder sessions', () => { + it('uses memory when the builder runs in the shared workspace', () => { const memory = {} as OrchestrationContext['memory']; - expect(getBuilderSessionMemory({ memory }, session)).toBe(memory); + expect(getBuilderSessionMemory({ memory }, true)).toBe(memory); }); - it('skips memory when there is no retained builder session', () => { + it('skips memory when the builder falls back to tool mode', () => { const memory = {} as OrchestrationContext['memory']; - expect(getBuilderSessionMemory({ memory }, undefined)).toBeUndefined(); + expect(getBuilderSessionMemory({ memory }, false)).toBeUndefined(); }); it('skips memory when the context has no memory store', () => { - expect(getBuilderSessionMemory({}, session)).toBeUndefined(); + expect(getBuilderSessionMemory({}, true)).toBeUndefined(); }); }); diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/credential-guardrails.prompt.test.ts b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/credential-guardrails.prompt.test.ts index 10db03df226..ed2ddd77d30 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/credential-guardrails.prompt.test.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/credential-guardrails.prompt.test.ts @@ -105,4 +105,31 @@ describe('credential guardrail prompts', () => { expect(prompt).not.toContain('workflows(action="publish")'); expect(prompt).not.toContain('Do NOT publish'); }); + + it('points sandbox builders at the task-specific workflow and chunks paths', () => { + const prompt = createSandboxBuilderAgentPrompt('/tmp/workspace', { + mainWorkflowPath: '/tmp/workspace/builder-work-items/wi-one/src/workflow.ts', + sourceDir: '/tmp/workspace/builder-work-items/wi-one/src', + chunksDir: '/tmp/workspace/builder-work-items/wi-one/chunks', + tsconfigPath: '/tmp/workspace/builder-work-items/wi-one/tsconfig.json', + }); + + expect(prompt).toContain( + 'Your active main workflow file is `/tmp/workspace/builder-work-items/wi-one/src/workflow.ts`', + ); + expect(prompt).toContain( + 'Use `/tmp/workspace/builder-work-items/wi-one/chunks/` for supporting chunk files', + ); + expect(prompt).toContain( + 'execute_command: cd /tmp/workspace && npx tsc --noEmit --project /tmp/workspace/builder-work-items/wi-one/tsconfig.json 2>&1', + ); + expect(prompt).not.toContain('Write workflow code to `/tmp/workspace/src/workflow.ts`'); + }); + + it('uses the provided workspace root for fallback tsc validation', () => { + const prompt = createSandboxBuilderAgentPrompt('/tmp/custom-workspace'); + + expect(prompt).toContain('execute_command: cd /tmp/custom-workspace && npx tsc --noEmit 2>&1'); + expect(prompt).not.toContain('execute_command: cd ~/workspace && npx tsc --noEmit 2>&1'); + }); }); diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/build-workflow-agent.prompt.ts b/packages/@n8n/instance-ai/src/tools/orchestration/build-workflow-agent.prompt.ts index 8664fc7b1a9..34bd342c063 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/build-workflow-agent.prompt.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/build-workflow-agent.prompt.ts @@ -157,7 +157,32 @@ ${SDK_RULES_AND_PATTERNS_TOOL} // ── Sandbox-based builder prompt ───────────────────────────────────────────── -export function createSandboxBuilderAgentPrompt(workspaceRoot: string): string { +export interface SandboxBuilderWorkspaceLayout { + mainWorkflowPath?: string; + sourceDir?: string; + chunksDir?: string; + tsconfigPath?: string; +} + +function relativeToWorkspace(workspaceRoot: string, filePath: string): string { + return filePath.startsWith(`${workspaceRoot}/`) + ? filePath.slice(workspaceRoot.length + 1) + : filePath; +} + +export function createSandboxBuilderAgentPrompt( + workspaceRoot: string, + layout: SandboxBuilderWorkspaceLayout = {}, +): string { + const sourceDir = layout.sourceDir ?? `${workspaceRoot}/src`; + const chunksDir = layout.chunksDir ?? `${workspaceRoot}/chunks`; + const mainWorkflowPath = layout.mainWorkflowPath ?? `${sourceDir}/workflow.ts`; + const tsconfigCommand = layout.tsconfigPath + ? `cd ${workspaceRoot} && npx tsc --noEmit --project ${layout.tsconfigPath} 2>&1` + : `cd ${workspaceRoot} && npx tsc --noEmit 2>&1`; + const sourceDirLabel = relativeToWorkspace(workspaceRoot, sourceDir); + const chunksDirLabel = relativeToWorkspace(workspaceRoot, chunksDir); + return `You are an expert n8n workflow builder working inside a sandbox with real TypeScript tooling. You write workflow code as files and use \`tsc\` for validation. ${BUILDER_OUTPUT_DISCIPLINE} @@ -174,18 +199,22 @@ ${workspaceRoot}/ workflows/ # existing n8n workflows as JSON node-types/ index.txt # searchable catalog: nodeType | displayName | description | version - src/ - workflow.ts # write your main workflow code here - chunks/ - *.ts # reusable node/workflow modules + ${sourceDirLabel}/ + workflow.ts # write this task's main workflow code here + ${chunksDirLabel}/ + *.ts # reusable node/workflow modules for this task \`\`\` +Your active main workflow file is \`${mainWorkflowPath}\`. +Use \`${chunksDir}/\` for supporting chunk files in this task. +Do not write this task's workflow code into any other builder task directory. + ## Modular Code -For complex workflows, split reusable pieces into separate files in \`chunks/\`: +For complex workflows, split reusable pieces into separate files in \`${chunksDir}/\`: \`\`\`typescript -// ${workspaceRoot}/chunks/weather.ts +// ${chunksDir}/weather.ts import { node } from '@n8n/workflow-sdk'; export const weatherNode = node({ @@ -200,7 +229,7 @@ export const weatherNode = node({ \`\`\` \`\`\`typescript -// ${workspaceRoot}/src/workflow.ts +// ${mainWorkflowPath} import { workflow, trigger } from '@n8n/workflow-sdk'; import { weatherNode } from '../chunks/weather'; @@ -210,7 +239,7 @@ export default workflow('my-workflow', 'My Workflow') .to(weatherNode); \`\`\` -The \`submit-workflow\` tool executes your code natively in the sandbox via tsx — local imports resolve naturally via Node.js module resolution. Both \`src/\` and \`chunks/\` files are included in tsc validation. +The \`submit-workflow\` tool executes your code natively in the sandbox via tsx — local imports resolve naturally via Node.js module resolution. Both the active source and chunks directories are included in tsc validation. ## Compositional Workflow Pattern @@ -221,7 +250,7 @@ For complex workflows, decompose into standalone sub-workflows (chunks) that can Each chunk uses \`executeWorkflowTrigger\` (v1.1) with explicit input schema: \`\`\`typescript -// ${workspaceRoot}/chunks/weather-data.ts +// ${chunksDir}/weather-data.ts import { workflow, node, trigger } from '@n8n/workflow-sdk'; const inputTrigger = trigger({ @@ -275,7 +304,7 @@ Supported input types: \`string\`, \`number\`, \`boolean\`, \`array\`, \`object\ Reference the submitted chunk by its workflow ID using \`executeWorkflow\`: \`\`\`typescript -// ${workspaceRoot}/src/workflow.ts +// ${mainWorkflowPath} import { workflow, node, trigger } from '@n8n/workflow-sdk'; const scheduleTrigger = trigger({ @@ -311,7 +340,7 @@ Replace \`CHUNK_WORKFLOW_ID\` with the actual ID returned by \`submit-workflow\` ### When to use this pattern -- **Simple workflows** (< 5 nodes): Write everything in \`src/workflow.ts\` directly. +- **Simple workflows** (< 5 nodes): Write everything in \`${mainWorkflowPath}\` directly. - **Complex workflows** (5+ nodes, multiple integrations): Decompose into chunks. Build, test, and compose. Each chunk is reusable across workflows. @@ -346,7 +375,7 @@ ${ASK_USER_FALLBACK} ## Sandbox-Specific Rules - **Full TypeScript/JavaScript support** — you can use any valid TS/JS: template literals, array methods (\`.map\`, \`.filter\`, \`.join\`), string methods (\`.trim\`, \`.split\`), loops, functions, \`readFileSync\`, etc. The code is executed natively via tsx. -- **For large HTML, use the file-based pattern.** Write HTML to \`chunks/page.html\`, then \`readFileSync\` + \`JSON.stringify\` in your SDK code. NEVER embed large HTML directly in jsCode — it will break. See the web_app_pattern section. +- **For large HTML, use the file-based pattern.** Write HTML to \`${chunksDir}/page.html\`, then \`readFileSync\` + \`JSON.stringify\` in your SDK code. NEVER embed large HTML directly in jsCode — it will break. See the web_app_pattern section. - **Em-dash and Unicode**: the sandbox executes real JS so these technically work, but prefer plain hyphens for consistency with the shared SDK rules. ## Credentials (sandbox mode) @@ -410,9 +439,9 @@ n8n normalizes column names to snake_case (e.g., \`dayName\` → \`day_name\`). \`\`\` Each line in \`examples/index.txt\` is \`filename | name | nodes | tags | source-id\`. Use the example as a reference for **structure** (which credential type each node uses, how nodes are wired, where sub-nodes attach to an agent, where sticky notes go) — not as a verbatim copy. The user's request will rarely match an example one-to-one. - The \`examples/\` directory is **read-only reference**. Never edit files there; \`src/\` and \`chunks/\` are your scratch. + The \`examples/\` directory is **read-only reference**. Never edit files there; \`${sourceDir}/\` and \`${chunksDir}/\` are your scratch. - Examples use \`newCredential('Name', 'id')\` for clarity. When you copy a pattern into \`src/workflow.ts\`, replace those calls with raw \`{ id, name }\` from \`credentials(action="list")\` per the rules above. + Examples use \`newCredential('Name', 'id')\` for clarity. When you copy a pattern into \`${mainWorkflowPath}\`, replace those calls with raw \`{ id, name }\` from \`credentials(action="list")\` per the rules above. If grep returns nothing, build from scratch. **Do not fabricate examples that do not exist.** @@ -427,13 +456,13 @@ n8n normalizes column names to snake_case (e.g., \`dayName\` → \`day_name\`). - **If \`explore-resources\` returns more than one match and the user did not name a specific one, use \`placeholder('Select ')\` for that parameter** (e.g. \`placeholder('Select a calendar')\`, \`placeholder('Select a Slack channel')\`). Picking one silently is a guess; after the build, the inline setup card in the AI Assistant panel surfaces placeholders so the user can choose. Only pick a single match without prompting. - If the resource can't be created via n8n (e.g., Slack channels), explain clearly in your summary what the user needs to set up. -5. **Write workflow code** to \`${workspaceRoot}/src/workflow.ts\`. +5. **Write workflow code** to \`${mainWorkflowPath}\`. 6. **Trace wiring before declaring done**: For workflows containing IF, Switch, or Merge nodes, trace each branch from its source to its target — confirm IF outputs are wired with \`.onTrue()\`/\`.onFalse()\`, every Switch rule output is wired by zero-based \`.onCase(index, target)\`, and the Merge mode matches the data shape. Read each node's \`@builderHint\` for selection criteria. 7. **Validate with tsc**: Run the TypeScript compiler for real type checking: \`\`\` - execute_command: cd ~/workspace && npx tsc --noEmit 2>&1 + execute_command: ${tsconfigCommand} \`\`\` Fix any errors using \`edit_file\` (with absolute path) to update the code, then re-run tsc. Iterate until clean. **Important**: If tsc reports errors you cannot resolve after 2 attempts, skip tsc and proceed to submit-workflow. The submit tool has its own validation. @@ -454,11 +483,11 @@ Follow the **Compositional Workflow Pattern** above. The process becomes: 3. **Resolve real resource IDs** (same as above — call \`nodes(action="explore-resources")\` for EVERY parameter with \`searchListMethod\` or \`loadOptionsMethod\`). Never assume IDs like "primary" or "default". If a resource doesn't exist, use a placeholder unless the user explicitly asked you to create that resource. 4. **Decompose** the workflow into logical chunks. Each chunk is a standalone sub-workflow with 2-4 nodes covering one capability (e.g., "fetch and format weather data", "generate AI recommendation", "store to data table"). 5. **For each chunk**: - a. Write the chunk to \`${workspaceRoot}/chunks/.ts\` with an \`executeWorkflowTrigger\` and explicit input schema. + a. Write the chunk to \`${chunksDir}/.ts\` with an \`executeWorkflowTrigger\` and explicit input schema. b. Run tsc. c. Submit the chunk: \`submit-workflow\` with \`filePath\` pointing to the chunk file. Test via \`executions(action="run")\`. d. Fix if needed (max 2 submission fix attempts per chunk). -6. **Write the main workflow** in \`${workspaceRoot}/src/workflow.ts\` that composes chunks via \`executeWorkflow\` nodes, referencing each chunk's workflow ID. +6. **Write the main workflow** in \`${mainWorkflowPath}\` that composes chunks via \`executeWorkflow\` nodes, referencing each chunk's workflow ID. 7. **Trace wiring before declaring done**: For workflows containing IF, Switch, or Merge nodes, trace each branch from its source to its target — confirm IF outputs are wired with \`.onTrue()\`/\`.onFalse()\`, every Switch rule output is wired by zero-based \`.onCase(index, target)\`, and the Merge mode matches the data shape. Read each node's \`@builderHint\` for selection criteria. 8. **Submit** the main workflow. 9. **Done**: Output ONE sentence summarizing what was built, including the workflow ID and any known issues. @@ -466,7 +495,7 @@ Follow the **Compositional Workflow Pattern** above. The process becomes: Do NOT produce visible output until the final step. All reasoning happens internally. ## Modifying Existing Workflows -When modifying an existing workflow, the current code is **already pre-loaded** into \`${workspaceRoot}/src/workflow.ts\` with SDK imports. +When modifying an existing workflow, the current code is **already pre-loaded** into \`${mainWorkflowPath}\` with SDK imports. **Pre-flight check before any edit**: If the change introduces a node type not already in the file, or touches parameter values you haven't just looked up (model IDs, RLC values, enum selections, credential types, versions, etc.), call \`nodes(action="type-definition")\` first. Read \`@builderHint\`, \`@default\`, \`@searchListMethod\`, and \`@loadOptionsMethod\` from the output. diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/build-workflow-agent.tool.ts b/packages/@n8n/instance-ai/src/tools/orchestration/build-workflow-agent.tool.ts index 6c0c13fb6d0..6677767ddce 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/build-workflow-agent.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/build-workflow-agent.tool.ts @@ -30,7 +30,6 @@ import { createVerifyBuiltWorkflowTool } from './verify-built-workflow.tool'; import { buildSubAgentBriefing } from '../../agent/sub-agent-briefing'; import { MAX_STEPS } from '../../constants/max-steps'; import type { Logger } from '../../logger'; -import type { BuilderSandboxSession } from '../../runtime/builder-sandbox-session-registry'; import { consumeStreamWithHitl, requireCompletedHitlText } from '../../stream/consume-with-hitl'; import { createToolRegistry, toolRegistryKeys, toolRegistryValues } from '../../tool-registry'; import { buildAgentTraceInputs, mergeTraceRunInputs } from '../../tracing/langsmith-tracing'; @@ -50,8 +49,11 @@ import { type WorkflowVerificationReadiness, type WorkflowLoopState, } from '../../workflow-loop'; -import type { BuilderWorkspace } from '../../workspace/builder-sandbox-factory'; -import { readFileViaSandbox } from '../../workspace/sandbox-fs'; +import { + readFileViaSandbox, + writeFileViaSandbox, + type SandboxWorkspace, +} from '../../workspace/sandbox-fs'; import { getWorkspaceRoot } from '../../workspace/sandbox-setup'; import { CREDENTIALS_TOOL_ID, @@ -89,9 +91,71 @@ const WORKFLOW_FINAL_SUBMIT_FAILED_FAILURE_SIGNATURE = 'workflow:final_submit_fa export function getBuilderSessionMemory( context: Pick, - activeBuilderSession: BuilderSandboxSession | undefined, + useSharedWorkspace: boolean, ): OrchestrationContext['memory'] { - return activeBuilderSession ? context.memory : undefined; + return useSharedWorkspace ? context.memory : undefined; +} + +const BUILDER_WORK_ITEMS_DIR = 'builder-work-items'; + +export interface BuilderWorkflowWorkspaceLayout { + workItemRoot: string; + sourceDir: string; + chunksDir: string; + mainWorkflowPath: string; + tsconfigPath: string; + relativeMainWorkflowPath: string; +} + +function safeWorkItemPathSegment(workItemId: string): string { + const slug = workItemId + .replace(/[^A-Za-z0-9_-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48); + const hash = createHash('sha256').update(workItemId).digest('hex').slice(0, 8); + + return `${slug || 'work-item'}-${hash}`; +} + +export function builderWorkflowWorkspaceLayout( + root: string, + workItemId: string, +): BuilderWorkflowWorkspaceLayout { + const relativeWorkItemRoot = `${BUILDER_WORK_ITEMS_DIR}/${safeWorkItemPathSegment(workItemId)}`; + const workItemRoot = `${root}/${relativeWorkItemRoot}`; + + return { + workItemRoot, + sourceDir: `${workItemRoot}/src`, + chunksDir: `${workItemRoot}/chunks`, + mainWorkflowPath: `${workItemRoot}/src/workflow.ts`, + tsconfigPath: `${workItemRoot}/tsconfig.json`, + relativeMainWorkflowPath: `${relativeWorkItemRoot}/src/workflow.ts`, + }; +} + +function renderBuilderTaskTsconfig(): string { + return `${JSON.stringify( + { + extends: '../../tsconfig.json', + include: ['src/**/*.ts', 'chunks/**/*.ts'], + }, + null, + 2, + )}\n`; +} + +async function writeBuilderWorkspaceFile( + workspace: SandboxWorkspace, + filePath: string, + content: string, +): Promise { + if (workspace.filesystem) { + await workspace.filesystem.writeFile(filePath, content, { recursive: true }); + return; + } + + await writeFileViaSandbox(workspace, filePath, content); } function toToolRegistry(tools: readonly BuiltTool[]): InstanceAiToolRegistry { @@ -805,7 +869,6 @@ async function getLatestBuildOutcome( async function compactSuccessfulBuilderMemory(input: { context: OrchestrationContext; binding: BuilderMemoryBinding; - activeBuilderSession: BuilderSandboxSession | undefined; domainContext: InstanceAiContext | undefined; workflowId: string | undefined; workItemId: string; @@ -826,7 +889,6 @@ async function compactSuccessfulBuilderMemory(input: { await compactBuilderMemoryThread({ context: input.context, binding: input.binding, - sessionId: input.activeBuilderSession?.sessionId, workflowId: input.workflowId, workItemId: input.workItemId, sourceFilePath: input.mainWorkflowPath, @@ -847,7 +909,6 @@ async function compactSuccessfulBuilderMemory(input: { async function finalizeSuccessfulMainWorkflowSubmit(input: { context: OrchestrationContext; binding: BuilderMemoryBinding; - activeBuilderSession: BuilderSandboxSession | undefined; domainContext: InstanceAiContext | undefined; workItemId: string; taskId: string; @@ -866,7 +927,6 @@ async function finalizeSuccessfulMainWorkflowSubmit(input: { await compactSuccessfulBuilderMemory({ context: input.context, binding: input.binding, - activeBuilderSession: input.activeBuilderSession, domainContext: input.domainContext, workflowId: input.mainWorkflowAttempt.workflowId, workItemId: input.workItemId, @@ -1166,9 +1226,9 @@ export async function startBuildWorkflowAgentTask( } const spawnBackgroundTask = context.spawnBackgroundTask; - const factory = context.builderSandboxFactory; + const sharedWorkspace = context.workspace; const domainContext = context.domainContext; - const useSandbox = !!factory && !!domainContext; + const useSandbox = !!sharedWorkspace && !!domainContext; let builderTools: InstanceAiToolRegistry; let prompt = BUILDER_AGENT_PROMPT; @@ -1221,15 +1281,13 @@ export async function startBuildWorkflowAgentTask( (input.workflowId ? `${context.runId}:default` : `wi_${nanoid(8)}`); const { workflowId } = input; - const reusedBuilderSession = - useSandbox && workflowId - ? context.builderSandboxSessionRegistry?.acquireByWorkflowId(context.threadId, workflowId) - : undefined; - const workItemId = reusedBuilderSession?.workItemId ?? baseWorkItemId; - const builderThreadId = reusedBuilderSession?.builderThreadId ?? randomUUID(); - const builderResourceId = - reusedBuilderSession?.builderResourceId ?? - createSubAgentResourceId(context.threadId, 'workflow-builder'); + const workItemId = baseWorkItemId; + const relativeMainWorkflowPath = builderWorkflowWorkspaceLayout( + '', + workItemId, + ).relativeMainWorkflowPath; + const builderThreadId = randomUUID(); + const builderResourceId = createSubAgentResourceId(context.threadId, 'workflow-builder'); const builderMemoryBinding: BuilderMemoryBinding = { resource: builderResourceId, thread: builderThreadId, @@ -1237,10 +1295,8 @@ export async function startBuildWorkflowAgentTask( // Build additional context based on sandbox mode and existing workflow let additionalContext = ''; - if (reusedBuilderSession && workflowId) { - additionalContext = ''; - } else if (useSandbox && workflowId) { - additionalContext = `[CONTEXT: Modifying existing workflow ${workflowId}. The current code is pre-loaded in ~/workspace/src/workflow.ts — read it first, then edit. Use workflowId "${workflowId}" when calling submit-workflow.]\n\n[WORK ITEM ID: ${workItemId}]`; + if (useSandbox && workflowId) { + additionalContext = `[CONTEXT: Modifying existing workflow ${workflowId}. The current code is pre-loaded in ${relativeMainWorkflowPath} — read it first, then edit. Use workflowId "${workflowId}" when calling submit-workflow.]\n\n[WORK ITEM ID: ${workItemId}]`; } else if (useSandbox) { additionalContext = `[WORK ITEM ID: ${workItemId}]`; } else if (workflowId) { @@ -1248,28 +1304,20 @@ export async function startBuildWorkflowAgentTask( } const runningTaskSummaries = context.getRunningTaskSummaries?.(); - const briefing = - reusedBuilderSession && workflowId - ? buildWarmBuilderFollowUp({ - task: input.task, - conversationContext: input.conversationContext, - workflowId, - workItemId, - }) - : await buildSubAgentBriefing({ - task: input.task, - conversationContext: input.conversationContext, - additionalContext: additionalContext || undefined, - requirements: useSandbox ? DETACHED_BUILDER_REQUIREMENTS : undefined, - iteration: context.iterationLog - ? { - log: context.iterationLog, - threadId: context.threadId, - taskKey: `build:${workflowId ?? 'new'}`, - } - : undefined, - runningTasks: runningTaskSummaries, - }); + const briefing = await buildSubAgentBriefing({ + task: input.task, + conversationContext: input.conversationContext, + additionalContext: additionalContext || undefined, + requirements: useSandbox ? DETACHED_BUILDER_REQUIREMENTS : undefined, + iteration: context.iterationLog + ? { + log: context.iterationLog, + threadId: context.threadId, + taskKey: `build:${workflowId ?? 'new'}`, + } + : undefined, + runningTasks: runningTaskSummaries, + }); const detachedTraceFactory = createDetachedSubAgentTraceFactory(context, { agentId: subAgentId, role: 'workflow-builder', @@ -1283,505 +1331,173 @@ export async function startBuildWorkflowAgentTask( conversationContext: input.conversationContext, }, }); - const createTraceContext = async () => { - try { - return await detachedTraceFactory(); - } catch (error) { - if (reusedBuilderSession) { - void context.builderSandboxSessionRegistry?.release(reusedBuilderSession.sessionId, { - keep: true, - reason: 'trace_setup_failed', - }); - } - throw error; - } - }; + const createTraceContext = async () => await detachedTraceFactory(); - let spawnOutcome: ReturnType; - try { - spawnOutcome = spawnBackgroundTask({ - taskId, - threadId: context.threadId, - agentId: subAgentId, + const spawnOutcome = spawnBackgroundTask({ + taskId, + threadId: context.threadId, + agentId: subAgentId, + role: 'workflow-builder', + createTraceContext, + plannedTaskId: input.plannedTaskId, + workItemId, + dedupeKey: { role: 'workflow-builder', - createTraceContext, plannedTaskId: input.plannedTaskId, - workItemId, - dedupeKey: { - role: 'workflow-builder', - plannedTaskId: input.plannedTaskId, - workflowId: input.workflowId, - }, - // When the orchestrator spawns a builder inside a checkpoint follow-up - // (e.g. to patch a runtime bug the verify exposed), tag the task so the - // safety net doesn't pre-emptively fail the checkpoint and the - // settlement path can re-enter the checkpoint context instead of a - // bare background-task-completed shell. - parentCheckpointId: - context.isCheckpointFollowUp === true ? context.checkpointTaskId : undefined, - run: async ( - signal, - drainCorrections, - waitForCorrection, - { traceContext }, - ): Promise => - await withTraceContextActor(traceContext, async () => { - let builderWs: BuilderWorkspace | undefined; - let activeBuilderSession: BuilderSandboxSession | undefined = reusedBuilderSession; - const submitAttempts = new Map(); - // Append-only history so a later failed submit for the main path - // cannot mask an earlier successful submit during post-error recovery. - const submitAttemptHistory: SubmitWorkflowAttempt[] = []; - try { - if (useSandbox) { - let workspace: BuilderWorkspace['workspace']; - let root: string; - if (activeBuilderSession) { - workspace = activeBuilderSession.workspace; - root = activeBuilderSession.root; - } else { - builderWs = await factory.create(subAgentId, domainContext, { - runId: context.runId, - threadId: context.threadId, - }); - workspace = builderWs.workspace; - root = await getWorkspaceRoot(workspace); - } + workflowId: input.workflowId, + }, + // When the orchestrator spawns a builder inside a checkpoint follow-up + // (e.g. to patch a runtime bug the verify exposed), tag the task so the + // safety net doesn't pre-emptively fail the checkpoint and the + // settlement path can re-enter the checkpoint context instead of a + // bare background-task-completed shell. + parentCheckpointId: + context.isCheckpointFollowUp === true ? context.checkpointTaskId : undefined, + run: async ( + signal, + drainCorrections, + waitForCorrection, + { traceContext }, + ): Promise => + await withTraceContextActor(traceContext, async () => { + const submitAttempts = new Map(); + // Append-only history so a later failed submit for the main path + // cannot mask an earlier successful submit during post-error recovery. + const submitAttemptHistory: SubmitWorkflowAttempt[] = []; + if (useSandbox && sharedWorkspace && domainContext) { + const workspace = sharedWorkspace; + const root = await getWorkspaceRoot(workspace); + const builderLayout = builderWorkflowWorkspaceLayout(root, workItemId); - prompt = createSandboxBuilderAgentPrompt(root); - if (!activeBuilderSession && builderWs) { - activeBuilderSession = context.builderSandboxSessionRegistry?.create({ - threadId: context.threadId, - workflowId, - workItemId, - builderThreadId, - builderResourceId, - builderWorkspace: builderWs, - root, - }); - } + prompt = createSandboxBuilderAgentPrompt(root, { + mainWorkflowPath: builderLayout.mainWorkflowPath, + sourceDir: builderLayout.sourceDir, + chunksDir: builderLayout.chunksDir, + tsconfigPath: builderLayout.tsconfigPath, + }); + await writeBuilderWorkspaceFile( + workspace, + builderLayout.tsconfigPath, + renderBuilderTaskTsconfig(), + ); - if (!reusedBuilderSession && workflowId && domainContext) { - try { - const json = await domainContext.workflowService.getAsWorkflowJSON(workflowId); - const rawCode = generateWorkflowCode(json); - const code = `${SDK_IMPORT_STATEMENT}\n\n${rawCode}`; - if (workspace.filesystem) { - await workspace.filesystem.writeFile(`${root}/src/workflow.ts`, code, { - recursive: true, - }); - } - } catch { - // Non-fatal — agent can still build from scratch + if (workflowId) { + try { + const json = await domainContext.workflowService.getAsWorkflowJSON(workflowId); + const rawCode = generateWorkflowCode(json); + const code = `${SDK_IMPORT_STATEMENT}\n\n${rawCode}`; + await writeBuilderWorkspaceFile(workspace, builderLayout.mainWorkflowPath, code); + } catch { + // Non-fatal — agent can still build from scratch + } + } else { + await writeBuilderWorkspaceFile( + workspace, + builderLayout.mainWorkflowPath, + `${SDK_IMPORT_STATEMENT}\n\n`, + ); + } + + const mainWorkflowPath = builderLayout.mainWorkflowPath; + const initialMainWorkflowSnapshot = createMainWorkflowSnapshot( + await readFileViaSandbox(workspace, mainWorkflowPath), + ); + builderTools.set( + 'submit-workflow', + createIdentityEnforcedSubmitWorkflowTool({ + context: domainContext, + workspace, + credentialMap: credMap, + root, + defaultFilePath: mainWorkflowPath, + currentRunId: context.runId, + getWorkflowLoopState: async () => + await context.workflowTaskService?.getWorkflowLoopState(workItemId), + onGuardFired: (event) => { + context.trackTelemetry?.('Builder remediation guard fired', { + thread_id: context.threadId, + run_id: context.runId, + work_item_id: workItemId, + workflow_id: event.workflowId, + category: event.category, + attempt_count: event.attemptCount, + reason: event.reason, + }); + }, + onAttempt: async (attempt) => { + submitAttempts.set(attempt.filePath, attempt); + submitAttemptHistory.push(attempt); + if (attempt.filePath !== mainWorkflowPath) { + return; } - } - - const mainWorkflowPath = `${root}/src/workflow.ts`; - const initialMainWorkflowSnapshot = createMainWorkflowSnapshot( - await readFileViaSandbox(workspace, mainWorkflowPath), - ); - builderTools.set( - 'submit-workflow', - createIdentityEnforcedSubmitWorkflowTool({ - context: domainContext, - workspace, - credentialMap: credMap, - root, - currentRunId: context.runId, - getWorkflowLoopState: async () => - await context.workflowTaskService?.getWorkflowLoopState(workItemId), - onGuardFired: (event) => { - context.trackTelemetry?.('Builder remediation guard fired', { - thread_id: context.threadId, - run_id: context.runId, - work_item_id: workItemId, - workflow_id: event.workflowId, - category: event.category, - attempt_count: event.attemptCount, - reason: event.reason, - }); - }, - onAttempt: async (attempt) => { - submitAttempts.set(attempt.filePath, attempt); - submitAttemptHistory.push(attempt); - if (attempt.filePath !== mainWorkflowPath) { - return; - } - if (attempt.success && attempt.workflowId && activeBuilderSession) { - context.builderSandboxSessionRegistry?.aliasWorkflowId( - activeBuilderSession.sessionId, - attempt.workflowId, - ); - } - if (!context.workflowTaskService) { - return; - } - - await context.workflowTaskService.reportBuildOutcome( - buildOutcome( - workItemId, - context.runId, - taskId, - attempt, - attempt.success - ? 'Workflow submitted and ready for verification.' - : (attempt.errors?.join(' ') ?? 'Workflow submission failed.'), - ), - ); - }, - }), - ); - - const tracedBuilderTools = traceSubAgentTools( - context, - builderTools, - 'workflow-builder', - ); - const runtimeWorkspaceTools = toToolRegistry(workspace.getTools()); - const builderMemory = getBuilderSessionMemory(context, activeBuilderSession); - const shouldUseBuilderMemory = Boolean(builderMemory); - - const subAgent = new Agent('Workflow Builder Agent') - .model(context.modelId) - .instructions(prompt, { - providerOptions: { - anthropic: { cacheControl: { type: 'ephemeral' } }, - }, - }) - .tool(toolRegistryValues(tracedBuilderTools)) - .workspace(workspace) - .checkpoint(context.checkpointStore ?? 'memory'); - if (builderMemory) { - subAgent.memory(builderMemory); - } - const telemetry = traceContext?.getTelemetry?.({ - agentRole: 'workflow-builder', - functionId: 'instance-ai.subagent.workflow-builder', - executionMode: 'background_subagent', - metadata: { agent_id: subAgentId, task_id: taskId }, - }); - if (telemetry) { - subAgent.telemetry(telemetry); - } - mergeTraceRunInputs( - traceContext?.actorRun, - buildAgentTraceInputs({ - systemPrompt: prompt, - tools: tracedBuilderTools, - runtimeTools: runtimeWorkspaceTools, - modelId: context.modelId, - }), - ); - - let finalText: string; - try { - const persistence = await createSubAgentPersistence(context, { - agentKind: 'workflow-builder', - threadId: builderThreadId, - resourceId: builderResourceId, - }); - const resumeOptions: Record = { - providerOptions: { - anthropic: { cacheControl: { type: 'ephemeral' } }, - }, - }; - const stream = await subAgent.stream(briefing, { - maxIterations: MAX_STEPS.BUILDER, - abortSignal: signal, - persistence, - providerOptions: { - anthropic: { cacheControl: { type: 'ephemeral' } }, - }, - }); - - const hitlResult = await consumeStreamWithHitl({ - agent: subAgent, - stream, - runId: context.runId, - agentId: subAgentId, - eventBus: context.eventBus, - logger: context.logger, - threadId: context.threadId, - abortSignal: signal, - waitForConfirmation: context.waitForConfirmation, - drainCorrections, - waitForCorrection, - maxIterations: MAX_STEPS.BUILDER, - resumeOptions, - persistence, - }); - - finalText = await requireCompletedHitlText( - hitlResult, - 'Workflow builder sub-agent', - ); - } catch (error) { - const recovered = resultFromPostStreamError({ - error, - submitAttempts: submitAttemptHistory, - mainWorkflowPath, - workItemId, - runId: context.runId, - taskId, - }); - if (recovered) { - await promoteMainWorkflow( - domainContext, - context.logger, - recovered.outcome.workflowId, - ); - return await finalizeBuildResult(context, workItemId, recovered); - } - throw error; - } - - const mainWorkflowAttempt = submitAttempts.get(mainWorkflowPath); - const currentMainWorkflow = await readFileViaSandbox(workspace, mainWorkflowPath); - const currentMainWorkflowHash = hashContent(currentMainWorkflow); - - if (!mainWorkflowAttempt) { - return await settleMissingMainWorkflowSubmit({ - context, - workItemId, - runId: context.runId, - taskId, - workflowId, - mainWorkflowPath, - initialMainWorkflowSnapshot, - currentMainWorkflow, - currentMainWorkflowHash, - submitTool: tracedBuilderTools.get('submit-workflow'), - submitAttempts, - submitAttemptHistory, - finalText, - onSuccessfulSubmit: async (attempt) => - await finalizeSuccessfulMainWorkflowSubmit({ - context, - binding: builderMemoryBinding, - activeBuilderSession, - domainContext, - workItemId, - taskId, - mainWorkflowPath, - mainWorkflowAttempt: attempt, - submitAttemptHistory, - lastRequestedChange: input.task, - finalText, - shouldUseBuilderMemory, - }), - onRecoveredSubmit: async (recovered) => { - await promoteMainWorkflow( - domainContext, - context.logger, - recovered.outcome.workflowId, - ); - return await finalizeBuildResult(context, workItemId, recovered); - }, - }); - } - - if (!mainWorkflowAttempt.success) { - const recovered = resultFromLaterFailedMainSubmit({ - failedAttempt: mainWorkflowAttempt, - submitAttempts: submitAttemptHistory, - mainWorkflowPath, - workItemId, - runId: context.runId, - taskId, - }); - if (recovered) { - await promoteMainWorkflow( - domainContext, - context.logger, - recovered.outcome.workflowId, - ); - return await finalizeBuildResult(context, workItemId, recovered); + if (!context.workflowTaskService) { + return; } - const errorText = - mainWorkflowAttempt.errors?.join(' ') ?? 'Unknown submit-workflow failure.'; - const text = `Error: workflow builder stopped after a failed submit-workflow for /src/workflow.ts. ${errorText}`; - return { - text, - outcome: buildOutcome( + await context.workflowTaskService.reportBuildOutcome( + buildOutcome( workItemId, context.runId, taskId, - mainWorkflowAttempt, - text, + attempt, + attempt.success + ? 'Workflow submitted and ready for verification.' + : (attempt.errors?.join(' ') ?? 'Workflow submission failed.'), ), - }; - } + ); + }, + }), + ); - if (mainWorkflowAttempt.sourceHash !== currentMainWorkflowHash) { - // Builder edited the file after its last submit — auto-re-submit - // instead of discarding the agent's work. - const submitTool = tracedBuilderTools.get('submit-workflow'); - if (submitTool?.handler) { - const resubmit = (await submitTool.handler( - { - filePath: mainWorkflowPath, - workflowId: mainWorkflowAttempt.workflowId, - }, - {}, - )) as SubmitWorkflowOutput; + const tracedBuilderTools = traceSubAgentTools(context, builderTools, 'workflow-builder'); + const runtimeWorkspaceTools = toToolRegistry(workspace.getTools()); + const builderMemory = getBuilderSessionMemory(context, true); + const shouldUseBuilderMemory = Boolean(builderMemory); - const refreshedAttempt = attemptFromAutoResubmit({ - latestAttempt: submitAttempts.get(mainWorkflowPath), - resubmit, - filePath: mainWorkflowPath, - sourceHash: currentMainWorkflowHash, - }); - if (resubmit.success && refreshedAttempt?.success) { - await promoteMainWorkflow( - domainContext, - context.logger, - refreshedAttempt.workflowId, - ); - await compactSuccessfulBuilderMemory({ - context, - binding: builderMemoryBinding, - activeBuilderSession, - domainContext, - workflowId: refreshedAttempt.workflowId, - workItemId, - mainWorkflowPath, - mainWorkflowAttempt: refreshedAttempt, - lastRequestedChange: input.task, - finalText, - shouldUseBuilderMemory, - }); - const outcome = await buildOutcomeWithLatestVerification( - context, - workItemId, - taskId, - refreshedAttempt, - finalText, - ); - return { - text: finalText, - outcome, - }; - } - - const resubmitErrors = - refreshedAttempt?.errors?.join(' ') ?? - formatSubmitWorkflowErrors(resubmit, 'Auto-re-submit failed.'); - if ( - refreshedAttempt && - !refreshedAttempt.success && - shouldRecoverSavedWorkflowAfterFailedSubmit(refreshedAttempt) - ) { - const recovered = resultFromLaterFailedMainSubmit({ - failedAttempt: refreshedAttempt, - submitAttempts: submitAttemptHistory, - mainWorkflowPath, - workItemId, - runId: context.runId, - taskId, - }); - if (recovered) { - await promoteMainWorkflow( - domainContext, - context.logger, - recovered.outcome.workflowId, - ); - return await finalizeBuildResult(context, workItemId, recovered); - } - } - const text = `Error: auto-re-submit of edited /src/workflow.ts failed. ${resubmitErrors}`; - return { - text, - outcome: buildOutcome( - workItemId, - context.runId, - taskId, - refreshedAttempt ?? undefined, - text, - ), - }; - } - } - - await promoteMainWorkflow( - domainContext, - context.logger, - mainWorkflowAttempt.workflowId, - ); - await compactSuccessfulBuilderMemory({ - context, - binding: builderMemoryBinding, - activeBuilderSession, - domainContext, - workflowId: mainWorkflowAttempt.workflowId, - workItemId, - mainWorkflowPath, - mainWorkflowAttempt, - lastRequestedChange: input.task, - finalText, - shouldUseBuilderMemory, - }); - const outcome = await buildOutcomeWithLatestVerification( - context, - workItemId, - taskId, - mainWorkflowAttempt, - finalText, - ); - return { - text: finalText, - outcome, - }; - } - - let fallbackMainWorkflowId: string | undefined; - recordSuccessfulWorkflowBuilds(builderTools.get('build-workflow'), (workflowId) => { - fallbackMainWorkflowId = workflowId; - }); - - const tracedBuilderTools = traceSubAgentTools( - context, - builderTools, - 'workflow-builder', - ); - - const subAgent = new Agent('Workflow Builder Agent') - .model(context.modelId) - .instructions(prompt, { - providerOptions: { - anthropic: { cacheControl: { type: 'ephemeral' } }, - }, - }) - .tool(toolRegistryValues(tracedBuilderTools)) - .checkpoint(context.checkpointStore ?? 'memory'); - const telemetry = traceContext?.getTelemetry?.({ - agentRole: 'workflow-builder', - functionId: 'instance-ai.subagent.workflow-builder', - executionMode: 'background_subagent', - metadata: { agent_id: subAgentId, task_id: taskId }, - }); - if (telemetry) { - subAgent.telemetry(telemetry); - } - mergeTraceRunInputs( - traceContext?.actorRun, - buildAgentTraceInputs({ - systemPrompt: prompt, - tools: tracedBuilderTools, - modelId: context.modelId, - }), - ); - - const resumeOptions: Record = { + const subAgent = new Agent('Workflow Builder Agent') + .model(context.modelId) + .instructions(prompt, { providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } }, }, - }; + }) + .tool(toolRegistryValues(tracedBuilderTools)) + .workspace(workspace) + .checkpoint(context.checkpointStore ?? 'memory'); + if (builderMemory) { + subAgent.memory(builderMemory); + } + const telemetry = traceContext?.getTelemetry?.({ + agentRole: 'workflow-builder', + functionId: 'instance-ai.subagent.workflow-builder', + executionMode: 'background_subagent', + metadata: { agent_id: subAgentId, task_id: taskId }, + }); + if (telemetry) { + subAgent.telemetry(telemetry); + } + mergeTraceRunInputs( + traceContext?.actorRun, + buildAgentTraceInputs({ + systemPrompt: prompt, + tools: tracedBuilderTools, + runtimeTools: runtimeWorkspaceTools, + modelId: context.modelId, + }), + ); + + let finalText: string; + try { const persistence = await createSubAgentPersistence(context, { agentKind: 'workflow-builder', threadId: builderThreadId, resourceId: builderResourceId, }); + const resumeOptions: Record = { + providerOptions: { + anthropic: { cacheControl: { type: 'ephemeral' } }, + }, + }; const stream = await subAgent.stream(briefing, { maxIterations: MAX_STEPS.BUILDER, abortSignal: signal, @@ -1808,41 +1524,293 @@ export async function startBuildWorkflowAgentTask( persistence, }); - const toolFinalText = await requireCompletedHitlText( - hitlResult, - 'Workflow builder sub-agent', - ); - await promoteMainWorkflow(domainContext, context.logger, fallbackMainWorkflowId); - return { text: toolFinalText }; - } finally { - if (activeBuilderSession && context.builderSandboxSessionRegistry) { - await context.builderSandboxSessionRegistry.release(activeBuilderSession.sessionId, { - keep: !signal.aborted, - reason: signal.aborted ? 'aborted' : 'builder_run_finished', + finalText = await requireCompletedHitlText(hitlResult, 'Workflow builder sub-agent'); + } catch (error) { + const recovered = resultFromPostStreamError({ + error, + submitAttempts: submitAttemptHistory, + mainWorkflowPath, + workItemId, + runId: context.runId, + taskId, + }); + if (recovered) { + await promoteMainWorkflow( + domainContext, + context.logger, + recovered.outcome.workflowId, + ); + return await finalizeBuildResult(context, workItemId, recovered); + } + throw error; + } + + const mainWorkflowAttempt = submitAttempts.get(mainWorkflowPath); + const currentMainWorkflow = await readFileViaSandbox(workspace, mainWorkflowPath); + const currentMainWorkflowHash = hashContent(currentMainWorkflow); + + if (!mainWorkflowAttempt) { + return await settleMissingMainWorkflowSubmit({ + context, + workItemId, + runId: context.runId, + taskId, + workflowId, + mainWorkflowPath, + initialMainWorkflowSnapshot, + currentMainWorkflow, + currentMainWorkflowHash, + submitTool: tracedBuilderTools.get('submit-workflow'), + submitAttempts, + submitAttemptHistory, + finalText, + onSuccessfulSubmit: async (attempt) => + await finalizeSuccessfulMainWorkflowSubmit({ + context, + binding: builderMemoryBinding, + domainContext, + workItemId, + taskId, + mainWorkflowPath, + mainWorkflowAttempt: attempt, + submitAttemptHistory, + lastRequestedChange: input.task, + finalText, + shouldUseBuilderMemory, + }), + onRecoveredSubmit: async (recovered) => { + await promoteMainWorkflow( + domainContext, + context.logger, + recovered.outcome.workflowId, + ); + return await finalizeBuildResult(context, workItemId, recovered); + }, + }); + } + + if (!mainWorkflowAttempt.success) { + const recovered = resultFromLaterFailedMainSubmit({ + failedAttempt: mainWorkflowAttempt, + submitAttempts: submitAttemptHistory, + mainWorkflowPath, + workItemId, + runId: context.runId, + taskId, + }); + if (recovered) { + await promoteMainWorkflow( + domainContext, + context.logger, + recovered.outcome.workflowId, + ); + return await finalizeBuildResult(context, workItemId, recovered); + } + + const errorText = + mainWorkflowAttempt.errors?.join(' ') ?? 'Unknown submit-workflow failure.'; + const text = `Error: workflow builder stopped after a failed submit-workflow for ${mainWorkflowPath}. ${errorText}`; + return { + text, + outcome: buildOutcome(workItemId, context.runId, taskId, mainWorkflowAttempt, text), + }; + } + + if (mainWorkflowAttempt.sourceHash !== currentMainWorkflowHash) { + // Builder edited the file after its last submit — auto-re-submit + // instead of discarding the agent's work. + const submitTool = tracedBuilderTools.get('submit-workflow'); + if (submitTool?.handler) { + const resubmit = (await submitTool.handler( + { + filePath: mainWorkflowPath, + workflowId: mainWorkflowAttempt.workflowId, + }, + {}, + )) as SubmitWorkflowOutput; + + const refreshedAttempt = attemptFromAutoResubmit({ + latestAttempt: submitAttempts.get(mainWorkflowPath), + resubmit, + filePath: mainWorkflowPath, + sourceHash: currentMainWorkflowHash, }); - } else { - await builderWs?.cleanup(); + if (resubmit.success && refreshedAttempt?.success) { + await promoteMainWorkflow( + domainContext, + context.logger, + refreshedAttempt.workflowId, + ); + await compactSuccessfulBuilderMemory({ + context, + binding: builderMemoryBinding, + domainContext, + workflowId: refreshedAttempt.workflowId, + workItemId, + mainWorkflowPath, + mainWorkflowAttempt: refreshedAttempt, + lastRequestedChange: input.task, + finalText, + shouldUseBuilderMemory, + }); + const outcome = await buildOutcomeWithLatestVerification( + context, + workItemId, + taskId, + refreshedAttempt, + finalText, + ); + return { + text: finalText, + outcome, + }; + } + + const resubmitErrors = + refreshedAttempt?.errors?.join(' ') ?? + formatSubmitWorkflowErrors(resubmit, 'Auto-re-submit failed.'); + if ( + refreshedAttempt && + !refreshedAttempt.success && + shouldRecoverSavedWorkflowAfterFailedSubmit(refreshedAttempt) + ) { + const recovered = resultFromLaterFailedMainSubmit({ + failedAttempt: refreshedAttempt, + submitAttempts: submitAttemptHistory, + mainWorkflowPath, + workItemId, + runId: context.runId, + taskId, + }); + if (recovered) { + await promoteMainWorkflow( + domainContext, + context.logger, + recovered.outcome.workflowId, + ); + return await finalizeBuildResult(context, workItemId, recovered); + } + } + const text = `Error: auto-re-submit of edited ${mainWorkflowPath} failed. ${resubmitErrors}`; + return { + text, + outcome: buildOutcome( + workItemId, + context.runId, + taskId, + refreshedAttempt ?? undefined, + text, + ), + }; } } - }), - }); - } catch (error) { - if (reusedBuilderSession) { - void context.builderSandboxSessionRegistry?.release(reusedBuilderSession.sessionId, { - keep: true, - reason: 'spawn_failed', - }); - } - throw error; - } + + await promoteMainWorkflow(domainContext, context.logger, mainWorkflowAttempt.workflowId); + await compactSuccessfulBuilderMemory({ + context, + binding: builderMemoryBinding, + domainContext, + workflowId: mainWorkflowAttempt.workflowId, + workItemId, + mainWorkflowPath, + mainWorkflowAttempt, + lastRequestedChange: input.task, + finalText, + shouldUseBuilderMemory, + }); + const outcome = await buildOutcomeWithLatestVerification( + context, + workItemId, + taskId, + mainWorkflowAttempt, + finalText, + ); + return { + text: finalText, + outcome, + }; + } + + let fallbackMainWorkflowId: string | undefined; + recordSuccessfulWorkflowBuilds(builderTools.get('build-workflow'), (workflowId) => { + fallbackMainWorkflowId = workflowId; + }); + + const tracedBuilderTools = traceSubAgentTools(context, builderTools, 'workflow-builder'); + + const subAgent = new Agent('Workflow Builder Agent') + .model(context.modelId) + .instructions(prompt, { + providerOptions: { + anthropic: { cacheControl: { type: 'ephemeral' } }, + }, + }) + .tool(toolRegistryValues(tracedBuilderTools)) + .checkpoint(context.checkpointStore ?? 'memory'); + const telemetry = traceContext?.getTelemetry?.({ + agentRole: 'workflow-builder', + functionId: 'instance-ai.subagent.workflow-builder', + executionMode: 'background_subagent', + metadata: { agent_id: subAgentId, task_id: taskId }, + }); + if (telemetry) { + subAgent.telemetry(telemetry); + } + mergeTraceRunInputs( + traceContext?.actorRun, + buildAgentTraceInputs({ + systemPrompt: prompt, + tools: tracedBuilderTools, + modelId: context.modelId, + }), + ); + + const resumeOptions: Record = { + providerOptions: { + anthropic: { cacheControl: { type: 'ephemeral' } }, + }, + }; + const persistence = await createSubAgentPersistence(context, { + agentKind: 'workflow-builder', + threadId: builderThreadId, + resourceId: builderResourceId, + }); + const stream = await subAgent.stream(briefing, { + maxIterations: MAX_STEPS.BUILDER, + abortSignal: signal, + persistence, + providerOptions: { + anthropic: { cacheControl: { type: 'ephemeral' } }, + }, + }); + + const hitlResult = await consumeStreamWithHitl({ + agent: subAgent, + stream, + runId: context.runId, + agentId: subAgentId, + eventBus: context.eventBus, + logger: context.logger, + threadId: context.threadId, + abortSignal: signal, + waitForConfirmation: context.waitForConfirmation, + drainCorrections, + waitForCorrection, + maxIterations: MAX_STEPS.BUILDER, + resumeOptions, + persistence, + }); + + const toolFinalText = await requireCompletedHitlText( + hitlResult, + 'Workflow builder sub-agent', + ); + await promoteMainWorkflow(domainContext, context.logger, fallbackMainWorkflowId); + return { text: toolFinalText }; + }), + }); if (spawnOutcome.status === 'duplicate') { - if (reusedBuilderSession) { - void context.builderSandboxSessionRegistry?.release(reusedBuilderSession.sessionId, { - keep: true, - reason: 'spawn_duplicate', - }); - } return { result: `Workflow build already in progress (task: ${spawnOutcome.existing.taskId}). Acknowledge and wait for the planned-task-follow-up — do not dispatch again.`, taskId: spawnOutcome.existing.taskId, @@ -1850,12 +1818,6 @@ export async function startBuildWorkflowAgentTask( }; } if (spawnOutcome.status === 'limit-reached') { - if (reusedBuilderSession) { - void context.builderSandboxSessionRegistry?.release(reusedBuilderSession.sessionId, { - keep: true, - reason: 'spawn_limit_reached', - }); - } return { result: 'Could not start build: concurrent background-task limit reached. Wait for an existing task to finish and try again.', diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/submit-workflow-identity.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/submit-workflow-identity.test.ts index 48a0841990d..2bbcb167c51 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/submit-workflow-identity.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/submit-workflow-identity.test.ts @@ -2,6 +2,7 @@ import { createRemediation } from '../../../workflow-loop/remediation'; import type { WorkflowLoopState } from '../../../workflow-loop/workflow-loop-state'; import { createPreSaveBudgetTracker, + withDefaultWorkflowFilePath, wrapSubmitExecuteWithIdentity, } from '../submit-workflow-identity'; import type { SubmitWorkflowInput, SubmitWorkflowOutput } from '../submit-workflow.tool'; @@ -9,6 +10,7 @@ import type { SubmitWorkflowInput, SubmitWorkflowOutput } from '../submit-workfl const ROOT = '/home/daytona/workspace'; const MAIN_PATH = `${ROOT}/src/workflow.ts`; const CHUNK_PATH = `${ROOT}/src/chunk.ts`; +const TASK_MAIN_PATH = `${ROOT}/builder-work-items/wi-one/src/workflow.ts`; function resolvePath(rawFilePath: string | undefined): string { if (!rawFilePath) return MAIN_PATH; @@ -41,6 +43,24 @@ function makeUnderlying(opts: { idPrefix?: string; gate?: Promise } = {}) return { execute, calls }; } +describe('withDefaultWorkflowFilePath', () => { + it('uses the task main workflow file when submit-workflow omits filePath', () => { + expect(withDefaultWorkflowFilePath({ name: 'Workflow' }, TASK_MAIN_PATH)).toEqual({ + name: 'Workflow', + filePath: TASK_MAIN_PATH, + }); + }); + + it('preserves explicit filePath values for chunks and follow-up submits', () => { + expect( + withDefaultWorkflowFilePath({ filePath: CHUNK_PATH, name: 'Chunk' }, TASK_MAIN_PATH), + ).toEqual({ + filePath: CHUNK_PATH, + name: 'Chunk', + }); + }); +}); + describe('wrapSubmitExecuteWithIdentity', () => { it('parallel submits for the same filePath produce one create and N-1 updates sharing the workflowId', async () => { let release: () => void = () => {}; diff --git a/packages/@n8n/instance-ai/src/tools/workflows/submit-workflow-identity.ts b/packages/@n8n/instance-ai/src/tools/workflows/submit-workflow-identity.ts index 3a0dad7df20..b287c370b6c 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/submit-workflow-identity.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/submit-workflow-identity.ts @@ -238,6 +238,13 @@ export function wrapSubmitExecuteWithIdentity( }; } +export function withDefaultWorkflowFilePath( + input: SubmitWorkflowInput, + defaultFilePath: string | undefined, +): SubmitWorkflowInput { + return defaultFilePath && !input.filePath ? { ...input, filePath: defaultFilePath } : input; +} + /** * Build a submit-workflow tool wired with identity enforcement. * Convenience factory used at the builder-agent callsite. @@ -248,6 +255,7 @@ export function createIdentityEnforcedSubmitWorkflowTool(args: { credentialMap?: CredentialMap; onAttempt: (attempt: SubmitWorkflowAttempt) => Promise | void; root: string; + defaultFilePath?: string; currentRunId?: string; getWorkflowLoopState?: () => Promise; onGuardFired?: SubmitGuardOptions['onGuardFired']; @@ -269,7 +277,10 @@ export function createIdentityEnforcedSubmitWorkflowTool(args: { const wrappedExecute = wrapSubmitExecuteWithIdentity( underlyingExecute, - (rawFilePath) => resolveSandboxWorkflowFilePath(rawFilePath, args.root), + (rawFilePath) => + rawFilePath + ? resolveSandboxWorkflowFilePath(rawFilePath, args.root) + : (args.defaultFilePath ?? resolveSandboxWorkflowFilePath(rawFilePath, args.root)), { budgetTracker, currentRunId: args.currentRunId, @@ -282,6 +293,9 @@ export function createIdentityEnforcedSubmitWorkflowTool(args: { .description(underlying.description) .input(submitWorkflowInputSchema) .output(submitWorkflowOutputSchema) - .handler(wrappedExecute) + .handler( + async (input) => + await wrappedExecute(withDefaultWorkflowFilePath(input, args.defaultFilePath)), + ) .build(); } diff --git a/packages/@n8n/instance-ai/src/tools/workflows/submit-workflow.tool.ts b/packages/@n8n/instance-ai/src/tools/workflows/submit-workflow.tool.ts index 6bd24060663..8aef83b6fb6 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/submit-workflow.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/submit-workflow.tool.ts @@ -168,7 +168,7 @@ export const submitWorkflowInputSchema = z.object({ filePath: z .string() .optional() - .describe('Path to the TypeScript workflow file (default: ~/workspace/src/workflow.ts)'), + .describe('Path to the TypeScript workflow file (defaults to the builder task main file)'), workflowId: z .string() .optional() diff --git a/packages/@n8n/instance-ai/src/types.ts b/packages/@n8n/instance-ai/src/types.ts index 10c28dc30dd..9efb0645ce7 100644 --- a/packages/@n8n/instance-ai/src/types.ts +++ b/packages/@n8n/instance-ai/src/types.ts @@ -32,7 +32,6 @@ import type { DomainAccessTracker } from './domain-access/domain-access-tracker' import type { InstanceAiEventBus } from './event-bus/event-bus.interface'; import type { Logger } from './logger'; import type { McpClientManager } from './mcp/mcp-client-manager'; -import type { BuilderSandboxSessionRegistry } from './runtime/builder-sandbox-session-registry'; import type { IterationLog } from './storage/iteration-log'; import type { IdRemapper, TraceIndex, TraceWriter } from './tracing/trace-replay'; import type { @@ -41,7 +40,6 @@ import type { WorkflowLoopAction, WorkflowLoopState, } from './workflow-loop/workflow-loop-state'; -import type { BuilderSandboxFactory } from './workspace/builder-sandbox-factory'; // ── Data shapes ────────────────────────────────────────────────────────────── @@ -1072,12 +1070,8 @@ export interface OrchestrationContext { plannedTaskService?: PlannedTaskService; /** Run one scheduler pass after plan/task state changes. */ schedulePlannedTasks?: () => Promise; - /** Sandbox workspace — when present, enables sandbox-based workflow building */ + /** Shared runtime workspace for the current orchestration context. */ workspace?: Workspace; - /** Factory for creating per-builder ephemeral sandboxes from a pre-warmed snapshot */ - builderSandboxFactory?: BuilderSandboxFactory; - /** Process-local registry for retaining recently finished builder sandboxes. */ - builderSandboxSessionRegistry?: BuilderSandboxSessionRegistry; /** Directories containing node type definition files (.ts) for materializing into sandbox */ nodeDefinitionDirs?: string[]; /** Native memory store — used to retrieve thread message history for sub-agents. */ @@ -1139,13 +1133,6 @@ export interface CreateInstanceAgentOptions { * Intended for tests and fallback paths that need the full toolset visible immediately. */ disableDeferredTools?: boolean; - /** - * @deprecated Ignored by the orchestrator. Passing a workspace here used to auto-register - * workspace tools on the orchestrator, which the LLM abused as a `sleep` primitive - * and mis-routed for build-task polling. Sandbox access is now scoped to the workflow-builder - * subagent via `builderSandboxFactory`; `orchestrationContext.workspace` still flows to it. - */ - workspace?: Workspace; /** IANA time zone for the current user (e.g. "Europe/Helsinki"). Falls back to instance default. */ timeZone?: string; } diff --git a/packages/@n8n/instance-ai/src/workspace/__tests__/create-workspace.test.ts b/packages/@n8n/instance-ai/src/workspace/__tests__/create-workspace.test.ts index 17a9b3f387f..5b0a8120ba1 100644 --- a/packages/@n8n/instance-ai/src/workspace/__tests__/create-workspace.test.ts +++ b/packages/@n8n/instance-ai/src/workspace/__tests__/create-workspace.test.ts @@ -33,6 +33,8 @@ describe('createSandbox', () => { const config: SandboxConfig = { enabled: true, provider: 'daytona', + id: 'instance-ai-thread-thread-1', + name: 'instance-ai-thread-thread-1', daytonaApiUrl: 'https://api.daytona.io', daytonaApiKey: 'test-key', image: 'node:20', @@ -44,6 +46,8 @@ describe('createSandbox', () => { expect(result).toBeInstanceOf(DaytonaSandbox); expect(getPrivateOptions(result)).toEqual( expect.objectContaining({ + id: 'instance-ai-thread-thread-1', + name: 'instance-ai-thread-thread-1', apiKey: 'test-key', apiUrl: 'https://api.daytona.io', image: 'node:20', diff --git a/packages/@n8n/instance-ai/src/workspace/__tests__/lazy-runtime-workspace.test.ts b/packages/@n8n/instance-ai/src/workspace/__tests__/lazy-runtime-workspace.test.ts new file mode 100644 index 00000000000..aff0d48aabd --- /dev/null +++ b/packages/@n8n/instance-ai/src/workspace/__tests__/lazy-runtime-workspace.test.ts @@ -0,0 +1,187 @@ +import { + Workspace, + type CommandResult, + type WorkspaceFilesystem, + type WorkspaceSandbox, +} from '@n8n/agents'; + +import { createLazyRuntimeWorkspace } from '../lazy-runtime-workspace'; + +function createMockWorkspace() { + const executeCommand = jest.fn< + Promise, + Parameters> + >( + async (_command, _args, options) => + await Promise.resolve({ + success: true, + exitCode: 0, + stdout: options?.env?.CUSTOM_ENV ?? '', + stderr: '', + executionTimeMs: 1, + }), + ); + const filesystem: WorkspaceFilesystem = { + id: 'fs', + name: 'Filesystem', + provider: 'test', + status: 'ready', + destroy: jest.fn(async () => { + filesystem.status = 'destroyed'; + await Promise.resolve(); + }), + getInstructions: jest.fn(() => 'Real filesystem instructions.'), + readFile: jest.fn(async () => await Promise.resolve('hello')), + writeFile: jest.fn(async () => await Promise.resolve()), + appendFile: jest.fn(async () => await Promise.resolve()), + deleteFile: jest.fn(async () => await Promise.resolve()), + copyFile: jest.fn(async () => await Promise.resolve()), + moveFile: jest.fn(async () => await Promise.resolve()), + mkdir: jest.fn(async () => await Promise.resolve()), + rmdir: jest.fn(async () => await Promise.resolve()), + readdir: jest.fn(async () => await Promise.resolve([])), + exists: jest.fn(async () => await Promise.resolve(true)), + stat: jest.fn( + async (path: string) => + await Promise.resolve({ + name: path, + path, + type: 'file' as const, + size: 5, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + modifiedAt: new Date('2026-01-01T00:00:00.000Z'), + }), + ), + }; + const sandbox: WorkspaceSandbox = { + id: 'sandbox', + name: 'Sandbox', + provider: 'test', + status: 'running', + stop: jest.fn(async () => { + sandbox.status = 'stopped'; + await Promise.resolve(); + }), + destroy: jest.fn(async () => { + sandbox.status = 'destroyed'; + await Promise.resolve(); + }), + getInstructions: jest.fn(() => 'Real sandbox instructions.'), + getDefaultCommandEnv: jest.fn(() => ({ CUSTOM_ENV: 'enabled' })), + executeCommand, + }; + + return { + workspace: new Workspace({ filesystem, sandbox }), + filesystem, + sandbox, + executeCommand, + }; +} + +describe('createLazyRuntimeWorkspace', () => { + it('advertises workspace tools without creating the real workspace', async () => { + const { workspace } = createMockWorkspace(); + const ensureWorkspace = jest.fn(async () => await Promise.resolve(workspace)); + const lazyWorkspace = createLazyRuntimeWorkspace({ ensureWorkspace }); + + const tools = lazyWorkspace.getTools(); + lazyWorkspace.getInstructions(); + + expect(ensureWorkspace).not.toHaveBeenCalled(); + expect(tools.some((tool) => tool.name === 'workspace_read_file')).toBe(true); + expect(tools.some((tool) => tool.name === 'workspace_execute_command')).toBe(true); + + const readFile = tools.find((tool) => tool.name === 'workspace_read_file'); + await readFile?.handler?.({ path: '/workspace/report.md' }, {}); + + expect(ensureWorkspace).toHaveBeenCalledTimes(1); + }); + + it('merges sandbox default env after the real workspace is created', async () => { + const { workspace, executeCommand } = createMockWorkspace(); + const ensureWorkspace = jest.fn(async () => await Promise.resolve(workspace)); + const lazyWorkspace = createLazyRuntimeWorkspace({ ensureWorkspace }); + const executeCommandTool = lazyWorkspace + .getTools() + .find((tool) => tool.name === 'workspace_execute_command'); + + const result = await executeCommandTool?.handler?.({ command: 'echo $CUSTOM_ENV' }, {}); + + expect(result).toMatchObject({ stdout: 'enabled' }); + expect(executeCommand.mock.calls[0]?.[0]).toBe('echo $CUSTOM_ENV'); + expect(executeCommand.mock.calls[0]?.[1]).toEqual([]); + expect(executeCommand.mock.calls[0]?.[2]?.env).toMatchObject({ + CUSTOM_ENV: 'enabled', + }); + }); + + it('retries workspace creation after the first lazy initialization fails', async () => { + const { workspace } = createMockWorkspace(); + const ensureWorkspace = jest + .fn() + .mockRejectedValueOnce(new Error('setup failed')) + .mockResolvedValueOnce(workspace); + const lazyWorkspace = createLazyRuntimeWorkspace({ ensureWorkspace }); + const readFile = lazyWorkspace.getTools().find((tool) => tool.name === 'workspace_read_file'); + + await expect(readFile?.handler?.({ path: '/workspace/report.md' }, {})).rejects.toThrow( + 'setup failed', + ); + await expect(readFile?.handler?.({ path: '/workspace/report.md' }, {})).resolves.toEqual({ + content: 'hello', + }); + + expect(ensureWorkspace).toHaveBeenCalledTimes(2); + }); + + it('retries workspace creation after the first lazy initialization returns unavailable', async () => { + const { workspace } = createMockWorkspace(); + const ensureWorkspace = jest + .fn() + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(workspace); + const lazyWorkspace = createLazyRuntimeWorkspace({ ensureWorkspace }); + const readFile = lazyWorkspace.getTools().find((tool) => tool.name === 'workspace_read_file'); + + await expect(readFile?.handler?.({ path: '/workspace/report.md' }, {})).rejects.toThrow( + 'Instance AI runtime workspace is unavailable.', + ); + await expect(readFile?.handler?.({ path: '/workspace/report.md' }, {})).resolves.toEqual({ + content: 'hello', + }); + + expect(ensureWorkspace).toHaveBeenCalledTimes(2); + }); + + it('reflects resolved provider statuses and instructions', async () => { + const { workspace } = createMockWorkspace(); + const ensureWorkspace = jest.fn(async () => await Promise.resolve(workspace)); + const lazyWorkspace = createLazyRuntimeWorkspace({ ensureWorkspace }); + + expect(lazyWorkspace.filesystem?.status).toBe('pending'); + expect(lazyWorkspace.sandbox?.status).toBe('pending'); + expect(lazyWorkspace.getInstructions()).toContain('create the runtime workspace on first use'); + + await lazyWorkspace.filesystem?.readFile('/workspace/report.md'); + + expect(lazyWorkspace.filesystem?.status).toBe('ready'); + expect(lazyWorkspace.sandbox?.status).toBe('running'); + expect(lazyWorkspace.getInstructions()).toContain('Real sandbox instructions.'); + expect(lazyWorkspace.getInstructions()).toContain('Real filesystem instructions.'); + }); + + it('destroys the resolved workspace when the lazy workspace is destroyed', async () => { + const { workspace, filesystem, sandbox } = createMockWorkspace(); + const ensureWorkspace = jest.fn(async () => await Promise.resolve(workspace)); + const lazyWorkspace = createLazyRuntimeWorkspace({ ensureWorkspace }); + + await lazyWorkspace.filesystem?.readFile('/workspace/report.md'); + await lazyWorkspace.destroy(); + + expect(sandbox.destroy).toHaveBeenCalledTimes(1); + expect(filesystem.destroy).toHaveBeenCalledTimes(1); + expect(lazyWorkspace.filesystem?.status).toBe('destroyed'); + expect(lazyWorkspace.sandbox?.status).toBe('destroyed'); + }); +}); diff --git a/packages/@n8n/instance-ai/src/workspace/__tests__/sandbox-setup.test.ts b/packages/@n8n/instance-ai/src/workspace/__tests__/sandbox-setup.test.ts index 1c42f39432a..8f6487e5642 100644 --- a/packages/@n8n/instance-ai/src/workspace/__tests__/sandbox-setup.test.ts +++ b/packages/@n8n/instance-ai/src/workspace/__tests__/sandbox-setup.test.ts @@ -3,9 +3,14 @@ import { jsonParse } from 'n8n-workflow'; import type { InstanceAiContext, SearchableNodeDescription } from '../../types'; import type { SandboxWorkspace } from '../sandbox-fs'; import type { setupSandboxWorkspace as setupSandboxWorkspaceFunction } from '../sandbox-setup'; -import { formatNodeCatalogLine } from '../sandbox-setup'; +import { formatNodeCatalogLine, getWorkspaceRoot } from '../sandbox-setup'; type SetupSandboxWorkspace = typeof setupSandboxWorkspaceFunction; +type LinkWorkspaceSdkIfEnabled = ( + workspace: SandboxWorkspace, + root: string, + logger?: { error: jest.Mock; info: jest.Mock }, +) => Promise; type RunInSandboxMock = jest.Mock< Promise<{ exitCode: number; stdout: string; stderr: string }>, [SandboxWorkspace, string, string?] @@ -19,12 +24,13 @@ function createSetupContext(): InstanceAiContext { }, workflowService: { list: jest.fn().mockResolvedValue([]), + get: jest.fn(), }, } as unknown as InstanceAiContext; } function createLocalWorkspace( - writeFile: jest.Mock, [string, string, { recursive?: boolean }?]>, + writeFile: jest.Mock, [string, string | Buffer, { recursive?: boolean }?]>, mkdir?: jest.Mock, [string, { recursive?: boolean }?]>, ): SandboxWorkspace { return { @@ -45,6 +51,12 @@ function loadSetupSandboxWorkspaceWithFsMocks( jest.doMock('../sandbox-fs', () => ({ runInSandbox, readFileViaSandbox, + writeFileViaSandbox: async (workspace: SandboxWorkspace, path: string) => { + const result = await runInSandbox(workspace, `write '${path}'`); + if (result.exitCode !== 0) { + throw new Error(`Failed to write file ${path}: ${result.stderr}`); + } + }, escapeSingleQuotes: (value: string) => value.replace(/'/g, "'\\''"), })); @@ -60,6 +72,34 @@ function loadSetupSandboxWorkspaceWithFsMocks( return sandboxSetup.setupSandboxWorkspace; } +function loadLinkWorkspaceSdkWithMocks( + packWorkspaceSdk: jest.Mock, + runInSandbox: RunInSandboxMock, +): LinkWorkspaceSdkIfEnabled { + jest.resetModules(); + jest.doMock('../pack-workspace-sdk', () => ({ + isLinkWorkspaceSdkEnabled: () => true, + packWorkspaceSdk, + })); + jest.doMock('../sandbox-fs', () => ({ + runInSandbox, + readFileViaSandbox: jest.fn(), + writeFileViaSandbox: jest.fn(), + escapeSingleQuotes: (value: string) => value.replace(/'/g, "'\\''"), + })); + + let sandboxSetup: { linkWorkspaceSdkIfEnabled: LinkWorkspaceSdkIfEnabled } | undefined; + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + sandboxSetup = require('../sandbox-setup') as { + linkWorkspaceSdkIfEnabled: LinkWorkspaceSdkIfEnabled; + }; + }); + + if (!sandboxSetup) throw new Error('Failed to load sandbox setup module'); + return sandboxSetup.linkWorkspaceSdkIfEnabled; +} + function loadSandboxPackageJson(linkSdk: boolean): { dependencies: Record; devDependencies: Record; @@ -131,7 +171,7 @@ describe('setupSandboxWorkspace', () => { runInSandbox, readFileViaSandbox, ); - const writeFile = jest.fn, [string, string, { recursive?: boolean }?]>( + const writeFile = jest.fn, [string, string | Buffer, { recursive?: boolean }?]>( async () => {}, ); @@ -161,7 +201,7 @@ describe('setupSandboxWorkspace', () => { runInSandbox, readFileViaSandbox, ); - const writeFile = jest.fn, [string, string, { recursive?: boolean }?]>( + const writeFile = jest.fn, [string, string | Buffer, { recursive?: boolean }?]>( async () => {}, ); const mkdir = jest.fn, [string, { recursive?: boolean }?]>(async () => {}); @@ -190,7 +230,7 @@ describe('setupSandboxWorkspace', () => { runInSandbox, readFileViaSandbox, ); - const writeFile = jest.fn, [string, string, { recursive?: boolean }?]>( + const writeFile = jest.fn, [string, string | Buffer, { recursive?: boolean }?]>( async () => {}, ); @@ -201,6 +241,37 @@ describe('setupSandboxWorkspace', () => { expect(writtenPaths.some((p) => /^\/sandbox\/examples\/.+\.ts$/.test(p))).toBe(true); }); + it('rejects setup file paths that escape the workspace root', async () => { + const runInSandbox: RunInSandboxMock = jest.fn< + Promise<{ exitCode: number; stdout: string; stderr: string }>, + [SandboxWorkspace, string, string?] + >(); + runInSandbox.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); + const readFileViaSandbox: ReadFileViaSandboxMock = jest.fn< + Promise, + [SandboxWorkspace, string] + >(); + readFileViaSandbox.mockResolvedValue(null); + const setupSandboxWorkspace = loadSetupSandboxWorkspaceWithFsMocks( + runInSandbox, + readFileViaSandbox, + ); + const writeFile = jest.fn, [string, string | Buffer, { recursive?: boolean }?]>( + async () => {}, + ); + const context = createSetupContext(); + const workflowService = context.workflowService as unknown as { + list: jest.Mock>, [{ limit: number }]>; + get: jest.Mock>, [string]>; + }; + workflowService.list.mockResolvedValue([{ id: '../escape' }]); + workflowService.get.mockResolvedValue({ id: '../escape' }); + + await expect(setupSandboxWorkspace(createLocalWorkspace(writeFile), context)).rejects.toThrow( + 'Sandbox workspace setup failed during write-workspace-files', + ); + }); + it('does not write the initialized marker when npm install fails', async () => { const runInSandbox: RunInSandboxMock = jest.fn< Promise<{ exitCode: number; stdout: string; stderr: string }>, @@ -216,7 +287,7 @@ describe('setupSandboxWorkspace', () => { runInSandbox, readFileViaSandbox, ); - const writeFile = jest.fn, [string, string, { recursive?: boolean }?]>( + const writeFile = jest.fn, [string, string | Buffer, { recursive?: boolean }?]>( async () => {}, ); @@ -230,6 +301,159 @@ describe('setupSandboxWorkspace', () => { { recursive: true }, ]); }); + + it('uses command fallback when a filesystem marker write fails', async () => { + const runInSandbox: RunInSandboxMock = jest.fn< + Promise<{ exitCode: number; stdout: string; stderr: string }>, + [SandboxWorkspace, string, string?] + >(); + runInSandbox.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); + const readFileViaSandbox: ReadFileViaSandboxMock = jest.fn< + Promise, + [SandboxWorkspace, string] + >(); + readFileViaSandbox.mockResolvedValue(null); + const setupSandboxWorkspace = loadSetupSandboxWorkspaceWithFsMocks( + runInSandbox, + readFileViaSandbox, + ); + const writeFile = jest + .fn, [string, string | Buffer, { recursive?: boolean }?]>() + .mockImplementation(async (path) => { + await Promise.resolve(); + if (path === '/sandbox/.sandbox-initialized') { + throw new Error('primary write failed'); + } + }); + + await expect( + setupSandboxWorkspace(createLocalWorkspace(writeFile), createSetupContext()), + ).resolves.toBe(true); + + expect( + runInSandbox.mock.calls.some(([, command]) => command.includes('.sandbox-initialized')), + ).toBe(true); + }); + + it('includes the failing setup step when marker fallback fails', async () => { + const runInSandbox: RunInSandboxMock = jest.fn< + Promise<{ exitCode: number; stdout: string; stderr: string }>, + [SandboxWorkspace, string, string?] + >(); + runInSandbox.mockImplementation(async (_workspace, command) => { + await Promise.resolve(); + return command.includes('.sandbox-initialized') + ? { exitCode: 1, stdout: '', stderr: 'fallback failed' } + : { exitCode: 0, stdout: '', stderr: '' }; + }); + const readFileViaSandbox: ReadFileViaSandboxMock = jest.fn< + Promise, + [SandboxWorkspace, string] + >(); + readFileViaSandbox.mockResolvedValue(null); + const setupSandboxWorkspace = loadSetupSandboxWorkspaceWithFsMocks( + runInSandbox, + readFileViaSandbox, + ); + const writeFile = jest + .fn, [string, string | Buffer, { recursive?: boolean }?]>() + .mockImplementation(async (path) => { + await Promise.resolve(); + if (path === '/sandbox/.sandbox-initialized') { + throw new Error('primary write failed'); + } + }); + + const error = await setupSandboxWorkspace( + createLocalWorkspace(writeFile), + createSetupContext(), + ).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain( + 'Sandbox workspace setup failed during write-initialization-marker', + ); + expect((error as Error).message).toContain( + 'Failed to write sandbox workspace file "/sandbox/.sandbox-initialized"', + ); + expect((error as Error).message).toContain('primary write failed'); + expect((error as Error).message).toContain('command fallback failed'); + }); + + it('retries packing the workspace SDK after a null pack result', async () => { + const originalLinkSdk = process.env.N8N_INSTANCE_AI_SANDBOX_LINK_SDK; + process.env.N8N_INSTANCE_AI_SANDBOX_LINK_SDK = '1'; + const tarball = Buffer.from('sdk'); + const packWorkspaceSdk = jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce({ + filename: 'workflow-sdk.tgz', + tarball, + version: '1.0.0', + sdkPath: '/host/sdk', + }); + const runInSandbox: RunInSandboxMock = jest.fn< + Promise<{ exitCode: number; stdout: string; stderr: string }>, + [SandboxWorkspace, string, string?] + >(); + runInSandbox.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); + const linkWorkspaceSdkIfEnabled = loadLinkWorkspaceSdkWithMocks(packWorkspaceSdk, runInSandbox); + const writeFile = jest.fn, [string, Buffer, { recursive?: boolean }?]>( + async () => {}, + ); + const workspace = { + filesystem: { + provider: 'daytona', + writeFile, + }, + } as unknown as SandboxWorkspace; + + try { + await expect(linkWorkspaceSdkIfEnabled(workspace, '/workspace')).rejects.toThrow( + 'workspace SDK could not be packed', + ); + await linkWorkspaceSdkIfEnabled(workspace, '/workspace'); + } finally { + if (originalLinkSdk === undefined) { + delete process.env.N8N_INSTANCE_AI_SANDBOX_LINK_SDK; + } else { + process.env.N8N_INSTANCE_AI_SANDBOX_LINK_SDK = originalLinkSdk; + } + } + + expect(packWorkspaceSdk).toHaveBeenCalledTimes(2); + expect(writeFile).toHaveBeenCalledWith('/workspace/workflow-sdk.tgz', tarball, { + recursive: true, + }); + }); +}); + +describe('getWorkspaceRoot', () => { + it('uses the resolved filesystem base path for lazy local workspaces', async () => { + let initialized = false; + const executeCommand = jest.fn(); + const init = jest.fn, []>(async () => { + await Promise.resolve(); + initialized = true; + }); + const workspace = { + filesystem: { + provider: 'lazy', + get basePath() { + return initialized ? '/sandbox' : undefined; + }, + init, + writeFile: jest.fn(), + mkdir: jest.fn(), + }, + sandbox: { + executeCommand, + }, + } as unknown as SandboxWorkspace; + + await expect(getWorkspaceRoot(workspace)).resolves.toBe('/sandbox'); + + expect(init).toHaveBeenCalledTimes(1); + expect(executeCommand).not.toHaveBeenCalled(); + }); }); describe('formatNodeCatalogLine', () => { diff --git a/packages/@n8n/instance-ai/src/workspace/create-workspace.ts b/packages/@n8n/instance-ai/src/workspace/create-workspace.ts index 0210c0c0c54..655c22f46be 100644 --- a/packages/@n8n/instance-ai/src/workspace/create-workspace.ts +++ b/packages/@n8n/instance-ai/src/workspace/create-workspace.ts @@ -21,6 +21,8 @@ interface DisabledSandboxConfig extends SandboxConfigBase { interface DaytonaSandboxConfig extends SandboxConfigBase { enabled: true; provider: 'daytona'; + id?: string; + name?: string; daytonaApiUrl?: string; daytonaApiKey?: string; image?: string; @@ -73,6 +75,8 @@ export async function createSandbox( // In proxy mode, resolve a fresh token via getAuthToken; in direct mode use the static key. const apiKey = config.getAuthToken ? await config.getAuthToken() : config.daytonaApiKey; return new DaytonaSandbox({ + id: config.id, + name: config.name, apiKey, apiUrl: config.daytonaApiUrl, ...(config.image ? { image: config.image } : {}), diff --git a/packages/@n8n/instance-ai/src/workspace/lazy-runtime-workspace.ts b/packages/@n8n/instance-ai/src/workspace/lazy-runtime-workspace.ts new file mode 100644 index 00000000000..4c99a33f663 --- /dev/null +++ b/packages/@n8n/instance-ai/src/workspace/lazy-runtime-workspace.ts @@ -0,0 +1,317 @@ +import { + BaseFilesystem, + BaseSandbox, + Workspace, + type CommandResult, + type CopyOptions, + type ExecuteCommandOptions, + type FileContent, + type FileEntry, + type FileStat, + type ListOptions, + type ProviderStatus, + type ReadOptions, + type RemoveOptions, + type WorkspaceFilesystem, + type WorkspaceSandbox, + type WriteOptions, +} from '@n8n/agents'; + +export type RuntimeWorkspaceResolver = () => Promise; + +export interface LazyRuntimeWorkspaceOptions { + ensureWorkspace: RuntimeWorkspaceResolver; + id?: string; + name?: string; +} + +type WorkspaceResolvedListener = (workspace: Workspace) => void; +type WorkspaceDestroyedListener = () => void; + +export function createLazyRuntimeWorkspace({ + ensureWorkspace, + id = 'instance-ai-runtime-workspace', + name = 'Instance AI runtime workspace', +}: LazyRuntimeWorkspaceOptions): Workspace { + const resolver = new LazyRuntimeWorkspaceResolver(ensureWorkspace); + + return new Workspace({ + id, + name, + filesystem: new LazyRuntimeFilesystem(resolver), + sandbox: new LazyRuntimeSandbox(resolver), + }); +} + +class LazyRuntimeWorkspaceResolver { + private workspacePromise: Promise | undefined; + private resolvedWorkspace: Workspace | undefined; + private workspaceDestroyPromise: Promise | undefined; + private readonly resolvedListeners = new Set(); + private readonly destroyedListeners = new Set(); + + constructor(private readonly ensureWorkspace: RuntimeWorkspaceResolver) {} + + get current(): Workspace | undefined { + return this.resolvedWorkspace; + } + + onResolved(listener: WorkspaceResolvedListener): void { + this.resolvedListeners.add(listener); + if (this.resolvedWorkspace) listener(this.resolvedWorkspace); + } + + onDestroyed(listener: WorkspaceDestroyedListener): void { + this.destroyedListeners.add(listener); + } + + async getWorkspace(): Promise { + this.workspacePromise ??= this.ensureWorkspace() + .then((workspace) => { + this.setResolvedWorkspace(workspace); + return workspace; + }) + .catch((error: unknown) => { + this.workspacePromise = undefined; + throw error; + }); + + const workspace = await this.workspacePromise; + if (!workspace) { + this.workspacePromise = undefined; + throw new Error('Instance AI runtime workspace is unavailable.'); + } + + return workspace; + } + + async destroyResolvedWorkspace(): Promise { + if (this.workspaceDestroyPromise) return await this.workspaceDestroyPromise; + + const workspace = + this.resolvedWorkspace ?? (await this.workspacePromise?.catch(() => undefined)); + if (!workspace) { + this.workspacePromise = undefined; + return; + } + + this.workspaceDestroyPromise = workspace.destroy().then(() => { + this.workspacePromise = undefined; + this.resolvedWorkspace = undefined; + this.notifyDestroyed(); + }); + try { + await this.workspaceDestroyPromise; + } finally { + this.workspaceDestroyPromise = undefined; + } + } + + async getFilesystem(): Promise { + const filesystem = (await this.getWorkspace()).filesystem; + if (!filesystem) { + throw new Error('Instance AI runtime workspace has no filesystem.'); + } + + return filesystem; + } + + async getSandbox(): Promise { + const sandbox = (await this.getWorkspace()).sandbox; + if (!sandbox) { + throw new Error('Instance AI runtime workspace has no sandbox.'); + } + + return sandbox; + } + + private setResolvedWorkspace(workspace: Workspace | undefined): void { + this.resolvedWorkspace = workspace; + if (!workspace) return; + + for (const listener of this.resolvedListeners) { + listener(workspace); + } + } + + private notifyDestroyed(): void { + for (const listener of this.destroyedListeners) { + listener(); + } + } +} + +class LazyRuntimeFilesystem extends BaseFilesystem { + readonly id = 'instance-ai-runtime-filesystem'; + readonly name = 'InstanceAiRuntimeFilesystem'; + readonly provider = 'lazy'; + status: ProviderStatus = 'pending'; + + constructor(private readonly resolver: LazyRuntimeWorkspaceResolver) { + super(); + this.resolver.onResolved((workspace) => { + this.status = workspace.filesystem?.status ?? this.status; + }); + this.resolver.onDestroyed(() => { + this.status = 'destroyed'; + }); + } + + get readOnly(): boolean | undefined { + return this.resolver.current?.filesystem?.readOnly; + } + + get basePath(): string | undefined { + return this.resolver.current?.filesystem?.basePath; + } + + override async init(): Promise { + const filesystem = await this.getFilesystem(); + await (filesystem._init?.() ?? filesystem.init?.()); + this.syncStatus(filesystem); + } + + override async destroy(): Promise { + await this.resolver.destroyResolvedWorkspace(); + } + + getInstructions(): string { + const instructions = this.resolver.current?.filesystem?.getInstructions?.(); + if (instructions) return instructions; + + return [ + 'Workspace file tools are available and create the runtime workspace on first use.', + 'Use relative workspace paths unless a loaded skill explicitly provides an absolute workspace path.', + ].join(' '); + } + + async readFile(path: string, options?: ReadOptions): Promise { + return await (await this.getFilesystem()).readFile(path, options); + } + + async writeFile(path: string, content: FileContent, options?: WriteOptions): Promise { + await (await this.getFilesystem()).writeFile(path, content, options); + } + + async appendFile(path: string, content: FileContent): Promise { + await (await this.getFilesystem()).appendFile(path, content); + } + + async deleteFile(path: string, options?: RemoveOptions): Promise { + await (await this.getFilesystem()).deleteFile(path, options); + } + + async copyFile(src: string, dest: string, options?: CopyOptions): Promise { + await (await this.getFilesystem()).copyFile(src, dest, options); + } + + async moveFile(src: string, dest: string, options?: CopyOptions): Promise { + await (await this.getFilesystem()).moveFile(src, dest, options); + } + + async mkdir(path: string, options?: { recursive?: boolean }): Promise { + await (await this.getFilesystem()).mkdir(path, options); + } + + async rmdir(path: string, options?: RemoveOptions): Promise { + await (await this.getFilesystem()).rmdir(path, options); + } + + async readdir(path: string, options?: ListOptions): Promise { + return await (await this.getFilesystem()).readdir(path, options); + } + + async exists(path: string): Promise { + return await (await this.getFilesystem()).exists(path); + } + + async stat(path: string): Promise { + return await (await this.getFilesystem()).stat(path); + } + + private async getFilesystem(): Promise { + const filesystem = await this.resolver.getFilesystem(); + this.syncStatus(filesystem); + return filesystem; + } + + private syncStatus(filesystem: WorkspaceFilesystem): void { + this.status = filesystem.status; + } +} + +class LazyRuntimeSandbox extends BaseSandbox { + readonly id = 'instance-ai-runtime-sandbox'; + readonly name = 'InstanceAiRuntimeSandbox'; + readonly provider = 'lazy'; + status: ProviderStatus = 'pending'; + + constructor(private readonly resolver: LazyRuntimeWorkspaceResolver) { + super(); + this.resolver.onResolved((workspace) => { + this.status = workspace.sandbox?.status ?? this.status; + }); + this.resolver.onDestroyed(() => { + this.status = 'destroyed'; + }); + } + + override async start(): Promise { + const sandbox = await this.getSandbox(); + await (sandbox._start?.() ?? sandbox.start?.()); + this.syncStatus(sandbox); + } + + override async stop(): Promise { + const sandbox = this.resolver.current?.sandbox; + if (!sandbox) return; + await (sandbox._stop?.() ?? sandbox.stop?.()); + this.syncStatus(sandbox); + } + + override async destroy(): Promise { + await this.resolver.destroyResolvedWorkspace(); + } + + getDefaultCommandEnv(): NodeJS.ProcessEnv { + return this.resolver.current?.sandbox?.getDefaultCommandEnv?.() ?? {}; + } + + override async executeCommand( + command: string, + args: string[] = [], + options?: ExecuteCommandOptions, + ): Promise { + const sandbox = await this.getSandbox(); + if (!sandbox.executeCommand) { + throw new Error('Instance AI runtime sandbox does not support command execution.'); + } + + const defaultEnv = sandbox.getDefaultCommandEnv?.(); + try { + return await sandbox.executeCommand(command, args, { + ...options, + ...(defaultEnv ? { env: { ...defaultEnv, ...options?.env } } : {}), + }); + } finally { + this.syncStatus(sandbox); + } + } + + override getInstructions(): string { + const instructions = this.resolver.current?.sandbox?.getInstructions?.(); + if (instructions) return instructions; + + return 'Workspace command tools are available and create the runtime sandbox on first use.'; + } + + private async getSandbox(): Promise { + const sandbox = await this.resolver.getSandbox(); + this.syncStatus(sandbox); + return sandbox; + } + + private syncStatus(sandbox: WorkspaceSandbox): void { + this.status = sandbox.status; + } +} diff --git a/packages/@n8n/instance-ai/src/workspace/sandbox-fs.ts b/packages/@n8n/instance-ai/src/workspace/sandbox-fs.ts index 1f3af281fd6..472b095be7d 100644 --- a/packages/@n8n/instance-ai/src/workspace/sandbox-fs.ts +++ b/packages/@n8n/instance-ai/src/workspace/sandbox-fs.ts @@ -19,10 +19,16 @@ export interface SandboxWorkspace { filesystem?: { provider?: string; basePath?: string; - writeFile: (path: string, content: string, options?: { recursive?: boolean }) => Promise; + init?: () => Promise; + writeFile: ( + path: string, + content: string | Buffer, + options?: { recursive?: boolean }, + ) => Promise; mkdir: (path: string, options?: { recursive?: boolean }) => Promise; }; sandbox?: { + provider?: string; executeCommand?: ( command: string, args?: string[], diff --git a/packages/@n8n/instance-ai/src/workspace/sandbox-setup.ts b/packages/@n8n/instance-ai/src/workspace/sandbox-setup.ts index 245f5ff485e..9fd74d3f56a 100644 --- a/packages/@n8n/instance-ai/src/workspace/sandbox-setup.ts +++ b/packages/@n8n/instance-ai/src/workspace/sandbox-setup.ts @@ -26,7 +26,11 @@ import { createRequire } from 'node:module'; import type { Logger } from '../logger'; import type { InstanceAiContext, SearchableNodeDescription } from '../types'; -import { isLinkWorkspaceSdkEnabled } from './pack-workspace-sdk'; +import { + isLinkWorkspaceSdkEnabled, + packWorkspaceSdk, + type WorkspaceSdkTarball, +} from './pack-workspace-sdk'; import { runInSandbox, readFileViaSandbox, @@ -36,6 +40,44 @@ import { } from './sandbox-fs'; const hostRequire = createRequire(__filename); +const NOOP_LOGGER: Logger = { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, +}; + +type SandboxWorkspaceSetupStep = + | 'resolve-workspace-root' + | 'read-initialization-marker' + | 'list-node-types' + | 'write-workspace-files' + | 'write-curated-examples' + | 'install-dependencies' + | 'link-workspace-sdk' + | 'write-initialization-marker'; + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export class SandboxWorkspaceSetupError extends Error { + constructor( + readonly step: SandboxWorkspaceSetupStep, + readonly originalError: unknown, + ) { + super(`Sandbox workspace setup failed during ${step}: ${getErrorMessage(originalError)}`); + this.name = 'SandboxWorkspaceSetupError'; + } +} + +async function setupStep(step: SandboxWorkspaceSetupStep, action: () => Promise): Promise { + try { + return await action(); + } catch (error) { + throw new SandboxWorkspaceSetupError(step, error); + } +} export const WORKSPACE_DIR = 'workspace'; @@ -99,6 +141,35 @@ const SANDBOX_TSX_VERSION = resolveHostDepVersion('tsx'); */ const SANDBOX_TYPES_NODE_VERSION = '24.10.1'; +function assertSafeWorkspaceRelativePath(path: string): void { + const segments = path.split('/'); + if ( + path.length === 0 || + path.startsWith('/') || + path.includes('\\') || + path.includes('\0') || + segments.some((segment) => segment === '..') + ) { + throw new Error(`Sandbox workspace path must stay within the workspace root: ${path}`); + } +} + +function joinWorkspacePath(root: string, path: string): string { + assertSafeWorkspaceRelativePath(path); + + const normalizedRoot = root.replace(/\/+$/, '') || '/'; + const normalizedPath = path + .split('/') + .filter((segment) => segment.length > 0 && segment !== '.') + .join('/'); + + if (normalizedPath.length === 0) { + throw new Error(`Sandbox workspace path must stay within the workspace root: ${path}`); + } + + return normalizedRoot === '/' ? `/${normalizedPath}` : `${normalizedRoot}/${normalizedPath}`; +} + function buildPackageJson(sdkSpecifier: string | null): string { const dependencies: Record = { tsx: SANDBOX_TSX_VERSION, @@ -163,6 +234,61 @@ function buildLocalProviderPackageJson(): string { return buildPackageJson(`file:${sdkPath}`); } +function getSandboxProvider(workspace: SandboxWorkspace): string | undefined { + return workspace.filesystem?.provider ?? workspace.sandbox?.provider; +} + +function buildWorkspacePackageJson(workspace: SandboxWorkspace): string { + return getSandboxProvider(workspace) === 'local' ? buildLocalProviderPackageJson() : PACKAGE_JSON; +} + +let sdkTarballPromise: Promise | null = null; + +export async function linkWorkspaceSdkIfEnabled( + workspace: SandboxWorkspace, + root: string, + logger?: Logger, +): Promise { + if (!isLinkWorkspaceSdkEnabled() || getSandboxProvider(workspace) === 'local') return; + + sdkTarballPromise ??= packWorkspaceSdk(logger ?? NOOP_LOGGER).catch((error: unknown) => { + sdkTarballPromise = null; + throw error; + }); + const packed = await sdkTarballPromise; + if (!packed) { + sdkTarballPromise = null; + throw new Error( + 'N8N_INSTANCE_AI_SANDBOX_LINK_SDK is enabled, but the workspace SDK could not be packed. Run `pnpm build` in packages/@n8n/workflow-sdk or unset N8N_INSTANCE_AI_SANDBOX_LINK_SDK.', + ); + } + + const remotePath = joinWorkspacePath(root, packed.filename); + if (workspace.filesystem) { + await writeWorkspaceFile(workspace, workspace.filesystem, remotePath, packed.tarball); + } else { + await writeFileViaSandbox(workspace, remotePath, packed.tarball); + } + + const install = await runInSandbox( + workspace, + `npm install '${escapeSingleQuotes(remotePath)}' --no-save --ignore-scripts --force`, + root, + ); + if (install.exitCode !== 0) { + logger?.error('Failed to link workspace SDK into sandbox', { + exitCode: install.exitCode, + stderr: install.stderr, + }); + throw new Error(`Failed to install workspace SDK tarball: ${install.stderr}`); + } + + logger?.info('Linked workspace SDK into sandbox', { + version: packed.version, + sdkPath: packed.sdkPath, + }); +} + /** * Runner script that executes a workflow TS file via tsx, calls validate() + toJSON(), * and outputs structured JSON to stdout. Executed via: node --import tsx build.mjs ./src/workflow.ts @@ -241,19 +367,21 @@ async function writeWorkspaceFiles( // `writeFile` only creates parent dirs as a side-effect of writing a file. await Promise.all( ALWAYS_PRESENT_DIRS.map( - async (dir) => await filesystem.mkdir(`${root}/${dir}`, { recursive: true }), + async (dir) => + await createWorkspaceDirectory(workspace, filesystem, joinWorkspacePath(root, dir)), ), ); await Promise.all( - [...files].map(async ([path, content]) => { - await filesystem.writeFile(`${root}/${path}`, content, { recursive: true }); - }), + [...files].map( + async ([path, content]) => + await writeWorkspaceFile(workspace, filesystem, joinWorkspacePath(root, path), content), + ), ); return; } const dirList = ALWAYS_PRESENT_DIRS.map( - (dir) => `'${escapeSingleQuotes(`${root}/${dir}`)}'`, + (dir) => `'${escapeSingleQuotes(joinWorkspacePath(root, dir))}'`, ).join(' '); const result = await runInSandbox(workspace, `mkdir -p ${dirList}`); if (result.exitCode !== 0) { @@ -261,7 +389,49 @@ async function writeWorkspaceFiles( } for (const [path, content] of files) { - await writeFileViaSandbox(workspace, `${root}/${path}`, content); + await writeFileViaSandbox(workspace, joinWorkspacePath(root, path), content); + } +} + +type WorkspaceFilesystem = NonNullable; + +async function createWorkspaceDirectory( + workspace: SandboxWorkspace, + filesystem: WorkspaceFilesystem, + path: string, +): Promise { + try { + await filesystem.mkdir(path, { recursive: true }); + } catch (error) { + try { + const result = await runInSandbox(workspace, `mkdir -p '${escapeSingleQuotes(path)}'`); + if (result.exitCode === 0) return; + + throw new Error(result.stderr || `mkdir exited with code ${result.exitCode}`); + } catch (fallbackError) { + throw new Error( + `Failed to create sandbox workspace directory "${path}": ${getErrorMessage(error)}; command fallback failed: ${getErrorMessage(fallbackError)}`, + ); + } + } +} + +async function writeWorkspaceFile( + workspace: SandboxWorkspace, + filesystem: WorkspaceFilesystem, + path: string, + content: string | Buffer, +): Promise { + try { + await filesystem.writeFile(path, content, { recursive: true }); + } catch (error) { + try { + await writeFileViaSandbox(workspace, path, content); + } catch (fallbackError) { + throw new Error( + `Failed to write sandbox workspace file "${path}": ${getErrorMessage(error)}; command fallback failed: ${getErrorMessage(fallbackError)}`, + ); + } } } @@ -273,12 +443,22 @@ const workspaceRootCache = new WeakMap(); function getLocalFilesystemRoot(workspace: SandboxWorkspace): string | null { const filesystem = workspace.filesystem; - if (!filesystem || filesystem.provider !== 'local') return null; + if (!filesystem) return null; + + const provider = filesystem.provider; + if (provider !== 'local' && provider !== 'lazy') return null; const basePath = Reflect.get(filesystem, 'basePath'); return typeof basePath === 'string' && basePath.length > 0 ? basePath : null; } +async function initializeLazyFilesystem(workspace: SandboxWorkspace): Promise { + const filesystem = workspace.filesystem; + if (filesystem?.provider !== 'lazy') return; + + await filesystem.init?.(); +} + export async function getWorkspaceRoot(workspace: SandboxWorkspace): Promise { const cached = workspaceRootCache.get(workspace); if (cached) return cached; @@ -289,6 +469,13 @@ export async function getWorkspaceRoot(workspace: SandboxWorkspace): Promise { - const root = await getWorkspaceRoot(workspace); - const markerFile = `${root}/.sandbox-initialized`; + const root = await setupStep( + 'resolve-workspace-root', + async () => await getWorkspaceRoot(workspace), + ); + const markerFile = joinWorkspacePath(root, '.sandbox-initialized'); // Check marker file for idempotency - const marker = await readFileViaSandbox(workspace, markerFile); + const marker = await setupStep( + 'read-initialization-marker', + async () => await readFileViaSandbox(workspace, markerFile), + ); if (marker !== null) return false; // ── Collect all files ────────────────────────────────────────────────── @@ -365,12 +558,15 @@ export async function setupSandboxWorkspace( // its workspace location via `file:` — this makes SDK changes visible in // the sandbox after `pnpm build`, without a publish. Daytona/n8n-sandbox // stay on the registry-pinned PACKAGE_JSON (they can't see the host FS). - files.set('package.json', buildLocalProviderPackageJson()); + files.set('package.json', buildWorkspacePackageJson(workspace)); files.set('tsconfig.json', TSCONFIG_JSON); files.set('build.mjs', BUILD_MJS); // Node types catalog - const nodeTypes = await context.nodeService.listSearchable(); + const nodeTypes = await setupStep( + 'list-node-types', + async () => await context.nodeService.listSearchable(), + ); const catalogLines = nodeTypes.map(formatNodeCatalogLine); files.set('node-types/index.txt', catalogLines.join('\n')); @@ -394,19 +590,36 @@ export async function setupSandboxWorkspace( // ── Write workspace files ────────────────────────────────────────────── - await writeWorkspaceFiles(workspace, root, files); - await writeCuratedExamples(workspace, context.logger); + await setupStep( + 'write-workspace-files', + async () => await writeWorkspaceFiles(workspace, root, files), + ); + await setupStep( + 'write-curated-examples', + async () => await writeCuratedExamples(workspace, context.logger), + ); // npm install (must run after package.json is in place) - const npmResult = await runInSandbox(workspace, 'npm install --ignore-scripts', root); - if (npmResult.exitCode !== 0) { - throw new Error(`Sandbox npm install failed: ${npmResult.stderr}`); - } + await setupStep('install-dependencies', async () => { + const npmResult = await runInSandbox(workspace, 'npm install --ignore-scripts', root); + if (npmResult.exitCode !== 0) { + throw new Error(`Sandbox npm install failed: ${npmResult.stderr}`); + } + }); - await writeWorkspaceFiles( - workspace, - root, - new Map([['.sandbox-initialized', new Date().toISOString()]]), + await setupStep( + 'link-workspace-sdk', + async () => await linkWorkspaceSdkIfEnabled(workspace, root, context.logger), + ); + + await setupStep( + 'write-initialization-marker', + async () => + await writeWorkspaceFiles( + workspace, + root, + new Map([['.sandbox-initialized', new Date().toISOString()]]), + ), ); return true; diff --git a/packages/cli/src/modules/instance-ai/__tests__/credit-counting.test.ts b/packages/cli/src/modules/instance-ai/__tests__/credit-counting.test.ts index 93870db8958..9a15fe869c0 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/credit-counting.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/credit-counting.test.ts @@ -10,10 +10,10 @@ jest.mock('@n8n/instance-ai', () => { disconnect = jest.fn(); }, createDomainAccessTracker: jest.fn(), - BuilderSandboxFactory: class {}, - SnapshotManager: class {}, createSandbox: jest.fn(), createWorkspace: jest.fn(), + createLazyRuntimeWorkspace: jest.fn(), + setupSandboxWorkspace: jest.fn(), workflowBuildOutcomeSchema: z.object({}), handleBuildOutcome: jest.fn(), handleVerificationVerdict: jest.fn(), diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts index e6bc48e41ba..3d673df7943 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts @@ -10,10 +10,13 @@ jest.mock('@n8n/instance-ai', () => { disconnect = jest.fn(); }, createDomainAccessTracker: jest.fn(), - BuilderSandboxFactory: class {}, - SnapshotManager: class {}, createSandbox: jest.fn(), createWorkspace: jest.fn(), + createLazyRuntimeWorkspace: jest.fn((args: { ensureWorkspace: () => Promise }) => ({ + id: 'lazy-runtime-workspace', + ensureWorkspace: args.ensureWorkspace, + })), + setupSandboxWorkspace: jest.fn(), workflowBuildOutcomeSchema: z.object({}), handleBuildOutcome: jest.fn(), handleVerificationVerdict: jest.fn(), @@ -33,6 +36,11 @@ jest.mock('@n8n/instance-ai', () => { ), createInstanceAgent: jest.fn(), createAllTools: jest.fn(), + WorkflowTaskCoordinator: class {}, + WorkflowLoopStorage: class {}, + ThreadTaskStorage: class {}, + PlannedTaskStorage: class {}, + PlannedTaskCoordinator: class {}, InstanceAiTerminalResponseGuard: class { constructor(private readonly options: { runId: string; rootAgentId: string }) {} @@ -114,7 +122,14 @@ jest.mock('@n8n/instance-ai', () => { import type { User } from '@n8n/db'; import type { InstanceAiAgentNode, InstanceAiEvent } from '@n8n/api-types'; import { + createAllTools, + createLazyRuntimeWorkspace, + createSandbox, + createWorkspace, resumeAgentRun, + setupSandboxWorkspace, + type InstanceAiContext, + type SandboxConfig, type ManagedBackgroundTask, type InstanceAiTraceContext, type SpawnBackgroundTaskOptions, @@ -461,6 +476,58 @@ function createTemporaryCleanupService({ } const fakeUser = { id: 'user-1' } as User; +const daytonaSandboxConfig = { + enabled: true, + provider: 'daytona', +} satisfies SandboxConfig; + +type WorkspaceServiceInternals = { + sandboxes: Map; + sandboxCreations: Map>; + resolveSandboxConfig: jest.MockedFunction<(user: User) => Promise>; + getOrCreateWorkspace: ( + threadId: string, + user: User, + context: InstanceAiContext, + ) => Promise; +}; + +type ShutdownServiceInternals = { + shutdown: () => Promise; + stopCheckpointPruning: jest.MockedFunction<() => void>; + liveness: { shutdown: jest.MockedFunction<() => void> }; + runState: { + shutdown: jest.MockedFunction< + () => { + activeRuns: []; + suspendedRuns: []; + } + >; + }; + backgroundTasks: { cancelAll: jest.MockedFunction<() => ManagedBackgroundTask[]> }; + traceContextsByRunId: Map; + finalizeRunTracing: jest.MockedFunction< + (runId: string, tracing: InstanceAiTraceContext | undefined, options: unknown) => Promise + >; + finalizeBackgroundTaskTracing: jest.MockedFunction< + (task: ManagedBackgroundTask, status: 'cancelled') => Promise + >; + finalizeRemainingMessageTraceRoots: jest.MockedFunction< + (threadId: string, options: unknown) => Promise + >; + gatewayRegistry: { disconnectAll: jest.MockedFunction<() => void> }; + sandboxes: Map< + string, + { + sandbox: unknown; + workspace: { destroy: jest.MockedFunction<() => Promise> }; + } + >; + domainAccessTrackersByThread: Map; + eventBus: { clear: jest.MockedFunction<() => void> }; + _mcpClientManager?: { disconnect: jest.MockedFunction<() => Promise> }; + logger: { debug: jest.Mock; warn: jest.Mock }; +}; type TerminalOutcomeServiceInternals = { replayUndeliveredTerminalOutcomes: ( @@ -685,6 +752,281 @@ function makeAgentTree(): InstanceAiAgentNode { }; } +describe('InstanceAiService — runtime workspace setup', () => { + beforeEach(() => { + jest.clearAllMocks(); + (createSandbox as jest.Mock).mockReset(); + (createWorkspace as jest.Mock).mockReset(); + (setupSandboxWorkspace as jest.Mock).mockReset(); + (createAllTools as jest.Mock).mockReset(); + (createLazyRuntimeWorkspace as jest.Mock).mockImplementation( + (args: { ensureWorkspace: () => Promise }) => ({ + id: 'lazy-runtime-workspace', + ensureWorkspace: args.ensureWorkspace, + }), + ); + }); + + it('serializes workspace creation for concurrent calls on the same thread', async () => { + const service = Object.create( + InstanceAiService.prototype, + ) as unknown as WorkspaceServiceInternals; + service.sandboxes = new Map(); + service.sandboxCreations = new Map(); + service.resolveSandboxConfig = jest.fn(async (_user: User) => daytonaSandboxConfig); + + let resolveSandbox!: (sandbox: unknown) => void; + const sandboxPromise = new Promise((resolve) => { + resolveSandbox = resolve; + }); + const sandbox = { id: 'sandbox-1' }; + const workspace = { + init: jest.fn(async () => {}), + destroy: jest.fn(async () => {}), + }; + (createSandbox as jest.Mock).mockReturnValue(sandboxPromise); + (createWorkspace as jest.Mock).mockReturnValue(workspace); + (setupSandboxWorkspace as jest.Mock).mockResolvedValue(undefined); + + const first = service.getOrCreateWorkspace('thread-1', fakeUser, {} as InstanceAiContext); + const second = service.getOrCreateWorkspace('thread-1', fakeUser, {} as InstanceAiContext); + resolveSandbox(sandbox); + const [firstEntry, secondEntry] = await Promise.all([first, second]); + + expect(firstEntry).toBe(secondEntry); + expect(createSandbox).toHaveBeenCalledTimes(1); + expect(createSandbox).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'instance-ai-thread-thread-1', + name: 'instance-ai-thread-thread-1', + }), + ); + expect(createWorkspace).toHaveBeenCalledTimes(1); + expect(workspace.init).toHaveBeenCalledTimes(1); + expect(setupSandboxWorkspace).toHaveBeenCalledTimes(1); + expect(service.sandboxCreations.size).toBe(0); + }); + + it('keeps the sandbox after setup failure and retries setup on the next use', async () => { + const service = Object.create( + InstanceAiService.prototype, + ) as unknown as WorkspaceServiceInternals; + service.sandboxes = new Map(); + service.sandboxCreations = new Map(); + service.resolveSandboxConfig = jest.fn(async (_user: User) => daytonaSandboxConfig); + + const sandbox = { id: 'sandbox-1' }; + const workspace = { + init: jest.fn(async () => {}), + destroy: jest.fn(async () => {}), + }; + (createSandbox as jest.Mock).mockResolvedValue(sandbox); + (createWorkspace as jest.Mock).mockReturnValue(workspace); + (setupSandboxWorkspace as jest.Mock) + .mockRejectedValueOnce(new Error('setup failed')) + .mockResolvedValueOnce(undefined); + + await expect( + service.getOrCreateWorkspace('thread-1', fakeUser, {} as InstanceAiContext), + ).rejects.toThrow('setup failed'); + + expect(service.sandboxes.has('thread-1')).toBe(true); + expect(workspace.destroy).not.toHaveBeenCalled(); + + const entry = await service.getOrCreateWorkspace('thread-1', fakeUser, {} as InstanceAiContext); + + expect(entry).toBe(service.sandboxes.get('thread-1')); + expect(createSandbox).toHaveBeenCalledTimes(1); + expect(setupSandboxWorkspace).toHaveBeenCalledTimes(2); + }); + + it('destroys the workspace when sandbox startup fails', async () => { + const service = Object.create( + InstanceAiService.prototype, + ) as unknown as WorkspaceServiceInternals; + service.sandboxes = new Map(); + service.sandboxCreations = new Map(); + service.resolveSandboxConfig = jest.fn(async (_user: User) => daytonaSandboxConfig); + + const sandbox = { id: 'sandbox-1' }; + const workspace = { + init: jest.fn(async () => { + throw new Error('init failed'); + }), + destroy: jest.fn(async () => {}), + }; + (createSandbox as jest.Mock).mockResolvedValue(sandbox); + (createWorkspace as jest.Mock).mockReturnValue(workspace); + + await expect( + service.getOrCreateWorkspace('thread-1', fakeUser, {} as InstanceAiContext), + ).rejects.toThrow('init failed'); + + expect(workspace.destroy).toHaveBeenCalledTimes(1); + expect(service.sandboxes.has('thread-1')).toBe(false); + expect(setupSandboxWorkspace).not.toHaveBeenCalled(); + }); + + it('defers sandbox creation and setup until the lazy workspace is used', async () => { + const service = Object.create(InstanceAiService.prototype) as unknown as { + createExecutionEnvironment: ( + user: User, + threadId: string, + runId: string, + abortSignal: AbortSignal, + ) => Promise<{ orchestrationContext: { workspace?: unknown } }>; + settingsService: { + getAdminSettings: jest.Mock; + isLocalGatewayDisabledForUser: jest.Mock; + getPermissions: jest.Mock; + }; + gatewayRegistry: { findGateway: jest.Mock }; + aiService: { isProxyEnabled: jest.Mock }; + adapterService: { + createContext: jest.Mock; + getNodeDefinitionDirs: jest.Mock; + }; + sourceControlPreferencesService: { getPreferences: jest.Mock }; + resolveAgentModelConfig: jest.Mock; + ensureThreadExists: jest.Mock; + agentMemory: unknown; + dbIterationLogStorage: unknown; + dbSnapshotStorage: unknown; + checkpointStore: unknown; + instanceAiConfig: { subAgentMaxSteps: number; browserMcp: boolean }; + defaultTimeZone: string; + eventBus: unknown; + logger: unknown; + telemetry: { track: jest.Mock }; + oauth2CallbackUrl: string; + webhookBaseUrl: string; + formBaseUrl: string; + runState: { touchActiveRun: jest.Mock; registerPendingConfirmation: jest.Mock }; + spawnBackgroundTask: jest.Mock; + cancelBackgroundTask: jest.Mock; + backgroundTasks: { touchTask: jest.Mock }; + schedulePlannedTasks: jest.Mock; + sendCorrectionToTask: jest.Mock; + sandboxes: Map; + sandboxCreations: Map>; + domainAccessTrackersByThread: Map; + resolveSandboxConfig: jest.Mock; + }; + service.settingsService = { + getAdminSettings: jest.fn(() => ({ localGatewayDisabled: false, sandboxEnabled: true })), + isLocalGatewayDisabledForUser: jest.fn(async () => false), + getPermissions: jest.fn(() => ({})), + }; + service.gatewayRegistry = { findGateway: jest.fn(() => undefined) }; + service.aiService = { isProxyEnabled: jest.fn(() => false) }; + service.adapterService = { + createContext: jest.fn(() => ({})), + getNodeDefinitionDirs: jest.fn(() => []), + }; + service.sourceControlPreferencesService = { + getPreferences: jest.fn(() => ({ branchReadOnly: false })), + }; + service.resolveAgentModelConfig = jest.fn(async () => 'model-1'); + service.ensureThreadExists = jest.fn(async () => {}); + service.agentMemory = {}; + service.dbIterationLogStorage = {}; + service.dbSnapshotStorage = {}; + service.checkpointStore = {}; + service.instanceAiConfig = { subAgentMaxSteps: 10, browserMcp: false }; + service.defaultTimeZone = 'UTC'; + service.eventBus = {}; + service.logger = {}; + service.telemetry = { track: jest.fn() }; + service.oauth2CallbackUrl = 'http://localhost/rest/oauth2-credential/callback'; + service.webhookBaseUrl = 'http://localhost/webhook'; + service.formBaseUrl = 'http://localhost/form'; + service.runState = { + touchActiveRun: jest.fn(), + registerPendingConfirmation: jest.fn(), + }; + service.spawnBackgroundTask = jest.fn(); + service.cancelBackgroundTask = jest.fn(); + service.backgroundTasks = { touchTask: jest.fn() }; + service.schedulePlannedTasks = jest.fn(); + service.sendCorrectionToTask = jest.fn(); + service.sandboxes = new Map(); + service.sandboxCreations = new Map(); + service.domainAccessTrackersByThread = new Map(); + service.resolveSandboxConfig = jest.fn(async (_user: User) => daytonaSandboxConfig); + (createAllTools as jest.Mock).mockReturnValue(new Map()); + const sandbox = { id: 'sandbox-1' }; + const workspace = { + init: jest.fn(async () => {}), + destroy: jest.fn(async () => {}), + }; + (createSandbox as jest.Mock).mockResolvedValue(sandbox); + (createWorkspace as jest.Mock).mockReturnValue(workspace); + (setupSandboxWorkspace as jest.Mock).mockResolvedValue(undefined); + + const environment = await service.createExecutionEnvironment( + fakeUser, + 'thread-1', + 'run-1', + new AbortController().signal, + ); + + expect(createLazyRuntimeWorkspace).toHaveBeenCalledTimes(1); + expect(createSandbox).not.toHaveBeenCalled(); + const lazyWorkspace = environment.orchestrationContext.workspace as { + ensureWorkspace: () => Promise; + }; + + await lazyWorkspace.ensureWorkspace(); + + expect(createSandbox).toHaveBeenCalledTimes(1); + expect(createSandbox).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'instance-ai-thread-thread-1', + name: 'instance-ai-thread-thread-1', + }), + ); + expect(createWorkspace).toHaveBeenCalledTimes(1); + expect(workspace.init).toHaveBeenCalledTimes(1); + expect(setupSandboxWorkspace).toHaveBeenCalledTimes(1); + }); +}); + +describe('InstanceAiService — shutdown', () => { + it('does not destroy thread-scoped sandboxes on service shutdown', async () => { + const service = Object.create( + InstanceAiService.prototype, + ) as unknown as ShutdownServiceInternals; + const workspace = { destroy: jest.fn(async () => {}) }; + service.stopCheckpointPruning = jest.fn(); + service.liveness = { shutdown: jest.fn() }; + service.runState = { + shutdown: jest.fn(() => ({ activeRuns: [], suspendedRuns: [] })), + }; + service.backgroundTasks = { cancelAll: jest.fn(() => []) }; + service.traceContextsByRunId = new Map(); + service.finalizeRunTracing = jest.fn( + async (_runId: string, _tracing: InstanceAiTraceContext | undefined, _options: unknown) => {}, + ); + service.finalizeBackgroundTaskTracing = jest.fn( + async (_task: ManagedBackgroundTask, _status: 'cancelled') => {}, + ); + service.finalizeRemainingMessageTraceRoots = jest.fn( + async (_threadId: string, _options: unknown) => {}, + ); + service.gatewayRegistry = { disconnectAll: jest.fn() }; + service.sandboxes = new Map([['thread-a', { sandbox: { id: 'sandbox-a' }, workspace }]]); + service.domainAccessTrackersByThread = new Map(); + service.eventBus = { clear: jest.fn() }; + service._mcpClientManager = { disconnect: jest.fn(async () => {}) }; + service.logger = { debug: jest.fn(), warn: jest.fn() }; + + await service.shutdown(); + + expect(workspace.destroy).not.toHaveBeenCalled(); + expect(service.sandboxes.has('thread-a')).toBe(true); + }); +}); + describe('InstanceAiService — background task auto-follow-up', () => { it('starts an internal follow-up when the last direct background task settles normally', async () => { const { service, task, getSpawnOptions } = createBackgroundTaskFollowUpService(); diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.threadPushRef.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.threadPushRef.test.ts index 3d817faa5fb..01f8d3f9c8f 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.threadPushRef.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.threadPushRef.test.ts @@ -8,10 +8,10 @@ jest.mock('@n8n/instance-ai', () => { disconnect = jest.fn(); }, createDomainAccessTracker: jest.fn(), - BuilderSandboxFactory: class {}, - SnapshotManager: class {}, createSandbox: jest.fn(), createWorkspace: jest.fn(), + createLazyRuntimeWorkspace: jest.fn(), + setupSandboxWorkspace: jest.fn(), workflowBuildOutcomeSchema: z.object({}), handleBuildOutcome: jest.fn(), handleVerificationVerdict: jest.fn(), @@ -70,7 +70,6 @@ describe('InstanceAiService — threadPushRef lifetime', () => { eventBus: { clearThread: jest.Mock }; finalizeRemainingMessageTraceRoots: jest.Mock; deleteTraceContextsForThread: jest.Mock; - builderSandboxSessions: { cleanupThread: jest.Mock }; destroySandbox: jest.Mock; reapAiTemporaryForThreadCleanup: jest.Mock; clearThreadState: (threadId: string) => Promise; @@ -89,7 +88,6 @@ describe('InstanceAiService — threadPushRef lifetime', () => { service.eventBus = { clearThread: jest.fn() }; service.finalizeRemainingMessageTraceRoots = jest.fn(async () => {}); service.deleteTraceContextsForThread = jest.fn(); - service.builderSandboxSessions = { cleanupThread: jest.fn(async () => {}) }; service.destroySandbox = jest.fn(async () => {}); service.reapAiTemporaryForThreadCleanup = jest.fn(async () => {}); diff --git a/packages/cli/src/modules/instance-ai/instance-ai.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.service.ts index cc71ab36798..1761bcbc918 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.service.ts @@ -12,11 +12,11 @@ import { type ToolCategory, type TaskList, } from '@n8n/api-types'; -import type { Message } from '@n8n/agents'; +import type { Message, Workspace } from '@n8n/agents'; import { Logger } from '@n8n/backend-common'; import { GlobalConfig, SsrfProtectionConfig, type InstanceAiConfig } from '@n8n/config'; import { OnLeaderStepdown, OnLeaderTakeover } from '@n8n/decorators'; -import { ErrorReporter, InstanceSettings } from 'n8n-core'; +import { InstanceSettings } from 'n8n-core'; import { SsrfProtectionService } from '@/services/ssrf/ssrf-protection.service'; import { AiBuilderTemporaryWorkflowRepository, UserRepository, type User } from '@n8n/db'; @@ -28,14 +28,14 @@ import { createAllTools, createSandbox, createWorkspace, + createLazyRuntimeWorkspace, + setupSandboxWorkspace, createInstanceAiTraceContext, createInternalOperationTraceContext, continueInstanceAiTraceContext, createInstanceAiLivenessPolicyConfig, InstanceAiLivenessPolicy, McpClientManager, - BuilderSandboxFactory, - SnapshotManager, createDomainAccessTracker, BackgroundTaskManager, buildAgentTreeFromEvents, @@ -49,7 +49,6 @@ import { TerminalOutcomeStorage, applyPlannedTaskPermissions, PLANNED_TASK_PERMISSION_OVERRIDES, - BuilderSandboxSessionRegistry, releaseTraceClient, submitLangsmithUserFeedback, resumeAgentRun, @@ -65,6 +64,7 @@ import { type ConfirmationData, type BuiltMemory, type DomainAccessTracker, + type InstanceAiContext, type ManagedBackgroundTask, type McpServerConfig, type ModelConfig, @@ -147,6 +147,28 @@ function isTextMessagePart(part: unknown): part is { type: 'text'; text: string const ORCHESTRATOR_AGENT_ID = 'agent-001'; +type RuntimeSandboxEntry = { + sandbox: NonNullable>>; + workspace: NonNullable>; + setupComplete: boolean; + setupPromise: Promise | undefined; +}; + +function getThreadScopedSandboxName(threadId: string): string { + return `instance-ai-thread-${threadId}`; +} + +function withThreadScopedSandboxIdentity(config: SandboxConfig, threadId: string): SandboxConfig { + if (!config.enabled || config.provider !== 'daytona') return config; + + const name = getThreadScopedSandboxName(threadId); + return { + ...config, + id: name, + name, + }; +} + function getUserFacingErrorMessage(error: unknown): string { if (error instanceof UserError) { return error.message; @@ -406,8 +428,6 @@ export class InstanceAiService { MAX_CONCURRENT_BACKGROUND_TASKS_PER_THREAD, ); - private readonly builderSandboxSessions: BuilderSandboxSessionRegistry; - /** Trace contexts keyed by the n8n run ID that started the orchestration turn. */ private readonly traceContextsByRunId = new Map< string, @@ -418,14 +438,15 @@ export class InstanceAiService { traceSlug?: string; } >(); - /** Active sandboxes keyed by thread ID — persisted across messages within a conversation. */ - private readonly sandboxes = new Map< - string, - { - sandbox: Awaited>; - workspace: ReturnType; - } - >(); + /** + * Shared runtime workspaces keyed by thread ID. This is only an in-process + * cache; deterministic sandbox names let providers reconnect after restart + * or from another main when the thread uses the workspace again. + */ + private readonly sandboxes = new Map(); + + /** In-flight runtime workspace creations keyed by thread ID. */ + private readonly sandboxCreations = new Map>(); /** Per-user Local Gateway connections. Handles pairing tokens, session keys, and tool dispatch. */ private readonly gatewayRegistry = new LocalGatewayRegistry(); @@ -488,7 +509,6 @@ export class InstanceAiService { private readonly telemetry: Telemetry, private readonly userRepository: UserRepository, private readonly aiBuilderTemporaryWorkflowRepository: AiBuilderTemporaryWorkflowRepository, - private readonly errorReporter: ErrorReporter, ssrfProtectionConfig: SsrfProtectionConfig, ssrfProtectionService: SsrfProtectionService, private readonly eventService: EventService, @@ -509,9 +529,6 @@ export class InstanceAiService { void this.finalizeCancelledSuspendedRun(suspended, reason); }, }); - this.builderSandboxSessions = new BuilderSandboxSessionRegistry( - this.instanceAiConfig.builderSandboxTtlMs, - ); this.defaultTimeZone = globalConfig.generic.timezone; const restEndpoint = globalConfig.endpoints.rest; this.oauth2CallbackUrl = `${this.urlService.getInstanceBaseUrl()}/${restEndpoint}/oauth2-credential/callback`; @@ -638,49 +655,92 @@ export class InstanceAiService { return base; } - private async createBuilderFactory(user: User): Promise { - const config = await this.resolveSandboxConfig(user); - if (!config.enabled) return undefined; - - if (config.provider === 'daytona') { - return new BuilderSandboxFactory( - config, - new SnapshotManager(config.image, this.logger, config.n8nVersion, this.errorReporter), - this.logger, - this.errorReporter, - ); + /** Get or create the shared runtime sandbox + workspace for a thread. */ + private async getOrCreateWorkspace( + threadId: string, + user: User, + context: InstanceAiContext, + ): Promise { + const existing = this.sandboxes.get(threadId); + if (existing) { + await this.ensureWorkspaceSetup(existing, context); + return existing; } - return new BuilderSandboxFactory(config, undefined, this.logger); + const pending = this.sandboxCreations.get(threadId); + if (pending) { + const entry = await pending; + if (entry) await this.ensureWorkspaceSetup(entry, context); + return entry; + } + + const creation = this.createWorkspaceEntry(threadId, user); + this.sandboxCreations.set(threadId, creation); + try { + const entry = await creation; + if (entry) await this.ensureWorkspaceSetup(entry, context); + return entry; + } finally { + this.sandboxCreations.delete(threadId); + } } - /** Get or create a sandbox + workspace for a thread. Returns undefined when sandbox is disabled. */ - private async getOrCreateWorkspace(threadId: string, user: User) { - const existing = this.sandboxes.get(threadId); - if (existing) return existing; + private async ensureWorkspaceSetup( + entry: RuntimeSandboxEntry, + context: InstanceAiContext, + ): Promise { + if (entry.setupComplete) return; - const config = await this.resolveSandboxConfig(user); + entry.setupPromise ??= setupSandboxWorkspace(entry.workspace, context) + .then(() => { + entry.setupComplete = true; + }) + .finally(() => { + entry.setupPromise = undefined; + }); + + await entry.setupPromise; + } + + private async createWorkspaceEntry( + threadId: string, + user: User, + ): Promise { + const config = withThreadScopedSandboxIdentity(await this.resolveSandboxConfig(user), threadId); if (!config.enabled) return undefined; const sandbox = await createSandbox(config); const workspace = createWorkspace(sandbox); if (!sandbox || !workspace) return undefined; + try { + await workspace.init(); + } catch (error) { + try { + await workspace.destroy(); + } catch { + // Best-effort cleanup when the sandbox cannot start + } + throw error; + } - const entry = { sandbox, workspace }; + const entry: RuntimeSandboxEntry = { + sandbox, + workspace, + setupComplete: false, + setupPromise: undefined, + }; this.sandboxes.set(threadId, entry); return entry; } - /** Destroy and remove the sandbox for a thread. */ + /** Destroy and remove the shared runtime workspace for a thread. */ private async destroySandbox(threadId: string): Promise { const entry = this.sandboxes.get(threadId); if (!entry?.sandbox) return; this.sandboxes.delete(threadId); try { - if ('destroy' in entry.sandbox && typeof entry.sandbox.destroy === 'function') { - await (entry.sandbox.destroy as () => Promise)(); - } + await entry.workspace?.destroy(); } catch (error) { this.logger.warn('Failed to destroy sandbox', { threadId, @@ -1681,7 +1741,6 @@ export class InstanceAiService { this.domainAccessTrackersByThread.delete(threadId); this.threadPushRef.delete(threadId); this.deleteTraceContextsForThread(threadId); - await this.builderSandboxSessions.cleanupThread(threadId, 'thread_cleared'); await this.destroySandbox(threadId); await this.reapAiTemporaryForThreadCleanup(threadId); this.eventBus.clearThread(threadId); @@ -1723,14 +1782,8 @@ export class InstanceAiService { this.gatewayRegistry.disconnectAll(); - // Destroy all active sandboxes - const sandboxCleanups = [...this.sandboxes.keys()].map( - async (threadId) => await this.destroySandbox(threadId), - ); - await Promise.allSettled([ - ...sandboxCleanups, - this.builderSandboxSessions.cleanupAll('service_shutdown'), - ]); + // Thread-scoped sandboxes survive service shutdown so a restarted process + // can reuse them. Thread deletion remains the teardown path. this.domainAccessTrackersByThread.clear(); this.traceContextsByRunId.clear(); @@ -2292,8 +2345,8 @@ export class InstanceAiService { messageGroupId?: string, pushRef?: string, ) { - const localGatewayDisabledGlobally = - this.settingsService.getAdminSettings().localGatewayDisabled; + const adminSettings = this.settingsService.getAdminSettings(); + const localGatewayDisabledGlobally = adminSettings.localGatewayDisabled; const localGatewayDisabledForUser = await this.settingsService.isLocalGatewayDisabledForUser( user.id, ); @@ -2394,7 +2447,24 @@ export class InstanceAiService { } const domainTools = createAllTools(context); - const sandboxEntry = await this.getOrCreateWorkspace(threadId, user); + let runtimeWorkspace: Workspace | undefined; + if (adminSettings.sandboxEnabled) { + let sandboxEntryPromise: Promise | undefined; + const getSandboxEntry = async () => { + sandboxEntryPromise ??= this.getOrCreateWorkspace(threadId, user, context).catch( + (error: unknown) => { + sandboxEntryPromise = undefined; + throw error; + }, + ); + + return await sandboxEntryPromise; + }; + + runtimeWorkspace = createLazyRuntimeWorkspace({ + ensureWorkspace: async () => (await getSandboxEntry())?.workspace, + }); + } const orchestrationContext: OrchestrationContext = { threadId, @@ -2451,9 +2521,7 @@ export class InstanceAiService { sendCorrectionToTask: (taskId, correction) => this.sendCorrectionToTask(threadId, taskId, correction), workflowTaskService: workflowTasks, - workspace: sandboxEntry?.workspace, - builderSandboxFactory: await this.createBuilderFactory(user), - builderSandboxSessionRegistry: this.builderSandboxSessions, + workspace: runtimeWorkspace, nodeDefinitionDirs: nodeDefDirs.length > 0 ? nodeDefDirs : undefined, domainContext: context, tracingProxyConfig, @@ -2470,7 +2538,6 @@ export class InstanceAiService { plannedTaskService, modelId, orchestrationContext, - sandboxEntry, }; }