@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
167 lines (166 loc) • 8.74 kB
JavaScript
import { findPhrenPath } from "../phren-paths.js";
import { listMachines } from "../profile-store.js";
import { localNames } from "./computer-names.js";
import { optionalHookPeers, peerRequest } from "./peers.js";
import { bridgeRoot, object } from "./protocol.js";
/** A computer name's first DNS label, lowercased: `Desk`, `desk.local` and
* `Desk.example.net` are one computer. DHCP and Bonjour add domains to the
* same machine's name, so full names do not identify a computer. */
export function computerLabel(name) {
const value = name.trim().toLowerCase();
// An IPv4 address is one name, not a label and a domain.
return /^\d+(\.\d+){3}$/.test(value) ? value : value.split(".")[0] ?? "";
}
const MAX_ROWS = 64;
const same = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
const namesOf = (row) => [row.name, ...row.aliases];
const shortestFirst = (a, b) => a.length - b.length || a.localeCompare(b);
/** Adds a name unless the row has it, or it (or its first label) already
* names another row: one name never points at two computers. */
function addAlias(row, name, rows = [row]) {
if (!name.trim() || namesOf(row).some(known => same(known, name)))
return;
const label = computerLabel(name);
if (rows.some(other => other !== row && namesOf(other).some(known => same(known, name) || computerLabel(known) === label)))
return;
row.aliases.push(name);
}
/** Folds what this Hook knows into one row per real computer. A machines.yaml
* name joins a computer when its first label matches any name that computer
* has, or when it shares a profile with names already folded into exactly one
* computer (a profile two computers claim folds nothing). The rest become
* unlinked rows: names sharing a label or a profile are one computer. */
export function foldComputers(facts, options = {}) {
const localName = facts.local.names.find(name => !name.includes(".")) ?? facts.local.names[0] ?? "local";
const linked = [{ ...(facts.local.id ? { id: facts.local.id } : {}), name: localName, aliases: [], local: true, linked: true, reachable: true }];
for (const name of facts.local.names)
addAlias(linked[0], name);
const peers = facts.peers.map(peer => {
const row = { ...(peer.id ? { id: peer.id } : {}), name: peer.name, aliases: [], local: false, linked: true,
...(peer.reachable === undefined ? {} : { reachable: peer.reachable }) };
linked.push(row);
return { peer, row };
});
// Owner-written names first, so a peer's report can never take one of them.
for (const { peer, row } of peers)
addAlias(row, peer.address, linked);
// `trusted` rows (grant matching) leave out what peers say about themselves.
if (!options.trusted)
for (const { peer, row } of peers)
for (const name of peer.names ?? [])
addAlias(row, name, linked);
const votes = new Map();
const claims = new Map();
const fold = (row, name, profile, claim) => {
addAlias(row, name, linked);
votes.set(row, votes.get(row) ?? new Map());
votes.get(row).set(profile, (votes.get(row).get(profile) ?? 0) + 1);
if (claim)
claims.set(profile, (claims.get(profile) ?? new Set()).add(row));
};
const rest = [];
for (const [name, profile] of Object.entries(facts.machines)) {
const label = computerLabel(name);
if (!label)
continue;
// A label two computers share folds into neither.
const hits = linked.filter(candidate => namesOf(candidate).some(known => computerLabel(known) === label));
if (hits.length === 1)
fold(hits[0], name, profile, true);
else
rest.push([name, profile]);
}
const unfolded = [];
for (const [name, profile] of rest) {
const claimed = claims.get(profile);
if (claimed?.size === 1)
fold([...claimed][0], name, profile, false);
else
unfolded.push([name, profile]);
}
for (const [row, counts] of votes)
row.profile = [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0][0];
const groups = [];
for (const [name, profile] of unfolded) {
const label = computerLabel(name);
const hits = groups.filter(group => group.profiles.has(profile) || group.names.some(known => computerLabel(known) === label));
const target = hits[0] ?? { names: [], profiles: new Set() };
if (!hits.length)
groups.push(target);
for (const other of hits.slice(1)) {
target.names.push(...other.names);
other.profiles.forEach(item => target.profiles.add(item));
groups.splice(groups.indexOf(other), 1);
}
target.names.push(name);
target.profiles.add(profile);
}
const unlinked = groups.map(group => {
const [name, ...aliases] = [...group.names].sort(shortestFirst);
const profile = facts.machines[name];
return { name, aliases, ...(profile ? { profile } : {}), local: false, linked: false };
});
const byName = (a, b) => a.name.localeCompare(b.name);
return [linked[0], ...linked.slice(1).sort(byName), ...unlinked.sort(byName)].slice(0, MAX_ROWS);
}
/** The computer a name refers to: an exact name or alias, else the same first
* label (case-insensitive), so `Desk.local` finds `Desk`. `local` is this
* computer. A name two rows answer to resolves to nothing. */
export function resolveComputer(computers, name) {
const wanted = name.trim().toLowerCase();
if (!wanted)
return undefined;
const only = (rows) => rows.length === 1 ? rows[0] : undefined;
const exact = computers.filter(row => namesOf(row).some(known => same(known, wanted)));
if (exact.length)
return only(exact);
if (wanted === "local" || wanted === "localhost")
return computers.find(row => row.local);
const label = computerLabel(wanted);
return only(computers.filter(row => namesOf(row).some(known => computerLabel(known) === label)));
}
/** What each peer's Hook last said about itself, so grant matching and
* listings know its hostname without an SSH round trip of their own. */
const peerHealth = new Map();
const PROBE_MS = 8_000;
/** Never throws: a missing hooks.yaml or machines.yaml only means fewer rows. */
export async function readComputers(options = {}) {
const { peers, peerError } = await optionalHookPeers(options.root ?? bridgeRoot()).catch(() => ({ peers: [], peerError: undefined }));
const facts = await Promise.all(peers.map(async (peer) => {
let reachable;
if (options.probe) {
let timer;
try {
const health = await Promise.race([peerRequest(peer, "/v1/health", undefined, PROBE_MS),
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("timeout")), PROBE_MS); timer.unref(); })]);
const computer = object(health.computer);
const names = [computer.name, ...(Array.isArray(computer.aliases) ? computer.aliases : [])].filter((name) => typeof name === "string");
peerHealth.set(peer.name, { ...(typeof computer.id === "string" ? { id: computer.id } : {}), names });
reachable = true;
}
catch {
reachable = false;
}
finally {
clearTimeout(timer);
}
}
const seen = peerHealth.get(peer.name);
return { name: peer.name, address: peer.address, ...(seen?.id ? { id: seen.id } : {}), names: seen?.names ?? [],
...(reachable === undefined ? {} : { reachable }) };
}));
const store = options.store !== undefined ? options.store : findPhrenPath();
const machines = store ? listMachines(store) : undefined;
const computers = foldComputers({ local: { ...(options.local?.id ? { id: options.local.id } : {}), names: options.local?.names ?? localNames() },
peers: facts, machines: machines?.ok ? machines.data : {} }, { trusted: options.trusted });
return { computers, ...(peerError ? { peerError } : {}) };
}
/** What a dispatch or hand-off's `computer` names, by owner-written names only: an alias,
* a machines.yaml name or `Desk.local` finds the enrolled peer (by its hooks.yaml name) or
* this computer. Unknown, ambiguous or unlinked names resolve to nothing. */
export async function linkedComputer(name, root) {
const row = resolveComputer((await readComputers({ trusted: true, ...(root ? { root } : {}) })).computers, name);
if (!row?.linked)
return undefined;
return row.local ? { local: true } : { peer: row.name };
}