diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index 193966ae91243..1235d1b4f4570 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -348,6 +348,9 @@ export type { InstanceAiAgentNode, InstanceAiTimelineEntry, InstanceAiMessage, + InstanceAiReferencedDataTable, + InstanceAiAppliedCredential, + InstanceAiWorkflowReferences, InstanceAiThreadSummary, InstanceAiSSEConnectionState, InstanceAiThreadInfo, diff --git a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts index cdac3ec0b90ef..c8bee42342494 100644 --- a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts +++ b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts @@ -1030,6 +1030,28 @@ export class InstanceAiEvalExecutionRequest extends Z.class({ scenarioHints: z.string().max(2000).optional(), }) {} +// --------------------------------------------------------------------------- +// Workflow references +// --------------------------------------------------------------------------- + +export interface InstanceAiReferencedDataTable { + id: string; + name: string; + projectId: string; +} + +export interface InstanceAiAppliedCredential { + id: string; + name: string; + credentialType: string; +} + +export interface InstanceAiWorkflowReferences { + workflowId: string; + referencedDataTables: InstanceAiReferencedDataTable[]; + appliedCredentials: InstanceAiAppliedCredential[]; +} + // --------------------------------------------------------------------------- // Sub-agent evaluation endpoint // --------------------------------------------------------------------------- diff --git a/packages/@n8n/instance-ai/src/tools/attachments/__tests__/parse-file.tool.test.ts b/packages/@n8n/instance-ai/src/tools/attachments/__tests__/parse-file.tool.test.ts index d1d84aebf0622..ac750ecd2642b 100644 --- a/packages/@n8n/instance-ai/src/tools/attachments/__tests__/parse-file.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/attachments/__tests__/parse-file.tool.test.ts @@ -46,6 +46,7 @@ function createMockContext(overrides?: Partial): InstanceAiCo listSearchable: jest.fn(), }, dataTableService: { + get: jest.fn(), list: jest.fn(), create: jest.fn(), delete: jest.fn(), diff --git a/packages/@n8n/instance-ai/src/tools/workflows.tool.ts b/packages/@n8n/instance-ai/src/tools/workflows.tool.ts index b8ed3a03cc18f..df3f11df5eff6 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows.tool.ts @@ -10,6 +10,7 @@ import { z } from 'zod'; import { sanitizeInputSchema } from '../agent/sanitize-mcp-schemas'; import type { InstanceAiContext } from '../types'; import { formatTimestamp } from '../utils/format-timestamp'; +import { resolveReferences } from './workflows/resolve-references'; import { setupSuspendSchema, setupResumeSchema } from './workflows/setup-workflow.schema'; import { analyzeWorkflow, @@ -401,6 +402,11 @@ async function handleSetup( const allFailedNodes = [...(failedNodes ?? []), ...credTestFailures]; const mergedFailedNodes = allFailedNodes.length > 0 ? allFailedNodes : undefined; + const references = await resolveReferences(context, input.workflowId, { + excludeNodeNames: credFailedNodeNames, + nodes: updatedWorkflow.nodes, + }); + if (pendingRequests.length > 0) { const skippedNodes = pendingRequests.map((r) => ({ nodeName: r.node.name, @@ -415,6 +421,7 @@ async function handleSetup( failedNodes: mergedFailedNodes, updatedNodes, updatedConnections, + ...(references ? { references } : {}), }; } @@ -424,6 +431,7 @@ async function handleSetup( failedNodes: mergedFailedNodes, updatedNodes, updatedConnections, + ...(references ? { references } : {}), }; } catch (error) { return { 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 954bcb0acdc8f..89366a1c2fef5 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 @@ -51,6 +51,7 @@ function createMockContext(overrides?: Partial): InstanceAiCo listSearchable: jest.fn(), }, dataTableService: { + get: jest.fn(), list: jest.fn(), create: jest.fn(), delete: jest.fn(), 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 0850178799b0b..ea1cc8f1ca43c 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 @@ -3,6 +3,7 @@ import { generateWorkflowCode, layoutWorkflowJSON } from '@n8n/workflow-sdk'; import { z } from 'zod'; import { buildCredentialMap, resolveCredentials } from './resolve-credentials'; +import { resolveReferences } from './resolve-references'; import { stripStaleCredentialsFromWorkflow } from './setup-workflow.service'; import { ensureWebhookIds } from './submit-workflow.tool'; import type { InstanceAiContext } from '../../types'; @@ -65,6 +66,17 @@ export function createBuildWorkflowTool(context: InstanceAiContext) { workflowId: z.string().optional(), errors: z.array(z.string()).optional(), warnings: z.array(z.string()).optional(), + references: z + .object({ + workflowId: z.string(), + referencedDataTables: z.array( + z.object({ id: z.string(), name: z.string(), projectId: z.string() }), + ), + appliedCredentials: z.array( + z.object({ id: z.string(), name: z.string(), credentialType: z.string() }), + ), + }) + .optional(), }), execute: async (input: z.infer) => { const permKey = input.workflowId ? 'updateWorkflow' : 'createWorkflow'; @@ -182,6 +194,7 @@ export function createBuildWorkflowTool(context: InstanceAiContext) { json, projectId ? { projectId } : undefined, ); + const references = await resolveReferences(context, updated.id); return { success: true, workflowId: updated.id, @@ -189,6 +202,7 @@ export function createBuildWorkflowTool(context: InstanceAiContext) { informational.length > 0 ? informational.map((w) => `[${w.code}]: ${w.message}`) : undefined, + ...(references ? { references } : {}), }; } else { const created = await context.workflowService.createFromWorkflowJSON(json, { @@ -196,6 +210,7 @@ export function createBuildWorkflowTool(context: InstanceAiContext) { markAsAiTemporary: true, }); (context.aiCreatedWorkflowIds ??= new Set()).add(created.id); + const references = await resolveReferences(context, created.id); return { success: true, workflowId: created.id, @@ -203,6 +218,7 @@ export function createBuildWorkflowTool(context: InstanceAiContext) { informational.length > 0 ? informational.map((w) => `[${w.code}]: ${w.message}`) : undefined, + ...(references ? { references } : {}), }; } } catch (error) { diff --git a/packages/@n8n/instance-ai/src/tools/workflows/resolve-references.ts b/packages/@n8n/instance-ai/src/tools/workflows/resolve-references.ts new file mode 100644 index 0000000000000..a4923fb5393e3 --- /dev/null +++ b/packages/@n8n/instance-ai/src/tools/workflows/resolve-references.ts @@ -0,0 +1,33 @@ +import type { InstanceAiWorkflowReferences } from '@n8n/api-types'; +import type { WorkflowJSON } from '@n8n/workflow-sdk'; + +import type { InstanceAiContext } from '../../types'; + +export async function resolveReferences( + context: InstanceAiContext, + workflowId: string, + options?: { excludeNodeNames?: Set; nodes?: WorkflowJSON['nodes'] }, +): Promise { + if (!context.getWorkflowReferences) return undefined; + try { + const references = await context.getWorkflowReferences(workflowId); + const exclude = options?.excludeNodeNames; + const nodes = options?.nodes; + if (!exclude || exclude.size === 0 || !nodes) return references; + + const validCredIds = new Set(); + for (const node of nodes) { + if (node.name && exclude.has(node.name)) continue; + for (const cred of Object.values(node.credentials ?? {})) { + if (cred?.id) validCredIds.add(cred.id); + } + } + return { + ...references, + appliedCredentials: references.appliedCredentials.filter((c) => validCredIds.has(c.id)), + }; + } catch (error) { + context.logger?.warn?.('getWorkflowReferences failed', { error }); + return undefined; + } +} diff --git a/packages/@n8n/instance-ai/src/tools/workflows/submit-workflow.tool.ts b/packages/@n8n/instance-ai/src/tools/workflows/submit-workflow.tool.ts index 898fa5d81327c..04c9704ecf275 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/submit-workflow.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/submit-workflow.tool.ts @@ -15,6 +15,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { z } from 'zod'; import { resolveCredentials, type CredentialMap } from './resolve-credentials'; +import { resolveReferences } from './resolve-references'; import { stripStaleCredentialsFromWorkflow } from './setup-workflow.service'; import type { InstanceAiContext } from '../../types'; import type { ValidationWarning } from '../../workflow-builder'; @@ -162,6 +163,17 @@ export const submitWorkflowOutputSchema = z.object({ verificationPinData: z.record(z.array(z.record(z.unknown()))).optional(), errors: z.array(z.string()).optional(), warnings: z.array(z.string()).optional(), + references: z + .object({ + workflowId: z.string(), + referencedDataTables: z.array( + z.object({ id: z.string(), name: z.string(), projectId: z.string() }), + ), + appliedCredentials: z.array( + z.object({ id: z.string(), name: z.string(), credentialType: z.string() }), + ), + }) + .optional(), }); export type SubmitWorkflowInput = z.infer; @@ -401,6 +413,7 @@ export function createSubmitWorkflowTool( : undefined, hasUnresolvedPlaceholders: hasPlaceholders || undefined, }); + const references = await resolveReferences(context, savedId); return { success: true, workflowId: savedId, @@ -418,6 +431,7 @@ export function createSubmitWorkflowTool( informational.length > 0 ? informational.map((w) => `[${w.code}]: ${w.message}`) : undefined, + ...(references ? { references } : {}), }; }, }); diff --git a/packages/@n8n/instance-ai/src/types.ts b/packages/@n8n/instance-ai/src/types.ts index 89809cb988a9e..903f164b9fa40 100644 --- a/packages/@n8n/instance-ai/src/types.ts +++ b/packages/@n8n/instance-ai/src/types.ts @@ -7,6 +7,7 @@ import type { TaskList, InstanceAiAttachment, InstanceAiPermissions, + InstanceAiWorkflowReferences, McpTool, McpToolCallRequest, McpToolCallResult, @@ -371,6 +372,7 @@ export interface DataTableFilterInput { // ── Data table service ─────────────────────────────────────────────────────── export interface InstanceAiDataTableService { + get(dataTableId: string): Promise; list(options?: { projectId?: string }): Promise; create( name: string, @@ -565,6 +567,7 @@ export interface InstanceAiContext { currentUserAttachments?: InstanceAiAttachment[]; /** Optional logger for diagnostics from domain tools. */ logger?: Logger; + getWorkflowReferences?(workflowId: string): Promise; } // ── Task storage ───────────────────────────────────────────────────────────── diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.security.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.security.test.ts index d4869282a6503..5106a7fde0dd7 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.security.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.security.test.ts @@ -46,6 +46,7 @@ import type { ExecutionPersistence } from '@/executions/execution-persistence'; import type { EventService } from '@/events/event.service'; import type { RoleService } from '@/services/role.service'; import type { Telemetry } from '@/telemetry'; +import type { OwnershipService } from '@/services/ownership.service'; jest.mock('@/permissions.ee/check-access'); jest.mock('@/workflow-execute-additional-data', () => ({ @@ -91,6 +92,7 @@ const executionPersistence = mock(); const eventService = mock(); const roleService = mock(); const telemetry = mock(); +const ownershipService = mock(); const aiBuilderTemporaryWorkflowRepository = mock(); const service = new InstanceAiAdapterService( @@ -123,6 +125,7 @@ const service = new InstanceAiAdapterService( eventService, roleService, telemetry, + ownershipService, aiBuilderTemporaryWorkflowRepository, ); @@ -371,3 +374,243 @@ describe('cleanupTestExecutions — scope and deletion pipeline', () => { expect(eventService.emit).not.toHaveBeenCalled(); }); }); + +// --------------------------------------------------------------------------- +// resolveWorkflowReferences — access-aware reference filtering +// --------------------------------------------------------------------------- + +describe('resolveWorkflowReferences — access-aware reference filtering', () => { + const workflowWithRefs = { + id: 'wf-1', + nodes: [ + { + id: 'n1', + name: 'Slack', + type: 'n8n-nodes-base.slack', + typeVersion: 1, + position: [0, 0], + parameters: {}, + credentials: { slackApi: { id: 'cred-ok', name: 'Prod' } }, + }, + { + id: 'n2', + name: 'Gmail', + type: 'n8n-nodes-base.gmail', + typeVersion: 1, + position: [0, 0], + parameters: {}, + credentials: { gmailOAuth2: { id: 'cred-unavailable', name: 'Stale' } }, + }, + { + id: 'n3', + name: 'Data Table (by id)', + type: 'n8n-nodes-base.dataTable', + typeVersion: 1, + position: [0, 0], + parameters: { dataTableId: { mode: 'id', value: 'dt-id-ok' } }, + }, + { + id: 'n4', + name: 'Data Table (id unavailable)', + type: 'n8n-nodes-base.dataTable', + typeVersion: 1, + position: [0, 0], + parameters: { dataTableId: { mode: 'id', value: 'dt-id-unavailable' } }, + }, + { + id: 'n5', + name: 'Data Table (by name)', + type: 'n8n-nodes-base.dataTable', + typeVersion: 1, + position: [0, 0], + parameters: { dataTableId: { mode: 'name', value: 'Customers' } }, + }, + { + id: 'n6', + name: 'Data Table (name unavailable)', + type: 'n8n-nodes-base.dataTable', + typeVersion: 1, + position: [0, 0], + parameters: { dataTableId: { mode: 'name', value: 'Secrets' } }, + }, + ], + }; + + beforeEach(() => { + workflowFinderService.findWorkflowForUser.mockReset(); + credentialsFinderService.findCredentialForUser.mockReset(); + ownershipService.getWorkflowProjectCached.mockReset(); + dataTableRepository.findOneBy.mockReset(); + dataTableService.getManyAndCount.mockReset(); + userHasScopesMock.mockReset(); + }); + + it('returns an empty reference set when the workflow is not accessible', async () => { + workflowFinderService.findWorkflowForUser.mockResolvedValue(null); + + const ctx = service.createContext(user); + const result = await ctx.getWorkflowReferences!('wf-1'); + + expect(result).toEqual({ + workflowId: 'wf-1', + referencedDataTables: [], + appliedCredentials: [], + }); + expect(workflowFinderService.findWorkflowForUser).toHaveBeenCalledWith('wf-1', user, [ + 'workflow:read', + ]); + expect(credentialsFinderService.findCredentialForUser).not.toHaveBeenCalled(); + }); + + it('omits unavailable credentials', async () => { + workflowFinderService.findWorkflowForUser.mockResolvedValue(workflowWithRefs as never); + ownershipService.getWorkflowProjectCached.mockResolvedValue({ id: 'proj-1' } as never); + credentialsFinderService.findCredentialForUser.mockImplementation(async (id) => + id === 'cred-ok' ? ({ id, name: 'Prod', type: 'slackApi' } as never) : null, + ); + userHasScopesMock.mockResolvedValue(false); + dataTableService.getManyAndCount.mockResolvedValue({ data: [], count: 0 } as never); + + const ctx = service.createContext(user); + const result = await ctx.getWorkflowReferences!('wf-1'); + + expect(result.appliedCredentials).toEqual([ + { id: 'cred-ok', name: 'Prod', credentialType: 'slackApi' }, + ]); + expect(credentialsFinderService.findCredentialForUser).toHaveBeenCalledWith( + 'cred-unavailable', + user, + ['credential:read'], + ); + }); + + it('omits unavailable id-referenced data tables', async () => { + workflowFinderService.findWorkflowForUser.mockResolvedValue({ + ...workflowWithRefs, + nodes: workflowWithRefs.nodes.filter((n) => n.parameters?.dataTableId?.mode === 'id'), + } as never); + ownershipService.getWorkflowProjectCached.mockResolvedValue({ id: 'proj-1' } as never); + userHasScopesMock.mockImplementation( + async (_user, _scopes, _ar, resource) => resource?.dataTableId === 'dt-id-ok', + ); + dataTableRepository.findOneBy.mockImplementation(async (where) => { + const id = (where as { id: string }).id; + return id === 'dt-id-ok' + ? ({ id, name: 'Allowed Table', projectId: 'proj-1' } as never) + : (null as never); + }); + dataTableService.getManyAndCount.mockResolvedValue({ data: [], count: 0 } as never); + + const ctx = service.createContext(user); + const result = await ctx.getWorkflowReferences!('wf-1'); + + expect(result.referencedDataTables).toEqual([ + { id: 'dt-id-ok', name: 'Allowed Table', projectId: 'proj-1' }, + ]); + expect(dataTableRepository.findOneBy).not.toHaveBeenCalledWith({ id: 'dt-id-unavailable' }); + }); + + it('omits unavailable name-referenced data tables', async () => { + workflowFinderService.findWorkflowForUser.mockResolvedValue({ + ...workflowWithRefs, + nodes: workflowWithRefs.nodes.filter((n) => n.parameters?.dataTableId?.mode === 'name'), + } as never); + ownershipService.getWorkflowProjectCached.mockResolvedValue({ id: 'proj-1' } as never); + dataTableService.getManyAndCount.mockImplementation(async (options) => { + const name = (options.filter as { name?: string } | undefined)?.name; + const table = + name === 'customers' + ? { id: 'dt-name-ok', name: 'Customers', projectId: 'proj-1' } + : { id: 'dt-name-unavailable', name: 'Secrets', projectId: 'proj-1' }; + return { data: [table], count: 1 } as never; + }); + userHasScopesMock.mockImplementation( + async (_user, _scopes, _ar, resource) => resource?.dataTableId === 'dt-name-ok', + ); + + const ctx = service.createContext(user); + const result = await ctx.getWorkflowReferences!('wf-1'); + + expect(result.referencedDataTables).toEqual([ + { id: 'dt-name-ok', name: 'Customers', projectId: 'proj-1' }, + ]); + expect(userHasScopesMock).toHaveBeenCalledWith(user, ['dataTable:read'], false, { + dataTableId: 'dt-name-unavailable', + }); + }); + + it('matches name-referenced data tables case-insensitively', async () => { + workflowFinderService.findWorkflowForUser.mockResolvedValue({ + id: 'wf-1', + nodes: [ + { + id: 'n-name', + name: 'By name', + type: 'n8n-nodes-base.dataTable', + typeVersion: 1, + position: [0, 0], + parameters: { dataTableId: { mode: 'name', value: 'CUSTOMERS' } }, + }, + ], + } as never); + ownershipService.getWorkflowProjectCached.mockResolvedValue({ id: 'proj-1' } as never); + dataTableService.getManyAndCount.mockImplementation(async (options) => { + expect(options).toEqual({ + filter: { projectId: 'proj-1', name: 'customers' }, + take: 1, + }); + return { + data: [{ id: 'dt-name-ok', name: 'Customers', projectId: 'proj-1' }], + count: 1, + } as never; + }); + userHasScopesMock.mockResolvedValue(true); + + const ctx = service.createContext(user); + const result = await ctx.getWorkflowReferences!('wf-1'); + + expect(result.referencedDataTables).toEqual([ + { id: 'dt-name-ok', name: 'Customers', projectId: 'proj-1' }, + ]); + }); + + it('prefers the id-path entry when the same table resolves via both id and name', async () => { + const nodes = [ + { + id: 'n-id', + name: 'By id', + type: 'n8n-nodes-base.dataTable', + typeVersion: 1, + position: [0, 0], + parameters: { dataTableId: { mode: 'id', value: 'dt-dup' } }, + }, + { + id: 'n-name', + name: 'By name', + type: 'n8n-nodes-base.dataTable', + typeVersion: 1, + position: [0, 0], + parameters: { dataTableId: { mode: 'name', value: 'Customers' } }, + }, + ]; + workflowFinderService.findWorkflowForUser.mockResolvedValue({ id: 'wf-1', nodes } as never); + ownershipService.getWorkflowProjectCached.mockResolvedValue({ id: 'proj-1' } as never); + userHasScopesMock.mockResolvedValue(true); + dataTableRepository.findOneBy.mockResolvedValue({ + id: 'dt-dup', + name: 'Customers', + projectId: 'proj-1', + } as never); + dataTableService.getManyAndCount.mockResolvedValue({ + data: [{ id: 'dt-dup', name: 'Customers', projectId: 'proj-1' }], + count: 1, + } as never); + + const ctx = service.createContext(user); + const result = await ctx.getWorkflowReferences!('wf-1'); + + expect(result.referencedDataTables).toEqual([ + { id: 'dt-dup', name: 'Customers', projectId: 'proj-1' }, + ]); + }); +}); 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 5dfdee5971342..7ead966caf9d6 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 @@ -748,6 +748,7 @@ function createNodeAdapterForTests(nodes: Array>) { {} as unknown as ConstructorParameters[27], {} as unknown as ConstructorParameters[28], {} as unknown as ConstructorParameters[29], + {} as unknown as ConstructorParameters[30], ); ( @@ -877,6 +878,7 @@ function createDataTableAdapterForTests(overrides?: { {} as unknown as ConstructorParameters[27], {} as unknown as ConstructorParameters[28], {} as unknown as ConstructorParameters[29], + {} as unknown as ConstructorParameters[30], ); const adapter = service.createContext(mockUser).dataTableService; @@ -1152,6 +1154,7 @@ function createWorkflowAdapterForTests(overrides?: { {} as unknown as ConstructorParameters[26], {} as unknown as ConstructorParameters[27], { track: jest.fn() } as unknown as ConstructorParameters[28], + {} as unknown as ConstructorParameters[29], mockAiBuilderTemporaryWorkflowRepository as unknown as AiBuilderTemporaryWorkflowRepository, ); @@ -1480,6 +1483,7 @@ function createExecutionAdapterForTests(overrides?: { sharingEnabled?: boolean } mockRoleService as unknown as RoleService, {} as unknown as ConstructorParameters[28], {} as unknown as ConstructorParameters[29], + {} as unknown as ConstructorParameters[30], ); const adapter = service.createContext(mockUser).executionService; 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 36f7f847a4117..9542256068037 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 @@ -36,6 +36,7 @@ import type { CredentialTypeSearchResult, } from '@n8n/instance-ai'; import { wrapUntrustedData } from '@n8n/instance-ai'; +import type { InstanceAiWorkflowReferences } from '@n8n/api-types'; import type { WorkflowJSON } from '@n8n/workflow-sdk'; import { GlobalConfig } from '@n8n/config'; import { Time } from '@n8n/constants'; @@ -107,6 +108,8 @@ import { License } from '@/license'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { DataTableRepository } from '@/modules/data-table/data-table.repository'; import { DataTableService } from '@/modules/data-table/data-table.service'; +import { extractRawRefsFromNodes } from '@/modules/workflow-index/workflow-index.service'; +import { OwnershipService } from '@/services/ownership.service'; import { SourceControlPreferencesService } from '@/modules/source-control.ee/source-control-preferences.service.ee'; import { userHasScopes } from '@/permissions.ee/check-access'; import { DynamicNodeParametersService } from '@/services/dynamic-node-parameters.service'; @@ -186,6 +189,7 @@ export class InstanceAiAdapterService { private readonly eventService: EventService, private readonly roleService: RoleService, private readonly telemetry: Telemetry, + private readonly ownershipService: OwnershipService, private readonly aiBuilderTemporaryWorkflowRepository: AiBuilderTemporaryWorkflowRepository, ) { this.logger = logger.scoped('instance-ai'); @@ -212,6 +216,97 @@ export class InstanceAiAdapterService { workspaceService: this.createWorkspaceAdapter(user), licenseHints: this.buildLicenseHints(), logger: this.logger, + getWorkflowReferences: async (workflowId) => + await this.resolveWorkflowReferences(user, workflowId), + }; + } + + private async resolveWorkflowReferences( + user: User, + workflowId: string, + ): Promise { + const empty: InstanceAiWorkflowReferences = { + workflowId, + referencedDataTables: [], + appliedCredentials: [], + }; + + const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [ + 'workflow:read', + ]); + if (!workflow) return empty; + + const project = await this.ownershipService.getWorkflowProjectCached(workflowId); + const { credentialIds, dataTableIdRefs, dataTableNameRefs } = extractRawRefsFromNodes( + workflow.nodes ?? [], + ); + + const credentialResults = await Promise.allSettled( + credentialIds.map(async (id) => { + const cred = await this.credentialsFinderService.findCredentialForUser(id, user, [ + 'credential:read', + ]); + if (!cred) throw new Error('inaccessible'); + return { id: cred.id, name: cred.name, credentialType: cred.type }; + }), + ); + const appliedCredentials = credentialResults + .filter( + (r): r is PromiseFulfilledResult<{ id: string; name: string; credentialType: string }> => + r.status === 'fulfilled', + ) + .map((r) => r.value); + + const dataTablesById = new Map(); + + await Promise.all( + dataTableIdRefs.map(async (id) => { + const allowed = await userHasScopes(user, ['dataTable:read'], false, { dataTableId: id }); + if (!allowed) return; + try { + const table = await this.dataTableRepository.findOneBy({ id }); + if (table) + dataTablesById.set(table.id, { + id: table.id, + name: table.name, + projectId: table.projectId, + }); + } catch { + // inaccessible or missing + } + }), + ); + + if (dataTableNameRefs.length > 0) { + try { + await Promise.all( + dataTableNameRefs.map(async (name) => { + const { data: matches } = await this.dataTableService.getManyAndCount({ + filter: { projectId: project.id, name: name.toLowerCase() }, + take: 1, + }); + const match = matches[0]; + if (!match || dataTablesById.has(match.id)) return; + const allowed = await userHasScopes(user, ['dataTable:read'], false, { + dataTableId: match.id, + }); + if (!allowed) return; + dataTablesById.set(match.id, { + id: match.id, + name: match.name, + projectId: project.id, + }); + }), + ); + } catch { + // fallback lookup failed — leave tables empty + } + } + + return { + workflowId, + referencedDataTables: [...dataTablesById.values()], + appliedCredentials, }; } @@ -1198,6 +1293,19 @@ export class InstanceAiAdapterService { }; return { + async get(dataTableId) { + const projectId = await resolveProjectIdForTable(['dataTable:read'], dataTableId); + const table = await dataTableRepository.findOneByOrFail({ id: dataTableId }); + return { + id: table.id, + name: table.name, + projectId, + columns: table.columns.map((c) => ({ id: c.id, name: c.name, type: c.type })), + createdAt: table.createdAt.toISOString(), + updatedAt: table.updatedAt.toISOString(), + }; + }, + async list(options) { const projectId = await resolveProjectId(['dataTable:listProject'], options?.projectId); const { data: tables } = await dataTableService.getManyAndCount({ diff --git a/packages/cli/src/modules/workflow-index/__tests__/workflow-index.service.test.ts b/packages/cli/src/modules/workflow-index/__tests__/workflow-index.service.test.ts index ad0adb61d342d..76d99a3553e19 100644 --- a/packages/cli/src/modules/workflow-index/__tests__/workflow-index.service.test.ts +++ b/packages/cli/src/modules/workflow-index/__tests__/workflow-index.service.test.ts @@ -11,7 +11,7 @@ import type { INode, IWorkflowBase } from 'n8n-workflow'; import { EventService } from '@/events/event.service'; -import { WorkflowIndexService } from '../workflow-index.service'; +import { extractRawRefsFromNodes, WorkflowIndexService } from '../workflow-index.service'; describe('WorkflowIndexService', () => { let service: WorkflowIndexService; @@ -738,3 +738,97 @@ describe('WorkflowIndexService', () => { }); }); }); + +describe('extractRawRefsFromNodes', () => { + const makeNode = (overrides: Partial = {}): INode => + ({ + id: 'n1', + name: 'Node 1', + type: 'n8n-nodes-base.dataTable', + typeVersion: 1, + position: [0, 0], + parameters: {}, + ...overrides, + }) as INode; + + it('collects credential ids from node.credentials', () => { + const nodes = [ + makeNode({ + type: 'n8n-nodes-base.slack', + credentials: { slackApi: { id: 'c1', name: 'Prod' } }, + }), + makeNode({ + type: 'n8n-nodes-base.slack', + credentials: { slackApi: { id: 'c2', name: 'Dev' } }, + }), + ]; + expect(extractRawRefsFromNodes(nodes).credentialIds).toEqual(['c1', 'c2']); + }); + + it('skips null credential ids', () => { + const nodes = [ + makeNode({ + type: 'n8n-nodes-base.slack', + credentials: { slackApi: { id: null as unknown as string, name: 'Missing' } }, + }), + ]; + expect(extractRawRefsFromNodes(nodes).credentialIds).toEqual([]); + }); + + it('extracts id-mode data-table references', () => { + const nodes = [ + makeNode({ parameters: { dataTableId: { mode: 'id', value: 'dt-1' } } }), + makeNode({ parameters: { dataTableId: { mode: 'list', value: 'dt-2' } } }), + ]; + const refs = extractRawRefsFromNodes(nodes); + expect(refs.dataTableIdRefs).toEqual(['dt-1', 'dt-2']); + expect(refs.dataTableNameRefs).toEqual([]); + }); + + it('extracts name-mode data-table references separately', () => { + const nodes = [makeNode({ parameters: { dataTableId: { mode: 'name', value: 'Customers' } } })]; + const refs = extractRawRefsFromNodes(nodes); + expect(refs.dataTableIdRefs).toEqual([]); + expect(refs.dataTableNameRefs).toEqual(['Customers']); + }); + + it('skips expression-based values', () => { + const nodes = [ + makeNode({ parameters: { dataTableId: { mode: 'id', value: '={{$json.tableId}}' } } }), + ]; + expect(extractRawRefsFromNodes(nodes).dataTableIdRefs).toEqual([]); + }); + + it('skips empty values', () => { + const nodes = [makeNode({ parameters: { dataTableId: { mode: 'id', value: '' } } })]; + expect(extractRawRefsFromNodes(nodes).dataTableIdRefs).toEqual([]); + }); + + it('ignores non-data-table node types', () => { + const nodes = [ + makeNode({ + type: 'n8n-nodes-base.set', + parameters: { dataTableId: { mode: 'id', value: 'dt-1' } }, + }), + ]; + expect(extractRawRefsFromNodes(nodes).dataTableIdRefs).toEqual([]); + }); + + it('covers all DATA_TABLE_NODE_TYPES (including evaluation nodes)', () => { + const nodes = [ + makeNode({ + type: 'n8n-nodes-base.evaluationTrigger', + parameters: { dataTableId: { mode: 'id', value: 'dt-eval' } }, + }), + ]; + expect(extractRawRefsFromNodes(nodes).dataTableIdRefs).toEqual(['dt-eval']); + }); + + it('de-dupes across multiple nodes', () => { + const nodes = [ + makeNode({ parameters: { dataTableId: { mode: 'id', value: 'dt-1' } } }), + makeNode({ parameters: { dataTableId: { mode: 'id', value: 'dt-1' } } }), + ]; + expect(extractRawRefsFromNodes(nodes).dataTableIdRefs).toEqual(['dt-1']); + }); +}); diff --git a/packages/cli/src/modules/workflow-index/workflow-index.service.ts b/packages/cli/src/modules/workflow-index/workflow-index.service.ts index c575e4c24d65c..d2bae0c2b2e70 100644 --- a/packages/cli/src/modules/workflow-index/workflow-index.service.ts +++ b/packages/cli/src/modules/workflow-index/workflow-index.service.ts @@ -381,3 +381,41 @@ export class WorkflowIndexService { return undefined; } } + +export interface RawExtractedRefs { + credentialIds: string[]; + dataTableIdRefs: string[]; + dataTableNameRefs: string[]; +} + +export function extractRawRefsFromNodes(nodes: INode[]): RawExtractedRefs { + const credentialIds = new Set(); + const dataTableIdRefs = new Set(); + const dataTableNameRefs = new Set(); + + for (const node of nodes) { + if (node.credentials) { + for (const cred of Object.values(node.credentials)) { + if (cred?.id) credentialIds.add(cred.id); + } + } + + if (!DATA_TABLE_NODE_TYPES.includes(node.type)) continue; + + const locator = node.parameters?.dataTableId as { mode?: string; value?: unknown } | undefined; + if (!locator || typeof locator.value !== 'string' || locator.value === '') continue; + if (locator.value.includes('{')) continue; + + if (locator.mode === 'name') { + dataTableNameRefs.add(locator.value); + } else { + dataTableIdRefs.add(locator.value); + } + } + + return { + credentialIds: [...credentialIds], + dataTableIdRefs: [...dataTableIdRefs], + dataTableNameRefs: [...dataTableNameRefs], + }; +} diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 0b9630dc1de67..2cbae9657572a 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -5143,6 +5143,9 @@ "instanceAi.artifactsPanel.noArtifacts": "No artifacts yet", "instanceAi.artifactsPanel.tasks": "Tasks", "instanceAi.artifactsPanel.openWorkflow": "Open", + "instanceAi.artifactsPanel.created": "Created", + "instanceAi.artifactsPanel.used": "Used", + "instanceAi.artifactsPanel.bound": "Bound", "instanceAi.artifactsPanel.archived": "Archived", "instanceAi.previewTabBar.collapse": "Collapse panel", "instanceAi.previewTabBar.openInEditor": "Open in editor", diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useResourceRegistry.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useResourceRegistry.test.ts index 1b7782b552977..f7995c1cb4eaf 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useResourceRegistry.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useResourceRegistry.test.ts @@ -48,11 +48,11 @@ function makeMessage(overrides: Partial = {}): InstanceAiMess function setup(workflowNameLookup?: (id: string) => string | undefined) { const messages = ref([]); - const { producedArtifacts, resourceNameIndex } = useResourceRegistry( + const { producedArtifacts, referencedArtifacts, resourceNameIndex } = useResourceRegistry( () => messages.value, workflowNameLookup, ); - return { messages, producedArtifacts, resourceNameIndex }; + return { messages, producedArtifacts, referencedArtifacts, resourceNameIndex }; } // --------------------------------------------------------------------------- @@ -471,4 +471,171 @@ describe('useResourceRegistry', () => { }); }); }); + + describe('workflow references', () => { + test('workflows(action="setup") result lands tables in referencedArtifacts and credentials in referencedArtifacts', async () => { + const { messages, producedArtifacts, referencedArtifacts } = setup(); + messages.value = [ + makeMessage({ + agentTree: makeAgentNode({ + toolCalls: [ + makeToolCall({ + toolName: 'workflows', + result: { + success: true, + references: { + workflowId: 'wf-1', + referencedDataTables: [{ id: 'dt-1', name: 'Customers', projectId: 'proj-1' }], + appliedCredentials: [ + { id: 'c-1', name: 'Prod Slack', credentialType: 'slackApi' }, + ], + }, + }, + }), + ], + }), + }), + ]; + await nextTick(); + + expect(referencedArtifacts.value.get('dt-1')).toEqual({ + type: 'data-table', + id: 'dt-1', + name: 'Customers', + projectId: 'proj-1', + } satisfies ResourceEntry); + expect(referencedArtifacts.value.get('c-1')).toEqual({ + type: 'credential', + id: 'c-1', + name: 'Prod Slack', + } satisfies ResourceEntry); + expect(producedArtifacts.value.has('dt-1')).toBe(false); + expect(producedArtifacts.value.has('c-1')).toBe(false); + }); + + test('a later emission for the same workflow replaces the earlier reference set', async () => { + const { messages, referencedArtifacts } = setup(); + messages.value = [ + makeMessage({ + agentTree: makeAgentNode({ + toolCalls: [ + makeToolCall({ + toolCallId: 'tc-early', + toolName: 'build-workflow', + startedAt: '2026-04-20T10:00:00Z', + result: { + success: true, + workflowId: 'wf-1', + references: { + workflowId: 'wf-1', + referencedDataTables: [{ id: 'dt-old', name: 'Old', projectId: 'p' }], + appliedCredentials: [], + }, + }, + }), + makeToolCall({ + toolCallId: 'tc-late', + toolName: 'build-workflow', + startedAt: '2026-04-20T11:00:00Z', + result: { + success: true, + workflowId: 'wf-1', + references: { + workflowId: 'wf-1', + referencedDataTables: [{ id: 'dt-new', name: 'New', projectId: 'p' }], + appliedCredentials: [], + }, + }, + }), + ], + }), + }), + ]; + await nextTick(); + + expect(referencedArtifacts.value.has('dt-old')).toBe(false); + expect(referencedArtifacts.value.get('dt-new')?.name).toBe('New'); + }); + + test('startedAt sorts emissions globally across parent and child agents', async () => { + const { messages, referencedArtifacts } = setup(); + messages.value = [ + makeMessage({ + agentTree: makeAgentNode({ + toolCalls: [ + makeToolCall({ + toolCallId: 'parent-late', + toolName: 'workflows', + startedAt: '2026-04-20T12:00:00Z', + result: { + references: { + workflowId: 'wf-1', + referencedDataTables: [{ id: 'dt-parent', name: 'Parent', projectId: 'p' }], + appliedCredentials: [], + }, + }, + }), + ], + children: [ + makeAgentNode({ + agentId: 'child', + toolCalls: [ + makeToolCall({ + toolCallId: 'child-later', + toolName: 'submit-workflow', + startedAt: '2026-04-20T13:00:00Z', + result: { + success: true, + workflowId: 'wf-1', + references: { + workflowId: 'wf-1', + referencedDataTables: [{ id: 'dt-child', name: 'Child', projectId: 'p' }], + appliedCredentials: [], + }, + }, + }), + ], + }), + ], + }), + }), + ]; + await nextTick(); + + expect(referencedArtifacts.value.has('dt-parent')).toBe(false); + expect(referencedArtifacts.value.get('dt-child')?.name).toBe('Child'); + }); + + test('produced wins over referenced when the same id appears in both', async () => { + const { messages, producedArtifacts, referencedArtifacts } = setup(); + messages.value = [ + makeMessage({ + agentTree: makeAgentNode({ + toolCalls: [ + makeToolCall({ + toolName: 'data-tables', + result: { + table: { id: 'dt-shared', name: 'Shared', projectId: 'p' }, + }, + }), + makeToolCall({ + toolName: 'workflows', + result: { + references: { + workflowId: 'wf-1', + referencedDataTables: [{ id: 'dt-shared', name: 'Shared', projectId: 'p' }], + appliedCredentials: [], + }, + }, + }), + ], + }), + }), + ]; + await nextTick(); + + expect(producedArtifacts.value.has('dt-shared')).toBe(true); + expect(referencedArtifacts.value.has('dt-shared')).toBe(false); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiArtifactsPanel.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiArtifactsPanel.vue index a3e109337f1f6..020c46fac2b2f 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiArtifactsPanel.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiArtifactsPanel.vue @@ -1,29 +1,67 @@ @@ -93,6 +144,9 @@ const artifactIconMap: Record = { /> {{ artifact.name }} + + {{ artifactBadgeLabel(artifact) }} + {{ i18n.baseText('instanceAi.artifactsPanel.archived') }} @@ -103,6 +157,7 @@ const artifactIconMap: Record = {
+
{{ i18n.baseText('instanceAi.artifactsPanel.noArtifacts') }} @@ -240,6 +295,7 @@ const artifactIconMap: Record = { } } +.artifactBadge, .archivedBadge { font-size: var(--font-size--3xs); color: var(--color--text--tint-1); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiMarkdown.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiMarkdown.vue index 81f482ad47f4a..f211bfb4d1b89 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiMarkdown.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiMarkdown.vue @@ -2,13 +2,17 @@ import ChatMarkdownChunk from '@/features/ai/chatHub/components/ChatMarkdownChunk.vue'; import type { ComponentPublicInstance } from 'vue'; import { computed, inject, onBeforeUnmount, onMounted, onUpdated, ref, useCssModule } from 'vue'; +import { useRouter } from 'vue-router'; import { useInstanceAiStore } from '../instanceAi.store'; +import { useUIStore } from '@/app/stores/ui.store'; const props = defineProps<{ content: string; }>(); const store = useInstanceAiStore(); +const uiStore = useUIStore(); +const router = useRouter(); const styles = useCssModule(); const wrapperRef = ref(null); @@ -160,7 +164,6 @@ function enhanceResourceLinks(): void { ) : undefined; - // Swap href to the real app URL (used for Cmd+click / new tab) link.href = type === 'data-table' && registryEntry?.projectId ? `/projects/${registryEntry.projectId}/datatables/${id}` @@ -169,21 +172,33 @@ function enhanceResourceLinks(): void { link.dataset.resourceId = id; applyResourceChip(link, type); - // Regular click opens preview; Cmd/Ctrl+click falls through to default (new tab) + const isReferencedTable = + type === 'data-table' && !store.producedArtifacts.has(id) && registryEntry?.projectId; + const handler = (e: MouseEvent) => { - if (e.metaKey || e.ctrlKey) return; // Let browser handle new-tab + if (e.metaKey || e.ctrlKey) return; - const canPreview = - (type === 'workflow' && openWorkflowPreview) || - (type === 'data-table' && registryEntry?.projectId && openDataTablePreview); + if (type === 'credential') { + e.preventDefault(); + uiStore.openExistingCredential(id); + return; + } - if (!canPreview) return; // Let browser navigate normally + if (type === 'workflow' && openWorkflowPreview) { + e.preventDefault(); + openWorkflowPreview(id); + return; + } + + if (isReferencedTable && registryEntry?.projectId) { + e.preventDefault(); + void router.push(`/projects/${registryEntry.projectId}/datatables/${id}`); + return; + } - e.preventDefault(); - if (type === 'workflow') { - openWorkflowPreview?.(id); - } else if (type === 'data-table' && registryEntry?.projectId) { - openDataTablePreview?.(id, registryEntry.projectId); + if (type === 'data-table' && registryEntry?.projectId && openDataTablePreview) { + e.preventDefault(); + openDataTablePreview(id, registryEntry.projectId); } }; link.addEventListener('click', handler); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.store.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.store.ts index 719a318d18a35..1785e634278a7 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.store.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.store.ts @@ -162,12 +162,8 @@ export const useInstanceAiStore = defineStore('instanceAi', () => { const gatewayDirectory = computed(() => instanceAiSettingsStore.gatewayDirectory); const activeDirectory = computed(() => gatewayDirectory.value); - // Resource registry — two collections derived from tool-call results: - // * producedArtifacts: resources the agent built/created/mutated (panel). - // * resourceNameIndex: every named resource seen, keyed by lowercased name - // (markdown linking). const workflowsListStore = useWorkflowsListStore(); - const { producedArtifacts, resourceNameIndex } = useResourceRegistry( + const { producedArtifacts, referencedArtifacts, resourceNameIndex } = useResourceRegistry( () => messages.value, (id) => workflowsListStore.getWorkflowById(id)?.name, () => archivedWorkflowIds.value, @@ -1069,6 +1065,7 @@ export const useInstanceAiStore = defineStore('instanceAi', () => { contextualSuggestion, currentTasks, producedArtifacts, + referencedArtifacts, resourceNameIndex, rateableResponseId, creditsRemaining, diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/useResourceRegistry.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/useResourceRegistry.ts index 9bc3fec85f50f..9d38081d5cb7b 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/useResourceRegistry.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/useResourceRegistry.ts @@ -3,6 +3,7 @@ import type { InstanceAiMessage, InstanceAiAgentNode, InstanceAiToolCallState, + InstanceAiWorkflowReferences, } from '@n8n/api-types'; export interface ResourceEntry { @@ -21,29 +22,16 @@ export interface ResourceEntry { archived?: boolean; } -// --------------------------------------------------------------------------- -// Internal helpers (defined before use to satisfy no-use-before-define) -// --------------------------------------------------------------------------- - interface Collections { - /** Resources produced/mutated by the agent in this thread, keyed by resource ID. */ produced: Map; - /** Every resource seen in any tool call, keyed by lowercased name — for markdown linking. */ byName: Map; + refsByWorkflow: Map>; } function optionalString(val: unknown): string | undefined { return typeof val === 'string' ? val : undefined; } -/** - * Upsert a produced artifact. When an entry for the same `id` already exists, - * optional fields provided by the new call win; fields it omits are preserved - * from the existing entry. Callers are responsible for resolving `name` using - * the existing entry as a fallback so partial updates (e.g. a patch - * `build-workflow` call that carries only a `workflowId`) don't regress a - * known name to 'Untitled'. - */ function recordProduced(col: Collections, entry: ResourceEntry): void { const existing = col.produced.get(entry.id); const merged: ResourceEntry = existing @@ -67,6 +55,26 @@ function indexByName(col: Collections, entry: ResourceEntry): void { col.byName.set(entry.name.toLowerCase(), entry); } +function recordReferencesForWorkflow(col: Collections, refs: InstanceAiWorkflowReferences): void { + const inner = new Map(); + for (const t of refs.referencedDataTables) { + const entry: ResourceEntry = { + type: 'data-table', + id: t.id, + name: t.name, + projectId: t.projectId, + }; + inner.set(t.id, entry); + indexByName(col, entry); + } + for (const c of refs.appliedCredentials) { + const entry: ResourceEntry = { type: 'credential', id: c.id, name: c.name }; + inner.set(c.id, entry); + indexByName(col, entry); + } + col.refsByWorkflow.set(refs.workflowId, inner); +} + function entryFromListItem( type: ResourceEntry['type'], obj: Record, @@ -82,7 +90,6 @@ function entryFromListItem( return entry; } -/** Tools whose results may contain resource info (workflows, credentials, data tables). */ const ARTIFACT_TOOLS = new Set([ 'build-workflow', 'build-workflow-with-agent', @@ -97,13 +104,25 @@ const ARTIFACT_TOOLS = new Set([ 'delete-data-table-rows', ]); +function isWorkflowReferences(val: unknown): val is InstanceAiWorkflowReferences { + if (!val || typeof val !== 'object') return false; + const r = val as Partial; + return ( + typeof r.workflowId === 'string' && + Array.isArray(r.referencedDataTables) && + Array.isArray(r.appliedCredentials) + ); +} + function extractFromToolCall(tc: InstanceAiToolCallState, col: Collections): void { if (!ARTIFACT_TOOLS.has(tc.toolName)) return; if (!tc.result || typeof tc.result !== 'object') return; const result = tc.result as Record; - // --- Workflows -------------------------------------------------------- - // List result: { workflows: [{ id, name }, ...] } — index by name only. + if (isWorkflowReferences(result.references)) { + recordReferencesForWorkflow(col, result.references); + } + if (Array.isArray(result.workflows)) { for (const wf of result.workflows as Array>) { const entry = entryFromListItem('workflow', wf); @@ -111,9 +130,6 @@ function extractFromToolCall(tc: InstanceAiToolCallState, col: Collections): voi } } - // build-workflow / build-workflow-with-agent / submit-workflow: - // { workflowId, workflowName? } — produced. Patch calls may omit the name, - // so fall back to the existing entry before regressing to 'Untitled'. if (typeof result.workflowId === 'string') { const existing = col.produced.get(result.workflowId); const name = @@ -124,7 +140,6 @@ function extractFromToolCall(tc: InstanceAiToolCallState, col: Collections): voi recordProduced(col, { type: 'workflow', id: result.workflowId, name }); } - // Single workflow object: { workflow: { id, name, ... } } — produced. if (result.workflow && typeof result.workflow === 'object') { const obj = result.workflow as Record; if (typeof obj.id === 'string') { @@ -141,8 +156,6 @@ function extractFromToolCall(tc: InstanceAiToolCallState, col: Collections): voi } } - // --- Credentials ----------------------------------------------------- - // Credentials never show in the panel; only needed for name linking. if (Array.isArray(result.credentials)) { for (const cred of result.credentials as Array>) { const entry = entryFromListItem('credential', cred); @@ -150,8 +163,6 @@ function extractFromToolCall(tc: InstanceAiToolCallState, col: Collections): voi } } - // --- Data tables ----------------------------------------------------- - // List results — index by name only. if (Array.isArray(result.tables)) { for (const table of result.tables as Array>) { const entry = entryFromListItem('data-table', table); @@ -165,7 +176,6 @@ function extractFromToolCall(tc: InstanceAiToolCallState, col: Collections): voi } } - // Singular data table (e.g. data-tables action=create) — produced. if (result.table && typeof result.table === 'object') { const obj = result.table as Record; if (typeof obj.id === 'string') { @@ -182,9 +192,6 @@ function extractFromToolCall(tc: InstanceAiToolCallState, col: Collections): voi } } - // Data table mutation results (insert/update/delete-data-table-rows): - // { dataTableId, projectId, tableName? } — produced. Preserves an - // existing name if the mutation result doesn't carry `tableName`. if (typeof result.dataTableId === 'string' && typeof result.projectId === 'string') { const existing = col.produced.get(result.dataTableId); const name = optionalString(result.tableName) ?? existing?.name ?? result.dataTableId; @@ -197,13 +204,20 @@ function extractFromToolCall(tc: InstanceAiToolCallState, col: Collections): voi } } -function collectFromAgentNode(node: InstanceAiAgentNode, col: Collections): void { - for (const tc of node.toolCalls) { - extractFromToolCall(tc, col); - } - for (const child of node.children) { - collectFromAgentNode(child, col); +function collectToolCalls(node: InstanceAiAgentNode, out: InstanceAiToolCallState[]): void { + for (const tc of node.toolCalls) out.push(tc); + for (const child of node.children) collectToolCalls(child, out); +} + +function compareByStartedAt(a: InstanceAiToolCallState, b: InstanceAiToolCallState): number { + if (a.startedAt && b.startedAt) { + if (a.startedAt < b.startedAt) return -1; + if (a.startedAt > b.startedAt) return 1; + return 0; } + if (a.startedAt) return -1; + if (b.startedAt) return 1; + return 0; } function enrichWorkflowNames( @@ -221,23 +235,6 @@ function enrichWorkflowNames( } } -// --------------------------------------------------------------------------- -// Composable -// --------------------------------------------------------------------------- - -/** - * Scans tool-call results in the conversation and returns two collections: - * - * - `producedArtifacts` (keyed by resource id) — things the agent built, - * submitted, created, or mutated. Powers the Artifacts panel and the - * canvas preview tabs. Repeated writes to the same resource update the - * existing entry instead of creating a duplicate. - * - * - `resourceNameIndex` (keyed by lowercased name) — every named resource - * seen in any tool call, including list results. Used only for markdown - * name→link replacement so references to listed workflows/tables still - * resolve. - */ export function useResourceRegistry( messages: () => InstanceAiMessage[], workflowNameLookup?: (id: string) => string | undefined, @@ -247,12 +244,16 @@ export function useResourceRegistry( const col: Collections = { produced: new Map(), byName: new Map(), + refsByWorkflow: new Map>(), }; + const toolCalls: InstanceAiToolCallState[] = []; for (const msg of messages()) { if (!msg.agentTree) continue; - collectFromAgentNode(msg.agentTree, col); + collectToolCalls(msg.agentTree, toolCalls); } + toolCalls.sort(compareByStartedAt); + for (const tc of toolCalls) extractFromToolCall(tc, col); if (workflowNameLookup) { enrichWorkflowNames(col, workflowNameLookup); @@ -270,8 +271,20 @@ export function useResourceRegistry( return col; }); + const referencedArtifacts = computed((): Map => { + const result = new Map(); + for (const inner of collections.value.refsByWorkflow.values()) { + for (const [id, entry] of inner) { + if (collections.value.produced.has(id)) continue; + result.set(id, entry); + } + } + return result; + }); + return { producedArtifacts: computed(() => collections.value.produced), + referencedArtifacts, resourceNameIndex: computed(() => collections.value.byName), }; }