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

@@ -0,0 +1,5 @@
---
"@dustinbyrne/kb": minor
---
Add scheduled tasks (cron jobs) to the dashboard. Users can create, edit, delete, and manually run automated tasks on recurring schedules. Supports preset intervals (hourly, daily, weekly, monthly) and custom cron expressions. Each schedule tracks run history, last run result, and next run time. A dedicated dashboard modal provides full CRUD operations, and the CronRunner engine executes due schedules automatically.

View File

@@ -28,6 +28,11 @@ function makeMockStore() {
vi.mock("@kb/core", () => ({
TaskStore: vi.fn().mockImplementation(() => makeMockStore()),
AutomationStore: vi.fn().mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
listSchedules: vi.fn().mockResolvedValue([]),
getDueSchedules: vi.fn().mockResolvedValue([]),
})),
}));
// ── Mock @kb/dashboard ─────────────────────────────────────────────
@@ -84,6 +89,10 @@ vi.mock("@kb/engine", async (importOriginal) => {
stop: vi.fn(),
})),
aiMergeTask: vi.fn().mockResolvedValue({ merged: true }),
CronRunner: vi.fn().mockImplementation(() => ({
start: vi.fn(),
stop: vi.fn(),
})),
scanIdleWorktrees: vi.fn().mockResolvedValue([]),
cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0),
};

View File

@@ -37,6 +37,16 @@ function makeMockStore() {
vi.mock("@kb/core", () => ({
TaskStore: vi.fn().mockImplementation(() => makeMockStore()),
AutomationStore: vi.fn().mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
listSchedules: vi.fn().mockResolvedValue([]),
getSchedule: vi.fn().mockResolvedValue(null),
createSchedule: vi.fn().mockResolvedValue({}),
updateSchedule: vi.fn().mockResolvedValue({}),
deleteSchedule: vi.fn().mockResolvedValue({}),
recordRun: vi.fn().mockResolvedValue({}),
getDueSchedules: vi.fn().mockResolvedValue([]),
})),
}));
// ── Hoisted shared mocks ───────────────────────────────────────────
@@ -145,6 +155,10 @@ vi.mock("@kb/engine", async (importOriginal) => {
handleNewComments: vi.fn().mockResolvedValue(undefined),
})),
aiMergeTask: vi.fn().mockImplementation(() => Promise.resolve({ merged: true })),
CronRunner: vi.fn().mockImplementation(() => ({
start: vi.fn(),
stop: vi.fn(),
})),
scanIdleWorktrees: vi.fn().mockResolvedValue([]),
cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0),
};

View File

