feat(KB-045): add scheduled tasks automation system

- Add AutomationStore and core automation types for cron-based scheduling
- Implement CronRunner engine for executing scheduled automations
- Add REST API routes for CRUD operations on automations
- Create UI components: ScheduleCard, ScheduleForm, and ScheduledTasksModal
- Integrate scheduled tasks into dashboard App.tsx and CLI dashboard command
- Add comprehensive tests for store, runner, API, and UI components
- Include changeset for the new scheduled tasks feature
This commit is contained in:
gsxdsm
2026-03-30 16:27:23 -07:00
parent 2a73e602f4
commit e03bd1ae38
27 changed files with 3806 additions and 8 deletions

View File

@@ -0,0 +1,531 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { AutomationStore } from "./automation-store.js";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { mkdtempSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import type { ScheduledTask, AutomationRunResult } from "./automation.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-automation-test-"));
}
describe("AutomationStore", () => {
let rootDir: string;
let store: AutomationStore;
beforeEach(async () => {
rootDir = makeTmpDir();
store = new AutomationStore(rootDir);
await store.init();
});
afterEach(async () => {
await rm(rootDir, { recursive: true, force: true });
});
// ── init ──────────────────────────────────────────────────────────
describe("init", () => {
it("creates the automations directory", async () => {
const dir = join(rootDir, ".kb", "automations");
expect(existsSync(dir)).toBe(true);
});
it("is idempotent", async () => {
await store.init();
await store.init();
const dir = join(rootDir, ".kb", "automations");
expect(existsSync(dir)).toBe(true);
});
});
// ── isValidCron ───────────────────────────────────────────────────
describe("isValidCron", () => {
it("accepts valid cron expressions", () => {
expect(AutomationStore.isValidCron("0 * * * *")).toBe(true);
expect(AutomationStore.isValidCron("*/5 * * * *")).toBe(true);
expect(AutomationStore.isValidCron("0 0 * * 1")).toBe(true);
expect(AutomationStore.isValidCron("0 9 1 * *")).toBe(true);
});
it("rejects invalid cron expressions", () => {
expect(AutomationStore.isValidCron("not a cron")).toBe(false);
expect(AutomationStore.isValidCron("60 * * * *")).toBe(false);
expect(AutomationStore.isValidCron("0 25 * * *")).toBe(false);
});
});
// ── computeNextRun ────────────────────────────────────────────────
describe("computeNextRun", () => {
it("returns a future ISO timestamp", () => {
const fromDate = new Date("2026-01-01T00:00:00Z");
const next = store.computeNextRun("0 * * * *", fromDate);
expect(new Date(next).getTime()).toBeGreaterThan(fromDate.getTime());
});
it("computes correct next run for hourly", () => {
const fromDate = new Date("2026-01-01T12:30:00Z");
const next = store.computeNextRun("0 * * * *", fromDate);
expect(new Date(next).getUTCHours()).toBe(13);
expect(new Date(next).getUTCMinutes()).toBe(0);
});
});
// ── createSchedule ────────────────────────────────────────────────
describe("createSchedule", () => {
it("creates a schedule with preset type", async () => {
const schedule = await store.createSchedule({
name: "Hourly check",
command: "echo hello",
scheduleType: "hourly",
});
expect(schedule.id).toBeTruthy();
expect(schedule.name).toBe("Hourly check");
expect(schedule.command).toBe("echo hello");
expect(schedule.scheduleType).toBe("hourly");
expect(schedule.cronExpression).toBe("0 * * * *");
expect(schedule.enabled).toBe(true);
expect(schedule.runCount).toBe(0);
expect(schedule.runHistory).toEqual([]);
expect(schedule.nextRunAt).toBeTruthy();
expect(schedule.createdAt).toBeTruthy();
expect(schedule.updatedAt).toBeTruthy();
});
it("creates a schedule with custom cron", async () => {
const schedule = await store.createSchedule({
name: "Every 5 min",
command: "ls",
scheduleType: "custom",
cronExpression: "*/5 * * * *",
});
expect(schedule.cronExpression).toBe("*/5 * * * *");
expect(schedule.scheduleType).toBe("custom");
});
it("creates disabled schedule without nextRunAt", async () => {
const schedule = await store.createSchedule({
name: "Disabled",
command: "echo",
scheduleType: "daily",
enabled: false,
});
expect(schedule.enabled).toBe(false);
expect(schedule.nextRunAt).toBeUndefined();
});
it("rejects empty name", async () => {
await expect(
store.createSchedule({ name: "", command: "echo", scheduleType: "hourly" }),
).rejects.toThrow("Name is required");
});
it("rejects empty command", async () => {
await expect(
store.createSchedule({ name: "Test", command: "", scheduleType: "hourly" }),
).rejects.toThrow("Command is required");
});
it("rejects custom type without cron expression", async () => {
await expect(
store.createSchedule({ name: "Test", command: "echo", scheduleType: "custom" }),
).rejects.toThrow("Cron expression is required");
});
it("rejects invalid cron expression", async () => {
await expect(
store.createSchedule({
name: "Test",
command: "echo",
scheduleType: "custom",
cronExpression: "bad cron",
}),
).rejects.toThrow("Invalid cron expression");
});
it("persists schedule to disk", async () => {
const schedule = await store.createSchedule({
name: "Persist test",
command: "echo persist",
scheduleType: "weekly",
});
const filePath = join(rootDir, ".kb", "automations", `${schedule.id}.json`);
expect(existsSync(filePath)).toBe(true);
});
it("emits schedule:created event", async () => {
const listener = vi.fn();
store.on("schedule:created", listener);
const schedule = await store.createSchedule({
name: "Event test",
command: "echo event",
scheduleType: "hourly",
});
expect(listener).toHaveBeenCalledWith(schedule);
});
it("stores optional timeoutMs", async () => {
const schedule = await store.createSchedule({
name: "Timeout test",
command: "echo",
scheduleType: "hourly",
timeoutMs: 60000,
});
expect(schedule.timeoutMs).toBe(60000);
});
});
// ── getSchedule ───────────────────────────────────────────────────
describe("getSchedule", () => {
it("reads a schedule by id", async () => {
const created = await store.createSchedule({
name: "Get test",
command: "echo get",
scheduleType: "daily",
});
const fetched = await store.getSchedule(created.id);
expect(fetched.id).toBe(created.id);
expect(fetched.name).toBe("Get test");
});
it("throws ENOENT for missing schedule", async () => {
await expect(store.getSchedule("nonexistent")).rejects.toThrow("not found");
});
});
// ── listSchedules ─────────────────────────────────────────────────
describe("listSchedules", () => {
it("returns empty array when no schedules", async () => {
const list = await store.listSchedules();
expect(list).toEqual([]);
});
it("returns all schedules sorted by createdAt", async () => {
await store.createSchedule({ name: "A", command: "echo a", scheduleType: "hourly" });
// Ensure different timestamps
await new Promise((r) => setTimeout(r, 5));
await store.createSchedule({ name: "B", command: "echo b", scheduleType: "daily" });
const list = await store.listSchedules();
expect(list).toHaveLength(2);
expect(list[0].name).toBe("A");
expect(list[1].name).toBe("B");
});
});
// ── updateSchedule ────────────────────────────────────────────────
describe("updateSchedule", () => {
it("updates name and command", async () => {
const schedule = await store.createSchedule({
name: "Original",
command: "echo original",
scheduleType: "hourly",
});
// Small delay to ensure different timestamp
await new Promise((r) => setTimeout(r, 5));
const updated = await store.updateSchedule(schedule.id, {
name: "Updated",
command: "echo updated",
});
expect(updated.name).toBe("Updated");
expect(updated.command).toBe("echo updated");
expect(new Date(updated.updatedAt).getTime()).toBeGreaterThanOrEqual(
new Date(schedule.updatedAt).getTime(),
);
});
it("updates schedule type from preset to custom", async () => {
const schedule = await store.createSchedule({
name: "Test",
command: "echo",
scheduleType: "hourly",
});
const updated = await store.updateSchedule(schedule.id, {
scheduleType: "custom",
cronExpression: "*/10 * * * *",
});
expect(updated.scheduleType).toBe("custom");
expect(updated.cronExpression).toBe("*/10 * * * *");
});
it("updates enabled state", async () => {
const schedule = await store.createSchedule({
name: "Toggle",
command: "echo",
scheduleType: "hourly",
});
const disabled = await store.updateSchedule(schedule.id, { enabled: false });
expect(disabled.enabled).toBe(false);
expect(disabled.nextRunAt).toBeUndefined();
const reenabled = await store.updateSchedule(schedule.id, { enabled: true });
expect(reenabled.enabled).toBe(true);
expect(reenabled.nextRunAt).toBeTruthy();
});
it("rejects empty name", async () => {
const schedule = await store.createSchedule({
name: "Test",
command: "echo",
scheduleType: "hourly",
});
await expect(
store.updateSchedule(schedule.id, { name: " " }),
).rejects.toThrow("Name cannot be empty");
});
it("rejects invalid cron on custom type", async () => {
const schedule = await store.createSchedule({
name: "Test",
command: "echo",
scheduleType: "hourly",
});
await expect(
store.updateSchedule(schedule.id, {
scheduleType: "custom",
cronExpression: "bad cron",
}),
).rejects.toThrow("Invalid cron expression");
});
it("emits schedule:updated event", async () => {
const schedule = await store.createSchedule({
name: "Event test",
command: "echo",
scheduleType: "hourly",
});
const listener = vi.fn();
store.on("schedule:updated", listener);
await store.updateSchedule(schedule.id, { name: "Updated" });
expect(listener).toHaveBeenCalledTimes(1);
});
});
// ── deleteSchedule ────────────────────────────────────────────────
describe("deleteSchedule", () => {
it("deletes a schedule", async () => {
const schedule = await store.createSchedule({
name: "Delete me",
command: "echo",
scheduleType: "hourly",
});
const deleted = await store.deleteSchedule(schedule.id);
expect(deleted.id).toBe(schedule.id);
const filePath = join(rootDir, ".kb", "automations", `${schedule.id}.json`);
expect(existsSync(filePath)).toBe(false);
});
it("throws for missing schedule", async () => {
await expect(store.deleteSchedule("nonexistent")).rejects.toThrow("not found");
});
it("emits schedule:deleted event", async () => {
const schedule = await store.createSchedule({
name: "Delete test",
command: "echo",
scheduleType: "hourly",
});
const listener = vi.fn();
store.on("schedule:deleted", listener);
await store.deleteSchedule(schedule.id);
expect(listener).toHaveBeenCalledWith(schedule);
});
});
// ── recordRun ─────────────────────────────────────────────────────
describe("recordRun", () => {
it("records a successful run", async () => {
const schedule = await store.createSchedule({
name: "Run test",
command: "echo hello",
scheduleType: "hourly",
});
const result: AutomationRunResult = {
success: true,
output: "hello\n",
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
};
const updated = await store.recordRun(schedule.id, result);
expect(updated.lastRunAt).toBe(result.startedAt);
expect(updated.lastRunResult).toEqual(result);
expect(updated.runCount).toBe(1);
expect(updated.runHistory).toHaveLength(1);
expect(updated.runHistory[0]).toEqual(result);
expect(updated.nextRunAt).toBeTruthy();
});
it("records a failed run", async () => {
const schedule = await store.createSchedule({
name: "Fail test",
command: "false",
scheduleType: "hourly",
});
const result: AutomationRunResult = {
success: false,
output: "",
error: "Command failed with exit code 1",
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
};
const updated = await store.recordRun(schedule.id, result);
expect(updated.lastRunResult?.success).toBe(false);
expect(updated.lastRunResult?.error).toContain("exit code 1");
expect(updated.runCount).toBe(1);
});
it("caps run history at MAX_RUN_HISTORY", async () => {
const schedule = await store.createSchedule({
name: "History test",
command: "echo",
scheduleType: "hourly",
});
for (let i = 0; i < 55; i++) {
await store.recordRun(schedule.id, {
success: true,
output: `run ${i}`,
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
});
}
const updated = await store.getSchedule(schedule.id);
expect(updated.runHistory.length).toBeLessThanOrEqual(50);
expect(updated.runCount).toBe(55);
});
it("emits schedule:run event", async () => {
const schedule = await store.createSchedule({
name: "Event test",
command: "echo",
scheduleType: "hourly",
});
const listener = vi.fn();
store.on("schedule:run", listener);
const result: AutomationRunResult = {
success: true,
output: "ok",
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
};
await store.recordRun(schedule.id, result);
expect(listener).toHaveBeenCalledTimes(1);
expect(listener.mock.calls[0][0].result).toEqual(result);
});
});
// ── getDueSchedules ───────────────────────────────────────────────
describe("getDueSchedules", () => {
it("returns schedules that are due", async () => {
const schedule = await store.createSchedule({
name: "Due test",
command: "echo",
scheduleType: "hourly",
});
// Force nextRunAt to the past by writing directly
const filePath = join(rootDir, ".kb", "automations", `${schedule.id}.json`);
const { readFile: rf, writeFile: wf } = await import("node:fs/promises");
const raw = await rf(filePath, "utf-8");
const parsed = JSON.parse(raw) as ScheduledTask;
parsed.nextRunAt = new Date(Date.now() - 60000).toISOString();
await wf(filePath, JSON.stringify(parsed, null, 2));
const due = await store.getDueSchedules();
expect(due.length).toBeGreaterThanOrEqual(1);
expect(due.some((d) => d.id === schedule.id)).toBe(true);
});
it("excludes disabled schedules", async () => {
const schedule = await store.createSchedule({
name: "Disabled test",
command: "echo",
scheduleType: "hourly",
enabled: false,
});
const due = await store.getDueSchedules();
expect(due.some((d) => d.id === schedule.id)).toBe(false);
});
it("excludes schedules with future nextRunAt", async () => {
const schedule = await store.createSchedule({
name: "Future test",
command: "echo",
scheduleType: "hourly",
});
// nextRunAt is in the future by default
const due = await store.getDueSchedules();
expect(due.some((d) => d.id === schedule.id)).toBe(false);
});
});
// ── Concurrent write safety ───────────────────────────────────────
describe("concurrency", () => {
it("handles concurrent updates safely", async () => {
const schedule = await store.createSchedule({
name: "Concurrent",
command: "echo",
scheduleType: "hourly",
});
// Fire multiple concurrent updates
const updates = Array.from({ length: 10 }, (_, i) =>
store.recordRun(schedule.id, {
success: true,
output: `run ${i}`,
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
}),
);
await Promise.all(updates);
const final = await store.getSchedule(schedule.id);
expect(final.runCount).toBe(10);
expect(final.runHistory).toHaveLength(10);
});
});
});

