import { beforeEach, describe, expect, it, vi } from "vitest"; const mockState = vi.hoisted(() => ({ schedules: [] as Array>, readyCandidates: [] as Array>, startedRuns: [] as Array>, finishedRuns: [] as Array>, dispatchEvents: [] as Array>, governanceCommands: [] as Array>, acquiredLeases: [] as Array>, releasedLeases: [] as Array>, policyPulls: [] as Array>, settings: { enabled: true, catch_up_on_startup: true, max_run_history: 100, max_catch_up_runs: 5, max_concurrent_schedule_runs: 1, }, dispatchPolicy: { enabled: true, max_per_sweep: 3, auto_dispatch_ready_steps: true, updated_at: null as string | null, }, })); vi.mock("../startupPorts", () => ({ getConfiguredPorts: () => ({ agent: 60006 }), })); vi.mock("../constants", () => ({ LOCALHOST_HOSTNAME: "127.0.0.1", })); vi.mock("./stateService", () => ({ readDigitalEmployeeCronSettings: () => mockState.settings, readDigitalEmployeePlanStepDispatchPolicy: () => mockState.dispatchPolicy, listDigitalEmployeeReadyPlanStepDispatchCandidates: (options?: number | { planId?: string | null }) => { const planId = typeof options === "object" && options ? options.planId : null; return planId ? mockState.readyCandidates.filter((candidate) => candidate.planId === planId) : mockState.readyCandidates; }, listDigitalEmployeeSchedules: () => mockState.schedules, listDueDigitalEmployeeSchedules: () => mockState.schedules, readDigitalEmployeeScheduleRuns: () => [], upsertDigitalEmployeeSchedule: vi.fn(), recordDigitalEmployeeScheduleRunStarted: (input: Record) => { const run = { id: `${input.scheduleId}:${input.dueAt}:1`, ...input }; mockState.startedRuns.push(run); return run; }, finishDigitalEmployeeScheduleRun: (id: string, status: string, options: Record) => { mockState.finishedRuns.push({ id, status, ...options }); return { id, status, ...options }; }, recordDigitalEmployeePlanStepDispatch: (input: Record) => { mockState.dispatchEvents.push(input); return { eventId: `dispatch-${input.status}`, kind: `plan_step_dispatch_${input.status}`, payload: input }; }, acquireDigitalEmployeePlanStepLease: (input: Record) => { mockState.acquiredLeases.push(input); const candidate = mockState.readyCandidates.find((item) => item.stepId === input.stepId); if (candidate?.leaseActive || candidate?.skipReason === "lease_active") { return { ok: false, reason: "lease_active", lease: { lease_id: `lease-${input.stepId}`, state: "active", owner_device_id: "device-remote", acquired_at: "2026-06-07T00:59:00.000Z", expires_at: "2026-06-07T01:04:00.000Z" }, }; } return { ok: true, lease: { lease_id: `lease-${input.stepId}`, state: "active", owner_device_id: "device-001", acquired_at: input.occurredAt, expires_at: "2026-06-07T01:05:10.000Z" }, }; }, releaseDigitalEmployeePlanStepLease: (input: Record) => { mockState.releasedLeases.push(input); return { ok: true, lease: { lease_id: input.leaseId, state: "released", owner_device_id: "device-001", acquired_at: "2026-06-07T01:00:10.000Z", expires_at: "2026-06-07T01:05:10.000Z", released_at: input.occurredAt, release_reason: input.reason } }; }, recordDigitalEmployeeGovernanceCommand: (input: Record) => { mockState.governanceCommands.push(input); return { planId: input.planId, taskId: input.taskId, runId: input.runId || `${input.taskId}:run:start_run:${input.commandId}`, }; }, })); vi.mock("./syncService", () => ({ pullDigitalEmployeePlanStepDispatchPolicy: (input: Record = {}) => { mockState.policyPulls.push(input); return Promise.resolve({ ok: true, endpoint: "https://manage.example.com/api/digital-employee/policies/plan-step-dispatch" }); }, })); describe("digital employee scheduler service", () => { beforeEach(() => { vi.resetModules(); mockState.readyCandidates = []; mockState.startedRuns = []; mockState.finishedRuns = []; mockState.dispatchEvents = []; mockState.governanceCommands = []; mockState.acquiredLeases = []; mockState.releasedLeases = []; mockState.policyPulls = []; mockState.dispatchPolicy = { enabled: true, max_per_sweep: 3, auto_dispatch_ready_steps: true, updated_at: null, }; mockState.schedules = [{ id: "schedule-1", planId: "plan-1", name: "晨间巡检", description: "每日巡检", cronExpr: "0 9 * * *", timezone: "system", status: "enabled", enabled: true, prompt: "执行晨间巡检", command: null, payload: {}, nextRunAt: "2026-06-07T01:00:00.000Z", lastRunAt: null, catchUpOnStartup: true, maxCatchUpRuns: 5, maxRunHistory: 100, failureCount: 0, lastError: null, syncStatus: "pending", createdAt: "2026-06-07T00:00:00.000Z", updatedAt: "2026-06-07T00:00:00.000Z", deletedAt: null, }]; vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, text: async () => JSON.stringify({ code: "0000", success: true, data: { request_id: "r1" } }), }))); }); it("computes the next 5-field cron run", async () => { const { nextCronRun } = await import("./schedulerService"); expect(nextCronRun("30 9 * * *", new Date("2026-06-07T01:29:30.000Z")).toISOString()) .toBe("2026-06-07T01:30:00.000Z"); }); it("activates due schedules through local ComputerServer", async () => { const { runDigitalEmployeeSchedulerCheck } = await import("./schedulerService"); const result = await runDigitalEmployeeSchedulerCheck(new Date("2026-06-07T01:00:10.000Z")); expect(result.activated).toBe(1); expect(mockState.startedRuns).toHaveLength(1); expect(fetch).toHaveBeenCalledWith( "http://127.0.0.1:60006/computer/chat", expect.objectContaining({ method: "POST" }), ); expect(mockState.finishedRuns[0]).toMatchObject({ status: "completed" }); }); it("records failed runs with retry next run", async () => { vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status: 500, text: async () => "boom", }))); const { runDigitalEmployeeSchedulerCheck } = await import("./schedulerService"); const result = await runDigitalEmployeeSchedulerCheck(new Date("2026-06-07T01:00:10.000Z")); expect(result.errors).toHaveLength(1); expect(mockState.finishedRuns[0]).toMatchObject({ status: "failed", error: "ComputerServer 500: boom" }); expect(mockState.finishedRuns[0].nextRunAt).toBeTruthy(); }); it("dispatches ready plan steps through local ComputerServer", async () => { mockState.schedules = []; mockState.readyCandidates = [readyStepCandidate()]; const { runDigitalEmployeeSchedulerCheck } = await import("./schedulerService"); const result = await runDigitalEmployeeSchedulerCheck(new Date("2026-06-07T01:00:10.000Z")); expect(result.readyStepDispatch).toMatchObject({ candidates: 1, dispatched: 1, skipped: 0, failed: 0 }); expect(fetch).toHaveBeenCalledWith( "http://127.0.0.1:60006/computer/chat", expect.objectContaining({ method: "POST" }), ); expect(mockState.dispatchEvents.map((event) => event.status)).toEqual(["started", "completed"]); expect(mockState.policyPulls).toEqual([{}]); expect(mockState.acquiredLeases).toEqual([expect.objectContaining({ stepId: "step-ready", triggerSource: "auto" })]); expect(mockState.releasedLeases).toEqual([expect.objectContaining({ stepId: "step-ready", leaseId: "lease-step-ready", reason: "completed" })]); expect(mockState.governanceCommands[0]).toMatchObject({ commandKind: "start_run", planId: "plan-1", taskId: "task-1", stepId: "step-ready", }); }); it("marks ready step dispatch failures", async () => { mockState.schedules = []; mockState.readyCandidates = [readyStepCandidate()]; vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status: 502, text: async () => "bad gateway", }))); const { runDigitalEmployeeSchedulerCheck } = await import("./schedulerService"); const result = await runDigitalEmployeeSchedulerCheck(new Date("2026-06-07T01:00:10.000Z")); expect(result.readyStepDispatch).toMatchObject({ candidates: 1, dispatched: 0, failed: 1 }); expect(result.readyStepDispatch.errors[0]).toMatchObject({ stepId: "step-ready", error: "ComputerServer 502: bad gateway" }); expect(mockState.dispatchEvents.map((event) => event.status)).toEqual(["started", "failed"]); expect(mockState.releasedLeases).toEqual([expect.objectContaining({ stepId: "step-ready", leaseId: "lease-step-ready", reason: "failed" })]); expect(mockState.governanceCommands).toHaveLength(0); }); it("skips ready plan steps with active leases", async () => { mockState.schedules = []; mockState.readyCandidates = [readyStepCandidate({ dispatchable: false, skipReason: "lease_active", leaseActive: true })]; const { runDigitalEmployeeSchedulerCheck } = await import("./schedulerService"); const result = await runDigitalEmployeeSchedulerCheck(new Date("2026-06-07T01:00:10.000Z")); expect(result.readyStepDispatch).toMatchObject({ candidates: 1, dispatched: 0, skipped: 1, failed: 0 }); expect(fetch).not.toHaveBeenCalled(); expect(mockState.acquiredLeases).toEqual([expect.objectContaining({ stepId: "step-ready", triggerSource: "auto" })]); expect(mockState.releasedLeases).toHaveLength(0); expect(mockState.dispatchEvents).toHaveLength(0); }); it("honors disabled ready step dispatch policy", async () => { mockState.readyCandidates = [readyStepCandidate()]; mockState.dispatchPolicy = { ...mockState.dispatchPolicy, enabled: false }; const { dispatchReadyPlanSteps } = await import("./schedulerService"); const result = await dispatchReadyPlanSteps(new Date("2026-06-07T01:00:10.000Z")); expect(result).toMatchObject({ candidates: 1, dispatched: 0, skipped: 1, failed: 0 }); expect(fetch).not.toHaveBeenCalled(); expect(mockState.dispatchEvents).toHaveLength(0); }); it("honors ready step auto dispatch switch", async () => { mockState.readyCandidates = [readyStepCandidate()]; mockState.dispatchPolicy = { ...mockState.dispatchPolicy, auto_dispatch_ready_steps: false }; const { dispatchReadyPlanSteps } = await import("./schedulerService"); const result = await dispatchReadyPlanSteps(new Date("2026-06-07T01:00:10.000Z")); expect(result).toMatchObject({ candidates: 1, dispatched: 0, skipped: 1, failed: 0 }); expect(fetch).not.toHaveBeenCalled(); expect(mockState.dispatchEvents).toHaveLength(0); }); it("allows manual plan dispatch to bypass the auto dispatch switch", async () => { mockState.readyCandidates = [readyStepCandidate()]; mockState.dispatchPolicy = { ...mockState.dispatchPolicy, auto_dispatch_ready_steps: false }; const { dispatchReadyPlanSteps } = await import("./schedulerService"); const result = await dispatchReadyPlanSteps({ planId: "plan-1", triggerSource: "manual", bypassAutoDispatchSwitch: true, now: new Date("2026-06-07T01:00:10.000Z"), }); expect(result).toMatchObject({ plan_id: "plan-1", trigger_source: "manual", candidates: 1, dispatched: 1, skipped: 0, failed: 0 }); expect(mockState.dispatchEvents[0]).toMatchObject({ status: "started", payload: expect.objectContaining({ trigger_source: "manual" }) }); expect(mockState.policyPulls).toHaveLength(0); }); it("keeps the enabled switch as a manual plan dispatch kill switch", async () => { mockState.readyCandidates = [readyStepCandidate()]; mockState.dispatchPolicy = { ...mockState.dispatchPolicy, enabled: false, auto_dispatch_ready_steps: false }; const { dispatchReadyPlanSteps } = await import("./schedulerService"); const result = await dispatchReadyPlanSteps({ planId: "plan-1", triggerSource: "manual", bypassAutoDispatchSwitch: true, now: new Date("2026-06-07T01:00:10.000Z"), }); expect(result).toMatchObject({ candidates: 1, dispatched: 0, skipped: 1, failed: 0 }); expect(fetch).not.toHaveBeenCalled(); expect(mockState.dispatchEvents).toHaveLength(0); }); it("dispatches only the requested plan in manual plan dispatch", async () => { mockState.readyCandidates = [ readyStepCandidate(), readyStepCandidate({ planId: "plan-2", stepId: "step-plan-2", taskId: "task-plan-2" }), ]; const { dispatchReadyPlanSteps } = await import("./schedulerService"); const result = await dispatchReadyPlanSteps({ planId: "plan-2", triggerSource: "manual", bypassAutoDispatchSwitch: true, now: new Date("2026-06-07T01:00:10.000Z"), }); expect(result).toMatchObject({ plan_id: "plan-2", candidates: 1, dispatched: 1, skipped: 0, failed: 0 }); expect(result.dispatched_step_ids).toEqual(["step-plan-2"]); expect(mockState.dispatchEvents.map((event) => event.stepId)).toEqual(["step-plan-2", "step-plan-2"]); }); it("limits ready step dispatches by policy max per sweep", async () => { mockState.readyCandidates = [ readyStepCandidate(), { ...readyStepCandidate(), stepId: "step-ready-2", taskId: "task-2" }, ]; mockState.dispatchPolicy = { ...mockState.dispatchPolicy, max_per_sweep: 1 }; const { dispatchReadyPlanSteps } = await import("./schedulerService"); const result = await dispatchReadyPlanSteps(new Date("2026-06-07T01:00:10.000Z")); expect(result).toMatchObject({ candidates: 2, dispatched: 1, skipped: 1, failed: 0 }); expect(fetch).toHaveBeenCalledTimes(1); expect(mockState.dispatchEvents.map((event) => event.stepId)).toEqual(["step-ready", "step-ready"]); }); }); function readyStepCandidate(overrides: Record = {}): Record { const stepId = typeof overrides.stepId === "string" ? overrides.stepId : "step-ready"; const taskId = typeof overrides.taskId === "string" ? overrides.taskId : "task-1"; const planId = typeof overrides.planId === "string" ? overrides.planId : "plan-1"; return { stepId, planId, taskId, title: "汇总客户跟进", taskTitle: "执行客户跟进汇总", prompt: "执行客户跟进汇总", preferredSkillIds: ["qimingclaw-acp-session"], dispatchable: true, step: { id: stepId, payload: {} }, task: { id: taskId, payload: {} }, plan: { id: planId }, ...overrides, }; }