feat(KB-045): add scheduled tasks automation system
- Add AutomationStore and core automation types for cron-based scheduling - Implement CronRunner engine for executing scheduled automations - Add REST API routes for CRUD operations on automations - Create UI components: ScheduleCard, ScheduleForm, and ScheduledTasksModal - Integrate scheduled tasks into dashboard App.tsx and CLI dashboard command - Add comprehensive tests for store, runner, API, and UI components - Include changeset for the new scheduled tasks feature
This commit is contained in:
@@ -14,6 +14,7 @@ import { GitHubImportModal } from "./components/GitHubImportModal";
|
||||
import { GitManagerModal } from "./components/GitManagerModal";
|
||||
import { UsageIndicator } from "./components/UsageIndicator";
|
||||
import { NewTaskModal } from "./components/NewTaskModal";
|
||||
import { ScheduledTasksModal } from "./components/ScheduledTasksModal";
|
||||
import { useTasks } from "./hooks/useTasks";
|
||||
import { ToastProvider, useToast } from "./hooks/useToast";
|
||||
import { useTheme } from "./hooks/useTheme";
|
||||
@@ -24,6 +25,7 @@ function AppInner() {
|
||||
const [planningInitialPlan, setPlanningInitialPlan] = useState<string | null>(null);
|
||||
const [detailTask, setDetailTask] = useState<TaskDetail | null>(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [schedulesOpen, setSchedulesOpen] = useState(false);
|
||||
const [githubImportOpen, setGitHubImportOpen] = useState(false);
|
||||
const [usageOpen, setUsageOpen] = useState(false);
|
||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||
@@ -129,6 +131,10 @@ function AppInner() {
|
||||
const handleOpenUsage = useCallback(() => setUsageOpen(true), []);
|
||||
const handleCloseUsage = useCallback(() => setUsageOpen(false), []);
|
||||
|
||||
// Schedules modal handlers
|
||||
const handleOpenSchedules = useCallback(() => setSchedulesOpen(true), []);
|
||||
const handleCloseSchedules = useCallback(() => setSchedulesOpen(false), []);
|
||||
|
||||
const handleToggleAutoMerge = useCallback(async () => {
|
||||
const next = !autoMerge;
|
||||
setAutoMerge(next);
|
||||
@@ -184,6 +190,7 @@ function AppInner() {
|
||||
onOpenGitHubImport={() => setGitHubImportOpen(true)}
|
||||
onOpenPlanning={handlePlanningOpen}
|
||||
onOpenUsage={handleOpenUsage}
|
||||
onOpenSchedules={handleOpenSchedules}
|
||||
onToggleTerminal={handleToggleTerminal}
|
||||
globalPaused={globalPaused}
|
||||
enginePaused={enginePaused}
|
||||
@@ -273,6 +280,12 @@ function AppInner() {
|
||||
isOpen={usageOpen}
|
||||
onClose={handleCloseUsage}
|
||||
/>
|
||||
{schedulesOpen && (
|
||||
<ScheduledTasksModal
|
||||
onClose={handleCloseSchedules}
|
||||
addToast={addToast}
|
||||
/>
|
||||
)}
|
||||
<NewTaskModal
|
||||
isOpen={newTaskModalOpen}
|
||||
onClose={handleNewTaskClose}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Task, TaskDetail, TaskAttachment, TaskCreateInput, AgentLogEntry, Column, MergeResult, Settings } from "@kb/core";
|
||||
import type { PlanningQuestion, PlanningSummary, PlanningResponse } from "@kb/core";
|
||||
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult } from "@kb/core";
|
||||
|
||||
async function api<T = unknown>(path: string, opts: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(`/api${path}`, {
|
||||
@@ -838,3 +839,53 @@ export function connectPlanningStream(
|
||||
isConnected: () => !isClosed && eventSource.readyState === EventSource.OPEN,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Automation / Scheduled Tasks ──────────────────────────────────
|
||||
|
||||
/** Response from the manual run trigger endpoint. */
|
||||
export interface AutomationRunResponse {
|
||||
schedule: ScheduledTask;
|
||||
result: AutomationRunResult;
|
||||
}
|
||||
|
||||
export function fetchAutomations(): Promise<ScheduledTask[]> {
|
||||
return api<ScheduledTask[]>("/automations");
|
||||
}
|
||||
|
||||
export function fetchAutomation(id: string): Promise<ScheduledTask> {
|
||||
return api<ScheduledTask>(`/automations/${id}`);
|
||||
}
|
||||
|
||||
export function createAutomation(input: ScheduledTaskCreateInput): Promise<ScheduledTask> {
|
||||
const { name, description, scheduleType, cronExpression, command, enabled, timeoutMs } = input;
|
||||
return api<ScheduledTask>("/automations", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, description, scheduleType, cronExpression, command, enabled, timeoutMs }),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAutomation(id: string, updates: ScheduledTaskUpdateInput): Promise<ScheduledTask> {
|
||||
const { name, description, scheduleType, cronExpression, command, enabled, timeoutMs } = updates;
|
||||
return api<ScheduledTask>(`/automations/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ name, description, scheduleType, cronExpression, command, enabled, timeoutMs }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteAutomation(id: string): Promise<void> {
|
||||
await api(`/automations/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export function runAutomation(id: string): Promise<AutomationRunResponse> {
|
||||
return api<AutomationRunResponse>(`/automations/${id}/run`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export function toggleAutomation(id: string): Promise<ScheduledTask> {
|
||||
return api<ScheduledTask>(`/automations/${id}/toggle`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -325,4 +325,42 @@ describe("Header", () => {
|
||||
expect(input).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("schedules button", () => {
|
||||
it("renders schedules button on desktop", () => {
|
||||
renderHeader({ onOpenSchedules: vi.fn() }, false);
|
||||
expect(screen.getByTitle("Scheduled tasks")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not render schedules button inline on mobile", () => {
|
||||
renderHeader({ onOpenSchedules: vi.fn() }, true);
|
||||
expect(screen.queryByTitle("Scheduled tasks")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onOpenSchedules when schedules button is clicked", () => {
|
||||
const onOpenSchedules = vi.fn();
|
||||
renderHeader({ onOpenSchedules }, false);
|
||||
fireEvent.click(screen.getByTitle("Scheduled tasks"));
|
||||
expect(onOpenSchedules).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("has correct data-testid for testing on desktop", () => {
|
||||
renderHeader({ onOpenSchedules: vi.fn() }, false);
|
||||
expect(screen.getByTestId("schedules-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
it("includes scheduled tasks in overflow menu on mobile", () => {
|
||||
renderHeader({ onOpenSchedules: vi.fn() }, true);
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByText("Scheduled Tasks")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onOpenSchedules from mobile overflow menu", () => {
|
||||
const onOpenSchedules = vi.fn();
|
||||
renderHeader({ onOpenSchedules }, true);
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
fireEvent.click(screen.getByTestId("overflow-schedules-btn"));
|
||||
expect(onOpenSchedules).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Settings, Pause, Play, Square, Download, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal } from "lucide-react";
|
||||
import { Settings, Pause, Play, Square, Download, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock } from "lucide-react";
|
||||
|
||||
interface HeaderProps {
|
||||
onOpenSettings?: () => void;
|
||||
onOpenGitHubImport?: () => void;
|
||||
onOpenPlanning?: () => void;
|
||||
onOpenUsage?: () => void;
|
||||
onOpenSchedules?: () => void;
|
||||
onToggleTerminal?: () => void;
|
||||
globalPaused?: boolean;
|
||||
enginePaused?: boolean;
|
||||
@@ -39,6 +40,7 @@ export function Header({
|
||||
onOpenGitHubImport,
|
||||
onOpenPlanning,
|
||||
onOpenUsage,
|
||||
onOpenSchedules,
|
||||
onToggleTerminal,
|
||||
globalPaused,
|
||||
enginePaused,
|
||||
@@ -236,6 +238,18 @@ export function Header({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Schedules button - desktop only (moved to overflow on mobile) */}
|
||||
{!isMobile && (
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onOpenSchedules}
|
||||
title="Scheduled tasks"
|
||||
data-testid="schedules-btn"
|
||||
>
|
||||
<Clock size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Terminal button - desktop only (moved to overflow on mobile) */}
|
||||
{!isMobile && (
|
||||
<button
|
||||
@@ -324,6 +338,15 @@ export function Header({
|
||||
<Lightbulb size={16} />
|
||||
<span>Create a task with AI planning</span>
|
||||
</button>
|
||||
<button
|
||||
className="mobile-overflow-item"
|
||||
onClick={() => handleOverflowAction(onOpenSchedules)}
|
||||
role="menuitem"
|
||||
data-testid="overflow-schedules-btn"
|
||||
>
|
||||
<Clock size={16} />
|
||||
<span>Scheduled Tasks</span>
|
||||
</button>
|
||||
<button
|
||||
className="mobile-overflow-item"
|
||||
onClick={() => handleOverflowAction(onOpenSettings)}
|
||||
|
||||
228
packages/dashboard/app/components/ScheduleCard.tsx
Normal file
228
packages/dashboard/app/components/ScheduleCard.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { Play, Pause, Pencil, Trash2, Clock, CheckCircle, XCircle, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import type { ScheduledTask, AutomationRunResult } from "@kb/core";
|
||||
|
||||
/**
|
||||
* Format a duration in milliseconds to a human-readable string.
|
||||
*/
|
||||
function formatDurationMs(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
if (minutes < 60) return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an ISO timestamp to a relative time string.
|
||||
*/
|
||||
function relativeTime(iso: string): string {
|
||||
const now = Date.now();
|
||||
const then = new Date(iso).getTime();
|
||||
const diffMs = now - then;
|
||||
|
||||
// Future
|
||||
if (diffMs < 0) {
|
||||
const absDiff = Math.abs(diffMs);
|
||||
if (absDiff < 60_000) return "in a moment";
|
||||
if (absDiff < 3_600_000) return `in ${Math.floor(absDiff / 60_000)}m`;
|
||||
if (absDiff < 86_400_000) return `in ${Math.floor(absDiff / 3_600_000)}h`;
|
||||
return `in ${Math.floor(absDiff / 86_400_000)}d`;
|
||||
}
|
||||
|
||||
// Past
|
||||
if (diffMs < 60_000) return "just now";
|
||||
if (diffMs < 3_600_000) return `${Math.floor(diffMs / 60_000)}m ago`;
|
||||
if (diffMs < 86_400_000) return `${Math.floor(diffMs / 3_600_000)}h ago`;
|
||||
return `${Math.floor(diffMs / 86_400_000)}d ago`;
|
||||
}
|
||||
|
||||
const SCHEDULE_TYPE_COLORS: Record<string, string> = {
|
||||
hourly: "var(--color-blue, #3b82f6)",
|
||||
daily: "var(--color-green, #22c55e)",
|
||||
weekly: "var(--color-purple, #a855f7)",
|
||||
monthly: "var(--color-orange, #f97316)",
|
||||
custom: "var(--color-gray, #6b7280)",
|
||||
};
|
||||
|
||||
interface ScheduleCardProps {
|
||||
schedule: ScheduledTask;
|
||||
onEdit: (schedule: ScheduledTask) => void;
|
||||
onDelete: (schedule: ScheduledTask) => void;
|
||||
onRun: (schedule: ScheduledTask) => void;
|
||||
onToggle: (schedule: ScheduledTask) => void;
|
||||
/** Whether a manual run is currently in progress. */
|
||||
running?: boolean;
|
||||
}
|
||||
|
||||
function RunResultBadge({ result }: { result: AutomationRunResult }) {
|
||||
const duration = new Date(result.completedAt).getTime() - new Date(result.startedAt).getTime();
|
||||
return (
|
||||
<span className={`schedule-run-badge ${result.success ? "success" : "failure"}`}>
|
||||
{result.success ? (
|
||||
<CheckCircle size={12} />
|
||||
) : (
|
||||
<XCircle size={12} />
|
||||
)}
|
||||
<span>{result.success ? "Success" : "Failed"}</span>
|
||||
<span className="schedule-run-duration">{formatDurationMs(duration)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RunHistoryItem({ result, index }: { result: AutomationRunResult; index: number }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const duration = new Date(result.completedAt).getTime() - new Date(result.startedAt).getTime();
|
||||
|
||||
return (
|
||||
<div className="schedule-history-item">
|
||||
<button
|
||||
className="schedule-history-header"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
aria-expanded={expanded}
|
||||
aria-label={`Run #${index + 1}: ${result.success ? "succeeded" : "failed"} ${relativeTime(result.startedAt)}`}
|
||||
>
|
||||
<span className={`schedule-history-status ${result.success ? "success" : "failure"}`}>
|
||||
{result.success ? <CheckCircle size={12} /> : <XCircle size={12} />}
|
||||
</span>
|
||||
<span className="schedule-history-time">{relativeTime(result.startedAt)}</span>
|
||||
<span className="schedule-history-duration">{formatDurationMs(duration)}</span>
|
||||
{expanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="schedule-history-detail">
|
||||
{result.output && (
|
||||
<pre className="schedule-history-output">{result.output}</pre>
|
||||
)}
|
||||
{result.error && (
|
||||
<div className="schedule-history-error">{result.error}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScheduleCard({ schedule, onEdit, onDelete, onRun, onToggle, running }: ScheduleCardProps) {
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (window.confirm(`Delete schedule "${schedule.name}"? This cannot be undone.`)) {
|
||||
onDelete(schedule);
|
||||
}
|
||||
}, [schedule, onDelete]);
|
||||
|
||||
const typeColor = SCHEDULE_TYPE_COLORS[schedule.scheduleType] ?? SCHEDULE_TYPE_COLORS.custom;
|
||||
|
||||
return (
|
||||
<div className={`schedule-card${schedule.enabled ? "" : " disabled"}`}>
|
||||
<div className="schedule-card-header">
|
||||
<div className="schedule-card-info">
|
||||
<div className="schedule-card-name-row">
|
||||
<span className="schedule-card-name">{schedule.name}</span>
|
||||
<span
|
||||
className="schedule-type-badge"
|
||||
style={{ borderColor: typeColor, color: typeColor }}
|
||||
>
|
||||
{schedule.scheduleType}
|
||||
</span>
|
||||
</div>
|
||||
{schedule.description && (
|
||||
<p className="schedule-card-description">{schedule.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="schedule-card-actions">
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => onRun(schedule)}
|
||||
disabled={running}
|
||||
title={running ? "Running…" : "Run now"}
|
||||
aria-label={running ? "Running…" : `Run ${schedule.name} now`}
|
||||
>
|
||||
<Play size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => onToggle(schedule)}
|
||||
title={schedule.enabled ? "Disable" : "Enable"}
|
||||
aria-label={schedule.enabled ? `Disable ${schedule.name}` : `Enable ${schedule.name}`}
|
||||
aria-pressed={schedule.enabled}
|
||||
>
|
||||
{schedule.enabled ? <Pause size={14} /> : <Play size={14} />}
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => onEdit(schedule)}
|
||||
title="Edit"
|
||||
aria-label={`Edit ${schedule.name}`}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={handleDelete}
|
||||
title="Delete"
|
||||
aria-label={`Delete ${schedule.name}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="schedule-card-meta">
|
||||
<div className="schedule-meta-item">
|
||||
<Clock size={12} />
|
||||
<code className="schedule-cron">{schedule.cronExpression}</code>
|
||||
</div>
|
||||
{schedule.nextRunAt && schedule.enabled && (
|
||||
<div className="schedule-meta-item">
|
||||
<span className="schedule-meta-label">Next:</span>
|
||||
<span title={schedule.nextRunAt}>{relativeTime(schedule.nextRunAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
{schedule.lastRunAt && (
|
||||
<div className="schedule-meta-item">
|
||||
<span className="schedule-meta-label">Last:</span>
|
||||
<span title={schedule.lastRunAt}>{relativeTime(schedule.lastRunAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
{schedule.lastRunResult && (
|
||||
<RunResultBadge result={schedule.lastRunResult} />
|
||||
)}
|
||||
<div className="schedule-meta-item">
|
||||
<span className="schedule-meta-label">Runs:</span>
|
||||
<span>{schedule.runCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{schedule.runHistory.length > 0 && (
|
||||
<div className="schedule-card-history">
|
||||
<button
|
||||
className="schedule-history-toggle"
|
||||
onClick={() => setShowHistory((h) => !h)}
|
||||
aria-expanded={showHistory}
|
||||
>
|
||||
{showHistory ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
<span>Run History ({schedule.runHistory.length})</span>
|
||||
</button>
|
||||
{showHistory && (
|
||||
<div className="schedule-history-list">
|
||||
{schedule.runHistory.slice(0, 10).map((result, i) => (
|
||||
<RunHistoryItem key={`${result.startedAt}-${i}`} result={result} index={i} />
|
||||
))}
|
||||
{schedule.runHistory.length > 10 && (
|
||||
<div className="schedule-history-more">
|
||||
…and {schedule.runHistory.length - 10} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
249
packages/dashboard/app/components/ScheduleForm.tsx
Normal file
249
packages/dashboard/app/components/ScheduleForm.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduleType } from "@kb/core";
|
||||
|
||||
/** Mapping from preset schedule types to their cron expressions. Mirrored from @kb/core. */
|
||||
const PRESET_CRON: Record<Exclude<ScheduleType, "custom">, string> = {
|
||||
hourly: "0 * * * *",
|
||||
daily: "0 0 * * *",
|
||||
weekly: "0 0 * * 1",
|
||||
monthly: "0 0 1 * *",
|
||||
};
|
||||
|
||||
const SCHEDULE_TYPE_LABELS: Record<ScheduleType, string> = {
|
||||
hourly: "Every hour",
|
||||
daily: "Every day (midnight)",
|
||||
weekly: "Every week (Monday)",
|
||||
monthly: "Every month (1st)",
|
||||
custom: "Custom cron expression",
|
||||
};
|
||||
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
|
||||
interface ScheduleFormProps {
|
||||
/** Existing schedule for editing. Omit for create mode. */
|
||||
schedule?: ScheduledTask;
|
||||
/** Called with form data on submit. */
|
||||
onSubmit: (input: ScheduledTaskCreateInput) => Promise<void>;
|
||||
/** Called when the user cancels. */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ScheduleForm({ schedule, onSubmit, onCancel }: ScheduleFormProps) {
|
||||
const isEditing = !!schedule;
|
||||
|
||||
const [name, setName] = useState(schedule?.name ?? "");
|
||||
const [description, setDescription] = useState(schedule?.description ?? "");
|
||||
const [scheduleType, setScheduleType] = useState<ScheduleType>(schedule?.scheduleType ?? "daily");
|
||||
const [cronExpression, setCronExpression] = useState(schedule?.cronExpression ?? "");
|
||||
const [command, setCommand] = useState(schedule?.command ?? "");
|
||||
const [enabled, setEnabled] = useState(schedule?.enabled ?? true);
|
||||
const [timeoutMs, setTimeoutMs] = useState<number>(schedule?.timeoutMs ?? 300000);
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Auto-fill cron expression when preset is selected
|
||||
useEffect(() => {
|
||||
if (scheduleType !== "custom") {
|
||||
setCronExpression(PRESET_CRON[scheduleType]);
|
||||
}
|
||||
}, [scheduleType]);
|
||||
|
||||
const validate = useCallback((): boolean => {
|
||||
const e: Record<string, string> = {};
|
||||
if (!name.trim()) e.name = "Name is required";
|
||||
if (!command.trim()) e.command = "Command is required";
|
||||
if (scheduleType === "custom") {
|
||||
if (!cronExpression.trim()) {
|
||||
e.cronExpression = "Cron expression is required for custom schedules";
|
||||
} else if (!isLikelyCron(cronExpression)) {
|
||||
e.cronExpression = "Invalid cron format — expected 5 fields (e.g. '0 */6 * * *')";
|
||||
}
|
||||
}
|
||||
if (timeoutMs < 1000) {
|
||||
e.timeoutMs = "Timeout must be at least 1 second (1000ms)";
|
||||
}
|
||||
setErrors(e);
|
||||
return Object.keys(e).length === 0;
|
||||
}, [name, command, scheduleType, cronExpression, timeoutMs]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit({
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
scheduleType,
|
||||
cronExpression: scheduleType === "custom" ? cronExpression.trim() : undefined,
|
||||
command: command.trim(),
|
||||
enabled,
|
||||
timeoutMs,
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
[validate, onSubmit, name, description, scheduleType, cronExpression, command, enabled, timeoutMs],
|
||||
);
|
||||
|
||||
const cronFieldId = "schedule-cron";
|
||||
const cronErrorId = "schedule-cron-error";
|
||||
const nameErrorId = "schedule-name-error";
|
||||
const commandErrorId = "schedule-command-error";
|
||||
const timeoutErrorId = "schedule-timeout-error";
|
||||
|
||||
return (
|
||||
<form className="schedule-form" onSubmit={handleSubmit} noValidate>
|
||||
<h4 className="settings-section-heading">
|
||||
{isEditing ? "Edit Schedule" : "New Schedule"}
|
||||
</h4>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="schedule-name">Name</label>
|
||||
<input
|
||||
id="schedule-name"
|
||||
type="text"
|
||||
placeholder="e.g. Update dependencies"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
aria-invalid={!!errors.name}
|
||||
aria-describedby={errors.name ? nameErrorId : undefined}
|
||||
/>
|
||||
{errors.name && (
|
||||
<small id={nameErrorId} className="field-error">{errors.name}</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="schedule-description">Description (optional)</label>
|
||||
<textarea
|
||||
id="schedule-description"
|
||||
placeholder="What does this schedule do?"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="schedule-type">Schedule</label>
|
||||
<select
|
||||
id="schedule-type"
|
||||
value={scheduleType}
|
||||
onChange={(e) => setScheduleType(e.target.value as ScheduleType)}
|
||||
>
|
||||
{Object.entries(SCHEDULE_TYPE_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor={cronFieldId}>
|
||||
Cron Expression
|
||||
</label>
|
||||
<input
|
||||
id={cronFieldId}
|
||||
type="text"
|
||||
placeholder="* * * * *"
|
||||
value={cronExpression}
|
||||
onChange={(e) => setCronExpression(e.target.value)}
|
||||
disabled={scheduleType !== "custom"}
|
||||
aria-invalid={!!errors.cronExpression}
|
||||
aria-describedby={errors.cronExpression ? cronErrorId : undefined}
|
||||
/>
|
||||
{errors.cronExpression ? (
|
||||
<small id={cronErrorId} className="field-error">{errors.cronExpression}</small>
|
||||
) : (
|
||||
<small>
|
||||
{scheduleType === "custom" ? (
|
||||
<>min hour day month weekday — <a href="https://crontab.guru" target="_blank" rel="noopener noreferrer">crontab.guru</a></>
|
||||
) : (
|
||||
`Auto-filled from preset: ${cronExpression}`
|
||||
)}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="schedule-command">Command</label>
|
||||
<input
|
||||
id="schedule-command"
|
||||
type="text"
|
||||
placeholder="e.g. npm run update-deps"
|
||||
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. Runs with your user permissions.</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="schedule-timeout">Timeout (ms)</label>
|
||||
<input
|
||||
id="schedule-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 (default 300000 = 5 min)</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="schedule-enabled" className="checkbox-label">
|
||||
<input
|
||||
id="schedule-enabled"
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
<small>When disabled, the schedule will not run automatically</small>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? "Saving…" : isEditing ? "Save Changes" : "Create Schedule"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
251
packages/dashboard/app/components/ScheduledTasksModal.tsx
Normal file
251
packages/dashboard/app/components/ScheduledTasksModal.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Plus, Clock } from "lucide-react";
|
||||
import type { ScheduledTask, ScheduledTaskCreateInput } from "@kb/core";
|
||||
import {
|
||||
fetchAutomations,
|
||||
createAutomation,
|
||||
updateAutomation,
|
||||
deleteAutomation,
|
||||
runAutomation,
|
||||
toggleAutomation,
|
||||
} from "../api";
|
||||
import { ScheduleForm } from "./ScheduleForm";
|
||||
import { ScheduleCard } from "./ScheduleCard";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
/** Polling interval for auto-refreshing the schedule list (30 seconds). */
|
||||
const POLL_INTERVAL_MS = 30_000;
|
||||
|
||||
interface ScheduledTasksModalProps {
|
||||
onClose: () => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
type ModalView = "list" | "create" | "edit";
|
||||
|
||||
export function ScheduledTasksModal({ onClose, addToast }: ScheduledTasksModalProps) {
|
||||
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);
|
||||
|
||||
// Load schedules
|
||||
const loadSchedules = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchAutomations();
|
||||
setSchedules(data);
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to load schedules", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [addToast]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSchedules();
|
||||
}, [loadSchedules]);
|
||||
|
||||
// Poll for updates while modal is open
|
||||
useEffect(() => {
|
||||
const interval = setInterval(loadSchedules, POLL_INTERVAL_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadSchedules]);
|
||||
|
||||
// Close on Escape (only when not in a sub-form)
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
if (view !== "list") {
|
||||
setView("list");
|
||||
setEditingSchedule(undefined);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKey);
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}, [onClose, view]);
|
||||
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
// CRUD handlers
|
||||
const handleCreate = useCallback(
|
||||
async (input: ScheduledTaskCreateInput) => {
|
||||
try {
|
||||
await createAutomation(input);
|
||||
addToast("Schedule created", "success");
|
||||
setView("list");
|
||||
await loadSchedules();
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to create schedule", "error");
|
||||
}
|
||||
},
|
||||
[addToast, loadSchedules],
|
||||
);
|
||||
|
||||
const handleEdit = useCallback((schedule: ScheduledTask) => {
|
||||
setEditingSchedule(schedule);
|
||||
setView("edit");
|
||||
}, []);
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
async (input: ScheduledTaskCreateInput) => {
|
||||
if (!editingSchedule) return;
|
||||
try {
|
||||
await updateAutomation(editingSchedule.id, input);
|
||||
addToast("Schedule updated", "success");
|
||||
setView("list");
|
||||
setEditingSchedule(undefined);
|
||||
await loadSchedules();
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to update schedule", "error");
|
||||
}
|
||||
},
|
||||
[editingSchedule, addToast, loadSchedules],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (schedule: ScheduledTask) => {
|
||||
try {
|
||||
await deleteAutomation(schedule.id);
|
||||
addToast(`Deleted "${schedule.name}"`, "success");
|
||||
await loadSchedules();
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to delete schedule", "error");
|
||||
}
|
||||
},
|
||||
[addToast, loadSchedules],
|
||||
);
|
||||
|
||||
const handleRun = useCallback(
|
||||
async (schedule: ScheduledTask) => {
|
||||
setRunningId(schedule.id);
|
||||
try {
|
||||
const { result } = await runAutomation(schedule.id);
|
||||
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],
|
||||
);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
async (schedule: ScheduledTask) => {
|
||||
try {
|
||||
await toggleAutomation(schedule.id);
|
||||
addToast(
|
||||
`"${schedule.name}" ${schedule.enabled ? "disabled" : "enabled"}`,
|
||||
"success",
|
||||
);
|
||||
await loadSchedules();
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to toggle schedule", "error");
|
||||
}
|
||||
},
|
||||
[addToast, loadSchedules],
|
||||
);
|
||||
|
||||
const handleFormCancel = useCallback(() => {
|
||||
setView("list");
|
||||
setEditingSchedule(undefined);
|
||||
}, []);
|
||||
|
||||
const renderContent = () => {
|
||||
if (view === "create") {
|
||||
return <ScheduleForm onSubmit={handleCreate} onCancel={handleFormCancel} />;
|
||||
}
|
||||
|
||||
if (view === "edit" && editingSchedule) {
|
||||
return (
|
||||
<ScheduleForm
|
||||
schedule={editingSchedule}
|
||||
onSubmit={handleUpdate}
|
||||
onCancel={handleFormCancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 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>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={handleOverlayClick}>
|
||||
<div className="modal modal-lg" role="dialog" aria-labelledby="schedules-modal-title">
|
||||
<div className="modal-header">
|
||||
<h3 id="schedules-modal-title">Scheduled Tasks</h3>
|
||||
<div className="modal-header-actions">
|
||||
{view === "list" && schedules.length > 0 && (
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => setView("create")}
|
||||
aria-label="Create new schedule"
|
||||
>
|
||||
<Plus size={14} />
|
||||
New Schedule
|
||||
</button>
|
||||
)}
|
||||
<button className="modal-close" onClick={onClose} aria-label="Close">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="schedule-modal-content">
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { ScheduleCard } from "../ScheduleCard";
|
||||
import type { ScheduledTask, AutomationRunResult } from "@kb/core";
|
||||
|
||||
// Mock lucide-react
|
||||
vi.mock("lucide-react", () => ({
|
||||
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>,
|
||||
Clock: () => <span data-testid="icon-clock">🕐</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>,
|
||||
}));
|
||||
|
||||
function makeResult(overrides: Partial<AutomationRunResult> = {}): AutomationRunResult {
|
||||
return {
|
||||
success: true,
|
||||
output: "hello world",
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
completedAt: "2026-01-01T00:00:05.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSchedule(overrides: Partial<ScheduledTask> = {}): ScheduledTask {
|
||||
return {
|
||||
id: "sched-1",
|
||||
name: "Update Dependencies",
|
||||
description: "Run npm update weekly",
|
||||
scheduleType: "weekly",
|
||||
cronExpression: "0 0 * * 1",
|
||||
command: "npm update",
|
||||
enabled: true,
|
||||
runCount: 5,
|
||||
runHistory: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ScheduleCard", () => {
|
||||
const onEdit = vi.fn();
|
||||
const onDelete = vi.fn();
|
||||
const onRun = vi.fn();
|
||||
const onToggle = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("displays schedule name", () => {
|
||||
render(
|
||||
<ScheduleCard schedule={makeSchedule()} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
|
||||
);
|
||||
expect(screen.getByText("Update Dependencies")).toBeDefined();
|
||||
});
|
||||
|
||||
it("displays schedule description", () => {
|
||||
render(
|
||||
<ScheduleCard schedule={makeSchedule()} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
|
||||
);
|
||||
expect(screen.getByText("Run npm update weekly")).toBeDefined();
|
||||
});
|
||||
|
||||
it("displays schedule type badge", () => {
|
||||
render(
|
||||
<ScheduleCard schedule={makeSchedule()} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
|
||||
);
|
||||
expect(screen.getByText("weekly")).toBeDefined();
|
||||
});
|
||||
|
||||
it("displays cron expression", () => {
|
||||
render(
|
||||
<ScheduleCard schedule={makeSchedule()} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
|
||||
);
|
||||
expect(screen.getByText("0 0 * * 1")).toBeDefined();
|
||||
});
|
||||
|
||||
it("displays run count", () => {
|
||||
render(
|
||||
<ScheduleCard schedule={makeSchedule({ runCount: 42 })} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
|
||||
);
|
||||
expect(screen.getByText("42")).toBeDefined();
|
||||
});
|
||||
|
||||
it("applies disabled class when schedule is disabled", () => {
|
||||
const { container } = render(
|
||||
<ScheduleCard schedule={makeSchedule({ enabled: false })} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
|
||||
);
|
||||
expect(container.querySelector(".schedule-card.disabled")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("does not apply disabled class when schedule is enabled", () => {
|
||||
const { container } = render(
|
||||
<ScheduleCard schedule={makeSchedule({ enabled: true })} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
|
||||
);
|
||||
expect(container.querySelector(".schedule-card.disabled")).toBeNull();
|
||||
});
|
||||
|
||||
describe("last run result", () => {
|
||||
it("shows success badge for successful last run", () => {
|
||||
const schedule = makeSchedule({ lastRunResult: makeResult({ success: true }) });
|
||||
render(
|
||||
<ScheduleCard schedule={schedule} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
|
||||
);
|
||||
expect(screen.getByText("Success")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows failure badge for failed last run", () => {
|
||||
const schedule = makeSchedule({ lastRunResult: makeResult({ success: false }) });
|
||||
render(
|
||||
<ScheduleCard schedule={schedule} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
|
||||
);
|
||||
expect(screen.getByText("Failed")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("action buttons", () => {
|
||||
it("calls onRun when run button is clicked", () => {
|
||||
const schedule = makeSchedule();
|
||||
render(
|
||||
<ScheduleCard schedule={schedule} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText(`Run ${schedule.name} now`));
|
||||
expect(onRun).toHaveBeenCalledWith(schedule);
|
||||
});
|
||||
|
||||
it("disables run button when running", () => {
|
||||
const schedule = makeSchedule();
|
||||
render(
|
||||
<ScheduleCard schedule={schedule} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} running={true} />
|
||||
);
|
||||
const btn = screen.getByLabelText("Running…");
|
||||
expect(btn.hasAttribute("disabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("calls onToggle when toggle button is clicked", () => {
|
||||
const schedule = makeSchedule();
|
||||
render(
|
||||
<ScheduleCard schedule={schedule} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText(`Disable ${schedule.name}`));
|
||||
expect(onToggle).toHaveBeenCalledWith(schedule);
|
||||
});
|
||||
|
||||
it("calls onEdit when edit button is clicked", () => {
|
||||
const schedule = makeSchedule();
|
||||
render(
|
||||
<ScheduleCard schedule={schedule} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText(`Edit ${schedule.name}`));
|
||||
expect(onEdit).toHaveBeenCalledWith(schedule);
|
||||
});
|
||||
|
||||
it("calls onDelete after confirm when delete button is clicked", () => {
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
const schedule = makeSchedule();
|
||||
render(
|
||||
<ScheduleCard schedule={schedule} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText(`Delete ${schedule.name}`));
|
||||
expect(confirmSpy).toHaveBeenCalledWith(expect.stringContaining("Update Dependencies"));
|
||||
expect(onDelete).toHaveBeenCalledWith(schedule);
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("does not call onDelete when confirm is cancelled", () => {
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
const schedule = makeSchedule();
|
||||
render(
|
||||
<ScheduleCard schedule={schedule} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText(`Delete ${schedule.name}`));
|
||||
expect(onDelete).not.toHaveBeenCalled();
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("run history", () => {
|
||||
it("does not show history toggle when no history", () => {
|
||||
render(
|
||||
<ScheduleCard schedule={makeSchedule({ runHistory: [] })} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
|
||||
);
|
||||
expect(screen.queryByText(/Run History/)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows history toggle when history exists", () => {
|
||||
const history = [makeResult()];
|
||||
render(
|
||||
<ScheduleCard schedule={makeSchedule({ runHistory: history })} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
|
||||
);
|
||||
expect(screen.getByText("Run History (1)")).toBeDefined();
|
||||
});
|
||||
|
||||
it("expands history on toggle click", () => {
|
||||
const history = [makeResult({ output: "test output" })];
|
||||
render(
|
||||
<ScheduleCard schedule={makeSchedule({ runHistory: history })} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
|
||||
);
|
||||
fireEvent.click(screen.getByText("Run History (1)"));
|
||||
// History items should now be visible
|
||||
expect(screen.getByText(/just now|ago/)).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { ScheduleForm } from "../ScheduleForm";
|
||||
import type { ScheduledTask } from "@kb/core";
|
||||
|
||||
// Mock @kb/core to provide type-only exports (no runtime values needed)
|
||||
vi.mock("@kb/core", () => ({}));
|
||||
|
||||
function makeSchedule(overrides: Partial<ScheduledTask> = {}): ScheduledTask {
|
||||
return {
|
||||
id: "test-id",
|
||||
name: "Test Schedule",
|
||||
description: "A test schedule",
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ScheduleForm", () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
const onCancel = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("create mode", () => {
|
||||
it("renders with empty fields for a new schedule", () => {
|
||||
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
expect(screen.getByText("New Schedule")).toBeDefined();
|
||||
expect(screen.getByLabelText("Name")).toHaveProperty("value", "");
|
||||
expect(screen.getByLabelText("Command")).toHaveProperty("value", "");
|
||||
});
|
||||
|
||||
it("shows 'Create Schedule' submit button text", () => {
|
||||
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
expect(screen.getByText("Create Schedule")).toBeDefined();
|
||||
});
|
||||
|
||||
it("defaults schedule type to daily", () => {
|
||||
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
const select = screen.getByLabelText("Schedule") as HTMLSelectElement;
|
||||
expect(select.value).toBe("daily");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edit mode", () => {
|
||||
it("populates fields from existing schedule", () => {
|
||||
const schedule = makeSchedule({ name: "My Job", command: "npm test" });
|
||||
render(<ScheduleForm schedule={schedule} onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
expect(screen.getByText("Edit Schedule")).toBeDefined();
|
||||
expect(screen.getByLabelText("Name")).toHaveProperty("value", "My Job");
|
||||
expect(screen.getByLabelText("Command")).toHaveProperty("value", "npm test");
|
||||
});
|
||||
|
||||
it("shows 'Save Changes' submit button text", () => {
|
||||
const schedule = makeSchedule();
|
||||
render(<ScheduleForm schedule={schedule} onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
expect(screen.getByText("Save Changes")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validation", () => {
|
||||
it("shows error when name is empty on submit", async () => {
|
||||
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "echo hi" } });
|
||||
fireEvent.click(screen.getByText("Create Schedule"));
|
||||
expect(screen.getByText("Name is required")).toBeDefined();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows error when command is empty on submit", async () => {
|
||||
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Job" } });
|
||||
fireEvent.click(screen.getByText("Create Schedule"));
|
||||
expect(screen.getByText("Command is required")).toBeDefined();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows error for invalid cron expression with custom type", async () => {
|
||||
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Job" } });
|
||||
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "echo hi" } });
|
||||
fireEvent.change(screen.getByLabelText("Schedule"), { target: { value: "custom" } });
|
||||
fireEvent.change(screen.getByLabelText("Cron Expression"), { target: { value: "invalid" } });
|
||||
fireEvent.click(screen.getByText("Create Schedule"));
|
||||
expect(screen.getByText(/Invalid cron format/)).toBeDefined();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows error for empty cron expression with custom type", async () => {
|
||||
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Job" } });
|
||||
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "echo hi" } });
|
||||
fireEvent.change(screen.getByLabelText("Schedule"), { target: { value: "custom" } });
|
||||
// Clear the cron field
|
||||
fireEvent.change(screen.getByLabelText("Cron Expression"), { target: { value: "" } });
|
||||
fireEvent.click(screen.getByText("Create Schedule"));
|
||||
expect(screen.getByText("Cron expression is required for custom schedules")).toBeDefined();
|
||||
});
|
||||
|
||||
it("sets aria-invalid on fields with errors", () => {
|
||||
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
fireEvent.click(screen.getByText("Create Schedule"));
|
||||
expect(screen.getByLabelText("Name").getAttribute("aria-invalid")).toBe("true");
|
||||
expect(screen.getByLabelText("Command").getAttribute("aria-invalid")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cron expression auto-fill", () => {
|
||||
it("auto-fills cron expression for preset types", () => {
|
||||
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
const cronField = screen.getByLabelText("Cron Expression") as HTMLInputElement;
|
||||
// Default is daily
|
||||
expect(cronField.value).toBe("0 0 * * *");
|
||||
expect(cronField.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("enables cron field when custom type is selected", () => {
|
||||
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
fireEvent.change(screen.getByLabelText("Schedule"), { target: { value: "custom" } });
|
||||
const cronField = screen.getByLabelText("Cron Expression") as HTMLInputElement;
|
||||
expect(cronField.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("updates cron expression when changing preset type", () => {
|
||||
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
fireEvent.change(screen.getByLabelText("Schedule"), { target: { value: "hourly" } });
|
||||
const cronField = screen.getByLabelText("Cron Expression") as HTMLInputElement;
|
||||
expect(cronField.value).toBe("0 * * * *");
|
||||
});
|
||||
});
|
||||
|
||||
describe("submission", () => {
|
||||
it("calls onSubmit with correct data for valid form", async () => {
|
||||
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "My Job" } });
|
||||
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "echo hello" } });
|
||||
fireEvent.click(screen.getByText("Create Schedule"));
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "My Job",
|
||||
command: "echo hello",
|
||||
scheduleType: "daily",
|
||||
enabled: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("includes cronExpression only for custom type", async () => {
|
||||
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Job" } });
|
||||
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "cmd" } });
|
||||
fireEvent.click(screen.getByText("Create Schedule"));
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cronExpression: undefined }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("includes cronExpression for custom type", async () => {
|
||||
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Job" } });
|
||||
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "cmd" } });
|
||||
fireEvent.change(screen.getByLabelText("Schedule"), { target: { value: "custom" } });
|
||||
fireEvent.change(screen.getByLabelText("Cron Expression"), { target: { value: "0 */6 * * *" } });
|
||||
fireEvent.click(screen.getByText("Create Schedule"));
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cronExpression: "0 */6 * * *", scheduleType: "custom" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancel", () => {
|
||||
it("calls onCancel when Cancel button is clicked", () => {
|
||||
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
fireEvent.click(screen.getByText("Cancel"));
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
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 } from "@kb/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>,
|
||||
}));
|
||||
|
||||
// Mock @kb/core (no runtime values needed — ScheduleForm inlines presets)
|
||||
vi.mock("@kb/core", () => ({}));
|
||||
|
||||
// Mock the API module
|
||||
const mockFetchAutomations = vi.fn();
|
||||
const mockCreateAutomation = vi.fn();
|
||||
const mockUpdateAutomation = vi.fn();
|
||||
const mockDeleteAutomation = vi.fn();
|
||||
const mockRunAutomation = vi.fn();
|
||||
const mockToggleAutomation = vi.fn();
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchAutomations: (...args: any[]) => mockFetchAutomations(...args),
|
||||
createAutomation: (...args: any[]) => mockCreateAutomation(...args),
|
||||
updateAutomation: (...args: any[]) => mockUpdateAutomation(...args),
|
||||
deleteAutomation: (...args: any[]) => mockDeleteAutomation(...args),
|
||||
runAutomation: (...args: any[]) => mockRunAutomation(...args),
|
||||
toggleAutomation: (...args: any[]) => mockToggleAutomation(...args),
|
||||
}));
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ScheduledTasksModal", () => {
|
||||
const onClose = vi.fn();
|
||||
const addToast = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchAutomations.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("renders modal with title", 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} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No scheduled tasks yet")).toBeDefined();
|
||||
});
|
||||
expect(screen.getByText("Create your first schedule")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows schedule cards when schedules exist", async () => {
|
||||
mockFetchAutomations.mockResolvedValue([makeSchedule({ name: "My Job" })]);
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("My Job")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows New Schedule button when schedules exist", async () => {
|
||||
mockFetchAutomations.mockResolvedValue([makeSchedule()]);
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New Schedule")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onClose when close button is clicked", async () => {
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
fireEvent.click(screen.getByLabelText("Close"));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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("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("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();
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("My Job")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Disable My Job"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToggleAutomation).toHaveBeenCalledWith("sched-1");
|
||||
expect(addToast).toHaveBeenCalledWith('"My Job" disabled', "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");
|
||||
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");
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7711,3 +7711,274 @@ html .column.drag-over * {
|
||||
color: var(--text-muted);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ── Scheduled Tasks ──────────────────────────────────────────────── */
|
||||
|
||||
.schedule-modal-content {
|
||||
padding: 16px 20px;
|
||||
overflow-y: auto;
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
.schedule-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 48px 24px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.schedule-empty-state h4 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.schedule-empty-state p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.schedule-empty-state .btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.schedule-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.schedule-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
background: var(--card-bg);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.schedule-card:hover {
|
||||
border-color: var(--border-hover, var(--border));
|
||||
}
|
||||
|
||||
.schedule-card.disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.schedule-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.schedule-card-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.schedule-card-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.schedule-card-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.schedule-type-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.schedule-card-description {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.schedule-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.schedule-card-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.schedule-meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.schedule-meta-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.schedule-cron {
|
||||
font-size: 11px;
|
||||
padding: 1px 5px;
|
||||
background: var(--bg-secondary, rgba(128, 128, 128, 0.1));
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.schedule-run-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.schedule-run-badge.success {
|
||||
color: var(--color-green, #22c55e);
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
}
|
||||
|
||||
.schedule-run-badge.failure {
|
||||
color: var(--color-red, #ef4444);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.schedule-run-duration {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Run History */
|
||||
.schedule-card-history {
|
||||
margin-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.schedule-history-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 2px 0;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.schedule-history-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.schedule-history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.schedule-history-item {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.schedule-history-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 4px 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.schedule-history-header:hover {
|
||||
background: var(--bg-secondary, rgba(128, 128, 128, 0.08));
|
||||
}
|
||||
|
||||
.schedule-history-status.success {
|
||||
color: var(--color-green, #22c55e);
|
||||
}
|
||||
|
||||
.schedule-history-status.failure {
|
||||
color: var(--color-red, #ef4444);
|
||||
}
|
||||
|
||||
.schedule-history-time {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.schedule-history-duration {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.schedule-history-detail {
|
||||
padding: 4px 6px 8px 26px;
|
||||
}
|
||||
|
||||
.schedule-history-output {
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
background: var(--bg-secondary, rgba(128, 128, 128, 0.08));
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.schedule-history-error {
|
||||
font-size: 11px;
|
||||
color: var(--color-red, #ef4444);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.schedule-history-more {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Schedule form within modal */
|
||||
.schedule-form {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.schedule-form .modal-actions {
|
||||
padding: 16px 0 0;
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@@ -3817,3 +3817,216 @@ describe("Terminal WebSocket close handler", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Automation Routes ─────────────────────────────────────────────
|
||||
|
||||
describe("Automation routes", () => {
|
||||
const FAKE_SCHEDULE = {
|
||||
id: "sched-001",
|
||||
name: "Test Schedule",
|
||||
description: "A test schedule",
|
||||
scheduleType: "hourly",
|
||||
cronExpression: "0 * * * *",
|
||||
command: "echo hello",
|
||||
enabled: true,
|
||||
runCount: 0,
|
||||
runHistory: [],
|
||||
nextRunAt: "2026-04-01T00:00:00.000Z",
|
||||
createdAt: "2026-03-30T00:00:00.000Z",
|
||||
updatedAt: "2026-03-30T00:00:00.000Z",
|
||||
};
|
||||
|
||||
function createMockAutomationStore() {
|
||||
return {
|
||||
listSchedules: vi.fn().mockResolvedValue([FAKE_SCHEDULE]),
|
||||
createSchedule: vi.fn().mockResolvedValue(FAKE_SCHEDULE),
|
||||
getSchedule: vi.fn().mockResolvedValue(FAKE_SCHEDULE),
|
||||
updateSchedule: vi.fn().mockResolvedValue(FAKE_SCHEDULE),
|
||||
deleteSchedule: vi.fn().mockResolvedValue(FAKE_SCHEDULE),
|
||||
recordRun: vi.fn().mockResolvedValue(FAKE_SCHEDULE),
|
||||
};
|
||||
}
|
||||
|
||||
function buildApp(automationStoreOverride?: ReturnType<typeof createMockAutomationStore>) {
|
||||
const store = createMockStore();
|
||||
const automationStore = automationStoreOverride ?? createMockAutomationStore();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store, { automationStore: automationStore as any }));
|
||||
return { app, automationStore };
|
||||
}
|
||||
|
||||
describe("GET /automations", () => {
|
||||
it("returns all schedules", async () => {
|
||||
const { app, automationStore } = buildApp();
|
||||
const res = await GET(app, "/api/automations");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(1);
|
||||
expect(automationStore.listSchedules).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns empty array when no automationStore provided", async () => {
|
||||
const store = createMockStore();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
const res = await GET(app, "/api/automations");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /automations", () => {
|
||||
it("creates a schedule", async () => {
|
||||
const { app, automationStore } = buildApp();
|
||||
const res = await REQUEST(app, "POST", "/api/automations", JSON.stringify({
|
||||
name: "Test",
|
||||
command: "echo test",
|
||||
scheduleType: "hourly",
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(201);
|
||||
expect(automationStore.createSchedule).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns 400 for missing name", async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await REQUEST(app, "POST", "/api/automations", JSON.stringify({
|
||||
command: "echo test",
|
||||
scheduleType: "hourly",
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Name is required");
|
||||
});
|
||||
|
||||
it("returns 400 for missing command", async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await REQUEST(app, "POST", "/api/automations", JSON.stringify({
|
||||
name: "Test",
|
||||
scheduleType: "hourly",
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Command is required");
|
||||
});
|
||||
|
||||
it("returns 400 for invalid schedule type", async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await REQUEST(app, "POST", "/api/automations", JSON.stringify({
|
||||
name: "Test",
|
||||
command: "echo test",
|
||||
scheduleType: "invalid",
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Invalid schedule type");
|
||||
});
|
||||
|
||||
it("returns 400 for custom type with missing cron", async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await REQUEST(app, "POST", "/api/automations", JSON.stringify({
|
||||
name: "Test",
|
||||
command: "echo test",
|
||||
scheduleType: "custom",
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Cron expression is required");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /automations/:id", () => {
|
||||
it("returns a schedule by id", async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await GET(app, "/api/automations/sched-001");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.id).toBe("sched-001");
|
||||
});
|
||||
|
||||
it("returns 404 for missing schedule", async () => {
|
||||
const mockStore = createMockAutomationStore();
|
||||
mockStore.getSchedule.mockRejectedValue(Object.assign(new Error("not found"), { code: "ENOENT" }));
|
||||
const { app } = buildApp(mockStore);
|
||||
const res = await GET(app, "/api/automations/missing");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /automations/:id", () => {
|
||||
it("updates a schedule", async () => {
|
||||
const { app, automationStore } = buildApp();
|
||||
const res = await REQUEST(app, "PATCH", "/api/automations/sched-001", JSON.stringify({
|
||||
name: "Updated",
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(automationStore.updateSchedule).toHaveBeenCalledWith("sched-001", expect.objectContaining({ name: "Updated" }));
|
||||
});
|
||||
|
||||
it("returns 404 for missing schedule", async () => {
|
||||
const mockStore = createMockAutomationStore();
|
||||
mockStore.updateSchedule.mockRejectedValue(Object.assign(new Error("not found"), { code: "ENOENT" }));
|
||||
const { app } = buildApp(mockStore);
|
||||
const res = await REQUEST(app, "PATCH", "/api/automations/missing", JSON.stringify({
|
||||
name: "Updated",
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /automations/:id", () => {
|
||||
it("deletes a schedule", async () => {
|
||||
const { app, automationStore } = buildApp();
|
||||
const res = await REQUEST(app, "DELETE", "/api/automations/sched-001");
|
||||
expect(res.status).toBe(200);
|
||||
expect(automationStore.deleteSchedule).toHaveBeenCalledWith("sched-001");
|
||||
});
|
||||
|
||||
it("returns 404 for missing schedule", async () => {
|
||||
const mockStore = createMockAutomationStore();
|
||||
mockStore.deleteSchedule.mockRejectedValue(Object.assign(new Error("not found"), { code: "ENOENT" }));
|
||||
const { app } = buildApp(mockStore);
|
||||
const res = await REQUEST(app, "DELETE", "/api/automations/missing");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /automations/:id/run", () => {
|
||||
it("runs a schedule and records the result", async () => {
|
||||
const mockStore = createMockAutomationStore();
|
||||
mockStore.getSchedule.mockResolvedValue({
|
||||
...FAKE_SCHEDULE,
|
||||
command: "echo manual-run",
|
||||
});
|
||||
const { app } = buildApp(mockStore);
|
||||
const res = await REQUEST(app, "POST", "/api/automations/sched-001/run");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.result).toBeDefined();
|
||||
expect(res.body.result.startedAt).toBeTruthy();
|
||||
expect(res.body.result.completedAt).toBeTruthy();
|
||||
expect(mockStore.recordRun).toHaveBeenCalledWith(
|
||||
"sched-001",
|
||||
expect.objectContaining({
|
||||
success: expect.any(Boolean),
|
||||
startedAt: expect.any(String),
|
||||
completedAt: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 404 for missing schedule", async () => {
|
||||
const mockStore = createMockAutomationStore();
|
||||
mockStore.getSchedule.mockRejectedValue(Object.assign(new Error("not found"), { code: "ENOENT" }));
|
||||
const { app } = buildApp(mockStore);
|
||||
const res = await REQUEST(app, "POST", "/api/automations/missing/run");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /automations/:id/toggle", () => {
|
||||
it("toggles enabled state", async () => {
|
||||
const mockStore = createMockAutomationStore();
|
||||
mockStore.getSchedule.mockResolvedValue({ ...FAKE_SCHEDULE, enabled: true });
|
||||
mockStore.updateSchedule.mockResolvedValue({ ...FAKE_SCHEDULE, enabled: false });
|
||||
const { app } = buildApp(mockStore);
|
||||
const res = await REQUEST(app, "POST", "/api/automations/sched-001/toggle");
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockStore.updateSchedule).toHaveBeenCalledWith("sched-001", { enabled: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,8 @@ import { Router, type Request, type Response, type NextFunction } from "express"
|
||||
import multer from "multer";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
import type { TaskStore, Column, MergeResult } from "@kb/core";
|
||||
import { COLUMNS, VALID_TRANSITIONS, type PrInfo, isGhAuthenticated } from "@kb/core";
|
||||
import type { TaskStore, Column, MergeResult, ScheduleType } from "@kb/core";
|
||||
import { COLUMNS, VALID_TRANSITIONS, type PrInfo, isGhAuthenticated, AUTOMATION_PRESETS, AutomationStore } from "@kb/core";
|
||||
import type { ServerOptions } from "./server.js";
|
||||
import { GitHubClient, getCurrentGitHubRepo, parseBadgeUrl } from "./github.js";
|
||||
import { githubRateLimiter } from "./github-poll.js";
|
||||
@@ -2755,6 +2755,232 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
// ── Automation / Scheduled Task Routes ────────────────────────────
|
||||
|
||||
const automationStore = options?.automationStore;
|
||||
|
||||
// GET /automations — list all scheduled tasks
|
||||
router.get("/automations", async (_req: Request, res: Response) => {
|
||||
if (!automationStore) {
|
||||
return res.json([]);
|
||||
}
|
||||
try {
|
||||
const schedules = await automationStore.listSchedules();
|
||||
res.json(schedules);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /automations — create a new schedule
|
||||
router.post("/automations", async (req: Request, res: Response) => {
|
||||
if (!automationStore) {
|
||||
return res.status(503).json({ error: "Automation store not available" });
|
||||
}
|
||||
try {
|
||||
const { name, description, scheduleType, cronExpression, command, enabled, timeoutMs } = req.body;
|
||||
|
||||
// Validation
|
||||
if (!name?.trim()) {
|
||||
return res.status(400).json({ error: "Name is required" });
|
||||
}
|
||||
if (!command?.trim()) {
|
||||
return res.status(400).json({ error: "Command is required" });
|
||||
}
|
||||
const validTypes = ["hourly", "daily", "weekly", "monthly", "custom"];
|
||||
if (!scheduleType || !validTypes.includes(scheduleType)) {
|
||||
return res.status(400).json({ error: `Invalid schedule type. Must be one of: ${validTypes.join(", ")}` });
|
||||
}
|
||||
if (scheduleType === "custom") {
|
||||
if (!cronExpression?.trim()) {
|
||||
return res.status(400).json({ error: "Cron expression is required for custom schedule type" });
|
||||
}
|
||||
if (!AutomationStore.isValidCron(cronExpression)) {
|
||||
return res.status(400).json({ error: `Invalid cron expression: "${cronExpression}"` });
|
||||
}
|
||||
}
|
||||
|
||||
const schedule = await automationStore.createSchedule({
|
||||
name,
|
||||
description,
|
||||
scheduleType: scheduleType as ScheduleType,
|
||||
cronExpression,
|
||||
command,
|
||||
enabled,
|
||||
timeoutMs,
|
||||
});
|
||||
res.status(201).json(schedule);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /automations/:id — get a single schedule
|
||||
router.get("/automations/:id", async (req, res) => {
|
||||
if (!automationStore) {
|
||||
return res.status(503).json({ error: "Automation store not available" });
|
||||
}
|
||||
try {
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const schedule = await automationStore.getSchedule(id);
|
||||
res.json(schedule);
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
return res.status(404).json({ error: "Schedule not found" });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /automations/:id — update a schedule
|
||||
router.patch("/automations/:id", async (req, res) => {
|
||||
if (!automationStore) {
|
||||
return res.status(503).json({ error: "Automation store not available" });
|
||||
}
|
||||
try {
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const { name, description, scheduleType, cronExpression, command, enabled, timeoutMs } = req.body;
|
||||
|
||||
// Validate cron if switching to custom
|
||||
if (scheduleType === "custom" && cronExpression) {
|
||||
if (!AutomationStore.isValidCron(cronExpression)) {
|
||||
return res.status(400).json({ error: `Invalid cron expression: "${cronExpression}"` });
|
||||
}
|
||||
}
|
||||
|
||||
const schedule = await automationStore.updateSchedule(id, {
|
||||
name,
|
||||
description,
|
||||
scheduleType,
|
||||
cronExpression,
|
||||
command,
|
||||
enabled,
|
||||
timeoutMs,
|
||||
});
|
||||
res.json(schedule);
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
return res.status(404).json({ error: "Schedule not found" });
|
||||
}
|
||||
if (err.message?.includes("cannot be empty") || err.message?.includes("Invalid cron")) {
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /automations/:id — delete a schedule
|
||||
router.delete("/automations/:id", async (req, res) => {
|
||||
if (!automationStore) {
|
||||
return res.status(503).json({ error: "Automation store not available" });
|
||||
}
|
||||
try {
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const deleted = await automationStore.deleteSchedule(id);
|
||||
res.json(deleted);
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
return res.status(404).json({ error: "Schedule not found" });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /automations/:id/run — trigger a manual run
|
||||
router.post("/automations/:id/run", async (req, res) => {
|
||||
if (!automationStore) {
|
||||
return res.status(503).json({ error: "Automation store not available" });
|
||||
}
|
||||
try {
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const schedule = await automationStore.getSchedule(id);
|
||||
|
||||
// Execute the command directly
|
||||
const { exec } = await import("node:child_process");
|
||||
const { promisify } = await import("node:util");
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const startedAt = new Date().toISOString();
|
||||
let result: import("@kb/core").AutomationRunResult;
|
||||
|
||||
try {
|
||||
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const MAX_BUFFER = 1024 * 1024;
|
||||
const { stdout, stderr } = await execAsync(schedule.command, {
|
||||
timeout: schedule.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
maxBuffer: MAX_BUFFER,
|
||||
shell: "/bin/sh",
|
||||
});
|
||||
|
||||
let output = stdout;
|
||||
if (stderr) {
|
||||
output += stdout ? "\n--- stderr ---\n" : "";
|
||||
output += stderr;
|
||||
}
|
||||
if (output.length > 10240) {
|
||||
output = output.slice(0, 10240) + "\n[output truncated]";
|
||||
}
|
||||
|
||||
result = {
|
||||
success: true,
|
||||
output,
|
||||
startedAt,
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
} catch (err: any) {
|
||||
const stdout = err.stdout ?? "";
|
||||
const stderr = err.stderr ?? "";
|
||||
let output = stdout;
|
||||
if (stderr) {
|
||||
output += stdout ? "\n--- stderr ---\n" : "";
|
||||
output += stderr;
|
||||
}
|
||||
if (output.length > 10240) {
|
||||
output = output.slice(0, 10240) + "\n[output truncated]";
|
||||
}
|
||||
|
||||
result = {
|
||||
success: false,
|
||||
output,
|
||||
error: err.killed
|
||||
? `Command timed out after ${(schedule.timeoutMs ?? 300000) / 1000}s`
|
||||
: err.message ?? String(err),
|
||||
startedAt,
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Record the result
|
||||
const updated = await automationStore.recordRun(schedule.id, result);
|
||||
res.json({ schedule: updated, result });
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
return res.status(404).json({ error: "Schedule not found" });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /automations/:id/toggle — toggle enabled/disabled
|
||||
router.post("/automations/:id/toggle", async (req, res) => {
|
||||
if (!automationStore) {
|
||||
return res.status(503).json({ error: "Automation store not available" });
|
||||
}
|
||||
try {
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const schedule = await automationStore.getSchedule(id);
|
||||
const updated = await automationStore.updateSchedule(id, {
|
||||
enabled: !schedule.enabled,
|
||||
});
|
||||
res.json(updated);
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
return res.status(404).json({ error: "Schedule not found" });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { join, dirname } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { Task, TaskStore, MergeResult } from "@kb/core";
|
||||
import type { Task, TaskStore, MergeResult, AutomationStore } from "@kb/core";
|
||||
import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
|
||||
import { createApiRoutes } from "./routes.js";
|
||||
import { createSSE } from "./sse.js";
|
||||
@@ -31,6 +31,8 @@ export interface ServerOptions {
|
||||
modelRegistry?: ModelRegistryLike;
|
||||
/** Optional BadgePubSub adapter for cross-instance badge snapshot fan-out — if not provided, creates from env or falls back to in-memory */
|
||||
badgePubSub?: BadgePubSub;
|
||||
/** Optional AutomationStore for scheduled task management */
|
||||
automationStore?: AutomationStore;
|
||||
}
|
||||
|
||||
type DashboardExpressApp = ReturnType<typeof express> & {
|
||||
|
||||
Reference in New Issue
Block a user