UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

246 lines (245 loc) • 13.1 kB
import { randomUUID } from "node:crypto"; import { hostname } from "node:os"; import path from "node:path"; import { BridgeError } from "./protocol.js"; import { getProjectSourcePath } from "../project-config.js"; import { getProjectDirs } from "../shared.js"; import { computerMatches, nextRun, readScheduleDocument, readScheduleRuns, runningStatuses, scheduleNotifications, scheduleSessionRoute, writeScheduleDocument, writeScheduleRuns, } from "./schedule-format.js"; export * from "./schedule-format.js"; export * from "./schedule-launch.js"; export * from "./schedule-watch.js"; export class Scheduler { now; store; launch; runsFile; computer; locateProject; push; log; serial = Promise.resolve(); ticking = false; /** Runs this process launched; any other open run was left by an earlier Hook. */ own = new Set(); recovered = false; /** When the scheduler last looked for due work; a health read shows it. */ lastTickAt; constructor(options) { this.now = options.now; this.store = options.store; this.launch = options.launch; this.runsFile = options.runsFile; if (typeof options.computer === "function") this.computer = options.computer; else { const computer = options.computer; this.computer = () => computer ?? hostname(); } this.locateProject = options.locateProject ?? (async () => undefined); this.push = options.push; this.log = options.log ?? (message => console.error(message)); } projectDirectories() { return getProjectDirs(this.store); } projectDirectory(project) { const found = this.projectDirectories().find(directory => path.basename(directory).toLowerCase() === project.toLowerCase()); if (!found) throw new BridgeError(404, `Unknown project "${project}".`); return found; } async exclusive(operation) { const previous = this.serial; let release; this.serial = new Promise(resolve => { release = resolve; }); await previous; try { return await operation(); } finally { release(); } } async statuses() { const runs = await readScheduleRuns(this.runsFile), result = [], computer = this.computer(); for (const directory of this.projectDirectories()) { let schedules; try { schedules = (await readScheduleDocument(directory)).schedules; } catch { continue; } const project = path.basename(directory); for (const schedule of schedules) { const matching = runs.filter(run => run.project === project && run.scheduleId === schedule.id); const last = matching.at(-1), running = matching.some(run => runningStatuses.has(run.status)); const due = computerMatches(schedule.computer, computer) ? nextRun(schedule, last) : null; result.push({ ...schedule, project, nextRun: due?.toISOString() ?? null, lastRun: last ? { startedAt: last.startedAt, ...(last.finishedAt ? { finishedAt: last.finishedAt } : {}), status: last.status, ...(last.reason ? { reason: last.reason } : {}), ...(last.blockedStartupPrompt ? { blockedStartupPrompt: last.blockedStartupPrompt } : {}), ...(last.blockNotified !== undefined ? { blockNotified: last.blockNotified } : {}), launch: last.launch } : null, running }); } } return { computer, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, schedules: result }; } async history(filters = {}) { const limit = Math.min(500, Math.max(1, filters.limit ?? 50)); return (await readScheduleRuns(this.runsFile)).filter(run => (!filters.project || run.project.toLowerCase() === filters.project.toLowerCase()) && (!filters.id || run.scheduleId === filters.id)).slice(-limit).reverse(); } async launchNow(projectName, id) { const prepared = await this.exclusive(async () => { const projectDir = this.projectDirectory(projectName), project = path.basename(projectDir); const file = await readScheduleDocument(projectDir), schedule = file.schedules.find(item => item.id === id); if (!schedule) throw new BridgeError(404, `Schedule "${id}" was not found in ${project}.`); if (!computerMatches(schedule.computer, this.computer())) throw new BridgeError(409, `${schedule.computer} owns this schedule; run it from that computer.`); const runs = await readScheduleRuns(this.runsFile); if (runs.some(run => run.project === project && run.scheduleId === id && runningStatuses.has(run.status))) { throw new BridgeError(409, `Schedule "${schedule.name}" is already running.`); } const run = { id: randomUUID(), scheduleId: id, project, startedAt: this.now().toISOString(), status: "launched", launch: { mode: "headless" } }; this.own.add(run.id); await writeScheduleRuns(this.runsFile, [...runs, run]); if (schedule.every === "once" && schedule.enabled) { const updatedAt = this.now().toISOString(); const schedules = file.schedules.map(item => item.id === id ? { ...item, enabled: false, updatedAt } : item); await writeScheduleDocument(projectDir, schedules, file.document); } const source = getProjectSourcePath(this.store, project); return { projectDir, project, schedule, run, cwd: source ?? await this.locateProject(project) ?? projectDir }; }); try { const launched = await this.launch({ schedule: prepared.schedule, project: prepared.project, projectDir: prepared.projectDir, cwd: prepared.cwd, runId: prepared.run.id, blockedStartup: promptText => this.recordBlockedStartup(prepared.run.id, prepared.schedule, promptText) }); const running = await this.updateRun(prepared.run.id, { status: "running", launch: launched.launch }); const notified = this.notifyRun(running, prepared.schedule, "scheduleStarted"); if (launched.completion) void launched.completion.then(async (result) => { await this.finishRun(prepared.run.id, prepared.schedule, result.status, result.reason, notified); }).catch(async (error) => { await this.finishRun(prepared.run.id, prepared.schedule, "failed", error instanceof Error ? error.message : "The scheduled agent failed.", notified); }).catch(error => this.log(`[schedule] Could not finish run ${prepared.run.id}: ${String(error)}`)); await notified; return running; } catch (error) { const reason = error instanceof Error ? error.message : "The scheduled agent could not start."; await this.finishRun(prepared.run.id, prepared.schedule, "failed", reason); throw error instanceof BridgeError ? error : new BridgeError(503, reason); } } async updateRun(id, patch) { return this.exclusive(async () => { const runs = await readScheduleRuns(this.runsFile), index = runs.findIndex(run => run.id === id); if (index < 0) throw new Error("The schedule run record disappeared."); runs[index] = { ...runs[index], ...patch }; await writeScheduleRuns(this.runsFile, runs); return runs[index]; }); } async finishRun(id, schedule, status, reason, previousNotification) { const run = await this.updateRun(id, { status, finishedAt: this.now().toISOString(), ...(reason ? { reason } : {}) }); await previousNotification; if (run.blockedStartupPrompt && run.blockNotified) return; await this.notifyRun(run, schedule, status === "failed" ? "scheduleFailed" : "scheduleFinished"); } async recordBlockedStartup(id, schedule, promptText) { try { const prompt = promptText.slice(0, 4000); const run = await this.updateRun(id, { status: "blocked", blockedStartupPrompt: prompt }); const delivered = await this.notifyRun(run, schedule, "scheduleBlocked", `Blocked at startup: ${prompt}`); if (delivered) await this.updateRun(id, { blockNotified: true }); } catch (error) { this.log(`[schedule] blocked-at-startup record for run ${id} failed: ${error instanceof Error ? error.message : "unknown error"}`); } } async notifyRun(run, schedule, kind, reason) { const preference = kind === "scheduleStarted" ? "start" : kind === "scheduleFinished" ? "finish" : "failure"; if (!scheduleNotifications(schedule).has(preference)) return false; const route = scheduleSessionRoute(schedule, run.launch); const text = reason ?? run.reason; const value = { kind, scheduleId: schedule.id, project: run.project, name: schedule.name, computer: schedule.computer, runId: run.id, status: kind === "scheduleStarted" ? "running" : kind === "scheduleFinished" ? (run.status === "needs-you" ? "needs-you" : "finished") : kind === "scheduleBlocked" ? "blocked" : "failed", ...(text ? { reason: text } : {}), ...(route ? { route } : {}) }; let result; try { result = this.push ? await this.push.notify(value) : { notified: false, reason: "no push config" }; } catch { result = { notified: false, reason: "push delivery failed" }; } try { await this.updateRun(run.id, { notified: result.notified, ...(result.reason ? { notifyReason: result.reason } : { notifyReason: undefined }) }); } catch (error) { this.log(`[schedule] ${kind} notification result for run ${run.id} could not be recorded: ${error instanceof Error ? error.message : "unknown error"}`); } if (!result.notified) this.log(`[schedule] ${kind} notification for run ${run.id}: ${result.reason ?? "not delivered"}`); return result.notified; } /** * Settles the runs an earlier Hook process left open (a restart or crash * mid-run). Each is followed to its real end where the launcher can, and * otherwise failed with the reason, since an open run blocks its schedule * for good. Runs once, on the first tick, with a launcher that can resume. */ async recoverOpenRuns() { const resume = this.launch.resume; if (this.recovered || !resume) return; this.recovered = true; const open = (await readScheduleRuns(this.runsFile)).filter(run => runningStatuses.has(run.status) && !this.own.has(run.id)); for (const run of open) { this.own.add(run.id); let schedule; try { schedule = (await readScheduleDocument(this.projectDirectory(run.project))).schedules.find(item => item.id === run.scheduleId); } catch { schedule = undefined; } if (!schedule) { await this.updateRun(run.id, { status: "failed", finishedAt: this.now().toISOString(), reason: "Phren Hook restarted during this run, and its schedule no longer exists." }) .catch(error => this.log(`[schedule] Could not close run ${run.id}: ${String(error)}`)); continue; } const known = schedule; this.log(`[schedule] Following run ${run.id} of "${known.name}", left ${run.status} by an earlier Hook process.`); void resume(run, known) .catch((error) => ({ status: "failed", reason: error instanceof Error ? error.message : "The scheduled agent failed." })) .then(result => this.finishRun(run.id, known, result.status, result.reason)) .catch(error => this.log(`[schedule] Could not finish run ${run.id}: ${String(error)}`)); } } async tick() { if (this.ticking) return; this.ticking = true; this.lastTickAt = this.now(); try { await this.recoverOpenRuns().catch(error => this.log(`[schedule] Recovering open runs failed: ${String(error)}`)); const status = await this.statuses(), now = this.now(); for (const schedule of status.schedules) { if (!schedule.enabled || schedule.running || !schedule.nextRun || new Date(schedule.nextRun) > now) continue; await this.launchNow(schedule.project, schedule.id).catch(() => { }); } } finally { this.ticking = false; } } close() { this.launch.close?.(); } }