diff --git a/AGENTS.md b/AGENTS.md index f12e02d..f291dba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ -# devctl +devctl -An agent-friendly coordinator for many devservers and their unique configurations. A macOS menu bar app and CLI over a launchd-supervised daemon, built so coding agents never lose track of running servers across compaction and session boundaries. All Swift, no Xcode. Design spec and phase plan: docs/design.md (the map for everything not yet built). CLI JSON contract: docs/cli-contract.md. Open work: BACKLOG.md, a maintainer-local file that is gitignored (it carries release-ops and known-issue detail that does not belong in a public repo), so a fresh clone will not have it. Commit and changeset hygiene: CONTRIBUTING.md. +An agent-friendly coordinator for many devservers and their unique configurations. A macOS menu bar app and CLI over a launchd-supervised daemon, built so coding agents never lose track of running servers across compaction and session boundaries. All Swift, no Xcode. Design spec and phase plan: docs/design.md (the map for everything not yet built). CLI JSON contract: docs/cli-contract.md. Release, signing, notarization, DMG, and the Homebrew cask bump: docs/releasing.md. macOS process, launchd, and SMAppService lifecycle notes: docs/macos-lifecycle.md. Open work: BACKLOG.md, a maintainer-local file that is gitignored (it carries release-ops and known-issue detail that does not belong in a public repo), so a fresh clone will not have it. Commit and changeset hygiene: CONTRIBUTING.md. Identity and stack - Swift 6.3 toolchain, swift-tools-version 6.2, Swift 6 language mode with strict concurrency. macOS 14+, single SPM package. @@ -9,35 +9,58 @@ Identity and stack - Three products, one daemon: devctld owns all server processes; devctl (CLI) and devctl.app (SwiftUI MenuBarExtra) are thin clients over a unix socket (default ~/Library/Application Support/devctl/daemon.sock; DEVCTL_SOCKET overrides; /tmp fallback near the sun_path limit), NDJSON protocol. devctl daemon install/uninstall/start/stop/restart manage the LaunchAgent (dev.quantizor.devctl); tests and the smoke gate run devctld --foreground. Codebase map -- Sources/DevCtlKit: shared core, the unit-test target of record. Models.swift (specs, phases, ServerStatus, whose displayPort is the one home for which of the three port fields a human is shown; ServerSpec.validationErrors is the per-spec check the `register` seam runs so a directly-registered spec is screened like a committed one), Wire.swift (JSONCoding, typed request/response/event frames, NDJSON framing, stable error codes), Client/DaemonClient.swift (blocking-POSIX socket actor used unchanged by CLI and app; a SO_RCVTIMEO response deadline, raised for a command carrying its own timeout, so a wedged daemon fails a request instead of hanging the client forever), Paths/Paths.swift (path constants, canonical project path, atomic write + load which distinguishes a missing file from an unreadable one from a corrupt one, with loadDefensively collapsing all three to nil for caches only, and per-call unique temp names so two writers in one process cannot rename each other's temp away, SHA-256 over CryptoKit with a chunked file-digest entry point so hashing a file costs a chunk of memory rather than the file; agent.path holds login-shell PATH for the daemon), Setup/ (SetupPlanner: first-run / upgrade decisions, harness offers, stage-and-rename binary install, and CLIOwner: whether devctl or Homebrew owns the CLI, decided by realpath-matching the running bundle against the Caskroom backlink rather than any `/Caskroom/` substring, which drives skipping the binary install and the PATH warning under brew; AppInstancePolicy decides which of two copies of one bundle quits at launch, scoped to the bundle path so the DMG-to-Applications handoff, the one case where two copies are correct, is left alone), Agent/ (AgentContext: the pure session-context renderer the hook injects, bad-state servers first with a devctl why recommendation and devctl's own stderr count, never raw child output; DiscoveryStanza), Net/LoopbackProbe.swift (dual-stack loopback listen probe shared by the daemon port pre-check and CLI doctor), Net/PortClaim.swift + PortMaterializer.swift (effectivePort claim: portSpan and named ports; env injection and URL rewrite, including resolving a root-relative head or healthcheck url against the server's own base), Config/ (ProjectConfig loader and validator; ConfigProjection projects merged specs back down to devservers.json, dropping everything the machine derived, for `config init`; EffectiveHost is the one home for the host a spawn will use, read by both prepareSpawn and config check; LocalOverlay; LockResource reads locks declarations and resolves a resource's state path; WatchPolicy is the pure settle/quiet/burst decision behind auto-restart and WatchPaths resolves the entries config check warns about), Resource/ResourceIdentity.swift (bounded fingerprint of a lock resource's state so `lock` can report a change made under a live holder), Launchd/ (LaunchdAdmin: dual install path; SMAppService via app deep link when /Applications/devctl.app exists, else legacy home LaunchAgent + Application Support bin/devctld; --legacy forces the home path; DaemonRecoveryPolicy decides whether an unreachable daemon is auto-restarted; AgentRebindPolicy + agent.rebind settle the ad-hoc CDHash window on DMG replace), DeepLink/ (parse/serialize + DeepLinkRunner + notification action map), Update/ (UpdateCheck: GitHub releases/latest against DevCtlVersion, one on-disk cache with an ETag shared by the app poll and `devctl doctor`, every failure silent, and never fed into AgentContext.render; DevCtlDistribution: the one home for the tap token, releases URL, and brew upgrade/uninstall commands), Log/DevCtlLog.swift (OSLog facade with a recording backend for tests). -- Sources/DevCtlDaemonCore: daemon logic as a library. Supervisor/ (ServerSupervisor actor per server: spawn, spool capture, health-gated phase machine, ensure/wait, group + descendant teardown through signalRun, the one signalling path, over ProcessTree.liveDescendants, the one home for the snapshot + parent-chain + session union, revalidated against ProcessIdentity start time so a recycled pid is never signaled; the ProcessLauncher seam; ProcessTree QA1123 sysctl sweep, plus narrowed/isAlive, the one home for turning a pid read off disk or the wire into one the kernel calls take, since a trapping conversion there is a crash loop under KeepAlive), Health/HealthProber.swift (EffectiveHealthcheck resolution, the HealthProber seam with ephemeral URLSession HTTP probes + BSD TCP, and PortGuard's lsof diagnostics, which live in that same file), Registry/ (owner of registry.json and state.json), Control/ (Router method dispatch + port pre-check + persisted resource locks with daemon-owned pause/resume and dead-holder auto-release + the boot-restore gate that answers daemon.info and refuses everything else with daemon-starting + NWListener ControlServer whose startAccepting awaits the listener's ready state and throws rather than suspending forever). prepareSpawn is the one funnel every start-shaped path takes and the one home for the trust gate: its `userInitiated` flag records trust for an explicit command acting on a committed server and refuses an autonomous restore/sweep of an unapproved project. register validates the spec and writeConfig refuses a project the daemon does not track; every project-scoped arm canonicalizes the path at the decode seam. -- Sources/devctld: thin main; identical behavior under launchd and --foreground (tests and the smoke gate use foreground). Applies agent.path into process env before spawn, accepts on the socket before boot restore and marks the router restoring across it so a client can tell a busy daemon from a dead one, and runs the watch sweep on its own timer once restore has finished, so a boot spawn is never read as a config change. -- Tests: DevCtlKitTests is the unit-test target of record and holds the schema goldens; DevCtlDaemonCoreTests drives a real Router over temp paths; DevCtlCLITests covers CLI behavior with a contract and no other way to exercise it (argument parsing, the lock notices and identity verdict), importing the executable target with @testable. TestSupport.swift is the one home for the fixture-server lookup and reserves ports 45000 to 45500 for the unit suites; touching it reaps fixtures orphaned by an interrupted run, but only those whose parent is gone and whose port is in that block, so a concurrent test run and smoke.sh (which orphans a fixture on purpose, outside the block) are both left alone. -- Sources/devctl: CLI (swift-argument-parser). Two files only: HookSupport.swift (HookContext, the thin socket fetch over DevCtlKit's AgentContext renderer, + HarnessAdapter registry, each adapter with install/uninstall/hookState over a settings file devctl does not own and never edits without being asked; adding a harness: CONTRIBUTING.md) and CLI.swift, which holds every command as a struct, including Switch (branch switching + lifecycle playbooks), Lock (run-under-resource-lock), Doctor (health report; owns the cross-project port-collision and squatter findings, plus report-only harness-hook and update findings), Uninstall (the one uninstall verb: agent, hooks, and CLI, with --agent-only for the cask and --purge for data; `daemon uninstall` is a deprecated alias warning on stderr), HookInstall/HookUninstall, and Link / x-url (deep links). CLI.swift is past the size where splitting is worth asking about. -- Sources/DevCtlApp: menu bar app (DaemonModel 2s-polling model + crash notifications with Open/Why actions; AgentService wraps SMAppService.agent for Login Items registration, escalating to unregister + register when the agent reads `enabled` but the socket stays silent (a bootout or replaced bundle leaves the registration intact with nothing loaded) and reporting `requiresApproval` as its own failure instead of retrying; unregister records the stop intent so the recovery poll does not undo it; an unreachable daemon self-recovers via AgentService under DaemonRecoveryPolicy, falling back to legacy LaunchdAdmin only when the bundle carries no agent plist; DaemonDownRow offers Start, or Open Login Items while approval is pending, since a deliberate `daemon stop` stands down auto recovery; PresenceLabel is AppKit-drawn colored tally dots only with renderingMode(.original); popover autogrows to a cap; nested head rows with UserDefaults-persisted pins; DashboardView logs/timeline/config tabs; SpotlightIndexer named Core Spotlight index; AppDeepLink handles `devctl://` including `daemon/ensure` and `daemon/unregister`; SetupPanel first-run / upgrade installer from bundle Resources, owner-aware so a Homebrew install drops the CLI-install line and the PATH warning; SetupPerformer owns everything about other copies of the app, including canonicalPath as the one home for bundle-path comparison, the AppInstancePolicy stand-down the delegate runs before anything else at launch, and a quitOtherInstances whose false answer refuses the replace rather than overwriting a bundle a live process is running; SettingsView is the Settings window (gear at the popover bottom): per-harness hook install/remove through the CLI, Start-at-login, and the update-check toggle, plus Uninstall (self-trash for a DMG install, the brew command for a cask); UpdateFooterRow is the popover's update banner, wired to DaemonModel's slow update poll, with a brew Upgrade button via TerminalRunner; TerminalRunner runs a brew command in a Terminal login shell because a GUI-launched app has no PATH). Pure DaemonClient consumer. -- Sources/fixture-server: test double dev server (heartbeat printer; TCP-listen, timed-exit, grandchild, ignore-sigterm, binary, flood modes; see its header comment). +- Sources/DevCtlKit: shared core, the unit-test target of record. + - Model/Models.swift: specs, phases, ServerStatus. displayPort is the one home for which of the three port fields a human sees. ServerSpec.validationErrors screens a directly-registered spec like a committed one. + - Protocol/Wire.swift: JSONCoding, typed request/response/event frames, NDJSON framing, stable error codes. + - Client/DaemonClient.swift: blocking-POSIX socket actor used unchanged by CLI and app, with an SO_RCVTIMEO response deadline so a wedged daemon fails a request rather than hanging the client. + - Paths/Paths.swift: path constants, canonical project path, AtomicFile (atomic write + the defensive load below, per-call unique temp names), chunked-digest SHA-256 over CryptoKit. agent.path holds the login-shell PATH for the daemon. + - Setup/: SetupPlanner (first-run/upgrade decisions, harness offers, stage-and-rename install), CLIOwner (devctl vs Homebrew ownership, decided by realpath-matching the running bundle against the Caskroom backlink), AppInstancePolicy (which of two copies of one bundle quits at launch, scoped to the bundle path so the DMG-to-Applications handoff is left alone). + - Agent/: AgentContext (the pure session-context renderer the hook injects, bad-state servers first, never raw child output), DiscoveryStanza. + - Net/: LoopbackProbe (dual-stack listen probe shared by daemon pre-check and doctor), PortClaim + PortMaterializer (effectivePort claim, env injection, URL rewrite, root-relative head/healthcheck resolution), PortCollision. + - Config/: ProjectConfig (loader/validator), ConfigProjection (projects merged specs back to devservers.json for config init), EffectiveHost (the one home for the host a spawn uses, read by prepareSpawn and config check), LocalOverlay, LockResource (resolves a locked resource's state path), WatchPolicy (settle/quiet/burst decision behind auto-restart) + WatchPaths. + - Resource/ResourceIdentity.swift: bounded fingerprint of a lock resource's state so lock can report a change made under a live holder. + - Launchd/: LaunchdAdmin (dual install path, SMAppService via app deep link when /Applications/devctl.app exists else legacy home LaunchAgent; --legacy forces the home path), DaemonRecoveryPolicy, AgentRebindPolicy (settles the ad-hoc CDHash window on DMG replace; see docs/macos-lifecycle.md). + - DeepLink/: parse/serialize + DeepLinkRunner + notification action map. + - Update/: UpdateCheck (GitHub releases/latest, one ETag-cached poll, every failure silent, never fed into AgentContext), DevCtlDistribution (the one home for the tap token, releases URL, and brew commands). + - Logs/: LogFormat + LogQuery (the since-query binary search that the monotonic-timestamp rule protects). + - Log/DevCtlLog.swift: OSLog facade with a recording backend for tests. +- Sources/DevCtlDaemonCore: daemon logic as a library. + - Supervisor/ServerSupervisor.swift: per-server actor (spawn, spool capture, health-gated phase machine, ensure/wait, teardown). signalRun is the one signalling path; it runs over ProcessTree.liveDescendants, revalidated against ProcessIdentity start time so a recycled pid is never signaled. + - Supervisor/ProcessTree.swift: QA1123 sysctl sweep; liveDescendants is the one home for the snapshot + parent-chain + session union; narrowed/isAlive is the one home for turning a pid read off disk or the wire into a kernel call (a trapping conversion here is a crash loop under KeepAlive). + - Supervisor/ProcessLauncher.swift: the launcher seam (swift-subprocess behind it). + - Health/HealthProber.swift: EffectiveHealthcheck resolution, the HealthProber seam (URLSession HTTP + BSD TCP), and PortGuard's lsof diagnostics. Health/PowerState.swift gates on power state. + - Registry/Registry.swift: owner of registry.json and state.json. + - Control/ControlServer.swift: the Router actor (method dispatch, port pre-check, persisted resource locks with daemon-owned pause/resume and dead-holder auto-release, the boot-restore gate, the NWListener ControlServer). prepareSpawn is the one funnel every start-shaped path takes and the one home for the trust gate: userInitiated records trust for an explicit command and refuses an autonomous restore/sweep of an unapproved project; register validates the spec; writeConfig refuses an untracked project; every project-scoped arm canonicalizes the path at the decode seam. + - Control/Why.swift, Events/EventStore.swift, LogStore/ (LogStore + SpoolTailer): why-report, event log, spool capture and tailer. +- Sources/devctld: thin main; identical behavior under launchd and --foreground. Applies agent.path into process env before spawn, accepts on the socket before boot restore and marks the router restoring across it (so a client tells a busy daemon from a dead one), and runs the watch sweep on its own timer only after restore finishes, so a boot spawn is never read as a config change. +- Tests: DevCtlKitTests is the unit-test target of record and holds the schema goldens; DevCtlDaemonCoreTests drives a real Router over temp paths; DevCtlCLITests covers CLI-only behavior with @testable import of the executable (argument parsing, lock notices, identity verdict). TestSupport.swift is the one home for the fixture-server lookup and reserves ports 45000 to 45500 for the unit suites; its fixture reaping is scoped to dead parents whose port is in that block, so a concurrent run and smoke.sh are both left alone. +- Sources/devctl: CLI (swift-argument-parser), two files. + - HookSupport.swift: HookContext (the thin socket fetch over AgentContext) + the HarnessAdapter registry (each adapter with install/uninstall/hookState over a settings file devctl does not own and never edits unasked; adding a harness: CONTRIBUTING.md). + - CLI.swift: every command as a struct, including Switch (branch switching + lifecycle playbooks), Lock (run-under-resource-lock), Doctor (health report; owns cross-project port-collision and squatter findings plus report-only harness-hook and update findings), Uninstall (the one uninstall verb: agent, hooks, CLI; --agent-only for the cask, --purge for data; daemon uninstall is a deprecated alias), HookInstall/HookUninstall, Link / x-url. Past the size where splitting is worth asking about. +- Sources/DevCtlApp: menu bar app, a pure DaemonClient consumer. DaemonModel (2s poll + crash notifications with Open/Why); AgentService wraps SMAppService.agent for Login Items and escalates to unregister + register when a registration reads enabled while the socket stays silent; SetupPerformer owns everything about other copies of the app, with canonicalPath the one home for bundle-path comparison; PresenceLabel (AppKit tally dots), DashboardView (logs/timeline/config), SpotlightIndexer, AppDeepLink (devctl:// including daemon/ensure and daemon/unregister), SetupPanel (owner-aware first-run installer), SettingsView (per-harness hooks, start-at-login, update toggle, Uninstall), UpdateFooterRow + TerminalRunner (a brew command in a Terminal login shell, because a GUI-launched app has no PATH). DMG/Applications bundle-id sharing and the rebind path: docs/macos-lifecycle.md. +- Sources/fixture-server: test-double dev server (heartbeat printer; listen-tcp, exit-after, spawn/orphan-grandchild, ignore-sigterm, emit-binary, flood modes; see its header comment). Commands -- make build: swift build -c release (all products) -- make test: swift test; budget under 30s, the run prints the live timing -- scripts/smoke.sh: the end-to-end gate. Debug-builds, boots a real devctld on a temp socket, then asserts register/start/status, spool capture, health/ensure/wait, port conflicts, marks/events/why, config recovery (`config init` round-tripping through `config check`, its refusal to clobber, `register --write`), relative-head resolution and its `config check` rejection, resource locks (pause + refused ensure + resume, option parsing, a contended acquire naming the holder, and the identity check firing under `--no-pause` while staying silent otherwise), restart (a new process, and a refusal under a live lock that leaves the server up), watch (a changed file restarts the server and the restarted process reads the new value, while a server declaring no watch is untouched), a command racing boot restore (it waits the window out rather than reporting the daemon gone, and the assertion says whether it caught the window), whole-group death on stop, child survival across a daemon kill, `link`/`x-url` deep-link dispatch, and that the assembled app declares `CFBundleURLSchemes=devctl`, ships AppIcon + CLI + daemon in Resources, and ships Helpers/devctld plus the in-bundle LaunchAgents BundleProgram plist. Run it after touching the supervisor, wire protocol, CLI, or deep links. -- scripts/smoke-deeplink.sh: Launch Services E2E for `devctl://` (warm + cold `open`), and the only gate that covers the launch stand-down, since a second copy of one bundle can only be produced by really launching one (`open -n`, then assert one copy survives). Requires a GUI session; run before merging URL-scheme or app-launch work. Kills only the copy under its own build path, so an installed /Applications app is left running. OSLog scrape is strict on a tty (`DEVCTL_OSLOG_STRICT=1` forces it). -- scripts/smoke-launchd.sh: the REAL LaunchAgent lifecycle via `daemon install --legacy` (install, restart bounce + re-ensure, install-upgrade bounce + re-ensure, deliberate-stop intent, auto-bootstrap resurrection, the deprecated `daemon uninstall` alias warning on stderr with clean stdout, and `uninstall --agent-only`). Mutates the user launchd domain; refuses to run if a home plist or bootstrapped job already exists; leaves nothing behind. SMAppService is exercised by installing from the app on a GUI session. -- scripts/smoke-cask.sh: the Homebrew cask gate. Default tier is non-destructive: a throwaway local tap via `brew tap-new --no-git`, the cask rewritten to a local file:// DMG with its real checksum, then style/audit/info/dry-run/fetch, then untap. `DEVCTL_CASK_DESTRUCTIVE=1 scripts/smoke-cask.sh --install` runs a real `brew install --cask` into a temp `--appdir` and asserts the Caskroom backlink resolves to the app and the CLI symlink lands in brew's bin; its uninstall runs the real `devctl uninstall --agent-only`, so it quits and unregisters a running app's agent (recoverable). The strict `--new-cask` audit needs a notarized image and runs in CI, not here. -- make app: assembles fat devctl.app via scripts/make-app-bundle.sh (CLI in Contents/Resources; signed Helpers/devctld + Contents/Library/LaunchAgents BundleProgram plist for SMAppService; AppIcon.icns; declares the `devctl://` URL scheme; writes both CFBundleShortVersionString and CFBundleVersion). Signing identity comes from scripts/signing-identity.sh: a Developer ID certificate when the keychain has one, else ad-hoc with a warning. `SIGN_IDENTITY=...` overrides, including `SIGN_IDENTITY=-` to force ad-hoc. `DEVCTL_REQUIRE_SIGNING=1` (set by the release build) fails rather than falling back to ad-hoc, enforced in make-app-bundle.sh because the Makefile's `$(shell ...)` swallows signing-identity.sh's exit code. make dmg: UDZO image via scripts/make-dmg.sh, holding the app alone (no /Applications symlink: the app installs itself after an in-app confirm) over a background rendered by scripts/make-dmg-background.swift that says to double-click. Finder window layout needs a GUI session and a volume name that is not already mounted; on a headless runner that pass is skipped and the image still ships. Two kinds of image: a TEST DMG (signed with whatever identity, ad-hoc when none, not notarized) is what a contributor gets and is produced whenever notarytool credentials are absent; a REAL DMG (notarized, stapled, `com.apple.quarantine`-stamped) is built where the credentials live (the `devctl-notary` keychain profile locally, App Store Connect API key env in CI). make dmg notarizes automatically when credentials are reachable; `DEVCTL_NOTARIZE=1` (implied by `DEVCTL_REQUIRE_SIGNING=1`) demands the real path and fails if they are missing; `SKIP_NOTARIZE=1` forces the fast loop; `DEVCTL_DMG_QUARANTINE=0` drops the stamp. scripts/notarize.sh holds the notarytool + staple step and still runs standalone. On Actions, `Release DMG` publishes only on a `release` event or a dispatch from main (the ref `Release` uses); dispatched from any other branch it builds, signs, notarizes and verifies, then returns the image as a run artifact instead of uploading, so the release path is testable without clobbering a published asset: `gh workflow run "Release DMG" --ref [-f tag=vX.Y.Z]`. After uploading, it dispatches `Bump Homebrew cask`, which injects the release's version + sha256 into `packaging/homebrew/devctl.rb` (the one home for the cask's structure) and pushes to the `quantizor/homebrew-tap` repo. make install: CLI + daemon to ~/.local/bin, app to /Applications, daemon install. -- Unified logging: subsystem `dev.quantizor.devctl` (categories daemon, supervisor, health, app, deeplink). Stream with `log stream --predicate 'subsystem == "dev.quantizor.devctl"' --level debug`. Child stdout/stderr stay in spool files; OSLog is for devctl's own behavior. -- Deep links: `devctl://open|ensure|stop|why//[/]` (query form also accepted). `devctl://daemon/ensure` and `devctl://daemon/unregister` ask the app to own SMAppService registration. `devctl link` prints; the app handles via Launch Services; `devctl x-url` runs the same runner for smoke. +- make build: swift build -c release (all products). +- make test: swift test; budget under 30s, the run prints the live timing. +- make app / make dmg / make install, plus signing, notarization, the Release DMG workflow, and the Homebrew cask bump: docs/releasing.md. +- scripts/smoke.sh: the end-to-end gate. Boots a real devctld on a temp socket and asserts register/start/status, spool capture, health/ensure/wait, port conflicts, marks/events/why, config recovery, relative-head resolution, resource locks, restart, watch, a boot-restore race, group teardown, child survival across a daemon kill, deep-link dispatch, and the assembled-app layout. Run after touching the supervisor, wire protocol, CLI, or deep links. +- scripts/smoke-deeplink.sh: Launch Services E2E for devctl:// and the only gate covering the launch stand-down. Needs a GUI session; run before merging URL-scheme or app-launch work. +- scripts/smoke-launchd.sh: the real LaunchAgent lifecycle via daemon install --legacy. Mutates the user launchd domain, refuses to run if a home plist or bootstrapped job exists, and leaves nothing behind. +- scripts/smoke-cask.sh: the Homebrew cask gate, non-destructive by default; DEVCTL_CASK_DESTRUCTIVE=1 --install runs a real install. Details: docs/releasing.md. +- Unified logging: subsystem dev.quantizor.devctl (categories daemon, supervisor, health, app, deeplink). Stream with `log stream --predicate 'subsystem == "dev.quantizor.devctl"' --level debug`. Child stdout/stderr stay in spool files; OSLog is for devctl's own behavior. +- Deep links: devctl://open|ensure|stop|why//[/] (query form also accepted). devctl://daemon/ensure and devctl://daemon/unregister ask the app to own SMAppService registration. devctl link prints; the app handles via Launch Services; devctl x-url runs the same runner for smoke. Hard rules - Output capture is spool-file fds, never pipes: children must survive daemon death without SIGPIPE. Do not introduce pipe-based capture anywhere. -- Teardown signals the process group AND every live descendant, found three ways because no one of them is sufficient: a sysctl parent-chain sweep snapshotted while the root still parents them (children that setpgid/setsid escape the group; orphans reparent to launchd and fall out of the parent-pid chain), a refresh of that snapshot every 200ms while the server is still starting (a worker forked a beat after spawn is otherwise in no snapshot, and with no healthcheck the first probe that would refresh it is a whole stabilization window away), and a live session sweep keyed on the run's session id, which is the only handle that survives the root exiting since createSession makes the root a session leader and an escaped child keeps the session even after reparenting. The session sweep refuses any session that is not led by the root pid and refuses the daemon's own session; without those guards it would signal the daemon and every server it supervises. The deliberate-stop and crash paths run one revalidated pass, signalRun over ProcessTree.liveDescendants, which unions all three sources and signals the root's process group only while the pid still names the process whose identity was captured while it was alive: a recycled pid is never hit, and the crash path, whose root is already reaped, passes rootIdentity nil so the group is never signaled at all. recordOutcome captures the run's pid, session, and snapshot at entry, since a concurrent start can replace them mid-teardown. Keep the three-source union and the revalidation when touching stop() or the crash exit path. +- Teardown signals the process group AND every live descendant, found three ways because no one of them suffices: a sysctl parent-chain snapshot taken while the root still parents them (setpgid/setsid children escape the group, and orphans reparent to launchd and leave the parent-pid chain), a refresh of that snapshot every 200ms while the server is still starting (a worker forked a beat after spawn is in no earlier snapshot, and with no healthcheck the first refreshing probe is a whole stabilization window away), and a session sweep keyed on the run's session id (the only handle that survives the root exiting, since createSession makes the root a session leader and an escaped child keeps the session after reparenting). The session sweep refuses any session not led by the root pid and refuses the daemon's own session, or it would signal the daemon and every server it supervises. The deliberate-stop and crash paths run one revalidated pass: signalRun over ProcessTree.liveDescendants unions all three sources and signals the root's group only while the pid still names the identity captured while it was alive (a recycled pid is never hit), and the crash path, whose root is already reaped, passes rootIdentity nil so the group is never signaled. Keep the three-source union and the revalidation when touching stop() or the crash exit path. - All JSON goes through JSONCoding: sorted keys, ISO-8601 UTC with milliseconds, no interior newlines. Never construct a raw JSONEncoder or JSONDecoder; the golden tests and NDJSON line framing depend on this determinism. - Wire methods are typed end to end: the daemon sniffs the {id, method} head, then re-decodes the full typed frame. A new method extends WireMethod plus Codable params/result types in Wire.swift; no untyped dictionaries on the wire. - Every CLI command supports --json with a stable schema generated from the shared Codable types; failures emit {ok:false, error:{code,message,hint}} on stdout, hint being the literal remediation command. Error codes grow append-only. Golden tests in Tests/DevCtlKitTests assert exact schema strings; a changed field is an API change: update docs/cli-contract.md in the same commit, then the golden. - Structured log files keep per-file monotonic timestamps (clamp on append); the since-query binary search depends on it. -- The daemon never acts on a project's committed config before trust is recorded, enforced in prepareSpawn: an explicit command records trust, an autonomous restore or watch sweep refuses an unapproved project. The SessionStart hook never emits raw log lines or command strings into agent context (child output is attacker-influenceable). A spec reaching the daemon through `register` is validated like a committed one, and writeConfig only writes for a project the daemon already tracks. -- A user-supplied regex (`logs --grep`) is screened before it runs: a nested unbounded quantifier is refused, because Swift's backtracking engine turns `(a+)+` into minutes of CPU on a single line and the match runs on the log actor. -- State files load through AtomicFile.load, which separates three outcomes: a missing file starts empty, a parse failure quarantines to .corrupt- and continues (never fatal, since a startup parse crash under launchd KeepAlive loops forever), and a file that exists but cannot be READ (EMFILE, an I/O error) throws so the daemon refuses to start rather than erasing it on the next write. The daemon's main probes registry, state, and locks this way before serving. loadDefensively collapses all three to nil and is only for a rebuildable cache or a secondary hint. Corollary: new fields on persisted types (registry, state) stay optional so existing files keep parsing. +- The daemon never acts on a project's committed config before trust is recorded, enforced in prepareSpawn: an explicit command records trust, an autonomous restore or watch sweep refuses an unapproved project. A spec reaching the daemon through register is validated like a committed one, and writeConfig only writes for a project the daemon already tracks. The SessionStart hook never emits raw log lines or command strings into agent context, since child output is attacker-influenceable. +- A user-supplied regex (logs --grep) is screened before it runs: a nested unbounded quantifier is refused, because Swift's backtracking engine turns (a+)+ into minutes of CPU on a single line and the match runs on the log actor. +- State files load through AtomicFile.load, which separates three outcomes: a missing file starts empty, a parse failure quarantines to .corrupt- and continues (never fatal, since a startup parse crash under launchd KeepAlive loops forever), and a file that exists but cannot be READ (EMFILE, an I/O error) throws so the daemon refuses to start rather than erasing it on the next write. loadDefensively collapses all three to nil and is only for a rebuildable cache. Corollary: new fields on persisted types stay optional so existing files keep parsing. - Registry/state writes are temp + fsync + rename. - Binary upgrades stage and rename(2); never overwrite a running signed Mach-O. @@ -57,14 +80,5 @@ Engineering rules - No database, no migrations; on-disk compatibility is the defensive-load rule above. - Before calling work done: hunt the input that breaks it, run make test and scripts/smoke.sh, and separate what you observed (file:line, command output) from what you inferred. -Stack notes (verified 2026; re-verify before building on them) -- swift-subprocess createSession = true gives the child a fresh session, so pgid == pid, which group-directed teardown relies on. -- launchd, for the launchd phase: jobs get a minimal PATH without Homebrew (the design captures the user's shell PATH at install). ThrottleInterval defaults to 10s between respawns. ExitTimeOut (SIGTERM to SIGKILL) defaults to 20s per launchd.plist(5); a sequential drain of many servers at 7s grace each can exceed it, so set it deliberately. 60 is the ceiling: launchd clamps anything larger and logs "ExitTimeOut is larger than the maximum allowed". -- SMAppService.Status is enabled = 1 and requiresApproval = 2, easy to misread from a raw value in a log. `enabled` means a registration exists, not that the job is loaded: after `launchctl bootout` or a replaced bundle the status still reads enabled while nothing runs, and `register()` on an already-registered service is a no-op, so the only way back is unregister + register. Expect launchd to log "Unknown key for plist importer (key: SHA256 type: data)" on every SMAppService submit; that key is Apple's, not ours. -- The BTM launch constraint pins the Team ID when one is present, and a Team ID survives a rebuild, so a Developer ID signed upgrade spawns immediately. Ad-hoc has no Team ID, so the constraint pins the CDHash, which every rebuild changes: that is the whole reason the rebind path below exists. `make app` and `make dmg` pick a Developer ID identity automatically, so this only bites a build made where the keychain has none. -- Replacing `Contents/Helpers/devctld` under an ad-hoc signature while the BTM item still exists binds the item to the old CDHash, and the next spawn dies with `SIGKILL (Code Signature Invalid)` / Launch Constraint Violation, recorded as a `devctld-*.ips` in `~/Library/Logs/DiagnosticReports`. Unregister does NOT clear this: `sfltool dumpbtm` (the store is `BackgroundItems-v16.btm` on macOS 26; `sfltool` needs neither sudo nor Full Disk Access) and the `registerLaunchItem: found existing item` log line both show the same item UUID across unregister+register, so re-registering only re-arms the same doomed constraint and burns another 10s ThrottleInterval per attempt. The constraint clears only when BTM invalidates the item, which xpcproxy triggers on the next spawn attempt rather than on a timed background sweep, and the item UUID changes when it does. Waiting longer for a spawn cannot help, because the job is killed on exec rather than running slowly. Diagnose an upgrade that comes up slowly by reading the BTM item UUID and the crash report, never by timing alone. -- DMG upgrades write `agent.rebind` and refuse to replace if the agent is still loaded. `AgentRebindPolicy` bounds how long the app waits before escalating; those waits only cover a job that is genuinely still spawning, not a constraint kill. -- DMG and `/Applications/devctl.app` share a bundle id: never register the SMAppService agent from the volume copy, and never treat `openApplication` of Applications as success unless a different pid is actually running from that path (`createsNewApplicationInstance` plus a peer wait). Deep links for daemon control use `open -a /Applications/devctl.app` so the volume copy cannot steal them. User agents live in domain gui/, never system, and launchctl list/print answer differently depending on the calling context. - Aesthetic Quiet instrument panel: monochrome SF Symbols glyph, tally-light status dots (subtle breathing animation on starting), dense but calm typography, no chrome. State changes register visibly but never shout. Visual changes are judged by rendering and viewing, never asserted in unit tests. diff --git a/docs/macos-lifecycle.md b/docs/macos-lifecycle.md new file mode 100644 index 0000000..aa32a01 --- /dev/null +++ b/docs/macos-lifecycle.md @@ -0,0 +1,36 @@ +# macOS lifecycle notes + +Hard-won platform behavior behind devctl's process teardown, launchd supervision, SMAppService registration, and the ad-hoc-signature rebind path. Verified 2026; re-verify before building on any of it, since these are Apple internals that move between OS releases. + +## Process sessions + +- swift-subprocess `createSession = true` gives the child a fresh session, so `pgid == pid`. Group-directed teardown relies on this. + +## launchd + +For the launchd phase: + +- Jobs get a minimal PATH without Homebrew. The design captures the user's shell PATH at install time to work around this. +- `ThrottleInterval` defaults to 10s between respawns. +- `ExitTimeOut` (SIGTERM to SIGKILL) defaults to 20s per `launchd.plist(5)`. A sequential drain of many servers at 7s grace each can exceed it, so set it deliberately. 60 is the ceiling: launchd clamps anything larger and logs `ExitTimeOut is larger than the maximum allowed`. +- User agents live in domain `gui/`, never `system`. `launchctl list`/`print` answer differently depending on the calling context. + +## SMAppService + +- `SMAppService.Status` is `enabled = 1` and `requiresApproval = 2`, easy to misread from a raw value in a log. +- `enabled` means a registration exists, not that the job is loaded: after `launchctl bootout` or a replaced bundle the status still reads `enabled` while nothing runs, and `register()` on an already-registered service is a no-op, so the only way back is unregister + register. +- Expect launchd to log `Unknown key for plist importer (key: SHA256 type: data)` on every SMAppService submit. That key is Apple's, not ours. + +## BTM launch constraint and the ad-hoc CDHash rebind saga + +- The BTM (Background Task Management) launch constraint pins the Team ID when one is present, and a Team ID survives a rebuild, so a Developer ID signed upgrade spawns immediately. Ad-hoc has no Team ID, so the constraint pins the CDHash, which every rebuild changes: that is the whole reason the rebind path exists. `make app` and `make dmg` pick a Developer ID identity automatically, so this only bites a build made where the keychain has none. +- Replacing `Contents/Helpers/devctld` under an ad-hoc signature while the BTM item still exists binds the item to the old CDHash, and the next spawn dies with `SIGKILL (Code Signature Invalid)` / Launch Constraint Violation, recorded as a `devctld-*.ips` in `~/Library/Logs/DiagnosticReports`. +- Unregister does NOT clear this. `sfltool dumpbtm` (the store is `BackgroundItems-v16.btm` on macOS 26; `sfltool` needs neither sudo nor Full Disk Access) and the `registerLaunchItem: found existing item` log line both show the same item UUID across unregister+register, so re-registering only re-arms the same doomed constraint and burns another 10s `ThrottleInterval` per attempt. +- The constraint clears only when BTM invalidates the item, which xpcproxy triggers on the next spawn attempt rather than on a timed background sweep, and the item UUID changes when it does. Waiting longer for a spawn cannot help, because the job is killed on exec rather than running slowly. +- Diagnose an upgrade that comes up slowly by reading the BTM item UUID and the crash report, never by timing alone. +- DMG upgrades write `agent.rebind` and refuse to replace if the agent is still loaded. `AgentRebindPolicy` bounds how long the app waits before escalating; those waits only cover a job that is genuinely still spawning, not a constraint kill. + +## DMG and /Applications share a bundle id + +- DMG and `/Applications/devctl.app` share a bundle id: never register the SMAppService agent from the volume copy, and never treat `openApplication` of Applications as success unless a different pid is actually running from that path (`createsNewApplicationInstance` plus a peer wait). +- Deep links for daemon control use `open -a /Applications/devctl.app` so the volume copy cannot steal them. diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..c6877fd --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,64 @@ +# Releasing devctl + +How the app bundle, DMG, signing, notarization, the GitHub release workflow, and the Homebrew cask bump fit together. Agents never bump versions or publish; version bumps and tagging live in CONTRIBUTING.md. + +## make app + +`make app` assembles the fat `devctl.app` via `scripts/make-app-bundle.sh`: + +- CLI in `Contents/Resources`. +- Signed `Helpers/devctld` plus a `Contents/Library/LaunchAgents` BundleProgram plist for SMAppService. +- `AppIcon.icns`. +- Declares the `devctl://` URL scheme. +- Writes both `CFBundleShortVersionString` and `CFBundleVersion`. + +## Signing + +Signing identity comes from `scripts/signing-identity.sh`: + +- A Developer ID certificate when the keychain has one, else ad-hoc with a warning. +- `SIGN_IDENTITY=...` overrides, including `SIGN_IDENTITY=-` to force ad-hoc. +- `DEVCTL_REQUIRE_SIGNING=1` (set by the release build) fails rather than falling back to ad-hoc. This is enforced in `make-app-bundle.sh` because the Makefile's `$(shell ...)` swallows `signing-identity.sh`'s exit code. + +Ad-hoc signing has consequences for launchd spawn under the BTM constraint; see docs/macos-lifecycle.md. + +## make dmg + +`make dmg` builds a UDZO image via `scripts/make-dmg.sh`: + +- Holds the app alone (no `/Applications` symlink: the app installs itself after an in-app confirm). +- Background rendered by `scripts/make-dmg-background.swift` that says to double-click. +- Finder window layout needs a GUI session and a volume name that is not already mounted. On a headless runner that pass is skipped and the image still ships. + +Two kinds of image: + +- TEST DMG: signed with whatever identity (ad-hoc when none), not notarized. This is what a contributor gets, produced whenever notarytool credentials are absent. +- REAL DMG: notarized, stapled, `com.apple.quarantine`-stamped. Built where the credentials live (the `devctl-notary` keychain profile locally, App Store Connect API key env in CI). + +## Notarization + +- `make dmg` notarizes automatically when credentials are reachable. +- `DEVCTL_NOTARIZE=1` (implied by `DEVCTL_REQUIRE_SIGNING=1`) demands the real path and fails if credentials are missing. +- `SKIP_NOTARIZE=1` forces the fast loop. +- `DEVCTL_DMG_QUARANTINE=0` drops the quarantine stamp. +- `scripts/notarize.sh` holds the notarytool + staple step and still runs standalone. + +## Release DMG workflow (GitHub Actions) + +`Release DMG` publishes only on a `release` event or a dispatch from `main` (the ref `Release` uses). Dispatched from any other branch it builds, signs, notarizes, and verifies, then returns the image as a run artifact instead of uploading, so the release path is testable without clobbering a published asset: + +```sh +gh workflow run "Release DMG" --ref [-f tag=vX.Y.Z] +``` + +After uploading, it dispatches `Bump Homebrew cask`, which injects the release's version + sha256 into `packaging/homebrew/devctl.rb` (the one home for the cask's structure) and pushes to the `quantizor/homebrew-tap` repo. + +## Homebrew cask gate (scripts/smoke-cask.sh) + +- Default tier is non-destructive: a throwaway local tap via `brew tap-new --no-git`, the cask rewritten to a local `file://` DMG with its real checksum, then style/audit/info/dry-run/fetch, then untap. +- `DEVCTL_CASK_DESTRUCTIVE=1 scripts/smoke-cask.sh --install` runs a real `brew install --cask` into a temp `--appdir` and asserts the Caskroom backlink resolves to the app and the CLI symlink lands in brew's bin. Its uninstall runs the real `devctl uninstall --agent-only`, so it quits and unregisters a running app's agent (recoverable). +- The strict `--new-cask` audit needs a notarized image and runs in CI, not here. + +## make install + +`make install`: CLI + daemon to `~/.local/bin`, app to `/Applications`, then `daemon install`.