mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 20:27:44 +02:00
[aidan] feat/trash: soft-delete workflows with restore and purge
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; })
|
||||
|
||||
Reference in New Issue
Block a user