@@ -1,10 +1,10 @@
import { execSync } from "node:child_process";
import type { AddressInfo } from "node:net";
import { createInterface } from "node:readline";
import { TaskStore } from "@kb/core";
import { TaskStore, AutomationStore } from "@kb/core";
import type { Settings, TaskDetail, PrInfo } from "@kb/core";
import { createServer, GitHubClient } from "@kb/dashboard";
import { TriageProcessor, TaskExecutor, Scheduler, AgentSemaphore, WorktreePool, aiMergeTask, UsageLimitPauser, PRIORITY_MERGE, scanIdleWorktrees, cleanupOrphanedWorktrees, NtfyNotifier, PrMonitor, PrCommentHandler } from "@kb/engine";
import { TriageProcessor, TaskExecutor, Scheduler, AgentSemaphore, WorktreePool, aiMergeTask, UsageLimitPauser, PRIORITY_MERGE, scanIdleWorktrees, cleanupOrphanedWorktrees, NtfyNotifier, PrMonitor, PrCommentHandler, CronRunner } from "@kb/engine";
import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
/**
@@ -203,6 +203,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
await store.init();
await store.watch();
// ── AutomationStore: scheduled task persistence ──────────────────────
const automationStore = new AutomationStore(cwd);
await automationStore.init();
// ── NtfyNotifier: push notifications for task completion and failures ─
const notifier = new NtfyNotifier(store);
notifier.start();
@@ -450,7 +454,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
const modelRegistry = new ModelRegistry(authStorage);
// Start the web server with AI merge, auth, and model registry wired in
const app = createServer(store, { onMerge, authStorage, modelRegistry });
const app = createServer(store, { onMerge, authStorage, modelRegistry, automationStore });
// Start the AI engine (unless in dev mode)
if (!opts.dev) {
@@ -485,6 +489,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
onBlocked: (t, deps) => console.log(`[engine] ${t.id} blocked by ${deps.join(", ")}`),
});
// ── CronRunner: scheduled task execution ──────────────────────────
const cronRunner = new CronRunner(store, automationStore);
cronRunner.start();
triage.start();
scheduler.start();
@@ -589,6 +597,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
process.on("SIGINT", () => {
triage.stop();
scheduler.stop();
cronRunner.stop();
notifier.stop();
if (mergeRetryTimer) clearTimeout(mergeRetryTimer);
store.stopWatching();
@@ -636,6 +645,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
console.log(` AI engine: ✓ active`);
console.log(` • triage: auto-specifying tasks`);
console.log(` • scheduler: dependency-aware execution`);
console.log(` • cron: scheduled task execution`);
}
console.log(` File watcher: ✓ active`);
console.log(` Press Ctrl+C to stop`);

View File

@@ -30,5 +30,8 @@
"typescript": "^5.7.0",
"vitest": "^3.1.0"
},
"private": true
"private": true,
"dependencies": {
"cron-parser": "^5.5.0"
}
}

View File

@@ -0,0 +1,531 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { AutomationStore } from "./automation-store.js";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { mkdtempSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import type { ScheduledTask, AutomationRunResult } from "./automation.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-automation-test-"));
}
describe("AutomationStore", () => {
let rootDir: string;
let store: AutomationStore;
beforeEach(async () => {
rootDir = makeTmpDir();
store = new AutomationStore(rootDir);
await store.init();
});
afterEach(async () => {
await rm(rootDir, { recursive: true, force: true });
});
// ── init ──────────────────────────────────────────────────────────
describe("init", () => {
it("creates the automations directory", async () => {
const dir = join(rootDir, ".kb", "automations");
expect(existsSync(dir)).toBe(true);
});
it("is idempotent", async () => {
await store.init();
await store.init();
const dir = join(rootDir, ".kb", "automations");
expect(existsSync(dir)).toBe(true);
});
});
// ── isValidCron ───────────────────────────────────────────────────
describe("isValidCron", () => {
it("accepts valid cron expressions", () => {
expect(AutomationStore.isValidCron("0 * * * *")).toBe(true);
expect(AutomationStore.isValidCron("*/5 * * * *")).toBe(true);
expect(AutomationStore.isValidCron("0 0 * * 1")).toBe(true);
expect(AutomationStore.isValidCron("0 9 1 * *")).toBe(true);
});
it("rejects invalid cron expressions", () => {
expect(AutomationStore.isValidCron("not a cron")).toBe(false);
expect(AutomationStore.isValidCron("60 * * * *")).toBe(false);
expect(AutomationStore.isValidCron("0 25 * * *")).toBe(false);
});
});
// ── computeNextRun ────────────────────────────────────────────────
describe("computeNextRun", () => {
it("returns a future ISO timestamp", () => {
const fromDate = new Date("2026-01-01T00:00:00Z");
const next = store.computeNextRun("0 * * * *", fromDate);
expect(new Date(next).getTime()).toBeGreaterThan(fromDate.getTime());
});
it("computes correct next run for hourly", () => {
const fromDate = new Date("2026-01-01T12:30:00Z");
const next = store.computeNextRun("0 * * * *", fromDate);
expect(new Date(next).getUTCHours()).toBe(13);
expect(new Date(next).getUTCMinutes()).toBe(0);
});
});
// ── createSchedule ────────────────────────────────────────────────
describe("createSchedule", () => {
it("creates a schedule with preset type", async () => {
const schedule = await store.createSchedule({
name: "Hourly check",
command: "echo hello",
scheduleType: "hourly",
});
expect(schedule.id).toBeTruthy();
expect(schedule.name).toBe("Hourly check");
expect(schedule.command).toBe("echo hello");
expect(schedule.scheduleType).toBe("hourly");
expect(schedule.cronExpression).toBe("0 * * * *");
expect(schedule.enabled).toBe(true);
expect(schedule.runCount).toBe(0);
expect(schedule.runHistory).toEqual([]);
expect(schedule.nextRunAt).toBeTruthy();
expect(schedule.createdAt).toBeTruthy();
expect(schedule.updatedAt).toBeTruthy();
});
it("creates a schedule with custom cron", async () => {
const schedule = await store.createSchedule({
name: "Every 5 min",
command: "ls",
scheduleType: "custom",
cronExpression: "*/5 * * * *",
});
expect(schedule.cronExpression).toBe("*/5 * * * *");
expect(schedule.scheduleType).toBe("custom");
});
it("creates disabled schedule without nextRunAt", async () => {
const schedule = await store.createSchedule({
name: "Disabled",
command: "echo",
scheduleType: "daily",
enabled: false,
});
expect(schedule.enabled).toBe(false);
expect(schedule.nextRunAt).toBeUndefined();
});
it("rejects empty name", async () => {
await expect(
store.createSchedule({ name: "", command: "echo", scheduleType: "hourly" }),
).rejects.toThrow("Name is required");
});
it("rejects empty command", async () => {
await expect(
store.createSchedule({ name: "Test", command: "", scheduleType: "hourly" }),
).rejects.toThrow("Command is required");
});
it("rejects custom type without cron expression", async () => {
await expect(
store.createSchedule({ name: "Test", command: "echo", scheduleType: "custom" }),
).rejects.toThrow("Cron expression is required");
});
it("rejects invalid cron expression", async () => {
await expect(
store.createSchedule({
name: "Test",
command: "echo",
scheduleType: "custom",
cronExpression: "bad cron",
}),
).rejects.toThrow("Invalid cron expression");
});
it("persists schedule to disk", async () => {
const schedule = await store.createSchedule({
name: "Persist test",
command: "echo persist",
scheduleType: "weekly",
});
const filePath = join(rootDir, ".kb", "automations", `${schedule.id}.json`);
expect(existsSync(filePath)).toBe(true);
});
it("emits schedule:created event", async () => {
const listener = vi.fn();
store.on("schedule:created", listener);
const schedule = await store.createSchedule({
name: "Event test",
command: "echo event",
scheduleType: "hourly",
});
expect(listener).toHaveBeenCalledWith(schedule);
});
it("stores optional timeoutMs", async () => {
const schedule = await store.createSchedule({
name: "Timeout test",
command: "echo",
scheduleType: "hourly",
timeoutMs: 60000,
});
expect(schedule.timeoutMs).toBe(60000);
});
});
// ── getSchedule ───────────────────────────────────────────────────
describe("getSchedule", () => {
it("reads a schedule by id", async () => {
const created = await store.createSchedule({
name: "Get test",
command: "echo get",
scheduleType: "daily",
});
const fetched = await store.getSchedule(created.id);
expect(fetched.id).toBe(created.id);
expect(fetched.name).toBe("Get test");
});
it("throws ENOENT for missing schedule", async () => {
await expect(store.getSchedule("nonexistent")).rejects.toThrow("not found");
});
});
// ── listSchedules ─────────────────────────────────────────────────
describe("listSchedules", () => {
it("returns empty array when no schedules", async () => {
const list = await store.listSchedules();
expect(list).toEqual([]);
});
it("returns all schedules sorted by createdAt", async () => {
await store.createSchedule({ name: "A", command: "echo a", scheduleType: "hourly" });
// Ensure different timestamps
await new Promise((r) => setTimeout(r, 5));
await store.createSchedule({ name: "B", command: "echo b", scheduleType: "daily" });
const list = await store.listSchedules();
expect(list).toHaveLength(2);
expect(list[0].name).toBe("A");
expect(list[1].name).toBe("B");
});
});
// ── updateSchedule ────────────────────────────────────────────────
describe("updateSchedule", () => {
it("updates name and command", async () => {
const schedule = await store.createSchedule({
name: "Original",
command: "echo original",
scheduleType: "hourly",
});
// Small delay to ensure different timestamp
await new Promise((r) => setTimeout(r, 5));
const updated = await store.updateSchedule(schedule.id, {
name: "Updated",
command: "echo updated",
});
expect(updated.name).toBe("Updated");
expect(updated.command).toBe("echo updated");
expect(new Date(updated.updatedAt).getTime()).toBeGreaterThanOrEqual(
new Date(schedule.updatedAt).getTime(),
);
});
it("updates schedule type from preset to custom", async () => {
const schedule = await store.createSchedule({
name: "Test",
command: "echo",
scheduleType: "hourly",
});
const updated = await store.updateSchedule(schedule.id, {
scheduleType: "custom",
cronExpression: "*/10 * * * *",
});
expect(updated.scheduleType).toBe("custom");
expect(updated.cronExpression).toBe("*/10 * * * *");
});
it("updates enabled state", async () => {
const schedule = await store.createSchedule({
name: "Toggle",
command: "echo",
scheduleType: "hourly",
});
const disabled = await store.updateSchedule(schedule.id, { enabled: false });
expect(disabled.enabled).toBe(false);
expect(disabled.nextRunAt).toBeUndefined();
const reenabled = await store.updateSchedule(schedule.id, { enabled: true });
expect(reenabled.enabled).toBe(true);
expect(reenabled.nextRunAt).toBeTruthy();
});
it("rejects empty name", async () => {
const schedule = await store.createSchedule({
name: "Test",
command: "echo",
scheduleType: "hourly",
});
await expect(
store.updateSchedule(schedule.id, { name: " " }),
).rejects.toThrow("Name cannot be empty");
});
it("rejects invalid cron on custom type", async () => {
const schedule = await store.createSchedule({
name: "Test",
command: "echo",
scheduleType: "hourly",
});
await expect(
store.updateSchedule(schedule.id, {
scheduleType: "custom",
cronExpression: "bad cron",
}),
).rejects.toThrow("Invalid cron expression");
});
it("emits schedule:updated event", async () => {
const schedule = await store.createSchedule({
name: "Event test",
command: "echo",
scheduleType: "hourly",
});
const listener = vi.fn();
store.on("schedule:updated", listener);
await store.updateSchedule(schedule.id, { name: "Updated" });
expect(listener).toHaveBeenCalledTimes(1);
});
});
// ── deleteSchedule ────────────────────────────────────────────────
describe("deleteSchedule", () => {
it("deletes a schedule", async () => {
const schedule = await store.createSchedule({
name: "Delete me",
command: "echo",
scheduleType: "hourly",
});
const deleted = await store.deleteSchedule(schedule.id);
expect(deleted.id).toBe(schedule.id);
const filePath = join(rootDir, ".kb", "automations", `${schedule.id}.json`);
expect(existsSync(filePath)).toBe(false);
});
it("throws for missing schedule", async () => {
await expect(store.deleteSchedule("nonexistent")).rejects.toThrow("not found");
});
it("emits schedule:deleted event", async () => {
const schedule = await store.createSchedule({
name: "Delete test",
command: "echo",
scheduleType: "hourly",
});
const listener = vi.fn();
store.on("schedule:deleted", listener);
await store.deleteSchedule(schedule.id);
expect(listener).toHaveBeenCalledWith(schedule);
});
});
// ── recordRun ─────────────────────────────────────────────────────
describe("recordRun", () => {
it("records a successful run", async () => {
const schedule = await store.createSchedule({
name: "Run test",
command: "echo hello",
scheduleType: "hourly",
});
const result: AutomationRunResult = {
success: true,
output: "hello\n",
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
};
const updated = await store.recordRun(schedule.id, result);
expect(updated.lastRunAt).toBe(result.startedAt);
expect(updated.lastRunResult).toEqual(result);
expect(updated.runCount).toBe(1);
expect(updated.runHistory).toHaveLength(1);
expect(updated.runHistory[0]).toEqual(result);
expect(updated.nextRunAt).toBeTruthy();
});
it("records a failed run", async () => {
const schedule = await store.createSchedule({
name: "Fail test",
command: "false",
scheduleType: "hourly",
});
const result: AutomationRunResult = {
success: false,
output: "",
error: "Command failed with exit code 1",
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
};
const updated = await store.recordRun(schedule.id, result);
expect(updated.lastRunResult?.success).toBe(false);
expect(updated.lastRunResult?.error).toContain("exit code 1");
expect(updated.runCount).toBe(1);
});
it("caps run history at MAX_RUN_HISTORY", async () => {
const schedule = await store.createSchedule({
name: "History test",
command: "echo",
scheduleType: "hourly",
});
for (let i = 0; i < 55; i++) {
await store.recordRun(schedule.id, {
success: true,
output: `run ${i}`,
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
});
}
const updated = await store.getSchedule(schedule.id);
expect(updated.runHistory.length).toBeLessThanOrEqual(50);
expect(updated.runCount).toBe(55);
});
it("emits schedule:run event", async () => {
const schedule = await store.createSchedule({
name: "Event test",
command: "echo",
scheduleType: "hourly",
});
const listener = vi.fn();
store.on("schedule:run", listener);
const result: AutomationRunResult = {
success: true,
output: "ok",
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
};
await store.recordRun(schedule.id, result);
expect(listener).toHaveBeenCalledTimes(1);
expect(listener.mock.calls[0][0].result).toEqual(result);
});
});
// ── getDueSchedules ───────────────────────────────────────────────
describe("getDueSchedules", () => {
it("returns schedules that are due", async () => {
const schedule = await store.createSchedule({
name: "Due test",
command: "echo",
scheduleType: "hourly",
});
// Force nextRunAt to the past by writing directly
const filePath = join(rootDir, ".kb", "automations", `${schedule.id}.json`);
const { readFile: rf, writeFile: wf } = await import("node:fs/promises");
const raw = await rf(filePath, "utf-8");
const parsed = JSON.parse(raw) as ScheduledTask;
parsed.nextRunAt = new Date(Date.now() - 60000).toISOString();
await wf(filePath, JSON.stringify(parsed, null, 2));
const due = await store.getDueSchedules();
expect(due.length).toBeGreaterThanOrEqual(1);
expect(due.some((d) => d.id === schedule.id)).toBe(true);
});
it("excludes disabled schedules", async () => {
const schedule = await store.createSchedule({
name: "Disabled test",
command: "echo",
scheduleType: "hourly",
enabled: false,
});
const due = await store.getDueSchedules();
expect(due.some((d) => d.id === schedule.id)).toBe(false);
});
it("excludes schedules with future nextRunAt", async () => {
const schedule = await store.createSchedule({
name: "Future test",
command: "echo",
scheduleType: "hourly",
});
// nextRunAt is in the future by default
const due = await store.getDueSchedules();
expect(due.some((d) => d.id === schedule.id)).toBe(false);
});
});
// ── Concurrent write safety ───────────────────────────────────────
describe("concurrency", () => {
it("handles concurrent updates safely", async () => {
const schedule = await store.createSchedule({
name: "Concurrent",
command: "echo",
scheduleType: "hourly",
});
// Fire multiple concurrent updates
const updates = Array.from({ length: 10 }, (_, i) =>
store.recordRun(schedule.id, {
success: true,
output: `run ${i}`,
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
}),
);
await Promise.all(updates);
const final = await store.getSchedule(schedule.id);
expect(final.runCount).toBe(10);
expect(final.runHistory).toHaveLength(10);
});
});
});

