@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
183 lines (182 loc) • 9.5 kB
JavaScript
/**
* One full reconcile of a store with its remote: clean up abandoned state,
* commit pending writes, merge the remote (diverged history included, see
* store-merge.ts), push what the remote lacks, and record the outcome where
* the Hook, `phren status` and doctor read it. The Hook runs this on a timer
* for every store, so a computer nobody is typing on still converges.
*/
import * as fs from "fs";
import * as path from "path";
import { tryFileLock } from "../governance/locks.js";
import { getRuntimeHealth, updateRuntimeHealth } from "../governance/policy.js";
import { runtimeFile } from "../phren-paths.js";
import { isGitAuthFailure, withStoreAuthBackoff } from "./auth.js";
import { aheadBehind, firstLine, logSyncOutcome } from "./outcome.js";
import { periodicPullEnabled, runGitWithin, runPollGit } from "./pull.js";
import { resolveAllStores } from "../store-registry.js";
import { debugLog } from "../shared.js";
import { errorMessage } from "../utils.js";
import { cleanupStore } from "./store-cleanup.js";
import { mergeStoreUpstream } from "./store-merge.js";
/** Hook store sync cadence; `PHREN_STORE_SYNC_SECONDS=0` turns it off. */
export const DEFAULT_STORE_SYNC_SECONDS = 300;
export function storeSyncSeconds(env = process.env) {
const raw = env.PHREN_STORE_SYNC_SECONDS;
if (raw === undefined || raw === "")
return DEFAULT_STORE_SYNC_SECONDS;
if (raw === "off")
return 0;
const seconds = Number(raw);
return Number.isInteger(seconds) && seconds >= 0 ? (seconds === 0 ? 0 : Math.max(30, seconds)) : DEFAULT_STORE_SYNC_SECONDS;
}
export async function syncStore(store, options = {}) {
const now = options.now ?? Date.now();
// A long-diverged store fetches and pushes far more than a poll's 10 s allows.
const raw = options.git ?? ((cwd, args) => runGitWithin(120_000, cwd, args));
const source = options.source ?? "store-sync";
if (!fs.existsSync(path.join(store, ".git")))
return { status: "skipped", detail: "The store is not a Git repository.", cleaned: [] };
const release = tryFileLock(runtimeFile(store, "git-op"));
if (!release)
return { status: "busy", detail: "Another Phren Git operation is running.", cleaned: [] };
let outcome;
try {
const cleaned = await cleanupStore(store, raw, { now });
outcome = { ...(await reconcile(store, withStoreAuthBackoff(raw, () => now), options.push !== false)), cleaned };
}
finally {
release();
}
record(store, source, outcome, now);
return outcome;
}
async function reconcile(store, git, push) {
const merge = () => mergeStoreUpstream(store, { git, commitMessage: "auto-save phren (store sync)" });
let merged = await merge();
let backupRef = merged.backupRef;
if (merged.status === "busy" || merged.status === "conflict" || merged.status === "error") {
return { status: merged.status, detail: merged.detail, counts: await aheadBehind(store, git) };
}
let counts = await aheadBehind(store, git);
if (!push || !counts || counts.ahead === 0) {
return { status: merged.status, detail: merged.detail, counts, ...(backupRef ? { backupRef } : {}) };
}
let pushed = await git(store, ["push", "--quiet"]);
// The remote moved between the merge and the push: merge once more.
if (!pushed.ok && !isGitAuthFailure(pushed.error)) {
merged = await merge();
backupRef ??= merged.backupRef;
if (merged.status === "updated" || merged.status === "unchanged")
pushed = await git(store, ["push", "--quiet"]);
else
return { status: merged.status === "busy" ? "busy" : merged.status, detail: merged.detail, counts: await aheadBehind(store, git) };
}
counts = await aheadBehind(store, git);
if (!pushed.ok)
return { status: "error", detail: `Push failed: ${firstLine(pushed.error, 400) || "unknown Git error"}`, counts };
return { status: "pushed", detail: `${merged.detail} Pushed local commits.`, counts, ...(backupRef ? { backupRef } : {}) };
}
function record(store, source, outcome, now) {
if (outcome.status === "skipped" || outcome.status === "busy")
return;
const at = new Date(now).toISOString();
const ok = outcome.status === "unchanged" || outcome.status === "updated" || outcome.status === "pushed";
const previous = getRuntimeHealth(store).lastSync;
const counts = outcome.counts ?? {};
const patch = ok
? {
lastPullAt: at, lastPullStatus: "ok", lastPullDetail: outcome.detail, lastSuccessfulPullAt: at,
...(outcome.status === "pushed" || outcome.counts?.ahead === 0
? { lastPushAt: at, lastPushStatus: "saved-pushed", lastPushDetail: outcome.detail, lastSuccessfulPushAt: at, consecutiveFailures: 0, unsyncedCommits: 0 }
: { unsyncedCommits: outcome.counts?.ahead ?? previous?.unsyncedCommits }),
lastError: undefined, lastErrorAt: undefined,
...counts,
}
: {
lastPullAt: at, lastPullStatus: "error", lastPullDetail: outcome.detail,
lastPushAt: at, lastPushStatus: outcome.status === "conflict" ? "pull-failed" : "error", lastPushDetail: outcome.detail,
consecutiveFailures: (previous?.consecutiveFailures ?? 0) + 1,
lastError: firstLine(outcome.detail, 1000), lastErrorAt: at,
...(typeof outcome.counts?.ahead === "number" ? { unsyncedCommits: outcome.counts.ahead } : {}),
...counts,
};
updateRuntimeHealth(store, { lastSync: patch });
// A quiet success is logged only when it follows a failure or changed something.
if (!ok || outcome.status !== "unchanged" || previous?.lastError || outcome.cleaned.length) {
const extra = [outcome.backupRef ? `kept ${outcome.backupRef}` : "", ...outcome.cleaned].filter(Boolean).join("; ");
logSyncOutcome(store, source, { ok, detail: `${outcome.status}: ${outcome.detail}${extra ? ` (${extra})` : ""}`, counts: outcome.counts });
}
}
/** Every store this computer has, in turn: a readonly or pull-only store only pulls. */
export async function syncAllStores(primary, options = {}) {
if (!periodicPullEnabled(primary))
return [];
const results = [];
for (const entry of resolveAllStores(primary).filter((store) => store.available !== false).slice(0, 16)) {
const push = entry.role !== "readonly" && entry.sync !== "pull-only";
results.push({ name: entry.name, outcome: await syncStore(entry.path, { ...options, push }) });
}
return results;
}
/** The Hook's sync loop: the first pass a minute after start, then every `seconds`. */
export function startStoreSyncLoop(primary, seconds = storeSyncSeconds()) {
if (!seconds)
return { stop: () => { } };
let running = false;
const tick = () => {
if (running)
return;
running = true;
void syncAllStores(primary, { source: "hook-sync" })
.catch((err) => debugLog(`hook store sync: ${errorMessage(err)}`))
.finally(() => { running = false; });
};
const first = setTimeout(tick, Math.min(60_000, seconds * 1000));
const timer = setInterval(tick, seconds * 1000);
first.unref();
timer.unref();
return { stop: () => { clearTimeout(first); clearInterval(timer); } };
}
/** A store with a remote that has not synced for this long is flagged. */
export const STORE_STALE_MS = 60 * 60 * 1000;
/** Diverged, stale and failing in one sentence, most urgent first. */
export function storeWarning(store, now = Date.now()) {
const parts = [];
if (store.diverged)
parts.push(`diverged from its remote (${store.ahead} ahead, ${store.behind} behind)`);
if (store.stale) {
const at = store.lastSyncedAt ? Date.parse(store.lastSyncedAt) : Number.NaN;
parts.push(Number.isNaN(at) ? "has not synced yet" : `has not synced for ${Math.max(1, Math.round((now - at) / 3_600_000))} h`);
}
if (store.error)
parts.push(`last error: ${store.error}`);
return parts.length ? `Store ${store.name} ${parts.join("; ")}` : undefined;
}
/** The newest successful pull or push in a store's sync record. */
export function lastSyncedAt(sync) {
return [sync?.lastSuccessfulPullAt, sync?.lastSuccessfulPushAt].filter((at) => !!at && !Number.isNaN(Date.parse(at)))
.sort((a, b) => Date.parse(b) - Date.parse(a))[0];
}
/** Where a store stands against its last-fetched upstream and its sync record; nothing is fetched. */
export async function storeSyncState(name, store, git = runPollGit, now = Date.now()) {
const upstream = await git(store, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
const state = { name, diverged: false, stale: false };
const sync = getRuntimeHealth(store).lastSync;
const synced = lastSyncedAt(sync);
if (synced)
state.lastSyncedAt = synced;
if (sync?.lastError)
state.error = sync.lastError;
if (upstream.ok && upstream.output) {
state.upstream = upstream.output;
const counts = await aheadBehind(store, git);
if (counts)
Object.assign(state, counts);
state.diverged = (state.ahead ?? 0) > 0 && (state.behind ?? 0) > 0;
state.stale = !synced || now - Date.parse(synced) > STORE_STALE_MS;
}
const warning = storeWarning(state, now);
if (warning)
state.warning = warning;
return state;
}