| 1 | import * as Scheduler from "../scheduling/Scheduler.ts"; |
| 2 | import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; |
| 3 | import { expect, it } from "@effect/vitest"; |
| 4 | import { ScheduledTaskUpsertInput } from "@t3tools/contracts"; |
| 5 | import * as DateTime from "effect/DateTime"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as Layer from "effect/Layer"; |
| 8 | import * as Schema from "effect/Schema"; |
| 9 | import * as TestClock from "effect/testing/TestClock"; |
| 10 | |
| 11 | import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; |
| 12 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; |
| 13 | import * as SecretRequests from "../secrets/SecretRequests.ts"; |
| 14 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 15 | import * as ScheduledTaskService from "./ScheduledTaskService.ts"; |
| 16 | |
| 17 | const decodeUpsertInput = Schema.decodeUnknownEffect(ScheduledTaskUpsertInput); |
| 18 | |
| 19 | it.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 | |
| 56 | it.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 | ); |