Flow delete offers to delete its runs, history included

DELETE /runs?flow= removes every Run row of the flow but left its flow_run
rows, so a deleted flow kept appearing in the history. history=true now
drops those too, and the flow delete dialogs (editor and bulk) send it when
"Also delete its runs" is ticked.
This commit is contained in:
2026-10-15 10:52:33 +02:00
parent 7ea53ccf17
commit 6fa44bacf3
11 changed files with 124 additions and 18 deletions
+1 -1
View File
@@ -556,7 +556,7 @@ async def delete_flow(
# Its files are gone; its values and queued work would otherwise linger.
# Its runs stay: a run record is a record of what ran, and surviving the
# flow it belonged to is the point of keeping one. `DELETE /runs?flow=`
# is what clears them.
# is what clears them, and with `history=true` its cascades too.
await run_in_threadpool(controller.forget_flow, name)
await controller.reload_flow(name)
return Message(message=f"Deleted flow '{name}'")
+8 -1
View File
@@ -29,7 +29,7 @@ from fluksio.flow.events import event_bus
from fluksio.flow.messages import requalify
from fluksio.flow.runs import OUTPUT_CAP, RunRejected, RunService, new_run_id
from fluksio.flow.store import FlowNotFound
from fluksio.models import Message, Run, RunArtifact, RunMetric, RunNode
from fluksio.models import FlowRun, Message, Run, RunArtifact, RunMetric, RunNode
router = APIRouter(
prefix="/runs", tags=["runs"], dependencies=[Depends(get_current_user)]
@@ -881,6 +881,7 @@ def delete_runs(
session: SessionDep,
user: CurrentUser,
flow: Annotated[str, Query(min_length=1)],
history: bool = False,
) -> Any:
"""Every run of one flow at once.
@@ -888,6 +889,10 @@ def delete_runs(
deleting a flow leaves its runs standing on purpose, so this is the only
way to clear a history without a call per run. ``flow`` is required —
there is no "delete every run of everything" here, by design.
``history`` also drops the flow's rows in the run history (``flow_run``),
its cascades included. That is for a flow being deleted, which would
otherwise stay in the history until the retention window passes.
"""
with writing(session):
live = _live_run(session, flow)
@@ -906,6 +911,8 @@ def delete_runs(
select(func.count()).select_from(Run).where(col(Run.flow) == flow)
).one()
_drop_runs(session, chosen)
if history:
session.execute(delete(FlowRun).where(col(FlowRun.flow) == flow))
session.commit()
event_bus.publish(
{
+28 -1
View File
@@ -33,7 +33,7 @@ from fluksio.flow.runs import (
)
from fluksio.flow.schemas import FlowDef, FlowInput, NodeDef
from fluksio.flow.store import FlowStore
from fluksio.models import Run, RunArtifact, RunMetric, RunNode
from fluksio.models import FlowRun, Run, RunArtifact, RunMetric, RunNode
def test_a_run_cache_finds_what_an_earlier_run_recorded(tmp_path):
@@ -1041,6 +1041,33 @@ def test_deleting_a_flows_runs_clears_that_flow_only(client, superuser_token_hea
session.commit()
def test_history_also_clears_a_flows_cascades(client, superuser_token_headers):
"""What deleting a flow together with its runs asks for."""
made = datetime.now(UTC)
url = f"{settings.API_V1_STR}/runs"
with Session(db_engine) as session:
session.add(Run(id="hist-1", flow="gone", status="ok", created_at=made))
session.add(FlowRun(id="hist-1", flow="gone", source="run", started_at=made))
session.add(FlowRun(id="hist-2", flow="gone", started_at=made))
session.add(FlowRun(id="hist-3", flow="other", started_at=made))
session.commit()
plain = client.delete(url, params={"flow": "gone"}, headers=superuser_token_headers)
assert plain.status_code == 200
with Session(db_engine) as session:
assert session.get(FlowRun, "hist-2") is not None
answer = client.delete(
url, params={"flow": "gone", "history": True}, headers=superuser_token_headers
)
assert answer.status_code == 200
with Session(db_engine) as session:
left = session.exec(select(FlowRun).where(col(FlowRun.flow) == "gone")).all()
assert left == []
session.delete(session.get(FlowRun, "hist-3"))
session.commit()
# -----------------------------------------------------------------------------
# A cached node's curve
#
+1 -1
View File
@@ -131,7 +131,7 @@ values. You can publish only to declared messages, just as dashboard controls do
| `GET` | `/runs/{id}/nodes/{node}/outputs` | read what one node returned |
| `POST` | `/runs/{id}/cancel`, `/runs/{id}/retry` | cancel, or create a retry |
| `DELETE` | `/runs/{id}` | delete a finished run and its records |
| `DELETE` | `/runs?flow={name}` | delete a flow's runs; refused while any are active |
| `DELETE` | `/runs?flow={name}` | delete a flow's runs; refused while any are active. `history=true` also clears its cascades from the history |
| `GET` | `/runs/{id}/metrics?name=&stride=` | read metric series |
| `GET` | `/runs/metrics/names` | list metric names across a selection |
| `GET` | `/runs/series/compare?ids=a,b,c&metric=&x=` | compare curves by step, time, or another metric |
+5 -3
View File
@@ -191,9 +191,11 @@ Select two or more runs to compare curves and the parameters that differ.
Plot against step, elapsed seconds or another metric from the same runs.
Comparisons have shareable URLs.
Deleting a flow keeps its run history. You can still inspect those runs, but
cannot retry them until the flow exists again. Delete history separately,
after active runs finish or are cancelled.
Deleting a flow keeps its run history, and the artifacts those runs recorded
stay on disk. You can still inspect those runs, but cannot retry them until
the flow exists again. Tick **Also delete its runs** in the delete dialog to
clear them with the flow, or delete history separately after active runs
finish or are cancelled.
### Taking it into a dataframe
+7 -1
View File
@@ -2343,8 +2343,13 @@ export class RunsService {
* deleting a flow leaves its runs standing on purpose, so this is the only
* way to clear a history without a call per run. ``flow`` is required —
* there is no "delete every run of everything" here, by design.
*
* ``history`` also drops the flow's rows in the run history (``flow_run``),
* its cascades included. That is for a flow being deleted, which would
* otherwise stay in the history until the retention window passes.
* @param data The data for the request.
* @param data.flow
* @param data.history
* @returns Message Successful Response
* @throws ApiError
*/
@@ -2353,7 +2358,8 @@ export class RunsService {
method: 'DELETE',
url: '/api/v1/runs',
query: {
flow: data.flow
flow: data.flow,
history: data.history
},
errors: {
422: 'Validation Error'
+1
View File
@@ -2125,6 +2125,7 @@ export type RunsReadRunsResponse = (Array<fluksio__api__routes__runs__RunRow>);
export type RunsDeleteRunsData = {
flow: string;
history?: boolean;
};
export type RunsDeleteRunsResponse = (Message);
@@ -277,6 +277,7 @@ export function ConfirmDelete({
pending,
description,
onConfirm,
children,
}: {
open: boolean
onOpenChange: (open: boolean) => void
@@ -286,6 +287,8 @@ export function ConfirmDelete({
/** What is actually lost, when the git-backed answer below is not it. */
description?: string
onConfirm: () => void
/** An option the delete takes, drawn between the question and the buttons. */
children?: ReactNode
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -307,6 +310,7 @@ export function ConfirmDelete({
)}
</DialogDescription>
</DialogHeader>
{children}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Keep {names.length === 1 ? "it" : "them"}
+9 -2
View File
@@ -31,6 +31,7 @@ import {
FlowsService,
type MessageSpec,
type NodeDef_Input,
RunsService,
} from "@/client"
import { DiscardDraftDialog } from "@/components/Common/Dock"
import { useTheme } from "@/components/theme-provider"
@@ -790,7 +791,13 @@ function FlowEditorInner({
})
const deleteMutation = useMutation({
mutationFn: () => FlowsService.deleteFlow({ name: flowName }),
mutationFn: async (withRuns: boolean) => {
await FlowsService.deleteFlow({ name: flowName })
// After the flow, which is refused while a run is live, so a refusal
// leaves both standing.
if (withRuns)
await RunsService.deleteRuns({ flow: flowName, history: true })
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: flowKeys.all })
setFlowPanelOpen(false)
@@ -1327,7 +1334,7 @@ function FlowEditorInner({
nodeCount={definitions.length}
// Confirmed, so it is a finished edit rather than a run of keystrokes.
onChange={(next) => commitDoc({ ...next, nodes: definitions }, true)}
onDelete={() => deleteMutation.mutate()}
onDelete={(withRuns) => deleteMutation.mutate(withRuns)}
enabled={detail.enabled ?? true}
toggling={enableMutation.isPending}
onToggleEnabled={(next) => enableMutation.mutate(next)}
+42 -4
View File
@@ -1,7 +1,8 @@
import { useState } from "react"
import { useId, useState } from "react"
import type { FlowDef_Input } from "@/client"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Dialog,
DialogContent,
@@ -34,7 +35,7 @@ export function FlowPanel({
definition: FlowDef_Input
nodeCount: number
onChange: (next: FlowDef_Input) => void
onDelete: () => void
onDelete: (withRuns: boolean) => void
hasDraft: boolean
enabled: boolean
toggling: boolean
@@ -42,6 +43,7 @@ export function FlowPanel({
onClose: () => void
}) {
const [confirmOpen, setConfirmOpen] = useState(false)
const [withRuns, setWithRuns] = useState(false)
return (
<>
@@ -64,7 +66,10 @@ export function FlowPanel({
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setConfirmOpen(true)}
onClick={() => {
setWithRuns(false)
setConfirmOpen(true)
}}
data-testid="delete-flow"
>
Delete flow
@@ -142,6 +147,7 @@ export function FlowPanel({
stays in the flow store's git repository.
</DialogDescription>
</DialogHeader>
<AlsoDeleteRuns checked={withRuns} onCheckedChange={setWithRuns} />
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmOpen(false)}>
Keep it
@@ -150,7 +156,7 @@ export function FlowPanel({
variant="destructive"
onClick={() => {
setConfirmOpen(false)
onDelete()
onDelete(withRuns)
}}
data-testid="confirm-delete-flow"
>
@@ -162,3 +168,35 @@ export function FlowPanel({
</>
)
}
/**
* Whether deleting a flow takes its runs along. Off by default: a run is a
* record of what ran, and keeping one keeps the artifacts it recorded.
*/
export function AlsoDeleteRuns({
checked,
onCheckedChange,
plural = false,
}: {
checked: boolean
onCheckedChange: (checked: boolean) => void
plural?: boolean
}) {
const id = useId()
return (
<div className="grid gap-1">
<label htmlFor={id} className="flex items-center gap-2 text-sm">
<Checkbox
id={id}
checked={checked}
onCheckedChange={(next) => onCheckedChange(next === true)}
data-testid="delete-flow-runs"
/>
Also delete {plural ? "their" : "its"} runs
</label>
<p className="text-sm text-muted-foreground">
Otherwise the runs stay, and keep their artifacts on disk.
</p>
</div>
)
}
+18 -4
View File
@@ -3,7 +3,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"
import { Workflow } from "lucide-react"
import { useState } from "react"
import { FlowsService } from "@/client"
import { FlowsService, RunsService } from "@/client"
import { OverviewCard, useSelection } from "@/components/Common/OverviewCard"
import {
ConfirmDelete,
@@ -11,6 +11,7 @@ import {
useDeleteSelected,
usePublishAll,
} from "@/components/Common/OverviewToolbar"
import { AlsoDeleteRuns } from "@/components/Flow/FlowPanel"
import { flowKeys, flowsQueryOptions } from "@/components/Flow/queries"
import { Button } from "@/components/ui/button"
import {
@@ -43,6 +44,7 @@ function Flows() {
const [search, setSearch] = useState("")
const [dialogOpen, setDialogOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const [withRuns, setWithRuns] = useState(false)
const create = useMutation({
mutationFn: (flow: string) =>
@@ -86,7 +88,10 @@ function Flows() {
const selection = useSelection(flows.map((flow) => flow.name))
const remove = useDeleteSelected(
(flow) => FlowsService.deleteFlow({ name: flow }),
async (flow) => {
await FlowsService.deleteFlow({ name: flow })
if (withRuns) await RunsService.deleteRuns({ flow, history: true })
},
"flow",
() => queryClient.invalidateQueries({ queryKey: flowKeys.all }),
)
@@ -113,7 +118,10 @@ function Flows() {
publishing={publishAll.isPending}
onPublishAll={() => publishAll.mutate(drafts)}
selectedCount={selection.selected.length}
onDeleteSelected={() => setDeleteOpen(true)}
onDeleteSelected={() => {
setWithRuns(false)
setDeleteOpen(true)
}}
/>
<DialogContent>
<form
@@ -160,7 +168,13 @@ function Flows() {
setDeleteOpen(false)
remove.mutate(selection.selected)
}}
/>
>
<AlsoDeleteRuns
checked={withRuns}
onCheckedChange={setWithRuns}
plural={selection.selected.length > 1}
/>
</ConfirmDelete>
{isPending ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">