From 6466b7c6270c1a238e6c9ae5b59362bfe67baecc Mon Sep 17 00:00:00 2001 From: logseq Date: Mon, 3 Aug 2026 10:00:14 +0200 Subject: [PATCH] Add systemd --user backend for Atlas on Linux Atlas's launchd collector and its com.lifeos.atlas background service had no Linux equivalent - the asset graph simply couldn't discover background services or schedule its own tick on Linux, mirroring the gap PR #1692 fixed for WorkSweep. - collectors/Systemd.ts: reads ~/.config/systemd/user/com.{lifeos,pai}.*.{service,timer} unit files directly (no subprocess - OnUnitActiveSec/OnBootSec/Persistent are plain text in the .timer file), registered alongside launchd in Atlas.ts. Same asset/edge shape as the launchd collector (service + RUNS_ON), so blast/owns/exposed queries work identically regardless of platform. - InstallAtlas.ts + com.lifeos.atlas.{plist,service,timer}.template: Atlas had no installer for its own tick service at all (macOS or Linux) - this adds both backends, mirroring InstallWorkSweep.ts's pattern (bun+gh PATH detection so the github collector resolves gh from the unit's restricted PATH, `loginctl enable-linger` via `id -un` so the Linux timer survives logout). - AtlasEventCapture.hook.ts: added the systemd-equivalent Bash/file matchers (systemctl --user commands, unit-file edits under ~/.config/systemd/user/) alongside the existing launchctl ones. - Launchd.ts: plutil is only called if AGENTS_DIR exists, but a directory existing doesn't imply the platform's own tool does (found testing this PR: a Linux box can have stray com.lifeos.*.plist files under ~/Library/LaunchAgents with no plutil to read them). Wrapped the spawn in try/catch, degrading to null the same way Github.ts already treats a missing `gh` - absent tool is a normal state, not a fault this collector should throw on. Verified end to end on a real Linux host: full atlas sync picks up the systemd collector's services correctly, `atlas tick` and the installed timer both confirmed working via `systemctl --user list-timers`, IntegrityCheck-equivalent manual review found no regressions on the darwin path (only new `if (IS_LINUX)`-shaped branches were added, same invariant #1692 documented). --- LifeOS/install/LIFEOS/ATLAS/Atlas.ts | 3 +- LifeOS/install/LIFEOS/ATLAS/InstallAtlas.ts | 253 ++++++++++++++++++ .../LIFEOS/ATLAS/collectors/Launchd.ts | 14 +- .../LIFEOS/ATLAS/collectors/Systemd.ts | 87 ++++++ .../ATLAS/com.lifeos.atlas.plist.template | 57 ++++ .../ATLAS/com.lifeos.atlas.service.template | 11 + .../ATLAS/com.lifeos.atlas.timer.template | 14 + .../LIFEOS/DOCUMENTATION/Atlas/AtlasSystem.md | 3 +- .../install/hooks/AtlasEventCapture.hook.ts | 6 +- 9 files changed, 442 insertions(+), 6 deletions(-) create mode 100755 LifeOS/install/LIFEOS/ATLAS/InstallAtlas.ts create mode 100644 LifeOS/install/LIFEOS/ATLAS/collectors/Systemd.ts create mode 100644 LifeOS/install/LIFEOS/ATLAS/com.lifeos.atlas.plist.template create mode 100644 LifeOS/install/LIFEOS/ATLAS/com.lifeos.atlas.service.template create mode 100644 LifeOS/install/LIFEOS/ATLAS/com.lifeos.atlas.timer.template diff --git a/LifeOS/install/LIFEOS/ATLAS/Atlas.ts b/LifeOS/install/LIFEOS/ATLAS/Atlas.ts index b1aea3d8e2..7e23ba320f 100644 --- a/LifeOS/install/LIFEOS/ATLAS/Atlas.ts +++ b/LifeOS/install/LIFEOS/ATLAS/Atlas.ts @@ -24,11 +24,12 @@ import { github } from "./collectors/Github"; import { projects } from "./collectors/Projects"; import { infraInventory } from "./collectors/InfraInventory"; import { launchd } from "./collectors/Launchd"; +import { systemd } from "./collectors/Systemd"; import { gear } from "./collectors/Gear"; import { secrets } from "./collectors/Secrets"; const COLLECTORS: Record = Object.fromEntries( - [cloudflare, github, projects, infraInventory, launchd, gear, secrets].map((c) => [c.name, c]), + [cloudflare, github, projects, infraInventory, launchd, systemd, gear, secrets].map((c) => [c.name, c]), ); const FULL_SYNC_INTERVAL_MS = 60 * 60 * 1000; diff --git a/LifeOS/install/LIFEOS/ATLAS/InstallAtlas.ts b/LifeOS/install/LIFEOS/ATLAS/InstallAtlas.ts new file mode 100755 index 0000000000..15033ac309 --- /dev/null +++ b/LifeOS/install/LIFEOS/ATLAS/InstallAtlas.ts @@ -0,0 +1,253 @@ +#!/usr/bin/env bun +/** + * InstallAtlas.ts — Materialize the Atlas tick unit(s) and bootstrap them. + * + * bun ~/.claude/LIFEOS/ATLAS/InstallAtlas.ts # install + * bun ~/.claude/LIFEOS/ATLAS/InstallAtlas.ts --uninstall # remove + * bun ~/.claude/LIFEOS/ATLAS/InstallAtlas.ts --status # check + * + * Same two-backend pattern as LIFEOS/TOOLS/InstallWorkSweep.ts (macOS launchd, + * Linux systemd --user, `id -un` + loginctl enable-linger so the Linux timer + * survives logout — the gap found in InstallWorkSweep.ts on 2026-08-03). + * + * macOS: materializes com.lifeos.atlas.plist.template into ~/Library/LaunchAgents/. + * Linux: materializes com.lifeos.atlas.{service,timer}.template into + * ~/.config/systemd/user/ — the timer fires `atlas tick` every 15 minutes; + * Atlas.ts's own lastFullSyncAt() gate means most ticks just process hint + * events cheaply and only run a real full sync once an hour is due. + * Other platforms: unsupported, exits loud rather than silently no-op. + * + * Idempotent. Re-running install bootouts/stops the prior load before + * bootstrapping/starting the fresh unit(s). + */ + +import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from "fs"; +import { join } from "path"; + +declare const Bun: { spawn: (cmd: string[], opts?: any) => any }; + +const HOME = process.env.HOME || ""; +const LABEL = "com.lifeos.atlas"; +const IS_LINUX = process.platform === "linux"; +const IS_MACOS = process.platform === "darwin"; + +// macOS (launchd) +const TEMPLATE_PATH = join(HOME, ".claude", "LIFEOS", "ATLAS", "com.lifeos.atlas.plist.template"); +const LAUNCH_AGENTS_DIR = join(HOME, "Library", "LaunchAgents"); +const TARGET_PLIST = join(LAUNCH_AGENTS_DIR, "com.lifeos.atlas.plist"); + +// Linux (systemd --user) +const SYSTEMD_USER_DIR = join(HOME, ".config", "systemd", "user"); +const SERVICE_TEMPLATE_PATH = join(HOME, ".claude", "LIFEOS", "ATLAS", "com.lifeos.atlas.service.template"); +const TIMER_TEMPLATE_PATH = join(HOME, ".claude", "LIFEOS", "ATLAS", "com.lifeos.atlas.timer.template"); +const TARGET_SERVICE = join(SYSTEMD_USER_DIR, "com.lifeos.atlas.service"); +const TARGET_TIMER = join(SYSTEMD_USER_DIR, "com.lifeos.atlas.timer"); + +async function uid(): Promise { + const proc = Bun.spawn(["id", "-u"], { stdout: "pipe", stderr: "ignore" }); + const out = await new Response(proc.stdout).text(); + await proc.exited; + return out.trim(); +} + +async function username(): Promise { + // `id -un` over process.env.USER — see InstallWorkSweep.ts's 2026-08-03 fix. + const proc = Bun.spawn(["id", "-un"], { stdout: "pipe", stderr: "ignore" }); + const out = await new Response(proc.stdout).text(); + await proc.exited; + return out.trim(); +} + +async function launchctl(args: string[]): Promise<{ ok: boolean; out: string; err: string }> { + const proc = Bun.spawn(["launchctl", ...args], { stdout: "pipe", stderr: "pipe" }); + const out = await new Response(proc.stdout).text(); + const err = await new Response(proc.stderr).text(); + const exit = await proc.exited; + return { ok: exit === 0, out, err }; +} + +async function detectBun(): Promise { + const proc = Bun.spawn(["which", "bun"], { stdout: "pipe", stderr: "ignore" }); + const out = await new Response(proc.stdout).text(); + await proc.exited; + const path = out.trim(); + if (!path) throw new Error("bun not found in PATH — install bun first"); + return path; +} + +async function detectGh(): Promise { + // The github collector shells out to `gh`; widen the unit's PATH the same + // way InstallWorkSweep.ts does for WorkSweep.ts's own gh calls. + const proc = Bun.spawn(["which", "gh"], { stdout: "pipe", stderr: "ignore" }); + const out = await new Response(proc.stdout).text(); + await proc.exited; + const path = out.trim(); + if (!path) throw new Error("gh not found in PATH — install the GitHub CLI first"); + return path; +} + +async function systemctl(args: string[]): Promise<{ ok: boolean; out: string; err: string }> { + const proc = Bun.spawn(["systemctl", "--user", ...args], { stdout: "pipe", stderr: "pipe" }); + const out = await new Response(proc.stdout).text(); + const err = await new Response(proc.stderr).text(); + const exit = await proc.exited; + return { ok: exit === 0, out, err }; +} + +async function installLaunchd(): Promise { + if (!existsSync(TEMPLATE_PATH)) { + console.error(`[InstallAtlas] template missing at ${TEMPLATE_PATH}`); + process.exit(1); + } + const bunPath = await detectBun(); + const bunDir = bunPath.replace(/\/bun$/, ""); + const ghPath = await detectGh(); + const ghDir = ghPath.replace(/\/gh$/, ""); + console.log(`[InstallAtlas] detected bun at ${bunPath}, gh at ${ghPath}`); + const template = readFileSync(TEMPLATE_PATH, "utf-8"); + const materialized = template + .replace(/\{\{HOME\}\}/g, HOME) + .replace(/\{\{BUN\}\}/g, bunPath) + .replace(/\{\{BUN_DIR\}\}/g, bunDir) + .replace(/\{\{GH_DIR\}\}/g, ghDir); + if (!existsSync(LAUNCH_AGENTS_DIR)) mkdirSync(LAUNCH_AGENTS_DIR, { recursive: true }); + + const u = await uid(); + if (existsSync(TARGET_PLIST)) { + await launchctl(["bootout", `gui/${u}`, TARGET_PLIST]); + } + + writeFileSync(TARGET_PLIST, materialized); + console.log(`[InstallAtlas] wrote ${TARGET_PLIST}`); + + const r = await launchctl(["bootstrap", `gui/${u}`, TARGET_PLIST]); + if (!r.ok) { + console.error(`[InstallAtlas] bootstrap failed: ${r.err.trim()}`); + process.exit(1); + } + console.log(`[InstallAtlas] launchd bootstrap OK — ${LABEL} active`); + + const status = await launchctl(["print", `gui/${u}/${LABEL}`]); + if (status.ok) { + const stateLine = status.out.split("\n").find((l) => l.includes("state =")); + console.log(`[InstallAtlas] ${stateLine?.trim() ?? "state unknown"}`); + } +} + +async function installSystemd(): Promise { + if (!existsSync(SERVICE_TEMPLATE_PATH) || !existsSync(TIMER_TEMPLATE_PATH)) { + console.error(`[InstallAtlas] template(s) missing at ${SERVICE_TEMPLATE_PATH} / ${TIMER_TEMPLATE_PATH}`); + process.exit(1); + } + const bunPath = await detectBun(); + const bunDir = bunPath.replace(/\/bun$/, ""); + const ghPath = await detectGh(); + const ghDir = ghPath.replace(/\/gh$/, ""); + console.log(`[InstallAtlas] detected bun at ${bunPath}, gh at ${ghPath}`); + const sub = (s: string) => s.replace(/\{\{HOME\}\}/g, HOME).replace(/\{\{BUN\}\}/g, bunPath).replace(/\{\{BUN_DIR\}\}/g, bunDir).replace(/\{\{GH_DIR\}\}/g, ghDir); + const service = sub(readFileSync(SERVICE_TEMPLATE_PATH, "utf-8")); + const timer = sub(readFileSync(TIMER_TEMPLATE_PATH, "utf-8")); + if (!existsSync(SYSTEMD_USER_DIR)) mkdirSync(SYSTEMD_USER_DIR, { recursive: true }); + + await systemctl(["stop", `${LABEL}.timer`]); + + writeFileSync(TARGET_SERVICE, service); + writeFileSync(TARGET_TIMER, timer); + console.log(`[InstallAtlas] wrote ${TARGET_SERVICE} and ${TARGET_TIMER}`); + + await systemctl(["daemon-reload"]); + + // Survive logout/reboot — see InstallWorkSweep.ts's 2026-08-03 fix for why. + const lingerUser = await username(); + if (lingerUser) { + const linger = Bun.spawn(["loginctl", "enable-linger", lingerUser], { stdout: "ignore", stderr: "pipe" }); + await linger.exited; + } else { + console.error(`[InstallAtlas] could not resolve username via 'id -un' — skipping loginctl enable-linger (timer may not survive logout)`); + } + + const r = await systemctl(["enable", "--now", `${LABEL}.timer`]); + if (!r.ok) { + console.error(`[InstallAtlas] systemctl enable failed: ${r.err.trim()}`); + process.exit(1); + } + console.log(`[InstallAtlas] systemd timer enabled — ${LABEL}.timer active`); + + const status = await systemctl(["is-active", `${LABEL}.timer`]); + console.log(`[InstallAtlas] ${LABEL}.timer state: ${status.out.trim() || "unknown"}`); +} + +async function uninstallLaunchd(): Promise { + const u = await uid(); + if (existsSync(TARGET_PLIST)) { + const r = await launchctl(["bootout", `gui/${u}`, TARGET_PLIST]); + console.log(`[InstallAtlas] bootout ${r.ok ? "OK" : "FAILED: " + r.err.trim()}`); + try { unlinkSync(TARGET_PLIST); console.log(`[InstallAtlas] removed ${TARGET_PLIST}`); } catch {} + } else { + console.log(`[InstallAtlas] no plist at ${TARGET_PLIST} — nothing to do`); + } +} + +async function uninstallSystemd(): Promise { + if (existsSync(TARGET_TIMER) || existsSync(TARGET_SERVICE)) { + const r = await systemctl(["disable", "--now", `${LABEL}.timer`]); + console.log(`[InstallAtlas] disable ${r.ok ? "OK" : "FAILED: " + r.err.trim()}`); + try { unlinkSync(TARGET_TIMER); } catch {} + try { unlinkSync(TARGET_SERVICE); } catch {} + await systemctl(["daemon-reload"]); + console.log(`[InstallAtlas] removed ${TARGET_SERVICE} and ${TARGET_TIMER}`); + } else { + console.log(`[InstallAtlas] no unit at ${TARGET_TIMER} — nothing to do`); + } +} + +async function statusLaunchd(): Promise { + const u = await uid(); + const r = await launchctl(["print", `gui/${u}/${LABEL}`]); + if (!r.ok) { + console.log(`[InstallAtlas] ${LABEL} not loaded`); + process.exit(1); + } + console.log(r.out); +} + +async function statusSystemd(): Promise { + const r = await systemctl(["status", `${LABEL}.timer`, "--no-pager"]); + if (!r.out.trim() && !r.err.trim()) { + console.log(`[InstallAtlas] ${LABEL}.timer not found`); + process.exit(1); + } + console.log(r.out || r.err); +} + +async function install(): Promise { + if (IS_LINUX) return installSystemd(); + if (IS_MACOS) return installLaunchd(); + console.error(`[InstallAtlas] unsupported platform: ${process.platform} (macOS and Linux only)`); + process.exit(1); +} + +async function uninstall(): Promise { + if (IS_LINUX) return uninstallSystemd(); + if (IS_MACOS) return uninstallLaunchd(); + console.error(`[InstallAtlas] unsupported platform: ${process.platform} (macOS and Linux only)`); + process.exit(1); +} + +async function status(): Promise { + if (IS_LINUX) return statusSystemd(); + if (IS_MACOS) return statusLaunchd(); + console.error(`[InstallAtlas] unsupported platform: ${process.platform} (macOS and Linux only)`); + process.exit(1); +} + +async function main(): Promise { + const arg = process.argv[2]; + if (arg === "--uninstall") return uninstall(); + if (arg === "--status") return status(); + return install(); +} + +if (import.meta.main) { + main().catch((err) => { console.error(`[InstallAtlas] Fatal: ${err}`); process.exit(1); }); +} diff --git a/LifeOS/install/LIFEOS/ATLAS/collectors/Launchd.ts b/LifeOS/install/LIFEOS/ATLAS/collectors/Launchd.ts index a3241a2172..7fa632df82 100644 --- a/LifeOS/install/LIFEOS/ATLAS/collectors/Launchd.ts +++ b/LifeOS/install/LIFEOS/ATLAS/collectors/Launchd.ts @@ -12,9 +12,17 @@ import type { AssetObs, CollectResult, Collector, EdgeObs } from "../Store"; const AGENTS_DIR = join(homedir(), "Library/LaunchAgents"); async function plutilRaw(keypath: string, plist: string): Promise { - const proc = Bun.spawn(["plutil", "-extract", keypath, "raw", "-o", "-", plist], { stdout: "pipe", stderr: "pipe" }); - const out = await new Response(proc.stdout).text(); - return (await proc.exited) === 0 ? out.trim() : null; + try { + const proc = Bun.spawn(["plutil", "-extract", keypath, "raw", "-o", "-", plist], { stdout: "pipe", stderr: "pipe" }); + const out = await new Response(proc.stdout).text(); + return (await proc.exited) === 0 ? out.trim() : null; + } catch { + // ENOENT — plutil itself missing. AGENTS_DIR existing doesn't imply the + // platform's own tool does (e.g. a Linux box with stray com.lifeos.*.plist + // files but no plutil binary). Same "absent tool is a normal state, not a + // fault" class Github.ts already treats a missing `gh` as. + return null; + } } export const launchd: Collector = { diff --git a/LifeOS/install/LIFEOS/ATLAS/collectors/Systemd.ts b/LifeOS/install/LIFEOS/ATLAS/collectors/Systemd.ts new file mode 100644 index 0000000000..236834ddf4 --- /dev/null +++ b/LifeOS/install/LIFEOS/ATLAS/collectors/Systemd.ts @@ -0,0 +1,87 @@ +/** + * Systemd collector — LifeOS background services (com.lifeos.* / com.pai.*) on this + * machine, Linux sibling of Launchd.ts (which degrades harmlessly here since + * ~/Library/LaunchAgents never exists on Linux). Reads unit files directly — + * no `systemctl show` shelling per unit, since OnUnitActiveSec/OnBootSec are + * plain text in the .timer file and this stays a pure fs read like Launchd's + * plutil calls, just without the subprocess. + */ + +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { homedir, hostname } from "node:os"; +import type { AssetObs, CollectResult, Collector, EdgeObs } from "../Store"; + +const UNITS_DIR = join(homedir(), ".config/systemd/user"); + +/** First `Key=value` match for `key` inside `[section]`, or null. Plain unit-file INI, no subprocess. */ +function unitValue(text: string, section: string, key: string): string | null { + const secMatch = text.match(new RegExp(`\\[${section}\\][^\\[]*`)); + if (!secMatch) return null; + const m = secMatch[0].match(new RegExp(`^${key}=(.*)$`, "m")); + return m ? m[1].trim() : null; +} + +export const systemd: Collector = { + name: "systemd", + async collect(): Promise { + const host = hostname(); + const machineKey = `machine:${host}`; + const assets: AssetObs[] = [{ kind: "machine", key: machineKey, name: host }]; + const edges: EdgeObs[] = []; + + // Absent source ⇒ degrade, never throw (ratchet gate 3, same as Launchd.ts) — + // a machine without this dir (macOS, or a fresh Linux box pre-install) reports + // incompleteness rather than a permanent sync failure. + if (!existsSync(UNITS_DIR)) return { complete: false, assets: [], edges: [] }; + const files = readdirSync(UNITS_DIR).filter((f) => /^com\.(lifeos|pai)\..*\.(service|timer)$/.test(f)); + // Group by label (strip .service/.timer) so a paired unit reports one asset + // with both its schedule (from the .timer) and its exec (from the .service). + const labels = new Map(); + for (const f of files) { + const isTimer = f.endsWith(".timer"); + const label = f.replace(/\.(service|timer)$/, ""); + const entry = labels.get(label) ?? {}; + if (isTimer) entry.timer = f; else entry.service = f; + labels.set(label, entry); + } + + for (const [label, { service, timer }] of labels) { + let onUnitActiveSec: number | null = null; + let onBootSec: string | null = null; + let persistent = false; + if (timer) { + const text = readFileSync(join(UNITS_DIR, timer), "utf8"); + const active = unitValue(text, "Timer", "OnUnitActiveSec"); + onUnitActiveSec = active ? parseDuration(active) : null; + onBootSec = unitValue(text, "Timer", "OnBootSec"); + persistent = unitValue(text, "Timer", "Persistent") === "true"; + } + assets.push({ + kind: "service", + key: `service:${label}`, + name: label, + attrs: { + service_unit: service ?? null, + timer_unit: timer ?? null, + on_unit_active_sec: onUnitActiveSec, + on_boot_sec: onBootSec, + persistent, + backend: "systemd", + }, + }); + edges.push({ kind: "RUNS_ON", srcKey: `service:${label}`, dstKey: machineKey, srcKind: "service", dstKind: "machine" }); + } + return { complete: true, assets, edges }; + }, +}; + +/** systemd time-span → seconds. Handles the shapes InstallWorkSweep-style templates + * actually emit ("3600", "60min", "1h", "30s") — not the full grammar. */ +function parseDuration(s: string): number | null { + if (/^\d+$/.test(s)) return Number(s); + const m = s.match(/^(\d+)\s*(s|min|h)$/); + if (!m) return null; + const n = Number(m[1]); + return m[2] === "h" ? n * 3600 : m[2] === "min" ? n * 60 : n; +} diff --git a/LifeOS/install/LIFEOS/ATLAS/com.lifeos.atlas.plist.template b/LifeOS/install/LIFEOS/ATLAS/com.lifeos.atlas.plist.template new file mode 100644 index 0000000000..2a574990c8 --- /dev/null +++ b/LifeOS/install/LIFEOS/ATLAS/com.lifeos.atlas.plist.template @@ -0,0 +1,57 @@ + + + + + + Label + com.lifeos.atlas + + ProgramArguments + + {{BUN}} + {{HOME}}/.claude/LIFEOS/ATLAS/Atlas.ts + tick + + + RunAtLoad + + + StartInterval + 900 + + ThrottleInterval + 60 + + StandardOutPath + {{HOME}}/.claude/LIFEOS/MEMORY/STATE/com.lifeos.atlas.log + + StandardErrorPath + {{HOME}}/.claude/LIFEOS/MEMORY/STATE/com.lifeos.atlas.log + + WorkingDirectory + {{HOME}}/.claude + + EnvironmentVariables + + PATH + {{BUN_DIR}}:{{GH_DIR}}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin + HOME + {{HOME}} + + + diff --git a/LifeOS/install/LIFEOS/ATLAS/com.lifeos.atlas.service.template b/LifeOS/install/LIFEOS/ATLAS/com.lifeos.atlas.service.template new file mode 100644 index 0000000000..f299aee485 --- /dev/null +++ b/LifeOS/install/LIFEOS/ATLAS/com.lifeos.atlas.service.template @@ -0,0 +1,11 @@ +# com.lifeos.atlas.service.template — oneshot unit that runs `atlas tick`. +# Widened PATH so bun/gh resolve regardless of install method (homebrew, ~/.bun, linuxbrew). +[Unit] +Description=LifeOS Atlas asset-graph tick (events-then-hourly-full-sync) + +[Service] +Type=oneshot +Environment=PATH={{BUN_DIR}}:{{GH_DIR}}:/usr/bin:/bin +ExecStart={{BUN}} {{HOME}}/.claude/LIFEOS/ATLAS/Atlas.ts tick +StandardOutput=append:{{HOME}}/.claude/LIFEOS/MEMORY/STATE/com.lifeos.atlas.log +StandardError=append:{{HOME}}/.claude/LIFEOS/MEMORY/STATE/com.lifeos.atlas.log diff --git a/LifeOS/install/LIFEOS/ATLAS/com.lifeos.atlas.timer.template b/LifeOS/install/LIFEOS/ATLAS/com.lifeos.atlas.timer.template new file mode 100644 index 0000000000..acd13240da --- /dev/null +++ b/LifeOS/install/LIFEOS/ATLAS/com.lifeos.atlas.timer.template @@ -0,0 +1,14 @@ +# com.lifeos.atlas.timer.template — fires `atlas tick` every 15 minutes (approximates +# the doc's launchd 15-min tick; Atlas.ts's own lastFullSyncAt() gate means most ticks +# just process hint events cheaply and only run a real full sync once an hour is due). +[Unit] +Description=Run LifeOS Atlas tick every 15 minutes + +[Timer] +OnBootSec=2min +OnUnitActiveSec=900 +AccuracySec=30s +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/LifeOS/install/LIFEOS/DOCUMENTATION/Atlas/AtlasSystem.md b/LifeOS/install/LIFEOS/DOCUMENTATION/Atlas/AtlasSystem.md index 4f1a373450..01bca30d54 100644 --- a/LifeOS/install/LIFEOS/DOCUMENTATION/Atlas/AtlasSystem.md +++ b/LifeOS/install/LIFEOS/DOCUMENTATION/Atlas/AtlasSystem.md @@ -21,7 +21,7 @@ Modeled on CNCF Cartography's design (sync-and-expire collectors, per-source obs | Redacted Pulse snapshot | `~/.local/state/lifeos/atlas/snapshot.json` | | Event hints | `~/.local/state/lifeos/atlas/events.jsonl` | | Hint hook | `hooks/AtlasEventCapture.hook.ts` (PostToolUse: Bash, Write, Edit, MultiEdit) | -| Background service | `com.lifeos.atlas` (launchd: 15-min tick + WatchPaths on events file) | +| Background service | `com.lifeos.atlas` — launchd (macOS, 15-min tick + WatchPaths on events file) or systemd --user timer (Linux, 15-min `OnUnitActiveSec`); installed by `LIFEOS/ATLAS/InstallAtlas.ts` | | Pulse | module `PULSE/modules/atlas.ts` → `GET /api/atlas`, `GET/POST /api/atlas/insights` → page `/atlas` (tier1 nav): live d3-force graph + Insights + Browse + Gaps tabs | | Insights cache | `MEMORY/STATE/atlas-insights.json` (Inference narrative, keyed by metric content hash) | | Tests | `LIFEOS/ATLAS/tests/store.test.ts` (sweep invariants) | @@ -49,6 +49,7 @@ Modeled on CNCF Cartography's design (sync-and-expire collectors, per-source obs | `projects` | `USER/PROJECTS.md` main table | projects; SERVES → domains, DEPLOYED_FROM → repos | | `infra-inventory` | `ARBOL/Shared/infra-inventory.ts` (observed, never replaced) | targets, security-plane system node; MONITORS → domains, REGISTERED_IN | | `launchd` | `~/Library/LaunchAgents/com.{lifeos,pai}.*` (plutil always `-o -`) | services, this machine; RUNS_ON edges | +| `systemd` | `~/.config/systemd/user/com.{lifeos,pai}.*.{service,timer}` (Linux sibling of `launchd` — unit files parsed directly, no subprocess) | services, this machine; RUNS_ON edges | | `gear` | `USER/GEAR.md` tables | devices with category/role | | `secrets` | the incident-response credential registry (`GenerateRegistry.ts --json`) + tier shapes (`DetectCriticalKeys.ts --format json`) | credentials (priority/cadence/vendor/dependencies attrs), orphaned credentials; HOLDS edges from this machine and from the config repo when the env file is tracked in it | diff --git a/LifeOS/install/hooks/AtlasEventCapture.hook.ts b/LifeOS/install/hooks/AtlasEventCapture.hook.ts index 97d2cc68f3..8e866bc2d4 100755 --- a/LifeOS/install/hooks/AtlasEventCapture.hook.ts +++ b/LifeOS/install/hooks/AtlasEventCapture.hook.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun /** - * @version 1.0.1 + * @version 1.0.2 * AtlasEventCapture.hook.ts — mutation hints for the Atlas asset graph. * * TRIGGER: PostToolUse (Bash, Write, Edit, MultiEdit) @@ -39,11 +39,15 @@ if (tool === "Bash") { if (/\bdns_records\b|zones\/.*\/dns/.test(cmd)) sources.add("cloudflare"); if (/\bgh\s+repo\s+(create|delete|rename|archive)/.test(cmd)) sources.add("github"); if (/launchctl\s+(load|unload|bootstrap|bootout)/.test(cmd)) sources.add("launchd"); + // Linux sibling of the launchctl match above, for the systemd collector. + if (/systemctl\s+--user\s+(enable|disable|start|stop|daemon-reload)/.test(cmd)) sources.add("systemd"); } else if (["Write", "Edit", "MultiEdit"].includes(tool)) { if (/\/PROJECTS\.md$/.test(filePath)) sources.add("projects"); if (/\/GEAR\.md$/.test(filePath)) sources.add("gear"); if (/infra-inventory\.ts$/.test(filePath)) sources.add("infra-inventory"); if (/Library\/LaunchAgents\/com\.(lifeos|pai)\./.test(filePath)) sources.add("launchd"); + // Linux sibling — a unit file edited directly under systemd's user dir. + if (/\.config\/systemd\/user\/com\.(lifeos|pai)\./.test(filePath)) sources.add("systemd"); } if (sources.size > 0) {