View File

@@ -0,0 +1,306 @@
import { EventEmitter } from "node:events";
import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { existsSync } from "node:fs";
import { randomUUID } from "node:crypto";
import { CronExpressionParser } from "cron-parser";
import type {
ScheduledTask,
ScheduledTaskCreateInput,
ScheduledTaskUpdateInput,
AutomationRunResult,
} from "./automation.js";
import { AUTOMATION_PRESETS, MAX_RUN_HISTORY } from "./automation.js";
import type { ScheduleType } from "./automation.js";
export interface AutomationStoreEvents {
"schedule:created": [schedule: ScheduledTask];
"schedule:updated": [schedule: ScheduledTask];
"schedule:deleted": [schedule: ScheduledTask];
"schedule:run": [data: { schedule: ScheduledTask; result: AutomationRunResult }];
}
export class AutomationStore extends EventEmitter<AutomationStoreEvents> {
private automationsDir: string;
/** Per-schedule promise chain for serializing writes. */
private scheduleLocks: Map<string, Promise<void>> = new Map();
constructor(private rootDir: string) {
super();
this.automationsDir = join(rootDir, ".kb", "automations");
}
/** Create the .kb/automations/ directory if it doesn't exist. */
async init(): Promise<void> {
await mkdir(this.automationsDir, { recursive: true });
}
// ── Locking ────────────────────────────────────────────────────────
/**
* Serialize all mutations to a given schedule's JSON file by chaining promises.
* Concurrent callers for the same ID will queue behind each other.
*/
private withScheduleLock<T>(id: string, fn: () => Promise<T>): Promise<T> {
const prev = this.scheduleLocks.get(id) ?? Promise.resolve();
let resolve: () => void;
const next = new Promise<void>((r) => { resolve = r; });
this.scheduleLocks.set(id, next);
return prev.then(async () => {
try {
return await fn();
} finally {
if (this.scheduleLocks.get(id) === next) {
this.scheduleLocks.delete(id);
}
resolve!();
}
});
}
// ── File I/O ───────────────────────────────────────────────────────
private schedulePath(id: string): string {
return join(this.automationsDir, `${id}.json`);
}
private async readScheduleJson(id: string): Promise<ScheduledTask> {
const filePath = this.schedulePath(id);
const raw = await readFile(filePath, "utf-8");
try {
return JSON.parse(raw) as ScheduledTask;
} catch (err) {
throw new Error(
`Failed to parse schedule JSON at ${filePath}: ${(err as Error).message}`,
);
}
}
/**
* Atomically write a schedule JSON file by writing to a temp file first,
* then renaming it into place.
*/
private async atomicWriteScheduleJson(id: string, schedule: ScheduledTask): Promise<void> {
const filePath = this.schedulePath(id);
const tmpPath = filePath + ".tmp";
await writeFile(tmpPath, JSON.stringify(schedule, null, 2));
await rename(tmpPath, filePath);
}
// ── Cron Computation ───────────────────────────────────────────────
/**
* Compute the next run time from a cron expression.
* @param cronExpression - A valid cron expression (5 fields).
* @param fromDate - The date to compute from. Defaults to now.
* @returns ISO-8601 timestamp of the next run.
*/
computeNextRun(cronExpression: string, fromDate?: Date): string {
const interval = CronExpressionParser.parse(cronExpression, {
currentDate: fromDate ?? new Date(),
});
const next = interval.next();
return next.toISOString() ?? new Date(next.getTime()).toISOString();
}
/**
* Validate a cron expression. Returns true if valid.
*/
static isValidCron(cronExpression: string): boolean {
try {
CronExpressionParser.parse(cronExpression);
return true;
} catch {
return false;
}
}
// ── CRUD ───────────────────────────────────────────────────────────
async createSchedule(input: ScheduledTaskCreateInput): Promise<ScheduledTask> {
if (!input.name?.trim()) {
throw new Error("Name is required and cannot be empty");
}
if (!input.command?.trim()) {
throw new Error("Command is required and cannot be empty");
}
// Resolve cron expression
let cronExpression: string;
if (input.scheduleType === "custom") {
if (!input.cronExpression?.trim()) {
throw new Error("Cron expression is required for custom schedule type");
}
if (!AutomationStore.isValidCron(input.cronExpression)) {
throw new Error(`Invalid cron expression: "${input.cronExpression}"`);
}
cronExpression = input.cronExpression.trim();
} else {
cronExpression = AUTOMATION_PRESETS[input.scheduleType];
}
const id = randomUUID();
const now = new Date().toISOString();
const enabled = input.enabled !== undefined ? input.enabled : true;
const schedule: ScheduledTask = {
id,
name: input.name.trim(),
description: input.description?.trim() || undefined,
scheduleType: input.scheduleType,
cronExpression,
command: input.command.trim(),
enabled,
runCount: 0,
runHistory: [],
timeoutMs: input.timeoutMs,
nextRunAt: enabled ? this.computeNextRun(cronExpression) : undefined,
createdAt: now,
updatedAt: now,
};
await this.atomicWriteScheduleJson(id, schedule);
this.emit("schedule:created", schedule);
return schedule;
}
async getSchedule(id: string): Promise<ScheduledTask> {
const filePath = this.schedulePath(id);
if (!existsSync(filePath)) {
throw Object.assign(new Error(`Schedule '${id}' not found`), { code: "ENOENT" });
}
return this.readScheduleJson(id);
}
async listSchedules(): Promise<ScheduledTask[]> {
if (!existsSync(this.automationsDir)) return [];
const entries = await readdir(this.automationsDir);
const schedules: ScheduledTask[] = [];
for (const entry of entries) {
if (!entry.endsWith(".json") || entry.endsWith(".tmp")) continue;
const id = entry.replace(/\.json$/, "");
try {
schedules.push(await this.readScheduleJson(id));
} catch {
// skip invalid files
}
}
return schedules.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
}
async updateSchedule(id: string, updates: ScheduledTaskUpdateInput): Promise<ScheduledTask> {
return this.withScheduleLock(id, async () => {
const schedule = await this.getSchedule(id);
if (updates.name !== undefined) {
if (!updates.name.trim()) throw new Error("Name cannot be empty");
schedule.name = updates.name.trim();
}
if (updates.description !== undefined) {
schedule.description = updates.description?.trim() || undefined;
}
if (updates.command !== undefined) {
if (!updates.command.trim()) throw new Error("Command cannot be empty");
schedule.command = updates.command.trim();
}
if (updates.timeoutMs !== undefined) {
schedule.timeoutMs = updates.timeoutMs;
}
// Handle schedule type / cron changes
if (updates.scheduleType !== undefined || updates.cronExpression !== undefined) {
const newType = updates.scheduleType ?? schedule.scheduleType;
let newCron: string;
if (newType === "custom") {
const customCron = updates.cronExpression ?? schedule.cronExpression;
if (!customCron?.trim()) {
throw new Error("Cron expression is required for custom schedule type");
}
if (!AutomationStore.isValidCron(customCron)) {
throw new Error(`Invalid cron expression: "${customCron}"`);
}
newCron = customCron.trim();
} else {
newCron = AUTOMATION_PRESETS[newType as Exclude<ScheduleType, "custom">];
}
schedule.scheduleType = newType;
schedule.cronExpression = newCron;
}
if (updates.enabled !== undefined) {
schedule.enabled = updates.enabled;
}
// Recompute next run if enabled
if (schedule.enabled) {
schedule.nextRunAt = this.computeNextRun(schedule.cronExpression);
} else {
schedule.nextRunAt = undefined;
}
schedule.updatedAt = new Date().toISOString();
await this.atomicWriteScheduleJson(id, schedule);
this.emit("schedule:updated", schedule);
return schedule;
});
}
async deleteSchedule(id: string): Promise<ScheduledTask> {
return this.withScheduleLock(id, async () => {
const schedule = await this.getSchedule(id);
const filePath = this.schedulePath(id);
const { unlink } = await import("node:fs/promises");
await unlink(filePath);
this.emit("schedule:deleted", schedule);
return schedule;
});
}
/**
* Record a run result for a schedule. Updates lastRunAt, lastRunResult,
* nextRunAt, runCount, and appends to runHistory.
*/
async recordRun(id: string, result: AutomationRunResult): Promise<ScheduledTask> {
return this.withScheduleLock(id, async () => {
const schedule = await this.getSchedule(id);
schedule.lastRunAt = result.startedAt;
schedule.lastRunResult = result;
schedule.runCount += 1;
// Prepend to history (most recent first), cap at MAX_RUN_HISTORY
schedule.runHistory.unshift(result);
if (schedule.runHistory.length > MAX_RUN_HISTORY) {
schedule.runHistory = schedule.runHistory.slice(0, MAX_RUN_HISTORY);
}
// Recompute next run
if (schedule.enabled) {
schedule.nextRunAt = this.computeNextRun(schedule.cronExpression);
}
schedule.updatedAt = new Date().toISOString();
await this.atomicWriteScheduleJson(id, schedule);
this.emit("schedule:run", { schedule, result });
return schedule;
});
}
/**
* Get all schedules that are due to run (nextRunAt <= now and enabled).
*/
async getDueSchedules(): Promise<ScheduledTask[]> {
const schedules = await this.listSchedules();
const now = new Date().toISOString();
return schedules.filter(
(s) => s.enabled && s.nextRunAt && s.nextRunAt <= now,
);
}
}

