-
+
- {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.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)}
-
-
-
- Download
-
-
+
+
+ Download
+
);
}
-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);
+}