View File

@@ -0,0 +1,306 @@
import { EventEmitter } from "node:events";
import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { existsSync } from "node:fs";
import { randomUUID } from "node:crypto";
import { CronExpressionParser } from "cron-parser";
import type {
ScheduledTask,
ScheduledTaskCreateInput,
ScheduledTaskUpdateInput,
AutomationRunResult,
} from "./automation.js";
import { AUTOMATION_PRESETS, MAX_RUN_HISTORY } from "./automation.js";
import type { ScheduleType } from "./automation.js";
export interface AutomationStoreEvents {
"schedule:created": [schedule: ScheduledTask];
"schedule:updated": [schedule: ScheduledTask];
"schedule:deleted": [schedule: ScheduledTask];
"schedule:run": [data: { schedule: ScheduledTask; result: AutomationRunResult }];
}
export class AutomationStore extends EventEmitter<AutomationStoreEvents> {
private automationsDir: string;
/** Per-schedule promise chain for serializing writes. */
private scheduleLocks: Map<string, Promise<void>> = new Map();
constructor(private rootDir: string) {
super();
this.automationsDir = join(rootDir, ".kb", "automations");
}
/** Create the .kb/automations/ directory if it doesn't exist. */
async init(): Promise<void> {
await mkdir(this.automationsDir, { recursive: true });
}
// ── Locking ────────────────────────────────────────────────────────
/**
* Serialize all mutations to a given schedule's JSON file by chaining promises.
* Concurrent callers for the same ID will queue behind each other.
*/
private withScheduleLock<T>(id: string, fn: () => Promise<T>): Promise<T> {
const prev = this.scheduleLocks.get(id) ?? Promise.resolve();
let resolve: () => void;
const next = new Promise<void>((r) => { resolve = r; });
this.scheduleLocks.set(id, next);
return prev.then(async () => {
try {
return await fn();
} finally {
if (this.scheduleLocks.get(id) === next) {
this.scheduleLocks.delete(id);
}
resolve!();
}
});
}
// ── File I/O ───────────────────────────────────────────────────────
private schedulePath(id: string): string {
return join(this.automationsDir, `${id}.json`);
}
private async readScheduleJson(id: string): Promise<ScheduledTask> {
const filePath = this.schedulePath(id);
const raw = await readFile(filePath, "utf-8");
try {
return JSON.parse(raw) as ScheduledTask;
} catch (err) {
throw new Error(
`Failed to parse schedule JSON at ${filePath}: ${(err as Error).message}`,
);
}
}
/**
* Atomically write a schedule JSON file by writing to a temp file first,
* then renaming it into place.
*/
private async atomicWriteScheduleJson(id: string, schedule: ScheduledTask): Promise<void> {
const filePath = this.schedulePath(id);
const tmpPath = filePath + ".tmp";
await writeFile(tmpPath, JSON.stringify(schedule, null, 2));
await rename(tmpPath, filePath);
}
// ── Cron Computation ───────────────────────────────────────────────
/**
* Compute the next run time from a cron expression.
* @param cronExpression - A valid cron expression (5 fields).
* @param fromDate - The date to compute from. Defaults to now.
* @returns ISO-8601 timestamp of the next run.
*/
computeNextRun(cronExpression: string, fromDate?: Date): string {
const interval = CronExpressionParser.parse(cronExpression, {
currentDate: fromDate ?? new Date(),
});
const next = interval.next();
return next.toISOString() ?? new Date(next.getTime()).toISOString();
}
/**
* Validate a cron expression. Returns true if valid.
*/
static isValidCron(cronExpression: string): boolean {
try {
CronExpressionParser.parse(cronExpression);
return true;
} catch {
return false;
}
}
// ── CRUD ───────────────────────────────────────────────────────────
async createSchedule(input: ScheduledTaskCreateInput): Promise<ScheduledTask> {
if (!input.name?.trim()) {
throw new Error("Name is required and cannot be empty");
}
if (!input.command?.trim()) {
throw new Error("Command is required and cannot be empty");
}
// Resolve cron expression
let cronExpression: string;
if (input.scheduleType === "custom") {
if (!input.cronExpression?.trim()) {
throw new Error("Cron expression is required for custom schedule type");
}
if (!AutomationStore.isValidCron(input.cronExpression)) {
throw new Error(`Invalid cron expression: "${input.cronExpression}"`);
}
cronExpression = input.cronExpression.trim();
} else {
cronExpression = AUTOMATION_PRESETS[input.scheduleType];
}
const id = randomUUID();
const now = new Date().toISOString();
const enabled = input.enabled !== undefined ? input.enabled : true;
const schedule: ScheduledTask = {
id,
name: input.name.trim(),
description: input.description?.trim() || undefined,
scheduleType: input.scheduleType,
cronExpression,
command: input.command.trim(),
enabled,
runCount: 0,
runHistory: [],
timeoutMs: input.timeoutMs,
nextRunAt: enabled ? this.computeNextRun(cronExpression) : undefined,
createdAt: now,
updatedAt: now,
};
await this.atomicWriteScheduleJson(id, schedule);
this.emit("schedule:created", schedule);
return schedule;
}
async getSchedule(id: string): Promise<ScheduledTask> {
const filePath = this.schedulePath(id);
if (!existsSync(filePath)) {
throw Object.assign(new Error(`Schedule '${id}' not found`), { code: "ENOENT" });
}
return this.readScheduleJson(id);
}
async listSchedules(): Promise<ScheduledTask[]> {
if (!existsSync(this.automationsDir)) return [];
const entries = await readdir(this.automationsDir);
const schedules: ScheduledTask[] = [];
for (const entry of entries) {
if (!entry.endsWith(".json") || entry.endsWith(".tmp")) continue;
const id = entry.replace(/\.json$/, "");
try {
schedules.push(await this.readScheduleJson(id));
} catch {
// skip invalid files
}
}
return schedules.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
}
async updateSchedule(id: string, updates: ScheduledTaskUpdateInput): Promise<ScheduledTask> {
return this.withScheduleLock(id, async () => {
const schedule = await this.getSchedule(id);
if (updates.name !== undefined) {
if (!updates.name.trim()) throw new Error("Name cannot be empty");
schedule.name = updates.name.trim();
}
if (updates.description !== undefined) {
schedule.description = updates.description?.trim() || undefined;
}
if (updates.command !== undefined) {
if (!updates.command.trim()) throw new Error("Command cannot be empty");
schedule.command = updates.command.trim();
}
if (updates.timeoutMs !== undefined) {
schedule.timeoutMs = updates.timeoutMs;
}
// Handle schedule type / cron changes
if (updates.scheduleType !== undefined || updates.cronExpression !== undefined) {
const newType = updates.scheduleType ?? schedule.scheduleType;
let newCron: string;
if (newType === "custom") {
const customCron = updates.cronExpression ?? schedule.cronExpression;
if (!customCron?.trim()) {
throw new Error("Cron expression is required for custom schedule type");
}
if (!AutomationStore.isValidCron(customCron)) {
throw new Error(`Invalid cron expression: "${customCron}"`);
}
newCron = customCron.trim();
} else {
newCron = AUTOMATION_PRESETS[newType as Exclude<ScheduleType, "custom">];
}
schedule.scheduleType = newType;
schedule.cronExpression = newCron;
}
if (updates.enabled !== undefined) {
schedule.enabled = updates.enabled;
}
// Recompute next run if enabled
if (schedule.enabled) {
schedule.nextRunAt = this.computeNextRun(schedule.cronExpression);
} else {
schedule.nextRunAt = undefined;
}
schedule.updatedAt = new Date().toISOString();
await this.atomicWriteScheduleJson(id, schedule);
this.emit("schedule:updated", schedule);
return schedule;
});
}
async deleteSchedule(id: string): Promise<ScheduledTask> {
return this.withScheduleLock(id, async () => {
const schedule = await this.getSchedule(id);
const filePath = this.schedulePath(id);
const { unlink } = await import("node:fs/promises");
await unlink(filePath);
this.emit("schedule:deleted", schedule);
return schedule;
});
}
/**
* Record a run result for a schedule. Updates lastRunAt, lastRunResult,
* nextRunAt, runCount, and appends to runHistory.
*/
async recordRun(id: string, result: AutomationRunResult): Promise<ScheduledTask> {
return this.withScheduleLock(id, async () => {
const schedule = await this.getSchedule(id);
schedule.lastRunAt = result.startedAt;
schedule.lastRunResult = result;
schedule.runCount += 1;
// Prepend to history (most recent first), cap at MAX_RUN_HISTORY
schedule.runHistory.unshift(result);
if (schedule.runHistory.length > MAX_RUN_HISTORY) {
schedule.runHistory = schedule.runHistory.slice(0, MAX_RUN_HISTORY);
}
// Recompute next run
if (schedule.enabled) {
schedule.nextRunAt = this.computeNextRun(schedule.cronExpression);
}
schedule.updatedAt = new Date().toISOString();
await this.atomicWriteScheduleJson(id, schedule);
this.emit("schedule:run", { schedule, result });
return schedule;
});
}
/**
* Get all schedules that are due to run (nextRunAt <= now and enabled).
*/
async getDueSchedules(): Promise<ScheduledTask[]> {
const schedules = await this.listSchedules();
const now = new Date().toISOString();
return schedules.filter(
(s) => s.enabled && s.nextRunAt && s.nextRunAt <= now,
);
}
}

