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:
@@ -3817,3 +3817,216 @@ describe("Terminal WebSocket close handler", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Automation Routes ─────────────────────────────────────────────
|
||||
|
||||
describe("Automation routes", () => {
|
||||
const FAKE_SCHEDULE = {
|
||||
id: "sched-001",
|
||||
name: "Test Schedule",
|
||||
description: "A test schedule",
|
||||
scheduleType: "hourly",
|
||||
cronExpression: "0 * * * *",
|
||||
command: "echo hello",
|
||||
enabled: true,
|
||||
runCount: 0,
|
||||
runHistory: [],
|
||||
nextRunAt: "2026-04-01T00:00:00.000Z",
|
||||
createdAt: "2026-03-30T00:00:00.000Z",
|
||||
updatedAt: "2026-03-30T00:00:00.000Z",
|
||||
};
|
||||
|
||||
function createMockAutomationStore() {
|
||||
return {
|
||||
listSchedules: vi.fn().mockResolvedValue([FAKE_SCHEDULE]),
|
||||
createSchedule: vi.fn().mockResolvedValue(FAKE_SCHEDULE),
|
||||
getSchedule: vi.fn().mockResolvedValue(FAKE_SCHEDULE),
|
||||
updateSchedule: vi.fn().mockResolvedValue(FAKE_SCHEDULE),
|
||||
deleteSchedule: vi.fn().mockResolvedValue(FAKE_SCHEDULE),
|
||||
recordRun: vi.fn().mockResolvedValue(FAKE_SCHEDULE),
|
||||
};
|
||||
}
|
||||
|
||||
function buildApp(automationStoreOverride?: ReturnType<typeof createMockAutomationStore>) {
|
||||
const store = createMockStore();
|
||||
const automationStore = automationStoreOverride ?? createMockAutomationStore();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store, { automationStore: automationStore as any }));
|
||||
return { app, automationStore };
|
||||
}
|
||||
|
||||
describe("GET /automations", () => {
|
||||
it("returns all schedules", async () => {
|
||||
const { app, automationStore } = buildApp();
|
||||
const res = await GET(app, "/api/automations");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(1);
|
||||
expect(automationStore.listSchedules).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns empty array when no automationStore provided", async () => {
|
||||
const store = createMockStore();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
const res = await GET(app, "/api/automations");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /automations", () => {
|
||||
it("creates a schedule", async () => {
|
||||
const { app, automationStore } = buildApp();
|
||||
const res = await REQUEST(app, "POST", "/api/automations", JSON.stringify({
|
||||
name: "Test",
|
||||
command: "echo test",
|
||||
scheduleType: "hourly",
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(201);
|
||||
expect(automationStore.createSchedule).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns 400 for missing name", async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await REQUEST(app, "POST", "/api/automations", JSON.stringify({
|
||||
command: "echo test",
|
||||
scheduleType: "hourly",
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Name is required");
|
||||
});
|
||||
|
||||
it("returns 400 for missing command", async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await REQUEST(app, "POST", "/api/automations", JSON.stringify({
|
||||
name: "Test",
|
||||
scheduleType: "hourly",
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Command is required");
|
||||
});
|
||||
|
||||
it("returns 400 for invalid schedule type", async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await REQUEST(app, "POST", "/api/automations", JSON.stringify({
|
||||
name: "Test",
|
||||
command: "echo test",
|
||||
scheduleType: "invalid",
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Invalid schedule type");
|
||||
});
|
||||
|
||||
it("returns 400 for custom type with missing cron", async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await REQUEST(app, "POST", "/api/automations", JSON.stringify({
|
||||
name: "Test",
|
||||
command: "echo test",
|
||||
scheduleType: "custom",
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Cron expression is required");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /automations/:id", () => {
|
||||
it("returns a schedule by id", async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await GET(app, "/api/automations/sched-001");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.id).toBe("sched-001");
|
||||
});
|
||||
|
||||
it("returns 404 for missing schedule", async () => {
|
||||
const mockStore = createMockAutomationStore();
|
||||
mockStore.getSchedule.mockRejectedValue(Object.assign(new Error("not found"), { code: "ENOENT" }));
|
||||
const { app } = buildApp(mockStore);
|
||||
const res = await GET(app, "/api/automations/missing");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /automations/:id", () => {
|
||||
it("updates a schedule", async () => {
|
||||
const { app, automationStore } = buildApp();
|
||||
const res = await REQUEST(app, "PATCH", "/api/automations/sched-001", JSON.stringify({
|
||||
name: "Updated",
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(automationStore.updateSchedule).toHaveBeenCalledWith("sched-001", expect.objectContaining({ name: "Updated" }));
|
||||
});
|
||||
|
||||
it("returns 404 for missing schedule", async () => {
|
||||
const mockStore = createMockAutomationStore();
|
||||
mockStore.updateSchedule.mockRejectedValue(Object.assign(new Error("not found"), { code: "ENOENT" }));
|
||||
const { app } = buildApp(mockStore);
|
||||
const res = await REQUEST(app, "PATCH", "/api/automations/missing", JSON.stringify({
|
||||
name: "Updated",
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /automations/:id", () => {
|
||||
it("deletes a schedule", async () => {
|
||||
const { app, automationStore } = buildApp();
|
||||
const res = await REQUEST(app, "DELETE", "/api/automations/sched-001");
|
||||
expect(res.status).toBe(200);
|
||||
expect(automationStore.deleteSchedule).toHaveBeenCalledWith("sched-001");
|
||||
});
|
||||
|
||||
it("returns 404 for missing schedule", async () => {
|
||||
const mockStore = createMockAutomationStore();
|
||||
mockStore.deleteSchedule.mockRejectedValue(Object.assign(new Error("not found"), { code: "ENOENT" }));
|
||||
const { app } = buildApp(mockStore);
|
||||
const res = await REQUEST(app, "DELETE", "/api/automations/missing");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /automations/:id/run", () => {
|
||||
it("runs a schedule and records the result", async () => {
|
||||
const mockStore = createMockAutomationStore();
|
||||
mockStore.getSchedule.mockResolvedValue({
|
||||
...FAKE_SCHEDULE,
|
||||
command: "echo manual-run",
|
||||
});
|
||||
const { app } = buildApp(mockStore);
|
||||
const res = await REQUEST(app, "POST", "/api/automations/sched-001/run");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.result).toBeDefined();
|
||||
expect(res.body.result.startedAt).toBeTruthy();
|
||||
expect(res.body.result.completedAt).toBeTruthy();
|
||||
expect(mockStore.recordRun).toHaveBeenCalledWith(
|
||||
"sched-001",
|
||||
expect.objectContaining({
|
||||
success: expect.any(Boolean),
|
||||
startedAt: expect.any(String),
|
||||
completedAt: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 404 for missing schedule", async () => {
|
||||
const mockStore = createMockAutomationStore();
|
||||
mockStore.getSchedule.mockRejectedValue(Object.assign(new Error("not found"), { code: "ENOENT" }));
|
||||
const { app } = buildApp(mockStore);
|
||||
const res = await REQUEST(app, "POST", "/api/automations/missing/run");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /automations/:id/toggle", () => {
|
||||
it("toggles enabled state", async () => {
|
||||
const mockStore = createMockAutomationStore();
|
||||
mockStore.getSchedule.mockResolvedValue({ ...FAKE_SCHEDULE, enabled: true });
|
||||
mockStore.updateSchedule.mockResolvedValue({ ...FAKE_SCHEDULE, enabled: false });
|
||||
const { app } = buildApp(mockStore);
|
||||
const res = await REQUEST(app, "POST", "/api/automations/sched-001/toggle");
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockStore.updateSchedule).toHaveBeenCalledWith("sched-001", { enabled: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,8 @@ import { Router, type Request, type Response, type NextFunction } from "express"
|
||||
import multer from "multer";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
import type { TaskStore, Column, MergeResult } from "@kb/core";
|
||||
import { COLUMNS, VALID_TRANSITIONS, type PrInfo, isGhAuthenticated } from "@kb/core";
|
||||
import type { TaskStore, Column, MergeResult, ScheduleType } from "@kb/core";
|
||||
import { COLUMNS, VALID_TRANSITIONS, type PrInfo, isGhAuthenticated, AUTOMATION_PRESETS, AutomationStore } from "@kb/core";
|
||||
import type { ServerOptions } from "./server.js";
|
||||
import { GitHubClient, getCurrentGitHubRepo, parseBadgeUrl } from "./github.js";
|
||||
import { githubRateLimiter } from "./github-poll.js";
|
||||
@@ -2755,6 +2755,232 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
// ── Automation / Scheduled Task Routes ────────────────────────────
|
||||
|
||||
const automationStore = options?.automationStore;
|
||||
|
||||
// GET /automations — list all scheduled tasks
|
||||
router.get("/automations", async (_req: Request, res: Response) => {
|
||||
if (!automationStore) {
|
||||
return res.json([]);
|
||||
}
|
||||
try {
|
||||
const schedules = await automationStore.listSchedules();
|
||||
res.json(schedules);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /automations — create a new schedule
|
||||
router.post("/automations", async (req: Request, res: Response) => {
|
||||
if (!automationStore) {
|
||||
return res.status(503).json({ error: "Automation store not available" });
|
||||
}
|
||||
try {
|
||||
const { name, description, scheduleType, cronExpression, command, enabled, timeoutMs } = req.body;
|
||||
|
||||
// Validation
|
||||
if (!name?.trim()) {
|
||||
return res.status(400).json({ error: "Name is required" });
|
||||
}
|
||||
if (!command?.trim()) {
|
||||
return res.status(400).json({ error: "Command is required" });
|
||||
}
|
||||
const validTypes = ["hourly", "daily", "weekly", "monthly", "custom"];
|
||||
if (!scheduleType || !validTypes.includes(scheduleType)) {
|
||||
return res.status(400).json({ error: `Invalid schedule type. Must be one of: ${validTypes.join(", ")}` });
|
||||
}
|
||||
if (scheduleType === "custom") {
|
||||
if (!cronExpression?.trim()) {
|
||||
return res.status(400).json({ error: "Cron expression is required for custom schedule type" });
|
||||
}
|
||||
if (!AutomationStore.isValidCron(cronExpression)) {
|
||||
return res.status(400).json({ error: `Invalid cron expression: "${cronExpression}"` });
|
||||
}
|
||||
}
|
||||
|
||||
const schedule = await automationStore.createSchedule({
|
||||
name,
|
||||
description,
|
||||
scheduleType: scheduleType as ScheduleType,
|
||||
cronExpression,
|
||||
command,
|
||||
enabled,
|
||||
timeoutMs,
|
||||
});
|
||||
res.status(201).json(schedule);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /automations/:id — get a single schedule
|
||||
router.get("/automations/:id", async (req, res) => {
|
||||
if (!automationStore) {
|
||||
return res.status(503).json({ error: "Automation store not available" });
|
||||
}
|
||||
try {
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const schedule = await automationStore.getSchedule(id);
|
||||
res.json(schedule);
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
return res.status(404).json({ error: "Schedule not found" });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /automations/:id — update a schedule
|
||||
router.patch("/automations/:id", async (req, res) => {
|
||||
if (!automationStore) {
|
||||
return res.status(503).json({ error: "Automation store not available" });
|
||||
}
|
||||
try {
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const { name, description, scheduleType, cronExpression, command, enabled, timeoutMs } = req.body;
|
||||
|
||||
// Validate cron if switching to custom
|
||||
if (scheduleType === "custom" && cronExpression) {
|
||||
if (!AutomationStore.isValidCron(cronExpression)) {
|
||||
return res.status(400).json({ error: `Invalid cron expression: "${cronExpression}"` });
|
||||
}
|
||||
}
|
||||
|
||||
const schedule = await automationStore.updateSchedule(id, {
|
||||
name,
|
||||
description,
|
||||
scheduleType,
|
||||
cronExpression,
|
||||
command,
|
||||
enabled,
|
||||
timeoutMs,
|
||||
});
|
||||
res.json(schedule);
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
return res.status(404).json({ error: "Schedule not found" });
|
||||
}
|
||||
if (err.message?.includes("cannot be empty") || err.message?.includes("Invalid cron")) {
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /automations/:id — delete a schedule
|
||||
router.delete("/automations/:id", async (req, res) => {
|
||||
if (!automationStore) {
|
||||
return res.status(503).json({ error: "Automation store not available" });
|
||||
}
|
||||
try {
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const deleted = await automationStore.deleteSchedule(id);
|
||||
res.json(deleted);
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
return res.status(404).json({ error: "Schedule not found" });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /automations/:id/run — trigger a manual run
|
||||
router.post("/automations/:id/run", async (req, res) => {
|
||||
if (!automationStore) {
|
||||
return res.status(503).json({ error: "Automation store not available" });
|
||||
}
|
||||
try {
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const schedule = await automationStore.getSchedule(id);
|
||||
|
||||
// Execute the command directly
|
||||
const { exec } = await import("node:child_process");
|
||||
const { promisify } = await import("node:util");
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const startedAt = new Date().toISOString();
|
||||
let result: import("@kb/core").AutomationRunResult;
|
||||
|
||||
try {
|
||||
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const MAX_BUFFER = 1024 * 1024;
|
||||
const { stdout, stderr } = await execAsync(schedule.command, {
|
||||
timeout: schedule.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
maxBuffer: MAX_BUFFER,
|
||||
shell: "/bin/sh",
|
||||
});
|
||||
|
||||
let output = stdout;
|
||||
if (stderr) {
|
||||
output += stdout ? "\n--- stderr ---\n" : "";
|
||||
output += stderr;
|
||||
}
|
||||
if (output.length > 10240) {
|
||||
output = output.slice(0, 10240) + "\n[output truncated]";
|
||||
}
|
||||
|
||||
result = {
|
||||
success: true,
|
||||
output,
|
||||
startedAt,
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
} catch (err: any) {
|
||||
const stdout = err.stdout ?? "";
|
||||
const stderr = err.stderr ?? "";
|
||||
let output = stdout;
|
||||
if (stderr) {
|
||||
output += stdout ? "\n--- stderr ---\n" : "";
|
||||
output += stderr;
|
||||
}
|
||||
if (output.length > 10240) {
|
||||
output = output.slice(0, 10240) + "\n[output truncated]";
|
||||
}
|
||||
|
||||
result = {
|
||||
success: false,
|
||||
output,
|
||||
error: err.killed
|
||||
? `Command timed out after ${(schedule.timeoutMs ?? 300000) / 1000}s`
|
||||
: err.message ?? String(err),
|
||||
startedAt,
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Record the result
|
||||
const updated = await automationStore.recordRun(schedule.id, result);
|
||||
res.json({ schedule: updated, result });
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
return res.status(404).json({ error: "Schedule not found" });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /automations/:id/toggle — toggle enabled/disabled
|
||||
router.post("/automations/:id/toggle", async (req, res) => {
|
||||
if (!automationStore) {
|
||||
return res.status(503).json({ error: "Automation store not available" });
|
||||
}
|
||||
try {
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const schedule = await automationStore.getSchedule(id);
|
||||
const updated = await automationStore.updateSchedule(id, {
|
||||
enabled: !schedule.enabled,
|
||||
});
|
||||
res.json(updated);
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
return res.status(404).json({ error: "Schedule not found" });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { join, dirname } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { Task, TaskStore, MergeResult } from "@kb/core";
|
||||
import type { Task, TaskStore, MergeResult, AutomationStore } from "@kb/core";
|
||||
import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
|
||||
import { createApiRoutes } from "./routes.js";
|
||||
import { createSSE } from "./sse.js";
|
||||
@@ -31,6 +31,8 @@ export interface ServerOptions {
|
||||
modelRegistry?: ModelRegistryLike;
|
||||
/** Optional BadgePubSub adapter for cross-instance badge snapshot fan-out — if not provided, creates from env or falls back to in-memory */
|
||||
badgePubSub?: BadgePubSub;
|
||||
/** Optional AutomationStore for scheduled task management */
|
||||
automationStore?: AutomationStore;
|
||||
}
|
||||
|
||||
type DashboardExpressApp = ReturnType<typeof express> & {
|
||||
|
||||
Reference in New Issue
Block a user