Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/restart-and-watch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"devctl": minor
---

`devctl restart <name>` is a real command. Agents were writing `devctl stop X && devctl ensure X` by hand, and several assumed the verb already existed. That pair has two problems this fixes: another session's `ensure` can land between the two commands, and a refusal (a held resource, a paused server, a config that no longer parses) arrives only after the server is already down, leaving it down. A restart now refuses before it stops anything, and keeps the server's resume-on-boot intent, which a manual stop clears.

A server can also list the config files it reads at boot but does not reload on its own, and devctl restarts it when one changes. Without that, a long-lived supervised server keeps running the old config, so a correct fix looks like it did nothing and a test harness keeps checking stale behavior. A server whose framework already reloads its own config declares nothing and behaves exactly as before. A config a server writes during its own startup will not bounce it, one save touching several files is a single restart, and a server that rewrites its own watched file has its watch suspended with a log line naming the culprit rather than restarting forever.
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ 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), 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), Paths/Paths.swift (path constants, canonical project path, atomic write + defensive load with per-call unique temp names so two writers in one process cannot rename each other's temp away, portable SHA-256; agent.path holds login-shell PATH for the daemon), Setup/SetupPlanner.swift (first-run / upgrade decisions, harness offers, stage-and-rename binary install), 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), 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), Log/DevCtlLog.swift (OSLog facade with a recording backend for tests).
- Sources/DevCtlKit: shared core, the unit-test target of record. Models.swift (specs, phases, ServerStatus), 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), Paths/Paths.swift (path constants, canonical project path, atomic write + defensive load with per-call unique temp names so two writers in one process cannot rename each other's temp away, portable SHA-256; agent.path holds login-shell PATH for the daemon), Setup/SetupPlanner.swift (first-run / upgrade decisions, harness offers, stage-and-rename binary install), 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), 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 with ProcessIdentity start-time revalidation; the ProcessLauncher seam; ProcessTree QA1123 sysctl sweep), 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 + NWListener ControlServer with stateUpdateHandlers).
- 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.
- 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, 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.
- Sources/devctl: CLI (swift-argument-parser). Two files only: HookSupport.swift (HookContext, the thin socket fetch over DevCtlKit's AgentContext renderer, + HarnessAdapter registry; 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), 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). Pure DaemonClient consumer.
Expand All @@ -20,7 +20,7 @@ Codebase map
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), 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.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), 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`). Requires a GUI session; run before merging URL-scheme work. 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, uninstall). 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.
- 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; ad-hoc signed; SIGN_IDENTITY upgrades; declares the `devctl://` URL scheme). 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. scripts/notarize.sh: notarytool + staple. make install: CLI + daemon to ~/.local/bin, app to /Applications, daemon install.
Expand Down
2 changes: 1 addition & 1 deletion BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,6 @@ Open work only; entries are removed by the change that resolves them.
- A restarting daemon is indistinguishable from a dead one for the length of `recoverAtStartup`, which is seconds when there is real state to restore. The listener only reaches `.ready` after restore, and the socket is unlinked in `ControlServer.init`, so clients get `daemon-unreachable` with ENOENT and nothing that says "starting". Moving the unlink later only changes the errno, since the daemon is unreachable either way: the fix is to answer during restore (bind early and reply "starting" to everything, or write a state file clients can read) so `devctl daemon status` can say restoring rather than down. Wanted because install/restart bounce servers, and an agent polling through that window sees a daemon that looks gone.
- Split `CLI.swift`: every command struct lives in that one file, which is past the size where splitting is worth asking about. A structure decision, not drift; the codebase map describes the current layout.
- Field-level config editing in the dashboard would preserve `devservers.json` formatting instead of normalizing writes. `devctl config init` writes indented JSON with sorted keys, so a recovered file and a dashboard-saved file now agree on shape, but a hand-authored one with its own ordering or comments still normalizes on save.

- `crashKillsSessionGrandchild` fails rarely: the session grandchild is still alive after the server turns crashed. Seen twice in roughly twenty full-suite runs on 2026-08-08, both while the machine was busy, and never in eight isolated runs or four quiet full runs. The test no longer sleeps a fixed slice over the sweep, it polls for five seconds, so a failure now means the descendant really survived that long rather than that the assertion was impatient. Suspect the crash exit path's descendant sweep racing a grandchild that has not yet been reparented. Reopen with a repro that names the surviving pid and its parent at the moment the sweep runs, before changing the sweep.
- Exact hashing above the file cap needs a streaming SHA-256. `SHA256Portable.digest` takes a whole `[UInt8]` with no incremental entry point, so a lock resource larger than 8 MiB is fingerprinted by head, tail, size, and mtime, and a middle-only rewrite that preserves all four escapes the identity check. The limit is asserted in `ResourceIdentityTests` and stated in the contract rather than left implicit.
- The identity check flags the risk window, not the damage: the flush that corrupts can land after the guarded command exits and the second capture is taken. Catching that would need the daemon to watch the resource across the whole hold, or to compare again once the paused set has resumed.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Commit that file where the whole team runs the same servers. Keep it gitignored
## The parts

- `devctld`: the daemon. Spool-file output capture (children survive daemon restarts without SIGPIPE), process-group plus descendant-sweep teardown, health-gated phases, crash forensics, structured logs with correlation marks, a unified event feed.
- `devctl`: the CLI. `ensure`, `wait`, `up`/`down`, `logs --since-mark`, `mark`, `events`, `why`, `open`, `switch`, `lock` (pause servers sharing a resource while a test harness runs, and report when a command changed that resource while a server still held it open), `config init`, `doctor`, and launchd management. Agents are the first-class consumer.
- `devctl`: the CLI. `ensure`, `wait`, `up`/`down`, `logs --since-mark`, `mark`, `events`, `restart`, `why`, `open`, `switch`, `lock` (pause servers sharing a resource while a test harness runs, and report when a command changed that resource while a server still held it open), `config init`, `doctor`, and launchd management. A server can list the config files it reads at boot and devctl restarts it when one changes. Agents are the first-class consumer.
- `devctl.app`: the menu bar. Presence dots with counts, per-project rows with click-to-open heads (pinnable), crash notifications, a dashboard with live logs, an event timeline, and a validating config editor. Every server and head is Spotlight-searchable.

## Building
Expand Down
14 changes: 7 additions & 7 deletions Sources/DevCtlApp/DaemonModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -348,14 +348,14 @@ final class DaemonModel {
ProjectAccessLog.shared.record(projectPath: server.project)
Task {
let client = DaemonClient(socketPath: DevCtlPaths().socketPath)
/** One request rather than a stop followed by an ensure: the pair
leaves the server down if the ensure is refused, and lets another
session's ensure land in between. */
_ = try? await client.request(
.serverStop,
params: ServerTargetParams(name: server.server, project: server.project),
expecting: ServerResult.self)
_ = try? await client.request(
.serverEnsure,
params: EnsureParams(name: server.server, project: server.project, timeoutSeconds: 60),
expecting: EnsureResult.self)
.serverRestart,
params: RestartParams(
names: [server.server], project: server.project, timeoutSeconds: 60),
expecting: GroupResult.self)
await refresh()
}
}
Expand Down
Loading
Loading