Merge pull request #659 from fabro-sh/feat/file-primary-artifacts-view

Show run artifacts by file with version history
This commit is contained in:
Bryan Helmkamp 2026-07-28 15:02:08 -04:00 committed by GitHub
commit f4384e9901
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 504 additions and 133 deletions

View file

@ -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() {
<div className="flex gap-6">
<StageSidebar stages={stages} runId={id!} activeLink="artifacts" />
<div className="min-w-0 flex-1">
<RunArtifactsBody runId={id!} artifactsQuery={artifactsQuery} stages={stages} />
<RunArtifactsBody
runId={id!}
artifactsQuery={artifactsQuery}
stagesQuery={stagesQuery}
stages={stages}
/>
</div>
</div>
);
@ -34,86 +43,40 @@ export default function RunArtifacts() {
function RunArtifactsBody({
runId,
artifactsQuery,
stagesQuery,
stages,
}: {
runId: string;
artifactsQuery: ReturnType<typeof useRunArtifacts>;
stagesQuery: ReturnType<typeof useRunStages>;
stages: ReturnType<typeof mapRunStagesToSidebarStages>;
}) {
if (artifactsQuery.error) {
const error = artifactsQuery.error ?? stagesQuery.error;
if (error) {
return (
<ErrorState
title="Couldn't load artifacts"
description={errorMessage(artifactsQuery.error)}
onRetry={() => 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 <LoadingState label="Loading artifacts…" />;
}
const entries = artifactsQuery.data?.data ?? [];
if (entries.length === 0) {
return (
<EmptyState
icon={PaperClipIcon}
title="No artifacts captured"
description="No stage in this run produced any artifacts."
/>
);
}
return <ArtifactList runId={runId} entries={entries} stages={stages} />;
return (
<ArtifactFiles
runId={runId}
entries={artifactsQuery.data?.data ?? []}
stages={stages}
/>
);
}
interface StageGroup {
key: string;
stageId: string;
retry: number;
label: string;
entries: RunArtifactEntry[];
totalBytes: number;
}
function groupArtifacts(
entries: readonly RunArtifactEntry[],
stages: ReturnType<typeof mapRunStagesToSidebarStages>,
): StageGroup[] {
const stageLabels = new Map<string, string>();
for (const stage of stages) {
stageLabels.set(stage.id, formatStageLabel(stage));
}
const groups = new Map<string, StageGroup>();
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<typeof mapRunStagesToSidebarStages>;
}) {
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 (
<EmptyState
icon={PaperClipIcon}
title="No artifacts captured"
description="No stage in this run produced any artifacts."
/>
);
}
return <ArtifactList runId={runId} files={files} />;
}
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 (
<div className="space-y-4">
<div className="flex items-baseline justify-between">
<div className="flex flex-wrap items-baseline justify-between gap-4">
<h2 className="text-sm font-medium text-fg">
{entries.length} {entries.length === 1 ? "artifact" : "artifacts"}
{files.length} {plural(files.length, "file", "files")}
{versioned && (
<span className="font-normal text-fg-muted">
{" "}
· {captures} {plural(captures, "version", "versions")}
</span>
)}
</h2>
<span className="text-xs tabular-nums text-fg-muted">
{formatBytes(totalBytes)} total
<span className="text-xs text-fg-muted tabular-nums">
{versioned
? `${formatBytes(latestBytes)} latest · ${formatBytes(storedBytes)} stored`
: `${formatBytes(latestBytes)} total`}
</span>
</div>
{groups.map((group) => (
<StageGroupCard key={group.key} runId={runId} group={group} />
))}
<section className="overflow-hidden rounded-md border border-line bg-panel-alt">
{files.map((file) => (
<ArtifactFileRow key={file.path} runId={runId} file={file} />
))}
</section>
</div>
);
}
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 (
<section className="overflow-hidden rounded-md border border-line bg-panel-alt">
<header className="flex items-baseline justify-between border-b border-line px-4 py-2.5">
<div className="flex items-baseline gap-2">
<h3 className="text-sm font-medium text-fg">{group.label}</h3>
{group.retry > 0 && (
<span className="rounded bg-overlay px-1.5 py-0.5 text-[11px] font-medium text-fg-3">
retry {group.retry}
<Disclosure as="div" className="border-t border-line first:border-t-0">
{({ open }) => (
<>
<div className="flex items-center gap-2 px-3 py-2.5 sm:gap-4 sm:px-4">
{hasEarlier ? (
<DisclosureButton className="group shrink-0 rounded-md p-1 text-fg-3 transition-colors hover:bg-overlay hover:text-fg-2 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500">
<span className="sr-only">
{open ? "Hide" : "Show"} earlier versions of {file.name}
</span>
<ChevronRightIcon
className="size-3.5 transition-transform group-data-open:rotate-90"
aria-hidden="true"
/>
</DisclosureButton>
) : (
<span className="size-5 shrink-0" aria-hidden="true" />
)}
<span className="min-w-0 flex-1" title={file.path}>
<span className="block truncate font-mono text-xs">
<span className="text-fg-muted">{file.dir}</span>
<span className="text-fg-2">{file.name}</span>
</span>
<span className="mt-0.5 block truncate text-[11px] text-fg-3 md:hidden">
<VersionLabel version={latest} />
</span>
</span>
{hasEarlier && (
<span className="hidden shrink-0 rounded-full bg-overlay-strong px-2 py-0.5 text-[11px] text-fg-3 lg:inline">
{file.versions.length}{" "}
{plural(file.versions.length, "version", "versions")}
</span>
)}
<span className="hidden max-w-48 shrink-0 truncate text-xs text-fg-3 md:inline">
<VersionLabel version={latest} />
</span>
<span className="shrink-0 text-xs text-fg-muted tabular-nums">
{formatBytes(latest.size)}
</span>
<DownloadLink runId={runId} file={file} version={latest} />
</div>
{hasEarlier && (
<DisclosurePanel
as="ul"
className="border-t border-line bg-black/15 py-1"
>
<EarlierVersions runId={runId} file={file} />
</DisclosurePanel>
)}
</div>
<span className="text-xs tabular-nums text-fg-muted">
{group.entries.length} {group.entries.length === 1 ? "file" : "files"}
{" · "}
{formatBytes(group.totalBytes)}
</span>
</header>
<ul className="divide-y divide-line">
{group.entries.map((entry) => (
<ArtifactRow
key={`${group.key}#${entry.relative_path}`}
runId={runId}
entry={entry}
/>
))}
</ul>
</section>
</>
)}
</Disclosure>
);
}
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 : (
<li
key={`${version.stageId}#${version.retry}`}
className="flex items-center gap-2 py-1.5 pr-3 pl-10 hover:bg-overlay sm:gap-4 sm:pr-4 sm:pl-14"
>
<span className="min-w-0 flex-1 truncate text-xs text-fg-3">
<VersionLabel version={version} />
</span>
<span className="shrink-0 text-xs text-fg-muted tabular-nums">
{formatBytes(version.size)}
</span>
<SizeDelta delta={version.delta} />
<DownloadLink runId={runId} file={file} version={version} />
</li>
),
)}
</>
);
}
function VersionLabel({ version }: { version: ArtifactVersion }) {
const attempt = attemptLabel(version);
return (
<>
{version.stageLabel}
{attempt && <span className="ml-2 text-fg-muted">{attempt}</span>}
</>
);
}
function attemptLabel(version: ArtifactVersion): string | null {
return version.retry > 1 ? `attempt ${version.retry}` : null;
}
function SizeDelta({ delta }: { delta: number | null }) {
if (delta === null) {
return <span className="shrink-0 text-[11px] text-fg-muted tabular-nums">first</span>;
}
const tone = delta < 0 ? "text-amber" : "text-mint";
const sign = delta < 0 ? "" : "+";
return (
<span className={`shrink-0 text-[11px] ${tone} tabular-nums`}>
{sign}
{formatBytes(Math.abs(delta))}
</span>
);
}
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 (
<li className="flex items-center gap-4 px-4 py-2">
<span
className="flex-1 truncate font-mono text-xs text-fg-2"
title={entry.relative_path}
>
{entry.relative_path}
</span>
<span className="shrink-0 tabular-nums text-xs text-fg-muted">
{formatBytes(entry.size)}
</span>
<a
href={href}
download={basename(entry.relative_path)}
className="inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-xs text-fg-3 transition-colors hover:bg-overlay hover:text-fg focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500"
>
<ArrowDownTrayIcon className="size-3.5" aria-hidden="true" />
Download
</a>
</li>
<a
href={href}
download={file.name}
aria-label={`Download ${file.name} from ${source}`}
className="inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-xs text-fg-3 transition-colors hover:bg-overlay hover:text-fg focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500"
>
<ArrowDownTrayIcon className="size-3.5" aria-hidden="true" />
<span className="hidden sm:inline">Download</span>
</a>
);
}
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;
}

View file

@ -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([]);
});
});

View file

@ -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<string, StageInfo> {
const info = new Map<string, StageInfo>();
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<string, [ArtifactVersion, ...ArtifactVersion[]]>();
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);
}