UNPKG

@phren/cli

Version:

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

241 lines (240 loc) • 12.7 kB
import { lstatSync, readFileSync } from "node:fs"; import path from "node:path"; import { z } from "zod"; import { conductorOriginChanged } from "./dispatch-origins.js"; import { isConductorName, paneAgentName } from "./herdr.js"; import { atomicInPrivateDir, BridgeError, bridgeRoot, id, objects, provider, serverName, targetSchema } from "./protocol.js"; /** * Which pane is this computer's conductor, as the Hook keeps it * (docs/conductor-sets.md). The role follows the pane: server, pane id and * terminal id. When the agent in that pane restarts or logs in again, the * role stays and the new session is recorded. The Herdr agent name or tmux * `@phren_agent` is only a label, read once to migrate a conductor that * predates this file. */ const recordSchema = z.object({ server: serverName, pane: id, terminal: z.string().max(200).optional(), workspace: id.optional(), tab: id.optional(), source: provider.optional(), session: z.string().max(200).optional(), since: z.string().datetime(), by: z.enum(["launch", "owner", "migrated"]), }); /** A cleared name keeps its time (`name: null`), so an older name arriving later does not return. */ const setNameSchema = z.object({ name: z.string().min(1).max(60).nullable(), namedAt: z.string().datetime() }); /** `conductor` absent is legacy mode: nothing has decided the role yet, so a * conductor is still recognized by its mux name. `null` is no conductor. */ export const successionSchema = z.object({ id: z.string().uuid(), generation: z.string().uuid(), side: z.enum(["origin", "successor"]), phase: z.enum(["preparing", "prepared", "committed", "aborted"]), originId: z.string().uuid(), destinationId: z.string().uuid(), previous: recordSchema, from: targetSchema, connection: z.object({ name: serverName, server: serverName, fingerprint: z.string().regex(/^[a-f0-9]{64}$/), id: z.string().uuid() }).strict(), workers: z.array(z.object({ id: z.string().uuid(), fingerprint: z.string().regex(/^[a-f0-9]{64}$/).optional() }).strict()).optional(), revoked: z.boolean().optional(), activated: z.boolean().optional(), target: targetSchema.optional(), resume: z.record(z.string(), z.unknown()).optional(), }).strict(); const stateSchema = z.object({ version: z.literal(1), conductor: recordSchema.nullable().optional(), set: setNameSchema.optional(), succession: successionSchema.optional() }); const roleFile = (root = bridgeRoot()) => path.join(root, "conductor-role.json"); /** The Hook is the only writer, so the last state it read or wrote stands in for the file. */ const cached = new Map(); /** The saved state; undefined before the file exists. */ export async function readRoleState(root = bridgeRoot()) { const file = roleFile(root); if (cached.has(file)) return cached.get(file); // Read once per process and synchronously: the overview's time budget // should not wait on disk for a file of a few hundred bytes. let text; try { const info = lstatSync(file); // Only a small regular file the Hook wrote is read; anything else counts as damaged. text = info.isFile() && info.size <= 65_536 ? readFileSync(file, "utf8") : ""; } catch { text = undefined; } let state; if (text !== undefined) { // A damaged file is a state with no conductor, never a return to names. try { state = stateSchema.parse(JSON.parse(text)); } catch { state = { version: 1, conductor: null }; } } cached.set(file, state); return state; } async function writeRoleState(state, root = bridgeRoot()) { const file = roleFile(root); await atomicInPrivateDir(file, JSON.stringify(state, null, 2) + "\n"); cached.set(file, state); conductorOriginChanged(state.conductor); } /** Every read-modify-write of the state runs one at a time: an overview poll * noting a moved pane must not interleave with a make or stop and bring back * the record it replaced. */ const queues = new Map(); function serialized(run, root = bridgeRoot()) { const queue = queues.get(root) ?? Promise.resolve(); const next = queue.then(run, run); queues.set(root, next.catch(() => undefined)); return next; } /** Uses the same serialization as every local role writer. */ export function updateConductorAuthority(root, change) { return serialized(async () => { const state = await change(await readRoleState(root) ?? { version: 1 }); await writeRoleState(state, root); return state; }, root); } export function assertRoleMutable(state) { if (state?.succession && state.succession.phase !== "aborted") throw new BridgeError(409, "The conductor is moving or delegated to another computer; reconcile its ownership before changing the role."); } /** For tests: forget the cached state. */ export function resetRoleState() { cached.clear(); } const liveStatus = (pane) => !["completed", "exited", "failed", "stopped"].includes(String(pane.agent_status)); /** A pane running an agent the Hook can talk to. */ export const runsAgent = (pane) => !!pane && provider.safeParse(pane.agent).success && liveStatus(pane); function recordFor(server, pane, by, session) { return { server, pane: String(pane.pane_id), ...(typeof pane.terminal_id === "string" ? { terminal: pane.terminal_id } : {}), ...(id.safeParse(pane.workspace_id).success ? { workspace: String(pane.workspace_id) } : {}), ...(id.safeParse(pane.tab_id).success ? { tab: String(pane.tab_id) } : {}), ...(provider.safeParse(pane.agent).success ? { source: pane.agent } : {}), ...(session ? { session } : {}), since: new Date().toISOString(), by, }; } /** The one pane in a snapshot whose agent reports this session; none when it is absent or ambiguous. */ function sessionPane(session, s) { const found = objects(s.panes).filter(p => runsAgent(p) && objects([p.agent_session])[0]?.value === session); return found.length === 1 ? found[0] : undefined; } /** The recorded pane in a snapshot of its server, when it is still the same terminal. */ function recordedPane(record, s) { const pane = objects(s.panes).find(p => p.pane_id === record.pane); if (!pane) return undefined; // A pane id the multiplexer gave to a new terminal is another pane. if (record.terminal && typeof pane.terminal_id === "string" && pane.terminal_id !== record.terminal) return undefined; return pane; } /** * The conductor's pane on `server`, from a snapshot of it, whether or not an * agent runs there right now. A recorded pane gone from the snapshot ends the * role. In legacy mode a live pane with a conductor name is adopted and saved. */ export function conductorPane(server, s, root = bridgeRoot()) { const write = (state) => writeRoleState(state, root); return serialized(async () => { const state = await readRoleState(root); if (state?.succession?.side === "origin" && state.succession.phase === "committed") return undefined; if (state?.succession?.side === "successor" && ["preparing", "prepared"].includes(state.succession.phase)) return undefined; if (state?.conductor === undefined) { const named = objects(s.panes).find(pane => isConductorName(paneAgentName(s, pane)) && runsAgent(pane)); if (!named) return undefined; await write({ ...state, version: 1, conductor: recordFor(server, named, "migrated") }); return named; } const record = state.conductor; if (!record || record.server !== server) return undefined; let pane = recordedPane(record, s); if (!pane) { // The pane went away or was recreated (a moved tab, a Herdr restart): the // role follows the session, so a pane running it takes the role over. const adopted = record.session ? sessionPane(record.session, s) : undefined; if (!adopted && state.succession && state.succession.phase !== "aborted") return undefined; if (!adopted) { await write({ ...state, conductor: null }); return undefined; } await write({ ...state, conductor: { ...recordFor(server, adopted, record.by, record.session), since: record.since } }); return adopted; } // Keep the place current: a pane moved to another tab or workspace is the same pane. const moved = (id.safeParse(pane.workspace_id).success && pane.workspace_id !== record.workspace) || (id.safeParse(pane.tab_id).success && pane.tab_id !== record.tab) || (provider.safeParse(pane.agent).success && pane.agent !== record.source); if (moved) await write({ ...state, conductor: { ...record, workspace: String(pane.workspace_id), tab: String(pane.tab_id), ...(provider.safeParse(pane.agent).success ? { source: pane.agent } : {}) } }); return pane; }, root); } /** The recorded conductor, whatever its server; undefined in legacy mode or with none. */ export async function recordedConductor() { return (await readRoleState())?.conductor ?? undefined; } /** Makes `pane` on `server` this computer's conductor, replacing any record. */ export function recordConductor(server, pane, by, session) { return serialized(async () => { const state = await readRoleState() ?? { version: 1 }; assertRoleMutable(state); const record = recordFor(server, pane, by, session); await writeRoleState({ ...state, conductor: record }); return record; }); } /** Move only the captured role. The optional hand-over holds the receipt * writer's queue while preparing routing and committing this role; it must * roll routing back if commit fails. No role writer can interleave here. */ // This CAS covers local targets. ConductorSuccessionService owns remote // generations while keeping the receipts and grants on their original Hook. export function transferConductor(expected, server, pane, session, handOver = commit => commit()) { return serialized(async () => { const state = await readRoleState(); const current = state?.conductor; assertRoleMutable(state); // Include the generation (since), terminal and session, so a stop/make // or another move cannot be overwritten even if it used the same pane. if (!current || !recordSchema.keyof().options.every(field => current[field] === expected[field])) { throw new BridgeError(409, "The conductor changed during the move; nothing was transferred."); } const record = recordFor(server, pane, "owner", session); await handOver(() => writeRoleState({ ...state, version: 1, conductor: record })); return record; }); } /** The session now running in the conductor's pane: a restart or new login reattaches here. */ export function noteConductorSession(server, pane, session) { return serialized(async () => { const state = await readRoleState(); const record = state?.conductor; if (!state || !record || record.server !== server || record.pane !== pane || record.session === session) return; await writeRoleState({ ...state, conductor: { ...record, session } }); }); } /** Ends the role. With `pane`, only when that pane holds it. Returns what was stopped. */ export function clearConductor(pane) { return serialized(async () => { const state = await readRoleState(); const record = state?.conductor ?? undefined; assertRoleMutable(state); if (record && pane !== undefined && record.pane !== pane) return undefined; // Written even with nothing to clear: a stop also ends legacy mode, so a // leftover conductor name is not adopted again. await writeRoleState({ ...state, version: 1, conductor: null }); return record; }); } /** The set name this Hook holds. */ export async function readSetName() { return (await readRoleState())?.set; } /** Keeps `name` when it is newer than the one held; `null` clears. Returns whether it changed. */ export function saveSetName(name, namedAt) { return serialized(async () => { const state = await readRoleState(); const held = state?.set; if (held && Date.parse(held.namedAt) >= Date.parse(namedAt)) return false; await writeRoleState({ ...state, version: 1, set: { name, namedAt } }); return held?.name !== name; }); }