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

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