[aidan] feat/trash: soft-delete workflows with restore and purge

This commit is contained in:
abccodes
2026-06-22 23:58:05 -07:00
parent 7b2928f35e
commit fd1ab46fa9
9 changed files with 218 additions and 19 deletions
+3
View File
@@ -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)
+10 -1
View File
@@ -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]:
+59 -2
View File
@@ -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.
+17
View File
@@ -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
@@ -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 (
<div style={{ width: 248, flex: 'none', borderRight: `1px solid ${WC.line}`, background: WC.rail, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ padding: '14px 12px 10px', flex: 'none' }}>
<div style={{ height: 30, borderRadius: 8, background: WC.paper, border: '1px solid rgba(33,30,27,0.08)', display: 'flex', alignItems: 'center', gap: 7, padding: '0 9px', color: WC.muted, fontSize: 12.5 }}>
<div style={{ height: 30, borderRadius: 8, background: WC.paper, border: `1px solid rgba(${WC.inkRGB},0.08)`, display: 'flex', alignItems: 'center', gap: 7, padding: '0 9px', color: WC.muted, fontSize: 12.5 }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="7" /><path d="M21 21l-4-4" /></svg>
<input
value={query}
@@ -57,6 +60,7 @@ const LeftRail: React.FC<{ nav: AppNav }> = ({ nav }) => {
placeholder="Search"
style={{ flex: 1, border: 'none', background: 'transparent', fontSize: 12.5, color: WC.ink }}
/>
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 10, opacity: 0.6 }}>K</span>
</div>
</div>
@@ -88,9 +92,9 @@ const LeftRail: React.FC<{ nav: AppNav }> = ({ nav }) => {
<div
key={w.id}
onClick={() => 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' }}
>
<div style={{ width: 8, height: 8, borderRadius: '50%', flex: 'none', background: colorForId(w.id), opacity: active ? 1 : 0.35 }} />
<div style={{ width: 8, height: 8, borderRadius: '50%', flex: 'none', background: colorForWorkflow(w), opacity: active ? 1 : 0.35 }} />
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontSize: 13.5, fontWeight: 600, color: active ? WC.ink : WC.muted, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{w.title || 'Untitled workflow'}</div>
<div style={{ fontSize: 11, color: WC.muted2, marginTop: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
@@ -98,9 +102,9 @@ const LeftRail: React.FC<{ nav: AppNav }> = ({ nav }) => {
</div>
</div>
<div
onClick={(e) => { 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"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9"><path d="M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13" /></svg>
</div>
@@ -111,6 +115,16 @@ const LeftRail: React.FC<{ nav: AppNav }> = ({ nav }) => {
<div style={{ padding: '18px 10px', fontSize: 12.5, color: WC.muted2 }}>No workflows yet.</div>
)}
</div>
<div style={{ flex: 'none', borderTop: `1px solid ${WC.line}`, padding: '8px' }}>
<div onClick={nav.goTrash} style={trashStyle}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8"><path d="M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13" /></svg>
<span>Trash</span>
{trashCount > 0 && (
<span style={{ marginLeft: 'auto', fontFamily: "'JetBrains Mono',monospace", fontSize: 10.5, color: WC.muted2 }}>{trashCount}</span>
)}
</div>
</div>
</div>
);
};
@@ -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 (
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', background: WC.paper }}>
<div style={{ flex: 'none', padding: '22px 30px 14px', borderBottom: `1px solid ${WC.line}` }}>
<div style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 11, letterSpacing: '0.06em', textTransform: 'uppercase', color: WC.muted2, marginBottom: 5 }}>Deleted workflows</div>
<h1 style={{ margin: 0, fontFamily: "'Newsreader',serif", fontSize: 29, fontWeight: 500, color: WC.ink, letterSpacing: '-0.015em' }}>Trash</h1>
</div>
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0, padding: '18px 30px 32px' }}>
{deleted.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 9 }}>
{deleted.map((w) => (
<div key={w.id} style={{ display: 'flex', alignItems: 'center', gap: 14, background: WC.raised, border: `1px solid rgba(${WC.inkRGB},0.08)`, borderRadius: WC.radius.md, padding: '13px 16px' }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', flex: 'none', background: colorForWorkflow(w) }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: WC.ink, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{w.title || 'Untitled workflow'}</div>
<div style={{ fontSize: 12, color: WC.muted, marginTop: 2 }}>{w.deleted_at ? `Deleted ${whenText(new Date(w.deleted_at), now)}` : 'Deleted'} · {w.steps.length} step{w.steps.length === 1 ? '' : 's'}</div>
</div>
<button onClick={() => dispatch(restoreWorkflow(w.id))} style={{ background: WC.raised, border: `1px solid rgba(${WC.inkRGB},0.14)`, borderRadius: 8, padding: '7px 14px', fontSize: 12.5, fontWeight: 600, color: WC.ink, cursor: 'pointer', flex: 'none' }}>Restore</button>
<button onClick={() => onPurge(w.id, w.title || 'this workflow')} style={{ background: WC.dangerBg, border: 'none', borderRadius: 8, padding: '7px 14px', fontSize: 12.5, fontWeight: 600, color: WC.danger, cursor: 'pointer', flex: 'none' }}>Delete forever</button>
</div>
))}
</div>
) : (
!loading && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '84px 20px', textAlign: 'center' }}>
<div style={{ width: 46, height: 46, borderRadius: 12, background: WC.inset, display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 14 }}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke={WC.faint} strokeWidth="1.7"><path d="M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13" /></svg>
</div>
<div style={{ fontFamily: "'Newsreader',serif", fontSize: 18, color: WC.ink, marginBottom: 4 }}>Trash is empty</div>
<div style={{ fontSize: 13, color: WC.muted, maxWidth: 300, lineHeight: 1.5 }}>Deleted workflows appear here. Restore them or remove them permanently.</div>
</div>
)
)}
</div>
</div>
);
};
export default TrashView;
@@ -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 (
<div style={{ flex: 1, display: 'flex', minHeight: 0, fontFamily: FONT_SANS, color: WC.ink, background: WC.paper }}>
@@ -60,6 +64,7 @@ const WorkflowsAppContent: React.FC = () => {
{mode === 'calendar' && <CalendarView nav={nav} />}
{mode === 'detail' && selectedId && <DetailView workflowId={selectedId} nav={nav} />}
{mode === 'new' && <ComposeView nav={nav} />}
{mode === 'trash' && <TrashView />}
</div>
);
};
@@ -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;
+34 -1
View File
@@ -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<string, WorkflowRunControlAction>;
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; })