import Foundation import OSLog import Subprocess #if canImport(Darwin) import Darwin #endif #if canImport(System) import System #else import SystemPackage #endif @MainActor final class RelayProcessManager: ObservableObject { static let shared = RelayProcessManager() private enum Defaults { static let projectRootPath = "clawdis.relayProjectRootPath" } enum Status: Equatable { case stopped case starting case running(pid: Int32) case restarting case failed(String) var label: String { switch self { case .stopped: "Stopped" case .starting: "Starting…" case let .running(pid): "Running (pid \(pid))" case .restarting: "Restarting…" case let .failed(reason): "Failed: \(reason)" } } } @Published private(set) var status: Status = .stopped @Published private(set) var log: String = "" @Published private(set) var restartCount: Int = 0 private var execution: Execution? private var desiredActive = false private var stopping = false private var recentCrashes: [Date] = [] private let logger = Logger(subsystem: "com.steipete.clawdis", category: "relay") private let logLimit = 20000 // characters to keep in-memory private let maxCrashes = 3 private let crashWindow: TimeInterval = 120 // seconds func setActive(_ active: Bool) { self.desiredActive = active if active { self.startIfNeeded() } else { self.stop() } } func startIfNeeded() { guard self.execution == nil, self.desiredActive else { return } if self.shouldGiveUpAfterCrashes() { self.status = .failed("Too many crashes; giving up") return } self.status = self.status == .restarting ? .restarting : .starting Task.detached { [weak self] in guard let self else { return } await self.spawnRelay() } } func stop() { self.desiredActive = false self.stopping = true guard let execution else { self.status = .stopped return } self.status = .stopped Task { await execution.teardown(using: [.gracefulShutDown(allowedDurationToNextStep: .seconds(1))]) } self.execution = nil } // MARK: - Internals private func spawnRelay() async { let command = self.resolveCommand() let cwd = self.defaultProjectRoot().path self.appendLog("[relay] starting: \(command.joined(separator: " ")) (cwd: \(cwd))\n") do { let result = try await run( .name(command.first ?? "clawdis"), arguments: Arguments(Array(command.dropFirst())), environment: self.makeEnvironment(), workingDirectory: FilePath(cwd)) { execution, stdin, stdout, stderr in self.didStart(execution) async let out: Void = self.stream(output: stdout, label: "stdout") async let err: Void = self.stream(output: stderr, label: "stderr") try await stdin.finish() await out await err } await self.handleTermination(status: result.terminationStatus) } catch { await self.handleError(error) } } private func didStart(_ execution: Execution) { self.execution = execution self.stopping = false self.status = .running(pid: execution.processIdentifier.value) self.logger.info("relay started pid \(execution.processIdentifier.value)") } private func handleTermination(status: TerminationStatus) async { let code: Int32 = switch status { case let .exited(exitCode): exitCode case let .unhandledException(sig): -Int32(sig) } self.execution = nil if self.stopping || !self.desiredActive { self.status = .stopped self.stopping = false return } self.recentCrashes.append(Date()) self.recentCrashes = self.recentCrashes.filter { Date().timeIntervalSince($0) < self.crashWindow } self.restartCount += 1 self.appendLog("[relay] exited (\(code)).\n") if self.shouldGiveUpAfterCrashes() { self.status = .failed("Too many crashes; stopped auto-restart.") self.logger.error("relay crash loop detected; giving up") return } self.status = .restarting self.logger.warning("relay crashed (code \(code)); restarting") try? await Task.sleep(nanoseconds: 750_000_000) self.startIfNeeded() } private func handleError(_ error: any Error) async { self.execution = nil var message = error.localizedDescription if let sp = error as? SubprocessError { message = "SubprocessError \(sp.code.value): \(sp)" } self.appendLog("[relay] failed: \(message)\n") self.logger.error("relay failed: \(message, privacy: .public)") if self.desiredActive, !self.shouldGiveUpAfterCrashes() { self.status = .restarting self.recentCrashes.append(Date()) self.startIfNeeded() } else { self.status = .failed(error.localizedDescription) } } private func shouldGiveUpAfterCrashes() -> Bool { self.recentCrashes = self.recentCrashes.filter { Date().timeIntervalSince($0) < self.crashWindow } return self.recentCrashes.count >= self.maxCrashes } private func stream(output: AsyncBufferSequence, label: String) async { do { for try await line in output.lines() { await MainActor.run { self.appendLog(line + "\n") } } } catch { await MainActor.run { self.appendLog("[relay \(label)] stream error: \(error.localizedDescription)\n") } } } private func appendLog(_ chunk: String) { self.log.append(chunk) if self.log.count > self.logLimit { self.log = String(self.log.suffix(self.logLimit)) } } private func resolveCommand() -> [String] { // Keep it simple: rely on system-installed clawdis/warelay. // Default to `clawdis relay`; users can provide an override via env if needed. if let override = ProcessInfo.processInfo.environment["CLAWDIS_RELAY_CMD"], !override.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return override.split(separator: " ").map(String.init) } if let clawdisPath = self.findExecutable(named: "clawdis") { return [clawdisPath, "relay"] } if let pnpm = self.findExecutable(named: "pnpm") { // Run pnpm from the project root (workingDirectory handles cwd). return [pnpm, "clawdis", "relay"] } if let node = self.findExecutable(named: "node") { let warelay = self.defaultProjectRoot().appendingPathComponent("bin/warelay.js").path if FileManager.default.isReadableFile(atPath: warelay) { return [node, warelay, "relay"] } } return ["clawdis", "relay"] } private func makeEnvironment() -> Environment { let merged = self.preferredPaths().joined(separator: ":") return .inherit.updating([ "PATH": merged, "PNPM_HOME": FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Library/pnpm").path, "CLAWDIS_PROJECT_ROOT": self.defaultProjectRoot().path, ]) } private func preferredPaths() -> [String] { let current = ProcessInfo.processInfo.environment["PATH"]? .split(separator: ":").map(String.init) ?? [] let extras = [ self.defaultProjectRoot().appendingPathComponent("node_modules/.bin").path, FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/pnpm").path, "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", ] var seen = Set() return (extras + current).filter { seen.insert($0).inserted } } private func findExecutable(named name: String) -> String? { for dir in self.preferredPaths() { let candidate = (dir as NSString).appendingPathComponent(name) if FileManager.default.isExecutableFile(atPath: candidate) { return candidate } } return nil } private func defaultProjectRoot() -> URL { if let stored = UserDefaults.standard.string(forKey: Defaults.projectRootPath), let url = self.expandPath(stored) { return url } let fallback = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Projects/clawdis") if FileManager.default.fileExists(atPath: fallback.path) { return fallback } return FileManager.default.homeDirectoryForCurrentUser } func setProjectRoot(path: String) { UserDefaults.standard.set(path, forKey: Defaults.projectRootPath) } func projectRootPath() -> String { UserDefaults.standard.string(forKey: Defaults.projectRootPath) ?? FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Projects/clawdis").path } private func expandPath(_ path: String) -> URL? { var expanded = path if expanded.hasPrefix("~") { let home = FileManager.default.homeDirectoryForCurrentUser.path expanded.replaceSubrange(expanded.startIndex...expanded.startIndex, with: home) } return URL(fileURLWithPath: expanded) } }