diff --git a/apps/fabro-web/app/lib/queries.ts b/apps/fabro-web/app/lib/queries.ts index ee33e9a04..510dd0c23 100644 --- a/apps/fabro-web/app/lib/queries.ts +++ b/apps/fabro-web/app/lib/queries.ts @@ -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( - 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( + id ? queryKeys.runs.commits(id) : null, + () => apiNullableData(() => runOutputsApi.listRunCommits(id!, 100)), + { keepPreviousData: true }, + ); +} + export function useRunStages(id: string | undefined) { return useSWR( id ? queryKeys.runs.stages(id) : null, diff --git a/apps/fabro-web/app/lib/query-keys.test.ts b/apps/fabro-web/app/lib/query-keys.test.ts index 448167013..ffc857f38 100644 --- a/apps/fabro-web/app/lib/query-keys.test.ts +++ b/apps/fabro-web/app/lib/query-keys.test.ts @@ -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"), diff --git a/apps/fabro-web/app/lib/query-keys.ts b/apps/fabro-web/app/lib/query-keys.ts index b575e918c..0b5a7f0ad 100644 --- a/apps/fabro-web/app/lib/query-keys.ts +++ b/apps/fabro-web/app/lib/query-keys.ts @@ -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, diff --git a/apps/fabro-web/app/lib/run-events.test.tsx b/apps/fabro-web/app/lib/run-events.test.tsx index 35d365e45..75a2349d9 100644 --- a/apps/fabro-web/app/lib/run-events.test.tsx +++ b/apps/fabro-web/app/lib/run-events.test.tsx @@ -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); diff --git a/apps/fabro-web/app/lib/run-events.ts b/apps/fabro-web/app/lib/run-events.ts index 607db43e2..a8dda4926 100644 --- a/apps/fabro-web/app/lib/run-events.ts +++ b/apps/fabro-web/app/lib/run-events.ts @@ -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), diff --git a/apps/fabro-web/app/routes/run-files.render.test.tsx b/apps/fabro-web/app/routes/run-files.render.test.tsx index 1bd46ea3d..c3aac6f78 100644 --- a/apps/fabro-web/app/routes/run-files.render.test.tsx +++ b/apps/fabro-web/app/routes/run-files.render.test.tsx @@ -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", () => { diff --git a/apps/fabro-web/app/routes/run-files.test.ts b/apps/fabro-web/app/routes/run-files.test.ts index 5e76ca733..cbebfd270 100644 --- a/apps/fabro-web/app/routes/run-files.test.ts +++ b/apps/fabro-web/app/routes/run-files.test.ts @@ -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( diff --git a/apps/fabro-web/app/routes/run-files.tsx b/apps/fabro-web/app/routes/run-files.tsx index 01ad8b1e5..7a2e70fb3 100644 --- a/apps/fabro-web/app/routes/run-files.tsx +++ b/apps/fabro-web/app/routes/run-files.tsx @@ -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[], +): RunCommitPickerOption[] { + const generatedVisits = new Map(); + 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} diff --git a/apps/fabro-web/app/routes/run-files/toolbar.tsx b/apps/fabro-web/app/routes/run-files/toolbar.tsx index a1601b609..269ada48f 100644 --- a/apps/fabro-web/app/routes/run-files/toolbar.tsx +++ b/apps/fabro-web/app/routes/run-files/toolbar.tsx @@ -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({
{showScopePicker ? ( - + ) : null}

{totalChanged} @@ -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 ( - + onChange(parsePickerValue(next))}>

- {selected.label} + {selectedLabel} {scopeOptions.map((option) => ( {({ selected }) => ( @@ -168,12 +191,55 @@ function DiffScopePicker({ )} ))} + {commits.length > 0 ? ( +
+ ) : null} + {commits.map((commit) => ( + + {({ selected }) => ( + <> + + {commit.label} + + {selected ? ( + + ))}
); } +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, diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index f862576a8..a63439f18 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -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 ────────────────────────────────────────────────── diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index 06df7e99d..0b78bd61e 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -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>, + Path(_id): Path, +) -> Response { + let sha = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let parent = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let tree = "cccccccccccccccccccccccccccccccccccccccc"; + let commit = RunCommit { + sha: sha_newtype::(sha), + short_sha: short_sha_newtype::(sha), + parents: vec![RunCommitParent { + sha: sha_newtype::(parent), + short_sha: short_sha_newtype::(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::(tree)), + }; + + ( + StatusCode::OK, + Json(PaginatedRunCommitList { + data: vec![commit], + meta: RunCommitsMeta { + source: RunCommitsMetaSource::Sandbox, + base_sha: sha_newtype::(parent), + head_sha: sha_newtype::(sha), + limit: std::num::NonZeroU64::new(100).expect("literal is non-zero"), + total_returned: 1, + truncated: false, + }, + }), + ) + .into_response() +} + +fn sha_newtype(sha: &str) -> T +where + T: TryFrom, + >::Error: std::fmt::Display, +{ + T::try_from(sha.to_string()).unwrap_or_else(|err| panic!("invalid demo SHA `{sha}`: {err}")) +} + +fn short_sha_newtype(sha: &str) -> T +where + T: TryFrom, + >::Error: std::fmt::Display, +{ + let short = sha.chars().take(7).collect::(); + 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, diff --git a/lib/crates/fabro-server/src/run_files.rs b/lib/crates/fabro-server/src/run_files.rs index b570f81a6..ba9dca1fa 100644 --- a/lib/crates/fabro-server/src/run_files.rs +++ b/lib/crates/fabro-server/src/run_files.rs @@ -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, } +/// Query parameters accepted by `GET /runs/{id}/commits`. +#[derive(Debug, Deserialize, Default)] +pub struct ListRunCommitsParams { + #[serde(default)] + pub limit: Option, +} + #[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>, + Path(id): Path, + Query(params): Query, +) -> 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, + 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, + run_id: &RunId, + limit: u64, +) -> std::result::Result { + 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::(&base_sha), + head_sha: sha_newtype::(&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 { + 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, 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 { + 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::(parent), + short_sha: short_sha_newtype::(parent), + }) + .collect(); + + Ok(RunCommit { + sha: sha_newtype::(sha), + short_sha: short_sha_newtype::(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::(tree_sha)), + }) +} + +fn split_commit_message(message: &str) -> (String, Option) { + let mut lines = message.lines(); + let subject = lines.next().unwrap_or_default().to_string(); + let body = lines.collect::>().join("\n").trim().to_string(); + (subject, (!body.is_empty()).then_some(body)) +} + +fn parse_commit_trailers(message: &str) -> HashMap { + 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::parse_from_rfc3339(value.trim()) + .ok() + .map(|d| d.with_timezone(&chrono::Utc)) +} + +fn sha_newtype(sha: &str) -> T +where + T: TryFrom, + >::Error: std::fmt::Display, +{ + T::try_from(sha.to_string()) + .unwrap_or_else(|err| panic!("invalid generated SHA `{sha}`: {err}")) +} + +fn short_sha_newtype(sha: &str) -> T +where + T: TryFrom, + >::Error: std::fmt::Display, +{ + let short = sha.chars().take(7).collect::(); + 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>, + 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 = 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, to_sha: Option, @@ -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>), 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>), 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!( diff --git a/lib/crates/fabro-server/src/server/handler/mod.rs b/lib/crates/fabro-server/src/server/handler/mod.rs index 04750a761..b7ce2243c 100644 --- a/lib/crates/fabro-server/src/server/handler/mod.rs +++ b/lib/crates/fabro-server/src/server/handler/mod.rs @@ -57,6 +57,7 @@ pub(super) fn demo_routes() -> Router> { .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), diff --git a/lib/crates/fabro-server/src/server/handler/runs.rs b/lib/crates/fabro-server/src/server/handler/runs.rs index 56394c3f8..152e5e1c3 100644 --- a/lib/crates/fabro-server/src/server/handler/runs.rs +++ b/lib/crates/fabro-server/src/server/handler/runs.rs @@ -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> { ) .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()) } diff --git a/lib/crates/fabro-server/tests/it/api/run_files.rs b/lib/crates/fabro-server/tests/it/api/run_files.rs index 000cf0b4f..1bbd45505 100644 --- a/lib/crates/fabro-server/tests/it/api/run_files.rs +++ b/lib/crates/fabro-server/tests/it/api/run_files.rs @@ -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="), + format!("GET /api/v1/runs/{fake}/files?from_sha="), ) .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() { diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 47f7a94c5..80d0ca5b6 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -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 diff --git a/lib/packages/fabro-api-client/src/api/run-outputs-api.ts b/lib/packages/fabro-api-client/src/api/run-outputs-api.ts index 68345330d..ca9c328db 100644 --- a/lib/packages/fabro-api-client/src/api/run-outputs-api.ts +++ b/lib/packages/fabro-api-client/src/api/run-outputs-api.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 => { + // 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> { + 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 { + 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} */ diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index 445cdf4e7..c001b96fc 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -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'; diff --git a/lib/packages/fabro-api-client/src/models/paginated-run-commit-list.ts b/lib/packages/fabro-api-client/src/models/paginated-run-commit-list.ts new file mode 100644 index 000000000..1290d80d2 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/paginated-run-commit-list.ts @@ -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; + 'meta': RunCommitsMeta; +} diff --git a/lib/packages/fabro-api-client/src/models/paginated-run-file-list.ts b/lib/packages/fabro-api-client/src/models/paginated-run-file-list.ts index eee374b51..1d84791af 100644 --- a/lib/packages/fabro-api-client/src/models/paginated-run-file-list.ts +++ b/lib/packages/fabro-api-client/src/models/paginated-run-file-list.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 @@ -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; '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]; diff --git a/lib/packages/fabro-api-client/src/models/run-commit-parent.ts b/lib/packages/fabro-api-client/src/models/run-commit-parent.ts new file mode 100644 index 000000000..1cc1cea61 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/run-commit-parent.ts @@ -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; +} diff --git a/lib/packages/fabro-api-client/src/models/run-commit-person.ts b/lib/packages/fabro-api-client/src/models/run-commit-person.ts new file mode 100644 index 000000000..6a33f0ce6 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/run-commit-person.ts @@ -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; +} diff --git a/lib/packages/fabro-api-client/src/models/run-commit.ts b/lib/packages/fabro-api-client/src/models/run-commit.ts new file mode 100644 index 000000000..f0b033ffc --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/run-commit.ts @@ -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; + 'author': RunCommitPerson; + 'committer': RunCommitPerson; + 'subject': string; + 'body'?: string | null; + 'message': string; + 'trailers': { [key: string]: string; }; + 'tree_sha': string | null; +} diff --git a/lib/packages/fabro-api-client/src/models/run-commits-meta.ts b/lib/packages/fabro-api-client/src/models/run-commits-meta.ts new file mode 100644 index 000000000..06730f348 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/run-commits-meta.ts @@ -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]; diff --git a/lib/packages/fabro-api-client/src/models/run-files-meta.ts b/lib/packages/fabro-api-client/src/models/run-files-meta.ts index 8c4adf641..564fe9efe 100644 --- a/lib/packages/fabro-api-client/src/models/run-files-meta.ts +++ b/lib/packages/fabro-api-client/src/models/run-files-meta.ts @@ -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',