Merge branch 'pr-126-fresh' into HEAD

This commit is contained in:
ciregenz
2026-07-15 00:23:59 -07:00
6 changed files with 119 additions and 82 deletions
+10 -37
View File
@@ -1,8 +1,5 @@
"""WorkflowExportable: shares a scheduled-task/workflow recipe (steps, schedule
shape, actions, model). The workflow store lives on the eric/workflow branch and
is NOT on eric/dev yet, so every store touch is lazy: on a build without it,
export finds nothing and import fails with a clear message, and the module still
imports cleanly. It lights up the moment the workflow forward-port lands.
shape, actions, model).
Safety: an imported workflow must never silently start running on someone else's
machine, so the schedule is forced off on import (the importer re-arms it). The
@@ -12,6 +9,8 @@ from __future__ import annotations
from backend.apps.swarm.exportable import DepRef, ExportContext, RemapTable
from backend.apps.swarm.models import EntityType, Requirement, RequirementKind
from backend.apps.workflows import storage
from backend.apps.workflows.models import Workflow
P_BUILTIN_MODES = {"agent", "ask", "plan", "view-builder", "skill-builder"}
@@ -52,10 +51,7 @@ class WorkflowExportable:
@classmethod
def load(cls, local_id: str) -> "WorkflowExportable | None":
store = p_store()
if store is None:
return None
wf = store.get_workflow(local_id)
wf = storage.get_workflow(local_id)
if wf is None:
return None
data = wf.model_dump(mode="json")
@@ -92,38 +88,15 @@ class WorkflowExportable:
@classmethod
def import_(cls, payload: dict, files: dict[str, bytes], remap: RemapTable) -> str:
store = p_store()
model = p_model()
if store is None or model is None:
from backend.apps.swarm.ziputil import BundleError
raise BundleError("this build doesn't support workflows yet; please update OpenSwarm")
clean = sanitize_workflow(payload)
clean.pop("id", None) # fresh id via the model's default_factory
wf = model(**clean)
store.save_workflow(wf)
wf = Workflow(**clean)
storage.save_workflow(wf)
return wf.id
@classmethod
def rollback(cls, local_id: str) -> None:
store = p_store()
if store is not None:
try:
store.delete_workflow(local_id)
except Exception:
pass
def p_store():
try:
from backend.apps.workflows import storage
return storage
except Exception:
return None
def p_model():
try:
from backend.apps.workflows.models import Workflow
return Workflow
except Exception:
return None
try:
storage.delete_workflow(local_id)
except Exception:
pass
@@ -9,6 +9,8 @@ import FileDownloadIcon from '@mui/icons-material/FileDownload';
import { useNavigate } from 'react-router-dom';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { fetchWorkflows } from '@/shared/state/workflowsSlice';
import ImportDigest, { DigestHandle } from './ImportDigest';
import ImportModal from './ImportModal';
@@ -43,6 +45,8 @@ const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
const ImportEntryPoint: React.FC = () => {
const c = useClaudeTokens();
const navigate = useNavigate();
const dispatch = useAppDispatch();
const dashboardId = useAppSelector((s) => s.tempState.lastDashboardId) || undefined;
const inputRef = useRef<HTMLInputElement | null>(null);
const digestRef = useRef<DigestHandle | null>(null);
const depth = useRef(0);
@@ -56,10 +60,12 @@ const ImportEntryPoint: React.FC = () => {
(rootType: string, rootId: string, name: string) => {
const msg = rootType === 'app' ? `Added ${name} to your Apps` : `Added ${name}`;
setToast({ msg, sev: 'success' });
// A workflow has no route of its own, so nothing would pull it in: an open Workflows hub only fetches on mount and would keep showing a stale list. Import drops dashboard_id, and /list keeps unassigned workflows for every dashboard, so this surfaces it wherever the user is.
if (rootType === 'workflow') dispatch(fetchWorkflows(dashboardId));
const to = DEST[rootType]?.(rootId);
if (to) navigate(to);
},
[navigate],
[navigate, dispatch, dashboardId],
);
const commitAndFinish = useCallback(
@@ -3,6 +3,7 @@ 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 ShareButton from '@/app/components/share/ShareButton';
import { colorForWorkflow, useWC } from './uiKit';
import WorkflowTitle from './WorkflowTitle';
import type { AppNav } from './types';
@@ -18,6 +19,7 @@ const LeftRail: React.FC<{ nav: AppNav }> = ({ nav }) => {
const items = useAppSelector((s) => s.workflows.items);
const trashCount = useAppSelector((s) => s.workflows.deleted.length);
const [query, setQuery] = useState('');
const [hovered, setHovered] = useState<string | null>(null);
const workflows = useMemo(() => Object.values(items)
.filter((w) => !w.unsaved)
@@ -93,6 +95,8 @@ const LeftRail: React.FC<{ nav: AppNav }> = ({ nav }) => {
<div
key={w.id}
onClick={() => nav.selectWorkflow(w.id)}
onMouseEnter={() => setHovered(w.id)}
onMouseLeave={() => setHovered((h) => (h === w.id ? null : h))}
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: colorForWorkflow(w), opacity: active ? 1 : 0.35 }} />
@@ -104,6 +108,22 @@ const LeftRail: React.FC<{ nav: AppNav }> = ({ nav }) => {
{active ? describeSchedule(w.schedule) : 'Paused'}
</div>
</div>
{/* Faded rather than unmounted on hover-out: ShareButton owns the modal's open state, so unmounting it would close the modal the moment the pointer left the row for the dialog. Also keeps the row from reflowing on hover. */}
<span
onClick={(e) => e.stopPropagation()}
style={{
display: 'flex',
flex: 'none',
opacity: hovered === w.id ? 1 : 0,
pointerEvents: hovered === w.id ? 'auto' : 'none',
transition: 'opacity 0.12s',
}}
>
<ShareButton
target={{ kind: 'workflow', id: w.id, name: w.title || 'Untitled workflow' }}
iconFontSize={13}
/>
</span>
<div
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' }}
@@ -1,11 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useAppDispatch } from '@/shared/hooks';
import { closeWorkflowsApp, setWorkflowsHubPosition, setWorkflowsHubSize } from '@/shared/state/dashboardLayoutSlice';
import EventRepeatIcon from '@mui/icons-material/EventRepeat';
import IconButton from '@mui/material/IconButton';
import CloseIcon from '@mui/icons-material/Close';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useWC, FONT_SERIF } from './uiKit';
import { setWorkflowsHubPosition, setWorkflowsHubSize } from '@/shared/state/dashboardLayoutSlice';
import { useWC } from './uiKit';
import WorkflowsAppContent from './WorkflowsAppContent';
type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
@@ -58,7 +54,6 @@ const WorkflowsAppCard: React.FC<Props> = ({
onCardSelect, onDragStart, onDragMove, onDragEnd, onBringToFront,
}) => {
const WC = useWC();
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const panRef = useRef({ panX, panY });
@@ -216,31 +211,14 @@ const WorkflowsAppCard: React.FC<Props> = ({
transition: noTransition ? 'none' : 'box-shadow 0.3s ease, border-color 0.2s ease',
}}
>
{/* TITLE BAR (drag handle) */}
<div
onPointerDown={onHeaderPointerDown}
onPointerMove={onHeaderPointerMove}
onPointerUp={onHeaderPointerUp}
style={{ height: 42, flex: 'none', display: 'flex', alignItems: 'center', padding: '0 16px', borderBottom: `1px solid ${WC.line}`, background: WC.panel, gap: 14, cursor: isDragging ? 'grabbing' : 'grab', touchAction: 'none', userSelect: 'none' }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<EventRepeatIcon sx={{ fontSize: 18, color: WC.accent, display: 'block' }} />
<span style={{ fontFamily: FONT_SERIF, fontSize: 14.5, fontWeight: 500, color: WC.ink, letterSpacing: '-0.01em', lineHeight: 1, transform: 'translateY(2.5px)' }}>Workflows</span>
</div>
<div style={{ flex: 1 }} />
<IconButton
aria-label="Close"
data-no-drag
size="small"
onClick={(e) => { e.stopPropagation(); dispatch(closeWorkflowsApp()); }}
onPointerDown={(e) => e.stopPropagation()}
sx={{ color: c.text.tertiary, '&:hover': { color: c.status.error, bgcolor: `${c.status.error}14` } }}
>
<CloseIcon fontSize="small" />
</IconButton>
</div>
<WorkflowsAppContent />
<WorkflowsAppContent
header={{
onPointerDown: onHeaderPointerDown,
onPointerMove: onHeaderPointerMove,
onPointerUp: onHeaderPointerUp,
dragging: isDragging,
}}
/>
{HANDLE_DEFS.map(({ dir, css }) => (
<div
@@ -1,12 +1,17 @@
import React, { useEffect, useMemo, useState } from 'react';
import EventRepeatIcon from '@mui/icons-material/EventRepeat';
import CloseIcon from '@mui/icons-material/Close';
import IconButton from '@mui/material/IconButton';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { clearWorkflowsAppTarget } from '@/shared/state/dashboardLayoutSlice';
import { clearWorkflowsAppTarget, closeWorkflowsApp } from '@/shared/state/dashboardLayoutSlice';
import {
fetchWorkflows, fetchAllRuns, fetchPausedState, fetchActiveRuns, fetchDeletedWorkflows,
} from '@/shared/state/workflowsSlice';
import { fetchMissedRuns } from '@/shared/state/missedRunsSlice';
import { FONT_SANS, useWC } from './uiKit';
import type { AppMode, CalView, AppNav } from './types';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import ShareButton from '@/app/components/share/ShareButton';
import { FONT_SANS, FONT_SERIF, useWC } from './uiKit';
import type { AppMode, CalView, AppNav, CardHeader } from './types';
import LeftRail from './LeftRail';
import HomeView from './HomeView';
import CalendarView from './CalendarView';
@@ -14,9 +19,10 @@ 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 = () => {
// The three-pane Workflows body plus its title bar. The card wraps this with drag/resize geometry and passes the drag handlers in; the title bar lives here because Share needs to know which workflow is open.
const WorkflowsAppContent: React.FC<{ header: CardHeader }> = ({ header }) => {
const WC = useWC();
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const target = useAppSelector((s) => s.dashboardLayout.workflowsAppTarget);
const dashboardId = useAppSelector((s) => s.tempState.lastDashboardId) || undefined;
@@ -26,6 +32,10 @@ const WorkflowsAppContent: React.FC = () => {
const [calView, setCalView] = useState<CalView>('month');
const [refDate, setRefDate] = useState<Date>(() => new Date());
// goHome leaves selectedId set, so gate on the mode too or Share lingers in the title bar after leaving the workflow.
const shared = useAppSelector((s) => (selectedId ? s.workflows.items[selectedId] : undefined));
const selected = mode === 'detail' ? shared : undefined;
useEffect(() => {
dispatch(fetchWorkflows(dashboardId));
dispatch(fetchAllRuns(200));
@@ -56,13 +66,53 @@ const WorkflowsAppContent: React.FC = () => {
}), [mode, selectedId, calView, refDate, dashboardId, dispatch]);
return (
<div style={{ flex: 1, display: 'flex', minHeight: 0, fontFamily: FONT_SANS, color: WC.ink, background: WC.page }}>
<LeftRail nav={nav} />
{mode === 'home' && <HomeView nav={nav} />}
{mode === 'calendar' && <CalendarView nav={nav} />}
{mode === 'detail' && selectedId && <DetailView workflowId={selectedId} nav={nav} />}
{mode === 'new' && <ComposeView nav={nav} />}
{mode === 'trash' && <TrashView />}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, fontFamily: FONT_SANS, color: WC.ink, background: WC.page }}>
{/* TITLE BAR (drag handle) */}
<div
onPointerDown={header.onPointerDown}
onPointerMove={header.onPointerMove}
onPointerUp={header.onPointerUp}
style={{ height: 42, flex: 'none', display: 'flex', alignItems: 'center', padding: '0 16px', borderBottom: `1px solid ${WC.line}`, background: WC.panel, gap: 14, cursor: header.dragging ? 'grabbing' : 'grab', touchAction: 'none', userSelect: 'none' }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<EventRepeatIcon sx={{ fontSize: 18, color: WC.accent, display: 'block' }} />
<span style={{ fontFamily: FONT_SERIF, fontSize: 14.5, fontWeight: 500, color: WC.ink, letterSpacing: '-0.01em', lineHeight: 1, transform: 'translateY(2.5px)' }}>Workflows</span>
</div>
<div style={{ flex: 1 }} />
{selected && (
// The share dialog portals to the body but its events still bubble the React tree, so stop them here or dragging the card follows a click inside the modal.
<span
data-no-drag
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
style={{ display: 'flex' }}
>
<ShareButton
target={{ kind: 'workflow', id: selected.id, name: selected.title || 'Untitled workflow' }}
iconFontSize={17}
/>
</span>
)}
<IconButton
aria-label="Close"
data-no-drag
size="small"
onClick={(e) => { e.stopPropagation(); dispatch(closeWorkflowsApp()); }}
onPointerDown={(e) => e.stopPropagation()}
sx={{ color: c.text.tertiary, '&:hover': { color: c.status.error, bgcolor: `${c.status.error}14` } }}
>
<CloseIcon fontSize="small" />
</IconButton>
</div>
<div style={{ flex: 1, display: 'flex', minHeight: 0 }}>
<LeftRail nav={nav} />
{mode === 'home' && <HomeView nav={nav} />}
{mode === 'calendar' && <CalendarView nav={nav} />}
{mode === 'detail' && selectedId && <DetailView workflowId={selectedId} nav={nav} />}
{mode === 'new' && <ComposeView nav={nav} />}
{mode === 'trash' && <TrashView />}
</div>
</div>
);
};
@@ -1,6 +1,16 @@
import type { PointerEvent } from 'react';
export type AppMode = 'home' | 'calendar' | 'detail' | 'new' | 'trash';
export type CalView = 'week' | 'month';
// The card owns drag geometry but the title bar renders inside the content (it needs nav state to know which workflow to share), so the card hands its drag handlers down.
export interface CardHeader {
onPointerDown: (e: PointerEvent) => void;
onPointerMove: (e: PointerEvent) => void;
onPointerUp: (e: PointerEvent) => void;
dragging: boolean;
}
// Navigation + ephemeral UI state for the Workflows app window. Data lives in Redux; this is only "where am I looking right now".
export interface AppNav {
mode: AppMode;