347 lines
15 KiB
TypeScript
347 lines
15 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const mockState = vi.hoisted(() => ({
|
|
schedules: [] as Array<Record<string, unknown>>,
|
|
readyCandidates: [] as Array<Record<string, unknown>>,
|
|
startedRuns: [] as Array<Record<string, unknown>>,
|
|
finishedRuns: [] as Array<Record<string, unknown>>,
|
|
dispatchEvents: [] as Array<Record<string, unknown>>,
|
|
governanceCommands: [] as Array<Record<string, unknown>>,
|
|
acquiredLeases: [] as Array<Record<string, unknown>>,
|
|
releasedLeases: [] as Array<Record<string, unknown>>,
|
|
policyPulls: [] as Array<Record<string, unknown>>,
|
|
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<string, unknown>) => {
|
|
const run = { id: `${input.scheduleId}:${input.dueAt}:1`, ...input };
|
|
mockState.startedRuns.push(run);
|
|
return run;
|
|
},
|
|
finishDigitalEmployeeScheduleRun: (id: string, status: string, options: Record<string, unknown>) => {
|
|
mockState.finishedRuns.push({ id, status, ...options });
|
|
return { id, status, ...options };
|
|
},
|
|
recordDigitalEmployeePlanStepDispatch: (input: Record<string, unknown>) => {
|
|
mockState.dispatchEvents.push(input);
|
|
return { eventId: `dispatch-${input.status}`, kind: `plan_step_dispatch_${input.status}`, payload: input };
|
|
},
|
|
acquireDigitalEmployeePlanStepLease: (input: Record<string, unknown>) => {
|
|
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<string, unknown>) => {
|
|
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<string, unknown>) => {
|
|
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<string, unknown> = {}) => {
|
|
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<string, unknown> = {}): Record<string, unknown> {
|
|
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,
|
|
};
|
|
}
|