View File

@@ -0,0 +1,79 @@
/** Schedule type presets plus a custom cron option. */
export type ScheduleType = "hourly" | "daily" | "weekly" | "monthly" | "custom";
/** Mapping from preset schedule types to their cron expressions. */
export const AUTOMATION_PRESETS: Record<Exclude<ScheduleType, "custom">, string> = {
hourly: "0 * * * *",
daily: "0 0 * * *",
weekly: "0 0 * * 1",
monthly: "0 0 1 * *",
};
/** Result of a single automation run. */
export interface AutomationRunResult {
success: boolean;
output: string;
error?: string;
startedAt: string;
completedAt: string;
}
/** A scheduled automation task. */
export interface ScheduledTask {
/** Unique identifier for this schedule (UUID). */
id: string;
/** Human-readable name for this schedule. */
name: string;
/** Optional description of what this schedule does. */
description?: string;
/** The type of schedule — preset or custom. */
scheduleType: ScheduleType;
/** The cron expression (auto-derived from preset or user-supplied for custom). */
cronExpression: string;
/** The shell command to execute. */
command: string;
/** Whether this schedule is currently active. */
enabled: boolean;
/** ISO-8601 timestamp of the last run start, if any. */
lastRunAt?: string;
/** Result of the most recent run, if any. */
lastRunResult?: AutomationRunResult;
/** ISO-8601 timestamp of the next scheduled run. */
nextRunAt?: string;
/** Total number of runs executed. */
runCount: number;
/** Per-schedule execution timeout in milliseconds. Default: 300000 (5 min). */
timeoutMs?: number;
/** History of recent run results (most recent first, capped at 50). */
runHistory: AutomationRunResult[];
/** ISO-8601 timestamp of when this schedule was created. */
createdAt: string;
/** ISO-8601 timestamp of when this schedule was last updated. */
updatedAt: string;
}
/** Input for creating a new scheduled task. */
export interface ScheduledTaskCreateInput {
name: string;
description?: string;
scheduleType: ScheduleType;
/** Required for 'custom' type; ignored for presets (auto-derived). */
cronExpression?: string;
command: string;
enabled?: boolean;
timeoutMs?: number;
}
/** Input for updating an existing scheduled task. */
export interface ScheduledTaskUpdateInput {
name?: string;
description?: string;
scheduleType?: ScheduleType;
cronExpression?: string;
command?: string;
enabled?: boolean;
timeoutMs?: number;
}
/** Maximum number of run history entries to retain per schedule. */
export const MAX_RUN_HISTORY = 50;

