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(), })); 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(); return { ...actual, runMemoryBackupCommand: coreModuleMocks.runMemoryBackupCommand, }; }); // 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 { 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 = {}): 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", }); }); 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).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).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).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).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).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).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 { 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 { 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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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).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 = [ // 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 = [ "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("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 }) .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 }) .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; window: { until?: string } }>; createRun: (input: { projectId: string; trigger: string; scope: string; window: { since?: string; until: string }; metadata: Record }) => { id: string; startedAt?: string; window: { until: string } }; appendRunEvent: (runId: string, event: Record) => void; updateRun: (runId: string, patch: Record) => void; }; listTasks: (opts: { column: string }) => Promise>; }; 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 }) .executeLegacyCommand(schedule, new Date().toISOString()); expect(runResult.output).not.toBe("evals-experimental-disabled"); }); }); });