1import * as Scheduler from "../scheduling/Scheduler.ts";
2import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
3import { expect, it } from "@effect/vitest";
4import { ScheduledTaskUpsertInput } from "@t3tools/contracts";
5import * as DateTime from "effect/DateTime";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Schema from "effect/Schema";
9import * as TestClock from "effect/testing/TestClock";
10
11import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";
12import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
13import * as SecretRequests from "../secrets/SecretRequests.ts";
14import * as SqlitePersistence from "../persistence/Sqlite.ts";
15import * as ScheduledTaskService from "./ScheduledTaskService.ts";
16
17const decodeUpsertInput = Schema.decodeUnknownEffect(ScheduledTaskUpsertInput);
18
19it.effect("rejects a stale form save after deletion while preserving explicit-id creates", () =>
20 Effect.gen(function* () {
21 const layerDependencies = Layer.mergeAll(
22 NodeCrypto.layer,
23 Scheduler.layer,
24 Layer.mock(ThreadLaunchService.ThreadLaunchService)({}),
25 Layer.mock(ThreadManagementService.ThreadManagementService)({}),
26 Layer.mock(SecretRequests.SecretRequests)({}),
27 );
28 yield* Effect.gen(function* () {
29 const service = yield* ScheduledTaskService.ScheduledTaskService;
30 const input = yield* decodeUpsertInput({
31 id: "scheduled-task:edit-after-delete",
32 title: "Review",
33 prompt: "Review the open pull requests.",
34 enabled: true,
35 schedule: { type: "interval", everyMs: 60_000 },
36 projectId: "project-stale-schedule",
37 workspaceStrategy: { type: "root" },
38 modelSelection: { instanceId: "codex", model: "gpt-5.4" },
39 runtimeMode: "full-access",
40 interactionMode: "default",
41 });
42 const created = yield* service.upsert(input);
43 const edit = yield* decodeUpsertInput({ ...input, requireExisting: true, title: "Edited" });
44 expect((yield* service.upsert(edit)).task.title).toBe("Edited");
45 yield* service.delete({ id: created.task.id });
46
47 const failure = yield* service.upsert(edit).pipe(Effect.flip);
48 expect(failure.message).toBe("Schedule task not found.");
49 expect((yield* service.list()).tasks).toEqual([]);
50
51 expect((yield* service.upsert(input)).task.id).toBe(created.task.id);
52 }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies))));
53 }).pipe(Effect.provide(SqlitePersistence.layerMemory)),
54);
55
56it.effect("preserves a due run when a save only pads the scheduled hour", () =>
57 Effect.gen(function* () {
58 const dueAt = DateTime.makeZonedUnsafe(
59 { year: 2026, month: 7, day: 1, hour: 9, minute: 0, second: 0, millisecond: 0 },
60 { timeZone: DateTime.zoneMakeLocal(), adjustForTimeZone: true },
61 );
62 yield* TestClock.setTime(DateTime.toEpochMillis(dueAt) - 1_000);
63
64 const layerDependencies = Layer.mergeAll(
65 NodeCrypto.layer,
66 Scheduler.layer,
67 Layer.mock(ThreadLaunchService.ThreadLaunchService)({}),
68 Layer.mock(ThreadManagementService.ThreadManagementService)({}),
69 Layer.mock(SecretRequests.SecretRequests)({}),
70 );
71 yield* Effect.gen(function* () {
72 const service = yield* ScheduledTaskService.ScheduledTaskService;
73 const input = yield* decodeUpsertInput({
74 commandId: "schedule-time-format",
75 title: "Morning review",
76 prompt: "Review the open pull requests.",
77 enabled: true,
78 schedule: { type: "fixed_time", timeOfDay: "9:00" },
79 projectId: "project-schedule-time-format",
80 workspaceStrategy: { type: "root" },
81 modelSelection: { instanceId: "codex", model: "gpt-5.4" },
82 runtimeMode: "full-access",
83 interactionMode: "default",
84 creationSource: "mcp",
85 });
86 const created = yield* service.upsert(input);
87 const expectedDueAt = DateTime.formatIso(DateTime.toUtc(dueAt));
88 expect(created.task.nextRunAt).toBe(expectedDueAt);
89
90 // Cross the due time before the scheduler's first five-second tick.
91 yield* TestClock.setTime(DateTime.toEpochMillis(dueAt) + 1_000);
92 const update = yield* decodeUpsertInput({
93 ...input,
94 id: created.task.id,
95 schedule: { type: "fixed_time", timeOfDay: "09:00" },
96 });
97 const updated = yield* service.upsert(update);
98 expect(updated.task.nextRunAt).toBe(expectedDueAt);
99 expect((yield* service.list()).tasks[0]?.nextRunAt).toBe(expectedDueAt);
100
101 const rescheduled = yield* service.upsert({
102 ...update,
103 schedule: { type: "fixed_time", timeOfDay: "09:30" },
104 });
105 expect(rescheduled.task.nextRunAt).toBe(
106 DateTime.formatIso(DateTime.toUtc(DateTime.add(dueAt, { minutes: 30 }))),
107 );
108 }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies))));
109 }).pipe(Effect.provide(SqlitePersistence.layerMemory)),
110);