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. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx
This commit is contained in:
@@ -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}'")
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user