fix(FN-1952): recover routines schedules merge

This commit is contained in:
gsxdsm
2026-04-16 23:07:40 -07:00
parent 64ae788e92
commit 8bf3e4a3ec
25 changed files with 1002 additions and 1016 deletions

View File

@@ -1,5 +1,5 @@
import { useState, useCallback } from "react";
import { Play, Pause, Pencil, Trash2, Clock, CheckCircle, XCircle, ChevronDown, ChevronUp, Calendar, Webhook, Code, Zap, Globe, Folder } from "lucide-react";
import { Play, Pause, Pencil, Trash2, Clock, CheckCircle, XCircle, ChevronDown, ChevronUp, Calendar, Webhook, Code, Zap, Globe, Folder, Layers } from "lucide-react";
import type { Routine, RoutineExecutionResult, RoutineTriggerType, RoutineCatchUpPolicy, RoutineExecutionPolicy } from "@fusion/core";
/**
@@ -224,6 +224,17 @@ export function RoutineCard({ routine, onEdit, onDelete, onRun, onToggle, runnin
</div>
<div className="routine-card-meta">
{routine.steps && routine.steps.length > 0 ? (
<div className="routine-meta-item">
<Layers size={12} />
<span className="routine-policy-badge">{routine.steps.length} step{routine.steps.length === 1 ? "" : "s"}</span>
</div>
) : routine.command ? (
<div className="routine-meta-item routine-meta-command-preview" title={routine.command}>
<code className="routine-cron">{routine.command}</code>
</div>
) : null}
{/* Cron expression for cron triggers */}
{routine.trigger.type === "cron" && cronExpression && (
<div className="routine-meta-item">

View File

@@ -1,4 +1,4 @@
import { useState, useCallback } from "react";
import { useState, useCallback, useEffect } from "react";
import { Calendar, Webhook, Code, Zap, Globe, Folder } from "lucide-react";
import type {
Routine,
@@ -12,7 +12,11 @@ import type {
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";
@@ -141,6 +145,16 @@ const CATCH_UP_POLICY_OPTIONS: { value: RoutineCatchUpPolicy; label: string }[]
{ 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;
@@ -184,10 +198,72 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
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<ActionMode>(
routine?.steps && routine.steps.length > 0 && !isSimpleAiPrompt && !isSimpleCreateTask ? "advanced" : "simple"
);
const [simpleActionType, setSimpleActionType] = useState<SimpleActionType>(() => {
if (isSimpleAiPrompt) return "ai-prompt";
if (isSimpleCreateTask) return "create-task";
return "command";
});
const [command, setCommand] = useState(routine?.command ?? "");
const [steps, setSteps] = useState<AutomationStep[]>(routine?.steps ?? []);
const [hasEditingSteps, setHasEditingSteps] = useState(false);
const [timeoutMs, setTimeoutMs] = useState<number>(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<ModelInfo[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
const [modelsError, setModelsError] = useState<string | null>(null);
const [errors, setErrors] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
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<string, string> = {};
if (!name.trim()) e.name = "Name is required";
@@ -210,9 +286,29 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
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, formScope, projectId]);
}, [name, triggerType, cronExpression, cronPreset, webhookPath, endpoint, formScope, projectId, actionMode, simpleActionType, command, prompt, taskDescription, modelProvider, modelId, steps, hasEditingSteps, timeoutMs]);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
@@ -228,11 +324,43 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
}
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,
@@ -243,13 +371,18 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
setSubmitting(false);
}
},
[validate, onSubmit, name, description, triggerType, cronExpression, webhookPath, webhookSecret, endpoint, executionPolicy, catchUpPolicy, enabled, formScope, projectId, routine?.scope],
[validate, onSubmit, name, description, triggerType, cronExpression, webhookPath, webhookSecret, endpoint, actionMode, simpleActionType, command, prompt, modelProvider, modelId, taskTitle, taskDescription, taskColumn, steps, timeoutMs, executionPolicy, catchUpPolicy, enabled, formScope, 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);
@@ -481,6 +614,103 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
</div>
)}
<div className="form-group">
<label>Action Mode</label>
<div className="schedule-mode-toggle" role="radiogroup" aria-label="Action mode">
<button
type="button"
className={`schedule-mode-btn${actionMode === "simple" ? " active" : ""}`}
onClick={() => setActionMode("simple")}
role="radio"
aria-checked={actionMode === "simple"}
>
Simple
</button>
<button
type="button"
className={`schedule-mode-btn${actionMode === "advanced" ? " active" : ""}`}
onClick={() => setActionMode("advanced")}
role="radio"
aria-checked={actionMode === "advanced"}
>
Multi-Step
</button>
</div>
<small>{actionMode === "simple" ? "Run one command, prompt, or task creation action" : "Run multiple actions sequentially"}</small>
</div>
{actionMode === "simple" ? (
<>
<div className="form-group">
<label>Action Type</label>
<div className="schedule-mode-toggle" role="radiogroup" aria-label="Action type">
<button type="button" className={`schedule-mode-btn${simpleActionType === "command" ? " active" : ""}`} onClick={() => setSimpleActionType("command")} role="radio" aria-checked={simpleActionType === "command"}>Command</button>
<button type="button" className={`schedule-mode-btn${simpleActionType === "ai-prompt" ? " active" : ""}`} onClick={() => setSimpleActionType("ai-prompt")} role="radio" aria-checked={simpleActionType === "ai-prompt"}>AI Prompt</button>
<button type="button" className={`schedule-mode-btn${simpleActionType === "create-task" ? " active" : ""}`} onClick={() => setSimpleActionType("create-task")} role="radio" aria-checked={simpleActionType === "create-task"}>Create Task</button>
</div>
</div>
{simpleActionType === "command" ? (
<div className="form-group">
<label htmlFor="routine-command">Command</label>
<input id="routine-command" type="text" placeholder="e.g. fn backup --create" value={command} onChange={(e) => setCommand(e.target.value)} aria-invalid={!!errors.command} aria-describedby={errors.command ? commandErrorId : undefined} />
{errors.command ? <small id={commandErrorId} className="field-error">{errors.command}</small> : <small>Shell command to execute.</small>}
</div>
) : simpleActionType === "ai-prompt" ? (
<>
<div className="form-group">
<label htmlFor="routine-prompt">Prompt</label>
<textarea id="routine-prompt" placeholder="e.g. Summarize recent activity and create action items" value={prompt} onChange={(e) => setPrompt(e.target.value)} rows={3} aria-invalid={!!errors.prompt} aria-describedby={errors.prompt ? promptErrorId : undefined} />
{errors.prompt ? <small id={promptErrorId} className="field-error">{errors.prompt}</small> : <small>AI prompt to execute.</small>}
</div>
<div className="form-group">
<label htmlFor="routine-model">Model (optional)</label>
<CustomModelDropdown id="routine-model" label="Model" models={models} value={modelValue} onChange={handleModelChange} placeholder="Use default" disabled={modelsLoading} />
{modelsError && <small className="field-error">{modelsError}</small>}
{errors.model && <small id={modelErrorId} className="field-error">{errors.model}</small>}
</div>
</>
) : (
<>
<div className="form-group">
<label htmlFor="routine-task-title">Task Title (optional)</label>
<input id="routine-task-title" type="text" placeholder="e.g. Review weekly dependencies" value={taskTitle} onChange={(e) => setTaskTitle(e.target.value)} />
</div>
<div className="form-group">
<label htmlFor="routine-task-description">Task Description</label>
<textarea id="routine-task-description" placeholder="e.g. Check npm dependencies for security vulnerabilities" value={taskDescription} onChange={(e) => setTaskDescription(e.target.value)} rows={4} aria-invalid={!!errors.taskDescription} aria-describedby={errors.taskDescription ? taskDescriptionErrorId : undefined} />
{errors.taskDescription ? <small id={taskDescriptionErrorId} className="field-error">{errors.taskDescription}</small> : <small>Describes the task that will be created.</small>}
</div>
<div className="form-group">
<label htmlFor="routine-task-column">Target Column</label>
<select id="routine-task-column" value={taskColumn} onChange={(e) => setTaskColumn(e.target.value)}>
<option value="triage">Triage</option>
<option value="todo">To Do</option>
</select>
</div>
<div className="form-group">
<label htmlFor="routine-task-model">Executor Model (optional)</label>
<CustomModelDropdown id="routine-task-model" label="Executor Model" models={models} value={modelValue} onChange={handleModelChange} placeholder="Use default" disabled={modelsLoading} />
{modelsError && <small className="field-error">{modelsError}</small>}
{errors.model && <small id={modelErrorId} className="field-error">{errors.model}</small>}
</div>
</>
)}
</>
) : (
<>
<ScheduleStepsEditor steps={steps} onChange={setSteps} onEditingChange={setHasEditingSteps} />
{errors.steps && <small className="field-error">{errors.steps}</small>}
{errors.stepsEditing && <small className="field-error">{errors.stepsEditing}</small>}
</>
)}
<div className="form-group">
<label htmlFor="routine-timeout">Timeout (ms)</label>
<input id="routine-timeout" type="number" min={1000} step={1000} value={timeoutMs} onChange={(e) => setTimeoutMs(Number(e.target.value))} aria-invalid={!!errors.timeoutMs} aria-describedby={errors.timeoutMs ? timeoutErrorId : undefined} />
{errors.timeoutMs ? <small id={timeoutErrorId} className="field-error">{errors.timeoutMs}</small> : <small>Maximum execution time in milliseconds.</small>}
</div>
{/* Execution Policy */}
<div className="form-group">
<label htmlFor="routine-execution-policy">Execution Policy</label>

