fix(core): Stop the AI Assistant from pulling the whole workflow through the model for small edits (#37644)

This commit is contained in:
oleg
2026-09-03 10:04:08 +00:00
committed by GitHub
parent c09ee10f1e
commit 8ef5ba691d
28 changed files with 1845 additions and 93 deletions
@@ -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/<name>.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/<name>.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/<name>.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
@@ -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' },
);
}
}
@@ -37,10 +37,25 @@ function makeManagedWorkflow(): WorkflowJSON {
};
}
function makeContext(workflow: WorkflowJSON): InstanceAiContext {
function makeContext(workflow: WorkflowJSON, files: Map<string, string>): InstanceAiContext {
const context = mock<InstanceAiContext>();
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<string, string>();
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',
@@ -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<string, string>) {
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<string, string>();
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<string, string>();
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<string, string>();
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<string, string>();
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<string, string>();
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<string, string>();
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<string, string>();
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<string, string>();
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<string, string>();
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<string, string>();
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<string, string>();
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();
@@ -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/<name>.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<Input, { act
};
}
const version = await context.workflowService.getVersion(input.workflowId, input.versionId);
if (input.full || isSmallPayload(version)) {
if (isSmallPayload(version) || (input.full && !exceedsFullPayloadLimit(version))) {
return { workflowId: input.workflowId, ...version };
}
const { nodes, connections, ...meta } = version;
@@ -538,18 +550,19 @@ async function handleGet(context: InstanceAiContext, input: Extract<Input, { act
...meta,
nodeCount: nodes.length,
structure: await summarizeWorkflowStructure(meta.name ?? '', nodes, connections),
note: STRUCTURE_ONLY_NOTE,
note: input.full ? FULL_PAYLOAD_TOO_LARGE_NOTE : STRUCTURE_ONLY_NOTE,
};
}
const detail = await context.workflowService.get(input.workflowId);
await rememberObservedWorkflowChecksum(context, input.workflowId, detail.checksum);
if (input.full || isSmallPayload(detail)) return detail;
if (isSmallPayload(detail)) return detail;
if (input.full && !exceedsFullPayloadLimit(detail)) return detail;
const { nodes, connections, ...meta } = detail;
return {
...meta,
nodeCount: nodes.length,
structure: await summarizeWorkflowStructure(meta.name, nodes, connections),
note: STRUCTURE_ONLY_NOTE,
note: input.full ? FULL_PAYLOAD_TOO_LARGE_NOTE : STRUCTURE_ONLY_NOTE,
};
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to fetch workflow';
@@ -622,21 +635,117 @@ async function handleGetJson(
}
}
const SOURCE_FILE_NOTES: Record<MaterializedSourceStatus, string> = {
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<Input, { action: 'get-as-code' }>,
) {
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,
@@ -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<string> {
@@ -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', () => {
@@ -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);
});
});
@@ -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', () => {
@@ -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<string, string>): 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<WorkflowJSON['nodes']>[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<string, string>();
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<string, string>();
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<typeof vi.fn>;
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<string, string>();
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<string, string>();
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<string, string>();
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<string, string>();
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<string, string>([
['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<string, string>([['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<string, string>([['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<string, string>();
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);
});
});
@@ -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<string, Position>();
// 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<string, SavedNode>();
const savedByName = new Map<string, SavedNode>();
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<SavedNode>();
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) {
@@ -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;
@@ -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.',
});
}
@@ -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<WorkflowSourceFileBinding[]> {
const normalizedFilePath = filePath ? normalizeWorkflowSourceFilePath(filePath) : undefined;
const threadBindings = (await readThreadBindings(context)) ?? {};
const merged = new Map<string, WorkflowSourceFileBinding>(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;
}
@@ -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');
}
@@ -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<SourceNodeIndexEntry[]> {
const { locateNodeDeclarations } = await import('@n8n/workflow-sdk');
const byId = new Map<string, number>();
const byName = new Map<string, number>();
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<string, number>();
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<MaterializedWorkflowSource> {
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,
};
}
@@ -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}"` : '') +
@@ -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');
@@ -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<string>;
/** Graph node names allowed to emit their id; absent when id emission is off */
nodesWithEmittableId?: ReadonlySet<string>;
/** 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.
@@ -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);
});
});
@@ -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
@@ -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,
});
}
@@ -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([]);
});
});
@@ -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<NodeDeclarationLocation, 'name' | 'id'> {
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;
}
@@ -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 }",
);
});
});
});
@@ -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
*/
@@ -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<string, string>;
expressionAnnotations?: Map<string, string>;
/** 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]}]`);
}
+5
View File
@@ -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';