Files
PaperClipAI/server/src/services/local-service-supervisor.ts
T
2026-08-16 19:23:16 +00:00

504 lines
16 KiB
TypeScript

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<string, unknown> | null;
}
export interface LocalServiceIdentityInput {
profileKind: string;
serviceName: string;
cwd: string;
command: string;
envFingerprint: string;
port: number | null;
scope: Record<string, unknown> | 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<string, unknown>;
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<string, unknown>;
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<string, unknown>)
: 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<string, unknown>;
}) {
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,
);
const record = records.find((entry) => entry.runtimeServiceId === input.runtimeServiceId) ?? null;
if (!record) return null;
let candidate = record;
if (!isPidAlive(candidate.pid)) {
const ownerPid = candidate.port ? await readLocalServicePortOwner(candidate.port) : null;
if (!ownerPid) {
await removeLocalServiceRegistryRecord(candidate.serviceKey);
return null;
}
candidate = {
...candidate,
pid: ownerPid,
processGroupId: candidate.processGroupId && isPidAlive(candidate.processGroupId) ? candidate.processGroupId : ownerPid,
lastSeenAt: new Date().toISOString(),
};
await writeLocalServiceRegistryRecord(candidate);
}
if (!(await isLikelyMatchingCommand(candidate))) {
await removeLocalServiceRegistryRecord(record.serviceKey);
return null;
}
if (!(await doesLocalServiceRecordMatchCwd(candidate))) {
await removeLocalServiceRegistryRecord(record.serviceKey);
return null;
}
return candidate;
}
export function isPidAlive(pid: number) {
if (!Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
export function isProcessGroupAlive(processGroupId: number | null | undefined) {
if (process.platform === "win32") return false;
if (typeof processGroupId !== "number" || !Number.isInteger(processGroupId) || processGroupId <= 0) return false;
try {
process.kill(-processGroupId, 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;
const normalize = (value: string) => value.replace(/["']/g, "").replace(/\s+/g, " ").trim();
const normalizedCommandLine = normalize(commandLine);
const normalizedRecordedCommand = normalize(record.command);
return normalizedCommandLine.includes(normalizedRecordedCommand) || normalizedCommandLine.includes(record.serviceName);
} catch {
return true;
}
}
export async function findAdoptableLocalService(input: {
serviceKey: string;
profileKind?: string | null;
serviceName?: string | null;
command?: string | null;
cwd?: string | null;
envFingerprint?: string | null;
port?: number | null;
url?: string | null;
}) {
const record =
await readLocalServiceRegistryRecord(input.serviceKey)
?? await adoptLocalServiceFromPortOwner(input);
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 (!(await doesLocalServiceRecordMatchCwd(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 readLocalServiceProcessGroupId(pid: number) {
if (process.platform === "win32") return null;
try {
const { stdout } = await execFileAsync("ps", ["-o", "pgid=", "-p", String(pid)]);
const parsed = Number.parseInt(stdout.trim(), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
} catch {
return null;
}
}
export async function isLocalServiceProcessOwnedBy(pid: number, ownerProcessId: number) {
if (pid === ownerProcessId) return true;
if (process.platform !== "win32") {
return (await readLocalServiceProcessGroupId(pid)) === ownerProcessId;
}
try {
const script = [
`$currentProcessId = ${pid}`,
"while ($currentProcessId -gt 0) {",
" $process = Get-CimInstance Win32_Process -Filter \"ProcessId = $currentProcessId\" -ErrorAction SilentlyContinue",
" if ($null -eq $process) { break }",
" $parentProcessId = [int]$process.ParentProcessId",
" Write-Output $parentProcessId",
" if ($parentProcessId -eq $currentProcessId) { break }",
" $currentProcessId = $parentProcessId",
"}",
].join("\n");
const { stdout } = await execFileAsync("powershell.exe", [
"-NoProfile",
"-NonInteractive",
"-Command",
script,
]);
return stdout
.split(/\r?\n/)
.map((line) => Number.parseInt(line.trim(), 10))
.some((ancestorPid) => ancestorPid === ownerProcessId);
} catch {
return false;
}
}
async function adoptLocalServiceFromPortOwner(input: {
serviceKey: string;
profileKind?: string | null;
serviceName?: string | null;
command?: string | null;
cwd?: string | null;
envFingerprint?: string | null;
port?: number | null;
url?: string | null;
}) {
if (!input.port) return null;
const ownerPid = await readLocalServicePortOwner(input.port);
if (!ownerPid) return null;
if (input.cwd) {
const ownerCwd = await readLocalServiceProcessCwd(ownerPid);
if (!ownerCwd || !(await isLocalServiceProcessInWorkspace(ownerCwd, input.cwd))) {
return null;
}
}
const processGroupId = await readLocalServiceProcessGroupId(ownerPid);
const pid = processGroupId && isPidAlive(processGroupId) ? processGroupId : ownerPid;
const now = new Date().toISOString();
const record: LocalServiceRegistryRecord = {
version: 1,
serviceKey: input.serviceKey,
profileKind: input.profileKind ?? "workspace-runtime",
serviceName: input.serviceName ?? "service",
command: input.command ?? input.serviceName ?? "service",
cwd: input.cwd ?? process.cwd(),
envFingerprint: input.envFingerprint ?? "",
port: input.port,
url: input.url ?? null,
pid,
processGroupId: processGroupId ?? pid,
provider: "local_process",
runtimeServiceId: null,
reuseKey: input.envFingerprint ?? null,
startedAt: now,
lastSeenAt: now,
metadata: null,
};
if (!(await isLikelyMatchingCommand(record))) return null;
await writeLocalServiceRegistryRecord(record);
return record;
}
export async function touchLocalServiceRegistryRecord(
serviceKey: string,
patch?: Partial<Omit<LocalServiceRegistryRecord, "serviceKey" | "version">>,
) {
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<LocalServiceRegistryRecord, "pid" | "processGroupId">,
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) {
const targetAlive = targetProcessGroup
? isProcessGroupAlive(record.processGroupId)
: isPidAlive(record.pid);
if (!targetAlive) {
return;
}
await delay(100);
}
const stillAlive = targetProcessGroup
? isProcessGroupAlive(record.processGroupId)
: isPidAlive(record.pid);
if (!stillAlive) 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) return null;
try {
if (process.platform === "win32") {
const { stdout } = await execFileAsync("netstat", ["-ano", "-p", "tcp"]);
for (const line of stdout.split(/\r?\n/)) {
const columns = line.trim().split(/\s+/);
if (columns.length < 5 || columns[0]?.toUpperCase() !== "TCP") continue;
const localAddress = columns[1] ?? "";
const separatorIndex = localAddress.lastIndexOf(":");
const localPort = Number.parseInt(localAddress.slice(separatorIndex + 1), 10);
const state = columns.at(-2)?.toUpperCase();
const pid = Number.parseInt(columns.at(-1) ?? "", 10);
if (localPort === port && state === "LISTENING" && Number.isInteger(pid) && pid > 0) {
return pid;
}
}
return null;
}
const { stdout } = await execFileAsync("lsof", ["-nP", `-iTCP:${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;
}
}
export async function readLocalServiceProcessCwd(pid: number) {
if (!Number.isInteger(pid) || pid <= 0 || process.platform !== "linux") return null;
try {
return await fs.readlink(`/proc/${pid}/cwd`);
} catch {
return null;
}
}
export async function isLocalServiceProcessInWorkspace(processCwd: string, workspaceCwd: string) {
try {
const [resolvedProcessCwd, resolvedWorkspaceCwd] = await Promise.all([
fs.realpath(processCwd),
fs.realpath(workspaceCwd),
]);
const relativePath = path.relative(resolvedWorkspaceCwd, resolvedProcessCwd);
return relativePath === "" || (!relativePath.startsWith(`..${path.sep}`) && relativePath !== "..");
} catch {
return false;
}
}
export async function isLocalServiceRegistryCwdCompatible(processCwd: string | null, workspaceCwd: string) {
if (!processCwd) return process.platform !== "linux";
return isLocalServiceProcessInWorkspace(processCwd, workspaceCwd);
}
async function doesLocalServiceRecordMatchCwd(record: LocalServiceRegistryRecord) {
if (!record.port) return true;
const ownerPid = await readLocalServicePortOwner(record.port);
if (!ownerPid) return false;
const ownerCwd = await readLocalServiceProcessCwd(ownerPid);
return isLocalServiceRegistryCwdCompatible(ownerCwd, record.cwd);
}