UNPKG

@phren/cli

Version:

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

277 lines (276 loc) • 13.7 kB
/** Periodic, non-interactive remote checks for running MCP servers. */ import { execFile } from "child_process"; import * as fs from "fs"; import * as path from "path"; import { promisify } from "util"; import { tryFileLock } from "../governance/locks.js"; import { updateRuntimeHealth } from "../governance/policy.js"; import { resolveManagementCapabilities } from "../init/management-preset.js"; import { readInstallPreferences } from "../init/preferences.js"; import { atomicWriteText, readRootManifest, runtimeFile } from "../phren-paths.js"; import { debugLog } from "../shared.js"; import { getNonPrimaryStores } from "../store-registry.js"; import { errorMessage } from "../utils.js"; import { nonInteractiveGitEnv } from "../utils-helpers.js"; import { activeStoreAuthFailure, authBackoffActive, storeAuthDetail, withStoreAuthBackoff } from "./auth.js"; import { inProgressGitOperation } from "./git-state.js"; import { aheadBehind, appendSyncLog, logSyncOutcome } from "./outcome.js"; import { mergeStoreUpstream } from "./store-merge.js"; export const DEFAULT_PULL_INTERVAL_SECONDS = 0; export const MIN_PULL_INTERVAL_SECONDS = 30; export const MAX_PULL_INTERVAL_SECONDS = 86_400; export function parsePullInterval(value) { if (value === "off") return 0; if (typeof value !== "string" && typeof value !== "number") return undefined; if (!/^\d+$/.test(String(value))) return undefined; const seconds = Number(value); return Number.isInteger(seconds) && (seconds === 0 || (seconds >= MIN_PULL_INTERVAL_SECONDS && seconds <= MAX_PULL_INTERVAL_SECONDS)) ? seconds : undefined; } export function resolvePullInterval(phrenPath, env = process.env) { return parsePullInterval(env.PHREN_PULL_INTERVAL_SECONDS) ?? parsePullInterval(readInstallPreferences(phrenPath).pullIntervalSeconds) ?? DEFAULT_PULL_INTERVAL_SECONDS; } export function periodicPullEnabled(phrenPath) { const manifest = readRootManifest(phrenPath); return manifest?.installMode !== "project-local" && (!manifest || manifest.syncMode === "managed-git") && resolveManagementCapabilities(phrenPath).lifecycleAutomations; } const execAsync = promisify(execFile); export const runPollGit = (cwd, args) => runGitWithin(10_000, cwd, args); /** One Git command with its output, or its error once it fails or `timeout` passes. */ export async function runGitWithin(timeout, cwd, args) { try { const { stdout } = await execAsync("git", args, { cwd, encoding: "utf8", timeout, maxBuffer: 64 * 1024 * 1024, env: nonInteractiveGitEnv({ ...process.env, GIT_OPTIONAL_LOCKS: "0" }), }); return { ok: true, output: stdout.trim() }; } catch (err) { return { ok: false, output: "", error: errorMessage(err) }; } } /** * A missing state file is a first check. A corrupt one is logged and moved * aside (kept for inspection) so the next check starts clean instead of * silently resetting the backoff on every poll. */ export function readPollState(phrenPath) { const file = runtimeFile(phrenPath, "pull-poll.json"); let text; try { text = fs.readFileSync(file, "utf8"); } catch (err) { if (err.code !== "ENOENT") logPeriodicSync(phrenPath, `poll state unreadable: ${errorMessage(err).split("\n")[0]}`); return {}; } let reason; try { const value = JSON.parse(text); if (value && typeof value === "object" && !Array.isArray(value)) return value; reason = "not a JSON object"; } catch (err) { reason = errorMessage(err).split("\n")[0]; } const aside = `${file}.corrupt-${Date.now()}`; try { fs.renameSync(file, aside); } catch (err) { debugLog(`poll state rename: ${errorMessage(err)}`); } logPeriodicSync(phrenPath, `poll state was corrupt (${reason}); moved it to ${path.basename(aside)} and started fresh`); return {}; } /** Includes user-owned operations: polling never continues or aborts these. */ async function worktreeBusy(cwd, git) { const dir = await git(cwd, ["rev-parse", "--absolute-git-dir"]); if (!dir.ok) return true; return inProgressGitOperation(cwd) !== undefined; } /** The periodic pull writes its decisions to the same log a background sync uses. */ function logPeriodicSync(phrenPath, detail) { appendSyncLog(phrenPath, "periodic-pull", detail); } /** Shared timestamps + a process lock give all MCP clients one check per store/interval. */ export async function pollStore(phrenPath, seconds, git = runPollGit, now = Date.now(), immediate = false) { const skipped = { status: "not-due", detail: "Periodic check not due." }; if ((!seconds && !immediate) || !periodicPullEnabled(phrenPath) || !fs.existsSync(path.join(phrenPath, ".git"))) return skipped; const releasePoll = tryFileLock(runtimeFile(phrenPath, "pull-poll")); if (!releasePoll) return immediate ? { status: "deferred", detail: "Coordinated pull is running." } : skipped; try { const auth = activeStoreAuthFailure(phrenPath); if (authBackoffActive(auth, now)) return { status: "not-due", detail: storeAuthDetail(auth) }; git = withStoreAuthBackoff(git, () => now); const previous = readPollState(phrenPath); const failures = Math.min(10, Math.max(0, Number(previous.failures) || 0)); const delay = Math.min(seconds * 2 ** failures, Math.max(seconds, 1800)) * 1000; if (!immediate && typeof previous.checkedAt === "number" && now >= previous.checkedAt && now - previous.checkedAt < delay) return skipped; const finish = async (result, verifiedRemote = false) => { atomicWriteText(runtimeFile(phrenPath, "pull-poll.json"), JSON.stringify({ checkedAt: now, failures: result.status === "error" ? failures + 1 : 0, ...result, }) + "\n"); if (verifiedRemote || result.status === "updated" || result.status === "error" || result.status === "deferred") { const at = new Date(now).toISOString(); const ok = result.status === "updated" || result.status === "unchanged"; const counts = await aheadBehind(phrenPath, git); updateRuntimeHealth(phrenPath, { lastSync: { lastPullAt: at, lastPullStatus: ok ? "ok" : "error", lastPullDetail: result.detail, ...(ok ? { lastSuccessfulPullAt: at } : {}), ...(counts ?? {}), } }); // A quiet "unchanged" poll is logged only when it follows a different outcome. if (result.status !== "unchanged" || previous.status !== "unchanged") { logSyncOutcome(phrenPath, "periodic-pull", { ok, detail: `${result.status}: ${result.detail}`, counts }); } } return result; }; const deferred = (detail) => finish({ status: "deferred", detail }); const failed = (detail) => finish({ status: "error", detail }); if (await worktreeBusy(phrenPath, git)) return deferred("Periodic pull deferred: a Git operation is in progress."); const branch = await git(phrenPath, ["symbolic-ref", "--quiet", "HEAD"]); if (!branch.ok) return deferred("Periodic pull deferred: no branch checked out."); const refs = await git(phrenPath, ["for-each-ref", "--format=%(upstream:remotename)%09%(upstream:remoteref)%09%(upstream)", branch.output]); const [remote, remoteRef, trackingRef] = refs.output.split("\t"); if (!refs.ok || !remote || !remoteRef || !trackingRef) return finish({ status: "unchanged", detail: "No tracking remote configured." }); const advertised = await git(phrenPath, ["ls-remote", "--quiet", "--exit-code", "--", remote, remoteRef]); if (!advertised.ok) return failed(`Periodic remote check failed: ${advertised.error}`); const remoteHead = advertised.output.split("\n").map((line) => line.split(/\s+/)) .find(([, ref]) => ref === remoteRef)?.[0]; if (!remoteHead || !/^[0-9a-f]{40,64}$/.test(remoteHead)) return failed("Tracking branch was not advertised by the remote."); const head = await git(phrenPath, ["rev-parse", "HEAD"]); if (!head.ok) return failed("Cannot read the store's current commit."); const status = await git(phrenPath, ["status", "--porcelain"]); if (!status.ok) return failed("Cannot read the store's working tree state."); const remoteUnchanged = remoteHead === head.output; // The common poll (clean tree, remote already at HEAD) needs no lock. if (remoteUnchanged && !status.output) return finish({ status: "unchanged", detail: "Remote is unchanged." }, true); const releaseGit = tryFileLock(runtimeFile(phrenPath, "git-op")); if (!releaseGit) return deferred("Periodic pull deferred: another Phren Git operation is running."); try { // Recheck after taking the mutation lock: hooks or another client may have changed the store. if (await worktreeBusy(phrenPath, git)) return deferred("Periodic pull deferred: a Git operation is in progress."); const currentBranch = await git(phrenPath, ["symbolic-ref", "--quiet", "HEAD"]); if (!currentBranch.ok || currentBranch.output !== branch.output) { return deferred("Periodic pull deferred: the branch changed while checking."); } const merged = await mergeStoreUpstream(phrenPath, { git, advertisedHead: remoteHead, commitMessage: "auto-save phren (periodic pull)", }); if (merged.committedLocalWrites) logPeriodicSync(phrenPath, "committed uncommitted store writes before pull"); if (merged.status === "busy" || merged.status === "conflict") { const reason = merged.status === "conflict" ? `local and remote history diverged. ${merged.detail}` : merged.detail; return deferred(`Periodic pull deferred: ${reason}`); } if (merged.status === "error") return failed(`Periodic pull failed: ${merged.detail}`); return finish({ status: merged.status, detail: merged.detail }, true); } finally { releaseGit(); } } finally { releasePoll(); } } export function startPullPolling(phrenPath, options) { let stopped = false; let running; const heads = new Map(); const startedAt = Date.now(); const git = options.git ?? runPollGit; let timer; const tick = async () => { if (stopped) return; try { const seconds = resolvePullInterval(phrenPath); if (!seconds || !periodicPullEnabled(phrenPath)) return; const stores = [...new Set([phrenPath, ...getNonPrimaryStores(phrenPath).map((store) => store.path)])]; for (const store of stores) { if (stopped) break; await options.runExclusive(async () => { if (stopped || !fs.existsSync(path.join(store, ".git"))) return; const before = await git(store, ["rev-parse", "HEAD"]); if (!before.ok) return; const previous = heads.get(store) ?? before.output; // The remote check itself runs once per configured interval // (pollStore keeps the shared timestamp); the 5 s tick only notices // another client's pull, so HEAD is read again only after a check. let after = before; if (Date.now() - startedAt >= seconds * 1000) { const result = await pollStore(store, seconds, git); if (result.status === "error") debugLog(result.detail); if (result.status !== "not-due") after = await git(store, ["rev-parse", "HEAD"]); } if (!after.ok) return; if (after.output !== previous) await options.onChange(); heads.set(store, after.output); }); } } catch (err) { debugLog(`periodic pull: ${errorMessage(err)}`); } finally { if (!stopped) { timer = setTimeout(run, 5000); timer.unref(); } } }; const run = () => { running = tick(); }; // Establish a local baseline without a startup network burst; first check is due after the configured interval. running = (async () => { for (const store of [phrenPath, ...getNonPrimaryStores(phrenPath).map((s) => s.path)]) { const head = await git(store, ["rev-parse", "HEAD"]); if (head.ok) heads.set(store, head.output); } })().catch((err) => { debugLog(`periodic pull startup: ${errorMessage(err)}`); }) .finally(() => { if (!stopped) { timer = setTimeout(run, 5000); timer.unref(); } }); return { stop: async () => { stopped = true; if (timer) clearTimeout(timer); await running; } }; }