From 9fee353fda3c5a19477766d2aafc0116437e872b Mon Sep 17 00:00:00 2001 From: SirKentut <81878031+SirKentut@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:21:12 -0700 Subject: [PATCH 1/4] [pierre] swarm: de-lazy WorkflowExportable now that the workflow store is on this branch The module was written when the workflow store lived only on eric/workflow, so every store touch went through p_store()/p_model() try/except-returning-None. Both resolve on this branch (the round-trip test already asserts as much), so those guards had become dead code that would swallow a genuine ImportError in storage.py and surface it to the user as "this build doesn't support workflows yet; please update OpenSwarm". Imports storage/Workflow at module level, drops the None branches, and rewrites the stale docstring. The safety contract is unchanged and still covered by test_workflow_sanitize_disables_schedule_and_strips_pii: an imported workflow never auto-runs, and the sharer's phone number never rides along. --- backend/apps/swarm/entities/workflows.py | 47 +++++------------------- 1 file changed, 10 insertions(+), 37 deletions(-) diff --git a/backend/apps/swarm/entities/workflows.py b/backend/apps/swarm/entities/workflows.py index 42a8e92e..0b2ddef3 100644 --- a/backend/apps/swarm/entities/workflows.py +++ b/backend/apps/swarm/entities/workflows.py @@ -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 From 6ee392839f723f8359226882d87c3586adf108fd Mon Sep 17 00:00:00 2001 From: SirKentut <81878031+SirKentut@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:55:00 -0700 Subject: [PATCH 2/4] [pierre] workflows: share the open workflow as a .swarm from the hub title bar Adds the Share affordance to the Workflows hub, left of the close button. It only appears in detail view, so it always has an unambiguous target; goHome leaves selectedId set, hence the explicit mode check. Everything under it already existed: WorkflowExportable is registered and ShareKind already had 'workflow'. The hub simply never rendered a ShareButton. The title bar moves from the card into the content. The card is at 281 of the linter's 300-line cap, so lifting nav state up into it would have breached the cap; pushing the bar down to the state instead keeps both files well under, and splits on a better seam anyway (card = drag/resize geometry, content = the app and its chrome). The card hands its drag handlers down via CardHeader. The share dialog portals to the body but its events still bubble the React tree, so the wrapper stops click/pointerdown; without it, dragging the card follows a click inside the modal. Verified in the running app: share icon absent on Home, present in detail and left of the X, and the modal builds a real workflow bundle. --- .../pages/Workflows/app/WorkflowsAppCard.tsx | 42 +++-------- .../Workflows/app/WorkflowsAppContent.tsx | 74 ++++++++++++++++--- frontend/src/app/pages/Workflows/app/types.ts | 10 +++ 3 files changed, 82 insertions(+), 44 deletions(-) diff --git a/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx b/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx index 60c150ad..88aab2f6 100644 --- a/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx +++ b/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx @@ -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 = ({ 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 = ({ transition: noTransition ? 'none' : 'box-shadow 0.3s ease, border-color 0.2s ease', }} > - {/* TITLE BAR (drag handle) */} -
-
- - Workflows -
-
- { e.stopPropagation(); dispatch(closeWorkflowsApp()); }} - onPointerDown={(e) => e.stopPropagation()} - sx={{ color: c.text.tertiary, '&:hover': { color: c.status.error, bgcolor: `${c.status.error}14` } }} - > - - -
- - + {HANDLE_DEFS.map(({ dir, css }) => (
{ +// 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('month'); const [refDate, setRefDate] = useState(() => 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 ( -
- - {mode === 'home' && } - {mode === 'calendar' && } - {mode === 'detail' && selectedId && } - {mode === 'new' && } - {mode === 'trash' && } +
+ {/* TITLE BAR (drag handle) */} +
+
+ + Workflows +
+
+ {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. + e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + style={{ display: 'flex' }} + > + + + )} + { e.stopPropagation(); dispatch(closeWorkflowsApp()); }} + onPointerDown={(e) => e.stopPropagation()} + sx={{ color: c.text.tertiary, '&:hover': { color: c.status.error, bgcolor: `${c.status.error}14` } }} + > + + +
+ +
+ + {mode === 'home' && } + {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 ca086556..6340614b 100644 --- a/frontend/src/app/pages/Workflows/app/types.ts +++ b/frontend/src/app/pages/Workflows/app/types.ts @@ -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; From 1375e6126bcb69c294d81c1fd6b973adcb6801a7 Mon Sep 17 00:00:00 2001 From: SirKentut <81878031+SirKentut@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:58:27 -0700 Subject: [PATCH 3/4] [pierre] workflows: share any workflow straight from the sidebar row on hover Puts Share beside the existing trash icon on each rail row, so a workflow can be shared without opening it first. Trash keeps its current always-visible styling. The button is faded out rather than unmounted when the row loses hover: ShareButton owns the modal's open state, so unmounting on hover-out would slam the dialog shut the instant the pointer left the row to reach it. Keeping it mounted also stops the row reflowing as the icon appears. --- .../src/app/pages/Workflows/app/LeftRail.tsx | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/frontend/src/app/pages/Workflows/app/LeftRail.tsx b/frontend/src/app/pages/Workflows/app/LeftRail.tsx index ad74e9d3..11625702 100644 --- a/frontend/src/app/pages/Workflows/app/LeftRail.tsx +++ b/frontend/src/app/pages/Workflows/app/LeftRail.tsx @@ -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(null); const workflows = useMemo(() => Object.values(items) .filter((w) => !w.unsaved) @@ -93,6 +95,8 @@ const LeftRail: React.FC<{ nav: AppNav }> = ({ nav }) => {
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' }} >
@@ -104,6 +108,22 @@ const LeftRail: React.FC<{ nav: AppNav }> = ({ nav }) => { {active ? describeSchedule(w.schedule) : 'Paused'}
+ {/* 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. */} + e.stopPropagation()} + style={{ + display: 'flex', + flex: 'none', + opacity: hovered === w.id ? 1 : 0, + pointerEvents: hovered === w.id ? 'auto' : 'none', + transition: 'opacity 0.12s', + }} + > + +
{ 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' }} From fb8745f9a3fe37bea7bcd2b340fef40a21a19daa Mon Sep 17 00:00:00 2001 From: SirKentut <81878031+SirKentut@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:04:47 -0700 Subject: [PATCH 4/4] [pierre] share: refresh the workflow list after importing a workflow .swarm Import already handled workflow bundles, but nothing told the UI. Unlike a dashboard there is no route to navigate to, so the hub -- which only fetches on mount -- kept showing a stale list and the imported workflow looked like it had vanished until a remount. Refetch on a workflow-root import. Import drops dashboard_id and /list keeps unassigned workflows for every dashboard, so it shows up wherever the user is. --- frontend/src/app/components/share/ImportEntryPoint.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/components/share/ImportEntryPoint.tsx b/frontend/src/app/components/share/ImportEntryPoint.tsx index a9a8ead0..8089d2b5 100644 --- a/frontend/src/app/components/share/ImportEntryPoint.tsx +++ b/frontend/src/app/components/share/ImportEntryPoint.tsx @@ -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((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(null); const digestRef = useRef(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(