From 3b245cf18c0445abde84d6828a7c868fa2140693 Mon Sep 17 00:00:00 2001 From: d-kimsuon Date: Sat, 25 Oct 2025 17:56:46 +0900 Subject: [PATCH] fix bugs after manual check --- src/app/api/[[...route]]/route.ts | 2 + .../sessionSidebar/SchedulerTab.tsx | 14 +- .../scheduler/SchedulerJobDialog.tsx | 242 ++++++++---------- src/server/core/scheduler/config.test.ts | 19 +- src/server/core/scheduler/config.ts | 13 +- src/server/core/scheduler/domain/Job.test.ts | 153 ++++++----- src/server/core/scheduler/domain/Job.ts | 44 ++-- .../core/scheduler/domain/Scheduler.test.ts | 88 ++++--- src/server/core/scheduler/domain/Scheduler.ts | 106 +++++--- src/server/core/scheduler/schema.ts | 21 +- src/server/hono/route.ts | 60 +---- 11 files changed, 383 insertions(+), 379 deletions(-) diff --git a/src/app/api/[[...route]]/route.ts b/src/app/api/[[...route]]/route.ts index 3d4d922..13072ff 100644 --- a/src/app/api/[[...route]]/route.ts +++ b/src/app/api/[[...route]]/route.ts @@ -16,6 +16,7 @@ import { GitService } from "../../../server/core/git/services/GitService"; import { ProjectRepository } from "../../../server/core/project/infrastructure/ProjectRepository"; import { ProjectController } from "../../../server/core/project/presentation/ProjectController"; import { ProjectMetaService } from "../../../server/core/project/services/ProjectMetaService"; +import { SchedulerConfigBaseDir } from "../../../server/core/scheduler/config"; import { SchedulerService } from "../../../server/core/scheduler/domain/Scheduler"; import { SchedulerController } from "../../../server/core/scheduler/presentation/SchedulerController"; import { SessionRepository } from "../../../server/core/session/infrastructure/SessionRepository"; @@ -57,6 +58,7 @@ await Effect.runPromise( Effect.provide(ClaudeCodeService.Live), Effect.provide(GitService.Live), Effect.provide(SchedulerService.Live), + Effect.provide(SchedulerConfigBaseDir.Live), ) .pipe( /** Infrastructure */ diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SchedulerTab.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SchedulerTab.tsx index ad64133..d66bcfd 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SchedulerTab.tsx +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SchedulerTab.tsx @@ -140,15 +140,11 @@ export const SchedulerTab: FC<{ projectId: string; sessionId: string }> = ({ if (job.schedule.type === "cron") { return `Cron: ${job.schedule.expression}`; } - const hours = Math.floor(job.schedule.delayMs / 3600000); - const minutes = Math.floor((job.schedule.delayMs % 3600000) / 60000); - const timeStr = - hours > 0 - ? `${hours}h ${minutes}m` - : minutes > 0 - ? `${minutes}m` - : `${job.schedule.delayMs}ms`; - return `${job.schedule.oneTime ? "Once" : "Recurring"}: ${timeStr}`; + if (job.schedule.type === "reserved") { + const date = new Date(job.schedule.reservedExecutionTime); + return `Reserved: ${date.toLocaleString()}`; + } + return "Unknown schedule type"; }; const formatLastRun = (lastRunAt: string | null) => { diff --git a/src/components/scheduler/SchedulerJobDialog.tsx b/src/components/scheduler/SchedulerJobDialog.tsx index d044c00..03d1bd6 100644 --- a/src/components/scheduler/SchedulerJobDialog.tsx +++ b/src/components/scheduler/SchedulerJobDialog.tsx @@ -1,7 +1,7 @@ "use client"; import { Trans, useLingui } from "@lingui/react"; -import { type FC, useCallback, useEffect, useState } from "react"; +import { type FC, useEffect, useState } from "react"; import { InlineCompletion } from "@/app/projects/[projectId]/components/chatForm/InlineCompletion"; import { useMessageCompletion } from "@/app/projects/[projectId]/components/chatForm/useMessageCompletion"; import { Button } from "@/components/ui/button"; @@ -40,8 +40,6 @@ export interface SchedulerJobDialogProps { isSubmitting?: boolean; } -type DelayUnit = "minutes" | "hours" | "days"; - export const SchedulerJobDialog: FC = ({ open, onOpenChange, @@ -53,10 +51,13 @@ export const SchedulerJobDialog: FC = ({ const { _, i18n } = useLingui(); const [name, setName] = useState(""); - const [scheduleType, setScheduleType] = useState<"cron" | "fixed">("cron"); + const [scheduleType, setScheduleType] = useState<"cron" | "reserved">("cron"); const [cronExpression, setCronExpression] = useState("0 9 * * *"); - const [delayValue, setDelayValue] = useState(60); // 60 minutes default - const [delayUnit, setDelayUnit] = useState("minutes"); + const [reservedDateTime, setReservedDateTime] = useState(() => { + const now = new Date(); + now.setHours(now.getHours() + 1); + return now.toISOString().slice(0, 16); + }); const [messageContent, setMessageContent] = useState(""); const [enabled, setEnabled] = useState(true); const [concurrencyPolicy, setConcurrencyPolicy] = useState<"skip" | "run">( @@ -66,36 +67,6 @@ export const SchedulerJobDialog: FC = ({ // Message completion hook const completion = useMessageCompletion(); - // Convert delay value and unit to milliseconds - const delayToMs = useCallback((value: number, unit: DelayUnit): number => { - switch (unit) { - case "minutes": - return value * 60 * 1000; - case "hours": - return value * 60 * 60 * 1000; - case "days": - return value * 24 * 60 * 60 * 1000; - } - }, []); - - // Convert milliseconds to delay value and unit - const msToDelay = useCallback( - (ms: number): { value: number; unit: DelayUnit } => { - const minutes = ms / (60 * 1000); - const hours = ms / (60 * 60 * 1000); - const days = ms / (24 * 60 * 60 * 1000); - - if (days >= 1 && days === Math.floor(days)) { - return { value: days, unit: "days" }; - } - if (hours >= 1 && hours === Math.floor(hours)) { - return { value: hours, unit: "hours" }; - } - return { value: minutes, unit: "minutes" }; - }, - [], - ); - // Initialize form with job data when editing useEffect(() => { if (job) { @@ -103,42 +74,82 @@ export const SchedulerJobDialog: FC = ({ setScheduleType(job.schedule.type); if (job.schedule.type === "cron") { setCronExpression(job.schedule.expression); - } else { - const { value, unit } = msToDelay(job.schedule.delayMs); - setDelayValue(value); - setDelayUnit(unit); + setConcurrencyPolicy(job.schedule.concurrencyPolicy); + } else if (job.schedule.type === "reserved") { + // Convert UTC time to local time for display + const date = new Date(job.schedule.reservedExecutionTime); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + setReservedDateTime(`${year}-${month}-${day}T${hours}:${minutes}`); } setMessageContent(job.message.content); setEnabled(job.enabled); - setConcurrencyPolicy(job.concurrencyPolicy); } else { // Reset form for new job setName(""); setScheduleType("cron"); setCronExpression("0 9 * * *"); - setDelayValue(60); - setDelayUnit("minutes"); + const now = new Date(); + now.setHours(now.getHours() + 1); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + const hours = String(now.getHours()).padStart(2, "0"); + const minutes = String(now.getMinutes()).padStart(2, "0"); + setReservedDateTime(`${year}-${month}-${day}T${hours}:${minutes}`); setMessageContent(""); setEnabled(true); setConcurrencyPolicy("skip"); } - }, [job, msToDelay]); + }, [job]); const handleSubmit = () => { - const delayMs = delayToMs(delayValue, delayUnit); const newJob: NewSchedulerJob = { name, schedule: scheduleType === "cron" - ? { type: "cron", expression: cronExpression } - : { type: "fixed", delayMs, oneTime: true }, + ? { + type: "cron", + expression: cronExpression, + concurrencyPolicy, + } + : { + type: "reserved", + // datetime-local returns "YYYY-MM-DDTHH:mm" in local time + // We need to treat this as local time and convert to UTC + reservedExecutionTime: (() => { + // datetime-local format: "YYYY-MM-DDTHH:mm" + // Parse as local time and convert to ISO string (UTC) + const match = reservedDateTime.match( + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/, + ); + if (!match) { + throw new Error("Invalid datetime format"); + } + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hours = Number(match[4]); + const minutes = Number(match[5]); + const localDate = new Date( + year, + month - 1, + day, + hours, + minutes, + ); + return localDate.toISOString(); + })(), + }, message: { content: messageContent, projectId, baseSessionId: null, }, enabled, - concurrencyPolicy, }; onSubmit(newJob); @@ -220,7 +231,7 @@ export const SchedulerJobDialog: FC = ({ - setDelayValue(Number.parseInt(e.target.value, 10)) - } - disabled={isSubmitting} - className="flex-1" - placeholder="60" + + setReservedDateTime(e.target.value)} + disabled={isSubmitting} + />

@@ -358,40 +340,42 @@ export const SchedulerJobDialog: FC = ({

- {/* Concurrency Policy */} -
- - -
+ {/* Concurrency Policy (only for cron schedules) */} + {scheduleType === "cron" && ( +
+ + +
+ )} diff --git a/src/server/core/scheduler/config.test.ts b/src/server/core/scheduler/config.test.ts index b483041..c83da96 100644 --- a/src/server/core/scheduler/config.test.ts +++ b/src/server/core/scheduler/config.test.ts @@ -9,17 +9,29 @@ import { getConfigPath, initializeConfig, readConfig, + SchedulerConfigBaseDir, writeConfig, } from "./config"; import type { SchedulerConfig } from "./schema"; describe("scheduler config", () => { let testDir: string; - const testLayer = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer); + let testLayer: Layer.Layer< + FileSystem.FileSystem | Path.Path | SchedulerConfigBaseDir + >; beforeEach(async () => { testDir = join(tmpdir(), `scheduler-test-${Date.now()}`); await mkdir(testDir, { recursive: true }); + + // Use test directory as base for config files + const testConfigBaseDir = Layer.succeed(SchedulerConfigBaseDir, testDir); + + testLayer = Layer.mergeAll( + NodeFileSystem.layer, + NodePath.layer, + testConfigBaseDir, + ); }); afterEach(async () => { @@ -31,7 +43,8 @@ describe("scheduler config", () => { getConfigPath.pipe(Effect.provide(testLayer)), ); - expect(result).toContain(".claude-code-viewer/scheduler/config.json"); + expect(result).toContain("/scheduler/schedules.json"); + expect(result).toContain(testDir); }); test("writeConfig and readConfig work correctly", async () => { @@ -43,6 +56,7 @@ describe("scheduler config", () => { schedule: { type: "cron", expression: "0 0 * * *", + concurrencyPolicy: "skip", }, message: { content: "test message", @@ -50,7 +64,6 @@ describe("scheduler config", () => { baseSessionId: null, }, enabled: true, - concurrencyPolicy: "skip", createdAt: "2025-10-25T00:00:00Z", lastRunAt: null, lastRunStatus: null, diff --git a/src/server/core/scheduler/config.ts b/src/server/core/scheduler/config.ts index 69a3005..83e42f6 100644 --- a/src/server/core/scheduler/config.ts +++ b/src/server/core/scheduler/config.ts @@ -1,6 +1,6 @@ import { homedir } from "node:os"; import { FileSystem, Path } from "@effect/platform"; -import { Data, Effect } from "effect"; +import { Context, Data, Effect, Layer } from "effect"; import { type SchedulerConfig, schedulerConfigSchema } from "./schema"; class ConfigFileNotFoundError extends Data.TaggedError( @@ -15,11 +15,18 @@ class ConfigParseError extends Data.TaggedError("ConfigParseError")<{ }> {} const CONFIG_DIR = "scheduler"; -const CONFIG_FILE = "config.json"; +const CONFIG_FILE = "schedules.json"; + +// Service to provide base directory (for testing) +export class SchedulerConfigBaseDir extends Context.Tag( + "SchedulerConfigBaseDir", +)() { + static Live = Layer.succeed(this, `${homedir()}/.claude-code-viewer`); +} export const getConfigPath = Effect.gen(function* () { const path = yield* Path.Path; - const baseDir = path.resolve(homedir(), ".claude-code-viewer"); + const baseDir = yield* SchedulerConfigBaseDir; return path.join(baseDir, CONFIG_DIR, CONFIG_FILE); }); diff --git a/src/server/core/scheduler/domain/Job.test.ts b/src/server/core/scheduler/domain/Job.test.ts index 7bb31ce..c84d511 100644 --- a/src/server/core/scheduler/domain/Job.test.ts +++ b/src/server/core/scheduler/domain/Job.test.ts @@ -1,16 +1,19 @@ import { describe, expect, test } from "vitest"; import type { SchedulerJob } from "../schema"; -import { calculateFixedDelay, shouldExecuteJob } from "./Job"; +import { calculateReservedDelay, shouldExecuteJob } from "./Job"; describe("shouldExecuteJob", () => { test("returns false when job is disabled", () => { const job: SchedulerJob = { id: "test-job", name: "Test Job", - schedule: { type: "cron", expression: "* * * * *" }, + schedule: { + type: "cron", + expression: "* * * * *", + concurrencyPolicy: "skip", + }, message: { content: "test", projectId: "proj-1", baseSessionId: null }, enabled: false, - concurrencyPolicy: "skip", createdAt: "2025-10-25T00:00:00Z", lastRunAt: null, lastRunStatus: null, @@ -23,10 +26,13 @@ describe("shouldExecuteJob", () => { const job: SchedulerJob = { id: "test-job", name: "Test Job", - schedule: { type: "cron", expression: "* * * * *" }, + schedule: { + type: "cron", + expression: "* * * * *", + concurrencyPolicy: "skip", + }, message: { content: "test", projectId: "proj-1", baseSessionId: null }, enabled: true, - concurrencyPolicy: "skip", createdAt: "2025-10-25T00:00:00Z", lastRunAt: null, lastRunStatus: null, @@ -35,14 +41,16 @@ describe("shouldExecuteJob", () => { expect(shouldExecuteJob(job, new Date())).toBe(true); }); - test("returns false for oneTime fixed job that has already run", () => { + test("returns false for reserved job that has already run", () => { const job: SchedulerJob = { id: "test-job", name: "Test Job", - schedule: { type: "fixed", delayMs: 60000, oneTime: true }, + schedule: { + type: "reserved", + reservedExecutionTime: "2025-10-25T00:01:00Z", + }, message: { content: "test", projectId: "proj-1", baseSessionId: null }, enabled: true, - concurrencyPolicy: "skip", createdAt: "2025-10-25T00:00:00Z", lastRunAt: "2025-10-25T00:01:00Z", lastRunStatus: "success", @@ -51,18 +59,19 @@ describe("shouldExecuteJob", () => { expect(shouldExecuteJob(job, new Date())).toBe(false); }); - test("returns false for oneTime fixed job when scheduled time has not arrived", () => { - const createdAt = new Date("2025-10-25T00:00:00Z"); + test("returns false for reserved job when scheduled time has not arrived", () => { const now = new Date("2025-10-25T00:00:30Z"); const job: SchedulerJob = { id: "test-job", name: "Test Job", - schedule: { type: "fixed", delayMs: 60000, oneTime: true }, + schedule: { + type: "reserved", + reservedExecutionTime: "2025-10-25T00:01:00Z", + }, message: { content: "test", projectId: "proj-1", baseSessionId: null }, enabled: true, - concurrencyPolicy: "skip", - createdAt: createdAt.toISOString(), + createdAt: "2025-10-25T00:00:00Z", lastRunAt: null, lastRunStatus: null, }; @@ -70,98 +79,88 @@ describe("shouldExecuteJob", () => { expect(shouldExecuteJob(job, now)).toBe(false); }); - test("returns true for oneTime fixed job when scheduled time has arrived", () => { - const createdAt = new Date("2025-10-25T00:00:00Z"); + test("returns true for reserved job when scheduled time has arrived", () => { const now = new Date("2025-10-25T00:01:01Z"); const job: SchedulerJob = { id: "test-job", name: "Test Job", - schedule: { type: "fixed", delayMs: 60000, oneTime: true }, + schedule: { + type: "reserved", + reservedExecutionTime: "2025-10-25T00:01:00Z", + }, message: { content: "test", projectId: "proj-1", baseSessionId: null }, enabled: true, - concurrencyPolicy: "skip", - createdAt: createdAt.toISOString(), + createdAt: "2025-10-25T00:00:00Z", lastRunAt: null, lastRunStatus: null, }; expect(shouldExecuteJob(job, now)).toBe(true); }); - - test("returns true for recurring fixed job", () => { - const job: SchedulerJob = { - id: "test-job", - name: "Test Job", - schedule: { type: "fixed", delayMs: 60000, oneTime: false }, - message: { content: "test", projectId: "proj-1", baseSessionId: null }, - enabled: true, - concurrencyPolicy: "skip", - createdAt: "2025-10-25T00:00:00Z", - lastRunAt: null, - lastRunStatus: null, - }; - - expect(shouldExecuteJob(job, new Date())).toBe(true); - }); }); -describe("calculateFixedDelay", () => { +describe("calculateReservedDelay", () => { test("calculates delay correctly for future scheduled time", () => { - const createdAt = new Date("2025-10-25T00:00:00Z"); const now = new Date("2025-10-25T00:00:30Z"); const job: SchedulerJob = { id: "test-job", name: "Test Job", - schedule: { type: "fixed", delayMs: 60000, oneTime: true }, + schedule: { + type: "reserved", + reservedExecutionTime: "2025-10-25T00:01:00Z", + }, message: { content: "test", projectId: "proj-1", baseSessionId: null }, enabled: true, - concurrencyPolicy: "skip", - createdAt: createdAt.toISOString(), - lastRunAt: null, - lastRunStatus: null, - }; - - const delay = calculateFixedDelay(job, now); - expect(delay).toBe(30000); - }); - - test("returns 0 for past scheduled time", () => { - const createdAt = new Date("2025-10-25T00:00:00Z"); - const now = new Date("2025-10-25T00:02:00Z"); - - const job: SchedulerJob = { - id: "test-job", - name: "Test Job", - schedule: { type: "fixed", delayMs: 60000, oneTime: true }, - message: { content: "test", projectId: "proj-1", baseSessionId: null }, - enabled: true, - concurrencyPolicy: "skip", - createdAt: createdAt.toISOString(), - lastRunAt: null, - lastRunStatus: null, - }; - - const delay = calculateFixedDelay(job, now); - expect(delay).toBe(0); - }); - - test("throws error for non-fixed schedule type", () => { - const job: SchedulerJob = { - id: "test-job", - name: "Test Job", - schedule: { type: "cron", expression: "* * * * *" }, - message: { content: "test", projectId: "proj-1", baseSessionId: null }, - enabled: true, - concurrencyPolicy: "skip", createdAt: "2025-10-25T00:00:00Z", lastRunAt: null, lastRunStatus: null, }; - expect(() => calculateFixedDelay(job, new Date())).toThrow( - "Job schedule type must be fixed", + const delay = calculateReservedDelay(job, now); + expect(delay).toBe(30000); + }); + + test("returns 0 for past scheduled time", () => { + const now = new Date("2025-10-25T00:02:00Z"); + + const job: SchedulerJob = { + id: "test-job", + name: "Test Job", + schedule: { + type: "reserved", + reservedExecutionTime: "2025-10-25T00:01:00Z", + }, + message: { content: "test", projectId: "proj-1", baseSessionId: null }, + enabled: true, + createdAt: "2025-10-25T00:00:00Z", + lastRunAt: null, + lastRunStatus: null, + }; + + const delay = calculateReservedDelay(job, now); + expect(delay).toBe(0); + }); + + test("throws error for non-reserved schedule type", () => { + const job: SchedulerJob = { + id: "test-job", + name: "Test Job", + schedule: { + type: "cron", + expression: "* * * * *", + concurrencyPolicy: "skip", + }, + message: { content: "test", projectId: "proj-1", baseSessionId: null }, + enabled: true, + createdAt: "2025-10-25T00:00:00Z", + lastRunAt: null, + lastRunStatus: null, + }; + + expect(() => calculateReservedDelay(job, new Date())).toThrow( + "Job schedule type must be reserved", ); }); }); diff --git a/src/server/core/scheduler/domain/Job.ts b/src/server/core/scheduler/domain/Job.ts index 4537ae7..b985677 100644 --- a/src/server/core/scheduler/domain/Job.ts +++ b/src/server/core/scheduler/domain/Job.ts @@ -20,23 +20,15 @@ export const executeJob = (job: SchedulerJob) => ); } - if (message.baseSessionId === null) { - yield* lifeCycleService.startTask({ - baseSession: { - cwd: project.meta.projectPath, - projectId: message.projectId, - sessionId: undefined, - }, - userConfig, - message: message.content, - }); - } else { - yield* lifeCycleService.continueTask({ - sessionProcessId: message.baseSessionId, - message: message.content, - baseSessionId: message.baseSessionId, - }); - } + yield* lifeCycleService.startTask({ + baseSession: { + cwd: project.meta.projectPath, + projectId: message.projectId, + sessionId: message.baseSessionId ?? undefined, + }, + userConfig, + message: message.content, + }); }); export const shouldExecuteJob = (job: SchedulerJob, now: Date): boolean => { @@ -48,26 +40,28 @@ export const shouldExecuteJob = (job: SchedulerJob, now: Date): boolean => { return true; } - if (job.schedule.type === "fixed" && job.schedule.oneTime) { + if (job.schedule.type === "reserved") { + // Reserved jobs are one-time, skip if already executed if (job.lastRunStatus !== null) { return false; } - const createdAt = new Date(job.createdAt); - const scheduledTime = new Date(createdAt.getTime() + job.schedule.delayMs); + const scheduledTime = new Date(job.schedule.reservedExecutionTime); return now >= scheduledTime; } return true; }; -export const calculateFixedDelay = (job: SchedulerJob, now: Date): number => { - if (job.schedule.type !== "fixed") { - throw new Error("Job schedule type must be fixed"); +export const calculateReservedDelay = ( + job: SchedulerJob, + now: Date, +): number => { + if (job.schedule.type !== "reserved") { + throw new Error("Job schedule type must be reserved"); } - const createdAt = new Date(job.createdAt); - const scheduledTime = new Date(createdAt.getTime() + job.schedule.delayMs); + const scheduledTime = new Date(job.schedule.reservedExecutionTime); const delay = scheduledTime.getTime() - now.getTime(); return Math.max(0, delay); diff --git a/src/server/core/scheduler/domain/Scheduler.test.ts b/src/server/core/scheduler/domain/Scheduler.test.ts index 4059771..2c8452d 100644 --- a/src/server/core/scheduler/domain/Scheduler.test.ts +++ b/src/server/core/scheduler/domain/Scheduler.test.ts @@ -1,5 +1,5 @@ -import { mkdir, rm, unlink } from "node:fs/promises"; -import { homedir, tmpdir } from "node:os"; +import { mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { NodeContext, NodeFileSystem, NodePath } from "@effect/platform-node"; import { Effect, Layer } from "effect"; @@ -9,6 +9,7 @@ import { ClaudeCodeSessionProcessService } from "../../claude-code/services/Clau import { EnvService } from "../../platform/services/EnvService"; import { UserConfigService } from "../../platform/services/UserConfigService"; import { ProjectRepository } from "../../project/infrastructure/ProjectRepository"; +import { SchedulerConfigBaseDir } from "../config"; import type { NewSchedulerJob } from "../schema"; import { SchedulerService } from "./Scheduler"; @@ -66,37 +67,42 @@ describe("SchedulerService", () => { getEnv: () => Effect.succeed(undefined), } as never); - const baseLayers = Layer.mergeAll( - NodeFileSystem.layer, - NodePath.layer, - NodeContext.layer, - mockSessionProcessService, - mockLifeCycleService, - mockProjectRepository, - mockUserConfigService, - mockEnvService, - ); - - const testLayer = Layer.mergeAll(SchedulerService.Live, baseLayers).pipe( - Layer.provide(baseLayers), - ); + let testConfigBaseDir: Layer.Layer; + let testLayer: Layer.Layer< + | import("@effect/platform").FileSystem.FileSystem + | import("@effect/platform").Path.Path + | import("@effect/platform-node").NodeContext.NodeContext + | ClaudeCodeSessionProcessService + | ClaudeCodeLifeCycleService + | ProjectRepository + | UserConfigService + | EnvService + | SchedulerConfigBaseDir + | SchedulerService + >; beforeEach(async () => { testDir = join(tmpdir(), `scheduler-test-${Date.now()}`); await mkdir(testDir, { recursive: true }); - // Clean up existing config file - const configPath = join( - homedir(), - ".claude-code-viewer", - "scheduler", - "config.json", + // Use test directory as base for config files + testConfigBaseDir = Layer.succeed(SchedulerConfigBaseDir, testDir); + + const baseLayers = Layer.mergeAll( + NodeFileSystem.layer, + NodePath.layer, + NodeContext.layer, + mockSessionProcessService, + mockLifeCycleService, + mockProjectRepository, + mockUserConfigService, + mockEnvService, + testConfigBaseDir, + ); + + testLayer = Layer.mergeAll(SchedulerService.Live, baseLayers).pipe( + Layer.provideMerge(baseLayers), ); - try { - await unlink(configPath); - } catch { - // Ignore if file doesn't exist - } }); afterEach(async () => { @@ -106,14 +112,17 @@ describe("SchedulerService", () => { test("addJob creates a new job with generated id", async () => { const newJob: NewSchedulerJob = { name: "Test Job", - schedule: { type: "cron", expression: "0 0 * * *" }, + schedule: { + type: "cron", + expression: "0 0 * * *", + concurrencyPolicy: "skip", + }, message: { content: "test message", projectId: "project-1", baseSessionId: null, }, enabled: false, - concurrencyPolicy: "skip", }; const result = await Effect.runPromise( @@ -134,14 +143,17 @@ describe("SchedulerService", () => { test("getJobs returns all jobs", async () => { const newJob: NewSchedulerJob = { name: "Test Job", - schedule: { type: "cron", expression: "0 0 * * *" }, + schedule: { + type: "cron", + expression: "0 0 * * *", + concurrencyPolicy: "skip", + }, message: { content: "test message", projectId: "project-1", baseSessionId: null, }, enabled: false, - concurrencyPolicy: "skip", }; const result = await Effect.runPromise( @@ -159,14 +171,17 @@ describe("SchedulerService", () => { test("updateJob modifies an existing job", async () => { const newJob: NewSchedulerJob = { name: "Test Job", - schedule: { type: "cron", expression: "0 0 * * *" }, + schedule: { + type: "cron", + expression: "0 0 * * *", + concurrencyPolicy: "skip", + }, message: { content: "test message", projectId: "project-1", baseSessionId: null, }, enabled: false, - concurrencyPolicy: "skip", }; const result = await Effect.runPromise( @@ -186,14 +201,17 @@ describe("SchedulerService", () => { test("deleteJob removes a job", async () => { const newJob: NewSchedulerJob = { name: "Test Job", - schedule: { type: "cron", expression: "0 0 * * *" }, + schedule: { + type: "cron", + expression: "0 0 * * *", + concurrencyPolicy: "skip", + }, message: { content: "test message", projectId: "project-1", baseSessionId: null, }, enabled: false, - concurrencyPolicy: "skip", }; const result = await Effect.runPromise( diff --git a/src/server/core/scheduler/domain/Scheduler.ts b/src/server/core/scheduler/domain/Scheduler.ts index bf61186..15269eb 100644 --- a/src/server/core/scheduler/domain/Scheduler.ts +++ b/src/server/core/scheduler/domain/Scheduler.ts @@ -1,4 +1,3 @@ -import { randomUUID } from "node:crypto"; import { Context, Cron, @@ -10,6 +9,7 @@ import { Ref, Schedule, } from "effect"; +import { ulid } from "ulid"; import type { InferEffect } from "../../../lib/effect/types"; import { initializeConfig, readConfig, writeConfig } from "../config"; import type { @@ -18,7 +18,7 @@ import type { SchedulerJob, UpdateSchedulerJob, } from "../schema"; -import { calculateFixedDelay, executeJob } from "./Job"; +import { calculateReservedDelay, executeJob } from "./Job"; class SchedulerJobNotFoundError extends Data.TaggedError( "SchedulerJobNotFoundError", @@ -74,42 +74,33 @@ const LayerImpl = Effect.gen(function* () { yield* Ref.update(fibersRef, (fibers) => new Map(fibers).set(job.id, fiber), ); - } else if (job.schedule.type === "fixed") { - // For oneTime jobs, skip scheduling if already executed - if (job.schedule.oneTime && job.lastRunStatus !== null) { + } else if (job.schedule.type === "reserved") { + // For reserved jobs, skip scheduling if already executed + if (job.lastRunStatus !== null) { return; } - const delay = calculateFixedDelay(job, now); + const delay = calculateReservedDelay(job, now); const delayDuration = Duration.millis(delay); - if (job.schedule.oneTime) { - const fiber = yield* Effect.delay( - runJobWithConcurrencyControl(job), - delayDuration, - ).pipe(Effect.forkDaemon); + const fiber = yield* Effect.delay( + runJobWithConcurrencyControl(job), + delayDuration, + ).pipe(Effect.forkDaemon); - yield* Ref.update(fibersRef, (fibers) => - new Map(fibers).set(job.id, fiber), - ); - } else { - const schedule = Schedule.spaced(delayDuration); - - const fiber = yield* Effect.repeat( - runJobWithConcurrencyControl(job), - schedule, - ).pipe(Effect.forkDaemon); - - yield* Ref.update(fibersRef, (fibers) => - new Map(fibers).set(job.id, fiber), - ); - } + yield* Ref.update(fibersRef, (fibers) => + new Map(fibers).set(job.id, fiber), + ); } }); const runJobWithConcurrencyControl = (job: SchedulerJob) => Effect.gen(function* () { - if (job.concurrencyPolicy === "skip") { + // Check concurrency policy (only for cron jobs) + if ( + job.schedule.type === "cron" && + job.schedule.concurrencyPolicy === "skip" + ) { const runningJobs = yield* Ref.get(runningJobsRef); if (runningJobs.has(job.id)) { return; @@ -118,6 +109,35 @@ const LayerImpl = Effect.gen(function* () { yield* Ref.update(runningJobsRef, (jobs) => new Set(jobs).add(job.id)); + // For reserved jobs, delete after execution without updating status + if (job.schedule.type === "reserved") { + const result = yield* executeJob(job).pipe( + Effect.matchEffect({ + onSuccess: () => Effect.void, + onFailure: () => Effect.void, + }), + ); + yield* Ref.update(runningJobsRef, (jobs) => { + const newJobs = new Set(jobs); + newJobs.delete(job.id); + return newJobs; + }); + + // Delete reserved job after execution (skip fiber stop, just delete from config) + yield* deleteJobFromConfig(job.id).pipe( + Effect.catchAll((error) => { + console.error( + `[Scheduler] Failed to delete reserved job ${job.id}:`, + error, + ); + return Effect.void; + }), + ); + + return result; + } + + // For non-reserved jobs, update status const result = yield* executeJob(job).pipe( Effect.matchEffect({ onSuccess: () => @@ -223,7 +243,7 @@ const LayerImpl = Effect.gen(function* () { ); const job: SchedulerJob = { ...newJob, - id: randomUUID(), + id: ulid(), createdAt: new Date().toISOString(), lastRunAt: null, lastRunStatus: null, @@ -278,6 +298,29 @@ const LayerImpl = Effect.gen(function* () { return updatedJob; }); + const deleteJobFromConfig = (jobId: string) => + Effect.gen(function* () { + const config = yield* readConfig.pipe( + Effect.catchTags({ + ConfigFileNotFoundError: () => + initializeConfig.pipe(Effect.map(() => ({ jobs: [] }))), + ConfigParseError: () => + initializeConfig.pipe(Effect.map(() => ({ jobs: [] }))), + }), + ); + const job = config.jobs.find((j) => j.id === jobId); + + if (job === undefined) { + return yield* Effect.fail(new SchedulerJobNotFoundError({ jobId })); + } + + const updatedConfig: SchedulerConfig = { + jobs: config.jobs.filter((j) => j.id !== jobId), + }; + + yield* writeConfig(updatedConfig); + }); + const deleteJob = (jobId: string) => Effect.gen(function* () { const config = yield* readConfig.pipe( @@ -295,12 +338,7 @@ const LayerImpl = Effect.gen(function* () { } yield* stopJob(jobId); - - const updatedConfig: SchedulerConfig = { - jobs: config.jobs.filter((j) => j.id !== jobId), - }; - - yield* writeConfig(updatedConfig); + yield* deleteJobFromConfig(jobId); }); return { diff --git a/src/server/core/scheduler/schema.ts b/src/server/core/scheduler/schema.ts index 7b9b5a5..11eb1d5 100644 --- a/src/server/core/scheduler/schema.ts +++ b/src/server/core/scheduler/schema.ts @@ -1,20 +1,23 @@ import { z } from "zod"; +// Concurrency policy (for cron jobs only) +export const concurrencyPolicySchema = z.enum(["skip", "run"]); + // Schedule type discriminated union export const cronScheduleSchema = z.object({ type: z.literal("cron"), expression: z.string(), + concurrencyPolicy: concurrencyPolicySchema, }); -export const fixedScheduleSchema = z.object({ - type: z.literal("fixed"), - delayMs: z.number().int().positive(), - oneTime: z.boolean(), +export const reservedScheduleSchema = z.object({ + type: z.literal("reserved"), + reservedExecutionTime: z.iso.datetime(), }); export const scheduleSchema = z.discriminatedUnion("type", [ cronScheduleSchema, - fixedScheduleSchema, + reservedScheduleSchema, ]); // Message configuration @@ -27,9 +30,6 @@ export const messageConfigSchema = z.object({ // Job status export const jobStatusSchema = z.enum(["success", "failed"]); -// Concurrency policy -export const concurrencyPolicySchema = z.enum(["skip", "run"]); - // Scheduler job export const schedulerJobSchema = z.object({ id: z.string(), @@ -37,7 +37,6 @@ export const schedulerJobSchema = z.object({ schedule: scheduleSchema, message: messageConfigSchema, enabled: z.boolean(), - concurrencyPolicy: concurrencyPolicySchema, createdAt: z.string().datetime(), lastRunAt: z.string().datetime().nullable(), lastRunStatus: jobStatusSchema.nullable(), @@ -50,7 +49,7 @@ export const schedulerConfigSchema = z.object({ // Type exports export type CronSchedule = z.infer; -export type FixedSchedule = z.infer; +export type ReservedSchedule = z.infer; export type Schedule = z.infer; export type MessageConfig = z.infer; export type JobStatus = z.infer; @@ -68,7 +67,6 @@ export const newSchedulerJobSchema = schedulerJobSchema }) .extend({ enabled: z.boolean().default(true), - concurrencyPolicy: concurrencyPolicySchema.default("skip"), }); export type NewSchedulerJob = z.infer; @@ -79,7 +77,6 @@ export const updateSchedulerJobSchema = schedulerJobSchema.partial().pick({ schedule: true, message: true, enabled: true, - concurrencyPolicy: true, }); export type UpdateSchedulerJob = z.infer; diff --git a/src/server/hono/route.ts b/src/server/hono/route.ts index 8d07fdf..b7b3229 100644 --- a/src/server/hono/route.ts +++ b/src/server/hono/route.ts @@ -19,7 +19,12 @@ import { EnvService } from "../core/platform/services/EnvService"; import { UserConfigService } from "../core/platform/services/UserConfigService"; import type { ProjectRepository } from "../core/project/infrastructure/ProjectRepository"; import { ProjectController } from "../core/project/presentation/ProjectController"; +import type { SchedulerConfigBaseDir } from "../core/scheduler/config"; import { SchedulerController } from "../core/scheduler/presentation/SchedulerController"; +import { + newSchedulerJobSchema, + updateSchedulerJobSchema, +} from "../core/scheduler/schema"; import type { VirtualConversationDatabase } from "../core/session/infrastructure/VirtualConversationDatabase"; import { SessionController } from "../core/session/presentation/SessionController"; import type { SessionMetaService } from "../core/session/services/SessionMetaService"; @@ -60,6 +65,7 @@ export const routes = (app: HonoAppType) => | UserConfigService | ClaudeCodeLifeCycleService | ProjectRepository + | SchedulerConfigBaseDir >(); if ((yield* envService.getEnv("NEXT_PHASE")) !== "phase-production-build") { @@ -460,30 +466,7 @@ export const routes = (app: HonoAppType) => .post( "/scheduler/jobs", - zValidator( - "json", - z.object({ - name: z.string(), - schedule: z.discriminatedUnion("type", [ - z.object({ - type: z.literal("cron"), - expression: z.string(), - }), - z.object({ - type: z.literal("fixed"), - delayMs: z.number().int().positive(), - oneTime: z.boolean(), - }), - ]), - message: z.object({ - content: z.string(), - projectId: z.string(), - baseSessionId: z.string().nullable(), - }), - enabled: z.boolean().default(true), - concurrencyPolicy: z.enum(["skip", "run"]).default("skip"), - }), - ), + zValidator("json", newSchedulerJobSchema), async (c) => { const response = await effectToResponse( c, @@ -499,34 +482,7 @@ export const routes = (app: HonoAppType) => .patch( "/scheduler/jobs/:id", - zValidator( - "json", - z.object({ - name: z.string().optional(), - schedule: z - .discriminatedUnion("type", [ - z.object({ - type: z.literal("cron"), - expression: z.string(), - }), - z.object({ - type: z.literal("fixed"), - delayMs: z.number().int().positive(), - oneTime: z.boolean(), - }), - ]) - .optional(), - message: z - .object({ - content: z.string(), - projectId: z.string(), - baseSessionId: z.string().nullable(), - }) - .optional(), - enabled: z.boolean().optional(), - concurrencyPolicy: z.enum(["skip", "run"]).optional(), - }), - ), + zValidator("json", updateSchedulerJobSchema), async (c) => { const response = await effectToResponse( c,