Skip to content
Source
Sign inSign up

ScheduledTaskService.interval.test.ts

316 lines12.4 KB

1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import { expect, it } from "@effect/vitest";
3import { ScheduledTaskUpsertInput } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Schema from "effect/Schema";
7import * as SqlClient from "effect/sql/SqlClient";
8import * as TestClock from "effect/testing/TestClock";
9import { afterEach, vi } from "vite-plus/test";
10​
11import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";
12import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
13import * as SqlitePersistence from "../persistence/Sqlite.ts";
14import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts";
15import * as Scheduler from "../scheduling/Scheduler.ts";
16import * as SecretRequests from "../secrets/SecretRequests.ts";
17import { ServerActivation } from "../serverActivation.ts";
18import * as ScheduledTaskService from "./ScheduledTaskService.ts";
19import * as ManagedPolicy from "../policy/ManagedPolicy.ts";
20​
21afterEach(() => vi.unstubAllEnvs());
22​
23const dependencies = Layer.mergeAll(
24 NodeCrypto.layer,
25 Scheduler.layer,
26 ManagedPolicy.layerTest(),
27 Layer.mock(ThreadLaunchService.ThreadLaunchService)({
28 launch: () => Effect.die("synthetic dispatch failure"),
29 }),
30 Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({
31 namedProjectsRoot: "/projects",
32 isScratchProject: () => Effect.succeed(false),
33 }),
34 Layer.mock(ThreadManagementService.ThreadManagementService)({}),
35 Layer.mock(SecretRequests.SecretRequests)({}),
36);
37const serviceLayer = ScheduledTaskService.layer.pipe(Layer.provide(dependencies));
38const decodeInput = Schema.decodeUnknownEffect(ScheduledTaskUpsertInput);
39const input = {
40 commandId: "interval-duration",
41 title: "Interval review",
42 prompt: "Review the open pull requests.",
43 enabled: true,
44 projectId: "project-interval-duration",
45 workspaceStrategy: { type: "root" },
46 modelSelection: { instanceId: "codex", model: "gpt-5.4" },
47 runtimeMode: "full-access",
48 interactionMode: "default",
49};
50​
51it.effect.each([
52 {
53 zone: "America/New_York",
54 from: "2026-03-08T06:30:00.000Z",
55 everyMs: 7_200_000,
56 expected: "2026-03-08T08:30:00.000Z",
57 },
58 {
59 zone: "America/New_York",
60 from: "2026-11-01T05:30:00.000Z",
61 everyMs: 7_200_000,
62 expected: "2026-11-01T07:30:00.000Z",
63 },
64 {
65 zone: "America/New_York",
66 from: "2026-11-01T06:30:00.000Z",
67 everyMs: 60_000,
68 expected: "2026-11-01T06:31:00.000Z",
69 },
70 {
71 zone: "UTC",
72 from: "2026-11-01T06:30:00.000Z",
73 everyMs: 60_000,
74 expected: "2026-11-01T06:31:00.000Z",
75 },
76 {
77 zone: "Asia/Tokyo",
78 from: "2026-03-08T06:30:00.000Z",
79 everyMs: 7_200_000,
80 expected: "2026-03-08T08:30:00.000Z",
81 },
82] as const)("persists elapsed interval $zone from $from", ({ zone, from, everyMs, expected }) =>
83 Effect.gen(function* () {
84 vi.stubEnv("TZ", zone);
85 yield* TestClock.setTime(Date.parse(from));
86 yield* Effect.gen(function* () {
87 const service = yield* ScheduledTaskService.ScheduledTaskService;
88 const decoded = yield* decodeInput({ ...input, schedule: { type: "interval", everyMs } });
89 const created = yield* service.upsert(decoded);
90 expect(created.task.nextRunAt).toBe(expected);
91 expect((yield* service.list()).tasks[0]?.nextRunAt).toBe(expected);
92​
93 const paused = yield* service.setEnabled({ id: created.task.id, enabled: false });
94 expect(paused.task.nextRunAt).toBeNull();
95 const resumed = yield* service.setEnabled({ id: created.task.id, enabled: true });
96 expect(resumed.task.nextRunAt).toBe(expected);
97​
98 // Exercise completion rescheduling without starting any provider process.
99 const completed = yield* service.runNow({ id: created.task.id });
100 expect(completed.task.lastRunStatus).toBe("failed");
101 expect(completed.threadId).toBeNull();
102 expect(completed.task.runCount).toBe(1);
103 expect(completed.task.nextRunAt).toBe(expected);
104 expect((yield* service.list()).tasks[0]?.nextRunAt).toBe(expected);
105 }).pipe(Effect.provide(serviceLayer));
106 }).pipe(
107 // Keep the real scheduler parked while exercising finite service actions.
108 Effect.provideService(ServerActivation, Effect.never),
109 Effect.provide(SqlitePersistence.layerMemory),
110 ),
111);
112​
113it.effect.each([
114 { from: "2026-03-07T23:30:00.000Z", expected: "2026-03-08T13:00:00.000Z" },
115 { from: "2026-10-31T23:30:00.000Z", expected: "2026-11-01T14:00:00.000Z" },
116] as const)("keeps fixed-time schedules on the local calendar from $from", ({ from, expected }) =>
117 Effect.gen(function* () {
118 vi.stubEnv("TZ", "America/New_York");
119 yield* TestClock.setTime(Date.parse(from));
120 yield* Effect.gen(function* () {
121 const service = yield* ScheduledTaskService.ScheduledTaskService;
122 const decoded = yield* decodeInput({
123 ...input,
124 schedule: { type: "fixed_time", timeOfDay: "09:00" },
125 });
126 const created = yield* service.upsert(decoded);
127 expect(created.task.nextRunAt).toBe(expected);
128 expect((yield* service.list()).tasks[0]?.nextRunAt).toBe(expected);
129 }).pipe(Effect.provide(serviceLayer));
130 }).pipe(
131 Effect.provideService(ServerActivation, Effect.never),
132 Effect.provide(SqlitePersistence.layerMemory),
133 ),
134);
135​
136// Each launch takes two minutes, so a manual run can start before the run time and end after it.
137const slowLaunchLayer = ScheduledTaskService.layer.pipe(
138 Layer.provide(
139 Layer.merge(
140 dependencies,
141 Layer.mock(ThreadLaunchService.ThreadLaunchService)({
142 launch: () => TestClock.adjust("2 minutes").pipe(Effect.andThen(Effect.die("synthetic"))),
143 }),
144 ),
145 ),
146);
147​
148it.effect("runs a one-time schedule once and rejects past times", () =>
149 Effect.gen(function* () {
150 yield* TestClock.setTime(Date.parse("2026-11-01T07:59:00.000Z"));
151 yield* Effect.gen(function* () {
152 const service = yield* ScheduledTaskService.ScheduledTaskService;
153 const decoded = yield* decodeInput({
154 ...input,
155 schedule: { type: "once", runAt: "2026-11-01T03:00:00-05:00" },
156 });
157 const created = yield* service.upsert(decoded);
158 expect(created.task.schedule).toEqual({ type: "once", runAt: "2026-11-01T08:00:00.000Z" });
159 expect(created.task.nextRunAt).toBe("2026-11-01T08:00:00.000Z");
160​
161 // A manual run that starts before the time keeps the scheduled run, even if it ends after.
162 const early = yield* service.runNow({ id: created.task.id });
163 expect(early.task.nextRunAt).toBe("2026-11-01T08:00:00.000Z");
164​
165 const due = yield* service.runNow({ id: created.task.id });
166 expect(due.task.nextRunAt).toBeNull();
167 expect((yield* service.list()).tasks[0]?.nextRunAt).toBeNull();
168​
169 yield* service.setEnabled({ id: created.task.id, enabled: false });
170 const resumed = yield* service
171 .setEnabled({ id: created.task.id, enabled: true })
172 .pipe(Effect.flip);
173 expect(resumed.message).toBe("The run time has passed. Pick a time in the future.");
174​
175 const past = yield* service
176 .upsert({ ...decoded, commandId: undefined, title: "Late" })
177 .pipe(Effect.flip);
178 expect(past.message).toBe("The run time has passed. Pick a time in the future.");
179 }).pipe(Effect.provide(slowLaunchLayer));
180 }).pipe(
181 Effect.provideService(ServerActivation, Effect.never),
182 Effect.provide(SqlitePersistence.layerMemory),
183 ),
184);
185​
186const ONCE = { type: "once", runAt: "2026-11-01T08:00:00Z" } as const;
187/** A next run that only the simulated concurrent write produces. */
188const MOVED = "2030-01-01T00:00:00.000Z";
189​
190const onceInput = {
191 ...input,
192 commandId: "once-race",
193 schedule: { type: "once", runAt: "2026-11-01T08:00:00Z" },
194};
195​
196it.effect.each([
197 { change: "a run completes", saved: ONCE, edited: ONCE, pause: false, expected: MOVED },
198 {
199 change: "a run completes during a padded-time edit",
200 saved: { type: "fixed_time", timeOfDay: "9:00" },
201 edited: { type: "fixed_time", timeOfDay: "09:00" },
202 pause: false,
203 expected: MOVED,
204 },
205 {
206 change: "another client pauses the task",
207 saved: ONCE,
208 edited: ONCE,
209 pause: true,
210 expected: "2026-11-01T08:00:00.000Z",
211 },
212] as const)(
213 "a title save keeps the stored next run only while nothing else changed: $change",
214 ({ saved, edited, pause, expected }) =>
215 Effect.gen(function* () {
216 yield* TestClock.setTime(Date.parse("2026-11-01T07:00:00.000Z"));
217 // The project check runs between the save's read and write, so it stands in for
218 // a concurrent write in that gap.
219 const writeMidSave = Layer.unwrap(
220 Effect.gen(function* () {
221 const sql = yield* SqlClient.SqlClient;
222 return Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({
223 namedProjectsRoot: "/projects",
224 isScratchProject: () =>
225 sql`UPDATE scheduled_tasks SET next_run_at = ${MOVED}, enabled = ${pause ? 0 : 1}`.pipe(
226 Effect.as(false),
227 Effect.orDie,
228 ),
229 });
230 }),
231 );
232 const layer = ScheduledTaskService.layer.pipe(
233 Layer.provide(Layer.merge(dependencies, writeMidSave)),
234 );
235 yield* Effect.gen(function* () {
236 const service = yield* ScheduledTaskService.ScheduledTaskService;
237 const worktree = { type: "worktree", baseRef: "main" };
238 yield* service.upsert(
239 yield* decodeInput({
240 ...onceInput,
241 schedule: saved,
242 workspaceStrategy: { type: "root" },
243 }),
244 );
245 const renamed = yield* service.upsert(
246 yield* decodeInput({
247 ...onceInput,
248 schedule: edited,
249 workspaceStrategy: worktree,
250 title: "Renamed",
251 }),
252 );
253 expect(renamed.task.enabled).toBe(true);
254 expect(renamed.task.nextRunAt).toBe(expected);
255 }).pipe(Effect.provide(layer));
256 }).pipe(
257 Effect.provideService(ServerActivation, Effect.never),
258 Effect.provide(SqlitePersistence.layerMemory),
259 ),
260);
261​
262it.effect("keeps a one-time run after a restart interrupts an earlier manual run", () =>
263 Effect.gen(function* () {
264 yield* TestClock.setTime(Date.parse("2026-11-01T07:59:00.000Z"));
265 const sql = yield* SqlClient.SqlClient;
266 const created = yield* Effect.gen(function* () {
267 const service = yield* ScheduledTaskService.ScheduledTaskService;
268 return yield* service.upsert(yield* decodeInput(onceInput));
269 }).pipe(Effect.provide(serviceLayer));
270 yield* sql`UPDATE scheduled_tasks SET last_run_status = 'running', last_run_at = '2026-11-01T07:59:00.000Z'`;
271​
272 yield* TestClock.setTime(Date.parse("2026-11-01T08:05:00.000Z"));
273 yield* Effect.gen(function* () {
274 const service = yield* ScheduledTaskService.ScheduledTaskService;
275 const [task] = (yield* service.list()).tasks;
276 expect(task?.id).toBe(created.task.id);
277 expect(task?.lastRunStatus).toBe("failed");
278 expect(task?.nextRunAt).toBe("2026-11-01T08:00:00.000Z");
279 }).pipe(Effect.provide(serviceLayer));
280 }).pipe(
281 Effect.provideService(ServerActivation, Effect.never),
282 Effect.provide(SqlitePersistence.layerMemory),
283 ),
284);
285​
286it.effect("reports the stored task when the row was replaced during the run", () =>
287 Effect.gen(function* () {
288 yield* TestClock.setTime(Date.parse("2026-11-01T07:00:00.000Z"));
289 // The launch stands in for a delete and same-id recreate while the run is out.
290 const replaceRowDuringLaunch = Layer.unwrap(
291 Effect.gen(function* () {
292 const sql = yield* SqlClient.SqlClient;
293 return Layer.mock(ThreadLaunchService.ThreadLaunchService)({
294 launch: () =>
295 sql`UPDATE scheduled_tasks SET last_run_status = 'never', last_run_at = NULL, run_count = 0`.pipe(
296 Effect.orDie,
297 Effect.andThen(Effect.die("synthetic dispatch failure")),
298 ),
299 });
300 }),
301 );
302 const layer = ScheduledTaskService.layer.pipe(
303 Layer.provide(Layer.merge(dependencies, replaceRowDuringLaunch)),
304 );
305 yield* Effect.gen(function* () {
306 const service = yield* ScheduledTaskService.ScheduledTaskService;
307 const created = yield* service.upsert(yield* decodeInput(onceInput));
308 const ran = yield* service.runNow({ id: created.task.id });
309 expect(ran.task.runCount).toBe(0);
310 expect(ran.task.lastRunStatus).toBe("never");
311 }).pipe(Effect.provide(layer));
312 }).pipe(
313 Effect.provideService(ServerActivation, Effect.never),
314 Effect.provide(SqlitePersistence.layerMemory),
315 ),
316);