mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
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:
commit
f4384e9901
3 changed files with 504 additions and 133 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
192
apps/fabro-web/app/routes/run-artifacts/group.test.ts
Normal file
192
apps/fabro-web/app/routes/run-artifacts/group.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
104
apps/fabro-web/app/routes/run-artifacts/group.ts
Normal file
104
apps/fabro-web/app/routes/run-artifacts/group.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue