@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
180 lines (179 loc) • 7.26 kB
JavaScript
/**
* Background sync and maintenance scheduling.
* Extracted from hooks-session.ts for modularity.
*/
import * as fs from "fs";
import * as path from "path";
import { fileURLToPath } from "url";
import { debugLog, errorMessage, runtimeFile, isFeatureEnabled, } from "./hooks-context.js";
import { qualityMarkers } from "../shared.js";
import { spawnDetachedChild } from "../shared/process.js";
import { resolveManagementCapabilities } from "../init/management-preset.js";
import { logger } from "../logger.js";
const SYNC_LOCK_STALE_MS = 10 * 60 * 1000; // 10 minutes
const MAINTENANCE_LOCK_STALE_MS = 2 * 60 * 60 * 1000; // 2 hours
export function resolveSubprocessArgs(command) {
// Prefer the entry script that started this process
const entry = process.argv[1];
if (entry && fs.existsSync(entry) && /index\.(ts|js)$/.test(entry))
return [entry, command];
// Fallback: look for index.js next to this file or one level up (for subdirectory builds)
const thisDir = path.dirname(fileURLToPath(import.meta.url));
for (const dir of [thisDir, path.dirname(thisDir)]) {
const candidate = path.join(dir, "index.js");
if (fs.existsSync(candidate))
return [candidate, command];
}
return null;
}
export function scheduleBackgroundSync(phrenPathLocal) {
const lockPath = runtimeFile(phrenPathLocal, "background-sync.lock");
const logPath = runtimeFile(phrenPathLocal, "background-sync.log");
const spawnArgs = resolveSubprocessArgs("background-sync");
if (!spawnArgs)
return false;
try {
if (fs.existsSync(lockPath)) {
const ageMs = Date.now() - fs.statSync(lockPath).mtimeMs;
if (ageMs <= SYNC_LOCK_STALE_MS)
return false;
fs.unlinkSync(lockPath);
}
}
catch (err) {
debugLog(`scheduleBackgroundSync: lock check failed: ${errorMessage(err)}`);
return false;
}
try {
fs.writeFileSync(lockPath, JSON.stringify({ startedAt: new Date().toISOString(), pid: process.pid }) + "\n", { flag: "wx" });
const logFd = fs.openSync(logPath, "a");
fs.writeSync(logFd, `[${new Date().toISOString()}] spawn ${process.execPath} ${spawnArgs.join(" ")}\n`);
const child = spawnDetachedChild(spawnArgs, { phrenPath: phrenPathLocal, logFd });
child.unref();
fs.closeSync(logFd);
return true;
}
catch (err) {
try {
fs.unlinkSync(lockPath);
}
catch { }
debugLog(`scheduleBackgroundSync: spawn failed: ${errorMessage(err)}`);
return false;
}
}
export function scheduleBackgroundMaintenance(phrenPathLocal, project) {
if (!isFeatureEnabled("PHREN_FEATURE_DAILY_MAINTENANCE", true))
return false;
// Lifecycle automations (daily maintenance) are off under the manual preset.
if (!resolveManagementCapabilities(phrenPathLocal).lifecycleAutomations)
return false;
const markers = qualityMarkers(phrenPathLocal);
if (fs.existsSync(markers.done))
return false;
if (fs.existsSync(markers.lock)) {
try {
const ageMs = Date.now() - fs.statSync(markers.lock).mtimeMs;
if (ageMs <= MAINTENANCE_LOCK_STALE_MS)
return false;
fs.unlinkSync(markers.lock);
}
catch (err) {
debugLog(`maybeRunBackgroundMaintenance: lock check failed: ${errorMessage(err)}`);
return false;
}
}
const spawnArgs = resolveSubprocessArgs("background-maintenance");
if (!spawnArgs)
return false;
try {
// Use exclusive open (O_EXCL) to atomically claim the lock; if another process
// already holds it this throws and we return false without spawning a duplicate.
const lockContent = JSON.stringify({
startedAt: new Date().toISOString(),
project: project || "all",
pid: process.pid,
}) + "\n";
let fd;
try {
fd = fs.openSync(markers.lock, "wx");
}
catch (err) {
// Another process already claimed the lock
logger.debug("hooks-session", `backgroundMaintenance lockClaim: ${errorMessage(err)}`);
return false;
}
try {
fs.writeSync(fd, lockContent);
}
finally {
fs.closeSync(fd);
}
if (project)
spawnArgs.push(project);
// Keep this log under .runtime/ (gitignored), matching background-sync.log.
// Writing it under .config/ makes it a tracked file that diverges per machine
// and breaks the Stop-hook fetch and merge.
const logPath = runtimeFile(phrenPathLocal, "background-maintenance.log");
const logFd = fs.openSync(logPath, "a");
fs.writeSync(logFd, `[${new Date().toISOString()}] spawn ${process.execPath} ${spawnArgs.join(" ")}\n`);
const child = spawnDetachedChild(spawnArgs, { phrenPath: phrenPathLocal, logFd });
child.on("exit", (code, signal) => {
const msg = `[${new Date().toISOString()}] exit code=${code ?? "null"} signal=${signal ?? "none"}\n`;
try {
fs.appendFileSync(logPath, msg);
}
catch (err) {
logger.debug("hooks-session", `backgroundMaintenance exitLog: ${errorMessage(err)}`);
}
if (code === 0) {
try {
fs.writeFileSync(markers.done, new Date().toISOString() + "\n");
}
catch (err) {
logger.debug("hooks-session", `backgroundMaintenance doneMarker: ${errorMessage(err)}`);
}
}
try {
fs.unlinkSync(markers.lock);
}
catch (err) {
logger.debug("hooks-session", `backgroundMaintenance unlockOnExit: ${errorMessage(err)}`);
}
});
child.on("error", (spawnErr) => {
const msg = `[${new Date().toISOString()}] spawn error: ${spawnErr.message}\n`;
try {
fs.appendFileSync(logPath, msg);
}
catch (err) {
logger.debug("hooks-session", `backgroundMaintenance errorLog: ${errorMessage(err)}`);
}
try {
fs.unlinkSync(markers.lock);
}
catch (err) {
logger.debug("hooks-session", `backgroundMaintenance unlockOnError: ${errorMessage(err)}`);
}
});
fs.closeSync(logFd);
child.unref();
return true;
}
catch (err) {
const errMsg = errorMessage(err);
try {
fs.appendFileSync(runtimeFile(phrenPathLocal, "background-maintenance.log"), `[${new Date().toISOString()}] spawn failed: ${errMsg}\n`);
}
catch (err) {
logger.debug("hooks-session", `backgroundMaintenance logSpawnFailure: ${errorMessage(err)}`);
}
try {
fs.unlinkSync(markers.lock);
}
catch (err) {
logger.debug("hooks-session", `backgroundMaintenance unlockOnFailure: ${errorMessage(err)}`);
}
return false;
}
}