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 2a73e602f4
commit e03bd1ae38
27 changed files with 3806 additions and 8 deletions

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";