

























@@ -14,6 +14,8 @@ const {
1414 fetchWithSsrFGuardMock,
1515 runCronIsolatedAgentTurnMock,
1616 cleanupBrowserSessionsForLifecycleEndMock,
17+ getGlobalHookRunnerMock,
18+ runCronChangedMock,
1719} = vi.hoisted(() => ({
1820enqueueSystemEventMock: vi.fn(),
1921requestHeartbeatNowMock: vi.fn(),
@@ -24,6 +26,11 @@ const {
2426fetchWithSsrFGuardMock: vi.fn(),
2527runCronIsolatedAgentTurnMock: vi.fn(async () => ({ status: "ok" as const, summary: "ok" })),
2628cleanupBrowserSessionsForLifecycleEndMock: vi.fn(async () => {}),
29+runCronChangedMock: vi.fn(async () => {}),
30+getGlobalHookRunnerMock: vi.fn(() => ({
31+hasHooks: (hookName: string) => hookName === "cron_changed",
32+runCronChanged: runCronChangedMock,
33+})),
2734}));
28352936function enqueueSystemEvent(...args: unknown[]) {
@@ -65,6 +72,14 @@ vi.mock("../config/config.js", async () => {
6572};
6673});
677475+vi.mock("../config/io.js", async () => {
76+const actual = await vi.importActual<typeof import("../config/io.js")>("../config/io.js");
77+return {
78+ ...actual,
79+getRuntimeConfig: () => loadConfigMock(),
80+};
81+});
82+6883vi.mock("../infra/net/fetch-guard.js", () => ({
6984fetchWithSsrFGuard: fetchWithSsrFGuardMock,
7085}));
@@ -77,6 +92,10 @@ vi.mock("../browser-lifecycle-cleanup.js", () => ({
7792cleanupBrowserSessionsForLifecycleEnd: cleanupBrowserSessionsForLifecycleEndMock,
7893}));
799495+vi.mock("../plugins/hook-runner-global.js", () => ({
96+getGlobalHookRunner: getGlobalHookRunnerMock,
97+}));
98+8099import { buildGatewayCronService } from "./server-cron.js";
8110082101function createCronConfig(name: string): OpenClawConfig {
@@ -100,6 +119,121 @@ describe("buildGatewayCronService", () => {
100119fetchWithSsrFGuardMock.mockClear();
101120runCronIsolatedAgentTurnMock.mockClear();
102121cleanupBrowserSessionsForLifecycleEndMock.mockClear();
122+runCronChangedMock.mockClear();
123+getGlobalHookRunnerMock.mockClear();
124+getGlobalHookRunnerMock.mockReturnValue({
125+hasHooks: (hookName: string) => hookName === "cron_changed",
126+runCronChanged: runCronChangedMock,
127+});
128+});
129+130+it("emits cron_changed hooks with computed next run state", async () => {
131+const cfg = createCronConfig("server-cron-hook");
132+loadConfigMock.mockReturnValue(cfg);
133+134+const state = buildGatewayCronService({
135+ cfg,
136+deps: {} as CliDeps,
137+broadcast: () => {},
138+});
139+try {
140+const job = await state.cron.add({
141+name: "scheduler-hook",
142+enabled: true,
143+schedule: { kind: "every", everyMs: 60_000, anchorMs: 1_000 },
144+sessionTarget: "main",
145+wakeMode: "next-heartbeat",
146+payload: { kind: "systemEvent", text: "sync external wake" },
147+});
148+149+expect(runCronChangedMock).toHaveBeenCalledWith(
150+expect.objectContaining({
151+action: "added",
152+jobId: job.id,
153+job: expect.objectContaining({
154+id: job.id,
155+state: expect.objectContaining({ nextRunAtMs: job.state.nextRunAtMs }),
156+}),
157+}),
158+expect.objectContaining({
159+config: cfg,
160+getCron: expect.any(Function),
161+}),
162+);
163+} finally {
164+state.cron.stop();
165+}
166+});
167+168+it("cron_changed removed events include the deleted job snapshot", async () => {
169+const cfg = createCronConfig("server-cron-hook-removed");
170+loadConfigMock.mockReturnValue(cfg);
171+172+const state = buildGatewayCronService({
173+ cfg,
174+deps: {} as CliDeps,
175+broadcast: () => {},
176+});
177+try {
178+const job = await state.cron.add({
179+name: "to-be-removed",
180+enabled: true,
181+schedule: { kind: "every", everyMs: 60_000, anchorMs: 1_000 },
182+sessionTarget: "main",
183+wakeMode: "next-heartbeat",
184+payload: { kind: "systemEvent", text: "will be removed" },
185+});
186+187+runCronChangedMock.mockClear();
188+await state.cron.remove(job.id);
189+190+expect(runCronChangedMock).toHaveBeenCalledWith(
191+expect.objectContaining({
192+action: "removed",
193+jobId: job.id,
194+job: expect.objectContaining({
195+id: job.id,
196+name: "to-be-removed",
197+}),
198+}),
199+expect.objectContaining({
200+getCron: expect.any(Function),
201+}),
202+);
203+} finally {
204+state.cron.stop();
205+}
206+});
207+208+it("cron_changed hook context uses runtime config from getRuntimeConfig()", async () => {
209+const startupCfg = createCronConfig("server-cron-hook-runtime-cfg");
210+const runtimeCfg = { ...startupCfg, _marker: "runtime" };
211+loadConfigMock.mockReturnValue(runtimeCfg);
212+213+const state = buildGatewayCronService({
214+cfg: startupCfg,
215+deps: {} as CliDeps,
216+broadcast: () => {},
217+});
218+try {
219+await state.cron.add({
220+name: "runtime-cfg-check",
221+enabled: true,
222+schedule: { kind: "every", everyMs: 60_000, anchorMs: 1_000 },
223+sessionTarget: "main",
224+wakeMode: "next-heartbeat",
225+payload: { kind: "systemEvent", text: "cfg check" },
226+});
227+228+// The hook context should use getRuntimeConfig() (runtimeCfg), not startupCfg
229+expect(runCronChangedMock).toHaveBeenCalledTimes(1);
230+const calls = runCronChangedMock.mock.calls as unknown[][];
231+const hookCtx = calls[0]?.[1] as { config?: unknown } | undefined;
232+expect(hookCtx?.config).toBe(runtimeCfg);
233+expect(hookCtx?.config).not.toBe(startupCfg);
234+} finally {
235+state.cron.stop();
236+}
103237});
104238105239it("routes main-target jobs to the scoped session for enqueue + wake", async () => {
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。