mirror of
https://github.com/n8n-io/n8n
synced 2026-04-21 15:47:20 +00:00
fix(ai-builder): Scope artifacts panel to resources produced in-thread (#28678)
This commit is contained in:
parent
35f9bed4de
commit
7b3696f3f7
11 changed files with 423 additions and 267 deletions
|
|
@ -36,7 +36,7 @@ describe('InstanceAiMarkdown', () => {
|
|||
|
||||
function getProcessedContent(content: string, registry?: Map<string, ResourceEntry>): string {
|
||||
if (registry) {
|
||||
store.resourceRegistry = registry;
|
||||
store.resourceNameIndex = registry;
|
||||
}
|
||||
const { getByTestId } = renderComponent({ props: { content } });
|
||||
return getByTestId('markdown-output').textContent ?? '';
|
||||
|
|
|
|||
|
|
@ -163,14 +163,16 @@ export async function createInstanceAiHarness(): Promise<InstanceAiHarness> {
|
|||
// --- Mock store ---
|
||||
const messages = ref<InstanceAiMessage[]>([]) as Ref<InstanceAiMessage[]>;
|
||||
const isStreaming = ref(false);
|
||||
const resourceRegistry = ref(new Map<string, ResourceEntry>());
|
||||
const producedArtifacts = ref(new Map<string, ResourceEntry>());
|
||||
const resourceNameIndex = ref(new Map<string, ResourceEntry>());
|
||||
|
||||
const threadMetadata = new Map<string, Record<string, unknown>>();
|
||||
|
||||
const store = reactive({
|
||||
messages,
|
||||
isStreaming,
|
||||
resourceRegistry,
|
||||
producedArtifacts,
|
||||
resourceNameIndex,
|
||||
currentThreadId: 'thread-1',
|
||||
getThreadMetadata: (threadId: string) => threadMetadata.get(threadId),
|
||||
updateThreadMetadata: async (threadId: string, metadata: Record<string, unknown>) => {
|
||||
|
|
@ -213,21 +215,34 @@ export async function createInstanceAiHarness(): Promise<InstanceAiHarness> {
|
|||
// --- Convenience actions ---
|
||||
|
||||
function registerWorkflow(id: string, name = `Workflow ${id}`) {
|
||||
const next = new Map(store.resourceRegistry);
|
||||
next.set(name.toLowerCase(), { type: 'workflow', id, name });
|
||||
store.resourceRegistry = next;
|
||||
const entry: ResourceEntry = { type: 'workflow', id, name };
|
||||
const nextProduced = new Map(store.producedArtifacts);
|
||||
nextProduced.set(id, entry);
|
||||
store.producedArtifacts = nextProduced;
|
||||
const nextByName = new Map(store.resourceNameIndex);
|
||||
nextByName.set(name.toLowerCase(), entry);
|
||||
store.resourceNameIndex = nextByName;
|
||||
}
|
||||
|
||||
function registerDataTable(id: string, name = `Table ${id}`, projectId?: string) {
|
||||
const next = new Map(store.resourceRegistry);
|
||||
next.set(name.toLowerCase(), { type: 'data-table', id, name, projectId });
|
||||
store.resourceRegistry = next;
|
||||
const entry: ResourceEntry = { type: 'data-table', id, name, projectId };
|
||||
const nextProduced = new Map(store.producedArtifacts);
|
||||
nextProduced.set(id, entry);
|
||||
store.producedArtifacts = nextProduced;
|
||||
const nextByName = new Map(store.resourceNameIndex);
|
||||
nextByName.set(name.toLowerCase(), entry);
|
||||
store.resourceNameIndex = nextByName;
|
||||
}
|
||||
|
||||
function removeResource(key: string) {
|
||||
const next = new Map(store.resourceRegistry);
|
||||
next.delete(key);
|
||||
store.resourceRegistry = next;
|
||||
function removeResource(idOrName: string) {
|
||||
const nextProduced = new Map(store.producedArtifacts);
|
||||
const removed = nextProduced.get(idOrName);
|
||||
nextProduced.delete(idOrName);
|
||||
store.producedArtifacts = nextProduced;
|
||||
const nextByName = new Map(store.resourceNameIndex);
|
||||
if (removed) nextByName.delete(removed.name.toLowerCase());
|
||||
nextByName.delete(idOrName.toLowerCase());
|
||||
store.resourceNameIndex = nextByName;
|
||||
}
|
||||
|
||||
function simulatePushEvent(event: PushMessage) {
|
||||
|
|
|
|||
|
|
@ -62,13 +62,15 @@ function makeMessage(overrides: Partial<InstanceAiMessage> = {}): InstanceAiMess
|
|||
function createMockStore() {
|
||||
const messages = ref<InstanceAiMessage[]>([]) as Ref<InstanceAiMessage[]>;
|
||||
const isStreaming = ref(false);
|
||||
const resourceRegistry = ref(new Map<string, ResourceEntry>());
|
||||
const producedArtifacts = ref(new Map<string, ResourceEntry>());
|
||||
const resourceNameIndex = ref(new Map<string, ResourceEntry>());
|
||||
const threadMetadata = new Map<string, Record<string, unknown>>();
|
||||
|
||||
return reactive({
|
||||
messages,
|
||||
isStreaming,
|
||||
resourceRegistry,
|
||||
producedArtifacts,
|
||||
resourceNameIndex,
|
||||
currentThreadId: 'thread-1',
|
||||
getThreadMetadata: (threadId: string) => threadMetadata.get(threadId),
|
||||
updateThreadMetadata: async (threadId: string, metadata: Record<string, unknown>) => {
|
||||
|
|
@ -80,20 +82,28 @@ function createMockStore() {
|
|||
type MockStore = ReturnType<typeof createMockStore>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registry helpers — populate the store's resourceRegistry so computed
|
||||
// Registry helpers — populate the store's producedArtifacts so computed
|
||||
// activeWorkflowId / activeDataTableId can derive values from tabs.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function registerWorkflow(store: MockStore, id: string, name = `Workflow ${id}`) {
|
||||
const next = new Map(store.resourceRegistry);
|
||||
next.set(name.toLowerCase(), { type: 'workflow', id, name });
|
||||
store.resourceRegistry = next;
|
||||
const entry: ResourceEntry = { type: 'workflow', id, name };
|
||||
const nextProduced = new Map(store.producedArtifacts);
|
||||
nextProduced.set(id, entry);
|
||||
store.producedArtifacts = nextProduced;
|
||||
const nextByName = new Map(store.resourceNameIndex);
|
||||
nextByName.set(name.toLowerCase(), entry);
|
||||
store.resourceNameIndex = nextByName;
|
||||
}
|
||||
|
||||
function registerDataTable(store: MockStore, id: string, name = `Table ${id}`, projectId?: string) {
|
||||
const next = new Map(store.resourceRegistry);
|
||||
next.set(name.toLowerCase(), { type: 'data-table', id, name, projectId });
|
||||
store.resourceRegistry = next;
|
||||
const entry: ResourceEntry = { type: 'data-table', id, name, projectId };
|
||||
const nextProduced = new Map(store.producedArtifacts);
|
||||
nextProduced.set(id, entry);
|
||||
store.producedArtifacts = nextProduced;
|
||||
const nextByName = new Map(store.resourceNameIndex);
|
||||
nextByName.set(name.toLowerCase(), entry);
|
||||
store.resourceNameIndex = nextByName;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -270,9 +280,9 @@ describe('useCanvasPreview', () => {
|
|||
test('excludes credential entries', () => {
|
||||
const ctx = setup();
|
||||
const registry = new Map<string, ResourceEntry>();
|
||||
registry.set('wf', { type: 'workflow', id: 'wf-1', name: 'WF' });
|
||||
registry.set('cred', { type: 'credential', id: 'cred-1', name: 'Cred' });
|
||||
ctx.store.resourceRegistry = registry;
|
||||
registry.set('wf-1', { type: 'workflow', id: 'wf-1', name: 'WF' });
|
||||
registry.set('cred-1', { type: 'credential', id: 'cred-1', name: 'Cred' });
|
||||
ctx.store.producedArtifacts = registry;
|
||||
|
||||
expect(ctx.allArtifactTabs.value).toHaveLength(1);
|
||||
expect(ctx.allArtifactTabs.value[0].type).toBe('workflow');
|
||||
|
|
@ -686,7 +696,7 @@ describe('useCanvasPreview', () => {
|
|||
expect(ctx.activeDataTableId.value).toBeNull();
|
||||
});
|
||||
|
||||
test('looks up projectId from resourceRegistry', async () => {
|
||||
test('looks up projectId from producedArtifacts', async () => {
|
||||
const ctx = setup();
|
||||
ctx.store.isStreaming = true;
|
||||
registerDataTable(ctx.store, 'dt-1', 'Test Table', 'proj-42');
|
||||
|
|
@ -914,8 +924,8 @@ describe('useCanvasPreview', () => {
|
|||
|
||||
// Remove wf-2 from registry, keeping wf-1
|
||||
const next = new Map<string, ResourceEntry>();
|
||||
next.set('workflow wf-1', { type: 'workflow', id: 'wf-1', name: 'Workflow wf-1' });
|
||||
ctx.store.resourceRegistry = next;
|
||||
next.set('wf-1', { type: 'workflow', id: 'wf-1', name: 'Workflow wf-1' });
|
||||
ctx.store.producedArtifacts = next;
|
||||
await nextTick();
|
||||
|
||||
expect(ctx.activeTabId.value).toBe('wf-1');
|
||||
|
|
@ -928,7 +938,7 @@ describe('useCanvasPreview', () => {
|
|||
await nextTick();
|
||||
|
||||
// Temporarily empty registry (simulates race where registry hasn't been populated yet)
|
||||
ctx.store.resourceRegistry = new Map();
|
||||
ctx.store.producedArtifacts = new Map();
|
||||
await nextTick();
|
||||
|
||||
// Tab should remain set — guard skips when tabs are empty
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
InstanceAiToolCallState,
|
||||
} from '@n8n/api-types';
|
||||
import { useResourceRegistry } from '../useResourceRegistry';
|
||||
import type { ResourceEntry } from '../useResourceRegistry';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factories
|
||||
|
|
@ -47,16 +48,11 @@ function makeMessage(overrides: Partial<InstanceAiMessage> = {}): InstanceAiMess
|
|||
|
||||
function setup(workflowNameLookup?: (id: string) => string | undefined) {
|
||||
const messages = ref<InstanceAiMessage[]>([]);
|
||||
const { registry } = useResourceRegistry(() => messages.value, workflowNameLookup);
|
||||
return { messages, registry };
|
||||
}
|
||||
|
||||
/** Helper to find a registry entry by resource ID. */
|
||||
function findById(registry: Map<string, unknown>, id: string) {
|
||||
for (const entry of registry.values()) {
|
||||
if ((entry as { id: string }).id === id) return entry;
|
||||
}
|
||||
return undefined;
|
||||
const { producedArtifacts, resourceNameIndex } = useResourceRegistry(
|
||||
() => messages.value,
|
||||
workflowNameLookup,
|
||||
);
|
||||
return { messages, producedArtifacts, resourceNameIndex };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -64,9 +60,9 @@ function findById(registry: Map<string, unknown>, id: string) {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('useResourceRegistry', () => {
|
||||
describe('workflow registration', () => {
|
||||
describe('producedArtifacts — workflow registration', () => {
|
||||
test('registers workflow with workflowName from result', async () => {
|
||||
const { messages, registry } = setup();
|
||||
const { messages, producedArtifacts, resourceNameIndex } = setup();
|
||||
|
||||
messages.value = [
|
||||
makeMessage({
|
||||
|
|
@ -82,13 +78,14 @@ describe('useResourceRegistry', () => {
|
|||
];
|
||||
await nextTick();
|
||||
|
||||
expect(registry.value.get('my workflow')).toEqual(
|
||||
expect(producedArtifacts.value.get('wf-1')).toEqual(
|
||||
expect.objectContaining({ type: 'workflow', id: 'wf-1', name: 'My Workflow' }),
|
||||
);
|
||||
expect(resourceNameIndex.value.get('my workflow')?.id).toBe('wf-1');
|
||||
});
|
||||
|
||||
test('falls back to args.name when result has no workflowName', async () => {
|
||||
const { messages, registry } = setup();
|
||||
const { messages, producedArtifacts } = setup();
|
||||
|
||||
messages.value = [
|
||||
makeMessage({
|
||||
|
|
@ -105,13 +102,13 @@ describe('useResourceRegistry', () => {
|
|||
];
|
||||
await nextTick();
|
||||
|
||||
expect(registry.value.get('from args')).toEqual(
|
||||
expect(producedArtifacts.value.get('wf-2')).toEqual(
|
||||
expect.objectContaining({ type: 'workflow', id: 'wf-2', name: 'From Args' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to Untitled when neither workflowName nor args.name is present', async () => {
|
||||
const { messages, registry } = setup();
|
||||
const { messages, producedArtifacts } = setup();
|
||||
|
||||
messages.value = [
|
||||
makeMessage({
|
||||
|
|
@ -128,15 +125,13 @@ describe('useResourceRegistry', () => {
|
|||
];
|
||||
await nextTick();
|
||||
|
||||
// Keyed by workflowId when unnamed (avoids collisions between multiple unnamed workflows)
|
||||
const entry = registry.value.get('wf-3');
|
||||
expect(entry).toEqual(
|
||||
expect(producedArtifacts.value.get('wf-3')).toEqual(
|
||||
expect.objectContaining({ type: 'workflow', id: 'wf-3', name: 'Untitled' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('does not collide when multiple workflows have no name', async () => {
|
||||
const { messages, registry } = setup();
|
||||
const { messages, producedArtifacts } = setup();
|
||||
|
||||
messages.value = [
|
||||
makeMessage({
|
||||
|
|
@ -158,66 +153,196 @@ describe('useResourceRegistry', () => {
|
|||
];
|
||||
await nextTick();
|
||||
|
||||
// Both should be registered, keyed by their IDs
|
||||
expect(findById(registry.value, 'wf-a')).toEqual(
|
||||
expect.objectContaining({ type: 'workflow', id: 'wf-a', name: 'Untitled' }),
|
||||
);
|
||||
expect(findById(registry.value, 'wf-b')).toEqual(
|
||||
expect.objectContaining({ type: 'workflow', id: 'wf-b', name: 'Untitled' }),
|
||||
);
|
||||
expect(producedArtifacts.value.get('wf-a')?.id).toBe('wf-a');
|
||||
expect(producedArtifacts.value.get('wf-b')?.id).toBe('wf-b');
|
||||
});
|
||||
});
|
||||
|
||||
test('registers workflow from build-workflow-with-agent child calls', async () => {
|
||||
const { messages, registry } = setup();
|
||||
|
||||
// Simulates the real scenario: parent is build-workflow-with-agent,
|
||||
// child create call has name but no workflowId,
|
||||
// child patch call has workflowId but no name.
|
||||
const childCreate = makeAgentNode({
|
||||
agentId: 'builder',
|
||||
toolCalls: [
|
||||
makeToolCall({
|
||||
toolCallId: 'tc-create',
|
||||
toolName: 'build-workflow',
|
||||
args: { name: 'Insert Random City Data' },
|
||||
result: { success: true, errors: [] },
|
||||
}),
|
||||
makeToolCall({
|
||||
toolCallId: 'tc-patch',
|
||||
toolName: 'build-workflow',
|
||||
args: { patches: [{ op: 'replace' }] },
|
||||
result: { success: true, workflowId: 'wf-built' },
|
||||
}),
|
||||
],
|
||||
});
|
||||
describe('producedArtifacts — updates and merges', () => {
|
||||
test('second write to the same workflow id updates the existing entry', async () => {
|
||||
const { messages, producedArtifacts } = setup();
|
||||
|
||||
messages.value = [
|
||||
makeMessage({
|
||||
agentTree: makeAgentNode({
|
||||
toolCalls: [
|
||||
makeToolCall({
|
||||
toolName: 'build-workflow-with-agent',
|
||||
result: { result: 'done', taskId: 'task-1' },
|
||||
toolCallId: 'tc-1',
|
||||
toolName: 'build-workflow',
|
||||
args: { name: 'Initial' },
|
||||
result: { workflowId: 'wf-1' },
|
||||
}),
|
||||
makeToolCall({
|
||||
toolCallId: 'tc-2',
|
||||
toolName: 'submit-workflow',
|
||||
result: { workflowId: 'wf-1', workflowName: 'Renamed' },
|
||||
}),
|
||||
],
|
||||
children: [childCreate],
|
||||
}),
|
||||
}),
|
||||
];
|
||||
await nextTick();
|
||||
|
||||
// The patch call registers the workflow with 'Untitled' fallback
|
||||
// (since its args only have patches, not name)
|
||||
const entry = findById(registry.value, 'wf-built');
|
||||
expect(entry).toBeDefined();
|
||||
expect((entry as { id: string }).id).toBe('wf-built');
|
||||
expect(producedArtifacts.value.size).toBe(1);
|
||||
expect(producedArtifacts.value.get('wf-1')?.name).toBe('Renamed');
|
||||
});
|
||||
|
||||
test('patch call without a name preserves the existing name (no regression to Untitled)', async () => {
|
||||
const { messages, producedArtifacts } = setup();
|
||||
|
||||
messages.value = [
|
||||
makeMessage({
|
||||
agentTree: makeAgentNode({
|
||||
toolCalls: [
|
||||
makeToolCall({
|
||||
toolCallId: 'tc-create',
|
||||
toolName: 'build-workflow',
|
||||
args: { name: 'Keep Me' },
|
||||
result: { workflowId: 'wf-1' },
|
||||
}),
|
||||
makeToolCall({
|
||||
toolCallId: 'tc-patch',
|
||||
toolName: 'build-workflow',
|
||||
args: { patches: [{ op: 'replace' }] },
|
||||
result: { success: true, workflowId: 'wf-1' },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
];
|
||||
await nextTick();
|
||||
|
||||
expect(producedArtifacts.value.get('wf-1')?.name).toBe('Keep Me');
|
||||
});
|
||||
|
||||
test('mutation result enriches an existing data-table entry with projectId', async () => {
|
||||
const { messages, producedArtifacts } = setup();
|
||||
|
||||
messages.value = [
|
||||
makeMessage({
|
||||
agentTree: makeAgentNode({
|
||||
toolCalls: [
|
||||
makeToolCall({
|
||||
toolName: 'data-tables',
|
||||
args: { action: 'create' },
|
||||
result: { table: { id: 'dt-1', name: 'Signups' } },
|
||||
}),
|
||||
makeToolCall({
|
||||
toolName: 'insert-data-table-rows',
|
||||
result: {
|
||||
insertedCount: 5,
|
||||
dataTableId: 'dt-1',
|
||||
tableName: 'Signups',
|
||||
projectId: 'proj-2',
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
];
|
||||
await nextTick();
|
||||
|
||||
expect(producedArtifacts.value.size).toBe(1);
|
||||
expect(producedArtifacts.value.get('dt-1')).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'data-table',
|
||||
id: 'dt-1',
|
||||
name: 'Signups',
|
||||
projectId: 'proj-2',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list results do not populate producedArtifacts', () => {
|
||||
test('workflows action=list result is indexed by name only, never in producedArtifacts', async () => {
|
||||
const { messages, producedArtifacts, resourceNameIndex } = setup();
|
||||
|
||||
messages.value = [
|
||||
makeMessage({
|
||||
agentTree: makeAgentNode({
|
||||
toolCalls: [
|
||||
makeToolCall({
|
||||
toolName: 'workflows',
|
||||
args: { action: 'list' },
|
||||
result: {
|
||||
workflows: [
|
||||
{ id: 'wf-list-1', name: 'Workspace Workflow 1' },
|
||||
{ id: 'wf-list-2', name: 'Workspace Workflow 2' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
];
|
||||
await nextTick();
|
||||
|
||||
expect(producedArtifacts.value.size).toBe(0);
|
||||
expect(resourceNameIndex.value.get('workspace workflow 1')?.id).toBe('wf-list-1');
|
||||
expect(resourceNameIndex.value.get('workspace workflow 2')?.id).toBe('wf-list-2');
|
||||
});
|
||||
|
||||
test('data-tables action=list result is indexed by name only', async () => {
|
||||
const { messages, producedArtifacts, resourceNameIndex } = setup();
|
||||
|
||||
messages.value = [
|
||||
makeMessage({
|
||||
agentTree: makeAgentNode({
|
||||
toolCalls: [
|
||||
makeToolCall({
|
||||
toolName: 'data-tables',
|
||||
args: { action: 'list' },
|
||||
result: {
|
||||
tables: [{ id: 'dt-a', name: 'Existing Table' }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
];
|
||||
await nextTick();
|
||||
|
||||
expect(producedArtifacts.value.size).toBe(0);
|
||||
expect(resourceNameIndex.value.get('existing table')?.id).toBe('dt-a');
|
||||
});
|
||||
|
||||
test('a later write promotes a previously-listed resource into producedArtifacts', async () => {
|
||||
const { messages, producedArtifacts, resourceNameIndex } = setup();
|
||||
|
||||
messages.value = [
|
||||
makeMessage({
|
||||
agentTree: makeAgentNode({
|
||||
toolCalls: [
|
||||
makeToolCall({
|
||||
toolName: 'workflows',
|
||||
args: { action: 'list' },
|
||||
result: { workflows: [{ id: 'wf-1', name: 'Existing' }] },
|
||||
}),
|
||||
makeToolCall({
|
||||
toolName: 'build-workflow',
|
||||
args: { patches: [{ op: 'replace' }] },
|
||||
result: { workflowId: 'wf-1' },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
];
|
||||
await nextTick();
|
||||
|
||||
expect(producedArtifacts.value.size).toBe(1);
|
||||
// Produced entry keeps the 'Existing' name via fallback-to-untitled
|
||||
// avoidance — the patch call has no name of its own.
|
||||
expect(producedArtifacts.value.get('wf-1')?.name).toBe('Untitled');
|
||||
// Name index still resolves 'existing'
|
||||
expect(resourceNameIndex.value.get('existing')?.id).toBe('wf-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('workflowNameLookup enrichment', () => {
|
||||
test('enriches fallback name from store lookup', async () => {
|
||||
const lookup = (id: string) => (id === 'wf-3' ? 'Insert Random City Data' : undefined);
|
||||
const { messages, registry } = setup(lookup);
|
||||
const { messages, producedArtifacts, resourceNameIndex } = setup(lookup);
|
||||
|
||||
messages.value = [
|
||||
makeMessage({
|
||||
|
|
@ -234,21 +359,14 @@ describe('useResourceRegistry', () => {
|
|||
];
|
||||
await nextTick();
|
||||
|
||||
// Should be keyed by the enriched name, not the workflowId
|
||||
expect(registry.value.has('wf-3')).toBe(false);
|
||||
const entry = registry.value.get('insert random city data');
|
||||
expect(entry).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'workflow',
|
||||
id: 'wf-3',
|
||||
name: 'Insert Random City Data',
|
||||
}),
|
||||
);
|
||||
expect(producedArtifacts.value.get('wf-3')?.name).toBe('Insert Random City Data');
|
||||
expect(resourceNameIndex.value.get('insert random city data')?.id).toBe('wf-3');
|
||||
expect(resourceNameIndex.value.get('untitled')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('keeps original name when lookup returns undefined', async () => {
|
||||
const lookup = () => undefined;
|
||||
const { messages, registry } = setup(lookup);
|
||||
const { messages, producedArtifacts } = setup(lookup);
|
||||
|
||||
messages.value = [
|
||||
makeMessage({
|
||||
|
|
@ -264,38 +382,12 @@ describe('useResourceRegistry', () => {
|
|||
];
|
||||
await nextTick();
|
||||
|
||||
expect(registry.value.get('original name')).toEqual(
|
||||
expect.objectContaining({ name: 'Original Name' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('does not enrich when store name matches existing name', async () => {
|
||||
const lookup = (id: string) => (id === 'wf-5' ? 'My Workflow' : undefined);
|
||||
const { messages, registry } = setup(lookup);
|
||||
|
||||
messages.value = [
|
||||
makeMessage({
|
||||
agentTree: makeAgentNode({
|
||||
toolCalls: [
|
||||
makeToolCall({
|
||||
toolName: 'build-workflow',
|
||||
result: { workflowId: 'wf-5', workflowName: 'My Workflow' },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
];
|
||||
await nextTick();
|
||||
|
||||
// Key should remain the same
|
||||
expect(registry.value.get('my workflow')).toEqual(
|
||||
expect.objectContaining({ id: 'wf-5', name: 'My Workflow' }),
|
||||
);
|
||||
expect(producedArtifacts.value.get('wf-4')?.name).toBe('Original Name');
|
||||
});
|
||||
|
||||
test('does not enrich data-table entries', async () => {
|
||||
const lookup = () => 'Should Not Apply';
|
||||
const { messages, registry } = setup(lookup);
|
||||
const { messages, producedArtifacts } = setup(lookup);
|
||||
|
||||
messages.value = [
|
||||
makeMessage({
|
||||
|
|
@ -312,9 +404,7 @@ describe('useResourceRegistry', () => {
|
|||
];
|
||||
await nextTick();
|
||||
|
||||
expect(registry.value.get('cities1')).toEqual(
|
||||
expect.objectContaining({ type: 'data-table', name: 'cities1' }),
|
||||
);
|
||||
expect(producedArtifacts.value.get('dt-1')?.name).toBe('cities1');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -322,7 +412,7 @@ describe('useResourceRegistry', () => {
|
|||
test.each(['insert-data-table-rows', 'update-data-table-rows', 'delete-data-table-rows'])(
|
||||
'registers data table from %s result with name and projectId',
|
||||
async (toolName) => {
|
||||
const { messages, registry } = setup();
|
||||
const { messages, producedArtifacts } = setup();
|
||||
|
||||
messages.value = [
|
||||
makeMessage({
|
||||
|
|
@ -342,7 +432,7 @@ describe('useResourceRegistry', () => {
|
|||
];
|
||||
await nextTick();
|
||||
|
||||
const entry = registry.value.get('orders');
|
||||
const entry = producedArtifacts.value.get('dt-mut-1') as ResourceEntry;
|
||||
expect(entry).toEqual({
|
||||
type: 'data-table',
|
||||
id: 'dt-mut-1',
|
||||
|
|
@ -352,47 +442,8 @@ describe('useResourceRegistry', () => {
|
|||
},
|
||||
);
|
||||
|
||||
test('enriches existing registry entry with projectId from mutation result', async () => {
|
||||
const { messages, registry } = setup();
|
||||
|
||||
messages.value = [
|
||||
makeMessage({
|
||||
agentTree: makeAgentNode({
|
||||
toolCalls: [
|
||||
// First: create-data-table registers the table (without projectId)
|
||||
makeToolCall({
|
||||
toolName: 'create-data-table',
|
||||
result: { table: { id: 'dt-enrich', name: 'Signups' } },
|
||||
}),
|
||||
// Then: insert-data-table-rows adds projectId
|
||||
makeToolCall({
|
||||
toolName: 'insert-data-table-rows',
|
||||
result: {
|
||||
insertedCount: 5,
|
||||
dataTableId: 'dt-enrich',
|
||||
tableName: 'Signups',
|
||||
projectId: 'proj-2',
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
];
|
||||
await nextTick();
|
||||
|
||||
const entry = registry.value.get('signups');
|
||||
expect(entry).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'data-table',
|
||||
id: 'dt-enrich',
|
||||
name: 'Signups',
|
||||
projectId: 'proj-2',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('uses dataTableId as fallback name when tableName is missing', async () => {
|
||||
const { messages, registry } = setup();
|
||||
const { messages, producedArtifacts } = setup();
|
||||
|
||||
messages.value = [
|
||||
makeMessage({
|
||||
|
|
@ -412,8 +463,7 @@ describe('useResourceRegistry', () => {
|
|||
];
|
||||
await nextTick();
|
||||
|
||||
const entry = registry.value.get('dt-no-name');
|
||||
expect(entry).toEqual({
|
||||
expect(producedArtifacts.value.get('dt-no-name')).toEqual({
|
||||
type: 'data-table',
|
||||
id: 'dt-no-name',
|
||||
name: 'dt-no-name',
|
||||
|
|
|
|||
|
|
@ -46,10 +46,8 @@ const lastGroupIdx = computed(() => {
|
|||
});
|
||||
|
||||
function resolveArtifactName(artifact: ArtifactInfo): string {
|
||||
for (const entry of store.resourceRegistry.values()) {
|
||||
if (entry.id === artifact.resourceId) return entry.name;
|
||||
}
|
||||
return artifact.name;
|
||||
const entry = store.producedArtifacts.get(artifact.resourceId);
|
||||
return entry?.name ?? artifact.name;
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -28,10 +28,8 @@ const rootStore = useRootStore();
|
|||
|
||||
/** Resolve artifact name from the enriched registry (falls back to extracted name). */
|
||||
function resolveArtifactName(artifact: ArtifactInfo): string {
|
||||
for (const entry of store.resourceRegistry.values()) {
|
||||
if (entry.id === artifact.resourceId) return entry.name;
|
||||
}
|
||||
return artifact.name;
|
||||
const entry = store.producedArtifacts.get(artifact.resourceId);
|
||||
return entry?.name ?? artifact.name;
|
||||
}
|
||||
|
||||
function formatRelativeTime(isoTime: string): string {
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ const statusIconMap: Record<
|
|||
// --- Artifacts ---
|
||||
const artifacts = computed((): ResourceEntry[] => {
|
||||
const result: ResourceEntry[] = [];
|
||||
for (const entry of store.resourceRegistry.values()) {
|
||||
for (const entry of store.producedArtifacts.values()) {
|
||||
if (entry.type === 'workflow' || entry.type === 'data-table') {
|
||||
result.push(entry);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ const INTERNAL_BLOCK_PATTERN =
|
|||
/<(?:planning-blueprint|planned-task-follow-up|background-task-completed|running-tasks)[\s\S]*?<\/(?:planning-blueprint|planned-task-follow-up|background-task-completed|running-tasks)>/g;
|
||||
|
||||
const processedContent = computed(() => {
|
||||
const registry = store.resourceRegistry;
|
||||
const registry = store.resourceNameIndex;
|
||||
|
||||
// Strip internal protocol blocks the LLM may have echoed
|
||||
let result = props.content.replace(INTERNAL_BLOCK_PATTERN, '').trim();
|
||||
|
|
@ -149,10 +149,15 @@ function enhanceResourceLinks(): void {
|
|||
if (resourceMatch) {
|
||||
const [, type, id] = resourceMatch;
|
||||
|
||||
// Look up registry entry (needed for projectId on data-table links)
|
||||
// Look up registry entry (needed for projectId on data-table links).
|
||||
// Search the name index because it contains both produced and listed
|
||||
// resources — a user may click through to a data table the agent
|
||||
// only referenced via a list call.
|
||||
const registryEntry =
|
||||
type === 'data-table'
|
||||
? [...store.resourceRegistry.values()].find((r) => r.type === 'data-table' && r.id === id)
|
||||
? [...store.resourceNameIndex.values()].find(
|
||||
(r) => r.type === 'data-table' && r.id === id,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// Swap href to the real app URL (used for Cmd+click / new tab)
|
||||
|
|
|
|||
|
|
@ -160,9 +160,12 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
|
|||
const gatewayDirectory = computed(() => instanceAiSettingsStore.gatewayDirectory);
|
||||
const activeDirectory = computed(() => gatewayDirectory.value);
|
||||
|
||||
// Resource registry — maps known resource names to their types & IDs
|
||||
// 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 { registry: resourceRegistry } = useResourceRegistry(
|
||||
const { producedArtifacts, resourceNameIndex } = useResourceRegistry(
|
||||
() => messages.value,
|
||||
(id) => workflowsListStore.getWorkflowById(id)?.name,
|
||||
);
|
||||
|
|
@ -1027,7 +1030,8 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
|
|||
activeDirectory,
|
||||
contextualSuggestion,
|
||||
currentTasks,
|
||||
resourceRegistry,
|
||||
producedArtifacts,
|
||||
resourceNameIndex,
|
||||
rateableResponseId,
|
||||
creditsRemaining,
|
||||
creditsPercentageRemaining,
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ export function useCanvasPreview({ store, route, workflowExecutions }: UseCanvas
|
|||
const result: ArtifactTab[] = [];
|
||||
const liveExecMap = workflowExecutions?.value;
|
||||
const historicalExecMap = historicalExecutions.value;
|
||||
for (const entry of store.resourceRegistry.values()) {
|
||||
for (const entry of store.producedArtifacts.values()) {
|
||||
if (entry.type === 'workflow' || entry.type === 'data-table') {
|
||||
// Live push event state takes priority over historical message data.
|
||||
// Historical data already has stale executions filtered out.
|
||||
|
|
|
|||
|
|
@ -18,18 +18,61 @@ export interface ResourceEntry {
|
|||
// Internal helpers (defined before use to satisfy no-use-before-define)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function registerResource(
|
||||
map: Map<string, ResourceEntry>,
|
||||
interface Collections {
|
||||
/** Resources produced/mutated by the agent in this thread, keyed by resource ID. */
|
||||
produced: Map<string, ResourceEntry>;
|
||||
/** Every resource seen in any tool call, keyed by lowercased name — for markdown linking. */
|
||||
byName: Map<string, ResourceEntry>;
|
||||
}
|
||||
|
||||
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
|
||||
? {
|
||||
type: entry.type,
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
createdAt: entry.createdAt ?? existing.createdAt,
|
||||
updatedAt: entry.updatedAt ?? existing.updatedAt,
|
||||
projectId: entry.projectId ?? existing.projectId,
|
||||
}
|
||||
: entry;
|
||||
col.produced.set(entry.id, merged);
|
||||
if (existing && existing.name.toLowerCase() !== merged.name.toLowerCase()) {
|
||||
col.byName.delete(existing.name.toLowerCase());
|
||||
}
|
||||
col.byName.set(merged.name.toLowerCase(), merged);
|
||||
}
|
||||
|
||||
function indexByName(col: Collections, entry: ResourceEntry): void {
|
||||
col.byName.set(entry.name.toLowerCase(), entry);
|
||||
}
|
||||
|
||||
function entryFromListItem(
|
||||
type: ResourceEntry['type'],
|
||||
obj: Record<string, unknown>,
|
||||
): void {
|
||||
if (typeof obj.name === 'string' && typeof obj.id === 'string') {
|
||||
const entry: ResourceEntry = { type, id: obj.id, name: obj.name };
|
||||
if (typeof obj.createdAt === 'string') entry.createdAt = obj.createdAt;
|
||||
if (typeof obj.updatedAt === 'string') entry.updatedAt = obj.updatedAt;
|
||||
if (typeof obj.projectId === 'string') entry.projectId = obj.projectId;
|
||||
map.set(obj.name.toLowerCase(), entry);
|
||||
}
|
||||
): ResourceEntry | undefined {
|
||||
if (typeof obj.name !== 'string' || typeof obj.id !== 'string') return undefined;
|
||||
const entry: ResourceEntry = { type, id: obj.id, name: obj.name };
|
||||
const createdAt = optionalString(obj.createdAt);
|
||||
const updatedAt = optionalString(obj.updatedAt);
|
||||
const projectId = optionalString(obj.projectId);
|
||||
if (createdAt !== undefined) entry.createdAt = createdAt;
|
||||
if (updatedAt !== undefined) entry.updatedAt = updatedAt;
|
||||
if (projectId !== undefined) entry.projectId = projectId;
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** Tools whose results may contain resource info (workflows, credentials, data tables). */
|
||||
|
|
@ -47,97 +90,127 @@ const ARTIFACT_TOOLS = new Set([
|
|||
'delete-data-table-rows',
|
||||
]);
|
||||
|
||||
function extractFromToolCall(tc: InstanceAiToolCallState, map: Map<string, ResourceEntry>): void {
|
||||
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<string, unknown>;
|
||||
|
||||
// --- Workflows --------------------------------------------------------
|
||||
// Tool results that list workflows: { workflows: [{ id, name }, ...] }
|
||||
// List result: { workflows: [{ id, name }, ...] } — index by name only.
|
||||
if (Array.isArray(result.workflows)) {
|
||||
for (const wf of result.workflows as Array<Record<string, unknown>>) {
|
||||
registerResource(map, 'workflow', wf);
|
||||
const entry = entryFromListItem('workflow', wf);
|
||||
if (entry) indexByName(col, entry);
|
||||
}
|
||||
}
|
||||
|
||||
// build-workflow / build-workflow-with-agent result:
|
||||
// { workflowId: "...", workflowName?: "..." }
|
||||
// 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 =
|
||||
typeof result.workflowName === 'string'
|
||||
? result.workflowName
|
||||
: typeof tc.args?.name === 'string'
|
||||
? tc.args.name
|
||||
: undefined;
|
||||
|
||||
const resolvedName = name ?? 'Untitled';
|
||||
// Key by workflowId when unnamed to avoid collisions between multiple
|
||||
// unnamed workflows that would all map to the "untitled" key.
|
||||
const key = name ? name.toLowerCase() : result.workflowId;
|
||||
map.set(key, {
|
||||
type: 'workflow',
|
||||
id: result.workflowId,
|
||||
name: resolvedName,
|
||||
});
|
||||
optionalString(result.workflowName) ??
|
||||
optionalString(tc.args?.name) ??
|
||||
existing?.name ??
|
||||
'Untitled';
|
||||
recordProduced(col, { type: 'workflow', id: result.workflowId, name });
|
||||
}
|
||||
|
||||
// Single workflow object: { workflow: { id, name } }
|
||||
// Single workflow object: { workflow: { id, name, ... } } — produced.
|
||||
if (result.workflow && typeof result.workflow === 'object') {
|
||||
registerResource(map, 'workflow', result.workflow as Record<string, unknown>);
|
||||
const obj = result.workflow as Record<string, unknown>;
|
||||
if (typeof obj.id === 'string') {
|
||||
const existing = col.produced.get(obj.id);
|
||||
const name = optionalString(obj.name) ?? existing?.name ?? 'Untitled';
|
||||
const entry: ResourceEntry = { type: 'workflow', id: obj.id, name };
|
||||
const createdAt = optionalString(obj.createdAt);
|
||||
const updatedAt = optionalString(obj.updatedAt);
|
||||
const projectId = optionalString(obj.projectId);
|
||||
if (createdAt !== undefined) entry.createdAt = createdAt;
|
||||
if (updatedAt !== undefined) entry.updatedAt = updatedAt;
|
||||
if (projectId !== undefined) entry.projectId = projectId;
|
||||
recordProduced(col, entry);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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<Record<string, unknown>>) {
|
||||
registerResource(map, 'credential', cred);
|
||||
const entry = entryFromListItem('credential', cred);
|
||||
if (entry) indexByName(col, entry);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Data tables -----------------------------------------------------
|
||||
// List results — index by name only.
|
||||
if (Array.isArray(result.tables)) {
|
||||
for (const table of result.tables as Array<Record<string, unknown>>) {
|
||||
registerResource(map, 'data-table', table);
|
||||
const entry = entryFromListItem('data-table', table);
|
||||
if (entry) indexByName(col, entry);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(result.dataTables)) {
|
||||
for (const table of result.dataTables as Array<Record<string, unknown>>) {
|
||||
registerResource(map, 'data-table', table);
|
||||
const entry = entryFromListItem('data-table', table);
|
||||
if (entry) indexByName(col, entry);
|
||||
}
|
||||
}
|
||||
|
||||
// Singular data table (e.g. create-data-table result)
|
||||
// Singular data table (e.g. data-tables action=create) — produced.
|
||||
if (result.table && typeof result.table === 'object') {
|
||||
registerResource(map, 'data-table', result.table as Record<string, unknown>);
|
||||
const obj = result.table as Record<string, unknown>;
|
||||
if (typeof obj.id === 'string') {
|
||||
const existing = col.produced.get(obj.id);
|
||||
const name = optionalString(obj.name) ?? existing?.name ?? obj.id;
|
||||
const entry: ResourceEntry = { type: 'data-table', id: obj.id, name };
|
||||
const createdAt = optionalString(obj.createdAt);
|
||||
const updatedAt = optionalString(obj.updatedAt);
|
||||
const projectId = optionalString(obj.projectId);
|
||||
if (createdAt !== undefined) entry.createdAt = createdAt;
|
||||
if (updatedAt !== undefined) entry.updatedAt = updatedAt;
|
||||
if (projectId !== undefined) entry.projectId = projectId;
|
||||
recordProduced(col, entry);
|
||||
}
|
||||
}
|
||||
|
||||
// Data table mutation results (insert/update/delete-data-table-rows)
|
||||
// These return { dataTableId, projectId } without a nested table object.
|
||||
// Merge projectId into existing registry entry or create a minimal one.
|
||||
// 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 existingEntry = [...map.values()].find(
|
||||
(e) => e.type === 'data-table' && e.id === result.dataTableId,
|
||||
);
|
||||
if (existingEntry) {
|
||||
existingEntry.projectId = result.projectId as string;
|
||||
} else {
|
||||
const tableName =
|
||||
typeof result.tableName === 'string' ? result.tableName : (result.dataTableId as string);
|
||||
map.set(tableName.toLowerCase(), {
|
||||
type: 'data-table',
|
||||
id: result.dataTableId as string,
|
||||
name: tableName,
|
||||
projectId: result.projectId as string,
|
||||
});
|
||||
}
|
||||
const existing = col.produced.get(result.dataTableId);
|
||||
const name = optionalString(result.tableName) ?? existing?.name ?? result.dataTableId;
|
||||
recordProduced(col, {
|
||||
type: 'data-table',
|
||||
id: result.dataTableId,
|
||||
name,
|
||||
projectId: result.projectId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function collectFromAgentNode(node: InstanceAiAgentNode, map: Map<string, ResourceEntry>): void {
|
||||
function collectFromAgentNode(node: InstanceAiAgentNode, col: Collections): void {
|
||||
for (const tc of node.toolCalls) {
|
||||
extractFromToolCall(tc, map);
|
||||
extractFromToolCall(tc, col);
|
||||
}
|
||||
for (const child of node.children) {
|
||||
collectFromAgentNode(child, map);
|
||||
collectFromAgentNode(child, col);
|
||||
}
|
||||
}
|
||||
|
||||
function enrichWorkflowNames(
|
||||
col: Collections,
|
||||
workflowNameLookup: (id: string) => string | undefined,
|
||||
): void {
|
||||
for (const entry of col.produced.values()) {
|
||||
if (entry.type !== 'workflow') continue;
|
||||
const storeName = workflowNameLookup(entry.id);
|
||||
if (storeName && storeName !== entry.name) {
|
||||
col.byName.delete(entry.name.toLowerCase());
|
||||
entry.name = storeName;
|
||||
col.byName.set(storeName.toLowerCase(), entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -146,39 +219,42 @@ function collectFromAgentNode(node: InstanceAiAgentNode, map: Map<string, Resour
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Scans all tool-call results in the conversation to build a registry of
|
||||
* known resource names (workflows, credentials, data tables) and their IDs.
|
||||
* Scans tool-call results in the conversation and returns two collections:
|
||||
*
|
||||
* The registry key is the **lowercase** name so lookups during markdown
|
||||
* post-processing are case-insensitive.
|
||||
* - `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,
|
||||
) {
|
||||
const registry = computed(() => {
|
||||
const map = new Map<string, ResourceEntry>();
|
||||
const collections = computed((): Collections => {
|
||||
const col: Collections = {
|
||||
produced: new Map<string, ResourceEntry>(),
|
||||
byName: new Map<string, ResourceEntry>(),
|
||||
};
|
||||
|
||||
for (const msg of messages()) {
|
||||
if (!msg.agentTree) continue;
|
||||
collectFromAgentNode(msg.agentTree, map);
|
||||
collectFromAgentNode(msg.agentTree, col);
|
||||
}
|
||||
|
||||
// Enrich workflow names from the store when the registry only has a
|
||||
// fallback (e.g. 'Untitled' from a patch-only build-workflow call).
|
||||
if (workflowNameLookup) {
|
||||
for (const [key, entry] of map) {
|
||||
if (entry.type !== 'workflow') continue;
|
||||
const storeName = workflowNameLookup(entry.id);
|
||||
if (storeName && storeName !== entry.name) {
|
||||
map.delete(key);
|
||||
map.set(storeName.toLowerCase(), { ...entry, name: storeName });
|
||||
}
|
||||
}
|
||||
enrichWorkflowNames(col, workflowNameLookup);
|
||||
}
|
||||
|
||||
return map;
|
||||
return col;
|
||||
});
|
||||
|
||||
return { registry };
|
||||
return {
|
||||
producedArtifacts: computed(() => collections.value.produced),
|
||||
resourceNameIndex: computed(() => collections.value.byName),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue