feat(api): add GET /runs/{id}/files spec + RunFilesMeta schema

Reintroduces the endpoint deleted in the April 5 server-only cleanup,
this time targeted at the web UI (not the CLI). Route registered with
not_implemented; real handler lands in Unit 5.

- FileDiff gains optional change_kind, truncated, truncation_reason,
  binary, sensitive fields (all additive, back-compat)
- New RunFilesMeta replaces PaginationMeta on PaginatedRunFileList
  (truncated, total_changed, to_sha, to_sha_committed_at, degraded,
  degraded_reason, patch, files_omitted_by_budget)
- from_sha / to_sha query params reserved for future use (non-default
  values 400 in v1)

Generated TS client picks up the new model; typecheck + openapi
conformance tests pass. No existing consumers of
PaginatedRunFileList['meta'] found in the monorepo.

Refs plan docs/plans/2026-04-19-002-feat-run-files-changed-tab-plan.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-04-19 15:33:24 -04:00
parent b849738a5b
commit f65c7c3fd8
No known key found for this signature in database
8 changed files with 360 additions and 7 deletions

View file

@ -1013,6 +1013,61 @@ paths:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/runs/{id}/files:
get:
operationId: listRunFiles
tags: [Run Outputs]
summary: List Run Files Changed
description: |
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. Once the sandbox is gone, the response may degrade to the unified-patch string captured at run end (see `meta.degraded` and `meta.patch`).
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`.
parameters:
- $ref: "#/components/parameters/RunId"
- $ref: "#/components/parameters/PageLimit"
- $ref: "#/components/parameters/PageOffset"
- 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.
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.
schema:
type: string
pattern: "^[0-9a-f]{7,40}$"
responses:
"200":
description: File diffs for the run
content:
application/json:
schema:
$ref: "#/components/schemas/PaginatedRunFileList"
"400":
description: Malformed query parameter (invalid SHA format, or non-default value for `from_sha`/`to_sha`).
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"404":
description: Run not found (or caller lacks access; returned as 404 to prevent enumeration).
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"503":
description: Transient sandbox subprocess failure (timeout, process kill). Safe to retry.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/runs/{id}/stages/{stageId}/artifacts:
get:
operationId: listStageArtifacts
@ -4204,7 +4259,14 @@ components:
example: 'import { parseArgs } from "node:util";'
FileDiff:
description: A before/after pair showing changes to a single file.
description: |
A before/after pair showing changes to a single file.
Contents conventions for non-modify cases:
- Added: `old_file.contents` is empty string; `new_file` holds the added contents.
- Deleted: `new_file.contents` is empty string; `old_file` holds the removed contents.
- Renamed (no content change): both sides hold identical contents; `old_file.name != new_file.name`.
- Symlink / submodule / binary / sensitive / truncated: contents are empty strings; consumers must render a placeholder based on the flag set.
type: object
required:
- old_file
@ -4214,6 +4276,35 @@ components:
$ref: "#/components/schemas/DiffFile"
new_file:
$ref: "#/components/schemas/DiffFile"
change_kind:
type: string
description: Optional classification of the change. Clients that don't recognize a value should fall back to inspecting the old/new contents.
enum:
- added
- modified
- deleted
- renamed
- symlink
- submodule
example: modified
truncated:
type: boolean
description: When `true`, `new_file.contents` and `old_file.contents` are empty strings because the file exceeded a cap (see `truncation_reason`).
example: false
truncation_reason:
type: string
description: Reason this file's contents were omitted. Absent when `truncated` is `false` or omitted.
enum:
- file_too_large
- budget_exhausted
binary:
type: boolean
description: When `true`, the file is non-textual; `contents` on both sides are empty strings.
example: false
sensitive:
type: boolean
description: When `true`, the file path matched the server's sensitive-path denylist; `contents` on both sides are empty strings regardless of truncation or binary flags.
example: false
DiffStats:
description: Aggregate line-change statistics for a diff.
@ -4231,8 +4322,57 @@ components:
description: Total lines deleted.
example: 234
RunFilesMeta:
description: |
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.
type: object
required:
- truncated
- total_changed
properties:
truncated:
type: boolean
description: True when any cap (file count, per-file size, or aggregate size) was hit for this response.
example: false
files_omitted_by_budget:
type: integer
description: Number of files dropped because the aggregate 5 MiB budget was exhausted. Zero or absent when no files were dropped for budget reasons.
example: 0
total_changed:
type: integer
description: Total files changed in the run (before caps were applied). May exceed `data.length` when truncation occurred.
example: 3
to_sha:
type: string
description: Head SHA the diff (or patch) was resolved against.
pattern: "^[0-9a-f]{7,40}$"
example: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"
to_sha_committed_at:
type: string
format: date-time
description: Commit time of `to_sha`, used by the UI for the "Checkpoint Xm ago" freshness label on Running runs.
degraded:
type: boolean
description: When `true`, this response carries only a unified patch string in `patch` (no per-file contents in `data`). The UI should render via a unified-patch component.
example: false
degraded_reason:
type: string
description: Why the response degraded to patch-only form. Absent when `degraded` is `false` or omitted.
enum:
- sandbox_unreachable
- sandbox_gone
- provider_unsupported
patch:
type: string
description: Unified-patch text captured at run end. Present only when `degraded` is `true`. Capped at 5 MiB; denylisted file sections are stripped and replaced with a placeholder line.
PaginatedRunFileList:
description: Paginated list of file diffs produced by a run.
description: |
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.
type: object
required:
- data
@ -4243,7 +4383,7 @@ components:
items:
$ref: "#/components/schemas/FileDiff"
meta:
$ref: "#/components/schemas/PaginationMeta"
$ref: "#/components/schemas/RunFilesMeta"
# ── Billing Schemas ──────────────────────────────────────────────────

View file

@ -1030,6 +1030,7 @@ fn demo_routes() -> Router<Arc<AppState>> {
.route("/runs/{id}/graph", get(demo::get_run_graph))
.route("/runs/{id}/stages", get(demo::get_run_stages))
.route("/runs/{id}/artifacts", get(demo::list_run_artifacts_stub))
.route("/runs/{id}/files", get(not_implemented))
.route(
"/runs/{id}/stages/{stageId}/turns",
get(demo::get_stage_turns),
@ -1110,6 +1111,7 @@ fn real_routes() -> Router<Arc<AppState>> {
.route("/runs/{id}/graph", get(get_graph))
.route("/runs/{id}/stages", get(list_run_stages))
.route("/runs/{id}/artifacts", get(list_run_artifacts))
.route("/runs/{id}/files", get(not_implemented))
.route("/runs/{id}/stages/{stageId}/turns", get(not_implemented))
.route(
"/runs/{id}/stages/{stageId}/artifacts",

View file

@ -145,6 +145,7 @@ models/run-checkpoint.ts
models/run-control-action.ts
models/run-error.ts
models/run-event.ts
models/run-files-meta.ts
models/run-list-item.ts
models/run-manifest.ts
models/run-projection-checkpoints-inner-inner.ts

View file

@ -24,12 +24,74 @@ import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError
// @ts-ignore
import type { ErrorResponse } from '../models';
// @ts-ignore
import type { PaginatedRunFileList } from '../models';
// @ts-ignore
import type { RunBilling } from '../models';
/**
* RunOutputsApi - axios parameter creator
*/
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. Once the sandbox is gone, the response may degrade to the unified-patch string captured at run end (see `meta.degraded` and `meta.patch`). 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 {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 {*} [options] Override http request option.
* @throws {RequiredError}
*/
listRunFiles: async (id: string, pageLimit?: number, pageOffset?: number, fromSha?: string, toSha?: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('listRunFiles', 'id', id)
const localVarPath = `/api/v1/runs/{id}/files`
.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 (pageLimit !== undefined) {
localVarQueryParameter['page[limit]'] = pageLimit;
}
if (pageOffset !== undefined) {
localVarQueryParameter['page[offset]'] = pageOffset;
}
if (fromSha !== undefined) {
localVarQueryParameter['from_sha'] = fromSha;
}
if (toSha !== undefined) {
localVarQueryParameter['to_sha'] = toSha;
}
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 token counts and billed totals broken down by stage and model for a specific run.
* @summary Retrieve Run Billing
@ -79,6 +141,23 @@ export const RunOutputsApiAxiosParamCreator = function (configuration?: Configur
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. Once the sandbox is gone, the response may degrade to the unified-patch string captured at run end (see `meta.degraded` and `meta.patch`). 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 {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 {*} [options] Override http request option.
* @throws {RequiredError}
*/
async listRunFiles(id: string, pageLimit?: number, pageOffset?: number, fromSha?: string, toSha?: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PaginatedRunFileList>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listRunFiles(id, pageLimit, pageOffset, fromSha, toSha, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['RunOutputsApi.listRunFiles']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Returns token counts and billed totals broken down by stage and model for a specific run.
* @summary Retrieve Run Billing
@ -101,6 +180,20 @@ export const RunOutputsApiFp = function(configuration?: Configuration) {
export const RunOutputsApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
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. Once the sandbox is gone, the response may degrade to the unified-patch string captured at run end (see `meta.degraded` and `meta.patch`). 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 {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 {*} [options] Override http request option.
* @throws {RequiredError}
*/
listRunFiles(id: string, pageLimit?: number, pageOffset?: number, fromSha?: string, toSha?: string, options?: RawAxiosRequestConfig): AxiosPromise<PaginatedRunFileList> {
return localVarFp.listRunFiles(id, pageLimit, pageOffset, fromSha, toSha, options).then((request) => request(axios, basePath));
},
/**
* Returns token counts and billed totals broken down by stage and model for a specific run.
* @summary Retrieve Run Billing
@ -118,6 +211,21 @@ export const RunOutputsApiFactory = function (configuration?: Configuration, bas
* RunOutputsApi - object-oriented interface
*/
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. Once the sandbox is gone, the response may degrade to the unified-patch string captured at run end (see `meta.degraded` and `meta.patch`). 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 {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 {*} [options] Override http request option.
* @throws {RequiredError}
*/
public listRunFiles(id: string, pageLimit?: number, pageOffset?: number, fromSha?: string, toSha?: string, options?: RawAxiosRequestConfig) {
return RunOutputsApiFp(this.configuration).listRunFiles(id, pageLimit, pageOffset, fromSha, toSha, options).then((request) => request(this.axios, this.basePath));
}
/**
* Returns token counts and billed totals broken down by stage and model for a specific run.
* @summary Retrieve Run Billing

View file

@ -18,10 +18,48 @@
import type { DiffFile } from './diff-file';
/**
* A before/after pair showing changes to a single file.
* A before/after pair showing changes to a single file. Contents conventions for non-modify cases: - Added: `old_file.contents` is empty string; `new_file` holds the added contents. - Deleted: `new_file.contents` is empty string; `old_file` holds the removed contents. - Renamed (no content change): both sides hold identical contents; `old_file.name != new_file.name`. - Symlink / submodule / binary / sensitive / truncated: contents are empty strings; consumers must render a placeholder based on the flag set.
*/
export interface FileDiff {
'old_file': DiffFile;
'new_file': DiffFile;
/**
* Optional classification of the change. Clients that don\'t recognize a value should fall back to inspecting the old/new contents.
*/
'change_kind'?: FileDiffChangeKindEnum;
/**
* When `true`, `new_file.contents` and `old_file.contents` are empty strings because the file exceeded a cap (see `truncation_reason`).
*/
'truncated'?: boolean;
/**
* Reason this file\'s contents were omitted. Absent when `truncated` is `false` or omitted.
*/
'truncation_reason'?: FileDiffTruncationReasonEnum;
/**
* When `true`, the file is non-textual; `contents` on both sides are empty strings.
*/
'binary'?: boolean;
/**
* When `true`, the file path matched the server\'s sensitive-path denylist; `contents` on both sides are empty strings regardless of truncation or binary flags.
*/
'sensitive'?: boolean;
}
export const FileDiffChangeKindEnum = {
ADDED: 'added',
MODIFIED: 'modified',
DELETED: 'deleted',
RENAMED: 'renamed',
SYMLINK: 'symlink',
SUBMODULE: 'submodule'
} as const;
export type FileDiffChangeKindEnum = typeof FileDiffChangeKindEnum[keyof typeof FileDiffChangeKindEnum];
export const FileDiffTruncationReasonEnum = {
FILE_TOO_LARGE: 'file_too_large',
BUDGET_EXHAUSTED: 'budget_exhausted'
} as const;
export type FileDiffTruncationReasonEnum = typeof FileDiffTruncationReasonEnum[keyof typeof FileDiffTruncationReasonEnum];

View file

@ -125,6 +125,7 @@ export * from './run-checkpoint';
export * from './run-control-action';
export * from './run-error';
export * from './run-event';
export * from './run-files-meta';
export * from './run-list-item';
export * from './run-manifest';
export * from './run-projection';

View file

@ -18,13 +18,13 @@
import type { FileDiff } from './file-diff';
// May contain unused imports in some cases
// @ts-ignore
import type { PaginationMeta } from './pagination-meta';
import type { RunFilesMeta } from './run-files-meta';
/**
* Paginated list of file diffs produced by a run.
* 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': PaginationMeta;
'meta': RunFilesMeta;
}

View file

@ -0,0 +1,63 @@
/* 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 `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 {
/**
* True when any cap (file count, per-file size, or aggregate size) was hit for this response.
*/
'truncated': boolean;
/**
* Number of files dropped because the aggregate 5 MiB budget was exhausted. Zero or absent when no files were dropped for budget reasons.
*/
'files_omitted_by_budget'?: number;
/**
* Total files changed in the run (before caps were applied). May exceed `data.length` when truncation occurred.
*/
'total_changed': number;
/**
* Head SHA the diff (or patch) was resolved against.
*/
'to_sha'?: string;
/**
* Commit time of `to_sha`, used by the UI for the \"Checkpoint Xm ago\" freshness label on Running runs.
*/
'to_sha_committed_at'?: string;
/**
* When `true`, this response carries only a unified patch string in `patch` (no per-file contents in `data`). The UI should render via a unified-patch component.
*/
'degraded'?: boolean;
/**
* Why the response degraded to patch-only form. Absent when `degraded` is `false` or omitted.
*/
'degraded_reason'?: RunFilesMetaDegradedReasonEnum;
/**
* Unified-patch text captured at run end. Present only when `degraded` is `true`. Capped at 5 MiB; denylisted file sections are stripped and replaced with a placeholder line.
*/
'patch'?: string;
}
export const RunFilesMetaDegradedReasonEnum = {
SANDBOX_UNREACHABLE: 'sandbox_unreachable',
SANDBOX_GONE: 'sandbox_gone',
PROVIDER_UNSUPPORTED: 'provider_unsupported'
} as const;
export type RunFilesMetaDegradedReasonEnum = typeof RunFilesMetaDegradedReasonEnum[keyof typeof RunFilesMetaDegradedReasonEnum];