Files
fusion/packages/core/src/automation-store.ts
gsxdsm 4fc58e0bba perf(core): opt-in in-memory SQLite for *-store tests
Adds an opt-in `inMemory` flag to `Database`/`ArchiveDatabase` (and
`{ inMemoryDb }` to TaskStore, AgentStore, RoutineStore,
AutomationStore, PluginStore) that swaps the on-disk fusion.db /
archive.db for SQLite's `:memory:` connection. Production callers
never set the flag, so behavior is unchanged.

Test files for each store now flip the flag in `beforeEach`. The
handful of tests that exercise cross-instance persistence (open store
A, close, open store B on same dir, expect data) construct disk-backed
stores explicitly inside the test body, marked with a comment at each
site.

Wall-clock impact:
- core:      69.4s → 18.5s  (3.7× faster, 3038 tests)
- dashboard: 156.6s → 30.0s (5.2× faster — improvement ripples through
                              any test that constructs a TaskStore)

The refactor eliminates the per-test SQLite open + WAL fsync + tmp
dir cleanup loop that dominated setup cost: ~50ms/test → ~5ms/test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 18:45:14 -07:00

426 lines
15 KiB
TypeScript

import { EventEmitter } from "node:events";
import { join } from "node:path";
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";
import { Database, fromJson } from "./db.js";
const CRON_TIMEZONE = "UTC";
export interface AutomationStoreEvents {
"schedule:created": [schedule: ScheduledTask];
"schedule:updated": [schedule: ScheduledTask];
"schedule:deleted": [schedule: ScheduledTask];
"schedule:run": [data: { schedule: ScheduledTask; result: AutomationRunResult }];
}
/** Database row shape for the automations table. */
interface ScheduleRow {
id: string;
name: string;
description: string | null;
scheduleType: string;
cronExpression: string;
command: string;
enabled: number;
timeoutMs: number | null;
steps: string | null;
nextRunAt: string | null;
lastRunAt: string | null;
lastRunResult: string | null;
runCount: number;
runHistory: string;
scope: string;
createdAt: string;
updatedAt: string;
}
export class AutomationStore extends EventEmitter<AutomationStoreEvents> {
/** Per-schedule promise chain for serializing writes. */
private scheduleLocks: Map<string, Promise<void>> = new Map();
/** SQLite database instance */
private _db: Database | null = null;
private readonly inMemoryDb: boolean;
constructor(private rootDir: string, options?: { inMemoryDb?: boolean }) {
super();
this.inMemoryDb = options?.inMemoryDb === true;
}
/**
* Get the SQLite database, initializing it on first access.
*/
private get db(): Database {
if (!this._db) {
const fusionDir = join(this.rootDir, ".fusion");
this._db = new Database(fusionDir, { inMemory: this.inMemoryDb });
this._db.init();
}
return this._db;
}
/** Initialize the store. */
async init(): Promise<void> {
// Ensure DB is initialized
const _ = this.db;
}
// ── Row Conversion ─────────────────────────────────────────────────
private rowToSchedule(row: ScheduleRow): ScheduledTask {
return {
id: row.id,
name: row.name,
description: row.description || undefined,
scheduleType: row.scheduleType as ScheduleType,
cronExpression: row.cronExpression,
command: row.command,
enabled: row.enabled === 1,
timeoutMs: row.timeoutMs ?? undefined,
steps: fromJson<ScheduledTask["steps"]>(row.steps),
nextRunAt: row.nextRunAt || undefined,
lastRunAt: row.lastRunAt || undefined,
lastRunResult: fromJson<AutomationRunResult>(row.lastRunResult),
runCount: row.runCount || 0,
runHistory: fromJson<AutomationRunResult[]>(row.runHistory) || [],
scope: (row.scope as "global" | "project") || "project",
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
private upsertSchedule(schedule: ScheduledTask): void {
this.db.prepare(`
INSERT OR REPLACE INTO automations (
id, name, description, scheduleType, cronExpression, command,
enabled, timeoutMs, steps, nextRunAt, lastRunAt, lastRunResult,
runCount, runHistory, scope, createdAt, updatedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
schedule.id,
schedule.name,
schedule.description ?? null,
schedule.scheduleType,
schedule.cronExpression,
schedule.command,
schedule.enabled ? 1 : 0,
schedule.timeoutMs ?? null,
schedule.steps ? JSON.stringify(schedule.steps) : null,
schedule.nextRunAt ?? null,
schedule.lastRunAt ?? null,
schedule.lastRunResult ? JSON.stringify(schedule.lastRunResult) : null,
schedule.runCount || 0,
JSON.stringify(schedule.runHistory || []),
schedule.scope ?? "project",
schedule.createdAt,
schedule.updatedAt,
);
}
// ── Locking ────────────────────────────────────────────────────────
/**
* Serialize all mutations to a given schedule 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!();
}
});
}
// ── Persistence ────────────────────────────────────────────────────
private async readScheduleJson(id: string): Promise<ScheduledTask> {
const row = this.db.prepare('SELECT * FROM automations WHERE id = ?').get(id) as unknown as ScheduleRow | undefined;
if (!row) {
throw Object.assign(new Error(`Schedule '${id}' not found`), { code: "ENOENT" });
}
return this.rowToSchedule(row);
}
private async persistSchedule(schedule: ScheduledTask): Promise<void> {
this.upsertSchedule(schedule);
this.db.bumpLastModified();
}
// ── 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(),
tz: CRON_TIMEZONE,
});
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");
}
const hasSteps = input.steps && input.steps.length > 0;
if (!hasSteps && !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,
steps: hasSteps ? input.steps : undefined,
nextRunAt: enabled ? this.computeNextRun(cronExpression) : undefined,
scope: input.scope ?? "project",
createdAt: now,
updatedAt: now,
};
await this.persistSchedule(schedule);
this.emit("schedule:created", schedule);
return schedule;
}
async getSchedule(id: string): Promise<ScheduledTask> {
return this.readScheduleJson(id);
}
async listSchedules(): Promise<ScheduledTask[]> {
const rows = this.db.prepare('SELECT * FROM automations ORDER BY createdAt ASC').all() as unknown as ScheduleRow[];
return rows.map((row) => this.rowToSchedule(row));
}
async updateSchedule(id: string, updates: ScheduledTaskUpdateInput): Promise<ScheduledTask> {
return this.withScheduleLock(id, async () => {
const schedule = await this.getSchedule(id);
const previousEnabled = schedule.enabled;
const previousScheduleType = schedule.scheduleType;
const previousCronExpression = schedule.cronExpression;
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.steps !== undefined) {
schedule.steps = updates.steps.length > 0 ? updates.steps : undefined;
}
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;
}
const cadenceChanged =
schedule.scheduleType !== previousScheduleType ||
schedule.cronExpression !== previousCronExpression;
const enabledFromDisabled = !previousEnabled && schedule.enabled;
const missingNextRunAt = !schedule.nextRunAt;
if (!schedule.enabled) {
schedule.nextRunAt = undefined;
} else if (cadenceChanged || enabledFromDisabled || missingNextRunAt) {
schedule.nextRunAt = this.computeNextRun(schedule.cronExpression);
}
schedule.updatedAt = new Date().toISOString();
await this.persistSchedule(schedule);
this.emit("schedule:updated", schedule);
return schedule;
});
}
/**
* Reorder the steps of a schedule by providing the step IDs in the desired order.
* The `stepIds` array must contain exactly the same IDs as the current steps.
*/
async reorderSteps(scheduleId: string, stepIds: string[]): Promise<ScheduledTask> {
return this.withScheduleLock(scheduleId, async () => {
const schedule = await this.getSchedule(scheduleId);
if (!schedule.steps || schedule.steps.length === 0) {
throw new Error("Schedule has no steps to reorder");
}
if (stepIds.length !== schedule.steps.length) {
throw new Error(
`Step ID count mismatch: expected ${schedule.steps.length}, got ${stepIds.length}`,
);
}
const stepMap = new Map(schedule.steps.map((s) => [s.id, s]));
const reordered = [];
for (const id of stepIds) {
const step = stepMap.get(id);
if (!step) {
throw new Error(`Unknown step ID: "${id}"`);
}
reordered.push(step);
}
schedule.steps = reordered;
schedule.updatedAt = new Date().toISOString();
await this.persistSchedule(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);
// Delete from SQLite
this.db.prepare('DELETE FROM automations WHERE id = ?').run(id);
this.db.bumpLastModified();
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.persistSchedule(schedule);
this.emit("schedule:run", { schedule, result });
return schedule;
});
}
/**
* Get all schedules that are due to run (nextRunAt <= now and enabled).
* Filters by scope: "global" or "project".
*/
async getDueSchedules(scope: "global" | "project"): Promise<ScheduledTask[]> {
const now = new Date().toISOString();
const rows = this.db.prepare(
'SELECT * FROM automations WHERE enabled = 1 AND nextRunAt IS NOT NULL AND nextRunAt <= ? AND scope = ?'
).all(now, scope) as unknown as ScheduleRow[];
return rows.map((row) => this.rowToSchedule(row));
}
/**
* Get all schedules that are due to run (nextRunAt <= now and enabled) for both scopes.
* Returns schedules from both "global" and "project" scopes.
*/
async getDueSchedulesAllScopes(): Promise<ScheduledTask[]> {
const now = new Date().toISOString();
const rows = this.db.prepare(
'SELECT * FROM automations WHERE enabled = 1 AND nextRunAt IS NOT NULL AND nextRunAt <= ?'
).all(now) as unknown as ScheduleRow[];
return rows.map((row) => this.rowToSchedule(row));
}
}