diff --git a/backend/apps/workflows/models.py b/backend/apps/workflows/models.py index 93a6f98e..36ed17d8 100644 --- a/backend/apps/workflows/models.py +++ b/backend/apps/workflows/models.py @@ -107,6 +107,9 @@ class Workflow(BaseModel): icon: str = "" # User-chosen swatch (hex). None falls back to the id-hash color in the UI. color: Optional[str] = None + # Soft-delete tombstone. Set = in Trash (hidden from lists + scheduler); + # restore nulls it, purge removes the record entirely. + deleted_at: Optional[datetime] = None system_prompt: Optional[str] = None use_synced_prompt: bool = True steps: list[WorkflowStep] = Field(default_factory=list) diff --git a/backend/apps/workflows/storage.py b/backend/apps/workflows/storage.py index c8d0125a..6aab9dbd 100644 --- a/backend/apps/workflows/storage.py +++ b/backend/apps/workflows/storage.py @@ -122,7 +122,16 @@ def init() -> None: def list_workflows() -> list[Workflow]: if not _cache_loaded: init() - return list(_workflow_cache.values()) + # Soft-deleted records are filtered here so the scheduler, calendar, and + # every list view skip them with no per-caller guard. Trash reads via + # list_deleted_workflows; restore/purge fetch by id with get_workflow. + return [w for w in _workflow_cache.values() if w.deleted_at is None] + + +def list_deleted_workflows() -> list[Workflow]: + if not _cache_loaded: + init() + return [w for w in _workflow_cache.values() if w.deleted_at is not None] def get_workflow(wid: str) -> Optional[Workflow]: diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py index 44c4e865..71d93d8b 100644 --- a/backend/apps/workflows/workflows.py +++ b/backend/apps/workflows/workflows.py @@ -704,6 +704,16 @@ async def list_calendar_events( return {"events": events} +@workflows.router.get("/deleted") +async def list_deleted_workflows(dashboard_id: Optional[str] = None): + """Trashed workflows, most-recently-deleted first. Backs the Trash screen.""" + items = storage.list_deleted_workflows() + if dashboard_id: + items = [w for w in items if not w.dashboard_id or w.dashboard_id == dashboard_id] + items.sort(key=lambda w: w.deleted_at or w.created_at, reverse=True) + return {"workflows": [_enriched(w) for w in items]} + + @workflows.router.get("/{workflow_id}") async def get_workflow(workflow_id: str): wf = storage.get_workflow(workflow_id) @@ -811,9 +821,20 @@ async def update_workflow( @workflows.router.delete("/{workflow_id}") async def delete_workflow(workflow_id: str): - existed = storage.delete_workflow(workflow_id) - if not existed: + """Soft-delete: move to Trash. The record stays on disk with deleted_at + set so it's hidden from every list and the scheduler but restorable. + /{id}/purge does the irreversible hard delete.""" + wf = storage.get_workflow(workflow_id) + if not wf or wf.deleted_at is not None: raise HTTPException(status_code=404, detail="Workflow not found") + wf.deleted_at = datetime.now() + wf.schedule.enabled = False + wf.next_run_at = None + storage.save_workflow(wf) + # Drop any pending missed fires so a trashed workflow can't haunt the card. + stale = [m.id for m in storage.list_missed() if m.workflow_id == workflow_id] + if stale: + storage.remove_missed(stale) scheduler.kick() try: from backend.apps.agents.core.ws_manager import ws_manager @@ -823,6 +844,42 @@ async def delete_workflow(workflow_id: str): return {"ok": True} +@workflows.router.post("/{workflow_id}/restore") +async def restore_workflow(workflow_id: str): + """Bring a trashed workflow back. Its schedule stays off (we disabled it + on delete); the user re-enables it deliberately.""" + wf = storage.get_workflow(workflow_id) + if not wf or wf.deleted_at is None: + raise HTTPException(status_code=404, detail="Workflow not in trash") + wf.deleted_at = None + storage.save_workflow(wf) + enriched = _enriched(wf) + try: + from backend.apps.agents.core.ws_manager import ws_manager + await ws_manager.broadcast_global("workflow:updated", { + "workflow_id": wf.id, + "workflow": enriched, + }) + except Exception: + pass + return enriched + + +@workflows.router.delete("/{workflow_id}/purge") +async def purge_workflow(workflow_id: str): + """Hard delete, only from Trash. Removes the record and its run history.""" + wf = storage.get_workflow(workflow_id) + if not wf or wf.deleted_at is None: + raise HTTPException(status_code=404, detail="Workflow not in trash") + storage.delete_workflow(workflow_id) + try: + from backend.apps.agents.core.ws_manager import ws_manager + await ws_manager.broadcast_global("workflow:deleted", {"workflow_id": workflow_id}) + except Exception: + pass + return {"ok": True} + + @workflows.router.post("/{workflow_id}/edit-agent-session") async def edit_agent_session(workflow_id: str): """Create (or return existing) Edit Agent session for this workflow. diff --git a/backend/tests/test_schedule_e2e.py b/backend/tests/test_schedule_e2e.py index e2256f91..5110166e 100644 --- a/backend/tests/test_schedule_e2e.py +++ b/backend/tests/test_schedule_e2e.py @@ -369,3 +369,20 @@ async def test_last_day_of_month_fires_on_month_end(): nxt2 = scheduler.compute_next_fire(wf, ref=nxt) local2 = nxt2.astimezone(tz) assert local2.month == 3 and local2.day == 31 + + +async def test_soft_deleted_excluded_from_list(): + """Soft-deleted workflows drop out of list_workflows (so the scheduler and + every list view skip them) but stay visible to list_deleted_workflows.""" + from backend.apps.workflows import storage + live = _make_wf(title="live") + trashed = _make_wf(title="trashed") + trashed.deleted_at = datetime.now() + storage.save_workflow(live) + storage.save_workflow(trashed) + active_ids = {w.id for w in storage.list_workflows()} + deleted_ids = {w.id for w in storage.list_deleted_workflows()} + assert live.id in active_ids and trashed.id not in active_ids + assert trashed.id in deleted_ids and live.id not in deleted_ids + # get_workflow still resolves a trashed record so restore/purge can fetch it. + assert storage.get_workflow(trashed.id) is not None diff --git a/frontend/src/app/pages/Workflows/app/LeftRail.tsx b/frontend/src/app/pages/Workflows/app/LeftRail.tsx index a9250800..dec6db0d 100644 --- a/frontend/src/app/pages/Workflows/app/LeftRail.tsx +++ b/frontend/src/app/pages/Workflows/app/LeftRail.tsx @@ -3,17 +3,19 @@ import type { CSSProperties } from 'react'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { deleteWorkflow } from '@/shared/state/workflowsSlice'; import { isScheduleActive, describeSchedule } from '@/app/pages/Workflows/scheduleUtils'; -import { colorForId, WC } from './uiKit'; +import { colorForWorkflow, useWC } from './uiKit'; import type { AppNav } from './types'; const navBase: CSSProperties = { - display: 'flex', alignItems: 'center', gap: 10, padding: '7px 9px', + display: 'flex', alignItems: 'center', gap: 9, padding: '6px 9px', borderRadius: 8, cursor: 'pointer', fontSize: 13.5, }; const LeftRail: React.FC<{ nav: AppNav }> = ({ nav }) => { + const WC = useWC(); const dispatch = useAppDispatch(); const items = useAppSelector((s) => s.workflows.items); + const trashCount = useAppSelector((s) => s.workflows.deleted.length); const [query, setQuery] = useState(''); const workflows = useMemo(() => Object.values(items) @@ -28,10 +30,8 @@ const LeftRail: React.FC<{ nav: AppNav }> = ({ nav }) => { const activeCount = workflows.filter((w) => isScheduleActive(w.schedule)).length; - const onDelete = (id: string, title: string) => { - // Hard delete: the backend has no soft-delete/restore, so confirm here - // rather than imply a recoverable trash that doesn't exist. - if (!window.confirm(`Delete "${title}"? This can't be undone.`)) return; + const onDelete = (id: string) => { + // Soft-delete: moves to Trash (recoverable), so no scary confirm. dispatch(deleteWorkflow(id)); if (nav.selectedId === id) nav.goHome(); }; @@ -45,11 +45,14 @@ const LeftRail: React.FC<{ nav: AppNav }> = ({ nav }) => { const newStyle: CSSProperties = nav.mode === 'new' ? { ...navBase, background: WC.accent, color: '#fff', fontWeight: 600 } : { ...navBase, color: WC.accent, fontWeight: 600 }; + const trashStyle: CSSProperties = nav.mode === 'trash' + ? { ...navBase, background: WC.selBg, color: WC.ink, fontWeight: 600 } + : { ...navBase, color: WC.ink3 }; return (
-
+
= ({ nav }) => { placeholder="Search" style={{ flex: 1, border: 'none', background: 'transparent', fontSize: 12.5, color: WC.ink }} /> + ⌘K
@@ -88,9 +92,9 @@ const LeftRail: React.FC<{ nav: AppNav }> = ({ nav }) => {
nav.selectWorkflow(w.id)} - style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 9px', borderRadius: 9, cursor: 'pointer', background: isSel ? WC.selBg : 'transparent' }} + style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '5px 9px', borderRadius: 8, cursor: 'pointer', background: isSel ? WC.selBg : 'transparent' }} > -
+
{w.title || 'Untitled workflow'}
@@ -98,9 +102,9 @@ const LeftRail: React.FC<{ nav: AppNav }> = ({ nav }) => {
{ e.stopPropagation(); onDelete(w.id, w.title || 'this workflow'); }} + onClick={(e) => { e.stopPropagation(); onDelete(w.id); }} style={{ width: 22, height: 22, borderRadius: 6, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: WC.faint, flex: 'none' }} - aria-label="Delete workflow" + aria-label="Move to trash" >
@@ -111,6 +115,16 @@ const LeftRail: React.FC<{ nav: AppNav }> = ({ nav }) => {
No workflows yet.
)}
+ +
+
+ + Trash + {trashCount > 0 && ( + {trashCount} + )} +
+
); }; diff --git a/frontend/src/app/pages/Workflows/app/TrashView.tsx b/frontend/src/app/pages/Workflows/app/TrashView.tsx new file mode 100644 index 00000000..a0878b74 --- /dev/null +++ b/frontend/src/app/pages/Workflows/app/TrashView.tsx @@ -0,0 +1,60 @@ +import React, { useEffect } from 'react'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { fetchDeletedWorkflows, restoreWorkflow, purgeWorkflow } from '@/shared/state/workflowsSlice'; +import { colorForWorkflow, useWC } from './uiKit'; +import { whenText } from './model'; + +const TrashView: React.FC = () => { + const WC = useWC(); + const dispatch = useAppDispatch(); + const dashboardId = useAppSelector((s) => s.tempState.lastDashboardId) || undefined; + const deleted = useAppSelector((s) => s.workflows.deleted); + const loading = useAppSelector((s) => s.workflows.deletedLoading); + + useEffect(() => { dispatch(fetchDeletedWorkflows(dashboardId)); }, [dashboardId, dispatch]); + + const now = new Date(); + const onPurge = (id: string, title: string) => { + if (!window.confirm(`Permanently delete "${title}"? This can't be undone.`)) return; + dispatch(purgeWorkflow(id)); + }; +// when clicking a run and the run card pops up, make the card pop up slightly more to the right. + return ( +
+
+
Deleted workflows
+

Trash

+
+ +
+ {deleted.length > 0 ? ( +
+ {deleted.map((w) => ( +
+
+
+
{w.title || 'Untitled workflow'}
+
{w.deleted_at ? `Deleted ${whenText(new Date(w.deleted_at), now)}` : 'Deleted'} · {w.steps.length} step{w.steps.length === 1 ? '' : 's'}
+
+ + +
+ ))} +
+ ) : ( + !loading && ( +
+
+ +
+
Trash is empty
+
Deleted workflows appear here. Restore them or remove them permanently.
+
+ ) + )} +
+
+ ); +}; + +export default TrashView; diff --git a/frontend/src/app/pages/Workflows/app/WorkflowsAppContent.tsx b/frontend/src/app/pages/Workflows/app/WorkflowsAppContent.tsx index 5012eb56..9e0fca6c 100644 --- a/frontend/src/app/pages/Workflows/app/WorkflowsAppContent.tsx +++ b/frontend/src/app/pages/Workflows/app/WorkflowsAppContent.tsx @@ -2,20 +2,22 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { clearWorkflowsAppTarget } from '@/shared/state/dashboardLayoutSlice'; import { - fetchWorkflows, fetchAllRuns, fetchPausedState, fetchActiveRuns, + fetchWorkflows, fetchAllRuns, fetchPausedState, fetchActiveRuns, fetchDeletedWorkflows, } from '@/shared/state/workflowsSlice'; import { fetchMissedRuns } from '@/shared/state/missedRunsSlice'; -import { FONT_SANS, WC } from './uiKit'; +import { FONT_SANS, useWC } from './uiKit'; import type { AppMode, CalView, AppNav } from './types'; import LeftRail from './LeftRail'; import HomeView from './HomeView'; import CalendarView from './CalendarView'; import DetailView from './DetailView'; import ComposeView from './ComposeView'; +import TrashView from './TrashView'; // The three-pane Workflows body, independent of how it's framed (canvas card). // Holds nav + data; the card chrome (title bar drag handle, resize) wraps it. const WorkflowsAppContent: React.FC = () => { + const WC = useWC(); const dispatch = useAppDispatch(); const target = useAppSelector((s) => s.dashboardLayout.workflowsAppTarget); const dashboardId = useAppSelector((s) => s.tempState.lastDashboardId) || undefined; @@ -31,6 +33,7 @@ const WorkflowsAppContent: React.FC = () => { dispatch(fetchPausedState()); dispatch(fetchActiveRuns()); dispatch(fetchMissedRuns()); + dispatch(fetchDeletedWorkflows(dashboardId)); }, [dashboardId, dispatch]); // A deep-link target (history/notifications/toasts) jumps to that workflow's @@ -48,10 +51,11 @@ const WorkflowsAppContent: React.FC = () => { goHome: () => setMode('home'), goCalendar: () => setMode('calendar'), goNew: () => { setSelectedId(null); setMode('new'); }, + goTrash: () => { dispatch(fetchDeletedWorkflows(dashboardId)); setMode('trash'); }, selectWorkflow: (id: string) => { setSelectedId(id); setMode('detail'); }, setCalView: (v: CalView) => setCalView(v), setRefDate: (d: Date) => setRefDate(d), - }), [mode, selectedId, calView, refDate]); + }), [mode, selectedId, calView, refDate, dashboardId, dispatch]); return (
@@ -60,6 +64,7 @@ const WorkflowsAppContent: React.FC = () => { {mode === 'calendar' && } {mode === 'detail' && selectedId && } {mode === 'new' && } + {mode === 'trash' && }
); }; diff --git a/frontend/src/app/pages/Workflows/app/types.ts b/frontend/src/app/pages/Workflows/app/types.ts index d2700ea6..c8e92065 100644 --- a/frontend/src/app/pages/Workflows/app/types.ts +++ b/frontend/src/app/pages/Workflows/app/types.ts @@ -1,4 +1,4 @@ -export type AppMode = 'home' | 'calendar' | 'detail' | 'new'; +export type AppMode = 'home' | 'calendar' | 'detail' | 'new' | 'trash'; export type CalView = 'week' | 'month'; // Navigation + ephemeral UI state for the Workflows app window. Data lives in @@ -11,6 +11,7 @@ export interface AppNav { goHome: () => void; goCalendar: () => void; goNew: () => void; + goTrash: () => void; selectWorkflow: (id: string) => void; setCalView: (v: CalView) => void; setRefDate: (d: Date) => void; diff --git a/frontend/src/shared/state/workflowsSlice.ts b/frontend/src/shared/state/workflowsSlice.ts index da35f243..002e9c7f 100644 --- a/frontend/src/shared/state/workflowsSlice.ts +++ b/frontend/src/shared/state/workflowsSlice.ts @@ -65,6 +65,8 @@ export interface Workflow { icon: string; /** User-chosen swatch (hex). Null/undefined falls back to the id-hash color. */ color?: string | null; + /** Soft-delete tombstone (ISO). Set = in Trash. */ + deleted_at?: string | null; system_prompt: string | null; use_synced_prompt: boolean; steps: WorkflowStep[]; @@ -197,9 +199,11 @@ interface State { allRunsLoading: boolean; runningToast: RunningToast | null; runControlPending: Record; + deleted: Workflow[]; + deletedLoading: boolean; } -const initialState: State = { items: {}, runs: {}, openCards: {}, loaded: false, loading: false, paused: false, active: [], cloudSmsEnabled: false, allRuns: [], allRunsLoading: false, runningToast: null, runControlPending: {} }; +const initialState: State = { items: {}, runs: {}, openCards: {}, loaded: false, loading: false, paused: false, active: [], cloudSmsEnabled: false, allRuns: [], allRunsLoading: false, runningToast: null, runControlPending: {}, deleted: [], deletedLoading: false }; function mergeRunIntoState(state: State, r: WorkflowRun) { const arr = state.runs[r.workflow_id] || []; @@ -398,6 +402,25 @@ export const deleteWorkflow = createAsyncThunk('workflows/delete', async (id: st return id; }); +export const fetchDeletedWorkflows = createAsyncThunk('workflows/fetchDeleted', async (dashboardId?: string) => { + const url = dashboardId ? `${API}/deleted?dashboard_id=${encodeURIComponent(dashboardId)}` : `${API}/deleted`; + const res = await fetch(url); + const data = await res.json(); + return data.workflows as Workflow[]; +}); + +export const restoreWorkflow = createAsyncThunk('workflows/restore', async (id: string) => { + const res = await fetch(`${API}/${id}/restore`, { method: 'POST' }); + if (!res.ok) throw new Error(`restore failed ${res.status}`); + return (await res.json()) as Workflow; +}); + +export const purgeWorkflow = createAsyncThunk('workflows/purge', async (id: string) => { + const res = await fetch(`${API}/${id}/purge`, { method: 'DELETE' }); + if (!res.ok) throw new Error(`purge failed ${res.status}`); + return id; +}); + type RunWorkflowNowArg = string | { id: string; signature?: string | null }; export const runWorkflowNow = createAsyncThunk('workflows/run', async (arg: RunWorkflowNowArg) => { @@ -609,6 +632,16 @@ const slice = createSlice({ state.allRuns = action.payload; }) .addCase(fetchAllRuns.rejected, (state) => { state.allRunsLoading = false; }) + .addCase(fetchDeletedWorkflows.pending, (state) => { state.deletedLoading = true; }) + .addCase(fetchDeletedWorkflows.fulfilled, (state, action) => { state.deletedLoading = false; state.deleted = action.payload; }) + .addCase(fetchDeletedWorkflows.rejected, (state) => { state.deletedLoading = false; }) + .addCase(restoreWorkflow.fulfilled, (state, action) => { + state.items[action.payload.id] = action.payload; + state.deleted = state.deleted.filter((w) => w.id !== action.payload.id); + }) + .addCase(purgeWorkflow.fulfilled, (state, action) => { + state.deleted = state.deleted.filter((w) => w.id !== action.payload); + }) .addCase(fetchPausedState.fulfilled, (state, action) => { state.paused = action.payload; }) .addCase(setPausedAll.fulfilled, (state, action) => { state.paused = action.payload; }) .addCase(fetchActiveRuns.fulfilled, (state, action) => { state.active = action.payload; })