Keep a flow's runs when the flow goes
A run record is a record of what ran, so deleting a flow no longer sweeps its
history: `_forget_runs` is gone from the flow-delete path, and `DELETE /runs/{id}`
is the only thing that removes a run one at a time. The in-flight guard stays —
that is about work, not history.
`DELETE /runs?flow=` is the counterpart to the list's flow filter and what a
reseed needs, sharing the four statements with the single-run delete and
refusing the same way while a run of that flow is still going.
A run whose flow is gone reads as one: `useFlowInputs` reports the 404 rather
than an empty declaration set, so the run page says "flow deleted", explains it,
and disables Retry, which the route would refuse anyway.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CL9zvnnvcp1mvA8o7impxk
This commit is contained in:
@@ -48,7 +48,7 @@ export const OpenAPI: OpenAPIConfig = {
|
||||
PASSWORD: undefined,
|
||||
TOKEN: undefined,
|
||||
USERNAME: undefined,
|
||||
VERSION: '0.1.4+dev',
|
||||
VERSION: '0.1.5+dev',
|
||||
WITH_CREDENTIALS: false,
|
||||
interceptors: {
|
||||
request: new Interceptors(),
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1830,6 +1830,12 @@ export type RunsReadRunsData = {
|
||||
|
||||
export type RunsReadRunsResponse = (Array<fluksio__api__routes__runs__RunRow>);
|
||||
|
||||
export type RunsDeleteRunsData = {
|
||||
flow: string;
|
||||
};
|
||||
|
||||
export type RunsDeleteRunsResponse = (Message);
|
||||
|
||||
export type RunsReadOverviewResponse = (Array<FlowRunsRow>);
|
||||
|
||||
export type RunsExportMetricsData = {
|
||||
@@ -1860,6 +1866,17 @@ export type RunsExportRunsData = {
|
||||
|
||||
export type RunsExportRunsResponse = (unknown);
|
||||
|
||||
export type RunsReadMetricNamesData = {
|
||||
flow?: (string | null);
|
||||
group?: (string | null);
|
||||
ids?: string;
|
||||
since?: (string | null);
|
||||
status?: (string | null);
|
||||
until?: (string | null);
|
||||
};
|
||||
|
||||
export type RunsReadMetricNamesResponse = (Array<(string)>);
|
||||
|
||||
export type RunsReadRunData = {
|
||||
runId: string;
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ import { OpenInDashboard } from "./OpenInDashboard"
|
||||
import {
|
||||
CARD,
|
||||
downloadArtifact,
|
||||
FLOW_GONE,
|
||||
isLive,
|
||||
NO_CURVE,
|
||||
paramText,
|
||||
@@ -42,7 +43,7 @@ export function RunDetail({ id }: { id: string }) {
|
||||
const cancel = useCancelRun()
|
||||
const retry = useRetryRun()
|
||||
const names = useMetricNames(id)
|
||||
const declared = useFlowInputs(run?.flow)
|
||||
const { declared, gone } = useFlowInputs(run?.flow)
|
||||
const [metric, setMetric] = useState("")
|
||||
|
||||
if (isPending || !run) return <Skeleton className="h-96 w-full rounded-lg" />
|
||||
@@ -96,6 +97,14 @@ export function RunDetail({ id }: { id: string }) {
|
||||
ran the working copy
|
||||
</span>
|
||||
)}
|
||||
{gone && (
|
||||
<span
|
||||
className="rounded-full border border-border px-2 py-0.5 text-muted-foreground text-xs"
|
||||
data-testid="flow-gone"
|
||||
>
|
||||
flow deleted
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<OpenInDashboard flow={run.flow} ids={[run.id]} />
|
||||
@@ -115,7 +124,8 @@ export function RunDetail({ id }: { id: string }) {
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => retry.mutate(run.id)}
|
||||
disabled={retry.isPending}
|
||||
// There is no flow left to run it against; the route would 404.
|
||||
disabled={retry.isPending || gone}
|
||||
data-testid="retry-run"
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
@@ -126,6 +136,7 @@ export function RunDetail({ id }: { id: string }) {
|
||||
</header>
|
||||
|
||||
{reason && <p className="text-muted-foreground text-sm">{reason}</p>}
|
||||
{gone && <p className="text-muted-foreground text-sm">{FLOW_GONE}</p>}
|
||||
|
||||
<section className={cn(CARD, "grid gap-4 sm:grid-cols-2 lg:grid-cols-4")}>
|
||||
<Fact label="Submitted">{ago(String(run.created_at ?? ""))}</Fact>
|
||||
@@ -157,7 +168,9 @@ export function RunDetail({ id }: { id: string }) {
|
||||
<h2 className="font-medium text-sm">Inputs</h2>
|
||||
{fed.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
This flow declares no inputs, so there was nothing to choose.
|
||||
{gone
|
||||
? "The run carried no parameters of its own, and its flow is gone, so what it took from the flow cannot be read back."
|
||||
: "This flow declares no inputs, so there was nothing to choose."}
|
||||
</p>
|
||||
) : (
|
||||
<dl className="grid gap-x-6 gap-y-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
|
||||
@@ -721,7 +721,7 @@ function ParamDiff({ rows }: { rows: RunRow[] }) {
|
||||
// ponytail: only when the picks share one flow — spanning flows would mean a
|
||||
// declaration lookup per flow, and a sweep comparison never does.
|
||||
const one = rows.every((run) => run.flow === rows[0].flow)
|
||||
const declared = useFlowInputs(one ? rows[0].flow : undefined)
|
||||
const { declared } = useFlowInputs(one ? rows[0].flow : undefined)
|
||||
const varying = varyingKeys(rows)
|
||||
// Not a parameter, but it is part of what produced the number, and in a
|
||||
// sweep it is often the only thing that moved.
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import { useMemo } from "react"
|
||||
|
||||
import { OpenAPI, RunsService } from "@/client"
|
||||
import { ApiError, OpenAPI, RunsService } from "@/client"
|
||||
import { flowQueryOptions } from "@/components/Flow/queries"
|
||||
import { apiToken } from "@/lib/portal"
|
||||
|
||||
@@ -176,8 +176,12 @@ export async function downloadArtifact(digest: string, name: string) {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
/** The server saying a thing is not there, rather than failing to answer. */
|
||||
const isMissing = (error: unknown) =>
|
||||
error instanceof ApiError && error.status === 404
|
||||
|
||||
/**
|
||||
* What a flow declares it can be given, by input name.
|
||||
* What a flow declares it can be given, by input name — and whether it is gone.
|
||||
*
|
||||
* A batch flow's inputs *are* its parameters — a run supplies values for the
|
||||
* ones it names and takes the flow's own for the rest — so this is what turns
|
||||
@@ -185,15 +189,21 @@ export async function downloadArtifact(digest: string, name: string) {
|
||||
*
|
||||
* The declarations are the flow's *current* ones, while a run carries the
|
||||
* `flow_version` it was submitted against. An input added since is shown on an
|
||||
* older run as a default it never actually received.
|
||||
* older run as a default it never actually received. And the flow may not be
|
||||
* there at all: deleting one leaves its runs standing, so `gone` is an ordinary
|
||||
* answer here rather than a fault, and the screen says so instead of drawing a
|
||||
* run that declares nothing.
|
||||
*/
|
||||
export function useFlowInputs(flow: string | undefined) {
|
||||
const { data } = useQuery({
|
||||
const { data, error } = useQuery({
|
||||
...flowQueryOptions(flow ?? ""),
|
||||
enabled: Boolean(flow),
|
||||
// A flow that is gone stays gone; asking three more times only delays it.
|
||||
retry: (count: number, failure: unknown) =>
|
||||
!isMissing(failure) && count < 3,
|
||||
})
|
||||
const inputs = data?.definition.inputs
|
||||
return useMemo(
|
||||
const declared = useMemo(
|
||||
() =>
|
||||
new Map<string, unknown>(
|
||||
(inputs ?? [])
|
||||
@@ -202,6 +212,7 @@ export function useFlowInputs(flow: string | undefined) {
|
||||
),
|
||||
[inputs],
|
||||
)
|
||||
return { declared, gone: isMissing(error) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -267,13 +278,17 @@ export async function cancelThenDelete(runId: string) {
|
||||
* Why a finished run can have nothing to draw.
|
||||
*
|
||||
* A cache hit replays no emissions, so a restored node's curve is read back
|
||||
* from the run that recorded it. Deleting that run — deleting its flow does —
|
||||
* takes the curve with it, and then a reused run has a result and nothing to
|
||||
* draw. Said out loud rather than left as an empty chart, which reads as a fault.
|
||||
* from the run that recorded it. Deleting that run takes the curve with it, and
|
||||
* then a reused run has a result and nothing to draw. Said out loud rather than
|
||||
* left as an empty chart, which reads as a fault.
|
||||
*/
|
||||
export const NO_CURVE =
|
||||
"No curve was recorded. A node restored from the cache is read back from the run that produced it, so this draws nothing once that run has been deleted — its outputs are still on the result."
|
||||
|
||||
/** Why a run can name a flow that is not there any more. */
|
||||
export const FLOW_GONE =
|
||||
"The flow this ran has been deleted. The run is kept as the record of what ran, but there is nothing left to retry it against."
|
||||
|
||||
/** A run id, short enough for a table cell. The tail is the random half. */
|
||||
export const shortId = (id: string) => id.slice(-8)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user