mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Add run commit diff picker
This commit is contained in:
parent
fb9ed01978
commit
010828ae7a
25 changed files with 1313 additions and 163 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(¶ms) {
|
||||
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 (¶ms.from_sha, ¶ms.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!(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 `to_sha`; when present, `scope` must be omitted and the response scope is `range`.
|
||||
* @param {string} [toSha] 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`.
|
||||
* @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 `to_sha`; when present, `scope` must be omitted and the response scope is `range`.
|
||||
* @param {string} [toSha] 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`.
|
||||
* @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 `to_sha`; when present, `scope` must be omitted and the response scope is `range`.
|
||||
* @param {string} [toSha] 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`.
|
||||
* @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 `to_sha`; when present, `scope` must be omitted and the response scope is `range`.
|
||||
* @param {string} [toSha] 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`.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
37
lib/packages/fabro-api-client/src/models/run-commit.ts
Normal file
37
lib/packages/fabro-api-client/src/models/run-commit.ts
Normal 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;
|
||||
}
|
||||
33
lib/packages/fabro-api-client/src/models/run-commits-meta.ts
Normal file
33
lib/packages/fabro-api-client/src/models/run-commits-meta.ts
Normal 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];
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue