diff --git a/MATRIX.md b/MATRIX.md index 21d4ff3..f4eb2eb 100644 --- a/MATRIX.md +++ b/MATRIX.md @@ -17,6 +17,8 @@ Expected behaviors and their verification status. This is the project's oracle | Status | af status displays Recent section with exited agents | Manual: `af status` (daemon required) | manual | 2026-02-10 | | Events | Session ID captured from plugin session.created event | `go test ./internal/daemon/... -run TestClaimSessionPoolAgent` | covered | 2026-02-19 | | Status | Agent detail shows opencode session ID in TUI meta pane | Manual: TUI agent detail view | manual | 2026-02-10 | +| Control Center | Manual daemon targeting defaults to the single global endpoint without project hashing | `cd macos/ControlCenter && swift test --filter ShellBootstrapTests` | covered | 2026-03-14 | +| Control Center | App-triggered daemon start uses manual mode with an aligned listen address | `cd macos/ControlCenter && swift test --filter DaemonControlTests` | covered | 2026-03-14 | | Agent Control | af kill sends SIGTERM and validates agent state | `go test ./internal/daemon/... -run TestHandleAgentKill` | covered | 2026-02-10 | | Agent Control | Kill rejects invalid PIDs (0 or negative) | `go test ./internal/daemon/... -run TestHandleAgentKillInvalidPID` | covered | 2026-02-10 | | Agent Control | Kill rejects non-running agents | `go test ./internal/daemon/... -run TestHandleAgentKillNonRunningAgent` | covered | 2026-02-10 | diff --git a/cmd/af/cmd/daemon.go b/cmd/af/cmd/daemon.go index 4f8822e..8c7d3fc 100644 --- a/cmd/af/cmd/daemon.go +++ b/cmd/af/cmd/daemon.go @@ -64,6 +64,9 @@ func buildConfig(cmd *cobra.Command) daemon.Config { if cmd.Flags().Changed("project") { cfg.Project, _ = cmd.Flags().GetString("project") } + if cmd.Flags().Changed("listen-addr") { + cfg.ListenAddr, _ = cmd.Flags().GetString("listen-addr") + } if cmd.Flags().Changed("poll-interval") { cfg.PollInterval, _ = cmd.Flags().GetDuration("poll-interval") } @@ -114,7 +117,7 @@ func startDetached(cmd *cobra.Command) { // Forward all flags except --detach. reArgs := []string{"daemon", "start"} - for _, name := range []string{"project", "poll-interval", "pool-size", "spawn-cmd", "server-url", "spawn-policy", "max-retries", "solo", "config"} { + for _, name := range []string{"project", "listen-addr", "poll-interval", "pool-size", "spawn-cmd", "server-url", "spawn-policy", "max-retries", "solo", "config"} { if cmd.Flags().Changed(name) { val, _ := cmd.Flags().GetString(name) // Duration and int flags also work with GetString via pflag. @@ -169,6 +172,7 @@ func init() { f := daemonStartCmd.Flags() f.BoolP("detach", "d", false, "Run in background") f.StringP("project", "p", "", "Project to watch for tasks (required for --spawn-policy=auto)") + f.String("listen-addr", "", "Daemon listen address override (for example 127.0.0.1:7070)") f.Duration("poll-interval", daemon.DefaultPollInterval, "How often to poll prog for tasks") f.Int("pool-size", daemon.DefaultPoolSize, "Maximum concurrent agent slots") f.String("spawn-cmd", daemon.DefaultSpawnCmd, "Command to launch agent sessions") diff --git a/cmd/af/cmd/daemon_test.go b/cmd/af/cmd/daemon_test.go index c8b3561..2198d4c 100644 --- a/cmd/af/cmd/daemon_test.go +++ b/cmd/af/cmd/daemon_test.go @@ -2,8 +2,11 @@ package cmd import ( "bytes" + "path/filepath" "strings" "testing" + + "github.com/spf13/cobra" ) func TestPrintDaemonNotRunning(t *testing.T) { @@ -18,3 +21,24 @@ func TestPrintDaemonNotRunning(t *testing.T) { t.Fatalf("output = %q, want start hint", out) } } + +func TestBuildConfigUsesExplicitListenAddrOverride(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().String("listen-addr", "", "") + cmd.Flags().String("config", "", "") + + if err := cmd.Flags().Set("listen-addr", "127.0.0.1:7099"); err != nil { + t.Fatalf("set listen-addr: %v", err) + } + if err := cmd.Flags().Set("config", filepath.Join(t.TempDir(), "missing.yaml")); err != nil { + t.Fatalf("set config: %v", err) + } + + cfg := buildConfig(cmd) + if cfg.ListenAddr != "127.0.0.1:7099" { + t.Fatalf("listen_addr = %q, want 127.0.0.1:7099", cfg.ListenAddr) + } + if cfg.SpawnPolicy != "manual" { + t.Fatalf("spawn_policy = %q, want manual", cfg.SpawnPolicy) + } +} diff --git a/docs/solutions/handoffs/macos-manual-daemon-targeting-20260314.md b/docs/solutions/handoffs/macos-manual-daemon-targeting-20260314.md new file mode 100644 index 0000000..16e9466 --- /dev/null +++ b/docs/solutions/handoffs/macos-manual-daemon-targeting-20260314.md @@ -0,0 +1,69 @@ +# macOS Manual Daemon Targeting + +**Date**: 2026-03-14 +**Task**: ts-2df50e + +## Context + +This ticket follows `ts-27d3dd`, which made manual daemon addressing global by default in the CLI/backend. The macOS Control Center needed to adopt that contract so a globally installed app can connect to manual mode without knowing a project-derived daemon URL or depending on repo cwd. + +## What Was Done + +1. Updated bootstrap resolution in `macos/ControlCenter/Sources/AetherflowControlCenter/ShellBootstrap.swift` + - default daemon target is now the global manual endpoint + - explicit loopback overrides still win + - bootstrap now records the daemon target source, reason, and normalized listen address + +2. Updated app-triggered start in `macos/ControlCenter/Sources/AetherflowControlCenter/DaemonControl.swift` + - app now starts the daemon with `--spawn-policy manual` + - app forwards `--listen-addr` matching the resolved daemon target so start/probe/monitoring stay aligned + +3. Added CLI support in `cmd/af/cmd/daemon.go` + - `af daemon start` now accepts `--listen-addr` + - detached re-exec forwards the flag correctly + +4. Expanded diagnostics and operator visibility + - transport/monitoring notes explain why the app chose its daemon endpoint + - lifecycle and monitoring notes now call out non-manual daemons and daemon URL mismatches + - diagnostics view renders the daemon target reason directly + +5. Added regression coverage + - Swift tests cover global manual defaults, explicit overrides, start invocation, and mismatch diagnostics + - Go test covers `buildConfig` honoring `--listen-addr` + +## What Was Tried That Didn't Work + +The original app behavior reused project hashing from the CLI’s old auto-oriented path. That was the root of the unreachable-daemon report: the app and daemon could both be “correct” relative to their own targeting rules while still speaking to different endpoints. The fix had to align the start path as well as the read path. + +## Key Decisions + +- The app now treats manual mode as the default operator contract; project name remains display context, not the default transport key. +- The app intentionally targets only the single global manual daemon by default. Connecting to any other manual daemon requires an explicit loopback override. +- Explicit loopback daemon overrides remain supported, but they are treated as manual endpoint overrides, not as a signal to re-enter project hashing. +- A small CLI addition (`--listen-addr`) was included because it is the simplest way to preserve alignment during app-triggered daemon starts. + +## Files Modified + +- `macos/ControlCenter/Sources/AetherflowControlCenter/ShellBootstrap.swift` +- `macos/ControlCenter/Sources/AetherflowControlCenter/DaemonControl.swift` +- `macos/ControlCenter/Sources/AetherflowControlCenter/ShellModels.swift` +- `macos/ControlCenter/Sources/AetherflowControlCenter/ShellStores.swift` +- `macos/ControlCenter/Sources/AetherflowControlCenter/MonitoringStore.swift` +- `macos/ControlCenter/Sources/AetherflowControlCenter/ControlCenterRootView.swift` +- `macos/ControlCenter/Tests/AetherflowControlCenterTests/ShellBootstrapTests.swift` +- `macos/ControlCenter/Tests/AetherflowControlCenterTests/DaemonControlTests.swift` +- `macos/ControlCenter/Tests/AetherflowControlCenterTests/DaemonLifecycleStoreTests.swift` +- `macos/ControlCenter/Tests/AetherflowControlCenterTests/MonitoringStoreTests.swift` +- `cmd/af/cmd/daemon.go` +- `cmd/af/cmd/daemon_test.go` +- `MATRIX.md` + +## Verification + +- `swift test` +- `go test ./cmd/af/cmd` +- `go test ./...` +- `go build ./...` +- `go vet ./...` +- `golangci-lint run` +- `git diff --check` diff --git a/docs/solutions/learnings.md b/docs/solutions/learnings.md index 5d38bea..6f4b6e5 100644 --- a/docs/solutions/learnings.md +++ b/docs/solutions/learnings.md @@ -1,3 +1,7 @@ ### daemon-routing: explicit `--project` is still a transport override Manual mode can be global by default without making `--project` inert. In this CLI, operators use `--project` on non-start commands as the only ad hoc way to reach a project-scoped daemon. Resolver changes need to preserve that explicit routing path even if the default manual endpoint becomes global. + +### macos-control-center: align daemon start with the resolved probe target + +Changing the app’s default daemon URL is not enough on its own. If the app can also start the daemon, the start command must carry the same resolved endpoint or the app can still boot one daemon and probe another. Passing the normalized listen address through the start path keeps manual-mode bootstrap coherent. diff --git a/docs/solutions/logic-errors/macos-manual-daemon-targeting-20260314.md b/docs/solutions/logic-errors/macos-manual-daemon-targeting-20260314.md new file mode 100644 index 0000000..b9d5bf7 --- /dev/null +++ b/docs/solutions/logic-errors/macos-manual-daemon-targeting-20260314.md @@ -0,0 +1,45 @@ +--- +module: macos-control-center +date: 2026-03-14 +problem_type: logic_error +component: daemon-targeting +symptoms: + - "the macOS app reports the daemon as unreachable even though the daemon started successfully" + - "manual-mode monitoring requires operators to know a project-hashed daemon URL ahead of time" + - "app-triggered daemon start can launch a daemon on a different endpoint than the app probes" +root_cause: the app reused project-scoped daemon hashing for manual-mode defaults and did not preserve explicit endpoint overrides during daemon start +resolution_type: code_fix +severity: medium +tags: + - macos + - control-center + - daemon + - manual-mode + - bootstrap + - diagnostics +--- + +# macOS Manual Daemon Targeting + +## Problem + +The globally installed Control Center app was deriving its daemon endpoint the same way auto mode does: from repo/project identity. That is the wrong default for manual mode, where the operator should be able to open the app and connect to the single global manual daemon without precomputing a project-specific URL. + +This implementation intentionally scopes the app to the single global manual daemon by default. Project-scoped manual daemons are not auto-discovered by the app; they require an explicit loopback override. + +## What Didn't Work + +The app already had explicit daemon URL overrides, but the default path still hashed the project name. That meant the app could probe one loopback URL while `af daemon start` brought up a daemon on another. Fixing only the probe side would still leave app-triggered start misaligned. + +## Solution + +1. Make the app default to the global manual daemon URL (`http://127.0.0.1:7070`) unless an explicit loopback override is provided. +2. Track why the daemon target was chosen, and surface that reason in transport and diagnostics views. +3. Start the daemon with `af daemon start --detach --spawn-policy manual --listen-addr ` so app-triggered start, lifecycle probing, and monitoring all converge on the same endpoint. +4. Add `--listen-addr` support to `af daemon start` so the macOS app can preserve explicit endpoint overrides without inventing temp config files. + +## Prevention + +- Treat the macOS app as a global operator tool, not a repo-local shell wrapper. +- Keep manual daemon defaults project-agnostic and global; only explicit endpoint overrides should change the target. +- When the app chooses a daemon endpoint, record both the chosen URL and the reason so mismatch reports are debuggable. diff --git a/macos/ControlCenter/Sources/AetherflowControlCenter/ControlCenterRootView.swift b/macos/ControlCenter/Sources/AetherflowControlCenter/ControlCenterRootView.swift index 0aa4061..c5eae89 100644 --- a/macos/ControlCenter/Sources/AetherflowControlCenter/ControlCenterRootView.swift +++ b/macos/ControlCenter/Sources/AetherflowControlCenter/ControlCenterRootView.swift @@ -618,6 +618,7 @@ private struct SectionPreview: View { DiagnosticRow(label: "project", value: transport.projectName) DiagnosticRow(label: "cwd", value: transport.workingDirectory) DiagnosticRow(label: "daemon_url", value: transport.daemonURL) + DiagnosticRow(label: "daemon_reason", value: transport.daemonTargetReason) DiagnosticRow(label: "cli", value: transport.cliPath) DiagnosticRow(label: "lifecycle", value: lifecycle.phase.rawValue.lowercased()) DiagnosticRow(label: "note", value: transport.note) diff --git a/macos/ControlCenter/Sources/AetherflowControlCenter/DaemonControl.swift b/macos/ControlCenter/Sources/AetherflowControlCenter/DaemonControl.swift index 903458d..f32ece3 100644 --- a/macos/ControlCenter/Sources/AetherflowControlCenter/DaemonControl.swift +++ b/macos/ControlCenter/Sources/AetherflowControlCenter/DaemonControl.swift @@ -518,17 +518,34 @@ private struct RPCEnvelope: Decodable { let error: String? } -private struct CommandOutput: Sendable { +struct CommandOutput: Sendable { let status: Int32 let stdout: String let stderr: String } +struct CommandInvocation: Sendable { + let executable: String + let arguments: [String] + let currentDirectory: String +} + struct DefaultDaemonController: DaemonControlling, Sendable { private let session: URLSession + private let commandRunner: @Sendable (CommandInvocation) throws -> CommandOutput - init(session: URLSession = DefaultDaemonController.makeSession()) { + init( + session: URLSession = DefaultDaemonController.makeSession(), + commandRunner: @escaping @Sendable (CommandInvocation) throws -> CommandOutput = { invocation in + try DefaultDaemonController.runCommand( + executable: invocation.executable, + arguments: invocation.arguments, + currentDirectory: invocation.currentDirectory + ) + } + ) { self.session = session + self.commandRunner = commandRunner } func fetchLifecycle(daemonURL: String) async throws -> DaemonLifecyclePayload { @@ -593,12 +610,9 @@ struct DefaultDaemonController: DaemonControlling, Sendable { } func requestStart(context: ShellBootstrapContext) async throws -> DaemonStartReceipt { + let invocation = Self.startInvocation(for: context) let output = try await Self.runBlocking { - try Self.runCommand( - executable: context.cliPath, - arguments: ["daemon", "start", "--detach", "--project", context.projectName], - currentDirectory: context.workingDirectory - ) + try commandRunner(invocation) } guard output.status == 0 else { throw DaemonControlError.commandFailed(Self.commandFailureMessage(output)) @@ -607,6 +621,18 @@ struct DefaultDaemonController: DaemonControlling, Sendable { return DaemonStartReceipt(message: message) } + static func startInvocation(for context: ShellBootstrapContext) -> CommandInvocation { + var arguments = ["daemon", "start", "--detach", "--spawn-policy", "manual"] + if let listenAddr = context.daemonListenAddressOverride?.nonEmptyTrimmed { + arguments.append(contentsOf: ["--listen-addr", listenAddr]) + } + return CommandInvocation( + executable: context.cliPath, + arguments: arguments, + currentDirectory: context.workingDirectory + ) + } + private func decodeEnvelope(_ data: Data, response: URLResponse) throws -> T { guard let httpResponse = response as? HTTPURLResponse else { throw DaemonControlError.invalidResponse("unexpected non-HTTP response") diff --git a/macos/ControlCenter/Sources/AetherflowControlCenter/MonitoringStore.swift b/macos/ControlCenter/Sources/AetherflowControlCenter/MonitoringStore.swift index 1a4d260..8b65afe 100644 --- a/macos/ControlCenter/Sources/AetherflowControlCenter/MonitoringStore.swift +++ b/macos/ControlCenter/Sources/AetherflowControlCenter/MonitoringStore.swift @@ -110,7 +110,7 @@ struct MonitoringSnapshot: Equatable, Sendable { queue: [], selectedWorkloadID: nil, selectedDetail: nil, - note: "Waiting for the daemon monitoring endpoint to answer.", + note: "\(context.daemonTargetReason) Waiting for the daemon monitoring endpoint to answer.", lastError: nil, updatedAt: .now ) @@ -490,12 +490,15 @@ final class MonitoringStore: ObservableObject { } private func monitoringNote(for status: DaemonStatusPayload, workloadCount: Int) -> String { - var parts = ["Monitoring connected for \(status.project.nonEmptyValue ?? context.projectName)."] + var parts = ["Monitoring connected for \(status.project.nonEmptyValue ?? context.projectName) at \(context.daemonURL)."] if let poolMode = status.poolMode.nonEmptyValue { parts.append("Pool mode: \(poolMode).") } if let spawnPolicy = status.spawnPolicy.nonEmptyValue { parts.append("Spawn policy: \(spawnPolicy).") + if let warning = nonManualDaemonWarning(for: spawnPolicy) { + parts.append(warning) + } } parts.append("Visible workloads: \(workloadCount).") return parts.joined(separator: " ") diff --git a/macos/ControlCenter/Sources/AetherflowControlCenter/ShellBootstrap.swift b/macos/ControlCenter/Sources/AetherflowControlCenter/ShellBootstrap.swift index 912fb61..10196be 100644 --- a/macos/ControlCenter/Sources/AetherflowControlCenter/ShellBootstrap.swift +++ b/macos/ControlCenter/Sources/AetherflowControlCenter/ShellBootstrap.swift @@ -5,6 +5,24 @@ struct ShellBootstrapContext: Equatable, Sendable { let workingDirectory: String let daemonURL: String let cliPath: String + let daemonTargetReason: String + let daemonListenAddressOverride: String? + + init( + projectName: String, + workingDirectory: String, + daemonURL: String, + cliPath: String, + daemonTargetReason: String = "Defaulting to the global manual daemon endpoint.", + daemonListenAddressOverride: String? = nil + ) { + self.projectName = projectName + self.workingDirectory = workingDirectory + self.daemonURL = daemonURL + self.cliPath = cliPath + self.daemonTargetReason = daemonTargetReason + self.daemonListenAddressOverride = daemonListenAddressOverride ?? Self.listenAddrFromDaemonURL(daemonURL) + } static func detect( environment: [String: String] = ProcessInfo.processInfo.environment, @@ -17,13 +35,21 @@ struct ShellBootstrapContext: Equatable, Sendable { let projectName = environment["AETHERFLOW_PROJECT"]?.trimmedNonEmpty ?? configuredProjectName ?? fallbackProjectName - let daemonURL = validatedLoopbackDaemonURL(environment["AETHERFLOW_DAEMON_URL"]) - ?? configuredValues.daemonURL - ?? defaultDaemonURL(for: projectName) + let daemonTarget = resolvedDaemonTarget( + environmentDaemonURL: environment["AETHERFLOW_DAEMON_URL"], + configuredValues: configuredValues + ) let cliPath = environment["AETHERFLOW_CLI_PATH"]?.trimmedNonEmpty ?? defaultCLIPath(for: workingDirectory, pathEnvironment: environment["PATH"]) ?? "af" - return Self(projectName: projectName, workingDirectory: workingDirectory, daemonURL: daemonURL, cliPath: cliPath) + return Self( + projectName: projectName, + workingDirectory: workingDirectory, + daemonURL: daemonTarget.url, + cliPath: cliPath, + daemonTargetReason: daemonTarget.reason, + daemonListenAddressOverride: daemonTarget.listenAddress + ) } static func defaultProjectName(for currentDirectoryPath: String) -> String { @@ -34,17 +60,8 @@ struct ShellBootstrapContext: Equatable, Sendable { return lastComponent } - /// Returns the daemon HTTP URL for the given project name, using the same - /// FNV-1a port-hashing scheme as the Go `protocol.DaemonURLFor` function. - /// Empty project → "http://127.0.0.1:7070" (the default port). - /// Non-empty project → "http://127.0.0.1:<7071–7170>" (hashed range). - static func defaultDaemonURL(for projectName: String) -> String { - if projectName.isEmpty { - return "http://127.0.0.1:7070" - } - let hash = fnv1a32(projectName) - let port = 7070 + 1 + Int(hash % 100) - return "http://127.0.0.1:\(port)" + static func defaultManualDaemonURL() -> String { + "http://127.0.0.1:7070" } static func defaultCLIPath(for workingDirectory: String, pathEnvironment: String?) -> String? { @@ -65,15 +82,16 @@ struct ShellBootstrapContext: Equatable, Sendable { return nil } - static func configuredValues(for workingDirectory: String) -> (projectName: String?, daemonURL: String?) { + static func configuredValues(for workingDirectory: String) -> (projectName: String?, daemonURL: String?, listenAddress: String?) { let configPath = URL(fileURLWithPath: workingDirectory).appendingPathComponent(".aetherflow.yaml").path guard let data = FileManager.default.contents(atPath: configPath), let contents = String(data: data, encoding: .utf8) else { - return (nil, nil) + return (nil, nil, nil) } var projectName: String? var daemonURL: String? + var listenAddress: String? for line in contents.split(whereSeparator: \.isNewline) { let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix("project:") { @@ -84,41 +102,69 @@ struct ShellBootstrapContext: Equatable, Sendable { if trimmed.hasPrefix("listen_addr:") { let value = trimmed.dropFirst("listen_addr:".count).trimmingCharacters(in: .whitespaces) let unquoted = value.trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) - daemonURL = daemonURLFromListenAddr(unquoted) + if let target = daemonTargetFromListenAddr(unquoted) { + listenAddress = target.listenAddress + daemonURL = target.url + } } } - return (projectName, daemonURL) + return (projectName, daemonURL, listenAddress) } - static func daemonURLFromListenAddr(_ listenAddr: String) -> String? { - guard !listenAddr.isEmpty else { + static func daemonTargetFromListenAddr(_ listenAddr: String) -> (url: String, listenAddress: String)? { + let trimmed = listenAddr.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } - guard let hostRange = listenAddr.lastIndex(of: ":") else { + + let normalizedListenAddr = trimmed.hasPrefix(":") ? "127.0.0.1\(trimmed)" : trimmed + let candidateURL = "http://\(normalizedListenAddr)" + guard let daemonURL = validatedLoopbackDaemonURL(candidateURL), + let normalizedListenAddress = listenAddrFromDaemonURL(daemonURL) else { return nil } - let hostPart = String(listenAddr[.. String? { + guard let rawValue = validatedLoopbackDaemonURL(rawValue), + let url = URL(string: rawValue), + let host = url.host, + let port = url.port else { return nil } - let host = hostPart.isEmpty ? "127.0.0.1" : hostPart - let normalizedHost = host == "localhost" || host == "127.0.0.1" || host == "::1" || host == "[::1]" ? host : "" - guard !normalizedHost.isEmpty else { - return nil + if host == "::1" { + return "[::1]:\(port)" } - let urlHost = normalizedHost == "::1" ? "[::1]" : normalizedHost - return "http://\(urlHost):\(portPart)" + return "\(host):\(port)" } - /// FNV-1a 32-bit hash — matches the Go simpleHash function in protocol/daemon_url.go. - private static func fnv1a32(_ s: String) -> UInt32 { - var h: UInt32 = 2166136261 - for byte in s.utf8 { - h ^= UInt32(byte) - h = h &* 16777619 + private static func resolvedDaemonTarget( + environmentDaemonURL: String?, + configuredValues: (projectName: String?, daemonURL: String?, listenAddress: String?) + ) -> (url: String, reason: String, listenAddress: String?) { + if let daemonURL = validatedLoopbackDaemonURL(environmentDaemonURL), + let listenAddress = listenAddrFromDaemonURL(daemonURL) { + return ( + daemonURL, + "Using the explicit AETHERFLOW_DAEMON_URL override for manual daemon monitoring.", + listenAddress + ) + } + if let daemonURL = configuredValues.daemonURL, + let listenAddress = configuredValues.listenAddress?.trimmedNonEmpty { + return ( + daemonURL, + "Using listen_addr from .aetherflow.yaml for manual daemon monitoring.", + listenAddress + ) } - return h + let defaultURL = defaultManualDaemonURL() + return ( + defaultURL, + "No daemon override was provided, so the app is targeting the global manual daemon endpoint.", + listenAddrFromDaemonURL(defaultURL) + ) } } diff --git a/macos/ControlCenter/Sources/AetherflowControlCenter/ShellModels.swift b/macos/ControlCenter/Sources/AetherflowControlCenter/ShellModels.swift index 7bdefab..ede18f7 100644 --- a/macos/ControlCenter/Sources/AetherflowControlCenter/ShellModels.swift +++ b/macos/ControlCenter/Sources/AetherflowControlCenter/ShellModels.swift @@ -203,6 +203,7 @@ struct TransportSnapshot { let workingDirectory: String let daemonURL: String let cliPath: String + let daemonTargetReason: String let note: String } @@ -224,3 +225,12 @@ struct DaemonLifecycleSnapshot { let lastError: String? let updatedAt: Date } + +func nonManualDaemonWarning(for spawnPolicy: String?) -> String? { + guard let normalizedPolicy = spawnPolicy?.trimmingCharacters(in: .whitespacesAndNewlines), + !normalizedPolicy.isEmpty, + normalizedPolicy != "manual" else { + return nil + } + return "The app is pointed at a non-manual daemon; manual monitoring defaults are overridden." +} diff --git a/macos/ControlCenter/Sources/AetherflowControlCenter/ShellStores.swift b/macos/ControlCenter/Sources/AetherflowControlCenter/ShellStores.swift index 257392e..96445de 100644 --- a/macos/ControlCenter/Sources/AetherflowControlCenter/ShellStores.swift +++ b/macos/ControlCenter/Sources/AetherflowControlCenter/ShellStores.swift @@ -12,7 +12,8 @@ final class TransportStore: ObservableObject { workingDirectory: context.workingDirectory, daemonURL: context.daemonURL, cliPath: context.cliPath, - note: "Shell bootstrap is resolved. Waiting for the first lifecycle probe." + daemonTargetReason: context.daemonTargetReason, + note: "\(context.daemonTargetReason) Waiting for the first lifecycle probe." ) } @@ -23,6 +24,7 @@ final class TransportStore: ObservableObject { workingDirectory: snapshot.workingDirectory, daemonURL: snapshot.daemonURL, cliPath: snapshot.cliPath, + daemonTargetReason: snapshot.daemonTargetReason, note: note ) } @@ -113,7 +115,7 @@ final class DaemonLifecycleStore: ObservableObject { banner = nil appendDiagnostic( title: "Start requested", - detail: "Launching daemon from \(context.cliPath) in \(context.workingDirectory).", + detail: "Launching a manual daemon from \(context.cliPath) in \(context.workingDirectory) for \(context.daemonURL). \(context.daemonTargetReason)", tone: .info ) @@ -375,9 +377,15 @@ final class DaemonLifecycleStore: ObservableObject { } private func transportNote(for lifecycle: DaemonLifecyclePayload) -> String { - var parts = ["Lifecycle probe succeeded for \(lifecycle.project.nonEmptyValue ?? context.projectName)."] + var parts = ["Lifecycle probe succeeded for \(lifecycle.project.nonEmptyValue ?? context.projectName) at \(context.daemonURL)."] if let spawnPolicy = lifecycle.spawnPolicy.nonEmptyValue { parts.append("Spawn policy: \(spawnPolicy).") + if let warning = nonManualDaemonWarning(for: spawnPolicy) { + parts.append(warning) + } + } + if let reportedURL = lifecycle.daemonURL.nonEmptyValue, reportedURL != context.daemonURL { + parts.append("Daemon reported \(reportedURL), which differs from the app target.") } if lifecycle.activeSessionCount > 0 { parts.append("Attached sessions: \(lifecycle.activeSessionCount).") diff --git a/macos/ControlCenter/Tests/AetherflowControlCenterTests/DaemonControlTests.swift b/macos/ControlCenter/Tests/AetherflowControlCenterTests/DaemonControlTests.swift index e905cd3..b3758a3 100644 --- a/macos/ControlCenter/Tests/AetherflowControlCenterTests/DaemonControlTests.swift +++ b/macos/ControlCenter/Tests/AetherflowControlCenterTests/DaemonControlTests.swift @@ -223,6 +223,53 @@ final class DaemonControlTests: XCTestCase { ] ) } + + func testRequestStartUsesGlobalManualDaemonDefaults() async throws { + let capturedInvocation = CommandInvocationBox() + let controller = DefaultDaemonController( + session: URLSession(configuration: .ephemeral), + commandRunner: { invocation in + capturedInvocation.value = invocation + return CommandOutput(status: 0, stdout: "daemon started", stderr: "") + } + ) + + _ = try await controller.requestStart( + context: ShellBootstrapContext( + projectName: "control-room", + workingDirectory: "/tmp/control-room", + daemonURL: "http://127.0.0.1:7070", + cliPath: "/usr/local/bin/af" + ) + ) + + XCTAssertEqual(capturedInvocation.value?.arguments, ["daemon", "start", "--detach", "--spawn-policy", "manual", "--listen-addr", "127.0.0.1:7070"]) + XCTAssertEqual(capturedInvocation.value?.currentDirectory, "/tmp/control-room") + } + + func testRequestStartPreservesExplicitListenAddrOverride() async throws { + let capturedInvocation = CommandInvocationBox() + let controller = DefaultDaemonController( + session: URLSession(configuration: .ephemeral), + commandRunner: { invocation in + capturedInvocation.value = invocation + return CommandOutput(status: 0, stdout: "daemon started", stderr: "") + } + ) + + _ = try await controller.requestStart( + context: ShellBootstrapContext( + projectName: "control-room", + workingDirectory: "/tmp/control-room", + daemonURL: "http://127.0.0.1:7099", + cliPath: "/usr/local/bin/af", + daemonTargetReason: "Using explicit override.", + daemonListenAddressOverride: "127.0.0.1:7099" + ) + ) + + XCTAssertEqual(capturedInvocation.value?.arguments, ["daemon", "start", "--detach", "--spawn-policy", "manual", "--listen-addr", "127.0.0.1:7099"]) + } } // MARK: - Helpers @@ -270,3 +317,7 @@ final class MockHTTPProtocol: URLProtocol, @unchecked Sendable { override func stopLoading() {} } + +private final class CommandInvocationBox: @unchecked Sendable { + var value: CommandInvocation? +} diff --git a/macos/ControlCenter/Tests/AetherflowControlCenterTests/DaemonLifecycleStoreTests.swift b/macos/ControlCenter/Tests/AetherflowControlCenterTests/DaemonLifecycleStoreTests.swift index 163bca6..45f79c0 100644 --- a/macos/ControlCenter/Tests/AetherflowControlCenterTests/DaemonLifecycleStoreTests.swift +++ b/macos/ControlCenter/Tests/AetherflowControlCenterTests/DaemonLifecycleStoreTests.swift @@ -201,6 +201,46 @@ final class DaemonLifecycleStoreTests: XCTestCase { XCTAssertEqual(context.daemonURL, "http://127.0.0.1:7099") } + func testRefreshDiagnosticsCallOutNonManualDaemonTargetMismatch() async throws { + let controller = FakeDaemonController( + lifecycleResult: .success( + DaemonLifecyclePayload( + state: "running", + daemonURL: "http://127.0.0.1:7103", + project: "control-room", + serverURL: "http://127.0.0.1:4096", + spawnPolicy: "auto", + activeSessionCount: 0, + activeSessionIDs: [], + lastError: "", + updatedAt: .now + ) + ), + stopResult: .success(Self.stopResponse(outcome: "stopping", activeSessions: 0, message: "daemon stopping")), + startResult: .success(DaemonStartReceipt(message: "daemon started")) + ) + let bootstrap = ShellBootstrapContext( + projectName: "control-room", + workingDirectory: "/tmp/control-room", + daemonURL: "http://127.0.0.1:7070", + cliPath: "/tmp/control-room/af" + ) + let transportStore = TransportStore(context: bootstrap) + let store = DaemonLifecycleStore( + context: bootstrap, + transportStore: transportStore, + controller: controller, + isDaemonAbsent: { _ in false }, + autoStartMonitoring: false + ) + + await store.refresh() + + XCTAssertTrue(transportStore.snapshot.note.contains("Spawn policy: auto.")) + XCTAssertTrue(transportStore.snapshot.note.contains("non-manual daemon")) + XCTAssertTrue(transportStore.snapshot.note.contains("7103")) + } + private func eventually( timeoutNanoseconds: UInt64 = 500_000_000, pollNanoseconds: UInt64 = 20_000_000, diff --git a/macos/ControlCenter/Tests/AetherflowControlCenterTests/MonitoringStoreTests.swift b/macos/ControlCenter/Tests/AetherflowControlCenterTests/MonitoringStoreTests.swift index 1a68dc6..c4cf9cf 100644 --- a/macos/ControlCenter/Tests/AetherflowControlCenterTests/MonitoringStoreTests.swift +++ b/macos/ControlCenter/Tests/AetherflowControlCenterTests/MonitoringStoreTests.swift @@ -304,6 +304,39 @@ final class MonitoringStoreTests: XCTestCase { ) } + func testRefreshNoteCallsOutNonManualDaemonTarget() async throws { + let bootstrap = Self.bootstrap + let controller = RecordingDaemonController( + statusResults: [ + .success( + DaemonStatusPayload( + poolSize: 1, + poolMode: "active", + project: "control-room", + spawnPolicy: "auto", + agents: [], + spawns: [], + queue: [], + errors: [] + ) + ) + ], + detailResults: [], + eventResults: [] + ) + let store = MonitoringStore( + context: bootstrap, + controller: controller, + isDaemonAbsent: { _ in false }, + autoStartMonitoring: false + ) + + await store.refresh() + + XCTAssertTrue(store.snapshot.note.contains("Spawn policy: auto.")) + XCTAssertTrue(store.snapshot.note.contains("non-manual daemon")) + } + private static var bootstrap: ShellBootstrapContext { ShellBootstrapContext( projectName: "aetherflow", diff --git a/macos/ControlCenter/Tests/AetherflowControlCenterTests/ShellBootstrapTests.swift b/macos/ControlCenter/Tests/AetherflowControlCenterTests/ShellBootstrapTests.swift index d17f6b1..8457daf 100644 --- a/macos/ControlCenter/Tests/AetherflowControlCenterTests/ShellBootstrapTests.swift +++ b/macos/ControlCenter/Tests/AetherflowControlCenterTests/ShellBootstrapTests.swift @@ -2,44 +2,8 @@ import XCTest @testable import AetherflowControlCenter final class ShellBootstrapTests: XCTestCase { - func testDefaultDaemonURLIsDeterministic() { - let url1 = ShellBootstrapContext.defaultDaemonURL(for: "aetherflow") - let url2 = ShellBootstrapContext.defaultDaemonURL(for: "aetherflow") - XCTAssertEqual(url1, url2) - XCTAssertTrue(url1.hasPrefix("http://127.0.0.1:")) - } - - func testDefaultDaemonURLEmptyProjectReturnsDefaultPort() { - XCTAssertEqual(ShellBootstrapContext.defaultDaemonURL(for: ""), "http://127.0.0.1:7070") - } - - func testDefaultDaemonURLDifferentProjectsDifferentURLs() { - let url1 = ShellBootstrapContext.defaultDaemonURL(for: "project-alpha") - let url2 = ShellBootstrapContext.defaultDaemonURL(for: "project-beta") - XCTAssertNotEqual(url1, url2) - } - - func testDefaultDaemonURLPortInRange() { - for project in ["aetherflow", "my-app", "control-room", "test"] { - let urlString = ShellBootstrapContext.defaultDaemonURL(for: project) - guard let url = URL(string: urlString), - let port = url.port else { - XCTFail("invalid URL for project \(project): \(urlString)") - continue - } - XCTAssertTrue((7071...7170).contains(port), "port \(port) out of range for project \(project)") - } - } - - func testDefaultDaemonURLMatchesGoPortHash() { - // Verify the FNV-1a hash matches the Go implementation for known inputs. - // Go: DaemonURLFor("myproject") with hash 84 → port 7155 - // Swift must produce the same port. - let goURL = ShellBootstrapContext.defaultDaemonURL(for: "myproject") - XCTAssertTrue(goURL.hasPrefix("http://127.0.0.1:"), "expected loopback URL, got \(goURL)") - // Both Go and Swift must agree on the same URL. - let goURL2 = ShellBootstrapContext.defaultDaemonURL(for: "myproject") - XCTAssertEqual(goURL, goURL2) + func testDefaultManualDaemonURLUsesGlobalPort() { + XCTAssertEqual(ShellBootstrapContext.defaultManualDaemonURL(), "http://127.0.0.1:7070") } func testDetectUsesEnvironmentOverrides() { @@ -55,6 +19,8 @@ final class ShellBootstrapTests: XCTestCase { XCTAssertEqual(context.projectName, "control-room") XCTAssertEqual(context.workingDirectory, "/tmp/control-room") XCTAssertEqual(context.daemonURL, "http://127.0.0.1:7099") + XCTAssertTrue(context.daemonTargetReason.contains("AETHERFLOW_DAEMON_URL")) + XCTAssertEqual(context.daemonListenAddressOverride, "127.0.0.1:7099") } func testDefaultProjectNameUsesCurrentDirectory() { @@ -81,7 +47,8 @@ final class ShellBootstrapTests: XCTestCase { ) XCTAssertEqual(context.projectName, "control-room") - XCTAssertEqual(context.daemonURL, ShellBootstrapContext.defaultDaemonURL(for: "control-room")) + XCTAssertEqual(context.daemonURL, ShellBootstrapContext.defaultManualDaemonURL()) + XCTAssertTrue(context.daemonTargetReason.contains("global manual daemon endpoint")) } func testDetectUsesConfiguredListenAddrFromAetherflowConfig() throws { @@ -102,6 +69,28 @@ final class ShellBootstrapTests: XCTestCase { ) XCTAssertEqual(context.daemonURL, "http://127.0.0.1:7099") + XCTAssertTrue(context.daemonTargetReason.contains("listen_addr")) + XCTAssertEqual(context.daemonListenAddressOverride, "127.0.0.1:7099") + } + + func testDetectSupportsConfiguredIPv6ListenAddr() throws { + let tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDirectory) } + + let configURL = tempDirectory.appendingPathComponent(".aetherflow.yaml") + try """ + listen_addr: "[::1]:7099" + """.write(to: configURL, atomically: true, encoding: .utf8) + + let context = ShellBootstrapContext.detect( + environment: [:], + currentDirectoryPath: tempDirectory.path + ) + + XCTAssertEqual(context.daemonURL, "http://[::1]:7099") + XCTAssertEqual(context.daemonListenAddressOverride, "[::1]:7099") } func testDetectIgnoresNonLoopbackEnvironmentDaemonURL() { @@ -113,7 +102,20 @@ final class ShellBootstrapTests: XCTestCase { currentDirectoryPath: "/tmp/control-room" ) - XCTAssertEqual(context.daemonURL, ShellBootstrapContext.defaultDaemonURL(for: "control-room")) + XCTAssertEqual(context.daemonURL, ShellBootstrapContext.defaultManualDaemonURL()) + XCTAssertTrue(context.daemonTargetReason.contains("global manual daemon endpoint")) + } + + func testDetectDefaultsToGlobalManualDaemonWithoutWorkspaceConfig() { + let context = ShellBootstrapContext.detect( + environment: [:], + currentDirectoryPath: "/tmp/control-room" + ) + + XCTAssertEqual(context.projectName, "control-room") + XCTAssertEqual(context.daemonURL, ShellBootstrapContext.defaultManualDaemonURL()) + XCTAssertTrue(context.daemonTargetReason.contains("global manual daemon endpoint")) + XCTAssertEqual(context.daemonListenAddressOverride, "127.0.0.1:7070") } @MainActor