import { useState, useCallback, useEffect, useRef } from "react"; import { Calendar, Webhook, Code, Zap, Globe, Folder } from "lucide-react"; import type { Routine, RoutineCreateInput, RoutineTrigger, RoutineTriggerType, RoutineCronTrigger, RoutineWebhookTrigger, RoutineApiTrigger, RoutineManualTrigger, RoutineCatchUpPolicy, RoutineExecutionPolicy, AutomationStep, } from "@fusion/core"; import { ScheduleStepsEditor } from "./ScheduleStepsEditor"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { fetchModels, type ModelInfo } from "../api"; type CronPresetType = "hourly" | "daily" | "weekly" | "monthly" | "custom"; const CRON_PRESETS: Record, string> = { hourly: "0 * * * *", daily: "0 0 * * *", weekly: "0 0 * * 1", monthly: "0 0 1 * *", }; const CRON_PRESET_LABELS: Record = { hourly: "Every hour", daily: "Every day (midnight)", weekly: "Every week (Monday)", monthly: "Every month (1st)", custom: "Custom cron expression", }; function resolveCronPreset(cronExpression: string): CronPresetType { const normalizedCron = cronExpression.trim(); for (const [preset, value] of Object.entries(CRON_PRESETS)) { if (value === normalizedCron) { return preset as Exclude; } } return "custom"; } /** * Simple cron expression validator (5-field format). * Checks basic structure — authoritative validation happens server-side. */ function isLikelyCron(expr: string): boolean { const parts = expr.trim().split(/\s+/); if (parts.length !== 5) return false; // Each field should contain digits, *, /, -, or , return parts.every((p) => /^[\d*,/-]+$/.test(p)); } /** * Build a trigger object from form state. */ function buildTrigger( triggerType: RoutineTriggerType, cronExpression: string, webhookPath: string, webhookSecret: string, endpoint: string, ): RoutineTrigger { switch (triggerType) { case "cron": return { type: "cron", cronExpression } as RoutineCronTrigger; case "webhook": return { type: "webhook", webhookPath: webhookPath || "/trigger/" + Math.random().toString(36).slice(2, 10), secret: webhookSecret || undefined, } as RoutineWebhookTrigger; case "api": return { type: "api", endpoint: endpoint || "/api/routine/" + Math.random().toString(36).slice(2, 10), } as RoutineApiTrigger; case "manual": return { type: "manual" } as RoutineManualTrigger; } } /** * Extract trigger fields from a Routine object. */ function extractTriggerFields(routine: Routine) { const trigger = routine.trigger; switch (trigger.type) { case "cron": return { triggerType: "cron" as RoutineTriggerType, cronExpression: (trigger as RoutineCronTrigger).cronExpression, webhookPath: "", webhookSecret: "", endpoint: "", }; case "webhook": return { triggerType: "webhook" as RoutineTriggerType, cronExpression: "", webhookPath: (trigger as RoutineWebhookTrigger).webhookPath, webhookSecret: (trigger as RoutineWebhookTrigger).secret || "", endpoint: "", }; case "api": return { triggerType: "api" as RoutineTriggerType, cronExpression: "", webhookPath: "", webhookSecret: "", endpoint: (trigger as RoutineApiTrigger).endpoint, }; case "manual": return { triggerType: "manual" as RoutineTriggerType, cronExpression: "", webhookPath: "", webhookSecret: "", endpoint: "", }; } } const EXECUTION_POLICY_OPTIONS: { value: RoutineExecutionPolicy; label: string }[] = [ { value: "parallel", label: "Allow concurrent runs" }, { value: "queue", label: "Queue after current (one at a time)" }, { value: "reject", label: "Reject new runs while running" }, ]; const CATCH_UP_POLICY_OPTIONS: { value: RoutineCatchUpPolicy; label: string }[] = [ { value: "skip", label: "Skip missed runs" }, { value: "run_one", label: "Run the most recent missed run" }, { value: "run", label: "Run all missed runs" }, ]; function generateStepId(): string { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return crypto.randomUUID(); } return `step-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; } type ActionMode = "simple" | "advanced"; type SimpleActionType = "command" | "ai-prompt" | "create-task"; interface RoutineEditorProps { /** Existing routine for editing. Omit for create mode. */ routine?: Routine; /** Called with form data on submit. */ onSubmit: (input: RoutineCreateInput) => Promise; /** Called when the user cancels. */ onCancel: () => void; /** Scope for the routine (global or project). Defaults to routine.scope or "project". */ scope?: "global" | "project"; /** Project ID for project-scoped routines. */ projectId?: string; /** Called when the user changes the scope via the toggle buttons. */ onScopeChange?: (scope: "global" | "project") => void; } export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, projectId, onScopeChange }: RoutineEditorProps) { const isEditing = !!routine; // Extract trigger fields if editing const initialTriggerFields = routine ? extractTriggerFields(routine) : { triggerType: "cron" as RoutineTriggerType, cronExpression: "0 * * * *", webhookPath: "", webhookSecret: "", endpoint: "", }; const [name, setName] = useState(routine?.name ?? ""); const [description, setDescription] = useState(routine?.description ?? ""); const [triggerType, setTriggerType] = useState(initialTriggerFields.triggerType); const [cronExpression, setCronExpression] = useState(initialTriggerFields.cronExpression); const [cronPreset, setCronPreset] = useState(() => { if (initialTriggerFields.triggerType !== "cron") return "custom"; return resolveCronPreset(initialTriggerFields.cronExpression); }); const [webhookPath, setWebhookPath] = useState(initialTriggerFields.webhookPath); const [webhookSecret, setWebhookSecret] = useState(initialTriggerFields.webhookSecret); const [endpoint, setEndpoint] = useState(initialTriggerFields.endpoint); const [executionPolicy, setExecutionPolicy] = useState( routine?.executionPolicy ?? "queue" ); const [catchUpPolicy, setCatchUpPolicy] = useState( routine?.catchUpPolicy ?? "run_one" ); const [enabled, setEnabled] = useState(routine?.enabled ?? true); const isSimpleAiPrompt = routine?.steps && routine.steps.length === 1 && routine.steps[0].type === "ai-prompt" && !routine.command; const isSimpleCreateTask = routine?.steps && routine.steps.length === 1 && routine.steps[0].type === "create-task" && !routine.command; const [actionMode, setActionMode] = useState( routine?.steps && routine.steps.length > 0 && !isSimpleAiPrompt && !isSimpleCreateTask ? "advanced" : "simple" ); const [simpleActionType, setSimpleActionType] = useState(() => { if (isSimpleAiPrompt) return "ai-prompt"; if (isSimpleCreateTask) return "create-task"; return "command"; }); const [command, setCommand] = useState(routine?.command ?? ""); const [steps, setSteps] = useState(routine?.steps ?? []); const [hasEditingSteps, setHasEditingSteps] = useState(false); const [timeoutMs, setTimeoutMs] = useState(routine?.timeoutMs ?? 300000); const [prompt, setPrompt] = useState(isSimpleAiPrompt ? routine.steps?.[0]?.prompt ?? "" : ""); const [taskTitle, setTaskTitle] = useState(isSimpleCreateTask ? routine.steps?.[0]?.taskTitle ?? "" : ""); const [taskDescription, setTaskDescription] = useState(isSimpleCreateTask ? routine.steps?.[0]?.taskDescription ?? "" : ""); const [taskColumn, setTaskColumn] = useState(isSimpleCreateTask ? routine.steps?.[0]?.taskColumn ?? "triage" : "triage"); const [modelProvider, setModelProvider] = useState( isSimpleAiPrompt || isSimpleCreateTask ? routine.steps?.[0]?.modelProvider ?? "" : "" ); const [modelId, setModelId] = useState( isSimpleAiPrompt || isSimpleCreateTask ? routine.steps?.[0]?.modelId ?? "" : "" ); const [models, setModels] = useState([]); const [modelsLoading, setModelsLoading] = useState(false); const [modelsError, setModelsError] = useState(null); // Scope toggle state const [localScope, setLocalScope] = useState<"global" | "project">(formScope ?? "global"); const [errors, setErrors] = useState>({}); const [submitting, setSubmitting] = useState(false); const isMountedRef = useRef(true); useEffect(() => { return () => { isMountedRef.current = false; }; }, []); // Sync localScope when formScope prop changes (e.g., when parent resets) useEffect(() => { if (formScope) setLocalScope(formScope); }, [formScope]); useEffect(() => { let cancelled = false; setModelsLoading(true); setModelsError(null); fetchModels() .then((response) => { if (!cancelled) setModels(response.models); }) .catch((err: unknown) => { if (!cancelled) setModelsError(err instanceof Error ? err.message : "Failed to load models"); }) .finally(() => { if (!cancelled) setModelsLoading(false); }); return () => { cancelled = true; }; }, []); const modelValue = modelProvider && modelId ? `${modelProvider}/${modelId}` : ""; const handleModelChange = useCallback((value: string) => { if (!value) { setModelProvider(""); setModelId(""); return; } const slashIdx = value.indexOf("/"); if (slashIdx !== -1) { setModelProvider(value.slice(0, slashIdx)); setModelId(value.slice(slashIdx + 1)); } }, []); const validate = useCallback((): boolean => { const e: Record = {}; if (!name.trim()) e.name = "Name is required"; // Scope validation: project scope requires projectId if (localScope === "project" && !projectId) { e.scope = "Project-specific entries require an active project."; } if (triggerType === "cron" && cronPreset === "custom") { if (!cronExpression.trim()) { e.cronExpression = "Cron expression is required"; } else if (!isLikelyCron(cronExpression)) { e.cronExpression = "Invalid cron format — expected 5 fields (e.g. '0 */6 * * *')"; } } if (triggerType === "webhook" && !webhookPath.trim()) { e.webhookPath = "Webhook path is required"; } if (triggerType === "api" && !endpoint.trim()) { e.endpoint = "API endpoint is required"; } if (actionMode === "simple") { if (simpleActionType === "command" && !command.trim()) e.command = "Command is required"; if (simpleActionType === "ai-prompt" && !prompt.trim()) e.prompt = "Prompt is required"; if (simpleActionType === "create-task" && !taskDescription.trim()) e.taskDescription = "Task description is required"; if ((modelProvider.trim() && !modelId.trim()) || (!modelProvider.trim() && modelId.trim())) { e.model = "Both model provider and model ID must be set, or both must be empty"; } } else { if (steps.length === 0) e.steps = "At least one step is required"; if (hasEditingSteps) e.stepsEditing = "Please save or cancel all step edits before saving the routine"; const incompleteSteps: string[] = []; steps.forEach((step, index) => { if (!step.name?.trim()) incompleteSteps.push(`Step ${index + 1}: Name is required`); if (step.type === "command" && !step.command?.trim()) incompleteSteps.push(`Step ${index + 1}: Command is required`); if (step.type === "ai-prompt" && !step.prompt?.trim()) incompleteSteps.push(`Step ${index + 1}: Prompt is required`); if (step.type === "create-task" && !step.taskDescription?.trim()) incompleteSteps.push(`Step ${index + 1}: Task description is required`); }); if (incompleteSteps.length > 0) e.steps = incompleteSteps.join("; "); } if (timeoutMs < 1000) e.timeoutMs = "Timeout must be at least 1 second (1000ms)"; setErrors(e); return Object.keys(e).length === 0; }, [name, triggerType, cronExpression, cronPreset, webhookPath, endpoint, localScope, projectId, actionMode, simpleActionType, command, prompt, taskDescription, modelProvider, modelId, steps, hasEditingSteps, timeoutMs]); const handleSubmit = useCallback( async (e: React.FormEvent) => { e.preventDefault(); if (!validate()) return; setSubmitting(true); try { // Determine scope: use edit mode's existing scope, otherwise use localScope // When localScope is "project" but no projectId provided, fall back to "global" let effectiveScope = routine?.scope ?? localScope ?? (projectId ? "project" : "global"); if (effectiveScope === "project" && !projectId) { effectiveScope = "global"; } const trigger = buildTrigger(triggerType, cronExpression, webhookPath, webhookSecret, endpoint); let actionCommand: string | undefined; let actionSteps: AutomationStep[] | undefined; if (actionMode === "simple") { if (simpleActionType === "command") { actionCommand = command.trim() || undefined; } else if (simpleActionType === "ai-prompt") { actionSteps = [{ id: generateStepId(), type: "ai-prompt", name: name.trim(), prompt: prompt.trim(), modelProvider: modelProvider.trim() || undefined, modelId: modelId.trim() || undefined, }]; } else { actionSteps = [{ id: generateStepId(), type: "create-task", name: name.trim(), taskTitle: taskTitle.trim() || undefined, taskDescription: taskDescription.trim(), taskColumn, modelProvider: modelProvider.trim() || undefined, modelId: modelId.trim() || undefined, }]; } } else { actionSteps = steps; } const input: RoutineCreateInput = { name: name.trim(), agentId: routine?.agentId ?? "", description: description.trim() || undefined, trigger, command: actionCommand, steps: actionSteps, timeoutMs, executionPolicy, catchUpPolicy, enabled, scope: effectiveScope, }; await onSubmit(input); } finally { if (isMountedRef.current) { setSubmitting(false); } } }, [validate, onSubmit, name, description, triggerType, cronExpression, webhookPath, webhookSecret, endpoint, actionMode, simpleActionType, command, prompt, modelProvider, modelId, taskTitle, taskDescription, taskColumn, steps, timeoutMs, executionPolicy, catchUpPolicy, enabled, localScope, projectId, routine?.scope, routine?.agentId], ); const nameErrorId = "routine-name-error"; const cronErrorId = "routine-cron-error"; const webhookErrorId = "routine-webhook-error"; const endpointErrorId = "routine-endpoint-error"; const commandErrorId = "routine-command-error"; const promptErrorId = "routine-prompt-error"; const taskDescriptionErrorId = "routine-task-description-error"; const modelErrorId = "routine-model-error"; const timeoutErrorId = "routine-timeout-error"; const handleCronPresetChange = useCallback((preset: CronPresetType) => { setCronPreset(preset); if (preset !== "custom") { setCronExpression(CRON_PRESETS[preset]); } }, []); return (

{isEditing ? "Edit Routine" : "New Routine"}

{/* Basic Info */}
setName(e.target.value)} aria-invalid={!!errors.name} aria-describedby={errors.name ? nameErrorId : undefined} /> {errors.name && ( {errors.name} )}