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:
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -30,5 +30,8 @@
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.1.0"
|
||||
},
|
||||
"private": true
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"cron-parser": "^5.5.0"
|
||||
}
|
||||
}
|
||||
|
||||
531
packages/core/src/automation-store.test.ts
Normal file
531
packages/core/src/automation-store.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
306
packages/core/src/automation-store.ts
Normal file
306
packages/core/src/automation-store.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
79
packages/core/src/automation.ts
Normal file
79
packages/core/src/automation.ts
Normal 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;
|
||||
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)}
|
||||
|
||||
228
packages/dashboard/app/components/ScheduleCard.tsx
Normal file
228
packages/dashboard/app/components/ScheduleCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
249
packages/dashboard/app/components/ScheduleForm.tsx
Normal file
249
packages/dashboard/app/components/ScheduleForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
251
packages/dashboard/app/components/ScheduledTasksModal.tsx
Normal file
251
packages/dashboard/app/components/ScheduledTasksModal.tsx
Normal 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">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="schedule-modal-content">
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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> & {
|
||||
|
||||
360
packages/engine/src/cron-runner.test.ts
Normal file
360
packages/engine/src/cron-runner.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
195
packages/engine/src/cron-runner.ts
Normal file
195
packages/engine/src/cron-runner.ts
Normal 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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user