[aidan] feat/workflows: validate steps before scheduling and keep chat tool memory

This commit is contained in:
abccodes
2026-06-18 04:12:52 -07:00
parent 213e3d75bb
commit 3f780bf2b3
14 changed files with 296 additions and 53 deletions
+18 -4
View File
@@ -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"
+11
View File
@@ -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):
+76 -11
View File
@@ -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&apos;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';
+18 -2
View File
@@ -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 {
+3 -1
View File
@@ -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') {