import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { promisify } from "node:util"; import { resolvePaperclipInstanceRoot } from "../home-paths.js"; const execFileAsync = promisify(execFile); export interface LocalServiceRegistryRecord { version: 1; serviceKey: string; profileKind: string; serviceName: string; command: string; cwd: string; envFingerprint: string; port: number | null; url: string | null; pid: number; processGroupId: number | null; provider: "local_process"; runtimeServiceId: string | null; reuseKey: string | null; startedAt: string; lastSeenAt: string; metadata: Record | null; } export interface LocalServiceIdentityInput { profileKind: string; serviceName: string; cwd: string; command: string; envFingerprint: string; port: number | null; scope: Record | null; } function stableStringify(value: unknown): string { if (Array.isArray(value)) { return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; } if (value && typeof value === "object") { const rec = value as Record; return `{${Object.keys(rec).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(rec[key])}`).join(",")}}`; } return JSON.stringify(value); } function sanitizeServiceKeySegment(value: string, fallback: string): string { const normalized = value .trim() .toLowerCase() .replace(/[^a-z0-9._-]+/g, "-") .replace(/-+/g, "-") .replace(/^-+|-+$/g, ""); return normalized || fallback; } function getRuntimeServicesDir() { return path.resolve(resolvePaperclipInstanceRoot(), "runtime-services"); } function getRuntimeServiceRegistryPath(serviceKey: string) { return path.resolve(getRuntimeServicesDir(), `${serviceKey}.json`); } function normalizeRegistryRecord(raw: unknown): LocalServiceRegistryRecord | null { if (!raw || typeof raw !== "object") return null; const rec = raw as Record; if ( rec.version !== 1 || typeof rec.serviceKey !== "string" || typeof rec.profileKind !== "string" || typeof rec.serviceName !== "string" || typeof rec.command !== "string" || typeof rec.cwd !== "string" || typeof rec.envFingerprint !== "string" || typeof rec.pid !== "number" ) { return null; } return { version: 1, serviceKey: rec.serviceKey, profileKind: rec.profileKind, serviceName: rec.serviceName, command: rec.command, cwd: rec.cwd, envFingerprint: rec.envFingerprint, port: typeof rec.port === "number" ? rec.port : null, url: typeof rec.url === "string" ? rec.url : null, pid: rec.pid, processGroupId: typeof rec.processGroupId === "number" ? rec.processGroupId : null, provider: "local_process", runtimeServiceId: typeof rec.runtimeServiceId === "string" ? rec.runtimeServiceId : null, reuseKey: typeof rec.reuseKey === "string" ? rec.reuseKey : null, startedAt: typeof rec.startedAt === "string" ? rec.startedAt : new Date().toISOString(), lastSeenAt: typeof rec.lastSeenAt === "string" ? rec.lastSeenAt : new Date().toISOString(), metadata: rec.metadata && typeof rec.metadata === "object" && !Array.isArray(rec.metadata) ? (rec.metadata as Record) : null, }; } async function safeReadRegistryRecord(filePath: string) { try { const raw = JSON.parse(await fs.readFile(filePath, "utf8")) as unknown; return normalizeRegistryRecord(raw); } catch { return null; } } export function createLocalServiceKey(input: LocalServiceIdentityInput) { const digest = createHash("sha256") .update( stableStringify({ profileKind: input.profileKind, serviceName: input.serviceName, cwd: path.resolve(input.cwd), command: input.command, envFingerprint: input.envFingerprint, port: input.port, scope: input.scope ?? null, }), ) .digest("hex") .slice(0, 24); return `${sanitizeServiceKeySegment(input.profileKind, "service")}-${sanitizeServiceKeySegment(input.serviceName, "service")}-${digest}`; } export async function writeLocalServiceRegistryRecord(record: LocalServiceRegistryRecord) { await fs.mkdir(getRuntimeServicesDir(), { recursive: true }); await fs.writeFile( getRuntimeServiceRegistryPath(record.serviceKey), `${JSON.stringify(record, null, 2)}\n`, "utf8", ); } export async function removeLocalServiceRegistryRecord(serviceKey: string) { await fs.rm(getRuntimeServiceRegistryPath(serviceKey), { force: true }); } export async function readLocalServiceRegistryRecord(serviceKey: string) { return await safeReadRegistryRecord(getRuntimeServiceRegistryPath(serviceKey)); } export async function listLocalServiceRegistryRecords(filter?: { profileKind?: string; metadata?: Record; }) { try { const entries = await fs.readdir(getRuntimeServicesDir(), { withFileTypes: true }); const records = await Promise.all( entries .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) .map((entry) => safeReadRegistryRecord(path.resolve(getRuntimeServicesDir(), entry.name))), ); return records .filter((record): record is LocalServiceRegistryRecord => record !== null) .filter((record) => { if (filter?.profileKind && record.profileKind !== filter.profileKind) return false; if (!filter?.metadata) return true; return Object.entries(filter.metadata).every(([key, value]) => record.metadata?.[key] === value); }) .sort((left, right) => left.serviceKey.localeCompare(right.serviceKey)); } catch { return []; } } export async function findLocalServiceRegistryRecordByRuntimeServiceId(input: { runtimeServiceId: string; profileKind?: string; }) { const records = await listLocalServiceRegistryRecords( input.profileKind ? { profileKind: input.profileKind } : undefined, ); return records.find((record) => record.runtimeServiceId === input.runtimeServiceId) ?? null; } export function isPidAlive(pid: number) { if (!Number.isInteger(pid) || pid <= 0) return false; try { process.kill(pid, 0); return true; } catch { return false; } } async function isLikelyMatchingCommand(record: LocalServiceRegistryRecord) { if (process.platform === "win32") return true; try { const { stdout } = await execFileAsync("ps", ["-o", "command=", "-p", String(record.pid)]); const commandLine = stdout.trim(); if (!commandLine) return false; return commandLine.includes(record.command) || commandLine.includes(record.serviceName); } catch { return true; } } export async function findAdoptableLocalService(input: { serviceKey: string; command?: string | null; cwd?: string | null; envFingerprint?: string | null; port?: number | null; }) { const record = await readLocalServiceRegistryRecord(input.serviceKey); if (!record) return null; if (!isPidAlive(record.pid)) { await removeLocalServiceRegistryRecord(input.serviceKey); return null; } if (!(await isLikelyMatchingCommand(record))) { await removeLocalServiceRegistryRecord(input.serviceKey); return null; } if (input.command && record.command !== input.command) return null; if (input.cwd && path.resolve(record.cwd) !== path.resolve(input.cwd)) return null; if (input.envFingerprint && record.envFingerprint !== input.envFingerprint) return null; if (input.port !== undefined && input.port !== null && record.port !== input.port) return null; return record; } export async function touchLocalServiceRegistryRecord( serviceKey: string, patch?: Partial>, ) { const existing = await readLocalServiceRegistryRecord(serviceKey); if (!existing) return null; const next: LocalServiceRegistryRecord = { ...existing, ...patch, version: 1, serviceKey, lastSeenAt: patch?.lastSeenAt ?? new Date().toISOString(), }; await writeLocalServiceRegistryRecord(next); return next; } export async function terminateLocalService( record: Pick, opts?: { signal?: NodeJS.Signals; forceAfterMs?: number }, ) { const signal = opts?.signal ?? "SIGTERM"; const targetProcessGroup = process.platform !== "win32" && record.processGroupId && record.processGroupId > 0; try { if (targetProcessGroup) { process.kill(-record.processGroupId!, signal); } else { process.kill(record.pid, signal); } } catch { return; } const deadline = Date.now() + (opts?.forceAfterMs ?? 2_000); while (Date.now() < deadline) { if (!isPidAlive(record.pid)) { return; } await delay(100); } if (!isPidAlive(record.pid)) return; try { if (targetProcessGroup) { process.kill(-record.processGroupId!, "SIGKILL"); } else { process.kill(record.pid, "SIGKILL"); } } catch { // Ignore cleanup races. } } export async function readLocalServicePortOwner(port: number) { if (!Number.isInteger(port) || port <= 0 || process.platform === "win32") return null; try { const { stdout } = await execFileAsync("lsof", ["-nPiTCP", `:${port}`, "-sTCP:LISTEN", "-t"]); const firstPid = stdout .split("\n") .map((line) => Number.parseInt(line.trim(), 10)) .find((value) => Number.isInteger(value) && value > 0); return firstPid ?? null; } catch { return null; } }