diff --git a/packages/@n8n/instance-ai/docs/streaming-protocol.md b/packages/@n8n/instance-ai/docs/streaming-protocol.md index 8368519e813..2bb08e9444e 100644 --- a/packages/@n8n/instance-ai/docs/streaming-protocol.md +++ b/packages/@n8n/instance-ai/docs/streaming-protocol.md @@ -320,9 +320,22 @@ Emitted only while the setup panel flag is on, through the host-wired `setupItemsEmitter` on the domain context. `build-workflow` replaces the snapshot on every successful save (open credential slots fanned out to their nodes, slots already bound to a stored credential, and nodes with unresolved -parameters); `credentials(action="setup")` re-analyses the saved workflow and -merges the result, with the announced `reason`/`setupHint` applied, into the -last snapshot. The emitter drops a snapshot whose content did not change. +parameters); `workflows(action="setup")` publishes the same whole-workflow +snapshot for normal setup calls; `credentials(action="setup")` re-analyses +the saved workflow and merges the result, with the announced `reason`/`setupHint` +applied, into the last snapshot. The emitter is seeded with the thread's +persisted snapshots at run start and drops a snapshot whose content did not +change, so a recomputed, unchanged list publishes nothing. Snapshot reads wait for +pending events to drain. The final workflow setup handoff confirms the stored +snapshot before it saves the setup routing marker. A failed handoff returns an +error instead of `announced: true`. Credential replacement requests and existing +setup cards keep their selection and resume flows. + +The agent observes saved workflows at the start of a user turn. This read updates +its private open-item memo. It does not publish a snapshot or select a workflow. +Observation checks saved credential bindings, required values, and placeholders. +It does not test connections or resource availability. Configured items have not +necessarily passed a connection test or workflow execution. ```json { diff --git a/packages/@n8n/instance-ai/docs/tools.md b/packages/@n8n/instance-ai/docs/tools.md index 2ca88fe49d6..c8d4797bb38 100644 --- a/packages/@n8n/instance-ai/docs/tools.md +++ b/packages/@n8n/instance-ai/docs/tools.md @@ -392,6 +392,28 @@ confirmation card. `nodesStillNeedingSetup` is what nobody has configured yet, `skippedByUser` what the user actively dismissed and the agent must not re-open (see `reopenSkipped`). +**Setup panel** (`N8N_INSTANCE_AI_SETUP_PANEL_ENABLED`): the normal setup call +analyzes the whole workflow, including bound slots. It publishes the `setup-items` +snapshot and confirms that it reached storage. It then saves the build's setup +routing marker. Only after both steps succeed does it return +`{ success: true, announced: true, workflowId, open, configured, validationWarnings, message }`. +The agent summarizes the result and ends its turn. `open` lists pending items. +`configured` lists stored bindings. Configuration does not prove that a connection +test or workflow execution passed. Failed connection checks appear in `validationWarnings`. + +Validation and destination approval run before the announcement. The agent follows +the returned guidance for errors, denials, or approvals. Explicit +`preferNewCredentials` requests use the selection card. Existing cards keep their +`apply`, `test-trigger`, and decline paths. + +Each new user turn carries a `` block with current saved +state and items that settled since the previous look. This observation does not +publish snapshots or change the current workflow target. It preserves announced +recipes and does not count temporary credential replacement requests as user progress. +This observation reads saved bindings and checks required values and placeholders. +It does not test credentials or fetch provider resource lists. It does not produce +fresh connection-test warnings. Live checks remain part of setup and verification. + ### `workflows(action="publish")` Publish a workflow version to production. Makes it active — it will run on triggers. diff --git a/packages/@n8n/instance-ai/src/agent/__tests__/system-prompt.setup-panel.test.ts b/packages/@n8n/instance-ai/src/agent/__tests__/system-prompt.setup-panel.test.ts new file mode 100644 index 00000000000..5b6649e2201 --- /dev/null +++ b/packages/@n8n/instance-ai/src/agent/__tests__/system-prompt.setup-panel.test.ts @@ -0,0 +1,28 @@ +/** + * Setup panel v2 changes what `workflows(action="setup")` does. The prompt has + * to describe the variant that is live, or the agent narrates a card that never + * opens (or waits on one that does). + */ + +import { getSystemPrompt } from '../system-prompt'; + +describe('getSystemPrompt — setup panel', () => { + it('describes the suspending setup card while the panel is off', () => { + const prompt = getSystemPrompt({}); + + expect(prompt).toContain('opens the inline setup card'); + expect(prompt).not.toContain(''); + }); + + it('describes the announce-and-return setup flow while the panel is on', () => { + const prompt = getSystemPrompt({ setupPanelEnabled: true }); + + expect(prompt).not.toContain('opens the inline setup card'); + expect(prompt).toContain('setup panel next to the chat'); + expect(prompt).toContain(''); + expect(prompt).toContain('end your turn'); + expect(prompt).toContain('When the result has `announced: true`'); + expect(prompt).toContain('wait for requested destination approvals'); + expect(prompt).toContain('Do not treat a resumed card as a panel announcement'); + }); +}); diff --git a/packages/@n8n/instance-ai/src/agent/instance-agent.ts b/packages/@n8n/instance-ai/src/agent/instance-agent.ts index 40f00498dad..0735211c6e3 100644 --- a/packages/@n8n/instance-ai/src/agent/instance-agent.ts +++ b/packages/@n8n/instance-ai/src/agent/instance-agent.ts @@ -23,6 +23,7 @@ import { } from '../tools'; import { createToolsFromLocalMcpServer } from '../tools/filesystem/create-tools-from-mcp-server'; import { ALWAYS_LOADED_TOOL_NAMES, CHECKPOINT_FOLLOW_UP_TOOL_NAMES } from '../tools/tool-ids'; +import { isSetupPanelEnabled } from '../tools/workflows/setup-items'; import { buildAgentTraceInputs, mergeTraceRunInputs } from '../tracing/langsmith-tracing'; import type { CreateInstanceAgentOptions, @@ -200,6 +201,7 @@ export async function createInstanceAgent( // Presence of the service IS the experiment gate — the host only wires it // for flagged-in users on project-bound runs. conversationHistoryEnabled: Boolean(context.conversationHistoryService), + setupPanelEnabled: isSetupPanelEnabled(context), workspaceRoot: orchestrationContext?.workspace && orchestrationContext.workspaceRoot ? orchestrationContext.workspaceRoot diff --git a/packages/@n8n/instance-ai/src/agent/system-prompt.ts b/packages/@n8n/instance-ai/src/agent/system-prompt.ts index b7eb35187fc..76a6a4cf065 100644 --- a/packages/@n8n/instance-ai/src/agent/system-prompt.ts +++ b/packages/@n8n/instance-ai/src/agent/system-prompt.ts @@ -24,6 +24,8 @@ interface SystemPromptOptions { /** Absolute or host-relative sandbox workspace root for `` paths in prompts. */ workspaceRoot?: string; conversationHistoryEnabled?: boolean; + /** Setup panel v2 flag: `workflows(action="setup")` announces instead of opening a card. */ + setupPanelEnabled?: boolean; } export function getDateTimeSection(timeZone?: string): string { @@ -151,6 +153,18 @@ If the user asks for a blocked operation, explain that the instance is in read-o `; } +/** + * Setup panel v2 changes what `workflows(action="setup")` does: it announces the + * checklist and returns instead of opening a card. Instance-wide flag, so the + * two variants never fragment the prompt cache within one instance. + */ +function getCredentialSetupBullet(setupPanelEnabled?: boolean): string { + if (setupPanelEnabled) { + return '**Credential setup** uses `workflows(action="setup")` when a workflowId is available. When the result has `announced: true`, the setup panel next to the chat lists the remaining credentials and parameters. Summarize that result, report any validation warnings, and end your turn. Other results need their returned guidance: correct validation errors, respect denials and skipped items, and wait for requested destination approvals. Explicit credential replacement and an already-open setup card keep their card flow, including apply and test-trigger results. Do not treat a resumed card as a panel announcement. Each new user turn carries a `` block with current configuration; trust it over older tool results. Configuration alone does not prove successful testing. Use `credentials(action="setup")` when the user explicitly asks to create a credential outside of any workflow context. Never call both tools for the same workflow. Never describe workflow setup as something the user starts from the canvas or editor, and never ask the user to paste secrets into chat.'; + } + return '**Credential setup** uses `workflows(action="setup")` when a workflowId is available — it opens the inline setup card in the AI Assistant panel and handles credentials, parameters, and triggers in one step. Use `credentials(action="setup")` only when the user explicitly asks to create a credential outside of any workflow context. Never call both tools for the same workflow. Never describe workflow setup as something the user starts from the canvas or editor. Setup cards are only open while the setup call is pending — once it returns a result, the card is resolved: describe the outcome (e.g. credentials selected and ready), never that a card is open or that the user still needs to authorize. When a node in `nodesStillNeedingSetup` carries `parameterIssues`, the connected credential can\'t reach the value that was configured (e.g. a model outside what the credential allows) — fix the value, then tell the user plainly which value didn\'t work and what you set instead. Never silently swap a model or other parameter without saying so. Nodes listed under `skippedByUser` are different: the user chose to skip them, so never re-open the setup card for those — say what stays unconfigured and offer to set it up later.'; +} + export function getSystemPrompt(options: SystemPromptOptions = {}): string { const { webhookBaseUrl, @@ -164,6 +178,7 @@ export function getSystemPrompt(options: SystemPromptOptions = {}): string { projectId, workspaceRoot, conversationHistoryEnabled, + setupPanelEnabled, } = options; return `You are the n8n Instance Agent — a helpful AI assistant embedded in an n8n instance. Your job is to understand the user's request and load one or more skills to help them achieve their goal. Once a skill is loaded, learn it in depth before continuing. You are also encouraged to call skills at any point in the conversation if it will help you achieve the user's goal. Match the user's request against skill descriptions in the catalog. Call \`load_skill\` before acting on a matched skill's guidance. A single turn may need more than one skill when routing requires it. Tool descriptions carry any load-before-call gates (\`load_skill\` / \`load_tool\`). @@ -208,7 +223,7 @@ Don't fabricate provider setup mechanics (credential field names, secret values, - **Standalone credential setup intent** — When using \`credentials(action="setup")\` outside workflow context, set \`requireUserSelection=true\` only when the user explicitly asks for a new, separate, or different credential, or asks to see the setup card or choose a credential even if one already exists. Omit it for ordinary setup requests so a sole existing service-scoped credential can still be selected automatically. - **Destructive operations** show a confirmation UI automatically — don't ask via text. -- **Credential setup** uses \`workflows(action="setup")\` when a workflowId is available — it opens the inline setup card in the AI Assistant panel and handles credentials, parameters, and triggers in one step. Use \`credentials(action="setup")\` only when the user explicitly asks to create a credential outside of any workflow context. Never call both tools for the same workflow. Never describe workflow setup as something the user starts from the canvas or editor. Setup cards are only open while the setup call is pending — once it returns a result, the card is resolved: describe the outcome (e.g. credentials selected and ready), never that a card is open or that the user still needs to authorize. When a node in \`nodesStillNeedingSetup\` carries \`parameterIssues\`, the connected credential can't reach the value that was configured (e.g. a model outside what the credential allows) — fix the value, then tell the user plainly which value didn't work and what you set instead. Never silently swap a model or other parameter without saying so. Nodes listed under \`skippedByUser\` are different: the user chose to skip them, so never re-open the setup card for those — say what stays unconfigured and offer to set it up later. +- ${getCredentialSetupBullet(setupPanelEnabled)} - **Error workflows are per workflow** — n8n has no global/instance-wide error workflow setting. Mention that only when the user explicitly asks about global error workflow behavior; build/assign steps live in \`workflow-builder\` and \`post-build-flow\`. - **Never expose credential secrets** — metadata only. diff --git a/packages/@n8n/instance-ai/src/index.ts b/packages/@n8n/instance-ai/src/index.ts index 530c8481d02..33000b623b8 100644 --- a/packages/@n8n/instance-ai/src/index.ts +++ b/packages/@n8n/instance-ai/src/index.ts @@ -493,7 +493,11 @@ export const RunStateRegistry: typeof RunStateRegistryMod.RunStateRegistry = laz () => loadRunStateRegistry().RunStateRegistry, ); export { orchestratorAgentId } from './runtime/orchestrator-identity'; -export { createSetupItemsEmitter } from './tools/workflows/setup-items'; +export { createSetupItemsEmitter, isSetupPanelEnabled } from './tools/workflows/setup-items'; +export { + formatWorkflowSetupStateNote, + observeWorkflowSetupStates, +} from './tools/workflows/setup-panel-state'; export type { RunDebugRecord } from './debug/run-debug-buffer'; export { RunDebugBuffer, diff --git a/packages/@n8n/instance-ai/src/tools/__tests__/credentials.tool.test.ts b/packages/@n8n/instance-ai/src/tools/__tests__/credentials.tool.test.ts index 07a6ba1e2fd..857310acfaa 100644 --- a/packages/@n8n/instance-ai/src/tools/__tests__/credentials.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/__tests__/credentials.tool.test.ts @@ -850,8 +850,10 @@ describe('credentials tool', () => { function panelContext(overrides: Parameters[0] = {}) { const emitter = { emit: vi.fn(() => true), + announce: vi.fn().mockResolvedValue(undefined), merge: vi.fn(() => true), lastWorkflowId: vi.fn<() => string | undefined>(() => undefined), + workflowIds: vi.fn(() => []), }; const context = createMockContext({ setupItemsEmitter: emitter, diff --git a/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.test.ts b/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.test.ts index 0a21b843d21..1f461d86bb5 100644 --- a/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.test.ts @@ -3270,3 +3270,314 @@ describe('node usage', () => { }); }); }); + +describe('workflows(action="setup") — setup panel', () => { + const openSlack = { + node: { name: 'Slack', type: 'n8n-nodes-base.slack' }, + credentialType: 'slackApi', + credentialNeedsAction: true, + needsAction: true, + }; + const boundGmail = { + node: { + name: 'Gmail', + type: 'n8n-nodes-base.gmail', + credentials: { gmailOAuth2: { id: 'cred-1', name: 'Work Gmail' } }, + }, + credentialType: 'gmailOAuth2', + credentialNeedsAction: false, + needsAction: false, + }; + const sheetParams = { + node: { name: 'Sheet', type: 'n8n-nodes-base.googleSheets' }, + parameterIssues: { documentId: ['missing'] }, + needsAction: true, + }; + + function panelContext(overrides: Parameters[0] = {}) { + const emitter = { + emit: vi.fn(() => true), + announce: vi.fn().mockResolvedValue(undefined), + merge: vi.fn(() => true), + workflowIds: vi.fn(() => []), + lastWorkflowId: vi.fn(), + }; + const markWorkflowSetupHandled = vi.fn().mockResolvedValue(undefined); + const context = createMockContext({ + setupItemsEmitter: emitter, + markWorkflowSetupHandled, + ...overrides, + }); + return { context, emitter, markWorkflowSetupHandled }; + } + + beforeEach(() => { + vi.clearAllMocks(); + (analyzeWorkflow as Mock).mockReset(); + }); + + it('announces the whole checklist and returns instead of suspending', async () => { + (analyzeWorkflow as Mock).mockResolvedValue([openSlack, boundGmail, sheetParams]); + const { context, emitter, markWorkflowSetupHandled } = panelContext(); + const suspend = vi.fn(); + + const tool = createWorkflowsTool(context, 'full'); + const result = await executeTool(tool, { action: 'setup', workflowId: 'wf1' }, { + suspend, + resumeData: undefined, + } as never); + + expect(analyzeWorkflow).toHaveBeenCalledWith(context, 'wf1', undefined, { + includeSettled: true, + }); + expect(suspend).not.toHaveBeenCalled(); + expect(emitter.announce).toHaveBeenCalledWith('wf1', [ + expect.objectContaining({ + id: 'wf1:credential:slackApi', + nodeBindings: [{ nodeName: 'Slack' }], + }), + expect.objectContaining({ + id: 'wf1:credential:gmailOAuth2', + nodeBindings: [{ nodeName: 'Gmail' }], + }), + expect.objectContaining({ id: 'wf1:parameters:Sheet', parameterNames: ['documentId'] }), + ]); + expect(markWorkflowSetupHandled).toHaveBeenCalledWith('wf1'); + expect(result).toMatchObject({ + success: true, + announced: true, + workflowId: 'wf1', + open: [ + { kind: 'credential', credentialType: 'slackApi', nodes: ['Slack'] }, + { kind: 'parameters', nodeName: 'Sheet', parameterNames: ['documentId'] }, + ], + configured: [{ kind: 'credential', credentialType: 'gmailOAuth2', nodes: ['Gmail'] }], + }); + expect((result as { message: string }).message).toContain('Finish your turn now'); + }); + + it('does not report an announcement or mark setup handled when publication fails', async () => { + (analyzeWorkflow as Mock).mockResolvedValue([openSlack]); + const { context, emitter, markWorkflowSetupHandled } = panelContext(); + emitter.announce.mockRejectedValue(new Error('storage unavailable')); + + const result = await executeTool( + createWorkflowsTool(context, 'full'), + { action: 'setup', workflowId: 'wf1' }, + { suspend: vi.fn(), resumeData: undefined } as never, + ); + + expect(result).toMatchObject({ success: false, announced: false }); + expect(markWorkflowSetupHandled).not.toHaveBeenCalled(); + }); + + it('retries the routing marker before reporting a successful handoff', async () => { + (analyzeWorkflow as Mock).mockResolvedValue([openSlack]); + const { context, emitter, markWorkflowSetupHandled } = panelContext(); + markWorkflowSetupHandled.mockRejectedValueOnce(new Error('storage unavailable')); + + const result = await executeTool( + createWorkflowsTool(context, 'full'), + { action: 'setup', workflowId: 'wf1' }, + { suspend: vi.fn(), resumeData: undefined } as never, + ); + + expect(result).toMatchObject({ success: true, announced: true }); + expect(markWorkflowSetupHandled).toHaveBeenCalledTimes(2); + expect(emitter.announce).toHaveBeenCalledTimes(1); + }); + + it('reports failure when the routing marker cannot be saved', async () => { + (analyzeWorkflow as Mock).mockResolvedValue([openSlack]); + const { context, markWorkflowSetupHandled } = panelContext(); + markWorkflowSetupHandled.mockRejectedValue(new Error('storage unavailable')); + + const result = await executeTool( + createWorkflowsTool(context, 'full'), + { action: 'setup', workflowId: 'wf1' }, + { suspend: vi.fn(), resumeData: undefined } as never, + ); + + expect(result).toMatchObject({ success: false, announced: false }); + expect(markWorkflowSetupHandled).toHaveBeenCalledTimes(2); + }); + + it('keeps explicit credential replacement in the selection card', async () => { + const replacement = { ...openSlack, preferNewCredential: true }; + (analyzeWorkflow as Mock).mockResolvedValue([replacement, boundGmail]); + const { context, emitter, markWorkflowSetupHandled } = panelContext(); + const suspend = vi.fn(); + + await executeTool( + createWorkflowsTool(context, 'full'), + { action: 'setup', workflowId: 'wf1', preferNewCredentials: ['slackApi'] }, + { suspend, resumeData: undefined } as never, + ); + + expect(suspend).toHaveBeenCalledWith(expect.objectContaining({ setupRequests: [replacement] })); + expect(emitter.announce).not.toHaveBeenCalled(); + expect(markWorkflowSetupHandled).not.toHaveBeenCalled(); + }); + + it('keeps failed connection checks in the announcement', async () => { + (analyzeWorkflow as Mock).mockResolvedValue([ + { ...boundGmail, credentialTestResult: { success: false, message: 'Connection failed' } }, + ]); + const { context } = panelContext(); + + const result = await executeTool( + createWorkflowsTool(context, 'full'), + { action: 'setup', workflowId: 'wf1' }, + { suspend: vi.fn(), resumeData: undefined } as never, + ); + + expect(result).toMatchObject({ + announced: true, + open: [], + validationWarnings: [ + { nodeName: 'Gmail', credentialType: 'gmailOAuth2', message: 'Connection failed' }, + ], + }); + }); + + it('does not scope the announcement to the nodes the last build changed', async () => { + (analyzeWorkflow as Mock).mockResolvedValue([openSlack, sheetParams]); + const { context, emitter } = panelContext({ + workflowBuildContext: { + threadId: 'thread-1', + runId: 'run-1', + taskId: 'task-1', + workItemId: 'wi-1', + workflowTaskService: { + getLatestBuildOutcomeForWorkflow: vi.fn().mockResolvedValue({ + workflowId: 'wf1', + runId: 'run-1', + changedNodeNames: ['Sheet'], + }), + }, + } as never, + runId: 'run-1', + }); + + const result = await executeTool( + createWorkflowsTool(context, 'full'), + { action: 'setup', workflowId: 'wf1' }, + { suspend: vi.fn(), resumeData: undefined } as never, + ); + + expect((emitter.announce as Mock).mock.calls[0][1] as unknown[]).toHaveLength(2); + expect((result as { open: unknown[] }).open).toHaveLength(2); + }); + + it('reports configuration without claiming successful testing', async () => { + (analyzeWorkflow as Mock).mockResolvedValue([boundGmail]); + const { context, emitter } = panelContext(); + + const result = await executeTool( + createWorkflowsTool(context, 'full'), + { action: 'setup', workflowId: 'wf1' }, + { suspend: vi.fn(), resumeData: undefined } as never, + ); + + expect(emitter.announce).toHaveBeenCalledWith('wf1', [ + expect.objectContaining({ id: 'wf1:credential:gmailOAuth2' }), + ]); + expect(result).toMatchObject({ success: true, announced: true, open: [] }); + expect((result as { message: string }).message).toContain('Do not describe the workflow'); + }); + + it('still reviews a templated credential destination before announcing', async () => { + const fixture = templatedSetupFixture({ testUrl: 'https://api.example.com/me' }); + (analyzeWorkflow as Mock).mockResolvedValue([fixture.request]); + const { context, emitter } = panelContext(); + const suspend = vi.fn(); + + await executeTool(createWorkflowsTool(context, 'full'), fixture.input, { + suspend, + resumeData: undefined, + } as never); + + expect(suspend).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Review where this credential will be used' }), + ); + expect(emitter.announce).not.toHaveBeenCalled(); + }); + + it('announces once the credential destination is approved', async () => { + const fixture = templatedSetupFixture({ testUrl: 'https://api.example.com/me' }); + (analyzeWorkflow as Mock).mockResolvedValue([fixture.request]); + const { context, emitter } = panelContext({ + grantSessionToolApproval: vi.fn().mockResolvedValue(undefined), + }); + const suspend = vi.fn(); + + const result = await executeTool(createWorkflowsTool(context, 'full'), fixture.input, { + suspend, + resumeData: { approved: true, credentialDestination: { origin: 'https://api.example.com' } }, + } as never); + + expect(suspend).not.toHaveBeenCalled(); + expect(emitter.announce).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ announced: true }); + }); + + it('still rejects plain generic auth on a new HTTP credential before announcing', async () => { + (analyzeWorkflow as Mock).mockResolvedValue([ + { + node: { name: 'HTTP', type: 'n8n-nodes-base.httpRequest' }, + credentialType: 'httpBearerAuth', + credentialNeedsAction: true, + needsAction: true, + existingCredentials: [], + }, + ]); + const { context, emitter } = panelContext(); + + const result = await executeTool( + createWorkflowsTool(context, 'full'), + { action: 'setup', workflowId: 'wf1' }, + { suspend: vi.fn(), resumeData: undefined } as never, + ); + + expect(result).toMatchObject({ error: 'plain_generic_auth' }); + expect(emitter.announce).not.toHaveBeenCalled(); + }); + + it('leaves the legacy resume paths untouched for an already-suspended card', async () => { + (analyzeWorkflow as Mock).mockResolvedValue([]); + const { context, emitter } = panelContext(); + + const result = await executeTool( + createWorkflowsTool(context, 'full'), + { action: 'setup', workflowId: 'wf1' }, + { + suspend: vi.fn(), + resumeData: { + approved: true, + action: 'apply', + nodeParameters: { Slack: { channel: '#ops' } }, + }, + } as never, + ); + + expect(applyNodeChanges).toHaveBeenCalled(); + expect(result).not.toMatchObject({ announced: true }); + expect(emitter.announce).not.toHaveBeenCalled(); + }); + + it('keeps the suspending card while the panel is off', async () => { + (analyzeWorkflow as Mock).mockResolvedValue([openSlack]); + const suspend = vi.fn(); + + await executeTool( + createWorkflowsTool(createMockContext(), 'full'), + { action: 'setup', workflowId: 'wf1' }, + { suspend, resumeData: undefined } as never, + ); + + expect(analyzeWorkflow).toHaveBeenCalledWith(expect.anything(), 'wf1', undefined, {}); + expect(suspend).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Configure credentials for your workflow' }), + ); + }); +}); diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/report-verification-verdict.tool.ts b/packages/@n8n/instance-ai/src/tools/orchestration/report-verification-verdict.tool.ts index 9eb42c762e9..96e49d4a78d 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/report-verification-verdict.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/report-verification-verdict.tool.ts @@ -179,7 +179,10 @@ export function createReportVerificationVerdictTool(context: OrchestrationContex } return { - guidance: formatWorkflowLoopGuidance(action, { workItemId: input.workItemId }), + guidance: formatWorkflowLoopGuidance(action, { + workItemId: input.workItemId, + setupPanelEnabled: context.setupPanelEnabled === true, + }), }; }) .build(); diff --git a/packages/@n8n/instance-ai/src/tools/workflows.tool.ts b/packages/@n8n/instance-ai/src/tools/workflows.tool.ts index 64b39cf9755..9b594e7c255 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows.tool.ts @@ -15,7 +15,7 @@ import { z } from 'zod'; import { sanitizeInputSchema } from '../agent/sanitize-mcp-schemas'; import { WorkflowSnapshotChangedError } from '../errors/workflow-snapshot-changed.error'; -import type { FolderResolutionFailure, InstanceAiContext } from '../types'; +import type { FolderResolutionFailure, InstanceAiContext, SetupItemsEmitter } from '../types'; import { findSetupHintProblems, findSetupHintTestUrlOriginProblem, @@ -24,6 +24,12 @@ import { TEMPLATABLE_PLAIN_AUTH_TYPES, } from './credentials.tool'; import { formatTimestamp } from '../utils/format-timestamp'; +import { isSetupPanelEnabled } from './workflows/setup-items'; +import { + describeSetupItem, + rememberWorkflowSetupState, + summarizeWorkflowSetupState, +} from './workflows/setup-panel-state'; import { completedSetupSubjects, describeSkippedSetup, @@ -225,7 +231,7 @@ const setupAction = z.object({ action: z .literal('setup') .describe( - 'Open the inline AI Assistant workflow setup card for credential and parameter configuration. Use for setup routing after a build.', + 'Configure workflow credentials and parameters after a build. Follow the returned guidance for a setup panel announcement, selection card, or approval.', ), workflowId: z.string().describe('ID of the workflow'), projectId: z.string().optional().describe(PROJECT_ID_FIELD_DESCRIPTION), @@ -242,7 +248,7 @@ const setupAction = z.object({ ) .optional() .describe( - 'Recipes for the Simplified Custom Auth credentials the user will create during setup: the card pre-fills the template and asks only for the placeholder values. Provide one per templated credential. REQUIRED before composing: load the `credential-recipe-research` skill and execute its lookup procedure — the template and testUrl must come from provider pages fetched there, never from memory.', + 'Recipes for the Simplified Custom Auth credentials the user will create during setup: the setup form pre-fills the template and asks only for the placeholder values. Provide one per templated credential. REQUIRED before composing: load the `credential-recipe-research` skill and execute its lookup procedure — the template and testUrl must come from provider pages fetched there, never from memory.', ), allowPlainGenericAuth: z .boolean() @@ -1371,6 +1377,66 @@ async function resolveSetupScopeNodeNames( } } +const SETUP_PANEL_ANNOUNCED_GUIDANCE = + 'The setup panel next to the chat now lists what this workflow still needs (`open`); nothing is ' + + 'waiting on you and no card is open. Finish your turn now: tell the user in one or two sentences ' + + 'what to configure in the panel — name the services and any values — then stop. Do not call setup ' + + 'again for this workflow, do not call `credentials(action="setup")`, and do not tell the user to ' + + 'open the editor or canvas. Items under `configured` have stored bindings. Report any ' + + 'validationWarnings; a binding does not prove that a connection or workflow test passed.'; + +const SETUP_PANEL_NOTHING_OPEN_GUIDANCE = + 'No setup items are open. The credential slots have stored bindings and no parameter is ' + + 'unresolved. Report any validationWarnings and finish your turn. Do not describe the workflow ' + + 'as tested or ready based on configuration alone. Do not call setup again for this workflow.'; + +/** + * Setup panel v2 replacement for the setup card: publish the workflow's + * checklist, tell the host this build's setup is handled, and hand the agent + * what to summarize. Run-independent on purpose — the panel shows the whole + * workflow, not the nodes the last build touched. + */ +async function announceWorkflowSetup( + context: InstanceAiContext & { setupItemsEmitter: SetupItemsEmitter }, + workflowId: string, + analyzedRequests: readonly SetupRequest[], +) { + const summary = summarizeWorkflowSetupState(workflowId, analyzedRequests); + try { + await context.setupItemsEmitter.announce(workflowId, summary.items); + try { + await context.markWorkflowSetupHandled?.(workflowId); + } catch { + // Retry a transient storage failure before the finalizer can route setup again. + await context.markWorkflowSetupHandled?.(workflowId); + } + } catch (error) { + context.logger?.warn('Failed to complete the setup panel handoff', { + workflowId, + error: error instanceof Error ? error.message : String(error), + }); + return { + success: false, + announced: false, + workflowId, + error: 'setup_announcement_failed', + message: + 'The setup handoff could not be saved. Report the failure. Do not claim setup is complete.', + }; + } + await rememberWorkflowSetupState(context, workflowId, analyzedRequests); + return { + success: true, + announced: true, + workflowId, + open: summary.open.map(describeSetupItem), + configured: summary.configured.map(describeSetupItem), + validationWarnings: summary.validationWarnings, + message: + summary.open.length > 0 ? SETUP_PANEL_ANNOUNCED_GUIDANCE : SETUP_PANEL_NOTHING_OPEN_GUIDANCE, + }; +} + async function handleSetup( context: InstanceAiContext, input: Extract, @@ -1397,12 +1463,16 @@ async function handleSetup( // State 1: Analyze workflow and suspend for user setup if (resumeData === undefined || resumeData === null || destinationDecision !== undefined) { - const allSetupRequests = await analyzeWorkflow( - context, - input.workflowId, - undefined, - preferNewCredentialOptions(input), - ); + // The setup panel lists bound slots too (rendered as done), so its snapshot + // needs the settled requests the card logic below must not see. + const setupPanelEnabled = isSetupPanelEnabled(context); + const analyzedRequests = await analyzeWorkflow(context, input.workflowId, undefined, { + ...preferNewCredentialOptions(input), + ...(setupPanelEnabled ? { includeSettled: true } : {}), + }); + const allSetupRequests = setupPanelEnabled + ? analyzedRequests.filter((request) => !!request.needsAction) + : analyzedRequests; // The user asked to come back to something they skipped, so that decision no longer // holds — drop it before partitioning so the card renders again. Scoped to what they @@ -1543,6 +1613,13 @@ async function handleSetup( return await suspendForCredentialDestination(ctx, state, input.workflowId, destination); } + // Setup panel v2: announce the final checklist and return. The user + // completes it in the panel; the turn ends with the agent's summary. + // Replacement needs an explicit selection. A saved binding already appears done in the panel. + if (isSetupPanelEnabled(context) && !input.preferNewCredentials?.length) { + return await announceWorkflowSetup(context, input.workflowId, analyzedRequests); + } + if (setupRequests.length === 0) { // Two different silences, and the agent has to say different things about them: cards // the user declined, and pre-existing nodes this build never touched. Both can hold at diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/build-workflow.tool.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/build-workflow.tool.test.ts index 362603fbefe..01a17f37856 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/build-workflow.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/build-workflow.tool.test.ts @@ -805,8 +805,10 @@ describe('createBuildWorkflowTool', () => { vi.mocked(analyzeWorkflow).mockResolvedValueOnce([openSlackRequest, boundGmailRequest]); const emitter = { emit: vi.fn(() => true), + announce: vi.fn().mockResolvedValue(undefined), merge: vi.fn(() => true), lastWorkflowId: vi.fn(), + workflowIds: vi.fn(() => []), }; const { context, filePath } = makeContext({ source: 'workflow source from workspace', @@ -848,8 +850,10 @@ describe('createBuildWorkflowTool', () => { vi.mocked(analyzeWorkflow).mockResolvedValueOnce([boundGmailRequest]); const emitter = { emit: vi.fn(() => true), + announce: vi.fn().mockResolvedValue(undefined), merge: vi.fn(() => true), lastWorkflowId: vi.fn(), + workflowIds: vi.fn(() => []), }; const { context, filePath } = makeContext({ source: 'workflow source from workspace', @@ -897,8 +901,10 @@ describe('createBuildWorkflowTool', () => { emit: vi.fn(() => { throw new Error('bus down'); }), + announce: vi.fn().mockResolvedValue(undefined), merge: vi.fn(() => true), lastWorkflowId: vi.fn(), + workflowIds: vi.fn(() => []), }; const { context, filePath } = makeContext({ source: 'workflow source from workspace', diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-items.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-items.test.ts index 5304ad9863f..834c18fc145 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-items.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-items.test.ts @@ -1,4 +1,8 @@ -import { instanceAiEventSchema, type InstanceAiEvent } from '@n8n/api-types'; +import { + instanceAiEventSchema, + type InstanceAiEvent, + type InstanceAiSetupItem, +} from '@n8n/api-types'; import { buildSetupItemsFromAnnouncement, @@ -63,6 +67,96 @@ function setupRequest(overrides: { } describe('createSetupItemsEmitter', () => { + it('waits for persisted state before it completes the final announcement', async () => { + const items: InstanceAiSetupItem[] = [ + { id: 'wf-1:credential:slackApi', kind: 'credential', credentialType: 'slackApi' }, + ]; + let resolve!: (items: InstanceAiSetupItem[]) => void; + const promise = new Promise((done) => { + resolve = done; + }); + const emitter = createSetupItemsEmitter({ + eventBus: { publish: vi.fn() }, + threadId: 'thread-1', + runId: 'run-1', + agentId: 'orchestrator', + readPersistedSnapshot: async () => await promise, + }); + const completed = vi.fn(); + + const announcement = emitter.announce('wf-1', items).then(completed); + await Promise.resolve(); + expect(completed).not.toHaveBeenCalled(); + resolve(items); + await announcement; + expect(completed).toHaveBeenCalledOnce(); + }); + + it.each(['missing', 'read failure', 'publish failure'])( + 'retries a final announcement after %s', + async (failure) => { + const items: InstanceAiSetupItem[] = [ + { id: 'wf-1:credential:slackApi', kind: 'credential', credentialType: 'slackApi' }, + ]; + const publish = vi.fn(); + const readPersistedSnapshot = vi.fn().mockResolvedValue(items); + if (failure === 'missing') readPersistedSnapshot.mockResolvedValueOnce(undefined); + if (failure === 'read failure') { + readPersistedSnapshot.mockRejectedValueOnce(new Error('read failed')); + } + if (failure === 'publish failure') { + publish.mockImplementationOnce(() => { + throw new Error('publish failed'); + }); + } + const emitter = createSetupItemsEmitter({ + eventBus: { publish }, + threadId: 'thread-1', + runId: 'run-1', + agentId: 'orchestrator', + readPersistedSnapshot, + }); + + await expect(emitter.announce('wf-1', items)).resolves.toBeUndefined(); + expect(publish).toHaveBeenCalledTimes(2); + }, + ); + + it('rejects an announcement that never reaches storage and allows a later retry', async () => { + const publish = vi.fn(); + const readPersistedSnapshot = vi.fn().mockResolvedValue(undefined); + const emitter = createSetupItemsEmitter({ + eventBus: { publish }, + threadId: 'thread-1', + runId: 'run-1', + agentId: 'orchestrator', + readPersistedSnapshot, + }); + + await expect(emitter.announce('wf-1', [])).rejects.toThrow('not persisted'); + readPersistedSnapshot.mockResolvedValue([]); + await expect(emitter.announce('wf-1', [])).resolves.toBeUndefined(); + expect(publish).toHaveBeenCalledTimes(3); + }); + + it('confirms a deduplicated announcement against storage', async () => { + const publish = vi.fn(); + const readPersistedSnapshot = vi.fn().mockResolvedValue([]); + const emitter = createSetupItemsEmitter({ + eventBus: { publish }, + threadId: 'thread-1', + runId: 'run-1', + agentId: 'orchestrator', + initialSnapshots: [{ workflowId: 'wf-1', items: [] }], + readPersistedSnapshot, + }); + + await emitter.announce('wf-1', []); + + expect(publish).not.toHaveBeenCalled(); + expect(readPersistedSnapshot).toHaveBeenCalledWith('wf-1'); + }); + it('publishes a durable setup-items snapshot for the workflow', () => { const { emitter, published } = makeEmitter(); @@ -484,3 +578,57 @@ describe('buildSetupItemsFromSetupRequests', () => { expect(items[0]).toMatchObject({ credentialType: 'httpTemplatedCustomAuth', setupHint }); }); }); + +describe('createSetupItemsEmitter — seeded from persisted snapshots', () => { + const slack = { + id: 'wf-1:credential:slackApi', + kind: 'credential' as const, + credentialType: 'slackApi', + nodeBindings: [{ nodeName: 'Slack' }], + }; + + function seededEmitter() { + const published: InstanceAiEvent[] = []; + const emitter = createSetupItemsEmitter({ + eventBus: { publish: (_threadId, event) => published.push(event) }, + threadId: 'thread-1', + runId: 'run-2', + agentId: 'orchestrator-run-2', + initialSnapshots: [ + { workflowId: 'wf-0', items: [] }, + { workflowId: 'wf-1', items: [slack] }, + ], + }); + return { emitter, published }; + } + + it('publishes nothing for a recomputed list that matches the persisted snapshot', () => { + const { emitter, published } = seededEmitter(); + + expect(emitter.emit('wf-1', [{ ...slack }])).toBe(false); + expect(published).toEqual([]); + }); + + it('merges onto the persisted snapshot from an earlier run', () => { + const { emitter, published } = seededEmitter(); + + emitter.merge('wf-1', [ + { id: 'wf-1:credential:gmailOAuth2', kind: 'credential', credentialType: 'gmailOAuth2' }, + ]); + + expect(published).toHaveLength(1); + expect(published[0].type === 'setup-items' && published[0].payload.items).toEqual([ + slack, + { id: 'wf-1:credential:gmailOAuth2', kind: 'credential', credentialType: 'gmailOAuth2' }, + ]); + }); + + it('lists known workflows with the most recently announced last', () => { + const { emitter } = seededEmitter(); + expect(emitter.workflowIds()).toEqual(['wf-0', 'wf-1']); + + emitter.emit('wf-0', [{ ...slack, id: 'wf-0:credential:slackApi' }]); + + expect(emitter.workflowIds()).toEqual(['wf-1', 'wf-0']); + }); +}); diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-panel-state.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-panel-state.test.ts new file mode 100644 index 00000000000..a687cc904db --- /dev/null +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-panel-state.test.ts @@ -0,0 +1,363 @@ +import type { InstanceAiEvent } from '@n8n/api-types'; +import { jsonParse } from 'n8n-workflow'; + +import type { InstanceAiContext } from '../../../types'; +import { createSetupItemsEmitter } from '../setup-items'; +import { + describeSetupItem, + formatWorkflowSetupStateNote, + observeWorkflowSetupStates, + openSetupItemIds, + recordWorkflowSetupState, + summarizeWorkflowSetupState, +} from '../setup-panel-state'; +import type { SetupRequest } from '../setup-workflow.schema'; +import { analyzeWorkflow } from '../setup-workflow.service'; + +vi.mock('../setup-workflow.service', () => ({ + analyzeWorkflow: vi.fn(), +})); + +function request(overrides: { + nodeName: string; + credentialType?: string; + boundCredentialId?: string; + credentialNeedsAction?: boolean; + parameterIssues?: Record; +}): SetupRequest { + const credentials = + overrides.credentialType && overrides.boundCredentialId + ? { [overrides.credentialType]: { id: overrides.boundCredentialId, name: 'Stored' } } + : undefined; + const hasIssues = Object.keys(overrides.parameterIssues ?? {}).length > 0; + return { + node: { + name: overrides.nodeName, + type: 'n8n-nodes-base.test', + ...(credentials ? { credentials } : {}), + }, + ...(overrides.credentialType ? { credentialType: overrides.credentialType } : {}), + ...(overrides.credentialNeedsAction !== undefined + ? { credentialNeedsAction: overrides.credentialNeedsAction } + : {}), + ...(overrides.parameterIssues ? { parameterIssues: overrides.parameterIssues } : {}), + needsAction: overrides.credentialNeedsAction === true || hasIssues, + } as unknown as SetupRequest; +} + +const openSlack = request({ + nodeName: 'Slack', + credentialType: 'slackApi', + credentialNeedsAction: true, +}); +const boundGmail = request({ + nodeName: 'Gmail', + credentialType: 'gmailOAuth2', + boundCredentialId: 'cred-1', + credentialNeedsAction: false, +}); +const sheetParams = request({ nodeName: 'Sheet', parameterIssues: { documentId: ['missing'] } }); + +function threadMemory(initialMetadata: Record = {}) { + let metadata = { ...initialMetadata }; + return { + metadata: () => metadata, + getThread: vi.fn( + async () => + await Promise.resolve({ + id: 'thread-1', + resourceId: 'user-1', + createdAt: new Date(), + updatedAt: new Date(), + metadata, + }), + ), + patchThread: vi.fn( + async (args: { + update: (current: { metadata: Record }) => { + metadata?: Record; + } | null; + }) => { + const patch = args.update({ metadata: { ...metadata } }); + if (patch?.metadata) metadata = patch.metadata; + return await Promise.resolve(null); + }, + ), + }; +} + +function context(overrides: Partial = {}): InstanceAiContext { + return { + logger: { warn: vi.fn(), debug: vi.fn() }, + threadId: 'thread-1', + ...overrides, + } as unknown as InstanceAiContext; +} + +function emitter() { + const published: InstanceAiEvent[] = []; + return { + published, + emitter: createSetupItemsEmitter({ + eventBus: { publish: (_threadId, event) => published.push(event) }, + threadId: 'thread-1', + runId: 'run-1', + agentId: 'orchestrator', + }), + }; +} + +describe('openSetupItemIds', () => { + it('lists open credential slots and nodes with parameter issues, not bound slots', () => { + expect([...openSetupItemIds('wf-1', [openSlack, boundGmail, sheetParams])]).toEqual([ + 'wf-1:credential:slackApi', + 'wf-1:parameters:Sheet', + ]); + }); +}); + +describe('summarizeWorkflowSetupState', () => { + it('splits the snapshot into open and configured items', () => { + const summary = summarizeWorkflowSetupState('wf-1', [openSlack, boundGmail, sheetParams]); + + expect(summary.items.map((item) => item.id)).toEqual([ + 'wf-1:credential:slackApi', + 'wf-1:credential:gmailOAuth2', + 'wf-1:parameters:Sheet', + ]); + expect(summary.open.map((item) => item.id)).toEqual([ + 'wf-1:credential:slackApi', + 'wf-1:parameters:Sheet', + ]); + expect(summary.configured.map((item) => item.id)).toEqual(['wf-1:credential:gmailOAuth2']); + expect(summary.settledSinceLastLook).toEqual([]); + }); + + it('names items that were open at the previous look and are not anymore', () => { + const previouslyOpen = new Set([ + 'wf-1:credential:gmailOAuth2', // bound since: still in the snapshot + 'wf-1:parameters:Sheet', // resolved since: gone from the snapshot + 'wf-1:credential:httpBasicAuth:Removed:primary', // node removed since + 'wf-1:credential:slackApi', // still open + ]); + + const summary = summarizeWorkflowSetupState('wf-1', [openSlack, boundGmail], previouslyOpen); + + expect(summary.settledSinceLastLook).toEqual([ + { kind: 'credential', credentialType: 'gmailOAuth2', nodes: ['Gmail'] }, + { kind: 'parameters', nodeName: 'Sheet' }, + { kind: 'credential', credentialType: 'httpBasicAuth', nodes: ['Removed:primary'] }, + ]); + }); +}); + +describe('describeSetupItem', () => { + it('keeps only what the agent needs to name the item', () => { + expect( + describeSetupItem({ + id: 'wf-1:credential:slackApi', + kind: 'credential', + credentialType: 'slackApi', + nodeBindings: [{ nodeName: 'Slack' }], + setupHint: { template: {}, placeholders: [] }, + }), + ).toEqual({ kind: 'credential', credentialType: 'slackApi', nodes: ['Slack'] }); + expect( + describeSetupItem({ + id: 'wf-1:parameters:Sheet', + kind: 'parameters', + nodeName: 'Sheet', + parameterNames: ['documentId'], + }), + ).toEqual({ kind: 'parameters', nodeName: 'Sheet', parameterNames: ['documentId'] }); + }); +}); + +describe('recordWorkflowSetupState', () => { + it('announces the snapshot and remembers the open items in thread metadata', async () => { + const memory = threadMemory(); + const { emitter: setupItemsEmitter, published } = emitter(); + const ctx = context({ threadMemory: memory, setupItemsEmitter }); + + const summary = await recordWorkflowSetupState(ctx, 'wf-1', [openSlack, boundGmail]); + + expect(published).toHaveLength(1); + expect(published[0]).toMatchObject({ + type: 'setup-items', + payload: { workflowId: 'wf-1', items: summary.items }, + }); + expect(memory.metadata()).toEqual({ + instanceAiSetupPanelOpenItems: { 'wf-1': ['wf-1:credential:slackApi'] }, + }); + }); + + it('diffs against the previous look and replaces the memo', async () => { + const memory = threadMemory({ + instanceAiSetupPanelOpenItems: { + 'wf-1': ['wf-1:credential:slackApi', 'wf-1:credential:gmailOAuth2'], + }, + }); + const ctx = context({ threadMemory: memory, setupItemsEmitter: emitter().emitter }); + + const summary = await recordWorkflowSetupState(ctx, 'wf-1', [openSlack, boundGmail]); + + expect(summary.settledSinceLastLook).toEqual([ + { kind: 'credential', credentialType: 'gmailOAuth2', nodes: ['Gmail'] }, + ]); + expect(memory.metadata()).toEqual({ + instanceAiSetupPanelOpenItems: { 'wf-1': ['wf-1:credential:slackApi'] }, + }); + }); + + it('keeps other keys in the thread metadata and caps the memo to recent workflows', async () => { + const stale = Object.fromEntries( + Array.from({ length: 10 }, (_, i) => [`wf-old-${i}`, [`wf-old-${i}:parameters:N`]]), + ); + const memory = threadMemory({ other: 'kept', instanceAiSetupPanelOpenItems: stale }); + const ctx = context({ threadMemory: memory }); + + await recordWorkflowSetupState(ctx, 'wf-new', [openSlack]); + + const memo = memory.metadata().instanceAiSetupPanelOpenItems as Record; + expect(memory.metadata().other).toBe('kept'); + expect(Object.keys(memo)).toHaveLength(10); + expect(memo['wf-old-0']).toBeUndefined(); + expect(memo['wf-new']).toEqual(['wf-new:credential:slackApi']); + }); + + it('never throws: a failing emitter or memo is logged and the summary still returns', async () => { + const warn = vi.fn(); + const ctx = context({ + logger: { warn, debug: vi.fn() } as unknown as InstanceAiContext['logger'], + threadMemory: { + getThread: vi.fn().mockRejectedValue(new Error('read failed')), + patchThread: vi.fn().mockRejectedValue(new Error('write failed')), + }, + setupItemsEmitter: { + emit: vi.fn(() => { + throw new Error('publish failed'); + }), + merge: vi.fn(() => true), + announce: vi.fn().mockResolvedValue(undefined), + workflowIds: vi.fn(() => []), + lastWorkflowId: vi.fn(), + }, + }); + + const summary = await recordWorkflowSetupState(ctx, 'wf-1', [openSlack]); + + expect(summary.open).toHaveLength(1); + expect(warn).toHaveBeenCalledTimes(3); + }); +}); + +describe('observeWorkflowSetupStates', () => { + beforeEach(() => { + vi.mocked(analyzeWorkflow).mockReset(); + }); + + it('re-analyzes each workflow with settled requests and skips one that fails', async () => { + vi.mocked(analyzeWorkflow).mockImplementation(async (_context, workflowId) => + workflowId === 'wf-gone' + ? await Promise.reject(new Error('not found')) + : await Promise.resolve([openSlack]), + ); + const ctx = context({ setupItemsEmitter: emitter().emitter }); + + const summaries = await observeWorkflowSetupStates(ctx, ['wf-gone', 'wf-1']); + + expect(analyzeWorkflow).toHaveBeenCalledWith(ctx, 'wf-1', undefined, { + includeSettled: true, + validationMode: 'configuration', + }); + expect(summaries.map((summary) => summary.workflowId)).toEqual(['wf-1']); + }); + + it('preserves announced recipes and the current build target during observation', async () => { + const { emitter: setupItemsEmitter, published } = emitter(); + const items = summarizeWorkflowSetupState('wf-1', [ + { ...openSlack, setupHint: { template: {}, placeholders: [] } }, + ]).items; + setupItemsEmitter.emit('wf-1', items); + setupItemsEmitter.emit('wf-2', []); + vi.mocked(analyzeWorkflow).mockResolvedValue([openSlack]); + + await observeWorkflowSetupStates(context({ setupItemsEmitter }), ['wf-2', 'wf-1']); + + expect(published).toHaveLength(2); + expect(setupItemsEmitter.lastWorkflowId()).toBe('wf-2'); + expect(setupItemsEmitter.emit('wf-1', items)).toBe(false); + }); + + it('does not turn a historical workflow into the target for standalone credentials', async () => { + const setupItemsEmitter = createSetupItemsEmitter({ + eventBus: { publish: vi.fn() }, + threadId: 'thread-1', + runId: 'run-2', + agentId: 'orchestrator', + initialSnapshots: [{ workflowId: 'wf-old', items: [] }], + }); + vi.mocked(analyzeWorkflow).mockResolvedValue([openSlack]); + + await observeWorkflowSetupStates(context({ setupItemsEmitter }), ['wf-old']); + + expect(setupItemsEmitter.lastWorkflowId()).toBeUndefined(); + }); + + it('does not report temporary credential replacement as user progress', async () => { + const memory = threadMemory(); + const ctx = context({ threadMemory: memory, setupItemsEmitter: emitter().emitter }); + await recordWorkflowSetupState(ctx, 'wf-1', [ + { ...openSlack, preferNewCredential: true, parameterIssues: { channel: ['missing'] } }, + ]); + vi.mocked(analyzeWorkflow).mockResolvedValue([ + { ...openSlack, credentialNeedsAction: false, parameterIssues: { channel: ['missing'] } }, + ]); + + const [summary] = await observeWorkflowSetupStates(ctx, ['wf-1']); + + expect(summary.settledSinceLastLook).toEqual([]); + expect(memory.metadata().instanceAiSetupPanelOpenItems).toEqual({ + 'wf-1': ['wf-1:parameters:Slack'], + }); + }); + + it('caps the number of workflows it analyzes per turn', async () => { + vi.mocked(analyzeWorkflow).mockResolvedValue([]); + + await observeWorkflowSetupStates(context(), ['a', 'b', 'c', 'd', 'e']); + + expect(analyzeWorkflow).toHaveBeenCalledTimes(3); + }); +}); + +describe('formatWorkflowSetupStateNote', () => { + it('is empty when there is nothing to report', () => { + expect(formatWorkflowSetupStateNote([])).toBe(''); + }); + + it('renders a compact JSON state after the guidance', () => { + const summary = summarizeWorkflowSetupState( + 'wf-1', + [openSlack, boundGmail], + new Set(['wf-1:credential:gmailOAuth2']), + ); + + const note = formatWorkflowSetupStateNote([summary]); + const [guidance, json] = note.split('\n'); + + expect(guidance).toContain('not proof of a successful connection test'); + expect(jsonParse(json)).toEqual({ + workflows: [ + { + workflowId: 'wf-1', + open: [{ kind: 'credential', credentialType: 'slackApi', nodes: ['Slack'] }], + configured: [{ kind: 'credential', credentialType: 'gmailOAuth2', nodes: ['Gmail'] }], + settledSinceLastTurn: [ + { kind: 'credential', credentialType: 'gmailOAuth2', nodes: ['Gmail'] }, + ], + }, + ], + }); + }); +}); diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-workflow.service.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-workflow.service.test.ts index 6397d4a41f6..12b0d719411 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-workflow.service.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-workflow.service.test.ts @@ -2,7 +2,9 @@ import { AI_GATEWAY_MANAGED_TAG } from '@n8n/api-types'; import type { WorkflowJSON, NodeJSON } from '@n8n/workflow-sdk'; import type { Mock } from 'vitest'; -import type { InstanceAiContext } from '../../../types'; +import type { ThreadPatch, ThreadRecord } from '../../../storage/thread-patch'; +import type { InstanceAiContext, NodeDescription } from '../../../types'; +import { observeWorkflowSetupStates } from '../setup-panel-state'; import type { SetupRequest } from '../setup-workflow.schema'; import { buildSetupRequests, @@ -882,11 +884,164 @@ describe('buildSetupRequests', () => { describe('analyzeWorkflow', () => { let context: InstanceAiContext; + const slackDescription: NodeDescription = { + name: 'n8n-nodes-base.slack', + displayName: 'Slack', + description: 'Slack node', + group: [], + version: 2, + properties: [], + inputs: ['main'], + outputs: ['main'], + credentials: [{ name: 'slackApi' }], + }; beforeEach(() => { context = createMockContext(); }); + it.each(['configuration', 'live', undefined] as const)( + 'uses the requested validation mode: %s', + async (validationMode) => { + const node = makeNode({ + credentials: { slackApi: { id: 'cred-1', name: 'Stored' } }, + parameters: { channel: { __rl: true, mode: 'list', value: 'channel-1' } }, + }); + vi.mocked(context.workflowService.getAsWorkflowJSON).mockResolvedValue( + makeWorkflowJSON([node]), + ); + vi.mocked(context.nodeService.getDescription).mockResolvedValue(slackDescription); + vi.mocked(context.credentialService.list).mockResolvedValue([ + { id: 'cred-1', name: 'Stored', type: 'slackApi' }, + ]); + context.credentialService.isTestable = vi.fn().mockResolvedValue(true); + vi.mocked(context.credentialService.test).mockResolvedValue({ + success: false, + message: 'Connection failed.', + }); + context.nodeService.findUnavailableLocatorValues = vi + .fn() + .mockResolvedValue([ + { name: 'channel', displayName: 'Channel', currentValue: 'channel-1' }, + ]); + + const [request] = await analyzeWorkflow(context, 'wf-1', undefined, { + includeSettled: true, + validationMode, + }); + + expect(context.credentialService.list).toHaveBeenCalledWith({ + type: 'slackApi', + workflowId: 'wf-1', + }); + if (validationMode === 'configuration') { + expect(context.nodeService.getDescription).toHaveBeenCalledWith( + node.type, + node.typeVersion, + { includeGatewayMetadata: false }, + ); + expect(request.needsAction).toBe(false); + expect(request.credentialTestResult).toBeUndefined(); + expect(request.parameterIssues).toBeUndefined(); + expect(context.credentialService.isTestable).not.toHaveBeenCalled(); + expect(context.credentialService.test).not.toHaveBeenCalled(); + expect(context.nodeService.findUnavailableLocatorValues).not.toHaveBeenCalled(); + } else { + expect(request.credentialTestResult).toEqual({ + success: false, + message: 'Connection failed.', + }); + expect(request.parameterIssues?.channel).toBeDefined(); + expect(context.credentialService.test).toHaveBeenCalledWith('cred-1'); + expect(context.nodeService.findUnavailableLocatorValues).toHaveBeenCalledOnce(); + } + }, + ); + + it('keeps an unbound slot open without checking Gateway availability during observation', async () => { + const node = makeNode(); + vi.mocked(context.workflowService.getAsWorkflowJSON).mockResolvedValue( + makeWorkflowJSON([node]), + ); + vi.mocked(context.nodeService.getDescription).mockResolvedValue(slackDescription); + vi.mocked(context.credentialService.list).mockResolvedValue([]); + context.credentialService.isAiGatewayCredentialType = vi.fn().mockResolvedValue(true); + + const [summary] = await observeWorkflowSetupStates(context, ['wf-1']); + + expect(summary.open).toHaveLength(1); + expect(summary.open[0]).toMatchObject({ kind: 'credential', credentialType: 'slackApi' }); + expect(summary.configured).toEqual([]); + expect(summary.validationWarnings).toEqual([]); + expect(context.credentialService.isAiGatewayCredentialType).not.toHaveBeenCalled(); + expect(context.credentialService.test).not.toHaveBeenCalled(); + expect(node.credentials).toBeUndefined(); + }); + + it('observes saved bindings and completed parameters without provider calls across turns', async () => { + let thread: ThreadRecord = { + id: 'thread-1', + resourceId: 'user-1', + createdAt: new Date(), + updatedAt: new Date(), + metadata: {}, + }; + context.threadMemory = { + getThread: async () => await Promise.resolve(thread), + patchThread: async ({ update }: { update: (current: ThreadRecord) => ThreadPatch }) => { + thread = { ...thread, ...update(thread) }; + return await Promise.resolve(thread); + }, + }; + const node = makeNode({ + parameters: { requiredValue: '', url: '<__PLACEHOLDER_VALUE__API URL__>' }, + }); + vi.mocked(context.workflowService.getAsWorkflowJSON).mockResolvedValue( + makeWorkflowJSON([node]), + ); + vi.mocked(context.nodeService.getDescription).mockResolvedValue(slackDescription); + vi.mocked(context.credentialService.list).mockResolvedValue([ + { id: 'cred-1', name: 'Stored', type: 'slackApi' }, + ]); + context.nodeService.getParameterIssues = vi + .fn>() + .mockImplementation(async (_type, _version, parameters) => { + const issues: Record = {}; + if (!parameters.requiredValue) issues.requiredValue = ['Required.']; + return await Promise.resolve(issues); + }); + context.nodeService.findUnavailableLocatorValues = vi.fn().mockResolvedValue([]); + + const [before] = await observeWorkflowSetupStates(context, ['wf-1']); + expect(before.open).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'credential', credentialType: 'slackApi' }), + expect.objectContaining({ kind: 'parameters', parameterNames: ['requiredValue', 'url'] }), + ]), + ); + expect(node.credentials).toBeUndefined(); + + node.credentials = { slackApi: { id: 'cred-1', name: 'Stored' } }; + node.parameters = { requiredValue: 'Filled', url: 'https://example.com/api' }; + const [after] = await observeWorkflowSetupStates(context, ['wf-1']); + expect(after.open).toEqual([]); + expect(after.configured).toHaveLength(1); + expect(after.settledSinceLastLook).toEqual( + expect.arrayContaining([ + { kind: 'credential', credentialType: 'slackApi', nodes: ['Slack'] }, + { kind: 'parameters', nodeName: 'Slack' }, + ]), + ); + expect(after.validationWarnings).toEqual([]); + + node.credentials = { slackApi: { id: 'cred-removed', name: 'Removed' } }; + const [removed] = await observeWorkflowSetupStates(context, ['wf-1']); + expect(removed.open).toHaveLength(1); + expect(removed.open[0]).toMatchObject({ kind: 'credential', credentialType: 'slackApi' }); + expect(context.credentialService.test).not.toHaveBeenCalled(); + expect(context.nodeService.findUnavailableLocatorValues).not.toHaveBeenCalled(); + }); + it('returns empty array for workflow with no actionable nodes', async () => { (context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue( makeWorkflowJSON([makeNode({ name: 'NoOp', type: 'n8n-nodes-base.noOp' })]), diff --git a/packages/@n8n/instance-ai/src/tools/workflows/build-workflow.tool.ts b/packages/@n8n/instance-ai/src/tools/workflows/build-workflow.tool.ts index 9d6125f4cf5..81931dd5052 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/build-workflow.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/build-workflow.tool.ts @@ -25,7 +25,8 @@ import { resolveCredentials, } from './resolve-credentials'; import { resolvedCredentialSchema } from './resolved-credential.schema'; -import { buildSetupItemsFromSetupRequests, isSetupPanelEnabled } from './setup-items'; +import { isSetupPanelEnabled } from './setup-items'; +import { recordWorkflowSetupState } from './setup-panel-state'; import { getSkippedSetupSubjects, partitionSkippedSetupRequests } from './setup-skip-state'; import { analyzeWorkflow, stripStaleCredentialsFromWorkflow } from './setup-workflow.service'; import { @@ -1189,18 +1190,8 @@ export function createBuildWorkflowTool(context: InstanceAiContext) { const setupRequests = analyzedRequests.filter((request) => !!request.needsAction); if (setupItemsEmitter) { // Every saved iteration re-announces the checklist; the emitter - // drops unchanged snapshots. Best-effort: never fail a build over it. - try { - setupItemsEmitter.emit( - saved.id, - buildSetupItemsFromSetupRequests(saved.id, analyzedRequests), - ); - } catch (error) { - context.logger.warn('Failed to emit setup-items snapshot for built workflow', { - workflowId: saved.id, - error: error instanceof Error ? error.message : String(error), - }); - } + // drops unchanged snapshots. Best-effort: never fails a build. + await recordWorkflowSetupState(context, saved.id, analyzedRequests); } // Two independent filters over the same list: `isInSetupScope` drops nodes this // build never touched, the skip partition drops cards the user declined. A node diff --git a/packages/@n8n/instance-ai/src/tools/workflows/setup-items.ts b/packages/@n8n/instance-ai/src/tools/workflows/setup-items.ts index 8544a39b982..cc4fead7310 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/setup-items.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/setup-items.ts @@ -10,6 +10,7 @@ import { type InstanceAiCredentialSetupHint, type InstanceAiSetupItem, } from '@n8n/api-types'; +import { OperationalError } from 'n8n-workflow'; import type { SetupRequest } from './setup-workflow.schema'; import type { InstanceAiEventBus } from '../../event-bus/event-bus.interface'; @@ -35,10 +36,24 @@ export function createSetupItemsEmitter(options: { threadId: string; runId: string; agentId: string; + /** + * The thread's latest persisted snapshot per workflow, oldest first. Seeds + * the dedupe cache so a run that recomputes an unchanged list publishes + * nothing, and lets `merge` build on a snapshot from an earlier run. + */ + initialSnapshots?: ReadonlyArray<{ workflowId: string; items: InstanceAiSetupItem[] }>; + /** The host drains pending events before reading this snapshot. */ + readPersistedSnapshot?: (workflowId: string) => Promise; }): SetupItemsEmitter { const { eventBus, threadId, runId, agentId } = options; const lastSnapshots = new Map(); let lastWorkflowId: string | undefined; + for (const snapshot of options.initialSnapshots ?? []) { + lastSnapshots.set(snapshot.workflowId, { + fingerprint: fingerprint(snapshot.items), + items: snapshot.items, + }); + } const publish = (workflowId: string, items: InstanceAiSetupItem[]): boolean => { const next = fingerprint(items); @@ -51,6 +66,8 @@ export function createSetupItemsEmitter(options: { }); // Cache only what was published, so a failed publish is retried by the // next identical snapshot instead of being treated as already sent. + // Re-insert so the most recently announced workflow is last. + lastSnapshots.delete(workflowId); lastSnapshots.set(workflowId, { fingerprint: next, items }); return true; }; @@ -63,6 +80,26 @@ export function createSetupItemsEmitter(options: { lastWorkflowId = workflowId; return publish(workflowId, items); }, + workflowIds: () => [...lastSnapshots.keys()], + async announce(workflowId, items) { + if (!options.readPersistedSnapshot) { + throw new OperationalError('Setup checklist persistence is unavailable'); + } + let failure: unknown = new OperationalError('Setup checklist was not persisted'); + for (let attempt = 0; attempt < 2; attempt++) { + try { + lastWorkflowId = workflowId; + publish(workflowId, items); + const persisted = await options.readPersistedSnapshot(workflowId); + if (persisted && fingerprint(persisted) === fingerprint(items)) return; + } catch (error) { + failure = error; + } + // A queued event can fail later in the durable drain. Let the next call retry it. + lastSnapshots.delete(workflowId); + } + throw failure; + }, merge(workflowId, items) { const byId = new Map(); for (const item of lastSnapshots.get(workflowId)?.items ?? []) byId.set(item.id, item); diff --git a/packages/@n8n/instance-ai/src/tools/workflows/setup-panel-state.ts b/packages/@n8n/instance-ai/src/tools/workflows/setup-panel-state.ts new file mode 100644 index 00000000000..f1b5e4962a5 --- /dev/null +++ b/packages/@n8n/instance-ai/src/tools/workflows/setup-panel-state.ts @@ -0,0 +1,282 @@ +/** + * Setup panel v2: the agent's view of a workflow's setup state. + * + * Read saved workflows to learn what changed between turns. Keep a memo of + * open items to identify changes at the next observation. Observation does + * not publish snapshots or select a build target. + */ +import type { InstanceAiSetupItem } from '@n8n/api-types'; + +import { + buildSetupItemsFromSetupRequests, + credentialSetupItemId, + isSetupPanelEnabled, + parametersSetupItemId, +} from './setup-items'; +import type { SetupRequest } from './setup-workflow.schema'; +import { getThread, patchThread } from '../../storage/thread-patch'; +import type { InstanceAiContext } from '../../types'; + +const METADATA_KEY = 'instanceAiSetupPanelOpenItems'; +/** Workflows kept in the memo; older entries fall off. */ +const MAX_REMEMBERED_WORKFLOWS = 10; +/** Bound the saved-state reads before the agent starts answering. */ +const MAX_OBSERVED_WORKFLOWS = 3; + +/** Compact, LLM-facing description of one checklist item. */ +export type SetupItemDescription = + | { kind: 'credential'; credentialType: string; nodes?: string[] } + | { kind: 'parameters'; nodeName: string; parameterNames?: string[] }; + +export interface WorkflowSetupStateSummary { + workflowId: string; + /** The full checklist derived from the current analysis. */ + items: InstanceAiSetupItem[]; + /** Items the user still has to act on. */ + open: InstanceAiSetupItem[]; + /** Items already satisfied: slots bound to a stored credential. */ + configured: InstanceAiSetupItem[]; + /** Items open at the agent's previous look that are no longer open. */ + settledSinceLastLook: SetupItemDescription[]; + /** A stored binding does not guarantee that its connection test passed. */ + validationWarnings: Array<{ nodeName: string; credentialType?: string; message?: string }>; +} + +/** Ids of the items the requests report as still needing the user. */ +export function openSetupItemIds( + workflowId: string, + requests: readonly SetupRequest[], +): Set { + const ids = new Set(); + for (const request of requests) { + if (request.credentialType !== undefined && request.credentialNeedsAction === true) { + ids.add(credentialSetupItemId(workflowId, request.credentialType, request.node.name)); + } + if (Object.keys(request.parameterIssues ?? {}).length > 0) { + ids.add(parametersSetupItemId(workflowId, request.node.name)); + } + } + return ids; +} + +export function describeSetupItem(item: InstanceAiSetupItem): SetupItemDescription { + if (item.kind === 'credential') { + const nodes = item.nodeBindings?.map((binding) => binding.nodeName) ?? []; + return { + kind: 'credential', + credentialType: item.credentialType, + ...(nodes.length > 0 ? { nodes } : {}), + }; + } + return { kind: 'parameters', nodeName: item.nodeName, parameterNames: item.parameterNames }; +} + +/** + * Recover a description from an id alone, for an item that left the snapshot + * (a parameter issue resolved, a node removed). Mirrors `credentialSetupItemId` + * and `parametersSetupItemId`. + */ +function describeSetupItemId(workflowId: string, id: string): SetupItemDescription | undefined { + const credentialPrefix = `${workflowId}:credential:`; + if (id.startsWith(credentialPrefix)) { + const key = id.slice(credentialPrefix.length); + const separator = key.indexOf(':'); + const credentialType = separator === -1 ? key : key.slice(0, separator); + const nodeName = separator === -1 ? undefined : key.slice(separator + 1); + if (!credentialType) return undefined; + return { kind: 'credential', credentialType, ...(nodeName ? { nodes: [nodeName] } : {}) }; + } + const parametersPrefix = `${workflowId}:parameters:`; + if (id.startsWith(parametersPrefix)) { + const nodeName = id.slice(parametersPrefix.length); + return nodeName ? { kind: 'parameters', nodeName } : undefined; + } + return undefined; +} + +/** Pure: snapshot plus open/configured split, diffed against the previous look. */ +export function summarizeWorkflowSetupState( + workflowId: string, + requests: readonly SetupRequest[], + previouslyOpenIds: ReadonlySet = new Set(), +): WorkflowSetupStateSummary { + const items = buildSetupItemsFromSetupRequests(workflowId, requests); + const openIds = openSetupItemIds(workflowId, requests); + const open = items.filter((item) => openIds.has(item.id)); + const configured = items.filter((item) => !openIds.has(item.id)); + const byId = new Map(items.map((item) => [item.id, item])); + const settledSinceLastLook = [...previouslyOpenIds] + .filter((id) => !openIds.has(id)) + .map((id) => { + const item = byId.get(id); + return item ? describeSetupItem(item) : describeSetupItemId(workflowId, id); + }) + .filter((description): description is SetupItemDescription => description !== undefined); + const validationWarnings = requests.flatMap((request) => + request.credentialTestResult?.success === false + ? [ + { + nodeName: request.node.name, + credentialType: request.credentialType, + message: request.credentialTestResult.message, + }, + ] + : [], + ); + return { workflowId, items, open, configured, settledSinceLastLook, validationWarnings }; +} + +type OpenItemsMemo = Record; + +function parseMemo(value: unknown): OpenItemsMemo { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return {}; + const memo: OpenItemsMemo = {}; + for (const [workflowId, ids] of Object.entries(value)) { + if (Array.isArray(ids) && ids.every((id) => typeof id === 'string')) memo[workflowId] = ids; + } + return memo; +} + +async function readOpenItemsMemo(context: InstanceAiContext): Promise { + if (!context.threadMemory || !context.threadId) return {}; + try { + const thread = await getThread(context.threadMemory, context.threadId); + return parseMemo(thread?.metadata?.[METADATA_KEY]); + } catch (error) { + context.logger?.warn('Failed to read the setup panel memo from thread metadata', { + error: error instanceof Error ? error.message : String(error), + }); + return {}; + } +} + +async function rememberOpenItems( + context: InstanceAiContext, + workflowId: string, + openIds: readonly string[], +): Promise { + if (!context.threadMemory || !context.threadId) return; + try { + await patchThread(context.threadMemory, { + threadId: context.threadId, + update: ({ metadata = {} }) => { + const memo = parseMemo(metadata[METADATA_KEY]); + // Re-insert so the most recently seen workflow is last and survives the cap. + delete memo[workflowId]; + memo[workflowId] = [...openIds]; + const entries = Object.entries(memo).slice(-MAX_REMEMBERED_WORKFLOWS); + return { metadata: { ...metadata, [METADATA_KEY]: Object.fromEntries(entries) } }; + }, + }); + } catch (error) { + context.logger?.warn('Failed to persist the setup panel memo to thread metadata', { + workflowId, + error: error instanceof Error ? error.message : String(error), + }); + } +} + +/** Do not turn a temporary request for replacement into a completion fact. */ +export async function rememberWorkflowSetupState( + context: InstanceAiContext, + workflowId: string, + requests: readonly SetupRequest[], +): Promise { + const savedRequests = requests.map((request) => + request.preferNewCredential ? { ...request, credentialNeedsAction: false } : request, + ); + await rememberOpenItems(context, workflowId, [...openSetupItemIds(workflowId, savedRequests)]); +} + +/** + * One "look" at a workflow the caller already analyzed with `includeSettled`: + * announce the snapshot, diff against the previous look, remember this one. + * A build must not fail because its early checklist could not be published. + * The final setup handoff confirms persistence separately. + */ +export async function recordWorkflowSetupState( + context: InstanceAiContext, + workflowId: string, + requests: readonly SetupRequest[], +): Promise { + const memo = await readOpenItemsMemo(context); + const summary = summarizeWorkflowSetupState( + workflowId, + requests, + new Set(memo[workflowId] ?? []), + ); + if (isSetupPanelEnabled(context)) { + try { + context.setupItemsEmitter.emit(workflowId, summary.items); + } catch (error) { + context.logger?.warn('Failed to emit setup-items snapshot for built workflow', { + workflowId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + await rememberWorkflowSetupState(context, workflowId, requests); + return summary; +} + +/** + * Ground truth at run start: re-analyze the workflows this thread announced and + * record a look at each. A workflow that no longer analyzes (deleted, no access) + * is skipped. Most recently announced first, capped to bound the cost. + */ +export async function observeWorkflowSetupStates( + context: InstanceAiContext, + workflowIds: readonly string[], +): Promise { + const { analyzeWorkflow } = await import('./setup-workflow.service.js'); + const memo = await readOpenItemsMemo(context); + const summaries: WorkflowSetupStateSummary[] = []; + for (const workflowId of workflowIds.slice(0, MAX_OBSERVED_WORKFLOWS)) { + try { + const requests = await analyzeWorkflow(context, workflowId, undefined, { + includeSettled: true, + validationMode: 'configuration', + }); + summaries.push( + summarizeWorkflowSetupState(workflowId, requests, new Set(memo[workflowId] ?? [])), + ); + await rememberWorkflowSetupState(context, workflowId, requests); + } catch (error) { + context.logger?.debug?.('Skipping setup state for a workflow that did not analyze', { + workflowId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + return summaries; +} + +/** + * The per-turn note the host wraps in ``. Empty when + * there is nothing to say (no announced workflow analyzed). + */ +export function formatWorkflowSetupStateNote( + summaries: readonly WorkflowSetupStateSummary[], +): string { + if (summaries.length === 0) return ''; + const workflows = summaries.map((summary) => ({ + workflowId: summary.workflowId, + open: summary.open.map(describeSetupItem), + configured: summary.configured.map(describeSetupItem), + settledSinceLastTurn: summary.settledSinceLastLook, + ...(summary.validationWarnings.length > 0 + ? { validationWarnings: summary.validationWarnings } + : {}), + })); + return [ + 'Setup state of the workflows this conversation built, recomputed just now from the saved ' + + 'workflows and the credentials in this project. The setup panel next to the chat shows the ' + + 'current checklist and the user completes items there. This observation does not test connections ' + + 'or resource availability. A configured item is not proof of a successful connection test or ' + + 'workflow execution. Never ask ' + + 'for secrets in chat. `settledSinceLastTurn` names items that were open at your previous ' + + 'look and are not anymore (configured, or removed from the workflow). Trust this over older ' + + 'tool results.', + JSON.stringify({ workflows }), + ].join('\n'); +} diff --git a/packages/@n8n/instance-ai/src/tools/workflows/setup-workflow.service.ts b/packages/@n8n/instance-ai/src/tools/workflows/setup-workflow.service.ts index a6d756eef43..2a19fecb650 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/setup-workflow.service.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/setup-workflow.service.ts @@ -40,6 +40,8 @@ import type { SetupRequest } from './setup-workflow.schema'; import { refreshWorkflowSourceFileBindingFromSave } from './workflow-file-bindings'; import type { InstanceAiContext } from '../../types'; +type SetupValidationMode = 'live' | 'configuration'; + // ── Credential cache ──────────────────────────────────────────────────────── /** Cache for deduplicating credential fetches across nodes with the same types. */ @@ -443,6 +445,7 @@ async function resolveCredentialState( cache: CredentialCache | undefined, workflowId: string | undefined, prefersNewCredential = false, + validationMode: SetupValidationMode = 'live', ): Promise { // Use cache to avoid duplicate fetches for the same credential type across nodes. // Scope to the workflow so we list only credentials the save path will accept — @@ -474,6 +477,14 @@ async function resolveCredentialState( typeof existingOnNode?.id === 'string' && existingOnNode.id ? existingOnNode.id : undefined; const hasExistingOnNode = existingCredentialId !== undefined || isAiGatewayManagedCredential(existingOnNode); + if (validationMode === 'configuration') { + const effectiveCredential = existingCredentials.find((c) => c.id === existingCredentialId); + return { + existingCredentials, + isAutoApplied: false, + ...(effectiveCredential ? { effectiveCredential } : {}), + }; + } let isAutoApplied = false; let autoAppliedGateway: true | undefined; @@ -600,6 +611,7 @@ async function resolveAppliedCredentialState( workflowId: string | undefined, nodeCredentials: Record | undefined, prefersNewCredential = false, + validationMode: SetupValidationMode = 'live', ): Promise { if (!credentialType) { return { existingCredentials: [], isAutoApplied: false }; @@ -611,6 +623,7 @@ async function resolveAppliedCredentialState( cache, workflowId, prefersNewCredential, + validationMode, ); if (state.isAutoApplied && nodeCredentials) { if (state.autoAppliedGateway) { @@ -629,6 +642,7 @@ async function resolveAppliedCredentialState( } interface NodeSetupContext { + validationMode: SetupValidationMode; nodeName: string; isTrigger: boolean; isTestable: boolean; @@ -687,6 +701,7 @@ async function buildRequestForCredentialType( workflowId, nodeCredentials, prefersNewCredential, + nodeCtx.validationMode, ); // The connected credential can rule out a parameter value chosen before it existed @@ -701,7 +716,7 @@ async function buildRequestForCredentialType( ? { id: AI_GATEWAY_MANAGED_TAG, name: N8N_CONNECT_DISPLAY_NAME } : undefined); const unavailableIssues = - credentialType && locatorCredential + nodeCtx.validationMode === 'live' && credentialType && locatorCredential ? await computeUnavailableLocatorIssues( context, node, @@ -794,6 +809,7 @@ export async function buildSetupRequests( workflowId?: string, preferNewCredentialTypes?: ReadonlySet, appliedCredentialIds?: ReadonlySet, + validationMode: SetupValidationMode = 'live', ): Promise { if (!node.name) return []; if (node.disabled) return []; @@ -801,9 +817,10 @@ export async function buildSetupRequests( const typeVersion = node.typeVersion ?? 1; const parameters = (node.parameters as Record) ?? {}; - const nodeDesc = await context.nodeService - .getDescription(node.type, typeVersion) - .catch(() => undefined); + const nodeDesc = await (validationMode === 'configuration' + ? context.nodeService.getDescription(node.type, typeVersion, { includeGatewayMetadata: false }) + : context.nodeService.getDescription(node.type, typeVersion) + ).catch(() => undefined); const isTrigger = nodeDesc?.group?.includes('trigger') ?? false; const isTestable = @@ -828,6 +845,7 @@ export async function buildSetupRequests( const requests: SetupRequest[] = []; const processedCredTypes = credentialTypes.length > 0 ? credentialTypes : [undefined]; const nodeCtx: NodeSetupContext = { + validationMode, nodeName: node.name, isTrigger, isTestable, @@ -1453,6 +1471,8 @@ export async function analyzeWorkflow( workflowId: string, triggerResults?: Record, options?: { + /** Passive observation reads saved configuration without provider calls. Defaults to live validation. */ + validationMode?: SetupValidationMode; /** Keep settled requests (needsAction=false) in the result. For reporting * consumers only (e.g. the apply path surfacing a just-applied credential * whose test failed) — never for card rendering, where settled slots must @@ -1488,6 +1508,7 @@ export async function analyzeWorkflow( workflowId, preferNewCredentialTypes, appliedCredentialIds, + options?.validationMode, ); }), ); diff --git a/packages/@n8n/instance-ai/src/types.ts b/packages/@n8n/instance-ai/src/types.ts index 981dbce58ed..c8bca47a668 100644 --- a/packages/@n8n/instance-ai/src/types.ts +++ b/packages/@n8n/instance-ai/src/types.ts @@ -752,7 +752,11 @@ export interface UnavailableLocatorValue { export interface InstanceAiNodeService { listAvailable(options?: { query?: string; gatewayCreditsOnly?: boolean }): Promise; - getDescription(nodeType: string, version?: number): Promise; + getDescription( + nodeType: string, + version?: number, + options?: { includeGatewayMetadata?: boolean }, + ): Promise; /** Return all node types with the richer fields needed by NodeSearchEngine. */ listSearchable(): Promise; /** Return the TypeScript type definition for a node, resolved by the host n8n instance. */ @@ -1431,6 +1435,13 @@ export interface InstanceAiContext { * paths stay in effect. */ setupItemsEmitter?: SetupItemsEmitter; + /** + * Setup panel v2: the setup tool announced a workflow's final checklist + * instead of suspending, so the host must treat that build's setup as + * handled and not route a `` follow-up for it. + * Wired by the host only while the setup panel flag is on. + */ + markWorkflowSetupHandled?: (workflowId: string) => Promise; /** * IDs of workflows the agent created during the **current run**. Populated by * build-workflow on every successful create (via `recordSessionOwnedWorkflow`). @@ -1503,6 +1514,8 @@ export interface InstanceAiContext { export interface SetupItemsEmitter { /** Replace the workflow's snapshot. Returns false when nothing changed (no event published). */ emit(workflowId: string, items: InstanceAiSetupItem[]): boolean; + /** Publish the final checklist and confirm persistence before setup routing ends. */ + announce(workflowId: string, items: InstanceAiSetupItem[]): Promise; /** * Upsert items (by id) into the workflow's last snapshot and publish the * merged list. For emitters that know only part of the checklist, e.g. a @@ -1514,6 +1527,8 @@ export interface SetupItemsEmitter { * artifact — the workflow the panel follows. Undefined before the first save. */ lastWorkflowId(): string | undefined; + /** Workflows with a known snapshot, most recently announced last. */ + workflowIds(): string[]; } // ── Task storage ───────────────────────────────────────────────────────────── @@ -1897,6 +1912,8 @@ export interface OrchestrationContext { messageGroupId?: string; userId: string; projectId?: string; + /** Setup panel v2 flag, mirrored from the domain context's `setupItemsEmitter` presence. */ + setupPanelEnabled?: boolean; orchestratorAgentId: string; modelId: ModelConfig; eventBus: InstanceAiEventBus; diff --git a/packages/@n8n/instance-ai/src/workflow-loop/__tests__/guidance.test.ts b/packages/@n8n/instance-ai/src/workflow-loop/__tests__/guidance.test.ts index c6c02c5228f..8f463fdb2b6 100644 --- a/packages/@n8n/instance-ai/src/workflow-loop/__tests__/guidance.test.ts +++ b/packages/@n8n/instance-ai/src/workflow-loop/__tests__/guidance.test.ts @@ -358,3 +358,29 @@ describe('formatWorkflowLoopGuidance', () => { }); }); }); + +describe('formatWorkflowLoopGuidance — setup panel', () => { + const action: WorkflowLoopAction = { + type: 'done', + summary: 'Built', + workflowId: 'wf-123', + mockedCredentialTypes: ['slackApi'], + }; + + it('still routes through workflows setup, but as an announcement that ends the turn', () => { + const result = formatWorkflowLoopGuidance(action, { setupPanelEnabled: true }); + + expect(result).toContain('workflows(action="setup")'); + expect(result).toContain('wf-123'); + expect(result).toContain('setup panel next to the chat'); + expect(result).toContain('end your turn'); + expect(result).toContain('When the result has `announced: true`'); + expect(result).toContain('Otherwise follow the returned guidance'); + expect(result).not.toContain('No card opens'); + expect(result).not.toContain('inline setup card'); + }); + + it('keeps the card wording while the panel is off', () => { + expect(formatWorkflowLoopGuidance(action, {})).toContain('inline setup card'); + }); +}); diff --git a/packages/@n8n/instance-ai/src/workflow-loop/guidance.ts b/packages/@n8n/instance-ai/src/workflow-loop/guidance.ts index 8a7950994e6..91cbf42d0fd 100644 --- a/packages/@n8n/instance-ai/src/workflow-loop/guidance.ts +++ b/packages/@n8n/instance-ai/src/workflow-loop/guidance.ts @@ -2,6 +2,8 @@ import type { WorkflowLoopAction } from './workflow-loop-state'; export interface WorkflowLoopGuidanceOptions { workItemId?: string; + /** Setup panel v2: `workflows(action="setup")` announces instead of opening a card. */ + setupPanelEnabled?: boolean; } function formatSourceFileInstruction(sourceFilePath: string | undefined): string { @@ -31,6 +33,16 @@ export function formatWorkflowLoopGuidance( ); } if (action.mockedCredentialTypes?.length || action.hasUnresolvedPlaceholders) { + if (options.setupPanelEnabled) { + return ( + 'Workflow verified successfully with temporary mock data. ' + + `Call \`workflows(action="setup")\` with workflowId "${action.workflowId ?? 'unknown'}" once: ` + + 'it lists the remaining credentials and values in the setup panel next to the chat and returns them to you. ' + + 'When the result has `announced: true`, summarize it, report any validation warnings, and end your turn. ' + + 'Otherwise follow the returned guidance for validation errors, approvals, skipped items, or an existing setup card. ' + + 'Do not call `credentials(action="setup")` or `apply-workflow-credentials`, and do not tell the user to open the editor or canvas.' + ); + } return ( 'Workflow verified successfully with temporary mock data. ' + `Call \`workflows(action="setup")\` with workflowId "${action.workflowId ?? 'unknown'}" ` + diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts index 105bd5d2d7e..fce08399b8e 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts @@ -5054,6 +5054,26 @@ describe('createNodeAdapter — n8n Connect annotations', () => { }); }); + it('reads a node description without fetching Gateway metadata when requested', async () => { + const { makeContext, getGatewayConfig } = createNodeServiceWithGateway([openAiNode], { + nodes: ['openAi'], + credentialTypes: ['openAiApi'], + providerConfig: {}, + }); + + const nodeService = makeContext().nodeService; + // Exclude the adapter's initial Gateway availability check. + getGatewayConfig.mockClear(); + const description = await nodeService.getDescription('openAi', undefined, { + includeGatewayMetadata: false, + }); + + expect(description.name).toBe('openAi'); + expect(description.properties).toEqual(openAiNode.properties); + expect(description.aiGateway).toBeUndefined(); + expect(getGatewayConfig).not.toHaveBeenCalled(); + }); + it('preserves the __operation_only__ marker for nodes without a resource dimension', async () => { const pdfCoNode = { name: 'pdfCo', diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts index 08f3584f276..44fb6c45094 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts @@ -20,6 +20,15 @@ vi.mock('@n8n/instance-ai', async () => { // passing test with silently empty metadata. threadProvenanceMetadata: vi.fn(() => ({ thread_source: 'evals' })), orchestratorAgentId: (runId: string) => `orchestrator-${runId}`, + isSetupPanelEnabled: (context: { setupItemsEmitter?: unknown }) => + context.setupItemsEmitter !== undefined, + createSetupItemsEmitter: vi.fn(() => ({ + emit: vi.fn(), + announce: vi.fn(), + merge: vi.fn(), + workflowIds: vi.fn(), + lastWorkflowId: vi.fn(), + })), isQuotaExhaustedError: (error: unknown) => typeof error === 'object' && error !== null && @@ -213,6 +222,7 @@ import { createLazyRuntimeWorkspace, createLazyWorkspaceRuntimeSkillSource, createOrchestratorRunControl, + createSetupItemsEmitter, createSandbox, createWorkspace, createInstanceAiTraceContext, @@ -631,7 +641,8 @@ describe('InstanceAiService — runtime workspace setup', () => { })); }); - it('defers sandbox creation and setup until the lazy workspace is used', async () => { + const snapshotModes = ['off', 'seeded', 'read failure']; + it.each(snapshotModes)('starts with snapshots %s', async (snapshotMode) => { const service = Object.create(InstanceAiService.prototype) as unknown as { createExecutionEnvironment: ( user: User, @@ -640,6 +651,7 @@ describe('InstanceAiService — runtime workspace setup', () => { abortSignal: AbortSignal, ) => Promise<{ orchestrationContext: { + setupPanelEnabled?: boolean; workspace?: unknown; runtimeSkills?: { registry: { skillsHash: string; skills: Array<{ id: string }> }; @@ -675,6 +687,7 @@ describe('InstanceAiService — runtime workspace setup', () => { instanceAiConfig: Record; defaultTimeZone: string; eventBus: unknown; + eventLog: { getSetupItemsSnapshots: Mock }; logger: { warn: Mock }; telemetry: { track: Mock }; oauth2CallbackUrl: string; @@ -703,7 +716,7 @@ describe('InstanceAiService — runtime workspace setup', () => { })), isLocalGatewayDisabledForUser: vi.fn(async () => false), getPermissions: vi.fn(() => ({})), - isInstanceAiSetupPanelEnabled: vi.fn(() => false), + isInstanceAiSetupPanelEnabled: vi.fn(() => snapshotMode !== 'off'), }; service.gatewayService = { findGateway: vi.fn(() => undefined), applyToolPolicy: vi.fn() }; service.aiService = { isProxyEnabled: vi.fn(() => false) }; @@ -730,6 +743,11 @@ describe('InstanceAiService — runtime workspace setup', () => { service.instanceAiConfig = {}; service.defaultTimeZone = 'UTC'; service.eventBus = {}; + const initialSnapshots = [{ workflowId: 'wf-old', items: [] }]; + service.eventLog = { getSetupItemsSnapshots: vi.fn().mockResolvedValue(initialSnapshots) }; + if (snapshotMode === 'read failure') { + service.eventLog.getSetupItemsSnapshots.mockRejectedValue(new Error('storage unavailable')); + } service.logger = { warn: vi.fn() }; service.telemetry = { track: vi.fn() }; service.oauth2CallbackUrl = 'http://localhost/rest/oauth2-credential/callback'; @@ -782,6 +800,17 @@ describe('InstanceAiService — runtime workspace setup', () => { 'run-1', new AbortController().signal, ); + expect(environment.orchestrationContext.setupPanelEnabled).toBe(snapshotMode !== 'off'); + if (snapshotMode === 'off') { + expect(service.eventLog.getSetupItemsSnapshots).not.toHaveBeenCalled(); + expect(createSetupItemsEmitter).not.toHaveBeenCalled(); + } else { + expect(createSetupItemsEmitter).toHaveBeenCalledWith( + expect.objectContaining({ + initialSnapshots: snapshotMode === 'seeded' ? initialSnapshots : [], + }), + ); + } expect(createLazyRuntimeWorkspace).toHaveBeenCalledTimes(2); expect(createLazyRuntimeWorkspace).toHaveBeenNthCalledWith( @@ -838,7 +867,7 @@ describe('InstanceAiService — runtime workspace setup', () => { error: 'claim failed', }); expect(service.instanceAiErrorReporter.report.mock.invocationCallOrder[0]).toBeLessThan( - service.logger.warn.mock.invocationCallOrder[0], + service.logger.warn.mock.invocationCallOrder.at(-1)!, ); expect(createSandbox).not.toHaveBeenCalled(); @@ -4687,6 +4716,7 @@ describe('InstanceAiService — deterministic workflow setup follow-up', () => { threadId: string, workflowId: string, runId?: string, + options?: { requirePersisted?: boolean }, ) => Promise; maybeStartWorkflowSetupFollowUp: (user: User, threadId: string) => Promise; }; @@ -4935,6 +4965,42 @@ describe('InstanceAiService — deterministic workflow setup follow-up', () => { ); }); + it.each(['rejected', 'not saved'])( + 'releases a %s routing marker so the handoff can retry', + async (failure) => { + const records = { 'wi-1': makeRecord() }; + const service = createSetupFollowUpService(records); + if (failure === 'rejected') { + service.markWorkItemSetupRouted.mockRejectedValueOnce(new Error('storage unavailable')); + } else { + service.markWorkItemSetupRouted.mockResolvedValueOnce(false); + } + + await expect( + service.markWorkflowSetupHandled('thread-a', 'wf-1', 'run-1', { requirePersisted: true }), + ).rejects.toThrow(); + expect(records['wi-1'].state.setupRoutingClaimId).toBeUndefined(); + await expect(service.markWorkflowSetupHandled('thread-a', 'wf-1', 'run-1')).resolves.toBe( + true, + ); + await expect(service.maybeStartWorkflowSetupFollowUp(fakeUser, 'thread-a')).resolves.toBe( + false, + ); + expect(service.startInternalFollowUpRun).not.toHaveBeenCalled(); + }, + ); + + it('keeps the legacy card result when its routing marker is not saved', async () => { + const records = { 'wi-1': makeRecord() }; + const service = createSetupFollowUpService(records); + service.markWorkItemSetupRouted.mockResolvedValueOnce(false); + + await expect(service.markWorkflowSetupHandled('thread-a', 'wf-1', 'run-1')).resolves.toBe( + false, + ); + expect(records['wi-1'].state.setupRoutingClaimId).toBeUndefined(); + }); + it('keeps setup for other workflows routable after one workflow setup completes', async () => { const records = { 'wi-1': makeRecord(), diff --git a/packages/cli/src/modules/instance-ai/__tests__/internal-messages.test.ts b/packages/cli/src/modules/instance-ai/__tests__/internal-messages.test.ts index e3e253b3492..5c5c2d17fd8 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/internal-messages.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/internal-messages.test.ts @@ -94,6 +94,12 @@ describe('cleanStoredUserMessage', () => { expect(cleanStoredUserMessage(stored)).toBe('User reply'); }); + it('strips block', () => { + const stored = + '\nSetup state.\n{"workflows":[]}\n\n\nUser reply'; + expect(cleanStoredUserMessage(stored)).toBe('User reply'); + }); + it('strips an block followed by user text', () => { const stored = `${instanceContextMarker()}\n\nCarry on where I left off`; expect(cleanStoredUserMessage(stored)).toBe('Carry on where I left off'); diff --git a/packages/cli/src/modules/instance-ai/event-bus/__tests__/durable-event-log.test.ts b/packages/cli/src/modules/instance-ai/event-bus/__tests__/durable-event-log.test.ts index 20366702139..932a0fb8d1a 100644 --- a/packages/cli/src/modules/instance-ai/event-bus/__tests__/durable-event-log.test.ts +++ b/packages/cli/src/modules/instance-ai/event-bus/__tests__/durable-event-log.test.ts @@ -1,13 +1,13 @@ +import type { InstanceAiEvent, InstanceAiSetupItem } from '@n8n/api-types'; import type { Logger } from '@n8n/backend-common'; -import type { InstanceAiEvent } from '@n8n/api-types'; import { QueryFailedError } from '@n8n/typeorm'; import { mock } from 'vitest-mock-extended'; import type { EventService } from '@/events/event.service'; +import type { InstanceAiEventLogRepository } from '../../repositories/instance-ai-event-log.repository'; import { DurableEventLog, type DrainedEvent } from '../durable-event-log'; import { DurableLogMetrics } from '../durable-log-metrics'; -import type { InstanceAiEventLogRepository } from '../../repositories/instance-ai-event-log.repository'; const THREAD = 'thread-1'; const RUN = 'run-1'; @@ -116,6 +116,20 @@ class FakeRepo { const set = new Set(runIds); return this.rows.filter((r) => set.has(r.event.runId)).map((r) => r.event); } + + async getSetupItemsSnapshots(_threadId: string) { + const latest = new Map(); + for (const { event } of [...this.rows].sort((a, b) => a.seq - b.seq)) { + if (event.type !== 'setup-items') continue; + const { workflowId, items } = event.payload; + latest.delete(workflowId); + latest.set( + workflowId, + items.filter((item): item is InstanceAiSetupItem => item !== null), + ); + } + return [...latest].map(([workflowId, items]) => ({ workflowId, items })); + } } function buildLog(repo: FakeRepo) { @@ -178,6 +192,61 @@ describe('DurableEventLog', () => { vi.useRealTimers(); }); + it('waits for an in-flight append before reading setup snapshots', async () => { + const repo = new FakeRepo(); + const { log } = buildLog(repo); + await publishAll(log, [ + { + type: 'setup-items', + runId: RUN, + agentId: AGENT, + payload: { + workflowId: 'wf-1', + items: [ + { id: 'wf-1:credential:slackApi', kind: 'credential', credentialType: 'slackApi' }, + ], + }, + }, + { + type: 'setup-items', + runId: RUN, + agentId: AGENT, + payload: { workflowId: 'wf-2', items: [] }, + }, + ]); + let releaseAppend!: () => void; + repo.gateNextAppend = new Promise((resolve) => { + releaseAppend = resolve; + }); + let enteredAppend!: () => void; + const entered = new Promise((resolve) => { + enteredAppend = resolve; + }); + repo.onGatedAppend = enteredAppend; + log.publish( + THREAD, + { + type: 'setup-items', + runId: RUN, + agentId: AGENT, + payload: { workflowId: 'wf-1', items: [] }, + }, + vi.fn(), + ); + await entered; + const read = vi.spyOn(repo, 'getSetupItemsSnapshots'); + + const snapshots = log.getSetupItemsSnapshots(THREAD); + await Promise.resolve(); + expect(read).not.toHaveBeenCalled(); + releaseAppend(); + + await expect(snapshots).resolves.toEqual([ + { workflowId: 'wf-2', items: [] }, + { workflowId: 'wf-1', items: [] }, + ]); + }); + it('coalesces a segment into one text-block flushed immediately before the next structural fact', async () => { const repo = new FakeRepo(); const { log } = buildLog(repo); diff --git a/packages/cli/src/modules/instance-ai/event-bus/durable-event-log.ts b/packages/cli/src/modules/instance-ai/event-bus/durable-event-log.ts index 48b50a813cb..f8e6310431f 100644 --- a/packages/cli/src/modules/instance-ai/event-bus/durable-event-log.ts +++ b/packages/cli/src/modules/instance-ai/event-bus/durable-event-log.ts @@ -249,6 +249,12 @@ export class DurableEventLog { return await this.repo.getForRuns(threadId, runIds); } + /** Latest persisted setup panel snapshot per workflow (see repository). */ + async getSetupItemsSnapshots(threadId: string) { + await this.flush(threadId); + return await this.repo.getSetupItemsSnapshots(threadId); + } + /** * The thread's still-open streamed segments, read from the coalesce buffers. * SYNCHRONOUS on purpose: the SSE bootstrap serves these in its synchronous diff --git a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts index 65a2c26564d..d2672797f46 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts @@ -3266,8 +3266,11 @@ export class InstanceAiAdapterService { }); }, - async getDescription(nodeType: string, version?: number) { - const [nodes, gatewayConfig] = await Promise.all([getNodes(), getGatewayConfig()]); + async getDescription(nodeType, version, options) { + const [nodes, gatewayConfig] = await Promise.all([ + getNodes(), + options?.includeGatewayMetadata === false ? Promise.resolve(null) : getGatewayConfig(), + ]); let desc = version !== undefined ? nodes.find((n) => { diff --git a/packages/cli/src/modules/instance-ai/instance-ai.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.service.ts index 50ec6293283..4b16d9e9d7a 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.service.ts @@ -74,6 +74,9 @@ import { createOrchestratorRunControl, createOrchestratorRunControlForState, createSetupItemsEmitter, + formatWorkflowSetupStateNote, + isSetupPanelEnabled, + observeWorkflowSetupStates, orchestratorAgentId, resolveAgentPreviewSession, saveAgentBuilderTarget, @@ -174,6 +177,8 @@ import { withPastConversations, withProjectContext, getProjectContextSection, + WORKFLOW_SETUP_STATE_OPEN_TAG, + WORKFLOW_SETUP_STATE_CLOSE_TAG, } from './internal-messages'; import { INSTANCE_AI_RUN_TIMEOUT_REASON, InstanceAiLivenessService } from './liveness'; import { InstanceAiMcpRegistryService } from './mcp'; @@ -2438,14 +2443,31 @@ export class InstanceAiService { context.runId = runId; // Setup panel v2: wire the durable `setup-items` sink only while the flag - // is on — its presence is the package-side gate. + // is on — its presence is the package-side gate. Seeded with the thread's + // persisted snapshots so a recomputed, unchanged list publishes nothing. if (this.settingsService.isInstanceAiSetupPanelEnabled()) { context.setupItemsEmitter = createSetupItemsEmitter({ eventBus: this.eventBus, threadId, runId, agentId: orchestratorAgentId(runId), + initialSnapshots: await this.eventLog.getSetupItemsSnapshots(threadId).catch((error) => { + this.logger.warn('Failed to read setup panel snapshots', { + threadId, + error: getErrorMessage(error), + }); + return []; + }), + readPersistedSnapshot: async (workflowId) => { + const snapshots = await this.eventLog.getSetupItemsSnapshots(threadId); + return snapshots.find((snapshot) => snapshot.workflowId === workflowId)?.items; + }, }); + context.markWorkflowSetupHandled = async (workflowId) => { + await this.markWorkflowSetupHandled(threadId, workflowId, runId, { + requirePersisted: true, + }); + }; } context.browserCredentialSetup = this.createBrowserCredentialSetupTracker(runId, user.id); @@ -2625,6 +2647,7 @@ export class InstanceAiService { messageGroupId, userId: user.id, projectId: boundProjectId, + setupPanelEnabled: isSetupPanelEnabled(context), orchestratorAgentId: orchestratorAgentId(runId), modelId, eventBus: this.eventBus, @@ -2924,6 +2947,34 @@ export class InstanceAiService { await this.schedulePlannedTasks(user, task.threadId); } + /** + * Setup panel v2 ground truth at run start: re-analyze the workflows this + * thread announced and tell the agent what is open and what the user + * completed since its previous look. Empty while the flag is off or the + * thread announced nothing. Best-effort: a failure never blocks the turn. + */ + private async buildWorkflowSetupStateBlock(context: InstanceAiContext): Promise { + const emitter = context.setupItemsEmitter; + if (!emitter) return ''; + // Most recently announced first — that is the workflow the turn is about. + const workflowIds = emitter.workflowIds().reverse(); + if (workflowIds.length === 0) return ''; + try { + const note = formatWorkflowSetupStateNote( + await observeWorkflowSetupStates(context, workflowIds), + ); + return note + ? `${WORKFLOW_SETUP_STATE_OPEN_TAG}\n${note}\n${WORKFLOW_SETUP_STATE_CLOSE_TAG}` + : ''; + } catch (error) { + this.logger.warn('Failed to build the workflow setup state block', { + threadId: context.threadId, + error: error instanceof Error ? error.message : String(error), + }); + return ''; + } + } + private buildWorkflowSetupFollowUpMessage(obligation: WorkflowVerificationObligation): string { const payload = { workflowId: obligation.workflowId, @@ -2947,6 +2998,7 @@ export class InstanceAiService { threadId: string, workflowId: string, runId?: string, + options: { requirePersisted?: boolean } = {}, ): Promise { const records = await this.listWorkflowLoopRecords(threadId); if (records.length === 0) return false; @@ -2982,17 +3034,30 @@ export class InstanceAiService { const claim = await this.claimWorkItemSetupRouting(threadId, record); if (!claim) continue; - const marked = await this.markWorkItemSetupRouted( - threadId, - record.state.workItemId, - claim.claimId, - ); + let marked: boolean; + try { + marked = await this.markWorkItemSetupRouted( + threadId, + record.state.workItemId, + claim.claimId, + ); + } catch (error) { + await this.releaseWorkItemSetupRoutingClaim( + threadId, + record.state.workItemId, + claim.claimId, + ); + throw error; + } if (!marked) { await this.releaseWorkItemSetupRoutingClaim( threadId, record.state.workItemId, claim.claimId, ); + if (options.requirePersisted) { + throw new OperationalError('Workflow setup routing marker was not saved'); + } this.logger.warn('Workflow setup completed but routing marker was not saved', { threadId, workItemId: record.state.workItemId, @@ -3828,6 +3893,11 @@ export class InstanceAiService { handoffContextBlock = buildHandoffContextBlock(handoffContext); } + // Internal follow-ups carry their own instructions; only a user turn + // needs the recomputed setup state. + const setupStateBlock = + resumeReason === undefined ? await this.buildWorkflowSetupStateBlock(context) : ''; + // Set heuristic title before agent starts — thread always has a title. // For an editor hand-off the user text is empty (the workflow is the // message), so title it with the workflow name and mark it refined so @@ -3911,6 +3981,7 @@ export class InstanceAiService { const messageWithContext = [ contextResourcesBlock, handoffContextBlock, + setupStateBlock, instanceContext?.block ?? '', messageBody, ] diff --git a/packages/cli/src/modules/instance-ai/internal-messages.ts b/packages/cli/src/modules/instance-ai/internal-messages.ts index e5aae2d1342..21c12a36d8c 100644 --- a/packages/cli/src/modules/instance-ai/internal-messages.ts +++ b/packages/cli/src/modules/instance-ai/internal-messages.ts @@ -51,6 +51,9 @@ export const PROJECT_CONTEXT_OPEN_TAG = ''; export const PROJECT_CONTEXT_CLOSE_TAG = ''; export const PAST_CONVERSATIONS_OPEN_TAG = ''; export const PAST_CONVERSATIONS_CLOSE_TAG = ''; +/** Setup panel v2: per-turn recomputed setup state of the workflows the thread built. */ +export const WORKFLOW_SETUP_STATE_OPEN_TAG = ''; +export const WORKFLOW_SETUP_STATE_CLOSE_TAG = ''; /** * Matches internal task-context prefix blocks injected by the service. The @@ -59,7 +62,7 @@ export const PAST_CONVERSATIONS_CLOSE_TAG = ''; * content is the workflow context). */ const TASK_CONTEXT_BLOCK = - /^(?:\n[\s\S]*?\n<\/running-tasks>||\n[\s\S]*?\n<\/planning-blueprint>|\n[\s\S]*?\n<\/background-task-completed>|\n[\s\S]*?\n<\/workflow-verification-follow-up>|\n[\s\S]*?\n<\/workflow-setup-required>|\n[\s\S]*?\n<\/editor-context>|\n[\s\S]*?\n<\/credential-context>|\n[\s\S]*?\n<\/agent-preview-context>|\n[\s\S]*?\n<\/instance-context>)(?:\n\n|$)/; + /^(?:\n[\s\S]*?\n<\/running-tasks>||\n[\s\S]*?\n<\/planning-blueprint>|\n[\s\S]*?\n<\/background-task-completed>|\n[\s\S]*?\n<\/workflow-verification-follow-up>|\n[\s\S]*?\n<\/workflow-setup-required>|\n[\s\S]*?\n<\/workflow-setup-state>|\n[\s\S]*?\n<\/editor-context>|\n[\s\S]*?\n<\/credential-context>|\n[\s\S]*?\n<\/agent-preview-context>|\n[\s\S]*?\n<\/instance-context>)(?:\n\n|$)/; /** Captures the leading JSON line inside an editor-context block. */ const EDITOR_CONTEXT_JSON = /^\n(\[[\s\S]*?\])\n/; diff --git a/packages/cli/src/modules/instance-ai/repositories/__tests__/instance-ai-event-log.repository.test.ts b/packages/cli/src/modules/instance-ai/repositories/__tests__/instance-ai-event-log.repository.test.ts index 1f7e1010f00..ef84c78c59d 100644 --- a/packages/cli/src/modules/instance-ai/repositories/__tests__/instance-ai-event-log.repository.test.ts +++ b/packages/cli/src/modules/instance-ai/repositories/__tests__/instance-ai-event-log.repository.test.ts @@ -216,4 +216,59 @@ describe('InstanceAiEventLogRepository', () => { expect(createQueryBuilder).not.toHaveBeenCalled(); }); }); + + describe('getSetupItemsSnapshots', () => { + it('keeps the latest snapshot per workflow and drops unparseable items', async () => { + const repo = Object.create( + InstanceAiEventLogRepository.prototype, + ) as InstanceAiEventLogRepository; + const snapshot = (seq: number, workflowId: string, items: unknown[]) => ({ + seq, + runId: 'run-1', + createdAt: new Date('2026-07-01T10:00:00.000Z'), + payload: JSON.stringify({ + type: 'setup-items', + runId: 'run-1', + agentId: 'a1', + payload: { workflowId, items }, + }), + }); + const slack = { + id: 'wf-1:credential:slackApi', + kind: 'credential', + credentialType: 'slackApi', + }; + const gmail = { + id: 'wf-1:credential:gmailOAuth2', + kind: 'credential', + credentialType: 'gmailOAuth2', + }; + const find = vi + .fn() + .mockResolvedValue([ + snapshot(1, 'wf-1', [slack]), + snapshot(2, 'wf-2', [ + { id: 'wf-2:parameters:N', kind: 'parameters', nodeName: 'N', parameterNames: [] }, + ]), + snapshot(3, 'wf-1', [slack, gmail, null]), + ] as InstanceAiEventLogEntry[]); + Object.defineProperty(repo, 'find', { value: find, configurable: true }); + + const snapshots = await repo.getSetupItemsSnapshots('thread-1'); + + expect(find).toHaveBeenCalledWith({ + where: { threadId: 'thread-1', type: 'setup-items' }, + order: { seq: 'ASC' }, + }); + expect(snapshots).toEqual([ + { + workflowId: 'wf-2', + items: [ + { id: 'wf-2:parameters:N', kind: 'parameters', nodeName: 'N', parameterNames: [] }, + ], + }, + { workflowId: 'wf-1', items: [slack, gmail] }, + ]); + }); + }); }); diff --git a/packages/cli/src/modules/instance-ai/repositories/instance-ai-event-log.repository.ts b/packages/cli/src/modules/instance-ai/repositories/instance-ai-event-log.repository.ts index c0e1f6c44c9..43d955f61c6 100644 --- a/packages/cli/src/modules/instance-ai/repositories/instance-ai-event-log.repository.ts +++ b/packages/cli/src/modules/instance-ai/repositories/instance-ai-event-log.repository.ts @@ -1,4 +1,4 @@ -import type { InstanceAiEvent } from '@n8n/api-types'; +import type { InstanceAiEvent, InstanceAiSetupItem } from '@n8n/api-types'; import { Service } from '@n8n/di'; import type { StoredEvent } from '@n8n/instance-ai'; import { DataSource, MoreThan, Repository } from '@n8n/typeorm'; @@ -181,6 +181,32 @@ export class InstanceAiEventLogRepository extends Repository> { + const rows = await this.find({ + where: { threadId, type: 'setup-items' }, + order: { seq: 'ASC' }, + }); + const latest = new Map(); + for (const row of rows) { + const event = this.toEvent(row); + if (event.type !== 'setup-items') continue; + const items = event.payload.items.filter( + (item): item is InstanceAiSetupItem => item !== null, + ); + latest.delete(event.payload.workflowId); + latest.set(event.payload.workflowId, items); + } + return [...latest.entries()].map(([workflowId, items]) => ({ workflowId, items })); + } + /** Timestamp of the run's most recent durable fact (sweep liveness proxy). */ async lastFactAt(threadId: string, runId: string): Promise { const row = await this.createQueryBuilder('e')