import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs"; import { join } from "node:path"; import { isCurrentSessionOwner } from "./session.ts"; import type { RawScheduleRecord, ScheduleSnapshot, ScheduleState } from "./types.ts"; /** * 返回项目 Schedule 存储目录. * * @param cwd 项目工作目录 * @param configDirName Pi 配置目录名 * @returns Schedule 根目录 */ export function scheduleRootPath(cwd: string, configDirName: string): string { return join(cwd, configDirName, "subagents", "schedules"); } /** * 列出真实的 Schedule 子目录. * * @param cwd 项目工作目录 * @param configDirName Pi 配置目录名 * @returns 可读取的 Schedule 目录路径 */ export function listScheduleDirectories(cwd: string, configDirName: string): string[] { const root = scheduleRootPath(cwd, configDirName); try { if (!existsSync(root) || lstatSync(root).isSymbolicLink()) return []; return readdirSync(root, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && !entry.isSymbolicLink()) .map((entry) => join(root, entry.name)); } catch { return []; } } function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function parseRecord(value: unknown): RawScheduleRecord | undefined { if (!isObject(value) || typeof value.id !== "string" || !value.id.trim()) return undefined; if (value.sessionOnly !== undefined && typeof value.sessionOnly !== "boolean") return undefined; const trigger = isObject(value.trigger) ? { kind: typeof value.trigger.kind === "string" ? value.trigger.kind : undefined, every: typeof value.trigger.every === "string" ? value.trigger.every : undefined, nextRunAt: typeof value.trigger.nextRunAt === "string" ? value.trigger.nextRunAt : undefined, at: typeof value.trigger.at === "string" ? value.trigger.at : undefined, } : undefined; return { id: value.id.trim(), name: typeof value.name === "string" ? value.name : undefined, paused: value.paused === true, activeRunId: typeof value.activeRunId === "string" && value.activeRunId.trim() ? value.activeRunId : undefined, sessionOnly: value.sessionOnly === true ? true : undefined, ownerSessionFile: typeof value.ownerSessionFile === "string" ? value.ownerSessionFile : undefined, trigger, }; } function readJson(file: string): unknown { try { return JSON.parse(readFileSync(file, "utf8")) as unknown; } catch { return undefined; } } function readStartedAt(directory: string, activeRunId: string | undefined): number | undefined { if (!activeRunId) return undefined; const history = readJson(join(directory, "history.json")); if (!isObject(history) || !Array.isArray(history.runs)) return undefined; const run = history.runs.find((item) => isObject(item) && item.id === activeRunId); if (!isObject(run) || typeof run.startedAt !== "string") return undefined; const parsed = Date.parse(run.startedAt); return Number.isFinite(parsed) ? parsed : undefined; } function stateFor(record: RawScheduleRecord, nextRunAt: number | undefined, now: number): ScheduleState | undefined { if (record.paused) return nextRunAt === undefined ? undefined : "paused"; if (record.activeRunId) return "running"; if (nextRunAt === undefined) return undefined; return nextRunAt <= now ? "overdue" : "scheduled"; } /** * 读取并筛选当前会话可见的 Schedule. * * session-only 任务只返回 owner 匹配当前会话的记录, 项目共享任务在所有会话返回. * * @param cwd 项目工作目录 * @param currentSessionFile 当前会话文件 * @param now 当前时间戳 * @param configDirName Pi 配置目录名 * @returns 当前会话可见的 Schedule 快照 */ export function readScheduleSnapshots( cwd: string, currentSessionFile: string | undefined, now: number, configDirName: string, ): ScheduleSnapshot[] { const snapshots: ScheduleSnapshot[] = []; for (const directory of listScheduleDirectories(cwd, configDirName)) { const value = parseRecord(readJson(join(directory, "schedule.json"))); if (!value) continue; if (value.sessionOnly !== undefined && typeof value.sessionOnly !== "boolean") continue; if (value.sessionOnly === true && !isCurrentSessionOwner(value.ownerSessionFile, currentSessionFile)) continue; const nextRunAt = typeof value.trigger?.nextRunAt === "string" ? Date.parse(value.trigger.nextRunAt) : undefined; const validNextRunAt = nextRunAt !== undefined && Number.isFinite(nextRunAt) ? nextRunAt : undefined; const state = stateFor(value, validNextRunAt, now); if (!state) continue; const triggerKind = value.trigger?.kind === "interval" ? "interval" : value.trigger?.kind === "once" ? "once" : undefined; if (!triggerKind) continue; snapshots.push({ id: value.id, name: value.name?.trim() || value.id, scope: value.sessionOnly === true ? "current" : "shared", state, triggerKind, ...(value.activeRunId ? { activeRunId: value.activeRunId } : {}), ...(value.trigger?.every ? { every: value.trigger.every } : {}), ...(validNextRunAt === undefined ? {} : { nextRunAt: validNextRunAt }), ...(value.activeRunId ? { startedAt: readStartedAt(directory, value.activeRunId) } : {}), }); } return snapshots.sort((left, right) => { const leftTime = left.nextRunAt ?? Number.POSITIVE_INFINITY; const rightTime = right.nextRunAt ?? Number.POSITIVE_INFINITY; return leftTime - rightTime || left.name.localeCompare(right.name); }); }