View File

@@ -0,0 +1,79 @@
/** Schedule type presets plus a custom cron option. */
export type ScheduleType = "hourly" | "daily" | "weekly" | "monthly" | "custom";
/** Mapping from preset schedule types to their cron expressions. */
export const AUTOMATION_PRESETS: Record<Exclude<ScheduleType, "custom">, string> = {
hourly: "0 * * * *",
daily: "0 0 * * *",
weekly: "0 0 * * 1",
monthly: "0 0 1 * *",
};
/** Result of a single automation run. */
export interface AutomationRunResult {
success: boolean;
output: string;
error?: string;
startedAt: string;
completedAt: string;
}
/** A scheduled automation task. */
export interface ScheduledTask {
/** Unique identifier for this schedule (UUID). */
id: string;
/** Human-readable name for this schedule. */
name: string;
/** Optional description of what this schedule does. */
description?: string;
/** The type of schedule — preset or custom. */
scheduleType: ScheduleType;
/** The cron expression (auto-derived from preset or user-supplied for custom). */
cronExpression: string;
/** The shell command to execute. */
command: string;
/** Whether this schedule is currently active. */
enabled: boolean;
/** ISO-8601 timestamp of the last run start, if any. */
lastRunAt?: string;
/** Result of the most recent run, if any. */
lastRunResult?: AutomationRunResult;
/** ISO-8601 timestamp of the next scheduled run. */
nextRunAt?: string;
/** Total number of runs executed. */
runCount: number;
/** Per-schedule execution timeout in milliseconds. Default: 300000 (5 min). */
timeoutMs?: number;
/** History of recent run results (most recent first, capped at 50). */
runHistory: AutomationRunResult[];
/** ISO-8601 timestamp of when this schedule was created. */
createdAt: string;
/** ISO-8601 timestamp of when this schedule was last updated. */
updatedAt: string;
}
/** Input for creating a new scheduled task. */
export interface ScheduledTaskCreateInput {
name: string;
description?: string;
scheduleType: ScheduleType;
/** Required for 'custom' type; ignored for presets (auto-derived). */
cronExpression?: string;
command: string;
enabled?: boolean;
timeoutMs?: number;
}
/** Input for updating an existing scheduled task. */
export interface ScheduledTaskUpdateInput {
name?: string;
description?: string;
scheduleType?: ScheduleType;
cronExpression?: string;
command?: string;
enabled?: boolean;
timeoutMs?: number;
}
/** Maximum number of run history entries to retain per schedule. */
export const MAX_RUN_HISTORY = 50;

View File

@@ -15,3 +15,7 @@ export {
getCurrentRepo,
type GhError,
} from "./gh-cli.js";
export { AUTOMATION_PRESETS, MAX_RUN_HISTORY } from "./automation.js";
export type { ScheduleType, ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult } from "./automation.js";
export { AutomationStore } from "./automation-store.js";
export type { AutomationStoreEvents } from "./automation-store.js";