From f67103d5213a372c43e898b9f3cd9413c9cdb730 Mon Sep 17 00:00:00 2001 From: Evan Jacobs Date: Sat, 8 Aug 2026 00:11:14 -0400 Subject: [PATCH 1/4] feat(restart): add a server-level restart as one daemon-side transition Agents wrote `devctl stop X && devctl ensure X` by hand in a dozen sessions, and several guessed the verb already existed. That pair has two defects a single transition removes: 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. Every refusal now happens before anything stops, which is what the headline test pins: moving the gate after the stop leaves the server down and the test fails on exactly that. The stop is non-retiring, so resume-on-boot survives what a deliberate stop would clear. The menu bar app drops its own stop-then-ensure pair, and the session context block names the verb, which is where the sessions that guessed at it were looking. --- Sources/DevCtlApp/DaemonModel.swift | 14 +- .../Control/ControlServer.swift | 63 +++++ Sources/DevCtlKit/Agent/AgentContext.swift | 2 +- Sources/DevCtlKit/Protocol/Wire.swift | 22 ++ Sources/devctl/CLI.swift | 52 ++++- .../DevCtlDaemonCoreTests/RestartTests.swift | 219 ++++++++++++++++++ Tests/DevCtlKitTests/AgentContextTests.swift | 4 +- scripts/smoke.sh | 16 ++ 8 files changed, 381 insertions(+), 11 deletions(-) create mode 100644 Tests/DevCtlDaemonCoreTests/RestartTests.swift diff --git a/Sources/DevCtlApp/DaemonModel.swift b/Sources/DevCtlApp/DaemonModel.swift index f6903d3..99e8869 100644 --- a/Sources/DevCtlApp/DaemonModel.swift +++ b/Sources/DevCtlApp/DaemonModel.swift @@ -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() } } diff --git a/Sources/DevCtlDaemonCore/Control/ControlServer.swift b/Sources/DevCtlDaemonCore/Control/ControlServer.swift index bafba5f..35bceee 100644 --- a/Sources/DevCtlDaemonCore/Control/ControlServer.swift +++ b/Sources/DevCtlDaemonCore/Control/ControlServer.swift @@ -213,6 +213,11 @@ public actor Router { let stopped = await supervisor.stop() DevCtlLog.daemon.info("stop \(target.name)@\(target.project)") return try respond(id: head.id, result: ServerResult(server: stopped)) + case .serverRestart: + let request = try decoder.decode(WireRequest.self, from: line) + var params = request.params + params.project = canonicalProjectPath(params.project) + return try respond(id: head.id, result: try await restartServers(params)) case .serverWait: let request = try decoder.decode(WireRequest.self, from: line) let target = ServerTargetParams( @@ -1335,6 +1340,64 @@ public actor Router { /** Wave-parallel group start honoring the dependency graph: a wave holds servers whose dependencies all settled in earlier waves. waitFor .started launches without blocking on health; the default blocks until healthy. */ + /** The one restart path. Every refusal happens before anything stops: a + client-side `stop && ensure` takes the server down and only then discovers + a held resource or a broken config, leaving it down. The stop is + non-retiring because the server is coming straight back, so resume-on-boot + survives what `stop` would otherwise clear. */ + private func restartServers(_ params: RestartParams) async throws -> GroupResult { + let merged = try await mergedSpecs(project: params.project) + var wanted = merged.specs + if let names = params.names { + for name in names where !merged.specs.contains(where: { $0.name == name }) { + throw WireError( + code: .notFound, + hint: "run: devctl status --json", + message: "no server named '\(name)' in \(params.project)") + } + wanted = merged.specs.filter { names.contains($0.name) } + } + var prepared: [(spec: ServerSpec, supervisor: ServerSupervisor)] = [] + for spec in wanted { + await recordTrustIfNeeded( + project: params.project, name: spec.name, fileNames: merged.fileNames) + try await lockGate(project: params.project, spec: spec) + try await refuseIfPaused(project: params.project, spec: spec) + prepared.append((spec: spec, supervisor: await supervisor(project: params.project, spec: spec))) + } + var results: [EnsureResult] = [] + for entry in prepared { + _ = await entry.supervisor.stop(deliberate: false) + let target = ServerTargetParams( + name: entry.spec.name, port: params.port, project: params.project) + try await prepareSpawn( + target: target, supervisor: entry.supervisor, portOverride: params.port) + results.append(await entry.supervisor.ensure(timeoutSeconds: params.timeoutSeconds)) + DevCtlLog.daemon.info("restart \(entry.spec.name)@\(params.project)") + } + return GroupResult(results: results.sorted { $0.server.server < $1.server.server }) + } + + /** A server sitting in a live holder's paused set must not be resurrected + behind the lock's back. lockGate covers an external holder of a resource + this server declares; this covers the case where the hold already stopped + it. */ + private func refuseIfPaused(project: String, spec: ServerSpec) async throws { + for declaration in spec.locks ?? [] { + let key = Self.lockKey(project: project, resource: declaration.name) + await releaseOrphanedLock(key: key) + guard let holder = resourceLocks[key], holder.paused.contains(spec.name) else { + continue + } + throw WireError( + code: .resourceLocked, + hint: "wait for pid \(holder.pid) to finish, or verify it: ps -p \(holder.pid)", + message: + "'\(spec.name)' is paused by the hold on '\(declaration.name)' (pid \(holder.pid)); it comes back when that run releases" + ) + } + } + private func groupUp(_ params: GroupParams) async throws -> GroupResult { let merged = try await mergedSpecs(project: params.project) var wanted = merged.specs diff --git a/Sources/DevCtlKit/Agent/AgentContext.swift b/Sources/DevCtlKit/Agent/AgentContext.swift index 0cec48b..5454de3 100644 --- a/Sources/DevCtlKit/Agent/AgentContext.swift +++ b/Sources/DevCtlKit/Agent/AgentContext.swift @@ -51,7 +51,7 @@ public enum AgentContext { } } lines.append( - "Useful: devctl ensure (idempotent start) · devctl wait --healthy · devctl why (root cause) · devctl logs --since-mark --json · devctl mark \"text\" · devctl events --since 10m · devctl lock -- … (exclusive access to a resource a server holds; prefer it over stopping the server). All support --json.") + "Useful: devctl ensure (idempotent start) · devctl restart (stop and re-ensure in one step; use it after editing a config the server reads at boot) · devctl wait --healthy · devctl why (root cause) · devctl logs --since-mark --json · devctl mark \"text\" · devctl events --since 10m · devctl lock -- … (exclusive access to a resource a server holds; prefer it over stopping the server). All support --json.") /** The invitation and its constraint stay on one line: render truncates from the end and re-appends only the closing fence, so a clause on its own line can be cut while the invitation above it survives. That diff --git a/Sources/DevCtlKit/Protocol/Wire.swift b/Sources/DevCtlKit/Protocol/Wire.swift index 382e118..a6ec0a5 100644 --- a/Sources/DevCtlKit/Protocol/Wire.swift +++ b/Sources/DevCtlKit/Protocol/Wire.swift @@ -186,6 +186,7 @@ public enum WireMethod: String, Sendable { case projectWriteConfig = "project.writeConfig" case serverEnsure = "server.ensure" case serverRegister = "server.register" + case serverRestart = "server.restart" case serverStart = "server.start" case serverStatus = "server.status" case serverStop = "server.stop" @@ -277,6 +278,27 @@ public struct ServerListResult: Codable, Equatable, Sendable { } } +/** A stop and a re-ensure as one daemon-side transition. Doing it from a client + leaves a window where another session's ensure lands between the two, and a + refusal (a held resource, a broken config) arrives after the server is + already down. `names` nil restarts every server in the project. */ +public struct RestartParams: Codable, Equatable, Sendable { + public var names: [String]? + /** One-shot port override, same pipeline as ensure and start. */ + public var port: Int? + public var project: String + public var timeoutSeconds: Double + + public init( + names: [String]? = nil, port: Int? = nil, project: String, timeoutSeconds: Double = 60 + ) { + self.names = names + self.port = port + self.project = project + self.timeoutSeconds = timeoutSeconds + } +} + public struct GroupParams: Codable, Equatable, Sendable { public var only: [String]? /** One-shot port override applied to each server this up starts. */ diff --git a/Sources/devctl/CLI.swift b/Sources/devctl/CLI.swift index 6128f39..230f6f0 100644 --- a/Sources/devctl/CLI.swift +++ b/Sources/devctl/CLI.swift @@ -13,7 +13,8 @@ struct DevCtl: AsyncParsableCommand { version: DevCtlVersion.version, subcommands: [ ConfigCommand.self, Context.self, Doctor.self, Down.self, Ensure.self, Events.self, - HookCommand.self, Link.self, Logs.self, Mark.self, Open.self, Register.self, Start.self, + HookCommand.self, Link.self, Logs.self, Mark.self, Open.self, Register.self, + Restart.self, Start.self, Lock.self, Statusline.self, Status.self, Stop.self, Switch.self, Trust.self, Unregister.self, Up.self, Wait.self, Why.self, XURL.self, DaemonCommand.self, ] @@ -354,6 +355,55 @@ struct Status: AsyncParsableCommand { } } +/** One daemon-side transition rather than a client-side stop then ensure: that + pair leaves a window for another session's ensure, and a refusal arrives only + after the server is already down. */ +struct Restart: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Stop and re-ensure a server as one transition.") + + @Flag(help: "Restart every server in the project.") + var all = false + + @OptionGroup var global: GlobalOptions + + @Argument(help: "Server name (omit with --all).") + var name: String? + + @Option(help: "Override the declared port for this run.") + var port: Int? + + @Option(help: "Per-server seconds to wait for health.") + var timeout: Double = 60 + + func run() async throws { + /** A bare `restart` is far likelier to be typed by reflex than `down` + is, and bouncing a whole project by accident is expensive. */ + if (name == nil) == !all { + CLIRunner.fail( + WireError( + code: .usage, + hint: "run: devctl restart --all", + message: name == nil + ? "devctl restart needs a server name, or --all for the whole project" + : "pass a server name or --all, not both"), + json: global.json) + } + let params = RestartParams( + names: name.map { [$0] }, port: port, project: global.resolvedProject(), + timeoutSeconds: timeout) + let result = await CLIRunner.run(json: global.json, bootstrap: !global.noBootstrap) { client in + try await client.request(.serverRestart, params: params, expecting: GroupResult.self) + } + CLIRunner.emit(result, json: global.json) { r in + r.results.map { CLIRunner.describe($0.server) }.joined(separator: "\n") + } + if result.results.contains(where: { $0.reason != nil }) { + Foundation.exit(1) + } + } +} + struct Stop: AsyncParsableCommand { static let configuration = CommandConfiguration(abstract: "Stop a running server (whole process group).") diff --git a/Tests/DevCtlDaemonCoreTests/RestartTests.swift b/Tests/DevCtlDaemonCoreTests/RestartTests.swift new file mode 100644 index 0000000..ac1b2fd --- /dev/null +++ b/Tests/DevCtlDaemonCoreTests/RestartTests.swift @@ -0,0 +1,219 @@ +import DevCtlKit +import Foundation +import Testing + +@testable import DevCtlDaemonCore + +/** `devctl stop X && devctl ensure X` was what twelve sessions wrote by hand. + It has two defects a single daemon-side transition removes: another session's + ensure can land between the two commands, and a refusal (a held resource, a + broken config) arrives only after the server is already down. */ +@Suite(.serialized) struct RestartTests { + /** A port per test: a case that fails before its teardown would otherwise + leave a listener behind and fail the next one for an unrelated reason. */ + private func env(port: Int) throws -> (paths: DevCtlPaths, project: String) { + let base = FileManager.default.temporaryDirectory + .appending(path: "devctl-restart-\(UUID().uuidString)") + let project = base.appending(path: "proj") + try FileManager.default.createDirectory(at: project, withIntermediateDirectories: true) + try writeConfig(port: port, project: project.path) + return ( + paths: DevCtlPaths( + dataDir: base.appending(path: "data"), logsDir: base.appending(path: "logs")), + project: project.path + ) + } + + private func writeConfig(port: Int, project: String) throws { + let fixture = try #require(Self.fixtureServerPath()) + let body = """ + { + "servers": { + "db": { + "command": ["\(fixture)", "--listen-tcp", "\(port)"], + "healthcheck": { "type": "tcp", "port": \(port) }, + "locks": ["data"], + "port": \(port) + } + }, + "version": 1 + } + """ + try Data(body.utf8) + .write(to: URL(fileURLWithPath: project).appending(path: "devservers.json")) + } + + private func handle( + _ router: Router, _ method: WireMethod, _ params: P, _ expecting: R.Type + ) async throws -> R { + let line = try NDJSON.encodeLine( + WireRequest(id: "t", method: method.rawValue, params: params)) + let data = await router.handle(line: line) + let response = try JSONCoding.decoder().decode(WireResponse.self, from: data) + if response.ok, let result = response.result { return result } + throw response.error ?? WireError(code: .internalError, message: "no result") + } + + private func phase(_ router: Router, _ project: String, _ name: String) async throws + -> ServerPhase + { + let list = try await handle( + router, .serverStatus, ProjectParams(project: project), ServerListResult.self) + return try #require(list.servers.first { $0.server == name }).phase + } + + @Test func restartReplacesThePidAndKeepsResumeOnBoot() async throws { + let env = try env(port: 45411) + let registry = Registry(paths: env.paths) + try await registry.setTrusted(project: env.project) + let router = Router(launcher: SubprocessLauncher(), paths: env.paths, registry: registry) + let first = try await handle( + router, .serverEnsure, + EnsureParams(name: "db", project: env.project, timeoutSeconds: 10), EnsureResult.self) + #expect(first.server.phase == .running) + let id = serverID(project: env.project, name: "db") + #expect(await registry.persistedState(serverID: id)?.resumeOnBoot == true) + + let restarted = try await handle( + router, .serverRestart, + RestartParams(names: ["db"], project: env.project, timeoutSeconds: 10), + GroupResult.self) + let server = try #require(restarted.results.first?.server) + #expect(server.phase == .running) + #expect(server.pid != first.server.pid) + /** A deliberate stop would clear this, so a hand-rolled stop-then-ensure + drops the boot intent and re-sets it; restart never drops it. */ + #expect(await registry.persistedState(serverID: id)?.resumeOnBoot == true) + + _ = try await handle( + router, .serverStop, ServerTargetParams(name: "db", project: env.project), + ServerResult.self) + } + + /** The headline: a stop-then-ensure pair takes the server down and is then + refused, leaving it down. Restart refuses before touching it. */ + @Test func restartUnderALiveLockIsRefusedAndLeavesTheServerRunning() async throws { + let env = try env(port: 45412) + let registry = Registry(paths: env.paths) + try await registry.setTrusted(project: env.project) + let router = Router(launcher: SubprocessLauncher(), paths: env.paths, registry: registry) + _ = try await handle( + router, .serverEnsure, + EnsureParams(name: "db", project: env.project, timeoutSeconds: 10), EnsureResult.self) + _ = try await handle( + router, .lockAcquire, + LockParams( + holderPid: Int(getpid()), pause: false, project: env.project, resource: "data", + resumeTimeoutSeconds: 10), LockResult.self) + + await #expect(throws: WireError.self) { + _ = try await handle( + router, .serverRestart, + RestartParams(names: ["db"], project: env.project, timeoutSeconds: 10), + GroupResult.self) + } + #expect(try await phase(router, env.project, "db") == .running) + + _ = try await handle( + router, .lockRelease, + LockParams( + holderPid: Int(getpid()), project: env.project, resource: "data", + resumeTimeoutSeconds: 10), LockResult.self) + _ = try await handle( + router, .serverStop, ServerTargetParams(name: "db", project: env.project), + ServerResult.self) + } + + /** A server the lock already paused must not come back behind the hold. */ + @Test func restartOfAPausedServerIsRefusedAndItStaysDown() async throws { + let env = try env(port: 45413) + let registry = Registry(paths: env.paths) + try await registry.setTrusted(project: env.project) + let router = Router(launcher: SubprocessLauncher(), paths: env.paths, registry: registry) + _ = try await handle( + router, .serverEnsure, + EnsureParams(name: "db", project: env.project, timeoutSeconds: 10), EnsureResult.self) + let acquired = try await handle( + router, .lockAcquire, + LockParams( + holderPid: Int(getpid()), project: env.project, resource: "data", + resumeTimeoutSeconds: 10), LockResult.self) + #expect(acquired.paused == ["db"]) + + await #expect(throws: WireError.self) { + _ = try await handle( + router, .serverRestart, + RestartParams(names: ["db"], project: env.project, timeoutSeconds: 10), + GroupResult.self) + } + #expect(try await phase(router, env.project, "db") == .stopped) + + _ = try await handle( + router, .lockRelease, + LockParams( + holderPid: Int(getpid()), project: env.project, resource: "data", + resumeTimeoutSeconds: 10), LockResult.self) + _ = try await handle( + router, .serverStop, ServerTargetParams(name: "db", project: env.project), + ServerResult.self) + } + + /** A bad save must not take a healthy server down. */ + @Test func restartWithABrokenConfigLeavesTheServerRunning() async throws { + let env = try env(port: 45414) + let registry = Registry(paths: env.paths) + try await registry.setTrusted(project: env.project) + let router = Router(launcher: SubprocessLauncher(), paths: env.paths, registry: registry) + _ = try await handle( + router, .serverEnsure, + EnsureParams(name: "db", project: env.project, timeoutSeconds: 10), EnsureResult.self) + try Data("{ not json".utf8) + .write(to: URL(fileURLWithPath: env.project).appending(path: "devservers.json")) + + await #expect(throws: WireError.self) { + _ = try await handle( + router, .serverRestart, + RestartParams(names: ["db"], project: env.project, timeoutSeconds: 10), + GroupResult.self) + } + /** Restore the config before reading status: the status path parses it + too, so a broken file would fail the assertion for the wrong reason. */ + try writeConfig(port: 45414, project: env.project) + #expect(try await phase(router, env.project, "db") == .running) + _ = try await handle( + router, .serverStop, ServerTargetParams(name: "db", project: env.project), + ServerResult.self) + } + + @Test func restartOfAnUnknownNameIsNotFoundAndTouchesNothing() async throws { + let env = try env(port: 45415) + let registry = Registry(paths: env.paths) + try await registry.setTrusted(project: env.project) + let router = Router(launcher: SubprocessLauncher(), paths: env.paths, registry: registry) + _ = try await handle( + router, .serverEnsure, + EnsureParams(name: "db", project: env.project, timeoutSeconds: 10), EnsureResult.self) + do { + _ = try await handle( + router, .serverRestart, + RestartParams(names: ["ghost"], project: env.project, timeoutSeconds: 10), + GroupResult.self) + Issue.record("expected not-found") + } catch let error as WireError { + #expect(error.code == .notFound) + } + #expect(try await phase(router, env.project, "db") == .running) + _ = try await handle( + router, .serverStop, ServerTargetParams(name: "db", project: env.project), + ServerResult.self) + } + + private static func fixtureServerPath() -> String? { + let candidate = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appending(path: ".build/debug/fixture-server") + return FileManager.default.fileExists(atPath: candidate.path) ? candidate.path : nil + } +} diff --git a/Tests/DevCtlKitTests/AgentContextTests.swift b/Tests/DevCtlKitTests/AgentContextTests.swift index 449fe81..c721dd9 100644 --- a/Tests/DevCtlKitTests/AgentContextTests.swift +++ b/Tests/DevCtlKitTests/AgentContextTests.swift @@ -62,7 +62,7 @@ import Testing This project's dev servers are managed by devctl (daemon-supervised; they and their logs survive session compaction and restarts). Prefer devctl over launching servers directly. - web: running · http://proj.localhost:3000/ · port 3000 · log /logs/web/current.log - Useful: devctl ensure (idempotent start) · devctl wait --healthy · devctl why (root cause) · devctl logs --since-mark --json · devctl mark "text" · devctl events --since 10m · devctl lock -- … (exclusive access to a resource a server holds; prefer it over stopping the server). All support --json. + Useful: devctl ensure (idempotent start) · devctl restart (stop and re-ensure in one step; use it after editing a config the server reads at boot) · devctl wait --healthy · devctl why (root cause) · devctl logs --since-mark --json · devctl mark "text" · devctl events --since 10m · devctl lock -- … (exclusive access to a resource a server holds; prefer it over stopping the server). All support --json. While you work, monitor devctl itself: if it misbehaves, surprises you, or a missing capability slows you down, flag it (a line in ~/code/devctl/BACKLOG.md, or tell the user) rather than silently working around it. Report devctl's behavior and how to reproduce it generically, never this project's name, paths, hosts, ports, or log lines: that file lives outside this project. """) @@ -85,7 +85,7 @@ import Testing - api: crashed · port 4000 · last exit exit 1 at 2025-07-18T19:46:40.000Z · log /logs/api/current.log 3 error lines since 2025-07-18T19:46:40.000Z, latest 2025-07-18T19:46:44.000Z run: devctl why api --json - Useful: devctl ensure (idempotent start) · devctl wait --healthy · devctl why (root cause) · devctl logs --since-mark --json · devctl mark "text" · devctl events --since 10m · devctl lock -- … (exclusive access to a resource a server holds; prefer it over stopping the server). All support --json. + Useful: devctl ensure (idempotent start) · devctl restart (stop and re-ensure in one step; use it after editing a config the server reads at boot) · devctl wait --healthy · devctl why (root cause) · devctl logs --since-mark --json · devctl mark "text" · devctl events --since 10m · devctl lock -- … (exclusive access to a resource a server holds; prefer it over stopping the server). All support --json. While you work, monitor devctl itself: if it misbehaves, surprises you, or a missing capability slows you down, flag it (a line in ~/code/devctl/BACKLOG.md, or tell the user) rather than silently working around it. Report devctl's behavior and how to reproduce it generically, never this project's name, paths, hosts, ports, or log lines: that file lives outside this project. """) diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 367907a..db33f60 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -439,6 +439,22 @@ pass "--no-pause over changed state fails loudly with resource-mutated" "$DEVCTL" lock data --no-pause -- true 2>"$WORK/quiet.err" >/dev/null || fail "--no-pause over untouched state failed" [[ ! -s "$WORK/quiet.err" ]] || fail "--no-pause over untouched state was noisy: $(cat "$WORK/quiet.err")" pass "an untouched locked resource stays silent" + +# restart is one daemon-side transition: a client-side stop-then-ensure takes the +# server down and only then discovers a refusal, leaving it down. +"$DEVCTL" up --timeout 15 --json > /dev/null || fail "up before restart" +RESTART_PID_BEFORE="$("$DEVCTL" status db --json | /usr/bin/python3 -c 'import json,sys; print(json.load(sys.stdin)["servers"][0]["pid"])')" +"$DEVCTL" restart db --timeout 15 --json > "$WORK/restart.json" || fail "restart db" +/usr/bin/python3 -c "import json;d=json.load(open('$WORK/restart.json'));s=d['results'][0]['server'];assert s['phase']=='running', d; assert s['pid']!=$RESTART_PID_BEFORE, d" || fail "restart did not replace the process" +pass "restart replaces the process and comes back healthy" + +set +e +"$DEVCTL" lock data --no-pause -- "$DEVCTL" restart db --timeout 5 --json > "$WORK/restart-locked.json" 2>/dev/null +set -e +/usr/bin/python3 -c "import json;d=json.load(open('$WORK/restart-locked.json'));assert d['error']['code']=='resource-locked', d" || fail "restart under a live lock was not refused" +"$DEVCTL" status db --json | /usr/bin/python3 -c 'import json,sys; d=json.load(sys.stdin)["servers"][0]; assert d["phase"]=="running", d' || fail "a refused restart left the server down" +pass "restart under a live lock is refused and the server stays up" + "$DEVCTL" down --json > /dev/null "$DEVCTL" down --json > /dev/null From 7ae6d3159ce39530edf9c95402edaa93da5ace41 Mon Sep 17 00:00:00 2001 From: Evan Jacobs Date: Sat, 8 Aug 2026 00:35:51 -0400 Subject: [PATCH 2/4] feat(watch): restart a server when a config it reads at boot changes A supervised server outlives the session that started it, so it keeps running the config it booted with: a correct fix reads as inert and a harness keeps asserting stale behavior. A server can now list the files it reads at boot. Polling stat rather than holding descriptors, because nearly every editor and build tool saves by writing a temp file and renaming it over the target, which leaves a held descriptor pointing at an unlinked inode; the same replace is why the fingerprint carries the inode and not just mtime. The settle window folds a server's own boot-time write into the baseline, the quiet window makes one save one restart, and a burst suspends the watch with the paths named rather than looping. A hit under a live resource lock is deferred, not dropped, and fires when the hold releases. The debounce is a pure function over an injected clock, so its tests never sleep on a timer. The smoke gate asserts the restarted process printed the new config value, since a changed pid alone would not show the feature working. --- .changeset/restart-and-watch.md | 7 + AGENTS.md | 6 +- README.md | 2 +- .../Control/ControlServer.swift | 58 +++- .../Supervisor/ServerSupervisor.swift | 79 ++++++ Sources/DevCtlKit/Config/ProjectConfig.swift | 25 +- Sources/DevCtlKit/Config/WatchPaths.swift | 49 ++++ Sources/DevCtlKit/Config/WatchPolicy.swift | 108 ++++++++ Sources/DevCtlKit/Model/Models.swift | 8 +- Sources/devctld/main.swift | 12 + Sources/fixture-server/main.swift | 25 ++ Tests/DevCtlDaemonCoreTests/WatchTests.swift | 249 ++++++++++++++++++ Tests/DevCtlKitTests/WatchPolicyTests.swift | 162 ++++++++++++ docs/cli-contract.md | 2 + docs/design.md | 3 +- scripts/smoke.sh | 53 ++++ 16 files changed, 839 insertions(+), 9 deletions(-) create mode 100644 .changeset/restart-and-watch.md create mode 100644 Sources/DevCtlKit/Config/WatchPaths.swift create mode 100644 Sources/DevCtlKit/Config/WatchPolicy.swift create mode 100644 Tests/DevCtlDaemonCoreTests/WatchTests.swift create mode 100644 Tests/DevCtlKitTests/WatchPolicyTests.swift diff --git a/.changeset/restart-and-watch.md b/.changeset/restart-and-watch.md new file mode 100644 index 0000000..9a7ac09 --- /dev/null +++ b/.changeset/restart-and-watch.md @@ -0,0 +1,7 @@ +--- +"devctl": minor +--- + +`devctl restart ` 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. diff --git a/AGENTS.md b/AGENTS.md index ffc5b7f..a093405 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. @@ -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. diff --git a/README.md b/README.md index cc49e09..294d6a0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Sources/DevCtlDaemonCore/Control/ControlServer.swift b/Sources/DevCtlDaemonCore/Control/ControlServer.swift index 35bceee..3a95a90 100644 --- a/Sources/DevCtlDaemonCore/Control/ControlServer.swift +++ b/Sources/DevCtlDaemonCore/Control/ControlServer.swift @@ -16,8 +16,16 @@ public actor Router { a daemon crash mid-hold can still resume the paused servers when the holder is gone. Stale holders (dead pids) evaporate on access. */ private var resourceLocks: [String: LockHolder] = [:] + /** Machine kill switch for the watch sweep, for the moment someone wants + their server to stop bouncing right now. An init parameter so tests can + set it without touching the environment. */ + private let watchEnabled: Bool - public init(launcher: any ProcessLauncher, paths: DevCtlPaths, registry: Registry) { + public init( + launcher: any ProcessLauncher, paths: DevCtlPaths, registry: Registry, + watchEnabled: Bool = ProcessInfo.processInfo.environment["DEVCTL_NO_WATCH"] != "1" + ) { + self.watchEnabled = watchEnabled self.events = EventStore(url: paths.eventsFile) self.launcher = launcher self.paths = paths @@ -1367,6 +1375,7 @@ public actor Router { } var results: [EnsureResult] = [] for entry in prepared { + await entry.supervisor.rearmWatch() _ = await entry.supervisor.stop(deliberate: false) let target = ServerTargetParams( name: entry.spec.name, port: params.port, project: params.project) @@ -1378,6 +1387,53 @@ public actor Router { return GroupResult(results: results.sorted { $0.server.server < $1.server.server }) } + /** One watch sweep over the resident supervisors. `now` is a parameter and + the restarted ids come back, so tests drive sweeps with a synthetic clock + instead of sleeping on the daemon's timer. */ + public func sweepWatches(now: Date = Date()) async -> [String] { + guard watchEnabled else { return [] } + var restarted: [String] = [] + for (id, supervisor) in supervisors { + guard let changed = await supervisor.evaluateWatch(now: now), !changed.isEmpty else { + continue + } + guard let split = Self.splitServerID(id) else { continue } + /** The daemon never acts on a project's config before trust is + recorded. A running server implies trust was recorded, so this is + belt and braces rather than the only guard. */ + guard await registry.isTrusted(project: split.project) else { continue } + let relative = changed.map { + $0.replacingOccurrences(of: split.project + "/", with: "") + } + do { + _ = try await restartServers( + RestartParams( + names: [split.name], project: split.project, timeoutSeconds: 60)) + await supervisor.recordWatchRestart(now) + restarted.append(id) + DevCtlLog.daemon.info( + "watch restart \(split.name)@\(split.project): \(relative.joined(separator: ", "))") + } catch { + /** A held resource or a held port: keep the pending change so the + edit fires once the refusal clears rather than being dropped. + Logged rather than swallowed, because a watch that silently + never fires is indistinguishable from one that is not armed. */ + DevCtlLog.daemon.info( + "watch restart refused for \(split.name)@\(split.project): \(String(describing: error))") + await supervisor.deferWatchRestart(now: now) + } + } + return restarted.sorted() + } + + static func splitServerID(_ id: String) -> (name: String, project: String)? { + guard let separator = id.range(of: "::", options: .backwards) else { return nil } + return ( + name: String(id[separator.upperBound...]), + project: String(id[id.startIndex..] = [] private var spec: ServerSpec private var startedAt: Date? + /** Taken once the run has been alive for the settle window rather than at + spawn, so a server that writes its own watched file while booting folds + that write into the baseline instead of bouncing itself for it. */ + private var watchBaseline: WatchFingerprint? + private var watchPending: (at: Date, stamp: WatchFingerprint)? + /** Deliberately not cleared at spawn: the oscillation the breaker detects + spans restarts by definition. */ + private var watchRestarts: [Date] = [] + private var watchSuspended = false private var stopRequested = false /** Carries the stop()'s intent into recordOutcome: deliberate clears the resume-on-boot flag, a launchd drain keeps it. */ @@ -91,6 +100,76 @@ public actor ServerSupervisor { spec = newSpec } + /** Absolute watched paths for this run, empty when the server declares no + `watch`, which is the whole no-configuration-needed path: everything + below returns immediately. */ + private var watchPaths: [String] { + WatchPaths.resolve(entries: spec.watch ?? [], project: projectPath).paths + } + + /** One watch evaluation. Returns the changed paths only when the caller + should restart: nil for idle, still settling, waiting out the quiet + window, suspended, or not running. The stats happen here so the Router's + sweep stays a fan-out. */ + public func evaluateWatch(now: Date = Date()) async -> [String]? { + guard !watchSuspended, phase == .running || phase == .unhealthy else { return nil } + let paths = watchPaths + guard !paths.isEmpty, let startedAt else { return nil } + let limits = WatchPolicy.Limits() + guard now.timeIntervalSince(startedAt) >= limits.settleSeconds else { return nil } + let observed = WatchFingerprint.take(paths: paths) + guard let baseline = watchBaseline else { + watchBaseline = observed + return nil + } + let decision = WatchPolicy.decide( + baseline: baseline, limits: limits, now: now, observed: observed, + pending: watchPending, recentRestarts: watchRestarts) + switch decision { + case .idle: + watchPending = nil + return nil + case .restart(let changed): + return changed + case .suspend(let changed): + /** A watch that quietly stopped working is worse than one that never + existed, so say which paths keep moving and stop. */ + watchSuspended = true + watchPending = nil + await logStore.append( + stream: .sys, + text: "watch suspended: \(changed.joined(separator: ", ")) keeps changing") + await events?.post( + kind: .marked, project: projectPath, server: spec.name, + detail: "watch suspended: \(changed.joined(separator: ", "))") + return nil + case .waiting: + if watchPending?.stamp != observed { watchPending = (at: now, stamp: observed) } + return nil + } + } + + /** The Router refused this restart (a held resource, a held port). Keep the + pending change so it fires once the refusal clears rather than dropping + the edit on the floor. */ + public func deferWatchRestart(now: Date) { + watchPending = nil + } + + public func recordWatchRestart(_ at: Date) { + watchRestarts.append(at) + watchPending = nil + watchBaseline = nil + } + + /** An explicit restart re-arms a tripped breaker: the feature must not be + dead for the rest of the daemon's life after one bad afternoon. */ + public func rearmWatch() { + watchSuspended = false + watchPending = nil + watchBaseline = nil + } + /** Port metadata for status/agents. Call after materializing the spawn spec. */ public func setPortMeta( claim: PortClaim? = nil, diff --git a/Sources/DevCtlKit/Config/ProjectConfig.swift b/Sources/DevCtlKit/Config/ProjectConfig.swift index 3f12fd0..9ca555b 100644 --- a/Sources/DevCtlKit/Config/ProjectConfig.swift +++ b/Sources/DevCtlKit/Config/ProjectConfig.swift @@ -49,6 +49,10 @@ public struct ProjectFileServer: Codable, Equatable, Sendable { public var shell: Bool? public var url: String? public var waitFor: WaitTarget? + /** Project-relative files this server reads at boot but does not reload on + its own. A change to one restarts the server. A server whose framework + already reloads its own config declares nothing here. */ + public var watch: [String]? public init( command: [String], @@ -66,7 +70,8 @@ public struct ProjectFileServer: Codable, Equatable, Sendable { portSpan: Int? = nil, shell: Bool? = nil, url: String? = nil, - waitFor: WaitTarget? = nil + waitFor: WaitTarget? = nil, + watch: [String]? = nil ) { self.command = command self.cwd = cwd @@ -84,6 +89,7 @@ public struct ProjectFileServer: Codable, Equatable, Sendable { self.shell = shell self.url = url self.waitFor = waitFor + self.watch = watch } } @@ -192,6 +198,20 @@ public enum ProjectConfigLoader { warnings.append("server '\(name)': icon '\(relative)' not found in the project") } } + /** Warn rather than error: a stray watch entry should not block a + whole project's config, and the survivors still work. */ + var watchEntries: [String]? + if let declared = entry.watch, !declared.isEmpty { + let resolved = WatchPaths.resolve(entries: declared, project: project) + warnings.append(contentsOf: resolved.warnings.map { "server '\(name)': \($0)" }) + let kept = declared.filter { candidate in + resolved.paths.contains { + $0 == URL(fileURLWithPath: project).appending(path: candidate) + .standardizedFileURL.path + } + } + watchEntries = kept.isEmpty ? nil : kept + } let serverHost = entry.host ?? host var url = entry.url if url == nil, let port = entry.port { @@ -224,7 +244,8 @@ public enum ProjectConfigLoader { portSpan: entry.portSpan, shell: entry.shell, url: url, - waitFor: entry.waitFor + waitFor: entry.waitFor, + watch: watchEntries ) view.errors.append(contentsOf: PortClaim.configErrors(spec: draft)) specs.append(draft) diff --git a/Sources/DevCtlKit/Config/WatchPaths.swift b/Sources/DevCtlKit/Config/WatchPaths.swift new file mode 100644 index 0000000..a8335ce --- /dev/null +++ b/Sources/DevCtlKit/Config/WatchPaths.swift @@ -0,0 +1,49 @@ +import Foundation + +/** One home for resolving a server's `watch` entries, so `config check` warns + about exactly the entries the daemon will ignore. */ +public enum WatchPaths { + /** Resolves project-relative watch entries to absolute paths, dropping the + ones devctl will not follow and saying why. Warnings rather than errors: + a stray watch entry must not block a whole project's config. */ + public static func resolve(entries: [String], project: String) + -> (paths: [String], warnings: [String]) + { + var paths: [String] = [] + var warnings: [String] = [] + let root = URL(fileURLWithPath: project).standardizedFileURL.path + let prefix = root.hasSuffix("/") ? root : root + "/" + for entry in entries { + guard !entry.isEmpty else { + warnings.append("watch entry is empty") + continue + } + /** No globs: matching would turn a handful of stats into a tree walk + on every sweep, and it makes "what bounces my server" unpredictable + in a file an agent may write unattended. */ + if entry.contains(where: { "*?[{".contains($0) }) { + warnings.append( + "watch entry '\(entry)' looks like a glob; list the files literally") + continue + } + if entry.hasPrefix("/") { + warnings.append( + "watch entry '\(entry)' is absolute; watch paths are relative to the project root") + continue + } + let absolute = URL(fileURLWithPath: project).appending(path: entry) + .standardizedFileURL.path + guard absolute.hasPrefix(prefix) else { + warnings.append("watch entry '\(entry)' points outside the project") + continue + } + var info = stat() + if stat(absolute, &info) == 0, info.st_mode & S_IFMT == S_IFDIR { + warnings.append("watch entry '\(entry)' is a directory; list the files inside it") + continue + } + paths.append(absolute) + } + return (paths: Array(Set(paths)).sorted(), warnings: warnings) + } +} diff --git a/Sources/DevCtlKit/Config/WatchPolicy.swift b/Sources/DevCtlKit/Config/WatchPolicy.swift new file mode 100644 index 0000000..46738f0 --- /dev/null +++ b/Sources/DevCtlKit/Config/WatchPolicy.swift @@ -0,0 +1,108 @@ +import Foundation + +/** One watched path's identity as a stat can tell it. Inode is load-bearing: an + atomic replace, which is how nearly every editor and build tool saves, can + land the same mtime second and the same size, and an mtime-only fingerprint + calls that no change. Never persisted, so the fingerprint is simply retaken + at every spawn. */ +public struct WatchStamp: Equatable, Sendable { + public var inode: UInt64 + public var modifiedAt: Double + public var size: UInt64 + + public init(inode: UInt64, modifiedAt: Double, size: UInt64) { + self.inode = inode + self.modifiedAt = modifiedAt + self.size = size + } +} + +/** Absolute path to stamp. An absent path is simply missing from the map, which + is what makes a config a build step has not generated yet legal, and its + appearance a change. */ +public struct WatchFingerprint: Equatable, Sendable { + public var stamps: [String: WatchStamp] + + public init(stamps: [String: WatchStamp] = [:]) { + self.stamps = stamps + } + + public static func take(paths: [String]) -> WatchFingerprint { + var stamps: [String: WatchStamp] = [:] + for path in paths { + var info = stat() + guard stat(path, &info) == 0 else { continue } + stamps[path] = WatchStamp( + inode: UInt64(info.st_ino), + modifiedAt: Double(info.st_mtimespec.tv_sec) + + Double(info.st_mtimespec.tv_nsec) / 1_000_000_000, + size: UInt64(info.st_size)) + } + return WatchFingerprint(stamps: stamps) + } + + public func changed(from other: WatchFingerprint) -> [String] { + var names = Set(stamps.keys) + names.formUnion(other.stamps.keys) + return names.filter { stamps[$0] != other.stamps[$0] }.sorted() + } +} + +/** Whether a watched change has settled enough to act on. Pure and clock-driven + so the debounce is exercised without sleeping on a real timer. */ +public enum WatchPolicy { + public struct Limits: Equatable, Sendable { + /** Auto-restarts allowed inside `burstWindowSeconds` before the watch + suspends itself. */ + public var burstLimit: Int + public var burstWindowSeconds: Double + /** How long the fingerprint must hold still before a restart fires, so + one save touching several files is one restart. */ + public var quietSeconds: Double + /** How long a run must be up before its baseline is taken, so a server + that writes its own watched file during boot does not bounce itself. */ + public var settleSeconds: Double + + public init( + burstLimit: Int = 3, burstWindowSeconds: Double = 60, quietSeconds: Double = 1, + settleSeconds: Double = 2 + ) { + self.burstLimit = burstLimit + self.burstWindowSeconds = burstWindowSeconds + self.quietSeconds = quietSeconds + self.settleSeconds = settleSeconds + } + } + + public enum Decision: Equatable, Sendable { + case idle + case restart(changed: [String]) + /** Changing faster than a person saves, which usually means the server + is writing its own watched file. devctl cannot tell those apart, so + it stops and says which paths keep moving rather than bouncing + forever or quietly rate-limiting. */ + case suspend(changed: [String]) + case waiting(changed: [String]) + } + + public static func decide( + baseline: WatchFingerprint, + limits: Limits = Limits(), + now: Date, + observed: WatchFingerprint, + pending: (at: Date, stamp: WatchFingerprint)?, + recentRestarts: [Date] + ) -> Decision { + let changed = observed.changed(from: baseline) + /** A revert back to the baseline cancels an armed restart: nothing the + server would read differently, so nothing to bounce for. */ + guard !changed.isEmpty else { return .idle } + let burst = recentRestarts.filter { now.timeIntervalSince($0) < limits.burstWindowSeconds } + guard burst.count < limits.burstLimit else { return .suspend(changed: changed) } + guard let pending, pending.stamp == observed else { return .waiting(changed: changed) } + guard now.timeIntervalSince(pending.at) >= limits.quietSeconds else { + return .waiting(changed: changed) + } + return .restart(changed: changed) + } +} diff --git a/Sources/DevCtlKit/Model/Models.swift b/Sources/DevCtlKit/Model/Models.swift index 79e3c3b..1d075f5 100644 --- a/Sources/DevCtlKit/Model/Models.swift +++ b/Sources/DevCtlKit/Model/Models.swift @@ -171,6 +171,10 @@ public struct ServerSpec: Codable, Equatable, Sendable { public var shell: Bool? public var url: String? public var waitFor: WaitTarget? + /** Project-relative files this server reads at boot and does not reload on + its own; a change restarts it. Empty or absent means today's behavior + exactly, which is what a self-reloading framework wants. */ + public var watch: [String]? public init( command: [String], @@ -189,7 +193,8 @@ public struct ServerSpec: Codable, Equatable, Sendable { portSpan: Int? = nil, shell: Bool? = nil, url: String? = nil, - waitFor: WaitTarget? = nil + waitFor: WaitTarget? = nil, + watch: [String]? = nil ) { self.command = command self.cwd = cwd @@ -208,6 +213,7 @@ public struct ServerSpec: Codable, Equatable, Sendable { self.shell = shell self.url = url self.waitFor = waitFor + self.watch = watch } } diff --git a/Sources/devctld/main.swift b/Sources/devctld/main.swift index b86e06b..58a277f 100644 --- a/Sources/devctld/main.swift +++ b/Sources/devctld/main.swift @@ -181,6 +181,18 @@ Task { "devctld \(DevCtlVersion.version) listening on \(socketPath) (pid \(getpid()))\n" .utf8)) } + /** The watch sweep starts only after restore, so a boot-time spawn is never + mistaken for a config change. Polling rather than an fd-based watcher: + nearly every editor and build tool saves by writing a temp file and + renaming it over the target, after which a held fd names an unlinked + inode and goes deaf to the path it was watching. */ + Task { + DevCtlLog.daemon.info("watch sweep started") + while true { + _ = await router.sweepWatches() + try? await Task.sleep(for: .milliseconds(500)) + } + } } dispatchMain() diff --git a/Sources/fixture-server/main.swift b/Sources/fixture-server/main.swift index ebe65f4..e7334bf 100644 --- a/Sources/fixture-server/main.swift +++ b/Sources/fixture-server/main.swift @@ -9,6 +9,10 @@ import Foundation --emit-binary write raw non-UTF8 bytes into stdout once --err-lines N write N lines to stderr at startup (error-tally fixture) --flood write lines as fast as possible + --print-file PATH print `config: ` of PATH once at startup, + which is how a watch test proves the RESTARTED process + read the new file rather than only that a pid changed + --touch-file PATH rewrite PATH every 300ms (self-write loop fixture) Default behavior: print a heartbeat line every 200ms. */ var listenPort: UInt16? @@ -19,6 +23,8 @@ var ignoreSigterm = false var emitBinary = false var errLines = 0 var flood = false +var printFile: String? +var touchFile: String? var argIterator = CommandLine.arguments.dropFirst().makeIterator() while let arg = argIterator.next() { @@ -37,6 +43,10 @@ while let arg = argIterator.next() { emitBinary = true case "--err-lines": errLines = argIterator.next().flatMap { Int($0) } ?? 0 + case "--print-file": + printFile = argIterator.next() + case "--touch-file": + touchFile = argIterator.next() case "--flood": flood = true default: @@ -64,6 +74,21 @@ if emitBinary { FileHandle.standardOutput.write(Data(junk)) } +/** Read once at startup, like a real server reading its config, so a watch test + can tell "the process restarted" from "the restarted process read the new + file", which is the difference the feature exists for. */ +if let printFile { + let contents = (try? String(contentsOfFile: printFile, encoding: .utf8)) ?? "" + print("config: \(contents.split(separator: "\n").first.map(String.init) ?? "")") +} + +if let touchFile { + Timer.scheduledTimer(withTimeInterval: 0.3, repeats: true) { _ in + try? Data("\(Date().timeIntervalSince1970)\n".utf8).write( + to: URL(fileURLWithPath: touchFile)) + } +} + /** A distinctive token so a test can assert the raw child bytes never leak into the agent context block, while its count is still surfaced. */ for index in 0.. ( + paths: DevCtlPaths, project: String + ) { + let base = FileManager.default.temporaryDirectory + .appending(path: "devctl-watch-\(UUID().uuidString)") + let project = base.appending(path: "proj") + try FileManager.default.createDirectory(at: project, withIntermediateDirectories: true) + try Data("v1\n".utf8).write(to: project.appending(path: "app.config.json")) + let fixture = try #require(Self.fixtureServerPath()) + let watchKey = watch.map { "\"watch\": [\"\($0)\"]," } ?? "" + let locksKey = locks ? "\"locks\": [\"data\"]," : "" + let body = """ + { + "servers": { + "web": { + "command": ["\(fixture)", "--listen-tcp", "\(port)"], + "healthcheck": { "type": "tcp", "port": \(port) }, + \(locksKey) + \(watchKey) + "port": \(port) + } + }, + "version": 1 + } + """ + try Data(body.utf8).write(to: project.appending(path: "devservers.json")) + return ( + paths: DevCtlPaths( + dataDir: base.appending(path: "data"), logsDir: base.appending(path: "logs")), + project: project.path + ) + } + + private func handle( + _ router: Router, _ method: WireMethod, _ params: P, _ expecting: R.Type + ) async throws -> R { + let line = try NDJSON.encodeLine( + WireRequest(id: "t", method: method.rawValue, params: params)) + let data = await router.handle(line: line) + let response = try JSONCoding.decoder().decode(WireResponse.self, from: data) + if response.ok, let result = response.result { return result } + throw response.error ?? WireError(code: .internalError, message: "no result") + } + + private func start(_ router: Router, _ project: String) async throws -> ServerStatus { + try await handle( + router, .serverEnsure, + EnsureParams(name: "web", project: project, timeoutSeconds: 10), EnsureResult.self + ).server + } + + private func stop(_ router: Router, _ project: String) async { + _ = try? await handle( + router, .serverStop, ServerTargetParams(name: "web", project: project), + ServerResult.self) + } + + /** Sweeps from an armed baseline through the quiet window, which is what a + real daemon does at its own cadence. */ + private func settle(_ router: Router, from: Date) async -> [String] { + var restarted: [String] = [] + for offset in [3.0, 4.0, 6.0] { + restarted += await router.sweepWatches(now: from.addingTimeInterval(offset)) + } + return restarted + } + + @Test func aWatchedFileChangeRestartsTheServer() async throws { + let env = try env(port: 45420, watch: "app.config.json") + let registry = Registry(paths: env.paths) + try await registry.setTrusted(project: env.project) + let router = Router(launcher: SubprocessLauncher(), paths: env.paths, registry: registry) + let first = try await start(router, env.project) + let now = Date() + /** Arms the baseline once the run is past the settle window. */ + _ = await router.sweepWatches(now: now.addingTimeInterval(3)) + + try Data("v2\n".utf8) + .write(to: URL(fileURLWithPath: env.project).appending(path: "app.config.json")) + let restarted = await settle(router, from: now) + #expect(restarted.count == 1) + + let after = try await handle( + router, .serverStatus, ProjectParams(project: env.project), ServerListResult.self) + let server = try #require(after.servers.first) + #expect(server.phase == .running) + #expect(server.pid != first.pid) + await stop(router, env.project) + } + + /** The baseline is taken after the settle window, so a write that lands + while the server is still booting (very often the server generating its + own config) is folded into the baseline instead of bouncing it. */ + @Test func aWriteInsideTheSettleWindowDoesNotRestart() async throws { + let env = try env(port: 45426, watch: "app.config.json") + let registry = Registry(paths: env.paths) + try await registry.setTrusted(project: env.project) + let router = Router(launcher: SubprocessLauncher(), paths: env.paths, registry: registry) + let first = try await start(router, env.project) + let now = Date() + /** Written before any sweep gets past the settle window. */ + try Data("v2\n".utf8) + .write(to: URL(fileURLWithPath: env.project).appending(path: "app.config.json")) + #expect(await settle(router, from: now).isEmpty) + let after = try await handle( + router, .serverStatus, ProjectParams(project: env.project), ServerListResult.self) + #expect(try #require(after.servers.first).pid == first.pid) + await stop(router, env.project) + } + + /** The declare-nothing contract: a server whose framework reloads its own + config must behave exactly as it did before this feature existed. */ + @Test func aServerWithNoWatchIsNeverRestarted() async throws { + let env = try env(port: 45421, watch: nil) + let registry = Registry(paths: env.paths) + try await registry.setTrusted(project: env.project) + let router = Router(launcher: SubprocessLauncher(), paths: env.paths, registry: registry) + let first = try await start(router, env.project) + let now = Date() + _ = await router.sweepWatches(now: now.addingTimeInterval(3)) + try Data("v2\n".utf8) + .write(to: URL(fileURLWithPath: env.project).appending(path: "app.config.json")) + #expect(await settle(router, from: now).isEmpty) + + let after = try await handle( + router, .serverStatus, ProjectParams(project: env.project), ServerListResult.self) + #expect(try #require(after.servers.first).pid == first.pid) + await stop(router, env.project) + } + + /** A stopped server has no armed watch, so an edit does not resurrect one + somebody deliberately took down. */ + @Test func aStoppedServerIsNotResurrectedByAnEdit() async throws { + let env = try env(port: 45422, watch: "app.config.json") + let registry = Registry(paths: env.paths) + try await registry.setTrusted(project: env.project) + let router = Router(launcher: SubprocessLauncher(), paths: env.paths, registry: registry) + _ = try await start(router, env.project) + let now = Date() + _ = await router.sweepWatches(now: now.addingTimeInterval(3)) + await stop(router, env.project) + + try Data("v2\n".utf8) + .write(to: URL(fileURLWithPath: env.project).appending(path: "app.config.json")) + #expect(await settle(router, from: now).isEmpty) + let after = try await handle( + router, .serverStatus, ProjectParams(project: env.project), ServerListResult.self) + #expect(try #require(after.servers.first).phase == .stopped) + } + + /** A watch hit must not bounce a server a harness is holding a lock against, + and the pending edit must survive to fire once the hold releases. */ + @Test func aWatchHitUnderALiveLockIsDeferredNotDropped() async throws { + let env = try env(port: 45423, watch: "app.config.json", locks: true) + let registry = Registry(paths: env.paths) + try await registry.setTrusted(project: env.project) + let router = Router(launcher: SubprocessLauncher(), paths: env.paths, registry: registry) + let first = try await start(router, env.project) + let now = Date() + _ = await router.sweepWatches(now: now.addingTimeInterval(3)) + _ = try await handle( + router, .lockAcquire, + LockParams( + holderPid: Int(getpid()), pause: false, project: env.project, resource: "data", + resumeTimeoutSeconds: 10), LockResult.self) + + try Data("v2\n".utf8) + .write(to: URL(fileURLWithPath: env.project).appending(path: "app.config.json")) + #expect(await settle(router, from: now).isEmpty) + let held = try await handle( + router, .serverStatus, ProjectParams(project: env.project), ServerListResult.self) + #expect(try #require(held.servers.first).pid == first.pid) + + _ = try await handle( + router, .lockRelease, + LockParams( + holderPid: Int(getpid()), project: env.project, resource: "data", + resumeTimeoutSeconds: 10), LockResult.self) + /** Continue the same synthetic timeline: a fresh wall clock would be + earlier than the timestamps already injected, so the quiet window + would never elapse. The edit is still pending, so it fires now that + the hold is gone. */ + #expect(await settle(router, from: now.addingTimeInterval(10)).count == 1) + let resumed = try await handle( + router, .serverStatus, ProjectParams(project: env.project), ServerListResult.self) + #expect(try #require(resumed.servers.first).pid != first.pid) + await stop(router, env.project) + } + + @Test func theKillSwitchStopsTheSweepEntirely() async throws { + let env = try env(port: 45424, watch: "app.config.json") + let registry = Registry(paths: env.paths) + try await registry.setTrusted(project: env.project) + let router = Router( + launcher: SubprocessLauncher(), paths: env.paths, registry: registry, + watchEnabled: false) + let first = try await start(router, env.project) + let now = Date() + try Data("v2\n".utf8) + .write(to: URL(fileURLWithPath: env.project).appending(path: "app.config.json")) + #expect(await settle(router, from: now).isEmpty) + let after = try await handle( + router, .serverStatus, ProjectParams(project: env.project), ServerListResult.self) + #expect(try #require(after.servers.first).pid == first.pid) + await stop(router, env.project) + } + + /** A watch hit is not a spec change, so it must not also raise specStale: + the two answer different questions and conflating them would make a + vite.config edit look like a devservers.json edit. */ + @Test func aWatchRestartDoesNotSetSpecStale() async throws { + let env = try env(port: 45425, watch: "app.config.json") + let registry = Registry(paths: env.paths) + try await registry.setTrusted(project: env.project) + let router = Router(launcher: SubprocessLauncher(), paths: env.paths, registry: registry) + _ = try await start(router, env.project) + let now = Date() + _ = await router.sweepWatches(now: now.addingTimeInterval(3)) + try Data("v2\n".utf8) + .write(to: URL(fileURLWithPath: env.project).appending(path: "app.config.json")) + #expect(await settle(router, from: now).count == 1) + let after = try await handle( + router, .serverStatus, ProjectParams(project: env.project), ServerListResult.self) + #expect(try #require(after.servers.first).specStale != true) + await stop(router, env.project) + } + + private static func fixtureServerPath() -> String? { + let candidate = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appending(path: ".build/debug/fixture-server") + return FileManager.default.fileExists(atPath: candidate.path) ? candidate.path : nil + } +} diff --git a/Tests/DevCtlKitTests/WatchPolicyTests.swift b/Tests/DevCtlKitTests/WatchPolicyTests.swift new file mode 100644 index 0000000..c64ed4c --- /dev/null +++ b/Tests/DevCtlKitTests/WatchPolicyTests.swift @@ -0,0 +1,162 @@ +import Foundation +import Testing + +@testable import DevCtlKit + +@Suite struct WatchPolicyTests { + private let start = Date(timeIntervalSince1970: 1_752_868_000) + + private func print(_ entries: [String: (UInt64, Double, UInt64)]) -> WatchFingerprint { + WatchFingerprint( + stamps: entries.mapValues { + WatchStamp(inode: $0.0, modifiedAt: $0.1, size: $0.2) + }) + } + + @Test func noChangeIsIdle() { + let base = print(["/p/vite.config.ts": (1, 100, 10)]) + #expect( + WatchPolicy.decide( + baseline: base, now: start, observed: base, pending: nil, recentRestarts: []) + == .idle) + } + + /** One save touching three files must be one restart, not three. */ + @Test func aBurstOfEditsSettlesIntoASingleRestart() { + let base = print([ + "/p/a.ts": (1, 100, 10), "/p/b.ts": (2, 100, 10), "/p/c.ts": (3, 100, 10), + ]) + let first = print([ + "/p/a.ts": (1, 101, 11), "/p/b.ts": (2, 100, 10), "/p/c.ts": (3, 100, 10), + ]) + #expect( + WatchPolicy.decide( + baseline: base, now: start, observed: first, pending: nil, recentRestarts: []) + == .waiting(changed: ["/p/a.ts"])) + let second = print([ + "/p/a.ts": (1, 101, 11), "/p/b.ts": (2, 101, 11), "/p/c.ts": (3, 101, 11), + ]) + /** The fingerprint moved again, so the quiet window re-arms. */ + #expect( + WatchPolicy.decide( + baseline: base, now: start.addingTimeInterval(0.4), observed: second, + pending: (at: start, stamp: first), recentRestarts: []) + == .waiting(changed: ["/p/a.ts", "/p/b.ts", "/p/c.ts"])) + /** Held still past the window, it fires once for all three. */ + #expect( + WatchPolicy.decide( + baseline: base, now: start.addingTimeInterval(2), observed: second, + pending: (at: start.addingTimeInterval(0.4), stamp: second), recentRestarts: []) + == .restart(changed: ["/p/a.ts", "/p/b.ts", "/p/c.ts"])) + } + + @Test func aRevertInsideTheQuietWindowCancelsTheArmedRestart() { + let base = print(["/p/a.ts": (1, 100, 10)]) + let edited = print(["/p/a.ts": (1, 101, 11)]) + #expect( + WatchPolicy.decide( + baseline: base, now: start.addingTimeInterval(5), observed: base, + pending: (at: start, stamp: edited), recentRestarts: []) + == .idle) + } + + /** The atomic-replace case: write a temp file and rename it over the target, + which is how nearly every editor saves. Same mtime second, same size, new + inode; an mtime-only stamp would call this no change. */ + @Test func anAtomicReplaceWithIdenticalMtimeAndSizeIsAChange() { + let base = print(["/p/a.ts": (1, 100, 10)]) + let replaced = print(["/p/a.ts": (2, 100, 10)]) + #expect( + WatchPolicy.decide( + baseline: base, now: start.addingTimeInterval(2), observed: replaced, + pending: (at: start, stamp: replaced), recentRestarts: []) + == .restart(changed: ["/p/a.ts"])) + } + + @Test func aPathAppearingOrDisappearingIsAChange() { + let absent = WatchFingerprint() + let present = print(["/p/generated.json": (1, 100, 10)]) + #expect( + WatchPolicy.decide( + baseline: absent, now: start.addingTimeInterval(2), observed: present, + pending: (at: start, stamp: present), recentRestarts: []) + == .restart(changed: ["/p/generated.json"])) + #expect( + WatchPolicy.decide( + baseline: present, now: start.addingTimeInterval(2), observed: absent, + pending: (at: start, stamp: absent), recentRestarts: []) + == .restart(changed: ["/p/generated.json"])) + } + + @Test func aBurstOfRestartsSuspendsTheWatch() { + let base = print(["/p/a.ts": (1, 100, 10)]) + let edited = print(["/p/a.ts": (1, 101, 11)]) + let restarts = [ + start.addingTimeInterval(-3), start.addingTimeInterval(-2), start.addingTimeInterval(-1), + ] + #expect( + WatchPolicy.decide( + baseline: base, now: start.addingTimeInterval(2), observed: edited, + pending: (at: start, stamp: edited), recentRestarts: restarts) + == .suspend(changed: ["/p/a.ts"])) + } + + /** A long-lived server that restarted a few times over an afternoon is not + oscillating, so the breaker only counts a recent window. */ + @Test func restartsOutsideTheWindowDoNotCountTowardTheBurst() { + let base = print(["/p/a.ts": (1, 100, 10)]) + let edited = print(["/p/a.ts": (1, 101, 11)]) + let old = [ + start.addingTimeInterval(-300), start.addingTimeInterval(-200), + start.addingTimeInterval(-100), + ] + #expect( + WatchPolicy.decide( + baseline: base, now: start.addingTimeInterval(2), observed: edited, + pending: (at: start, stamp: edited), recentRestarts: old) + == .restart(changed: ["/p/a.ts"])) + } +} + +@Suite struct WatchPathsTests { + @Test func relativeEntriesResolveAgainstTheProjectRoot() { + let resolved = WatchPaths.resolve(entries: ["vite.config.ts"], project: "/Users/x/proj") + #expect(resolved.paths == ["/Users/x/proj/vite.config.ts"]) + #expect(resolved.warnings.isEmpty) + } + + @Test func globsAbsolutePathsAndEscapesAreDroppedWithAReason() { + let resolved = WatchPaths.resolve( + entries: ["**/*.ts", "/etc/hosts", "../outside.ts", ""], project: "/Users/x/proj") + #expect(resolved.paths.isEmpty) + #expect(resolved.warnings.count == 4) + #expect(resolved.warnings.contains { $0.contains("looks like a glob") }) + #expect(resolved.warnings.contains { $0.contains("is absolute") }) + #expect(resolved.warnings.contains { $0.contains("points outside the project") }) + #expect(resolved.warnings.contains { $0.contains("is empty") }) + } + + @Test func duplicatesCollapseAndSort() { + let resolved = WatchPaths.resolve( + entries: ["b.ts", "a.ts", "b.ts"], project: "/Users/x/proj") + #expect(resolved.paths == ["/Users/x/proj/a.ts", "/Users/x/proj/b.ts"]) + } + + @Test func aDirectoryIsDroppedWithAReason() throws { + let dir = FileManager.default.temporaryDirectory + .appending(path: "devctl-watch-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: dir.appending(path: "sub"), withIntermediateDirectories: true) + let resolved = WatchPaths.resolve(entries: ["sub"], project: dir.path) + #expect(resolved.paths.isEmpty) + #expect(resolved.warnings.contains { $0.contains("is a directory") }) + } + + /** A config a build step has not produced yet is legal, and its appearance + is the change worth restarting for. */ + @Test func aPathThatDoesNotExistYetIsKept() { + let resolved = WatchPaths.resolve(entries: ["generated.json"], project: "/Users/x/proj") + #expect(resolved.paths == ["/Users/x/proj/generated.json"]) + #expect(resolved.warnings.isEmpty) + } +} diff --git a/docs/cli-contract.md b/docs/cli-contract.md index fe6e19f..31b32a4 100644 --- a/docs/cli-contract.md +++ b/docs/cli-contract.md @@ -70,6 +70,8 @@ Filled in per phase as each lands; golden tests reference the examples in this f - `devctl link [head] [--json]` → prints a `devctl://` URL for the cwd project (`devctl://ensure//`, etc.). Verbs: `open`, `ensure`, `stop`, `why`. `--json` → `{url}`. For Raycast/Shortcuts/docs; the menu bar app handles the same URLs via Launch Services. - `devctl x-url [--json]` (hidden): dispatches a `devctl://` URL through the same `DeepLinkRunner` the app uses (no Launch Services). Smoke/CI entry. Success → `DeepLinkRunResult` `{verb, projectPath, detail?}`; bad URL / unknown slug → usage/not-found. - `devctl config check --json` → `{errors, host, servers, warnings}` from the daemon's own validator (cycles, unknown dependencies, and unresolvable `heads` / `healthcheck.url` values are errors; duplicate declared ports, unknown versions, an http healthcheck with no url, and bare-loopback hosts and heads are warnings); errors ⇒ exit 1. Name servers after the project (not a generic `web`) and give each a `.localhost` host, not bare `localhost`: the subdomain keeps browser cookies/storage/service workers isolated per project. +- `devctl restart | --all [--port P] [--timeout 60] --json` → `{results: [{reason?, server}]}`. Stops and re-ensures as one daemon-side transition, so no other session's `ensure` lands in between and a refusal (a held resource, a paused server, a config that no longer parses) arrives before anything stops rather than after. The stop is non-retiring, so resume-on-boot survives it. A bare `restart` with neither a name nor `--all` is a usage error; any `reason` ⇒ exit 1. +- A server may declare `watch`: project-relative files it reads at boot and does not reload itself (`"watch": ["vite.config.ts"]`). A change to one restarts that server. A server whose framework already reloads its own config declares nothing and behaves exactly as before. Paths are literal, relative to the project root, and may not exist yet (their appearance is a change); a glob, an absolute path, a path outside the project, or a directory is a `config check` warning and is ignored. The baseline is taken after the server has been up for a settle window, so a config the server writes during its own boot does not bounce it, and a change must hold still for a quiet window, so one save touching several files is one restart. A restart is refused while a resource the server declares is held, and the pending change fires when that hold releases rather than being dropped. Repeated restarts inside a short window suspend the watch with a `sys` log line naming the paths, since devctl cannot tell a server rewriting its own file from a person saving repeatedly; an explicit `devctl restart` re-arms it. `DEVCTL_NO_WATCH=1` disables the sweep for the whole daemon. - `devctl config init [--dry-run] [--force] [--host H] [--name N --cmd word … [--port P]] --json` → `{check: CheckResult, content, notRecovered?, path, written}`. Writes devservers.json from the servers the daemon already knows, which is the way back from losing a gitignored file. Refuses an existing file with `already-exists` unless `--force`; `--dry-run` returns the content and writes nothing. The projection writes only what the file declares: an effective or rebound port, a worktree-derived host, a materialized url, an absolute icon path, and the port and host keys devctl injects into the environment are all dropped, so the file stays portable to another checkout on another machine. `lifecycle` has no runtime counterpart and cannot be recovered; it is named in `notRecovered` rather than silently lost. The written file is indented, unlike every wire frame. - `devctl config check --json` also reports `effectiveHost` and `effectiveHostReason` (`linked-worktree`, `local-overlay`, `server-override`) when a start from this directory would use a host other than the declared one, plus `serverHosts` for the servers that differ from the project. A linked worktree gets an ephemeral `worktree-