View File

@@ -15,3 +15,7 @@ export {
getCurrentRepo,
type GhError,
} from "./gh-cli.js";
export { AUTOMATION_PRESETS, MAX_RUN_HISTORY } from "./automation.js";
export type { ScheduleType, ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult } from "./automation.js";
export { AutomationStore } from "./automation-store.js";
export type { AutomationStoreEvents } from "./automation-store.js";

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

View File

@@ -0,0 +1,360 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { CronRunner } from "./cron-runner.js";
import type { TaskStore, AutomationStore, ScheduledTask, AutomationRunResult, Settings } from "@kb/core";
import { DEFAULT_SETTINGS } from "@kb/core";
function createMockSchedule(overrides: Partial<ScheduledTask> = {}): ScheduledTask {
return {
id: "test-schedule-id",
name: "Test Schedule",
description: "A test schedule",
scheduleType: "hourly",
cronExpression: "0 * * * *",
command: "echo hello",
enabled: true,
runCount: 0,
runHistory: [],
nextRunAt: new Date(Date.now() - 60000).toISOString(), // past = due
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
function createMockStore(settingsOverrides: Partial<Settings> = {}): TaskStore {
return {
getSettings: vi.fn().mockResolvedValue({
...DEFAULT_SETTINGS,
...settingsOverrides,
}),
on: vi.fn(),
off: vi.fn(),
} as unknown as TaskStore;
}
function createMockAutomationStore(schedules: ScheduledTask[] = []): AutomationStore {
return {
getDueSchedules: vi.fn().mockResolvedValue(schedules),
recordRun: vi.fn().mockResolvedValue(undefined),
getSchedule: vi.fn().mockImplementation(async (id: string) => {
const s = schedules.find((s) => s.id === id);
if (!s) throw new Error(`Schedule ${id} not found`);
return s;
}),
} as unknown as AutomationStore;
}
describe("CronRunner", () => {
let runner: CronRunner;
afterEach(() => {
if (runner) runner.stop();
});
describe("start/stop", () => {
it("starts and stops without error", () => {
const store = createMockStore();
const automationStore = createMockAutomationStore();
runner = new CronRunner(store, automationStore);
runner.start();
expect(runner["running"]).toBe(true);
runner.stop();
expect(runner["running"]).toBe(false);
});
it("is idempotent on start", () => {
const store = createMockStore();
const automationStore = createMockAutomationStore();
runner = new CronRunner(store, automationStore);
runner.start();
runner.start(); // should not double-start
runner.stop();
});
});
describe("tick", () => {
it("skips when globalPause is true", async () => {
const store = createMockStore({ globalPause: true });
const automationStore = createMockAutomationStore([createMockSchedule()]);
runner = new CronRunner(store, automationStore);
await runner.tick();
expect(automationStore.getDueSchedules).not.toHaveBeenCalled();
});
it("skips when enginePaused is true", async () => {
const store = createMockStore({ enginePaused: true });
const automationStore = createMockAutomationStore([createMockSchedule()]);
runner = new CronRunner(store, automationStore);
await runner.tick();
expect(automationStore.getDueSchedules).not.toHaveBeenCalled();
});
it("does nothing when no schedules are due", async () => {
const store = createMockStore();
const automationStore = createMockAutomationStore([]);
runner = new CronRunner(store, automationStore);
await runner.tick();
expect(automationStore.getDueSchedules).toHaveBeenCalledTimes(1);
expect(automationStore.recordRun).not.toHaveBeenCalled();
});
it("executes due schedules", async () => {
const store = createMockStore();
const schedule = createMockSchedule({ command: "echo test-output" });
const automationStore = createMockAutomationStore([schedule]);
runner = new CronRunner(store, automationStore);
await runner.tick();
expect(automationStore.recordRun).toHaveBeenCalledTimes(1);
const [id, result] = (automationStore.recordRun as ReturnType<typeof vi.fn>).mock.calls[0] as [string, AutomationRunResult];
expect(id).toBe(schedule.id);
expect(result.success).toBe(true);
expect(result.output).toContain("test-output");
});
it("re-entrance guard prevents overlapping ticks", async () => {
const store = createMockStore();
const schedule = createMockSchedule({ command: "sleep 0.1 && echo done" });
const automationStore = createMockAutomationStore([schedule]);
runner = new CronRunner(store, automationStore);
// Start first tick
const tick1 = runner.tick();
// Attempt second tick immediately
const tick2 = runner.tick();
await Promise.all([tick1, tick2]);
// Should only have recorded one run (second tick was a no-op)
expect(automationStore.recordRun).toHaveBeenCalledTimes(1);
});
});
describe("executeSchedule", () => {
it("records successful execution", async () => {
const store = createMockStore();
const schedule = createMockSchedule({ command: "echo success" });
const automationStore = createMockAutomationStore([schedule]);
runner = new CronRunner(store, automationStore);
const result = await runner.executeSchedule(schedule);
expect(result.success).toBe(true);
expect(result.output).toContain("success");
expect(result.startedAt).toBeTruthy();
expect(result.completedAt).toBeTruthy();
});
it("records failed execution", async () => {
const store = createMockStore();
const schedule = createMockSchedule({ command: "exit 1" });
const automationStore = createMockAutomationStore([schedule]);
runner = new CronRunner(store, automationStore);
const result = await runner.executeSchedule(schedule);
expect(result.success).toBe(false);
expect(result.error).toBeTruthy();
});
it("records timeout execution", async () => {
const store = createMockStore();
const schedule = createMockSchedule({
command: "sleep 60",
timeoutMs: 100, // very short timeout
});
const automationStore = createMockAutomationStore([schedule]);
runner = new CronRunner(store, automationStore);
const result = await runner.executeSchedule(schedule);
expect(result.success).toBe(false);
expect(result.error).toContain("timed out");
}, 10000);
it("prevents concurrent runs of the same schedule", async () => {
const store = createMockStore();
const schedule = createMockSchedule({ command: "sleep 0.2 && echo done" });
const automationStore = createMockAutomationStore([schedule]);
runner = new CronRunner(store, automationStore);
// Start execution
const exec1 = runner.executeSchedule(schedule);
// in-flight set should contain the schedule
expect(runner["inFlight"].has(schedule.id)).toBe(true);
await exec1;
// After completion, in-flight should be cleared
expect(runner["inFlight"].has(schedule.id)).toBe(false);
});
it("calls recordRun on automation store", async () => {
const store = createMockStore();
const schedule = createMockSchedule({ command: "echo recorded" });
const automationStore = createMockAutomationStore([schedule]);
runner = new CronRunner(store, automationStore);
await runner.executeSchedule(schedule);
expect(automationStore.recordRun).toHaveBeenCalledWith(
schedule.id,
expect.objectContaining({
success: true,
output: expect.stringContaining("recorded"),
}),
);
});
it("captures stderr output", async () => {
const store = createMockStore();
const schedule = createMockSchedule({ command: "echo err >&2" });
const automationStore = createMockAutomationStore([schedule]);
runner = new CronRunner(store, automationStore);
const result = await runner.executeSchedule(schedule);
expect(result.success).toBe(true);
expect(result.output).toContain("err");
});
});
describe("concurrent schedule prevention in tick", () => {
it("skips schedule already in-flight during tick", async () => {
const store = createMockStore();
const schedule = createMockSchedule({ command: "sleep 0.3 && echo done" });
const automationStore = createMockAutomationStore([schedule]);
runner = new CronRunner(store, automationStore);
// Start first execution (will block for 300ms)
const exec1 = runner.executeSchedule(schedule);
// Tick while execution in progress — should skip the in-flight schedule
await runner.tick();
await exec1;
// Should only have recorded one run (tick skipped it)
expect(automationStore.recordRun).toHaveBeenCalledTimes(1);
});
});
describe("mid-tick pause detection", () => {
it("stops executing schedules when pause is detected mid-tick", async () => {
const store = createMockStore();
const schedules = [
createMockSchedule({ id: "s1", name: "First", command: "echo first" }),
createMockSchedule({ id: "s2", name: "Second", command: "echo second" }),
];
const automationStore = createMockAutomationStore(schedules);
// Mock getSettings to return paused AFTER first two calls
// (1st call = initial tick check, 2nd call = before first schedule)
let callCount = 0;
(store.getSettings as ReturnType<typeof vi.fn>).mockImplementation(async () => {
callCount++;
return {
...DEFAULT_SETTINGS,
globalPause: callCount > 2, // pause before second schedule
};
});
runner = new CronRunner(store, automationStore);
await runner.tick();
// Should only execute first schedule, not second
expect(automationStore.recordRun).toHaveBeenCalledTimes(1);
const [id] = (automationStore.recordRun as ReturnType<typeof vi.fn>).mock.calls[0] as [string, AutomationRunResult];
expect(id).toBe("s1");
});
});
describe("multiple schedules", () => {
it("executes all due schedules in a single tick", async () => {
const store = createMockStore();
const schedules = [
createMockSchedule({ id: "s1", name: "First", command: "echo first" }),
createMockSchedule({ id: "s2", name: "Second", command: "echo second" }),
];
const automationStore = createMockAutomationStore(schedules);
runner = new CronRunner(store, automationStore);
await runner.tick();
expect(automationStore.recordRun).toHaveBeenCalledTimes(2);
});
});
describe("output truncation", () => {
it("truncates large output to prevent memory exhaustion", async () => {
const store = createMockStore();
// Generate output larger than 10KB using printf
const schedule = createMockSchedule({
command: "python3 -c \"print('x' * 15000)\"",
});
const automationStore = createMockAutomationStore([schedule]);
runner = new CronRunner(store, automationStore);
const result = await runner.executeSchedule(schedule);
expect(result.success).toBe(true);
// MAX_OUTPUT_LENGTH = 10 * 1024 = 10240
expect(result.output.length).toBeLessThanOrEqual(10240 + 20);
expect(result.output).toContain("[output truncated]");
});
});
describe("error handling", () => {
it("continues to next schedule when recordRun fails", async () => {
const store = createMockStore();
const schedules = [
createMockSchedule({ id: "s1", name: "First", command: "echo first" }),
createMockSchedule({ id: "s2", name: "Second", command: "echo second" }),
];
const automationStore = createMockAutomationStore(schedules);
// Make recordRun fail for first schedule
let recordCalls = 0;
(automationStore.recordRun as ReturnType<typeof vi.fn>).mockImplementation(async () => {
recordCalls++;
if (recordCalls === 1) throw new Error("Storage error");
return undefined;
});
runner = new CronRunner(store, automationStore);
await runner.tick();
// Should still have attempted both schedules
expect(automationStore.recordRun).toHaveBeenCalledTimes(2);
});
});
describe("poll interval", () => {
it("enforces minimum poll interval of 10 seconds", () => {
const store = createMockStore();
const automationStore = createMockAutomationStore();
runner = new CronRunner(store, automationStore, { pollIntervalMs: 1000 });
expect(runner["pollIntervalMs"]).toBe(10000);
});
it("defaults to 60 seconds", () => {
const store = createMockStore();
const automationStore = createMockAutomationStore();
runner = new CronRunner(store, automationStore);
expect(runner["pollIntervalMs"]).toBe(60000);
});
});
});

View File

@@ -0,0 +1,195 @@
import { exec } from "node:child_process";
import { promisify } from "node:util";
import type { TaskStore } from "@kb/core";
import type { AutomationStore } from "@kb/core";
import type { ScheduledTask, AutomationRunResult } from "@kb/core";
import { createLogger } from "./logger.js";
const execAsync = promisify(exec);
const log = createLogger("cron-runner");
/** Default execution timeout: 5 minutes. */
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
/** Maximum output buffer: 1 MB. */
const MAX_BUFFER = 1024 * 1024;
/** Maximum output string stored in result: 10 KB. */
const MAX_OUTPUT_LENGTH = 10 * 1024;
/** Default poll interval: 60 seconds. */
const DEFAULT_POLL_INTERVAL_MS = 60 * 1000;
/** Minimum poll interval: 10 seconds. */
const MIN_POLL_INTERVAL_MS = 10 * 1000;
export interface CronRunnerOptions {
/** Polling interval in milliseconds. Default: 60000 (60s). Minimum: 10000 (10s). */
pollIntervalMs?: number;
}
/**
* CronRunner polls the AutomationStore for due schedules and executes them.
*
* - Respects `globalPause` and `enginePaused` settings — skips execution when either is true.
* - Prevents concurrent runs of the same schedule.
* - Enforces per-schedule timeouts and output size limits.
* - Uses a re-entrance guard like Scheduler to prevent overlapping ticks.
*/
export class CronRunner {
private running = false;
private ticking = false;
private pollInterval: ReturnType<typeof setInterval> | null = null;
private pollIntervalMs: number;
/** Schedule IDs currently being executed — prevents concurrent runs of the same schedule. */
private inFlight = new Set<string>();
constructor(
private store: TaskStore,
private automationStore: AutomationStore,
private options: CronRunnerOptions = {},
) {
this.pollIntervalMs = Math.max(
MIN_POLL_INTERVAL_MS,
options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
);
}
/** Start the polling loop. */
start(): void {
if (this.running) return;
this.running = true;
log.log(`Started (poll every ${this.pollIntervalMs / 1000}s)`);
// Run first tick immediately
void this.tick();
this.pollInterval = setInterval(() => {
void this.tick();
}, this.pollIntervalMs);
}
/** Stop the polling loop. Does NOT abort in-flight executions. */
stop(): void {
if (!this.running) return;
this.running = false;
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
log.log("Stopped");
}
/**
* Single poll cycle: find due schedules and execute them.
* Re-entrance guarded — if already ticking, the call is a no-op.
*/
async tick(): Promise<void> {
if (this.ticking) return;
this.ticking = true;
try {
// Check pause settings
const settings = await this.store.getSettings();
if (settings.globalPause || settings.enginePaused) {
return;
}
const dueSchedules = await this.automationStore.getDueSchedules();
if (dueSchedules.length === 0) return;
for (const schedule of dueSchedules) {
// Skip if already in-flight (prevents concurrent runs of same schedule)
if (this.inFlight.has(schedule.id)) {
log.warn(`Skipping ${schedule.name} (${schedule.id}) — still running from previous tick`);
continue;
}
// Re-check pause on each schedule (may have changed mid-loop)
const currentSettings = await this.store.getSettings();
if (currentSettings.globalPause || currentSettings.enginePaused) {
log.log("Pause detected mid-tick — stopping schedule execution");
break;
}
await this.executeSchedule(schedule);
}
} catch (err) {
log.error(`Tick error: ${(err as Error).message}`);
} finally {
this.ticking = false;
}
}
/**
* Execute a single schedule's command.
* - Tracks in-flight state to prevent concurrent runs.
* - Enforces timeout and output buffer limits.
* - Records the run result in the automation store.
*/
async executeSchedule(schedule: ScheduledTask): Promise<AutomationRunResult> {
this.inFlight.add(schedule.id);
const startedAt = new Date().toISOString();
log.log(`Executing ${schedule.name} (${schedule.id}): ${schedule.command}`);
let result: AutomationRunResult;
try {
const timeoutMs = schedule.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const { stdout, stderr } = await execAsync(schedule.command, {
timeout: timeoutMs,
maxBuffer: MAX_BUFFER,
shell: "/bin/sh",
});
const output = truncateOutput(stdout, stderr);
result = {
success: true,
output,
startedAt,
completedAt: new Date().toISOString(),
};
log.log(`✓ ${schedule.name} completed (${result.output.length} bytes output)`);
} catch (err: any) {
const stdout = err.stdout ?? "";
const stderr = err.stderr ?? "";
const output = truncateOutput(stdout, stderr);
const errorMessage = err.killed
? `Command timed out after ${(schedule.timeoutMs ?? DEFAULT_TIMEOUT_MS) / 1000}s`
: err.message ?? String(err);
result = {
success: false,
output,
error: errorMessage,
startedAt,
completedAt: new Date().toISOString(),
};
log.warn(`✗ ${schedule.name} failed: ${errorMessage}`);
} finally {
this.inFlight.delete(schedule.id);
}
// Record run result
try {
await this.automationStore.recordRun(schedule.id, result);
} catch (recordErr) {
log.error(`Failed to record run for ${schedule.id}: ${(recordErr as Error).message}`);
}
return result;
}
}
/** Combine and truncate stdout/stderr to stay within storage limits. */
function truncateOutput(stdout: string, stderr: string): string {
let combined = stdout;
if (stderr) {
// Add separator only if there's also stdout content
combined += stdout ? "\n--- stderr ---\n" : "";
combined += stderr;
}
if (combined.length > MAX_OUTPUT_LENGTH) {
combined = combined.slice(0, MAX_OUTPUT_LENGTH) + "\n[output truncated]";
}
return combined;
}

View File

@@ -12,3 +12,4 @@ export { isUsageLimitError, UsageLimitPauser } from "./usage-limit-detector.js";
export { PrMonitor, type PrComment, type TrackedPr, type OnNewCommentsCallback } from "./pr-monitor.js";
export { PrCommentHandler } from "./pr-comment-handler.js";
export { NtfyNotifier, type NtfyNotifierOptions } from "./notifier.js";
export { CronRunner, type CronRunnerOptions } from "./cron-runner.js";

18
pnpm-lock.yaml generated
View File

@@ -71,6 +71,10 @@ importers:
version: 2.8.3
packages/core:
dependencies:
cron-parser:
specifier: ^5.5.0
version: 5.5.0
devDependencies:
'@types/node':
specifier: ^25.5.0
@@ -2041,6 +2045,10 @@ packages:
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
cron-parser@5.5.0:
resolution: {integrity: sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==}
engines: {node: '>=18'}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -2695,6 +2703,10 @@ packages:
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
luxon@3.7.2:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'}
lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
@@ -6064,6 +6076,10 @@ snapshots:
crelt@1.0.6: {}
cron-parser@5.5.0:
dependencies:
luxon: 3.7.2
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -6810,6 +6826,8 @@ snapshots:
dependencies:
react: 19.2.4
luxon@3.7.2: {}
lz-string@1.5.0: {}
magic-string@0.30.21: