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.
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