diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index e29f57290..18a7849f4 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -89,6 +89,9 @@ paths: $ref: "#/components/schemas/InstallSessionResponse" "401": description: Invalid or missing install token + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -116,12 +119,18 @@ paths: $ref: "#/components/schemas/InstallLlmValidationResponse" "401": description: Invalid or missing install token + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "422": description: Credential validation failed + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -145,12 +154,18 @@ paths: description: LLM settings recorded "401": description: Invalid or missing install token + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "422": description: Invalid install input + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -174,12 +189,18 @@ paths: description: Server configuration recorded "401": description: Invalid or missing install token + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "422": description: Invalid canonical URL + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -207,12 +228,18 @@ paths: $ref: "#/components/schemas/InstallObjectStoreValidationResponse" "401": description: Invalid or missing install token + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "422": description: Object-store validation failed + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -236,12 +263,18 @@ paths: description: Object-store configuration recorded "401": description: Invalid or missing install token + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "422": description: Invalid install input + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -269,12 +302,18 @@ paths: $ref: "#/components/schemas/InstallSandboxValidationResponse" "401": description: Invalid or missing install token + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "422": description: Sandbox validation failed + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -298,12 +337,18 @@ paths: description: Sandbox configuration recorded "401": description: Invalid or missing install token + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "422": description: Invalid install input + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -331,12 +376,18 @@ paths: $ref: "#/components/schemas/InstallGithubTokenTestResponse" "401": description: Invalid or missing install token + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "422": description: GitHub token validation failed + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -360,12 +411,18 @@ paths: description: GitHub token recorded "401": description: Invalid or missing install token + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "422": description: Invalid install input + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -393,12 +450,18 @@ paths: $ref: "#/components/schemas/InstallGithubAppManifestResponse" "401": description: Invalid or missing install token + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "422": description: Invalid install input or missing prior steps + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -427,12 +490,18 @@ paths: description: Browser redirected back into the install SPA "400": description: Invalid or expired GitHub App callback state + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "502": description: GitHub manifest conversion failed + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -454,18 +523,27 @@ paths: $ref: "#/components/schemas/InstallFinishResponse" "401": description: Invalid or missing install token + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "422": description: Install session is incomplete + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Install persistence failed + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -535,6 +613,9 @@ paths: $ref: "#/components/schemas/UserResponse" "401": description: Not authenticated + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -579,6 +660,9 @@ paths: $ref: "#/components/schemas/RunStatusResponse" "400": description: Invalid Graphviz source + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -601,12 +685,18 @@ paths: $ref: "#/components/schemas/RunSummary" "400": description: Selector is invalid or ambiguous + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: No run matched the selector + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -633,6 +723,9 @@ paths: $ref: "#/components/schemas/PreflightResponse" "400": description: Invalid manifest or workflow + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -659,6 +752,9 @@ paths: $ref: "#/components/schemas/ValidateResponse" "400": description: Invalid manifest or workflow + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -686,6 +782,9 @@ paths: format: binary "400": description: Invalid manifest or workflow + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -708,6 +807,9 @@ paths: $ref: "#/components/schemas/RunSummary" "404": description: Run not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -725,12 +827,18 @@ paths: description: Run deleted or already absent "404": description: Run not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "409": description: Run is active and requires `force=true` + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -753,12 +861,18 @@ paths: $ref: "#/components/schemas/RunStatusResponse" "404": description: Run not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "409": description: Run is not running + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -787,12 +901,18 @@ paths: $ref: "#/components/schemas/RunStatusResponse" "404": description: Run not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "409": description: Run is not in submitted status + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -815,12 +935,18 @@ paths: $ref: "#/components/schemas/RunStatusResponse" "404": description: Run not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "409": description: Run is not running + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -843,12 +969,18 @@ paths: $ref: "#/components/schemas/RunStatusResponse" "404": description: Run not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "409": description: Run is not paused + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -875,12 +1007,18 @@ paths: $ref: "#/components/schemas/RunStatusResponse" "404": description: Run not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "409": description: Run is not terminal and cannot be archived + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -919,24 +1057,36 @@ paths: $ref: "#/components/schemas/RewindResponse" "400": description: Invalid rewind target + 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: Source run is archived or is not terminal + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "501": description: Operation unsupported for this run + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -967,24 +1117,36 @@ paths: $ref: "#/components/schemas/ForkResponse" "400": description: Invalid fork target + 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: Source run is archived + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "501": description: Operation unsupported for this run + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -1011,12 +1173,18 @@ paths: $ref: "#/components/schemas/TimelineEntryResponse" "404": description: Run not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "501": description: Operation unsupported for this run + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -1042,12 +1210,18 @@ paths: $ref: "#/components/schemas/RunStatusResponse" "404": description: Run not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "409": description: Run is active and cannot be unarchived + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -1070,6 +1244,9 @@ paths: type: string "404": description: Run not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -1092,6 +1269,9 @@ paths: type: string "404": description: Run not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -1116,6 +1296,9 @@ paths: - type: "null" "404": description: Run not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -1155,6 +1338,9 @@ paths: $ref: "#/components/schemas/RunProjection" "404": description: Run not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -1177,6 +1363,9 @@ paths: type: string "404": description: Run not found, or no run log has been written yet + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -1205,12 +1394,18 @@ paths: $ref: "#/components/schemas/PullRequestRecord" "400": description: Pull request creation does not apply to this run + 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: @@ -1219,18 +1414,27 @@ paths: description: >- Pull request already exists for this run. Clients can GET /runs/{id}/pull_request to retrieve the stored record. + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "502": description: GitHub rejected the pull request creation request + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "503": description: GitHub integration is unavailable on the server + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -1251,24 +1455,36 @@ paths: $ref: "#/components/schemas/PullRequestDetail" "400": description: Pull request lookup does not apply to this run + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Run or stored pull request record not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "502": description: Stored pull request record exists but GitHub could not find it + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "503": description: GitHub integration is unavailable on the server + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -1297,24 +1513,36 @@ paths: $ref: "#/components/schemas/MergeRunPullRequestResponse" "400": description: Pull request merge does not apply to this run + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Run or stored pull request record not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "502": description: GitHub rejected the merge request + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "503": description: GitHub integration is unavailable on the server + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -1337,24 +1565,36 @@ paths: $ref: "#/components/schemas/CloseRunPullRequestResponse" "400": description: Pull request close does not apply to this run + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Run or stored pull request record not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "502": description: GitHub rejected the close request + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "503": description: GitHub integration is unavailable on the server + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -1379,6 +1619,9 @@ paths: $ref: "#/components/schemas/PaginatedEventList" "404": description: Run not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -1405,12 +1648,18 @@ paths: $ref: "#/components/schemas/AppendEventResponse" "400": description: Invalid event payload + 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: @@ -1434,6 +1683,9 @@ paths: type: string "404": description: Run not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -1481,6 +1733,9 @@ paths: $ref: "#/components/schemas/WriteBlobResponse" "404": description: Run not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -1505,6 +1760,9 @@ paths: format: binary "404": description: Run or blob not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -1529,6 +1787,9 @@ paths: $ref: "#/components/schemas/PaginatedApiQuestionList" "404": description: Run not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -1554,18 +1815,27 @@ paths: description: Answer accepted "400": description: Invalid option key + 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: Question no longer exists or already answered + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -1590,6 +1860,9 @@ paths: $ref: "#/components/schemas/PaginatedRunStageList" "404": description: Run not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -1615,6 +1888,9 @@ paths: $ref: "#/components/schemas/PaginatedStageTurnList" "404": description: Run or stage not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -1637,6 +1913,9 @@ paths: $ref: "#/components/schemas/RunArtifactListResponse" "404": description: Run not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -1680,18 +1959,27 @@ paths: $ref: "#/components/schemas/PaginatedRunFileList" "400": description: Malformed query parameter (invalid SHA format, or non-default value for `from_sha`/`to_sha`). + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Run not found (or caller lacks access; returned as 404 to prevent enumeration). + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "503": description: Transient sandbox subprocess failure (timeout, process kill). Safe to retry. + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -1715,6 +2003,9 @@ paths: $ref: "#/components/schemas/ArtifactListResponse" "404": description: Run not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -1753,12 +2044,18 @@ paths: description: Artifact written "400": description: Invalid filename, multipart manifest, checksum, or upload body + 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: @@ -1784,12 +2081,18 @@ paths: format: binary "400": description: Missing filename + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Run, stage, or artifact not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -1812,6 +2115,9 @@ paths: $ref: "#/components/schemas/RunBilling" "404": description: Run not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -1834,6 +2140,9 @@ paths: $ref: "#/components/schemas/WorkflowSettings" "404": description: Run not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -1862,12 +2171,18 @@ paths: $ref: "#/components/schemas/PreviewUrlResponse" "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 + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -1896,12 +2211,18 @@ paths: $ref: "#/components/schemas/SshAccessResponse" "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 provider does not support SSH + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -1935,12 +2256,18 @@ paths: $ref: "#/components/schemas/SandboxFileListResponse" "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 + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -1969,12 +2296,18 @@ paths: format: binary "404": description: Run or file 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 + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -2003,12 +2336,18 @@ paths: description: File written "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 + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -2068,6 +2407,9 @@ paths: $ref: "#/components/schemas/SavedQuery" "404": description: Query not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -2094,6 +2436,9 @@ paths: $ref: "#/components/schemas/SavedQuery" "404": description: Query not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -2110,6 +2455,9 @@ paths: description: Query deleted "404": description: Query not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -2136,6 +2484,9 @@ paths: $ref: "#/components/schemas/ExecuteQueryResponse" "400": description: Bad SQL or query error + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -2254,6 +2605,9 @@ paths: $ref: "#/components/schemas/PruneRunsResponse" "400": description: Invalid prune request + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -2294,6 +2648,9 @@ paths: $ref: "#/components/schemas/SecretMetadata" "400": description: Invalid secret name or request body + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -2313,18 +2670,27 @@ paths: description: Secret deleted "400": description: Invalid secret name or request body + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Secret not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "500": description: Secret store write failed + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -2378,6 +2744,9 @@ paths: $ref: "#/components/schemas/PaginatedModelList" "400": description: Invalid filter value + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -2406,12 +2775,18 @@ paths: $ref: "#/components/schemas/ModelTestResult" "400": description: Invalid test mode + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" "404": description: Model not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -2445,6 +2820,9 @@ paths: $ref: "#/components/schemas/CompletionResponse" "400": description: Invalid request + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" content: application/json: schema: @@ -2652,6 +3030,16 @@ components: $ref: "#/components/schemas/ModelTestMode" example: basic + headers: + XRequestId: + description: > + Server-generated request identifier emitted on every response and + referenced on standard error responses for correlating client errors + with server logs. + schema: + type: string + format: uuid + schemas: InstallSessionResponse: description: Current browser-install session snapshot with secrets redacted. @@ -4194,6 +4582,10 @@ components: type: string description: Optional machine-readable error code for structured client handling. example: access_token_expired + request_id: + type: string + format: uuid + description: Server-generated request identifier; matches the x-request-id response header. ErrorResponse: description: Standard error response containing one or more error entries. @@ -4206,6 +4598,10 @@ components: description: List of error entries. items: $ref: "#/components/schemas/ErrorResponseEntry" + request_id: + type: string + format: uuid + description: Server-generated request identifier; matches the x-request-id response header. leftover_env_keys: type: array description: >- diff --git a/lib/crates/fabro-server/src/lib.rs b/lib/crates/fabro-server/src/lib.rs index ca6cae4dc..b136fa652 100644 --- a/lib/crates/fabro-server/src/lib.rs +++ b/lib/crates/fabro-server/src/lib.rs @@ -25,6 +25,7 @@ pub mod install; pub mod ip_allowlist; pub mod jwt_auth; pub mod manifest_validation; +mod request_id; mod run_files; mod run_files_security; mod run_manifest; diff --git a/lib/crates/fabro-server/src/request_id.rs b/lib/crates/fabro-server/src/request_id.rs new file mode 100644 index 000000000..eee8a53ba --- /dev/null +++ b/lib/crates/fabro-server/src/request_id.rs @@ -0,0 +1,418 @@ +use std::fmt; + +use axum::body::{Body, HttpBody as _, to_bytes}; +use axum::extract::Request; +use axum::http::header::HeaderName; +use axum::http::{HeaderValue, header}; +use axum::middleware::Next; +use axum::response::Response; +use serde_json::{Value, json}; +use tracing::warn; +use uuid::Uuid; + +const BODY_REWRITE_LIMIT: usize = 1 << 20; +const REQUEST_ID_HEADER: HeaderName = HeaderName::from_static("x-request-id"); + +#[derive(Clone, Copy, Debug)] +pub(crate) struct RequestId(pub Uuid); + +impl RequestId { + pub(crate) fn render(self) -> String { + self.0.to_string() + } +} + +impl fmt::Display for RequestId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +pub(crate) async fn layer(mut req: Request, next: Next) -> Response { + let request_id = RequestId(Uuid::new_v4()); + req.extensions_mut().insert(request_id); + + let mut response = next.run(req).await; + response.headers_mut().insert( + REQUEST_ID_HEADER, + HeaderValue::from_str(&request_id.render()).expect("uuid should be a valid header value"), + ); + if response.status().as_u16() >= 400 && is_json_response(&response) { + inject_request_id_into_body(response, request_id).await + } else { + response + } +} + +fn is_json_response(response: &Response) -> bool { + response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| { + value.split(';').next().is_some_and(|media_type| { + media_type.trim().eq_ignore_ascii_case("application/json") + }) + }) +} + +async fn inject_request_id_into_body(response: Response, request_id: RequestId) -> Response { + match response.body().size_hint().upper() { + Some(size) if size <= BODY_REWRITE_LIMIT as u64 => {} + _ => return response, + } + + let (mut parts, body) = response.into_parts(); + let bytes = match to_bytes(body, BODY_REWRITE_LIMIT).await { + Ok(bytes) => bytes, + Err(err) => { + warn!( + request_id = %request_id, + ?err, + "request_id middleware: failed to buffer response body for rewrite" + ); + parts.headers.remove(header::CONTENT_LENGTH); + return Response::from_parts(parts, Body::empty()); + } + }; + + let mut value: Value = match serde_json::from_slice(&bytes) { + Ok(value) => value, + Err(_) => return Response::from_parts(parts, Body::from(bytes)), + }; + + let Value::Object(object) = &mut value else { + return Response::from_parts(parts, Body::from(bytes)); + }; + + let rendered = request_id.render(); + if let Some(errors) = object.get_mut("errors").and_then(Value::as_array_mut) { + for error in errors { + if let Value::Object(error) = error { + error.insert("request_id".to_owned(), json!(rendered)); + } + } + } + object.insert("request_id".to_owned(), json!(rendered)); + + let new_bytes = serde_json::to_vec(&value).unwrap_or_else(|_| bytes.to_vec()); + parts.headers.insert( + header::CONTENT_LENGTH, + HeaderValue::from_str(&new_bytes.len().to_string()) + .expect("content length should be a valid header value"), + ); + Response::from_parts(parts, Body::from(new_bytes)) +} + +#[cfg(test)] +mod tests { + use axum::body::{Body, to_bytes}; + use axum::http::{Request, StatusCode, header}; + use axum::response::{IntoResponse, Response}; + use axum::routing::get; + use axum::{Json, Router, middleware}; + use bytes::Bytes; + use futures_util::stream; + use serde_json::json; + use tower::ServiceExt as _; + use uuid::Uuid; + + async fn ok_handler() -> impl IntoResponse { + StatusCode::OK + } + + async fn api_error_handler() -> impl IntoResponse { + ( + StatusCode::BAD_REQUEST, + Json(json!({ + "errors": [ + { + "status": "400", + "title": "Bad Request", + "detail": "invalid input" + }, + { + "status": "400", + "title": "Bad Request", + "detail": "missing field" + } + ] + })), + ) + } + + async fn legacy_error_handler() -> impl IntoResponse { + ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "login required" })), + ) + } + + async fn stale_request_id_handler() -> impl IntoResponse { + ( + StatusCode::BAD_REQUEST, + Json(json!({ + "request_id": "stale-top-level", + "errors": [ + { + "status": "400", + "title": "Bad Request", + "detail": "invalid input", + "request_id": "stale-entry" + } + ] + })), + ) + } + + async fn text_error_handler() -> impl IntoResponse { + ( + StatusCode::BAD_REQUEST, + [(header::CONTENT_TYPE, "text/plain")], + "plain error", + ) + } + + async fn malformed_json_handler() -> impl IntoResponse { + ( + StatusCode::BAD_REQUEST, + [(header::CONTENT_TYPE, "application/json")], + r#"{"error":"#, + ) + } + + async fn mixed_case_json_handler() -> impl IntoResponse { + Response::builder() + .status(StatusCode::BAD_REQUEST) + .header(header::CONTENT_TYPE, "Application/JSON; charset=utf-8") + .body(Body::from(r#"{"error":"mixed case"}"#)) + .unwrap() + } + + async fn oversized_json_handler() -> impl IntoResponse { + Response::builder() + .status(StatusCode::BAD_REQUEST) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(vec![b'a'; super::BODY_REWRITE_LIMIT + 1])) + .unwrap() + } + + async fn unknown_size_json_handler() -> impl IntoResponse { + let stream = stream::once(async { + Ok::<_, std::convert::Infallible>(Bytes::from_static(b"{\"error\":\"streamed\"}")) + }); + Response::builder() + .status(StatusCode::BAD_REQUEST) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from_stream(stream)) + .unwrap() + } + + async fn send(request: Request) -> axum::response::Response { + Router::new() + .route("/", get(ok_handler)) + .route("/api-error", get(api_error_handler)) + .route("/legacy-error", get(legacy_error_handler)) + .route("/stale-request-id", get(stale_request_id_handler)) + .route("/text-error", get(text_error_handler)) + .route("/malformed-json", get(malformed_json_handler)) + .route("/mixed-case-json", get(mixed_case_json_handler)) + .route("/oversized-json", get(oversized_json_handler)) + .route("/unknown-size-json", get(unknown_size_json_handler)) + .layer(middleware::from_fn(super::layer)) + .oneshot(request) + .await + .expect("request should complete") + } + + fn request_id_header(response: &axum::response::Response) -> String { + let request_id = response + .headers() + .get("x-request-id") + .expect("request id header should be set") + .to_str() + .expect("request id should be ascii") + .to_owned(); + Uuid::parse_str(&request_id).expect("request id should be a hyphenated uuid"); + request_id + } + + async fn response_bytes(response: axum::response::Response) -> Bytes { + to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should buffer") + } + + async fn response_json(response: axum::response::Response) -> serde_json::Value { + let bytes = response_bytes(response).await; + serde_json::from_slice(&bytes).expect("body should remain JSON") + } + + #[tokio::test] + async fn sets_request_id_header_on_success_response() { + let response = send(Request::builder().uri("/").body(Body::empty()).unwrap()).await; + + assert_eq!(response.status(), StatusCode::OK); + request_id_header(&response); + + let bytes = response_bytes(response).await; + assert!(bytes.is_empty()); + } + + #[tokio::test] + async fn overwrites_inbound_request_id_header() { + let response = send( + Request::builder() + .uri("/") + .header("x-request-id", "GARBAGE-SHOULD-BE-IGNORED") + .body(Body::empty()) + .unwrap(), + ) + .await; + + let request_id = request_id_header(&response); + assert_ne!(request_id, "GARBAGE-SHOULD-BE-IGNORED"); + } + + #[tokio::test] + async fn injects_request_id_into_api_error_body() { + let response = send( + Request::builder() + .uri("/api-error") + .body(Body::empty()) + .unwrap(), + ) + .await; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let request_id = request_id_header(&response); + + let body = response_json(response).await; + + assert_eq!(body["request_id"], request_id); + let errors = body["errors"] + .as_array() + .expect("errors should be an array"); + assert_eq!(errors.len(), 2); + assert!(errors.iter().all(|error| error["request_id"] == request_id)); + } + + #[tokio::test] + async fn injects_request_id_into_legacy_json_error_body() { + let response = send( + Request::builder() + .uri("/legacy-error") + .body(Body::empty()) + .unwrap(), + ) + .await; + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let request_id = request_id_header(&response); + let body = response_json(response).await; + + assert_eq!(body["error"], "login required"); + assert_eq!(body["request_id"], request_id); + } + + #[tokio::test] + async fn overwrites_stale_request_ids_in_json_error_body() { + let response = send( + Request::builder() + .uri("/stale-request-id") + .body(Body::empty()) + .unwrap(), + ) + .await; + + let request_id = request_id_header(&response); + let body = response_json(response).await; + + assert_eq!(body["request_id"], request_id); + assert_eq!(body["errors"][0]["request_id"], request_id); + } + + #[tokio::test] + async fn leaves_non_json_error_body_untouched() { + let response = send( + Request::builder() + .uri("/text-error") + .body(Body::empty()) + .unwrap(), + ) + .await; + + request_id_header(&response); + assert_eq!( + response_bytes(response).await, + Bytes::from_static(b"plain error") + ); + } + + #[tokio::test] + async fn leaves_malformed_json_error_body_untouched() { + let response = send( + Request::builder() + .uri("/malformed-json") + .body(Body::empty()) + .unwrap(), + ) + .await; + + request_id_header(&response); + assert_eq!( + response_bytes(response).await, + Bytes::from_static(br#"{"error":"#) + ); + } + + #[tokio::test] + async fn injects_request_id_into_mixed_case_json_error_body() { + let response = send( + Request::builder() + .uri("/mixed-case-json") + .body(Body::empty()) + .unwrap(), + ) + .await; + + let request_id = request_id_header(&response); + let body = response_json(response).await; + + assert_eq!(body["error"], "mixed case"); + assert_eq!(body["request_id"], request_id); + } + + #[tokio::test] + async fn leaves_oversized_json_error_body_untouched() { + let response = send( + Request::builder() + .uri("/oversized-json") + .body(Body::empty()) + .unwrap(), + ) + .await; + + request_id_header(&response); + let bytes = response_bytes(response).await; + assert_eq!(bytes.len(), super::BODY_REWRITE_LIMIT + 1); + assert!(bytes.iter().all(|byte| *byte == b'a')); + } + + #[tokio::test] + async fn leaves_unknown_size_json_error_body_untouched() { + let response = send( + Request::builder() + .uri("/unknown-size-json") + .body(Body::empty()) + .unwrap(), + ) + .await; + + request_id_header(&response); + assert_eq!( + response_bytes(response).await, + Bytes::from_static(b"{\"error\":\"streamed\"}") + ); + } +} diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index aa76498a9..421fbeda4 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -120,6 +120,7 @@ use crate::github_webhooks::{ }; use crate::ip_allowlist::{IpAllowlistConfig, ip_allowlist_middleware}; use crate::jwt_auth::{self, AuthMode, AuthenticatedService, AuthenticatedSubject}; +use crate::request_id::{self, RequestId}; use crate::run_files::{FilesInFlight, list_run_files, new_files_in_flight}; use crate::run_selector::{ResolveRunError, resolve_run_by_selector}; use crate::server_secrets::{LlmClientResult, ServerSecrets}; @@ -1083,6 +1084,7 @@ pub fn build_router_with_options( )) .layer(middleware::from_fn(security_headers::layer)) .layer(middleware::from_fn(http_log_middleware)) + .layer(middleware::from_fn(request_id::layer)) } async fn http_log_middleware(req: axum_extract::Request, next: Next) -> Response { @@ -1091,14 +1093,20 @@ async fn http_log_middleware(req: axum_extract::Request, next: Next) -> Response } let method = req.method().clone(); let path = req.uri().path().to_string(); + let request_id = req + .extensions() + .get::() + .copied() + .map(RequestId::render) + .unwrap_or_default(); let start = std::time::Instant::now(); let response = next.run(req).await; let status = response.status().as_u16(); let latency_ms = start.elapsed().as_millis(); if status >= 500 { - error!(%method, %path, status, latency_ms, "HTTP response"); + error!(%method, %path, status, latency_ms, request_id = %request_id, "HTTP response"); } else { - info!(%method, %path, status, latency_ms, "HTTP response"); + info!(%method, %path, status, latency_ms, request_id = %request_id, "HTTP response"); } response } diff --git a/lib/packages/fabro-api-client/src/models/error-response-entry.ts b/lib/packages/fabro-api-client/src/models/error-response-entry.ts index c27bb7adf..c094a8d9e 100644 --- a/lib/packages/fabro-api-client/src/models/error-response-entry.ts +++ b/lib/packages/fabro-api-client/src/models/error-response-entry.ts @@ -34,5 +34,9 @@ export interface ErrorResponseEntry { * Optional machine-readable error code for structured client handling. */ 'code'?: string; + /** + * Server-generated request identifier; matches the x-request-id response header. + */ + 'request_id'?: string; } diff --git a/lib/packages/fabro-api-client/src/models/error-response.ts b/lib/packages/fabro-api-client/src/models/error-response.ts index ee0d45ad1..50fa24dfc 100644 --- a/lib/packages/fabro-api-client/src/models/error-response.ts +++ b/lib/packages/fabro-api-client/src/models/error-response.ts @@ -25,6 +25,10 @@ export interface ErrorResponse { * List of error entries. */ 'errors': Array; + /** + * Server-generated request identifier; matches the x-request-id response header. + */ + 'request_id'?: string; /** * Optional list of runtime env keys that were written before an install failure. Currently populated by `POST /install/finish` failure responses only. */