mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
[aidan] feat/workflows: validate steps before scheduling and keep chat tool memory
This commit is contained in:
@@ -79,7 +79,11 @@ def p_make_remember_approval(workflow_id: str):
|
||||
return p_remember_approval
|
||||
|
||||
|
||||
def p_persist_step_tool_usage(workflow_id: str, step_usage: dict[str, dict[str, bool]]) -> None:
|
||||
def p_persist_step_tool_usage(
|
||||
workflow_id: str,
|
||||
step_usage: dict[str, dict[str, bool]],
|
||||
tested_signature: Optional[str] = None,
|
||||
) -> None:
|
||||
fresh = storage.get_workflow(workflow_id)
|
||||
if fresh is None:
|
||||
return
|
||||
@@ -92,6 +96,8 @@ def p_persist_step_tool_usage(workflow_id: str, step_usage: dict[str, dict[str,
|
||||
for sid, tools in (step_usage or {}).items()
|
||||
if sid in live_ids and isinstance(tools, dict)
|
||||
}
|
||||
if tested_signature is not None:
|
||||
fresh.tested_signature = tested_signature
|
||||
storage.save_workflow(fresh)
|
||||
try:
|
||||
from backend.apps.agents.core.ws_manager import ws_manager
|
||||
@@ -150,7 +156,12 @@ def _monthly_spend_so_far(wf: Workflow) -> float:
|
||||
return total
|
||||
|
||||
|
||||
async def execute(wf: Workflow, triggered_by: str = "schedule", scheduled_for: Optional[datetime] = None) -> WorkflowRun:
|
||||
async def execute(
|
||||
wf: Workflow,
|
||||
triggered_by: str = "schedule",
|
||||
scheduled_for: Optional[datetime] = None,
|
||||
tested_signature: Optional[str] = None,
|
||||
) -> WorkflowRun:
|
||||
from backend.apps.agents.agent_manager import (
|
||||
agent_manager,
|
||||
clear_workflow_approval_memory,
|
||||
@@ -363,10 +374,13 @@ async def execute(wf: Workflow, triggered_by: str = "schedule", scheduled_for: O
|
||||
runs_delta = 1 if (triggered_by == "schedule" and run.status in ("success", "ran_late", "failure")) else 0
|
||||
storage.record_run(run)
|
||||
wf.last_run_at = run.finished_at
|
||||
_persist_run_fields(wf, {
|
||||
run_fields = {
|
||||
"last_run_at": run.finished_at,
|
||||
"last_run_status": wf.last_run_status,
|
||||
}, schedule_runs_count_delta=runs_delta)
|
||||
}
|
||||
if triggered_by == "manual" and run.status in ("success", "ran_late") and isinstance(tested_signature, str):
|
||||
run_fields["tested_signature"] = tested_signature
|
||||
_persist_run_fields(wf, run_fields, schedule_runs_count_delta=runs_delta)
|
||||
except Exception as e:
|
||||
logger.exception("Workflow run failed: %s", e)
|
||||
run.status = "failure"
|
||||
|
||||
@@ -106,6 +106,11 @@ class Workflow(BaseModel):
|
||||
default_factory=lambda: [PermissionTier(kind="notify")]
|
||||
)
|
||||
source_session_id: Optional[str] = None
|
||||
# Tool names observed in the source chat when this workflow was generated.
|
||||
# This preserves conversion context without pretending those calls map to
|
||||
# generated workflow step ids. Explicit approval decisions still live in
|
||||
# remembered_approvals and are the only values reused as permissions.
|
||||
source_tools: list[str] = Field(default_factory=list)
|
||||
dashboard_id: Optional[str] = None
|
||||
model: str = "sonnet"
|
||||
mode: str = "agent"
|
||||
@@ -148,6 +153,11 @@ class Workflow(BaseModel):
|
||||
# the scheduled/unscheduled lists until the first commit clears the flag,
|
||||
# so an in-progress build doesn't litter the sidebar.
|
||||
unsaved: bool = False
|
||||
# Stable signature of the steps last validated by a test run (or seeded at
|
||||
# chat conversion). The FE compares it against the current steps before
|
||||
# scheduling: a mismatch means "edited since you last approved tools" and
|
||||
# triggers the test-first warning. Computed FE-side so there's one algorithm.
|
||||
tested_signature: Optional[str] = None
|
||||
|
||||
|
||||
class WorkflowRun(BaseModel):
|
||||
@@ -195,6 +205,7 @@ class WorkflowCreate(BaseModel):
|
||||
mode: Optional[str] = None
|
||||
provider: Optional[str] = None
|
||||
cost_cap_usd_monthly: Optional[float] = None
|
||||
tested_signature: Optional[str] = None
|
||||
|
||||
|
||||
class WorkflowUpdate(BaseModel):
|
||||
|
||||
@@ -96,30 +96,79 @@ def _derive_icon(wf: Workflow) -> str:
|
||||
return "W"
|
||||
|
||||
|
||||
def p_source_session_approvals(session_id: Optional[str]) -> dict[str, str]:
|
||||
def _source_tool_name(value) -> str:
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
name = value.strip()
|
||||
return name if name else ""
|
||||
|
||||
|
||||
def _collect_tool_names_from_content(content, out: set[str]) -> None:
|
||||
if isinstance(content, list):
|
||||
for item in content:
|
||||
_collect_tool_names_from_content(item, out)
|
||||
return
|
||||
if not isinstance(content, dict):
|
||||
return
|
||||
block_type = content.get("type")
|
||||
if block_type == "tool_use":
|
||||
name = _source_tool_name(content.get("name") or content.get("tool"))
|
||||
if name:
|
||||
out.add(name)
|
||||
for key in ("tool_name", "tool"):
|
||||
name = _source_tool_name(content.get(key))
|
||||
if name:
|
||||
out.add(name)
|
||||
nested = content.get("content")
|
||||
if nested is not content:
|
||||
_collect_tool_names_from_content(nested, out)
|
||||
|
||||
|
||||
def p_source_session_memory(session_id: Optional[str]) -> tuple[dict[str, str], list[str]]:
|
||||
if not session_id:
|
||||
return {}
|
||||
return {}, []
|
||||
try:
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
sess = agent_manager.sessions.get(session_id)
|
||||
decisions = getattr(sess, "approval_decisions", None) if sess is not None else None
|
||||
messages = getattr(sess, "messages", None) if sess is not None else None
|
||||
tool_latencies = getattr(sess, "tool_latencies", None) if sess is not None else None
|
||||
if decisions is None:
|
||||
from backend.apps.agents.manager.session.session_store import _load_session_data
|
||||
data = _load_session_data(session_id) or {}
|
||||
decisions = data.get("approval_decisions") or []
|
||||
messages = data.get("messages") or []
|
||||
tool_latencies = data.get("tool_latencies") or {}
|
||||
except Exception:
|
||||
return {}
|
||||
out: dict[str, str] = {}
|
||||
return {}, []
|
||||
approvals: dict[str, str] = {}
|
||||
tools: set[str] = set()
|
||||
for entry in decisions or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
tool = _source_tool_name(entry.get("tool"))
|
||||
if tool:
|
||||
tools.add(tool)
|
||||
if entry.get("sensitive_pattern"):
|
||||
continue
|
||||
tool = str(entry.get("tool") or "")
|
||||
behavior = entry.get("behavior")
|
||||
if tool and behavior in ("allow", "deny"):
|
||||
out[tool] = behavior
|
||||
return out
|
||||
approvals[tool] = behavior
|
||||
if isinstance(tool_latencies, dict):
|
||||
for tool in tool_latencies.keys():
|
||||
name = _source_tool_name(tool)
|
||||
if name:
|
||||
tools.add(name)
|
||||
for msg in messages or []:
|
||||
role = getattr(msg, "role", None) if not isinstance(msg, dict) else msg.get("role")
|
||||
content = getattr(msg, "content", None) if not isinstance(msg, dict) else msg.get("content")
|
||||
if role == "tool_call":
|
||||
tool_name = getattr(msg, "tool_name", None) if not isinstance(msg, dict) else msg.get("tool_name")
|
||||
name = _source_tool_name(tool_name)
|
||||
if name:
|
||||
tools.add(name)
|
||||
_collect_tool_names_from_content(content, tools)
|
||||
return approvals, sorted(tools)
|
||||
|
||||
|
||||
def p_prune_step_tool_usage(wf: Workflow) -> None:
|
||||
@@ -199,7 +248,13 @@ async def create_workflow(body: WorkflowCreate):
|
||||
auto_named=body.auto_named,
|
||||
unsaved=body.unsaved,
|
||||
)
|
||||
wf.remembered_approvals = p_source_session_approvals(body.source_session_id)
|
||||
source_approvals, source_tools = p_source_session_memory(body.source_session_id)
|
||||
wf.remembered_approvals = source_approvals
|
||||
wf.source_tools = source_tools
|
||||
# Convert-from-chat passes the steps signature so the workflow counts as
|
||||
# already validated (the chat already prompted for permissions); a blank
|
||||
# "New" create leaves it None so the first schedule warns to test first.
|
||||
wf.tested_signature = body.tested_signature
|
||||
if not wf.icon:
|
||||
wf.icon = _derive_icon(wf)
|
||||
_normalize_schedule_state(wf)
|
||||
@@ -869,6 +924,7 @@ async def test_run_workflow(workflow_id: str, body: dict):
|
||||
wf = storage.get_workflow(workflow_id)
|
||||
if not wf:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
tested_signature = body.get("signature") if isinstance(body, dict) else None
|
||||
draft_steps = (body or {}).get("steps")
|
||||
step_entries: list[WorkflowStep]
|
||||
if isinstance(draft_steps, list) and draft_steps:
|
||||
@@ -951,7 +1007,11 @@ async def test_run_workflow(workflow_id: str, body: dict):
|
||||
final = "error"
|
||||
finally:
|
||||
try:
|
||||
executor.p_persist_step_tool_usage(wf.id, get_workflow_step_usage(session.id))
|
||||
executor.p_persist_step_tool_usage(
|
||||
wf.id,
|
||||
get_workflow_step_usage(session.id),
|
||||
tested_signature=tested_signature if isinstance(tested_signature, str) else None,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("test-run step usage persist failed")
|
||||
set_workflow_approval_step(session.id, None)
|
||||
@@ -1049,7 +1109,7 @@ async def schedule_agent_session(workflow_id: str):
|
||||
|
||||
|
||||
@workflows.router.post("/{workflow_id}/run")
|
||||
async def run_workflow_now(workflow_id: str):
|
||||
async def run_workflow_now(workflow_id: str, body: Optional[dict] = None):
|
||||
wf = storage.get_workflow(workflow_id)
|
||||
if not wf:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
@@ -1057,7 +1117,12 @@ async def run_workflow_now(workflow_id: str):
|
||||
# or we end up with two rows per manual fire (one orphan "running"
|
||||
# row from this handler plus the real one from the executor).
|
||||
pre_ids = {r.id for r in storage.list_runs(wf.id, limit=10)}
|
||||
asyncio.create_task(executor.execute(wf, triggered_by="manual"))
|
||||
tested_signature = body.get("signature") if isinstance(body, dict) else None
|
||||
asyncio.create_task(executor.execute(
|
||||
wf,
|
||||
triggered_by="manual",
|
||||
tested_signature=tested_signature if isinstance(tested_signature, str) else None,
|
||||
))
|
||||
|
||||
# Poll briefly for the newly created run id. We also surface the
|
||||
# run's status + error string when it lands quickly (e.g. cost-cap
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { addWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { openWorkflowCard, type Workflow } from '@/shared/state/workflowsSlice';
|
||||
import { needsScheduleTestWarning } from './scheduleUtils';
|
||||
|
||||
interface Props {
|
||||
anchorEl: HTMLElement | null;
|
||||
@@ -23,7 +24,11 @@ export default function AddToSchedulePopover({ anchorEl, workflow, onClose }: Pr
|
||||
const makeSchedule = useCallback(() => {
|
||||
if (!workflow) return;
|
||||
dispatch(addWorkflowCard({ workflowId: workflow.id }));
|
||||
dispatch(openWorkflowCard({ workflowId: workflow.id, view: 'scheduling' }));
|
||||
// Untested steps: land on the saved card so its Schedule button can warn and
|
||||
// offer a test run (which needs the card's sidecar context). Otherwise go
|
||||
// straight to scheduling.
|
||||
const view = needsScheduleTestWarning(workflow) ? 'saved' : 'scheduling';
|
||||
dispatch(openWorkflowCard({ workflowId: workflow.id, view }));
|
||||
onClose();
|
||||
}, [dispatch, workflow, onClose]);
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ import StepList from './StepList';
|
||||
import AgentChat from '@/app/pages/AgentChat/AgentChat';
|
||||
import { useOpenSidecar } from './WorkflowCardLiveViews';
|
||||
import EditAgentSavePopovers, { type SavePhase } from './EditAgentSavePopovers';
|
||||
import { runWorkflowTest } from './runWorkflowTest';
|
||||
import { needsScheduleTestWarning } from './scheduleUtils';
|
||||
|
||||
interface Props {
|
||||
workflow: Workflow;
|
||||
@@ -136,11 +138,6 @@ export default function EditAgentView({ workflow, steps, isFixMode = false, onEd
|
||||
} catch { /* best-effort */ }
|
||||
}, [testSessionId]);
|
||||
|
||||
const onSaveClick = useCallback((e: React.MouseEvent<HTMLElement>) => {
|
||||
setSaveAnchorEl(e.currentTarget);
|
||||
setSavePhase('ask-test');
|
||||
}, []);
|
||||
|
||||
const onSaveNow = useCallback(async () => {
|
||||
if (!canSave) return;
|
||||
setSavePhase('idle');
|
||||
@@ -152,24 +149,20 @@ export default function EditAgentView({ workflow, steps, isFixMode = false, onEd
|
||||
toSaved();
|
||||
}, [canSave, dispatch, workflow.id, toSaved]);
|
||||
|
||||
const onSaveClick = useCallback((e: React.MouseEvent<HTMLElement>) => {
|
||||
if (!canSave) return;
|
||||
// Already validated this exact version? Skip the "test first?" nudge.
|
||||
if (!needsScheduleTestWarning(workflow)) { void onSaveNow(); return; }
|
||||
setSaveAnchorEl(e.currentTarget);
|
||||
setSavePhase('ask-test');
|
||||
}, [canSave, workflow, onSaveNow]);
|
||||
|
||||
const onRunTest = useCallback(async () => {
|
||||
try {
|
||||
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
|
||||
const res = await fetch(`${API_BASE}/workflows/${encodeURIComponent(workflow.id)}/test-run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(tok ? { Authorization: `Bearer ${tok}` } : {}) },
|
||||
body: JSON.stringify({ steps: draftSteps }),
|
||||
});
|
||||
if (!res.ok) { setSavePhase('idle'); return; }
|
||||
const data = await res.json();
|
||||
const sid = data?.session_id as string | undefined;
|
||||
if (!sid) { setSavePhase('idle'); return; }
|
||||
setTestSessionId(sid);
|
||||
// The Test Agent card now owns the post-test decision (Continue editing /
|
||||
// Save workflow) in its own footer, so just close this popover.
|
||||
setSavePhase('idle');
|
||||
await openSidecar(sid, 'testing');
|
||||
} catch { setSavePhase('idle'); }
|
||||
setSavePhase('idle');
|
||||
// The Test Agent card now owns the post-test decision (Continue editing /
|
||||
// Save workflow) in its own footer, so just close this popover.
|
||||
const sid = await runWorkflowTest(workflow.id, draftSteps, openSidecar);
|
||||
if (sid) setTestSessionId(sid);
|
||||
}, [workflow.id, draftSteps, openSidecar]);
|
||||
|
||||
const onDiscardClick = useCallback((e: React.MouseEvent<HTMLElement>) => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { API_BASE } from '@/shared/config';
|
||||
import type { Workflow } from '@/shared/state/workflowsSlice';
|
||||
import { runWorkflowNow, deleteWorkflow, updateWorkflow, openWorkflowCard } from '@/shared/state/workflowsSlice';
|
||||
import { addWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { WEEKDAY_FULL, WEEKDAY_LABEL_SHORT, addDays, sameDay, startOfMonthGrid, startOfWeek, formatTime, formatHourLabel } from './scheduleUtils';
|
||||
import { WEEKDAY_FULL, WEEKDAY_LABEL_SHORT, addDays, sameDay, startOfMonthGrid, startOfWeek, formatTime, formatHourLabel, stepsSignature } from './scheduleUtils';
|
||||
|
||||
interface Props {
|
||||
view: 'Week' | 'Month' | 'List';
|
||||
@@ -40,7 +40,10 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
|
||||
const closeMenu = () => setCtxMenu(null);
|
||||
const onRunNow = () => {
|
||||
if (!ctxMenu) return;
|
||||
dispatch(runWorkflowNow(ctxMenu.workflow.id));
|
||||
dispatch(runWorkflowNow({
|
||||
id: ctxMenu.workflow.id,
|
||||
signature: stepsSignature(ctxMenu.workflow.steps),
|
||||
}));
|
||||
closeMenu();
|
||||
};
|
||||
const onPauseToggle = () => {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onTestFirst: () => void;
|
||||
onScheduleAnyway: () => void;
|
||||
}
|
||||
|
||||
// Shown before scheduling a workflow whose current steps haven't been validated
|
||||
// by a test run. Scheduled fires can't pause to ask for tool permission, so an
|
||||
// untested workflow that needs approval would silently fail on its first run.
|
||||
export default function ScheduleTestWarningDialog({ open, onClose, onTestFirst, onScheduleAnyway }: Props) {
|
||||
const c = useClaudeTokens();
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||
<DialogTitle sx={{ fontSize: '1rem', fontWeight: 700 }}>Test before scheduling?</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography sx={{ fontSize: '0.86rem', color: c.text.secondary, lineHeight: 1.5 }}>
|
||||
Scheduled runs can't pause to ask for permission. If this workflow uses tools that
|
||||
need your approval, it could fail when it runs on its own. A quick test run lets you
|
||||
approve those tools now.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2, gap: 1 }}>
|
||||
<Box
|
||||
role="button"
|
||||
onClick={onScheduleAnyway}
|
||||
sx={{ fontSize: '0.84rem', fontWeight: 600, color: c.text.secondary, cursor: 'pointer', px: 1, py: 0.5, '&:hover': { color: c.text.primary } }}>
|
||||
Schedule anyway
|
||||
</Box>
|
||||
<Box
|
||||
role="button"
|
||||
onClick={onTestFirst}
|
||||
sx={{ fontSize: '0.84rem', fontWeight: 700, color: '#fff', bgcolor: c.accent.primary, borderRadius: 999, cursor: 'pointer', px: 1.5, py: 0.6, '&:hover': { filter: 'brightness(1.06)' } }}>
|
||||
Test first
|
||||
</Box>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import { Typewriter } from '@/app/components/feedback/Animated';
|
||||
import StopRounded from '@mui/icons-material/StopRounded';
|
||||
import PauseRounded from '@mui/icons-material/PauseRounded';
|
||||
import { StatusDot, RunSparkline, LastFiredHint, isStaleSinceLastRun, isRealTitle } from './workflowVisuals';
|
||||
import { stepsSignature } from './scheduleUtils';
|
||||
import { store } from '@/shared/state/store';
|
||||
import { getAgentWorkTime, fmtSeconds } from '@/shared/agentWorkTime';
|
||||
|
||||
@@ -393,8 +394,12 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
use_synced_prompt: true,
|
||||
model: defaultModel || (d.model as string),
|
||||
mode: defaultMode || (d.mode as string),
|
||||
tested_signature: ((d.source_session_id as string | undefined) || card?.sourceSessionId)
|
||||
? stepsSignature(draftSteps)
|
||||
: undefined,
|
||||
} as Partial<Workflow>));
|
||||
const wf = (result as unknown as { payload: Workflow }).payload;
|
||||
if (!createWorkflow.fulfilled.match(result)) return null;
|
||||
const wf = result.payload as Workflow;
|
||||
if (!wf?.id) return null;
|
||||
dispatch(rekeyOpenCard({ oldId: workflowId, newId: wf.id }));
|
||||
dispatch(rekeyWorkflowCard({ oldId: workflowId, newId: wf.id }));
|
||||
@@ -645,7 +650,7 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
const wf = await persistDraft();
|
||||
if (!wf) return;
|
||||
dispatch(openWorkflowCardAction({ workflowId: wf.id, sourceSessionId: card?.sourceSessionId || null, view: 'saved', draft: null }));
|
||||
await dispatch(runWorkflowNow(wf.id));
|
||||
await dispatch(runWorkflowNow({ id: wf.id, signature: stepsSignature(wf.steps) }));
|
||||
await dispatch(fetchRuns(wf.id));
|
||||
} finally {
|
||||
setTimeout(() => setRunStarting(false), 600);
|
||||
@@ -675,7 +680,7 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
if (runStarting) return;
|
||||
setRunStarting(true);
|
||||
try {
|
||||
const result = await dispatch(runWorkflowNow(workflow.id));
|
||||
const result = await dispatch(runWorkflowNow({ id: workflow.id, signature: stepsSignature(workflow.steps) }));
|
||||
await dispatch(fetchRuns(workflow.id));
|
||||
if (runWorkflowNow.fulfilled.match(result)) {
|
||||
const payload = result.payload;
|
||||
|
||||
@@ -26,7 +26,10 @@ import { placeCard, removeWorkflowCard } from '@/shared/state/dashboardLayoutSli
|
||||
import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice';
|
||||
import { CostChip, humanDuration, routingFor, StreakBadge } from './workflowVisuals';
|
||||
import StepList from './StepList';
|
||||
import { isScheduleConfigured } from './scheduleUtils';
|
||||
import { isScheduleConfigured, needsScheduleTestWarning, stepsSignature } from './scheduleUtils';
|
||||
import ScheduleTestWarningDialog from './ScheduleTestWarningDialog';
|
||||
import { runWorkflowTest } from './runWorkflowTest';
|
||||
import { useOpenSidecar } from './WorkflowCardLiveViews';
|
||||
|
||||
export function statusColor(s: string, c: ReturnType<typeof useClaudeTokens>): string {
|
||||
if (s === 'success') return c.status.success;
|
||||
@@ -173,8 +176,12 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft,
|
||||
// happened to run on, so a converted workflow behaves like a fresh chat.
|
||||
model: defaultModel || (liveDraft.model as string),
|
||||
mode: defaultMode || (liveDraft.mode as string),
|
||||
// Converting a chat carries its prior approvals, so count it as already
|
||||
// validated for these steps: scheduling won't nag to test first.
|
||||
tested_signature: sourceSessionId ? stepsSignature(steps) : undefined,
|
||||
} as Partial<Workflow>));
|
||||
const wf = (result as unknown as { payload: Workflow }).payload;
|
||||
if (!createWorkflow.fulfilled.match(result)) return null;
|
||||
const wf = result.payload as Workflow;
|
||||
if (wf?.id) return wf;
|
||||
return null;
|
||||
}, [canSave, dispatch, title, description, steps, sourceSessionId, liveDraft, defaultModel, defaultMode]);
|
||||
@@ -347,9 +354,25 @@ export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Wo
|
||||
const openEditAgent = useCallback(() => {
|
||||
dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'edit_agent' } }));
|
||||
}, [dispatch, workflow.id]);
|
||||
const openSidecar = useOpenSidecar(workflow.id);
|
||||
const [warnOpen, setWarnOpen] = useState(false);
|
||||
const openScheduling = useCallback(() => {
|
||||
dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'scheduling', showScheduleNudge: false } }));
|
||||
}, [dispatch, workflow.id]);
|
||||
// Gate the schedule action: warn first if the current steps haven't been
|
||||
// validated by a test run (so an unattended fire won't silently deny a tool).
|
||||
const requestSchedule = useCallback(() => {
|
||||
if (needsScheduleTestWarning(workflow)) { setWarnOpen(true); return; }
|
||||
openScheduling();
|
||||
}, [workflow, openScheduling]);
|
||||
const onTestFirst = useCallback(() => {
|
||||
setWarnOpen(false);
|
||||
void runWorkflowTest(workflow.id, workflow.draft_steps ?? workflow.steps, openSidecar);
|
||||
}, [workflow.id, workflow.draft_steps, workflow.steps, openSidecar]);
|
||||
const onScheduleAnyway = useCallback(() => {
|
||||
setWarnOpen(false);
|
||||
openScheduling();
|
||||
}, [openScheduling]);
|
||||
const onToggleStep = useCallback((stepId: string) => {
|
||||
dispatch(toggleExpandedStep({ workflowId: workflow.id, stepId }));
|
||||
}, [dispatch, workflow.id]);
|
||||
@@ -438,7 +461,7 @@ export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Wo
|
||||
Not now
|
||||
</Box>
|
||||
<Box
|
||||
onClick={openScheduling}
|
||||
onClick={requestSchedule}
|
||||
role="button"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.5,
|
||||
@@ -457,7 +480,7 @@ export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Wo
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||
{showNudge ? <Box /> : (
|
||||
<Box
|
||||
onClick={scheduleClickable ? openScheduling : undefined}
|
||||
onClick={scheduleClickable ? requestSchedule : undefined}
|
||||
role={scheduleClickable ? 'button' : undefined}
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.6,
|
||||
@@ -487,6 +510,12 @@ export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Wo
|
||||
Edit
|
||||
</Box>
|
||||
</Box>
|
||||
<ScheduleTestWarningDialog
|
||||
open={warnOpen}
|
||||
onClose={() => setWarnOpen(false)}
|
||||
onTestFirst={onTestFirst}
|
||||
onScheduleAnyway={onScheduleAnyway}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import Tooltip from '@mui/material/Tooltip';
|
||||
import { useEffect } from 'react';
|
||||
import ScheduleCalendar from './ScheduleCalendar';
|
||||
import AddToSchedulePopover from './AddToSchedulePopover';
|
||||
import { WEEKDAY_LABEL, addDays, sameDay, startOfMonthGrid, isWorkflowSchedulable } from './scheduleUtils';
|
||||
import { WEEKDAY_LABEL, addDays, sameDay, startOfMonthGrid, isWorkflowSchedulable, stepsSignature } from './scheduleUtils';
|
||||
import { isRealTitle } from './workflowVisuals';
|
||||
import { Typewriter } from '@/app/components/feedback/Animated';
|
||||
|
||||
@@ -539,7 +539,10 @@ const WorkflowsHubCard: React.FC<Props> = ({
|
||||
anchorPosition={sidebarCtxMenu ? { top: sidebarCtxMenu.y, left: sidebarCtxMenu.x } : undefined}>
|
||||
<MenuItem onClick={() => {
|
||||
if (!sidebarCtxMenu) return;
|
||||
dispatch(runWorkflowNow(sidebarCtxMenu.workflow.id));
|
||||
dispatch(runWorkflowNow({
|
||||
id: sidebarCtxMenu.workflow.id,
|
||||
signature: stepsSignature(sidebarCtxMenu.workflow.steps),
|
||||
}));
|
||||
closeSidebarCtxMenu();
|
||||
}}>Run now</MenuItem>
|
||||
{sidebarCtxMenu && isWorkflowSchedulable(sidebarCtxMenu.workflow) && (
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { API_BASE, getAuthToken } from '@/shared/config';
|
||||
import type { WorkflowStep } from '@/shared/state/workflowsSlice';
|
||||
import { stepsSignature } from './scheduleUtils';
|
||||
|
||||
type OpenSidecar = (sessionId: string, kind: 'testing') => Promise<void>;
|
||||
|
||||
// Kick off a Test Agent run for the given (possibly-draft) steps and wire its
|
||||
// session into the workflow card's sidecar. The signature rides along so a
|
||||
// completed test run stamps the workflow as validated (see scheduleUtils +
|
||||
// the test-run endpoint). Returns the session id, or null if it didn't start.
|
||||
export async function runWorkflowTest(
|
||||
workflowId: string,
|
||||
steps: WorkflowStep[],
|
||||
openSidecar: OpenSidecar,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
|
||||
const res = await fetch(`${API_BASE}/workflows/${encodeURIComponent(workflowId)}/test-run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(tok ? { Authorization: `Bearer ${tok}` } : {}) },
|
||||
body: JSON.stringify({ steps, signature: stepsSignature(steps) }),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
const sid = data?.session_id as string | undefined;
|
||||
if (!sid) return null;
|
||||
await openSidecar(sid, 'testing');
|
||||
return sid;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Workflow, ScheduleConfig } from '@/shared/state/workflowsSlice';
|
||||
import type { Workflow, ScheduleConfig, WorkflowStep } from '@/shared/state/workflowsSlice';
|
||||
|
||||
export const WEEKDAY_LABEL = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
|
||||
export const WEEKDAY_LABEL_SHORT = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'];
|
||||
@@ -39,6 +39,23 @@ export function isWorkflowSchedulable(workflow: Workflow): boolean {
|
||||
return isScheduleConfigured(workflow.schedule);
|
||||
}
|
||||
|
||||
// Stable fingerprint of the steps that actually drive behavior (order + id +
|
||||
// text). label is just the at-a-glance headline, so it's left out. Computed
|
||||
// only here so the backend stores exactly what the FE compares: no cross-
|
||||
// language hashing drift.
|
||||
export function stepsSignature(steps: WorkflowStep[] | null | undefined): string {
|
||||
return JSON.stringify((steps || []).map((s) => [s.id, s.text]));
|
||||
}
|
||||
|
||||
// True when the current steps haven't been validated by a test run (or seeded
|
||||
// at chat conversion) since they were last edited. Drives the test-first
|
||||
// warning before scheduling.
|
||||
export function needsScheduleTestWarning(workflow: Workflow): boolean {
|
||||
const steps = workflow.draft_steps ?? workflow.steps;
|
||||
if (!steps || steps.length === 0) return false;
|
||||
return stepsSignature(steps) !== (workflow.tested_signature ?? '');
|
||||
}
|
||||
|
||||
export function formatTime(hour: number, minute: number): string {
|
||||
const h12 = ((hour + 11) % 12) + 1;
|
||||
const suffix = hour < 12 ? 'am' : 'pm';
|
||||
|
||||
@@ -91,10 +91,16 @@ export interface Workflow {
|
||||
* unattended scheduled fire doesn't stall on a prompt. tool name -> answer. */
|
||||
remembered_approvals?: Record<string, 'allow' | 'deny'>;
|
||||
step_tool_usage?: Record<string, Record<string, boolean>>;
|
||||
/** Tool names observed in the source chat when this workflow was generated. */
|
||||
source_tools?: string[];
|
||||
/** False once the user explicitly renames the workflow; backend may auto-rename while true. */
|
||||
auto_named?: boolean;
|
||||
/** True while a brand-new "+ New" workflow is still being built and hasn't been saved; hub hides these. */
|
||||
unsaved?: boolean;
|
||||
/** Signature of the steps last validated by a test run (or seeded at chat
|
||||
* conversion). Compared against the current steps to decide whether to warn
|
||||
* before scheduling. See scheduleUtils.needsScheduleTestWarning. */
|
||||
tested_signature?: string | null;
|
||||
}
|
||||
|
||||
export interface WorkflowRun {
|
||||
@@ -326,8 +332,18 @@ export const deleteWorkflow = createAsyncThunk('workflows/delete', async (id: st
|
||||
return id;
|
||||
});
|
||||
|
||||
export const runWorkflowNow = createAsyncThunk('workflows/run', async (id: string) => {
|
||||
const res = await fetch(`${API}/${id}/run`, { method: 'POST' });
|
||||
type RunWorkflowNowArg = string | { id: string; signature?: string | null };
|
||||
|
||||
export const runWorkflowNow = createAsyncThunk('workflows/run', async (arg: RunWorkflowNowArg) => {
|
||||
const id = typeof arg === 'string' ? arg : arg.id;
|
||||
const signature = typeof arg === 'string' ? null : arg.signature;
|
||||
const res = await fetch(`${API}/${id}/run`, {
|
||||
method: 'POST',
|
||||
...(signature ? {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ signature }),
|
||||
} : {}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`run failed ${res.status}`);
|
||||
const data = await res.json();
|
||||
return {
|
||||
|
||||
@@ -28,6 +28,7 @@ import { addBrowserCardFromBackend, markBrowserCardEnding, setBrowserCardPositio
|
||||
import { upsertOutput } from '../state/outputsSlice';
|
||||
import { displaySessionName } from '../state/sessionDisplay';
|
||||
import { upsertRun, ackRun, runWorkflowNow, openWorkflowCard, upsertWorkflow, removeWorkflow } from '../state/workflowsSlice';
|
||||
import { stepsSignature } from '@/app/pages/Workflows/scheduleUtils';
|
||||
import { getAuthToken } from '../config';
|
||||
import { notifyAgentCompletion } from '../notifications';
|
||||
|
||||
@@ -946,7 +947,8 @@ import { WS_BASE } from '@/shared/config';
|
||||
return;
|
||||
}
|
||||
if (outcome === 'rerun') {
|
||||
store.dispatch(runWorkflowNow(workflowId));
|
||||
const wf = store.getState().workflows.items[workflowId];
|
||||
store.dispatch(wf ? runWorkflowNow({ id: workflowId, signature: stepsSignature(wf.steps) }) : runWorkflowNow(workflowId));
|
||||
return;
|
||||
}
|
||||
if (outcome === 'edit' || outcome === 'open') {
|
||||
|
||||
Reference in New Issue
Block a user