import { rm } from "node:fs/promises"; import { isAbsolute, join } from "node:path"; import { DEFAULT_CONFIG } from "../config/config.js"; import { createProjectId, isProjectId, isRunId, isScheduleId } from "../shared/ids.js"; import { SCHEDULE_STATES, type SchedulePauseReason, type ScheduleRecord, type ScheduleState, type ScheduleTiming, } from "../shared/types.js"; import { hasOnlyKeys, isPositiveSafeInteger, isRecord } from "../shared/validation.js"; import { hasValidStoredCompletionDefinition, isCanonicalIsoDate } from "./record-validation.js"; import { listRecordIds, readStoredJsonRecord, writeStoredJsonRecord } from "./json-record-files.js"; import { assertWriterLease, type WriterLease } from "./lease.js"; const MAX_SCHEDULE_RECORD_BYTES = 1024 * 1024; const OVERSIZED_SCHEDULE_RECORD = `Schedule record exceeds ${MAX_SCHEDULE_RECORD_BYTES} bytes`; const PAUSE_REASONS: readonly SchedulePauseReason[] = ["completed", "missed", "interrupted", "user"]; function parseTiming(value: unknown): ScheduleTiming | undefined { if (!isRecord(value) || typeof value.kind !== "string") return undefined; if (value.kind === "once" && hasOnlyKeys(value, ["kind", "fireAt"]) && isCanonicalIsoDate(value.fireAt)) { return value as unknown as ScheduleTiming; } if (value.kind === "recurring" && hasOnlyKeys(value, ["kind", "intervalMs", "anchorAt"]) && isPositiveSafeInteger(value.intervalMs) && value.intervalMs >= DEFAULT_CONFIG.scheduling.minimumRecurringMs && isCanonicalIsoDate(value.anchorAt)) { return value as unknown as ScheduleTiming; } return undefined; } function hasCoherentState(record: Record, timing: ScheduleTiming): boolean { const state = record.state as ScheduleState; const hasActive = typeof record.activeRunId === "string" && isRunId(record.activeRunId); const hasPending = isCanonicalIsoDate(record.pendingSince); const hasNext = isCanonicalIsoDate(record.nextFireAt); const hasPause = typeof record.pauseReason === "string" && PAUSE_REASONS.includes(record.pauseReason as SchedulePauseReason); const timingMatches = timing.kind === "once" ? (!hasNext || record.nextFireAt === timing.fireAt) : (!hasNext || (Date.parse(record.nextFireAt as string) > Date.parse(timing.anchorAt) && (Date.parse(record.nextFireAt as string) - Date.parse(timing.anchorAt)) % timing.intervalMs === 0)); if (!timingMatches) return false; if (state === "enabled") return !hasActive && !hasPending && !hasPause && hasNext; if (state === "running") { return hasActive && !hasPending && !hasPause && (timing.kind === "recurring" ? hasNext : !hasNext); } if (state === "pending_coalesced") { return timing.kind === "recurring" && hasActive && hasPending && !hasPause && hasNext; } return !hasActive && !hasPending && hasPause && !hasNext; } export function parseScheduleRecord(value: unknown): ScheduleRecord { if (!isRecord(value) || !hasOnlyKeys(value, [ "schemaVersion", "scheduleId", "projectId", "projectRoot", "state", "goal", "constraints", "verifierCommands", "budget", "expression", "normalizedExpression", "timing", "nextFireAt", "activeRunId", "pendingSince", "lastTriggeredAt", "lastCompletedAt", "pauseReason", "createdAt", "updatedAt", ])) throw new Error("Schedule record has an invalid shape"); const timing = parseTiming(value.timing); if ( value.schemaVersion !== 1 || typeof value.scheduleId !== "string" || !isScheduleId(value.scheduleId) || typeof value.projectId !== "string" || !isProjectId(value.projectId) || typeof value.projectRoot !== "string" || !isAbsolute(value.projectRoot) || createProjectId(value.projectRoot) !== value.projectId || typeof value.state !== "string" || !SCHEDULE_STATES.includes(value.state as ScheduleState) || !hasValidStoredCompletionDefinition(value) || typeof value.expression !== "string" || value.expression.trim().length === 0 || Buffer.byteLength(value.expression, "utf8") > 4 * 1024 || typeof value.normalizedExpression !== "string" || value.normalizedExpression.trim().length === 0 || Buffer.byteLength(value.normalizedExpression, "utf8") > 8 * 1024 || timing === undefined || (value.nextFireAt !== undefined && !isCanonicalIsoDate(value.nextFireAt)) || (value.activeRunId !== undefined && (typeof value.activeRunId !== "string" || !isRunId(value.activeRunId))) || (value.pendingSince !== undefined && !isCanonicalIsoDate(value.pendingSince)) || (value.lastTriggeredAt !== undefined && !isCanonicalIsoDate(value.lastTriggeredAt)) || (value.lastCompletedAt !== undefined && !isCanonicalIsoDate(value.lastCompletedAt)) || (value.pauseReason !== undefined && (typeof value.pauseReason !== "string" || !PAUSE_REASONS.includes(value.pauseReason as SchedulePauseReason))) || !isCanonicalIsoDate(value.createdAt) || !isCanonicalIsoDate(value.updatedAt) || !hasCoherentState(value, timing) ) { throw new Error("Schedule record has an invalid shape"); } return value as unknown as ScheduleRecord; } function scheduleFileName(scheduleId: string): string { if (!isScheduleId(scheduleId)) throw new Error(`Invalid schedule ID: ${scheduleId}`); return `${scheduleId}.json`; } export function scheduleLeasePath(dataRoot: string, projectId: string): string { if (!isProjectId(projectId)) throw new Error(`Invalid project ID: ${projectId}`); return join(dataRoot, "projects", projectId, "schedule-store.lease.json"); } export function scheduleExecutionLeasePath(dataRoot: string, projectId: string): string { if (!isProjectId(projectId)) throw new Error(`Invalid project ID: ${projectId}`); return join(dataRoot, "projects", projectId, "schedule-execution.lease.json"); } export function scheduleClaimLeasePath(dataRoot: string, projectId: string, scheduleId: string): string { if (!isProjectId(projectId)) throw new Error(`Invalid project ID: ${projectId}`); if (!isScheduleId(scheduleId)) throw new Error(`Invalid schedule ID: ${scheduleId}`); return join(dataRoot, "projects", projectId, "schedule-claims", `${scheduleId}.lease.json`); } export class ScheduleStore { readonly #projectId: string; readonly #directory: string; readonly #expectedLeasePath: string; readonly #lease: WriterLease | undefined; constructor(dataRoot: string, projectId: string, lease?: WriterLease) { if (!isProjectId(projectId)) throw new Error(`Invalid project ID: ${projectId}`); this.#projectId = projectId; this.#directory = join(dataRoot, "projects", projectId, "schedules"); this.#expectedLeasePath = scheduleLeasePath(dataRoot, projectId); if (lease && lease.path !== this.#expectedLeasePath) throw new Error("Schedule lease does not belong to this project store"); this.#lease = lease; } async save(schedule: ScheduleRecord): Promise { await this.#assertMutationLease(); if (schedule.projectId !== this.#projectId) throw new Error("Schedule project ID does not match this store"); parseScheduleRecord(schedule); await writeStoredJsonRecord(this.#path(schedule.scheduleId), schedule, MAX_SCHEDULE_RECORD_BYTES, OVERSIZED_SCHEDULE_RECORD); } async load(scheduleId: string): Promise { const path = this.#path(scheduleId); const loaded = await readStoredJsonRecord( path, "schedule", MAX_SCHEDULE_RECORD_BYTES, OVERSIZED_SCHEDULE_RECORD, parseScheduleRecord, ); if (loaded === undefined) return undefined; if (loaded.record.projectId !== this.#projectId) throw new Error("Stored schedule belongs to a different project"); if (loaded.migrated) { await this.#assertMutationLease(); await writeStoredJsonRecord(path, loaded.record, MAX_SCHEDULE_RECORD_BYTES, OVERSIZED_SCHEDULE_RECORD); } return loaded.record; } async list(): Promise { const schedules: ScheduleRecord[] = []; for (const scheduleId of await listRecordIds(this.#directory, /^(schedule_[0-9a-f]{8})\.json$/)) { const schedule = await this.load(scheduleId); if (schedule) schedules.push(schedule); } return schedules; } async delete(scheduleId: string): Promise { await this.#assertMutationLease(); await rm(this.#path(scheduleId), { force: true }); } async #assertMutationLease(): Promise { if (!this.#lease) throw new Error("Schedule-store mutation requires the project schedule lease"); await assertWriterLease(this.#lease); } #path(scheduleId: string): string { return join(this.#directory, scheduleFileName(scheduleId)); } }