mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 04:37:44 +02:00
[aidan] feat/workflows: require at least one step to save a workflow
This commit is contained in:
@@ -151,6 +151,10 @@ def _normalize_schedule_state(wf: Workflow) -> None:
|
||||
wf.next_run_at = scheduler.compute_next_fire(wf) if wf.schedule.enabled else None
|
||||
|
||||
|
||||
def _has_nonempty_steps(steps: list[WorkflowStep] | None) -> bool:
|
||||
return any(bool((s.text or "").strip()) for s in (steps or []))
|
||||
|
||||
|
||||
def _parse_calendar_bound(value: str, label: str) -> datetime:
|
||||
raw = (value or "").strip()
|
||||
if raw.endswith("Z"):
|
||||
@@ -166,6 +170,8 @@ def _parse_calendar_bound(value: str, label: str) -> datetime:
|
||||
|
||||
@workflows.router.post("/create")
|
||||
async def create_workflow(body: WorkflowCreate):
|
||||
if not body.unsaved and not _has_nonempty_steps(body.steps):
|
||||
raise HTTPException(status_code=400, detail="Workflow must have at least one step")
|
||||
actions = body.actions
|
||||
# Scheduled workflows default to freeze=on for safety. The user can
|
||||
# flip "Full agent access" in the editor with an explicit confirm.
|
||||
@@ -785,15 +791,21 @@ async def commit_draft(workflow_id: str):
|
||||
wf = storage.get_workflow(workflow_id)
|
||||
if not wf:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
# Clicking Save is the user committing to this workflow, so reveal it in
|
||||
# the hub (clears the "+ New" build-in-progress flag) even if there's no
|
||||
# pending draft to flush.
|
||||
wf.unsaved = False
|
||||
if wf.draft_steps is None:
|
||||
if not _has_nonempty_steps(wf.steps):
|
||||
raise HTTPException(status_code=400, detail="Workflow must have at least one step")
|
||||
# Clicking Save is the user committing to this workflow, so reveal it
|
||||
# in the hub (clears the "+ New" build-in-progress flag).
|
||||
wf.unsaved = False
|
||||
await p_end_edit_session(wf)
|
||||
storage.save_workflow(wf)
|
||||
return _enriched(wf)
|
||||
before = wf.model_dump(mode="json")
|
||||
if not _has_nonempty_steps(wf.draft_steps):
|
||||
raise HTTPException(status_code=400, detail="Workflow must have at least one step")
|
||||
# Clicking Save is the user committing to this workflow, so reveal it in
|
||||
# the hub (clears the "+ New" build-in-progress flag).
|
||||
wf.unsaved = False
|
||||
wf.steps = wf.draft_steps
|
||||
wf.draft_steps = None
|
||||
await p_relabel_changed_steps(wf, before.get("steps") or [])
|
||||
|
||||
@@ -937,9 +937,13 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
if (id) dispatch(removeCard(id));
|
||||
}, [linkedWorkflowId, id, dispatch]);
|
||||
|
||||
const onTestSaveWorkflow = useCallback(() => {
|
||||
const onTestSaveWorkflow = useCallback(async () => {
|
||||
if (linkedWorkflowId) {
|
||||
dispatch(commitDraft(linkedWorkflowId));
|
||||
try {
|
||||
await dispatch(commitDraft(linkedWorkflowId)).unwrap();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
dispatch(updateWorkflowCard({ workflowId: linkedWorkflowId, patch: { view: 'saved' } }));
|
||||
dispatch(setCardSidecar({ workflowId: linkedWorkflowId, sessionId: null, kind: null }));
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@ export default function EditAgentView({ workflow, steps, isFixMode = false, onEd
|
||||
const [saveAnchorEl, setSaveAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const [testSessionId, setTestSessionId] = useState<string | null>(null);
|
||||
const draftSteps = workflow.draft_steps ?? steps;
|
||||
const canSave = draftSteps.some((s) => (s.text || '').trim().length > 0);
|
||||
// A draft always exists in edit mode (we snapshot on entry), so only flag
|
||||
// "unsaved" once the draft actually diverges from the committed steps.
|
||||
const hasChanges = workflow.draft_steps != null && JSON.stringify(workflow.draft_steps) !== JSON.stringify(workflow.steps);
|
||||
@@ -140,10 +141,15 @@ export default function EditAgentView({ workflow, steps, isFixMode = false, onEd
|
||||
}, []);
|
||||
|
||||
const onSaveNow = useCallback(async () => {
|
||||
if (!canSave) return;
|
||||
setSavePhase('idle');
|
||||
await dispatch(commitDraft(workflow.id));
|
||||
try {
|
||||
await dispatch(commitDraft(workflow.id)).unwrap();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
toSaved();
|
||||
}, [dispatch, workflow.id, toSaved]);
|
||||
}, [canSave, dispatch, workflow.id, toSaved]);
|
||||
|
||||
const onRunTest = useCallback(async () => {
|
||||
try {
|
||||
@@ -206,11 +212,13 @@ export default function EditAgentView({ workflow, steps, isFixMode = false, onEd
|
||||
Discard
|
||||
</Box>
|
||||
<Box
|
||||
onClick={onSaveClick}
|
||||
onClick={canSave ? onSaveClick : undefined}
|
||||
role="button"
|
||||
title={canSave ? undefined : 'Add at least one step before saving'}
|
||||
sx={{
|
||||
fontSize: '0.8rem', fontWeight: 700, color: '#fff', bgcolor: c.accent.primary,
|
||||
px: 1.2, py: 0.35, borderRadius: 999, cursor: 'pointer',
|
||||
px: 1.2, py: 0.35, borderRadius: 999, cursor: canSave ? 'pointer' : 'not-allowed',
|
||||
opacity: canSave ? 1 : 0.45,
|
||||
'&:hover': { filter: 'brightness(1.05)' },
|
||||
}}>
|
||||
Save
|
||||
|
||||
@@ -30,6 +30,30 @@ const PRESETS: Preset[] = [
|
||||
{ label: 'Every month on the 1st', hint: 'Monthly summary, billing report', build: () => ({ enabled: true, repeat_unit: 'month', repeat_every: 1, hour: 9, minute: 0 }) },
|
||||
];
|
||||
|
||||
function extractStepsFromSession(session: { messages?: Array<{ role: string; content: unknown; hidden?: boolean }> } | null | undefined): Array<{ id: string; text: string }> {
|
||||
const out: Array<{ id: string; text: string }> = [];
|
||||
for (const msg of session?.messages || []) {
|
||||
if (msg.role !== 'user' || msg.hidden) continue;
|
||||
const text = typeof msg.content === 'string'
|
||||
? msg.content
|
||||
: Array.isArray(msg.content)
|
||||
? msg.content.map((b: any) => (typeof b === 'string' ? b : b?.text || '')).join(' ')
|
||||
: '';
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length < 6) continue;
|
||||
out.push({ id: `step-${out.length + 1}-${Date.now().toString(36)}`, text: trimmed.slice(0, 400) });
|
||||
if (out.length === 3) break;
|
||||
}
|
||||
if (out.length === 0 && session?.messages?.length) {
|
||||
const fallback = session.messages.find((m) => m.role === 'user');
|
||||
if (fallback) {
|
||||
const text = typeof fallback.content === 'string' ? fallback.content : '';
|
||||
out.push({ id: `step-1-${Date.now().toString(36)}`, text: text.slice(0, 400) || 'Run the original task' });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
anchorEl: HTMLElement | null;
|
||||
onClose: () => void;
|
||||
@@ -61,6 +85,7 @@ export default function ScheduleThisPopover({ anchorEl, onClose, sessionId, sess
|
||||
const sessionDashboardId = useAppSelector(
|
||||
(s) => sessionId ? s.agents.sessions[sessionId]?.dashboard_id : null,
|
||||
);
|
||||
const sourceSession = useAppSelector((s) => sessionId ? s.agents.sessions[sessionId] : null);
|
||||
|
||||
// Dup-detect: a chat session can only sanely have one schedule attached.
|
||||
// If we find one already, offer "Open existing" instead of silently
|
||||
@@ -82,6 +107,7 @@ export default function ScheduleThisPopover({ anchorEl, onClose, sessionId, sess
|
||||
const result = await dispatch(createWorkflow({
|
||||
title,
|
||||
source_session_id: sessionId,
|
||||
steps: extractStepsFromSession(sourceSession),
|
||||
schedule,
|
||||
} as Partial<Workflow>));
|
||||
if (createWorkflow.fulfilled.match(result)) {
|
||||
@@ -98,7 +124,7 @@ export default function ScheduleThisPopover({ anchorEl, onClose, sessionId, sess
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, dispatch, sessionId, title, onClose, onCreated]);
|
||||
}, [busy, dispatch, sessionId, sourceSession, title, onClose, onCreated]);
|
||||
|
||||
const openCustom = useCallback(() => {
|
||||
// Open a local draft. NO backend create yet — the workflow only
|
||||
|
||||
@@ -381,12 +381,14 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
const persistDraft = useCallback(async (): Promise<Workflow | null> => {
|
||||
const d = card?.draft;
|
||||
if (!d || persistingRef.current) return null;
|
||||
const draftSteps = d.steps || [];
|
||||
if (!draftSteps.some((s) => (s.text || '').trim().length > 0)) return null;
|
||||
persistingRef.current = true;
|
||||
try {
|
||||
const result = await dispatch(createWorkflow({
|
||||
title: (d.title as string) || 'New workflow',
|
||||
description: (d.description as string) || '',
|
||||
steps: (d.steps || []).map((s) => ({ id: s.id, text: s.text })),
|
||||
steps: draftSteps.map((s) => ({ id: s.id, text: s.text })),
|
||||
source_session_id: (d.source_session_id as string | undefined) || card?.sourceSessionId || null,
|
||||
use_synced_prompt: true,
|
||||
model: defaultModel || (d.model as string),
|
||||
|
||||
@@ -123,6 +123,7 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft,
|
||||
const liveDraft = (card?.draft ?? initialDraft ?? {}) as Partial<Workflow>;
|
||||
const title = (liveDraft.title as string) || 'New workflow';
|
||||
const description = (liveDraft.description as string) || '';
|
||||
const canSave = steps.some((s) => (s.text || '').trim().length > 0);
|
||||
// The new workflow runs with the user's configured default model/mode (their
|
||||
// subscription, etc.), falling back to whatever the source chat used. Without
|
||||
// this the backend picks its own default, which surprised users who'd set a
|
||||
@@ -161,6 +162,7 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft,
|
||||
}, [closeRequestNonce]);
|
||||
|
||||
const saveWorkflow = useCallback(async (): Promise<Workflow | null> => {
|
||||
if (!canSave) return null;
|
||||
const result = await dispatch(createWorkflow({
|
||||
title,
|
||||
description,
|
||||
@@ -175,7 +177,7 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft,
|
||||
const wf = (result as unknown as { payload: Workflow }).payload;
|
||||
if (wf?.id) return wf;
|
||||
return null;
|
||||
}, [dispatch, title, description, steps, sourceSessionId, liveDraft, defaultModel, defaultMode]);
|
||||
}, [canSave, dispatch, title, description, steps, sourceSessionId, liveDraft, defaultModel, defaultMode]);
|
||||
|
||||
const onIgnore = useCallback(async () => {
|
||||
if (busy) return;
|
||||
@@ -183,7 +185,7 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft,
|
||||
}, [busy]);
|
||||
|
||||
const onSaveThenSchedule = useCallback(async () => {
|
||||
if (busy) return;
|
||||
if (busy || !canSave) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const wf = await saveWorkflow();
|
||||
@@ -191,10 +193,10 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft,
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, saveWorkflow, onSaved]);
|
||||
}, [busy, canSave, saveWorkflow, onSaved]);
|
||||
|
||||
const onSaveDraft = useCallback(async () => {
|
||||
if (busy) return;
|
||||
if (busy || !canSave) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const wf = await saveWorkflow();
|
||||
@@ -203,7 +205,7 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft,
|
||||
setBusy(false);
|
||||
setSavePromptOpen(false);
|
||||
}
|
||||
}, [busy, saveWorkflow, onSaved]);
|
||||
}, [busy, canSave, saveWorkflow, onSaved]);
|
||||
|
||||
const onDontSave = useCallback(() => {
|
||||
setSavePromptOpen(false);
|
||||
@@ -252,15 +254,16 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft,
|
||||
Not now
|
||||
</Box>
|
||||
<Box
|
||||
onClick={onSaveThenSchedule}
|
||||
onClick={canSave ? onSaveThenSchedule : undefined}
|
||||
role="button"
|
||||
title={canSave ? undefined : 'Add at least one step before saving'}
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.5,
|
||||
fontSize: '0.88rem', fontWeight: 700,
|
||||
px: 1.75, py: 0.6, borderRadius: 999,
|
||||
color: '#fff', bgcolor: c.accent.primary,
|
||||
cursor: busy ? 'wait' : 'pointer',
|
||||
opacity: busy ? 0.6 : 1,
|
||||
cursor: busy ? 'wait' : canSave ? 'pointer' : 'not-allowed',
|
||||
opacity: busy || !canSave ? 0.6 : 1,
|
||||
'&:hover': { bgcolor: c.accent.primary, filter: 'brightness(1.06)' },
|
||||
}}>
|
||||
Schedule Workflow
|
||||
@@ -288,8 +291,9 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft,
|
||||
</Box>
|
||||
<Box
|
||||
role="button"
|
||||
onClick={onSaveDraft}
|
||||
sx={{ fontSize: '0.84rem', fontWeight: 700, color: '#fff', bgcolor: c.accent.primary, borderRadius: 999, cursor: busy ? 'wait' : 'pointer', px: 1.5, py: 0.6, opacity: busy ? 0.6 : 1, '&:hover': { filter: 'brightness(1.06)' } }}>
|
||||
onClick={canSave ? onSaveDraft : undefined}
|
||||
title={canSave ? undefined : 'Add at least one step before saving'}
|
||||
sx={{ fontSize: '0.84rem', fontWeight: 700, color: '#fff', bgcolor: c.accent.primary, borderRadius: 999, cursor: busy ? 'wait' : canSave ? 'pointer' : 'not-allowed', px: 1.5, py: 0.6, opacity: busy || !canSave ? 0.6 : 1, '&:hover': { filter: 'brightness(1.06)' } }}>
|
||||
Save
|
||||
</Box>
|
||||
</DialogActions>
|
||||
|
||||
Reference in New Issue
Block a user