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:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user