From cd419f94a93cc377155cbaebae61c0b4b45edf67 Mon Sep 17 00:00:00 2001 From: Christopher Date: Wed, 24 Jun 2026 13:42:11 +1000 Subject: [PATCH] fix(dashboard): overlay git artifacts into the existing run file tree Files stored in agentv/artifacts/v1 (trace.json, transcript.jsonl) were rendered as a duplicate parallel tree rooted at the suite/test dir instead of overlaying under the same folders as the local run artifacts. buildLocalResultArtifactTree roots the local tree at the artifacts' common dir, so top-level node names are basenames (outputs, task) while their path stays relative to the run-manifest dir. ensureCatalogFileNode previously nested git entries by splitting the full displayPath, so it failed to find the existing basename-named node and created a duplicate subtree. Fix: thread the common dir as rootPrefix, strip it from the displayPath for nesting segments (matching the local folder names) while keeping the full path for content reads. Adds overlayCatalogFileNodes + a regression test. Fixes EntityProcess/agentv-beads#12 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../commands/results/serve-file-tree.test.ts | 94 +++++++++++++++++++ apps/cli/src/commands/results/serve.ts | 91 ++++++++++++++---- 2 files changed, 167 insertions(+), 18 deletions(-) create mode 100644 apps/cli/src/commands/results/serve-file-tree.test.ts diff --git a/apps/cli/src/commands/results/serve-file-tree.test.ts b/apps/cli/src/commands/results/serve-file-tree.test.ts new file mode 100644 index 000000000..be1dc9c1b --- /dev/null +++ b/apps/cli/src/commands/results/serve-file-tree.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'bun:test'; + +import { type ArtifactCatalogEntry, type FileNode, overlayCatalogFileNodes } from './serve.js'; + +/** + * Reproduces the bug where git-stored `agentv/artifacts/v1` files were rendered + * in a duplicate parallel subtree instead of overlaying onto the existing tree. + * + * The local file tree is rooted at the artifacts' common dir, so its top-level + * nodes are named by basename (`outputs`) while their `path` stays relative to + * the run manifest dir (`//outputs`). A git catalog entry whose + * displayPath carries the full `//...` prefix must merge into that + * existing `outputs` folder, not create a second `//outputs` tree. + */ +function localTreeRootedAtTestDir(prefix: string): FileNode[] { + return [ + { + name: 'outputs', + path: `${prefix}/outputs`, + type: 'dir', + children: [ + { name: 'answer.md', path: `${prefix}/outputs/answer.md`, type: 'file', storage: 'local' }, + ], + }, + { name: 'grading.json', path: `${prefix}/grading.json`, type: 'file', storage: 'local' }, + ]; +} + +function gitTraceEntry(prefix: string): ArtifactCatalogEntry { + return { + displayPath: `${prefix}/outputs/trace.json`, + kind: 'trace', + storage: 'git', + ref: 'agentv/artifacts/v1', + key: `runs/default/2026-06-22T01-12-44-924Z/${prefix}/outputs/trace.json`, + }; +} + +function findByName(nodes: readonly FileNode[], name: string): FileNode | undefined { + return nodes.find((node) => node.name === name); +} + +describe('overlayCatalogFileNodes', () => { + const prefix = 'wtg-academy-n1-test/test-01-biosecurity'; + + it('overlays git artifacts into the existing folder instead of a duplicate subtree', () => { + const files = localTreeRootedAtTestDir(prefix); + overlayCatalogFileNodes(files, [gitTraceEntry(prefix)], prefix); + + // No duplicate `wtg-academy-n1-test` root node was created. + expect(findByName(files, 'wtg-academy-n1-test')).toBeUndefined(); + + // trace.json merged into the existing top-level `outputs` folder... + const outputs = findByName(files, 'outputs'); + expect(outputs?.type).toBe('dir'); + const trace = findByName(outputs?.children ?? [], 'trace.json'); + expect(trace).toBeDefined(); + // ...alongside the local answer.md, and with its full manifest-relative path + // preserved for content reads. + expect(findByName(outputs?.children ?? [], 'answer.md')).toBeDefined(); + expect(trace?.path).toBe(`${prefix}/outputs/trace.json`); + expect(trace?.storage).toBe('git'); + expect(trace?.ref).toBe('agentv/artifacts/v1'); + }); + + it('does not re-add local files already present in the tree', () => { + const files = localTreeRootedAtTestDir(prefix); + const localEntry: ArtifactCatalogEntry = { + displayPath: `${prefix}/grading.json`, + kind: 'artifact', + storage: 'local', + path: `${prefix}/grading.json`, + }; + overlayCatalogFileNodes(files, [localEntry], prefix); + + expect(files.filter((node) => node.name === 'grading.json')).toHaveLength(1); + expect(findByName(files, 'wtg-academy-n1-test')).toBeUndefined(); + }); + + it('falls back to full-path nesting when no root prefix applies', () => { + const files: FileNode[] = []; + const entry: ArtifactCatalogEntry = { + displayPath: 'outputs/trace.json', + kind: 'trace', + storage: 'git', + ref: 'agentv/artifacts/v1', + }; + overlayCatalogFileNodes(files, [entry], undefined); + + const outputs = findByName(files, 'outputs'); + expect(outputs?.type).toBe('dir'); + expect(findByName(outputs?.children ?? [], 'trace.json')?.path).toBe('outputs/trace.json'); + }); +}); diff --git a/apps/cli/src/commands/results/serve.ts b/apps/cli/src/commands/results/serve.ts index 1029475d2..596df8bcc 100644 --- a/apps/cli/src/commands/results/serve.ts +++ b/apps/cli/src/commands/results/serve.ts @@ -247,7 +247,7 @@ function writeFeedback(cwd: string, data: FeedbackData): void { // ── Shared utilities (used by handler functions) ───────────────────────── -interface FileNode { +export interface FileNode { name: string; path: string; type: 'file' | 'dir'; @@ -262,7 +262,7 @@ interface FileNode { type ArtifactCatalogStorage = 'local' | 'git'; -interface ArtifactCatalogEntry { +export interface ArtifactCatalogEntry { readonly displayPath: string; readonly kind: 'transcript' | 'trace' | 'answer' | 'artifact'; readonly storage: ArtifactCatalogStorage; @@ -316,22 +316,40 @@ function flattenFileTree(nodes: readonly FileNode[]): FileNode[] { return files; } -function ensureCatalogFileNode(root: FileNode[], entry: ArtifactCatalogEntry): void { - const segments = entry.displayPath.split('/').filter(Boolean); +export function ensureCatalogFileNode( + root: FileNode[], + entry: ArtifactCatalogEntry, + rootPrefix?: string, +): void { + // The local file tree is rooted at `rootPrefix` (the artifacts' common dir): + // its top-level node names are relative to that dir, but each node's `path` + // stays relative to the run manifest dir. To overlay a catalog entry into the + // SAME tree we must nest by the prefix-stripped segments (so we match the + // existing folder names) while still recording the full `path` for content + // reads. Skipping this is what made git artifacts form a duplicate subtree. + const fullPath = entry.displayPath; + const nestPath = + rootPrefix && fullPath.startsWith(`${rootPrefix}/`) + ? fullPath.slice(rootPrefix.length + 1) + : fullPath; + const usePrefix = nestPath !== fullPath ? rootPrefix : undefined; + + const segments = nestPath.split('/').filter(Boolean); if (segments.length === 0) return; let siblings = root; - let currentPath = ''; + let nestedPath = ''; for (let i = 0; i < segments.length; i += 1) { const segment = segments[i]; if (!segment) continue; - currentPath = currentPath ? `${currentPath}/${segment}` : segment; + nestedPath = nestedPath ? `${nestedPath}/${segment}` : segment; + const fullNodePath = usePrefix ? `${usePrefix}/${nestedPath}` : nestedPath; const isFile = i === segments.length - 1; let node = siblings.find((candidate) => candidate.name === segment); if (!node) { node = { name: segment, - path: currentPath, + path: fullNodePath, type: isFile ? 'file' : 'dir', ...(isFile ? { @@ -364,6 +382,25 @@ function ensureCatalogFileNode(root: FileNode[], entry: ArtifactCatalogEntry): v } } +/** + * Overlays catalog entries (notably git-stored `agentv/artifacts/v1` files) onto + * the local file tree. Git entries and any catalog entry not already present as + * a local file are inserted; `rootPrefix` keeps them rooted in the same tree. + */ +export function overlayCatalogFileNodes( + files: FileNode[], + catalog: readonly ArtifactCatalogEntry[], + rootPrefix?: string, +): FileNode[] { + const localFilePaths = new Set(flattenFileTree(files).map((file) => file.path)); + for (const entry of catalog) { + if (entry.storage === 'git' || !localFilePaths.has(entry.displayPath)) { + ensureCatalogFileNode(files, entry, rootPrefix); + } + } + return files; +} + function inferLanguage(filePath: string): string { const ext = path.extname(filePath).toLowerCase(); const langMap: Record = { @@ -768,22 +805,44 @@ function resultArtifactTreeRootPaths( ].filter((p, index, all): p is string => !!p && all.indexOf(p) === index); } -function buildLocalResultArtifactTree( - baseDir: string, +/** + * The directory (relative to the run manifest) that all of a test's artifacts + * share. The local file tree is rooted here, so its top-level nodes are named + * by their basename (e.g. `outputs`, `task`) while their `path` stays relative + * to the manifest dir. Git-stored catalog entries must be overlaid using this + * same prefix, otherwise they nest by their full path and produce a duplicate + * parallel subtree instead of merging into the existing folders. + */ +function artifactTreeCommonDir( record: ResultManifestRecord, catalog: readonly ArtifactCatalogEntry[], -): FileNode[] { +): string | undefined { const knownPaths = resultArtifactTreeRootPaths(record, catalog); - if (knownPaths.length === 0) return []; + if (knownPaths.length === 0) return undefined; const artifactDirs = knownPaths.map((p) => path.dirname(p)); let commonDir = artifactDirs[0]; for (const dir of artifactDirs) { while (!dir.startsWith(commonDir)) { - commonDir = path.dirname(commonDir); + const parent = path.dirname(commonDir); + if (parent === commonDir) break; + commonDir = parent; } } + return commonDir === '.' || commonDir === '' ? undefined : commonDir; +} +function buildLocalResultArtifactTree( + baseDir: string, + record: ResultManifestRecord, + catalog: readonly ArtifactCatalogEntry[], +): FileNode[] { + const commonDir = artifactTreeCommonDir(record, catalog); + if (commonDir === undefined) { + return resultArtifactTreeRootPaths(record, catalog).length === 0 + ? [] + : buildFileTree(baseDir, baseDir); + } return buildFileTree(path.join(baseDir, commonDir), baseDir); } @@ -1614,12 +1673,8 @@ async function handleEvalFiles(c: C, { searchDir, projectId }: DataContext) { runPath: relativeRunPathFromManifestPath(meta.path), }); const files = buildLocalResultArtifactTree(baseDir, record, catalog); - const localFilePaths = new Set(flattenFileTree(files).map((file) => file.path)); - for (const entry of catalog) { - if (entry.storage === 'git' || !localFilePaths.has(entry.displayPath)) { - ensureCatalogFileNode(files, entry); - } - } + const rootPrefix = artifactTreeCommonDir(record, catalog); + overlayCatalogFileNodes(files, catalog, rootPrefix); return c.json({ files }); } catch { return c.json({ error: 'Failed to load file tree' }, 500);