From 887a126f21704263914b3a8572f4094c149a1f63 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Wed, 21 Oct 2026 03:59:24 +0000 Subject: [PATCH] feat: show estimated job run times --- packages/api-client/src/schema.d.ts | 10 ++++++ .../api/src/postgres/run-query-repository.ts | 18 +++++++++-- packages/api/src/run-read-contracts.ts | 1 + packages/app/src/features/runs/history.tsx | 9 +++--- packages/app/src/features/runs/list.tsx | 3 +- packages/app/src/shared/estimated-time.ts | 7 ++++ packages/app/test/jobs.browser.test.mjs | 4 +-- packages/app/test/runs.browser.test.mjs | 2 +- packages/docs/openapi.yaml | 32 +++++++++++++++++++ 9 files changed, 76 insertions(+), 10 deletions(-) create mode 100644 packages/app/src/shared/estimated-time.ts diff --git a/packages/api-client/src/schema.d.ts b/packages/api-client/src/schema.d.ts index c1c2b38d..ab539f12 100644 --- a/packages/api-client/src/schema.d.ts +++ b/packages/api-client/src/schema.d.ts @@ -1537,6 +1537,11 @@ export interface components { created_at: string; updated_at: string; started_at: string | null; + estimated_time?: { + low: number; + median: number; + high: number; + } | null; } & { [key: string]: unknown; }; @@ -1566,6 +1571,11 @@ export interface components { created_at: string; updated_at: string; started_at: string | null; + estimated_time?: { + low: number; + median: number; + high: number; + } | null; }[]; nextCursor: string | null; }; diff --git a/packages/api/src/postgres/run-query-repository.ts b/packages/api/src/postgres/run-query-repository.ts index 02b5b4b7..4f6830e5 100644 --- a/packages/api/src/postgres/run-query-repository.ts +++ b/packages/api/src/postgres/run-query-repository.ts @@ -32,8 +32,22 @@ export class RunQueryRepository { } values.push(limit + 1); const order = sort ? `CASE WHEN ${sortExpressions[sort]} IS NULL THEN 1 ELSE 0 END ASC, ${sortExpressions[sort]} ${direction} NULLS LAST, r.id ${direction}` : "r.created_at DESC,r.id DESC"; - const result = await this.database.pool.query(`SELECT r.id,r.job_id,j.project_id,j.name job_name,p.name project_name,r.issue_id,r.issue_url,r.issue_title,r.run_name,r.agent_name,r.workspace_name,r.agent_kind,r.state,r.provider,r.execution_backend_kind backend_kind,r.execution_capabilities capabilities,r.destination_url,r.artifact_state,r.artifact_error,r.created_at,r.updated_at,jr.started_at - FROM app.runs r JOIN app.jobs j ON j.tenant_id=r.tenant_id AND j.id=r.job_id JOIN app.projects p ON p.tenant_id=j.tenant_id AND p.id=j.project_id JOIN app.job_runs jr ON jr.tenant_id=r.tenant_id AND jr.id=r.id JOIN app.invocations i ON i.tenant_id=r.tenant_id AND i.id=r.invocation_id WHERE ${clauses.join(" AND ")} ORDER BY ${order} LIMIT $${values.length}`, values); + const result = await this.database.pool.query(`SELECT r.id,r.job_id,j.project_id,j.name job_name,p.name project_name,r.issue_id,r.issue_url,r.issue_title,r.run_name,r.agent_name,r.workspace_name,r.agent_kind,r.state,r.provider,r.execution_backend_kind backend_kind,r.execution_capabilities capabilities,r.destination_url,r.artifact_state,r.artifact_error,r.created_at,r.updated_at,jr.started_at,e.estimated_time + FROM app.runs r JOIN app.jobs j ON j.tenant_id=r.tenant_id AND j.id=r.job_id JOIN app.projects p ON p.tenant_id=j.tenant_id AND p.id=j.project_id JOIN app.job_runs jr ON jr.tenant_id=r.tenant_id AND jr.id=r.id JOIN app.invocations i ON i.tenant_id=r.tenant_id AND i.id=r.invocation_id + LEFT JOIN LATERAL ( + WITH durations AS ( + SELECT EXTRACT(EPOCH FROM (completed.updated_at - completed.started_at)) duration + FROM app.job_runs completed + WHERE completed.tenant_id=r.tenant_id AND completed.job_id=r.job_id AND completed.state IN ('succeeded','failed','stopped') + AND completed.started_at IS NOT NULL AND completed.updated_at >= completed.started_at + ), stats AS (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY duration) median FROM durations) + SELECT CASE WHEN stats.median IS NULL THEN NULL ELSE json_build_object( + 'low', round(avg(durations.duration) FILTER (WHERE durations.duration <= stats.median)), + 'median', round(stats.median), + 'high', round(avg(durations.duration) FILTER (WHERE durations.duration >= stats.median)) + ) END estimated_time FROM durations CROSS JOIN stats GROUP BY stats.median + ) e ON true + WHERE ${clauses.join(" AND ")} ORDER BY ${order} LIMIT $${values.length}`, values); const rows = result.rows.slice(0, limit), hasMore = result.rows.length > limit; const last = rows.at(-1); const nextCursor = hasMore && last ? sort ? (() => { diff --git a/packages/api/src/run-read-contracts.ts b/packages/api/src/run-read-contracts.ts index b0a6a9f6..9c5c9203 100644 --- a/packages/api/src/run-read-contracts.ts +++ b/packages/api/src/run-read-contracts.ts @@ -18,6 +18,7 @@ export const revisionTracePage = z.object({ items: z.array(traceEventResponse), export const traceRevisionSql = "jsonb_build_array(c.generation,p.source_kind,p.artifact_sha256,p.parser_version,p.updated_at)::text"; export const runSummaryResponse = z.object({ id: z.string(), job_id: z.string(), project_id: z.string().optional(), project_name: z.string().optional(), job_name: z.string(), issue_id: z.string(), issue_url: z.string().nullable(), issue_title: z.string(), run_name: z.string(), agent_name: z.string(), workspace_name: z.string(), agent_kind: z.string(), state: z.enum([...executionState.options, "done", "ignored"]), provider: z.string(), backend_kind: z.string(), capabilities: z.array(z.string()), destination_url: z.string().nullable(), artifact_state: z.enum(["pending", "collecting", "stored", "partial", "failed"]), artifact_error: z.string().nullable(), created_at: z.string(), updated_at: z.string(), started_at: z.string().nullable(), + estimated_time: z.object({ low: z.number(), median: z.number(), high: z.number() }).nullable().optional(), }); export const runPageResponse = z.object({ items: z.array(runSummaryResponse), nextCursor: z.string().nullable() }); // Public JSON after serialization: enumerate known safe fields and keep opaque diff --git a/packages/app/src/features/runs/history.tsx b/packages/app/src/features/runs/history.tsx index 9d172433..48d492aa 100644 --- a/packages/app/src/features/runs/history.tsx +++ b/packages/app/src/features/runs/history.tsx @@ -2,6 +2,7 @@ import { Button, RunStatusDot, buttonClasses, SortableHeader, StatusHeader, type import { Link, useNavigate, useSearch } from "@tanstack/react-router"; import { useQuery } from "@tanstack/react-query"; import { historyQuery } from "./queries"; +import { formatEstimatedTime } from "../../shared/estimated-time"; export type HistorySort = "run" | "status" | "created" | "agent"; export type HistorySearch = { cursor?: string; previous?: string; states?: RunStatus[]; sort?: HistorySort; direction?: "asc" | "desc" }; export function RunHistory({ jobId }: { jobId: string }) { @@ -21,13 +22,13 @@ export function RunHistory({ jobId }: { jobId: string }) { const clearStatuses = () => void navigate({ to: "/jobs/$jobId", params: { jobId }, search: { ...search, states: undefined, sort: undefined, direction: undefined, cursor: undefined, previous: undefined } }); return
{runs.isPending &&

Loading run history…

}{runs.error &&

{runs.error.message}

} - {runs.data && <> - {columns.slice(1).map(([column, label]) => sortColumn(column)} />)} + {runs.data && <>
Job runs{search.sort ? "" : ", newest first"}
+ {columns.slice(1, 2).map(([column, label]) => sortColumn(column)} />)}{columns.slice(2).map(([column, label]) => sortColumn(column)} />)}{runs.data.items.map(run => - - )}{runs.data.items.length === 0 && } + + )}{runs.data.items.length === 0 && }
Job runs{search.sort ? "" : ", newest first"}
Estimated
{run.run_name || run.issue_title || "Run"}{run.agent_kind}
{search.states?.length ? <>

No runs match the selected statuses.

: "No runs found."}
{run.estimated_time ? `${formatEstimatedTime(run.estimated_time.low)}–${formatEstimatedTime(run.estimated_time.median)}–${formatEstimatedTime(run.estimated_time.high)}` : "—"}{run.agent_kind}
{search.states?.length ? <>

No runs match the selected statuses.

: "No runs found."}
} ; return
{runs.isPending &&

Loading runs…

}{runs.error &&

{runs.error.message}

} - {runs.data && <>{statusHeader}{sortableHeader("job", "Job name")}{sortableHeader("run", "Run")}{sortableHeader("created", "Created")}{runs.data.items.map(run => )}{runs.data.items.length === 0 && }
Job runs{search.sort ? "" : ", newest first"}
Project name
{run.project_id && run.project_name ? {run.project_name} : —}{run.job_name}{run.run_name || "Run"}
{search.states?.length ? <>

No runs match the selected statuses.

: jobId ? "No runs found." : "No runs yet"}
} + {runs.data && <>{statusHeader}{sortableHeader("job", "Job name")}{sortableHeader("run", "Run")}{sortableHeader("created", "Created")}{runs.data.items.map(run => )}{runs.data.items.length === 0 && }
Job runs{search.sort ? "" : ", newest first"}
Project nameEstimated
{run.project_id && run.project_name ? {run.project_name} : —}{run.job_name}{run.run_name || "Run"}{run.estimated_time ? `${formatEstimatedTime(run.estimated_time.low)}–${formatEstimatedTime(run.estimated_time.median)}–${formatEstimatedTime(run.estimated_time.high)}` : "—"}
{search.states?.length ? <>

No runs match the selected statuses.

: jobId ? "No runs found." : "No runs yet"}
} {pagination}
; } diff --git a/packages/app/src/shared/estimated-time.ts b/packages/app/src/shared/estimated-time.ts new file mode 100644 index 00000000..3826748e --- /dev/null +++ b/packages/app/src/shared/estimated-time.ts @@ -0,0 +1,7 @@ +export const formatEstimatedTime = (seconds: number) => { + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60), remainder = seconds % 60; + if (minutes < 60) return remainder ? `${minutes}m ${remainder}s` : `${minutes}m`; + const hours = Math.floor(minutes / 60), remainingMinutes = minutes % 60; + return remainingMinutes ? `${hours}h ${remainingMinutes}m` : `${hours}h`; +}; diff --git a/packages/app/test/jobs.browser.test.mjs b/packages/app/test/jobs.browser.test.mjs index aed27fdb..9e598ee4 100644 --- a/packages/app/test/jobs.browser.test.mjs +++ b/packages/app/test/jobs.browser.test.mjs @@ -335,9 +335,9 @@ test("run table headers sort every column accessibly and persist in the URL", as await page.goto(origin + `/jobs/${jobId}`); const table = page.getByRole("table"); await table.getByRole("columnheader").nth(0).getByRole("button").waitFor(); - assert.deepEqual(await table.locator("thead th").allTextContents(), ["Status", "Run", "Created", "Agent"]); + assert.deepEqual(await table.locator("thead th").allTextContents(), ["Status", "Run", "Estimated", "Created", "Agent"]); assert.equal(await table.getByRole("columnheader", { name: /Agent/ }).count(), 1); - for (const index of [0, 1, 2, 3]) assert.equal(await table.getByRole("columnheader").nth(index).getByRole("button").count(), 1); + for (const index of [0, 1, 3, 4]) assert.equal(await table.getByRole("columnheader").nth(index).getByRole("button").count(), 1); const runButton = page.getByRole("button", { name: "Run job", exact: true }), optionsButton = page.getByRole("button", { name: "More run options", exact: true }); const runBox = await runButton.boundingBox(), optionsBox = await optionsButton.boundingBox(); assert.equal(runBox.x + runBox.width, optionsBox.x); diff --git a/packages/app/test/runs.browser.test.mjs b/packages/app/test/runs.browser.test.mjs index 368aa71c..9fd74b76 100644 --- a/packages/app/test/runs.browser.test.mjs +++ b/packages/app/test/runs.browser.test.mjs @@ -14,7 +14,7 @@ test("all-jobs runs index uses a tenant-scoped page, navigation, refresh, and cr await page.route("**/api/v1/runs?**", route => { const url = new URL(route.request().url()); requests.push(url); refreshes++; const second = url.searchParams.has("cursor"); return route.fulfill({ json: { items: second ? [runs[20]] : runs.slice(0, 20), nextCursor: second ? null : "page-2" } }); }); await page.goto(origin + "/job-runs"); await page.getByRole("heading", { name: "Job Runs", exact: true }).waitFor(); await page.getByRole("link", { name: "Run 0", exact: true }).waitFor(); assert.equal(await page.getByRole("link", { name: "Jobs", exact: true }).count(), 0); assert.equal(await page.getByRole("link", { name: "Runs", exact: true }).evaluate(el => el.className.includes("border-factorize-500")), true); - const table = page.getByRole("table"); assert.deepEqual(await table.locator("thead th").allTextContents(), ["Status", "Project name", "Job name", "Run", "Created"]); assert.equal(await page.getByText("Run 0", { exact: true }).count(), 1); assert.equal(await page.getByRole("link", { name: "Project Alpha", exact: true }).first().getAttribute("href"), `/projects/${projectId}`); assert.equal(await page.locator("td time").count(), 20); assert.equal(await page.locator("td time").first().getAttribute("datetime"), time); + const table = page.getByRole("table"); assert.deepEqual(await table.locator("thead th").allTextContents(), ["Status", "Project name", "Job name", "Run", "Estimated", "Created"]); assert.equal(await page.getByText("Run 0", { exact: true }).count(), 1); assert.equal(await page.getByRole("link", { name: "Project Alpha", exact: true }).first().getAttribute("href"), `/projects/${projectId}`); assert.equal(await page.locator("td time").count(), 20); assert.equal(await page.locator("td time").first().getAttribute("datetime"), time); assert.ok(requests.every(url => url.searchParams.get("limit") === "20" && !url.searchParams.has("jobId"))); await page.getByRole("link", { name: "Next page" }).click(); await page.getByRole("link", { name: "Run 20", exact: true }).waitFor(); await page.reload(); await page.getByRole("link", { name: "Run 20", exact: true }).waitFor(); assert.ok(refreshes >= 2); await context.close(); diff --git a/packages/docs/openapi.yaml b/packages/docs/openapi.yaml index 9a5e1e0d..9830605b 100644 --- a/packages/docs/openapi.yaml +++ b/packages/docs/openapi.yaml @@ -110,6 +110,22 @@ components: type: - string - "null" + estimated_time: + anyOf: + - type: object + properties: + low: + type: number + median: + type: number + high: + type: number + required: + - low + - median + - high + additionalProperties: false + - type: "null" required: - id - job_id @@ -212,6 +228,22 @@ components: type: - string - "null" + estimated_time: + anyOf: + - type: object + properties: + low: + type: number + median: + type: number + high: + type: number + required: + - low + - median + - high + additionalProperties: false + - type: "null" required: - id - job_id