import { useState, useCallback, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; 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 * *", }; // CRON_PRESET_LABELS are now built inside the component with t() — see useCronPresetLabels below 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: "", }; } } // EXECUTION_POLICY_OPTIONS and CATCH_UP_POLICY_OPTIONS labels are now built inside the component with t() 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 { t } = useTranslation("app"); const isEditing = !!routine; const CRON_PRESET_LABELS: Record = { hourly: t("schedule.cronPresetHourly", "Every hour"), daily: t("schedule.cronPresetDaily", "Every day (midnight)"), weekly: t("schedule.cronPresetWeekly", "Every week (Monday)"), monthly: t("schedule.cronPresetMonthly", "Every month (1st)"), custom: t("schedule.cronPresetCustom", "Custom cron expression"), }; const EXECUTION_POLICY_OPTIONS: { value: RoutineExecutionPolicy; label: string }[] = [ { value: "parallel", label: t("schedule.executionPolicyParallel", "Allow concurrent runs") }, { value: "queue", label: t("schedule.executionPolicyQueue", "Queue after current (one at a time)") }, { value: "reject", label: t("schedule.executionPolicyReject", "Reject new runs while running") }, ]; const CATCH_UP_POLICY_OPTIONS: { value: RoutineCatchUpPolicy; label: string }[] = [ { value: "skip", label: t("schedule.catchUpPolicySkip", "Skip missed runs") }, { value: "run_one", label: t("schedule.catchUpPolicyRunOne", "Run the most recent missed run") }, { value: "run", label: t("schedule.catchUpPolicyRunAll", "Run all missed runs") }, ]; // 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 : t("schedule.errorLoadModels", "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 = t("schedule.errorNameRequired", "Name is required"); // Scope validation: project scope requires projectId if (localScope === "project" && !projectId) { e.scope = t("schedule.errorScopeNoProject", "Project-specific entries require an active project."); } if (triggerType === "cron" && cronPreset === "custom") { if (!cronExpression.trim()) { e.cronExpression = t("schedule.errorCronRequired", "Cron expression is required"); } else if (!isLikelyCron(cronExpression)) { e.cronExpression = t("schedule.errorCronInvalid", "Invalid cron format — expected 5 fields (e.g. '0 */6 * * *')"); } } if (triggerType === "webhook" && !webhookPath.trim()) { e.webhookPath = t("schedule.errorWebhookPathRequired", "Webhook path is required"); } if (triggerType === "api" && !endpoint.trim()) { e.endpoint = t("schedule.errorApiEndpointRequired", "API endpoint is required"); } if (actionMode === "simple") { if (simpleActionType === "command" && !command.trim()) e.command = t("schedule.errorCommandRequired", "Command is required"); if (simpleActionType === "ai-prompt" && !prompt.trim()) e.prompt = t("schedule.errorPromptRequired", "Prompt is required"); if (simpleActionType === "create-task" && !taskDescription.trim()) e.taskDescription = t("schedule.errorTaskDescriptionRequired", "Task description is required"); if ((modelProvider.trim() && !modelId.trim()) || (!modelProvider.trim() && modelId.trim())) { e.model = t("schedule.errorModelIncomplete", "Both model provider and model ID must be set, or both must be empty"); } } else { if (steps.length === 0) e.steps = t("schedule.errorStepsRequired", "At least one step is required"); if (hasEditingSteps) e.stepsEditing = t("schedule.errorStepsEditing", "Please save or cancel all step edits before saving the routine"); const incompleteSteps: string[] = []; steps.forEach((step, index) => { if (!step.name?.trim()) incompleteSteps.push(t("schedule.errorStepNameRequired", "Step {{n}}: Name is required", { n: index + 1 })); if (step.type === "command" && !step.command?.trim()) incompleteSteps.push(t("schedule.errorStepCommandRequired", "Step {{n}}: Command is required", { n: index + 1 })); if (step.type === "ai-prompt" && !step.prompt?.trim()) incompleteSteps.push(t("schedule.errorStepPromptRequired", "Step {{n}}: Prompt is required", { n: index + 1 })); if (step.type === "create-task" && !step.taskDescription?.trim()) incompleteSteps.push(t("schedule.errorStepTaskDescRequired", "Step {{n}}: Task description is required", { n: index + 1 })); }); if (incompleteSteps.length > 0) e.steps = incompleteSteps.join("; "); } if (timeoutMs < 1000) e.timeoutMs = t("schedule.errorTimeoutMin", "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 ? t("schedule.editRoutineHeading", "Edit Routine") : t("schedule.newRoutineHeading", "New Routine")}

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