From 6b0c4a430849d2ecf697e788719960eb385153c2 Mon Sep 17 00:00:00 2001 From: stroblme Date: Thu, 15 Oct 2026 10:52:33 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx --- backend/fluksio/api/routes/flows.py | 2 +- backend/fluksio/api/routes/runs.py | 9 +++- backend/tests/api/routes/test_runs.py | 29 +++++++++++- docs/code/api.md | 2 +- docs/concepts/runs.md | 8 ++-- frontend/src/client/sdk.gen.ts | 8 +++- frontend/src/client/types.gen.ts | 1 + .../src/components/Common/OverviewToolbar.tsx | 4 ++ frontend/src/components/Flow/FlowEditor.tsx | 11 ++++- frontend/src/components/Flow/FlowPanel.tsx | 46 +++++++++++++++++-- frontend/src/routes/_layout/flows/index.tsx | 22 +++++++-- 11 files changed, 124 insertions(+), 18 deletions(-) diff --git a/backend/fluksio/api/routes/flows.py b/backend/fluksio/api/routes/flows.py index 756233b..2e8be16 100644 --- a/backend/fluksio/api/routes/flows.py +++ b/backend/fluksio/api/routes/flows.py @@ -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}'") diff --git a/backend/fluksio/api/routes/runs.py b/backend/fluksio/api/routes/runs.py index b843e0f..c895e9b 100644 --- a/backend/fluksio/api/routes/runs.py +++ b/backend/fluksio/api/routes/runs.py @@ -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( { diff --git a/backend/tests/api/routes/test_runs.py b/backend/tests/api/routes/test_runs.py index dfc8764..3f512ff 100644 --- a/backend/tests/api/routes/test_runs.py +++ b/backend/tests/api/routes/test_runs.py @@ -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 # diff --git a/docs/code/api.md b/docs/code/api.md index 311bf3f..84f42cd 100644 --- a/docs/code/api.md +++ b/docs/code/api.md @@ -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 | diff --git a/docs/concepts/runs.md b/docs/concepts/runs.md index 75125d2..ea77265 100644 --- a/docs/concepts/runs.md +++ b/docs/concepts/runs.md @@ -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 diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 3b0aab8..293dd18 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -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' diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 0e287dc..acf17da 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -2125,6 +2125,7 @@ export type RunsReadRunsResponse = (Array); export type RunsDeleteRunsData = { flow: string; + history?: boolean; }; export type RunsDeleteRunsResponse = (Message); diff --git a/frontend/src/components/Common/OverviewToolbar.tsx b/frontend/src/components/Common/OverviewToolbar.tsx index b0c8d05..2512290 100644 --- a/frontend/src/components/Common/OverviewToolbar.tsx +++ b/frontend/src/components/Common/OverviewToolbar.tsx @@ -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 ( @@ -307,6 +310,7 @@ export function ConfirmDelete({ )} + {children}