FN-5407 adds paired central backup support to the Fusion task management system, with both the core backup engine and CLI commands updated to handle central database backup pairs. Documentation was updated to reflect the new capability, and two stabilization fixes were included to handle central bac Fusion-Task-Id: FN-5407
1983 lines
77 KiB
TypeScript
1983 lines
77 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { CronRunner, createAiPromptExecutor, isInProcessBackupCommand, isInProcessMemoryBackupCommand, isInProcessScheduledEvalCommand } from "../cron-runner.js";
|
|
import type { AiPromptExecutor } from "../cron-runner.js";
|
|
import type { TaskStore, AutomationStore, ScheduledTask, AutomationRunResult, AutomationStep, Settings } from "@fusion/core";
|
|
import { randomUUID } from "node:crypto";
|
|
|
|
const cronLoggerSpies = vi.hoisted(() => ({
|
|
log: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
}));
|
|
|
|
const piModuleMocks = vi.hoisted(() => ({
|
|
createFnAgent: vi.fn(),
|
|
promptWithFallback: vi.fn(),
|
|
}));
|
|
|
|
const coreModuleMocks = vi.hoisted(() => ({
|
|
runMemoryBackupCommand: vi.fn(),
|
|
runBackupCommand: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../logger.js", () => ({
|
|
createLogger: () => ({
|
|
log: cronLoggerSpies.log,
|
|
warn: cronLoggerSpies.warn,
|
|
error: cronLoggerSpies.error,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("../pi.js", () => ({
|
|
createFnAgent: piModuleMocks.createFnAgent,
|
|
promptWithFallback: piModuleMocks.promptWithFallback,
|
|
}));
|
|
|
|
vi.mock("@fusion/core", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("@fusion/core")>();
|
|
return {
|
|
...actual,
|
|
runMemoryBackupCommand: coreModuleMocks.runMemoryBackupCommand,
|
|
runBackupCommand: coreModuleMocks.runBackupCommand,
|
|
};
|
|
});
|
|
|
|
// Default settings inline to avoid @fusion/core build dependency during tests
|
|
const DEFAULT_SETTINGS: Settings = {
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 30000,
|
|
autoResolveConflicts: true,
|
|
requirePlanApproval: false,
|
|
recycleWorktrees: false,
|
|
worktreeNaming: "random",
|
|
globalPause: false,
|
|
enginePaused: false,
|
|
ntfyEnabled: false,
|
|
defaultProvider: "anthropic",
|
|
defaultModelId: "claude-sonnet-4-5",
|
|
planningProvider: "anthropic",
|
|
planningModelId: "claude-sonnet-4-5",
|
|
validatorProvider: "openai",
|
|
validatorModelId: "gpt-4o",
|
|
taskStuckTimeoutMs: undefined,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: true,
|
|
};
|
|
|
|
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),
|
|
getDueSchedulesAllScopes: 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;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
piModuleMocks.promptWithFallback.mockResolvedValue(undefined);
|
|
piModuleMocks.createFnAgent.mockResolvedValue({
|
|
session: {
|
|
dispose: vi.fn(),
|
|
},
|
|
});
|
|
coreModuleMocks.runMemoryBackupCommand.mockResolvedValue({
|
|
success: true,
|
|
output: "memory backup ok",
|
|
});
|
|
coreModuleMocks.runBackupCommand.mockResolvedValue({
|
|
success: true,
|
|
output: "Backup created: fusion-2026-01-01-000000.db + fusion-central-2026-01-01-000000.db (1 MB).",
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
it("is safe to stop when not started", () => {
|
|
const store = createMockStore();
|
|
const automationStore = createMockAutomationStore();
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
// Should not throw
|
|
expect(() => runner.stop()).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("createAiPromptExecutor", () => {
|
|
it("returns response text even when session disposal throws", async () => {
|
|
piModuleMocks.createFnAgent.mockImplementation(async (options: { onText?: (delta: string) => void }) => {
|
|
options.onText?.("hello ");
|
|
options.onText?.("world");
|
|
return {
|
|
session: {
|
|
dispose: () => {
|
|
throw new Error("dispose failed");
|
|
},
|
|
},
|
|
};
|
|
});
|
|
|
|
const executor = await createAiPromptExecutor("/test/project");
|
|
const result = await executor("Summarize this");
|
|
|
|
expect(result).toBe("hello world");
|
|
expect(piModuleMocks.promptWithFallback).toHaveBeenCalledTimes(1);
|
|
expect(cronLoggerSpies.warn).toHaveBeenCalledWith(
|
|
expect.stringContaining("Session disposal failed: dispose failed"),
|
|
);
|
|
});
|
|
});
|
|
|
|
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("handles errors in tick gracefully", async () => {
|
|
const store = createMockStore();
|
|
const automationStore = createMockAutomationStore([createMockSchedule()]);
|
|
|
|
// Make getDueSchedules throw an error
|
|
(automationStore.getDueSchedules as ReturnType<typeof vi.fn>).mockRejectedValue(
|
|
new Error("Database error")
|
|
);
|
|
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
// Should not throw
|
|
await expect(runner.tick()).resolves.toBeUndefined();
|
|
});
|
|
|
|
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("clears inFlight even when recordRun throws", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({ command: "echo in-flight-cleanup" });
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
(automationStore.recordRun as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("record failed"));
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(runner["inFlight"].has(schedule.id)).toBe(false);
|
|
});
|
|
|
|
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");
|
|
});
|
|
|
|
it("calls recordRun before callback on success", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({ command: "echo success" });
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
|
|
const callOrder: string[] = [];
|
|
const onProcessed = vi.fn().mockImplementation(() => {
|
|
callOrder.push("callback");
|
|
});
|
|
|
|
runner = new CronRunner(store, automationStore, { onScheduleRunProcessed: onProcessed });
|
|
await runner.executeSchedule(schedule);
|
|
|
|
expect(callOrder).toContain("callback");
|
|
// recordRun should have been called first
|
|
expect(automationStore.recordRun).toHaveBeenCalledBefore(onProcessed);
|
|
});
|
|
|
|
it("invokes callback on successful execution", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({ command: "echo success" });
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
|
|
const onProcessed = vi.fn();
|
|
runner = new CronRunner(store, automationStore, { onScheduleRunProcessed: onProcessed });
|
|
|
|
await runner.executeSchedule(schedule);
|
|
|
|
expect(onProcessed).toHaveBeenCalledTimes(1);
|
|
expect(onProcessed).toHaveBeenCalledWith(
|
|
schedule,
|
|
expect.objectContaining({ success: true }),
|
|
);
|
|
});
|
|
|
|
it("invokes callback on failed execution", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({ command: "exit 1" });
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
|
|
const onProcessed = vi.fn();
|
|
runner = new CronRunner(store, automationStore, { onScheduleRunProcessed: onProcessed });
|
|
|
|
await runner.executeSchedule(schedule);
|
|
|
|
expect(onProcessed).toHaveBeenCalledTimes(1);
|
|
expect(onProcessed).toHaveBeenCalledWith(
|
|
schedule,
|
|
expect.objectContaining({ success: false }),
|
|
);
|
|
});
|
|
|
|
it("does not invoke callback when not provided", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({ command: "echo test" });
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
// Should not throw
|
|
await runner.executeSchedule(schedule);
|
|
});
|
|
|
|
it("callback errors do not throw or alter returned result", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({ command: "echo success" });
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
|
|
const onProcessed = vi.fn().mockRejectedValue(new Error("callback failed"));
|
|
runner = new CronRunner(store, automationStore, { onScheduleRunProcessed: onProcessed });
|
|
|
|
// Should not throw
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.output).toContain("success");
|
|
});
|
|
|
|
it("callback errors do not prevent recordRun", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({ command: "echo test" });
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
|
|
const onProcessed = vi.fn().mockRejectedValue(new Error("callback failed"));
|
|
runner = new CronRunner(store, automationStore, { onScheduleRunProcessed: onProcessed });
|
|
|
|
await runner.executeSchedule(schedule);
|
|
|
|
expect(automationStore.recordRun).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("passes schedule and result to callback", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({
|
|
id: "custom-id",
|
|
name: "Custom Schedule",
|
|
command: "echo custom",
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
|
|
let receivedSchedule: ScheduledTask | undefined;
|
|
let receivedResult: AutomationRunResult | undefined;
|
|
const onProcessed = vi.fn().mockImplementation((s: ScheduledTask, r: AutomationRunResult) => {
|
|
receivedSchedule = s;
|
|
receivedResult = r;
|
|
});
|
|
|
|
runner = new CronRunner(store, automationStore, { onScheduleRunProcessed: onProcessed });
|
|
await runner.executeSchedule(schedule);
|
|
|
|
expect(receivedSchedule).toEqual(schedule);
|
|
expect(receivedResult).toBeDefined();
|
|
expect(receivedResult!.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
// ── Multi-step execution ──────────────────────────────────────────
|
|
|
|
describe("multi-step execution", () => {
|
|
function makeStep(overrides: Partial<AutomationStep> = {}): AutomationStep {
|
|
return {
|
|
id: randomUUID(),
|
|
type: "command",
|
|
name: "Test step",
|
|
command: "echo step-output",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
it("legacy single-command mode still works when no steps are defined", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({ command: "echo legacy-mode" });
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.output).toContain("legacy-mode");
|
|
expect(result.stepResults).toBeUndefined();
|
|
});
|
|
|
|
it("falls back to legacy mode when steps array is empty", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({
|
|
command: "echo empty-steps-fallback",
|
|
steps: [],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.output).toContain("empty-steps-fallback");
|
|
expect(result.stepResults).toBeUndefined();
|
|
});
|
|
|
|
it("executes multiple command steps sequentially", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [
|
|
makeStep({ name: "Step 1", command: "echo first" }),
|
|
makeStep({ name: "Step 2", command: "echo second" }),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.stepResults).toHaveLength(2);
|
|
expect(result.stepResults![0].success).toBe(true);
|
|
expect(result.stepResults![0].output).toContain("first");
|
|
expect(result.stepResults![1].success).toBe(true);
|
|
expect(result.stepResults![1].output).toContain("second");
|
|
});
|
|
|
|
it("stops on step failure when continueOnFailure is false", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [
|
|
makeStep({ name: "Failing step", command: "exit 1" }),
|
|
makeStep({ name: "Should not run", command: "echo should-not-run" }),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.stepResults).toHaveLength(1); // Only the first step ran
|
|
expect(result.stepResults![0].success).toBe(false);
|
|
expect(result.error).toContain("1 step(s) failed");
|
|
expect(result.error).toContain("execution stopped");
|
|
});
|
|
|
|
it("continues after failure when continueOnFailure is true", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [
|
|
makeStep({ name: "Failing step", command: "exit 1", continueOnFailure: true }),
|
|
makeStep({ name: "Should still run", command: "echo continued" }),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(false); // Overall failure
|
|
expect(result.stepResults).toHaveLength(2); // Both steps ran
|
|
expect(result.stepResults![0].success).toBe(false);
|
|
expect(result.stepResults![1].success).toBe(true);
|
|
expect(result.stepResults![1].output).toContain("continued");
|
|
});
|
|
|
|
it("continueOnFailure also advances after a create-task failure", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [
|
|
makeStep({
|
|
type: "create-task",
|
|
name: "Invalid create task",
|
|
taskDescription: "",
|
|
continueOnFailure: true,
|
|
command: undefined,
|
|
}),
|
|
makeStep({ name: "Still runs", command: "echo still-ran" }),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.stepResults).toHaveLength(2);
|
|
expect(result.stepResults![0].success).toBe(false);
|
|
expect(result.stepResults![1].success).toBe(true);
|
|
expect(result.stepResults![1].output).toContain("still-ran");
|
|
});
|
|
|
|
it("uses per-step timeout override", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
timeoutMs: 30000, // schedule-level timeout (large)
|
|
steps: [
|
|
makeStep({ name: "Slow step", command: "sleep 60", timeoutMs: 100 }), // step-level timeout (tiny)
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.stepResults).toHaveLength(1);
|
|
expect(result.stepResults![0].error).toContain("timed out");
|
|
}, 10000);
|
|
|
|
it("falls back to schedule-level timeout when step has no timeout", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
timeoutMs: 100, // tiny schedule-level timeout
|
|
steps: [
|
|
makeStep({ name: "Slow step", command: "sleep 60" }), // no step-level timeout
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.stepResults![0].error).toContain("timed out");
|
|
}, 10000);
|
|
|
|
it("handles AI prompt step execution with executor", async () => {
|
|
const store = createMockStore();
|
|
const mockFn = vi.fn()
|
|
.mockResolvedValue("AI analysis complete: no issues found");
|
|
const mockExecutor = mockFn as unknown as AiPromptExecutor;
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [
|
|
makeStep({
|
|
type: "ai-prompt",
|
|
name: "AI Analysis",
|
|
prompt: "Analyze the codebase",
|
|
modelProvider: "anthropic",
|
|
modelId: "claude-sonnet-4-5",
|
|
command: undefined,
|
|
}),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore, { aiPromptExecutor: mockExecutor });
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.stepResults).toHaveLength(1);
|
|
expect(result.stepResults![0].success).toBe(true);
|
|
expect(result.stepResults![0].output).toContain("AI analysis complete");
|
|
// Verify executor was called with correct model params from step
|
|
expect(mockExecutor).toHaveBeenCalledWith(
|
|
"Analyze the codebase",
|
|
"anthropic",
|
|
"claude-sonnet-4-5",
|
|
);
|
|
});
|
|
|
|
it("fails AI prompt step when no prompt is provided", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [
|
|
makeStep({
|
|
type: "ai-prompt",
|
|
name: "Empty prompt",
|
|
prompt: "",
|
|
command: undefined,
|
|
}),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.stepResults![0].error).toContain("no prompt specified");
|
|
});
|
|
|
|
it("fails command step when no command is provided", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [
|
|
makeStep({ name: "Empty command", command: "" }),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.stepResults![0].error).toContain("no command specified");
|
|
});
|
|
|
|
it("handles unknown step type gracefully", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [
|
|
makeStep({ name: "Unknown step", type: "unknown-type" as any }),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.stepResults).toHaveLength(1);
|
|
expect(result.stepResults![0].success).toBe(false);
|
|
expect(result.stepResults![0].error).toContain("Unknown step type");
|
|
});
|
|
|
|
it("aggregates output from all steps with headers", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [
|
|
makeStep({ name: "Alpha", command: "echo alpha-output" }),
|
|
makeStep({ name: "Beta", command: "echo beta-output" }),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.output).toContain("Step 1: Alpha");
|
|
expect(result.output).toContain("alpha-output");
|
|
expect(result.output).toContain("Step 2: Beta");
|
|
expect(result.output).toContain("beta-output");
|
|
});
|
|
|
|
it("records run result with stepResults to automation store", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [makeStep({ name: "S1", command: "echo ok" })],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
await runner.executeSchedule(schedule);
|
|
|
|
expect(automationStore.recordRun).toHaveBeenCalledWith(
|
|
schedule.id,
|
|
expect.objectContaining({
|
|
success: true,
|
|
stepResults: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
stepName: "S1",
|
|
success: true,
|
|
}),
|
|
]),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("mixed step types execute correctly", async () => {
|
|
const store = createMockStore();
|
|
const mockFn = vi.fn()
|
|
.mockResolvedValue("Summary: all good");
|
|
const mockExecutor = mockFn as unknown as AiPromptExecutor;
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [
|
|
makeStep({ name: "Build", command: "echo build-done" }),
|
|
makeStep({
|
|
type: "ai-prompt",
|
|
name: "Summarize",
|
|
prompt: "Summarize results",
|
|
command: undefined,
|
|
}),
|
|
makeStep({ name: "Deploy", command: "echo deployed" }),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore, { aiPromptExecutor: mockExecutor });
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.stepResults).toHaveLength(3);
|
|
expect(result.stepResults![0].stepName).toBe("Build");
|
|
expect(result.stepResults![1].stepName).toBe("Summarize");
|
|
expect(result.stepResults![2].stepName).toBe("Deploy");
|
|
expect(result.stepResults![1].output).toContain("Summary: all good");
|
|
});
|
|
|
|
// ── AI prompt step execution ────────────────────────────────────────
|
|
|
|
function createAiMockExecutor(
|
|
response: string = "mock AI response",
|
|
): AiPromptExecutor {
|
|
const fn = vi.fn().mockResolvedValue(response);
|
|
return fn as unknown as AiPromptExecutor;
|
|
}
|
|
|
|
it("returns success with AI response when executor is configured", async () => {
|
|
const store = createMockStore();
|
|
const mockExecutor = createAiMockExecutor("Analysis complete: 3 findings");
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [
|
|
makeStep({
|
|
type: "ai-prompt",
|
|
name: "Code Review",
|
|
prompt: "Review the code for issues",
|
|
command: undefined,
|
|
}),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore, { aiPromptExecutor: mockExecutor });
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.stepResults![0].success).toBe(true);
|
|
expect(result.stepResults![0].output).toContain("Analysis complete: 3 findings");
|
|
});
|
|
|
|
it("passes step model provider and model ID to executor", async () => {
|
|
const store = createMockStore();
|
|
const mockExecutor = createAiMockExecutor("response");
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [
|
|
makeStep({
|
|
type: "ai-prompt",
|
|
name: "Step with model",
|
|
prompt: "Do something",
|
|
modelProvider: "openai",
|
|
modelId: "gpt-4o",
|
|
command: undefined,
|
|
}),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore, { aiPromptExecutor: mockExecutor });
|
|
|
|
await runner.executeSchedule(schedule);
|
|
|
|
expect(mockExecutor).toHaveBeenCalledWith("Do something", "openai", "gpt-4o");
|
|
});
|
|
|
|
it("falls back to settings defaults when step has no model", async () => {
|
|
const store = createMockStore({
|
|
defaultProvider: "anthropic",
|
|
defaultModelId: "claude-sonnet-4-5",
|
|
});
|
|
const mockExecutor = createAiMockExecutor("response");
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [
|
|
makeStep({
|
|
type: "ai-prompt",
|
|
name: "Step without model",
|
|
prompt: "Use defaults",
|
|
command: undefined,
|
|
}),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore, { aiPromptExecutor: mockExecutor });
|
|
|
|
await runner.executeSchedule(schedule);
|
|
|
|
expect(mockExecutor).toHaveBeenCalledWith("Use defaults", "anthropic", "claude-sonnet-4-5");
|
|
});
|
|
|
|
it("returns configuration error when no executor is provided", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [
|
|
makeStep({
|
|
type: "ai-prompt",
|
|
name: "No executor",
|
|
prompt: "This should fail",
|
|
command: undefined,
|
|
}),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.stepResults![0].success).toBe(false);
|
|
expect(result.stepResults![0].error).toContain("AI execution is not configured");
|
|
expect(result.stepResults![0].output).toBe("");
|
|
});
|
|
|
|
it("fails early when prompt is empty even with executor configured", async () => {
|
|
const store = createMockStore();
|
|
const mockExecutor = createAiMockExecutor("should not be called");
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [
|
|
makeStep({
|
|
type: "ai-prompt",
|
|
name: "Empty prompt",
|
|
prompt: " ",
|
|
command: undefined,
|
|
}),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore, { aiPromptExecutor: mockExecutor });
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.stepResults![0].error).toContain("no prompt specified");
|
|
expect(mockExecutor).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("handles timeout by returning failure", async () => {
|
|
const store = createMockStore();
|
|
const mockFn = vi.fn()
|
|
.mockImplementation(() => new Promise((resolve) => {
|
|
setTimeout(() => resolve("too late"), 5000);
|
|
}));
|
|
const mockExecutor = mockFn as unknown as AiPromptExecutor;
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
timeoutMs: 100,
|
|
steps: [
|
|
makeStep({
|
|
type: "ai-prompt",
|
|
name: "Slow AI",
|
|
prompt: "Take your time",
|
|
command: undefined,
|
|
}),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore, { aiPromptExecutor: mockExecutor });
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.stepResults![0].success).toBe(false);
|
|
expect(result.stepResults![0].error).toContain("timed out");
|
|
});
|
|
|
|
it("handles executor throwing an error", async () => {
|
|
const store = createMockStore();
|
|
const mockFn = vi.fn()
|
|
.mockRejectedValue(new Error("API rate limit exceeded"));
|
|
const mockExecutor = mockFn as unknown as AiPromptExecutor;
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [
|
|
makeStep({
|
|
type: "ai-prompt",
|
|
name: "Failing AI",
|
|
prompt: "Cause an error",
|
|
command: undefined,
|
|
}),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore, { aiPromptExecutor: mockExecutor });
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.stepResults![0].success).toBe(false);
|
|
expect(result.stepResults![0].error).toContain("API rate limit exceeded");
|
|
});
|
|
|
|
it("truncates output when AI response exceeds MAX_OUTPUT_LENGTH", async () => {
|
|
const store = createMockStore();
|
|
const longResponse = "x".repeat(15_000);
|
|
const mockExecutor = createAiMockExecutor(longResponse);
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [
|
|
makeStep({
|
|
type: "ai-prompt",
|
|
name: "Verbose AI",
|
|
prompt: "Give me lots of text",
|
|
command: undefined,
|
|
}),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore, { aiPromptExecutor: mockExecutor });
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.stepResults![0].success).toBe(true);
|
|
expect(result.stepResults![0].output).toContain("[output truncated]");
|
|
expect(result.stepResults![0].output.length).toBeLessThanOrEqual(10 * 1024 + 50);
|
|
});
|
|
|
|
it("uses per-step timeout override over schedule timeout", async () => {
|
|
const store = createMockStore();
|
|
const mockFn = vi.fn()
|
|
.mockImplementation(() => new Promise((resolve) => {
|
|
setTimeout(() => resolve("too late"), 5000);
|
|
}));
|
|
const mockExecutor = mockFn as unknown as AiPromptExecutor;
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
timeoutMs: 30000,
|
|
steps: [
|
|
makeStep({
|
|
type: "ai-prompt",
|
|
name: "Slow AI",
|
|
prompt: "Take your time",
|
|
timeoutMs: 100,
|
|
command: undefined,
|
|
}),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore, { aiPromptExecutor: mockExecutor });
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.stepResults![0].error).toContain("timed out");
|
|
});
|
|
});
|
|
|
|
// ── Create-task step execution ─────────────────────────────────────────
|
|
|
|
describe("create-task step execution", () => {
|
|
function makeCreateTaskStep(overrides: Partial<AutomationStep> = {}): AutomationStep {
|
|
return {
|
|
id: randomUUID(),
|
|
type: "create-task",
|
|
name: "Create task step",
|
|
taskDescription: "Review dependencies for security vulnerabilities",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
it("successfully creates a task when taskDescription is provided", async () => {
|
|
const mockTask = { id: "FN-1234", title: "Review", description: "Review dependencies" };
|
|
const createTaskMock = vi.fn().mockResolvedValue(mockTask);
|
|
const store = createMockStore({} as any);
|
|
(store as any).createTask = createTaskMock;
|
|
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [makeCreateTaskStep({ taskDescription: "Check npm packages" })],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.stepResults).toHaveLength(1);
|
|
expect(result.stepResults![0].success).toBe(true);
|
|
expect(result.stepResults![0].output).toContain("Created task FN-1234");
|
|
expect(createTaskMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("returns error when taskDescription is missing", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [makeCreateTaskStep({ taskDescription: "" })],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.stepResults).toHaveLength(1);
|
|
expect(result.stepResults![0].success).toBe(false);
|
|
expect(result.stepResults![0].error).toContain("no task description specified");
|
|
});
|
|
|
|
it("passes taskTitle, taskColumn, modelProvider, modelId to store.createTask()", async () => {
|
|
const mockTask = { id: "FN-5678", title: "Weekly Review", description: "Check packages" };
|
|
const createTaskMock = vi.fn().mockResolvedValue(mockTask);
|
|
const store = createMockStore({} as any);
|
|
(store as any).createTask = createTaskMock;
|
|
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [makeCreateTaskStep({
|
|
taskTitle: "Weekly Review",
|
|
taskDescription: "Check packages",
|
|
taskColumn: "todo",
|
|
modelProvider: "anthropic",
|
|
modelId: "claude-sonnet-4-5",
|
|
})],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
await runner.executeSchedule(schedule);
|
|
|
|
expect(createTaskMock).toHaveBeenCalledWith({
|
|
title: "Weekly Review",
|
|
description: "Check packages",
|
|
column: "todo",
|
|
modelProvider: "anthropic",
|
|
modelId: "claude-sonnet-4-5",
|
|
source: {
|
|
sourceType: "cron",
|
|
sourceMetadata: { scheduleId: "test-schedule-id", stepId: expect.any(String) },
|
|
},
|
|
});
|
|
});
|
|
|
|
it("defaults column to triage when taskColumn is not set", async () => {
|
|
const mockTask = { id: "FN-9999", title: "", description: "Some task" };
|
|
const createTaskMock = vi.fn().mockResolvedValue(mockTask);
|
|
const store = createMockStore({} as any);
|
|
(store as any).createTask = createTaskMock;
|
|
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [makeCreateTaskStep({ taskDescription: "Some task" })],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
await runner.executeSchedule(schedule);
|
|
|
|
expect(createTaskMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({ column: "triage" }),
|
|
);
|
|
});
|
|
|
|
it("handles store.createTask() errors gracefully", async () => {
|
|
const createTaskMock = vi.fn().mockRejectedValue(new Error("Database constraint violation"));
|
|
const store = createMockStore({} as any);
|
|
(store as any).createTask = createTaskMock;
|
|
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [makeCreateTaskStep({ taskDescription: "This will fail" })],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.stepResults).toHaveLength(1);
|
|
expect(result.stepResults![0].success).toBe(false);
|
|
expect(result.stepResults![0].error).toContain("Database constraint violation");
|
|
});
|
|
|
|
it("trims whitespace from task fields", async () => {
|
|
const mockTask = { id: "FN-0001", title: "Cleaned", description: "Trimmed" };
|
|
const createTaskMock = vi.fn().mockResolvedValue(mockTask);
|
|
const store = createMockStore({} as any);
|
|
(store as any).createTask = createTaskMock;
|
|
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [makeCreateTaskStep({
|
|
taskTitle: " Cleaned ",
|
|
taskDescription: " Trimmed ",
|
|
modelProvider: " anthropic ",
|
|
modelId: " claude-sonnet-4-5 ",
|
|
})],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
await runner.executeSchedule(schedule);
|
|
|
|
expect(createTaskMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
title: "Cleaned",
|
|
description: "Trimmed",
|
|
modelProvider: "anthropic",
|
|
modelId: "claude-sonnet-4-5",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("sets title to undefined when taskTitle is not provided", async () => {
|
|
const mockTask = { id: "FN-0002", title: "", description: "Minimal task" };
|
|
const createTaskMock = vi.fn().mockResolvedValue(mockTask);
|
|
const store = createMockStore({} as any);
|
|
(store as any).createTask = createTaskMock;
|
|
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [makeCreateTaskStep({ taskTitle: undefined })],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
await runner.executeSchedule(schedule);
|
|
|
|
// When taskTitle is undefined, title should be undefined in the input
|
|
const callArg = createTaskMock.mock.calls[0][0];
|
|
expect(callArg.title).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ── Scoped lane regression tests (FN-1766) ─────────────────────────────────
|
|
|
|
describe("scoped lane polling", () => {
|
|
it("scope='project' calls getDueSchedules with 'project'", async () => {
|
|
const store = createMockStore();
|
|
const automationStore = createMockAutomationStore([]);
|
|
runner = new CronRunner(store, automationStore, { scope: "project" });
|
|
|
|
await runner.tick();
|
|
|
|
expect(automationStore.getDueSchedules).toHaveBeenCalledWith("project");
|
|
expect(automationStore.getDueSchedulesAllScopes).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("scope='global' calls getDueSchedules with 'global'", async () => {
|
|
const store = createMockStore();
|
|
const automationStore = createMockAutomationStore([]);
|
|
runner = new CronRunner(store, automationStore, { scope: "global" });
|
|
|
|
await runner.tick();
|
|
|
|
expect(automationStore.getDueSchedules).toHaveBeenCalledWith("global");
|
|
expect(automationStore.getDueSchedulesAllScopes).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("scope='all' calls getDueSchedulesAllScopes", async () => {
|
|
const store = createMockStore();
|
|
const automationStore = createMockAutomationStore([]);
|
|
runner = new CronRunner(store, automationStore, { scope: "all" });
|
|
|
|
await runner.tick();
|
|
|
|
expect(automationStore.getDueSchedulesAllScopes).toHaveBeenCalled();
|
|
expect(automationStore.getDueSchedules).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("scope='project' skips global-scoped schedules", async () => {
|
|
const store = createMockStore();
|
|
const globalSchedule = createMockSchedule({ id: "global-schedule", scope: "global" });
|
|
const automationStore = createMockAutomationStore([globalSchedule]);
|
|
runner = new CronRunner(store, automationStore, { scope: "project" });
|
|
|
|
await runner.tick();
|
|
|
|
// Should NOT execute the global schedule
|
|
expect(automationStore.recordRun).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("scope='global' skips project-scoped schedules", async () => {
|
|
const store = createMockStore();
|
|
const projectSchedule = createMockSchedule({ id: "project-schedule", scope: "project" });
|
|
const automationStore = createMockAutomationStore([projectSchedule]);
|
|
runner = new CronRunner(store, automationStore, { scope: "global" });
|
|
|
|
await runner.tick();
|
|
|
|
// Should NOT execute the project schedule
|
|
expect(automationStore.recordRun).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("scope='all' executes both global and project schedules", async () => {
|
|
const store = createMockStore();
|
|
const globalSchedule = createMockSchedule({ id: "global-sched", name: "Global", scope: "global", command: "echo global" });
|
|
const projectSchedule = createMockSchedule({ id: "project-sched", name: "Project", scope: "project", command: "echo project" });
|
|
const automationStore = createMockAutomationStore([globalSchedule, projectSchedule]);
|
|
(automationStore.getDueSchedulesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([globalSchedule, projectSchedule]);
|
|
runner = new CronRunner(store, automationStore, { scope: "all" });
|
|
|
|
await runner.tick();
|
|
|
|
// Should execute both schedules
|
|
expect(automationStore.recordRun).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("scope='all' deduplicates by schedule ID — no double execution", async () => {
|
|
const store = createMockStore();
|
|
// Same schedule ID in both scopes
|
|
const scheduleInBothScopes = createMockSchedule({ id: "shared-id", name: "Shared", scope: "project", command: "echo shared" });
|
|
const automationStore = createMockAutomationStore([scheduleInBothScopes]);
|
|
// getDueSchedulesAllScopes returns it once
|
|
(automationStore.getDueSchedulesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([scheduleInBothScopes]);
|
|
runner = new CronRunner(store, automationStore, { scope: "all" });
|
|
|
|
await runner.tick();
|
|
|
|
// Should only execute once
|
|
expect(automationStore.recordRun).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("scope='all' skips schedule from wrong scope after scope mismatch", async () => {
|
|
const store = createMockStore();
|
|
// Schedule says global but runner is polling project scope
|
|
const mismatchedSchedule = createMockSchedule({ id: "mismatch", name: "Mismatched", scope: "global", command: "echo wrong" });
|
|
const automationStore = createMockAutomationStore([mismatchedSchedule]);
|
|
runner = new CronRunner(store, automationStore, { scope: "project" });
|
|
|
|
await runner.tick();
|
|
|
|
// Should NOT execute - schedule is global but runner is in project scope
|
|
expect(automationStore.recordRun).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("scope='all' includes lane tag in execution log context", async () => {
|
|
const store = createMockStore();
|
|
const globalSchedule = createMockSchedule({ id: "global-log", name: "Global", scope: "global", command: "echo global" });
|
|
const projectSchedule = createMockSchedule({ id: "project-log", name: "Project", scope: "project", command: "echo project" });
|
|
const automationStore = createMockAutomationStore([globalSchedule, projectSchedule]);
|
|
(automationStore.getDueSchedulesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([globalSchedule, projectSchedule]);
|
|
runner = new CronRunner(store, automationStore, { scope: "all" });
|
|
|
|
await runner.tick();
|
|
|
|
// Both schedules executed
|
|
expect(automationStore.recordRun).toHaveBeenCalledTimes(2);
|
|
expect(automationStore.recordRun).toHaveBeenCalledWith("global-log", expect.any(Object));
|
|
expect(automationStore.recordRun).toHaveBeenCalledWith("project-log", expect.any(Object));
|
|
});
|
|
|
|
it("scope='project' default when not specified", () => {
|
|
const store = createMockStore();
|
|
const automationStore = createMockAutomationStore([]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
expect(runner["scope"]).toBe("project");
|
|
});
|
|
});
|
|
|
|
describe("scoped lane pause regressions", () => {
|
|
it("scope='project' skips when initially paused", async () => {
|
|
const store = createMockStore({ globalPause: true });
|
|
const schedule = createMockSchedule({ scope: "project" });
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore, { scope: "project" });
|
|
|
|
await runner.tick();
|
|
|
|
expect(automationStore.getDueSchedules).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("scope='global' skips when initially paused", async () => {
|
|
const store = createMockStore({ enginePaused: true });
|
|
const schedule = createMockSchedule({ scope: "global" });
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore, { scope: "global" });
|
|
|
|
await runner.tick();
|
|
|
|
expect(automationStore.getDueSchedules).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("scope='all' skips when initially paused", async () => {
|
|
const store = createMockStore({ globalPause: true });
|
|
const globalSchedule = createMockSchedule({ scope: "global" });
|
|
const projectSchedule = createMockSchedule({ scope: "project" });
|
|
const automationStore = createMockAutomationStore([globalSchedule, projectSchedule]);
|
|
(automationStore.getDueSchedulesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([globalSchedule, projectSchedule]);
|
|
runner = new CronRunner(store, automationStore, { scope: "all" });
|
|
|
|
await runner.tick();
|
|
|
|
expect(automationStore.getDueSchedulesAllScopes).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("scope='project' halts mid-tick when pause flips", async () => {
|
|
const schedules = [
|
|
createMockSchedule({ id: "s1", name: "First", scope: "project", command: "echo first" }),
|
|
createMockSchedule({ id: "s2", name: "Second", scope: "project", command: "echo second" }),
|
|
];
|
|
const automationStore = createMockAutomationStore(schedules);
|
|
|
|
// Mock getSettings to pause after first schedule executes
|
|
// Initial check (1), check before s1 (2), check after s1 starts (3) = pause
|
|
let getSettingsCalls = 0;
|
|
const mockStore: TaskStore = {
|
|
getSettings: vi.fn().mockImplementation(async () => {
|
|
getSettingsCalls++;
|
|
// Pause is detected on 3rd call: initial check, before s1, then before s2
|
|
return {
|
|
...DEFAULT_SETTINGS,
|
|
globalPause: getSettingsCalls >= 3, // Pause on 3rd call (before s2)
|
|
};
|
|
}),
|
|
on: vi.fn(),
|
|
off: vi.fn(),
|
|
} as unknown as TaskStore;
|
|
|
|
runner = new CronRunner(mockStore, automationStore, { scope: "project" });
|
|
|
|
await runner.tick();
|
|
|
|
// Should only execute first schedule (s1 executes, s2 is skipped due to pause)
|
|
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("scoped lane ID-overlap regressions", () => {
|
|
it("identical IDs across global and project scopes must not cross lane boundaries", async () => {
|
|
const store = createMockStore();
|
|
// Same ID in both scopes - store returns both
|
|
const globalSchedule = createMockSchedule({ id: "overlap-id", name: "Global Overlap", scope: "global", command: "echo global" });
|
|
const projectSchedule = createMockSchedule({ id: "overlap-id", name: "Project Overlap", scope: "project", command: "echo project" });
|
|
const automationStore = createMockAutomationStore([globalSchedule, projectSchedule]);
|
|
// getDueSchedulesAllScopes returns both (simulating both lanes having schedules with same ID)
|
|
(automationStore.getDueSchedulesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([globalSchedule, projectSchedule]);
|
|
runner = new CronRunner(store, automationStore, { scope: "all" });
|
|
|
|
await runner.tick();
|
|
|
|
// With scope="all", both schedules share the same ID
|
|
// The runner should execute once (first occurrence wins due to deduplication)
|
|
expect(automationStore.recordRun).toHaveBeenCalledTimes(1);
|
|
// The ID should be "overlap-id"
|
|
const [id] = (automationStore.recordRun as ReturnType<typeof vi.fn>).mock.calls[0] as [string, AutomationRunResult];
|
|
expect(id).toBe("overlap-id");
|
|
});
|
|
|
|
it("scope='project' with overlapping ID only processes project-scoped version", async () => {
|
|
const store = createMockStore();
|
|
// Only the project-scoped version should be processed
|
|
const projectSchedule = createMockSchedule({ id: "overlap-id", name: "Project Only", scope: "project", command: "echo project" });
|
|
const automationStore = createMockAutomationStore([projectSchedule]);
|
|
runner = new CronRunner(store, automationStore, { scope: "project" });
|
|
|
|
await runner.tick();
|
|
|
|
expect(automationStore.recordRun).toHaveBeenCalledTimes(1);
|
|
const [id] = (automationStore.recordRun as ReturnType<typeof vi.fn>).mock.calls[0] as [string, AutomationRunResult];
|
|
expect(id).toBe("overlap-id");
|
|
});
|
|
|
|
it("scope='global' with overlapping ID only processes global-scoped version", async () => {
|
|
const store = createMockStore();
|
|
// Only the global-scoped version should be processed
|
|
const globalSchedule = createMockSchedule({ id: "overlap-id", name: "Global Only", scope: "global", command: "echo global" });
|
|
const automationStore = createMockAutomationStore([globalSchedule]);
|
|
runner = new CronRunner(store, automationStore, { scope: "global" });
|
|
|
|
await runner.tick();
|
|
|
|
expect(automationStore.recordRun).toHaveBeenCalledTimes(1);
|
|
const [id] = (automationStore.recordRun as ReturnType<typeof vi.fn>).mock.calls[0] as [string, AutomationRunResult];
|
|
expect(id).toBe("overlap-id");
|
|
});
|
|
});
|
|
|
|
describe("utility-lane semaphore boundary", () => {
|
|
it("CronRunner does not use task-lane semaphore operations", async () => {
|
|
const store = createMockStore();
|
|
const automationStore = createMockAutomationStore([]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
// Cast to any to access internal state for semaphore trap
|
|
const runnerAny = runner as any;
|
|
|
|
// Verify no semaphore-related methods exist on the runner
|
|
expect(runnerAny.acquire).toBeUndefined();
|
|
expect(runnerAny.release).toBeUndefined();
|
|
expect(runnerAny.run).toBeUndefined();
|
|
expect(runnerAny.semaphore).toBeUndefined();
|
|
expect(runnerAny["_semaphore"]).toBeUndefined();
|
|
});
|
|
|
|
it("CronRunner constructor does not accept semaphore parameter", () => {
|
|
const store = createMockStore();
|
|
const automationStore = createMockAutomationStore();
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
// Verify internal state doesn't have semaphore references
|
|
const keys = Object.keys(runner);
|
|
const hasSemaphore = keys.some(k => k.toLowerCase().includes("semaphore") || k.toLowerCase().includes("acquire"));
|
|
expect(hasSemaphore).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ── Cross-package integration tests (FN-1743) ─────────────────────────────────
|
|
//
|
|
// These tests verify end-to-end scoped scheduling wiring across packages.
|
|
// They ensure that:
|
|
// 1. CronRunner bound to a project store executes scoped schedules only for that project lane
|
|
// 2. CronRunner with both global and project stores executes both lanes in the same tick
|
|
// 3. Execution logs contain lane identity (scope tag + projectId)
|
|
// 4. Pause behavior applies uniformly across both lanes
|
|
// 5. Lane diagnostics are verifiable in test assertions
|
|
|
|
describe("cross-package dual-lane execution integration", () => {
|
|
it("scope='all' executes both global and project lanes in the same tick with correct order", async () => {
|
|
const store = createMockStore();
|
|
const globalSchedule = createMockSchedule({
|
|
id: "global-1",
|
|
name: "Global Schedule",
|
|
scope: "global",
|
|
command: "echo global",
|
|
});
|
|
const projectSchedule = createMockSchedule({
|
|
id: "project-1",
|
|
name: "Project Schedule",
|
|
scope: "project",
|
|
command: "echo project",
|
|
});
|
|
const automationStore = createMockAutomationStore([globalSchedule, projectSchedule]);
|
|
// getDueSchedulesAllScopes returns both lanes
|
|
(automationStore.getDueSchedulesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([globalSchedule, projectSchedule]);
|
|
runner = new CronRunner(store, automationStore, { scope: "all" });
|
|
|
|
await runner.tick();
|
|
|
|
// Both schedules should be executed in the same tick
|
|
expect(automationStore.recordRun).toHaveBeenCalledTimes(2);
|
|
// Verify order: global first, then project (based on getDueSchedulesAllScopes result order)
|
|
const calls = (automationStore.recordRun as ReturnType<typeof vi.fn>).mock.calls;
|
|
expect(calls[0][0]).toBe("global-1");
|
|
expect(calls[1][0]).toBe("project-1");
|
|
});
|
|
|
|
it("scope='all' lane identity is preserved in execution context", async () => {
|
|
const store = createMockStore();
|
|
const globalSchedule = createMockSchedule({
|
|
id: "global-lane-test",
|
|
name: "Global Lane",
|
|
scope: "global",
|
|
command: "echo global-lane",
|
|
});
|
|
const projectSchedule = createMockSchedule({
|
|
id: "project-lane-test",
|
|
name: "Project Lane",
|
|
scope: "project",
|
|
command: "echo project-lane",
|
|
});
|
|
const automationStore = createMockAutomationStore([globalSchedule, projectSchedule]);
|
|
(automationStore.getDueSchedulesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([globalSchedule, projectSchedule]);
|
|
runner = new CronRunner(store, automationStore, { scope: "all" });
|
|
|
|
await runner.tick();
|
|
|
|
// Verify both lanes were executed
|
|
expect(automationStore.recordRun).toHaveBeenCalledTimes(2);
|
|
const calls = (automationStore.recordRun as ReturnType<typeof vi.fn>).mock.calls;
|
|
// Each call receives the schedule ID and result
|
|
expect(calls[0][0]).toBe("global-lane-test");
|
|
expect(calls[1][0]).toBe("project-lane-test");
|
|
});
|
|
|
|
it("pause applies uniformly across both lanes when scope='all'", async () => {
|
|
const store = createMockStore({ globalPause: true });
|
|
const globalSchedule = createMockSchedule({ id: "global-pause", name: "Global", scope: "global", command: "echo global" });
|
|
const projectSchedule = createMockSchedule({ id: "project-pause", name: "Project", scope: "project", command: "echo project" });
|
|
const automationStore = createMockAutomationStore([globalSchedule, projectSchedule]);
|
|
(automationStore.getDueSchedulesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([globalSchedule, projectSchedule]);
|
|
runner = new CronRunner(store, automationStore, { scope: "all" });
|
|
|
|
await runner.tick();
|
|
|
|
// Neither lane should execute when paused
|
|
expect(automationStore.getDueSchedulesAllScopes).not.toHaveBeenCalled();
|
|
expect(automationStore.recordRun).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("identical schedule IDs across global and project lanes execute once (deduplication)", async () => {
|
|
const store = createMockStore();
|
|
// Same ID in both scopes
|
|
const globalSchedule = createMockSchedule({
|
|
id: "shared-schedule",
|
|
name: "Shared Global",
|
|
scope: "global",
|
|
command: "echo shared-global",
|
|
});
|
|
const projectSchedule = createMockSchedule({
|
|
id: "shared-schedule",
|
|
name: "Shared Project",
|
|
scope: "project",
|
|
command: "echo shared-project",
|
|
});
|
|
const automationStore = createMockAutomationStore([globalSchedule, projectSchedule]);
|
|
(automationStore.getDueSchedulesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([globalSchedule, projectSchedule]);
|
|
runner = new CronRunner(store, automationStore, { scope: "all" });
|
|
|
|
await runner.tick();
|
|
|
|
// Only one execution should occur due to deduplication
|
|
expect(automationStore.recordRun).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("cross-package scoped isolation integration", () => {
|
|
it("CronRunner bound to project store never executes global-lane schedules", async () => {
|
|
const store = createMockStore();
|
|
const globalSchedule = createMockSchedule({ id: "global-iso", name: "Global", scope: "global", command: "echo global" });
|
|
const automationStore = createMockAutomationStore([globalSchedule]);
|
|
runner = new CronRunner(store, automationStore, { scope: "project" });
|
|
|
|
await runner.tick();
|
|
|
|
// Project-scoped runner should not execute global schedule
|
|
expect(automationStore.recordRun).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("CronRunner bound to global store never executes project-lane schedules", async () => {
|
|
const store = createMockStore();
|
|
const projectSchedule = createMockSchedule({ id: "project-iso", name: "Project", scope: "project", command: "echo project" });
|
|
const automationStore = createMockAutomationStore([projectSchedule]);
|
|
runner = new CronRunner(store, automationStore, { scope: "global" });
|
|
|
|
await runner.tick();
|
|
|
|
// Global-scoped runner should not execute project schedule
|
|
expect(automationStore.recordRun).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("scope='all' executes schedules from both lanes but never crosses lane boundaries", async () => {
|
|
const store = createMockStore();
|
|
const globalSchedule = createMockSchedule({ id: "global-boundary", name: "Global", scope: "global", command: "echo global" });
|
|
const projectSchedule = createMockSchedule({ id: "project-boundary", name: "Project", scope: "project", command: "echo project" });
|
|
const automationStore = createMockAutomationStore([globalSchedule, projectSchedule]);
|
|
(automationStore.getDueSchedulesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([globalSchedule, projectSchedule]);
|
|
runner = new CronRunner(store, automationStore, { scope: "all" });
|
|
|
|
await runner.tick();
|
|
|
|
// Both lanes should execute
|
|
expect(automationStore.recordRun).toHaveBeenCalledTimes(2);
|
|
// But each schedule is executed exactly once (no double execution)
|
|
const calls = (automationStore.recordRun as ReturnType<typeof vi.fn>).mock.calls;
|
|
expect(calls[0][0]).toBe("global-boundary");
|
|
expect(calls[1][0]).toBe("project-boundary");
|
|
});
|
|
});
|
|
|
|
describe("isInProcessBackupCommand", () => {
|
|
const positives = [
|
|
// Bare binary forms.
|
|
"fn backup --create",
|
|
"fusion backup --create",
|
|
"runfusion.ai backup --create",
|
|
"runfusion backup --create",
|
|
"@runfusion/fusion backup --create",
|
|
// npx prefix without flags.
|
|
"npx runfusion.ai backup --create",
|
|
"npx @runfusion/fusion backup --create",
|
|
// npx with flags — including the canonical `npx -y runfusion.ai`
|
|
// form FN_NPX_INVOCATION emits.
|
|
"npx -y runfusion.ai backup --create",
|
|
"npx --yes runfusion.ai backup --create",
|
|
"npx -y -p runfusion.ai runfusion.ai backup --create",
|
|
"npx --package=runfusion.ai runfusion.ai backup --create",
|
|
// Whitespace / case tolerance.
|
|
"FN BACKUP --CREATE",
|
|
"fn backup --create --some-other-flag",
|
|
" fn backup --create ",
|
|
];
|
|
|
|
const negatives: Array<string | undefined> = [
|
|
// Wrong subcommand — must not be intercepted, the in-process path
|
|
// only does create+cleanup and would silently swallow these.
|
|
"fn backup --list",
|
|
"fn backup --restore /tmp/old.db",
|
|
"fn backup --cleanup",
|
|
"fn backup",
|
|
"fn task list",
|
|
"echo hello",
|
|
"fn-other backup --create",
|
|
"fnbackup --create",
|
|
"fnext backup --create",
|
|
"",
|
|
undefined,
|
|
// Shell continuations after --create encode user-authored side
|
|
// effects we cannot mirror in-process; let them shell out.
|
|
"fn backup --create && notify-send done",
|
|
"fn backup --create || echo failed",
|
|
"fn backup --create | tee /tmp/log",
|
|
"fn backup --create ; rm -rf /tmp/garbage",
|
|
"fn backup --create > /tmp/out.txt",
|
|
"fn backup --create 2> /tmp/err.txt",
|
|
"fn backup --create `whoami`",
|
|
"fn backup --create $(date)",
|
|
// Trailing positional arguments aren't `--flag`s — refuse.
|
|
"fn backup --create extra-positional",
|
|
];
|
|
|
|
for (const cmd of positives) {
|
|
it(`intercepts: ${cmd}`, () => {
|
|
expect(isInProcessBackupCommand(cmd)).toBe(true);
|
|
});
|
|
}
|
|
|
|
for (const cmd of negatives) {
|
|
it(`does NOT intercept: ${JSON.stringify(cmd)}`, () => {
|
|
expect(isInProcessBackupCommand(cmd)).toBe(false);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe("isInProcessMemoryBackupCommand", () => {
|
|
const positives = [
|
|
"fn memory-backup --create",
|
|
"npx runfusion.ai memory-backup --create",
|
|
" fn memory-backup --create ",
|
|
"FN MEMORY-BACKUP --CREATE",
|
|
];
|
|
|
|
const negatives: Array<string | undefined> = [
|
|
"fn memory-backup --list",
|
|
"fn memory-backup",
|
|
"fn backup --create",
|
|
"",
|
|
undefined,
|
|
];
|
|
|
|
for (const cmd of positives) {
|
|
it(`intercepts: ${cmd}`, () => {
|
|
expect(isInProcessMemoryBackupCommand(cmd)).toBe(true);
|
|
});
|
|
}
|
|
|
|
for (const cmd of negatives) {
|
|
it(`does NOT intercept: ${JSON.stringify(cmd)}`, () => {
|
|
expect(isInProcessMemoryBackupCommand(cmd)).toBe(false);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe("backup in-process dispatch", () => {
|
|
it("routes fn backup --create through runBackupCommand", async () => {
|
|
const store = createMockStore() as unknown as TaskStore & { getFusionDir: () => string };
|
|
store.getFusionDir = vi.fn().mockReturnValue("/tmp/.fusion");
|
|
const schedule = createMockSchedule({ id: "db-bk", command: "fn backup --create" });
|
|
runner = new CronRunner(store, createMockAutomationStore());
|
|
|
|
const runResult = await (runner as unknown as { executeLegacyCommand: (s: ScheduledTask, startedAt: string) => Promise<AutomationRunResult> })
|
|
.executeLegacyCommand(schedule, new Date().toISOString());
|
|
|
|
expect(coreModuleMocks.runBackupCommand).toHaveBeenCalledWith("/tmp/.fusion", expect.any(Object));
|
|
expect(runResult.success).toBe(true);
|
|
expect(runResult.output).toContain("fusion-central-");
|
|
});
|
|
});
|
|
|
|
describe("memory backup in-process dispatch", () => {
|
|
it("routes fn memory-backup --create through runMemoryBackupCommand", async () => {
|
|
const store = createMockStore() as unknown as TaskStore & { getFusionDir: () => string };
|
|
store.getFusionDir = vi.fn().mockReturnValue("/tmp/.fusion");
|
|
const schedule = createMockSchedule({ id: "mem-bk", command: "fn memory-backup --create" });
|
|
runner = new CronRunner(store, createMockAutomationStore());
|
|
|
|
const runResult = await (runner as unknown as { executeLegacyCommand: (s: ScheduledTask, startedAt: string) => Promise<AutomationRunResult> })
|
|
.executeLegacyCommand(schedule, new Date().toISOString());
|
|
|
|
expect(coreModuleMocks.runMemoryBackupCommand).toHaveBeenCalledWith("/tmp/.fusion", expect.any(Object));
|
|
expect(runResult.success).toBe(true);
|
|
expect(runResult.output).toContain("memory backup ok");
|
|
});
|
|
});
|
|
|
|
describe("isInProcessScheduledEvalCommand", () => {
|
|
it("matches canonical scheduled eval command", () => {
|
|
expect(isInProcessScheduledEvalCommand("fn eval --scheduled-batch")).toBe(true);
|
|
expect(isInProcessScheduledEvalCommand("fusion eval --scheduled-batch --flag")).toBe(true);
|
|
});
|
|
|
|
it("rejects non-canonical and shell-metachar commands", () => {
|
|
expect(isInProcessScheduledEvalCommand("fn eval")).toBe(false);
|
|
expect(isInProcessScheduledEvalCommand("fn eval --scheduled-batch && echo x")).toBe(false);
|
|
expect(isInProcessScheduledEvalCommand("echo fn eval --scheduled-batch")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("scheduled eval command feature gating", () => {
|
|
it("returns a disabled result when evalsView experimental feature is off", async () => {
|
|
const store = createMockStore({ experimentalFeatures: { evalsView: false } as Settings["experimentalFeatures"] });
|
|
const schedule = createMockSchedule({ id: "eval-off", command: "fn eval --scheduled-batch" });
|
|
runner = new CronRunner(store, createMockAutomationStore());
|
|
|
|
const runResult = await (runner as unknown as { executeLegacyCommand: (s: ScheduledTask, startedAt: string) => Promise<AutomationRunResult> })
|
|
.executeLegacyCommand(schedule, new Date().toISOString());
|
|
expect(runResult.success).toBe(false);
|
|
expect(runResult.output).toBe("evals-experimental-disabled");
|
|
expect(runResult.error).toBe("Evals experimental feature is disabled");
|
|
});
|
|
|
|
it("does not return the disabled sentinel when evalsView experimental feature is on", async () => {
|
|
const store = createMockStore({ experimentalFeatures: { evalsView: true } as Settings["experimentalFeatures"] }) as unknown as {
|
|
getEvalStore: () => {
|
|
listRuns: () => Array<{ id: string; status: string; metadata?: Record<string, unknown>; window: { until?: string } }>;
|
|
createRun: (input: { projectId: string; trigger: string; scope: string; window: { since?: string; until: string }; metadata: Record<string, unknown> }) => { id: string; startedAt?: string; window: { until: string } };
|
|
appendRunEvent: (runId: string, event: Record<string, unknown>) => void;
|
|
updateRun: (runId: string, patch: Record<string, unknown>) => void;
|
|
};
|
|
listTasks: (opts: { column: string }) => Promise<Array<{ id: string; column: string; createdAt: string; updatedAt: string; executionCompletedAt?: string; title: string; summary?: string }>>;
|
|
};
|
|
|
|
store.getEvalStore = () => ({
|
|
listRuns: () => [],
|
|
createRun: () => ({ id: "run-1", window: { until: new Date().toISOString() } }),
|
|
appendRunEvent: () => {},
|
|
updateRun: () => {},
|
|
});
|
|
store.listTasks = async () => [];
|
|
|
|
const schedule = createMockSchedule({ id: "eval-on", command: "fn eval --scheduled-batch" });
|
|
runner = new CronRunner(store as unknown as TaskStore, createMockAutomationStore());
|
|
|
|
const runResult = await (runner as unknown as { executeLegacyCommand: (s: ScheduledTask, startedAt: string) => Promise<AutomationRunResult> })
|
|
.executeLegacyCommand(schedule, new Date().toISOString());
|
|
expect(runResult.output).not.toBe("evals-experimental-disabled");
|
|
});
|
|
});
|
|
});
|