diff --git a/cli/src/__tests__/service-manager.test.ts b/cli/src/__tests__/service-manager.test.ts new file mode 100644 index 00000000000..1d0a0bafbbd --- /dev/null +++ b/cli/src/__tests__/service-manager.test.ts @@ -0,0 +1,125 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + assertForegroundRunAllowed, + detectServiceManager, + LaunchdServiceManager, + renderLaunchdPlist, + renderSystemdUnit, + SystemdServiceManager, + type CommandRunner, + type ServiceManager, +} from "../services/service-manager.js"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true }))); + delete process.env.PAPERCLIP_SERVICE_MANAGED; +}); + +async function temporaryDirectory(): Promise { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-service-test-")); + temporaryDirectories.push(directory); + return directory; +} + +describe("service definition generation", () => { + it("generates a stable systemd notify unit without secrets", () => { + const unit = renderSystemdUnit({ instanceId: "team-a", shimPath: "/home/alice/.local/bin/paperclipai", homeDir: "/home/alice/.paperclip" }); + expect(unit).toContain("Type=notify"); + expect(unit).toContain("NotifyAccess=all"); + expect(unit).toContain('ExecStart="/home/alice/.local/bin/paperclipai" run --instance "team-a"'); + expect(unit).toContain("Restart=always"); + expect(unit).toContain("TimeoutStopSec=300"); + expect(unit).not.toContain("API_KEY"); + }); + + it("generates a launchd agent with keepalive and instance logs", () => { + const plist = renderLaunchdPlist({ instanceId: "team-a", shimPath: "/Users/alice/.local/bin/paperclipai", homeDir: "/Users/alice/.paperclip", stdoutPath: "/Users/alice/.paperclip/instances/team-a/logs/service.log", stderrPath: "/Users/alice/.paperclip/instances/team-a/logs/service.err.log" }); + expect(plist).toContain("ing.paperclip.paperclipai.team-a"); + expect(plist).toContain("RunAtLoad"); + expect(plist).toContain("KeepAlive"); + expect(plist).toContain("service.err.log"); + }); +}); + +describe("systemd drift regeneration", () => { + it("rewrites a drifted unit and reloads the user manager", async () => { + const userHome = await temporaryDirectory(); + const calls: string[] = []; + const runner: CommandRunner = async (command, args) => { + calls.push([command, ...args].join(" ")); + return { stdout: "", stderr: "" }; + }; + const manager = new SystemdServiceManager("default", runner, path.join(userHome, ".paperclip"), path.join(userHome, ".local/bin/paperclipai"), userHome); + await fs.mkdir(path.dirname(manager.definitionPath), { recursive: true }); + await fs.writeFile(manager.definitionPath, "stale\n", "utf8"); + + const result = await manager.install({ startNow: false, startOnLogin: false }); + + expect(result.changed).toBe(true); + expect(await fs.readFile(manager.definitionPath, "utf8")).toBe(manager.renderDefinition()); + expect(calls).toContain("systemctl --user daemon-reload"); + }); +}); + +describe("service adapter dispatch", () => { + it("selects launchd on macOS", async () => { + const detection = await detectServiceManager({ platform: "darwin", instanceId: "default" }); + expect(detection.supported).toBe(true); + if (detection.supported) expect(detection.manager).toBeInstanceOf(LaunchdServiceManager); + }); + + it("selects systemd only when the user manager is reachable", async () => { + const runner: CommandRunner = async () => ({ stdout: "", stderr: "" }); + const detection = await detectServiceManager({ platform: "linux", instanceId: "default", runner }); + expect(detection.supported).toBe(true); + if (detection.supported) expect(detection.manager).toBeInstanceOf(SystemdServiceManager); + }); + + it("returns a foreground-run skip on unsupported hosts", async () => { + const runner: CommandRunner = async () => { throw new Error("no bus"); }; + const detection = await detectServiceManager({ platform: "linux", instanceId: "default", runner }); + expect(detection).toEqual({ supported: false, reason: expect.stringContaining("paperclipai run") }); + }); +}); + +describe("launchd lifecycle", () => { + it("disables login startup and unloads the keepalive job when stopped", async () => { + const userHome = await temporaryDirectory(); + const calls: string[] = []; + const runner: CommandRunner = async (command, args) => { + calls.push([command, ...args].join(" ")); + return { stdout: "", stderr: "" }; + }; + const manager = new LaunchdServiceManager("team-a", runner, path.join(userHome, ".paperclip"), path.join(userHome, ".local/bin/paperclipai"), userHome); + + await manager.install({ startNow: false, startOnLogin: false }); + await manager.stop(); + + expect(calls).toContain(`launchctl disable gui/${process.getuid?.() ?? 0}/ing.paperclip.paperclipai.team-a`); + expect(calls).toContain(`launchctl bootout gui/${process.getuid?.() ?? 0}/ing.paperclip.paperclipai.team-a`); + expect(calls.some((call) => call.includes("launchctl kill"))).toBe(false); + }); +}); + +describe("single-writer guard", () => { + const activeManager = { status: async () => ({ active: true, serviceName: "paperclipai.service" }) } as unknown as ServiceManager; + const detector = async () => ({ supported: true as const, manager: activeManager }); + + it("refuses a second foreground writer", async () => { + await expect(assertForegroundRunAllowed("default", false, detector)).rejects.toThrow("already running"); + }); + + it("allows an explicit force override", async () => { + await expect(assertForegroundRunAllowed("default", true, detector)).resolves.toBeUndefined(); + }); + + it("allows the supervisor-owned process", async () => { + process.env.PAPERCLIP_SERVICE_MANAGED = "1"; + await expect(assertForegroundRunAllowed("default", false, detector)).resolves.toBeUndefined(); + }); +}); diff --git a/cli/src/commands/run.ts b/cli/src/commands/run.ts index 9bc9655b448..ddc9b81d65d 100644 --- a/cli/src/commands/run.ts +++ b/cli/src/commands/run.ts @@ -16,6 +16,7 @@ import { resolvePaperclipHomeDir, resolvePaperclipInstanceId, } from "../config/home.js"; +import { assertForegroundRunAllowed } from "../services/service-manager.js"; interface RunOptions { config?: string; @@ -23,6 +24,7 @@ interface RunOptions { repair?: boolean; yes?: boolean; bind?: "loopback" | "lan" | "tailnet"; + force?: boolean; } interface StartedServer { @@ -35,6 +37,7 @@ interface StartedServer { export async function runCommand(opts: RunOptions): Promise { const instanceId = resolvePaperclipInstanceId(opts.instance); process.env.PAPERCLIP_INSTANCE_ID = instanceId; + await assertForegroundRunAllowed(instanceId, opts.force); const homeDir = resolvePaperclipHomeDir(); fs.mkdirSync(homeDir, { recursive: true }); diff --git a/cli/src/commands/service.ts b/cli/src/commands/service.ts new file mode 100644 index 00000000000..1613019e856 --- /dev/null +++ b/cli/src/commands/service.ts @@ -0,0 +1,158 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import * as p from "@clack/prompts"; +import type { Command } from "commander"; +import { cliVersion } from "../version.js"; +import { readConfig, resolveConfigPath } from "../config/store.js"; +import { resolvePaperclipHomeDir, resolvePaperclipInstanceId } from "../config/home.js"; +import { detectServiceManager, type ServiceManager, type ServiceStatus } from "../services/service-manager.js"; + +type CommonOptions = { instance?: string; json?: boolean }; +type HealthResult = { ok: boolean; serverVersion: string | null; error?: string }; + +function output(value: unknown, json: boolean | undefined): void { + if (json) console.log(JSON.stringify(value, null, 2)); + else if (typeof value === "string") console.log(value); + else console.log(JSON.stringify(value, null, 2)); +} + +async function resolveManager(opts: CommonOptions): Promise { + const detection = await detectServiceManager({ instanceId: opts.instance }); + if (detection.supported) return detection.manager; + output({ supported: false, message: detection.reason }, opts.json); + return null; +} + +function healthUrl(instanceId: string): string { + process.env.PAPERCLIP_INSTANCE_ID = instanceId; + const config = readConfig(resolveConfigPath()); + const port = config?.server.port ?? 3100; + const configuredHost = config?.server.host?.trim(); + const host = !configuredHost || configuredHost === "0.0.0.0" || configuredHost === "::" ? "127.0.0.1" : configuredHost; + return `http://${host}:${port}/api/health`; +} + +async function probeHealth(instanceId: string): Promise { + try { + const response = await fetch(healthUrl(instanceId), { signal: AbortSignal.timeout(2_000) }); + const body = await response.json() as { status?: unknown; serverVersion?: unknown; version?: unknown }; + return { ok: response.ok && body.status === "ok", serverVersion: typeof body.serverVersion === "string" ? body.serverVersion : typeof body.version === "string" ? body.version : null }; + } catch (error) { + return { ok: false, serverVersion: null, error: error instanceof Error ? error.message : String(error) }; + } +} + +async function waitForHealth(instanceId: string, expectedVersion: string | null, timeoutMs = 60_000): Promise { + const deadline = Date.now() + timeoutMs; + let last: HealthResult = { ok: false, serverVersion: null }; + while (Date.now() < deadline) { + last = await probeHealth(instanceId); + if (last.ok && (!expectedVersion || last.serverVersion === expectedVersion)) return last; + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error(`Paperclip service did not become healthy${expectedVersion ? ` at version ${expectedVersion}` : ""}: ${last.error ?? `reported ${last.serverVersion ?? "no version"}`}`); +} + +async function writeHotRestartIntent(status: ServiceStatus, instanceId: string, drainRequired: boolean): Promise<{ requestedAt: string; previousVersion: string }> { + if (!status.pid) throw new Error(`Cannot restart ${status.serviceName}: supervisor did not report a server pid.`); + const health = await probeHealth(instanceId); + const homeDir = resolvePaperclipHomeDir(); + const requestedAt = new Date().toISOString(); + await fs.mkdir(homeDir, { recursive: true }); + await fs.rm(path.join(homeDir, "hot-restart-report.json"), { force: true }); + await fs.writeFile(path.join(homeDir, "hot-restart-intent.json"), `${JSON.stringify({ + version: 1, + requestedAt, + previousServerPid: status.pid, + previousServerVersion: health.serverVersion, + drainRequired, + requestedByRunId: process.env.PAPERCLIP_RUN_ID?.trim() || null, + }, null, 2)}\n`, "utf8"); + return { requestedAt, previousVersion: health.serverVersion ?? cliVersion }; +} + +async function waitForRestartReport(requestedAt: string, timeoutMs = 10_000): Promise { + const reportPath = path.join(resolvePaperclipHomeDir(), "hot-restart-report.json"); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const report = JSON.parse(await fs.readFile(reportPath, "utf8")) as { requestedAt?: unknown }; + if (report.requestedAt === requestedAt) return report; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + return null; +} + +export async function restartManagedService(input: { instanceId?: string; expectedVersion?: string | null; waitForDrain?: boolean } = {}): Promise<{ status: ServiceStatus; health: HealthResult; report: unknown | null }> { + const instanceId = resolvePaperclipInstanceId(input.instanceId); + const detection = await detectServiceManager({ instanceId }); + if (!detection.supported) throw new Error(detection.reason); + const before = await detection.manager.status(); + const intent = await writeHotRestartIntent(before, instanceId, input.waitForDrain ?? false); + await detection.manager.restart(); + const health = await waitForHealth(instanceId, input.expectedVersion ?? intent.previousVersion); + return { status: await detection.manager.status(), health, report: await waitForRestartReport(intent.requestedAt) }; +} + +export function registerServiceCommands(program: Command): void { + const service = program.command("service").description("Manage Paperclip as a background service"); + const common = (command: Command) => command.option("-i, --instance ", "Local instance id (default: default)").option("--json", "Print machine-readable JSON", false); + + common(service.command("install").description("Install and register the background service")) + .option("--no-start-now", "Install without starting now") + .option("--no-start-on-login", "Install without enabling start on login") + .option("--enable-linger", "Allow systemd startup without an active login session", false) + .action(async (opts) => { + const manager = await resolveManager(opts); if (!manager) return; + const result = await manager.install({ startNow: opts.startNow, startOnLogin: opts.startOnLogin }); + let lingerEnabled = false; + if (manager.enableLinger) { + let consent = opts.enableLinger === true; + if (!consent && process.stdin.isTTY && process.stdout.isTTY) { + consent = await p.confirm({ message: "Allow Paperclip to run without an active login session? This runs 'loginctl enable-linger' for your user and may request system authorization.", initialValue: false }) === true; + } + if (consent) { await manager.enableLinger(); lingerEnabled = true; } + } + output({ installed: true, changed: result.changed, platform: manager.platform, serviceName: manager.serviceName, definitionPath: manager.definitionPath, lingerEnabled }, opts.json); + }); + + common(service.command("uninstall").description("Stop, disable, and remove the background service")).action(async (opts) => { + const manager = await resolveManager(opts); if (!manager) return; + await manager.uninstall(); + const status = await manager.status(); + if (status.installed || status.active) throw new Error(`${manager.serviceName} is still loaded after uninstall.`); + output({ uninstalled: true, serviceName: manager.serviceName }, opts.json); + }); + + for (const verb of ["start", "stop"] as const) { + common(service.command(verb).description(`${verb === "start" ? "Start" : "Stop"} the background service`)).action(async (opts) => { + const manager = await resolveManager(opts); if (!manager) return; + await manager[verb](); + output(await manager.status(), opts.json); + }); + } + + common(service.command("restart").description("Hot-restart the service while preserving active agent runs")) + .option("--wait", "Wait for active runs to drain instead of adopting them", false) + .option("--expected-version ", "Require the restarted server to report this version") + .action(async (opts) => output(await restartManagedService({ instanceId: opts.instance, expectedVersion: opts.expectedVersion, waitForDrain: opts.wait }), opts.json)); + + common(service.command("status").description("Show supervisor and health status")).action(async (opts) => { + const manager = await resolveManager(opts); if (!manager) return; + const instanceId = resolvePaperclipInstanceId(opts.instance); + output({ ...await manager.status(), health: await probeHealth(instanceId) }, opts.json); + }); + + common(service.command("logs").description("Show service logs")) + .option("-f, --follow", "Follow new log output", false) + .option("-n, --lines ", "Number of recent lines", "100") + .action(async (opts) => { + const manager = await resolveManager(opts); if (!manager) return; + const lines = Number.parseInt(opts.lines, 10); + if (!Number.isInteger(lines) || lines < 1) throw new Error("--lines must be a positive integer."); + await manager.logs(opts.follow, lines); + }); +} diff --git a/cli/src/index.ts b/cli/src/index.ts index 2adb131a2b0..e3fcb0435d4 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -43,6 +43,7 @@ import { registerAdapterCommands } from "./commands/client/adapter.js"; import { registerAssetCommands } from "./commands/client/asset.js"; import { registerSkillCommands } from "./commands/client/skill.js"; import { cliVersion } from "./version.js"; +import { registerServiceCommands } from "./commands/service.js"; const program = new Command(); const DATA_DIR_OPTION_HELP = @@ -131,9 +132,11 @@ const run = program .option("--bind ", "On first run, use onboarding reachability preset (loopback, lan, tailnet)") .option("--repair", "Attempt automatic repairs during doctor", true) .option("--no-repair", "Disable automatic repairs during doctor") + .option("--force", "Run even when the same instance is active under the service manager") .action(runCommand); registerRunCommands(run); +registerServiceCommands(program); const heartbeat = program.command("heartbeat").description("Heartbeat utilities"); diff --git a/cli/src/services/service-manager.ts b/cli/src/services/service-manager.ts new file mode 100644 index 00000000000..c20f80efbb4 --- /dev/null +++ b/cli/src/services/service-manager.ts @@ -0,0 +1,285 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { resolvePaperclipHomeDir, resolvePaperclipInstanceId } from "../config/home.js"; + +const execFileAsync = promisify(execFile); + +export type ServicePlatform = "systemd" | "launchd"; +export type ServiceStatus = { + platform: ServicePlatform; + serviceName: string; + installed: boolean; + active: boolean; + enabled: boolean; + pid: number | null; + detail?: string; + linger?: boolean | null; +}; +export type ServiceInstallOptions = { startNow: boolean; startOnLogin: boolean }; + +export interface ServiceManager { + readonly platform: ServicePlatform; + readonly instanceId: string; + readonly serviceName: string; + readonly definitionPath: string; + renderDefinition(): string; + install(options: ServiceInstallOptions): Promise<{ changed: boolean }>; + uninstall(): Promise; + start(): Promise; + stop(): Promise; + restart(): Promise; + status(): Promise; + logs(follow: boolean, lines: number): Promise; + enableLinger?(): Promise; +} + +export type CommandResult = { stdout: string; stderr: string }; +export type CommandRunner = (command: string, args: string[], options?: { inherit?: boolean }) => Promise; + +export const defaultCommandRunner: CommandRunner = async (command, args, options) => { + if (options?.inherit) { + await new Promise((resolve, reject) => { + const child = execFile(command, args, { windowsHide: true }, (error) => error ? reject(error) : resolve()); + child.stdout?.pipe(process.stdout); + child.stderr?.pipe(process.stderr); + }); + return { stdout: "", stderr: "" }; + } + const result = await execFileAsync(command, args, { encoding: "utf8", windowsHide: true }); + return { stdout: result.stdout, stderr: result.stderr }; +}; + +function escapeSystemd(value: string): string { + return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); +} + +function escapeXml(value: string): string { + return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); +} + +export function resolveServiceShimPath(homeDir = os.homedir()): string { + return process.env.PAPERCLIP_SHIM_PATH?.trim() || path.join(homeDir, ".local", "bin", "paperclipai"); +} + +export function systemdServiceName(instanceId: string): string { + return instanceId === "default" ? "paperclipai.service" : `paperclipai-${instanceId}.service`; +} + +export function launchdServiceName(instanceId: string): string { + return instanceId === "default" ? "ing.paperclip.paperclipai" : `ing.paperclip.paperclipai.${instanceId}`; +} + +export function renderSystemdUnit(input: { instanceId: string; shimPath: string; homeDir: string }): string { + return `[Unit] +Description=Paperclip AI (${input.instanceId}) +After=network.target +StartLimitIntervalSec=60 +StartLimitBurst=5 + +[Service] +Type=notify +NotifyAccess=all +ExecStart="${escapeSystemd(input.shimPath)}" run --instance "${escapeSystemd(input.instanceId)}" +Environment="PAPERCLIP_SERVICE_MANAGED=1" +Environment="PAPERCLIP_INSTANCE_ID=${escapeSystemd(input.instanceId)}" +Environment="PAPERCLIP_HOME=${escapeSystemd(input.homeDir)}" +WorkingDirectory=%h +Restart=always +RestartSec=5 +TimeoutStopSec=300 + +[Install] +WantedBy=default.target +`; +} + +export function renderLaunchdPlist(input: { instanceId: string; shimPath: string; homeDir: string; stdoutPath: string; stderrPath: string }): string { + const label = launchdServiceName(input.instanceId); + return ` + + + + Label${escapeXml(label)} + ProgramArguments + + ${escapeXml(input.shimPath)}run--instance${escapeXml(input.instanceId)} + + EnvironmentVariables + + PAPERCLIP_SERVICE_MANAGED1 + PAPERCLIP_INSTANCE_ID${escapeXml(input.instanceId)} + PAPERCLIP_HOME${escapeXml(input.homeDir)} + + RunAtLoad + KeepAlive + ThrottleInterval5 + ExitTimeOut300 + StandardOutPath${escapeXml(input.stdoutPath)} + StandardErrorPath${escapeXml(input.stderrPath)} + + +`; +} + +async function writeIfChanged(filePath: string, contents: string): Promise { + try { + if (await fs.readFile(filePath, "utf8") === contents) return false; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, contents, { encoding: "utf8", mode: 0o644 }); + return true; +} + +export class SystemdServiceManager implements ServiceManager { + readonly platform = "systemd" as const; + readonly serviceName: string; + readonly definitionPath: string; + + constructor(readonly instanceId: string, private readonly runner: CommandRunner = defaultCommandRunner, private readonly homeDir = resolvePaperclipHomeDir(), private readonly shimPath = resolveServiceShimPath(), userHomeDir = os.homedir()) { + this.serviceName = systemdServiceName(instanceId); + this.definitionPath = path.join(userHomeDir, ".config", "systemd", "user", this.serviceName); + } + + renderDefinition(): string { + return renderSystemdUnit({ instanceId: this.instanceId, shimPath: this.shimPath, homeDir: this.homeDir }); + } + + private async ensureCurrent(): Promise { + const changed = await writeIfChanged(this.definitionPath, this.renderDefinition()); + if (changed) await this.runner("systemctl", ["--user", "daemon-reload"]); + return changed; + } + + async install(options: ServiceInstallOptions): Promise<{ changed: boolean }> { + const changed = await this.ensureCurrent(); + if (options.startOnLogin) await this.runner("systemctl", ["--user", "enable", this.serviceName]); + else await this.runner("systemctl", ["--user", "disable", this.serviceName]).catch(() => undefined); + if (options.startNow) await this.start(); + return { changed }; + } + + async uninstall(): Promise { + await this.stop().catch(() => undefined); + await this.runner("systemctl", ["--user", "disable", this.serviceName]).catch(() => undefined); + await fs.rm(this.definitionPath, { force: true }); + await this.runner("systemctl", ["--user", "daemon-reload"]); + await this.runner("systemctl", ["--user", "reset-failed", this.serviceName]).catch(() => undefined); + } + + async start(): Promise { await this.ensureCurrent(); await this.runner("systemctl", ["--user", "start", this.serviceName]); } + async stop(): Promise { await this.runner("systemctl", ["--user", "stop", this.serviceName]); } + async restart(): Promise { await this.ensureCurrent(); await this.runner("systemctl", ["--user", "restart", this.serviceName]); } + + async status(): Promise { + let output: string; + try { + output = (await this.runner("systemctl", ["--user", "show", this.serviceName, "--property=LoadState,ActiveState,UnitFileState,MainPID"])).stdout; + } catch { + return { platform: this.platform, serviceName: this.serviceName, installed: false, active: false, enabled: false, pid: null, linger: await this.lingerStatus() }; + } + const values = Object.fromEntries(output.trim().split(/\r?\n/).map((line) => line.split(/=(.*)/s).slice(0, 2))); + const pid = Number(values.MainPID); + return { platform: this.platform, serviceName: this.serviceName, installed: values.LoadState === "loaded", active: values.ActiveState === "active", enabled: values.UnitFileState === "enabled", pid: Number.isInteger(pid) && pid > 0 ? pid : null, detail: values.ActiveState, linger: await this.lingerStatus() }; + } + + private async lingerStatus(): Promise { + try { + const result = await this.runner("loginctl", ["show-user", String(process.getuid?.() ?? os.userInfo().username), "--property=Linger", "--value"]); + return result.stdout.trim() === "yes"; + } catch { return null; } + } + + async enableLinger(): Promise { await this.runner("loginctl", ["enable-linger", os.userInfo().username]); } + async logs(follow: boolean, lines: number): Promise { await this.runner("journalctl", ["--user", "--unit", this.serviceName, "--lines", String(lines), ...(follow ? ["--follow"] : [])], { inherit: true }); } +} + +export class LaunchdServiceManager implements ServiceManager { + readonly platform = "launchd" as const; + readonly serviceName: string; + readonly definitionPath: string; + private readonly domain = `gui/${process.getuid?.() ?? 0}`; + private readonly stdoutPath: string; + private readonly stderrPath: string; + + constructor(readonly instanceId: string, private readonly runner: CommandRunner = defaultCommandRunner, private readonly homeDir = resolvePaperclipHomeDir(), private readonly shimPath = resolveServiceShimPath(), userHomeDir = os.homedir()) { + this.serviceName = launchdServiceName(instanceId); + this.definitionPath = path.join(userHomeDir, "Library", "LaunchAgents", `${this.serviceName}.plist`); + const logDir = path.join(homeDir, "instances", instanceId, "logs"); + this.stdoutPath = path.join(logDir, "service.log"); + this.stderrPath = path.join(logDir, "service.err.log"); + } + + renderDefinition(): string { return renderLaunchdPlist({ instanceId: this.instanceId, shimPath: this.shimPath, homeDir: this.homeDir, stdoutPath: this.stdoutPath, stderrPath: this.stderrPath }); } + + async install(options: ServiceInstallOptions): Promise<{ changed: boolean }> { + await fs.mkdir(path.dirname(this.stdoutPath), { recursive: true }); + const changed = await writeIfChanged(this.definitionPath, this.renderDefinition()); + if (changed) await this.runner("launchctl", ["bootout", `${this.domain}/${this.serviceName}`]).catch(() => undefined); + await this.runner("launchctl", [options.startOnLogin ? "enable" : "disable", `${this.domain}/${this.serviceName}`]); + if (options.startOnLogin || options.startNow) { + await this.runner("launchctl", ["bootstrap", this.domain, this.definitionPath]).catch(async () => this.runner("launchctl", ["kickstart", "-k", `${this.domain}/${this.serviceName}`])); + } + if (!options.startNow) await this.stop().catch(() => undefined); + return { changed }; + } + + async uninstall(): Promise { + await this.runner("launchctl", ["bootout", `${this.domain}/${this.serviceName}`]).catch(() => undefined); + await this.runner("launchctl", ["enable", `${this.domain}/${this.serviceName}`]).catch(() => undefined); + await fs.rm(this.definitionPath, { force: true }); + } + async start(): Promise { await this.install({ startNow: true, startOnLogin: true }); } + async stop(): Promise { await this.runner("launchctl", ["bootout", `${this.domain}/${this.serviceName}`]); } + async restart(): Promise { await writeIfChanged(this.definitionPath, this.renderDefinition()); await this.runner("launchctl", ["kickstart", "-k", `${this.domain}/${this.serviceName}`]); } + + async status(): Promise { + try { + const result = await this.runner("launchctl", ["print", `${this.domain}/${this.serviceName}`]); + const pidMatch = result.stdout.match(/\bpid\s*=\s*(\d+)/); + const pid = pidMatch ? Number(pidMatch[1]) : null; + return { platform: this.platform, serviceName: this.serviceName, installed: true, active: Boolean(pid), enabled: await this.isEnabled(), pid, detail: pid ? "running" : "loaded" }; + } catch { + let installed = true; + try { await fs.access(this.definitionPath); } catch { installed = false; } + return { platform: this.platform, serviceName: this.serviceName, installed, active: false, enabled: installed && await this.isEnabled(), pid: null }; + } + } + + private async isEnabled(): Promise { + try { + const result = await this.runner("launchctl", ["print-disabled", this.domain]); + return !new RegExp(`"${this.serviceName.replaceAll(".", "\\.")}"\\s*=>\\s*true`).test(result.stdout); + } catch { return true; } + } + + async logs(follow: boolean, lines: number): Promise { await this.runner("tail", ["-n", String(lines), ...(follow ? ["-F"] : []), this.stdoutPath, this.stderrPath], { inherit: true }); } +} + +export type ServiceManagerDetection = { supported: true; manager: ServiceManager } | { supported: false; reason: string }; + +export async function detectServiceManager(input: { instanceId?: string; platform?: NodeJS.Platform; runner?: CommandRunner } = {}): Promise { + const instanceId = resolvePaperclipInstanceId(input.instanceId); + const platform = input.platform ?? process.platform; + const runner = input.runner ?? defaultCommandRunner; + if (platform === "darwin") return { supported: true, manager: new LaunchdServiceManager(instanceId, runner) }; + if (platform !== "linux") return { supported: false, reason: `Service management is not supported on ${platform}. Use paperclipai run instead.` }; + try { + await runner("systemctl", ["--user", "show-environment"]); + return { supported: true, manager: new SystemdServiceManager(instanceId, runner) }; + } catch { + return { supported: false, reason: "No usable systemd user manager was detected (common in containers and WSL1). Use paperclipai run instead." }; + } +} + +export async function assertForegroundRunAllowed(instanceId: string, force = false, detector: typeof detectServiceManager = detectServiceManager): Promise { + if (force || process.env.PAPERCLIP_SERVICE_MANAGED === "1") return; + const detection = await detector({ instanceId }); + if (!detection.supported) return; + const status = await detection.manager.status(); + if (status.active) throw new Error(`Paperclip instance '${instanceId}' is already running as ${status.serviceName}. Use 'paperclipai service status --instance ${instanceId}' or pass --force to bypass this safety check.`); +} diff --git a/server/src/index.ts b/server/src/index.ts index 2755cc9177c..744e9bac210 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -65,6 +65,7 @@ import { maybePersistWorktreeRuntimePorts } from "./worktree-config.js"; import { initTelemetry, getTelemetryClient } from "./telemetry.js"; import { conflict } from "./errors.js"; import { coordinateHeartbeatSchedulerShutdown } from "./shutdown.js"; +import { systemdNotify } from "./services/systemd-notify.js"; import type { InstanceDatabaseBackupRunResult, InstanceDatabaseBackupTrigger, @@ -1146,6 +1147,9 @@ export async function startServer(): Promise { server.listen(listenPort, config.host, () => { server.off("error", onError); logger.info(`Server listening on ${config.host}:${listenPort}`); + void systemdNotify(["--ready", `--status=Listening on ${config.host}:${listenPort}`]).then((notified) => { + if (notified) logger.info("Notified systemd that Paperclip is ready"); + }); if (process.env.PAPERCLIP_OPEN_ON_LISTEN === "true") { const openHost = config.host === "0.0.0.0" || config.host === "::" ? "127.0.0.1" : config.host; const url = `http://${openHost}:${listenPort}`; @@ -1199,6 +1203,7 @@ export async function startServer(): Promise { { const shutdown = async (signal: "SIGINT" | "SIGTERM") => { + await systemdNotify(["--stopping", `--status=Stopping after ${signal}`]); heartbeatSchedulerStopped = true; if (heartbeatSchedulerInterval) { clearInterval(heartbeatSchedulerInterval); diff --git a/server/src/services/systemd-notify.ts b/server/src/services/systemd-notify.ts new file mode 100644 index 00000000000..793cbd62b87 --- /dev/null +++ b/server/src/services/systemd-notify.ts @@ -0,0 +1,8 @@ +import { execFile } from "node:child_process"; + +export async function systemdNotify(args: string[]): Promise { + if (!process.env.NOTIFY_SOCKET?.trim()) return false; + return await new Promise((resolve) => { + execFile("systemd-notify", args, { windowsHide: true }, (error) => resolve(!error)); + }); +}