Add run commit diff picker

This commit is contained in:
Bryan Helmkamp 2026-05-09 14:49:15 -04:00
parent fb9ed01978
commit 010828ae7a
No known key found for this signature in database
25 changed files with 1313 additions and 163 deletions

View file

@ -6,6 +6,7 @@ import type {
CommandLogResponse,
EventEnvelope,
PaginatedBoardRunList,
PaginatedRunCommitList,
PaginatedRunFileList,
PaginatedRunList,
PaginatedRunStageList,
@ -36,7 +37,12 @@ import {
workflowsApi,
type PaginatedEnvelope,
} from "./api-client";
import { queryKeys, type RunFileScope, type RunGraphDirection } from "./query-keys";
import {
queryKeys,
runFileScopeSelection,
type RunFileSelection,
type RunGraphDirection,
} from "./query-keys";
const immutableOptions: SWRConfiguration = {
revalidateIfStale: false,
@ -97,18 +103,40 @@ export function useRunState(id: string | undefined) {
export function useRunFiles(
id: string | undefined,
scope: RunFileScope = "committed",
selection: RunFileSelection = runFileScopeSelection("committed"),
) {
return useSWR<PaginatedRunFileList | null>(
id ? queryKeys.runs.files(id, scope) : null,
id ? queryKeys.runs.files(id, selection) : null,
() =>
apiNullableData(() =>
runOutputsApi.listRunFiles(id!, undefined, undefined, scope),
selection.kind === "scope"
? runOutputsApi.listRunFiles(
id!,
undefined,
undefined,
selection.scope,
)
: runOutputsApi.listRunFiles(
id!,
undefined,
undefined,
undefined,
selection.fromSha,
selection.toSha,
),
),
{ keepPreviousData: true },
);
}
export function useRunCommits(id: string | undefined) {
return useSWR<PaginatedRunCommitList | null>(
id ? queryKeys.runs.commits(id) : null,
() => apiNullableData(() => runOutputsApi.listRunCommits(id!, 100)),
{ keepPreviousData: true },
);
}
export function useRunStages(id: string | undefined) {
return useSWR<PaginatedRunStageList | null>(
id ? queryKeys.runs.stages(id) : null,

View file

@ -10,14 +10,24 @@ describe("queryKeys", () => {
"runs",
"files",
"run 1",
"scope",
"committed",
]);
expect(queryKeys.runs.files("run 1", "all")).toEqual([
expect(queryKeys.runs.files("run 1", { kind: "scope", scope: "all" })).toEqual([
"runs",
"files",
"run 1",
"scope",
"all",
]);
expect(
queryKeys.runs.files("run 1", {
kind: "commit",
fromSha: "abc1234",
toSha: "def5678",
}),
).toEqual(["runs", "files", "run 1", "commit", "abc1234", "def5678"]);
expect(queryKeys.runs.commits("run 1")).toEqual(["runs", "commits", "run 1"]);
expect(queryKeys.runs.graph("run-1", "TB")).toEqual(["runs", "graph", "run-1", "TB"]);
expect(queryKeys.runs.stageLog("run 1", "build step@2", 12, 34)).toEqual([
"runs",
@ -39,7 +49,10 @@ describe("queryKeys", () => {
test("event-mapped keys match query hook resources", () => {
expect(queryKeysForRunEvent("run-1", "checkpoint.completed")).toEqual(
queryKeys.runs.filesAllScopes("run-1"),
[
...queryKeys.runs.filesAllScopes("run-1"),
queryKeys.runs.commits("run-1"),
],
);
expect(queryKeysForRunEvent("run-1", "stage.completed", "stage-1")).toEqual([
queryKeys.runs.stages("run-1"),

View file

@ -1,13 +1,28 @@
export type RunGraphDirection = "LR" | "TB" | "BT" | "RL";
export type RunFileScope = "committed" | "uncommitted" | "all";
export type RunFileSelection =
| { kind: "scope"; scope: RunFileScope }
| { kind: "commit"; fromSha: string; toSha: string };
export type QueryKey = readonly unknown[];
export const RUN_FILE_SCOPES = ["committed", "uncommitted", "all"] as const;
export function runFileScopeSelection(
scope: RunFileScope = "committed",
): RunFileSelection {
return { kind: "scope", scope };
}
function pathSegment(value: string): string {
return encodeURIComponent(value);
}
function fileSelectionKey(selection: RunFileSelection): readonly unknown[] {
return selection.kind === "scope"
? ["scope", selection.scope]
: ["commit", selection.fromSha, selection.toSha];
}
export const queryKeys = {
auth: {
config: () => ["auth", "config"] as const,
@ -27,10 +42,13 @@ export const queryKeys = {
runs: {
detail: (id: string) => ["runs", "detail", id] as const,
state: (id: string) => ["runs", "state", id] as const,
files: (id: string, scope: RunFileScope = "committed") =>
["runs", "files", id, scope] as const,
files: (id: string, selection: RunFileSelection = runFileScopeSelection()) =>
["runs", "files", id, ...fileSelectionKey(selection)] as const,
filesAllScopes: (id: string) =>
RUN_FILE_SCOPES.map((scope) => ["runs", "files", id, scope] as const),
RUN_FILE_SCOPES.map((scope) =>
queryKeys.runs.files(id, runFileScopeSelection(scope)),
),
commits: (id: string) => ["runs", "commits", id] as const,
stages: (id: string) => ["runs", "stages", id] as const,
graph: (id: string, direction?: RunGraphDirection) =>
["runs", "graph", id, direction ?? null] as const,

View file

@ -43,6 +43,7 @@ describe("queryKeysForRunEvent", () => {
expect(queryKeysForRunEvent("run-1", "run.completed")).toEqual([
queryKeys.runs.detail("run-1"),
...queryKeys.runs.filesAllScopes("run-1"),
queryKeys.runs.commits("run-1"),
queryKeys.runs.billing("run-1"),
queryKeys.runs.stages("run-1"),
queryKeys.runs.graph("run-1", "LR"),
@ -106,7 +107,10 @@ describe("subscribeToRunEvents", () => {
source.emit({ event: "checkpoint.completed", run_id: "run-coordinated" });
expect(created).toEqual(["/api/v1/attach"]);
expect(keys).toEqual(queryKeys.runs.filesAllScopes("run-coordinated"));
expect(keys).toEqual([
...queryKeys.runs.filesAllScopes("run-coordinated"),
queryKeys.runs.commits("run-coordinated"),
]);
cleanup();
coordinator.close();
@ -167,7 +171,10 @@ describe("subscribeToRunEvents", () => {
source.emit({ event: "checkpoint.completed" });
expect(source.closed).toBe(false);
expect(keys).toEqual(queryKeys.runs.filesAllScopes("run-refcount"));
expect(keys).toEqual([
...queryKeys.runs.filesAllScopes("run-refcount"),
queryKeys.runs.commits("run-refcount"),
]);
secondCleanup();
expect(source.closed).toBe(true);

View file

@ -99,13 +99,17 @@ export function queryKeysForRunEvent(
stageId?: string,
): SseKey[] {
if (event === "checkpoint.completed") {
return queryKeys.runs.filesAllScopes(runId);
return [
...queryKeys.runs.filesAllScopes(runId),
queryKeys.runs.commits(runId),
];
}
if (TERMINAL_EVENTS.has(event)) {
return [
queryKeys.runs.detail(runId),
...queryKeys.runs.filesAllScopes(runId),
queryKeys.runs.commits(runId),
queryKeys.runs.billing(runId),
queryKeys.runs.stages(runId),
queryKeys.runs.graph(runId, "LR"),
@ -202,6 +206,7 @@ function resyncKeysForRun(runId: string) {
return [
queryKeys.runs.detail(runId),
...queryKeys.runs.filesAllScopes(runId),
queryKeys.runs.commits(runId),
queryKeys.runs.billing(runId),
queryKeys.runs.stages(runId),
queryKeys.runs.events(runId, 1000),

View file

@ -6,6 +6,7 @@ import { MemoryRouter, Route, Routes } from "react-router";
import { ToastProvider } from "../components/toast";
let currentFilesPayload: any = null;
let currentCommitsPayload: any = null;
let currentRunStatus = "succeeded";
const useRunFilesCalls: any[] = [];
@ -54,14 +55,21 @@ mock.module("../lib/queries", () => ({
source_directory: null,
},
}),
useRunFiles: (id: string | undefined, scope: string | undefined) => {
useRunFilesCalls.push({ id, scope });
return {
data: currentFilesPayload,
useRunCommits: () => ({
data: currentCommitsPayload,
error: null,
isLoading: false,
isValidating: false,
mutate: mock(() => Promise.resolve(currentFilesPayload)),
mutate: mock(() => Promise.resolve(currentCommitsPayload)),
}),
useRunFiles: (id: string | undefined, selection: any) => {
useRunFilesCalls.push({ id, selection });
return {
data: currentFilesPayload,
error: null,
isLoading: false,
isValidating: false,
mutate: mock(() => Promise.resolve(currentFilesPayload)),
};
},
useRunQuestions: () => ({ data: [] }),
@ -83,8 +91,9 @@ function makeFiles(count: number) {
function makePayload(count: number, source = "sandbox") {
return {
data: makeFiles(count),
source,
meta: {
source,
scope: "committed",
degraded: false,
degraded_reason: null,
total_changed: count,
@ -106,8 +115,9 @@ function makePatchPayload(patch: string) {
unified_patch: patch,
},
],
source: "sandbox",
meta: {
source: "sandbox",
scope: "committed",
degraded: false,
degraded_reason: null,
total_changed: 1,
@ -149,6 +159,7 @@ describe("RunFiles rendering", () => {
}
});
currentFilesPayload = null;
currentCommitsPayload = null;
currentRunStatus = "succeeded";
multiFileDiffCalls.length = 0;
patchDiffCalls.length = 0;
@ -174,7 +185,35 @@ describe("RunFiles rendering", () => {
renderRunFiles("/runs/run_1/files?scope=all#file=src/file-0.ts");
expect(useRunFilesCalls[0]).toEqual({ id: "run_1", scope: "all" });
expect(useRunFilesCalls[0]).toEqual({
id: "run_1",
selection: { kind: "scope", scope: "all" },
});
});
test("passes a selected commit range to useRunFiles", () => {
currentFilesPayload = makePayload(1);
currentCommitsPayload = {
data: [
{
sha: "b".repeat(40),
short_sha: "bbbbbbb",
subject: "fabro(run_1): implement (succeeded)",
parents: [{ sha: "a".repeat(40), short_sha: "aaaaaaa" }],
},
],
};
renderRunFiles(`/runs/run_1/files?commit=${"b".repeat(40)}`);
expect(useRunFilesCalls[0]).toEqual({
id: "run_1",
selection: {
kind: "commit",
fromSha: "a".repeat(40),
toSha: "b".repeat(40),
},
});
});
test("shows the scope picker only for sandbox responses", () => {

View file

@ -1,9 +1,11 @@
import { describe, expect, test } from "bun:test";
import {
buildRunCommitOptions,
deepLinkToastMessage,
emptyTransitionToastMessage,
extractRequestId,
fabroGeneratedCommitStage,
normalizeRunFileScope,
} from "./run-files";
@ -24,10 +26,11 @@ function buildRunFilesPayload({
meta: {
degraded,
total_changed: files.length,
source: "sandbox",
scope: "committed",
stats: { additions: 0, deletions: 0 },
truncated: false,
},
source: "sandbox",
} as any;
}
@ -106,6 +109,44 @@ describe("normalizeRunFileScope", () => {
});
});
describe("buildRunCommitOptions", () => {
test("shortens Fabro-generated subjects to stage visits", () => {
const commits = [
{
sha: "a".repeat(40),
short_sha: "aaaaaaa",
subject: "fabro(run_1): implement (succeeded)",
parents: [{ sha: "1".repeat(40), short_sha: "1111111" }],
},
{
sha: "b".repeat(40),
short_sha: "bbbbbbb",
subject: "fabro(run_1): implement (succeeded)",
parents: [{ sha: "a".repeat(40), short_sha: "aaaaaaa" }],
},
];
expect(buildRunCommitOptions(commits).map((commit) => commit.label)).toEqual([
"implement@1",
"implement@2",
]);
});
test("leaves externally generated commit subjects intact", () => {
const [option] = buildRunCommitOptions([
{
sha: "a".repeat(40),
short_sha: "aaaaaaa",
subject: "Fix README typo",
parents: [{ sha: "1".repeat(40), short_sha: "1111111" }],
},
]);
expect(fabroGeneratedCommitStage("Fix README typo")).toBeNull();
expect(option.label).toBe("Fix README typo");
});
});
describe("deepLinkToastMessage", () => {
test("returns the missing-file message when the requested file is absent", () => {
expect(

View file

@ -19,6 +19,7 @@ import { useToast } from "../components/toast";
import type {
FileDiff as ApiFileDiff,
PaginatedRunFileList,
RunCommit,
} from "@qltysh/fabro-api-client";
import {
DegradedBanner,
@ -34,12 +35,21 @@ import {
RunFilesErrorBoundary,
} from "./run-files/states";
import { useFileKeyboardNav } from "./run-files/keyboard";
import { Toolbar, type DiffStyle } from "./run-files/toolbar";
import {
Toolbar,
type DiffCommitOption,
type DiffPickerValue,
type DiffStyle,
} from "./run-files/toolbar";
import { fileCacheKey, stringHash } from "./run-files/cache-keys";
import { VirtualizedDiffList } from "./run-files/virtualized-diff-list";
import { ApiError, extractRequestId } from "../lib/api-client";
import { useRun, useRunFiles } from "../lib/queries";
import type { RunFileScope } from "../lib/query-keys";
import { useRun, useRunCommits, useRunFiles } from "../lib/queries";
import {
runFileScopeSelection,
type RunFileScope,
type RunFileSelection,
} from "../lib/query-keys";
export { extractRequestId };
@ -67,6 +77,39 @@ export function normalizeRunFileScope(value: string | null): RunFileScope {
return "committed";
}
export function fabroGeneratedCommitStage(subject: string): string | null {
const match = subject.match(/^fabro\([^)]+\):\s+(.+?)\s+\([^)]+\)$/);
const stage = match?.[1]?.trim();
return stage ? stage : null;
}
export type RunCommitPickerOption = DiffCommitOption & {
fromSha: string | null;
toSha: string;
};
export function buildRunCommitOptions(
commits: Pick<RunCommit, "sha" | "short_sha" | "subject" | "parents">[],
): RunCommitPickerOption[] {
const generatedVisits = new Map<string, number>();
return commits.map((commit) => {
const stage = fabroGeneratedCommitStage(commit.subject);
let label = commit.subject || commit.short_sha;
if (stage) {
const visit = (generatedVisits.get(stage) ?? 0) + 1;
generatedVisits.set(stage, visit);
label = `${stage}@${visit}`;
}
return {
sha: commit.sha,
fromSha: commit.parents[0]?.sha ?? null,
toSha: commit.sha,
label,
title: `${commit.short_sha} ${commit.subject}`.trim(),
};
});
}
function useNarrowViewport(): boolean {
const [narrow, setNarrow] = useState(() => {
if (typeof window === "undefined") return false;
@ -219,7 +262,7 @@ function fileDiffRenderKey({
}: {
file: ApiFileDiff;
index: number;
scope: RunFileScope;
scope: string;
toSha: string | null | undefined;
}): string {
const display = file.new_file.name || file.old_file.name || `file-${index}`;
@ -334,10 +377,34 @@ export default function RunFiles() {
const params = useParams();
const location = useLocation();
const navigate = useNavigate();
const selectedScope = normalizeRunFileScope(
new URLSearchParams(location.search).get("scope"),
const searchParams = useMemo(
() => new URLSearchParams(location.search),
[location.search],
);
const selectedScope = normalizeRunFileScope(searchParams.get("scope"));
const selectedCommitSha = searchParams.get("commit");
const commitsQuery = useRunCommits(params.id);
const commitOptions = useMemo(
() => buildRunCommitOptions(commitsQuery.data?.data ?? []),
[commitsQuery.data],
);
const selectedCommit = selectedCommitSha
? commitOptions.find((commit) => commit.sha === selectedCommitSha)
: undefined;
const waitingForCommitSelection =
!!selectedCommitSha && commitsQuery.data === undefined && !commitsQuery.error;
const fileSelection: RunFileSelection =
selectedCommit && selectedCommit.fromSha
? {
kind: "commit",
fromSha: selectedCommit.fromSha,
toSha: selectedCommit.toSha,
}
: runFileScopeSelection(selectedScope);
const filesQuery = useRunFiles(
waitingForCommitSelection ? undefined : params.id,
fileSelection,
);
const filesQuery = useRunFiles(params.id, selectedScope);
const runQuery = useRun(params.id);
const { push } = useToast();
const narrow = useNarrowViewport();
@ -364,7 +431,7 @@ export default function RunFiles() {
const data: PaginatedRunFileList | null =
filesQuery.data ?? lastGoodDataRef.current;
const isInitialLoading = filesQuery.isLoading && !data;
const isInitialLoading = (waitingForCommitSelection || filesQuery.isLoading) && !data;
const isRevalidating = filesQuery.isValidating;
// Revalidation error is whatever the most recent loader call returned;
@ -405,10 +472,16 @@ export default function RunFiles() {
setMinSpinUntil(Date.now() + MIN_REFRESH_SPIN_MS);
void filesQuery.mutate();
}, [filesQuery]);
const handleScopeChange = useCallback(
(scope: RunFileScope) => {
const handlePickerChange = useCallback(
(selection: DiffPickerValue) => {
const search = new URLSearchParams(location.search);
search.set("scope", scope);
if (selection.kind === "commit") {
search.set("commit", selection.sha);
search.delete("scope");
} else {
search.set("scope", selection.scope);
search.delete("commit");
}
navigate({
pathname: location.pathname,
search: `?${search.toString()}`,
@ -512,10 +585,14 @@ export default function RunFiles() {
}
const { data: files, meta } = data;
const showScopePicker = data.source === "sandbox";
const effectiveScope: RunFileScope = showScopePicker
? selectedScope
: "committed";
const showScopePicker = data.meta.source === "sandbox";
const pickerSelection: DiffPickerValue =
selectedCommit && selectedCommit.fromSha
? { kind: "commit", sha: selectedCommit.sha }
: { kind: "scope", scope: showScopePicker ? selectedScope : "committed" };
const effectiveScope = fileSelection.kind === "commit"
? `commit:${fileSelection.toSha}`
: fileSelection.scope;
// Refresh is disabled when the server reports the same `to_sha` it
// reported on the previous successful fetch — no new checkpoint yet.
@ -532,9 +609,10 @@ export default function RunFiles() {
additions: meta.stats.additions,
deletions: meta.stats.deletions,
}}
scope={effectiveScope}
selection={pickerSelection}
commits={commitOptions}
showScopePicker={showScopePicker}
onScopeChange={handleScopeChange}
onPickerChange={handlePickerChange}
onRefresh={handleRefresh}
refreshing={showRefreshing}
refreshDisabled={refreshDisabled}

View file

@ -22,11 +22,22 @@ type ChangeSummary = {
deletions: number;
};
export type DiffPickerValue =
| { kind: "scope"; scope: RunFileScope }
| { kind: "commit"; sha: string };
export type DiffCommitOption = {
sha: string;
label: string;
title: string;
};
export function Toolbar({
changeSummary,
scope,
selection,
commits,
showScopePicker,
onScopeChange,
onPickerChange,
onRefresh,
refreshing,
refreshDisabled,
@ -37,9 +48,10 @@ export function Toolbar({
diffStyleForced,
}: {
changeSummary: ChangeSummary;
scope: RunFileScope;
selection: DiffPickerValue;
commits: DiffCommitOption[];
showScopePicker: boolean;
onScopeChange: (scope: RunFileScope) => void;
onPickerChange: (selection: DiffPickerValue) => void;
onRefresh: () => void;
refreshing: boolean;
/** True when the server has nothing new to show (to_sha unchanged). */
@ -66,7 +78,11 @@ export function Toolbar({
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b border-line pb-3">
<div className="flex min-w-0 items-center gap-3">
{showScopePicker ? (
<DiffScopePicker value={scope} onChange={onScopeChange} />
<DiffScopePicker
value={selection}
commits={commits}
onChange={onPickerChange}
/>
) : null}
<p className="text-base font-semibold text-fg">
<span className="tabular-nums">{totalChanged}</span>
@ -125,32 +141,39 @@ const scopeOptions: Array<{ value: RunFileScope; label: string }> = [
function DiffScopePicker({
value,
commits,
onChange,
}: {
value: RunFileScope;
onChange: (scope: RunFileScope) => void;
value: DiffPickerValue;
commits: DiffCommitOption[];
onChange: (selection: DiffPickerValue) => void;
}) {
const selected =
scopeOptions.find((o) => o.value === value) ?? scopeOptions[0];
const selectedValue = pickerValueKey(value);
const selectedLabel =
value.kind === "scope"
? (scopeOptions.find((o) => o.value === value.scope) ?? scopeOptions[0])
.label
: (commits.find((commit) => commit.sha === value.sha)?.label ??
value.sha.slice(0, 7));
return (
<Listbox value={value} onChange={onChange}>
<Listbox value={selectedValue} onChange={(next) => onChange(parsePickerValue(next))}>
<div className="relative">
<ListboxButton
aria-label="Diff scope"
className="flex h-7 items-center gap-1.5 rounded-md border border-line bg-panel px-2.5 text-xs font-medium text-fg-3 transition-colors hover:bg-overlay hover:text-fg data-open:bg-overlay data-open:text-fg"
className="flex h-7 max-w-56 items-center gap-1.5 rounded-md border border-line bg-panel px-2.5 text-xs font-medium text-fg-3 transition-colors hover:bg-overlay hover:text-fg data-open:bg-overlay data-open:text-fg"
>
<span>{selected.label}</span>
<span className="truncate">{selectedLabel}</span>
<ChevronUpDownIcon className="size-3.5 text-fg-muted" aria-hidden="true" />
</ListboxButton>
<ListboxOptions
transition
anchor={{ to: "bottom start", gap: 4 }}
className="z-20 w-44 origin-top-left rounded-md bg-panel py-1 shadow-xl shadow-black/30 outline-1 -outline-offset-1 outline-line-strong transition data-closed:scale-95 data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in focus:outline-none"
className="z-20 w-64 origin-top-left rounded-md bg-panel py-1 shadow-xl shadow-black/30 outline-1 -outline-offset-1 outline-line-strong transition data-closed:scale-95 data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in focus:outline-none"
>
{scopeOptions.map((option) => (
<ListboxOption
key={option.value}
value={option.value}
value={`scope:${option.value}`}
className="flex cursor-default items-center justify-between gap-3 px-3 py-1.5 text-xs text-fg-3 data-focus:bg-overlay data-focus:text-fg data-selected:text-fg"
>
{({ selected }) => (
@ -168,12 +191,55 @@ function DiffScopePicker({
)}
</ListboxOption>
))}
{commits.length > 0 ? (
<div
role="separator"
className="my-1 border-t border-line"
/>
) : null}
{commits.map((commit) => (
<ListboxOption
key={commit.sha}
value={`commit:${commit.sha}`}
title={commit.title}
className="flex cursor-default items-center justify-between gap-3 px-3 py-1.5 text-xs text-fg-3 data-focus:bg-overlay data-focus:text-fg data-selected:text-fg"
>
{({ selected }) => (
<>
<span className={`truncate ${selected ? "font-medium" : ""}`}>
{commit.label}
</span>
{selected ? (
<CheckIcon
className="size-3.5 shrink-0 text-teal-300"
aria-hidden="true"
/>
) : null}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</div>
</Listbox>
);
}
function pickerValueKey(value: DiffPickerValue): string {
return value.kind === "scope" ? `scope:${value.scope}` : `commit:${value.sha}`;
}
function parsePickerValue(value: string): DiffPickerValue {
if (value.startsWith("commit:")) {
return { kind: "commit", sha: value.slice("commit:".length) };
}
const scope = value.slice("scope:".length);
if (scope === "all" || scope === "uncommitted" || scope === "committed") {
return { kind: "scope", scope };
}
return { kind: "scope", scope: "committed" };
}
function DiffLayoutToggle({
value,
onChange,

View file

@ -2255,14 +2255,14 @@ paths:
- name: from_sha
in: query
required: false
description: Reserved for future use. Only the default value is accepted in the current API version; any other value returns 400.
description: Explicit start SHA for a commit-range diff. Must be supplied together with `to_sha`; when present, `scope` must be omitted and the response scope is `range`.
schema:
type: string
pattern: "^[0-9a-f]{7,40}$"
- name: to_sha
in: query
required: false
description: Reserved for future use. Only the default value is accepted in the current API version; any other value returns 400.
description: Explicit end SHA for a commit-range diff. Must be supplied together with `from_sha`; when present, `scope` must be omitted and the response scope is `range`.
schema:
type: string
pattern: "^[0-9a-f]{7,40}$"
@ -2274,7 +2274,7 @@ paths:
schema:
$ref: "#/components/schemas/PaginatedRunFileList"
"400":
description: Malformed query parameter (invalid SHA format, or non-default value for `from_sha`/`to_sha`).
description: Malformed query parameter (invalid SHA format, one-sided SHA range, or `scope` combined with an explicit SHA range).
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
@ -2301,6 +2301,70 @@ paths:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/runs/{id}/commits:
get:
operationId: listRunCommits
tags: [Run Outputs]
summary: List Run Commits
description: |
Returns commits on the run branch since the run's base SHA, sourced directly from sandbox Git.
The list uses first-parent chronological history and is capped by `limit` (default and maximum: 100). Commit data is Git-authoritative; Fabro-generated commit messages are not required for correctness.
parameters:
- $ref: "#/components/parameters/RunId"
- name: limit
in: query
required: false
description: Maximum number of commits to return. Defaults to 100 and is capped at 100.
schema:
type: integer
minimum: 1
maximum: 100
default: 100
responses:
"200":
description: Commits on the run branch since the run base.
content:
application/json:
schema:
$ref: "#/components/schemas/PaginatedRunCommitList"
"400":
description: Malformed run id or query parameter.
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"404":
description: Run not found.
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"409":
description: Run has no active sandbox or no base SHA.
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"503":
description: Sandbox Git history is temporarily unavailable.
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/runs/{id}/stages/{stageId}/artifacts:
get:
operationId: listStageArtifacts
@ -7196,10 +7260,26 @@ components:
Replaces `PaginationMeta` on the files endpoint — the naturally-bounded list does not use cursor pagination but exposes caps and a degraded-response path instead.
type: object
required:
- source
- scope
- truncated
- total_changed
- stats
properties:
source:
type: string
description: Source used to materialize this response. `sandbox` honors the requested scope from the run-owned sandbox; `final_patch` is fallback committed/final diff data from stored run state.
enum:
- sandbox
- final_patch
scope:
type: string
description: Diff scope materialized for this response.
enum:
- committed
- uncommitted
- all
- range
stats:
$ref: "#/components/schemas/DiffStats"
truncated:
@ -7244,7 +7324,6 @@ components:
required:
- data
- meta
- source
properties:
data:
type: array
@ -7252,12 +7331,123 @@ components:
$ref: "#/components/schemas/FileDiff"
meta:
$ref: "#/components/schemas/RunFilesMeta"
RunCommitParent:
description: Parent commit pointer.
type: object
required:
- sha
- short_sha
properties:
sha:
type: string
pattern: "^[0-9a-f]{7,40}$"
short_sha:
type: string
pattern: "^[0-9a-f]{7,12}$"
RunCommitPerson:
description: Git author or committer identity.
type: object
required:
- name
- email
- date
properties:
name:
type: string
email:
type: string
date:
type: ["string", "null"]
format: date-time
RunCommit:
description: A Git commit on a run branch.
type: object
required:
- sha
- short_sha
- parents
- author
- committer
- subject
- message
- trailers
- tree_sha
properties:
sha:
type: string
pattern: "^[0-9a-f]{7,40}$"
short_sha:
type: string
pattern: "^[0-9a-f]{7,12}$"
parents:
type: array
items:
$ref: "#/components/schemas/RunCommitParent"
author:
$ref: "#/components/schemas/RunCommitPerson"
committer:
$ref: "#/components/schemas/RunCommitPerson"
subject:
type: string
body:
type: ["string", "null"]
message:
type: string
trailers:
type: object
additionalProperties:
type: string
tree_sha:
type: ["string", "null"]
pattern: "^[0-9a-f]{7,40}$"
RunCommitsMeta:
description: Metadata for a `PaginatedRunCommitList` response.
type: object
required:
- source
- base_sha
- head_sha
- limit
- total_returned
- truncated
properties:
source:
type: string
description: Source used to materialize this response. `sandbox` honors the requested scope from the run-owned sandbox; `final_patch` is fallback committed/final diff data from stored run state.
enum:
- sandbox
- final_patch
base_sha:
type: string
pattern: "^[0-9a-f]{7,40}$"
head_sha:
type: string
pattern: "^[0-9a-f]{7,40}$"
limit:
type: integer
minimum: 1
maximum: 100
total_returned:
type: integer
minimum: 0
truncated:
type: boolean
PaginatedRunCommitList:
description: Git commits on a run branch since the run base.
type: object
required:
- data
- meta
properties:
data:
type: array
items:
$ref: "#/components/schemas/RunCommit"
meta:
$ref: "#/components/schemas/RunCommitsMeta"
# ── Billing Schemas ──────────────────────────────────────────────────

View file

@ -16,8 +16,11 @@ use axum::response::sse::{Event, Sse};
use axum::response::{IntoResponse, Response};
use fabro_api::types::{
CreateSecretRequest, DeleteSecretRequest, DiffFile, DiffStats, EventEnvelope, FileDiff,
FileDiffChangeKind, PaginatedEventList, PaginatedRunFileList, PaginatedRunFileListSource,
PaginationMeta, RunArtifactListResponse, RunFilesMeta,
FileDiffChangeKind, PaginatedEventList, PaginatedRunCommitList, PaginatedRunFileList,
PaginationMeta, RunArtifactListResponse, RunCommit, RunCommitParent, RunCommitParentSha,
RunCommitParentShortSha, RunCommitPerson, RunCommitSha, RunCommitShortSha, RunCommitTreeSha,
RunCommitsMeta, RunCommitsMetaBaseSha, RunCommitsMetaHeadSha, RunCommitsMetaSource,
RunFilesMeta, RunFilesMetaScope, RunFilesMetaSource,
};
use serde_json::json;
@ -193,13 +196,86 @@ pub(crate) async fn list_run_files_stub(
(StatusCode::OK, Json(demo_run_files())).into_response()
}
pub(crate) async fn list_run_commits_stub(
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
Path(_id): Path<String>,
) -> Response {
let sha = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
let parent = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let tree = "cccccccccccccccccccccccccccccccccccccccc";
let commit = RunCommit {
sha: sha_newtype::<RunCommitSha>(sha),
short_sha: short_sha_newtype::<RunCommitShortSha>(sha),
parents: vec![RunCommitParent {
sha: sha_newtype::<RunCommitParentSha>(parent),
short_sha: short_sha_newtype::<RunCommitParentShortSha>(parent),
}],
author: RunCommitPerson {
name: "Fabro".to_string(),
email: "bot@fabro.sh".to_string(),
date: None,
},
committer: RunCommitPerson {
name: "Fabro".to_string(),
email: "bot@fabro.sh".to_string(),
date: None,
},
subject: "fabro(demo): implement (succeeded)".to_string(),
body: None,
message: "fabro(demo): implement (succeeded)\n\nFabro-Run: demo\nFabro-Completed: 1\n"
.to_string(),
trailers: [
("Fabro-Run".to_string(), "demo".to_string()),
("Fabro-Completed".to_string(), "1".to_string()),
]
.into_iter()
.collect(),
tree_sha: Some(sha_newtype::<RunCommitTreeSha>(tree)),
};
(
StatusCode::OK,
Json(PaginatedRunCommitList {
data: vec![commit],
meta: RunCommitsMeta {
source: RunCommitsMetaSource::Sandbox,
base_sha: sha_newtype::<RunCommitsMetaBaseSha>(parent),
head_sha: sha_newtype::<RunCommitsMetaHeadSha>(sha),
limit: std::num::NonZeroU64::new(100).expect("literal is non-zero"),
total_returned: 1,
truncated: false,
},
}),
)
.into_response()
}
fn sha_newtype<T>(sha: &str) -> T
where
T: TryFrom<String>,
<T as TryFrom<String>>::Error: std::fmt::Display,
{
T::try_from(sha.to_string()).unwrap_or_else(|err| panic!("invalid demo SHA `{sha}`: {err}"))
}
fn short_sha_newtype<T>(sha: &str) -> T
where
T: TryFrom<String>,
<T as TryFrom<String>>::Error: std::fmt::Display,
{
let short = sha.chars().take(7).collect::<String>();
T::try_from(short.clone())
.unwrap_or_else(|err| panic!("invalid demo short SHA `{short}`: {err}"))
}
fn demo_run_files() -> PaginatedRunFileList {
let old_main = "import { parseArgs } from \"node:util\";\n\nexport function run(argv: string[]) {\n const { values } = parseArgs({ args: argv, options: { config: { type: \"string\" } } });\n console.log(values.config);\n}\n";
let new_main = "import { parseArgs } from \"node:util\";\nimport { loadConfig } from \"./config.js\";\n\nexport async function run(argv: string[]) {\n const { values } = parseArgs({ args: argv, options: { config: { type: \"string\" } } });\n const config = await loadConfig(values.config ?? \".fabro/project.toml\");\n console.log(JSON.stringify(config, null, 2));\n}\n";
let new_config = "import { readFile } from \"node:fs/promises\";\nimport { parse as parseToml } from \"@iarna/toml\";\n\nexport async function loadConfig(path: string) {\n const contents = await readFile(path, \"utf8\");\n return parseToml(contents);\n}\n";
PaginatedRunFileList {
data: vec![
data: vec![
FileDiff {
binary: None,
change_kind: Some(FileDiffChangeKind::Modified),
@ -249,8 +325,9 @@ fn demo_run_files() -> PaginatedRunFileList {
unified_patch: None,
},
],
source: PaginatedRunFileListSource::Sandbox,
meta: RunFilesMeta {
meta: RunFilesMeta {
source: RunFilesMetaSource::Sandbox,
scope: RunFilesMetaScope::Committed,
truncated: false,
files_omitted_by_budget: None,
total_changed: 3,

View file

@ -17,6 +17,7 @@
use std::collections::{HashMap, HashSet};
use std::future::Future;
use std::num::NonZeroU64;
use std::panic::AssertUnwindSafe;
use std::sync::Arc;
use std::time::Instant;
@ -28,7 +29,10 @@ use axum::response::{IntoResponse, Response};
use fabro_agent::Sandbox;
use fabro_api::types::{
DiffFile, DiffStats, FileDiff, FileDiffChangeKind, FileDiffTruncationReason, ListRunFilesScope,
PaginatedRunFileList, PaginatedRunFileListSource, RunFilesMeta, RunFilesMetaDegradedReason,
PaginatedRunCommitList, PaginatedRunFileList, RunCommit, RunCommitParent, RunCommitParentSha,
RunCommitParentShortSha, RunCommitPerson, RunCommitSha, RunCommitShortSha, RunCommitTreeSha,
RunCommitsMeta, RunCommitsMetaBaseSha, RunCommitsMetaHeadSha, RunCommitsMetaSource,
RunFilesMeta, RunFilesMetaDegradedReason, RunFilesMetaScope, RunFilesMetaSource,
RunFilesMetaToSha,
};
use fabro_sandbox::reconnect::reconnect_for_run;
@ -93,6 +97,13 @@ pub struct ListRunFilesParams {
pub scope: Option<ListRunFilesScope>,
}
/// Query parameters accepted by `GET /runs/{id}/commits`.
#[derive(Debug, Deserialize, Default)]
pub struct ListRunCommitsParams {
#[serde(default)]
pub limit: Option<NonZeroU64>,
}
#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq)]
pub(crate) struct RunFilesMaterializationKey {
run_id: RunId,
@ -188,13 +199,12 @@ where
/// `GET /api/v1/runs/{id}/files` handler.
///
/// 1. Parse + authenticate. Reject non-default `from_sha`/`to_sha` (v1 only
/// serves the full run diff).
/// 1. Parse + authenticate. Validate scope/range query combinations.
/// 2. Load the run projection. 404 covers both missing run and missing access —
/// IDOR-safe.
/// 3. Reconnect and start the sandbox, then build a structured diff.
/// 4. On garbage-collected base commits, fall through to a degraded response
/// built from `RunProjection.final_patch`.
/// 4. On garbage-collected base commits for aggregate scopes, fall through to a
/// degraded response built from `RunProjection.final_patch`.
///
/// All logging emits a single `tracing::info!` with an allowlisted field
/// set enforced by [`RunFilesMetrics::emit`] — no paths, contents, or raw
@ -211,20 +221,23 @@ pub async fn list_run_files(
Err(resp) => return resp,
};
// 2. SHA format + non-default rejection.
// 2. SHA format + range validation.
if let Err(resp) = validate_sha_params(&params) {
return resp;
}
// 3. Coalesce the materialization.
let scope = params.scope.unwrap_or_default();
let state_cloned = Arc::clone(&state);
let id_cloned = id;
let result: Shared =
let result: Shared = if let (Some(from_sha), Some(to_sha)) = (params.from_sha, params.to_sha) {
Arc::new(materialize_sandbox_range_path(&state, &id, &from_sha, &to_sha).await)
} else {
// 3. Coalesce the materialization.
let scope = params.scope.unwrap_or_default();
let state_cloned = Arc::clone(&state);
let id_cloned = id;
coalesced_list_run_files(&state.files_in_flight, &id, scope, move || async move {
materialize_sandbox_path(&state_cloned, &id_cloned, scope).await
})
.await;
.await
};
match (*result).clone() {
Ok(body) => (StatusCode::OK, Json(body)).into_response(),
@ -232,15 +245,42 @@ pub async fn list_run_files(
}
}
/// `GET /api/v1/runs/{id}/commits` handler.
pub async fn list_run_commits(
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Query(params): Query<ListRunCommitsParams>,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(resp) => return resp,
};
let limit = params.limit.map_or(100, |limit| limit.get().min(100));
match materialize_run_commits(&state, &id, limit).await {
Ok(body) => (StatusCode::OK, Json(body)).into_response(),
Err(err) => err.into_response(),
}
}
fn validate_sha_params(params: &ListRunFilesParams) -> std::result::Result<(), Response> {
validate_one_sha(params.from_sha.as_deref(), "from_sha")?;
validate_one_sha(params.to_sha.as_deref(), "to_sha")?;
// v1 rejects non-default values per R15 — default = absent.
if params.from_sha.is_some() || params.to_sha.is_some() {
return Err(ApiError::bad_request(
"The `from_sha` and `to_sha` parameters are reserved for a future API version.",
)
.into_response());
match (&params.from_sha, &params.to_sha) {
(Some(_), Some(_)) if params.scope.is_some() => {
return Err(ApiError::bad_request(
"`scope` cannot be combined with `from_sha` and `to_sha`.",
)
.into_response());
}
(Some(_), None) | (None, Some(_)) => {
return Err(ApiError::bad_request(
"`from_sha` and `to_sha` must be supplied together.",
)
.into_response());
}
_ => {}
}
Ok(())
}
@ -258,6 +298,199 @@ fn validate_one_sha(value: Option<&str>, param_name: &str) -> std::result::Resul
Ok(())
}
async fn materialize_sandbox_range_path(
state: &Arc<AppState>,
run_id: &RunId,
from_sha: &str,
to_sha: &str,
) -> ListRunFilesResult {
let start = Instant::now();
let projection = load_projection(state, run_id).await?;
let sandbox = reconnect_run_sandbox(state, run_id, &projection).await?;
let (resolved_to_sha, to_sha_committed_at) =
resolve_ref_sha_and_time(sandbox.as_ref(), to_sha).await?;
materialize_committed_range_sandbox_path(
sandbox.as_ref(),
None,
from_sha,
&resolved_to_sha,
to_sha_committed_at,
RunFilesMetaScope::Range,
run_id,
start,
)
.await
}
async fn materialize_run_commits(
state: &Arc<AppState>,
run_id: &RunId,
limit: u64,
) -> std::result::Result<PaginatedRunCommitList, ApiError> {
let projection = load_projection(state, run_id).await?;
let base_sha = projection
.start
.as_ref()
.and_then(|s| s.base_sha.clone())
.ok_or_else(|| ApiError::new(StatusCode::CONFLICT, "Run has no base SHA."))?;
let sandbox = reconnect_run_sandbox(state, run_id, &projection).await?;
let (head_sha, _) = resolve_ref_sha_and_time(sandbox.as_ref(), "HEAD").await?;
let output = git_log_commits(sandbox.as_ref(), &base_sha, &head_sha, limit + 1).await?;
let mut commits = parse_git_log_commits(&output)?;
let truncated = commits.len() > usize::try_from(limit).unwrap_or(usize::MAX);
commits.truncate(usize::try_from(limit).unwrap_or(usize::MAX));
let total_returned = u64::try_from(commits.len()).unwrap_or(u64::MAX);
Ok(PaginatedRunCommitList {
data: commits,
meta: RunCommitsMeta {
source: RunCommitsMetaSource::Sandbox,
base_sha: sha_newtype::<RunCommitsMetaBaseSha>(&base_sha),
head_sha: sha_newtype::<RunCommitsMetaHeadSha>(&head_sha),
limit: NonZeroU64::new(limit).expect("commit limit is non-zero"),
total_returned,
truncated,
},
})
}
async fn git_log_commits(
sandbox: &dyn Sandbox,
base_sha: &str,
head_sha: &str,
limit: u64,
) -> std::result::Result<String, ApiError> {
let base_q = shell_quote(base_sha);
let head_q = shell_quote(head_sha);
let format_q =
shell_quote("%H%x1f%T%x1f%P%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1f%B%x1e");
sandbox_git_stdout(
sandbox,
&format!(
"git -c maintenance.auto=0 -c gc.auto=0 -c core.hooksPath=/dev/null -c core.fsmonitor=false -c core.quotePath=false log --first-parent --reverse --max-count={limit} --format={format_q} {base_q}..{head_q}"
),
"git log",
)
.await
}
fn parse_git_log_commits(stdout: &str) -> std::result::Result<Vec<RunCommit>, ApiError> {
stdout
.split('\x1e')
.filter_map(|record| {
let record = record.trim_matches('\n');
(!record.is_empty()).then_some(record)
})
.map(parse_git_log_commit)
.collect()
}
fn parse_git_log_commit(record: &str) -> std::result::Result<RunCommit, ApiError> {
let mut fields = record.splitn(10, '\x1f');
let sha = fields.next().unwrap_or_default();
let tree_sha = fields.next().unwrap_or_default();
let parents = fields.next().unwrap_or_default();
let author_name = fields.next().unwrap_or_default();
let author_email = fields.next().unwrap_or_default();
let author_date = fields.next().unwrap_or_default();
let committer_name = fields.next().unwrap_or_default();
let committer_email = fields.next().unwrap_or_default();
let committer_date = fields.next().unwrap_or_default();
let message = fields
.next()
.unwrap_or_default()
.trim_end_matches('\n')
.to_string();
if sha.is_empty() {
return Err(ApiError::bad_request(
"Malformed git log output: missing commit SHA.",
));
}
let (subject, body) = split_commit_message(&message);
let parents = parents
.split_whitespace()
.map(|parent| RunCommitParent {
sha: sha_newtype::<RunCommitParentSha>(parent),
short_sha: short_sha_newtype::<RunCommitParentShortSha>(parent),
})
.collect();
Ok(RunCommit {
sha: sha_newtype::<RunCommitSha>(sha),
short_sha: short_sha_newtype::<RunCommitShortSha>(sha),
parents,
author: RunCommitPerson {
name: author_name.to_string(),
email: author_email.to_string(),
date: parse_git_date(author_date),
},
committer: RunCommitPerson {
name: committer_name.to_string(),
email: committer_email.to_string(),
date: parse_git_date(committer_date),
},
subject,
body,
message: message.clone(),
trailers: parse_commit_trailers(&message),
tree_sha: (!tree_sha.is_empty()).then(|| sha_newtype::<RunCommitTreeSha>(tree_sha)),
})
}
fn split_commit_message(message: &str) -> (String, Option<String>) {
let mut lines = message.lines();
let subject = lines.next().unwrap_or_default().to_string();
let body = lines.collect::<Vec<_>>().join("\n").trim().to_string();
(subject, (!body.is_empty()).then_some(body))
}
fn parse_commit_trailers(message: &str) -> HashMap<String, String> {
let mut trailers = HashMap::new();
for line in message.lines().rev() {
let line = line.trim();
if line.is_empty() {
if trailers.is_empty() {
continue;
}
break;
}
let Some((key, value)) = line.split_once(": ") else {
break;
};
if !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
break;
}
trailers.insert(key.to_string(), value.to_string());
}
trailers
}
fn parse_git_date(value: &str) -> Option<chrono::DateTime<chrono::Utc>> {
chrono::DateTime::parse_from_rfc3339(value.trim())
.ok()
.map(|d| d.with_timezone(&chrono::Utc))
}
fn sha_newtype<T>(sha: &str) -> T
where
T: TryFrom<String>,
<T as TryFrom<String>>::Error: std::fmt::Display,
{
T::try_from(sha.to_string())
.unwrap_or_else(|err| panic!("invalid generated SHA `{sha}`: {err}"))
}
fn short_sha_newtype<T>(sha: &str) -> T
where
T: TryFrom<String>,
<T as TryFrom<String>>::Error: std::fmt::Display,
{
let short = sha.chars().take(7).collect::<String>();
T::try_from(short.clone())
.unwrap_or_else(|err| panic!("invalid generated short SHA `{short}`: {err}"))
}
/// Materialize the response for `GET /runs/{id}/files`. Prefers the live
/// sandbox path; falls through to a `final_patch`-based degraded response
/// when the base objects are gone; falls through to an empty envelope when
@ -273,7 +506,10 @@ async fn materialize_sandbox_path(
let Some(base_sha) = projection.start.as_ref().and_then(|s| s.base_sha.clone()) else {
// Run hasn't started yet — no base_sha, no diff to compute.
return Ok(empty_envelope(PaginatedRunFileListSource::FinalPatch));
return Ok(empty_envelope(
RunFilesMetaSource::FinalPatch,
RunFilesMetaScope::Committed,
));
};
let sandbox = match reconnect_run_sandbox(state, run_id, &projection).await {
@ -301,10 +537,24 @@ async fn materialize_sandbox_path(
.await
}
ListRunFilesScope::Uncommitted => {
materialize_working_tree_sandbox_path(sandbox.as_ref(), "HEAD", run_id, start).await
materialize_working_tree_sandbox_path(
sandbox.as_ref(),
"HEAD",
RunFilesMetaScope::Uncommitted,
run_id,
start,
)
.await
}
ListRunFilesScope::All => {
materialize_working_tree_sandbox_path(sandbox.as_ref(), &base_sha, run_id, start).await
materialize_working_tree_sandbox_path(
sandbox.as_ref(),
&base_sha,
RunFilesMetaScope::All,
run_id,
start,
)
.await
}
};
@ -336,13 +586,35 @@ async fn materialize_committed_sandbox_path(
) -> ListRunFilesResult {
// Resolve HEAD (sha + commit time) in one round-trip.
let (to_sha, to_sha_committed_at) = resolve_head_sha_and_time(sandbox).await?;
materialize_committed_range_sandbox_path(
sandbox,
Some(projection),
base_sha,
&to_sha,
to_sha_committed_at,
RunFilesMetaScope::Committed,
run_id,
start,
)
.await
}
async fn materialize_committed_range_sandbox_path(
sandbox: &dyn Sandbox,
fallback_projection: Option<&fabro_store::RunProjection>,
base_sha: &str,
to_sha: &str,
to_sha_committed_at: Option<chrono::DateTime<chrono::Utc>>,
scope: RunFilesMetaScope,
run_id: &RunId,
start: Instant,
) -> ListRunFilesResult {
// Enumerate changes and classify binary vs text in parallel — both
// traversals are mutually independent once `to_sha` is known, and
// running them sequentially would add ~100 ms per request on Daytona.
let (raw_res, numstat_res) = tokio::join!(
list_changed_files_raw(sandbox, base_sha, &to_sha),
list_diff_numstat(sandbox, base_sha, &to_sha),
list_changed_files_raw(sandbox, base_sha, to_sha),
list_diff_numstat(sandbox, base_sha, to_sha),
);
// Permanent errors (bad_sha, missing object) fall through to the
@ -350,12 +622,15 @@ async fn materialize_committed_sandbox_path(
let raw_entries = match raw_res {
Ok(v) => v,
Err(DiffError::Permanent { .. }) => {
return Ok(build_fallback_response(
projection,
RunFilesMetaDegradedReason::SandboxGone,
run_id,
start,
));
if let Some(projection) = fallback_projection {
return Ok(build_fallback_response(
projection,
RunFilesMetaDegradedReason::SandboxGone,
run_id,
start,
));
}
return Err(ApiError::bad_request("Invalid git diff range."));
}
Err(DiffError::Transient { message }) => {
return Err(transient_503("git diff --raw", &message));
@ -429,9 +704,10 @@ async fn materialize_committed_sandbox_path(
.emit(run_id);
Ok(PaginatedRunFileList {
data: response_data,
source: PaginatedRunFileListSource::Sandbox,
meta: RunFilesMeta {
data: response_data,
meta: RunFilesMeta {
source: RunFilesMetaSource::Sandbox,
scope,
truncated,
files_omitted_by_budget: (files_omitted_by_budget > 0)
.then(|| i64::try_from(files_omitted_by_budget).unwrap_or(i64::MAX)),
@ -448,6 +724,7 @@ async fn materialize_committed_sandbox_path(
async fn materialize_working_tree_sandbox_path(
sandbox: &dyn Sandbox,
base_ref: &str,
scope: RunFilesMetaScope,
run_id: &RunId,
start: Instant,
) -> ListRunFilesResult {
@ -470,7 +747,8 @@ async fn materialize_working_tree_sandbox_path(
Ok(build_patch_backed_response(
&entries,
PatchBackedResponseMeta {
source: PaginatedRunFileListSource::Sandbox,
source: RunFilesMetaSource::Sandbox,
scope,
degraded: false,
degraded_reason: None,
to_sha: Some(to_sha_wrapper(&to_sha)),
@ -510,7 +788,7 @@ fn build_fallback_response(
start: Instant,
) -> PaginatedRunFileList {
let Some(patch) = projection.final_patch.as_deref() else {
return empty_envelope(PaginatedRunFileListSource::FinalPatch);
return empty_envelope(RunFilesMetaSource::FinalPatch, RunFilesMetaScope::Committed);
};
let entries: Vec<String> = split_patch_sections(patch)
@ -532,7 +810,8 @@ fn build_fallback_response(
build_patch_backed_response(
&entries,
PatchBackedResponseMeta {
source: PaginatedRunFileListSource::FinalPatch,
source: RunFilesMetaSource::FinalPatch,
scope: RunFilesMetaScope::Committed,
degraded: true,
degraded_reason: Some(reason),
to_sha,
@ -544,7 +823,8 @@ fn build_fallback_response(
}
struct PatchBackedResponseMeta {
source: PaginatedRunFileListSource,
source: RunFilesMetaSource,
scope: RunFilesMetaScope,
degraded: bool,
degraded_reason: Option<RunFilesMetaDegradedReason>,
to_sha: Option<RunFilesMetaToSha>,
@ -639,9 +919,10 @@ fn build_patch_backed_response(
.emit(run_id);
PaginatedRunFileList {
data: response_data,
source: meta_input.source,
meta: RunFilesMeta {
data: response_data,
meta: RunFilesMeta {
source: meta_input.source,
scope: meta_input.scope,
truncated,
files_omitted_by_budget: (files_omitted_by_budget > 0)
.then(|| i64::try_from(files_omitted_by_budget).unwrap_or(i64::MAX)),
@ -860,22 +1141,23 @@ fn degraded_file_diff(
}
}
fn empty_envelope(source: PaginatedRunFileListSource) -> PaginatedRunFileList {
fn empty_envelope(source: RunFilesMetaSource, scope: RunFilesMetaScope) -> PaginatedRunFileList {
PaginatedRunFileList {
data: Vec::new(),
source,
meta: RunFilesMeta {
truncated: false,
source,
scope,
truncated: false,
files_omitted_by_budget: None,
total_changed: 0,
stats: DiffStats {
total_changed: 0,
stats: DiffStats {
additions: 0,
deletions: 0,
},
to_sha: None,
to_sha_committed_at: None,
degraded: Some(false),
degraded_reason: None,
to_sha: None,
to_sha_committed_at: None,
degraded: Some(false),
degraded_reason: None,
},
}
}
@ -935,9 +1217,17 @@ async fn reconnect_run_sandbox(
async fn resolve_head_sha_and_time(
sandbox: &dyn Sandbox,
) -> std::result::Result<(String, Option<chrono::DateTime<chrono::Utc>>), ApiError> {
resolve_ref_sha_and_time(sandbox, "HEAD").await
}
async fn resolve_ref_sha_and_time(
sandbox: &dyn Sandbox,
git_ref: &str,
) -> std::result::Result<(String, Option<chrono::DateTime<chrono::Utc>>), ApiError> {
let ref_q = shell_quote(git_ref);
let res = sandbox
.exec_command(
"git -c core.hooksPath=/dev/null show -s --format=%H\\ %cI HEAD",
&format!("git -c core.hooksPath=/dev/null show -s --format=%H\\ %cI {ref_q}"),
SANDBOX_GIT_TIMEOUT_MS,
None,
None,
@ -948,7 +1238,7 @@ async fn resolve_head_sha_and_time(
if !res.is_success() {
return Err(ApiError::new(
StatusCode::SERVICE_UNAVAILABLE,
"Failed to resolve sandbox HEAD.",
"Failed to resolve sandbox git ref.",
));
}
parse_head_show_output(&res.stdout).ok_or_else(|| {
@ -1426,9 +1716,10 @@ mod tests {
fn ok_response() -> PaginatedRunFileList {
PaginatedRunFileList {
data: Vec::new(),
source: PaginatedRunFileListSource::Sandbox,
meta: fabro_api::types::RunFilesMeta {
data: Vec::new(),
meta: fabro_api::types::RunFilesMeta {
source: RunFilesMetaSource::Sandbox,
scope: RunFilesMetaScope::Committed,
truncated: false,
files_omitted_by_budget: None,
total_changed: 0,
@ -1566,12 +1857,18 @@ diff --git a/src/live.rs b/src/live.rs
commands: StdMutex::new(Vec::new()),
};
let body =
materialize_working_tree_sandbox_path(&sandbox, "HEAD", &RunId::new(), Instant::now())
.await
.expect("working tree materialization should succeed");
let body = materialize_working_tree_sandbox_path(
&sandbox,
"HEAD",
RunFilesMetaScope::Uncommitted,
&RunId::new(),
Instant::now(),
)
.await
.expect("working tree materialization should succeed");
assert_eq!(body.source, PaginatedRunFileListSource::Sandbox);
assert_eq!(body.meta.source, RunFilesMetaSource::Sandbox);
assert_eq!(body.meta.scope, RunFilesMetaScope::Uncommitted);
assert_eq!(body.data.len(), 1);
let commands = sandbox.commands.lock().expect("commands lock poisoned");
assert_eq!(commands.len(), 2);
@ -1580,6 +1877,38 @@ diff --git a/src/live.rs b/src/live.rs
assert!(!commands.iter().any(|command| command.contains("ls-files")));
}
#[test]
fn parse_git_log_commits_keeps_external_and_fabro_metadata() {
let stdout = concat!(
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\x1f",
"cccccccccccccccccccccccccccccccccccccccc\x1f",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\x1f",
"Fabro\x1fbot@fabro.sh\x1f2026-05-09T17:12:40Z\x1f",
"Fabro\x1fbot@fabro.sh\x1f2026-05-09T17:12:40Z\x1f",
"fabro(run_1): implement (succeeded)\n\nFabro-Run: run_1\nFabro-Completed: 1\n\x1e",
"dddddddddddddddddddddddddddddddddddddddd\x1f",
"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\x1f",
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\x1f",
"Alice\x1falice@example.com\x1f2026-05-09T18:00:00Z\x1f",
"Alice\x1falice@example.com\x1f2026-05-09T18:00:00Z\x1f",
"external tool update\n\nLonger body.\n\x1e",
);
let commits = parse_git_log_commits(stdout).expect("git log should parse");
assert_eq!(commits.len(), 2);
assert_eq!(commits[0].subject, "fabro(run_1): implement (succeeded)");
assert_eq!(
commits[0].trailers.get("Fabro-Run").map(String::as_str),
Some("run_1")
);
assert_eq!(commits[0].parents.len(), 1);
assert_eq!(&*commits[0].short_sha, "bbbbbbb");
assert_eq!(commits[1].subject, "external tool update");
assert_eq!(commits[1].body.as_deref(), Some("Longer body."));
assert!(commits[1].trailers.is_empty());
}
#[tokio::test]
async fn concurrent_calls_for_same_run_share_one_materialization() {
let inflight = new_registry();
@ -2055,7 +2384,8 @@ index 1111111..2222222 160000
serde_json::to_value(build_patch_backed_response(
entries,
PatchBackedResponseMeta {
source: PaginatedRunFileListSource::Sandbox,
source: RunFilesMetaSource::Sandbox,
scope: RunFilesMetaScope::Committed,
degraded: false,
degraded_reason: None,
to_sha: Some(to_sha_wrapper(
@ -2087,7 +2417,8 @@ diff --git a/{path} b/{path}
let entries = vec![simple_patch("src/live.rs")];
let body = sandbox_patch_response_json(&entries);
assert_eq!(body["source"].as_str(), Some("sandbox"));
assert_eq!(body["meta"]["source"].as_str(), Some("sandbox"));
assert_eq!(body["meta"]["scope"].as_str(), Some("committed"));
assert_eq!(body["meta"]["degraded"].as_bool(), Some(false));
assert!(body["meta"]["degraded_reason"].is_null());
assert_eq!(

View file

@ -57,6 +57,7 @@ pub(super) fn demo_routes() -> Router<Arc<AppState>> {
.route("/runs/{id}/stages", get(demo::get_run_stages))
.route("/runs/{id}/artifacts", get(demo::list_run_artifacts_stub))
.route("/runs/{id}/files", get(demo::list_run_files_stub))
.route("/runs/{id}/commits", get(demo::list_run_commits_stub))
.route(
"/runs/{id}/stages/{stageId}/events",
get(demo::get_stage_events),

View file

@ -37,7 +37,7 @@ use crate::error::ApiError;
use crate::principal_middleware::{
RequestAuth, RequireCommandLog, RequireRunScoped, RequiredUser, require_user,
};
use crate::run_files::list_run_files;
use crate::run_files::{list_run_commits, list_run_files};
use crate::run_manifest;
use crate::run_selector::{ResolveRunError, resolve_run_by_selector};
@ -66,6 +66,7 @@ pub(super) fn routes() -> Router<Arc<AppState>> {
)
.route("/runs/{id}/settings", get(get_run_settings))
.route("/runs/{id}/files", get(list_run_files))
.route("/runs/{id}/commits", get(list_run_commits))
.merge(manifest_routes())
}

View file

@ -146,11 +146,9 @@ async fn malformed_from_sha_query_returns_400() {
}
#[tokio::test]
async fn non_default_from_sha_returns_400_even_when_hex() {
async fn one_sided_from_sha_returns_400_even_when_hex() {
let app = fabro_server::test_support::build_test_router(test_app_state());
let fake = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
// Well-formed hex SHA but v1 reserves the parameter for a future
// version; any non-default value must be rejected.
let req = Request::builder()
.method("GET")
.uri(format!(
@ -163,7 +161,7 @@ async fn non_default_from_sha_returns_400_even_when_hex() {
response_status(
resp,
StatusCode::BAD_REQUEST,
format!("GET /api/v1/runs/{fake}/files?from_sha=<non-default>"),
format!("GET /api/v1/runs/{fake}/files?from_sha=<one-sided>"),
)
.await;
}
@ -238,7 +236,8 @@ async fn submitted_run_without_sandbox_returns_empty_envelope() {
"expected empty data: {body}"
);
assert_eq!(body["meta"]["total_changed"], 0);
assert_eq!(body["source"].as_str(), Some("final_patch"));
assert_eq!(body["meta"]["source"].as_str(), Some("final_patch"));
assert_eq!(body["meta"]["scope"].as_str(), Some("committed"));
// Degraded is false because there's no final_patch either — the run
// simply hasn't produced anything to diff.
assert_eq!(body["meta"]["degraded"].as_bool(), Some(false));
@ -288,7 +287,8 @@ diff --git a/.env.production b/.env.production
assert_eq!(body["meta"]["degraded"].as_bool(), Some(true));
assert!(body["meta"]["degraded_reason"].is_string());
assert_eq!(body["source"].as_str(), Some("final_patch"));
assert_eq!(body["meta"]["source"].as_str(), Some("final_patch"));
assert_eq!(body["meta"]["scope"].as_str(), Some("committed"));
assert!(body["meta"].get("patch").is_none());
assert_eq!(body["meta"]["total_changed"], 2);
assert_eq!(body["meta"]["truncated"].as_bool(), Some(false));
@ -341,7 +341,8 @@ diff --git a/src/lib.rs b/src/lib.rs
)
.await;
assert_eq!(body["source"].as_str(), Some("final_patch"));
assert_eq!(body["meta"]["source"].as_str(), Some("final_patch"));
assert_eq!(body["meta"]["scope"].as_str(), Some("committed"));
assert_eq!(body["meta"]["degraded"].as_bool(), Some(true));
assert_eq!(body["data"].as_array().map(Vec::len), Some(1));
}
@ -364,7 +365,8 @@ async fn demo_mode_returns_fixture_without_touching_store() {
let body = response_json(resp, StatusCode::OK, "GET /api/v1/runs/whatever/files").await;
// Demo fixture ships three entries (modified + added + renamed).
assert_eq!(body["source"].as_str(), Some("sandbox"));
assert_eq!(body["meta"]["source"].as_str(), Some("sandbox"));
assert_eq!(body["meta"]["scope"].as_str(), Some("committed"));
let data = body["data"].as_array().expect("data array");
assert_eq!(data.len(), 3, "demo fixture should have 3 entries");
// At least one entry must render with populated contents to prove the
@ -394,7 +396,9 @@ async fn response_envelope_matches_openapi_paginated_run_file_list_shape() {
assert!(body["data"].is_array());
assert!(body["meta"].is_object());
assert!(body["source"].is_string());
assert!(body.get("source").is_none());
assert!(body["meta"]["source"].is_string());
assert!(body["meta"]["scope"].is_string());
assert!(body["meta"]["truncated"].is_boolean());
assert!(body["meta"]["total_changed"].is_number());
for entry in body["data"].as_array().unwrap() {

View file

@ -172,6 +172,7 @@ models/paginated-board-run-list.ts
models/paginated-event-list.ts
models/paginated-history-entry-list.ts
models/paginated-model-list.ts
models/paginated-run-commit-list.ts
models/paginated-run-file-list.ts
models/paginated-run-list.ts
models/paginated-run-stage-list.ts
@ -231,6 +232,10 @@ models/run-billing.ts
models/run-checkpoint-settings.ts
models/run-checkpoint.ts
models/run-client-provenance.ts
models/run-commit-parent.ts
models/run-commit-person.ts
models/run-commit.ts
models/run-commits-meta.ts
models/run-control-action.ts
models/run-error.ts
models/run-event.ts

View file

@ -5,7 +5,7 @@
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
@ -24,6 +24,8 @@ import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError
// @ts-ignore
import type { ErrorResponse } from '../models';
// @ts-ignore
import type { PaginatedRunCommitList } from '../models';
// @ts-ignore
import type { PaginatedRunFileList } from '../models';
// @ts-ignore
import type { RunBilling } from '../models';
@ -33,14 +35,59 @@ import type { RunBilling } from '../models';
export const RunOutputsApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
* Returns the set of file changes produced by a run as a list of before/after diffs. While the run\'s sandbox is reachable, diffs are resolved live against the sandbox working tree at the current HEAD. Degraded responses keep the same `data: FileDiff[]` shape. File contents are null on every entry; non-sensitive non-flagged entries include `unified_patch`, while sensitive / binary / symlink / submodule / truncated entries render through the same placeholder flags used by the live path. Responses are bounded by per-file (256 KiB / 20k lines), per-run aggregate (5 MiB), and per-request (200 files) caps. Files exceeding a cap are returned with `truncated: true` and empty `contents`. Sensitive paths (credentials, keys) are elided with `sensitive: true` and empty `contents`.
* Returns commits on the run branch since the run\'s base SHA, sourced directly from sandbox Git. The list uses first-parent chronological history and is capped by `limit` (default and maximum: 100). Commit data is Git-authoritative; Fabro-generated commit messages are not required for correctness.
* @summary List Run Commits
* @param {string} id Unique run identifier (ULID).
* @param {number} [limit] Maximum number of commits to return. Defaults to 100 and is capped at 100.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listRunCommits: async (id: string, limit?: number, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('listRunCommits', 'id', id)
const localVarPath = `/api/v1/runs/{id}/commits`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (limit !== undefined) {
localVarQueryParameter['limit'] = limit;
}
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Returns the set of file changes produced by a run as a list of before/after diffs. While the run\'s sandbox is reachable, diffs are resolved live against the sandbox working tree at the current HEAD. Degraded responses keep the same `data: FileDiff[]` shape. File contents are null on every entry; non-sensitive non-flagged entries include `unified_patch`, while sensitive / binary / symlink / submodule / truncated entries render through the same placeholder flags used by the live path. Responses are bounded by per-file (256 KiB / 20k lines), per-run aggregate (5 MiB), and per-request (200 files) caps. Files exceeding a cap are returned with `truncated: true` and empty `contents`. Sensitive paths (credentials, keys) are elided with `sensitive: true` and empty `contents`.
* @summary List Run Files Changed
* @param {string} id Unique run identifier (ULID).
* @param {number} [pageLimit] Maximum number of items to return per page.
* @param {number} [pageOffset] Number of items to skip before returning results.
* @param {ListRunFilesScopeEnum} [scope] Diff scope to return. Defaults to committed changes only. Sandbox-backed responses honor all scopes; final-patch fallback responses always represent the stored committed/final diff.
* @param {string} [fromSha] Reserved for future use. Only the default value is accepted in the current API version; any other value returns 400.
* @param {string} [toSha] Reserved for future use. Only the default value is accepted in the current API version; any other value returns 400.
* @param {ListRunFilesScopeEnum} [scope] Diff scope to return. Defaults to committed changes only. Sandbox-backed responses honor all scopes for tracked files; untracked files are excluded. Final-patch fallback responses always represent the stored committed/final diff.
* @param {string} [fromSha] Explicit start SHA for a commit-range diff. Must be supplied together with &#x60;to_sha&#x60;; when present, &#x60;scope&#x60; must be omitted and the response scope is &#x60;range&#x60;.
* @param {string} [toSha] Explicit end SHA for a commit-range diff. Must be supplied together with &#x60;from_sha&#x60;; when present, &#x60;scope&#x60; must be omitted and the response scope is &#x60;range&#x60;.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -147,14 +194,28 @@ export const RunOutputsApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = RunOutputsApiAxiosParamCreator(configuration)
return {
/**
* Returns the set of file changes produced by a run as a list of before/after diffs. While the run\'s sandbox is reachable, diffs are resolved live against the sandbox working tree at the current HEAD. Degraded responses keep the same `data: FileDiff[]` shape. File contents are null on every entry; non-sensitive non-flagged entries include `unified_patch`, while sensitive / binary / symlink / submodule / truncated entries render through the same placeholder flags used by the live path. Responses are bounded by per-file (256 KiB / 20k lines), per-run aggregate (5 MiB), and per-request (200 files) caps. Files exceeding a cap are returned with `truncated: true` and empty `contents`. Sensitive paths (credentials, keys) are elided with `sensitive: true` and empty `contents`.
* Returns commits on the run branch since the run\'s base SHA, sourced directly from sandbox Git. The list uses first-parent chronological history and is capped by `limit` (default and maximum: 100). Commit data is Git-authoritative; Fabro-generated commit messages are not required for correctness.
* @summary List Run Commits
* @param {string} id Unique run identifier (ULID).
* @param {number} [limit] Maximum number of commits to return. Defaults to 100 and is capped at 100.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async listRunCommits(id: string, limit?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PaginatedRunCommitList>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listRunCommits(id, limit, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['RunOutputsApi.listRunCommits']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Returns the set of file changes produced by a run as a list of before/after diffs. While the run\'s sandbox is reachable, diffs are resolved live against the sandbox working tree at the current HEAD. Degraded responses keep the same `data: FileDiff[]` shape. File contents are null on every entry; non-sensitive non-flagged entries include `unified_patch`, while sensitive / binary / symlink / submodule / truncated entries render through the same placeholder flags used by the live path. Responses are bounded by per-file (256 KiB / 20k lines), per-run aggregate (5 MiB), and per-request (200 files) caps. Files exceeding a cap are returned with `truncated: true` and empty `contents`. Sensitive paths (credentials, keys) are elided with `sensitive: true` and empty `contents`.
* @summary List Run Files Changed
* @param {string} id Unique run identifier (ULID).
* @param {number} [pageLimit] Maximum number of items to return per page.
* @param {number} [pageOffset] Number of items to skip before returning results.
* @param {ListRunFilesScopeEnum} [scope] Diff scope to return. Defaults to committed changes only. Sandbox-backed responses honor all scopes; final-patch fallback responses always represent the stored committed/final diff.
* @param {string} [fromSha] Reserved for future use. Only the default value is accepted in the current API version; any other value returns 400.
* @param {string} [toSha] Reserved for future use. Only the default value is accepted in the current API version; any other value returns 400.
* @param {ListRunFilesScopeEnum} [scope] Diff scope to return. Defaults to committed changes only. Sandbox-backed responses honor all scopes for tracked files; untracked files are excluded. Final-patch fallback responses always represent the stored committed/final diff.
* @param {string} [fromSha] Explicit start SHA for a commit-range diff. Must be supplied together with &#x60;to_sha&#x60;; when present, &#x60;scope&#x60; must be omitted and the response scope is &#x60;range&#x60;.
* @param {string} [toSha] Explicit end SHA for a commit-range diff. Must be supplied together with &#x60;from_sha&#x60;; when present, &#x60;scope&#x60; must be omitted and the response scope is &#x60;range&#x60;.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -187,14 +248,25 @@ export const RunOutputsApiFactory = function (configuration?: Configuration, bas
const localVarFp = RunOutputsApiFp(configuration)
return {
/**
* Returns the set of file changes produced by a run as a list of before/after diffs. While the run\'s sandbox is reachable, diffs are resolved live against the sandbox working tree at the current HEAD. Degraded responses keep the same `data: FileDiff[]` shape. File contents are null on every entry; non-sensitive non-flagged entries include `unified_patch`, while sensitive / binary / symlink / submodule / truncated entries render through the same placeholder flags used by the live path. Responses are bounded by per-file (256 KiB / 20k lines), per-run aggregate (5 MiB), and per-request (200 files) caps. Files exceeding a cap are returned with `truncated: true` and empty `contents`. Sensitive paths (credentials, keys) are elided with `sensitive: true` and empty `contents`.
* Returns commits on the run branch since the run\'s base SHA, sourced directly from sandbox Git. The list uses first-parent chronological history and is capped by `limit` (default and maximum: 100). Commit data is Git-authoritative; Fabro-generated commit messages are not required for correctness.
* @summary List Run Commits
* @param {string} id Unique run identifier (ULID).
* @param {number} [limit] Maximum number of commits to return. Defaults to 100 and is capped at 100.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listRunCommits(id: string, limit?: number, options?: RawAxiosRequestConfig): AxiosPromise<PaginatedRunCommitList> {
return localVarFp.listRunCommits(id, limit, options).then((request) => request(axios, basePath));
},
/**
* Returns the set of file changes produced by a run as a list of before/after diffs. While the run\'s sandbox is reachable, diffs are resolved live against the sandbox working tree at the current HEAD. Degraded responses keep the same `data: FileDiff[]` shape. File contents are null on every entry; non-sensitive non-flagged entries include `unified_patch`, while sensitive / binary / symlink / submodule / truncated entries render through the same placeholder flags used by the live path. Responses are bounded by per-file (256 KiB / 20k lines), per-run aggregate (5 MiB), and per-request (200 files) caps. Files exceeding a cap are returned with `truncated: true` and empty `contents`. Sensitive paths (credentials, keys) are elided with `sensitive: true` and empty `contents`.
* @summary List Run Files Changed
* @param {string} id Unique run identifier (ULID).
* @param {number} [pageLimit] Maximum number of items to return per page.
* @param {number} [pageOffset] Number of items to skip before returning results.
* @param {ListRunFilesScopeEnum} [scope] Diff scope to return. Defaults to committed changes only. Sandbox-backed responses honor all scopes; final-patch fallback responses always represent the stored committed/final diff.
* @param {string} [fromSha] Reserved for future use. Only the default value is accepted in the current API version; any other value returns 400.
* @param {string} [toSha] Reserved for future use. Only the default value is accepted in the current API version; any other value returns 400.
* @param {ListRunFilesScopeEnum} [scope] Diff scope to return. Defaults to committed changes only. Sandbox-backed responses honor all scopes for tracked files; untracked files are excluded. Final-patch fallback responses always represent the stored committed/final diff.
* @param {string} [fromSha] Explicit start SHA for a commit-range diff. Must be supplied together with &#x60;to_sha&#x60;; when present, &#x60;scope&#x60; must be omitted and the response scope is &#x60;range&#x60;.
* @param {string} [toSha] Explicit end SHA for a commit-range diff. Must be supplied together with &#x60;from_sha&#x60;; when present, &#x60;scope&#x60; must be omitted and the response scope is &#x60;range&#x60;.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -219,14 +291,26 @@ export const RunOutputsApiFactory = function (configuration?: Configuration, bas
*/
export class RunOutputsApi extends BaseAPI {
/**
* Returns the set of file changes produced by a run as a list of before/after diffs. While the run\'s sandbox is reachable, diffs are resolved live against the sandbox working tree at the current HEAD. Degraded responses keep the same `data: FileDiff[]` shape. File contents are null on every entry; non-sensitive non-flagged entries include `unified_patch`, while sensitive / binary / symlink / submodule / truncated entries render through the same placeholder flags used by the live path. Responses are bounded by per-file (256 KiB / 20k lines), per-run aggregate (5 MiB), and per-request (200 files) caps. Files exceeding a cap are returned with `truncated: true` and empty `contents`. Sensitive paths (credentials, keys) are elided with `sensitive: true` and empty `contents`.
* Returns commits on the run branch since the run\'s base SHA, sourced directly from sandbox Git. The list uses first-parent chronological history and is capped by `limit` (default and maximum: 100). Commit data is Git-authoritative; Fabro-generated commit messages are not required for correctness.
* @summary List Run Commits
* @param {string} id Unique run identifier (ULID).
* @param {number} [limit] Maximum number of commits to return. Defaults to 100 and is capped at 100.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public listRunCommits(id: string, limit?: number, options?: RawAxiosRequestConfig) {
return RunOutputsApiFp(this.configuration).listRunCommits(id, limit, options).then((request) => request(this.axios, this.basePath));
}
/**
* Returns the set of file changes produced by a run as a list of before/after diffs. While the run\'s sandbox is reachable, diffs are resolved live against the sandbox working tree at the current HEAD. Degraded responses keep the same `data: FileDiff[]` shape. File contents are null on every entry; non-sensitive non-flagged entries include `unified_patch`, while sensitive / binary / symlink / submodule / truncated entries render through the same placeholder flags used by the live path. Responses are bounded by per-file (256 KiB / 20k lines), per-run aggregate (5 MiB), and per-request (200 files) caps. Files exceeding a cap are returned with `truncated: true` and empty `contents`. Sensitive paths (credentials, keys) are elided with `sensitive: true` and empty `contents`.
* @summary List Run Files Changed
* @param {string} id Unique run identifier (ULID).
* @param {number} [pageLimit] Maximum number of items to return per page.
* @param {number} [pageOffset] Number of items to skip before returning results.
* @param {ListRunFilesScopeEnum} [scope] Diff scope to return. Defaults to committed changes only. Sandbox-backed responses honor all scopes; final-patch fallback responses always represent the stored committed/final diff.
* @param {string} [fromSha] Reserved for future use. Only the default value is accepted in the current API version; any other value returns 400.
* @param {string} [toSha] Reserved for future use. Only the default value is accepted in the current API version; any other value returns 400.
* @param {ListRunFilesScopeEnum} [scope] Diff scope to return. Defaults to committed changes only. Sandbox-backed responses honor all scopes for tracked files; untracked files are excluded. Final-patch fallback responses always represent the stored committed/final diff.
* @param {string} [fromSha] Explicit start SHA for a commit-range diff. Must be supplied together with &#x60;to_sha&#x60;; when present, &#x60;scope&#x60; must be omitted and the response scope is &#x60;range&#x60;.
* @param {string} [toSha] Explicit end SHA for a commit-range diff. Must be supplied together with &#x60;from_sha&#x60;; when present, &#x60;scope&#x60; must be omitted and the response scope is &#x60;range&#x60;.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/

View file

@ -149,6 +149,7 @@ export * from './paginated-board-run-list';
export * from './paginated-event-list';
export * from './paginated-history-entry-list';
export * from './paginated-model-list';
export * from './paginated-run-commit-list';
export * from './paginated-run-file-list';
export * from './paginated-run-list';
export * from './paginated-run-stage-list';
@ -208,6 +209,10 @@ export * from './run-billing-totals';
export * from './run-checkpoint';
export * from './run-checkpoint-settings';
export * from './run-client-provenance';
export * from './run-commit';
export * from './run-commit-parent';
export * from './run-commit-person';
export * from './run-commits-meta';
export * from './run-control-action';
export * from './run-error';
export * from './run-event';

View file

@ -0,0 +1,29 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { RunCommit } from './run-commit';
// May contain unused imports in some cases
// @ts-ignore
import type { RunCommitsMeta } from './run-commits-meta';
/**
* Git commits on a run branch since the run base.
*/
export interface PaginatedRunCommitList {
'data': Array<RunCommit>;
'meta': RunCommitsMeta;
}

View file

@ -5,7 +5,7 @@
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
@ -21,20 +21,9 @@ import type { FileDiff } from './file-diff';
import type { RunFilesMeta } from './run-files-meta';
/**
* List of file diffs produced by a run, with metadata describing truncation and degraded-response state. Naturally bounded: at most 200 files per response. Consumers should inspect `meta.truncated` rather than assuming `data.length` equals the run\'s total change count.
* List of file diffs produced by a run, with metadata describing truncation and degraded-response state. Naturally bounded: at most 200 files per response. Consumers should inspect `meta.truncated` rather than assuming `data.length` equals the run\'s total change count.
*/
export interface PaginatedRunFileList {
'data': Array<FileDiff>;
'meta': RunFilesMeta;
/**
* Source used to materialize this response. `sandbox` honors the requested scope from the run-owned sandbox; `final_patch` is fallback committed/final diff data from stored run state.
*/
'source': PaginatedRunFileListSourceEnum;
}
export const PaginatedRunFileListSourceEnum = {
SANDBOX: 'sandbox',
FINAL_PATCH: 'final_patch'
} as const;
export type PaginatedRunFileListSourceEnum = typeof PaginatedRunFileListSourceEnum[keyof typeof PaginatedRunFileListSourceEnum];

View file

@ -0,0 +1,23 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Parent commit pointer.
*/
export interface RunCommitParent {
'sha': string;
'short_sha': string;
}

View file

@ -0,0 +1,24 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Git author or committer identity.
*/
export interface RunCommitPerson {
'name': string;
'email': string;
'date': string | null;
}

View file

@ -0,0 +1,37 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { RunCommitParent } from './run-commit-parent';
// May contain unused imports in some cases
// @ts-ignore
import type { RunCommitPerson } from './run-commit-person';
/**
* A Git commit on a run branch.
*/
export interface RunCommit {
'sha': string;
'short_sha': string;
'parents': Array<RunCommitParent>;
'author': RunCommitPerson;
'committer': RunCommitPerson;
'subject': string;
'body'?: string | null;
'message': string;
'trailers': { [key: string]: string; };
'tree_sha': string | null;
}

View file

@ -0,0 +1,33 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Metadata for a `PaginatedRunCommitList` response.
*/
export interface RunCommitsMeta {
'source': RunCommitsMetaSourceEnum;
'base_sha': string;
'head_sha': string;
'limit': number;
'total_returned': number;
'truncated': boolean;
}
export const RunCommitsMetaSourceEnum = {
SANDBOX: 'sandbox'
} as const;
export type RunCommitsMetaSourceEnum = typeof RunCommitsMetaSourceEnum[keyof typeof RunCommitsMetaSourceEnum];

View file

@ -21,6 +21,14 @@ import type { DiffStats } from './diff-stats';
* Metadata for a `PaginatedRunFileList` response. Replaces `PaginationMeta` on the files endpoint the naturally-bounded list does not use cursor pagination but exposes caps and a degraded-response path instead.
*/
export interface RunFilesMeta {
/**
* Source used to materialize this response. `sandbox` honors the requested scope from the run-owned sandbox; `final_patch` is fallback committed/final diff data from stored run state.
*/
'source': RunFilesMetaSourceEnum;
/**
* Diff scope materialized for this response.
*/
'scope': RunFilesMetaScopeEnum;
'stats': DiffStats;
/**
* True when any cap (file count, per-file size, or aggregate size) was hit for this response.
@ -52,6 +60,20 @@ export interface RunFilesMeta {
'degraded_reason'?: RunFilesMetaDegradedReasonEnum;
}
export const RunFilesMetaSourceEnum = {
SANDBOX: 'sandbox',
FINAL_PATCH: 'final_patch'
} as const;
export type RunFilesMetaSourceEnum = typeof RunFilesMetaSourceEnum[keyof typeof RunFilesMetaSourceEnum];
export const RunFilesMetaScopeEnum = {
COMMITTED: 'committed',
UNCOMMITTED: 'uncommitted',
ALL: 'all',
RANGE: 'range'
} as const;
export type RunFilesMetaScopeEnum = typeof RunFilesMetaScopeEnum[keyof typeof RunFilesMetaScopeEnum];
export const RunFilesMetaDegradedReasonEnum = {
SANDBOX_UNREACHABLE: 'sandbox_unreachable',
SANDBOX_GONE: 'sandbox_gone',