From 47f032e5352f96526836a97cd60b55ea8ca89b64 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 2 Mar 2026 12:16:01 -0500 Subject: [PATCH] Add `POST /runs/{id}/preview` endpoint and Preview button on Run page Adds a new API endpoint for generating sandbox preview URLs (stubbed to return google.com in demo mode). The Run detail page now shows a Preview button that POSTs to this endpoint and opens the returned URL in a new tab. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/arc-web/app/routes/run-detail.tsx | 41 +++++++++- crates/arc-api/src/demo/mod.rs | 8 ++ crates/arc-api/src/server.rs | 2 + openapi/arc-api.yaml | 42 ++++++++++ .../src/.openapi-generator/FILES | 2 + packages/arc-api-client/src/api/runs-api.ts | 80 +++++++++++++++++++ packages/arc-api-client/src/models/index.ts | 2 + .../src/models/preview-url-request.ts | 21 +++++ .../src/models/preview-url-response.ts | 20 +++++ 9 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 packages/arc-api-client/src/models/preview-url-request.ts create mode 100644 packages/arc-api-client/src/models/preview-url-response.ts diff --git a/apps/arc-web/app/routes/run-detail.tsx b/apps/arc-web/app/routes/run-detail.tsx index 2cfcbe215..ee559669a 100644 --- a/apps/arc-web/app/routes/run-detail.tsx +++ b/apps/arc-web/app/routes/run-detail.tsx @@ -1,11 +1,12 @@ +import { useEffect } from "react"; import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/20/solid"; import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react"; -import { Link, Outlet, useLocation } from "react-router"; +import { Link, Outlet, useFetcher, useLocation } from "react-router"; import { statusColors } from "../data/runs"; import type { ColumnStatus } from "../data/runs"; import { apiJson } from "../api-client"; import { formatElapsedSecs, formatDurationSecs } from "../lib/format"; -import type { RunListItem } from "@qltysh/arc-api-client"; +import type { RunListItem, PreviewUrlResponse } from "@qltysh/arc-api-client"; import type { Route } from "./+types/run-detail"; const tabs = [ @@ -38,6 +39,18 @@ export async function loader({ params }: Route.LoaderArgs) { }; } +export async function action({ params, request }: Route.ActionArgs) { + const formData = await request.formData(); + const port = formData.get("port"); + const expiresInSecs = formData.get("expires_in_secs"); + const result = await apiJson(`/runs/${params.id}/preview`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ port: Number(port), expires_in_secs: Number(expiresInSecs) }), + }); + return result; +} + export function meta({ data }: Route.MetaArgs) { const run = data?.run; return [{ title: run ? `${run.title} — Arc` : "Run — Arc" }]; @@ -47,6 +60,13 @@ export default function RunDetail({ loaderData, params }: Route.ComponentProps) const { run } = loaderData; const { pathname } = useLocation(); const basePath = `/runs/${params.id}`; + const previewFetcher = useFetcher(); + + useEffect(() => { + if (previewFetcher.data?.url) { + window.open(previewFetcher.data.url, "_blank"); + } + }, [previewFetcher.data]); if (!run) { return

Run not found.

; @@ -90,6 +110,23 @@ export default function RunDetail({ loaderData, params }: Route.ComponentProps) Open PR + {run.sandboxId && ( + + + + + + )} {run.sandboxId && ( diff --git a/crates/arc-api/src/demo/mod.rs b/crates/arc-api/src/demo/mod.rs index 652b8a196..29fba1156 100644 --- a/crates/arc-api/src/demo/mod.rs +++ b/crates/arc-api/src/demo/mod.rs @@ -99,6 +99,14 @@ pub async fn steer_run_stub( (StatusCode::OK, Json(serde_json::json!({"accepted": true}))).into_response() } +pub async fn generate_preview_url_stub( + _auth: AuthenticatedService, + State(_state): State>, + Path(_id): Path, +) -> Response { + (StatusCode::OK, Json(serde_json::json!({"url": "https://google.com"}))).into_response() +} + pub async fn get_run_status( _auth: AuthenticatedService, State(_state): State>, diff --git a/crates/arc-api/src/server.rs b/crates/arc-api/src/server.rs index 6ce0cc4ef..d9d813c16 100644 --- a/crates/arc-api/src/server.rs +++ b/crates/arc-api/src/server.rs @@ -79,6 +79,7 @@ pub fn build_router(state: Arc, auth_mode: AuthMode) -> Router { .route("/runs/{id}/verifications", get(crate::demo::get_run_verifications)) .route("/runs/{id}/configuration", get(crate::demo::get_run_configuration)) .route("/runs/{id}/steer", post(crate::demo::steer_run_stub)) + .route("/runs/{id}/preview", post(crate::demo::generate_preview_url_stub)) .route("/workflows", get(crate::demo::list_workflows)) .route("/workflows/{name}", get(crate::demo::get_workflow)) .route("/workflows/{name}/runs", get(crate::demo::list_workflow_runs).post(crate::demo::trigger_workflow_run_stub)) @@ -115,6 +116,7 @@ pub fn build_router(state: Arc, auth_mode: AuthMode) -> Router { .route("/runs/{id}/verifications", get(not_implemented)) .route("/runs/{id}/configuration", get(not_implemented)) .route("/runs/{id}/steer", post(not_implemented)) + .route("/runs/{id}/preview", post(not_implemented)) .route("/workflows", get(not_implemented)) .route("/workflows/{name}", get(not_implemented)) .route("/workflows/{name}/runs", get(not_implemented).post(not_implemented)) diff --git a/openapi/arc-api.yaml b/openapi/arc-api.yaml index 3fc3224df..755c09260 100644 --- a/openapi/arc-api.yaml +++ b/openapi/arc-api.yaml @@ -398,6 +398,29 @@ paths: "404": description: Run not found + /runs/{id}/preview: + post: + operationId: generatePreviewUrl + tags: [Runs] + summary: Generate a sandbox preview URL + parameters: + - $ref: "#/components/parameters/RunId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PreviewUrlRequest" + responses: + "200": + description: Preview URL generated + content: + application/json: + schema: + $ref: "#/components/schemas/PreviewUrlResponse" + "404": + description: Run not found + # ── Workflows ───────────────────────────────────────────────────────── /workflows: @@ -1252,6 +1275,25 @@ components: guidance: type: string + PreviewUrlRequest: + type: object + required: + - port + - expires_in_secs + properties: + port: + type: integer + expires_in_secs: + type: integer + + PreviewUrlResponse: + type: object + required: + - url + properties: + url: + type: string + # ── Workflow Schemas ───────────────────────────────────────────────── WorkflowListItem: diff --git a/packages/arc-api-client/src/.openapi-generator/FILES b/packages/arc-api-client/src/.openapi-generator/FILES index a37293cb7..cfa7e3dbc 100644 --- a/packages/arc-api-client/src/.openapi-generator/FILES +++ b/packages/arc-api-client/src/.openapi-generator/FILES @@ -32,6 +32,8 @@ models/file-checkpoint.ts models/file-diff.ts models/history-entry.ts models/index.ts +models/preview-url-request.ts +models/preview-url-response.ts models/project.ts models/recent-control-result.ts models/retro-list-item.ts diff --git a/packages/arc-api-client/src/api/runs-api.ts b/packages/arc-api-client/src/api/runs-api.ts index ad9cc7943..49d816240 100644 --- a/packages/arc-api-client/src/api/runs-api.ts +++ b/packages/arc-api-client/src/api/runs-api.ts @@ -28,6 +28,10 @@ import type { CancelRun200Response } from '../models'; // @ts-ignore import type { ErrorResponse } from '../models'; // @ts-ignore +import type { PreviewUrlRequest } from '../models'; +// @ts-ignore +import type { PreviewUrlResponse } from '../models'; +// @ts-ignore import type { RunFiles } from '../models'; // @ts-ignore import type { RunListItem } from '../models'; @@ -92,6 +96,45 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration) options: localVarRequestOptions, }; }, + /** + * + * @summary Generate a sandbox preview URL + * @param {string} id + * @param {PreviewUrlRequest} previewUrlRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + generatePreviewUrl: async (id: string, previewUrlRequest: PreviewUrlRequest, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('generatePreviewUrl', 'id', id) + // verify required parameter 'previewUrlRequest' is not null or undefined + assertParamExists('generatePreviewUrl', 'previewUrlRequest', previewUrlRequest) + const localVarPath = `/runs/{id}/preview` + .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: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + localVarHeaderParameter['Content-Type'] = 'application/json'; + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(previewUrlRequest, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @summary Get run checkpoint @@ -712,6 +755,20 @@ export const RunsApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['RunsApi.cancelRun']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * + * @summary Generate a sandbox preview URL + * @param {string} id + * @param {PreviewUrlRequest} previewUrlRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async generatePreviewUrl(id: string, previewUrlRequest: PreviewUrlRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.generatePreviewUrl(id, previewUrlRequest, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['RunsApi.generatePreviewUrl']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * * @summary Get run checkpoint @@ -956,6 +1013,17 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath? cancelRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.cancelRun(id, options).then((request) => request(axios, basePath)); }, + /** + * + * @summary Generate a sandbox preview URL + * @param {string} id + * @param {PreviewUrlRequest} previewUrlRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + generatePreviewUrl(id: string, previewUrlRequest: PreviewUrlRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.generatePreviewUrl(id, previewUrlRequest, options).then((request) => request(axios, basePath)); + }, /** * * @summary Get run checkpoint @@ -1148,6 +1216,18 @@ export class RunsApi extends BaseAPI { return RunsApiFp(this.configuration).cancelRun(id, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @summary Generate a sandbox preview URL + * @param {string} id + * @param {PreviewUrlRequest} previewUrlRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public generatePreviewUrl(id: string, previewUrlRequest: PreviewUrlRequest, options?: RawAxiosRequestConfig) { + return RunsApiFp(this.configuration).generatePreviewUrl(id, previewUrlRequest, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @summary Get run checkpoint diff --git a/packages/arc-api-client/src/models/index.ts b/packages/arc-api-client/src/models/index.ts index 828d0dcf5..bae04dabe 100644 --- a/packages/arc-api-client/src/models/index.ts +++ b/packages/arc-api-client/src/models/index.ts @@ -18,6 +18,8 @@ export * from './execute-query-response'; export * from './file-checkpoint'; export * from './file-diff'; export * from './history-entry'; +export * from './preview-url-request'; +export * from './preview-url-response'; export * from './project'; export * from './recent-control-result'; export * from './retro-list-item'; diff --git a/packages/arc-api-client/src/models/preview-url-request.ts b/packages/arc-api-client/src/models/preview-url-request.ts new file mode 100644 index 000000000..729526da3 --- /dev/null +++ b/packages/arc-api-client/src/models/preview-url-request.ts @@ -0,0 +1,21 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Arc Run API + * HTTP API for managing Arc 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. + */ + + + +export interface PreviewUrlRequest { + 'port': number; + 'expires_in_secs': number; +} + diff --git a/packages/arc-api-client/src/models/preview-url-response.ts b/packages/arc-api-client/src/models/preview-url-response.ts new file mode 100644 index 000000000..028aaae9f --- /dev/null +++ b/packages/arc-api-client/src/models/preview-url-response.ts @@ -0,0 +1,20 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Arc Run API + * HTTP API for managing Arc 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. + */ + + + +export interface PreviewUrlResponse { + 'url': string; +} +