diff --git a/apps/fabro-web/app/routes/run-artifacts.tsx b/apps/fabro-web/app/routes/run-artifacts.tsx index c44b6c633..a314973c9 100644 --- a/apps/fabro-web/app/routes/run-artifacts.tsx +++ b/apps/fabro-web/app/routes/run-artifacts.tsx @@ -1,14 +1,18 @@ import { useMemo } from "react"; import { useParams } from "react-router"; -import { ArrowDownTrayIcon, PaperClipIcon } from "@heroicons/react/24/outline"; +import { Disclosure, DisclosureButton, DisclosurePanel } from "@headlessui/react"; +import { ArrowDownTrayIcon, ChevronRightIcon, PaperClipIcon } from "@heroicons/react/24/outline"; import type { RunArtifactEntry } from "@qltysh/fabro-api-client"; import { EmptyState, ErrorState, LoadingState } from "../components/state"; import { StageSidebar } from "../components/stage-sidebar"; import { stageArtifactDownloadUrl } from "../lib/api-client"; import { formatBytes } from "../lib/format"; +import { plural } from "../lib/plural"; import { useRunArtifacts, useRunStages } from "../lib/queries"; -import { formatStageLabel, mapRunStagesToSidebarStages } from "../lib/stage-sidebar"; +import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar"; +import type { ArtifactFile, ArtifactVersion } from "./run-artifacts/group"; +import { groupArtifactsByFile } from "./run-artifacts/group"; export const handle = { wide: true }; @@ -25,7 +29,12 @@ export default function RunArtifacts() {
- +
); @@ -34,86 +43,40 @@ export default function RunArtifacts() { function RunArtifactsBody({ runId, artifactsQuery, + stagesQuery, stages, }: { runId: string; artifactsQuery: ReturnType; + stagesQuery: ReturnType; stages: ReturnType; }) { - if (artifactsQuery.error) { + const error = artifactsQuery.error ?? stagesQuery.error; + if (error) { return ( void artifactsQuery.mutate()} + description={errorMessage(error)} + onRetry={() => { + if (artifactsQuery.error) void artifactsQuery.mutate(); + if (stagesQuery.error) void stagesQuery.mutate(); + }} /> ); } - if (artifactsQuery.data === undefined) { + if (artifactsQuery.data === undefined || stagesQuery.data === undefined) { return ; } - const entries = artifactsQuery.data?.data ?? []; - if (entries.length === 0) { - return ( - - ); - } - return ; + return ( + + ); } -interface StageGroup { - key: string; - stageId: string; - retry: number; - label: string; - entries: RunArtifactEntry[]; - totalBytes: number; -} - -function groupArtifacts( - entries: readonly RunArtifactEntry[], - stages: ReturnType, -): StageGroup[] { - const stageLabels = new Map(); - for (const stage of stages) { - stageLabels.set(stage.id, formatStageLabel(stage)); - } - - const groups = new Map(); - for (const entry of entries) { - const key = `${entry.stage_id}#${entry.retry}`; - const existing = groups.get(key); - if (existing) { - existing.entries.push(entry); - existing.totalBytes += entry.size; - } else { - groups.set(key, { - key, - stageId: entry.stage_id, - retry: entry.retry, - label: stageLabels.get(entry.stage_id) ?? entry.node_slug, - entries: [entry], - totalBytes: entry.size, - }); - } - } - - for (const group of groups.values()) { - group.entries.sort((a, b) => a.relative_path.localeCompare(b.relative_path)); - } - const sortedGroups = Array.from(groups.values()); - sortedGroups.sort((a, b) => { - const labelCmp = a.label.localeCompare(b.label); - return labelCmp !== 0 ? labelCmp : a.retry - b.retry; - }); - return sortedGroups; -} - -function ArtifactList({ +function ArtifactFiles({ runId, entries, stages, @@ -122,97 +85,209 @@ function ArtifactList({ entries: readonly RunArtifactEntry[]; stages: ReturnType; }) { - const groups = useMemo(() => groupArtifacts(entries, stages), [entries, stages]); - const totalBytes = useMemo( - () => entries.reduce((sum, entry) => sum + entry.size, 0), - [entries], - ); + const files = useMemo(() => groupArtifactsByFile(entries, stages), [entries, stages]); + + if (files.length === 0) { + return ( + + ); + } + return ; +} + +function ArtifactList({ runId, files }: { runId: string; files: readonly ArtifactFile[] }) { + const { captures, latestBytes, storedBytes } = useMemo(() => { + let captures = 0; + let latestBytes = 0; + let storedBytes = 0; + for (const file of files) { + captures += file.versions.length; + latestBytes += file.versions[0].size; + for (const version of file.versions) storedBytes += version.size; + } + return { captures, latestBytes, storedBytes }; + }, [files]); + + // Only mention versions once some file actually has more than one. + const versioned = captures > files.length; return (
-
+

- {entries.length} {entries.length === 1 ? "artifact" : "artifacts"} + {files.length} {plural(files.length, "file", "files")} + {versioned && ( + + {" "} + · {captures} {plural(captures, "version", "versions")} + + )}

- - {formatBytes(totalBytes)} total + + {versioned + ? `${formatBytes(latestBytes)} latest · ${formatBytes(storedBytes)} stored` + : `${formatBytes(latestBytes)} total`}
- {groups.map((group) => ( - - ))} +
+ {files.map((file) => ( + + ))} +
); } -function StageGroupCard({ runId, group }: { runId: string; group: StageGroup }) { +function ArtifactFileRow({ runId, file }: { runId: string; file: ArtifactFile }) { + const hasEarlier = file.versions.length > 1; + const latest = file.versions[0]; + return ( -
-
-
-

{group.label}

- {group.retry > 0 && ( - - retry {group.retry} + + {({ open }) => ( + <> +
+ {hasEarlier ? ( + + + {open ? "Hide" : "Show"} earlier versions of {file.name} + + + ) : ( +
+ + {hasEarlier && ( + + + )} -
- - {group.entries.length} {group.entries.length === 1 ? "file" : "files"} - {" · "} - {formatBytes(group.totalBytes)} - -
-
    - {group.entries.map((entry) => ( - - ))} -
-
+ + )} + ); } -function ArtifactRow({ runId, entry }: { runId: string; entry: RunArtifactEntry }) { +function EarlierVersions({ runId, file }: { runId: string; file: ArtifactFile }) { + return ( + <> + {file.versions.map((version, index) => + index === 0 ? null : ( +
  • + + + + + {formatBytes(version.size)} + + + +
  • + ), + )} + + ); +} + +function VersionLabel({ version }: { version: ArtifactVersion }) { + const attempt = attemptLabel(version); + return ( + <> + {version.stageLabel} + {attempt && {attempt}} + + ); +} + +function attemptLabel(version: ArtifactVersion): string | null { + return version.retry > 1 ? `attempt ${version.retry}` : null; +} + +function SizeDelta({ delta }: { delta: number | null }) { + if (delta === null) { + return first; + } + const tone = delta < 0 ? "text-amber" : "text-mint"; + const sign = delta < 0 ? "−" : "+"; + return ( + + {sign} + {formatBytes(Math.abs(delta))} + + ); +} + +function DownloadLink({ + runId, + file, + version, +}: { + runId: string; + file: ArtifactFile; + version: ArtifactVersion; +}) { const href = stageArtifactDownloadUrl( runId, - entry.stage_id, - entry.relative_path, - entry.retry, + version.stageId, + file.path, + version.retry, ); - + const attempt = attemptLabel(version); + const source = attempt ? `${version.stageLabel}, ${attempt}` : version.stageLabel; return ( -
  • - - {entry.relative_path} - - - {formatBytes(entry.size)} - - - -
  • + + ); } -function basename(path: string): string { - const idx = path.lastIndexOf("/"); - return idx >= 0 ? path.slice(idx + 1) : path; -} - function errorMessage(error: unknown): string | undefined { return error instanceof Error ? error.message : undefined; } diff --git a/apps/fabro-web/app/routes/run-artifacts/group.test.ts b/apps/fabro-web/app/routes/run-artifacts/group.test.ts new file mode 100644 index 000000000..72762c13e --- /dev/null +++ b/apps/fabro-web/app/routes/run-artifacts/group.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, test } from "bun:test"; +import { StageHandler, StageState } from "@qltysh/fabro-api-client"; +import type { RunArtifactEntry } from "@qltysh/fabro-api-client"; + +import type { Stage } from "../../lib/stage-sidebar"; +import { groupArtifactsByFile, splitArtifactPath } from "./group"; + +function stage(nodeId: string, startedAt: string | null, visit = 1): Stage { + return { + id: `${nodeId}@${visit}`, + name: nodeId, + handler: StageHandler.AGENT, + nodeId, + visit, + graphVisit: null, + resumedFromStageId: null, + status: StageState.SUCCEEDED, + duration: "1s", + startedAt, + providerUsed: null, + }; +} + +function artifact( + nodeSlug: string, + path: string, + size: number, + retry = 1, + visit = 1, +): RunArtifactEntry { + return { + stage_id: `${nodeSlug}@${visit}`, + node_slug: nodeSlug, + retry, + relative_path: path, + size, + }; +} + +/** Mirrors run 01KYJ8ZR0N: one report rewritten by four stages. */ +const REPORT = ".ai/reports/2026-07-27-wrk-002-instance-lifecycle.md"; + +const STAGES: Stage[] = [ + stage("start", "2026-07-27T17:12:08Z"), + stage("plan", "2026-07-27T17:21:44Z"), + stage("implement_plan", "2026-07-27T17:44:18Z"), + stage("simplify", "2026-07-27T18:43:11Z"), + stage("consolidate_reviews", "2026-07-27T19:44:26Z"), + stage("fix_review_findings", "2026-07-27T19:49:22Z"), +]; + +describe("splitArtifactPath", () => { + test("splits a nested path into directory prefix and filename", () => { + expect(splitArtifactPath(".ai/reports/run.md")).toEqual({ + dir: ".ai/reports/", + name: "run.md", + }); + }); + + test("leaves a root-level path without a directory", () => { + expect(splitArtifactPath("README.md")).toEqual({ dir: "", name: "README.md" }); + }); +}); + +describe("groupArtifactsByFile", () => { + test("collapses repeated captures of one path into a single file", () => { + const files = groupArtifactsByFile( + [ + artifact("consolidate_reviews", REPORT, 14323), + artifact("fix_review_findings", REPORT, 17483), + artifact("implement_plan", REPORT, 8422), + artifact("simplify", REPORT, 13162), + ], + STAGES, + ); + + expect(files).toHaveLength(1); + expect(files[0].path).toBe(REPORT); + expect(files[0].dir).toBe(".ai/reports/"); + expect(files[0].name).toBe("2026-07-27-wrk-002-instance-lifecycle.md"); + expect(files[0].versions).toHaveLength(4); + }); + + test("orders versions newest first using the API stage order", () => { + const stages = [ + stage("implement_plan", "2026-07-27T20:00:00Z"), + stage("simplify", "2026-07-27T18:43:11Z"), + ]; + const files = groupArtifactsByFile( + [ + artifact("implement_plan", REPORT, 8422), + artifact("simplify", REPORT, 13162), + ], + stages, + ); + + expect(files[0].versions.map((v) => v.stageLabel)).toEqual([ + "simplify", + "implement_plan", + ]); + expect(files[0].versions[0].size).toBe(13162); + }); + + test.each([ + ["equal", "2026-07-27T18:43:11Z", "2026-07-27T18:43:11Z"], + ["missing", null, null], + ])("preserves API order when stage timestamps are %s", (_case, firstAt, secondAt) => { + const stages = [ + stage("implement_plan", firstAt), + stage("simplify", secondAt), + ]; + const files = groupArtifactsByFile( + [ + artifact("simplify", REPORT, 13162), + artifact("implement_plan", REPORT, 8422), + ], + stages, + ); + + expect(files[0].versions.map((v) => v.stageLabel)).toEqual([ + "simplify", + "implement_plan", + ]); + }); + + test("reports the byte change each capture introduced, oldest capture first", () => { + const files = groupArtifactsByFile( + [ + artifact("implement_plan", REPORT, 8422), + artifact("simplify", REPORT, 13162), + artifact("consolidate_reviews", REPORT, 14323), + artifact("fix_review_findings", REPORT, 17483), + ], + STAGES, + ); + + // versions are newest-first, so deltas read 17483-14323, 14323-13162, ... + expect(files[0].versions.map((v) => v.delta)).toEqual([3160, 1161, 4740, null]); + }); + + test("drops captures from graph control nodes", () => { + const files = groupArtifactsByFile( + [ + artifact("start", ".ai/reports/pre-existing.md", 12402), + artifact("plan", ".ai/plans/plan.md", 21749), + ], + STAGES, + ); + + expect(files.map((file) => file.path)).toEqual([".ai/plans/plan.md"]); + }); + + test("sorts files by their most recent capture", () => { + const files = groupArtifactsByFile( + [ + artifact("plan", ".ai/plans/plan.md", 21749), + artifact("fix_review_findings", REPORT, 17483), + artifact("simplify", ".ai/reviews/bugs.xml", 5231), + ], + STAGES, + ); + + expect(files.map((file) => file.path)).toEqual([ + REPORT, + ".ai/reviews/bugs.xml", + ".ai/plans/plan.md", + ]); + }); + + test("keeps retries of one stage as separate ordered versions", () => { + const files = groupArtifactsByFile( + [ + artifact("simplify", REPORT, 13162, 2), + artifact("simplify", REPORT, 9000, 1), + ], + STAGES, + ); + + expect(files[0].versions.map((v) => v.retry)).toEqual([2, 1]); + expect(files[0].versions[0].size).toBe(13162); + expect(files[0].versions.map((v) => v.delta)).toEqual([4162, null]); + }); + + test("returns no files when every capture came from a control node", () => { + const files = groupArtifactsByFile( + [artifact("start", ".ai/reports/pre-existing.md", 12402)], + STAGES, + ); + + expect(files).toEqual([]); + }); +}); diff --git a/apps/fabro-web/app/routes/run-artifacts/group.ts b/apps/fabro-web/app/routes/run-artifacts/group.ts new file mode 100644 index 000000000..b3577e3c6 --- /dev/null +++ b/apps/fabro-web/app/routes/run-artifacts/group.ts @@ -0,0 +1,104 @@ +import type { RunArtifactEntry } from "@qltysh/fabro-api-client"; + +import { isVisibleStage } from "../../data/runs"; +import type { Stage } from "../../lib/stage-sidebar"; +import { formatStageLabel } from "../../lib/stage-sidebar"; + +/** One capture of a file, written by a single stage attempt. */ +export interface ArtifactVersion { + stageId: string; + stageLabel: string; + retry: number; + size: number; + /** Byte change this capture introduced; null for the first capture. */ + delta: number | null; +} + +/** One artifact path together with its capture history, newest first. */ +export interface ArtifactFile { + path: string; + /** Directory prefix including the trailing slash, or "" at the root. */ + dir: string; + name: string; + versions: readonly [ArtifactVersion, ...ArtifactVersion[]]; +} + +export function splitArtifactPath(path: string): { dir: string; name: string } { + const idx = path.lastIndexOf("/"); + return idx >= 0 + ? { dir: path.slice(0, idx + 1), name: path.slice(idx + 1) } + : { dir: "", name: path }; +} + +interface StageInfo { + label: string; + order: number; +} + +/** Stage display data keyed by ID, preserving the API's event order. */ +function stageInfoById(stages: readonly Stage[]): Map { + const info = new Map(); + stages.forEach((stage, order) => { + info.set(stage.id, { label: formatStageLabel(stage), order }); + }); + return info; +} + +/** + * Collapse raw `(stage, retry, path)` capture keys into one entry per file, + * carrying the ordered history of every capture of that path. + * + * Captures from graph control nodes (`start`, `exit`) are dropped: those nodes + * run no work, so anything they match is a pre-existing workspace file rather + * than something the run produced. + */ +export function groupArtifactsByFile( + entries: readonly RunArtifactEntry[], + stages: readonly Stage[], +): ArtifactFile[] { + const stageInfo = stageInfoById(stages); + const byPath = new Map(); + + for (const entry of entries) { + if (!isVisibleStage(entry.node_slug)) continue; + + const info = stageInfo.get(entry.stage_id); + const version: ArtifactVersion = { + stageId: entry.stage_id, + stageLabel: info?.label ?? entry.node_slug, + retry: entry.retry, + size: entry.size, + delta: null, + }; + const bucket = byPath.get(entry.relative_path); + if (bucket) bucket.push(version); + else byPath.set(entry.relative_path, [version]); + } + + const files: Array<{ file: ArtifactFile; order: number }> = []; + for (const [path, versions] of byPath) { + // Oldest first, so each version's delta is the change that capture introduced. + versions.sort( + (a, b) => + (stageInfo.get(a.stageId)?.order ?? -1) - + (stageInfo.get(b.stageId)?.order ?? -1) || + a.retry - b.retry || + a.stageId.localeCompare(b.stageId), + ); + versions.forEach((version, index) => { + version.delta = index === 0 ? null : version.size - versions[index - 1].size; + }); + + versions.reverse(); + const latest = versions[0]; + const { dir, name } = splitArtifactPath(path); + files.push({ + file: { path, dir, name, versions }, + order: stageInfo.get(latest.stageId)?.order ?? -1, + }); + } + + // Most recently written file first — the page answers "what just happened?". + files.sort((a, b) => b.order - a.order || a.file.path.localeCompare(b.file.path)); + return files.map((entry) => entry.file); +}