View File

@@ -1,26 +1,13 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { Plus, Clock, Zap, Globe, Folder } from "lucide-react";
import type {
ScheduledTask,
ScheduledTaskCreateInput,
Routine,
RoutineCreateInput,
} from "@fusion/core";
import { Plus, Zap, Globe, Folder } from "lucide-react";
import type { Routine, RoutineCreateInput } from "@fusion/core";
import {
fetchAutomations,
createAutomation,
updateAutomation,
deleteAutomation,
runAutomation,
toggleAutomation,
fetchRoutines,
createRoutine,
updateRoutine,
deleteRoutine,
runRoutine,
} from "../api";
import { ScheduleForm } from "./ScheduleForm";
import { ScheduleCard } from "./ScheduleCard";
import { RoutineCard } from "./RoutineCard";
import { RoutineEditor } from "./RoutineEditor";
import type { ToastType } from "../hooks/useToast";
@@ -38,24 +25,10 @@ interface ScheduledTasksModalProps {
projectId?: string;
}
type ModalView = "list" | "create" | "edit";
type ActiveTab = "schedules" | "routines";
export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledTasksModalProps) {
// Tab state
const [activeTab, setActiveTab] = useState<ActiveTab>("schedules");
// Scope state: defaults to "project" when projectId exists, else "global"
const [activeScope, setActiveScope] = useState<SchedulingScope>(() => projectId ? "project" : "global");
// Schedule state
const [schedules, setSchedules] = useState<ScheduledTask[]>([]);
const [loading, setLoading] = useState(true);
const [view, setView] = useState<ModalView>("list");
const [editingSchedule, setEditingSchedule] = useState<ScheduledTask | undefined>();
/** Track which schedule is currently running a manual execution. */
const [runningId, setRunningId] = useState<string | null>(null);
// Routine state
const [routines, setRoutines] = useState<Routine[]>([]);
const [routineView, setRoutineView] = useState<"list" | "create" | "edit">("list");
@@ -68,18 +41,6 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
projectId: activeScope === "project" ? projectId : undefined,
}), [activeScope, projectId]);
// Load schedules
const loadSchedules = useCallback(async () => {
try {
const data = await fetchAutomations(scopeOptions);
setSchedules(data);
} catch (err: any) {
addToast(err.message || "Failed to load schedules", "error");
} finally {
setLoading(false);
}
}, [addToast, scopeOptions]);
// Load routines
const loadRoutines = useCallback(async () => {
try {
@@ -91,44 +52,32 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
}, [addToast, scopeOptions]);
useEffect(() => {
void loadSchedules();
void loadRoutines();
}, [loadSchedules, loadRoutines]);
}, [loadRoutines]);
// Poll for updates while modal is open
useEffect(() => {
const interval = setInterval(() => {
void loadSchedules();
void loadRoutines();
}, POLL_INTERVAL_MS);
return () => clearInterval(interval);
}, [loadSchedules, loadRoutines]);
}, [loadRoutines]);
// Close on Escape (only when not in a sub-form)
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
if (activeTab === "schedules") {
if (view !== "list") {
setView("list");
setEditingSchedule(undefined);
} else {
onClose();
}
if (routineView !== "list") {
setRoutineView("list");
setEditingRoutine(undefined);
} else {
// Routines tab
if (routineView !== "list") {
setRoutineView("list");
setEditingRoutine(undefined);
} else {
onClose();
}
onClose();
}
}
};
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [onClose, activeTab, view, routineView]);
}, [onClose, routineView]);
const handleOverlayClick = useCallback(
(e: React.MouseEvent) => {
@@ -137,97 +86,6 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
[onClose],
);
// ── Schedule CRUD handlers ──────────────────────────────────────────────
const handleCreate = useCallback(
async (input: ScheduledTaskCreateInput) => {
try {
await createAutomation(input, scopeOptions);
addToast("Schedule created", "success");
setView("list");
await loadSchedules();
} catch (err: any) {
addToast(err.message || "Failed to create schedule", "error");
}
},
[addToast, loadSchedules, scopeOptions],
);
const handleEdit = useCallback((schedule: ScheduledTask) => {
setEditingSchedule(schedule);
setView("edit");
}, []);
const handleUpdate = useCallback(
async (input: ScheduledTaskCreateInput) => {
if (!editingSchedule) return;
try {
await updateAutomation(editingSchedule.id, input, scopeOptions);
addToast("Schedule updated", "success");
setView("list");
setEditingSchedule(undefined);
await loadSchedules();
} catch (err: any) {
addToast(err.message || "Failed to update schedule", "error");
}
},
[editingSchedule, addToast, loadSchedules, scopeOptions],
);
const handleDelete = useCallback(
async (schedule: ScheduledTask) => {
try {
await deleteAutomation(schedule.id, scopeOptions);
addToast(`Deleted "${schedule.name}"`, "success");
await loadSchedules();
} catch (err: any) {
addToast(err.message || "Failed to delete schedule", "error");
}
},
[addToast, loadSchedules, scopeOptions],
);
const handleRun = useCallback(
async (schedule: ScheduledTask) => {
setRunningId(schedule.id);
try {
const { result } = await runAutomation(schedule.id, scopeOptions);
if (result.success) {
addToast(`"${schedule.name}" completed successfully`, "success");
} else {
addToast(`"${schedule.name}" failed: ${result.error || "Unknown error"}`, "error");
}
await loadSchedules();
} catch (err: any) {
addToast(err.message || "Failed to run schedule", "error");
} finally {
setRunningId(null);
}
},
[addToast, loadSchedules, scopeOptions],
);
const handleToggle = useCallback(
async (schedule: ScheduledTask) => {
try {
await toggleAutomation(schedule.id, scopeOptions);
addToast(
`"${schedule.name}" ${schedule.enabled ? "disabled" : "enabled"}`,
"success",
);
await loadSchedules();
} catch (err: any) {
addToast(err.message || "Failed to toggle schedule", "error");
}
},
[addToast, loadSchedules, scopeOptions],
);
const handleFormCancel = useCallback(() => {
setView("list");
setEditingSchedule(undefined);
}, []);
// ── Routine CRUD handlers ───────────────────────────────────────────────
const handleCreateRoutine = useCallback(
@@ -319,86 +177,17 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
setEditingRoutine(undefined);
}, []);
// ── Tab switch handlers ─────────────────────────────────────────────────
const handleTabSwitch = useCallback((tab: ActiveTab) => {
setActiveTab(tab);
setView("list");
setEditingSchedule(undefined);
setRoutineView("list");
setEditingRoutine(undefined);
}, []);
// ── Scope switch handler ───────────────────────────────────────────────
const handleScopeSwitch = useCallback((scope: SchedulingScope) => {
setActiveScope(scope);
// Reset to list view when switching scope
setView("list");
setEditingSchedule(undefined);
setRoutineView("list");
setEditingRoutine(undefined);
}, []);
// ── Render content ─────────────────────────────────────────────────────
const renderSchedulesContent = () => {
if (view === "create") {
return <ScheduleForm onSubmit={handleCreate} onCancel={handleFormCancel} scope={activeScope} projectId={projectId} onScopeChange={handleScopeSwitch} />;
}
if (view === "edit" && editingSchedule) {
return (
<ScheduleForm
schedule={editingSchedule}
onSubmit={handleUpdate}
onCancel={handleFormCancel}
scope={activeScope}
projectId={projectId}
onScopeChange={handleScopeSwitch}
/>
);
}
// List view
if (loading) {
return <div className="settings-empty-state settings-loading">Loading schedules</div>;
}
if (schedules.length === 0) {
return (
<div className="schedule-empty-state">
<Clock size={48} strokeWidth={1} />
<h4>No scheduled tasks yet</h4>
<p>Create a schedule to automate recurring tasks.</p>
<button
className="btn btn-primary btn-sm"
onClick={() => setView("create")}
>
<Plus size={14} />
Create your first schedule
</button>
</div>
);
}
return (
<div className="schedule-list">
{schedules.map((s) => (
<ScheduleCard
key={s.id}
schedule={s}
onEdit={handleEdit}
onDelete={handleDelete}
onRun={handleRun}
onToggle={handleToggle}
running={runningId === s.id}
/>
))}
</div>
);
};
const renderRoutinesContent = () => {
if (routineView === "create") {
return <RoutineEditor onSubmit={handleCreateRoutine} onCancel={handleRoutineCancel} scope={activeScope} projectId={projectId} />;
@@ -421,14 +210,14 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
return (
<div className="routine-empty-state">
<Zap size={48} strokeWidth={1} />
<h4>No routines yet</h4>
<p>Create a routine to assign recurring tasks to agents.</p>
<h4>No automations yet</h4>
<p>Create an automation with a schedule, webhook, API, or manual trigger.</p>
<button
className="btn btn-primary btn-sm"
onClick={() => setRoutineView("create")}
>
<Plus size={14} />
Create your first routine
Create your first automation
</button>
</div>
);
@@ -452,23 +241,17 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
};
const renderContent = () => {
if (activeTab === "schedules") {
return renderSchedulesContent();
}
return renderRoutinesContent();
};
// Determine if we're in "list" view for showing the "New" button
const isShowingList =
activeTab === "schedules" ? view === "list" && schedules.length > 0 : routineView === "list" && routines.length > 0;
const isShowingEmptyState =
activeTab === "schedules" ? view === "list" && schedules.length === 0 && !loading : routineView === "list" && routines.length === 0;
routineView === "list" && routines.length > 0;
return (
<div className="modal-overlay open" onClick={handleOverlayClick}>
<div className="modal modal-lg" role="dialog" aria-modal="true" aria-labelledby="schedules-modal-title">
<div className="modal-header">
<h3 id="schedules-modal-title">Scheduled Tasks</h3>
<h3 id="schedules-modal-title">Automations</h3>
<div className="modal-header-actions">
{/* Scope selector */}
<div className="scheduling-scope-selector" role="group" aria-label="Scheduling scope">
@@ -477,7 +260,7 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
className={`scope-btn${activeScope === "global" ? " active" : ""}`}
onClick={() => handleScopeSwitch("global")}
aria-pressed={activeScope === "global"}
title="Global (user-level) schedules"
title="Global (user-level) automations"
>
<Globe size={14} />
Global
@@ -487,7 +270,7 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
className={`scope-btn${activeScope === "project" ? " active" : ""}`}
onClick={() => handleScopeSwitch("project")}
aria-pressed={activeScope === "project"}
title="Project-scoped schedules"
title="Project-scoped automations"
>
<Folder size={14} />
Project
@@ -496,17 +279,11 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
{isShowingList && (
<button
className="btn btn-primary btn-sm"
onClick={() => {
if (activeTab === "schedules") {
setView("create");
} else {
setRoutineView("create");
}
}}
aria-label={activeTab === "schedules" ? "Create new schedule" : "Create new routine"}
onClick={() => setRoutineView("create")}
aria-label="Create new automation"
>
<Plus size={14} />
{activeTab === "schedules" ? "New Schedule" : "New Routine"}
New Automation
</button>
)}
<button className="modal-close" onClick={onClose} aria-label="Close">
@@ -515,25 +292,21 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
</div>
</div>
{/* Tab navigation */}
<div className="scheduling-summary" aria-live="polite">
<Zap size={14} />
<span>{routines.length} automation{routines.length === 1 ? "" : "s"}</span>
</div>
<div className="detail-tabs" role="tablist">
<button
className={`detail-tab${activeTab === "schedules" ? " detail-tab-active" : ""}`}
role="tab"
id="tab-schedules"
aria-selected={activeTab === "schedules"}
aria-controls="scheduled-tasks-content"
onClick={() => handleTabSwitch("schedules")}
>
<Clock size={14} /> Schedules
</button>
<button
className={`detail-tab${activeTab === "routines" ? " detail-tab-active" : ""}`}
className="detail-tab detail-tab-active"
role="tab"
id="tab-routines"
aria-selected={activeTab === "routines"}
aria-selected="true"
aria-controls="scheduled-tasks-content"
onClick={() => handleTabSwitch("routines")}
onClick={() => {
setRoutineView("list");
setEditingRoutine(undefined);
}}
>
<Zap size={14} /> Routines
</button>

View File

@@ -130,6 +130,7 @@ beforeEach(() => {
});
afterEach(() => {
vi.useRealTimers();
// Clean up localStorage
localStorage.removeItem("kb-onboarding-state");
});
@@ -1486,20 +1487,22 @@ describe("ModelOnboardingModal", () => {
});
// Click Login
vi.useFakeTimers();
fireEvent.click(screen.getByText("Login"));
await act(async () => {
await Promise.resolve();
await vi.advanceTimersByTimeAsync(2000);
});
// Wait for the login to complete (poll detects authenticated on 2nd call)
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Login successful", "success");
}, { timeout: 3000 });
expect(addToast).toHaveBeenCalledWith("Login successful", "success");
// Check that login outcome was persisted
await waitFor(() => {
const saveCall = mockSaveOnboardingState.mock.calls.find(
(call) => call[1]?.stepData?.["ai-setup"]?.loginOutcomes?.anthropic === "success"
);
expect(saveCall).toBeDefined();
}, { timeout: 3000 });
const saveCall = mockSaveOnboardingState.mock.calls.find(
(call) => call[1]?.stepData?.["ai-setup"]?.loginOutcomes?.anthropic === "success"
);
expect(saveCall).toBeDefined();
});
it("login outcome persisted to stepData after successful login", async () => {
@@ -1532,15 +1535,19 @@ describe("ModelOnboardingModal", () => {
});
// Click Login
vi.useFakeTimers();
fireEvent.click(screen.getByText("Login"));
await act(async () => {
await Promise.resolve();
await vi.advanceTimersByTimeAsync(2000);
});
// Wait for saveOnboardingState to be called with success outcome
await waitFor(() => {
const successCall = mockSaveOnboardingState.mock.calls.find(
(call) => call[1]?.stepData?.["ai-setup"]?.loginOutcomes?.anthropic === "success"
);
expect(successCall).toBeDefined();
}, { timeout: 3000 });
const successCall = mockSaveOnboardingState.mock.calls.find(
(call) => call[1]?.stepData?.["ai-setup"]?.loginOutcomes?.anthropic === "success"
);
expect(successCall).toBeDefined();
});
it("stale pending outcomes are filtered on mount", async () => {

View File

@@ -22,6 +22,7 @@ function makeRoutine(overrides: Partial<Routine> = {}): Routine {
name: "Test Routine",
description: "A test routine",
trigger: { type: "cron", cronExpression: "0 * * * *" },
command: "echo test",
executionPolicy: "parallel",
catchUpPolicy: "skip",
enabled: true,
@@ -37,6 +38,10 @@ describe("RoutineEditor", () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
const onCancel = vi.fn();
const fillCommand = (value = "echo test") => {
fireEvent.change(screen.getByLabelText("Command"), { target: { value } });
};
beforeEach(() => {
vi.clearAllMocks();
});
@@ -286,6 +291,7 @@ describe("RoutineEditor", () => {
it("calls onSubmit with correct RoutineCreateInput shape on valid create", async () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "New Routine" } });
fillCommand();
fireEvent.click(screen.getByText("Create Routine"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
@@ -304,6 +310,7 @@ describe("RoutineEditor", () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Weekly Routine" } });
fireEvent.change(screen.getByLabelText("Frequency"), { target: { value: "weekly" } });
fillCommand();
fireEvent.click(screen.getByText("Create Routine"));
await waitFor(() => {
@@ -335,6 +342,7 @@ describe("RoutineEditor", () => {
const slowSubmit = vi.fn().mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
render(<RoutineEditor onSubmit={slowSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "New Routine" } });
fillCommand();
// Use role and name to find the submit button specifically
const submitButton = screen.getByRole("button", { name: "Create Routine" });
@@ -349,6 +357,7 @@ describe("RoutineEditor", () => {
it("re-enables submit button after submission completes", async () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "New Routine" } });
fillCommand();
fireEvent.click(screen.getByText("Create Routine"));
await waitFor(() => {
@@ -364,6 +373,7 @@ describe("RoutineEditor", () => {
fireEvent.click(screen.getByText("Webhook"));
fireEvent.change(screen.getByLabelText("Webhook Path"), { target: { value: "/trigger/my-hook" } });
fireEvent.change(screen.getByLabelText("Webhook Secret (optional)"), { target: { value: "my-secret" } });
fillCommand();
fireEvent.click(screen.getByText("Create Routine"));
await waitFor(() => {
@@ -384,6 +394,7 @@ describe("RoutineEditor", () => {
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "API Routine" } });
fireEvent.click(screen.getByText("API"));
fireEvent.change(screen.getByLabelText("API Endpoint"), { target: { value: "/api/my-routine" } });
fillCommand();
fireEvent.click(screen.getByText("Create Routine"));
await waitFor(() => {
@@ -402,6 +413,7 @@ describe("RoutineEditor", () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Manual Routine" } });
fireEvent.click(screen.getByText("Manual"));
fillCommand();
fireEvent.click(screen.getByText("Create Routine"));
await waitFor(() => {

View File

@@ -1,32 +1,30 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { ScheduledTasksModal } from "../ScheduledTasksModal";
import type { ScheduledTask, AutomationRunResult, Routine } from "@fusion/core";
import type { Routine } from "@fusion/core";
// Mock lucide-react
vi.mock("lucide-react", () => ({
Plus: () => <span data-testid="icon-plus">+</span>,
Clock: (props: any) => <span data-testid="icon-clock" style={props.strokeWidth ? {} : {}}>🕐</span>,
Play: () => <span data-testid="icon-play"></span>,
Pause: () => <span data-testid="icon-pause"></span>,
Pencil: () => <span data-testid="icon-pencil"></span>,
Trash2: () => <span data-testid="icon-trash">🗑</span>,
CheckCircle: () => <span data-testid="icon-check"></span>,
XCircle: () => <span data-testid="icon-x"></span>,
ChevronDown: () => <span data-testid="icon-down"></span>,
ChevronUp: () => <span data-testid="icon-up"></span>,
Calendar: () => <span data-testid="icon-calendar">📅</span>,
Webhook: () => <span data-testid="icon-webhook">🔗</span>,
Code: () => <span data-testid="icon-code">💻</span>,
Zap: () => <span data-testid="icon-zap"></span>,
Globe: () => <span data-testid="icon-globe">🌍</span>,
Folder: () => <span data-testid="icon-folder">📁</span>,
Clock: () => <span data-testid="icon-clock">Clock</span>,
Play: () => <span data-testid="icon-play">Play</span>,
Pause: () => <span data-testid="icon-pause">Pause</span>,
Pencil: () => <span data-testid="icon-pencil">Edit</span>,
Trash2: () => <span data-testid="icon-trash">Delete</span>,
CheckCircle: () => <span data-testid="icon-check">Success</span>,
XCircle: () => <span data-testid="icon-x">Failure</span>,
ChevronDown: () => <span data-testid="icon-down">Down</span>,
ChevronUp: () => <span data-testid="icon-up">Up</span>,
Calendar: () => <span data-testid="icon-calendar">Calendar</span>,
Webhook: () => <span data-testid="icon-webhook">Webhook</span>,
Code: () => <span data-testid="icon-code">Code</span>,
Zap: () => <span data-testid="icon-zap">Zap</span>,
Globe: () => <span data-testid="icon-globe">Global</span>,
Folder: () => <span data-testid="icon-folder">Project</span>,
Layers: () => <span data-testid="icon-layers">Layers</span>,
}));
// Mock @fusion/core (no runtime values needed — ScheduleForm inlines presets)
vi.mock("@fusion/core", () => ({}));
// Mock the API module
const mockFetchAutomations = vi.fn();
const mockCreateAutomation = vi.fn();
const mockUpdateAutomation = vi.fn();
@@ -60,7 +58,6 @@ vi.mock("../../api", () => ({
}),
}));
// Mock CustomModelDropdown
vi.mock("../CustomModelDropdown", () => ({
CustomModelDropdown: ({ value, onChange, disabled, models }: any) => (
<select
@@ -79,23 +76,6 @@ vi.mock("../CustomModelDropdown", () => ({
),
}));
function makeSchedule(overrides: Partial<ScheduledTask> = {}): ScheduledTask {
return {
id: "sched-1",
name: "Test Schedule",
description: "A test",
scheduleType: "daily",
cronExpression: "0 0 * * *",
command: "echo hello",
enabled: true,
runCount: 0,
runHistory: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
function makeRoutine(overrides: Partial<Routine> = {}): Routine {
return {
id: "routine-001",
@@ -123,691 +103,214 @@ describe("ScheduledTasksModal", () => {
mockFetchRoutines.mockResolvedValue([]);
});
it("renders modal with title", async () => {
it("renders the unified automations modal", async () => {
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
expect(screen.getByText("Scheduled Tasks")).toBeDefined();
});
it("has role=dialog and aria-labelledby", () => {
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
const dialog = screen.getByRole("dialog");
expect(dialog).toBeDefined();
expect(dialog.getAttribute("aria-labelledby")).toBe("schedules-modal-title");
});
it("shows loading state initially", () => {
mockFetchAutomations.mockReturnValue(new Promise(() => {})); // never resolves
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
expect(screen.getByText("Loading schedules…")).toBeDefined();
});
it("shows empty state when no schedules", async () => {
mockFetchAutomations.mockResolvedValue([]);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
expect(screen.getByText("Automations")).toBeDefined();
expect(screen.getByRole("dialog").getAttribute("aria-labelledby")).toBe("schedules-modal-title");
await waitFor(() => {
expect(screen.getByText("No scheduled tasks yet")).toBeDefined();
expect(screen.getByText("No automations yet")).toBeDefined();
});
expect(screen.getByText("Create your first schedule")).toBeDefined();
expect(screen.getByText("Create your first automation")).toBeDefined();
expect(screen.getByText("Routines")).toBeDefined();
expect(mockFetchAutomations).not.toHaveBeenCalled();
});
it("shows schedule cards when schedules exist", async () => {
mockFetchAutomations.mockResolvedValue([makeSchedule({ name: "My Job" })]);
it("shows routine cards and the new automation button when routines exist", async () => {
mockFetchRoutines.mockResolvedValue([
makeRoutine({ name: "Database Backup", command: "fn backup --create" }),
]);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("My Job")).toBeDefined();
expect(screen.getByText("Database Backup")).toBeDefined();
});
expect(screen.getByText("fn backup --create")).toBeDefined();
expect(screen.getByText("New Automation")).toBeDefined();
});
it("shows New Schedule button when schedules exist", async () => {
mockFetchAutomations.mockResolvedValue([makeSchedule()]);
it("uses routine APIs with global scope by default", async () => {
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("New Schedule")).toBeDefined();
expect(mockFetchRoutines).toHaveBeenCalledWith({ scope: "global" });
});
});
it("calls onClose when close button is clicked", async () => {
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
fireEvent.click(screen.getByLabelText("Close"));
expect(onClose).toHaveBeenCalled();
});
it("uses routine APIs with project scope when projectId is provided", async () => {
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} projectId="proj-456" />);
it("calls onClose when overlay is clicked", async () => {
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
const overlay = screen.getByRole("dialog").parentElement!;
fireEvent.click(overlay);
expect(onClose).toHaveBeenCalled();
});
it("calls onClose on Escape when in list view", async () => {
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("No scheduled tasks yet")).toBeDefined();
});
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalled();
});
describe("Scope behavior", () => {
it("defaults to global scope when no projectId provided", async () => {
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("No scheduled tasks yet")).toBeDefined();
});
// Verify fetchAutomations was called with global scope
expect(mockFetchAutomations).toHaveBeenCalledWith({ scope: "global" });
});
it("defaults to project scope when projectId is provided", async () => {
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} projectId="proj-123" />);
await waitFor(() => {
expect(screen.getByText("No scheduled tasks yet")).toBeDefined();
});
// Verify fetchAutomations was called with project scope and projectId
expect(mockFetchAutomations).toHaveBeenCalledWith({ scope: "project", projectId: "proj-123" });
});
it("forwards projectId to fetchRoutines when projectId is provided", async () => {
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} projectId="proj-456" />);
await waitFor(() => {
expect(screen.getByText("No scheduled tasks yet")).toBeDefined();
});
// Verify fetchRoutines was called with project scope and projectId
expect(mockFetchRoutines).toHaveBeenCalledWith({ scope: "project", projectId: "proj-456" });
});
});
it("can switch from global to project scope and reloads data", async () => {
mockFetchAutomations.mockResolvedValue([makeSchedule({ name: "Test Job" })]);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} projectId="proj-789" />);
// Initial load with project scope
await waitFor(() => {
expect(mockFetchAutomations).toHaveBeenCalledWith({ scope: "project", projectId: "proj-789" });
});
// Clear mocks to track the reload after scope switch
mockFetchAutomations.mockClear();
// Click the global scope button
const globalBtn = screen.getByRole("button", { name: /global/i });
fireEvent.click(globalBtn);
// Should reload with global scope
await waitFor(() => {
expect(mockFetchAutomations).toHaveBeenCalledWith({ scope: "global" });
});
it("reloads routines when switching scope", async () => {
mockFetchRoutines.mockResolvedValue([makeRoutine({ name: "Scoped Routine" })]);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} projectId="proj-789" />);
await waitFor(() => {
expect(mockFetchRoutines).toHaveBeenCalledWith({ scope: "project", projectId: "proj-789" });
});
mockFetchRoutines.mockClear();
it("switches from project to global scope and reloads data", async () => {
mockFetchAutomations.mockResolvedValue([makeSchedule({ name: "Test Job" })]);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
// Initial load with global scope
await waitFor(() => {
expect(mockFetchAutomations).toHaveBeenCalledWith({ scope: "global" });
});
// Clear mocks to track the reload after scope switch
mockFetchAutomations.mockClear();
// Click the project scope button
const projectBtn = screen.getByRole("button", { name: /project/i });
fireEvent.click(projectBtn);
// Should reload with project scope (but no projectId available, so falls back to global)
await waitFor(() => {
expect(mockFetchAutomations).toHaveBeenCalledWith({ scope: "project" });
});
});
fireEvent.click(screen.getByRole("button", { name: /global/i }));
it("resets view to list when switching scope", async () => {
mockFetchAutomations.mockResolvedValue([makeSchedule({ name: "Test Job" })]);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("New Schedule")).toBeDefined();
});
// Open create form
fireEvent.click(screen.getByText("New Schedule"));
await waitFor(() => {
expect(screen.getByText("New Schedule", { selector: "h4" })).toBeDefined();
});
// Clear mocks
mockFetchAutomations.mockClear();
// Switch scope - should reset to list view
const globalBtn = screen.getByRole("button", { name: /global/i });
fireEvent.click(globalBtn);
await waitFor(() => {
// Should be back to list view, not create form
expect(screen.queryByText("New Schedule", { selector: "h4" })).toBeNull();
expect(screen.getByText("Test Job")).toBeDefined();
});
await waitFor(() => {
expect(mockFetchRoutines).toHaveBeenCalledWith({ scope: "global" });
});
});
describe("create flow", () => {
it("shows create form when clicking New Schedule", async () => {
mockFetchAutomations.mockResolvedValue([makeSchedule()]);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("New Schedule")).toBeDefined();
});
fireEvent.click(screen.getByText("New Schedule"));
expect(screen.getByText("New Schedule", { selector: "h4" })).toBeDefined();
expect(screen.getByLabelText("Name")).toBeDefined();
it("opens the routine editor from the empty state", async () => {
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Create your first automation")).toBeDefined();
});
fireEvent.click(screen.getByText("Create your first automation"));
it("shows create form from empty state CTA button", async () => {
mockFetchAutomations.mockResolvedValue([]);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Create your first schedule")).toBeDefined();
});
fireEvent.click(screen.getByText("Create your first schedule"));
expect(screen.getByLabelText("Name")).toBeDefined();
expect(screen.getByText("New Routine", { selector: "h4" })).toBeDefined();
expect(screen.getByLabelText("Name")).toBeDefined();
});
it("creates a command automation and returns to the list", async () => {
const created = makeRoutine({ name: "New Automation", command: "echo test" });
mockFetchRoutines
.mockResolvedValueOnce([])
.mockResolvedValueOnce([created]);
mockCreateRoutine.mockResolvedValue(created);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Create your first automation")).toBeDefined();
});
fireEvent.click(screen.getByText("Create your first automation"));
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "New Automation" } });
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "echo test" } });
fireEvent.click(screen.getByText("Create Routine"));
it("goes back to list on Escape from create form", async () => {
mockFetchAutomations.mockResolvedValue([makeSchedule()]);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("New Schedule")).toBeDefined();
});
fireEvent.click(screen.getByText("New Schedule"));
expect(screen.getByLabelText("Name")).toBeDefined();
fireEvent.keyDown(document, { key: "Escape" });
// Should not close the modal, just go back to list
expect(onClose).not.toHaveBeenCalled();
});
it("creates schedule and returns to list on success", async () => {
const created = makeSchedule({ name: "New Job" });
mockFetchAutomations
.mockResolvedValueOnce([]) // initial load
.mockResolvedValueOnce([created]); // after create
mockCreateAutomation.mockResolvedValue(created);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Create your first schedule")).toBeDefined();
});
fireEvent.click(screen.getByText("Create your first schedule"));
// Fill form
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "New Job" } });
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "echo test" } });
fireEvent.click(screen.getByText("Create Schedule"));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Schedule created", "success");
});
});
it("changing scope in create form updates modal's activeScope and reloads data", async () => {
const schedule = makeSchedule({ name: "Test Job" });
mockFetchAutomations
.mockResolvedValueOnce([schedule]) // initial load with project scope
.mockResolvedValueOnce([]); // reload with global scope after scope switch
mockCreateAutomation.mockResolvedValue(schedule);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} projectId="proj-123" />);
// Initial load with project scope
await waitFor(() => {
expect(mockFetchAutomations).toHaveBeenCalledWith({ scope: "project", projectId: "proj-123" });
expect(screen.getByText("Test Job")).toBeDefined();
});
// Open create form
fireEvent.click(screen.getByText("New Schedule"));
await waitFor(() => {
expect(screen.getByText("New Schedule", { selector: "h4" })).toBeDefined();
});
// Clear mocks to track reload
mockFetchAutomations.mockClear();
// Click Global scope button in the form (note: icon chars in accessible name)
const globalBtn = screen.getByRole("radio", { name: "🌍Global" });
fireEvent.click(globalBtn);
// Modal should reload with global scope
await waitFor(() => {
expect(mockFetchAutomations).toHaveBeenCalledWith({ scope: "global" });
});
// Should be back in list view
await waitFor(() => {
expect(screen.queryByText("New Schedule", { selector: "h4" })).toBeNull();
});
await waitFor(() => {
expect(mockCreateRoutine).toHaveBeenCalledWith(
expect.objectContaining({ name: "New Automation", command: "echo test" }),
{ scope: "global" },
);
expect(addToast).toHaveBeenCalledWith("Routine created", "success");
});
});
describe("toggle", () => {
it("calls toggleAutomation and shows toast", async () => {
const schedule = makeSchedule({ name: "My Job", enabled: true });
mockFetchAutomations.mockResolvedValue([schedule]);
mockToggleAutomation.mockResolvedValue({ ...schedule, enabled: false });
it("edits routines through the unified interface", async () => {
const routine = makeRoutine({ name: "My Routine", command: "echo before" });
const updated = { ...routine, name: "Updated Routine", command: "echo after" };
mockFetchRoutines
.mockResolvedValueOnce([routine])
.mockResolvedValueOnce([updated]);
mockUpdateRoutine.mockResolvedValue(updated);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("My Job")).toBeDefined();
});
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
fireEvent.click(screen.getByLabelText("Disable My Job"));
await waitFor(() => {
expect(mockToggleAutomation).toHaveBeenCalledWith("sched-1", { scope: "global" });
expect(addToast).toHaveBeenCalledWith('"My Job" disabled', "success");
});
await waitFor(() => {
expect(screen.getByText("My Routine")).toBeDefined();
});
fireEvent.click(screen.getByLabelText("Edit My Routine"));
await waitFor(() => {
expect(screen.getByText("Edit Routine", { selector: "h4" })).toBeDefined();
});
it("forwards projectId when projectId is provided", async () => {
const schedule = makeSchedule({ name: "My Job", enabled: true });
mockFetchAutomations.mockResolvedValue([schedule]);
mockToggleAutomation.mockResolvedValue({ ...schedule, enabled: false });
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Updated Routine" } });
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "echo after" } });
fireEvent.click(screen.getByText("Save Changes"));
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} projectId="proj-123" />);
await waitFor(() => {
expect(screen.getByText("My Job")).toBeDefined();
});
fireEvent.click(screen.getByLabelText("Disable My Job"));
await waitFor(() => {
expect(mockToggleAutomation).toHaveBeenCalledWith("sched-1", { scope: "project", projectId: "proj-123" });
});
await waitFor(() => {
expect(mockUpdateRoutine).toHaveBeenCalledWith(
"routine-001",
expect.objectContaining({ name: "Updated Routine", command: "echo after" }),
{ scope: "global" },
);
expect(addToast).toHaveBeenCalledWith("Routine updated", "success");
});
});
describe("delete", () => {
it("calls deleteAutomation after confirm", async () => {
const schedule = makeSchedule({ name: "My Job" });
mockFetchAutomations.mockResolvedValue([schedule]);
mockDeleteAutomation.mockResolvedValue(schedule);
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("My Job")).toBeDefined();
});
fireEvent.click(screen.getByLabelText("Delete My Job"));
await waitFor(() => {
expect(mockDeleteAutomation).toHaveBeenCalledWith("sched-1", { scope: "global" });
expect(addToast).toHaveBeenCalledWith('Deleted "My Job"', "success");
});
confirmSpy.mockRestore();
});
});
describe("manual run", () => {
it("calls runAutomation and shows success toast", async () => {
const schedule = makeSchedule({ name: "My Job" });
mockFetchAutomations.mockResolvedValue([schedule]);
const result: AutomationRunResult = {
success: true,
output: "ok",
startedAt: "2026-01-01T00:00:00.000Z",
completedAt: "2026-01-01T00:00:01.000Z",
};
mockRunAutomation.mockResolvedValue({ schedule, result });
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("My Job")).toBeDefined();
});
fireEvent.click(screen.getByLabelText("Run My Job now"));
await waitFor(() => {
expect(mockRunAutomation).toHaveBeenCalledWith("sched-1", { scope: "global" });
expect(addToast).toHaveBeenCalledWith('"My Job" completed successfully', "success");
});
});
it("shows error toast when run fails", async () => {
const schedule = makeSchedule({ name: "My Job" });
mockFetchAutomations.mockResolvedValue([schedule]);
const result: AutomationRunResult = {
success: false,
output: "",
error: "Command not found",
startedAt: "2026-01-01T00:00:00.000Z",
completedAt: "2026-01-01T00:00:01.000Z",
};
mockRunAutomation.mockResolvedValue({ schedule, result });
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("My Job")).toBeDefined();
});
fireEvent.click(screen.getByLabelText("Run My Job now"));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(
expect.stringContaining("Command not found"),
"error",
);
});
});
});
describe("error handling", () => {
it("shows error toast when loading fails", async () => {
mockFetchAutomations.mockRejectedValue(new Error("Network error"));
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Network error", "error");
});
});
});
// ── Routine Tab Tests ─────────────────────────────────────────────────────
describe("Tab navigation", () => {
it("shows both Schedules and Routines tabs", async () => {
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Schedules")).toBeDefined();
expect(screen.getByText("Routines")).toBeDefined();
});
});
it("defaults to Schedules tab", async () => {
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("No scheduled tasks yet")).toBeDefined();
});
});
it("clicking Routines tab switches to routines view", async () => {
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Schedules")).toBeDefined();
});
fireEvent.click(screen.getByText("Routines"));
await waitFor(() => {
expect(screen.getByText("No routines yet")).toBeDefined();
});
});
it("clicking Schedules tab switches back to schedules view", async () => {
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
// Switch to Routines
fireEvent.click(screen.getByText("Routines"));
await waitFor(() => {
expect(screen.getByText("No routines yet")).toBeDefined();
});
// Switch back to Schedules
fireEvent.click(screen.getByText("Schedules"));
await waitFor(() => {
expect(screen.getByText("No scheduled tasks yet")).toBeDefined();
});
});
it("switching tabs resets sub-views", async () => {
mockFetchAutomations.mockResolvedValue([makeSchedule()]);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("New Schedule")).toBeDefined();
});
// Open create form
fireEvent.click(screen.getByText("New Schedule"));
expect(screen.getByText("New Schedule", { selector: "h4" })).toBeDefined();
// Switch to Routines tab
fireEvent.click(screen.getByText("Routines"));
await waitFor(() => {
expect(screen.getByText("No routines yet")).toBeDefined();
});
// Switch back to Schedules - should be in list view, not create
fireEvent.click(screen.getByText("Schedules"));
await waitFor(() => {
expect(screen.queryByText("New Schedule", { selector: "h4" })).toBeNull();
expect(screen.getByText("New Schedule")).toBeDefined(); // The button
});
});
});
describe("Routines list", () => {
it("shows empty state when no routines exist", async () => {
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
fireEvent.click(screen.getByText("Routines"));
await waitFor(() => {
expect(screen.getByText("No routines yet")).toBeDefined();
expect(screen.getByText("Create your first routine")).toBeDefined();
});
});
it("shows routine cards when routines exist", async () => {
mockFetchRoutines.mockResolvedValue([makeRoutine({ name: "My Routine" })]);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
fireEvent.click(screen.getByText("Routines"));
await waitFor(() => {
expect(screen.getByText("My Routine")).toBeDefined();
});
});
it('shows "New Routine" button when routines exist', async () => {
mockFetchRoutines.mockResolvedValue([makeRoutine()]);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
fireEvent.click(screen.getByText("Routines"));
await waitFor(() => {
expect(screen.getByText("New Routine")).toBeDefined();
});
});
});
describe("Routines create flow", () => {
it('shows RoutineEditor when clicking "New Routine"', async () => {
mockFetchRoutines.mockResolvedValue([makeRoutine()]);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
fireEvent.click(screen.getByText("Routines"));
await waitFor(() => {
expect(screen.getByText("New Routine")).toBeDefined();
});
fireEvent.click(screen.getByText("New Routine"));
await waitFor(() => {
expect(screen.getByText("New Routine", { selector: "h4" })).toBeDefined();
});
});
it("shows RoutineEditor from empty state CTA", async () => {
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
fireEvent.click(screen.getByText("Routines"));
await waitFor(() => {
expect(screen.getByText("Create your first routine")).toBeDefined();
});
fireEvent.click(screen.getByText("Create your first routine"));
await waitFor(() => {
expect(screen.getByText("New Routine", { selector: "h4" })).toBeDefined();
});
});
it("returns to list on Escape from create form", async () => {
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
fireEvent.click(screen.getByText("Routines"));
await waitFor(() => {
expect(screen.getByText("Create your first routine")).toBeDefined();
});
fireEvent.click(screen.getByText("Create your first routine"));
expect(screen.getByText("New Routine", { selector: "h4" })).toBeDefined();
fireEvent.keyDown(document, { key: "Escape" });
// Should not close the modal, just go back to list
expect(onClose).not.toHaveBeenCalled();
});
it("creates routine and returns to list on success", async () => {
const created = makeRoutine({ name: "New Routine" });
mockFetchRoutines
.mockResolvedValueOnce([]) // initial load
.mockResolvedValueOnce([created]); // after create
mockCreateRoutine.mockResolvedValue(created);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
fireEvent.click(screen.getByText("Routines"));
await waitFor(() => {
expect(screen.getByText("Create your first routine")).toBeDefined();
});
fireEvent.click(screen.getByText("Create your first routine"));
// Fill form
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "New Routine" } });
fireEvent.click(screen.getByText("Create Routine"));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Routine created", "success");
});
});
});
describe("Routines edit flow", () => {
it("shows RoutineEditor with pre-filled data when editing", async () => {
const routine = makeRoutine({ name: "My Routine" });
mockFetchRoutines.mockResolvedValue([routine]);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
fireEvent.click(screen.getByText("Routines"));
await waitFor(() => {
expect(screen.getByText("My Routine")).toBeDefined();
});
fireEvent.click(screen.getByLabelText("Edit My Routine"));
await waitFor(() => {
expect(screen.getByText("Edit Routine", { selector: "h4" })).toBeDefined();
expect(screen.getByLabelText("Name")).toHaveValue("My Routine");
});
});
it("updates routine and returns to list on success", async () => {
const routine = makeRoutine({ name: "My Routine" });
const updated = { ...routine, name: "Updated Routine" };
mockFetchRoutines
.mockResolvedValueOnce([routine]) // initial load
.mockResolvedValueOnce([updated]); // after update
mockUpdateRoutine.mockResolvedValue(updated);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
fireEvent.click(screen.getByText("Routines"));
await waitFor(() => {
expect(screen.getByText("My Routine")).toBeDefined();
});
fireEvent.click(screen.getByLabelText("Edit My Routine"));
await waitFor(() => {
expect(screen.getByLabelText("Name")).toHaveValue("My Routine");
});
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Updated Routine" } });
fireEvent.click(screen.getByText("Save Changes"));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Routine updated", "success");
});
});
});
describe("Routines run", () => {
it("calls runRoutine and shows success toast", async () => {
const routine = makeRoutine({ name: "My Routine" });
mockFetchRoutines.mockResolvedValue([routine]);
const result = {
it("runs routines and shows success or failure toasts", async () => {
const routine = makeRoutine({ name: "My Routine" });
mockFetchRoutines.mockResolvedValue([routine]);
mockRunRoutine.mockResolvedValue({
result: {
routineId: routine.id,
success: true,
output: "Done",
startedAt: "2026-04-08T00:00:00.000Z",
completedAt: "2026-04-08T00:01:00.000Z",
};
mockRunRoutine.mockResolvedValue({ result });
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
fireEvent.click(screen.getByText("Routines"));
await waitFor(() => {
expect(screen.getByText("My Routine")).toBeDefined();
});
fireEvent.click(screen.getByLabelText("Run My Routine now"));
await waitFor(() => {
expect(mockRunRoutine).toHaveBeenCalledWith("routine-001", { scope: "global" });
expect(addToast).toHaveBeenCalledWith('"My Routine" completed successfully', "success");
});
},
});
it("shows error toast when run fails", async () => {
const routine = makeRoutine({ name: "My Routine" });
mockFetchRoutines.mockResolvedValue([routine]);
const result = {
routineId: routine.id,
success: false,
error: "Failed",
startedAt: "2026-04-08T00:00:00.000Z",
completedAt: "2026-04-08T00:01:00.000Z",
};
mockRunRoutine.mockResolvedValue({ result });
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
fireEvent.click(screen.getByText("Routines"));
await waitFor(() => {
expect(screen.getByText("My Routine")).toBeDefined();
});
fireEvent.click(screen.getByLabelText("Run My Routine now"));
await waitFor(() => {
expect(screen.getByText("My Routine")).toBeDefined();
});
fireEvent.click(screen.getByLabelText("Run My Routine now"));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(
expect.stringContaining("Failed"),
"error",
);
});
await waitFor(() => {
expect(mockRunRoutine).toHaveBeenCalledWith("routine-001", { scope: "global" });
expect(addToast).toHaveBeenCalledWith('"My Routine" completed successfully', "success");
});
});
describe("Routines delete", () => {
it("calls deleteRoutine after confirm dialog", async () => {
const routine = makeRoutine({ name: "My Routine" });
mockFetchRoutines.mockResolvedValue([routine]);
mockDeleteRoutine.mockResolvedValue(undefined);
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
it("deletes routines after confirmation", async () => {
const routine = makeRoutine({ name: "My Routine" });
mockFetchRoutines.mockResolvedValue([routine]);
mockDeleteRoutine.mockResolvedValue(undefined);
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
fireEvent.click(screen.getByText("Routines"));
await waitFor(() => {
expect(screen.getByText("My Routine")).toBeDefined();
});
fireEvent.click(screen.getByLabelText("Delete My Routine"));
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(mockDeleteRoutine).toHaveBeenCalledWith("routine-001", { scope: "global" });
expect(addToast).toHaveBeenCalledWith('Deleted "My Routine"', "success");
});
await waitFor(() => {
expect(screen.getByText("My Routine")).toBeDefined();
});
fireEvent.click(screen.getByLabelText("Delete My Routine"));
confirmSpy.mockRestore();
await waitFor(() => {
expect(mockDeleteRoutine).toHaveBeenCalledWith("routine-001", { scope: "global" });
expect(addToast).toHaveBeenCalledWith('Deleted "My Routine"', "success");
});
confirmSpy.mockRestore();
});
it("toggles routines through updateRoutine", async () => {
const routine = makeRoutine({ name: "My Routine", enabled: true });
mockFetchRoutines.mockResolvedValue([routine]);
mockUpdateRoutine.mockResolvedValue({ ...routine, enabled: false });
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("My Routine")).toBeDefined();
});
fireEvent.click(screen.getByLabelText("Disable My Routine"));
await waitFor(() => {
expect(mockUpdateRoutine).toHaveBeenCalledWith("routine-001", { enabled: false }, { scope: "global" });
expect(addToast).toHaveBeenCalledWith('"My Routine" disabled', "success");
});
});
describe("Routines toggle", () => {
it("calls updateRoutine with flipped enabled state", async () => {
const routine = makeRoutine({ name: "My Routine", enabled: true });
const updated = { ...routine, enabled: false };
mockFetchRoutines.mockResolvedValue([routine]);
mockUpdateRoutine.mockResolvedValue(updated);
it("backs out of editor on Escape and closes from list on Escape", async () => {
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
fireEvent.click(screen.getByText("Routines"));
await waitFor(() => {
expect(screen.getByText("My Routine")).toBeDefined();
});
fireEvent.click(screen.getByLabelText("Disable My Routine"));
await waitFor(() => {
expect(mockUpdateRoutine).toHaveBeenCalledWith("routine-001", { enabled: false }, { scope: "global" });
expect(addToast).toHaveBeenCalledWith('"My Routine" disabled', "success");
});
await waitFor(() => {
expect(screen.getByText("Create your first automation")).toBeDefined();
});
fireEvent.click(screen.getByText("Create your first automation"));
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByText("No automations yet")).toBeDefined();
});
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { useWorkspaces } from "../useWorkspaces";
import * as api from "../../api";
@@ -16,6 +16,7 @@ describe("useWorkspaces", () => {
afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
});
it("loads project and task workspaces", async () => {
@@ -41,6 +42,7 @@ describe("useWorkspaces", () => {
});
it("polls for workspace updates", async () => {
vi.useFakeTimers();
mockFetchWorkspaces
.mockResolvedValueOnce({ project: "/repo", tasks: [] })
.mockResolvedValueOnce({
@@ -48,17 +50,23 @@ describe("useWorkspaces", () => {
tasks: [{ id: "FN-200", title: "Later", worktree: "/repo/.worktrees/kb-200" }],
});
const { result } = renderHook(() => useWorkspaces());
const { result, unmount } = renderHook(() => useWorkspaces());
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.loading).toBe(false);
expect(result.current.workspaces).toEqual([]);
// Wait for the polling interval (10 seconds) - use real timers
await new Promise((resolve) => setTimeout(resolve, 10000));
await act(async () => {
await vi.advanceTimersByTimeAsync(10000);
});
await waitFor(() => expect(result.current.workspaces).toHaveLength(1));
expect(result.current.workspaces).toHaveLength(1);
expect(mockFetchWorkspaces).toHaveBeenCalledTimes(2);
}, 15000);
unmount();
});
it("surfaces fetch errors", async () => {
mockFetchWorkspaces.mockRejectedValueOnce(new Error("Failed to load workspaces"));

View File

@@ -18,7 +18,7 @@ import * as nodeFs from "node:fs";
import { promisify } from "node:util";
import type { TaskStore, Column, ScheduleType, ActivityEventType, ModelPreset, MessageType, ParticipantType, RoutineTriggerType, ProjectSettings } from "@fusion/core";
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, type Task, type PiExtensionEntry, type PiExtensionSettings, getCurrentRepo, isGhAuthenticated, AutomationStore, validateBackupSchedule, validateBackupRetention, validateBackupDir, syncBackupAutomation, exportSettings, importSettings, validateImportData, MessageStore, RoutineStore, isWebhookTrigger, resolveMemoryBackend, getMemoryBackendCapabilities, listMemoryBackendTypes, readMemory, writeMemory, MemoryBackendError, discoverPiExtensions, updatePiExtensionDisabledIds, getFusionAgentDir, getLegacyPiAgentDir } from "@fusion/core";
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, type Task, type PiExtensionEntry, type PiExtensionSettings, getCurrentRepo, isGhAuthenticated, AutomationStore, validateBackupSchedule, validateBackupRetention, validateBackupDir, syncBackupRoutine, exportSettings, importSettings, validateImportData, MessageStore, RoutineStore, isWebhookTrigger, resolveMemoryBackend, getMemoryBackendCapabilities, listMemoryBackendTypes, readMemory, writeMemory, MemoryBackendError, discoverPiExtensions, updatePiExtensionDisabledIds, getFusionAgentDir, getLegacyPiAgentDir } from "@fusion/core";
import type { ServerOptions } from "./server.js";
import { GitHubClient, parseBadgeUrl } from "./github.js";
import { githubRateLimiter } from "./github-poll.js";
@@ -2409,14 +2409,14 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
const settings = await scopedStore.updateSettings(clientSettings);
// Sync backup automation schedule when backup settings change
const automationStoreForProject = engine?.getAutomationStore() ?? options?.automationStore;
if (automationStoreForProject) {
// Sync backup routine when backup settings change.
const routineStoreForProject = engine?.getRoutineStore() ?? options?.routineStore;
if (routineStoreForProject) {
try {
await syncBackupAutomation(automationStoreForProject, settings);
await syncBackupRoutine(routineStoreForProject, settings);
} catch (err) {
// Log but don't fail the settings update if automation sync fails
console.error("Failed to sync backup automation:", err);
// Log but don't fail the settings update if routine sync fails
console.error("Failed to sync backup routine:", err);
}
}
@@ -9553,7 +9553,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
const routineStore = resolveRoutineStore(req, scope);
try {
const { name, agentId, description, trigger, catchUpPolicy, executionPolicy, enabled } = req.body;
const { name, agentId, description, trigger, command, steps, timeoutMs, catchUpPolicy, executionPolicy, enabled } = req.body;
// Validation
if (!name?.trim()) {
@@ -9577,6 +9577,14 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
throw badRequest(`Invalid cron expression: "${trigger.cronExpression}"`);
}
}
const hasSteps = Array.isArray(steps) && steps.length > 0;
const hasCommand = typeof command === "string" && command.trim().length > 0;
if (hasSteps) {
const stepErr = validateAutomationSteps(steps);
if (stepErr) {
throw badRequest(stepErr);
}
}
if (catchUpPolicy !== undefined) {
const validCatchUpPolicies: Array<"run" | "skip" | "run_one"> = ["run", "skip", "run_one"];
if (!validCatchUpPolicies.includes(catchUpPolicy)) {
@@ -9599,6 +9607,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
agentId: typeof agentId === "string" ? agentId.trim() : "",
description,
trigger,
command: hasCommand ? command : undefined,
steps: hasSteps ? steps : undefined,
timeoutMs,
catchUpPolicy,
executionPolicy,
enabled,
@@ -9655,7 +9666,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
}
const { name, description, trigger, catchUpPolicy, executionPolicy, enabled } = req.body;
const { name, description, trigger, command, steps, timeoutMs, catchUpPolicy, executionPolicy, enabled } = req.body;
// Validate name if provided
if (name !== undefined && !name.trim()) {
@@ -9676,11 +9687,20 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
}
}
if (Array.isArray(steps) && steps.length > 0) {
const stepErr = validateAutomationSteps(steps);
if (stepErr) {
throw badRequest(stepErr);
}
}
const routine = await routineStore.updateRoutine(id, {
name: name !== undefined ? name.trim() : undefined,
description,
trigger,
command: command !== undefined ? command : undefined,
steps: steps !== undefined ? steps : undefined,
timeoutMs,
catchUpPolicy,
executionPolicy,
enabled,
@@ -17021,8 +17041,8 @@ function validateAutomationSteps(steps: unknown[]): string | null {
if (!step.id || typeof step.id !== "string") {
return `Step ${i + 1}: id is required`;
}
if (!step.type || (step.type !== "command" && step.type !== "ai-prompt")) {
return `Step ${i + 1}: type must be "command" or "ai-prompt"`;
if (!step.type || (step.type !== "command" && step.type !== "ai-prompt" && step.type !== "create-task")) {
return `Step ${i + 1}: type must be "command", "ai-prompt", or "create-task"`;
}
if (!step.name || typeof step.name !== "string" || !step.name.trim()) {
return `Step ${i + 1}: name is required`;
@@ -17037,6 +17057,11 @@ function validateAutomationSteps(steps: unknown[]): string | null {
return `Step ${i + 1}: prompt is required for ai-prompt steps`;
}
}
if (step.type === "create-task") {
if (!step.taskDescription || typeof step.taskDescription !== "string" || !step.taskDescription.trim()) {
return `Step ${i + 1}: taskDescription is required for create-task steps`;
}
}
// Validate model fields are both present or both absent
const hasProvider = step.modelProvider && typeof step.modelProvider === "string";
const hasModelId = step.modelId && typeof step.modelId === "string";

View File

@@ -3184,6 +3184,10 @@ describe("usage", () => {
});
describe("withTimeout", () => {
afterEach(() => {
vi.useRealTimers();
});
it("resolves with provider result when fetch completes within timeout", async () => {
const provider: ProviderUsage = {
name: "TestProvider",
@@ -3197,31 +3201,39 @@ describe("usage", () => {
});
it("returns error provider when fetch exceeds timeout", async () => {
vi.useFakeTimers();
const slowPromise = new Promise<ProviderUsage>((resolve) => {
setTimeout(() => resolve({ name: "Slow", icon: "🐌", status: "ok", windows: [] }), 10000);
});
const result = await withTimeout(slowPromise, "Slow", 50); // 50ms timeout
const resultPromise = withTimeout(slowPromise, "Slow", 50);
await vi.advanceTimersByTimeAsync(50);
const result = await resultPromise;
expect(result.status).toBe("error");
expect(result.error).toBe("Timed out after 0s");
expect(result.name).toBe("Slow");
});
it("includes timeout duration in error message for different durations", async () => {
vi.useFakeTimers();
// 100ms => "0s"
const result100 = await withTimeout(
const result100Promise = withTimeout(
new Promise<ProviderUsage>(() => {}),
"Test",
100,
);
await vi.advanceTimersByTimeAsync(100);
const result100 = await result100Promise;
expect(result100.error).toBe("Timed out after 0s");
// 10_000ms is too long to actually wait, but we can verify the format
// by using a 1050ms timeout (rounds to 1s)
const result1s = await withTimeout(
const result1sPromise = withTimeout(
new Promise<ProviderUsage>(() => {}),
"Test",
1050,
);
await vi.advanceTimersByTimeAsync(1050);
const result1s = await result1sPromise;
expect(result1s.error).toBe("Timed out after 1s");
});

View File

@@ -3,7 +3,7 @@ import react from "@vitejs/plugin-react";
import { resolve } from "node:path";
import { availableParallelism } from "node:os";
const defaultMaxWorkers = Math.max(1, Math.min(2, Math.ceil(availableParallelism() / 8)));
const defaultMaxWorkers = Math.max(1, Math.min(4, Math.ceil(availableParallelism() / 4)));
const maxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? String(defaultMaxWorkers), 10);
export default defineConfig({