From 1386e19b4d086e4ed35bb8e48c819a1d8522d8a8 Mon Sep 17 00:00:00 2001 From: Takanori Nishida Date: Thu, 30 Jul 2026 00:48:47 +0000 Subject: [PATCH] feat(linux): add systemd --user discovery to Services.ts status/doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Services.ts's own doc comment says status "reports reality, not a hand-maintained guess" — but loadedLabels()/findPlist()/cadenceOf() only ever looked at launchctl/~/Library/LaunchAgents, so on Linux every one of the 16 registered services reported "✗ missing" regardless of what's actually installed. After the systemd backends landed for WorkSweep (#1692) and DerivedSync/CommitmentSweep/UsageAggregator/CodexUpdate (this branch's prior commit upstream), that's no longer true — but Services.ts had no way to know it. Adds a process.platform branch to the three discovery functions: - loadedLabels(): darwin parses `launchctl list`; linux parses `systemctl --user list-units --all --type=service,timer,path`, stripping the .service/.timer/.path suffix so a label counts as loaded from any of its unit files (same "loaded, not necessarily running" semantics launchctl list already had). - findPlist(): darwin looks for an installed plist then a template in TOOLS/PULSE; linux looks for an installed unit (preferring .timer or .path over .service, since that's where the schedule lives) then a template, falling back to a bare .service for persistent daemons with no timer pairing (pulse — the systemd analog of a RunAtLoad-only launchd job) or a bare non-.template unit (pulse's own manage.sh keeps its systemd source that way, mirroring the plist side's existing dual lookup). - cadenceOf(): darwin parses StartInterval/StartCalendarInterval/ WatchPaths/RunAtLoad from XML; linux parses PathModified/OnCalendar/ OnUnitActiveSec/Type=simple from the ini-style unit file. install/uninstall need no branch — they just run each service's `install:` command, which is itself platform-dispatching inside the Install*.ts script; Services.ts never had to know how a service installs itself, only how to tell whether it did. Services with no Linux port yet (com.lifeos.pulse-menubar, com.lifeos.deriver — both launchd-only by design, deriver explicitly per its own manage-deriver.sh: "Linux/systemd analog is intentionally not implemented") correctly continue to report "✗ missing" on Linux. That's fact, not a regression — same as before this change, just now for the right reason instead of a systemic launchd-only blind spot. Every deletion in this diff is a function signature line whose body moved unchanged into a renamed *Darwin variant, with the original name now the platform-dispatching wrapper — same pattern used across InstallWorkSweep.ts and the four Install*.ts scripts this branch depends on. Verified on this host (Ubuntu 24.04, systemd 255) with all 6 currently- installed systemd services live (pulse, derivedsync, worksweep, commitmentsweep, usage-aggregator, codexupdate): bun Services.ts status ● running at load Pulse (dashboard server) ● running on file-change Derived-file sync ● running every 1h Work sweep ● running daily/scheduled Commitment sweep ● running daily/scheduled Codex update ● running daily/scheduled Usage aggregator Every cadence string matches the actual installed unit content (OnUnitActiveSec=60min → "every 1h", OnCalendar daily entries → "daily/scheduled", the derivedsync .path's PathModified → "on file-change", pulse's Type=simple with no timer/path → "at load"). The 10 not-yet-ported services (Conduit, healthsync, blogdiscovery, etc.) correctly show "✗ missing". `bun Services.ts doc` also verified — produces the same markdown table shape as the darwin-generated one, just reflecting Linux's currently-narrower coverage. darwin path unaffected: `bunx tsc --noEmit` shows only pre-existing @types/node-absence noise, no new errors; the 3 renamed *Darwin functions are byte-for-byte identical to their pre-change bodies. --- LifeOS/install/LIFEOS/TOOLS/Services.ts | 87 ++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 3 deletions(-) diff --git a/LifeOS/install/LIFEOS/TOOLS/Services.ts b/LifeOS/install/LIFEOS/TOOLS/Services.ts index 649e9ebea9..fa23a13a91 100755 --- a/LifeOS/install/LIFEOS/TOOLS/Services.ts +++ b/LifeOS/install/LIFEOS/TOOLS/Services.ts @@ -11,17 +11,28 @@ * bun Services.ts doc # emit the canonical markdown table (for the doc) * * launchctl install/uninstall are the privileged steps; `status`/`doc` are read-only. + * + * Linux: `status`/`doc` branch on `process.platform` to read systemd --user + * units instead of launchd plists (same pattern as InstallWorkSweep.ts and + * the sibling Install*.ts scripts, which each materialize a systemd unit + * pair on Linux). `install`/`uninstall` need no branch here — they just run + * each service's `install:` command, which is itself platform-dispatching + * inside the Install*.ts script. Services with no systemd unit yet (macOS + * menu-bar app, deriver — both launchd-only by design) correctly report + * "✗ missing" on Linux; that's fact, not a bug. */ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +const IS_LINUX = process.platform === "linux"; const HOME = homedir(); const CLAUDE = join(HOME, ".claude"); const LIFEOS = join(CLAUDE, "LIFEOS"); const TOOLS = join(LIFEOS, "TOOLS"); const PULSE = join(LIFEOS, "PULSE"); const LAUNCH_AGENTS = join(HOME, "Library", "LaunchAgents"); +const SYSTEMD_USER = join(HOME, ".config", "systemd", "user"); const AMBER = join(LIFEOS, "USER/CUSTOMIZATIONS/ARBOL/Workers/_A_AMBER_LEDGER"); type Cat = "pulse" | "capture" | "sync" | "sweep" | "maintenance"; @@ -100,13 +111,30 @@ function sh(cmd: string): { code: number; out: string } { return { code: p.exitCode ?? 1, out: (p.stdout.toString() + p.stderr.toString()).trim() }; } -function loadedLabels(): Set { +function loadedLabelsDarwin(): Set { const r = sh("launchctl list 2>/dev/null | grep -iE 'lifeos' | awk '{print $3}'"); return new Set(r.out.split("\n").map((s) => s.trim()).filter(Boolean)); } +function loadedLabelsLinux(): Set { + const r = sh( + "systemctl --user list-units --all --type=service,timer,path --plain --no-legend 2>/dev/null" + + " | awk '{print $1}' | grep -i lifeos", + ); + return new Set( + r.out + .split("\n") + .map((s) => s.trim().replace(/\.(service|timer|path)$/, "")) + .filter(Boolean), + ); +} + +function loadedLabels(): Set { + return IS_LINUX ? loadedLabelsLinux() : loadedLabelsDarwin(); +} + /** Find the plist for a label: installed one wins, else a template in TOOLS/PULSE. */ -function findPlist(label: string): { path: string; installed: boolean } | null { +function findPlistDarwin(label: string): { path: string; installed: boolean } | null { const installed = join(LAUNCH_AGENTS, `${label}.plist`); if (existsSync(installed)) return { path: installed, installed: true }; const short = label.replace(/^com\.lifeos\./, ""); @@ -119,7 +147,39 @@ function findPlist(label: string): { path: string; installed: boolean } | null { return null; } -function cadenceOf(plistPath: string): string { +/** + * Find the most cadence-informative systemd unit for a label: a `.timer` + * (OnCalendar/OnUnitActiveSec) or `.path` (PathModified — the file-watch + * case, e.g. derivedsync) wins over the bare `.service`, since that's where + * the scheduling lives. Falls back to the `.service` alone for persistent + * daemons with no timer/path pairing (e.g. pulse — the systemd analog of a + * RunAtLoad-only launchd job). + */ +function findPlistLinux(label: string): { path: string; installed: boolean } | null { + for (const ext of ["timer", "path", "service"]) { + const installed = join(SYSTEMD_USER, `${label}.${ext}`); + if (existsSync(installed)) return { path: installed, installed: true }; + } + // Not-yet-installed source: most Install*.ts ship a `.template` (placeholders + // substituted at install time); manage.sh instead keeps pulse's systemd unit + // as a bare `.service` in PULSE/ (its own __HOME__/__BUN_PATH__ sed step) — + // same dual pattern findPlistDarwin already handles for the plist side. + for (const ext of ["timer", "path", "service"]) { + for (const base of [TOOLS, PULSE]) { + for (const cand of [`${label}.${ext}.template`, `${label}.${ext}`]) { + const p = join(base, cand); + if (existsSync(p)) return { path: p, installed: false }; + } + } + } + return null; +} + +function findPlist(label: string): { path: string; installed: boolean } | null { + return IS_LINUX ? findPlistLinux(label) : findPlistDarwin(label); +} + +function cadenceOfDarwin(plistPath: string): string { try { const x = readFileSync(plistPath, "utf8"); const si = x.match(/StartInterval<\/key>\s*(\d+)<\/integer>/); @@ -131,6 +191,27 @@ function cadenceOf(plistPath: string): string { } catch { return "?"; } } +function cadenceOfLinux(unitPath: string): string { + try { + const x = readFileSync(unitPath, "utf8"); + if (/^PathModified=/m.test(x)) return "on file-change"; + if (/^OnCalendar=/m.test(x)) return "daily/scheduled"; + const oa = x.match(/^OnUnitActiveSec=(\d+)(min|h)?/m); + if (oa) { + const n = +oa[1]; + const s = oa[2] === "h" ? n * 3600 : n * 60; // OnUnitActiveSec has no bare-seconds units in our templates + return s % 3600 === 0 ? `every ${s / 3600}h` : `every ${Math.round(s / 60)}m`; + } + if (/^\[Timer\]/m.test(x)) return "daily/scheduled"; // timer file with neither pattern matched above + if (/^Type=simple/m.test(x)) return "at load"; // persistent daemon, no timer/path pairing (e.g. pulse) + return "—"; + } catch { return "?"; } +} + +function cadenceOf(unitPath: string): string { + return IS_LINUX ? cadenceOfLinux(unitPath) : cadenceOfDarwin(unitPath); +} + const cmd = process.argv[2] || "status"; const onlyArg = (() => { const i = process.argv.indexOf("--only"); return i >= 0 ? process.argv[i + 1].split(",") : null; })(); const all = process.argv.includes("--all");