diff --git a/packages/@n8n/instance-ai/skills/workflow-builder/SKILL.md b/packages/@n8n/instance-ai/skills/workflow-builder/SKILL.md index aea96a33775..79254b563ba 100644 --- a/packages/@n8n/instance-ai/skills/workflow-builder/SKILL.md +++ b/packages/@n8n/instance-ai/skills/workflow-builder/SKILL.md @@ -34,12 +34,17 @@ You are an expert n8n workflow builder. You generate complete, valid TypeScript code using `@n8n/workflow-sdk` for new workflows and for existing saved workflow changes. -Always write the complete TypeScript SDK source with +For a new workflow, write the complete TypeScript SDK source with `workspace_write_file` first, then call `build-workflow({ filePath })`. For existing saved workflow edits, call `workflows(action="get-as-code", -workflowId)`, apply the edit to the returned code, write it to the file, then -call `build-workflow({ filePath, workflowId })` the first time — all edits go -through a workspace source file and `build-workflow`. Do not load +workflowId)`: it writes the current source to a bound workspace file +(`src/workflows/.workflow.ts`) and returns the `filePath` plus a `nodes` +index with line numbers. Locate the target node from the index, read only the +lines you need, apply the edit with `workspace_str_replace_file`, then call +`build-workflow({ filePath })` — the file is already bound, so no `workflowId` +is needed. Never re-emit the whole source with `workspace_write_file`, and do +not fetch the same unchanged workflow again in another format. All edits go +through the workspace source file and `build-workflow`. Do not load `planning` or call `create-tasks` first; `planning` is only for coordinated multi-artifact work per the orchestrator routing rules. Do not create a plan just for verification. @@ -60,11 +65,12 @@ editing anything — never guess at the cause or change the node on a hunch. When called with failure details for an existing workflow, start from the workspace source file if one is available in the conversation or tool output. If -you only have a saved n8n workflow ID, use `workflows(action="get-as-code")`, -make the smallest requested edit to the returned code, write it to a stable -`src/workflows/.workflow.ts` path, then call `build-workflow` once with -`filePath` and `workflowId`. Later repairs should reuse the same `filePath`; -`build-workflow` remembers the bound workflow ID. +you only have a saved n8n workflow ID, use `workflows(action="get-as-code")`: +it writes the source to a bound `src/workflows/.workflow.ts` file and +returns its `filePath` with a node index. Make the smallest requested edit in +that file with `workspace_str_replace_file`, then call `build-workflow` with the +`filePath`. Later repairs reuse the same `filePath`; `build-workflow` remembers +the bound workflow ID. For repairs, prefer editing the workspace file directly with file tools (`workspace_str_replace_file`) and calling `build-workflow` again with the same @@ -197,9 +203,9 @@ follow its build → publish → assign steps. `src/workflows/main.workflow.ts` for a one-off new workflow, or a clearly named `.workflow.ts` file when multiple source files are useful. For an existing workflow with no source file in context, call - `workflows(action="get-as-code", workflowId)`, apply your edit to the - returned code, and pass the n8n `workflowId` only on the first - `build-workflow` call. + `workflows(action="get-as-code", workflowId)` and use the `filePath` it + returns — the file is written and bound for you. Edit it in place; do not + rewrite it. 6. Produce complete TypeScript SDK code and write it with `workspace_write_file` (new/full rewrite) or `workspace_str_replace_file` (targeted edit). Do not put secrets in the source file. @@ -239,9 +245,10 @@ follow its build → publish → assign steps. `workflow-sdk validate` on that file, then calling `build-workflow` again with the same `filePath`. Save again before any verification step. 11. Modify existing workflows by editing the workspace `.workflow.ts` source - file. If the file was created from `workflows(action="get-as-code")`, pass - the real n8n `workflowId` on the first `build-workflow` call so the file is - bound to the saved workflow. Never pass local SDK workflow IDs as n8n + file with scoped replacements. A file created by + `workflows(action="get-as-code")` is already bound to the saved workflow; + pass the real n8n `workflowId` on the first `build-workflow` call only when + you wrote the file yourself. Never pass local SDK workflow IDs as n8n workflow IDs. 12. After a successful direct `build-workflow` result, if the tool output contains `postBuildFlow.required: true`, follow the inlined @@ -269,9 +276,12 @@ Use the current turn's higher-priority instructions to decide who verifies: successful `build-workflow`. The checkpoint task owns verification. Build/save success is not workflow-quality evidence. When this turn is -responsible for verification or repair, inspect the persisted workflow -(`workflows(action="get-as-code", workflowId)` or the bound workspace source -file) before reporting a verdict, judging the saved graph against the user's +responsible for verification or repair, inspect the persisted workflow before +reporting a verdict: read the bound workspace source file you just built, or call +`workflows(action="get-as-code", workflowId)` when the workflow may have changed +outside this conversation (it reports whether the file is still current, refreshes +it when the saved workflow changed, and returns `conflict` when the file holds +unbuilt edits — build or discard those first). Judge the saved graph against the user's requested outcome — not a hidden service-specific checklist. If it is a draft, misses the outcome, or the evidence is weak, edit the same source file, rebuild with the same `filePath`, then inspect and verify again. @@ -543,10 +553,11 @@ every reported error and warning before calling `build-workflow`. `{{ }}`. `$json` is only the current item from the immediate predecessor. - Use string values directly for discriminator fields like `resource` and `operation`, for example `resource: 'message'`. -- When editing a pre-loaded workflow, remove every `position` array — from node - configs and from `sticky()` options alike. Positions are auto-calculated, and - the saved workflow's own layout is restored on save, so nothing you drop here - is lost. Leaving some in place is worse than dropping all of them. +- When editing a saved workflow, leave layout alone. The source `get-as-code` + writes carries no `position` arrays: the saved layout is restored on save by + node `id`, and nodes you add are placed by the layout engine. Do not add a + `position` to any node, and never run a whole-file substitution (for example + `sed`) over the source to change layout. - When editing a pre-loaded workflow, keep every `config.id` value **exactly** as `get-as-code` produced it, on the node it came with. `id` is the node's permanent identity in n8n — execution logs, poll cursors, deduplication state @@ -554,8 +565,8 @@ every reported error and warning before calling `build-workflow`. Move it, rewire it, change its parameters — the `id` stays. Never invent, edit, renumber or reuse an `id`, and never copy one from a template, another workflow or another node. **Omit `id` entirely for any node you are adding** — one is - assigned on save. Deleting a node means deleting its `id` line with it. This is - the opposite of `position`: drop every `position`, keep every `id`. + assigned on save. Deleting a node means deleting its `id` line with it. Like + `position`, `id` is saved state: never write one by hand. - Use `placeholder('hint')` directly as the parameter value. Do not wrap placeholders in `expr()`, objects, or arrays unless the node definition explicitly expects an object and the placeholder is the direct value of one diff --git a/packages/@n8n/instance-ai/src/errors/workflow-snapshot-changed.error.ts b/packages/@n8n/instance-ai/src/errors/workflow-snapshot-changed.error.ts new file mode 100644 index 00000000000..41ca1c3de8b --- /dev/null +++ b/packages/@n8n/instance-ai/src/errors/workflow-snapshot-changed.error.ts @@ -0,0 +1,14 @@ +import { OperationalError } from 'n8n-workflow'; + +/** + * Thrown when a workflow keeps changing while its source and concurrency token + * are being read, so no consistent snapshot could be bound to a source file. + */ +export class WorkflowSnapshotChangedError extends OperationalError { + constructor(workflowId: string) { + super( + `Workflow ${workflowId} changed while its source was being read. Call get-as-code again.`, + { level: 'warning' }, + ); + } +} diff --git a/packages/@n8n/instance-ai/src/tools/__tests__/workflows.get-as-code.integration.test.ts b/packages/@n8n/instance-ai/src/tools/__tests__/workflows.get-as-code.integration.test.ts index a2b93787ca9..4328c9b7d87 100644 --- a/packages/@n8n/instance-ai/src/tools/__tests__/workflows.get-as-code.integration.test.ts +++ b/packages/@n8n/instance-ai/src/tools/__tests__/workflows.get-as-code.integration.test.ts @@ -37,10 +37,25 @@ function makeManagedWorkflow(): WorkflowJSON { }; } -function makeContext(workflow: WorkflowJSON): InstanceAiContext { +function makeContext(workflow: WorkflowJSON, files: Map): InstanceAiContext { const context = mock(); context.threadId = undefined; context.threadMemory = undefined; + context.logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + // get-as-code writes the source into the bound workspace file; back it with a map. + context.workspace = { + filesystem: { + readFile: vi.fn(async (path: string) => { + const content = files.get(path); + if (content === undefined) throw new Error(`ENOENT ${path}`); + return await Promise.resolve(content); + }), + writeFile: vi.fn(async (path: string, content: string | Buffer) => { + files.set(path, Buffer.isBuffer(content) ? content.toString('utf-8') : content); + await Promise.resolve(); + }), + }, + } as unknown as InstanceAiContext['workspace']; context.workflowService.getAsWorkflowJSON = vi.fn().mockResolvedValue(workflow); context.workflowService.get = vi.fn().mockResolvedValue({ id: 'wf-managed', @@ -59,7 +74,8 @@ function makeContext(workflow: WorkflowJSON): InstanceAiContext { describe('workflows get-as-code integration', () => { it('returns real TypeScript for a managed credential and refreshes its binding', async () => { - const context = makeContext(makeManagedWorkflow()); + const files = new Map(); + const context = makeContext(makeManagedWorkflow(), files); const filePath = 'src/workflows/managed.workflow.ts'; await saveWorkflowSourceFileBinding(context, { filePath, @@ -78,6 +94,9 @@ describe('workflows get-as-code integration', () => { expect(result.code).not.toBe(''); expect(result.code).toContain("newCredential('Gateway credits')"); expect(result.code).not.toContain("newCredential('Gateway credits',"); + // The source lands in the file the workflow is already bound to, ready to build. + expect(files.get(filePath)).toBe(result.code); + expect(files.get(filePath)).toMatch(/^import \{[^}]+\} from '@n8n\/workflow-sdk';\n/); await expect(getWorkflowSourceFileBinding(context, filePath)).resolves.toMatchObject({ workflowVersionId: 'v-current', workflowChecksum: 'checksum-current', diff --git a/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.test.ts b/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.test.ts index fffb165a7b1..9ad16d9aaab 100644 --- a/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.test.ts @@ -16,7 +16,7 @@ import { applyNodeChanges, buildCompletedReport, } from '../workflows/setup-workflow.service'; -import { STRUCTURE_ONLY_NOTE } from '../workflows/summarize-workflow'; +import { FULL_PAYLOAD_TOO_LARGE_NOTE, STRUCTURE_ONLY_NOTE } from '../workflows/summarize-workflow'; import { getWorkflowSourceFileBinding, refreshWorkflowSourceFileBindingFromSave, @@ -193,6 +193,8 @@ describe('workflows tool', () => { expect(result).toEqual({ workflowId: 'w1', name: 'Test WF', + nodeCount: 0, + nodes: [], code: '// generated code', }); }); @@ -923,11 +925,403 @@ describe('workflows tool', () => { await executeTool(tool, { action: 'get-as-code', workflowId: 'wf1' }, {} as never); expect(vi.mocked(generateWorkflowCode)).toHaveBeenCalledWith( - expect.objectContaining({ includeNodeIds: true }), + expect.objectContaining({ includeNodeIds: true, includePositions: false }), ); }); }); + describe('get-as-code source file materialization', () => { + const GENERATED = [ + 'const trigger1 = trigger({', + " type: 'n8n-nodes-base.manualTrigger',", + ' version: 1,', + " config: { id: 'n1', name: 'Start' }", + '});', + "export default workflow('wf1', 'Test WF').add(trigger1);", + ].join('\n'); + + function createWorkspaceContext(files: Map) { + const context = createMockContext({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + workspace: { + filesystem: { + readFile: vi.fn(async (path: string) => { + const content = files.get(path); + if (content === undefined) throw new Error(`ENOENT ${path}`); + return await Promise.resolve(content); + }), + writeFile: vi.fn(async (path: string, content: string | Buffer) => { + files.set(path, Buffer.isBuffer(content) ? content.toString('utf-8') : content); + await Promise.resolve(); + }), + }, + } as unknown as InstanceAiContext['workspace'], + }); + (context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue({ + name: 'Test WF', + nodes: [ + { + id: 'n1', + name: 'Start', + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + ], + connections: {}, + }); + (context.workflowService.get as Mock).mockResolvedValue({ + id: 'wf1', + name: 'Test WF', + versionId: 'v1', + checksum: 'c1', + activeVersionId: null, + isArchived: false, + createdAt: '2024-01-01', + updatedAt: '2024-01-01', + nodes: [], + connections: {}, + }); + vi.mocked(generateWorkflowCode).mockReturnValue(GENERATED); + return context; + } + + afterEach(() => { + vi.mocked(generateWorkflowCode).mockReturnValue('// generated code'); + }); + + it('writes a build-ready source file, binds it, and returns a node index', async () => { + const files = new Map(); + const context = createWorkspaceContext(files); + const tool = createWorkflowsTool(context, 'full'); + + const result = await executeTool( + tool, + { action: 'get-as-code', workflowId: 'wf1' }, + {} as never, + ); + + const filePath = 'src/workflows/test-wf.workflow.ts'; + expect(result).toMatchObject({ + workflowId: 'wf1', + name: 'Test WF', + filePath, + status: 'written', + nodeCount: 1, + nodes: [{ name: 'Start', type: 'n8n-nodes-base.manualTrigger', line: 3 }], + }); + expect(files.get(filePath)).toBe( + `import { workflow, trigger } from '@n8n/workflow-sdk';\n\n${GENERATED}`, + ); + await expect(getWorkflowSourceFileBinding(context, filePath)).resolves.toMatchObject({ + workflowId: 'wf1', + workflowVersionId: 'v1', + workflowChecksum: 'c1', + }); + }); + + it('inlines the source only while it is small', async () => { + const files = new Map(); + const context = createWorkspaceContext(files); + const tool = createWorkflowsTool(context, 'full'); + + const small = await executeTool<{ code?: string }>( + tool, + { action: 'get-as-code', workflowId: 'wf1' }, + {} as never, + ); + expect(small.code).toContain(GENERATED); + + files.clear(); + const largeTool = createWorkflowsTool(createWorkspaceContext(files), 'full'); + vi.mocked(generateWorkflowCode).mockReturnValue(`${GENERATED}\n// ${'x'.repeat(20_000)}`); + const large = await executeTool<{ code?: string; status: string }>( + largeTool, + { action: 'get-as-code', workflowId: 'wf1' }, + {} as never, + ); + expect(large.code).toBeUndefined(); + expect(large.status).toBe('written'); + }); + + it('does not rewrite a file that already matches the saved workflow', async () => { + const files = new Map(); + const context = createWorkspaceContext(files); + const tool = createWorkflowsTool(context, 'full'); + await executeTool(tool, { action: 'get-as-code', workflowId: 'wf1' }, {} as never); + const writeFile = context.workspace?.filesystem?.writeFile as Mock; + writeFile.mockClear(); + + const result = await executeTool<{ status: string }>( + tool, + { action: 'get-as-code', workflowId: 'wf1' }, + {} as never, + ); + + expect(result.status).toBe('current'); + expect(writeFile).not.toHaveBeenCalled(); + }); + + it('reports a conflict instead of clobbering unbuilt edits', async () => { + const files = new Map(); + const context = createWorkspaceContext(files); + const tool = createWorkflowsTool(context, 'full'); + await executeTool(tool, { action: 'get-as-code', workflowId: 'wf1' }, {} as never); + const filePath = 'src/workflows/test-wf.workflow.ts'; + const edited = `${files.get(filePath)}\n// local edit`; + files.set(filePath, edited); + + const result = await executeTool<{ status: string; code?: string }>( + tool, + { action: 'get-as-code', workflowId: 'wf1' }, + {} as never, + ); + + expect(result.status).toBe('conflict'); + expect(result.code).toBeUndefined(); + expect(files.get(filePath)).toBe(edited); + }); + + it('regenerates the file when the saved workflow changed and the file has no local edits', async () => { + const files = new Map(); + const context = createWorkspaceContext(files); + const tool = createWorkflowsTool(context, 'full'); + await executeTool(tool, { action: 'get-as-code', workflowId: 'wf1' }, {} as never); + + (context.workflowService.get as Mock).mockResolvedValue({ + id: 'wf1', + name: 'Test WF', + versionId: 'v2', + checksum: 'c2', + activeVersionId: null, + isArchived: false, + createdAt: '2024-01-01', + updatedAt: '2024-01-02', + nodes: [], + connections: {}, + }); + const regenerated = GENERATED.replace("name: 'Start'", "name: 'Start (renamed)'"); + vi.mocked(generateWorkflowCode).mockReturnValue(regenerated); + + const result = await executeTool<{ status: string; nodes: Array<{ line: number }> }>( + tool, + { action: 'get-as-code', workflowId: 'wf1' }, + {} as never, + ); + + expect(result.status).toBe('refreshed'); + expect(files.get('src/workflows/test-wf.workflow.ts')).toContain(regenerated); + await expect( + getWorkflowSourceFileBinding(context, 'src/workflows/test-wf.workflow.ts'), + ).resolves.toMatchObject({ workflowChecksum: 'c2', workflowVersionId: 'v2' }); + }); + + it('indexes the file on disk, not the regenerated code, when it reports a conflict', async () => { + const files = new Map(); + const context = createWorkspaceContext(files); + const tool = createWorkflowsTool(context, 'full'); + await executeTool(tool, { action: 'get-as-code', workflowId: 'wf1' }, {} as never); + const filePath = 'src/workflows/test-wf.workflow.ts'; + // Two lines prepended: the node declaration moves from line 3 to line 5 on disk. + files.set(filePath, `// note\n// note\n${files.get(filePath)}`); + + const result = await executeTool<{ status: string; nodes: Array<{ line: number }> }>( + tool, + { action: 'get-as-code', workflowId: 'wf1' }, + {} as never, + ); + + expect(result.status).toBe('conflict'); + expect(result.nodes[0].line).toBe(5); + }); + + it('keeps the concurrency token on the old version when it reports a conflict', async () => { + const files = new Map(); + const context = createWorkspaceContext(files); + const tool = createWorkflowsTool(context, 'full'); + const filePath = 'src/workflows/test-wf.workflow.ts'; + await executeTool(tool, { action: 'get-as-code', workflowId: 'wf1' }, {} as never); + // The agent edits the file without building, then the user edits the canvas. + files.set(filePath, files.get(filePath)!.replace("name: 'Start'", "name: 'Start (edited)'")); + (context.workflowService.get as Mock).mockResolvedValue({ + id: 'wf1', + name: 'Test WF', + versionId: 'v2', + checksum: 'c2', + activeVersionId: null, + isArchived: false, + createdAt: '2024-01-01', + updatedAt: '2024-01-02', + nodes: [], + connections: {}, + }); + + const result = await executeTool<{ status: string }>( + tool, + { action: 'get-as-code', workflowId: 'wf1' }, + {} as never, + ); + + expect(result.status).toBe('conflict'); + // The file still derives from v1, so a build of it must hit the lost-update guard. + await expect(getWorkflowSourceFileBinding(context, filePath)).resolves.toMatchObject({ + workflowChecksum: 'c1', + workflowVersionId: 'v1', + }); + }); + + it('moves the concurrency token forward when only the canvas changed and the source is current', async () => { + const files = new Map(); + const context = createWorkspaceContext(files); + const tool = createWorkflowsTool(context, 'full'); + const filePath = 'src/workflows/test-wf.workflow.ts'; + await executeTool(tool, { action: 'get-as-code', workflowId: 'wf1' }, {} as never); + // A node was moved: new version, same generated source (positions are not emitted). + (context.workflowService.get as Mock).mockResolvedValue({ + id: 'wf1', + name: 'Test WF', + versionId: 'v2', + checksum: 'c2', + activeVersionId: null, + isArchived: false, + createdAt: '2024-01-01', + updatedAt: '2024-01-02', + nodes: [], + connections: {}, + }); + + const result = await executeTool<{ status: string }>( + tool, + { action: 'get-as-code', workflowId: 'wf1' }, + {} as never, + ); + + expect(result.status).toBe('current'); + await expect(getWorkflowSourceFileBinding(context, filePath)).resolves.toMatchObject({ + workflowChecksum: 'c2', + workflowVersionId: 'v2', + }); + }); + + it('retries when the workflow changes between the source read and the checksum read', async () => { + const files = new Map(); + const context = createWorkspaceContext(files); + const stable = { versionId: 'v2', checksum: 'c2' }; + const detail = (v: { versionId: string; checksum: string }) => ({ + id: 'wf1', + name: 'Test WF', + activeVersionId: null, + isArchived: false, + createdAt: '2024-01-01', + updatedAt: '2024-01-01', + nodes: [], + connections: {}, + ...v, + }); + (context.workflowService.get as Mock) + .mockResolvedValueOnce(detail({ versionId: 'v1', checksum: 'c1' })) + .mockResolvedValueOnce(detail(stable)) + .mockResolvedValue(detail(stable)); + const tool = createWorkflowsTool(context, 'full'); + + const result = await executeTool<{ status: string; error?: string }>( + tool, + { action: 'get-as-code', workflowId: 'wf1' }, + {} as never, + ); + + expect(result.error).toBeUndefined(); + expect(result.status).toBe('written'); + await expect( + getWorkflowSourceFileBinding(context, 'src/workflows/test-wf.workflow.ts'), + ).resolves.toMatchObject({ workflowChecksum: 'c2' }); + }); + + it('fails instead of binding a torn snapshot when the workflow keeps changing', async () => { + const files = new Map(); + const context = createWorkspaceContext(files); + let n = 0; + (context.workflowService.get as Mock).mockImplementation(async () => { + n += 1; + return await Promise.resolve({ + id: 'wf1', + name: 'Test WF', + versionId: `v${n}`, + checksum: `c${n}`, + activeVersionId: null, + isArchived: false, + createdAt: '2024-01-01', + updatedAt: '2024-01-01', + nodes: [], + connections: {}, + }); + }); + const tool = createWorkflowsTool(context, 'full'); + + const result = await executeTool<{ error?: string }>( + tool, + { action: 'get-as-code', workflowId: 'wf1' }, + {} as never, + ); + + expect(result.error).toContain('changed while its source was being read'); + expect(files.size).toBe(0); + }); + + it('keeps historical reads inline and unbound', async () => { + const files = new Map(); + const context = createWorkspaceContext(files); + const tool = createWorkflowsTool(context, 'full'); + + const result = await executeTool<{ code?: string; filePath?: string }>( + tool, + { action: 'get-as-code', workflowId: 'wf1', versionId: 'v0' }, + {} as never, + ); + + expect(result.code).toContain(GENERATED); + expect(result.filePath).toBeUndefined(); + expect(files.size).toBe(0); + }); + }); + + describe('get with full: true', () => { + it('refuses to inline a workflow above the full-payload limit', async () => { + const context = createMockContext(); + const nodes = Array.from({ length: 300 }, (_, i) => ({ + id: `n${i}`, + name: `Node ${i}`, + type: 'n8n-nodes-base.set', + typeVersion: 3.4, + position: [0, 0], + parameters: { assignments: { assignments: [{ name: 'k', value: 'v'.repeat(400) }] } }, + })); + (context.workflowService.get as Mock).mockResolvedValue({ + id: 'wf1', + name: 'Big', + versionId: 'v1', + activeVersionId: null, + isArchived: false, + createdAt: '2024-01-01', + updatedAt: '2024-01-01', + nodes, + connections: {}, + }); + const tool = createWorkflowsTool(context, 'full'); + + const result = await executeTool<{ nodes?: unknown; nodeCount?: number; note?: string }>( + tool, + { action: 'get', workflowId: 'wf1', full: true }, + {} as never, + ); + + expect(result.nodes).toBeUndefined(); + expect(result.nodeCount).toBe(300); + expect(result.note).toBe(FULL_PAYLOAD_TOO_LARGE_NOTE); + }); + }); + describe('workflow source binding refresh', () => { it('refreshes bound checksum after current-version get-as-code', async () => { const context = createMockContext(); diff --git a/packages/@n8n/instance-ai/src/tools/workflows.tool.ts b/packages/@n8n/instance-ai/src/tools/workflows.tool.ts index 0c480c80546..e2ace61bdbf 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows.tool.ts @@ -17,6 +17,7 @@ import { z } from 'zod'; import { sanitizeInputSchema } from '../agent/sanitize-mcp-schemas'; import { WorkflowSaveConflictError } from '../errors/workflow-save-conflict.error'; +import { WorkflowSnapshotChangedError } from '../errors/workflow-snapshot-changed.error'; import type { InstanceAiContext } from '../types'; import { findSetupHintProblems, @@ -51,6 +52,8 @@ import { buildCompletedReport, } from './workflows/setup-workflow.service'; import { + exceedsFullPayloadLimit, + FULL_PAYLOAD_TOO_LARGE_NOTE, isSmallPayload, STRUCTURE_ONLY_NOTE, summarizeWorkflowStructure, @@ -61,8 +64,17 @@ import { canSkipWorkflowUpdateHitl, formatWarning, } from './workflows/workflow-build-context'; -import { refreshWorkflowSourceFileBindingFromWorkflow } from './workflows/workflow-file-bindings'; +import { + refreshWorkflowSourceFileBindingFromSave, + refreshWorkflowSourceFileBindingFromWorkflow, +} from './workflows/workflow-file-bindings'; import { ensureUniqueNodeIds, getReferencedWorkflowIds } from './workflows/workflow-json-utils'; +import { + INLINE_SOURCE_LIMIT_CHARS, + indexSourceNodes, + materializeWorkflowSource, + type MaterializedSourceStatus, +} from './workflows/workflow-source-materializer'; import { nodeGroupDroppedWarnings } from './workflows/workflow-validation-warnings'; // ── Action schemas ────────────────────────────────────────────────────────── @@ -128,7 +140,7 @@ const getAsCodeAction = z.object({ action: z .literal('get-as-code') .describe( - 'Convert an existing workflow to TypeScript SDK code. Call before precise patches when you need the current code. Pass versionId for a past version instead of the current draft.', + 'Write an existing workflow as TypeScript SDK source into the workspace (src/workflows/.workflow.ts), bind the file to the workflow, and return the file path plus a node index with line numbers. Edit the file with scoped replacements and save with build-workflow. Source is inlined only when small. Pass versionId for a past version instead of the current draft.', ), workflowId: z.string().describe('ID of the workflow'), versionId: z.string().optional().describe('Version ID'), @@ -529,7 +541,7 @@ async function handleGet(context: InstanceAiContext, input: Extract = { + written: + 'Source written to filePath and bound to this workflow. Locate nodes with the `nodes` index (line numbers) and read only those lines — for a large file use a ranged shell read such as `sed -n START,ENDp filePath` via workspace_execute_command, since workspace_read_file returns the whole file. Apply edits with workspace_str_replace_file, then call build-workflow with this filePath. Do not rewrite the whole file.', + refreshed: + 'The saved workflow changed since the file was written, so the file was regenerated from the saved workflow. Re-apply any edit you still need with workspace_str_replace_file, then build-workflow.', + current: + 'The file already matches the saved workflow; nothing was written. Edit it with workspace_str_replace_file and call build-workflow with this filePath.', + conflict: + 'The file has edits that were never built, so it was left untouched. Build it with build-workflow to save them, or delete the file and call get-as-code again to start from the saved workflow.', +}; + +/** + * The source and the concurrency token come from two reads. A save landing between + * them would bind older source to a newer checksum, and a later build could then + * overwrite that save. Re-read the checksum after generating and retry once when + * it moved; give up loudly instead of binding a torn snapshot. + */ +async function readConsistentWorkflowSnapshot( + context: InstanceAiContext, + workflowId: string, +): Promise<{ json: WorkflowJSON; saved: { versionId: string; checksum?: string } }> { + for (let attempt = 0; attempt < 2; attempt++) { + const before = await context.workflowService.get(workflowId); + const json = await context.workflowService.getAsWorkflowJSON(workflowId); + const after = await context.workflowService.get(workflowId); + if (before.checksum === after.checksum && before.versionId === after.versionId) { + return { json, saved: { versionId: after.versionId, checksum: after.checksum } }; + } + } + throw new WorkflowSnapshotChangedError(workflowId); +} + async function handleGetAsCode( context: InstanceAiContext, input: Extract, ) { - const { generateWorkflowCode } = await import('@n8n/workflow-sdk'); - try { - const json = await context.workflowService.getAsWorkflowJSON(input.workflowId, input.versionId); + const { generateWorkflowCode, buildImports } = await import('@n8n/workflow-sdk'); + const toCode = (json: WorkflowJSON): string => { // Emit node ids: this code is edited and built back into the same saved workflow, - // and carrying the ids through is what keeps node identity stable. - const code = generateWorkflowCode({ workflow: json, includeNodeIds: true }); - // Historical reads must not advance the optimistic-concurrency lock. - if (!input.versionId) { - await refreshWorkflowSourceFileBindingFromWorkflow(context, input.workflowId); + // and carrying the ids through is what keeps node identity stable. Positions stay + // out: build-workflow restores the saved layout by id, so a position in the file + // is only an invitation to edit layout. + const body = generateWorkflowCode({ + workflow: json, + includeNodeIds: true, + includePositions: false, + }); + // The file must build as-is, so it carries the import line codegen omits. + const importLine = buildImports(body); + return importLine ? `${importLine}\n\n${body}` : body; + }; + try { + // Historical reads are not bound to a file and must not advance the + // optimistic-concurrency lock; they stay inline. + if (input.versionId) { + const json = await context.workflowService.getAsWorkflowJSON( + input.workflowId, + input.versionId, + ); + const code = toCode(json); + return { + workflowId: input.workflowId, + name: json.name, + nodeCount: json.nodes?.length ?? 0, + nodes: await indexSourceNodes(json, code), + code, + }; } - return { workflowId: input.workflowId, name: json.name, code }; + + const { json, saved } = await readConsistentWorkflowSnapshot(context, input.workflowId); + const code = toCode(json); + const nodeCount = json.nodes?.length ?? 0; + const base = { workflowId: input.workflowId, name: json.name, nodeCount }; + + // Without a workspace there is no file to write; the code stays inline and the + // conversation's view of the workflow still moves to the current version. + if (!context.workspace) { + await refreshWorkflowSourceFileBindingFromSave(context, input.workflowId, { + versionId: saved.versionId, + checksum: saved.checksum, + }); + return { ...base, nodes: await indexSourceNodes(json, code), code }; + } + + const materialized = await materializeWorkflowSource(context, { + workflowId: input.workflowId, + name: json.name, + code, + saved: { versionId: saved.versionId, checksum: saved.checksum }, + }); + // A conflict leaves source generated from an older version on disk, so the + // binding keeps that version's token: building that file must hit the + // lost-update guard instead of overwriting whatever changed the workflow since. + if (materialized.status !== 'conflict') { + await refreshWorkflowSourceFileBindingFromSave(context, input.workflowId, { + versionId: saved.versionId, + checksum: saved.checksum, + }); + } + + return { + ...base, + filePath: materialized.filePath, + status: materialized.status, + // Index what is on disk: for `current` and `conflict` that is not the regenerated code. + nodes: await indexSourceNodes(json, materialized.content), + note: SOURCE_FILE_NOTES[materialized.status], + ...(materialized.status !== 'conflict' && code.length <= INLINE_SOURCE_LIMIT_CHARS + ? { code } + : {}), + }; } catch (error) { return { workflowId: input.workflowId, diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/get-as-code-node-identity.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/get-as-code-node-identity.test.ts index 28a7c5eaf18..b5340a87927 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/get-as-code-node-identity.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/get-as-code-node-identity.test.ts @@ -63,9 +63,14 @@ function createContext(): InstanceAiContext { } as unknown as InstanceAiContext; } -/** Rebuild the way the sandbox does: run the source, then serialize. */ +/** + * Rebuild the way the sandbox does: run the source, then serialize. The sandbox + * compiles real TypeScript, so the import line get-as-code emits is fine there; + * the SDK parser used here rejects imports, so strip it first. + */ function rebuild(code: string): WorkflowJSON { - return parseWorkflowCodeToBuilder(code).toJSON(); + const body = code.replace(/^import\s[^\n]*\n+/gm, ''); + return parseWorkflowCodeToBuilder(body).toJSON(); } async function getAsCode(): Promise { diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/preserve-node-positions.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/preserve-node-positions.test.ts index 78bff587f88..cbc13899b38 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/preserve-node-positions.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/preserve-node-positions.test.ts @@ -68,7 +68,7 @@ describe('preserveExistingNodePositions', () => { expect(positionsByName(built)).toEqual({ A: [320, 480], B: [528, 480] }); }); - it('ignores a workflow whose nodes were all renamed, leaving the fresh layout', async () => { + it('ignores a workflow whose nodes were all replaced, leaving the fresh layout', async () => { const saved = workflow([node('Old name', [4000, 900])]); const built = workflow([node('New name', [0, 0])]); @@ -76,6 +76,32 @@ describe('preserveExistingNodePositions', () => { expect(positionsByName(built)).toEqual({ 'New name': [0, 0] }); }); + + it('follows a renamed node by its id', async () => { + const saved = workflow([node('Old name', [4000, 900]), node('Other', [4208, 900])]); + const built = workflow([ + { ...node('New name', [0, 0]), id: 'old-name' }, + node('Other', [208, 0]), + ]); + + await preserveExistingNodePositions(built, 'wf-1', contextReturning(saved)); + + expect(positionsByName(built)).toEqual({ 'New name': [4000, 900], Other: [4208, 900] }); + }); + + it('does not hand a position claimed by id out again by name', async () => { + // "A" was renamed to "B" (same id) and a new node took the name "A". + const saved = workflow([node('A', [4000, 900])]); + const built = workflow([ + { ...node('B', [0, 0]), id: 'a' }, + { ...node('A', [208, 0]), id: 'fresh' }, + ]); + + await preserveExistingNodePositions(built, 'wf-1', contextReturning(saved)); + + expect(positionsByName(built).B).toEqual([4000, 900]); + expect(positionsByName(built).A).not.toEqual([4000, 900]); + }); }); describe('added nodes', () => { diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/summarize-workflow.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/summarize-workflow.test.ts index f338cfc9469..9b81e9e5676 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/summarize-workflow.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/summarize-workflow.test.ts @@ -1,5 +1,9 @@ import type { WorkflowNode } from '../../../types'; -import { summarizeWorkflowStructure } from '../summarize-workflow'; +import { + summarizeWorkflowStructure, + exceedsFullPayloadLimit, + FULL_PAYLOAD_LIMIT_BYTES, +} from '../summarize-workflow'; const nodes: WorkflowNode[] = [ { name: 'A', type: 'n8n-nodes-base.noOp', typeVersion: 1, position: [0, 0], parameters: {} }, @@ -75,3 +79,12 @@ describe('summarizeWorkflowStructure', () => { }); }); }); + +describe('exceedsFullPayloadLimit', () => { + it('measures UTF-8 bytes, not characters', () => { + // Each character is 3 bytes, so 40k characters exceed the 100 KB limit. + const detail = { text: '\u4e2d'.repeat(40_000) }; + expect(JSON.stringify(detail).length).toBeLessThan(FULL_PAYLOAD_LIMIT_BYTES); + expect(exceedsFullPayloadLimit(detail)).toBe(true); + }); +}); diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/workflow-build-remediation.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/workflow-build-remediation.test.ts index fc0c7ccf533..4a33f258f67 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/workflow-build-remediation.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/workflow-build-remediation.test.ts @@ -13,6 +13,7 @@ describe('createSaveFailureRemediation', () => { reason: 'workflow_modified_externally', }); expect(remediation.guidance).toContain('get-as-code'); + expect(remediation.guidance).toContain('status "conflict"'); }); it('blocks source edits when a user holds the editor write lock', () => { diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/workflow-source-materializer.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/workflow-source-materializer.test.ts new file mode 100644 index 00000000000..90a668bdc84 --- /dev/null +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/workflow-source-materializer.test.ts @@ -0,0 +1,371 @@ +import type { WorkflowJSON } from '@n8n/workflow-sdk'; + +import type { InstanceAiContext } from '../../../types'; +import { + getWorkflowSourceFileBinding, + hashWorkflowSource, + saveWorkflowSourceFileBinding, +} from '../workflow-file-bindings'; +import { + indexSourceNodes, + materializeWorkflowSource, + workflowSourceFileSlug, +} from '../workflow-source-materializer'; + +function createContext(files: Map): InstanceAiContext { + return { + userId: 'user-1', + permissions: {}, + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + workspace: { + filesystem: { + readFile: vi.fn(async (path: string) => { + const content = files.get(path); + if (content === undefined) throw new Error(`ENOENT ${path}`); + return await Promise.resolve(content); + }), + writeFile: vi.fn(async (path: string, content: string | Buffer) => { + files.set(path, Buffer.isBuffer(content) ? content.toString('utf-8') : content); + await Promise.resolve(); + }), + }, + }, + } as unknown as InstanceAiContext; +} + +const CODE_V1 = + "const a = node({ config: { id: 'n1', name: 'A' } });\nexport default workflow('w', 'W');"; +const CODE_V2 = + "const a = node({ config: { id: 'n1', name: 'A (renamed)' } });\nexport default workflow('w', 'W');"; + +describe('workflowSourceFileSlug', () => { + it('derives a kebab-case file name from the workflow name', () => { + expect(workflowSourceFileSlug('Regional order dispatch (pilot regions)', 'wf1')).toBe( + 'regional-order-dispatch-pilot-regions', + ); + }); + + it('falls back to the workflow id when the name has no usable characters', () => { + expect(workflowSourceFileSlug('🚀🚀', 'AbC123')).toBe('abc123'); + }); + + it('sanitizes a path-shaped id used as the fallback', () => { + expect(workflowSourceFileSlug('', '../etc/passwd')).toBe('etc-passwd'); + expect(workflowSourceFileSlug('', '///')).toBe('workflow'); + }); +}); + +describe('indexSourceNodes', () => { + type NodeJson = NonNullable[number]; + const nodeJson = (id: string, name: string, type = 'n8n-nodes-base.set'): NodeJson => ({ + id, + name, + type, + typeVersion: 1, + position: [0, 0], + parameters: {}, + }); + const withNodes = (...nodes: NodeJson[]): WorkflowJSON => ({ name: 'W', nodes, connections: {} }); + + it('reports the line of each node declaration', async () => { + const code = [ + "import { node, workflow } from '@n8n/workflow-sdk';", + '', + 'const first = node({', + " type: 'n8n-nodes-base.set',", + ' version: 3.4,', + " config: { id: 'n1', name: 'First' }", + '});', + "const second = node({ type: 'n8n-nodes-base.if', version: 2.2, config: { id: 'n2', name: 'Second' } });", + "export default workflow('w', 'W').add(first).to(second);", + ].join('\n'); + const json = withNodes(nodeJson('n1', 'First'), nodeJson('n2', 'Second', 'n8n-nodes-base.if')); + + await expect(indexSourceNodes(json, code)).resolves.toEqual([ + { name: 'First', type: 'n8n-nodes-base.set', line: 3 }, + { name: 'Second', type: 'n8n-nodes-base.if', line: 8 }, + ]); + }); + + it('locates a node by name when its id is emitted for another node, ignoring the name in values', async () => { + // Codegen emits the shared id once. The second node's name also appears as a + // parameter key on the first node and inside the sticky note's content. + const code = [ + "const first = node({ type: 't', version: 1, config: { id: 'shared', name: 'First', parameters: { assignments: [{ name: 'Second' }] } } });", + 'const second = node({', + " type: 't',", + ' version: 1,', + " config: { name: 'Second' }", + '});', + "export default workflow('w', 'W').add(first).to(second).add(sticky(`# Notes", + "], { name: 'Second'", + "`, [], { name: 'Notes' }));", + ].join('\n'); + const json = withNodes( + nodeJson('shared', 'First'), + nodeJson('shared', 'Second'), + nodeJson('note', 'Notes', 'n8n-nodes-base.stickyNote'), + ); + + await expect(indexSourceNodes(json, code)).resolves.toEqual([ + { name: 'First', type: 'n8n-nodes-base.set', line: 1 }, + { name: 'Second', type: 'n8n-nodes-base.set', line: 2 }, + { name: 'Notes', type: 'n8n-nodes-base.stickyNote', line: 7 }, + ]); + }); + + it('falls back to a unique id when the file renamed the node', async () => { + const code = + "const a = node({ type: 't', version: 1, config: { id: 'n1', name: 'Renamed' } });"; + + await expect(indexSourceNodes(withNodes(nodeJson('n1', 'Original')), code)).resolves.toEqual([ + { name: 'Original', type: 'n8n-nodes-base.set', line: 1 }, + ]); + }); + + it('reports line 0 for a node the source does not declare, or when the source does not parse', async () => { + const json = withNodes(nodeJson('n1', 'Missing')); + const expected = [{ name: 'Missing', type: 'n8n-nodes-base.set', line: 0 }]; + + await expect( + indexSourceNodes( + json, + "const a = node({ type: 't', version: 1, config: { id: 'x', name: 'Other' } });", + ), + ).resolves.toEqual(expected); + await expect( + indexSourceNodes(json, "const a = node({ config: { id: 'n1', name: 'Missing' }"), + ).resolves.toEqual(expected); + }); +}); + +describe('materializeWorkflowSource', () => { + it('writes the file under src/workflows and binds it to the workflow', async () => { + const files = new Map(); + const context = createContext(files); + + const result = await materializeWorkflowSource(context, { + workflowId: 'wf1', + name: 'Order Dispatch', + code: CODE_V1, + saved: { versionId: 'v1', checksum: 'c1' }, + }); + + expect(result).toEqual({ + filePath: 'src/workflows/order-dispatch.workflow.ts', + status: 'written', + sourceHash: hashWorkflowSource(CODE_V1), + content: CODE_V1, + }); + expect(files.get('src/workflows/order-dispatch.workflow.ts')).toBe(CODE_V1); + await expect( + getWorkflowSourceFileBinding(context, 'src/workflows/order-dispatch.workflow.ts'), + ).resolves.toEqual({ + filePath: 'src/workflows/order-dispatch.workflow.ts', + workflowId: 'wf1', + workflowVersionId: 'v1', + workflowChecksum: 'c1', + sourceHash: hashWorkflowSource(CODE_V1), + }); + }); + + it('reports the file as current and writes nothing when neither side changed', async () => { + const files = new Map(); + const context = createContext(files); + const saved = { versionId: 'v1', checksum: 'c1' }; + await materializeWorkflowSource(context, { + workflowId: 'wf1', + name: 'W', + code: CODE_V1, + saved, + }); + const writeFile = context.workspace?.filesystem?.writeFile as ReturnType; + writeFile.mockClear(); + + const result = await materializeWorkflowSource(context, { + workflowId: 'wf1', + name: 'W', + code: CODE_V1, + saved, + }); + + expect(result.status).toBe('current'); + expect(writeFile).not.toHaveBeenCalled(); + }); + + it('regenerates the file when the saved workflow changed and the file has no local edits', async () => { + const files = new Map(); + const context = createContext(files); + await materializeWorkflowSource(context, { + workflowId: 'wf1', + name: 'W', + code: CODE_V1, + saved: { versionId: 'v1', checksum: 'c1' }, + }); + + const result = await materializeWorkflowSource(context, { + workflowId: 'wf1', + name: 'W', + code: CODE_V2, + saved: { versionId: 'v2', checksum: 'c2' }, + }); + + expect(result.status).toBe('refreshed'); + expect(files.get('src/workflows/w.workflow.ts')).toBe(CODE_V2); + await expect( + getWorkflowSourceFileBinding(context, 'src/workflows/w.workflow.ts'), + ).resolves.toMatchObject({ workflowChecksum: 'c2', sourceHash: hashWorkflowSource(CODE_V2) }); + }); + + it('leaves a file with unbuilt edits alone and reports a conflict', async () => { + const files = new Map(); + const context = createContext(files); + await materializeWorkflowSource(context, { + workflowId: 'wf1', + name: 'W', + code: CODE_V1, + saved: { versionId: 'v1', checksum: 'c1' }, + }); + const edited = CODE_V1.replace("name: 'A'", "name: 'A edited'"); + files.set('src/workflows/w.workflow.ts', edited); + + const result = await materializeWorkflowSource(context, { + workflowId: 'wf1', + name: 'W', + code: CODE_V2, + saved: { versionId: 'v2', checksum: 'c2' }, + }); + + expect(result.status).toBe('conflict'); + expect(files.get('src/workflows/w.workflow.ts')).toBe(edited); + await expect( + getWorkflowSourceFileBinding(context, 'src/workflows/w.workflow.ts'), + ).resolves.toMatchObject({ workflowChecksum: 'c1' }); + }); + + it('reuses the path the workflow is already bound to', async () => { + const files = new Map(); + const context = createContext(files); + await saveWorkflowSourceFileBinding(context, { + filePath: 'src/workflows/main.workflow.ts', + workflowId: 'wf1', + }); + + const result = await materializeWorkflowSource(context, { + workflowId: 'wf1', + name: 'Some Other Name', + code: CODE_V1, + saved: { versionId: 'v1', checksum: 'c1' }, + }); + + expect(result.filePath).toBe('src/workflows/main.workflow.ts'); + expect(files.has('src/workflows/main.workflow.ts')).toBe(true); + }); + + it('picks a distinct path when the slug is bound to another workflow', async () => { + const files = new Map(); + const context = createContext(files); + await saveWorkflowSourceFileBinding(context, { + filePath: 'src/workflows/w.workflow.ts', + workflowId: 'other', + }); + + const result = await materializeWorkflowSource(context, { + workflowId: 'AbCdEfGh', + name: 'W', + code: CODE_V1, + saved: { versionId: 'v1' }, + }); + + expect(result.filePath).toBe('src/workflows/w-abcdefgh.workflow.ts'); + }); + + it('does not overwrite a file at the slug path that this thread never bound', async () => { + const files = new Map([ + ['src/workflows/w.workflow.ts', '// agent-written draft'], + ]); + const context = createContext(files); + + const result = await materializeWorkflowSource(context, { + workflowId: 'wf1', + name: 'W', + code: CODE_V1, + saved: { versionId: 'v1', checksum: 'c1' }, + }); + + expect(result.status).toBe('conflict'); + expect(result.content).toBe('// agent-written draft'); + expect(files.get('src/workflows/w.workflow.ts')).toBe('// agent-written draft'); + }); + + it('ignores a .json binding and writes the TypeScript source to its own path', async () => { + const jsonSource = '{"name":"W","nodes":[],"connections":{}}'; + const files = new Map([['src/workflows/wf1.json', jsonSource]]); + const context = createContext(files); + // build-workflow binds the path it built from, a JSON source included. + await saveWorkflowSourceFileBinding(context, { + filePath: 'src/workflows/wf1.json', + workflowId: 'wf1', + workflowVersionId: 'v1', + workflowChecksum: 'c1', + sourceHash: hashWorkflowSource(jsonSource), + }); + + const result = await materializeWorkflowSource(context, { + workflowId: 'wf1', + name: 'W', + code: CODE_V1, + saved: { versionId: 'v1', checksum: 'c1' }, + }); + + expect(result).toMatchObject({ filePath: 'src/workflows/w.workflow.ts', status: 'written' }); + expect(files.get('src/workflows/wf1.json')).toBe(jsonSource); + }); + + it('prefers the TypeScript binding when the workflow has several', async () => { + const files = new Map([['src/workflows/mine.workflow.ts', CODE_V1]]); + const context = createContext(files); + await saveWorkflowSourceFileBinding(context, { + filePath: 'src/workflows/wf1.json', + workflowId: 'wf1', + }); + await saveWorkflowSourceFileBinding(context, { + filePath: 'src/workflows/mine.workflow.ts', + workflowId: 'wf1', + workflowVersionId: 'v1', + workflowChecksum: 'c1', + sourceHash: hashWorkflowSource(CODE_V1), + }); + + const result = await materializeWorkflowSource(context, { + workflowId: 'wf1', + name: 'W', + code: CODE_V1, + saved: { versionId: 'v1', checksum: 'c1' }, + }); + + expect(result).toMatchObject({ filePath: 'src/workflows/mine.workflow.ts', status: 'current' }); + }); + + it('rewrites an unedited file when codegen output changed for the same saved workflow', async () => { + const files = new Map(); + const context = createContext(files); + const saved = { versionId: 'v1', checksum: 'c1' }; + await materializeWorkflowSource(context, { + workflowId: 'wf1', + name: 'W', + code: CODE_V1, + saved, + }); + + const result = await materializeWorkflowSource(context, { + workflowId: 'wf1', + name: 'W', + code: CODE_V2, + saved, + }); + + expect(result.status).toBe('refreshed'); + expect(files.get('src/workflows/w.workflow.ts')).toBe(CODE_V2); + }); +}); diff --git a/packages/@n8n/instance-ai/src/tools/workflows/preserve-node-positions.ts b/packages/@n8n/instance-ai/src/tools/workflows/preserve-node-positions.ts index 557955b76d4..a358f151ba4 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/preserve-node-positions.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/preserve-node-positions.ts @@ -172,7 +172,8 @@ function separateAddedNodes(added: NodeJSON[], allNodes: NodeJSON[]): void { * The sandbox build has no view of the saved workflow, so `toJSON({ tidyUp: true })` * lays the whole graph out from scratch and every node lands wherever the layout * engine put it — scattering a canvas the user had arranged by hand. Reconciling by - * name here keeps the user's layout authoritative, mirroring ensureWebhookIds. + * id (name for nodes without one) keeps the user's layout authoritative, so a + * renamed node keeps its place, mirroring ensureWebhookIds. * * Nodes the build added are translated into the saved canvas's frame, keeping the * layout engine's relative arrangement, then nudged clear of anything they land on. @@ -195,24 +196,50 @@ export async function preserveExistingNodePositions( ); } - const savedPositionsByName = new Map(); + // Saved positions are claimed by id first: preserveExistingNodeIds has just run, + // so a surviving node carries its saved id even after a rename. Name is the + // fallback for a node without one, and a position claimed by id is not handed + // out again by name. + type SavedNode = { id?: string; name?: string; position: Position }; + const savedById = new Map(); + const savedByName = new Map(); for (const node of existing.nodes ?? []) { - if (node.name && Array.isArray(node.position)) { - savedPositionsByName.set(node.name, [node.position[0], node.position[1]]); - } + if (!Array.isArray(node.position)) continue; + const saved: SavedNode = { + id: node.id, + name: node.name, + position: [node.position[0], node.position[1]], + }; + if (saved.id) savedById.set(saved.id, saved); + if (saved.name) savedByName.set(saved.name, saved); } - if (savedPositionsByName.size === 0) return; + if (savedById.size === 0 && savedByName.size === 0) return; const nodes = json.nodes ?? []; const survivors: Survivor[] = []; const added: NodeJSON[] = []; + const claimed = new Set(); + const unclaimed: NodeJSON[] = []; for (const node of nodes) { - const saved = node.name ? savedPositionsByName.get(node.name) : undefined; - if (saved) survivors.push({ node, saved }); - else added.push(node); + const saved = node.id ? savedById.get(node.id) : undefined; + if (saved) { + claimed.add(saved); + survivors.push({ node, saved: saved.position }); + } else { + unclaimed.push(node); + } + } + for (const node of unclaimed) { + const saved = node.name ? savedByName.get(node.name) : undefined; + if (saved && !claimed.has(saved)) { + claimed.add(saved); + survivors.push({ node, saved: saved.position }); + } else { + added.push(node); + } } - // Every node is new (or renamed) — there is no prior layout left to honour. + // Every node is new (or replaced) — there is no prior layout left to honour. if (survivors.length === 0) return; if (added.length > 0) { diff --git a/packages/@n8n/instance-ai/src/tools/workflows/summarize-workflow.ts b/packages/@n8n/instance-ai/src/tools/workflows/summarize-workflow.ts index 9f044358c61..3e179f7049e 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/summarize-workflow.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/summarize-workflow.ts @@ -5,7 +5,17 @@ import { isSafeObjectProperty, NodeConnectionTypes } from 'n8n-workflow'; import type { WorkflowNode } from '../../types'; export const STRUCTURE_ONLY_NOTE = - 'Node parameters omitted to keep context small. Pass full: true to include them in one call, or use get-as-code (optionally with versionId) for parameter-level detail.'; + 'Node parameters omitted to keep context small. For parameter-level detail use get-as-code, which writes the workflow source to a workspace file and returns a node index; pass full: true only for workflows small enough to inline.'; + +export const FULL_PAYLOAD_TOO_LARGE_NOTE = + 'This workflow is too large to inline with full: true. Use get-as-code: it writes the source to a workspace file and returns a node index with line numbers, so you can read only the nodes you need.'; + +/** Above this a `full: true` read is refused in favour of the file-backed get-as-code. */ +export const FULL_PAYLOAD_LIMIT_BYTES = 100_000; + +export function exceedsFullPayloadLimit(detail: unknown): boolean { + return Buffer.byteLength(JSON.stringify(detail), 'utf8') > FULL_PAYLOAD_LIMIT_BYTES; +} // Below this, summarizing saves too little to be worth a possible second full fetch. export const PARAMETERS_INLINE_LIMIT_BYTES = 4096; diff --git a/packages/@n8n/instance-ai/src/tools/workflows/workflow-build-remediation.ts b/packages/@n8n/instance-ai/src/tools/workflows/workflow-build-remediation.ts index 8d0358b52bd..153784f8622 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/workflow-build-remediation.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/workflow-build-remediation.ts @@ -55,7 +55,7 @@ export function createWorkflowModifiedExternallyRemediation(): RemediationMetada return createCodeFixableRemediation({ reason: 'workflow_modified_externally', guidance: - 'The workflow was modified outside this conversation since your last save (canvas edit, setup, credential change, or version revert). Call workflows(action="get-as-code", workflowId), re-apply your intended change to the returned code, write it to the same filePath, then call build-workflow again with the same filePath.', + 'The workflow was modified outside this conversation since your last save (canvas edit, setup, credential change, or version revert). Call workflows(action="get-as-code", workflowId): it regenerates the bound source file from the saved workflow. Re-apply your intended change in that file with workspace_str_replace_file, then call build-workflow again with the same filePath. If get-as-code reports status "conflict", the file still holds your unbuilt edits on top of the old version: delete the file, call get-as-code again, and re-apply the change before building.', }); } diff --git a/packages/@n8n/instance-ai/src/tools/workflows/workflow-file-bindings.ts b/packages/@n8n/instance-ai/src/tools/workflows/workflow-file-bindings.ts index db4b4e0999a..3a3976b25b7 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/workflow-file-bindings.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/workflow-file-bindings.ts @@ -83,6 +83,30 @@ export async function getWorkflowSourceFileBinding( return getFallbackBindings(context).get(normalizedFilePath); } +/** + * Bindings that point at a workflow, or at a file path. Thread metadata wins over + * the in-memory fallback for the same path. `workflowId` undefined matches every + * binding, which lets a caller ask "who owns this path" regardless of workflow. + */ +export async function findWorkflowSourceFileBindingsForWorkflow( + context: InstanceAiContext, + workflowId: string | undefined, + filePath?: string, +): Promise { + const normalizedFilePath = filePath ? normalizeWorkflowSourceFilePath(filePath) : undefined; + const threadBindings = (await readThreadBindings(context)) ?? {}; + const merged = new Map(Object.entries(threadBindings)); + for (const [path, binding] of getFallbackBindings(context)) { + if (!merged.has(path)) merged.set(path, binding); + } + + return Array.from(merged.values()).filter( + (binding) => + (workflowId === undefined || binding.workflowId === workflowId) && + (normalizedFilePath === undefined || binding.filePath === normalizedFilePath), + ); +} + export async function saveWorkflowSourceFileBinding( context: InstanceAiContext, binding: WorkflowSourceFileBinding, @@ -92,6 +116,10 @@ export async function saveWorkflowSourceFileBinding( filePath: normalizeWorkflowSourceFilePath(binding.filePath), }; + // Always keep the run-local copy: a later thread-metadata read can fail, and the + // binding must still be found so an existing file is never treated as unbound. + getFallbackBindings(context).set(normalizedBinding.filePath, normalizedBinding); + if (context.threadMemory && context.threadId) { try { const updatedThread = await patchThread(context.threadMemory, { @@ -111,7 +139,6 @@ export async function saveWorkflowSourceFileBinding( } } - getFallbackBindings(context).set(normalizedBinding.filePath, normalizedBinding); return normalizedBinding; } diff --git a/packages/@n8n/instance-ai/src/tools/workflows/workflow-source-compiler.ts b/packages/@n8n/instance-ai/src/tools/workflows/workflow-source-compiler.ts index c032c2cdf86..b1284f0f713 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/workflow-source-compiler.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/workflow-source-compiler.ts @@ -58,7 +58,7 @@ function isWorkflowJson(value: unknown): value is WorkflowJSON { ); } -function isTypeScriptWorkflowSource(filePath: string): boolean { +export function isTypeScriptWorkflowSource(filePath: string): boolean { const normalized = filePath.toLowerCase(); return normalized.endsWith('.ts') || normalized.endsWith('.tsx'); } diff --git a/packages/@n8n/instance-ai/src/tools/workflows/workflow-source-materializer.ts b/packages/@n8n/instance-ai/src/tools/workflows/workflow-source-materializer.ts new file mode 100644 index 00000000000..172d36d6a2b --- /dev/null +++ b/packages/@n8n/instance-ai/src/tools/workflows/workflow-source-materializer.ts @@ -0,0 +1,184 @@ +import type { WorkflowJSON } from '@n8n/workflow-sdk'; + +import { + findWorkflowSourceFileBindingsForWorkflow, + hashWorkflowSource, + normalizeWorkflowSourceFilePath, + saveWorkflowSourceFileBinding, + type WorkflowSourceFileBinding, +} from './workflow-file-bindings'; +import { isTypeScriptWorkflowSource } from './workflow-source-compiler'; +import type { InstanceAiContext } from '../../types'; +import { readWorkspaceFile, writeWorkspaceFile } from '../../workspace/workspace-files'; + +/** + * Source at or below this size is also returned inline. Above it the agent reads + * the file: the whole source would otherwise sit in context twice — once as the + * tool result and again when the model re-emits it into a file. + */ +export const INLINE_SOURCE_LIMIT_CHARS = 12_000; + +export const WORKFLOW_SOURCE_DIR = 'src/workflows'; + +export type MaterializedSourceStatus = + /** The file was written for the first time in this thread. */ + | 'written' + /** The saved workflow changed since the file was written, so it was regenerated. */ + | 'refreshed' + /** The file already matches the saved workflow; nothing was written. */ + | 'current' + /** The file has edits that were never built, or is not one this thread wrote. It was left alone. */ + | 'conflict'; + +export interface SourceNodeIndexEntry { + name: string; + type: string; + /** 1-based line of the node's declaration in the source file. */ + line: number; +} + +export interface MaterializedWorkflowSource { + filePath: string; + status: MaterializedSourceStatus; + sourceHash: string; + /** The source now on disk at filePath — what a node index must describe. */ + content: string; +} + +function slugify(value: string, maxLength: number): string { + return value + .toLowerCase() + .normalize('NFKD') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, maxLength) + .replace(/-+$/g, ''); +} + +/** File name derived from the workflow name; falls back to the id for empty names. */ +export function workflowSourceFileSlug(name: string, workflowId: string): string { + const slug = slugify(name, 60); + if (slug.length > 0) return slug; + const idSlug = slugify(workflowId, 60); + return idSlug.length > 0 ? idSlug : 'workflow'; +} + +/** + * Line of each node's declaration, so the agent can jump to a node with a ranged + * read instead of scanning the whole file. Declarations come from parsing the + * source, so the same text inside a sticky note or a parameter value cannot match. + * A node is found by its id when that id is unique, else by its name; a node the + * source does not declare, or source that does not parse, reports line 0. + */ +export async function indexSourceNodes( + json: WorkflowJSON, + code: string, +): Promise { + const { locateNodeDeclarations } = await import('@n8n/workflow-sdk'); + const byId = new Map(); + const byName = new Map(); + for (const { id, name, line } of locateNodeDeclarations(code)) { + if (id !== undefined && !byId.has(id)) byId.set(id, line); + if (name !== undefined && !byName.has(name)) byName.set(name, line); + } + + const nodes = json.nodes ?? []; + const idCounts = new Map(); + for (const node of nodes) idCounts.set(node.id, (idCounts.get(node.id) ?? 0) + 1); + + return nodes.map((node) => { + const name = node.name ?? ''; + // Codegen emits a duplicated id for its first claimant only, so it locates that node alone. + const byUniqueId = idCounts.get(node.id) === 1 ? byId.get(node.id) : undefined; + return { name, type: node.type, line: byUniqueId ?? byName.get(name) ?? 0 }; + }); +} + +async function resolveSourceFilePath( + context: InstanceAiContext, + workflowId: string, + name: string, +): Promise<{ filePath: string; binding?: WorkflowSourceFileBinding }> { + // build-workflow binds whatever path it built from, a JSON source included. Only + // a TypeScript binding can take generated TypeScript; anything else is left alone. + const own = (await findWorkflowSourceFileBindingsForWorkflow(context, workflowId)).find( + (binding) => isTypeScriptWorkflowSource(binding.filePath), + ); + if (own) return { filePath: own.filePath, binding: own }; + + const slug = workflowSourceFileSlug(name, workflowId); + const candidate = `${WORKFLOW_SOURCE_DIR}/${slug}.workflow.ts`; + const taken = await findWorkflowSourceFileBindingsForWorkflow(context, undefined, candidate); + // Another workflow already owns this path; suffix with the id so both stay distinct. + const filePath = + taken.length === 0 + ? candidate + : `${WORKFLOW_SOURCE_DIR}/${slug}-${slugify(workflowId, 8) || 'x'}.workflow.ts`; + return { filePath: normalizeWorkflowSourceFilePath(filePath) }; +} + +/** + * Write generated workflow source into the thread's bound workspace file and + * record the binding, so `build-workflow` can save the file back to the same + * workflow without an explicit id. + * + * The file is the agent's editing surface, so it is never clobbered while it + * carries unbuilt edits: when the file on disk no longer matches the hash the + * binding recorded, the call reports `conflict` and writes nothing. When the + * file still matches and the saved workflow has not changed, nothing is written + * either — the file is already current. + */ +export async function materializeWorkflowSource( + context: InstanceAiContext, + options: { + workflowId: string; + name: string; + code: string; + saved: { versionId: string; checksum?: string }; + abortSignal?: AbortSignal; + }, +): Promise { + const workspace = context.workspace; + if (!workspace) { + throw new Error('Runtime workspace is required to materialize workflow source.'); + } + const { workflowId, name, code, saved } = options; + const { filePath, binding } = await resolveSourceFilePath(context, workflowId, name); + const sourceHash = hashWorkflowSource(code); + const fileOptions = { + logger: context.logger, + resourceLabel: 'Workflow source file', + abortSignal: options.abortSignal, + }; + + const existing = await readWorkspaceFile(workspace, filePath, fileOptions); + if (existing !== null) { + const existingHash = hashWorkflowSource(existing); + // A file this thread never recorded a hash for is someone else's work in + // progress — an agent-written source, or a binding whose metadata was lost. + if (binding?.sourceHash === undefined || existingHash !== binding.sourceHash) { + return { filePath, status: 'conflict', sourceHash: existingHash, content: existing }; + } + // The file is exactly what this thread last wrote. It is current only if the + // regenerated source is byte-identical; a codegen change also warrants a rewrite. + if (existingHash === sourceHash) { + return { filePath, status: 'current', sourceHash, content: existing }; + } + } + + await writeWorkspaceFile(workspace, filePath, code, fileOptions); + await saveWorkflowSourceFileBinding(context, { + filePath, + workflowId, + workflowVersionId: saved.versionId, + ...(saved.checksum !== undefined ? { workflowChecksum: saved.checksum } : {}), + sourceHash, + }); + + return { + filePath, + status: existing !== null ? 'refreshed' : 'written', + sourceHash, + content: code, + }; +} diff --git a/packages/@n8n/instance-ai/src/workflow-loop/guidance.ts b/packages/@n8n/instance-ai/src/workflow-loop/guidance.ts index 3ccbed103df..8a7950994e6 100644 --- a/packages/@n8n/instance-ai/src/workflow-loop/guidance.ts +++ b/packages/@n8n/instance-ai/src/workflow-loop/guidance.ts @@ -43,7 +43,7 @@ export function formatWorkflowLoopGuidance( } case 'verify': return ( - `VERIFY: Inspect the persisted workflow ${action.workflowId} with \`workflows(action="get-as-code", workflowId)\` or read the bound workspace source file, then compare it to the requested outcome. ` + + `VERIFY: Inspect the persisted workflow ${action.workflowId}: read the bound workspace source file you just built, or call \`workflows(action="get-as-code", workflowId)\` when you need to check for outside changes — it refreshes the file when the saved workflow changed and returns a node index. If it reports status "conflict", the file has unbuilt edits: build or discard them and call it again before trusting the index. Compare the relevant lines to the requested outcome. ` + 'Build/save success only means a workflow was saved. ' + `Use \`verify-built-workflow\` with workflowId "${action.workflowId ?? 'unknown'}"` + (options.workItemId ? ` and workItemId "${options.workItemId}"` : '') + diff --git a/packages/@n8n/workflow-sdk/src/codegen/code-generator.test.ts b/packages/@n8n/workflow-sdk/src/codegen/code-generator.test.ts index 77470e0060b..3f14f4a57a1 100644 --- a/packages/@n8n/workflow-sdk/src/codegen/code-generator.test.ts +++ b/packages/@n8n/workflow-sdk/src/codegen/code-generator.test.ts @@ -987,7 +987,7 @@ describe('code-generator', () => { const code = generateFromWorkflow(json); expect(code).toContain('sticky('); - expect(code).toContain('## Documentation\\n\\nThis is a note.'); + expect(code).toContain('sticky(`## Documentation\n\nThis is a note.`'); expect(code).toContain('color: 4'); expect(code).toContain('width: 300'); expect(code).toContain('height: 200'); diff --git a/packages/@n8n/workflow-sdk/src/codegen/code-generator.ts b/packages/@n8n/workflow-sdk/src/codegen/code-generator.ts index 1e3f4256006..6cbe7098a76 100644 --- a/packages/@n8n/workflow-sdk/src/codegen/code-generator.ts +++ b/packages/@n8n/workflow-sdk/src/codegen/code-generator.ts @@ -34,7 +34,7 @@ import { isMergeType, generateDefaultNodeName, } from './node-type-utils'; -import { escapeString, escapeRegexChars } from './string-utils'; +import { escapeString, escapeRegexChars, formatStringLiteral } from './string-utils'; import { formatValue, formatCredentials } from './subnode-generator'; import type { SemanticGraph, SemanticNode, AiConnectionType } from './types'; import { getVarName, getUniqueVarName } from './variable-names'; @@ -68,6 +68,8 @@ export interface GenerateCodeOptions extends ExecutionContextOptions { * surfaces that round-trip code back into a saved workflow should opt in. */ includeNodeIds?: boolean; + /** Emit saved node positions. On by default; see `GenerateWorkflowCodeOptions`. */ + includePositions?: boolean; } /** @@ -90,6 +92,8 @@ interface GenerationContext { pinnedNodes?: Set; /** Graph node names allowed to emit their id; absent when id emission is off */ nodesWithEmittableId?: ReadonlySet; + /** Leave `position` out of every node, subnode, and sticky note. */ + omitPositions?: boolean; } /** @@ -165,7 +169,7 @@ function generateSubnodeCall( } const pos = subnodeNode.json.position; - if (pos && (pos[0] !== 0 || pos[1] !== 0)) { + if (!ctx.omitPositions && pos && (pos[0] !== 0 || pos[1] !== 0)) { configParts.push(`position: [${pos[0]}, ${pos[1]}]`); } appendNodeConfigOptions(configParts, subnodeNode); @@ -357,7 +361,7 @@ function generateSubnodeCallWithVarRefs( } const pos = subnodeNode.json.position; - if (pos && (pos[0] !== 0 || pos[1] !== 0)) { + if (!ctx.omitPositions && pos && (pos[0] !== 0 || pos[1] !== 0)) { configParts.push(`position: [${pos[0]}, ${pos[1]}]`); } appendNodeConfigOptions(configParts, subnodeNode); @@ -478,7 +482,11 @@ function generateNodeConfig(node: SemanticNode, ctx: GenerationContext): string } if (node.json.parameters && Object.keys(node.json.parameters).length > 0) { - configParts.push(`parameters: ${formatValue(node.json.parameters, ctx)}`); + // Parameters sit two levels inside the node call (call → config → parameters), so a + // multi-line value lays out relative to the config block, not the file margin. + configParts.push( + `parameters: ${formatValue(node.json.parameters, { ...ctx, indent: ctx.indent + 2 })}`, + ); } if (node.json.credentials && Object.keys(node.json.credentials).length > 0) { @@ -487,7 +495,7 @@ function generateNodeConfig(node: SemanticNode, ctx: GenerationContext): string // Include position if non-zero const pos = node.json.position; - if (pos && (pos[0] !== 0 || pos[1] !== 0)) { + if (!ctx.omitPositions && pos && (pos[0] !== 0 || pos[1] !== 0)) { configParts.push(`position: [${pos[0]}, ${pos[1]}]`); } appendNodeConfigOptions(configParts, node); @@ -615,7 +623,7 @@ function getNodesInsideSticky(stickyNode: SemanticNode, ctx: GenerationContext): * New signature: sticky(content, nodes, config?) */ function generateStickyCall(node: SemanticNode, ctx: GenerationContext): string { - const content = escapeString((node.json.parameters?.content as string) ?? ''); + const content = formatStringLiteral((node.json.parameters?.content as string) ?? ''); // Get nodes inside this sticky's bounds const nodesInside = getNodesInsideSticky(node, ctx); @@ -647,12 +655,12 @@ function generateStickyCall(node: SemanticNode, ctx: GenerationContext): string } const pos = node.json.position; - if (pos && (pos[0] !== 0 || pos[1] !== 0)) { + if (!ctx.omitPositions && pos && (pos[0] !== 0 || pos[1] !== 0)) { options.push(`position: [${pos[0]}, ${pos[1]}]`); } const optionsStr = options.length > 0 ? `, { ${options.join(', ')} }` : ''; - return `sticky('${content}', ${nodesStr}${optionsStr})`; + return `sticky(${content}, ${nodesStr}${optionsStr})`; } /** @@ -677,7 +685,11 @@ function generateMergeCall(node: SemanticNode, ctx: GenerationContext): string { } if (node.json.parameters && Object.keys(node.json.parameters).length > 0) { - configParts.push(`parameters: ${formatValue(node.json.parameters, ctx)}`); + // Parameters sit two levels inside the node call (call → config → parameters), so a + // multi-line value lays out relative to the config block, not the file margin. + configParts.push( + `parameters: ${formatValue(node.json.parameters, { ...ctx, indent: ctx.indent + 2 })}`, + ); } if (node.json.credentials && Object.keys(node.json.credentials).length > 0) { @@ -686,7 +698,7 @@ function generateMergeCall(node: SemanticNode, ctx: GenerationContext): string { // Include position if non-zero const pos = node.json.position; - if (pos && (pos[0] !== 0 || pos[1] !== 0)) { + if (!ctx.omitPositions && pos && (pos[0] !== 0 || pos[1] !== 0)) { configParts.push(`position: [${pos[0]}, ${pos[1]}]`); } appendNodeConfigOptions(configParts, node); @@ -1407,6 +1419,7 @@ export function generateCode( nodesWithEmittableId: executionContext?.includeNodeIds ? collectNodesWithEmittableId(graph) : undefined, + omitPositions: executionContext?.includePositions === false, }; // Pre-register all node variable names to detect and resolve collisions. diff --git a/packages/@n8n/workflow-sdk/src/codegen/codegen.test.ts b/packages/@n8n/workflow-sdk/src/codegen/codegen.test.ts index 49a6efdaa6b..1d25b083fb6 100644 --- a/packages/@n8n/workflow-sdk/src/codegen/codegen.test.ts +++ b/packages/@n8n/workflow-sdk/src/codegen/codegen.test.ts @@ -2,6 +2,79 @@ import { generateWorkflowCode } from './index'; import { parseWorkflowCode } from './parse-workflow-code'; import type { WorkflowJSON } from '../types/base'; +describe('includePositions', () => { + const workflow: WorkflowJSON = { + name: 'Positions', + nodes: [ + { + id: 'trigger', + name: 'Start', + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [100, 200], + parameters: {}, + }, + { + id: 'agent', + name: 'Agent', + type: '@n8n/n8n-nodes-langchain.agent', + typeVersion: 1.7, + position: [300, 200], + parameters: {}, + }, + { + id: 'model', + name: 'Model', + type: '@n8n/n8n-nodes-langchain.lmChatOpenAi', + typeVersion: 1, + position: [300, 400], + parameters: {}, + }, + { + id: 'merge', + name: 'Merge', + type: 'n8n-nodes-base.merge', + typeVersion: 3, + position: [500, 200], + parameters: {}, + }, + { + id: 'note', + name: 'Note', + type: 'n8n-nodes-base.stickyNote', + typeVersion: 1, + position: [0, 600], + parameters: { content: 'hello' }, + }, + ], + connections: { + Start: { main: [[{ node: 'Agent', type: 'main', index: 0 }]] }, + Agent: { main: [[{ node: 'Merge', type: 'main', index: 0 }]] }, + Model: { ai_languageModel: [[{ node: 'Agent', type: 'ai_languageModel', index: 0 }]] }, + }, + }; + + it('emits positions by default', () => { + const code = generateWorkflowCode({ workflow, includeNodeIds: true }); + expect(code).toContain('position: [100, 200]'); + expect(code).toContain('position: [300, 400]'); + expect(code).toContain('position: [500, 200]'); + expect(code).toContain('position: [0, 600]'); + }); + + it('leaves positions out of nodes, subnodes, merges, and sticky notes when disabled', () => { + const code = generateWorkflowCode({ workflow, includeNodeIds: true, includePositions: false }); + expect(code).not.toContain('position'); + expect(code).toContain("id: 'trigger'"); + expect(code).toContain("sticky('hello'"); + expect( + parseWorkflowCode(code) + .nodes.map((n) => n.name) + .sort(), + ).toEqual(['Agent', 'Merge', 'Model', 'Note', 'Start']); + }); +}); + describe('generateWorkflowCode', () => { it('never emits pinData from the source workflow', () => { // Intentional (INS-1216): pinned data must not round-trip through @@ -223,7 +296,7 @@ describe('generateWorkflowCode', () => { const code = generateWorkflowCode(json); - expect(code).toContain("sticky('## Documentation\\n\\nThis is a note.'"); + expect(code).toContain('sticky(`## Documentation\n\nThis is a note.`'); expect(code).toContain('color: 4'); }); @@ -415,9 +488,10 @@ describe('generateWorkflowCode', () => { const code = generateWorkflowCode(json); - // Should properly escape + // Single-line strings keep escaped quotes; multi-line strings become template + // literals with real line breaks so each line stays editable on its own. expect(code).toContain("\\'quotes\\'"); - expect(code).toContain('\\n'); + expect(code).toContain("jsCode: `const x = 'hello';\nreturn x;`"); }); it('should generate code with variables-first format', () => { @@ -3142,4 +3216,50 @@ describe('Sequential polling loops', () => { // Retry Wait 2 → Check Job 2 (cycle back) expect(parsed.connections['Retry Wait 2']?.main[0]?.[0]?.node).toBe('Check Job 2'); }); + + it('should emit multi-line strings as template literals and round-trip them exactly', () => { + const jsonBody = + '{\n "orderId": "={{ $json.order_id }}",\n "note": "a `tick` and ${not_an_expr} and a \\ backslash",\n "quote": "it\'s"\n}'; + const jsCode = + 'const msg = `Hello ${name}`;\r\nreturn [{ json: { msg, when: $now.toISO() } }];'; + const json: WorkflowJSON = { + name: 'Multi-line strings', + nodes: [ + { + id: 'n1', + name: 'Send', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 4.2, + position: [0, 0], + parameters: { method: 'POST', specifyBody: 'json', jsonBody: `=${jsonBody}` }, + }, + { + id: 'n2', + name: 'Code', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [200, 0], + parameters: { jsCode }, + }, + ], + connections: { Send: { main: [[{ node: 'Code', type: 'main', index: 0 }]] } }, + }; + + const code = generateWorkflowCode(json); + + // Each body line is its own source line, so a scoped edit can target one key. + expect(code).toContain('jsonBody: expr(`{\n "orderId": "={{ $json.order_id }}",\n'); + // The parameters object nests under config; only the template content keeps its own indentation. + expect(code).toContain( + " parameters: {\n method: 'POST',\n specifyBody: 'json',\n jsonBody: expr(`{\n \"orderId\"", + ); + // The CR is escaped: a raw CR inside a template literal would be normalized to LF. + expect(code).toContain('jsCode: `const msg = \\`Hello \\${name}\\`;\\r\n'); + + const parsed = parseWorkflowCode(code); + const send = parsed.nodes.find((n) => n.name === 'Send'); + const codeNode = parsed.nodes.find((n) => n.name === 'Code'); + expect(send?.parameters?.jsonBody).toBe(`=${jsonBody}`); + expect(codeNode?.parameters?.jsCode).toBe(jsCode); + }); }); diff --git a/packages/@n8n/workflow-sdk/src/codegen/emit-instance-ai.test.ts b/packages/@n8n/workflow-sdk/src/codegen/emit-instance-ai.test.ts index 53705c4cfe8..0fa46cf7d7c 100644 --- a/packages/@n8n/workflow-sdk/src/codegen/emit-instance-ai.test.ts +++ b/packages/@n8n/workflow-sdk/src/codegen/emit-instance-ai.test.ts @@ -237,8 +237,10 @@ describe('emit-instance-ai', () => { 'isSwitchNodeType', 'isWebhookType', // Codegen + parse round-trip + 'buildImports', 'emitInstanceAi', 'generateWorkflowCode', + 'locateNodeDeclarations', 'parseWorkflowCode', 'parseWorkflowCodeToBuilder', // Code-step helpers — appear as arguments to `.code()` callbacks, not as diff --git a/packages/@n8n/workflow-sdk/src/codegen/index.ts b/packages/@n8n/workflow-sdk/src/codegen/index.ts index e02cf86f2ca..2f1b94ed049 100644 --- a/packages/@n8n/workflow-sdk/src/codegen/index.ts +++ b/packages/@n8n/workflow-sdk/src/codegen/index.ts @@ -54,6 +54,12 @@ export interface GenerateWorkflowCodeOptions { * edited and built back into the same saved workflow. */ includeNodeIds?: boolean; + /** + * Emit each node's saved `position`. On by default. Surfaces that hand the code + * to an editor and restore the saved layout on save can turn it off, so nothing + * in the file invites a layout edit. + */ + includePositions?: boolean; } // Re-export individual functions for testing and extension @@ -63,6 +69,7 @@ export { buildCompositeTree } from './composite-builder'; export { generateCode } from './code-generator'; export { emitInstanceAi, + buildImports, SDK_IMPORTABLE_FUNCTIONS, type EmitInstanceAiOptions, } from './emit-instance-ai'; @@ -114,6 +121,7 @@ export function generateWorkflowCode(input: WorkflowJSON | GenerateWorkflowCodeO valuesExcluded, pinnedNodes, includeNodeIds, + includePositions, } = isOptionsObject(input) ? input : { @@ -124,6 +132,7 @@ export function generateWorkflowCode(input: WorkflowJSON | GenerateWorkflowCodeO valuesExcluded: undefined, pinnedNodes: undefined, includeNodeIds: undefined, + includePositions: undefined, }; // Phase 1: Build semantic graph @@ -159,5 +168,6 @@ export function generateWorkflowCode(input: WorkflowJSON | GenerateWorkflowCodeO valuesExcluded, pinnedNodes: pinnedNodes ? new Set(pinnedNodes) : undefined, includeNodeIds, + includePositions, }); } diff --git a/packages/@n8n/workflow-sdk/src/codegen/locate-node-declarations.test.ts b/packages/@n8n/workflow-sdk/src/codegen/locate-node-declarations.test.ts new file mode 100644 index 00000000000..9493620174b --- /dev/null +++ b/packages/@n8n/workflow-sdk/src/codegen/locate-node-declarations.test.ts @@ -0,0 +1,119 @@ +import { buildImports } from './emit-instance-ai'; +import { generateWorkflowCode } from './index'; +import { locateNodeDeclarations } from './locate-node-declarations'; +import type { WorkflowJSON } from '../types/base'; + +function lineOf(code: string, text: string): number { + const index = code.split('\n').findIndex((line) => line.includes(text)); + if (index < 0) throw new Error(`"${text}" not in code`); + return index + 1; +} + +describe('locateNodeDeclarations', () => { + it('locates every node in generated source, including nodes without an emitted id', () => { + const workflow: WorkflowJSON = { + name: 'W', + nodes: [ + { + id: 'shared', + name: 'Start', + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + { + id: 'shared', + name: 'Second', + type: 'n8n-nodes-base.noOp', + typeVersion: 1, + position: [200, 0], + parameters: {}, + }, + { + id: 'note', + name: 'Notes', + type: 'n8n-nodes-base.stickyNote', + typeVersion: 1, + position: [0, 200], + parameters: { content: '# Notes\nname: Notes' }, + }, + ], + connections: { Start: { main: [[{ node: 'Second', type: 'main', index: 0 }]] } }, + }; + const body = generateWorkflowCode({ workflow, includeNodeIds: true }); + const code = `${buildImports(body)}\n\n${body}`; + + expect(locateNodeDeclarations(code)).toEqual([ + { name: 'Start', id: 'shared', line: lineOf(code, 'trigger({') }, + { name: 'Second', line: lineOf(code, 'const second = node({') }, + { name: 'Notes', id: 'note', line: lineOf(code, 'sticky(`') }, + ]); + }); + + it('ignores node-head text inside strings, templates, and parameters', () => { + const code = [ + "import { node, sticky, workflow } from '@n8n/workflow-sdk';", + '', + 'const carrier = node({', + " type: 'n8n-nodes-base.set',", + ' version: 3.4,', + ' config: {', + " name: 'config: {',", + ' parameters: {', + " config: { name: 'Second' },", + " assignments: [{ name: 'Second', value: 'x' }],", + ' body: `line one', + "], { name: 'Second'", + 'line three`', + ' }', + ' }', + '});', + 'const second = node({', + " type: 'n8n-nodes-base.noOp',", + ' version: 1,', + " config: { name: 'Second' }", + '});', + "export default workflow('w', 'W').add(carrier).to(second).add(sticky(`# Notes", + "], { name: 'Third'", + "name: 'Fourth'`, [], { name: 'Notes', color: 4 }));", + ].join('\n'); + + expect(locateNodeDeclarations(code)).toEqual([ + { name: 'config: {', line: 3 }, + { name: 'Second', line: 17 }, + { name: 'Notes', line: 22 }, + ]); + }); + + it('reads the options of both sticky signatures', () => { + const code = [ + "const a = sticky('## A', { name: 'Note A', id: 'sa' });", + "const b = sticky('## B', [a], { name: 'Note B' });", + "const c = sticky('## C', [a]);", + "const d = sticky('## D');", + ].join('\n'); + + expect(locateNodeDeclarations(code)).toEqual([ + { name: 'Note A', id: 'sa', line: 1 }, + { name: 'Note B', line: 2 }, + ]); + }); + + it('reads names from template literals and quoted keys, and tolerates TypeScript annotations', () => { + const code = [ + "import type { NodeInstance } from '@n8n/workflow-sdk';", + "const first: NodeInstance = node({ type: 't', version: 1, config: { \"name\": `First`, id: 'n1' } });", + "const second = node({ type: 't', version: 1, config: { name: `${prefix} Second` } });", + ].join('\n'); + + expect(locateNodeDeclarations(code)).toEqual([ + { name: 'First', id: 'n1', line: 2 }, + { line: 3 }, + ]); + }); + + it('returns nothing for source that does not parse', () => { + expect(locateNodeDeclarations("const a = node({ config: { name: 'A' }")).toEqual([]); + }); +}); diff --git a/packages/@n8n/workflow-sdk/src/codegen/locate-node-declarations.ts b/packages/@n8n/workflow-sdk/src/codegen/locate-node-declarations.ts new file mode 100644 index 00000000000..e138b23616d --- /dev/null +++ b/packages/@n8n/workflow-sdk/src/codegen/locate-node-declarations.ts @@ -0,0 +1,90 @@ +/** + * Locate node declarations in workflow SDK source by parsing it, without + * interpreting it. A text search cannot tell a node head apart from the same + * text inside a sticky note, a prompt, or a parameter value; the AST can. + */ +import type { CallExpression, Node, ObjectExpression } from 'estree'; + +import { parseSDKCode } from '../ast-interpreter'; +import { walkAst } from '../lint/ast-walk'; +import { prepareSourceForLint } from '../lint/sdk/workflow-sdk-lint'; + +export interface NodeDeclarationLocation { + /** The node's `name`, when the declaration carries one. */ + name?: string; + /** The node's `id`, when the declaration carries one. */ + id?: string; + /** 1-based line of the builder call that declares the node. */ + line: number; +} + +/** + * Every node declaration the source contains, in source order. Returns an empty + * list when the source does not parse: a half-edited file has no reliable + * locations to report. + */ +export function locateNodeDeclarations(source: string): NodeDeclarationLocation[] { + let program: Node; + try { + program = parseSDKCode(prepareSourceForLint(source).code); + } catch { + return []; + } + + const found: NodeDeclarationLocation[] = []; + walkAst(program, (node) => { + if (node.type !== 'CallExpression') return; + const head = declaredNodeHead(node); + if (!head) return; + found.push({ ...readIdentity(head), line: node.loc?.start.line ?? 0 }); + }); + return found; +} + +/** + * The object that names the node a builder call declares. Node builders take + * `{ type, version, config: { id, name, ... } }`; `sticky(content, nodes, options)` + * names the note in its trailing options, also in the older `sticky(content, options)` + * form the builder still accepts. A `config` key nested deeper, such as a parameter + * that happens to be called `config`, is not a node head. + */ +function declaredNodeHead(call: CallExpression): ObjectExpression | undefined { + const args = call.arguments; + if (call.callee.type === 'Identifier' && call.callee.name === 'sticky') { + const options = args.length >= 2 ? args[args.length - 1] : undefined; + return options?.type === 'ObjectExpression' ? options : undefined; + } + + const first = args[0]; + if (first?.type !== 'ObjectExpression') return undefined; + const config = directProperty(first, 'config'); + return config?.type === 'ObjectExpression' ? config : undefined; +} + +function readIdentity(head: ObjectExpression): Pick { + const name = stringValue(directProperty(head, 'name')); + const id = stringValue(directProperty(head, 'id')); + return { ...(name !== undefined ? { name } : {}), ...(id !== undefined ? { id } : {}) }; +} + +function directProperty(object: ObjectExpression, key: string): Node | undefined { + for (const property of object.properties) { + if (property.type !== 'Property' || property.computed) continue; + const propertyKey = property.key; + const matches = + (propertyKey.type === 'Identifier' && propertyKey.name === key) || + (propertyKey.type === 'Literal' && propertyKey.value === key); + if (matches) return property.value; + } + return undefined; +} + +/** A plain string literal, or a template literal with no substitutions. */ +function stringValue(value: Node | undefined): string | undefined { + if (!value) return undefined; + if (value.type === 'Literal' && typeof value.value === 'string') return value.value; + if (value.type === 'TemplateLiteral' && value.expressions.length === 0) { + return value.quasis[0]?.value.cooked ?? undefined; + } + return undefined; +} diff --git a/packages/@n8n/workflow-sdk/src/codegen/string-utils.test.ts b/packages/@n8n/workflow-sdk/src/codegen/string-utils.test.ts index 0cd2204fe56..8917ebe9125 100644 --- a/packages/@n8n/workflow-sdk/src/codegen/string-utils.test.ts +++ b/packages/@n8n/workflow-sdk/src/codegen/string-utils.test.ts @@ -1,4 +1,12 @@ -import { escapeString, needsQuoting, formatKey, escapeRegexChars } from './string-utils'; +import { + escapeString, + needsQuoting, + formatKey, + escapeRegexChars, + escapeTemplateLiteral, + formatStringLiteral, + indentContinuationLines, +} from './string-utils'; describe('string-utils', () => { describe('escapeString', () => { @@ -164,4 +172,45 @@ describe('string-utils', () => { expect(escapeRegexChars('simple')).toBe('simple'); }); }); + + describe('formatStringLiteral', () => { + it('keeps single-line strings single-quoted', () => { + expect(formatStringLiteral("it's fine")).toBe("'it\\'s fine'"); + }); + + it('uses a template literal when the value has line breaks', () => { + expect(formatStringLiteral('line1\nline2')).toBe('`line1\nline2`'); + }); + + it('escapes backticks, interpolations and backslashes inside template literals', () => { + expect(escapeTemplateLiteral('a `b` ${c} d\\e\n')).toBe('a \\`b\\` \\${c} d\\\\e\n'); + }); + + it('escapes carriage returns, which a template literal would otherwise normalize away', () => { + expect(formatStringLiteral('a\r\nb')).toBe('`a\\r\nb`'); + }); + + it('keeps a value with only a carriage return single-quoted', () => { + expect(formatStringLiteral('a\rb')).toBe("'a\\rb'"); + }); + }); + + describe('indentContinuationLines', () => { + it('indents continuation lines outside template literals only', () => { + const text = '{\n a: `x\ny`,\n b: 1\n}'; + expect(indentContinuationLines(text, ' ')).toBe('{\n a: `x\ny`,\n b: 1\n }'); + }); + + it('treats an escaped backtick as content, not a boundary', () => { + const text = 'a: `x \\` y\nz`,\nb: 1'; + expect(indentContinuationLines(text, ' ')).toBe('a: `x \\` y\nz`,\n b: 1'); + }); + + it('ignores backticks inside quoted strings and comments', () => { + const text = "{\n a: 'tick ` here',\n /** @example `x` */\n b: `line1\nline2`,\n c: 1\n}"; + expect(indentContinuationLines(text, ' ')).toBe( + "{\n a: 'tick ` here',\n /** @example `x` */\n b: `line1\nline2`,\n c: 1\n }", + ); + }); + }); }); diff --git a/packages/@n8n/workflow-sdk/src/codegen/string-utils.ts b/packages/@n8n/workflow-sdk/src/codegen/string-utils.ts index 252b91a40c5..23f1356065e 100644 --- a/packages/@n8n/workflow-sdk/src/codegen/string-utils.ts +++ b/packages/@n8n/workflow-sdk/src/codegen/string-utils.ts @@ -18,6 +18,106 @@ export function escapeString(str: string): string { .replace(/\r/g, '\\r'); } +/** + * Escape a string for use inside a template literal. Line feeds stay raw so the + * generated source keeps the value's own line structure. Carriage returns are + * escaped: a template literal normalizes a raw CR or CRLF to LF, which would + * silently change the value. + */ +export function escapeTemplateLiteral(str: string): string { + return str + .replace(/\\/g, '\\\\') + .replace(/\r/g, '\\r') + .replace(/`/g, '\\`') + .replace(/\$\{/g, '\\${'); +} + +/** + * True when a string value is emitted as a multi-line template literal. + */ +export function isMultilineStringValue(str: string): boolean { + return str.includes('\n'); +} + +/** + * Format a string value as a code literal. Values with line feeds become template + * literals so each line of a JSON body, prompt, or script lands on its own source + * line — a scoped text edit can then target one line instead of a single escaped + * line that holds the whole value. Everything else stays a single-quoted string. + */ +export function formatStringLiteral(str: string): string { + if (isMultilineStringValue(str)) { + return `\`${escapeTemplateLiteral(str)}\``; + } + return `'${escapeString(str)}'`; +} + +type ScanState = 'code' | 'single' | 'double' | 'template' | 'block-comment'; + +/** + * Advance a lexical scan over one line of generated code. Only template-literal + * content lines must keep their exact text, but a backtick inside a quoted + * string or a comment must not be mistaken for a template boundary. + */ +function scanLine(line: string, state: ScanState): ScanState { + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + switch (state) { + case 'code': + if (ch === "'") state = 'single'; + else if (ch === '"') state = 'double'; + else if (ch === '`') state = 'template'; + else if (ch === '/' && line[i + 1] === '*') { + state = 'block-comment'; + i++; + } else if (ch === '/' && line[i + 1] === '/') { + return state; + } + break; + case 'single': + case 'double': + case 'template': { + if (ch === '\\') { + i++; + } else if ( + (state === 'single' && ch === "'") || + (state === 'double' && ch === '"') || + (state === 'template' && ch === '`') + ) { + state = 'code'; + } + break; + } + case 'block-comment': + if (ch === '*' && line[i + 1] === '/') { + state = 'code'; + i++; + } + break; + } + } + // Quoted strings cannot span lines; only templates and block comments carry over. + return state === 'single' || state === 'double' ? 'code' : state; +} + +/** + * Indent every line after the first, except lines that sit inside a template + * literal — indenting those would change the string value. + */ +export function indentContinuationLines(text: string, indent: string): string { + const lines = text.split('\n'); + if (lines.length === 1) return text; + + let state: ScanState = 'code'; + return lines + .map((line, index) => { + const result = index === 0 || state === 'template' ? line : `${indent}${line}`; + state = scanLine(line, state); + return result; + }) + .join('\n'); +} + /** * Check if a key needs to be quoted to be a valid JS identifier */ diff --git a/packages/@n8n/workflow-sdk/src/codegen/subnode-generator.ts b/packages/@n8n/workflow-sdk/src/codegen/subnode-generator.ts index d1f09468f4c..a86435e21c5 100644 --- a/packages/@n8n/workflow-sdk/src/codegen/subnode-generator.ts +++ b/packages/@n8n/workflow-sdk/src/codegen/subnode-generator.ts @@ -16,6 +16,9 @@ import { import { generateDefaultNodeName } from './node-type-utils'; import { escapeString, + formatStringLiteral, + indentContinuationLines, + isMultilineStringValue, formatKey, isPlaceholderValue, extractPlaceholderHint, @@ -82,6 +85,8 @@ interface SubnodeGenerationContext { graph: SemanticGraph; nodeNameToVarName: Map; expressionAnnotations?: Map; + /** Leave `position` out of subnode configs; set by the code generator. */ + omitPositions?: boolean; } /** @@ -120,6 +125,32 @@ function containsExpressionAnnotation(value: unknown, ctx?: FormatValueContext): return false; } +/** + * Check if a value or any nested value is a string that formats as a multi-line + * template literal. Such objects use multi-line formatting so the template + * literal starts on its own property line. + */ +function containsMultilineString(value: unknown): boolean { + if (typeof value === 'string') { + return !isPlaceholderValue(value) && isMultilineStringValue(value); + } + if (Array.isArray(value)) { + return value.some((v) => containsMultilineString(v)); + } + if (typeof value === 'object' && value !== null) { + return Object.values(value).some((v) => containsMultilineString(v)); + } + return false; +} + +/** + * True when a formatted value should be laid out across lines: it carries an + * expression annotation or a multi-line template literal. + */ +function needsMultilineLayout(value: unknown, ctx?: FormatValueContext): boolean { + return containsExpressionAnnotation(value, ctx) || containsMultilineString(value); +} + /** * Format a value for code output. * When expression annotations are present in an object, uses multi-line formatting. @@ -136,14 +167,14 @@ export function formatValue(value: unknown, ctx?: FormatValueContext): string { } if (value.startsWith('=')) { const inner = value.slice(1); - const formatted = `expr('${escapeString(inner)}')`; + const formatted = `expr(${formatStringLiteral(inner)})`; if (ctx?.expressionAnnotations?.has(value)) { const annotation = ctx.expressionAnnotations.get(value)!; return `/** @example ${annotation} */\n${formatted}`; } return formatted; } - const formatted = `'${escapeString(value)}'`; + const formatted = formatStringLiteral(value); if (ctx?.expressionAnnotations?.has(value)) { const annotation = ctx.expressionAnnotations.get(value)!; return `/** @example ${annotation} */\n${formatted}`; @@ -155,10 +186,10 @@ export function formatValue(value: unknown, ctx?: FormatValueContext): string { const innerCtx = ctx ? { ...ctx, indent: (ctx.indent ?? 0) + 1 } : ctx; const formattedElements = value.map((v) => formatValue(v, innerCtx)); // Check if any element contains annotation - if so, use multi-line - if (containsExpressionAnnotation(value, ctx)) { + if (needsMultilineLayout(value, ctx)) { const baseIndent = ' '.repeat(ctx?.indent ?? 0); const elementIndent = ' '.repeat((ctx?.indent ?? 0) + 1); - return `[\n${formattedElements.map((e) => `${elementIndent}${e}`).join(',\n')}\n${baseIndent}]`; + return `[\n${formattedElements.map((e) => `${elementIndent}${indentContinuationLines(e, elementIndent)}`).join(',\n')}\n${baseIndent}]`; } return `[${formattedElements.join(', ')}]`; } @@ -173,33 +204,35 @@ export function formatValue(value: unknown, ctx?: FormatValueContext): string { })); // Check if object contains any expression annotations - if so, use multi-line format - if (containsExpressionAnnotation(value, ctx)) { + if (needsMultilineLayout(value, ctx)) { const baseIndent = ' '.repeat(ctx?.indent ?? 0); const propIndent = ' '.repeat((ctx?.indent ?? 0) + 1); const lines = formattedEntries.map((e, i) => { const isLast = i === formattedEntries.length - 1; const valueLines = e.value.split('\n'); - // Check if this is a block comment before a value (not a nested object/array) - // Block comments start with /** and the last line is the actual value + // A block comment before a value (an @example annotation): the comment lines + // get property indent, then `key: value` follows — the value itself may span + // lines when it is a template literal. + const commentEnd = valueLines[0].trim().startsWith('/**') + ? valueLines.findIndex((l) => l.trim().endsWith('*/')) + : -1; const isBlockCommentPrefixed = - valueLines.length > 1 && - valueLines[0].trim().startsWith('/**') && + commentEnd >= 0 && + commentEnd < valueLines.length - 1 && !valueLines[valueLines.length - 1].trim().match(/^[}\]]$/); if (isBlockCommentPrefixed) { - // Comment lines get property indent, last line is key: value - const commentLines = valueLines.slice(0, -1).map((l) => `${propIndent}${l}`); - const valueLine = `${propIndent}${e.key}: ${valueLines[valueLines.length - 1]}`; + const commentLines = valueLines.slice(0, commentEnd + 1).map((l) => `${propIndent}${l}`); + const rawValue = valueLines.slice(commentEnd + 1).join('\n'); + const valueLine = `${propIndent}${e.key}: ${indentContinuationLines(rawValue, propIndent)}`; return [...commentLines, isLast ? valueLine : `${valueLine},`].join('\n'); } // For nested objects/arrays or single-line values, add proper indentation if (valueLines.length > 1) { // Multi-line nested object/array - indent each line properly - const indentedValue = valueLines - .map((line, lineIdx) => (lineIdx === 0 ? line : `${propIndent}${line}`)) - .join('\n'); + const indentedValue = indentContinuationLines(e.value, propIndent); const propLine = `${propIndent}${e.key}: ${indentedValue}`; return isLast ? propLine : `${propLine},`; } @@ -240,7 +273,7 @@ function generateSubnodeConfigParts( } const pos = node.json.position; - if (pos && (pos[0] !== 0 || pos[1] !== 0)) { + if (!ctx.omitPositions && pos && (pos[0] !== 0 || pos[1] !== 0)) { configParts.push(`position: [${pos[0]}, ${pos[1]}]`); } diff --git a/packages/@n8n/workflow-sdk/src/index.ts b/packages/@n8n/workflow-sdk/src/index.ts index 6213d2f8854..60627039212 100644 --- a/packages/@n8n/workflow-sdk/src/index.ts +++ b/packages/@n8n/workflow-sdk/src/index.ts @@ -188,10 +188,15 @@ export type { CodeExecutionMode } from './lint/code-node/extract-snippets'; export { generateWorkflowCode } from './codegen/index'; export { emitInstanceAi, + buildImports, SDK_IMPORTABLE_FUNCTIONS, type EmitInstanceAiOptions, } from './codegen/index'; export { parseWorkflowCode, parseWorkflowCodeToBuilder } from './codegen/parse-workflow-code'; +export { + locateNodeDeclarations, + type NodeDeclarationLocation, +} from './codegen/locate-node-declarations'; // Type generation utilities (for runtime type generation in CLI) export * from './generate-types';