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 (
-
@@ -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; })