From 3a434657bc6a2a239cfe1207551e98a8cd41cead Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Fri, 31 Jul 2026 22:12:15 +0200 Subject: [PATCH] docs(spec): start-on-login via launchd user agent (macOS) Design for `xy service install|uninstall|start|stop|status`, backed by the service-manager crate. Covers the launchd environment problem (PATH snapshot at install time), the daemon's own rotating log file, and the accepted blind spot for pre-logger launch failures. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EGntTHCW3sEPy1VBRopNNp --- .../2026-07-31-xy-start-on-login-design.md | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-31-xy-start-on-login-design.md diff --git a/docs/superpowers/specs/2026-07-31-xy-start-on-login-design.md b/docs/superpowers/specs/2026-07-31-xy-start-on-login-design.md new file mode 100644 index 0000000..65be295 --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-xy-start-on-login-design.md @@ -0,0 +1,240 @@ +# xy — Start on Login (macOS) + +**Date:** 2026-07-31 +**Status:** Approved — ready for implementation planning + +## Problem + +The daemon must be launched by hand (`xy daemon`) and dies with the terminal +that started it. The MVP spec (2026-05-25) listed "auto-start at login via +launchd" as an explicit non-goal; this design delivers it. + +## Goals + +- Install a user-level launchd agent that starts `xy daemon` at login and + restarts it if it dies. +- Manage that agent from `xy` itself — install, uninstall, start, stop, status. +- Survive the environment launchd hands a login agent, which is far poorer than + an interactive shell's. +- Give the daemon a log file, so "it didn't come up at login" is diagnosable. + +## Non-goals (deferred) + +- Linux (systemd) and Windows support. The CLI shape is chosen so these are a + change inside one module, not a change to the command surface. +- System-level (root) agents. User agents only. +- Managing the agent over IPC. `xy service *` deliberately bypasses the daemon. + +## Decisions + +### launchd owns the daemon's lifecycle + +Once installed, the LaunchAgent is the sanctioned way to run the daemon on +macOS: `RunAtLoad` starts it at login and `KeepAlive` restarts it on crash. +`xy daemon` remains available for foreground and development use; the existing +pidfile keeps the two from colliding. + +### Environment: snapshot `PATH` at install time + +A LaunchAgent inherits `PATH=/usr/bin:/bin:/usr/sbin:/sbin` and nothing from +the user's shell. Supervised MCP servers inherit whatever the daemon has, and +servers launched through toolchain shims (`pnpx`, `bunx`) will not find their +interpreter under that PATH. + +`xy service install` therefore captures the invoking shell's `PATH` and writes +it into the plist's `EnvironmentVariables`. The snapshot is deterministic and +visible in the plist, at the cost of going stale when a new toolchain is added +— `xy service install --force` re-snapshots. + +Rejected: wrapping the daemon in `/bin/zsh -lc`, which makes login-time startup +depend on shell rc being fast and non-interactive-safe, and adds a process to +the tree for no gain here. + +Verified 2026-07-31: `XDG_CONFIG_HOME`, `XDG_STATE_HOME` and `XDG_RUNTIME_DIR` +are all unset on this machine, so `etcetera`'s `Xdg` strategy resolves to +`~/.config` and `~/.local/state` identically for the CLI and for a +launchd-spawned daemon. There is no path-mismatch risk to design around. + +### Third-party crate: `service-manager` + +`service-manager = "0.11"` (469K downloads, 29 reverse deps, updated +2026-02-18) provides `LaunchdServiceManager::user()` targeting +`~/Library/LaunchAgents`, an install context carrying `environment`, +`autostart` and `restart_policy`, and a `status()` returning +`NotInstalled` / `Running` / `Stopped(Option)`. + +Rejected alternatives: + +- **`auto-launch`** (4.6M downloads) is built for GUI applications at login. It + has no notion of `KeepAlive`, restart policy, or service status — the wrong + abstraction for a supervised daemon. +- **Hand-rolled plist + `launchctl`** would re-implement the crate and leave us + owning modern-vs-legacy `launchctl` compatibility. + +### Use the crate's generated plist; log from inside the daemon + +`LaunchdInstallConfig` exposes only `keep_alive` — there is no way to set +`StandardOutPath` or `StandardErrorPath` through the typed API. Rather than +bypass it with the `contents: Option` escape hatch and hand-author plist +XML, the daemon gains its own log file (see below). + +**Accepted limitation.** Failures occurring before the daemon's logger exists — +missing binary, dyld error, malformed plist — appear in neither `xy.log` nor +`xy service status` beyond a bare `Stopped`. Documented recourse: + + launchctl print gui/$UID/se.aceofba.xy + +### Program path: `current_exe()`, with a warning + +`xy service install` resolves and canonicalizes `std::env::current_exe()`. If +the result contains a `target/debug` or `target/release` path component, it +prints a warning that the agent will break on `cargo clean` and proceeds. + +## Architecture + +`service-manager` is added to `[workspace.dependencies]` and consumed by the +`xy` crate alone. `xy-protocol`, `xy-supervisor` and `xy-ipc` are unchanged +except for one addition to `logs.rs` (below). Nothing crosses the IPC boundary: +`xy service *` manipulates launchd directly, which is what makes it work when +the daemon is dead. + +Two new modules in `crates/xy`, Rust-2018 layout (`foo.rs` + `foo/`): + +- **`src/service.rs`** — the OS-facing unit. Owns `AgentSpec` and thin + `install` / `uninstall` / `start` / `stop` / `status` functions that translate + it into `ServiceInstallCtx` and back out into an `AgentStatus`. Knows about + launchd; knows nothing about clap or printing. Returns data, never prints, so + it is testable without a terminal. +- **`src/cli/service.rs`** — the presentation unit. Parses the verbs, calls into + `service.rs`, formats human output, maps outcomes to exit codes. Mirrors the + existing `cli/mod.rs` / `cli/format.rs` split. + +`main.rs` gains a `Service { #[command(subcommand)] verb: ServiceCmd }` arm. + +`service.rs` wires `LaunchdServiceManager::user()` under +`cfg(target_os = "macos")`. On other targets the verbs return +`start-on-login is macOS-only for now` and exit 1. + +## The agent + +| `AgentSpec` field | Value | Plist key | +|---|---|---| +| `label` | `se.aceofba.xy` | `Label` | +| `program` | canonicalized `current_exe()` | `ProgramArguments[0]` | +| `args` | `["daemon"]` | `ProgramArguments[1..]` | +| `environment` | `[("PATH", )]` | `EnvironmentVariables` | +| `working_directory` | `$HOME` | `WorkingDirectory` | +| `autostart` | `true` | `RunAtLoad` | +| `restart_policy` | `Always { delay_secs: Some(10) }` | `KeepAlive` | +| `username` | `None` — runs as the invoking user | — | + +launchd collapses `RestartPolicy` to a `KeepAlive` boolean: `Never` maps to +false, `Always` and `OnFailure` both map to true, and `delay_secs` is ignored. +`Always` is chosen for the explicit `KeepAlive: true` it produces; the delay is +carried only so the value stays meaningful if this spec is extended to systemd. + +The label is the reverse-DNS form of the `git.aceofba.se` remote. It lives on +`AgentSpec` rather than being a constant so tests can install under a distinct +label. + +`working_directory` is `$HOME` rather than launchd's default `/`, so that a +server config using a relative `working_dir` resolves somewhere predictable. + +## Daemon logging + +`main.rs` is reordered so `Paths::resolve()` and `ensure_dirs()` run *before* +`tracing` is initialised. Today `ensure_dirs()` is called inside `daemon::run`, +which is too late to open a log file for the logger itself. + +For the `Cmd::Daemon` arm only, the subscriber writes to `stderr.and(file)` via +`MakeWriterExt`, where the file half is `log_dir/xy.log` backed by the existing +`xy_supervisor::logs::RotatingLogWriter` (10 MB × 5, the same rotation used for +per-server logs). A small `MakeWriter` newtype wrapping +`Arc>` bridges the two. + +Every other subcommand keeps stderr-only logging; CLI output does not belong in +the daemon's log. + +This requires one targeted addition to existing code: +`impl std::io::Write for RotatingLogWriter` in `xy-supervisor/src/logs.rs`. The +type already tracks `written` and rotates, but today exposes only +`write_line(tag, line)`, which prefixes a tag the daemon's own log does not +want. + +Result: `~/.local/state/xy/logs/` becomes uniform — `xy.log` for the daemon, +`.log` per supervised server, all rotated by the same code. + +## CLI + + xy service install [--force] + xy service uninstall + xy service start + xy service stop + xy service status + +- **`install`** — exits 1 if the plist already exists at + `~/Library/LaunchAgents/se.aceofba.xy.plist`, directing the user to + `--force`. Presence of that file is the definition of "installed" throughout; + the crate's `ServiceStatus::NotInstalled` is treated as corroborating, not + authoritative, because it cannot distinguish a missing plist from an + unloadable one. With `--force`, uninstalls first, which re-snapshots `PATH` and + re-resolves `current_exe()`; this is also the upgrade path after installing a + new binary or adding a toolchain. Warns and proceeds on a build-tree program + path. On success it writes the plist *and* bootstraps, leaving a running + daemon. +- **`uninstall`** — stops, then removes the plist. Not-installed is not an + error: prints `not installed`, exits 0, matching how `xy stop` already reports + `not running`. +- **`start`** / **`stop`** — bootstrap and bootout, leaving the plist in place. + Both exit 1 if the agent is not installed. `stop` on an already-stopped agent + exits 0. +- **`status`** — reports label, plist path, state, program path, snapshotted + `PATH`, and log path. Never fails on state, only on an inability to query. + State is `running` / `stopped` / `not installed`, taken directly from + `ServiceStatus`; the reason string from `Stopped` is surfaced verbatim, being + the main window into launch failures. There is deliberately no separate + "loaded" line, because the crate's API cannot distinguish a loaded-but-stopped + agent from an unloaded one. The pid is read from the existing + `paths.pidfile`; the crate does not expose one. The snapshot date is the + plist's mtime, not a value stored inside it. + +Sample output: + + $ xy service status + agent: se.aceofba.xy (user) + plist: ~/Library/LaunchAgents/se.aceofba.xy.plist + state: running (pid 4821) + program: /Users/olsson/.cargo/bin/xy + path: /opt/homebrew/bin:… (snapshotted 2026-07-31) + log: ~/.local/state/xy/logs/xy.log + +### Exit codes + +Reuses the established scheme, minus the codes that cannot apply. `0` success, +`1` operational error (launchctl failed, agent missing, permission denied). +Code `2` (daemon unreachable) is structurally impossible because these commands +never open the socket; `3` (config invalid) does not arise. + +## Testing + +TDD throughout — failing test first, then minimal implementation. + +- Unit tests in `service.rs` for the `AgentSpec` → `ServiceInstallCtx` mapping: + label parses, args are `["daemon"]`, the `PATH` snapshot is captured, `$HOME` + becomes the working directory. No launchd involved. +- Unit tests for the dev-build path predicate against a table of sample paths. +- Formatting tests in `cli/service.rs` rendering an `AgentStatus` to expected + text, mirroring the existing `cli/format.rs` tests. +- A test for `impl io::Write for RotatingLogWriter` covering byte accounting and + the rotation threshold. The rotation logic is currently exercised only through + `write_line`. +- `tests/service.rs` — a real install → status → stop → start → uninstall cycle, + marked `#[ignore]` and using the label `se.aceofba.xy-test` so that a stray + `cargo nextest run` can never install a live agent. Run manually with + `--ignored`. + +## Documentation + +`README.md` gains the five `xy service` verbs, a note that the agent snapshots +`PATH` at install time and that `--force` re-snapshots, and the +`launchctl print` recourse for pre-logger failures.