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:
gsxdsm
2026-03-30 16:27:23 -07:00
parent c492466232
commit 4c38950e5e
27 changed files with 3806 additions and 8 deletions

View File

@@ -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}

View File

@@ -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",
});
}

View File

@@ -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();
});
});
});

View File

@@ -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)}

View 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>
);
}

View 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>
);
}

View 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">
&times;
</button>
</div>
</div>
<div className="schedule-modal-content">
{renderContent()}
</div>
</div>
</div>
);
}

View File

@@ -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();
});
});
});

View File

@@ -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();
});
});
});

View File

@@ -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");
});
});
});
});

View File

@@ -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;
}

View File

@@ -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 });
});
});
});

View File

@@ -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;
}

View File

@@ -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> & {