mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
fix(core): Stop the AI Assistant from pulling the whole workflow through the model for small edits (#37644)
This commit is contained in:
@@ -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' },
|
||||
);
|
||||
}
|
||||
}
|
||||
+21
-2
@@ -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,
|
||||
|
||||
+7
-2
@@ -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> {
|
||||
|
||||
+27
-1
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+1
@@ -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', () => {
|
||||
|
||||
+371
@@ -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]}]`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user