@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
270 lines (269 loc) • 12.8 kB
JavaScript
import { randomBytes } from "node:crypto";
import { readFile } from "node:fs/promises";
import path from "node:path";
import * as yaml from "js-yaml";
import { z } from "zod";
import { atomicInPrivateDir, bridgeRoot, object } from "./protocol.js";
import { isAccountSlug } from "./claude-accounts.js";
/** The schedules.yaml store: the schedule and run record shapes, validation,
* the five timing forms evaluated in local time, and the run history file. */
export const SCHEDULE_EVERY = ["interval", "daily", "weekly", "once", "cron"];
export const SCHEDULE_HARNESSES = ["claude", "codex", "opencode", "copilot"];
export const SCHEDULE_NOTIFY = ["start", "finish", "failure"];
export const WEEKDAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
export const SCHEDULE_RUN_STATUSES = ["launched", "running", "blocked", "finished", "needs-you", "failed", "skipped"];
const scheduleId = z.string().regex(/^[a-f0-9]{8}$/);
const plain = (max) => z.string().min(1).max(max).refine(value => !/[\x00-\x08\x0b-\x1f\x7f]/.test(value));
const singleLine = (max) => z.string().min(1).max(max).refine(value => value.trim().length > 0 && !/[\x00-\x1f\x7f]/.test(value));
const scheduleName = singleLine(80);
const timestamp = z.string().datetime({ offset: true });
const localTimestamp = z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/);
const clockTime = z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/);
const schedulePath = (projectDir) => path.join(projectDir, "schedules.yaml");
export const runningStatuses = new Set(["launched", "running", "blocked"]);
const MAX_SCHEDULES = 64;
const MAX_RUNS = 2000;
function validateLocalTimestamp(value) {
localTimestamp.parse(value);
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/.exec(value);
const parts = match.slice(1).map(Number), date = new Date(Date.UTC(parts[0], parts[1] - 1, parts[2], parts[3], parts[4], parts[5]));
if (date.getUTCFullYear() !== parts[0] || date.getUTCMonth() !== parts[1] - 1 || date.getUTCDate() !== parts[2]
|| date.getUTCHours() !== parts[3] || date.getUTCMinutes() !== parts[4] || date.getUTCSeconds() !== parts[5]) {
throw new Error(`Invalid local schedule time: ${value}`);
}
return value;
}
export function intervalMilliseconds(value) {
const match = /^(\d+)(m|h|d)$/.exec(value);
if (!match || Number(match[1]) < 1)
throw new Error("Interval must look like 30m, 6h, or 2d.");
const unit = match[2] === "m" ? 60_000 : match[2] === "h" ? 3_600_000 : 86_400_000;
const milliseconds = Number(match[1]) * unit;
if (!Number.isSafeInteger(milliseconds))
throw new Error("Schedule interval is too large.");
return milliseconds;
}
export function parseSchedule(value) {
const raw = object(value);
const every = z.enum(SCHEDULE_EVERY).parse(raw.every);
const computer = singleLine(200).parse(raw.computer);
if (canonicalComputer(computer) === "any")
throw new Error('Schedule computer "any" is not supported.');
const schedule = {
...raw,
id: scheduleId.parse(raw.id),
name: scheduleName.parse(raw.name),
enabled: z.boolean().parse(raw.enabled),
computer,
harness: z.enum(SCHEDULE_HARNESSES).parse(raw.harness),
every,
prompt: plain(8000).parse(raw.prompt),
createdAt: timestamp.parse(raw.createdAt),
updatedAt: timestamp.parse(raw.updatedAt),
};
if (raw.model !== undefined)
schedule.model = singleLine(200).parse(raw.model);
if (raw.account !== undefined)
schedule.account = z.string().refine(isAccountSlug, "Account must be default or a lowercase slug.").parse(raw.account);
if (raw.notify !== undefined)
schedule.notify = z.array(z.enum(SCHEDULE_NOTIFY)).max(3).parse(raw.notify)
.filter((kind, index, kinds) => kinds.indexOf(kind) === index);
if (every === "daily" || every === "weekly")
schedule.at = clockTime.parse(raw.at);
if (every === "weekly")
schedule.days = z.array(z.enum(WEEKDAYS)).min(1).max(7).parse(raw.days).filter((day, index, days) => days.indexOf(day) === index);
if (every === "interval") {
schedule.interval = singleLine(32).parse(raw.interval);
intervalMilliseconds(schedule.interval);
}
if (every === "once")
schedule.once = validateLocalTimestamp(singleLine(32).parse(raw.once));
if (every === "cron") {
schedule.cron = singleLine(200).parse(raw.cron);
parseCron(schedule.cron);
}
return schedule;
}
export async function readScheduleDocument(projectDir) {
let text;
try {
text = await readFile(schedulePath(projectDir), "utf8");
}
catch (error) {
if (error.code === "ENOENT")
return { document: { version: 1 }, schedules: [] };
throw error;
}
const document = object(yaml.load(text, { schema: yaml.CORE_SCHEMA }));
if (document.version !== 1)
throw new Error("schedules.yaml must have version: 1.");
if (!Array.isArray(document.schedules))
throw new Error("schedules.yaml must contain a schedules list.");
if (document.schedules.length > MAX_SCHEDULES)
throw new Error(`A project can have at most ${MAX_SCHEDULES} schedules.`);
const schedules = document.schedules.map(parseSchedule);
if (new Set(schedules.map(item => item.id)).size !== schedules.length)
throw new Error("Schedule IDs must be unique within a project.");
return { document, schedules };
}
export async function writeScheduleDocument(projectDir, schedules, original = { version: 1 }) {
if (schedules.length > MAX_SCHEDULES)
throw new Error(`A project can have at most ${MAX_SCHEDULES} schedules.`);
schedules.forEach(parseSchedule);
const text = yaml.dump({ ...original, version: 1, schedules }, { lineWidth: 1000, noRefs: true, sortKeys: false });
await atomicInPrivateDir(schedulePath(projectDir), text);
}
export function canonicalComputer(value) {
const normalized = value.trim().toLowerCase();
return normalized.endsWith(".local") ? normalized.slice(0, -".local".length) : normalized;
}
export function computerMatches(wanted, current) {
return canonicalComputer(wanted) === canonicalComputer(current);
}
export function scheduleNotifications(schedule) {
return new Set(schedule.notify ?? ["finish", "failure"]);
}
export function scheduleSessionRoute(schedule, launch) {
if (launch.mode !== "herdr" || !launch.server || !launch.workspaceId || !launch.tabId || !launch.paneId)
return undefined;
const route = Buffer.from(JSON.stringify({ server: launch.server, workspace: launch.workspaceId, tab: launch.tabId,
pane: launch.paneId, source: schedule.harness })).toString("base64url");
return `phren://session?route=${encodeURIComponent(route)}`;
}
function parseCronField(text, minimum, maximum, sunday = false) {
const values = new Set(), wildcard = text === "*" || text.startsWith("*/");
for (const part of text.split(",")) {
const [rangeText, stepText] = part.split("/");
if (part.split("/").length > 2 || stepText !== undefined && !/^\d+$/.test(stepText))
throw new Error(`Invalid cron field: ${text}`);
const step = stepText === undefined ? 1 : Number(stepText);
if (step < 1)
throw new Error(`Invalid cron step: ${text}`);
let start, end;
if (rangeText === "*") {
start = minimum;
end = maximum;
}
else if (/^\d+$/.test(rangeText))
start = end = Number(rangeText);
else {
const range = /^(\d+)-(\d+)$/.exec(rangeText);
if (!range)
throw new Error(`Invalid cron field: ${text}`);
start = Number(range[1]);
end = Number(range[2]);
}
if (start < minimum || end > maximum || start > end)
throw new Error(`Cron field is out of range: ${text}`);
for (let value = start; value <= end; value += step)
values.add(sunday && value === 7 ? 0 : value);
}
return { values: [...values].sort((left, right) => left - right), wildcard };
}
function parseCron(value) {
const fields = value.trim().split(/\s+/);
if (fields.length !== 5)
throw new Error("Cron schedules need five fields.");
return {
minute: parseCronField(fields[0], 0, 59), hour: parseCronField(fields[1], 0, 23),
day: parseCronField(fields[2], 1, 31), month: parseCronField(fields[3], 1, 12),
weekday: parseCronField(fields[4], 0, 7, true),
};
}
function cronDayMatches(cron, date) {
const day = cron.day.values.includes(date.getDate()), weekday = cron.weekday.values.includes(date.getDay());
if (cron.day.wildcard && cron.weekday.wildcard)
return true;
if (cron.day.wildcard)
return weekday;
if (cron.weekday.wildcard)
return day;
return day || weekday;
}
function nextCron(schedule, after) {
const cron = parseCron(schedule.cron);
const day = new Date(after.getFullYear(), after.getMonth(), after.getDate());
for (let offset = 0; offset < 366 * 8; offset++) {
const date = new Date(day.getFullYear(), day.getMonth(), day.getDate() + offset);
if (!cron.month.values.includes(date.getMonth() + 1) || !cronDayMatches(cron, date))
continue;
for (const hour of cron.hour.values)
for (const minute of cron.minute.values) {
const candidate = new Date(date.getFullYear(), date.getMonth(), date.getDate(), hour, minute);
if (candidate.getHours() === hour && candidate.getMinutes() === minute && candidate > after)
return candidate;
}
}
return null;
}
function nextDailyOrWeekly(schedule, after) {
const [hour, minute] = schedule.at.split(":").map(Number);
const allowed = schedule.every === "weekly" ? new Set(schedule.days.map(day => WEEKDAYS.indexOf(day))) : undefined;
for (let offset = 0; offset < 370; offset++) {
const candidate = new Date(after.getFullYear(), after.getMonth(), after.getDate() + offset, hour, minute);
if (candidate.getHours() !== hour || candidate.getMinutes() !== minute || candidate <= after)
continue;
if (!allowed || allowed.has(candidate.getDay()))
return candidate;
}
return null;
}
function localDate(value) {
validateLocalTimestamp(value);
const parts = value.match(/\d+/g).map(Number);
const date = new Date(parts[0], parts[1] - 1, parts[2], parts[3], parts[4], parts[5]);
return date.getFullYear() === parts[0] && date.getMonth() === parts[1] - 1 && date.getDate() === parts[2]
&& date.getHours() === parts[3] && date.getMinutes() === parts[4] && date.getSeconds() === parts[5] ? date : null;
}
export function nextRun(schedule, lastRun) {
if (schedule.every === "once")
return lastRun ? null : localDate(schedule.once);
const after = new Date(lastRun?.startedAt ?? schedule.createdAt);
if (!Number.isFinite(after.getTime()))
return null;
if (schedule.every === "interval")
return new Date(after.getTime() + intervalMilliseconds(schedule.interval));
if (schedule.every === "cron")
return nextCron(schedule, after);
return nextDailyOrWeekly(schedule, after);
}
export async function readScheduleRuns(file) {
let text;
try {
text = await readFile(file, "utf8");
}
catch (error) {
if (error.code === "ENOENT")
return [];
throw error;
}
const runs = [];
for (const line of text.split("\n")) {
if (!line.trim())
continue;
try {
const raw = object(JSON.parse(line));
const launch = object(raw.launch);
if (typeof raw.id !== "string" || typeof raw.scheduleId !== "string" || typeof raw.project !== "string"
|| typeof raw.startedAt !== "string" || !SCHEDULE_RUN_STATUSES.includes(String(raw.status))
|| !["herdr", "headless"].includes(String(launch.mode)))
continue;
runs.push(raw);
}
catch { /* A torn final line does not hide older history. */ }
}
return runs;
}
export async function writeScheduleRuns(file, runs) {
const kept = runs.slice(-MAX_RUNS);
await atomicInPrivateDir(file, kept.map(run => JSON.stringify(run)).join("\n") + (kept.length ? "\n" : ""));
}
export function newScheduleId(existing) {
const ids = new Set(existing);
for (;;) {
const id = randomBytes(4).toString("hex");
if (!ids.has(id))
return id;
}
}
export function scheduleRunsFile() { return path.join(bridgeRoot(), "schedule-runs.jsonl"); }