From f804f649209661bfef8c79da1d3b9270360b3e3f Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 11:50:23 -0600 Subject: [PATCH 1/2] feat: add serve command, iOS app, and core refactors --- .gitignore | 2 + PPG CLI/PPG CLI/WebSocketManager.swift | 383 +++++ .../PPG CLITests/WebSocketManagerTests.swift | 212 +++ ios/PPGMobile/PPGMobile/App/ContentView.swift | 17 + .../PPGMobile/App/PPGMobileApp.swift | 31 + .../AppIcon.appiconset/Contents.json | 13 + .../PPGMobile/Assets.xcassets/Contents.json | 6 + .../PPGMobile/Models/AgentVariant.swift | 73 + .../PPGMobile/Models/DashboardModels.swift | 107 ++ ios/PPGMobile/PPGMobile/Models/Manifest.swift | 207 +++ .../PPGMobile/Models/ServerConnection.swift | 170 ++ .../Models/ServerConnectionTests.swift | 151 ++ .../PPGMobile/Networking/PPGClient.swift | 292 ++++ .../PPGMobile/Networking/TokenStorage.swift | 107 ++ .../Networking/WebSocketManager.swift | 391 +++++ ios/PPGMobile/PPGMobile/State/AppState.swift | 314 ++++ .../PPGMobile/State/ManifestStore.swift | 121 ++ .../PPGMobile/Views/Dashboard/AgentRow.swift | 103 ++ .../Views/Dashboard/DashboardView.swift | 128 ++ .../Views/Dashboard/WorktreeCard.swift | 79 + .../Views/Dashboard/WorktreeDetailView.swift | 173 ++ .../Views/Settings/AddServerView.swift | 115 ++ .../Views/Settings/QRScannerView.swift | 220 +++ .../Views/Settings/SettingsView.swift | 236 +++ .../PPGMobile/Views/Spawn/SpawnView.swift | 185 ++ .../Views/Terminal/TerminalInputBar.swift | 27 + .../Views/Terminal/TerminalView.swift | 236 +++ .../PPGMobileTests/AgentVariantTests.swift | 30 + .../PPGMobileTests/ManifestTests.swift | 208 +++ .../ServerConnectionTests.swift | 139 ++ ios/PPGMobile/project.yml | 38 + package-lock.json | 1510 ++++++++--------- package.json | 8 +- src/cli.ts | 52 + src/commands/init.ts | 1 - src/commands/kill.ts | 349 +--- src/commands/list.ts | 77 +- src/commands/merge.ts | 135 +- src/commands/pr.ts | 89 +- src/commands/restart.ts | 112 +- src/commands/serve.test.ts | 370 ++++ src/commands/serve.ts | 201 +++ src/commands/spawn.test.ts | 237 +-- src/commands/spawn.ts | 493 +----- src/commands/status.ts | 16 +- src/core/agent.ts | 86 +- src/core/kill.test.ts | 74 + src/core/kill.ts | 36 + src/core/lifecycle.ts | 15 + src/core/merge.test.ts | 119 ++ src/core/merge.ts | 105 ++ src/core/operations/kill.test.ts | 478 ++++++ src/core/operations/kill.ts | 262 +++ src/core/operations/merge.test.ts | 330 ++++ src/core/operations/merge.ts | 152 ++ src/core/operations/restart.test.ts | 277 +++ src/core/operations/restart.ts | 126 ++ src/core/operations/spawn.test.ts | 446 +++++ src/core/operations/spawn.ts | 453 +++++ src/core/pr.ts | 98 ++ src/core/prompt.test.ts | 127 ++ src/core/prompt.ts | 63 + src/core/serve.test.ts | 103 ++ src/core/serve.ts | 130 ++ src/core/spawn.ts | 227 +++ src/core/tls.ts | 100 ++ src/lib/errors.ts | 30 + src/lib/paths.test.ts | 40 + src/lib/paths.ts | 44 + src/server/auth.test.ts | 544 ++++++ src/server/auth.ts | 286 ++++ src/server/error-handler.test.ts | 182 ++ src/server/error-handler.ts | 100 ++ src/server/index.test.ts | 71 + src/server/index.ts | 170 ++ src/server/routes/agents.test.ts | 497 ++++++ src/server/routes/agents.ts | 289 ++++ src/server/routes/config.test.ts | 252 +++ src/server/routes/config.ts | 68 + src/server/routes/spawn.test.ts | 353 ++++ src/server/routes/spawn.ts | 141 ++ src/server/routes/status.test.ts | 344 ++++ src/server/routes/status.ts | 150 ++ src/server/routes/worktrees.test.ts | 383 +++++ src/server/routes/worktrees.ts | 124 ++ src/server/tls.test.ts | 253 +++ src/server/tls.ts | 502 ++++++ src/server/ws/events.ts | 110 ++ src/server/ws/handler.test.ts | 463 +++++ src/server/ws/handler.ts | 214 +++ src/server/ws/terminal.test.ts | 347 ++++ src/server/ws/terminal.ts | 240 +++ src/server/ws/watcher.test.ts | 391 +++++ src/server/ws/watcher.ts | 172 ++ src/test-fixtures.ts | 14 +- 95 files changed, 16656 insertions(+), 2089 deletions(-) create mode 100644 PPG CLI/PPG CLI/WebSocketManager.swift create mode 100644 PPG CLI/PPG CLITests/WebSocketManagerTests.swift create mode 100644 ios/PPGMobile/PPGMobile/App/ContentView.swift create mode 100644 ios/PPGMobile/PPGMobile/App/PPGMobileApp.swift create mode 100644 ios/PPGMobile/PPGMobile/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ios/PPGMobile/PPGMobile/Assets.xcassets/Contents.json create mode 100644 ios/PPGMobile/PPGMobile/Models/AgentVariant.swift create mode 100644 ios/PPGMobile/PPGMobile/Models/DashboardModels.swift create mode 100644 ios/PPGMobile/PPGMobile/Models/Manifest.swift create mode 100644 ios/PPGMobile/PPGMobile/Models/ServerConnection.swift create mode 100644 ios/PPGMobile/PPGMobile/Models/ServerConnectionTests.swift create mode 100644 ios/PPGMobile/PPGMobile/Networking/PPGClient.swift create mode 100644 ios/PPGMobile/PPGMobile/Networking/TokenStorage.swift create mode 100644 ios/PPGMobile/PPGMobile/Networking/WebSocketManager.swift create mode 100644 ios/PPGMobile/PPGMobile/State/AppState.swift create mode 100644 ios/PPGMobile/PPGMobile/State/ManifestStore.swift create mode 100644 ios/PPGMobile/PPGMobile/Views/Dashboard/AgentRow.swift create mode 100644 ios/PPGMobile/PPGMobile/Views/Dashboard/DashboardView.swift create mode 100644 ios/PPGMobile/PPGMobile/Views/Dashboard/WorktreeCard.swift create mode 100644 ios/PPGMobile/PPGMobile/Views/Dashboard/WorktreeDetailView.swift create mode 100644 ios/PPGMobile/PPGMobile/Views/Settings/AddServerView.swift create mode 100644 ios/PPGMobile/PPGMobile/Views/Settings/QRScannerView.swift create mode 100644 ios/PPGMobile/PPGMobile/Views/Settings/SettingsView.swift create mode 100644 ios/PPGMobile/PPGMobile/Views/Spawn/SpawnView.swift create mode 100644 ios/PPGMobile/PPGMobile/Views/Terminal/TerminalInputBar.swift create mode 100644 ios/PPGMobile/PPGMobile/Views/Terminal/TerminalView.swift create mode 100644 ios/PPGMobile/PPGMobileTests/AgentVariantTests.swift create mode 100644 ios/PPGMobile/PPGMobileTests/ManifestTests.swift create mode 100644 ios/PPGMobile/PPGMobileTests/ServerConnectionTests.swift create mode 100644 ios/PPGMobile/project.yml create mode 100644 src/commands/serve.test.ts create mode 100644 src/commands/serve.ts create mode 100644 src/core/kill.test.ts create mode 100644 src/core/kill.ts create mode 100644 src/core/lifecycle.ts create mode 100644 src/core/merge.test.ts create mode 100644 src/core/merge.ts create mode 100644 src/core/operations/kill.test.ts create mode 100644 src/core/operations/kill.ts create mode 100644 src/core/operations/merge.test.ts create mode 100644 src/core/operations/merge.ts create mode 100644 src/core/operations/restart.test.ts create mode 100644 src/core/operations/restart.ts create mode 100644 src/core/operations/spawn.test.ts create mode 100644 src/core/operations/spawn.ts create mode 100644 src/core/prompt.test.ts create mode 100644 src/core/prompt.ts create mode 100644 src/core/serve.test.ts create mode 100644 src/core/serve.ts create mode 100644 src/core/spawn.ts create mode 100644 src/core/tls.ts create mode 100644 src/server/auth.test.ts create mode 100644 src/server/auth.ts create mode 100644 src/server/error-handler.test.ts create mode 100644 src/server/error-handler.ts create mode 100644 src/server/index.test.ts create mode 100644 src/server/index.ts create mode 100644 src/server/routes/agents.test.ts create mode 100644 src/server/routes/agents.ts create mode 100644 src/server/routes/config.test.ts create mode 100644 src/server/routes/config.ts create mode 100644 src/server/routes/spawn.test.ts create mode 100644 src/server/routes/spawn.ts create mode 100644 src/server/routes/status.test.ts create mode 100644 src/server/routes/status.ts create mode 100644 src/server/routes/worktrees.test.ts create mode 100644 src/server/routes/worktrees.ts create mode 100644 src/server/tls.test.ts create mode 100644 src/server/tls.ts create mode 100644 src/server/ws/events.ts create mode 100644 src/server/ws/handler.test.ts create mode 100644 src/server/ws/handler.ts create mode 100644 src/server/ws/terminal.test.ts create mode 100644 src/server/ws/terminal.ts create mode 100644 src/server/ws/watcher.test.ts create mode 100644 src/server/ws/watcher.ts diff --git a/.gitignore b/.gitignore index b4d222c..ffb7a93 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ DerivedData/ **/xcuserdata/ *.xcuserstate *.profraw +*.xcodeproj +*.xcworkspace diff --git a/PPG CLI/PPG CLI/WebSocketManager.swift b/PPG CLI/PPG CLI/WebSocketManager.swift new file mode 100644 index 0000000..32975fa --- /dev/null +++ b/PPG CLI/PPG CLI/WebSocketManager.swift @@ -0,0 +1,383 @@ +import Foundation + +// MARK: - Notifications + +extension Notification.Name { + static let webSocketStateDidChange = Notification.Name("PPGWebSocketStateDidChange") + static let webSocketDidReceiveEvent = Notification.Name("PPGWebSocketDidReceiveEvent") +} + +// MARK: - Connection State + +nonisolated enum WebSocketConnectionState: Equatable, Sendable { + case disconnected + case connecting + case connected + case reconnecting(attempt: Int) + + var isConnected: Bool { self == .connected } + + var isReconnecting: Bool { + if case .reconnecting = self { return true } + return false + } +} + +// MARK: - Server Events + +nonisolated enum WebSocketEvent: Sendable { + case manifestUpdated(ManifestModel) + case agentStatusChanged(agentId: String, status: AgentStatus) + case worktreeStatusChanged(worktreeId: String, status: String) + case pong + case unknown(type: String, payload: String) +} + +// MARK: - Client Commands + +nonisolated enum WebSocketCommand: Sendable { + case subscribe(channel: String) + case unsubscribe(channel: String) + case terminalInput(agentId: String, data: String) + + var jsonString: String { + let dict: [String: String] + switch self { + case .subscribe(let channel): + dict = ["type": "subscribe", "channel": channel] + case .unsubscribe(let channel): + dict = ["type": "unsubscribe", "channel": channel] + case .terminalInput(let agentId, let data): + dict = ["type": "terminal_input", "agentId": agentId, "data": data] + } + guard let data = try? JSONSerialization.data(withJSONObject: dict, options: [.sortedKeys]), + let str = String(data: data, encoding: .utf8) else { + return "{}" + } + return str + } +} + +// MARK: - WebSocketManager + +nonisolated class WebSocketManager: NSObject, @unchecked Sendable, URLSessionWebSocketDelegate { + + /// Notification userInfo key for connection state. + static let stateUserInfoKey = "PPGWebSocketState" + /// Notification userInfo key for received event. + static let eventUserInfoKey = "PPGWebSocketEvent" + + // MARK: - Configuration + + private let url: URL + private let maxReconnectDelay: TimeInterval = 30.0 + private let baseReconnectDelay: TimeInterval = 1.0 + private let pingInterval: TimeInterval = 30.0 + + // MARK: - State + + private let queue = DispatchQueue(label: "ppg.websocket-manager", qos: .utility) + + /// Internal state — only read/write on `queue`. + private var _state: WebSocketConnectionState = .disconnected + + /// Thread-safe read of the current connection state. + var state: WebSocketConnectionState { + queue.sync { _state } + } + + private var session: URLSession? + private var task: URLSessionWebSocketTask? + private var pingTimer: DispatchSourceTimer? + private var reconnectWorkItem: DispatchWorkItem? + private var reconnectAttempt = 0 + private var intentionalDisconnect = false + private var isHandlingConnectionLoss = false + + // MARK: - Init + + init(url: URL) { + self.url = url + super.init() + } + + convenience init?(urlString: String) { + guard let url = URL(string: urlString) else { return nil } + self.init(url: url) + } + + deinit { + // Synchronous cleanup — safe because we're the last reference holder. + intentionalDisconnect = true + pingTimer?.cancel() + pingTimer = nil + task?.cancel(with: .goingAway, reason: nil) + task = nil + session?.invalidateAndCancel() + session = nil + } + + // MARK: - Public API + + func connect() { + queue.async { [weak self] in + self?.doConnect() + } + } + + func disconnect() { + queue.async { [weak self] in + self?.doDisconnect() + } + } + + func send(_ command: WebSocketCommand) { + queue.async { [weak self] in + self?.doSend(command.jsonString) + } + } + + // MARK: - Connection Lifecycle + + private func doConnect() { + guard _state == .disconnected || _state.isReconnecting else { return } + + intentionalDisconnect = false + isHandlingConnectionLoss = false + reconnectWorkItem?.cancel() + reconnectWorkItem = nil + + if _state.isReconnecting { + // Already in reconnect flow — keep the attempt counter + } else { + reconnectAttempt = 0 + setState(.connecting) + } + + let config = URLSessionConfiguration.default + config.waitsForConnectivity = true + session = URLSession(configuration: config, delegate: self, delegateQueue: nil) + + let wsTask = session!.webSocketTask(with: url) + task = wsTask + wsTask.resume() + } + + private func doDisconnect() { + intentionalDisconnect = true + isHandlingConnectionLoss = false + reconnectWorkItem?.cancel() + reconnectWorkItem = nil + stopPingTimer() + task?.cancel(with: .goingAway, reason: nil) + task = nil + session?.invalidateAndCancel() + session = nil + reconnectAttempt = 0 + setState(.disconnected) + } + + /// Set state on the queue and post a notification on main. + private func setState(_ newState: WebSocketConnectionState) { + guard _state != newState else { return } + _state = newState + DispatchQueue.main.async { + NotificationCenter.default.post( + name: .webSocketStateDidChange, + object: nil, + userInfo: [WebSocketManager.stateUserInfoKey: newState] + ) + } + } + + // MARK: - Sending + + private func doSend(_ text: String) { + guard _state == .connected, let task = task else { return } + task.send(.string(text)) { error in + if let error = error { + NSLog("[WebSocketManager] send error: \(error.localizedDescription)") + } + } + } + + // MARK: - Receiving + + private func listenForMessages(for expectedTask: URLSessionWebSocketTask) { + expectedTask.receive { [weak self] result in + guard let self = self else { return } + self.queue.async { + guard self.task === expectedTask else { return } + switch result { + case .success(let message): + self.handleMessage(message) + self.listenForMessages(for: expectedTask) + case .failure(let error): + if !self.intentionalDisconnect { + NSLog("[WebSocketManager] receive error: \(error.localizedDescription)") + self.handleConnectionLost() + } + } + } + } + } + + private func handleMessage(_ message: URLSessionWebSocketTask.Message) { + let text: String + switch message { + case .string(let s): + text = s + case .data(let d): + guard let s = String(data: d, encoding: .utf8) else { return } + text = s + @unknown default: + return + } + + guard let event = parseEvent(text) else { return } + + DispatchQueue.main.async { + NotificationCenter.default.post( + name: .webSocketDidReceiveEvent, + object: nil, + userInfo: [WebSocketManager.eventUserInfoKey: event] + ) + } + } + + // MARK: - Event Parsing + + /// Parse a JSON text message into a typed event. Internal for testability. + func parseEvent(_ text: String) -> WebSocketEvent? { + guard let data = text.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let type = json["type"] as? String else { + return nil + } + + switch type { + case "manifest_updated": + if let payloadData = json["manifest"], + let payloadJSON = try? JSONSerialization.data(withJSONObject: payloadData), + let manifest = try? JSONDecoder().decode(ManifestModel.self, from: payloadJSON) { + return .manifestUpdated(manifest) + } + return .unknown(type: type, payload: text) + + case "agent_status_changed": + if let agentId = json["agentId"] as? String, + let statusRaw = json["status"] as? String, + let status = AgentStatus(rawValue: statusRaw) { + return .agentStatusChanged(agentId: agentId, status: status) + } + return .unknown(type: type, payload: text) + + case "worktree_status_changed": + if let worktreeId = json["worktreeId"] as? String, + let status = json["status"] as? String { + return .worktreeStatusChanged(worktreeId: worktreeId, status: status) + } + return .unknown(type: type, payload: text) + + case "pong": + return .pong + + default: + return .unknown(type: type, payload: text) + } + } + + // MARK: - Keepalive Ping + + private func startPingTimer() { + stopPingTimer() + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now() + pingInterval, repeating: pingInterval) + timer.setEventHandler { [weak self] in + self?.sendPing() + } + timer.resume() + pingTimer = timer + } + + private func stopPingTimer() { + pingTimer?.cancel() + pingTimer = nil + } + + private func sendPing() { + task?.sendPing { [weak self] error in + if let error = error { + NSLog("[WebSocketManager] ping error: \(error.localizedDescription)") + self?.queue.async { self?.handleConnectionLost() } + } + } + } + + // MARK: - Reconnect + + private func handleConnectionLost() { + guard !intentionalDisconnect else { return } + guard !isHandlingConnectionLoss else { return } + isHandlingConnectionLoss = true + stopPingTimer() + task?.cancel(with: .abnormalClosure, reason: nil) + task = nil + session?.invalidateAndCancel() + session = nil + scheduleReconnect() + } + + private func scheduleReconnect() { + reconnectAttempt += 1 + setState(.reconnecting(attempt: reconnectAttempt)) + + let delay = min(baseReconnectDelay * pow(2.0, Double(reconnectAttempt - 1)), maxReconnectDelay) + NSLog("[WebSocketManager] reconnecting in %.1fs (attempt %d)", delay, reconnectAttempt) + + let workItem = DispatchWorkItem { [weak self] in + guard let self = self, !self.intentionalDisconnect else { return } + self.reconnectWorkItem = nil + self.doConnect() + } + reconnectWorkItem?.cancel() + reconnectWorkItem = workItem + queue.asyncAfter(deadline: .now() + delay, execute: workItem) + } + + // MARK: - URLSessionWebSocketDelegate + + func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) { + queue.async { [weak self] in + guard let self = self else { return } + guard self.task === webSocketTask else { return } + self.reconnectAttempt = 0 + self.isHandlingConnectionLoss = false + self.setState(.connected) + self.startPingTimer() + self.listenForMessages(for: webSocketTask) + } + } + + func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + queue.async { [weak self] in + guard let self = self else { return } + guard self.task === webSocketTask else { return } + if self.intentionalDisconnect { + self.setState(.disconnected) + } else { + self.handleConnectionLost() + } + } + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: (any Error)?) { + guard error != nil else { return } + queue.async { [weak self] in + guard let self = self, !self.intentionalDisconnect else { return } + guard let webSocketTask = task as? URLSessionWebSocketTask, + self.task === webSocketTask else { return } + self.handleConnectionLost() + } + } +} diff --git a/PPG CLI/PPG CLITests/WebSocketManagerTests.swift b/PPG CLI/PPG CLITests/WebSocketManagerTests.swift new file mode 100644 index 0000000..70dac9d --- /dev/null +++ b/PPG CLI/PPG CLITests/WebSocketManagerTests.swift @@ -0,0 +1,212 @@ +import XCTest +@testable import PPG_CLI + +final class WebSocketManagerTests: XCTestCase { + + // MARK: - WebSocketConnectionState + + func testIsConnectedReturnsTrueOnlyWhenConnected() { + XCTAssertTrue(WebSocketConnectionState.connected.isConnected) + XCTAssertFalse(WebSocketConnectionState.disconnected.isConnected) + XCTAssertFalse(WebSocketConnectionState.connecting.isConnected) + XCTAssertFalse(WebSocketConnectionState.reconnecting(attempt: 1).isConnected) + } + + func testIsReconnectingReturnsTrueOnlyWhenReconnecting() { + XCTAssertTrue(WebSocketConnectionState.reconnecting(attempt: 1).isReconnecting) + XCTAssertTrue(WebSocketConnectionState.reconnecting(attempt: 5).isReconnecting) + XCTAssertFalse(WebSocketConnectionState.connected.isReconnecting) + XCTAssertFalse(WebSocketConnectionState.disconnected.isReconnecting) + XCTAssertFalse(WebSocketConnectionState.connecting.isReconnecting) + } + + func testReconnectingEquality() { + XCTAssertEqual( + WebSocketConnectionState.reconnecting(attempt: 3), + WebSocketConnectionState.reconnecting(attempt: 3) + ) + XCTAssertNotEqual( + WebSocketConnectionState.reconnecting(attempt: 1), + WebSocketConnectionState.reconnecting(attempt: 2) + ) + } + + // MARK: - WebSocketCommand.jsonString + + func testSubscribeCommandProducesValidJSON() { + let cmd = WebSocketCommand.subscribe(channel: "manifest") + let json = parseJSON(cmd.jsonString) + XCTAssertEqual(json?["type"] as? String, "subscribe") + XCTAssertEqual(json?["channel"] as? String, "manifest") + } + + func testUnsubscribeCommandProducesValidJSON() { + let cmd = WebSocketCommand.unsubscribe(channel: "agents") + let json = parseJSON(cmd.jsonString) + XCTAssertEqual(json?["type"] as? String, "unsubscribe") + XCTAssertEqual(json?["channel"] as? String, "agents") + } + + func testTerminalInputCommandProducesValidJSON() { + let cmd = WebSocketCommand.terminalInput(agentId: "ag-12345678", data: "ls -la\n") + let json = parseJSON(cmd.jsonString) + XCTAssertEqual(json?["type"] as? String, "terminal_input") + XCTAssertEqual(json?["agentId"] as? String, "ag-12345678") + XCTAssertEqual(json?["data"] as? String, "ls -la\n") + } + + func testCommandEscapesSpecialCharactersInChannel() { + // A channel name with quotes should not break JSON structure + let cmd = WebSocketCommand.subscribe(channel: #"test"channel"#) + let json = parseJSON(cmd.jsonString) + XCTAssertEqual(json?["channel"] as? String, #"test"channel"#) + } + + func testCommandEscapesSpecialCharactersInAgentId() { + let cmd = WebSocketCommand.terminalInput(agentId: #"id"with"quotes"#, data: "x") + let json = parseJSON(cmd.jsonString) + XCTAssertEqual(json?["agentId"] as? String, #"id"with"quotes"#) + } + + func testTerminalInputPreservesControlCharacters() { + let cmd = WebSocketCommand.terminalInput(agentId: "ag-1", data: "line1\nline2\ttab\r") + let json = parseJSON(cmd.jsonString) + XCTAssertEqual(json?["data"] as? String, "line1\nline2\ttab\r") + } + + // MARK: - parseEvent + + func testParseAgentStatusChangedEvent() { + let manager = WebSocketManager(url: URL(string: "ws://localhost")!) + let json = #"{"type":"agent_status_changed","agentId":"ag-abc","status":"completed"}"# + let event = manager.parseEvent(json) + + if case .agentStatusChanged(let agentId, let status) = event { + XCTAssertEqual(agentId, "ag-abc") + XCTAssertEqual(status, .completed) + } else { + XCTFail("Expected agentStatusChanged, got \(String(describing: event))") + } + } + + func testParseWorktreeStatusChangedEvent() { + let manager = WebSocketManager(url: URL(string: "ws://localhost")!) + let json = #"{"type":"worktree_status_changed","worktreeId":"wt-xyz","status":"active"}"# + let event = manager.parseEvent(json) + + if case .worktreeStatusChanged(let worktreeId, let status) = event { + XCTAssertEqual(worktreeId, "wt-xyz") + XCTAssertEqual(status, "active") + } else { + XCTFail("Expected worktreeStatusChanged, got \(String(describing: event))") + } + } + + func testParsePongEvent() { + let manager = WebSocketManager(url: URL(string: "ws://localhost")!) + let event = manager.parseEvent(#"{"type":"pong"}"#) + + if case .pong = event { + // pass + } else { + XCTFail("Expected pong, got \(String(describing: event))") + } + } + + func testParseUnknownEventType() { + let manager = WebSocketManager(url: URL(string: "ws://localhost")!) + let json = #"{"type":"custom_event","foo":"bar"}"# + let event = manager.parseEvent(json) + + if case .unknown(let type, let payload) = event { + XCTAssertEqual(type, "custom_event") + XCTAssertEqual(payload, json) + } else { + XCTFail("Expected unknown, got \(String(describing: event))") + } + } + + func testParseManifestUpdatedEvent() { + let manager = WebSocketManager(url: URL(string: "ws://localhost")!) + let json = """ + {"type":"manifest_updated","manifest":{"version":1,"projectRoot":"/tmp","sessionName":"s","worktrees":{},"createdAt":"t","updatedAt":"t"}} + """ + let event = manager.parseEvent(json) + + if case .manifestUpdated(let manifest) = event { + XCTAssertEqual(manifest.version, 1) + XCTAssertEqual(manifest.projectRoot, "/tmp") + XCTAssertEqual(manifest.sessionName, "s") + } else { + XCTFail("Expected manifestUpdated, got \(String(describing: event))") + } + } + + func testParseManifestUpdatedWithInvalidManifestFallsBackToUnknown() { + let manager = WebSocketManager(url: URL(string: "ws://localhost")!) + let json = #"{"type":"manifest_updated","manifest":{"bad":"data"}}"# + let event = manager.parseEvent(json) + + if case .unknown(let type, _) = event { + XCTAssertEqual(type, "manifest_updated") + } else { + XCTFail("Expected unknown fallback, got \(String(describing: event))") + } + } + + func testParseReturnsNilForInvalidJSON() { + let manager = WebSocketManager(url: URL(string: "ws://localhost")!) + XCTAssertNil(manager.parseEvent("not json")) + } + + func testParseReturnsNilForMissingType() { + let manager = WebSocketManager(url: URL(string: "ws://localhost")!) + XCTAssertNil(manager.parseEvent(#"{"channel":"test"}"#)) + } + + func testParseAgentStatusWithInvalidStatusFallsBackToUnknown() { + let manager = WebSocketManager(url: URL(string: "ws://localhost")!) + let json = #"{"type":"agent_status_changed","agentId":"ag-1","status":"bogus"}"# + let event = manager.parseEvent(json) + + if case .unknown(let type, _) = event { + XCTAssertEqual(type, "agent_status_changed") + } else { + XCTFail("Expected unknown fallback for invalid status, got \(String(describing: event))") + } + } + + func testParseAgentStatusWithMissingFieldsFallsBackToUnknown() { + let manager = WebSocketManager(url: URL(string: "ws://localhost")!) + let json = #"{"type":"agent_status_changed","agentId":"ag-1"}"# + let event = manager.parseEvent(json) + + if case .unknown(let type, _) = event { + XCTAssertEqual(type, "agent_status_changed") + } else { + XCTFail("Expected unknown fallback for missing status, got \(String(describing: event))") + } + } + + // MARK: - Initial State + + func testInitialStateIsDisconnected() { + let manager = WebSocketManager(url: URL(string: "ws://localhost")!) + XCTAssertEqual(manager.state, .disconnected) + } + + func testConvenienceInitReturnsNilForEmptyString() { + XCTAssertNil(WebSocketManager(urlString: "")) + } + + func testConvenienceInitSucceedsForValidURL() { + XCTAssertNotNil(WebSocketManager(urlString: "ws://localhost:8080")) + } + + // MARK: - Helpers + + private func parseJSON(_ string: String) -> [String: Any]? { + guard let data = string.data(using: .utf8) else { return nil } + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } +} diff --git a/ios/PPGMobile/PPGMobile/App/ContentView.swift b/ios/PPGMobile/PPGMobile/App/ContentView.swift new file mode 100644 index 0000000..8dcab23 --- /dev/null +++ b/ios/PPGMobile/PPGMobile/App/ContentView.swift @@ -0,0 +1,17 @@ +import SwiftUI + +struct ContentView: View { + var body: some View { + VStack { + Image(systemName: "terminal") + .imageScale(.large) + .foregroundStyle(.tint) + Text("PPG Mobile") + } + .padding() + } +} + +#Preview { + ContentView() +} diff --git a/ios/PPGMobile/PPGMobile/App/PPGMobileApp.swift b/ios/PPGMobile/PPGMobile/App/PPGMobileApp.swift new file mode 100644 index 0000000..d545c9a --- /dev/null +++ b/ios/PPGMobile/PPGMobile/App/PPGMobileApp.swift @@ -0,0 +1,31 @@ +import SwiftUI + +@main +struct PPGMobileApp: App { + @State private var appState = AppState() + + var body: some Scene { + WindowGroup { + TabView { + DashboardView() + .tabItem { + Label("Dashboard", systemImage: "square.grid.2x2") + } + + SpawnView() + .tabItem { + Label("Spawn", systemImage: "plus.circle") + } + + SettingsView() + .tabItem { + Label("Settings", systemImage: "gear") + } + } + .environment(appState) + .task { + await appState.autoConnect() + } + } + } +} diff --git a/ios/PPGMobile/PPGMobile/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/PPGMobile/PPGMobile/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..b121e3b --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images": [ + { + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/ios/PPGMobile/PPGMobile/Assets.xcassets/Contents.json b/ios/PPGMobile/PPGMobile/Assets.xcassets/Contents.json new file mode 100644 index 0000000..74d6a72 --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/ios/PPGMobile/PPGMobile/Models/AgentVariant.swift b/ios/PPGMobile/PPGMobile/Models/AgentVariant.swift new file mode 100644 index 0000000..b8c8556 --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Models/AgentVariant.swift @@ -0,0 +1,73 @@ +import SwiftUI + +/// Known agent types with their display properties. +/// +/// Maps to the `agentType` field on `AgentEntry`. New variants can be added +/// without schema changes since `agentType` is a free-form string — unknown +/// values return `nil` from `AgentVariant.from(_:)` and fall back to defaults +/// in the `AgentEntry` convenience extensions. +enum AgentVariant: String, CaseIterable, Identifiable { + case claude + case codex + case opencode + + var id: String { rawValue } + + /// Human-readable display name. + var displayName: String { + switch self { + case .claude: "Claude" + case .codex: "Codex" + case .opencode: "OpenCode" + } + } + + /// SF Symbol icon for this agent type. + var icon: String { sfSymbol } + + var sfSymbol: String { + switch self { + case .claude: "brain.head.profile" + case .codex: "terminal" + case .opencode: "chevron.left.forwardslash.chevron.right" + } + } + + /// Brand color for this agent type. + var color: Color { + switch self { + case .claude: .orange + case .codex: .cyan + case .opencode: .purple + } + } + + /// Resolve an `agentType` string to a known variant, or `nil` if unknown. + static func from(_ agentType: String) -> AgentVariant? { + AgentVariant(rawValue: agentType.lowercased()) + } +} + +// MARK: - AgentEntry integration + +extension AgentEntry { + /// The known variant for this agent, or `nil` for custom agent types. + var variant: AgentVariant? { + AgentVariant.from(agentType) + } + + /// Display name — uses the variant's name if known, otherwise the raw `agentType`. + var displayName: String { + variant?.displayName ?? agentType + } + + /// Icon — uses the variant's symbol if known, otherwise a generic terminal icon. + var iconName: String { + variant?.sfSymbol ?? "terminal" + } + + /// Color — uses the variant's color if known, otherwise secondary. + var brandColor: Color { + variant?.color ?? .secondary + } +} diff --git a/ios/PPGMobile/PPGMobile/Models/DashboardModels.swift b/ios/PPGMobile/PPGMobile/Models/DashboardModels.swift new file mode 100644 index 0000000..bd0b030 --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Models/DashboardModels.swift @@ -0,0 +1,107 @@ +import SwiftUI + +// MARK: - Connection State (UI-only, distinct from WebSocketConnectionState) + +enum ConnectionState { + case disconnected + case connecting + case connected + case error(String) +} + +// MARK: - Diff Stats + +struct DiffStats { + let filesChanged: Int + let insertions: Int + let deletions: Int +} + +struct DiffResponse: Codable { + let diff: String? + let stats: DiffStatsResponse? +} + +struct DiffStatsResponse: Codable { + let filesChanged: Int + let insertions: Int + let deletions: Int +} + +// MARK: - API Response Types + +struct SpawnResponse: Codable { + let success: Bool + let worktreeId: String +} + +struct LogsResponse: Codable { + let output: String +} + +struct Config: Codable { + let sessionName: String? +} + +struct TemplatesResponse: Codable { + let templates: [String] +} + +struct PromptsResponse: Codable { + let prompts: [String] +} + +struct SwarmsResponse: Codable { + let swarms: [String] +} + +struct ErrorResponse: Codable { + let error: String +} + +// MARK: - AgentStatus UI Extensions + +extension AgentStatus { + var icon: String { sfSymbol } + + var isActive: Bool { + self == .spawning || self == .running + } + + var isTerminal: Bool { + switch self { + case .completed, .failed, .killed, .lost: true + default: false + } + } +} + +// MARK: - WorktreeStatus UI Extensions + +extension WorktreeStatus { + var icon: String { sfSymbol } + + var isTerminal: Bool { + self == .merged || self == .cleaned + } +} + +// MARK: - AgentEntry UI Extensions + +extension AgentEntry { + var startDate: Date? { + ISO8601DateFormatter().date(from: startedAt) + } +} + +// MARK: - WorktreeEntry UI Extensions + +extension WorktreeEntry { + var createdDate: Date? { + ISO8601DateFormatter().date(from: createdAt) + } + + var mergedDate: Date? { + mergedAt.flatMap { ISO8601DateFormatter().date(from: $0) } + } +} diff --git a/ios/PPGMobile/PPGMobile/Models/Manifest.swift b/ios/PPGMobile/PPGMobile/Models/Manifest.swift new file mode 100644 index 0000000..f43f291 --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Models/Manifest.swift @@ -0,0 +1,207 @@ +import SwiftUI + +// MARK: - Agent Status + +/// Lifecycle status for an agent process. +/// +/// Matches the ppg agent lifecycle: +/// spawning → running → completed | failed | killed | lost +/// +/// Custom decoding also accepts the current TypeScript status values: +/// `"idle"` → `.running`, `"exited"` → `.completed`, `"gone"` → `.lost` +enum AgentStatus: String, Codable, CaseIterable, Hashable { + case spawning + case running + case waiting + case completed + case failed + case killed + case lost + + /// Maps legacy/TS status strings to lifecycle values. + private static let aliases: [String: AgentStatus] = [ + "idle": .running, + "exited": .completed, + "gone": .lost, + "waiting": .waiting, + ] + + init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + if let direct = AgentStatus(rawValue: raw) { + self = direct + } else if let mapped = Self.aliases[raw] { + self = mapped + } else { + throw DecodingError.dataCorrupted( + .init(codingPath: decoder.codingPath, + debugDescription: "Unknown AgentStatus: \(raw)") + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } + + var label: String { + rawValue.capitalized + } + + var color: Color { + switch self { + case .spawning: .orange + case .running: .green + case .waiting: .yellow + case .completed: .blue + case .failed: .red + case .killed: .gray + case .lost: .secondary + } + } + + var sfSymbol: String { + switch self { + case .spawning: "arrow.triangle.2.circlepath" + case .running: "play.circle.fill" + case .waiting: "pause.circle" + case .completed: "checkmark.circle.fill" + case .failed: "xmark.circle.fill" + case .killed: "stop.circle.fill" + case .lost: "questionmark.circle" + } + } +} + +// MARK: - Worktree Status + +/// Lifecycle status for a git worktree. +/// +/// Matches the ppg worktree lifecycle: +/// active → merging → merged → cleaned +/// → failed +enum WorktreeStatus: String, Codable, CaseIterable, Hashable { + case active + case spawning + case running + case merging + case merged + case failed + case cleaned + + var label: String { + rawValue.capitalized + } + + var color: Color { + switch self { + case .active: .green + case .spawning: .yellow + case .running: .green + case .merging: .orange + case .merged: .blue + case .failed: .red + case .cleaned: .gray + } + } + + var sfSymbol: String { + switch self { + case .active: "arrow.branch" + case .spawning: "hourglass" + case .running: "play.circle.fill" + case .merging: "arrow.triangle.merge" + case .merged: "checkmark.circle" + case .failed: "xmark.circle" + case .cleaned: "trash.circle" + } + } +} + +// MARK: - Agent Entry + +/// A single agent (CLI process) running in a tmux pane. +/// +/// JSON keys use camelCase matching the server schema (e.g. `agentType`, `startedAt`). +struct AgentEntry: Codable, Identifiable, Hashable { + let id: String + let name: String + let agentType: String + var status: AgentStatus + let tmuxTarget: String + let prompt: String + let startedAt: String + var exitCode: Int? + var sessionId: String? + + // MARK: Hashable (identity-based) + + static func == (lhs: AgentEntry, rhs: AgentEntry) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +// MARK: - Worktree Entry + +/// An isolated git checkout on branch `ppg/`. +struct WorktreeEntry: Codable, Identifiable, Hashable { + let id: String + let name: String + let path: String + let branch: String + let baseBranch: String + var status: WorktreeStatus + let tmuxWindow: String + var prUrl: String? + var agents: [String: AgentEntry] + let createdAt: String + var mergedAt: String? + + // MARK: Hashable (identity-based) + + static func == (lhs: WorktreeEntry, rhs: WorktreeEntry) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +// MARK: - Manifest + +/// Top-level runtime state persisted in `.ppg/manifest.json`. +struct Manifest: Codable { + let version: Int + let projectRoot: String + let sessionName: String + var worktrees: [String: WorktreeEntry] + let createdAt: String + var updatedAt: String +} + +// MARK: - Convenience + +extension Manifest { + /// All agents across all worktrees, flattened. + var allAgents: [AgentEntry] { + worktrees.values.flatMap { $0.agents.values } + } + + /// Worktrees sorted by creation date (newest first). + var sortedWorktrees: [WorktreeEntry] { + worktrees.values.sorted { $0.createdAt > $1.createdAt } + } +} + +extension WorktreeEntry { + /// Agents sorted by start date (newest first). + var sortedAgents: [AgentEntry] { + agents.values.sorted { $0.startedAt > $1.startedAt } + } +} diff --git a/ios/PPGMobile/PPGMobile/Models/ServerConnection.swift b/ios/PPGMobile/PPGMobile/Models/ServerConnection.swift new file mode 100644 index 0000000..9e40bf6 --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Models/ServerConnection.swift @@ -0,0 +1,170 @@ +import Foundation + +/// Connection configuration for a ppg server instance. +/// +/// Stores the host, port, TLS CA certificate, and auth token needed to +/// communicate with a ppg server over REST and WebSocket. +struct ServerConnection: Codable, Identifiable, Hashable { + let id: UUID + var name: String + var host: String + var port: Int + var token: String + var caCertificate: String? + var isDefault: Bool + + init(id: UUID = UUID(), name: String = "My Mac", host: String, port: Int = 7700, token: String, caCertificate: String? = nil, isDefault: Bool = false) { + self.id = id + self.name = name + self.host = host + self.port = port + self.token = token + self.caCertificate = caCertificate + self.isDefault = isDefault + } + + /// Human-readable label (e.g. "192.168.1.5:7700"). + var displayName: String { + "\(host):\(port)" + } + + // MARK: - URL Builders + + private var usesTLS: Bool { + caCertificate != nil + } + + private var scheme: String { + usesTLS ? "https" : "http" + } + + private var wsScheme: String { + usesTLS ? "wss" : "ws" + } + + /// Base URL for REST API requests (e.g. `http://192.168.1.5:7700`). + /// Returns `nil` if the host is malformed. + var baseURL: URL? { + makeURL(scheme: scheme) + } + + /// URL for the API root. + var apiURL: URL? { + baseURL?.appendingPathComponent("api") + } + + /// URL for a specific REST API endpoint. + /// Returns `nil` if the base URL cannot be constructed. + /// + /// connection.restURL(for: "/api/status") + func restURL(for path: String) -> URL? { + guard let base = baseURL else { return nil } + return base.appending(path: path) + } + + /// WebSocket URL with auth token in query string. + /// Returns `nil` if the host is malformed. + /// + /// connection.webSocketURL // ws://192.168.1.5:7700/ws?token=abc123 + var webSocketURL: URL? { + makeURL( + scheme: wsScheme, + path: "/ws", + queryItems: [URLQueryItem(name: "token", value: token)] + ) + } + + // MARK: - QR Code + + /// Generates the QR code string for this connection. + /// + /// ppg://connect?host=192.168.1.5&port=7700&token=abc123 + /// ppg://connect?host=192.168.1.5&port=7700&ca=BASE64...&token=abc123 + var qrCodeString: String { + var components = URLComponents() + components.scheme = "ppg" + components.host = "connect" + var items = [ + URLQueryItem(name: "host", value: host), + URLQueryItem(name: "port", value: String(port)), + ] + if let ca = caCertificate { + items.append(URLQueryItem(name: "ca", value: ca)) + } + items.append(URLQueryItem(name: "token", value: token)) + components.queryItems = items + return components.string ?? "ppg://connect" + } + + /// Parse a ppg serve QR code payload. + /// Format: ppg://connect?host=&port=&token=[&ca=] + static func fromQRCode(_ payload: String) -> ServerConnection? { + guard let components = URLComponents(string: payload), + components.scheme?.lowercased() == "ppg", + components.host?.lowercased() == "connect" + else { + return nil + } + + let params = Dictionary( + (components.queryItems ?? []).compactMap { item in + item.value.map { (item.name, $0) } + }, + uniquingKeysWith: { _, last in last } + ) + + guard let host = params["host"], isValidHost(host), + let token = params["token"], !token.isEmpty + else { + return nil + } + + let port = params["port"].flatMap(Int.init) ?? 7700 + guard (1...65_535).contains(port) else { return nil } + let ca = params["ca"].flatMap { Data(base64Encoded: $0) != nil ? $0 : nil } + + return ServerConnection( + name: host == "0.0.0.0" ? "Local Mac" : host, + host: host, + port: port, + token: token, + caCertificate: ca + ) + } + + // MARK: - Auth Header + + /// Authorization header value for REST requests. + var authorizationHeader: String { + "Bearer \(token)" + } + + // MARK: - Private Helpers + + private func makeURL( + scheme: String, + path: String = "", + queryItems: [URLQueryItem] = [] + ) -> URL? { + var components = URLComponents() + components.scheme = scheme + components.host = host + components.port = port + components.path = path + components.queryItems = queryItems.isEmpty ? nil : queryItems + return components.url + } + + private static func isValidHost(_ host: String) -> Bool { + guard !host.isEmpty, + host.rangeOfCharacter(from: .whitespacesAndNewlines) == nil + else { + return false + } + + var components = URLComponents() + components.scheme = "http" + components.host = host + return components.url != nil + } +} diff --git a/ios/PPGMobile/PPGMobile/Models/ServerConnectionTests.swift b/ios/PPGMobile/PPGMobile/Models/ServerConnectionTests.swift new file mode 100644 index 0000000..a1ff952 --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Models/ServerConnectionTests.swift @@ -0,0 +1,151 @@ +import XCTest +@testable import PPGMobile + +final class ServerConnectionTests: XCTestCase { + + // MARK: - fromQRCode + + func testValidQRCodeParsesCorrectly() { + let qr = "ppg://connect?host=192.168.1.10&port=7700&token=abc123" + let conn = ServerConnection.fromQRCode(qr) + + XCTAssertNotNil(conn) + XCTAssertEqual(conn?.host, "192.168.1.10") + XCTAssertEqual(conn?.port, 7700) + XCTAssertEqual(conn?.token, "abc123") + XCTAssertNil(conn?.ca) + } + + func testValidQRCodeWithCAParsesCorrectly() { + // "dGVzdA==" is base64 for "test" + let qr = "ppg://connect?host=myhost&port=8080&token=secret&ca=dGVzdA==" + let conn = ServerConnection.fromQRCode(qr) + + XCTAssertNotNil(conn) + XCTAssertEqual(conn?.host, "myhost") + XCTAssertEqual(conn?.port, 8080) + XCTAssertEqual(conn?.token, "secret") + XCTAssertEqual(conn?.ca, "dGVzdA==") + } + + func testMissingHostReturnsNil() { + let qr = "ppg://connect?port=7700&token=abc123" + XCTAssertNil(ServerConnection.fromQRCode(qr)) + } + + func testEmptyHostReturnsNil() { + let qr = "ppg://connect?host=&port=7700&token=abc123" + XCTAssertNil(ServerConnection.fromQRCode(qr)) + } + + func testMissingTokenReturnsNil() { + let qr = "ppg://connect?host=myhost&port=7700" + XCTAssertNil(ServerConnection.fromQRCode(qr)) + } + + func testEmptyTokenReturnsNil() { + let qr = "ppg://connect?host=myhost&port=7700&token=" + XCTAssertNil(ServerConnection.fromQRCode(qr)) + } + + func testMissingPortDefaultsTo7700() { + let qr = "ppg://connect?host=myhost&token=abc123" + let conn = ServerConnection.fromQRCode(qr) + + XCTAssertNotNil(conn) + XCTAssertEqual(conn?.port, 7700) + } + + func testInvalidPortReturnsNil() { + XCTAssertNil(ServerConnection.fromQRCode("ppg://connect?host=myhost&port=0&token=abc123")) + XCTAssertNil(ServerConnection.fromQRCode("ppg://connect?host=myhost&port=70000&token=abc123")) + } + + func testInvalidHostReturnsNil() { + let qr = "ppg://connect?host=my%20host&port=7700&token=abc123" + XCTAssertNil(ServerConnection.fromQRCode(qr)) + } + + func testWrongSchemeReturnsNil() { + let qr = "http://connect?host=myhost&port=7700&token=abc123" + XCTAssertNil(ServerConnection.fromQRCode(qr)) + } + + func testWrongHostReturnsNil() { + let qr = "ppg://pair?host=myhost&port=7700&token=abc123" + XCTAssertNil(ServerConnection.fromQRCode(qr)) + } + + func testNonPPGStringReturnsNil() { + XCTAssertNil(ServerConnection.fromQRCode("https://example.com")) + XCTAssertNil(ServerConnection.fromQRCode("just some text")) + XCTAssertNil(ServerConnection.fromQRCode("")) + } + + func testDuplicateQueryParamsDoNotCrash() { + let qr = "ppg://connect?host=myhost&token=first&token=second&port=7700" + let conn = ServerConnection.fromQRCode(qr) + + XCTAssertNotNil(conn) + // Last value wins per uniquingKeysWith + XCTAssertEqual(conn?.token, "second") + } + + func testInvalidBase64CAIsDiscarded() { + let qr = "ppg://connect?host=myhost&port=7700&token=abc&ca=not-valid-base64!!!" + let conn = ServerConnection.fromQRCode(qr) + + XCTAssertNotNil(conn) + XCTAssertNil(conn?.ca) + } + + func testLocalhostNameMapping() { + let qr = "ppg://connect?host=0.0.0.0&port=7700&token=abc123" + let conn = ServerConnection.fromQRCode(qr) + + XCTAssertEqual(conn?.name, "Local Mac") + } + + func testNonLocalhostUsesHostAsName() { + let qr = "ppg://connect?host=workstation.local&port=7700&token=abc123" + let conn = ServerConnection.fromQRCode(qr) + + XCTAssertEqual(conn?.name, "workstation.local") + } + + // MARK: - URL construction + + func testBaseURLUsesHTTPWithoutCA() { + let conn = ServerConnection(host: "myhost", port: 7700, token: "abc") + XCTAssertEqual(conn.baseURL?.absoluteString, "http://myhost:7700") + } + + func testBaseURLUsesHTTPSWithCA() { + let conn = ServerConnection(host: "myhost", port: 7700, token: "abc", ca: "dGVzdA==") + XCTAssertEqual(conn.baseURL?.absoluteString, "https://myhost:7700") + } + + func testWsURLUsesWSSWithCA() { + let conn = ServerConnection(host: "myhost", port: 7700, token: "abc", ca: "dGVzdA==") + XCTAssertEqual(conn.wsURL?.scheme, "wss") + } + + func testWsURLPercentEncodesToken() { + let conn = ServerConnection(host: "myhost", port: 7700, token: "abc+def&ghi=jkl") + guard let url = conn.wsURL else { + XCTFail("Expected wsURL to be generated") + return + } + + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + let tokenValue = components?.queryItems?.first(where: { $0.name == "token" })?.value + XCTAssertEqual(tokenValue, "abc+def&ghi=jkl") + XCTAssertEqual(components?.queryItems?.count, 1) + } + + func testInvalidHostDoesNotCrashURLBuilding() { + let conn = ServerConnection(host: "bad host", port: 7700, token: "abc") + XCTAssertNil(conn.baseURL) + XCTAssertNil(conn.wsURL) + } +} diff --git a/ios/PPGMobile/PPGMobile/Networking/PPGClient.swift b/ios/PPGMobile/PPGMobile/Networking/PPGClient.swift new file mode 100644 index 0000000..612d65e --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Networking/PPGClient.swift @@ -0,0 +1,292 @@ +import Foundation + +// MARK: - Error Types + +enum PPGClientError: LocalizedError { + case notConfigured + case invalidURL(String) + case network(URLError) + case unauthorized + case notFound(String) + case conflict(String) + case serverError(Int, String) + case decodingError(DecodingError) + case invalidResponse + + var errorDescription: String? { + switch self { + case .notConfigured: + return "No server connection configured" + case .invalidURL(let path): + return "Invalid URL: \(path)" + case .network(let error): + return "Network error: \(error.localizedDescription)" + case .unauthorized: + return "Authentication failed — check your token" + case .notFound(let msg): + return "Not found: \(msg)" + case .conflict(let msg): + return "Conflict: \(msg)" + case .serverError(let code, let msg): + return "Server error (\(code)): \(msg)" + case .decodingError(let error): + return "Failed to decode response: \(error.localizedDescription)" + case .invalidResponse: + return "Invalid server response" + } + } +} + +// MARK: - TLS Delegate + +/// Allows connections to servers using a self-signed certificate +/// by trusting a pinned CA certificate bundled with the app. +private final class PinnedCertDelegate: NSObject, URLSessionDelegate, Sendable { + private let pinnedCert: SecCertificate? + + init(pinnedCertificateNamed name: String = "ppg-ca") { + if let url = Bundle.main.url(forResource: name, withExtension: "der"), + let data = try? Data(contentsOf: url) { + pinnedCert = SecCertificateCreateWithData(nil, data as CFData) + } else { + pinnedCert = nil + } + } + + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let serverTrust = challenge.protectionSpace.serverTrust, + let pinned = pinnedCert else { + completionHandler(.performDefaultHandling, nil) + return + } + + // Set the pinned CA as the sole anchor for evaluation + SecTrustSetAnchorCertificates(serverTrust, [pinned] as CFArray) + SecTrustSetAnchorCertificatesOnly(serverTrust, true) + + var error: CFError? + if SecTrustEvaluateWithError(serverTrust, &error) { + completionHandler(.useCredential, URLCredential(trust: serverTrust)) + } else { + completionHandler(.cancelAuthenticationChallenge, nil) + } + } +} + +// MARK: - REST Client + +/// Thread-safe REST client for the ppg serve API. +/// +/// Covers all 13 endpoints (7 read + 6 write) with async/await, +/// bearer token auth, and optional pinned-CA TLS trust. +actor PPGClient { + private let session: URLSession + private var connection: ServerConnection? + + init() { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 15 + config.timeoutIntervalForResource = 30 + let delegate = PinnedCertDelegate() + self.session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil) + } + + func configure(connection: ServerConnection) { + self.connection = connection + } + + // MARK: - Connection Test + + /// Verifies reachability and auth by hitting the status endpoint. + /// Returns `true` on success, throws on failure. + @discardableResult + func testConnection() async throws -> Bool { + let _: Manifest = try await get("/api/status") + return true + } + + // MARK: - Read API + + func fetchStatus() async throws -> Manifest { + return try await get("/api/status") + } + + func fetchWorktree(id: String) async throws -> WorktreeEntry { + return try await get("/api/worktrees/\(id)") + } + + func fetchDiff(worktreeId: String) async throws -> DiffResponse { + return try await get("/api/worktrees/\(worktreeId)/diff") + } + + func fetchAgentLogs(agentId: String, lines: Int = 200) async throws -> LogsResponse { + return try await get("/api/agents/\(agentId)/logs?lines=\(lines)") + } + + func fetchConfig() async throws -> Config { + return try await get("/api/config") + } + + func fetchTemplates() async throws -> TemplatesResponse { + return try await get("/api/templates") + } + + func fetchPrompts() async throws -> PromptsResponse { + return try await get("/api/prompts") + } + + func fetchSwarms() async throws -> SwarmsResponse { + return try await get("/api/swarms") + } + + // MARK: - Write API + + func spawn( + name: String?, + agent: String?, + prompt: String, + template: String? = nil, + base: String? = nil, + count: Int = 1 + ) async throws -> SpawnResponse { + var body: [String: Any] = ["prompt": prompt, "count": count] + if let name { body["name"] = name } + if let agent { body["agent"] = agent } + if let template { body["template"] = template } + if let base { body["base"] = base } + return try await post("/api/spawn", body: body) + } + + func sendToAgent(agentId: String, text: String, keys: Bool = false, enter: Bool = true) async throws { + var body: [String: Any] = ["text": text, "keys": keys] + if !enter { body["enter"] = false } + let _: SuccessResponse = try await post("/api/agents/\(agentId)/send", body: body) + } + + func killAgent(agentId: String) async throws { + let body: [String: Any] = [:] + let _: SuccessResponse = try await post("/api/agents/\(agentId)/kill", body: body) + } + + func restartAgent(agentId: String, prompt: String? = nil) async throws { + var body: [String: Any] = [:] + if let prompt { body["prompt"] = prompt } + let _: SuccessResponse = try await post("/api/agents/\(agentId)/restart", body: body) + } + + func mergeWorktree(worktreeId: String, strategy: String = "squash", force: Bool = false) async throws { + let body: [String: Any] = ["strategy": strategy, "force": force] + let _: SuccessResponse = try await post("/api/worktrees/\(worktreeId)/merge", body: body) + } + + func killWorktree(worktreeId: String) async throws { + let body: [String: Any] = [:] + let _: SuccessResponse = try await post("/api/worktrees/\(worktreeId)/kill", body: body) + } + + func createPR(worktreeId: String, title: String? = nil, body prBody: String? = nil, draft: Bool = false) async throws -> PRResponse { + var body: [String: Any] = ["draft": draft] + if let title { body["title"] = title } + if let prBody { body["body"] = prBody } + return try await post("/api/worktrees/\(worktreeId)/pr", body: body) + } + + // MARK: - Private Helpers + + private func get(_ path: String) async throws -> T { + let request = try makeRequest(path: path, method: "GET") + let (data, response) = try await performRequest(request) + try validateResponse(response, data: data) + return try decode(data) + } + + private func post(_ path: String, body: [String: Any]) async throws -> T { + var request = try makeRequest(path: path, method: "POST") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + let (data, response) = try await performRequest(request) + try validateResponse(response, data: data) + return try decode(data) + } + + private func makeRequest(path: String, method: String) throws -> URLRequest { + guard let conn = connection else { + throw PPGClientError.notConfigured + } + guard let url = URL(string: path, relativeTo: conn.baseURL) else { + throw PPGClientError.invalidURL(path) + } + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("Bearer \(conn.token)", forHTTPHeaderField: "Authorization") + return request + } + + private func performRequest(_ request: URLRequest) async throws -> (Data, URLResponse) { + do { + return try await session.data(for: request) + } catch let urlError as URLError { + throw PPGClientError.network(urlError) + } catch { + throw error + } + } + + private func decode(_ data: Data) throws -> T { + do { + return try JSONDecoder().decode(T.self, from: data) + } catch let decodingError as DecodingError { + throw PPGClientError.decodingError(decodingError) + } catch { + throw error + } + } + + private func validateResponse(_ response: URLResponse, data: Data) throws { + guard let http = response as? HTTPURLResponse else { + throw PPGClientError.invalidResponse + } + guard (200...299).contains(http.statusCode) else { + let msg = (try? JSONDecoder().decode(ErrorResponse.self, from: data))?.error + ?? String(data: data, encoding: .utf8) + ?? "Unknown error" + + switch http.statusCode { + case 401: + throw PPGClientError.unauthorized + case 404: + throw PPGClientError.notFound(msg) + case 409: + throw PPGClientError.conflict(msg) + default: + throw PPGClientError.serverError(http.statusCode, msg) + } + } + } +} + +// MARK: - Response Types (used only by PPGClient) + +private struct SuccessResponse: Decodable { + let success: Bool? + + init(from decoder: Decoder) throws { + let container = try? decoder.container(keyedBy: CodingKeys.self) + success = try container?.decodeIfPresent(Bool.self, forKey: .success) + } + + private enum CodingKeys: String, CodingKey { + case success + } +} + +struct PRResponse: Codable { + let success: Bool + let worktreeId: String + let prUrl: String +} diff --git a/ios/PPGMobile/PPGMobile/Networking/TokenStorage.swift b/ios/PPGMobile/PPGMobile/Networking/TokenStorage.swift new file mode 100644 index 0000000..1b29555 --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Networking/TokenStorage.swift @@ -0,0 +1,107 @@ +import Foundation +import Security + +// MARK: - Error Types + +enum KeychainError: LocalizedError { + case itemNotFound + case unexpectedStatus(OSStatus) + case invalidData + + var errorDescription: String? { + switch self { + case .itemNotFound: + return "Token not found in keychain" + case .unexpectedStatus(let status): + return "Keychain operation failed with status \(status)" + case .invalidData: + return "Token data could not be encoded or decoded" + } + } +} + +// MARK: - Protocol + +protocol TokenStoring { + func save(token: String, for connectionId: UUID) throws + func load(for connectionId: UUID) throws -> String + func delete(for connectionId: UUID) throws +} + +// MARK: - Implementation + +struct TokenStorage: TokenStoring { + private let serviceName = "com.ppg.mobile" + + func save(token: String, for connectionId: UUID) throws { + guard let data = token.data(using: .utf8) else { + throw KeychainError.invalidData + } + + var query = baseQuery(for: connectionId) + query[kSecValueData as String] = data + query[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlocked + + let status = SecItemAdd(query as CFDictionary, nil) + + switch status { + case errSecSuccess: + return + case errSecDuplicateItem: + let updateAttributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked + ] + let updateStatus = SecItemUpdate( + baseQuery(for: connectionId) as CFDictionary, + updateAttributes as CFDictionary + ) + guard updateStatus == errSecSuccess else { + throw KeychainError.unexpectedStatus(updateStatus) + } + default: + throw KeychainError.unexpectedStatus(status) + } + } + + func load(for connectionId: UUID) throws -> String { + var query = baseQuery(for: connectionId) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess else { + if status == errSecItemNotFound { + throw KeychainError.itemNotFound + } + throw KeychainError.unexpectedStatus(status) + } + + guard let data = result as? Data, + let token = String(data: data, encoding: .utf8) else { + throw KeychainError.invalidData + } + + return token + } + + func delete(for connectionId: UUID) throws { + let status = SecItemDelete(baseQuery(for: connectionId) as CFDictionary) + + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainError.unexpectedStatus(status) + } + } + + // MARK: - Private + + private func baseQuery(for connectionId: UUID) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: connectionId.uuidString + ] + } +} diff --git a/ios/PPGMobile/PPGMobile/Networking/WebSocketManager.swift b/ios/PPGMobile/PPGMobile/Networking/WebSocketManager.swift new file mode 100644 index 0000000..af13821 --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Networking/WebSocketManager.swift @@ -0,0 +1,391 @@ +import Foundation + +// MARK: - Connection State + +enum WebSocketConnectionState: Equatable, Sendable { + case disconnected + case connecting + case connected + case reconnecting(attempt: Int) + + var isConnected: Bool { self == .connected } + + var isReconnecting: Bool { + if case .reconnecting = self { return true } + return false + } +} + +// MARK: - Server Events + +enum WebSocketEvent: Sendable { + case manifestUpdated(Manifest) + case agentStatusChanged(agentId: String, status: AgentStatus) + case worktreeStatusChanged(worktreeId: String, status: String) + case pong + case unknown(type: String, payload: String) +} + +// MARK: - Server Message (for terminal streaming) + +struct ServerMessage { + let type: String + let agentId: String? + let data: String? +} + +// MARK: - WebSocketManager + +final class WebSocketManager: NSObject, @unchecked Sendable, URLSessionWebSocketDelegate { + + // MARK: - Callbacks + + var onStateChange: ((WebSocketConnectionState) -> Void)? + var onEvent: ((WebSocketEvent) -> Void)? + var onMessage: ((ServerMessage) -> Void)? + + // MARK: - Configuration + + private let url: URL + private let maxReconnectDelay: TimeInterval = 30.0 + private let baseReconnectDelay: TimeInterval = 1.0 + private let pingInterval: TimeInterval = 30.0 + + // MARK: - State + + private let queue = DispatchQueue(label: "ppg.websocket-manager", qos: .utility) + private var _state: WebSocketConnectionState = .disconnected + + var state: WebSocketConnectionState { + queue.sync { _state } + } + + private var session: URLSession? + private var task: URLSessionWebSocketTask? + private var pingTimer: DispatchSourceTimer? + private var reconnectWorkItem: DispatchWorkItem? + private var reconnectAttempt = 0 + private var intentionalDisconnect = false + private var isHandlingConnectionLoss = false + + // MARK: - Init + + init(url: URL) { + self.url = url + super.init() + } + + convenience init?(urlString: String) { + guard let url = URL(string: urlString) else { return nil } + self.init(url: url) + } + + deinit { + intentionalDisconnect = true + pingTimer?.cancel() + pingTimer = nil + task?.cancel(with: .goingAway, reason: nil) + task = nil + session?.invalidateAndCancel() + session = nil + } + + // MARK: - Public API + + func connect() { + queue.async { [weak self] in + self?.doConnect() + } + } + + func disconnect() { + queue.async { [weak self] in + self?.doDisconnect() + } + } + + func sendTerminalInput(agentId: String, text: String) { + let dict: [String: String] = ["type": "terminal_input", "agentId": agentId, "data": text] + guard let data = try? JSONSerialization.data(withJSONObject: dict, options: [.sortedKeys]), + let str = String(data: data, encoding: .utf8) else { return } + queue.async { [weak self] in + self?.doSend(str) + } + } + + func subscribeTerminal(agentId: String) { + let dict: [String: String] = ["type": "subscribe", "channel": "terminal:\(agentId)"] + guard let data = try? JSONSerialization.data(withJSONObject: dict, options: [.sortedKeys]), + let str = String(data: data, encoding: .utf8) else { return } + queue.async { [weak self] in + self?.doSend(str) + } + } + + func unsubscribeTerminal(agentId: String) { + let dict: [String: String] = ["type": "unsubscribe", "channel": "terminal:\(agentId)"] + guard let data = try? JSONSerialization.data(withJSONObject: dict, options: [.sortedKeys]), + let str = String(data: data, encoding: .utf8) else { return } + queue.async { [weak self] in + self?.doSend(str) + } + } + + // MARK: - Connection Lifecycle + + private func doConnect() { + guard _state == .disconnected || _state.isReconnecting else { return } + + intentionalDisconnect = false + isHandlingConnectionLoss = false + reconnectWorkItem?.cancel() + reconnectWorkItem = nil + + if _state.isReconnecting { + // Keep attempt counter + } else { + reconnectAttempt = 0 + setState(.connecting) + } + + let config = URLSessionConfiguration.default + config.waitsForConnectivity = true + session = URLSession(configuration: config, delegate: self, delegateQueue: nil) + + let wsTask = session!.webSocketTask(with: url) + task = wsTask + wsTask.resume() + } + + private func doDisconnect() { + intentionalDisconnect = true + isHandlingConnectionLoss = false + reconnectWorkItem?.cancel() + reconnectWorkItem = nil + stopPingTimer() + task?.cancel(with: .goingAway, reason: nil) + task = nil + session?.invalidateAndCancel() + session = nil + reconnectAttempt = 0 + setState(.disconnected) + } + + private func setState(_ newState: WebSocketConnectionState) { + guard _state != newState else { return } + _state = newState + let callback = onStateChange + DispatchQueue.main.async { + callback?(newState) + } + } + + // MARK: - Sending + + private func doSend(_ text: String) { + guard _state == .connected, let task = task else { return } + task.send(.string(text)) { error in + if let error = error { + NSLog("[WebSocketManager] send error: \(error.localizedDescription)") + } + } + } + + // MARK: - Receiving + + private func listenForMessages(for expectedTask: URLSessionWebSocketTask) { + expectedTask.receive { [weak self] result in + guard let self = self else { return } + self.queue.async { + guard self.task === expectedTask else { return } + switch result { + case .success(let message): + self.handleMessage(message) + self.listenForMessages(for: expectedTask) + case .failure(let error): + if !self.intentionalDisconnect { + NSLog("[WebSocketManager] receive error: \(error.localizedDescription)") + self.handleConnectionLost() + } + } + } + } + } + + private func handleMessage(_ message: URLSessionWebSocketTask.Message) { + let text: String + switch message { + case .string(let s): + text = s + case .data(let d): + guard let s = String(data: d, encoding: .utf8) else { return } + text = s + @unknown default: + return + } + + // Parse as generic ServerMessage for terminal streaming + if let serverMsg = parseServerMessage(text) { + let callback = onMessage + DispatchQueue.main.async { + callback?(serverMsg) + } + } + + // Parse as typed event + if let event = parseEvent(text) { + let callback = onEvent + DispatchQueue.main.async { + callback?(event) + } + } + } + + // MARK: - Event Parsing + + private func parseServerMessage(_ text: String) -> ServerMessage? { + guard let data = text.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let type = json["type"] as? String else { + return nil + } + return ServerMessage( + type: type, + agentId: json["agentId"] as? String, + data: json["data"] as? String + ) + } + + func parseEvent(_ text: String) -> WebSocketEvent? { + guard let data = text.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let type = json["type"] as? String else { + return nil + } + + switch type { + case "manifest_updated": + if let payloadData = json["manifest"], + let payloadJSON = try? JSONSerialization.data(withJSONObject: payloadData), + let manifest = try? JSONDecoder().decode(Manifest.self, from: payloadJSON) { + return .manifestUpdated(manifest) + } + return .unknown(type: type, payload: text) + + case "agent_status_changed": + if let agentId = json["agentId"] as? String, + let statusRaw = json["status"] as? String, + let status = AgentStatus(rawValue: statusRaw) { + return .agentStatusChanged(agentId: agentId, status: status) + } + return .unknown(type: type, payload: text) + + case "worktree_status_changed": + if let worktreeId = json["worktreeId"] as? String, + let status = json["status"] as? String { + return .worktreeStatusChanged(worktreeId: worktreeId, status: status) + } + return .unknown(type: type, payload: text) + + case "pong": + return .pong + + default: + return .unknown(type: type, payload: text) + } + } + + // MARK: - Keepalive Ping + + private func startPingTimer() { + stopPingTimer() + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now() + pingInterval, repeating: pingInterval) + timer.setEventHandler { [weak self] in + self?.sendPing() + } + timer.resume() + pingTimer = timer + } + + private func stopPingTimer() { + pingTimer?.cancel() + pingTimer = nil + } + + private func sendPing() { + task?.sendPing { [weak self] error in + if let error = error { + NSLog("[WebSocketManager] ping error: \(error.localizedDescription)") + self?.queue.async { self?.handleConnectionLost() } + } + } + } + + // MARK: - Reconnect + + private func handleConnectionLost() { + guard !intentionalDisconnect else { return } + guard !isHandlingConnectionLoss else { return } + isHandlingConnectionLoss = true + stopPingTimer() + task?.cancel(with: .abnormalClosure, reason: nil) + task = nil + session?.invalidateAndCancel() + session = nil + scheduleReconnect() + } + + private func scheduleReconnect() { + reconnectAttempt += 1 + setState(.reconnecting(attempt: reconnectAttempt)) + + let delay = min(baseReconnectDelay * pow(2.0, Double(reconnectAttempt - 1)), maxReconnectDelay) + NSLog("[WebSocketManager] reconnecting in %.1fs (attempt %d)", delay, reconnectAttempt) + + let workItem = DispatchWorkItem { [weak self] in + guard let self = self, !self.intentionalDisconnect else { return } + self.reconnectWorkItem = nil + self.doConnect() + } + reconnectWorkItem?.cancel() + reconnectWorkItem = workItem + queue.asyncAfter(deadline: .now() + delay, execute: workItem) + } + + // MARK: - URLSessionWebSocketDelegate + + func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) { + queue.async { [weak self] in + guard let self = self else { return } + guard self.task === webSocketTask else { return } + self.reconnectAttempt = 0 + self.isHandlingConnectionLoss = false + self.setState(.connected) + self.startPingTimer() + self.listenForMessages(for: webSocketTask) + } + } + + func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + queue.async { [weak self] in + guard let self = self else { return } + guard self.task === webSocketTask else { return } + if self.intentionalDisconnect { + self.setState(.disconnected) + } else { + self.handleConnectionLost() + } + } + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: (any Error)?) { + guard error != nil else { return } + queue.async { [weak self] in + guard let self = self, !self.intentionalDisconnect else { return } + guard let webSocketTask = task as? URLSessionWebSocketTask, + self.task === webSocketTask else { return } + self.handleConnectionLost() + } + } +} diff --git a/ios/PPGMobile/PPGMobile/State/AppState.swift b/ios/PPGMobile/PPGMobile/State/AppState.swift new file mode 100644 index 0000000..c50dd7b --- /dev/null +++ b/ios/PPGMobile/PPGMobile/State/AppState.swift @@ -0,0 +1,314 @@ +import Foundation + +// MARK: - UserDefaults Keys + +private enum DefaultsKey { + static let savedConnections = "ppg_saved_connections" + static let lastConnectionId = "ppg_last_connection_id" +} + +/// Codable projection of ServerConnection without the token. +/// Tokens are stored separately in Keychain via TokenStorage. +private struct PersistedConnection: Codable { + let id: UUID + var name: String + var host: String + var port: Int + var caCertificate: String? + + init(from connection: ServerConnection) { + self.id = connection.id + self.name = connection.name + self.host = connection.host + self.port = connection.port + self.caCertificate = connection.caCertificate + } + + func toServerConnection(token: String) -> ServerConnection { + ServerConnection( + id: id, + name: name, + host: host, + port: port, + token: token, + caCertificate: caCertificate + ) + } +} + +// MARK: - AppState + +/// Root application state managing server connections and the REST/WS lifecycle. +/// +/// `AppState` is the single entry point for connection management. It persists +/// connection metadata to `UserDefaults` and tokens to Keychain via `TokenStorage`. +/// Auto-connects to the last-used server on launch and coordinates `PPGClient` +/// (REST) and `WebSocketManager` (WS) through `ManifestStore`. +@MainActor +@Observable +final class AppState { + + // MARK: - Connection State + + /// All saved server connections. + private(set) var connections: [ServerConnection] = [] + + /// The currently active connection, or `nil` if disconnected. + private(set) var activeConnection: ServerConnection? + + /// Whether a connection attempt is in progress. + private(set) var isConnecting = false + + /// User-facing error message, cleared on next connect attempt. + private(set) var errorMessage: String? + + // MARK: - WebSocket State + + /// Current WebSocket connection state. + private(set) var webSocketState: WebSocketConnectionState = .disconnected + + // MARK: - Connection Status (for Settings UI) + + var connectionStatus: ConnectionState { + if isConnecting { return .connecting } + if let error = errorMessage { return .error(error) } + if activeConnection != nil { return .connected } + return .disconnected + } + + // MARK: - Dependencies + + let client = PPGClient() + let manifestStore: ManifestStore + private(set) var wsManager: WebSocketManager? + + private let tokenStorage = TokenStorage() + + // MARK: - Computed + + var manifest: Manifest? { manifestStore.manifest } + var templates: [String] { [] } + + // MARK: - Init + + init() { + self.manifestStore = ManifestStore(client: client) + loadConnections() + } + + // MARK: - Auto-Connect + + /// Connects to the last-used server if one exists. + func autoConnect() async { + guard let lastId = UserDefaults.standard.string(forKey: DefaultsKey.lastConnectionId), + let uuid = UUID(uuidString: lastId), + let connection = connections.first(where: { $0.id == uuid }) else { + return + } + await connect(to: connection) + } + + // MARK: - Connect / Disconnect + + func connect(to connection: ServerConnection) async { + guard !isConnecting else { return } + + if activeConnection != nil { + disconnect() + } + + isConnecting = true + errorMessage = nil + + await client.configure(connection: connection) + + do { + try await client.testConnection() + } catch { + isConnecting = false + errorMessage = "Cannot reach server: \(error.localizedDescription)" + return + } + + activeConnection = connection + UserDefaults.standard.set(connection.id.uuidString, forKey: DefaultsKey.lastConnectionId) + + startWebSocket(for: connection) + await manifestStore.refresh() + + isConnecting = false + } + + func disconnect() { + stopWebSocket() + activeConnection = nil + manifestStore.clear() + webSocketState = .disconnected + } + + // MARK: - Agent Actions + + func killAgent(_ agentId: String) async { + do { + try await client.killAgent(agentId: agentId) + await manifestStore.refresh() + } catch { + errorMessage = "Failed to kill agent: \(error.localizedDescription)" + } + } + + // MARK: - Connection CRUD + + func addConnection(_ connection: ServerConnection) { + // Remove duplicate host:port + if let existing = connections.first(where: { $0.host == connection.host && $0.port == connection.port }), + existing.id != connection.id { + try? tokenStorage.delete(for: existing.id) + } + + if let index = connections.firstIndex(where: { $0.host == connection.host && $0.port == connection.port }) { + connections[index] = connection + } else { + connections.append(connection) + } + saveConnections() + } + + func addConnectionAndConnect(_ connection: ServerConnection) async { + addConnection(connection) + await connect(to: connection) + } + + func removeConnection(_ connection: ServerConnection) { + if activeConnection?.id == connection.id { + disconnect() + } + connections.removeAll { $0.id == connection.id } + try? tokenStorage.delete(for: connection.id) + saveConnections() + + if let lastId = UserDefaults.standard.string(forKey: DefaultsKey.lastConnectionId), + lastId == connection.id.uuidString { + UserDefaults.standard.removeObject(forKey: DefaultsKey.lastConnectionId) + } + } + + func updateConnection(_ connection: ServerConnection) async { + guard let index = connections.firstIndex(where: { $0.id == connection.id }) else { return } + connections[index] = connection + saveConnections() + + if activeConnection?.id == connection.id { + await connect(to: connection) + } + } + + // MARK: - Error Handling + + func clearError() { + errorMessage = nil + } + + // MARK: - WebSocket Lifecycle + + private func startWebSocket(for connection: ServerConnection) { + stopWebSocket() + + guard let wsURL = connection.webSocketURL else { return } + let ws = WebSocketManager(url: wsURL) + ws.onStateChange = { [weak self] state in + Task { @MainActor in + self?.webSocketState = state + } + } + ws.onEvent = { [weak self] event in + Task { @MainActor in + self?.handleWebSocketEvent(event) + } + } + wsManager = ws + ws.connect() + } + + private func stopWebSocket() { + wsManager?.disconnect() + wsManager = nil + } + + private func handleWebSocketEvent(_ event: WebSocketEvent) { + switch event { + case .manifestUpdated(let manifest): + manifestStore.applyManifest(manifest) + + case .agentStatusChanged(let agentId, let status): + manifestStore.updateAgentStatus(agentId: agentId, status: status) + + case .worktreeStatusChanged(let worktreeId, let statusRaw): + if let status = WorktreeStatus(rawValue: statusRaw) { + manifestStore.updateWorktreeStatus(worktreeId: worktreeId, status: status) + } + + case .pong: + break + + case .unknown: + break + } + } + + // MARK: - Persistence + + private func loadConnections() { + guard let data = UserDefaults.standard.data(forKey: DefaultsKey.savedConnections) else { + return + } + + let persisted: [PersistedConnection] + do { + persisted = try JSONDecoder().decode([PersistedConnection].self, from: data) + } catch { + errorMessage = "Failed to load saved connections." + return + } + + var loaded: [ServerConnection] = [] + var failedTokenLoad = false + for entry in persisted { + do { + let token = try tokenStorage.load(for: entry.id) + loaded.append(entry.toServerConnection(token: token)) + } catch { + failedTokenLoad = true + } + } + connections = loaded + + if failedTokenLoad { + errorMessage = "Some saved connection tokens could not be loaded." + } + } + + private func saveConnections() { + let persisted = connections.map { PersistedConnection(from: $0) } + do { + let data = try JSONEncoder().encode(persisted) + UserDefaults.standard.set(data, forKey: DefaultsKey.savedConnections) + } catch { + errorMessage = "Failed to save connections." + return + } + + var failedTokenSave = false + for connection in connections { + do { + try tokenStorage.save(token: connection.token, for: connection.id) + } catch { + failedTokenSave = true + } + } + + if failedTokenSave { + errorMessage = "Some connection tokens could not be saved." + } + } +} diff --git a/ios/PPGMobile/PPGMobile/State/ManifestStore.swift b/ios/PPGMobile/PPGMobile/State/ManifestStore.swift new file mode 100644 index 0000000..1c065a7 --- /dev/null +++ b/ios/PPGMobile/PPGMobile/State/ManifestStore.swift @@ -0,0 +1,121 @@ +import Foundation + +// MARK: - ManifestStore + +/// Caches the ppg manifest and applies incremental WebSocket updates. +/// +/// `ManifestStore` owns the manifest data and provides read access to views. +/// It is updated either by a full REST fetch or by individual WebSocket events +/// (agent/worktree status changes) to keep the UI responsive without polling. +@MainActor +@Observable +final class ManifestStore { + + // MARK: - Published State + + /// The cached manifest, or `nil` if not yet loaded. + private(set) var manifest: Manifest? + + /// Whether a fetch is currently in progress. + private(set) var isLoading = false + + /// Last error from a fetch or WebSocket update. + private(set) var error: String? + + /// Timestamp of the last successful refresh. + private(set) var lastRefreshed: Date? + + // MARK: - Dependencies + + private let client: PPGClient + + // MARK: - Init + + init(client: PPGClient) { + self.client = client + } + + // MARK: - Full Refresh + + /// Fetches the full manifest from the REST API and replaces the cache. + func refresh() async { + isLoading = true + error = nil + defer { isLoading = false } + + do { + let fetched = try await client.fetchStatus() + manifest = fetched + lastRefreshed = Date() + } catch { + self.error = error.localizedDescription + } + } + + // MARK: - Incremental Updates + + /// Applies a full manifest snapshot received from WebSocket. + func applyManifest(_ updated: Manifest) { + manifest = updated + lastRefreshed = Date() + error = nil + } + + /// Updates a single agent's status in the cached manifest. + func updateAgentStatus(agentId: String, status: AgentStatus) { + guard var m = manifest else { return } + for (wtId, var worktree) in m.worktrees { + if var agent = worktree.agents[agentId] { + agent.status = status + worktree.agents[agentId] = agent + m.worktrees[wtId] = worktree + manifest = m + lastRefreshed = Date() + error = nil + return + } + } + } + + /// Updates a single worktree's status in the cached manifest. + func updateWorktreeStatus(worktreeId: String, status: WorktreeStatus) { + guard var m = manifest, + var worktree = m.worktrees[worktreeId] else { return } + worktree.status = status + m.worktrees[worktreeId] = worktree + manifest = m + lastRefreshed = Date() + error = nil + } + + // MARK: - Clear + + /// Resets the store to its initial empty state. + func clear() { + manifest = nil + isLoading = false + error = nil + lastRefreshed = nil + } + + // MARK: - Convenience + + /// All worktrees sorted by creation date (newest first). + var sortedWorktrees: [WorktreeEntry] { + manifest?.sortedWorktrees ?? [] + } + + /// All agents across all worktrees. + var allAgents: [AgentEntry] { + manifest?.allAgents ?? [] + } + + /// Counts of agents by status. + var agentCounts: [AgentStatus: Int] { + var counts: [AgentStatus: Int] = [:] + for agent in allAgents { + counts[agent.status, default: 0] += 1 + } + return counts + } +} diff --git a/ios/PPGMobile/PPGMobile/Views/Dashboard/AgentRow.swift b/ios/PPGMobile/PPGMobile/Views/Dashboard/AgentRow.swift new file mode 100644 index 0000000..cdebad2 --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Views/Dashboard/AgentRow.swift @@ -0,0 +1,103 @@ +import SwiftUI + +struct AgentRow: View { + let agent: AgentEntry + var onKill: (() -> Void)? + var onRestart: (() -> Void)? + + @State private var confirmingKill = false + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Image(systemName: agent.status.icon) + .foregroundStyle(agent.status.color) + .font(.body) + + VStack(alignment: .leading, spacing: 1) { + Text(agent.name) + .font(.subheadline) + .fontWeight(.medium) + + Text(agent.agentType) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + statusLabel + } + + Text(agent.prompt) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + + HStack { + if let date = agent.startDate { + Text(date, style: .relative) + .font(.caption2) + .foregroundStyle(.tertiary) + } + + Spacer() + + actionButtons + } + } + .padding(.vertical, 4) + .confirmationDialog("Kill Agent", isPresented: $confirmingKill) { + if let onKill { + Button("Kill", role: .destructive) { + onKill() + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Kill agent \"\(agent.name)\"? This cannot be undone.") + } + } + + // MARK: - Status Label + + private var statusLabel: some View { + Text(agent.status.label) + .font(.caption) + .fontWeight(.medium) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(agent.status.color.opacity(0.12)) + .foregroundStyle(agent.status.color) + .clipShape(Capsule()) + } + + // MARK: - Action Buttons + + @ViewBuilder + private var actionButtons: some View { + HStack(spacing: 12) { + if agent.status.isActive, onKill != nil { + Button { + confirmingKill = true + } label: { + Image(systemName: "stop.fill") + .font(.caption) + .foregroundStyle(.red) + } + .buttonStyle(.borderless) + } + + if (agent.status == .failed || agent.status == .killed), let onRestart { + Button { + onRestart() + } label: { + Image(systemName: "arrow.counterclockwise") + .font(.caption) + .foregroundStyle(.blue) + } + .buttonStyle(.borderless) + } + } + } +} diff --git a/ios/PPGMobile/PPGMobile/Views/Dashboard/DashboardView.swift b/ios/PPGMobile/PPGMobile/Views/Dashboard/DashboardView.swift new file mode 100644 index 0000000..806a934 --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Views/Dashboard/DashboardView.swift @@ -0,0 +1,128 @@ +import SwiftUI + +struct DashboardView: View { + @Environment(AppState.self) private var appState + + var body: some View { + NavigationStack { + Group { + switch appState.connectionStatus { + case .disconnected: + disconnectedView + case .connecting: + ProgressView("Connecting...") + case .connected: + if appState.manifestStore.sortedWorktrees.isEmpty { + emptyStateView + } else { + worktreeList + } + case .error(let message): + errorView(message) + } + } + .navigationTitle(appState.manifest?.sessionName ?? "PPG") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + Task { await appState.manifestStore.refresh() } + } label: { + Image(systemName: "arrow.clockwise") + } + .disabled(appState.activeConnection == nil) + } + } + } + } + + // MARK: - Worktree List + + private var worktreeList: some View { + let worktrees = appState.manifestStore.sortedWorktrees + + return List { + let active = worktrees.filter { !$0.status.isTerminal } + let completed = worktrees.filter { $0.status.isTerminal } + + if !active.isEmpty { + Section("Active") { + ForEach(active) { worktree in + NavigationLink(value: worktree.id) { + WorktreeCard(worktree: worktree) + } + } + } + } + + if !completed.isEmpty { + Section("Completed") { + ForEach(completed) { worktree in + NavigationLink(value: worktree.id) { + WorktreeCard(worktree: worktree) + } + } + } + } + } + .listStyle(.insetGrouped) + .refreshable { + await appState.manifestStore.refresh() + } + .navigationDestination(for: String.self) { worktreeId in + if appState.manifest?.worktrees[worktreeId] != nil { + WorktreeDetailView(worktreeId: worktreeId) + } else { + ContentUnavailableView( + "Worktree Not Found", + systemImage: "questionmark.folder", + description: Text("This worktree may have been removed.") + ) + } + } + } + + // MARK: - Empty State + + private var emptyStateView: some View { + ContentUnavailableView { + Label("No Worktrees", systemImage: "arrow.triangle.branch") + } description: { + Text("Spawn agents from the CLI to see them here.") + } actions: { + Button("Refresh") { + Task { await appState.manifestStore.refresh() } + } + } + } + + // MARK: - Disconnected State + + private var disconnectedView: some View { + ContentUnavailableView { + Label("Disconnected", systemImage: "wifi.slash") + } description: { + Text("Unable to reach the ppg service. Check that the CLI is running and the server is started.") + } actions: { + Button("Retry") { + Task { await appState.autoConnect() } + } + .buttonStyle(.borderedProminent) + } + } + + // MARK: - Error State + + private func errorView(_ message: String) -> some View { + ContentUnavailableView { + Label("Connection Error", systemImage: "exclamationmark.triangle") + } description: { + Text(message) + } actions: { + Button("Retry") { + appState.clearError() + Task { await appState.autoConnect() } + } + .buttonStyle(.borderedProminent) + } + } +} diff --git a/ios/PPGMobile/PPGMobile/Views/Dashboard/WorktreeCard.swift b/ios/PPGMobile/PPGMobile/Views/Dashboard/WorktreeCard.swift new file mode 100644 index 0000000..e6d819b --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Views/Dashboard/WorktreeCard.swift @@ -0,0 +1,79 @@ +import SwiftUI + +struct WorktreeCard: View { + let worktree: WorktreeEntry + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(worktree.name) + .font(.headline) + + Text(worktree.branch) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + + statusBadge + } + + HStack(spacing: 12) { + Label("\(worktree.agents.count)", systemImage: "person.2") + .font(.subheadline) + .foregroundStyle(.secondary) + + if !activeAgents.isEmpty { + Label("\(activeAgents.count) active", systemImage: "bolt.fill") + .font(.caption) + .foregroundStyle(.green) + } + + if !failedAgents.isEmpty { + Label("\(failedAgents.count) failed", systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.red) + } + + Spacer() + + if let date = worktree.createdDate { + Text(date, style: .relative) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + .padding(.vertical, 4) + } + + // MARK: - Status Badge + + private var statusBadge: some View { + HStack(spacing: 4) { + Image(systemName: worktree.status.icon) + .font(.caption2) + Text(worktree.status.label) + .font(.caption) + .fontWeight(.medium) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(worktree.status.color.opacity(0.15)) + .foregroundStyle(worktree.status.color) + .clipShape(Capsule()) + } + + // MARK: - Helpers + + private var activeAgents: [AgentEntry] { + worktree.sortedAgents.filter { $0.status.isActive } + } + + private var failedAgents: [AgentEntry] { + worktree.sortedAgents.filter { $0.status == .failed } + } +} diff --git a/ios/PPGMobile/PPGMobile/Views/Dashboard/WorktreeDetailView.swift b/ios/PPGMobile/PPGMobile/Views/Dashboard/WorktreeDetailView.swift new file mode 100644 index 0000000..cf64176 --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Views/Dashboard/WorktreeDetailView.swift @@ -0,0 +1,173 @@ +import SwiftUI + +struct WorktreeDetailView: View { + let worktreeId: String + @Environment(AppState.self) private var appState + + @State private var confirmingMerge = false + @State private var confirmingKill = false + + private var worktree: WorktreeEntry? { + appState.manifest?.worktrees[worktreeId] + } + + var body: some View { + Group { + if let worktree { + List { + infoSection(worktree) + agentsSection(worktree) + actionsSection(worktree) + } + .listStyle(.insetGrouped) + .navigationTitle(worktree.name) + .navigationBarTitleDisplayMode(.large) + .confirmationDialog("Merge Worktree", isPresented: $confirmingMerge) { + Button("Squash Merge") { + Task { + try? await appState.client.mergeWorktree(worktreeId: worktreeId) + await appState.manifestStore.refresh() + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Merge \"\(worktree.name)\" back to the base branch?") + } + .confirmationDialog("Kill Worktree", isPresented: $confirmingKill) { + Button("Kill All Agents", role: .destructive) { + Task { + try? await appState.client.killWorktree(worktreeId: worktreeId) + await appState.manifestStore.refresh() + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Kill all agents in \"\(worktree.name)\"? This cannot be undone.") + } + } else { + ContentUnavailableView( + "Worktree Not Found", + systemImage: "questionmark.folder", + description: Text("This worktree may have been removed.") + ) + } + } + } + + // MARK: - Info Section + + private func infoSection(_ worktree: WorktreeEntry) -> some View { + Section { + LabeledContent("Status") { + HStack(spacing: 4) { + Image(systemName: worktree.status.icon) + .font(.caption2) + Text(worktree.status.label) + .fontWeight(.medium) + } + .foregroundStyle(worktree.status.color) + } + + LabeledContent("Branch") { + Text(worktree.branch) + .font(.footnote.monospaced()) + .foregroundStyle(.secondary) + } + + LabeledContent("Agents") { + Text("\(worktree.agents.count)") + } + + if let date = worktree.createdDate { + LabeledContent("Created") { + Text(date, style: .relative) + } + } + + if let mergedDate = worktree.mergedDate { + LabeledContent("Merged") { + Text(mergedDate, style: .relative) + } + } + } header: { + Text("Details") + } + } + + // MARK: - Agents Section + + private func agentsSection(_ worktree: WorktreeEntry) -> some View { + Section { + if worktree.agents.isEmpty { + Text("No agents") + .foregroundStyle(.secondary) + } else { + ForEach(worktree.sortedAgents) { agent in + AgentRow( + agent: agent, + onKill: { + Task { await appState.killAgent(agent.id) } + }, + onRestart: { + Task { + try? await appState.client.restartAgent(agentId: agent.id) + await appState.manifestStore.refresh() + } + } + ) + } + } + } header: { + HStack { + Text("Agents") + Spacer() + Text(agentSummary(worktree)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + // MARK: - Actions Section + + private func actionsSection(_ worktree: WorktreeEntry) -> some View { + Section { + if worktree.status == .active || worktree.status == .running { + Button { + confirmingMerge = true + } label: { + Label("Merge Worktree", systemImage: "arrow.triangle.merge") + } + + Button(role: .destructive) { + confirmingKill = true + } label: { + Label("Kill All Agents", systemImage: "xmark.octagon") + } + } + + Button { + Task { + try? await appState.client.createPR(worktreeId: worktreeId) + await appState.manifestStore.refresh() + } + } label: { + Label("Create Pull Request", systemImage: "arrow.triangle.pull") + } + .disabled(worktree.status != .active && worktree.status != .running && worktree.status != .merged) + } header: { + Text("Actions") + } + } + + // MARK: - Helpers + + private func agentSummary(_ worktree: WorktreeEntry) -> String { + let active = worktree.sortedAgents.filter { $0.status.isActive }.count + let total = worktree.agents.count + if active > 0 { + return "\(active)/\(total) active" + } + return "\(total) total" + } +} diff --git a/ios/PPGMobile/PPGMobile/Views/Settings/AddServerView.swift b/ios/PPGMobile/PPGMobile/Views/Settings/AddServerView.swift new file mode 100644 index 0000000..6bca076 --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Views/Settings/AddServerView.swift @@ -0,0 +1,115 @@ +import SwiftUI + +struct AddServerView: View { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + + @State private var name = "My Mac" + @State private var host = "" + @State private var port = "7700" + @State private var token = "" + @State private var showToken = false + + var body: some View { + NavigationStack { + Form { + Section("Server Details") { + TextField("Name", text: $name) + + TextField("Host (e.g., 192.168.1.100)", text: $host) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + + TextField("Port", text: $port) + .keyboardType(.numberPad) + } + + Section("Authentication") { + HStack { + Group { + if showToken { + TextField("Token", text: $token) + .fontDesign(.monospaced) + } else { + SecureField("Token", text: $token) + } + } + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + + Button { + showToken.toggle() + } label: { + Image(systemName: showToken ? "eye.slash" : "eye") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + + Section { + Button { + addServer() + } label: { + HStack { + Spacer() + Text("Add Server") + .fontWeight(.semibold) + Spacer() + } + } + .disabled(!isValid) + } + } + .navigationTitle("Add Server") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + } + } + + private var isValid: Bool { + !trimmedHost.isEmpty && parsedPort != nil + } + + private var trimmedName: String { + name.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var trimmedHost: String { + host.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var trimmedToken: String { + token.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var parsedPort: Int? { + guard + let value = Int(port.trimmingCharacters(in: .whitespacesAndNewlines)), + (1...65_535).contains(value) + else { + return nil + } + return value + } + + private func addServer() { + guard let validatedPort = parsedPort else { return } + let connection = ServerConnection( + name: trimmedName.isEmpty ? "My Mac" : trimmedName, + host: trimmedHost, + port: validatedPort, + token: trimmedToken + ) + appState.addConnection(connection) + Task { + await appState.connect(to: connection) + } + dismiss() + } +} diff --git a/ios/PPGMobile/PPGMobile/Views/Settings/QRScannerView.swift b/ios/PPGMobile/PPGMobile/Views/Settings/QRScannerView.swift new file mode 100644 index 0000000..4c69ed1 --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Views/Settings/QRScannerView.swift @@ -0,0 +1,220 @@ +import SwiftUI +import AVFoundation + +/// QR code scanner for pairing with ppg serve. +/// Scans for ppg://connect URLs and creates a ServerConnection. +struct QRScannerView: View { + let onScan: (ServerConnection) -> Void + @Environment(\.dismiss) private var dismiss + @State private var scannedCode: String? + @State private var scannerResetToken = UUID() + @State private var showError = false + @State private var errorMessage = "" + @State private var permissionDenied = false + + var body: some View { + NavigationStack { + ZStack { + if permissionDenied { + cameraPermissionView + } else { + QRCameraView(onCodeScanned: handleScan) + .id(scannerResetToken) + .ignoresSafeArea() + + scanOverlay + } + } + .navigationTitle("Scan QR Code") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + .alert("Invalid QR Code", isPresented: $showError) { + Button("OK") { restartScanner() } + } message: { + Text(errorMessage) + } + .task { + await checkCameraPermission() + } + } + } + + private var scanOverlay: some View { + VStack { + Spacer() + + VStack(spacing: 12) { + Image(systemName: "qrcode.viewfinder") + .font(.system(size: 48)) + .foregroundStyle(.white) + + Text("Point camera at the QR code shown by `ppg serve`") + .font(.subheadline) + .foregroundStyle(.white) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + .padding() + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .padding() + } + } + + private var cameraPermissionView: some View { + ContentUnavailableView { + Label("Camera Access Required", systemImage: "camera.fill") + } description: { + Text("PPG Mobile needs camera access to scan QR codes for server pairing.") + } actions: { + Button("Open Settings") { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + .buttonStyle(.borderedProminent) + } + } + + @MainActor + private func checkCameraPermission() async { + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + permissionDenied = false + case .notDetermined: + let granted = await AVCaptureDevice.requestAccess(for: .video) + permissionDenied = !granted + case .denied, .restricted: + permissionDenied = true + @unknown default: + permissionDenied = true + } + } + + private func handleScan(_ code: String) { + guard scannedCode == nil else { return } + scannedCode = code + + if let connection = ServerConnection.fromQRCode(code) { + onScan(connection) + } else { + errorMessage = "This QR code doesn't contain a valid ppg server connection.\n\nExpected format: ppg://connect?host=...&port=...&token=..." + showError = true + } + } + + private func restartScanner() { + scannedCode = nil + scannerResetToken = UUID() + } +} + +// MARK: - Camera UIViewRepresentable + +/// UIViewRepresentable wrapper for AVCaptureSession QR code scanning. +/// Manages session lifecycle on appear/disappear and handles preview bounds correctly. +struct QRCameraView: UIViewRepresentable { + let onCodeScanned: (String) -> Void + + func makeUIView(context: Context) -> CameraPreviewView { + let view = CameraPreviewView() + let coordinator = context.coordinator + + let session = AVCaptureSession() + coordinator.session = session + + guard let device = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: device) + else { return view } + + if session.canAddInput(input) { + session.addInput(input) + } + + let output = AVCaptureMetadataOutput() + if session.canAddOutput(output) { + session.addOutput(output) + output.setMetadataObjectsDelegate(coordinator, queue: .main) + output.metadataObjectTypes = [.qr] + } + + let previewLayer = AVCaptureVideoPreviewLayer(session: session) + previewLayer.videoGravity = .resizeAspectFill + view.previewLayer = previewLayer + view.layer.addSublayer(previewLayer) + + coordinator.startSession() + + return view + } + + func updateUIView(_ uiView: CameraPreviewView, context: Context) { + uiView.previewLayer?.frame = uiView.bounds + } + + static func dismantleUIView(_ uiView: CameraPreviewView, coordinator: Coordinator) { + coordinator.stopSession() + } + + func makeCoordinator() -> Coordinator { + Coordinator(onCodeScanned: onCodeScanned) + } + + // MARK: - Preview UIView + + /// Custom UIView that keeps the preview layer sized to its bounds. + class CameraPreviewView: UIView { + var previewLayer: AVCaptureVideoPreviewLayer? + + override func layoutSubviews() { + super.layoutSubviews() + previewLayer?.frame = bounds + } + } + + // MARK: - Coordinator + + class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate { + let onCodeScanned: (String) -> Void + var session: AVCaptureSession? + private var hasScanned = false + + init(onCodeScanned: @escaping (String) -> Void) { + self.onCodeScanned = onCodeScanned + } + + func startSession() { + guard let session, !session.isRunning else { return } + DispatchQueue.global(qos: .userInitiated).async { + session.startRunning() + } + } + + func stopSession() { + guard let session, session.isRunning else { return } + DispatchQueue.global(qos: .userInitiated).async { + session.stopRunning() + } + } + + func metadataOutput( + _ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection + ) { + guard !hasScanned, + let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject, + object.type == .qr, + let value = object.stringValue + else { return } + + hasScanned = true + stopSession() + onCodeScanned(value) + } + } +} diff --git a/ios/PPGMobile/PPGMobile/Views/Settings/SettingsView.swift b/ios/PPGMobile/PPGMobile/Views/Settings/SettingsView.swift new file mode 100644 index 0000000..05ff5ec --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Views/Settings/SettingsView.swift @@ -0,0 +1,236 @@ +import SwiftUI + +struct SettingsView: View { + @Environment(AppState.self) private var appState + + @State private var showAddManual = false + @State private var showQRScanner = false + @State private var deleteTarget: ServerConnection? + @State private var testResult: TestResult? + @State private var showQRError = false + + private let repositoryURL = URL(string: "https://github.com/2witstudios/ppg-cli") + + private enum TestResult: Equatable { + case testing + case success + case failure(String) + } + + var body: some View { + NavigationStack { + List { + currentConnectionSection + savedServersSection + addServerSection + aboutSection + } + .navigationTitle("Settings") + .sheet(isPresented: $showQRScanner) { + QRScannerView { connection in + handleQRScan(connection) + } + } + .sheet(isPresented: $showAddManual) { + AddServerView() + } + .confirmationDialog( + "Delete Server", + isPresented: .init( + get: { deleteTarget != nil }, + set: { if !$0 { deleteTarget = nil } } + ), + presenting: deleteTarget + ) { server in + Button("Delete \"\(server.name)\"", role: .destructive) { + appState.removeConnection(server) + deleteTarget = nil + } + } message: { server in + Text("Remove \(server.name) (\(server.host):\(server.port))? This cannot be undone.") + } + .alert("Invalid QR Code", isPresented: $showQRError) { + Button("OK", role: .cancel) {} + } message: { + Text("The scanned code is not a valid ppg server. Expected format: ppg://connect?host=...&port=...&token=...") + } + } + } + + // MARK: - Sections + + @ViewBuilder + private var currentConnectionSection: some View { + Section("Current Connection") { + if let conn = appState.activeConnection { + HStack { + VStack(alignment: .leading) { + Text(conn.name) + .font(.headline) + Text("\(conn.host):\(conn.port)") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + connectionStatusBadge + } + + testConnectionRow + + Button("Disconnect", role: .destructive) { + appState.disconnect() + } + } else { + Text("Not connected") + .foregroundStyle(.secondary) + } + } + } + + @ViewBuilder + private var savedServersSection: some View { + Section("Saved Servers") { + ForEach(appState.connections) { conn in + Button { + Task { await appState.connect(to: conn) } + } label: { + HStack { + VStack(alignment: .leading) { + Text(conn.name) + Text("\(conn.host):\(conn.port)") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if appState.activeConnection?.id == conn.id { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + } + } + } + .foregroundStyle(.primary) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button("Delete", role: .destructive) { + deleteTarget = conn + } + } + } + + if appState.connections.isEmpty { + Text("No saved servers") + .foregroundStyle(.secondary) + } + } + } + + @ViewBuilder + private var addServerSection: some View { + Section("Add Server") { + Button { + showQRScanner = true + } label: { + Label("Scan QR Code", systemImage: "qrcode.viewfinder") + } + + Button { + showAddManual = true + } label: { + Label("Enter Manually", systemImage: "keyboard") + } + } + } + + @ViewBuilder + private var aboutSection: some View { + Section("About") { + LabeledContent("PPG Mobile", value: appVersion) + LabeledContent("Server Protocol", value: "v1") + + if let repositoryURL { + Link(destination: repositoryURL) { + Label("GitHub Repository", systemImage: "link") + } + } + } + } + + // MARK: - Subviews + + @ViewBuilder + private var connectionStatusBadge: some View { + switch appState.connectionStatus { + case .connected: + Label("Connected", systemImage: "circle.fill") + .font(.caption) + .foregroundStyle(.green) + case .connecting: + ProgressView() + .controlSize(.small) + case .error(let msg): + Label(msg, systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.orange) + .lineLimit(1) + case .disconnected: + Label("Disconnected", systemImage: "circle") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder + private var testConnectionRow: some View { + Button { + testConnection() + } label: { + HStack { + Label("Test Connection", systemImage: "antenna.radiowaves.left.and.right") + Spacer() + switch testResult { + case .testing: + ProgressView() + .controlSize(.small) + case .success: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + case .failure(let message): + Label(message, systemImage: "xmark.circle.fill") + .font(.caption) + .foregroundStyle(.red) + .lineLimit(1) + case nil: + EmptyView() + } + } + } + .disabled(testResult == .testing) + } + + // MARK: - Actions + + private func handleQRScan(_ connection: ServerConnection) { + showQRScanner = false + appState.addConnection(connection) + Task { await appState.connect(to: connection) } + } + + private func testConnection() { + testResult = .testing + Task { @MainActor in + do { + _ = try await appState.client.fetchStatus() + testResult = .success + } catch { + testResult = .failure(error.localizedDescription) + } + try? await Task.sleep(for: .seconds(3)) + if !Task.isCancelled { + testResult = nil + } + } + } + + private var appVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0" + } +} diff --git a/ios/PPGMobile/PPGMobile/Views/Spawn/SpawnView.swift b/ios/PPGMobile/PPGMobile/Views/Spawn/SpawnView.swift new file mode 100644 index 0000000..a8f3f27 --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Views/Spawn/SpawnView.swift @@ -0,0 +1,185 @@ +import SwiftUI + +struct SpawnView: View { + @Environment(AppState.self) private var appState + + // Form fields + @State private var name = "" + @State private var prompt = "" + @State private var selectedVariant: AgentVariant = .claude + @State private var count = 1 + @State private var baseBranch = "" + + // UI state + @State private var isSpawning = false + @State private var errorMessage: String? + @State private var spawnedWorktreeId: String? + + private static let namePattern = /^[a-zA-Z0-9][a-zA-Z0-9\-]*$/ + + private var sanitizedName: String { + name.trimmingCharacters(in: .whitespaces) + } + + private var isFormValid: Bool { + let hasName = !sanitizedName.isEmpty && sanitizedName.wholeMatch(of: Self.namePattern) != nil + let hasPrompt = !prompt.trimmingCharacters(in: .whitespaces).isEmpty + return hasName && hasPrompt + } + + private var spawnableVariants: [AgentVariant] { + [.claude, .codex, .opencode] + } + + private var availableBranches: [String] { + var branches = Set() + branches.insert("main") + if let manifest = appState.manifestStore.manifest { + for wt in manifest.worktrees.values { + branches.insert(wt.baseBranch) + } + } + return branches.sorted() + } + + var body: some View { + NavigationStack { + Form { + nameSection + agentSection + promptSection + baseBranchSection + errorSection + } + .scrollDismissesKeyboard(.interactively) + .disabled(isSpawning) + .navigationTitle("Spawn") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + spawnButton + } + } + .navigationDestination(for: String.self) { worktreeId in + WorktreeDetailView(worktreeId: worktreeId) + } + } + } + + // MARK: - Sections + + private var nameSection: some View { + Section { + TextField("Worktree name", text: $name) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } header: { + Text("Name") + } footer: { + if !sanitizedName.isEmpty && sanitizedName.wholeMatch(of: Self.namePattern) == nil { + Text("Only letters, numbers, and hyphens allowed") + .foregroundStyle(.red) + } else { + Text("Required. Letters, numbers, and hyphens (ppg/)") + } + } + } + + private var agentSection: some View { + Section("Agent") { + Picker("Type", selection: $selectedVariant) { + ForEach(spawnableVariants, id: \.self) { variant in + Label(variant.displayName, systemImage: variant.icon) + .tag(variant) + } + } + + Stepper("Count: \(count)", value: $count, in: 1...10) + } + } + + private var promptSection: some View { + Section { + TextEditor(text: $prompt) + .frame(minHeight: 120) + .font(.body) + } header: { + Text("Prompt") + } footer: { + Text("Required — describe the task for the agent") + } + } + + private var baseBranchSection: some View { + Section { + Picker("Base branch", selection: $baseBranch) { + Text("Default (current)").tag("") + ForEach(availableBranches, id: \.self) { branch in + Text(branch).tag(branch) + } + } + } footer: { + Text("Branch to create the worktree from") + } + } + + @ViewBuilder + private var errorSection: some View { + if let errorMessage { + Section { + Label(errorMessage, systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) + } + } + } + + private var spawnButton: some View { + Button { + Task { await spawnWorktree() } + } label: { + if isSpawning { + ProgressView() + } else { + Text("Spawn") + .bold() + } + } + .disabled(!isFormValid || isSpawning) + } + + // MARK: - Actions + + @MainActor + private func spawnWorktree() async { + isSpawning = true + errorMessage = nil + + let trimmedPrompt = prompt.trimmingCharacters(in: .whitespaces) + + do { + let response = try await appState.client.spawn( + name: sanitizedName, + agent: selectedVariant.rawValue, + prompt: trimmedPrompt, + base: baseBranch.isEmpty ? nil : baseBranch, + count: count + ) + + await appState.manifestStore.refresh() + clearForm() + spawnedWorktreeId = response.worktreeId + } catch { + errorMessage = error.localizedDescription + } + + isSpawning = false + } + + private func clearForm() { + name = "" + prompt = "" + selectedVariant = .claude + count = 1 + baseBranch = "" + errorMessage = nil + } +} diff --git a/ios/PPGMobile/PPGMobile/Views/Terminal/TerminalInputBar.swift b/ios/PPGMobile/PPGMobile/Views/Terminal/TerminalInputBar.swift new file mode 100644 index 0000000..87cabee --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Views/Terminal/TerminalInputBar.swift @@ -0,0 +1,27 @@ +import SwiftUI + +/// Bottom input bar for sending text to a terminal pane via WebSocket. +struct TerminalInputBar: View { + @Binding var text: String + let onSend: () -> Void + + var body: some View { + HStack(spacing: 8) { + TextField("Send to terminal...", text: $text) + .font(.system(.body, design: .monospaced)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .textFieldStyle(.roundedBorder) + .onSubmit(onSend) + + Button(action: onSend) { + Image(systemName: "arrow.up.circle.fill") + .font(.title2) + } + .disabled(text.isEmpty) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.bar) + } +} diff --git a/ios/PPGMobile/PPGMobile/Views/Terminal/TerminalView.swift b/ios/PPGMobile/PPGMobile/Views/Terminal/TerminalView.swift new file mode 100644 index 0000000..ff7e827 --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Views/Terminal/TerminalView.swift @@ -0,0 +1,236 @@ +import Foundation +import SwiftUI + +/// Terminal output view that subscribes to WebSocket terminal streaming. +/// Displays raw text output from tmux capture-pane with ANSI stripped server-side. +struct TerminalView: View { + let agentId: String + let agentName: String + + @Environment(AppState.self) private var appState + @State private var viewModel = TerminalViewModel() + @State private var inputText = "" + @State private var showKillConfirm = false + + var body: some View { + VStack(spacing: 0) { + terminalContent + + TerminalInputBar(text: $inputText) { + guard !inputText.isEmpty, let ws = appState.wsManager else { return } + ws.sendTerminalInput(agentId: agentId, text: inputText) + inputText = "" + } + } + .navigationTitle(agentName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Kill", systemImage: "xmark.circle") { + showKillConfirm = true + } + .tint(.red) + .disabled(agentIsTerminal) + } + } + .confirmationDialog("Kill Agent", isPresented: $showKillConfirm) { + Button("Kill Agent", role: .destructive) { + Task { await appState.killAgent(agentId) } + } + Button("Cancel", role: .cancel) {} + } + .task { await viewModel.subscribe(agentId: agentId, appState: appState) } + .onDisappear { + if let ws = appState.wsManager { + viewModel.unsubscribe(agentId: agentId, wsManager: ws) + } + } + } + + @ViewBuilder + private var terminalContent: some View { + ScrollViewReader { proxy in + ScrollView { + VStack(spacing: 0) { + if viewModel.output.isEmpty { + Text(statusMessage) + .font(.system(.footnote, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + } else { + Text(viewModel.output) + .font(.system(.footnote, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .textSelection(.enabled) + } + Color.clear + .frame(height: 1) + .id("terminal-bottom") + } + } + .defaultScrollAnchor(.bottom) + .background(Color.black) + .foregroundStyle(.green) + .onChange(of: viewModel.output) { _, _ in + withAnimation { + proxy.scrollTo("terminal-bottom", anchor: .bottom) + } + } + } + } + + private var statusMessage: String { + if appState.activeConnection == nil { + return "Not connected to server" + } + if viewModel.isSubscribed { + return "Waiting for output..." + } + return "Loading terminal output..." + } + + private var agentIsTerminal: Bool { + guard let manifest = appState.manifest else { return true } + for worktree in manifest.worktrees.values { + if let agent = worktree.agents[agentId] { + return agent.status.isTerminal + } + } + return true + } +} + +// MARK: - View Model + +@Observable +@MainActor +final class TerminalViewModel { + var output = "" + var hasError = false + private(set) var isSubscribed = false + + private static let maxOutputLength = 50_000 + private var subscriptionID: UUID? + + func subscribe(agentId: String, appState: AppState) async { + guard !isSubscribed else { return } + isSubscribed = true + + // Fetch initial log content via REST + do { + let logs = try await appState.client.fetchAgentLogs(agentId: agentId, lines: 200) + output = logs.output + trimOutput() + } catch { + output = "Failed to load logs: \(error.localizedDescription)" + hasError = true + } + + // Subscribe to live WebSocket updates + guard let ws = appState.wsManager else { return } + subscriptionID = TerminalMessageRouter.shared.addSubscriber(wsManager: ws) { [weak self] message in + guard message.type == "terminal:output", message.agentId == agentId, let data = message.data else { + return + } + Task { @MainActor [weak self] in + guard let self else { return } + self.output += data + self.trimOutput() + } + } + ws.subscribeTerminal(agentId: agentId) + } + + func unsubscribe(agentId: String, wsManager: WebSocketManager) { + guard isSubscribed else { return } + isSubscribed = false + wsManager.unsubscribeTerminal(agentId: agentId) + if let subscriptionID { + TerminalMessageRouter.shared.removeSubscriber(wsManager: wsManager, subscriberID: subscriptionID) + self.subscriptionID = nil + } + } + + private func trimOutput() { + guard output.count > Self.maxOutputLength else { return } + let startIndex = output.index(output.endIndex, offsetBy: -Self.maxOutputLength) + if let newlineIndex = output[startIndex...].firstIndex(of: "\n") { + output = String(output[output.index(after: newlineIndex)...]) + } else { + output = String(output[startIndex...]) + } + } +} + +// MARK: - Terminal Message Router + +/// Multiplexes WebSocket messages so multiple terminal views can subscribe safely. +private final class TerminalMessageRouter { + static let shared = TerminalMessageRouter() + + private struct State { + var previousOnMessage: ((ServerMessage) -> Void)? + var subscribers: [UUID: (ServerMessage) -> Void] + } + + private let lock = NSLock() + private var states: [ObjectIdentifier: State] = [:] + + private init() {} + + func addSubscriber( + wsManager: WebSocketManager, + subscriber: @escaping (ServerMessage) -> Void + ) -> UUID { + let managerID = ObjectIdentifier(wsManager) + let subscriberID = UUID() + + lock.lock() + if states[managerID] == nil { + let previousOnMessage = wsManager.onMessage + states[managerID] = State(previousOnMessage: previousOnMessage, subscribers: [:]) + wsManager.onMessage = { [weak self] message in + self?.dispatch(message: message, managerID: managerID) + } + } + states[managerID]?.subscribers[subscriberID] = subscriber + lock.unlock() + + return subscriberID + } + + func removeSubscriber(wsManager: WebSocketManager, subscriberID: UUID) { + let managerID = ObjectIdentifier(wsManager) + + lock.lock() + guard var state = states[managerID] else { + lock.unlock() + return + } + + state.subscribers.removeValue(forKey: subscriberID) + if state.subscribers.isEmpty { + states.removeValue(forKey: managerID) + lock.unlock() + wsManager.onMessage = state.previousOnMessage + return + } + + states[managerID] = state + lock.unlock() + } + + private func dispatch(message: ServerMessage, managerID: ObjectIdentifier) { + lock.lock() + let state = states[managerID] + let subscribers = state?.subscribers.values.map { $0 } ?? [] + lock.unlock() + + state?.previousOnMessage?(message) + for subscriber in subscribers { + subscriber(message) + } + } +} diff --git a/ios/PPGMobile/PPGMobileTests/AgentVariantTests.swift b/ios/PPGMobile/PPGMobileTests/AgentVariantTests.swift new file mode 100644 index 0000000..616a3e5 --- /dev/null +++ b/ios/PPGMobile/PPGMobileTests/AgentVariantTests.swift @@ -0,0 +1,30 @@ +import Testing +import SwiftUI +@testable import PPGMobile + +@Suite("AgentVariant") +struct AgentVariantTests { + @Test("resolves known agent types case-insensitively") + func resolvesKnownTypes() { + #expect(AgentVariant.from("claude") == .claude) + #expect(AgentVariant.from("codex") == .codex) + #expect(AgentVariant.from("opencode") == .opencode) + #expect(AgentVariant.from("Claude") == .claude) + #expect(AgentVariant.from("CODEX") == .codex) + } + + @Test("returns nil for unknown agent types") + func returnsNilForUnknown() { + #expect(AgentVariant.from("gpt4") == nil) + #expect(AgentVariant.from("") == nil) + #expect(AgentVariant.from("custom-agent") == nil) + } + + @Test("every variant has a non-empty displayName and sfSymbol") + func displayProperties() { + for variant in AgentVariant.allCases { + #expect(!variant.displayName.isEmpty) + #expect(!variant.sfSymbol.isEmpty) + } + } +} diff --git a/ios/PPGMobile/PPGMobileTests/ManifestTests.swift b/ios/PPGMobile/PPGMobileTests/ManifestTests.swift new file mode 100644 index 0000000..a56dcb0 --- /dev/null +++ b/ios/PPGMobile/PPGMobileTests/ManifestTests.swift @@ -0,0 +1,208 @@ +import Testing +import Foundation +@testable import PPGMobile + +@Suite("AgentStatus") +struct AgentStatusTests { + @Test("decodes canonical lifecycle values") + func decodesCanonicalValues() throws { + let cases = ["spawning", "running", "completed", "failed", "killed", "lost"] + for value in cases { + let json = Data("\"\(value)\"".utf8) + let status = try JSONDecoder().decode(AgentStatus.self, from: json) + #expect(status.rawValue == value) + } + } + + @Test("decodes TypeScript alias 'idle' as .running") + func decodesIdleAlias() throws { + let json = Data("\"idle\"".utf8) + let status = try JSONDecoder().decode(AgentStatus.self, from: json) + #expect(status == .running) + } + + @Test("decodes TypeScript alias 'exited' as .completed") + func decodesExitedAlias() throws { + let json = Data("\"exited\"".utf8) + let status = try JSONDecoder().decode(AgentStatus.self, from: json) + #expect(status == .completed) + } + + @Test("decodes TypeScript alias 'gone' as .lost") + func decodesGoneAlias() throws { + let json = Data("\"gone\"".utf8) + let status = try JSONDecoder().decode(AgentStatus.self, from: json) + #expect(status == .lost) + } + + @Test("rejects unknown status values") + func rejectsUnknown() { + let json = Data("\"banana\"".utf8) + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(AgentStatus.self, from: json) + } + } + + @Test("encodes using lifecycle rawValue, not alias") + func encodesToCanonicalValue() throws { + let json = Data("\"idle\"".utf8) + let status = try JSONDecoder().decode(AgentStatus.self, from: json) + let encoded = try JSONEncoder().encode(status) + let raw = String(data: encoded, encoding: .utf8) + #expect(raw == "\"running\"") + } + + @Test("every case has a non-empty label, color, and sfSymbol") + func displayProperties() { + for status in AgentStatus.allCases { + #expect(!status.label.isEmpty) + #expect(!status.sfSymbol.isEmpty) + } + } +} + +@Suite("WorktreeStatus") +struct WorktreeStatusTests { + @Test("decodes all worktree status values") + func decodesAllValues() throws { + let cases = ["active", "merging", "merged", "failed", "cleaned"] + for value in cases { + let json = Data("\"\(value)\"".utf8) + let status = try JSONDecoder().decode(WorktreeStatus.self, from: json) + #expect(status.rawValue == value) + } + } + + @Test("every case has a non-empty label and sfSymbol") + func displayProperties() { + for status in WorktreeStatus.allCases { + #expect(!status.label.isEmpty) + #expect(!status.sfSymbol.isEmpty) + } + } +} + +@Suite("Manifest decoding") +struct ManifestDecodingTests { + static let sampleJSON = """ + { + "version": 1, + "projectRoot": "/Users/test/project", + "sessionName": "ppg", + "worktrees": { + "wt-abc123": { + "id": "wt-abc123", + "name": "feature-auth", + "path": "/Users/test/project/.worktrees/wt-abc123", + "branch": "ppg/feature-auth", + "baseBranch": "main", + "status": "active", + "tmuxWindow": "ppg:1", + "agents": { + "ag-test1234": { + "id": "ag-test1234", + "name": "claude", + "agentType": "claude", + "status": "running", + "tmuxTarget": "ppg:1.0", + "prompt": "Implement auth", + "startedAt": "2025-01-15T10:30:00.000Z" + } + }, + "createdAt": "2025-01-15T10:30:00.000Z" + } + }, + "createdAt": "2025-01-15T10:00:00.000Z", + "updatedAt": "2025-01-15T10:30:00.000Z" + } + """ + + @Test("decodes a full manifest from server JSON") + func decodesFullManifest() throws { + let data = Data(Self.sampleJSON.utf8) + let manifest = try JSONDecoder().decode(Manifest.self, from: data) + + #expect(manifest.version == 1) + #expect(manifest.sessionName == "ppg") + #expect(manifest.worktrees.count == 1) + + let worktree = manifest.worktrees["wt-abc123"] + #expect(worktree?.name == "feature-auth") + #expect(worktree?.status == .active) + #expect(worktree?.agents.count == 1) + + let agent = worktree?.agents["ag-test1234"] + #expect(agent?.agentType == "claude") + #expect(agent?.status == .running) + } + + @Test("decodes manifest with TypeScript status aliases") + func decodesWithAliases() throws { + let json = """ + { + "version": 1, + "projectRoot": "/test", + "sessionName": "ppg", + "worktrees": { + "wt-xyz789": { + "id": "wt-xyz789", + "name": "review", + "path": "/test/.worktrees/wt-xyz789", + "branch": "ppg/review", + "baseBranch": "main", + "status": "active", + "tmuxWindow": "ppg:2", + "agents": { + "ag-alias001": { + "id": "ag-alias001", + "name": "codex", + "agentType": "codex", + "status": "idle", + "tmuxTarget": "ppg:2.0", + "prompt": "Review code", + "startedAt": "2025-01-15T11:00:00.000Z" + }, + "ag-alias002": { + "id": "ag-alias002", + "name": "claude", + "agentType": "claude", + "status": "exited", + "tmuxTarget": "ppg:2.1", + "prompt": "Fix bug", + "startedAt": "2025-01-15T11:00:00.000Z", + "exitCode": 0 + }, + "ag-alias003": { + "id": "ag-alias003", + "name": "opencode", + "agentType": "opencode", + "status": "gone", + "tmuxTarget": "ppg:2.2", + "prompt": "Test", + "startedAt": "2025-01-15T11:00:00.000Z" + } + }, + "createdAt": "2025-01-15T11:00:00.000Z" + } + }, + "createdAt": "2025-01-15T10:00:00.000Z", + "updatedAt": "2025-01-15T11:00:00.000Z" + } + """ + let data = Data(json.utf8) + let manifest = try JSONDecoder().decode(Manifest.self, from: data) + let agents = manifest.worktrees["wt-xyz789"]!.agents + + #expect(agents["ag-alias001"]?.status == .running) // idle → running + #expect(agents["ag-alias002"]?.status == .completed) // exited → completed + #expect(agents["ag-alias003"]?.status == .lost) // gone → lost + } + + @Test("allAgents flattens agents across worktrees") + func allAgentsFlattens() throws { + let data = Data(Self.sampleJSON.utf8) + let manifest = try JSONDecoder().decode(Manifest.self, from: data) + #expect(manifest.allAgents.count == 1) + #expect(manifest.allAgents.first?.id == "ag-test1234") + } +} diff --git a/ios/PPGMobile/PPGMobileTests/ServerConnectionTests.swift b/ios/PPGMobile/PPGMobileTests/ServerConnectionTests.swift new file mode 100644 index 0000000..beaabd3 --- /dev/null +++ b/ios/PPGMobile/PPGMobileTests/ServerConnectionTests.swift @@ -0,0 +1,139 @@ +import Testing +import Foundation +@testable import PPGMobile + +@Suite("ServerConnection") +struct ServerConnectionTests { + + static func make( + host: String = "192.168.1.5", + port: Int = 7700, + ca: String? = nil, + token: String = "abc123" + ) -> ServerConnection { + ServerConnection(id: UUID(), host: host, port: port, caCertificate: ca, token: token) + } + + // MARK: - URL Builders + + @Test("baseURL uses http when no CA certificate") + func baseURLWithoutCA() { + let conn = Self.make() + #expect(conn.baseURL?.absoluteString == "http://192.168.1.5:7700") + } + + @Test("baseURL uses https when CA certificate is present") + func baseURLWithCA() { + let conn = Self.make(ca: "FAKECERT") + #expect(conn.baseURL?.absoluteString == "https://192.168.1.5:7700") + } + + @Test("restURL appends path to base URL") + func restURLAppendsPath() { + let conn = Self.make() + let url = conn.restURL(for: "/api/status") + #expect(url?.absoluteString == "http://192.168.1.5:7700/api/status") + } + + @Test("webSocketURL uses ws scheme without CA") + func webSocketWithoutCA() { + let conn = Self.make() + let url = conn.webSocketURL + #expect(url?.scheme == "ws") + #expect(url?.host == "192.168.1.5") + #expect(url?.port == 7700) + #expect(url?.path == "/ws") + #expect(url?.absoluteString.contains("token=abc123") == true) + } + + @Test("webSocketURL uses wss scheme with CA") + func webSocketWithCA() { + let conn = Self.make(ca: "FAKECERT") + #expect(conn.webSocketURL?.scheme == "wss") + } + + // MARK: - QR Code Round-trip + + @Test("qrCodeString produces parseable ppg:// URL") + func qrCodeStringFormat() { + let conn = Self.make() + let qr = conn.qrCodeString + #expect(qr.hasPrefix("ppg://connect?")) + #expect(qr.contains("host=192.168.1.5")) + #expect(qr.contains("port=7700")) + #expect(qr.contains("token=abc123")) + } + + @Test("fromQRCode round-trips with qrCodeString") + func qrRoundTrip() { + let original = Self.make() + let qr = original.qrCodeString + let parsed = ServerConnection.fromQRCode(qr) + + #expect(parsed?.host == original.host) + #expect(parsed?.port == original.port) + #expect(parsed?.token == original.token) + #expect(parsed?.caCertificate == original.caCertificate) + } + + @Test("fromQRCode round-trips with CA certificate") + func qrRoundTripWithCA() { + let original = Self.make(ca: "BASE64CERTDATA+/=") + let qr = original.qrCodeString + let parsed = ServerConnection.fromQRCode(qr) + + #expect(parsed?.host == original.host) + #expect(parsed?.caCertificate == original.caCertificate) + } + + @Test("fromQRCode round-trips with special characters in token") + func qrRoundTripSpecialChars() { + let original = Self.make(token: "tok+en/with=special&chars") + let qr = original.qrCodeString + let parsed = ServerConnection.fromQRCode(qr) + + #expect(parsed?.token == original.token) + } + + // MARK: - QR Parsing Edge Cases + + @Test("fromQRCode returns nil for non-ppg scheme") + func rejectsWrongScheme() { + #expect(ServerConnection.fromQRCode("https://connect?host=x&port=1&token=t") == nil) + } + + @Test("fromQRCode returns nil for wrong host") + func rejectsWrongHost() { + #expect(ServerConnection.fromQRCode("ppg://wrong?host=x&port=1&token=t") == nil) + } + + @Test("fromQRCode returns nil when required fields are missing") + func rejectsMissingFields() { + #expect(ServerConnection.fromQRCode("ppg://connect?host=x&port=1") == nil) // no token + #expect(ServerConnection.fromQRCode("ppg://connect?host=x&token=t") == nil) // no port + #expect(ServerConnection.fromQRCode("ppg://connect?port=1&token=t") == nil) // no host + } + + @Test("fromQRCode returns nil for non-numeric port") + func rejectsNonNumericPort() { + #expect(ServerConnection.fromQRCode("ppg://connect?host=x&port=abc&token=t") == nil) + } + + @Test("fromQRCode returns nil for empty string") + func rejectsEmptyString() { + #expect(ServerConnection.fromQRCode("") == nil) + } + + @Test("fromQRCode returns nil for garbage input") + func rejectsGarbage() { + #expect(ServerConnection.fromQRCode("not a url at all") == nil) + } + + // MARK: - Auth Header + + @Test("authorizationHeader has Bearer prefix") + func authHeader() { + let conn = Self.make(token: "my-secret-token") + #expect(conn.authorizationHeader == "Bearer my-secret-token") + } +} diff --git a/ios/PPGMobile/project.yml b/ios/PPGMobile/project.yml new file mode 100644 index 0000000..79a3013 --- /dev/null +++ b/ios/PPGMobile/project.yml @@ -0,0 +1,38 @@ +name: PPGMobile +options: + bundleIdPrefix: com.2witstudios + deploymentTarget: + iOS: "17.0" + xcodeVersion: "16.0" + generateEmptyDirectories: true + +settings: + base: + SWIFT_VERSION: "5.9" + +targets: + PPGMobile: + type: application + platform: iOS + sources: + - path: PPGMobile + excludes: + - "**/*Tests.swift" + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.2witstudios.ppg-mobile + INFOPLIST_GENERATION_MODE: GeneratedFile + MARKETING_VERSION: "1.0.0" + CURRENT_PROJECT_VERSION: "1" + GENERATE_INFOPLIST_FILE: true + INFOPLIST_KEY_UIApplicationSceneManifest_Generation: true + INFOPLIST_KEY_UILaunchScreen_Generation: true + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight" + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight" + SWIFT_EMIT_LOC_STRINGS: true + +schemes: + PPGMobile: + build: + targets: + PPGMobile: all diff --git a/package-lock.json b/package-lock.json index a036a8f..5866ff1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,20 +9,26 @@ "version": "0.3.3", "license": "MIT", "dependencies": { + "@fastify/cors": "^11.2.0", "commander": "^14.0.0", "cron-parser": "^5.5.0", "execa": "^9.5.2", + "fastify": "^5.7.4", "nanoid": "^5.1.5", "proper-lockfile": "^4.1.2", + "qrcode-terminal": "^0.12.0", "write-file-atomic": "^7.0.0", + "ws": "^8.19.0", "yaml": "^2.7.1" }, "bin": { "ppg": "dist/cli.js" }, "devDependencies": { - "@types/node": "^22.13.4", + "@types/node": "^22.19.13", "@types/proper-lockfile": "^4.1.4", + "@types/qrcode-terminal": "^0.12.2", + "@types/ws": "^8.18.1", "tsup": "^8.4.0", "tsx": "^4.19.3", "typescript": "^5.7.3", @@ -32,827 +38,203 @@ "node": ">=20" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.58.0.tgz", - "integrity": "sha512-mr0tmS/4FoVk1cnaeN244A/wjvGDNItZKR8hRhnmCzygyRXYtKF5jVDSIILR1U97CTzAYmbgIj/Dukg62ggG5w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.58.0.tgz", - "integrity": "sha512-+s++dbp+/RTte62mQD9wLSbiMTV+xr/PeRJEc/sFZFSBRlHPNPVaf5FXlzAL77Mr8FtSfQqCN+I598M8U41ccQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.58.0.tgz", - "integrity": "sha512-MFWBwTcYs0jZbINQBXHfSrpSQJq3IUOakcKPzfeSznONop14Pxuqa0Kg19GD0rNBMPQI2tFtu3UzapZpH0Uc1Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.58.0.tgz", - "integrity": "sha512-yiKJY7pj9c9JwzuKYLFaDZw5gma3fI9bkPEIyofvVfsPqjCWPglSHdpdwXpKGvDeYDms3Qal8qGMEHZ1M/4Udg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.58.0.tgz", - "integrity": "sha512-x97kCoBh5MOevpn/CNK9W1x8BEzO238541BGWBc315uOlN0AD/ifZ1msg+ZQB05Ux+VF6EcYqpiagfLJ8U3LvQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.58.0.tgz", - "integrity": "sha512-Aa8jPoZ6IQAG2eIrcXPpjRcMjROMFxCt1UYPZZtCxRV68WkuSigYtQ/7Zwrcr2IvtNJo7T2JfDXyMLxq5L4Jlg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.58.0.tgz", - "integrity": "sha512-Ob8YgT5kD/lSIYW2Rcngs5kNB/44Q2RzBSPz9brf2WEtcGR7/f/E9HeHn1wYaAwKBni+bdXEwgHvUd0x12lQSA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.58.0.tgz", - "integrity": "sha512-K+RI5oP1ceqoadvNt1FecL17Qtw/n9BgRSzxif3rTL2QlIu88ccvY+Y9nnHe/cmT5zbH9+bpiJuG1mGHRVwF4Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.58.0.tgz", - "integrity": "sha512-T+17JAsCKUjmbopcKepJjHWHXSjeW7O5PL7lEFaeQmiVyw4kkc5/lyYKzrv6ElWRX/MrEWfPiJWqbTvfIvjM1Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.58.0.tgz", - "integrity": "sha512-cCePktb9+6R9itIJdeCFF9txPU7pQeEHB5AbHu/MKsfH/k70ZtOeq1k4YAtBv9Z7mmKI5/wOLYjQ+B9QdxR6LA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.58.0.tgz", - "integrity": "sha512-iekUaLkfliAsDl4/xSdoCJ1gnnIXvoNz85C8U8+ZxknM5pBStfZjeXgB8lXobDQvvPRCN8FPmmuTtH+z95HTmg==", + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", "cpu": [ - "loong64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.58.0.tgz", - "integrity": "sha512-68ofRgJNl/jYJbxFjCKE7IwhbfxOl1muPN4KbIqAIe32lm22KmU7E8OPvyy68HTNkI2iV/c8y2kSPSm2mW/Q9Q==", - "cpu": [ - "loong64" + "darwin" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.58.0.tgz", - "integrity": "sha512-dpz8vT0i+JqUKuSNPCP5SYyIV2Lh0sNL1+FhM7eLC457d5B9/BC3kDPp5BBftMmTNsBarcPcoz5UGSsnCiw4XQ==", - "cpu": [ - "ppc64" + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } ], - "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.58.0.tgz", - "integrity": "sha512-4gdkkf9UJ7tafnweBCR/mk4jf3Jfl0cKX9Np80t5i78kjIH0ZdezUv/JDI2VtruE5lunfACqftJ8dIMGN4oHew==", - "cpu": [ - "ppc64" + "node_modules/@fastify/cors": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz", + "integrity": "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } ], - "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.58.0.tgz", - "integrity": "sha512-YFS4vPnOkDTD/JriUeeZurFYoJhPf9GQQEF/v4lltp3mVcBmnsAdjEWhr2cjUCZzZNzxCG0HZOvJU44UGHSdzw==", - "cpu": [ - "riscv64" + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "license": "MIT" }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.58.0.tgz", - "integrity": "sha512-x2xgZlFne+QVNKV8b4wwaCS8pwq3y14zedZ5DqLzjdRITvreBk//4Knbcvm7+lWmms9V9qFp60MtUd0/t/PXPw==", - "cpu": [ - "riscv64" + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } ], - "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "fast-json-stringify": "^6.0.0" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.58.0.tgz", - "integrity": "sha512-jIhrujyn4UnWF8S+DHSkAkDEO3hLX0cjzxJZPLF80xFyzyUIYgSMRcYQ3+uqEoyDD2beGq7Dj7edi8OnJcS/hg==", - "cpu": [ - "s390x" + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "license": "MIT" }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.58.0.tgz", - "integrity": "sha512-+410Srdoh78MKSJxTQ+hZ/Mx+ajd6RjjPwBPNd0R3J9FtL6ZA0GqiiyNjCO9In0IzZkCNrpGymSfn+kgyPQocg==", - "cpu": [ - "x64" + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } ], - "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "dequal": "^2.0.3" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.58.0.tgz", - "integrity": "sha512-ZjMyby5SICi227y1MTR3VYBpFTdZs823Rs/hpakufleBoufoOIB6jtm9FEoxn/cgO7l6PM2rCEl5Kre5vX0QrQ==", - "cpu": [ - "x64" + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } ], - "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.58.0.tgz", - "integrity": "sha512-ds4iwfYkSQ0k1nb8LTcyXw//ToHOnNTJtceySpL3fa7tc/AsE+UpUFphW126A6fKBGJD5dhRvg8zw1rvoGFxmw==", - "cpu": [ - "x64" - ], + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.58.0.tgz", - "integrity": "sha512-fd/zpJniln4ICdPkjWFhZYeY/bpnaN9pGa6ko+5WD38I0tTqk9lXMgXZg09MNdhpARngmxiCg0B0XUamNw/5BQ==", - "cpu": [ - "arm64" - ], + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] + "engines": { + "node": ">=6.0.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.58.0.tgz", - "integrity": "sha512-YpG8dUOip7DCz3nr/JUfPbIUo+2d/dy++5bFzgi4ugOGBIox+qMbbqt/JoORwvI/C9Kn2tz6+Bieoqd5+B1CjA==", - "cpu": [ - "arm64" - ], + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "license": "MIT" }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.58.0.tgz", - "integrity": "sha512-b9DI8jpFQVh4hIXFr0/+N/TzLdpBIoPzjt0Rt4xJbW3mzguV3mduR9cNgiuFcuL/TeORejJhCWiAXe3E/6PxWA==", - "cpu": [ - "ia32" - ], + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.58.0.tgz", - "integrity": "sha512-CSrVpmoRJFN06LL9xhkitkwUcTZtIotYAF5p6XOR2zW0Zz5mzb3IPpcoPhB02frzMHFNo1reQ9xSF5fFm3hUsQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" }, - "node_modules/@rollup/rollup-win32-x64-msvc": { + "node_modules/@rollup/rollup-darwin-x64": { "version": "4.58.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.58.0.tgz", - "integrity": "sha512-QFsBgQNTnh5K0t/sBsjJLq24YVqEIVkGpfN2VHsnN90soZyhaiA9UUHufcctVNL4ypJY0wrwad0wslx2KJQ1/w==", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.58.0.tgz", + "integrity": "sha512-yiKJY7pj9c9JwzuKYLFaDZw5gma3fI9bkPEIyofvVfsPqjCWPglSHdpdwXpKGvDeYDms3Qal8qGMEHZ1M/4Udg==", "cpu": [ "x64" ], @@ -860,7 +242,7 @@ "license": "MIT", "optional": true, "os": [ - "win32" + "darwin" ] }, "node_modules/@sec-ant/readable-stream": { @@ -907,9 +289,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.19.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", - "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "version": "22.19.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz", + "integrity": "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==", "dev": true, "license": "MIT", "dependencies": { @@ -926,6 +308,13 @@ "@types/retry": "*" } }, + "node_modules/@types/qrcode-terminal": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/qrcode-terminal/-/qrcode-terminal-0.12.2.tgz", + "integrity": "sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/retry": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.5.tgz", @@ -933,6 +322,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitest/expect": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", @@ -1048,6 +447,12 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1061,6 +466,39 @@ "node": ">=0.4.0" } }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -1074,8 +512,37 @@ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", - "engines": { - "node": ">=12" + "engines": { + "node": ">=12" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" } }, "node_modules/bundle-require": { @@ -1173,6 +640,19 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cron-parser": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.5.0.tgz", @@ -1227,6 +707,15 @@ "node": ">=6" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1322,6 +811,125 @@ "node": ">=12.0.0" } }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz", + "integrity": "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.4.tgz", + "integrity": "sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1355,6 +963,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/find-my-way": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz", + "integrity": "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/fix-dts-default-cjs-exports": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", @@ -1435,6 +1057,15 @@ "node": ">=0.8.19" } }, + "node_modules/ipaddr.js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -1494,6 +1125,68 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -1638,6 +1331,15 @@ "node": ">=0.10.0" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/parse-ms": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", @@ -1696,6 +1398,43 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -1824,6 +1563,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/proper-lockfile": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", @@ -1841,6 +1596,20 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/qrcode-terminal": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz", + "integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==", + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -1855,6 +1624,24 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -1875,6 +1662,15 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -1884,6 +1680,22 @@ "node": ">= 4" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, "node_modules/rollup": { "version": "4.58.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.58.0.tgz", @@ -1929,6 +1741,68 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-regex2": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz", + "integrity": "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -1969,6 +1843,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -1989,6 +1872,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -2084,6 +1976,18 @@ "node": ">=0.8" } }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2145,6 +2049,15 @@ "node": ">=14.0.0" } }, + "node_modules/toad-cache": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -2491,6 +2404,27 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yaml": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", diff --git a/package.json b/package.json index b4cd8bf..229265e 100644 --- a/package.json +++ b/package.json @@ -45,17 +45,23 @@ ], "license": "MIT", "dependencies": { + "@fastify/cors": "^11.2.0", "commander": "^14.0.0", "cron-parser": "^5.5.0", "execa": "^9.5.2", + "fastify": "^5.7.4", "nanoid": "^5.1.5", "proper-lockfile": "^4.1.2", + "qrcode-terminal": "^0.12.0", "write-file-atomic": "^7.0.0", + "ws": "^8.19.0", "yaml": "^2.7.1" }, "devDependencies": { - "@types/node": "^22.13.4", + "@types/node": "^22.19.13", "@types/proper-lockfile": "^4.1.4", + "@types/qrcode-terminal": "^0.12.2", + "@types/ws": "^8.18.1", "tsup": "^8.4.0", "tsx": "^4.19.3", "typescript": "^5.7.3", diff --git a/src/cli.ts b/src/cli.ts index bfb207a..74a3ecd 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -263,6 +263,50 @@ worktreeCmd await worktreeCreateCommand(options); }); +const serveCmd = program.command('serve').description('Manage the ppg API server'); + +serveCmd + .command('start') + .description('Start the serve daemon in a tmux window') + .option('-p, --port ', 'Port to listen on', parsePort, 3100) + .option('-H, --host ', 'Host to bind to', '127.0.0.1') + .option('--token ', 'Bearer token for authentication') + .option('--json', 'Output as JSON') + .action(async (options) => { + const { serveStartCommand } = await import('./commands/serve.js'); + await serveStartCommand(options); + }); + +serveCmd + .command('stop') + .description('Stop the serve daemon') + .option('--json', 'Output as JSON') + .action(async (options) => { + const { serveStopCommand } = await import('./commands/serve.js'); + await serveStopCommand(options); + }); + +serveCmd + .command('status') + .description('Show serve daemon status and recent log') + .option('-l, --lines ', 'Number of recent log lines to show', (v: string) => Number(v), 20) + .option('--json', 'Output as JSON') + .action(async (options) => { + const { serveStatusCommand } = await import('./commands/serve.js'); + await serveStatusCommand(options); + }); + +serveCmd + .command('_daemon', { hidden: true }) + .description('Internal: run the serve daemon (called by ppg serve start)') + .option('-p, --port ', 'Port to listen on', parsePort, 3100) + .option('-H, --host ', 'Host to bind to', '127.0.0.1') + .option('--token ', 'Bearer token for authentication') + .action(async (options) => { + const { serveDaemonCommand } = await import('./commands/serve.js'); + await serveDaemonCommand(options); + }); + program .command('ui') .alias('dashboard') @@ -372,6 +416,14 @@ function parsePositiveInt(optionName: string) { }; } +function parsePort(v: string): number { + const n = Number(v); + if (!Number.isInteger(n) || n < 1 || n > 65535) { + throw new Error('--port must be an integer between 1 and 65535'); + } + return n; +} + async function main() { try { await program.parseAsync(process.argv); diff --git a/src/commands/init.ts b/src/commands/init.ts index 2cfc672..962fc94 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -28,7 +28,6 @@ Never run \`claude\`, \`codex\`, or \`opencode\` directly as bash commands — t - \`ppg spawn --name --prompt "" --json\` — Spawn worktree + agent - \`ppg spawn --name --agent codex --prompt "" --json\` — Use Codex agent - \`ppg spawn --name --agent opencode --prompt "" --json\` — Use OpenCode agent -- \`ppg spawn --branch --prompt "" --json\` — Attach to existing branch (e.g. from a PR) - \`ppg spawn --worktree --agent codex --prompt "review --base main" --json\` — Codex review - \`ppg status --json\` — Check statuses - \`ppg aggregate --all --json\` — Collect results (includes PR URLs) diff --git a/src/commands/kill.ts b/src/commands/kill.ts index 294b6ee..b703d5f 100644 --- a/src/commands/kill.ts +++ b/src/commands/kill.ts @@ -1,13 +1,9 @@ -import { readManifest, updateManifest, findAgent, resolveWorktree } from '../core/manifest.js'; -import { killAgent, killAgents } from '../core/agent.js'; -import { checkPrState } from '../core/pr.js'; +import { performKill, type KillResult } from '../core/operations/kill.js'; +import { getCurrentPaneId } from '../core/self.js'; +import { readManifest } from '../core/manifest.js'; import { getRepoRoot } from '../core/worktree.js'; -import { cleanupWorktree } from '../core/cleanup.js'; -import { getCurrentPaneId, excludeSelf } from '../core/self.js'; -import { listSessionPanes, type PaneInfo } from '../core/tmux.js'; -import { PpgError, NotInitializedError, AgentNotFoundError, WorktreeNotFoundError } from '../lib/errors.js'; +import { listSessionPanes } from '../core/tmux.js'; import { output, success, info, warn } from '../lib/output.js'; -import type { AgentEntry } from '../types/manifest.js'; export interface KillOptions { agent?: string; @@ -22,314 +18,87 @@ export interface KillOptions { export async function killCommand(options: KillOptions): Promise { const projectRoot = await getRepoRoot(); - if (!options.agent && !options.worktree && !options.all) { - throw new PpgError('One of --agent, --worktree, or --all is required', 'INVALID_ARGS'); - } - // Capture self-identification once at the start const selfPaneId = getCurrentPaneId(); - let paneMap: Map | undefined; + let paneMap: Map | undefined; if (selfPaneId) { const manifest = await readManifest(projectRoot); paneMap = await listSessionPanes(manifest.sessionName); } - if (options.agent) { - await killSingleAgent(projectRoot, options.agent, options, selfPaneId, paneMap); - } else if (options.worktree) { - await killWorktreeAgents(projectRoot, options.worktree, options, selfPaneId, paneMap); - } else if (options.all) { - await killAllAgents(projectRoot, options, selfPaneId, paneMap); - } -} - -async function killSingleAgent( - projectRoot: string, - agentId: string, - options: KillOptions, - selfPaneId: string | null, - paneMap?: Map, -): Promise { - const manifest = await readManifest(projectRoot); - const found = findAgent(manifest, agentId); - if (!found) throw new AgentNotFoundError(agentId); - - const { agent } = found; - const isTerminal = agent.status !== 'running'; - - // Self-protection check - if (selfPaneId && paneMap) { - const { skipped } = excludeSelf([agent], selfPaneId, paneMap); - if (skipped.length > 0) { - warn(`Cannot kill agent ${agentId} — it contains the current ppg process`); - if (options.json) { - output({ success: false, skipped: [agentId], reason: 'self-protection' }, true); - } - return; - } - } - - if (options.delete) { - // For --delete: skip kill if already in terminal state, just clean up - if (!isTerminal) { - info(`Killing agent ${agentId}`); - await killAgent(agent); - } - // Kill the tmux pane explicitly (handles already-dead) - await import('../core/tmux.js').then((tmux) => tmux.killPane(agent.tmuxTarget)); - - await updateManifest(projectRoot, (m) => { - const f = findAgent(m, agentId); - if (f) { - delete f.worktree.agents[agentId]; - } - return m; - }); - - if (options.json) { - output({ success: true, killed: [agentId], deleted: [agentId] }, true); - } else { - success(`Deleted agent ${agentId}`); - } - } else { - if (isTerminal) { - if (options.json) { - output({ success: true, killed: [], message: `Agent ${agentId} already ${agent.status}` }, true); - } else { - info(`Agent ${agentId} already ${agent.status}, skipping kill`); - } - return; - } - - info(`Killing agent ${agentId}`); - await killAgent(agent); - - await updateManifest(projectRoot, (m) => { - const f = findAgent(m, agentId); - if (f) { - f.agent.status = 'gone'; - } - return m; - }); - - if (options.json) { - output({ success: true, killed: [agentId] }, true); - } else { - success(`Killed agent ${agentId}`); - } - } -} - -async function killWorktreeAgents( - projectRoot: string, - worktreeRef: string, - options: KillOptions, - selfPaneId: string | null, - paneMap?: Map, -): Promise { - const manifest = await readManifest(projectRoot); - const wt = resolveWorktree(manifest, worktreeRef); - - if (!wt) throw new WorktreeNotFoundError(worktreeRef); - - let toKill = Object.values(wt.agents) - .filter((a) => a.status === 'running'); - - // Self-protection: filter out agents that would kill the current process - const skippedIds: string[] = []; - if (selfPaneId && paneMap) { - const { safe, skipped } = excludeSelf(toKill, selfPaneId, paneMap); - toKill = safe; - for (const a of skipped) { - skippedIds.push(a.id); - warn(`Skipping agent ${a.id} — contains current ppg process`); - } - } - - const killedIds = toKill.map((a) => a.id); - - for (const a of toKill) info(`Killing agent ${a.id}`); - await killAgents(toKill); - - await updateManifest(projectRoot, (m) => { - const mWt = m.worktrees[wt.id]; - if (mWt) { - for (const agent of Object.values(mWt.agents)) { - if (killedIds.includes(agent.id)) { - agent.status = 'gone'; - } - } - } - return m; + const result = await performKill({ + projectRoot, + agent: options.agent, + worktree: options.worktree, + all: options.all, + remove: options.remove, + delete: options.delete, + includeOpenPrs: options.includeOpenPrs, + selfPaneId, + paneMap, }); - // Check for open PR before deleting worktree - let skippedOpenPr = false; - if (options.delete && !options.includeOpenPrs) { - const prState = await checkPrState(wt.branch); - if (prState === 'OPEN') { - skippedOpenPr = true; - warn(`Skipping deletion of worktree ${wt.id} (${wt.name}) — has open PR on branch ${wt.branch}. Use --include-open-prs to override.`); - } - } + formatOutput(result, options); +} - // --delete implies --remove (always clean up worktree) - const shouldRemove = (options.remove || options.delete) && !skippedOpenPr; - if (shouldRemove) { - await removeWorktreeCleanup(projectRoot, wt.id, selfPaneId, paneMap); +function formatOutput(result: KillResult, options: KillOptions): void { + if (options.json) { + output(result, true); + return; } - // --delete also removes the worktree entry from manifest - if (options.delete && !skippedOpenPr) { - await updateManifest(projectRoot, (m) => { - delete m.worktrees[wt.id]; - return m; - }); + // Emit per-agent progress for killed agents + for (const id of result.killed) { + info(`Killing agent ${id}`); } - if (options.json) { - output({ - success: true, - killed: killedIds, - skipped: skippedIds.length > 0 ? skippedIds : undefined, - removed: shouldRemove ? [wt.id] : [], - deleted: (options.delete && !skippedOpenPr) ? [wt.id] : [], - skippedOpenPrs: skippedOpenPr ? [wt.id] : undefined, - }, true); - } else { - success(`Killed ${killedIds.length} agent(s) in worktree ${wt.id}`); - if (skippedIds.length > 0) { - warn(`Skipped ${skippedIds.length} agent(s) due to self-protection`); - } - if (options.delete && !skippedOpenPr) { - success(`Deleted worktree ${wt.id}`); - } else if (options.remove && !skippedOpenPr) { - success(`Removed worktree ${wt.id}`); + if (result.skipped?.length) { + for (const id of result.skipped) { + warn(`Skipping agent ${id} — contains current ppg process`); } } -} -async function killAllAgents( - projectRoot: string, - options: KillOptions, - selfPaneId: string | null, - paneMap?: Map, -): Promise { - const manifest = await readManifest(projectRoot); - let toKill: AgentEntry[] = []; - - for (const wt of Object.values(manifest.worktrees)) { - for (const agent of Object.values(wt.agents)) { - if (agent.status === 'running') { - toKill.push(agent); - } + if (result.skippedOpenPrs?.length) { + for (const id of result.skippedOpenPrs) { + warn(`Skipping deletion of worktree ${id} — has open PR`); } } - // Self-protection: filter out agents that would kill the current process - const skippedIds: string[] = []; - if (selfPaneId && paneMap) { - const { safe, skipped } = excludeSelf(toKill, selfPaneId, paneMap); - toKill = safe; - for (const a of skipped) { - skippedIds.push(a.id); - warn(`Skipping agent ${a.id} — contains current ppg process`); + if (options.agent) { + if (result.deleted?.length) { + success(`Deleted agent ${options.agent}`); + } else if (result.killed.length > 0) { + success(`Killed agent ${options.agent}`); + } else if (result.message) { + info(result.message); } - } - - const killedIds = toKill.map((a) => a.id); - for (const a of toKill) info(`Killing agent ${a.id}`); - await killAgents(toKill); - - // Only track active worktrees for removal (not already merged/cleaned) - const activeWorktreeIds = Object.values(manifest.worktrees) - .filter((wt) => wt.status === 'active') - .map((wt) => wt.id); - - await updateManifest(projectRoot, (m) => { - for (const wt of Object.values(m.worktrees)) { - for (const agent of Object.values(wt.agents)) { - if (killedIds.includes(agent.id)) { - agent.status = 'gone'; - } - } + } else if (options.worktree) { + if (result.killed.length > 0 || !result.skipped?.length) { + success(`Killed ${result.killed.length} agent(s) in worktree ${options.worktree}`); } - return m; - }); - - // Filter out worktrees with open PRs - let worktreesToRemove = activeWorktreeIds; - const openPrWorktreeIds: string[] = []; - if (options.delete && !options.includeOpenPrs) { - worktreesToRemove = []; - for (const wtId of activeWorktreeIds) { - const wt = manifest.worktrees[wtId]; - if (wt) { - const prState = await checkPrState(wt.branch); - if (prState === 'OPEN') { - openPrWorktreeIds.push(wtId); - warn(`Skipping deletion of worktree ${wtId} (${wt.name}) — has open PR`); - } else { - worktreesToRemove.push(wtId); - } - } + if (result.skipped?.length) { + warn(`Skipped ${result.skipped.length} agent(s) due to self-protection`); } - } - - // --delete implies --remove - const shouldRemove = options.remove || options.delete; - if (shouldRemove) { - for (const wtId of worktreesToRemove) { - await removeWorktreeCleanup(projectRoot, wtId, selfPaneId, paneMap); + if (result.deleted?.length) { + success(`Deleted worktree ${options.worktree}`); + } else if (result.removed?.length) { + success(`Removed worktree ${options.worktree}`); } - } - - // --delete also removes worktree entries from manifest - if (options.delete) { - await updateManifest(projectRoot, (m) => { - for (const wtId of worktreesToRemove) { - delete m.worktrees[wtId]; - } - return m; - }); - } - - if (options.json) { - output({ - success: true, - killed: killedIds, - skipped: skippedIds.length > 0 ? skippedIds : undefined, - removed: shouldRemove ? worktreesToRemove : [], - deleted: options.delete ? worktreesToRemove : [], - skippedOpenPrs: openPrWorktreeIds.length > 0 ? openPrWorktreeIds : undefined, - }, true); - } else { - success(`Killed ${killedIds.length} agent(s) across ${activeWorktreeIds.length} worktree(s)`); - if (skippedIds.length > 0) { - warn(`Skipped ${skippedIds.length} agent(s) due to self-protection`); + } else if (options.all) { + const wtMsg = result.worktreeCount !== undefined + ? ` across ${result.worktreeCount} worktree(s)` + : ''; + success(`Killed ${result.killed.length} agent(s)${wtMsg}`); + if (result.skipped?.length) { + warn(`Skipped ${result.skipped.length} agent(s) due to self-protection`); } - if (openPrWorktreeIds.length > 0) { - warn(`Skipped deletion of ${openPrWorktreeIds.length} worktree(s) with open PRs`); + if (result.skippedOpenPrs?.length) { + warn(`Skipped deletion of ${result.skippedOpenPrs.length} worktree(s) with open PRs`); } - if (options.delete) { - success(`Deleted ${worktreesToRemove.length} worktree(s)`); - } else if (options.remove) { - success(`Removed ${worktreesToRemove.length} worktree(s)`); + if (result.deleted?.length) { + success(`Deleted ${result.deleted.length} worktree(s)`); + } else if (result.removed?.length) { + success(`Removed ${result.removed.length} worktree(s)`); } } } - -async function removeWorktreeCleanup( - projectRoot: string, - wtId: string, - selfPaneId: string | null, - paneMap?: Map, -): Promise { - const manifest = await readManifest(projectRoot); - const wt = resolveWorktree(manifest, wtId); - if (!wt) return; - await cleanupWorktree(projectRoot, wt, { - selfPaneId, - paneMap, - }); -} diff --git a/src/commands/list.ts b/src/commands/list.ts index 866e0c3..f1c1e9c 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -1,7 +1,6 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; import { getRepoRoot } from '../core/worktree.js'; import { listTemplatesWithSource } from '../core/template.js'; +import { listPromptsWithSource, enrichEntryMetadata } from '../core/prompt.js'; import { listSwarmsWithSource, loadSwarm } from '../core/swarm.js'; import { templatesDir, promptsDir, globalTemplatesDir, globalPromptsDir } from '../lib/paths.js'; import { PpgError } from '../lib/errors.js'; @@ -34,18 +33,9 @@ async function listTemplatesCommand(options: ListOptions): Promise { } const templates = await Promise.all( - entries.map(async ({ name, source }) => { - const dir = source === 'local' ? templatesDir(projectRoot) : globalTemplatesDir(); - const filePath = path.join(dir, `${name}.md`); - const content = await fs.readFile(filePath, 'utf-8'); - const firstLine = content.split('\n').find((l) => l.trim().length > 0) ?? ''; - const description = firstLine.replace(/^#+\s*/, '').trim(); - - const vars = [...content.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]); - const uniqueVars = [...new Set(vars)]; - - return { name, description, variables: uniqueVars, source }; - }), + entries.map(({ name, source }) => + enrichEntryMetadata(name, source, templatesDir(projectRoot), globalTemplatesDir()), + ), ); if (options.json) { @@ -111,52 +101,10 @@ async function listSwarmsCommand(options: ListOptions): Promise { console.log(formatTable(swarms, columns)); } -interface PromptEntry { - name: string; - source: 'local' | 'global'; -} - -async function listPromptEntries(projectRoot: string): Promise { - const localDir = promptsDir(projectRoot); - const globalDir = globalPromptsDir(); - - let localFiles: string[] = []; - try { - localFiles = (await fs.readdir(localDir)).filter((f) => f.endsWith('.md')).sort(); - } catch { - // directory doesn't exist - } - - let globalFiles: string[] = []; - try { - globalFiles = (await fs.readdir(globalDir)).filter((f) => f.endsWith('.md')).sort(); - } catch { - // directory doesn't exist - } - - const seen = new Set(); - const result: PromptEntry[] = []; - - for (const file of localFiles) { - const name = file.replace(/\.md$/, ''); - seen.add(name); - result.push({ name, source: 'local' }); - } - - for (const file of globalFiles) { - const name = file.replace(/\.md$/, ''); - if (!seen.has(name)) { - result.push({ name, source: 'global' }); - } - } - - return result; -} - async function listPromptsCommand(options: ListOptions): Promise { const projectRoot = await getRepoRoot(); - const entries = await listPromptEntries(projectRoot); + const entries = await listPromptsWithSource(projectRoot); if (entries.length === 0) { if (options.json) { @@ -168,18 +116,9 @@ async function listPromptsCommand(options: ListOptions): Promise { } const prompts = await Promise.all( - entries.map(async ({ name, source }) => { - const dir = source === 'local' ? promptsDir(projectRoot) : globalPromptsDir(); - const filePath = path.join(dir, `${name}.md`); - const content = await fs.readFile(filePath, 'utf-8'); - const firstLine = content.split('\n').find((l) => l.trim().length > 0) ?? ''; - const description = firstLine.replace(/^#+\s*/, '').trim(); - - const vars = [...content.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]); - const uniqueVars = [...new Set(vars)]; - - return { name, description, variables: uniqueVars, source }; - }), + entries.map(({ name, source }) => + enrichEntryMetadata(name, source, promptsDir(projectRoot), globalPromptsDir()), + ), ); if (options.json) { diff --git a/src/commands/merge.ts b/src/commands/merge.ts index 5dca227..53694dc 100644 --- a/src/commands/merge.ts +++ b/src/commands/merge.ts @@ -1,15 +1,8 @@ -import { execa } from 'execa'; -import { requireManifest, updateManifest, resolveWorktree } from '../core/manifest.js'; -import { refreshAllAgentStatuses } from '../core/agent.js'; -import { getRepoRoot, getCurrentBranch } from '../core/worktree.js'; -import { cleanupWorktree } from '../core/cleanup.js'; -import { getCurrentPaneId } from '../core/self.js'; -import { listSessionPanes, type PaneInfo } from '../core/tmux.js'; -import { PpgError, WorktreeNotFoundError, MergeFailedError } from '../lib/errors.js'; +import { performMerge } from '../core/operations/merge.js'; +import { getRepoRoot } from '../core/worktree.js'; import { output, success, info, warn } from '../lib/output.js'; -import { execaEnv } from '../lib/env.js'; -export interface MergeOptions { +export interface MergeCommandOptions { strategy?: 'squash' | 'no-ff'; cleanup?: boolean; dryRun?: boolean; @@ -17,122 +10,48 @@ export interface MergeOptions { json?: boolean; } -export async function mergeCommand(worktreeId: string, options: MergeOptions): Promise { +export async function mergeCommand(worktreeId: string, options: MergeCommandOptions): Promise { const projectRoot = await getRepoRoot(); - await requireManifest(projectRoot); - const manifest = await updateManifest(projectRoot, async (m) => { - return refreshAllAgentStatuses(m, projectRoot); - }); - - const wt = resolveWorktree(manifest, worktreeId); - - if (!wt) throw new WorktreeNotFoundError(worktreeId); - - // Check all agents finished - const agents = Object.values(wt.agents); - const incomplete = agents.filter((a) => a.status === 'running'); - - if (incomplete.length > 0 && !options.force) { - const ids = incomplete.map((a) => a.id).join(', '); - throw new PpgError( - `${incomplete.length} agent(s) still running: ${ids}. Use --force to merge anyway.`, - 'AGENTS_RUNNING', - ); - } - if (options.dryRun) { info('Dry run — no changes will be made'); - info(`Would merge branch ${wt.branch} into ${wt.baseBranch} using ${options.strategy ?? 'squash'} strategy`); - if (options.cleanup !== false) { - info(`Would remove worktree ${wt.id} and delete branch ${wt.branch}`); - } - return; } - // Set worktree status to merging - await updateManifest(projectRoot, (m) => { - if (m.worktrees[wt.id]) { - m.worktrees[wt.id].status = 'merging'; - } - return m; + const result = await performMerge({ + projectRoot, + worktreeRef: worktreeId, + strategy: options.strategy, + cleanup: options.cleanup, + dryRun: options.dryRun, + force: options.force, }); - const strategy = options.strategy ?? 'squash'; - - try { - const currentBranch = await getCurrentBranch(projectRoot); - if (currentBranch !== wt.baseBranch) { - info(`Switching to base branch ${wt.baseBranch}`); - await execa('git', ['checkout', wt.baseBranch], { ...execaEnv, cwd: projectRoot }); - } - - info(`Merging ${wt.branch} into ${wt.baseBranch} (${strategy})`); - - if (strategy === 'squash') { - await execa('git', ['merge', '--squash', wt.branch], { ...execaEnv, cwd: projectRoot }); - await execa('git', ['commit', '-m', `ppg: merge ${wt.name} (${wt.branch})`], { - ...execaEnv, - cwd: projectRoot, - }); - } else { - await execa('git', ['merge', '--no-ff', wt.branch, '-m', `ppg: merge ${wt.name} (${wt.branch})`], { - ...execaEnv, - cwd: projectRoot, - }); + if (result.dryRun) { + info(`Would merge branch ${result.branch} into ${result.baseBranch} using ${result.strategy} strategy`); + if (options.cleanup !== false) { + info(`Would remove worktree ${result.worktreeId} and delete branch ${result.branch}`); } - - success(`Merged ${wt.branch} into ${wt.baseBranch}`); - } catch (err) { - await updateManifest(projectRoot, (m) => { - if (m.worktrees[wt.id]) { - m.worktrees[wt.id].status = 'failed'; - } - return m; - }); - throw new MergeFailedError( - `Merge failed: ${err instanceof Error ? err.message : err}`, - ); + return; } - // Mark as merged - await updateManifest(projectRoot, (m) => { - if (m.worktrees[wt.id]) { - m.worktrees[wt.id].status = 'merged'; - m.worktrees[wt.id].mergedAt = new Date().toISOString(); - } - return m; - }); - - // Cleanup with self-protection - let selfProtected = false; - if (options.cleanup !== false) { - info('Cleaning up...'); - - const selfPaneId = getCurrentPaneId(); - let paneMap: Map | undefined; - if (selfPaneId) { - paneMap = await listSessionPanes(manifest.sessionName); - } - - const cleanupResult = await cleanupWorktree(projectRoot, wt, { selfPaneId, paneMap }); - selfProtected = cleanupResult.selfProtected; + success(`Merged ${result.branch} into ${result.baseBranch}`); - if (selfProtected) { - warn(`Some tmux targets skipped during cleanup — contains current ppg process`); + if (result.cleaned) { + if (result.selfProtected) { + warn('Some tmux targets skipped during cleanup — contains current ppg process'); } - success(`Cleaned up worktree ${wt.id}`); + success(`Cleaned up worktree ${result.worktreeId}`); } if (options.json) { output({ success: true, - worktreeId: wt.id, - branch: wt.branch, - baseBranch: wt.baseBranch, - strategy, - cleaned: options.cleanup !== false, - selfProtected: selfProtected || undefined, + worktreeId: result.worktreeId, + branch: result.branch, + baseBranch: result.baseBranch, + strategy: result.strategy, + cleaned: result.cleaned, + selfProtected: result.selfProtected || undefined, }, true); } } diff --git a/src/commands/pr.ts b/src/commands/pr.ts index aeb559d..534b471 100644 --- a/src/commands/pr.ts +++ b/src/commands/pr.ts @@ -1,13 +1,12 @@ -import { execa } from 'execa'; import { updateManifest, resolveWorktree } from '../core/manifest.js'; import { refreshAllAgentStatuses } from '../core/agent.js'; import { getRepoRoot } from '../core/worktree.js'; -import { PpgError, NotInitializedError, WorktreeNotFoundError, GhNotFoundError } from '../lib/errors.js'; +import { createWorktreePr } from '../core/pr.js'; +import { NotInitializedError, WorktreeNotFoundError } from '../lib/errors.js'; import { output, success, info } from '../lib/output.js'; -import { execaEnv } from '../lib/env.js'; -// GitHub PR body limit is 65536 chars; leave room for truncation notice -const MAX_BODY_LENGTH = 60_000; +// Re-export for backwards compatibility with existing tests/consumers +export { buildBodyFromResults, truncateBody } from '../core/pr.js'; export interface PrOptions { title?: string; @@ -31,82 +30,16 @@ export async function prCommand(worktreeRef: string, options: PrOptions): Promis const wt = resolveWorktree(manifest, worktreeRef); if (!wt) throw new WorktreeNotFoundError(worktreeRef); - // Verify gh is available - try { - await execa('gh', ['--version'], execaEnv); - } catch { - throw new GhNotFoundError(); - } - - // Push the worktree branch - info(`Pushing branch ${wt.branch} to origin`); - try { - await execa('git', ['push', '-u', 'origin', wt.branch], { ...execaEnv, cwd: projectRoot }); - } catch (err) { - throw new PpgError( - `Failed to push branch ${wt.branch}: ${err instanceof Error ? err.message : err}`, - 'INVALID_ARGS', - ); - } - - // Build PR title and body - const title = options.title ?? wt.name; - const body = options.body ?? await buildBodyFromResults(Object.values(wt.agents)); - - // Build gh pr create args - const ghArgs = [ - 'pr', 'create', - '--head', wt.branch, - '--base', wt.baseBranch, - '--title', title, - '--body', body, - ]; - if (options.draft) { - ghArgs.push('--draft'); - } - - info(`Creating PR: ${title}`); - let prUrl: string; - try { - const result = await execa('gh', ghArgs, { ...execaEnv, cwd: projectRoot }); - prUrl = result.stdout.trim(); - } catch (err) { - throw new PpgError( - `Failed to create PR: ${err instanceof Error ? err.message : err}`, - 'INVALID_ARGS', - ); - } - - // Store PR URL in manifest - await updateManifest(projectRoot, (m) => { - if (m.worktrees[wt.id]) { - m.worktrees[wt.id].prUrl = prUrl; - } - return m; + info(`Creating PR for ${wt.branch}`); + const result = await createWorktreePr(projectRoot, wt, { + title: options.title, + body: options.body, + draft: options.draft, }); if (options.json) { - output({ - success: true, - worktreeId: wt.id, - branch: wt.branch, - baseBranch: wt.baseBranch, - prUrl, - }, true); + output({ success: true, ...result }, true); } else { - success(`PR created: ${prUrl}`); + success(`PR created: ${result.prUrl}`); } } - -/** Build PR body from agent prompts, with truncation. */ -export async function buildBodyFromResults(agents: { id: string; prompt: string }[]): Promise { - if (agents.length === 0) return ''; - const sections = agents.map((a) => `## Agent: ${a.id}\n\n${a.prompt}`); - return truncateBody(sections.join('\n\n---\n\n')); -} - -/** Truncate body to stay within GitHub's PR body size limit. */ -export function truncateBody(body: string): string { - if (body.length <= MAX_BODY_LENGTH) return body; - return body.slice(0, MAX_BODY_LENGTH) + '\n\n---\n\n*[Truncated — full results available in `.ppg/results/`]*'; -} diff --git a/src/commands/restart.ts b/src/commands/restart.ts index c2627e5..4411e3e 100644 --- a/src/commands/restart.ts +++ b/src/commands/restart.ts @@ -1,15 +1,6 @@ -import fs from 'node:fs/promises'; -import { requireManifest, updateManifest, findAgent } from '../core/manifest.js'; -import { loadConfig, resolveAgentConfig } from '../core/config.js'; -import { spawnAgent, killAgent } from '../core/agent.js'; -import { getRepoRoot } from '../core/worktree.js'; -import * as tmux from '../core/tmux.js'; +import { performRestart } from '../core/operations/restart.js'; import { openTerminalWindow } from '../core/terminal.js'; -import { agentId as genAgentId, sessionId as genSessionId } from '../lib/id.js'; -import { agentPromptFile } from '../lib/paths.js'; -import { PpgError, AgentNotFoundError } from '../lib/errors.js'; import { output, success, info } from '../lib/output.js'; -import { renderTemplate, type TemplateContext } from '../core/template.js'; export interface RestartOptions { prompt?: string; @@ -19,105 +10,28 @@ export interface RestartOptions { } export async function restartCommand(agentRef: string, options: RestartOptions): Promise { - const projectRoot = await getRepoRoot(); - const config = await loadConfig(projectRoot); - - const manifest = await requireManifest(projectRoot); - - const found = findAgent(manifest, agentRef); - if (!found) throw new AgentNotFoundError(agentRef); - - const { worktree: wt, agent: oldAgent } = found; - - // Kill old agent if still running - if (oldAgent.status === 'running') { - info(`Killing existing agent ${oldAgent.id}`); - await killAgent(oldAgent); - } - - // Read original prompt from prompt file, or use override - let promptText: string; - if (options.prompt) { - promptText = options.prompt; - } else { - const pFile = agentPromptFile(projectRoot, oldAgent.id); - try { - promptText = await fs.readFile(pFile, 'utf-8'); - } catch { - throw new PpgError( - `Could not read original prompt for agent ${oldAgent.id}. Use --prompt to provide one.`, - 'PROMPT_NOT_FOUND', - ); - } - } - - // Resolve agent config - const agentConfig = resolveAgentConfig(config, options.agent ?? oldAgent.agentType); - - // Ensure tmux session - await tmux.ensureSession(manifest.sessionName); - - // Create new tmux window in same worktree - const newAgentId = genAgentId(); - const windowTarget = await tmux.createWindow(manifest.sessionName, `${wt.name}-restart`, wt.path); - - // Render template vars - const ctx: TemplateContext = { - WORKTREE_PATH: wt.path, - BRANCH: wt.branch, - AGENT_ID: newAgentId, - PROJECT_ROOT: projectRoot, - TASK_NAME: wt.name, - PROMPT: promptText, - }; - const renderedPrompt = renderTemplate(promptText, ctx); - - const newSessionId = genSessionId(); - const agentEntry = await spawnAgent({ - agentId: newAgentId, - agentConfig, - prompt: renderedPrompt, - worktreePath: wt.path, - tmuxTarget: windowTarget, - projectRoot, - branch: wt.branch, - sessionId: newSessionId, - }); - - // Update manifest: mark old agent as gone, add new agent - await updateManifest(projectRoot, (m) => { - const mWt = m.worktrees[wt.id]; - if (mWt) { - const mOldAgent = mWt.agents[oldAgent.id]; - if (mOldAgent && mOldAgent.status === 'running') { - mOldAgent.status = 'gone'; - } - mWt.agents[newAgentId] = agentEntry; - } - return m; + const result = await performRestart({ + agentRef, + prompt: options.prompt, + agentType: options.agent, }); // Only open Terminal window when explicitly requested via --open (fire-and-forget) if (options.open === true) { - openTerminalWindow(manifest.sessionName, windowTarget, `${wt.name}-restart`).catch(() => {}); + openTerminalWindow(result.sessionName, result.newAgent.tmuxTarget, `${result.newAgent.worktreeName}-restart`).catch(() => {}); } if (options.json) { output({ success: true, - oldAgentId: oldAgent.id, - newAgent: { - id: newAgentId, - tmuxTarget: windowTarget, - sessionId: newSessionId, - worktreeId: wt.id, - worktreeName: wt.name, - branch: wt.branch, - path: wt.path, - }, + oldAgentId: result.oldAgentId, + newAgent: result.newAgent, }, true); } else { - success(`Restarted agent ${oldAgent.id} → ${newAgentId} in worktree ${wt.name}`); - info(` New agent ${newAgentId} → ${windowTarget}`); + if (result.killedOldAgent) { + info(`Killed existing agent ${result.oldAgentId}`); + } + success(`Restarted agent ${result.oldAgentId} → ${result.newAgent.id} in worktree ${result.newAgent.worktreeName}`); + info(` New agent ${result.newAgent.id} → ${result.newAgent.tmuxTarget}`); } } diff --git a/src/commands/serve.test.ts b/src/commands/serve.test.ts new file mode 100644 index 0000000..51d7b1f --- /dev/null +++ b/src/commands/serve.test.ts @@ -0,0 +1,370 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +// Mock dependencies +vi.mock('../core/worktree.js', () => ({ + getRepoRoot: vi.fn(() => '/fake/project'), +})); + +vi.mock('../core/manifest.js', () => ({ + requireManifest: vi.fn(() => ({ sessionName: 'ppg-test' })), + readManifest: vi.fn(() => ({ sessionName: 'ppg-test' })), +})); + +vi.mock('../core/tmux.js', () => ({ + ensureSession: vi.fn(), + createWindow: vi.fn(() => 'ppg-test:1'), + sendKeys: vi.fn(), + listSessionWindows: vi.fn(() => []), + killWindow: vi.fn(), +})); + +vi.mock('../lib/paths.js', async (importOriginal) => { + const actual = await importOriginal() as Record; + return { + ...actual, + manifestPath: vi.fn((root: string) => path.join(root, '.ppg', 'manifest.json')), + servePidPath: vi.fn((root: string) => path.join(root, '.ppg', 'serve.pid')), + serveJsonPath: vi.fn((root: string) => path.join(root, '.ppg', 'serve.json')), + serveLogPath: vi.fn((root: string) => path.join(root, '.ppg', 'logs', 'serve.log')), + logsDir: vi.fn((root: string) => path.join(root, '.ppg', 'logs')), + }; +}); + +vi.mock('../core/serve.js', async (importOriginal) => { + const actual = await importOriginal() as Record; + return { + ...actual, + isServeRunning: vi.fn(() => false), + getServePid: vi.fn(() => null), + getServeInfo: vi.fn(() => null), + readServeLog: vi.fn(() => []), + runServeDaemon: vi.fn(), + }; +}); + +vi.mock('../lib/output.js', async (importOriginal) => { + const actual = await importOriginal() as Record; + return { + ...actual, + output: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + }; +}); + +const { serveStartCommand, serveStopCommand, serveStatusCommand, serveDaemonCommand, buildPairingUrl, getLocalIp, verifyToken } = await import('./serve.js'); +const { output, success, warn, info } = await import('../lib/output.js'); +const { isServeRunning, getServePid, getServeInfo, readServeLog, runServeDaemon } = await import('../core/serve.js'); +const { requireManifest } = await import('../core/manifest.js'); +const tmux = await import('../core/tmux.js'); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('serveStartCommand', () => { + test('given no server running, should start daemon in tmux window', async () => { + await serveStartCommand({ port: 3000, host: 'localhost' }); + + expect(requireManifest).toHaveBeenCalledWith('/fake/project'); + expect(tmux.ensureSession).toHaveBeenCalledWith('ppg-test'); + expect(tmux.createWindow).toHaveBeenCalledWith('ppg-test', 'ppg-serve', '/fake/project'); + expect(tmux.sendKeys).toHaveBeenCalledWith('ppg-test:1', 'ppg serve _daemon --port 3000 --host localhost'); + expect(success).toHaveBeenCalledWith('Serve daemon starting in tmux window: ppg-test:1'); + }); + + test('given custom port and host, should pass them to daemon command', async () => { + await serveStartCommand({ port: 8080, host: '0.0.0.0' }); + + expect(tmux.sendKeys).toHaveBeenCalledWith('ppg-test:1', 'ppg serve _daemon --port 8080 --host 0.0.0.0'); + }); + + test('given server already running, should warn and return', async () => { + vi.mocked(isServeRunning).mockResolvedValue(true); + vi.mocked(getServePid).mockResolvedValue(12345); + vi.mocked(getServeInfo).mockResolvedValue({ + pid: 12345, + port: 3000, + host: 'localhost', + startedAt: '2026-01-01T00:00:00.000Z', + }); + + await serveStartCommand({ port: 3000, host: 'localhost' }); + + expect(tmux.createWindow).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith('Serve daemon is already running (PID: 12345)'); + expect(info).toHaveBeenCalledWith('Listening on localhost:3000'); + }); + + test('given json option, should output JSON on success', async () => { + await serveStartCommand({ port: 3000, host: 'localhost', json: true }); + + expect(output).toHaveBeenCalledWith( + expect.objectContaining({ success: true, port: 3000, host: 'localhost', tmuxWindow: 'ppg-test:1' }), + true, + ); + }); + + test('given json option and already running, should output JSON error', async () => { + vi.mocked(isServeRunning).mockResolvedValue(true); + vi.mocked(getServePid).mockResolvedValue(12345); + vi.mocked(getServeInfo).mockResolvedValue({ + pid: 12345, + port: 3000, + host: 'localhost', + startedAt: '2026-01-01T00:00:00.000Z', + }); + + await serveStartCommand({ port: 3000, host: 'localhost', json: true }); + + expect(output).toHaveBeenCalledWith( + expect.objectContaining({ success: false, error: 'Serve daemon is already running', pid: 12345 }), + true, + ); + }); + + test('given project not initialized, should throw NotInitializedError', async () => { + const err = Object.assign(new Error('Not initialized'), { code: 'NOT_INITIALIZED' }); + vi.mocked(requireManifest).mockRejectedValue(err); + + await expect(serveStartCommand({ port: 3000, host: 'localhost' })).rejects.toThrow('Not initialized'); + expect(tmux.createWindow).not.toHaveBeenCalled(); + }); + + test('given invalid host with shell metacharacters, should throw INVALID_ARGS', async () => { + await expect(serveStartCommand({ port: 3000, host: 'localhost; rm -rf /' })) + .rejects.toThrow('Invalid host'); + }); +}); + +describe('serveStopCommand', () => { + test('given running server, should kill process and clean up', async () => { + vi.mocked(getServePid).mockResolvedValue(99999); + const mockKill = vi.spyOn(process, 'kill').mockImplementation(() => true); + vi.spyOn(fs, 'unlink').mockResolvedValue(undefined); + + await serveStopCommand({}); + + expect(mockKill).toHaveBeenCalledWith(99999, 'SIGTERM'); + expect(fs.unlink).toHaveBeenCalledWith('/fake/project/.ppg/serve.pid'); + expect(fs.unlink).toHaveBeenCalledWith('/fake/project/.ppg/serve.json'); + expect(success).toHaveBeenCalledWith('Serve daemon stopped (PID: 99999)'); + + mockKill.mockRestore(); + }); + + test('given no server running, should warn', async () => { + await serveStopCommand({}); + + expect(warn).toHaveBeenCalledWith('Serve daemon is not running'); + }); + + test('given running server with tmux window, should kill the tmux window', async () => { + vi.mocked(getServePid).mockResolvedValue(99999); + vi.spyOn(process, 'kill').mockImplementation(() => true); + vi.spyOn(fs, 'unlink').mockResolvedValue(undefined); + vi.mocked(tmux.listSessionWindows).mockResolvedValue([ + { index: 0, name: 'bash' }, + { index: 1, name: 'ppg-serve' }, + ]); + + await serveStopCommand({}); + + expect(tmux.killWindow).toHaveBeenCalledWith('ppg-test:1'); + + vi.mocked(process.kill).mockRestore(); + }); + + test('given process already dead when killing, should still clean up files', async () => { + vi.mocked(getServePid).mockResolvedValue(99999); + vi.spyOn(process, 'kill').mockImplementation(() => { throw new Error('ESRCH'); }); + vi.spyOn(fs, 'unlink').mockResolvedValue(undefined); + + await serveStopCommand({}); + + expect(fs.unlink).toHaveBeenCalledWith('/fake/project/.ppg/serve.pid'); + expect(fs.unlink).toHaveBeenCalledWith('/fake/project/.ppg/serve.json'); + expect(success).toHaveBeenCalledWith('Serve daemon stopped (PID: 99999)'); + + vi.mocked(process.kill).mockRestore(); + }); + + test('given json option and not running, should output JSON', async () => { + await serveStopCommand({ json: true }); + + expect(output).toHaveBeenCalledWith( + expect.objectContaining({ success: false, error: 'Serve daemon is not running' }), + true, + ); + }); + + test('given json option and running, should output JSON on success', async () => { + vi.mocked(getServePid).mockResolvedValue(88888); + vi.spyOn(process, 'kill').mockImplementation(() => true); + vi.spyOn(fs, 'unlink').mockResolvedValue(undefined); + + await serveStopCommand({ json: true }); + + expect(output).toHaveBeenCalledWith( + expect.objectContaining({ success: true, pid: 88888 }), + true, + ); + + vi.mocked(process.kill).mockRestore(); + }); +}); + +describe('serveStatusCommand', () => { + test('given running server, should show status with connection info', async () => { + vi.mocked(isServeRunning).mockResolvedValue(true); + vi.mocked(getServePid).mockResolvedValue(12345); + vi.mocked(getServeInfo).mockResolvedValue({ + pid: 12345, + port: 3000, + host: 'localhost', + startedAt: '2026-01-01T00:00:00.000Z', + }); + vi.mocked(readServeLog).mockResolvedValue(['[2026-01-01T00:00:00.000Z] Started']); + + await serveStatusCommand({}); + + expect(success).toHaveBeenCalledWith('Serve daemon is running (PID: 12345)'); + expect(info).toHaveBeenCalledWith('Listening on localhost:3000'); + expect(info).toHaveBeenCalledWith('Started at 2026-01-01T00:00:00.000Z'); + }); + + test('given no server running, should warn', async () => { + await serveStatusCommand({}); + + expect(warn).toHaveBeenCalledWith('Serve daemon is not running'); + }); + + test('given json option and running, should output JSON with connection info', async () => { + vi.mocked(isServeRunning).mockResolvedValue(true); + vi.mocked(getServePid).mockResolvedValue(12345); + vi.mocked(getServeInfo).mockResolvedValue({ + pid: 12345, + port: 3000, + host: 'localhost', + startedAt: '2026-01-01T00:00:00.000Z', + }); + vi.mocked(readServeLog).mockResolvedValue([]); + + await serveStatusCommand({ json: true }); + + expect(output).toHaveBeenCalledWith( + expect.objectContaining({ + running: true, + pid: 12345, + host: 'localhost', + port: 3000, + startedAt: '2026-01-01T00:00:00.000Z', + recentLog: [], + }), + true, + ); + }); + + test('given json option and not running, should output JSON', async () => { + await serveStatusCommand({ json: true }); + + expect(output).toHaveBeenCalledWith( + expect.objectContaining({ running: false, pid: null, recentLog: [] }), + true, + ); + }); + + test('given custom lines option, should pass to readServeLog', async () => { + await serveStatusCommand({ lines: 50 }); + + expect(readServeLog).toHaveBeenCalledWith('/fake/project', 50); + }); +}); + +describe('serveDaemonCommand', () => { + test('given initialized project, should call runServeDaemon with correct args', async () => { + await serveDaemonCommand({ port: 4000, host: '0.0.0.0' }); + + expect(requireManifest).toHaveBeenCalledWith('/fake/project'); + expect(runServeDaemon).toHaveBeenCalledWith('/fake/project', 4000, '0.0.0.0'); + }); + + test('given project not initialized, should throw', async () => { + const err = Object.assign(new Error('Not initialized'), { code: 'NOT_INITIALIZED' }); + vi.mocked(requireManifest).mockRejectedValue(err); + + await expect(serveDaemonCommand({ port: 3000, host: 'localhost' })).rejects.toThrow('Not initialized'); + expect(runServeDaemon).not.toHaveBeenCalled(); + }); +}); + +describe('buildPairingUrl', () => { + test('given valid params, should encode all fields into ppg:// URL', () => { + const url = buildPairingUrl({ + host: '192.168.1.10', + port: 7700, + fingerprint: 'AA:BB:CC', + token: 'test-token-123', + }); + + expect(url).toContain('ppg://connect'); + expect(url).toContain('host=192.168.1.10'); + expect(url).toContain('port=7700'); + expect(url).toContain('ca=AA%3ABB%3ACC'); + expect(url).toContain('token=test-token-123'); + }); + + test('given special characters in token, should URL-encode them', () => { + const url = buildPairingUrl({ + host: '10.0.0.1', + port: 8080, + fingerprint: 'DE:AD:BE:EF', + token: 'a+b/c=d', + }); + + expect(url).toContain('token=a%2Bb%2Fc%3Dd'); + }); +}); + +describe('getLocalIp', () => { + test('should return a non-empty string', () => { + const ip = getLocalIp(); + expect(ip).toBeTruthy(); + expect(typeof ip).toBe('string'); + }); + + test('should return a valid IPv4 address', () => { + const ip = getLocalIp(); + const ipv4Pattern = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/; + expect(ip).toMatch(ipv4Pattern); + }); +}); + +describe('verifyToken', () => { + test('given matching tokens, should return true', () => { + expect(verifyToken('correct-token', 'correct-token')).toBe(true); + }); + + test('given different tokens of same length, should return false', () => { + expect(verifyToken('aaaa-bbbb-cccc', 'xxxx-yyyy-zzzz')).toBe(false); + }); + + test('given different length tokens, should return false', () => { + expect(verifyToken('short', 'much-longer-token')).toBe(false); + }); + + test('given empty provided token, should return false', () => { + expect(verifyToken('', 'expected-token')).toBe(false); + }); + + test('given both empty, should return true', () => { + expect(verifyToken('', '')).toBe(true); + }); +}); diff --git a/src/commands/serve.ts b/src/commands/serve.ts new file mode 100644 index 0000000..320c87f --- /dev/null +++ b/src/commands/serve.ts @@ -0,0 +1,201 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import { randomBytes, timingSafeEqual } from 'node:crypto'; +import { getRepoRoot } from '../core/worktree.js'; +import { requireManifest, readManifest } from '../core/manifest.js'; +import { runServeDaemon, isServeRunning, getServePid, getServeInfo, readServeLog } from '../core/serve.js'; +import { startServer } from '../server/index.js'; +import * as tmux from '../core/tmux.js'; +import { servePidPath, serveJsonPath } from '../lib/paths.js'; +import { PpgError } from '../lib/errors.js'; +import { output, info, success, warn } from '../lib/output.js'; + +export interface ServeStartOptions { + port?: number; + host?: string; + token?: string; + json?: boolean; +} + +export interface ServeOptions { + json?: boolean; +} + +export interface ServeStatusOptions { + lines?: number; + json?: boolean; +} + +const SERVE_WINDOW_NAME = 'ppg-serve'; +const VALID_HOST = /^[\w.:-]+$/; + +export function buildPairingUrl(params: { + host: string; + port: number; + fingerprint: string; + token: string; +}): string { + const { host, port, fingerprint, token } = params; + const url = new URL('ppg://connect'); + url.searchParams.set('host', host); + url.searchParams.set('port', String(port)); + url.searchParams.set('ca', fingerprint); + url.searchParams.set('token', token); + return url.toString(); +} + +export function getLocalIp(): string { + const interfaces = os.networkInterfaces(); + for (const name of Object.keys(interfaces)) { + for (const iface of interfaces[name] ?? []) { + if (iface.family === 'IPv4' && !iface.internal) { + return iface.address; + } + } + } + return '127.0.0.1'; +} + +export function verifyToken(provided: string, expected: string): boolean { + const a = Buffer.from(provided); + const b = Buffer.from(expected); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +export async function serveStartCommand(options: ServeStartOptions): Promise { + const projectRoot = await getRepoRoot(); + await requireManifest(projectRoot); + + const port = options.port!; + const host = options.host!; + + if (!VALID_HOST.test(host)) { + throw new PpgError(`Invalid host: "${host}"`, 'INVALID_ARGS'); + } + + // Check if already running + if (await isServeRunning(projectRoot)) { + const pid = await getServePid(projectRoot); + const serveInfo = await getServeInfo(projectRoot); + if (options.json) { + output({ success: false, error: 'Serve daemon is already running', pid, ...serveInfo }, true); + } else { + warn(`Serve daemon is already running (PID: ${pid})`); + if (serveInfo) { + info(`Listening on ${serveInfo.host}:${serveInfo.port}`); + } + } + return; + } + + // Start daemon in a tmux window + const manifest = await readManifest(projectRoot); + const sessionName = manifest.sessionName; + await tmux.ensureSession(sessionName); + + const windowTarget = await tmux.createWindow(sessionName, SERVE_WINDOW_NAME, projectRoot); + let command = `ppg serve _daemon --port ${port} --host ${host}`; + if (options.token) { + command += ` --token ${options.token}`; + } + await tmux.sendKeys(windowTarget, command); + + if (options.json) { + output({ + success: true, + tmuxWindow: windowTarget, + port, + host, + }, true); + } else { + success(`Serve daemon starting in tmux window: ${windowTarget}`); + info(`Configured for ${host}:${port}`); + info(`Attach: tmux select-window -t ${windowTarget}`); + } +} + +export async function serveStopCommand(options: ServeOptions): Promise { + const projectRoot = await getRepoRoot(); + + const pid = await getServePid(projectRoot); + if (!pid) { + if (options.json) { + output({ success: false, error: 'Serve daemon is not running' }, true); + } else { + warn('Serve daemon is not running'); + } + return; + } + + // Kill the process + try { + process.kill(pid, 'SIGTERM'); + } catch { + // Already dead + } + + // Clean up PID and JSON files (daemon cleanup handler may not have run yet) + try { await fs.unlink(servePidPath(projectRoot)); } catch { /* already gone */ } + try { await fs.unlink(serveJsonPath(projectRoot)); } catch { /* already gone */ } + + // Try to kill the tmux window too + try { + const manifest = await readManifest(projectRoot); + const windows = await tmux.listSessionWindows(manifest.sessionName); + const serveWindow = windows.find((w) => w.name === SERVE_WINDOW_NAME); + if (serveWindow) { + await tmux.killWindow(`${manifest.sessionName}:${serveWindow.index}`); + } + } catch { /* best effort */ } + + if (options.json) { + output({ success: true, pid }, true); + } else { + success(`Serve daemon stopped (PID: ${pid})`); + } +} + +export async function serveStatusCommand(options: ServeStatusOptions): Promise { + const projectRoot = await getRepoRoot(); + + const running = await isServeRunning(projectRoot); + const pid = running ? await getServePid(projectRoot) : null; + const serveInfo = running ? await getServeInfo(projectRoot) : null; + const recentLines = await readServeLog(projectRoot, options.lines ?? 20); + + if (options.json) { + output({ + running, + pid, + ...(serveInfo ? { host: serveInfo.host, port: serveInfo.port, startedAt: serveInfo.startedAt } : {}), + recentLog: recentLines, + }, true); + return; + } + + if (running) { + success(`Serve daemon is running (PID: ${pid})`); + if (serveInfo) { + info(`Listening on ${serveInfo.host}:${serveInfo.port}`); + info(`Started at ${serveInfo.startedAt}`); + } + } else { + warn('Serve daemon is not running'); + } + + if (recentLines.length > 0) { + console.log('\nRecent log:'); + for (const line of recentLines) { + console.log(` ${line}`); + } + } else { + info('No serve log entries yet'); + } +} + +export async function serveDaemonCommand(options: { port: number; host: string; token?: string }): Promise { + const projectRoot = await getRepoRoot(); + await requireManifest(projectRoot); + await startServer({ projectRoot, port: options.port, host: options.host, token: options.token }); +} diff --git a/src/commands/spawn.test.ts b/src/commands/spawn.test.ts index ee642c7..1efcc23 100644 --- a/src/commands/spawn.test.ts +++ b/src/commands/spawn.test.ts @@ -1,56 +1,11 @@ -import { access } from 'node:fs/promises'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { describe, expect, test, vi, beforeEach } from 'vitest'; import { spawnCommand } from './spawn.js'; -import { loadConfig, resolveAgentConfig } from '../core/config.js'; -import { readManifest, resolveWorktree, updateManifest } from '../core/manifest.js'; -import { spawnAgent } from '../core/agent.js'; -import { getRepoRoot } from '../core/worktree.js'; -import { agentId, sessionId } from '../lib/id.js'; -import * as tmux from '../core/tmux.js'; - -vi.mock('node:fs/promises', async () => { - const actual = await vi.importActual('node:fs/promises'); - const mockedAccess = vi.fn(); - return { - ...actual, - access: mockedAccess, - default: { - ...actual, - access: mockedAccess, - }, - }; -}); +import { performSpawn } from '../core/operations/spawn.js'; +import type { SpawnResult } from '../core/operations/spawn.js'; +import type { AgentEntry } from '../types/manifest.js'; -vi.mock('../core/config.js', () => ({ - loadConfig: vi.fn(), - resolveAgentConfig: vi.fn(), -})); - -vi.mock('../core/manifest.js', () => ({ - readManifest: vi.fn(), - updateManifest: vi.fn(), - resolveWorktree: vi.fn(), -})); - -vi.mock('../core/agent.js', () => ({ - spawnAgent: vi.fn(), -})); - -vi.mock('../core/worktree.js', () => ({ - getRepoRoot: vi.fn(), - getCurrentBranch: vi.fn(), - createWorktree: vi.fn(), - adoptWorktree: vi.fn(), -})); - -vi.mock('../core/tmux.js', () => ({ - ensureSession: vi.fn(), - createWindow: vi.fn(), - splitPane: vi.fn(), -})); - -vi.mock('../core/terminal.js', () => ({ - openTerminalWindow: vi.fn(), +vi.mock('../core/operations/spawn.js', () => ({ + performSpawn: vi.fn(), })); vi.mock('../lib/output.js', () => ({ @@ -59,137 +14,85 @@ vi.mock('../lib/output.js', () => ({ info: vi.fn(), })); -vi.mock('../lib/id.js', () => ({ - worktreeId: vi.fn(), - agentId: vi.fn(), - sessionId: vi.fn(), -})); +const mockedPerformSpawn = vi.mocked(performSpawn); +const { output, success, info } = await import('../lib/output.js'); + +function makeAgent(id: string, target: string): AgentEntry { + return { + id, + name: 'claude', + agentType: 'claude', + status: 'running', + tmuxTarget: target, + prompt: 'Do work', + startedAt: '2026-02-27T00:00:00.000Z', + sessionId: 'session-1', + }; +} -const mockedAccess = vi.mocked(access); -const mockedLoadConfig = vi.mocked(loadConfig); -const mockedResolveAgentConfig = vi.mocked(resolveAgentConfig); -const mockedReadManifest = vi.mocked(readManifest); -const mockedUpdateManifest = vi.mocked(updateManifest); -const mockedResolveWorktree = vi.mocked(resolveWorktree); -const mockedSpawnAgent = vi.mocked(spawnAgent); -const mockedGetRepoRoot = vi.mocked(getRepoRoot); -const mockedAgentId = vi.mocked(agentId); -const mockedSessionId = vi.mocked(sessionId); -const mockedEnsureSession = vi.mocked(tmux.ensureSession); -const mockedCreateWindow = vi.mocked(tmux.createWindow); -const mockedSplitPane = vi.mocked(tmux.splitPane); - -function createManifest(tmuxWindow = '') { +function makeResult(overrides?: Partial): SpawnResult { return { - version: 1 as const, - projectRoot: '/tmp/repo', - sessionName: 'ppg-test', - worktrees: { - wt1: { - id: 'wt1', - name: 'feature', - path: '/tmp/repo/.ppg/worktrees/wt1', - branch: 'ppg/feature', - baseBranch: 'main', - status: 'active' as const, - tmuxWindow, - agents: {} as Record, - createdAt: '2026-02-27T00:00:00.000Z', - }, + worktree: { + id: 'wt1', + name: 'feature', + branch: 'ppg/feature', + path: '/tmp/repo/.worktrees/wt1', + tmuxWindow: 'ppg-test:1', }, - createdAt: '2026-02-27T00:00:00.000Z', - updatedAt: '2026-02-27T00:00:00.000Z', + agents: [makeAgent('ag-1', 'ppg-test:1')], + ...overrides, }; } describe('spawnCommand', () => { - let manifestState = createManifest(); - let nextAgent = 1; - let nextSession = 1; - beforeEach(() => { vi.clearAllMocks(); - manifestState = createManifest(); - nextAgent = 1; - nextSession = 1; - - mockedAccess.mockResolvedValue(undefined); - mockedGetRepoRoot.mockResolvedValue('/tmp/repo'); - mockedLoadConfig.mockResolvedValue({ - sessionName: 'ppg-test', - defaultAgent: 'claude', - agents: { - claude: { - name: 'claude', - command: 'claude', - interactive: true, - }, - }, - envFiles: [], - symlinkNodeModules: false, - }); - mockedResolveAgentConfig.mockReturnValue({ - name: 'claude', - command: 'claude', - interactive: true, - }); - mockedReadManifest.mockImplementation(async () => structuredClone(manifestState)); - mockedResolveWorktree.mockImplementation((manifest, ref) => (manifest as any).worktrees[ref as string]); - mockedUpdateManifest.mockImplementation(async (_projectRoot, updater) => { - manifestState = await updater(structuredClone(manifestState)); - return manifestState as any; - }); - mockedAgentId.mockImplementation(() => `ag-${nextAgent++}`); - mockedSessionId.mockImplementation(() => `session-${nextSession++}`); - mockedSpawnAgent.mockImplementation(async (opts: any) => ({ - id: opts.agentId, - name: opts.agentConfig.name, - agentType: opts.agentConfig.name, - status: 'running', - tmuxTarget: opts.tmuxTarget, - prompt: opts.prompt, - startedAt: '2026-02-27T00:00:00.000Z', - sessionId: opts.sessionId, - })); - mockedSplitPane.mockResolvedValue({ target: 'ppg-test:1.1' } as any); + mockedPerformSpawn.mockResolvedValue(makeResult()); }); - test('given lazy tmux window and spawn failure, should persist tmux window before agent writes', async () => { - mockedCreateWindow - .mockResolvedValueOnce('ppg-test:7') - .mockResolvedValueOnce('ppg-test:8'); - mockedSpawnAgent.mockRejectedValueOnce(new Error('spawn failed')); + test('given basic options, should call performSpawn and output success', async () => { + await spawnCommand({ prompt: 'Do work', count: 1 }); + + expect(mockedPerformSpawn).toHaveBeenCalledWith({ prompt: 'Do work', count: 1 }); + expect(success).toHaveBeenCalledWith(expect.stringContaining('Spawned worktree wt1')); + expect(info).toHaveBeenCalledWith(expect.stringContaining('Agent ag-1')); + }); + + test('given json option, should output JSON', async () => { + await spawnCommand({ prompt: 'Do work', count: 1, json: true }); + + expect(output).toHaveBeenCalledWith( + expect.objectContaining({ success: true, worktree: expect.objectContaining({ id: 'wt1' }) }), + true, + ); + }); + + test('given worktree option, should show added message', async () => { + await spawnCommand({ worktree: 'wt1', prompt: 'Do work', count: 1 }); + + expect(success).toHaveBeenCalledWith(expect.stringContaining('Added 1 agent(s) to worktree')); + }); + + test('given performSpawn failure, should propagate error', async () => { + mockedPerformSpawn.mockRejectedValueOnce(new Error('spawn failed')); await expect( - spawnCommand({ - worktree: 'wt1', - prompt: 'Do work', - count: 1, - }), + spawnCommand({ prompt: 'Do work', count: 1 }), ).rejects.toThrow('spawn failed'); - - expect(manifestState.worktrees.wt1.tmuxWindow).toBe('ppg-test:7'); - expect(Object.keys(manifestState.worktrees.wt1.agents)).toHaveLength(0); - expect(mockedUpdateManifest).toHaveBeenCalledTimes(1); - expect(mockedEnsureSession).toHaveBeenCalledTimes(1); }); - test('given existing worktree, should update manifest after each spawned agent', async () => { - manifestState = createManifest('ppg-test:1'); - mockedCreateWindow - .mockResolvedValueOnce('ppg-test:2') - .mockResolvedValueOnce('ppg-test:3'); - - await spawnCommand({ - worktree: 'wt1', - prompt: 'Do work', - count: 2, - }); - - expect(mockedUpdateManifest).toHaveBeenCalledTimes(2); - expect(Object.keys(manifestState.worktrees.wt1.agents)).toEqual(['ag-1', 'ag-2']); - expect(manifestState.worktrees.wt1.agents['ag-1'].tmuxTarget).toBe('ppg-test:2'); - expect(manifestState.worktrees.wt1.agents['ag-2'].tmuxTarget).toBe('ppg-test:3'); - expect(mockedEnsureSession).not.toHaveBeenCalled(); + test('given multiple agents, should show all agents', async () => { + mockedPerformSpawn.mockResolvedValue(makeResult({ + agents: [ + makeAgent('ag-1', 'ppg-test:1'), + makeAgent('ag-2', 'ppg-test:2'), + ], + })); + + await spawnCommand({ prompt: 'Do work', count: 2 }); + + expect(success).toHaveBeenCalledWith(expect.stringContaining('2 agent(s)')); + expect(info).toHaveBeenCalledWith(expect.stringContaining('ag-1')); + expect(info).toHaveBeenCalledWith(expect.stringContaining('ag-2')); }); }); diff --git a/src/commands/spawn.ts b/src/commands/spawn.ts index 873aaa3..ec1453d 100644 --- a/src/commands/spawn.ts +++ b/src/commands/spawn.ts @@ -1,495 +1,44 @@ -import fs from 'node:fs/promises'; -import { loadConfig, resolveAgentConfig } from '../core/config.js'; -import { readManifest, updateManifest, resolveWorktree } from '../core/manifest.js'; -import { getRepoRoot, getCurrentBranch, createWorktree, adoptWorktree } from '../core/worktree.js'; -import { setupWorktreeEnv } from '../core/env.js'; -import { loadTemplate, renderTemplate, type TemplateContext } from '../core/template.js'; -import { spawnAgent } from '../core/agent.js'; -import * as tmux from '../core/tmux.js'; -import { openTerminalWindow } from '../core/terminal.js'; -import { worktreeId as genWorktreeId, agentId as genAgentId, sessionId as genSessionId } from '../lib/id.js'; -import { manifestPath } from '../lib/paths.js'; -import { PpgError, NotInitializedError, WorktreeNotFoundError } from '../lib/errors.js'; +import { performSpawn, type PerformSpawnOptions, type SpawnResult } from '../core/operations/spawn.js'; import { output, success, info } from '../lib/output.js'; -import { normalizeName } from '../lib/name.js'; -import { parseVars } from '../lib/vars.js'; -import type { WorktreeEntry, AgentEntry } from '../types/manifest.js'; -import type { Config, AgentConfig } from '../types/config.js'; -export interface SpawnOptions { - name?: string; - agent?: string; - prompt?: string; - promptFile?: string; - template?: string; - var?: string[]; - base?: string; - branch?: string; - worktree?: string; - count?: number; - split?: boolean; - open?: boolean; +export interface SpawnOptions extends PerformSpawnOptions { json?: boolean; } export async function spawnCommand(options: SpawnOptions): Promise { - const projectRoot = await getRepoRoot(); - const config = await loadConfig(projectRoot); + const { json, ...spawnOpts } = options; - // Verify initialized (lightweight file check instead of full manifest read) - try { - await fs.access(manifestPath(projectRoot)); - } catch { - throw new NotInitializedError(projectRoot); - } - - const agentConfig = resolveAgentConfig(config, options.agent); - const count = options.count ?? 1; - - // Validate vars early — before any side effects (worktree/tmux creation) - const userVars = parseVars(options.var ?? []); - - // Resolve prompt - const promptText = await resolvePrompt(options, projectRoot); - - // Validate conflicting flags - if (options.branch && options.worktree) { - throw new PpgError('--branch and --worktree are mutually exclusive', 'INVALID_ARGS'); - } - if (options.branch && options.base) { - throw new PpgError('--branch and --base are mutually exclusive (--base is for new branches)', 'INVALID_ARGS'); - } - - if (options.worktree) { - // Add agent(s) to existing worktree - await spawnIntoExistingWorktree( - projectRoot, - agentConfig, - options.worktree, - promptText, - count, - options, - userVars, - ); - } else if (options.branch) { - // Create worktree from existing branch - await spawnOnExistingBranch( - projectRoot, - config, - agentConfig, - options.branch, - promptText, - count, - options, - userVars, - ); - } else { - // Create new worktree + agent(s) - await spawnNewWorktree( - projectRoot, - config, - agentConfig, - promptText, - count, - options, - userVars, - ); - } -} - -async function resolvePrompt(options: SpawnOptions, projectRoot: string): Promise { - if (options.prompt) return options.prompt; - - if (options.promptFile) { - return fs.readFile(options.promptFile, 'utf-8'); - } - - if (options.template) { - return loadTemplate(projectRoot, options.template); - } + const result = await performSpawn(spawnOpts); - throw new PpgError('One of --prompt, --prompt-file, or --template is required', 'INVALID_ARGS'); + emitSpawnResult(result, options); } -interface SpawnBatchOptions { - projectRoot: string; - agentConfig: AgentConfig; - promptText: string; - userVars: Record; - count: number; - split: boolean; - worktreePath: string; - branch: string; - taskName: string; - sessionName: string; - windowTarget: string; - windowNamePrefix: string; - reuseWindowForFirstAgent: boolean; - onAgentSpawned?: (agent: AgentEntry) => Promise; -} - -interface SpawnTargetOptions { - index: number; - split: boolean; - reuseWindowForFirstAgent: boolean; - windowTarget: string; - sessionName: string; - windowNamePrefix: string; - worktreePath: string; -} - -async function resolveAgentTarget(opts: SpawnTargetOptions): Promise { - if (opts.index === 0 && opts.reuseWindowForFirstAgent) { - return opts.windowTarget; - } - if (opts.split) { - const direction = opts.index % 2 === 1 ? 'horizontal' : 'vertical'; - const pane = await tmux.splitPane(opts.windowTarget, direction, opts.worktreePath); - return pane.target; - } - return tmux.createWindow(opts.sessionName, `${opts.windowNamePrefix}-${opts.index}`, opts.worktreePath); -} - -async function spawnAgentBatch(opts: SpawnBatchOptions): Promise { - const agents: AgentEntry[] = []; - for (let i = 0; i < opts.count; i++) { - const aId = genAgentId(); - const target = await resolveAgentTarget({ - index: i, - split: opts.split, - reuseWindowForFirstAgent: opts.reuseWindowForFirstAgent, - windowTarget: opts.windowTarget, - sessionName: opts.sessionName, - windowNamePrefix: opts.windowNamePrefix, - worktreePath: opts.worktreePath, - }); - - const ctx: TemplateContext = { - WORKTREE_PATH: opts.worktreePath, - BRANCH: opts.branch, - AGENT_ID: aId, - PROJECT_ROOT: opts.projectRoot, - TASK_NAME: opts.taskName, - PROMPT: opts.promptText, - ...opts.userVars, - }; - - const agentEntry = await spawnAgent({ - agentId: aId, - agentConfig: opts.agentConfig, - prompt: renderTemplate(opts.promptText, ctx), - worktreePath: opts.worktreePath, - tmuxTarget: target, - projectRoot: opts.projectRoot, - branch: opts.branch, - sessionId: genSessionId(), - }); - - agents.push(agentEntry); - if (opts.onAgentSpawned) { - await opts.onAgentSpawned(agentEntry); - } - } - - return agents; -} - -interface EmitSpawnResultOptions { - json: boolean | undefined; - successMessage: string; - worktree: { - id: string; - name: string; - branch: string; - path: string; - tmuxWindow: string; - }; - agents: AgentEntry[]; - attachRef?: string; -} - -function emitSpawnResult(opts: EmitSpawnResultOptions): void { - if (opts.json) { +function emitSpawnResult(result: SpawnResult, options: SpawnOptions): void { + if (options.json) { output({ success: true, - worktree: opts.worktree, - agents: opts.agents.map((a) => ({ - id: a.id, - tmuxTarget: a.tmuxTarget, - sessionId: a.sessionId, - })), + worktree: result.worktree, + agents: result.agents, }, true); return; } - success(opts.successMessage); - for (const a of opts.agents) { - info(` Agent ${a.id} → ${a.tmuxTarget}`); - } - if (opts.attachRef) { - info(`Attach: ppg attach ${opts.attachRef}`); - } -} - -async function spawnNewWorktree( - projectRoot: string, - config: Config, - agentConfig: AgentConfig, - promptText: string, - count: number, - options: SpawnOptions, - userVars: Record, -): Promise { - const baseBranch = options.base ?? await getCurrentBranch(projectRoot); - const wtId = genWorktreeId(); - const name = options.name ? normalizeName(options.name, wtId) : wtId; - const branchName = `ppg/${name}`; - - // Create git worktree - info(`Creating worktree ${wtId} on branch ${branchName}`); - const wtPath = await createWorktree(projectRoot, wtId, { - branch: branchName, - base: baseBranch, - }); - - // Setup env - await setupWorktreeEnv(projectRoot, wtPath, config); - - // Ensure tmux session (manifest is the source of truth for session name) - const manifest = await readManifest(projectRoot); - const sessionName = manifest.sessionName; - await tmux.ensureSession(sessionName); - - // Create tmux window - const windowTarget = await tmux.createWindow(sessionName, name, wtPath); - - // Register skeleton worktree in manifest before spawning agents - // so partial failures leave a record for cleanup - const worktreeEntry: WorktreeEntry = { - id: wtId, - name, - path: wtPath, - branch: branchName, - baseBranch, - status: 'active', - tmuxWindow: windowTarget, - agents: {}, - createdAt: new Date().toISOString(), - }; - - await updateManifest(projectRoot, (m) => { - m.worktrees[wtId] = worktreeEntry; - return m; - }); - - // Spawn agents — one tmux window per agent (default), or split panes (--split) - const agents = await spawnAgentBatch({ - projectRoot, - agentConfig, - promptText, - userVars, - count, - split: options.split === true, - worktreePath: wtPath, - branch: branchName, - taskName: name, - sessionName, - windowTarget, - windowNamePrefix: name, - reuseWindowForFirstAgent: true, - onAgentSpawned: async (agentEntry) => { - // Update manifest incrementally after each agent spawn. - await updateManifest(projectRoot, (m) => { - if (m.worktrees[wtId]) { - m.worktrees[wtId].agents[agentEntry.id] = agentEntry; - } - return m; - }); - }, - }); - - // Only open Terminal window when explicitly requested via --open (fire-and-forget) - if (options.open === true) { - openTerminalWindow(sessionName, windowTarget, name).catch(() => {}); - } - - emitSpawnResult({ - json: options.json, - successMessage: `Spawned worktree ${wtId} with ${agents.length} agent(s)`, - worktree: { - id: wtId, - name, - branch: branchName, - path: wtPath, - tmuxWindow: windowTarget, - }, - agents, - attachRef: wtId, - }); -} - -async function spawnOnExistingBranch( - projectRoot: string, - config: Config, - agentConfig: AgentConfig, - branch: string, - promptText: string, - count: number, - options: SpawnOptions, - userVars: Record, -): Promise { - const baseBranch = await getCurrentBranch(projectRoot); - const wtId = genWorktreeId(); - - // Derive name from branch if --name not provided (strip ppg/ prefix if present) - const derivedName = branch.startsWith('ppg/') ? branch.slice(4) : branch; - const name = options.name ? normalizeName(options.name, wtId) : normalizeName(derivedName, wtId); - - // Create git worktree from existing branch (no -b flag) - info(`Creating worktree ${wtId} from existing branch ${branch}`); - const wtPath = await adoptWorktree(projectRoot, wtId, branch); + const agentCount = result.agents.length; - // Setup env - await setupWorktreeEnv(projectRoot, wtPath, config); - - // Ensure tmux session - const manifest = await readManifest(projectRoot); - const sessionName = manifest.sessionName; - await tmux.ensureSession(sessionName); - - // Create tmux window - const windowTarget = await tmux.createWindow(sessionName, name, wtPath); - - // Register worktree in manifest - const worktreeEntry: WorktreeEntry = { - id: wtId, - name, - path: wtPath, - branch, - baseBranch, - status: 'active', - tmuxWindow: windowTarget, - agents: {}, - createdAt: new Date().toISOString(), - }; - - await updateManifest(projectRoot, (m) => { - m.worktrees[wtId] = worktreeEntry; - return m; - }); - - const agents = await spawnAgentBatch({ - projectRoot, - agentConfig, - promptText, - userVars, - count, - split: options.split === true, - worktreePath: wtPath, - branch, - taskName: name, - sessionName, - windowTarget, - windowNamePrefix: name, - reuseWindowForFirstAgent: true, - onAgentSpawned: async (agentEntry) => { - await updateManifest(projectRoot, (m) => { - if (m.worktrees[wtId]) { - m.worktrees[wtId].agents[agentEntry.id] = agentEntry; - } - return m; - }); - }, - }); - - if (options.open === true) { - openTerminalWindow(sessionName, windowTarget, name).catch(() => {}); + if (options.worktree) { + success(`Added ${agentCount} agent(s) to worktree ${result.worktree.id}`); + } else if (options.branch) { + success(`Spawned worktree ${result.worktree.id} from branch ${options.branch} with ${agentCount} agent(s)`); + } else { + success(`Spawned worktree ${result.worktree.id} with ${agentCount} agent(s)`); } - emitSpawnResult({ - json: options.json, - successMessage: `Spawned worktree ${wtId} from branch ${branch} with ${agents.length} agent(s)`, - worktree: { - id: wtId, - name, - branch, - path: wtPath, - tmuxWindow: windowTarget, - }, - agents, - attachRef: wtId, - }); -} - -async function spawnIntoExistingWorktree( - projectRoot: string, - agentConfig: AgentConfig, - worktreeRef: string, - promptText: string, - count: number, - options: SpawnOptions, - userVars: Record, -): Promise { - const manifest = await readManifest(projectRoot); - const wt = resolveWorktree(manifest, worktreeRef); - - if (!wt) throw new WorktreeNotFoundError(worktreeRef); - - // Lazily create tmux window if worktree has none (standalone worktree) - let windowTarget = wt.tmuxWindow; - if (!windowTarget) { - await tmux.ensureSession(manifest.sessionName); - windowTarget = await tmux.createWindow(manifest.sessionName, wt.name, wt.path); - - // Persist tmux window before spawning agents so partial failures are tracked. - await updateManifest(projectRoot, (m) => { - const mWt = m.worktrees[wt.id]; - if (!mWt) return m; - mWt.tmuxWindow = windowTarget; - return m; - }); + for (const a of result.agents) { + info(` Agent ${a.id} → ${a.tmuxTarget}`); } - const agents = await spawnAgentBatch({ - projectRoot, - agentConfig, - promptText, - userVars, - count, - split: options.split === true, - worktreePath: wt.path, - branch: wt.branch, - taskName: wt.name, - sessionName: manifest.sessionName, - windowTarget, - windowNamePrefix: `${wt.name}-agent`, - // For existing worktrees, only reuse the primary pane when explicitly splitting. - reuseWindowForFirstAgent: options.split === true, - onAgentSpawned: async (agentEntry) => { - await updateManifest(projectRoot, (m) => { - const mWt = m.worktrees[wt.id]; - if (!mWt) return m; - mWt.agents[agentEntry.id] = agentEntry; - return m; - }); - }, - }); - - // Only open Terminal window when explicitly requested via --open (fire-and-forget) - if (options.open === true) { - openTerminalWindow(manifest.sessionName, windowTarget, wt.name).catch(() => {}); + // Only show attach hint for newly created worktrees, not when adding to existing + if (!options.worktree) { + info(`Attach: ppg attach ${result.worktree.id}`); } - - emitSpawnResult({ - json: options.json, - successMessage: `Added ${agents.length} agent(s) to worktree ${wt.id}`, - worktree: { - id: wt.id, - name: wt.name, - branch: wt.branch, - path: wt.path, - tmuxWindow: windowTarget, - }, - agents, - }); } diff --git a/src/commands/status.ts b/src/commands/status.ts index 326139d..ff66132 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -4,6 +4,8 @@ import { refreshAllAgentStatuses } from '../core/agent.js'; import { getRepoRoot } from '../core/worktree.js'; import { output, formatStatus, formatTable, type Column } from '../lib/output.js'; import type { AgentEntry, WorktreeEntry } from '../types/manifest.js'; +import { computeLifecycle } from '../core/lifecycle.js'; +export { computeLifecycle, type WorktreeLifecycle } from '../core/lifecycle.js'; export interface StatusOptions { json?: boolean; @@ -104,20 +106,6 @@ function printWorktreeStatus(wt: WorktreeEntry): void { console.log(table.split('\n').map((l) => ` ${l}`).join('\n')); } -export type WorktreeLifecycle = 'merged' | 'cleaned' | 'busy' | 'shipped' | 'idle'; - -export function computeLifecycle(wt: WorktreeEntry): WorktreeLifecycle { - if (wt.status === 'merged') return 'merged'; - if (wt.status === 'cleaned') return 'cleaned'; - - const agents = Object.values(wt.agents); - - if (agents.some((a) => a.status === 'running')) return 'busy'; - if (wt.prUrl) return 'shipped'; - - return 'idle'; -} - function formatTime(iso: string): string { if (!iso) return '—'; const d = new Date(iso); diff --git a/src/core/agent.ts b/src/core/agent.ts index be24e82..def1c23 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -3,7 +3,9 @@ import { agentPromptFile, agentPromptsDir } from '../lib/paths.js'; import { getPaneInfo, listSessionPanes, type PaneInfo } from './tmux.js'; import { updateManifest } from './manifest.js'; import { PpgError } from '../lib/errors.js'; -import type { AgentEntry, AgentStatus } from '../types/manifest.js'; +import { agentId as genAgentId, sessionId as genSessionId } from '../lib/id.js'; +import { renderTemplate, type TemplateContext } from './template.js'; +import type { AgentEntry, AgentStatus, WorktreeEntry } from '../types/manifest.js'; import type { AgentConfig } from '../types/config.js'; import * as tmux from './tmux.js'; @@ -242,6 +244,88 @@ export async function killAgents(agents: AgentEntry[]): Promise { })); } +export interface RestartAgentOptions { + projectRoot: string; + agentId: string; + worktree: WorktreeEntry; + oldAgent: AgentEntry; + sessionName: string; + agentConfig: AgentConfig; + promptText: string; +} + +export interface RestartAgentResult { + oldAgentId: string; + newAgentId: string; + tmuxTarget: string; + sessionId: string; + worktreeId: string; + worktreeName: string; + branch: string; + path: string; +} + +/** + * Restart an agent: kill old, spawn new in a fresh tmux window, update manifest. + */ +export async function restartAgent(opts: RestartAgentOptions): Promise { + const { projectRoot, worktree: wt, oldAgent, sessionName, agentConfig, promptText } = opts; + + // Kill old agent if still running + if (oldAgent.status === 'running') { + await killAgent(oldAgent); + } + + await tmux.ensureSession(sessionName); + const newAgentId = genAgentId(); + const windowTarget = await tmux.createWindow(sessionName, `${wt.name}-restart`, wt.path); + + const ctx: TemplateContext = { + WORKTREE_PATH: wt.path, + BRANCH: wt.branch, + AGENT_ID: newAgentId, + PROJECT_ROOT: projectRoot, + TASK_NAME: wt.name, + PROMPT: promptText, + }; + const renderedPrompt = renderTemplate(promptText, ctx); + + const newSessionId = genSessionId(); + const agentEntry = await spawnAgent({ + agentId: newAgentId, + agentConfig, + prompt: renderedPrompt, + worktreePath: wt.path, + tmuxTarget: windowTarget, + projectRoot, + branch: wt.branch, + sessionId: newSessionId, + }); + + await updateManifest(projectRoot, (m) => { + const mWt = m.worktrees[wt.id]; + if (mWt) { + const mOldAgent = mWt.agents[oldAgent.id]; + if (mOldAgent && mOldAgent.status === 'running') { + mOldAgent.status = 'gone'; + } + mWt.agents[newAgentId] = agentEntry; + } + return m; + }); + + return { + oldAgentId: oldAgent.id, + newAgentId, + tmuxTarget: windowTarget, + sessionId: newSessionId, + worktreeId: wt.id, + worktreeName: wt.name, + branch: wt.branch, + path: wt.path, + }; +} + async function fileExists(filePath: string): Promise { try { await fs.access(filePath); diff --git a/src/core/kill.test.ts b/src/core/kill.test.ts new file mode 100644 index 0000000..a6db7d1 --- /dev/null +++ b/src/core/kill.test.ts @@ -0,0 +1,74 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { makeWorktree, makeAgent } from '../test-fixtures.js'; +import type { Manifest } from '../types/manifest.js'; + +// ---- Mocks ---- + +let manifestState: Manifest; + +vi.mock('./manifest.js', () => ({ + updateManifest: vi.fn(async (_root: string, updater: (m: Manifest) => Manifest | Promise) => { + manifestState = await updater(structuredClone(manifestState)); + return manifestState; + }), +})); + +vi.mock('./agent.js', () => ({ + killAgents: vi.fn(), +})); + +// ---- Imports (after mocks) ---- + +import { killWorktreeAgents } from './kill.js'; +import { killAgents } from './agent.js'; + +describe('killWorktreeAgents', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('given worktree with running agents, should kill running agents and set status to gone', async () => { + const agent1 = makeAgent({ id: 'ag-run00001', status: 'running' }); + const agent2 = makeAgent({ id: 'ag-idle0001', status: 'idle' }); + const wt = makeWorktree({ + id: 'wt-abc123', + agents: { 'ag-run00001': agent1, 'ag-idle0001': agent2 }, + }); + manifestState = { + version: 1, + projectRoot: '/tmp/project', + sessionName: 'ppg', + worktrees: { 'wt-abc123': structuredClone(wt) }, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; + + const result = await killWorktreeAgents('/tmp/project', wt); + + expect(result.killed).toEqual(['ag-run00001']); + expect(vi.mocked(killAgents)).toHaveBeenCalledWith([agent1]); + expect(manifestState.worktrees['wt-abc123'].agents['ag-run00001'].status).toBe('gone'); + expect(manifestState.worktrees['wt-abc123'].agents['ag-idle0001'].status).toBe('idle'); + }); + + test('given worktree with no running agents, should return empty killed list', async () => { + const agent = makeAgent({ id: 'ag-done0001', status: 'exited' }); + const wt = makeWorktree({ + id: 'wt-abc123', + agents: { 'ag-done0001': agent }, + }); + manifestState = { + version: 1, + projectRoot: '/tmp/project', + sessionName: 'ppg', + worktrees: { 'wt-abc123': structuredClone(wt) }, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; + + const result = await killWorktreeAgents('/tmp/project', wt); + + expect(result.killed).toEqual([]); + expect(vi.mocked(killAgents)).toHaveBeenCalledWith([]); + }); +}); diff --git a/src/core/kill.ts b/src/core/kill.ts new file mode 100644 index 0000000..ef26e67 --- /dev/null +++ b/src/core/kill.ts @@ -0,0 +1,36 @@ +import { updateManifest } from './manifest.js'; +import { killAgents } from './agent.js'; +import type { WorktreeEntry } from '../types/manifest.js'; + +export interface KillWorktreeResult { + worktreeId: string; + killed: string[]; +} + +/** Kill all running agents in a worktree and set their status to 'gone'. */ +export async function killWorktreeAgents( + projectRoot: string, + wt: WorktreeEntry, +): Promise { + const toKill = Object.values(wt.agents).filter((a) => a.status === 'running'); + const killedIds = toKill.map((a) => a.id); + + await killAgents(toKill); + + await updateManifest(projectRoot, (m) => { + const mWt = m.worktrees[wt.id]; + if (mWt) { + for (const agent of Object.values(mWt.agents)) { + if (killedIds.includes(agent.id)) { + agent.status = 'gone'; + } + } + } + return m; + }); + + return { + worktreeId: wt.id, + killed: killedIds, + }; +} diff --git a/src/core/lifecycle.ts b/src/core/lifecycle.ts new file mode 100644 index 0000000..5fa282d --- /dev/null +++ b/src/core/lifecycle.ts @@ -0,0 +1,15 @@ +import type { WorktreeEntry } from '../types/manifest.js'; + +export type WorktreeLifecycle = 'merged' | 'cleaned' | 'busy' | 'shipped' | 'idle'; + +export function computeLifecycle(wt: WorktreeEntry): WorktreeLifecycle { + if (wt.status === 'merged') return 'merged'; + if (wt.status === 'cleaned') return 'cleaned'; + + const agents = Object.values(wt.agents); + + if (agents.some((a) => a.status === 'running')) return 'busy'; + if (wt.prUrl) return 'shipped'; + + return 'idle'; +} diff --git a/src/core/merge.test.ts b/src/core/merge.test.ts new file mode 100644 index 0000000..99eb75a --- /dev/null +++ b/src/core/merge.test.ts @@ -0,0 +1,119 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { makeWorktree, makeAgent } from '../test-fixtures.js'; +import type { Manifest } from '../types/manifest.js'; + +// ---- Mocks ---- + +let manifestState: Manifest; + +vi.mock('./manifest.js', () => ({ + updateManifest: vi.fn(async (_root: string, updater: (m: Manifest) => Manifest | Promise) => { + manifestState = await updater(structuredClone(manifestState)); + return manifestState; + }), +})); + +vi.mock('./worktree.js', () => ({ + getCurrentBranch: vi.fn(() => 'main'), +})); + +vi.mock('./cleanup.js', () => ({ + cleanupWorktree: vi.fn(async () => ({ selfProtected: false, selfProtectedTargets: [] })), +})); + +vi.mock('execa', () => ({ + execa: vi.fn(), +})); + +vi.mock('../lib/env.js', () => ({ + execaEnv: {}, +})); + +// ---- Imports (after mocks) ---- + +import { mergeWorktree } from './merge.js'; +import { getCurrentBranch } from './worktree.js'; +import { cleanupWorktree } from './cleanup.js'; +import { execa } from 'execa'; + +describe('mergeWorktree', () => { + beforeEach(() => { + vi.clearAllMocks(); + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + manifestState = { + version: 1, + projectRoot: '/tmp/project', + sessionName: 'ppg', + worktrees: { 'wt-abc123': wt }, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; + }); + + test('given valid worktree, should merge with squash and update manifest to merged', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + + const result = await mergeWorktree('/tmp/project', wt); + + expect(result.strategy).toBe('squash'); + expect(result.cleaned).toBe(true); + expect(manifestState.worktrees['wt-abc123'].status).toBe('merged'); + expect(manifestState.worktrees['wt-abc123'].mergedAt).toBeDefined(); + }); + + test('given no-ff strategy, should call git merge --no-ff', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + + await mergeWorktree('/tmp/project', wt, { strategy: 'no-ff' }); + + const calls = vi.mocked(execa).mock.calls; + const mergeCall = calls.find((c) => c[0] === 'git' && (c[1] as string[])?.[0] === 'merge'); + expect(mergeCall).toBeDefined(); + expect((mergeCall![1] as string[])).toContain('--no-ff'); + }); + + test('given different current branch, should checkout base branch first', async () => { + vi.mocked(getCurrentBranch).mockResolvedValueOnce('feature-x'); + const wt = makeWorktree({ id: 'wt-abc123', baseBranch: 'main', agents: {} }); + + await mergeWorktree('/tmp/project', wt); + + const calls = vi.mocked(execa).mock.calls; + const checkoutCall = calls.find((c) => c[0] === 'git' && (c[1] as string[])?.[0] === 'checkout'); + expect(checkoutCall).toBeDefined(); + expect((checkoutCall![1] as string[])).toContain('main'); + }); + + test('given running agents without force, should throw AGENTS_RUNNING', async () => { + const agent = makeAgent({ id: 'ag-running1', status: 'running' }); + const wt = makeWorktree({ id: 'wt-abc123', agents: { 'ag-running1': agent } }); + + await expect(mergeWorktree('/tmp/project', wt)).rejects.toThrow('agent(s) still running'); + }); + + test('given running agents with force, should merge anyway', async () => { + const agent = makeAgent({ id: 'ag-running1', status: 'running' }); + const wt = makeWorktree({ id: 'wt-abc123', agents: { 'ag-running1': agent } }); + + const result = await mergeWorktree('/tmp/project', wt, { force: true }); + + expect(result.worktreeId).toBe('wt-abc123'); + }); + + test('given cleanup false, should skip cleanup', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + + const result = await mergeWorktree('/tmp/project', wt, { cleanup: false }); + + expect(result.cleaned).toBe(false); + expect(vi.mocked(cleanupWorktree)).not.toHaveBeenCalled(); + }); + + test('given git merge failure, should set status to failed and throw', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + vi.mocked(execa).mockRejectedValueOnce(new Error('conflict')); + + await expect(mergeWorktree('/tmp/project', wt)).rejects.toThrow('Merge failed'); + expect(manifestState.worktrees['wt-abc123'].status).toBe('failed'); + }); +}); diff --git a/src/core/merge.ts b/src/core/merge.ts new file mode 100644 index 0000000..ad98701 --- /dev/null +++ b/src/core/merge.ts @@ -0,0 +1,105 @@ +import { execa } from 'execa'; +import { updateManifest } from './manifest.js'; +import { getCurrentBranch } from './worktree.js'; +import { cleanupWorktree, type CleanupOptions } from './cleanup.js'; +import { PpgError, MergeFailedError } from '../lib/errors.js'; +import { execaEnv } from '../lib/env.js'; +import type { WorktreeEntry } from '../types/manifest.js'; + +export interface MergeWorktreeOptions { + strategy?: 'squash' | 'no-ff'; + cleanup?: boolean; + force?: boolean; + cleanupOptions?: CleanupOptions; +} + +export interface MergeWorktreeResult { + worktreeId: string; + branch: string; + baseBranch: string; + strategy: 'squash' | 'no-ff'; + cleaned: boolean; + selfProtected: boolean; +} + +/** Merge a worktree branch into its base branch. Updates manifest status throughout. */ +export async function mergeWorktree( + projectRoot: string, + wt: WorktreeEntry, + options: MergeWorktreeOptions = {}, +): Promise { + const { strategy = 'squash', cleanup = true, force = false } = options; + + // Check all agents finished + const incomplete = Object.values(wt.agents).filter((a) => a.status === 'running'); + if (incomplete.length > 0 && !force) { + const ids = incomplete.map((a) => a.id).join(', '); + throw new PpgError( + `${incomplete.length} agent(s) still running: ${ids}. Use --force to merge anyway.`, + 'AGENTS_RUNNING', + ); + } + + // Set worktree status to merging + await updateManifest(projectRoot, (m) => { + if (m.worktrees[wt.id]) { + m.worktrees[wt.id].status = 'merging'; + } + return m; + }); + + try { + const currentBranch = await getCurrentBranch(projectRoot); + if (currentBranch !== wt.baseBranch) { + await execa('git', ['checkout', wt.baseBranch], { ...execaEnv, cwd: projectRoot }); + } + + if (strategy === 'squash') { + await execa('git', ['merge', '--squash', wt.branch], { ...execaEnv, cwd: projectRoot }); + await execa('git', ['commit', '-m', `ppg: merge ${wt.name} (${wt.branch})`], { + ...execaEnv, + cwd: projectRoot, + }); + } else { + await execa('git', ['merge', '--no-ff', wt.branch, '-m', `ppg: merge ${wt.name} (${wt.branch})`], { + ...execaEnv, + cwd: projectRoot, + }); + } + } catch (err) { + await updateManifest(projectRoot, (m) => { + if (m.worktrees[wt.id]) { + m.worktrees[wt.id].status = 'failed'; + } + return m; + }); + throw new MergeFailedError( + `Merge failed: ${err instanceof Error ? err.message : err}`, + ); + } + + // Mark as merged + await updateManifest(projectRoot, (m) => { + if (m.worktrees[wt.id]) { + m.worktrees[wt.id].status = 'merged'; + m.worktrees[wt.id].mergedAt = new Date().toISOString(); + } + return m; + }); + + // Cleanup + let selfProtected = false; + if (cleanup) { + const cleanupResult = await cleanupWorktree(projectRoot, wt, options.cleanupOptions); + selfProtected = cleanupResult.selfProtected; + } + + return { + worktreeId: wt.id, + branch: wt.branch, + baseBranch: wt.baseBranch, + strategy, + cleaned: cleanup, + selfProtected, + }; +} diff --git a/src/core/operations/kill.test.ts b/src/core/operations/kill.test.ts new file mode 100644 index 0000000..d09fb64 --- /dev/null +++ b/src/core/operations/kill.test.ts @@ -0,0 +1,478 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import type { Manifest, AgentEntry, WorktreeEntry } from '../../types/manifest.js'; +import type { PaneInfo } from '../tmux.js'; + +// --- Mocks --- + +vi.mock('../manifest.js', () => ({ + readManifest: vi.fn(), + updateManifest: vi.fn(), + findAgent: vi.fn(), + resolveWorktree: vi.fn(), +})); + +vi.mock('../agent.js', () => ({ + killAgent: vi.fn(async () => {}), + killAgents: vi.fn(async () => {}), +})); + +vi.mock('../pr.js', () => ({ + checkPrState: vi.fn(async () => 'UNKNOWN'), +})); + +vi.mock('../cleanup.js', () => ({ + cleanupWorktree: vi.fn(async () => ({ + worktreeId: 'wt-abc123', + manifestUpdated: true, + tmuxKilled: 1, + tmuxSkipped: 0, + tmuxFailed: 0, + selfProtected: false, + selfProtectedTargets: [], + })), +})); + +vi.mock('../self.js', () => ({ + excludeSelf: vi.fn(), +})); + +vi.mock('../tmux.js', () => ({ + killPane: vi.fn(async () => {}), +})); + +import { performKill } from './kill.js'; +import { readManifest, updateManifest, findAgent, resolveWorktree } from '../manifest.js'; +import { killAgent, killAgents } from '../agent.js'; +import { checkPrState } from '../pr.js'; +import { cleanupWorktree } from '../cleanup.js'; +import { excludeSelf } from '../self.js'; +import { killPane } from '../tmux.js'; +import { PpgError } from '../../lib/errors.js'; + +// --- Helpers --- + +function makeAgent(id: string, overrides: Partial = {}): AgentEntry { + return { + id, + name: 'test', + agentType: 'claude', + status: 'running', + tmuxTarget: 'ppg:1.0', + prompt: 'do stuff', + startedAt: new Date().toISOString(), + ...overrides, + }; +} + +function makeWorktree(overrides: Partial = {}): WorktreeEntry { + return { + id: 'wt-abc123', + name: 'test-wt', + path: '/tmp/wt', + branch: 'ppg/test-wt', + baseBranch: 'main', + status: 'active', + tmuxWindow: 'ppg:1', + agents: {}, + createdAt: new Date().toISOString(), + ...overrides, + }; +} + +function makeManifest(worktrees: Record = {}): Manifest { + return { + version: 1, + projectRoot: '/project', + sessionName: 'ppg', + worktrees, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; +} + +// Shared manifest reference for updateManifest mock +let _manifest: Manifest; +function currentManifest(): Manifest { + return JSON.parse(JSON.stringify(_manifest)); +} + +// --- Tests --- + +describe('performKill', () => { + beforeEach(() => { + vi.clearAllMocks(); + _manifest = makeManifest(); + // Establish defaults for all mocks after clearing + vi.mocked(readManifest).mockResolvedValue(_manifest); + vi.mocked(updateManifest).mockImplementation(async (_root: string, updater: (m: any) => any) => { + return updater(currentManifest()); + }); + vi.mocked(findAgent).mockReturnValue(undefined); + vi.mocked(resolveWorktree).mockReturnValue(undefined); + vi.mocked(checkPrState).mockResolvedValue('UNKNOWN'); + vi.mocked(excludeSelf).mockImplementation((agents: AgentEntry[]) => ({ safe: agents, skipped: [] })); + }); + + test('throws INVALID_ARGS when no target specified', async () => { + const err = await performKill({ projectRoot: '/project' }).catch((e) => e); + expect(err).toBeInstanceOf(PpgError); + expect((err as PpgError).code).toBe('INVALID_ARGS'); + }); + + describe('single agent kill', () => { + beforeEach(() => { + const agent = makeAgent('ag-12345678'); + const wt = makeWorktree({ agents: { 'ag-12345678': agent } }); + _manifest = makeManifest({ 'wt-abc123': wt }); + vi.mocked(readManifest).mockResolvedValue(_manifest); + vi.mocked(findAgent).mockReturnValue({ worktree: wt, agent }); + }); + + test('kills a running agent and updates manifest', async () => { + const result = await performKill({ + projectRoot: '/project', + agent: 'ag-12345678', + }); + + expect(killAgent).toHaveBeenCalled(); + expect(updateManifest).toHaveBeenCalled(); + expect(result.success).toBe(true); + expect(result.killed).toEqual(['ag-12345678']); + }); + + test('manifest updater sets agent status to gone', async () => { + await performKill({ + projectRoot: '/project', + agent: 'ag-12345678', + }); + + // Verify the updater was called and inspect what it does + const updaterCall = vi.mocked(updateManifest).mock.calls[0]; + expect(updaterCall[0]).toBe('/project'); + const updater = updaterCall[1]; + + // Run the updater against a test manifest to verify the mutation + const testManifest = makeManifest({ + 'wt-abc123': makeWorktree({ + agents: { 'ag-12345678': makeAgent('ag-12345678') }, + }), + }); + // findAgent mock returns matching agent from test manifest + vi.mocked(findAgent).mockReturnValue({ + worktree: testManifest.worktrees['wt-abc123'], + agent: testManifest.worktrees['wt-abc123'].agents['ag-12345678'], + }); + const result = updater(testManifest) as Manifest; + expect(result.worktrees['wt-abc123'].agents['ag-12345678'].status).toBe('gone'); + }); + + test('skips kill for terminal-state agent', async () => { + const goneAgent = makeAgent('ag-12345678', { status: 'gone' }); + const wt = makeWorktree({ agents: { 'ag-12345678': goneAgent } }); + vi.mocked(findAgent).mockReturnValue({ worktree: wt, agent: goneAgent }); + + const result = await performKill({ + projectRoot: '/project', + agent: 'ag-12345678', + }); + + expect(killAgent).not.toHaveBeenCalled(); + expect(result.success).toBe(true); + expect(result.killed).toEqual([]); + expect(result.message).toContain('already gone'); + }); + + test('throws AgentNotFoundError for unknown agent', async () => { + vi.mocked(findAgent).mockReturnValue(undefined); + + const err = await performKill({ projectRoot: '/project', agent: 'ag-unknown' }).catch((e) => e); + expect(err).toBeInstanceOf(PpgError); + expect((err as PpgError).code).toBe('AGENT_NOT_FOUND'); + }); + + test('self-protection returns success:false with skipped', async () => { + const agent = makeAgent('ag-12345678'); + vi.mocked(excludeSelf).mockReturnValue({ + safe: [], + skipped: [agent], + }); + + const result = await performKill({ + projectRoot: '/project', + agent: 'ag-12345678', + selfPaneId: '%5', + paneMap: new Map(), + }); + + expect(killAgent).not.toHaveBeenCalled(); + expect(result.success).toBe(false); + expect(result.killed).toEqual([]); + expect(result.skipped).toEqual(['ag-12345678']); + }); + + test('--delete removes agent from manifest', async () => { + const result = await performKill({ + projectRoot: '/project', + agent: 'ag-12345678', + delete: true, + }); + + expect(killAgent).toHaveBeenCalled(); + expect(killPane).toHaveBeenCalled(); + expect(updateManifest).toHaveBeenCalled(); + expect(result.success).toBe(true); + expect(result.deleted).toEqual(['ag-12345678']); + }); + + test('--delete manifest updater removes agent entry', async () => { + await performKill({ + projectRoot: '/project', + agent: 'ag-12345678', + delete: true, + }); + + const updaterCall = vi.mocked(updateManifest).mock.calls[0]; + const updater = updaterCall[1]; + + const testManifest = makeManifest({ + 'wt-abc123': makeWorktree({ + agents: { 'ag-12345678': makeAgent('ag-12345678') }, + }), + }); + vi.mocked(findAgent).mockReturnValue({ + worktree: testManifest.worktrees['wt-abc123'], + agent: testManifest.worktrees['wt-abc123'].agents['ag-12345678'], + }); + const result = updater(testManifest) as Manifest; + expect(result.worktrees['wt-abc123'].agents['ag-12345678']).toBeUndefined(); + }); + + test('--delete on terminal agent skips kill but still deletes', async () => { + const idleAgent = makeAgent('ag-12345678', { status: 'idle' }); + const wt = makeWorktree({ agents: { 'ag-12345678': idleAgent } }); + vi.mocked(findAgent).mockReturnValue({ worktree: wt, agent: idleAgent }); + + const result = await performKill({ + projectRoot: '/project', + agent: 'ag-12345678', + delete: true, + }); + + expect(killAgent).not.toHaveBeenCalled(); + expect(killPane).toHaveBeenCalled(); + expect(result.success).toBe(true); + expect(result.deleted).toEqual(['ag-12345678']); + expect(result.killed).toEqual([]); + }); + + test('propagates killAgent errors', async () => { + vi.mocked(killAgent).mockRejectedValue(new Error('tmux crash')); + + const err = await performKill({ + projectRoot: '/project', + agent: 'ag-12345678', + }).catch((e) => e); + + expect(err).toBeInstanceOf(Error); + expect(err.message).toBe('tmux crash'); + }); + }); + + describe('worktree kill', () => { + let agent1: AgentEntry; + let agent2: AgentEntry; + let wt: WorktreeEntry; + + beforeEach(() => { + agent1 = makeAgent('ag-aaaaaaaa', { tmuxTarget: 'ppg:1.0' }); + agent2 = makeAgent('ag-bbbbbbbb', { tmuxTarget: 'ppg:1.1' }); + wt = makeWorktree({ + agents: { + 'ag-aaaaaaaa': agent1, + 'ag-bbbbbbbb': agent2, + }, + }); + _manifest = makeManifest({ 'wt-abc123': wt }); + vi.mocked(readManifest).mockResolvedValue(_manifest); + vi.mocked(resolveWorktree).mockReturnValue(wt); + }); + + test('kills all running agents in worktree', async () => { + const result = await performKill({ + projectRoot: '/project', + worktree: 'wt-abc123', + }); + + expect(killAgents).toHaveBeenCalledWith([agent1, agent2]); + expect(result.success).toBe(true); + expect(result.killed).toEqual(['ag-aaaaaaaa', 'ag-bbbbbbbb']); + }); + + test('throws WorktreeNotFoundError for unknown worktree', async () => { + vi.mocked(resolveWorktree).mockReturnValue(undefined); + + const err = await performKill({ projectRoot: '/project', worktree: 'wt-unknown' }).catch((e) => e); + expect(err).toBeInstanceOf(PpgError); + expect((err as PpgError).code).toBe('WORKTREE_NOT_FOUND'); + }); + + test('--remove triggers worktree cleanup', async () => { + await performKill({ + projectRoot: '/project', + worktree: 'wt-abc123', + remove: true, + }); + + expect(cleanupWorktree).toHaveBeenCalled(); + }); + + test('--delete removes worktree from manifest', async () => { + const result = await performKill({ + projectRoot: '/project', + worktree: 'wt-abc123', + delete: true, + }); + + expect(cleanupWorktree).toHaveBeenCalled(); + expect(result.deleted).toEqual(['wt-abc123']); + }); + + test('--delete skips worktree with open PR', async () => { + vi.mocked(checkPrState).mockResolvedValue('OPEN'); + + const result = await performKill({ + projectRoot: '/project', + worktree: 'wt-abc123', + delete: true, + }); + + expect(cleanupWorktree).not.toHaveBeenCalled(); + expect(result.deleted).toEqual([]); + expect(result.skippedOpenPrs).toEqual(['wt-abc123']); + }); + + test('--delete --include-open-prs overrides PR check', async () => { + vi.mocked(checkPrState).mockResolvedValue('OPEN'); + + const result = await performKill({ + projectRoot: '/project', + worktree: 'wt-abc123', + delete: true, + includeOpenPrs: true, + }); + + expect(cleanupWorktree).toHaveBeenCalled(); + expect(result.deleted).toEqual(['wt-abc123']); + }); + + test('filters non-running agents', async () => { + const idleAgent = makeAgent('ag-cccccccc', { status: 'idle' }); + const wtMixed = makeWorktree({ + agents: { + 'ag-aaaaaaaa': agent1, + 'ag-cccccccc': idleAgent, + }, + }); + vi.mocked(resolveWorktree).mockReturnValue(wtMixed); + + const result = await performKill({ + projectRoot: '/project', + worktree: 'wt-abc123', + }); + + expect(result.killed).toEqual(['ag-aaaaaaaa']); + }); + }); + + describe('kill all', () => { + let agent1: AgentEntry; + let agent2: AgentEntry; + let wt1: WorktreeEntry; + let wt2: WorktreeEntry; + + beforeEach(() => { + agent1 = makeAgent('ag-aaaaaaaa'); + agent2 = makeAgent('ag-bbbbbbbb'); + wt1 = makeWorktree({ + id: 'wt-111111', + agents: { 'ag-aaaaaaaa': agent1 }, + }); + wt2 = makeWorktree({ + id: 'wt-222222', + name: 'other-wt', + agents: { 'ag-bbbbbbbb': agent2 }, + }); + _manifest = makeManifest({ 'wt-111111': wt1, 'wt-222222': wt2 }); + vi.mocked(readManifest).mockResolvedValue(_manifest); + // resolveWorktree is called inside removeWorktreeCleanup for --delete/--remove + vi.mocked(resolveWorktree).mockImplementation((_m, ref) => { + if (ref === 'wt-111111') return wt1; + if (ref === 'wt-222222') return wt2; + return undefined; + }); + }); + + test('kills agents across all worktrees', async () => { + const result = await performKill({ + projectRoot: '/project', + all: true, + }); + + expect(killAgents).toHaveBeenCalled(); + expect(result.success).toBe(true); + expect(result.killed).toHaveLength(2); + expect(result.killed).toContain('ag-aaaaaaaa'); + expect(result.killed).toContain('ag-bbbbbbbb'); + }); + + test('includes worktreeCount in result', async () => { + const result = await performKill({ + projectRoot: '/project', + all: true, + }); + + expect(result.worktreeCount).toBe(2); + }); + + test('--delete removes all active worktrees', async () => { + const result = await performKill({ + projectRoot: '/project', + all: true, + delete: true, + }); + + expect(cleanupWorktree).toHaveBeenCalledTimes(2); + expect(result.deleted).toHaveLength(2); + }); + + test('--remove triggers cleanup without manifest deletion', async () => { + const result = await performKill({ + projectRoot: '/project', + all: true, + remove: true, + }); + + expect(cleanupWorktree).toHaveBeenCalledTimes(2); + expect(result.removed).toHaveLength(2); + // --remove without --delete: cleanup happens but entries stay in manifest + expect(result.deleted).toEqual([]); + }); + + test('self-protection filters agents', async () => { + vi.mocked(excludeSelf).mockReturnValue({ + safe: [agent2], + skipped: [agent1], + }); + + const result = await performKill({ + projectRoot: '/project', + all: true, + selfPaneId: '%5', + paneMap: new Map(), + }); + + expect(result.killed).toEqual(['ag-bbbbbbbb']); + expect(result.skipped).toEqual(['ag-aaaaaaaa']); + }); + }); +}); diff --git a/src/core/operations/kill.ts b/src/core/operations/kill.ts new file mode 100644 index 0000000..4ad9433 --- /dev/null +++ b/src/core/operations/kill.ts @@ -0,0 +1,262 @@ +import { readManifest, updateManifest, findAgent, resolveWorktree } from '../manifest.js'; +import { killAgent, killAgents } from '../agent.js'; +import { checkPrState } from '../pr.js'; +import { cleanupWorktree } from '../cleanup.js'; +import { excludeSelf } from '../self.js'; +import { killPane, type PaneInfo } from '../tmux.js'; +import { PpgError, AgentNotFoundError, WorktreeNotFoundError } from '../../lib/errors.js'; +import type { AgentEntry } from '../../types/manifest.js'; + +export interface KillInput { + projectRoot: string; + agent?: string; + worktree?: string; + all?: boolean; + remove?: boolean; + delete?: boolean; + includeOpenPrs?: boolean; + selfPaneId?: string | null; + paneMap?: Map; +} + +export interface KillResult { + success: boolean; + killed: string[]; + skipped?: string[]; + removed?: string[]; + deleted?: string[]; + skippedOpenPrs?: string[]; + worktreeCount?: number; + message?: string; +} + +export async function performKill(input: KillInput): Promise { + const { projectRoot } = input; + + if (!input.agent && !input.worktree && !input.all) { + throw new PpgError('One of --agent, --worktree, or --all is required', 'INVALID_ARGS'); + } + + if (input.agent) { + return killSingleAgent(projectRoot, input.agent, input); + } else if (input.worktree) { + return killWorktreeAgents(projectRoot, input.worktree, input); + } else { + return killAllAgents(projectRoot, input); + } +} + +async function killSingleAgent( + projectRoot: string, + agentId: string, + input: KillInput, +): Promise { + const manifest = await readManifest(projectRoot); + const found = findAgent(manifest, agentId); + if (!found) throw new AgentNotFoundError(agentId); + + const { agent } = found; + const isTerminal = agent.status !== 'running'; + + // Self-protection check + if (input.selfPaneId && input.paneMap) { + const { skipped } = excludeSelf([agent], input.selfPaneId, input.paneMap); + if (skipped.length > 0) { + return { success: false, killed: [], skipped: [agentId], message: 'self-protection' }; + } + } + + if (input.delete) { + if (!isTerminal) { + await killAgent(agent); + } + await killPane(agent.tmuxTarget); + + await updateManifest(projectRoot, (m) => { + const f = findAgent(m, agentId); + if (f) { + delete f.worktree.agents[agentId]; + } + return m; + }); + + return { success: true, killed: isTerminal ? [] : [agentId], deleted: [agentId] }; + } + + if (isTerminal) { + return { success: true, killed: [], message: `Agent ${agentId} already ${agent.status}` }; + } + + await killAgent(agent); + + await updateManifest(projectRoot, (m) => { + const f = findAgent(m, agentId); + if (f) { + f.agent.status = 'gone'; + } + return m; + }); + + return { success: true, killed: [agentId] }; +} + +async function killWorktreeAgents( + projectRoot: string, + worktreeRef: string, + input: KillInput, +): Promise { + const manifest = await readManifest(projectRoot); + const wt = resolveWorktree(manifest, worktreeRef); + if (!wt) throw new WorktreeNotFoundError(worktreeRef); + + let toKill = Object.values(wt.agents).filter((a) => a.status === 'running'); + + const skippedIds: string[] = []; + if (input.selfPaneId && input.paneMap) { + const { safe, skipped } = excludeSelf(toKill, input.selfPaneId, input.paneMap); + toKill = safe; + for (const a of skipped) skippedIds.push(a.id); + } + + const killedIds = toKill.map((a) => a.id); + await killAgents(toKill); + + await updateManifest(projectRoot, (m) => { + const mWt = m.worktrees[wt.id]; + if (mWt) { + for (const agent of Object.values(mWt.agents)) { + if (killedIds.includes(agent.id)) { + agent.status = 'gone'; + } + } + } + return m; + }); + + // Check for open PR before deleting worktree + let skippedOpenPr = false; + if (input.delete && !input.includeOpenPrs) { + const prState = await checkPrState(wt.branch); + if (prState === 'OPEN') { + skippedOpenPr = true; + } + } + + const shouldRemove = (input.remove || input.delete) && !skippedOpenPr; + if (shouldRemove) { + await removeWorktreeCleanup(projectRoot, wt.id, input.selfPaneId ?? null, input.paneMap); + } + + if (input.delete && !skippedOpenPr) { + await updateManifest(projectRoot, (m) => { + delete m.worktrees[wt.id]; + return m; + }); + } + + return { + success: true, + killed: killedIds, + skipped: skippedIds.length > 0 ? skippedIds : undefined, + removed: shouldRemove ? [wt.id] : [], + deleted: (input.delete && !skippedOpenPr) ? [wt.id] : [], + skippedOpenPrs: skippedOpenPr ? [wt.id] : undefined, + }; +} + +async function killAllAgents( + projectRoot: string, + input: KillInput, +): Promise { + const manifest = await readManifest(projectRoot); + let toKill: AgentEntry[] = []; + + for (const wt of Object.values(manifest.worktrees)) { + for (const agent of Object.values(wt.agents)) { + if (agent.status === 'running') { + toKill.push(agent); + } + } + } + + const skippedIds: string[] = []; + if (input.selfPaneId && input.paneMap) { + const { safe, skipped } = excludeSelf(toKill, input.selfPaneId, input.paneMap); + toKill = safe; + for (const a of skipped) skippedIds.push(a.id); + } + + const killedIds = toKill.map((a) => a.id); + await killAgents(toKill); + + const activeWorktreeIds = Object.values(manifest.worktrees) + .filter((wt) => wt.status === 'active') + .map((wt) => wt.id); + + await updateManifest(projectRoot, (m) => { + for (const wt of Object.values(m.worktrees)) { + for (const agent of Object.values(wt.agents)) { + if (killedIds.includes(agent.id)) { + agent.status = 'gone'; + } + } + } + return m; + }); + + // Filter out worktrees with open PRs + let worktreesToRemove = activeWorktreeIds; + const openPrWorktreeIds: string[] = []; + if (input.delete && !input.includeOpenPrs) { + worktreesToRemove = []; + for (const wtId of activeWorktreeIds) { + const wt = manifest.worktrees[wtId]; + if (wt) { + const prState = await checkPrState(wt.branch); + if (prState === 'OPEN') { + openPrWorktreeIds.push(wtId); + } else { + worktreesToRemove.push(wtId); + } + } + } + } + + const shouldRemove = input.remove || input.delete; + if (shouldRemove) { + for (const wtId of worktreesToRemove) { + await removeWorktreeCleanup(projectRoot, wtId, input.selfPaneId ?? null, input.paneMap); + } + } + + if (input.delete) { + await updateManifest(projectRoot, (m) => { + for (const wtId of worktreesToRemove) { + delete m.worktrees[wtId]; + } + return m; + }); + } + + return { + success: true, + killed: killedIds, + skipped: skippedIds.length > 0 ? skippedIds : undefined, + removed: shouldRemove ? worktreesToRemove : [], + deleted: input.delete ? worktreesToRemove : [], + skippedOpenPrs: openPrWorktreeIds.length > 0 ? openPrWorktreeIds : undefined, + worktreeCount: activeWorktreeIds.length, + }; +} + +async function removeWorktreeCleanup( + projectRoot: string, + wtId: string, + selfPaneId: string | null, + paneMap?: Map, +): Promise { + const manifest = await readManifest(projectRoot); + const wt = resolveWorktree(manifest, wtId); + if (!wt) return; + await cleanupWorktree(projectRoot, wt, { selfPaneId, paneMap }); +} diff --git a/src/core/operations/merge.test.ts b/src/core/operations/merge.test.ts new file mode 100644 index 0000000..d3c21e8 --- /dev/null +++ b/src/core/operations/merge.test.ts @@ -0,0 +1,330 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import type { Manifest, WorktreeEntry } from '../../types/manifest.js'; + +// --- Mocks --- + +const mockExeca = vi.fn(async () => ({ stdout: 'main', stderr: '' })); +vi.mock('execa', () => ({ + execa: (...args: unknown[]) => (mockExeca as Function)(...args), +})); + +const mockManifest = (): Manifest => ({ + version: 1, + projectRoot: '/project', + sessionName: 'ppg', + worktrees: { + 'wt-abc123': makeWorktree(), + }, + createdAt: '2025-01-01T00:00:00.000Z', + updatedAt: '2025-01-01T00:00:00.000Z', +}); + +let latestManifest: Manifest; + +vi.mock('../manifest.js', () => ({ + requireManifest: vi.fn(async () => latestManifest), + updateManifest: vi.fn(async (_root: string, updater: (m: Manifest) => Manifest | Promise) => { + latestManifest = await updater(latestManifest); + return latestManifest; + }), + resolveWorktree: vi.fn((manifest: Manifest, ref: string) => { + return manifest.worktrees[ref] ?? + Object.values(manifest.worktrees).find((wt) => wt.name === ref || wt.branch === ref); + }), +})); + +vi.mock('../agent.js', () => ({ + refreshAllAgentStatuses: vi.fn(async (m: Manifest) => m), +})); + +vi.mock('../worktree.js', () => ({ + getCurrentBranch: vi.fn(async () => 'main'), +})); + +vi.mock('../cleanup.js', () => ({ + cleanupWorktree: vi.fn(async () => ({ + worktreeId: 'wt-abc123', + manifestUpdated: true, + tmuxKilled: 1, + tmuxSkipped: 0, + tmuxFailed: 0, + selfProtected: false, + selfProtectedTargets: [], + })), +})); + +vi.mock('../self.js', () => ({ + getCurrentPaneId: vi.fn(() => null), +})); + +vi.mock('../tmux.js', () => ({ + listSessionPanes: vi.fn(async () => new Map()), +})); + +vi.mock('../../lib/env.js', () => ({ + execaEnv: { env: { PATH: '/usr/bin' } }, +})); + +import { performMerge } from './merge.js'; +import { updateManifest } from '../manifest.js'; +import { getCurrentBranch } from '../worktree.js'; +import { cleanupWorktree } from '../cleanup.js'; +import { getCurrentPaneId } from '../self.js'; +import { listSessionPanes } from '../tmux.js'; +import { PpgError, MergeFailedError, WorktreeNotFoundError } from '../../lib/errors.js'; + +function makeWorktree(overrides: Partial = {}): WorktreeEntry { + return { + id: 'wt-abc123', + name: 'test-feature', + path: '/project/.worktrees/wt-abc123', + branch: 'ppg/test-feature', + baseBranch: 'main', + status: 'active', + tmuxWindow: 'ppg:1', + agents: { + 'ag-00000001': { + id: 'ag-00000001', + name: 'claude-1', + agentType: 'claude', + status: 'exited', + tmuxTarget: 'ppg:1.0', + prompt: 'do stuff', + startedAt: '2025-01-01T00:00:00.000Z', + }, + }, + createdAt: '2025-01-01T00:00:00.000Z', + ...overrides, + }; +} + +describe('performMerge', () => { + beforeEach(() => { + vi.clearAllMocks(); + latestManifest = mockManifest(); + mockExeca.mockResolvedValue({ stdout: 'main', stderr: '' }); + }); + + test('performs squash merge and returns result', async () => { + const result = await performMerge({ + projectRoot: '/project', + worktreeRef: 'wt-abc123', + strategy: 'squash', + }); + + expect(result.merged).toBe(true); + expect(result.strategy).toBe('squash'); + expect(result.worktreeId).toBe('wt-abc123'); + expect(result.branch).toBe('ppg/test-feature'); + expect(result.baseBranch).toBe('main'); + expect(result.cleaned).toBe(true); + expect(result.dryRun).toBe(false); + }); + + test('defaults to squash strategy', async () => { + const result = await performMerge({ + projectRoot: '/project', + worktreeRef: 'wt-abc123', + }); + + expect(result.strategy).toBe('squash'); + // Verify git merge --squash was called + expect(mockExeca).toHaveBeenCalledWith( + 'git', ['merge', '--squash', 'ppg/test-feature'], + expect.objectContaining({ cwd: '/project' }), + ); + }); + + test('supports no-ff merge strategy', async () => { + await performMerge({ + projectRoot: '/project', + worktreeRef: 'wt-abc123', + strategy: 'no-ff', + }); + + expect(mockExeca).toHaveBeenCalledWith( + 'git', ['merge', '--no-ff', 'ppg/test-feature', '-m', 'ppg: merge test-feature (ppg/test-feature)'], + expect.objectContaining({ cwd: '/project' }), + ); + }); + + test('state transitions: active → merging → merged → cleaned', async () => { + const statusLog: string[] = []; + vi.mocked(updateManifest).mockImplementation(async (_root, updater) => { + latestManifest = await updater(latestManifest); + const wt = latestManifest.worktrees['wt-abc123']; + if (wt) statusLog.push(wt.status); + return latestManifest; + }); + + await performMerge({ + projectRoot: '/project', + worktreeRef: 'wt-abc123', + }); + + // Call order: refreshAllAgentStatuses (active) → set merging → set merged + // Note: cleanup's manifest update is mocked, so 'cleaned' is not tracked here + expect(statusLog).toContain('merging'); + expect(statusLog).toContain('merged'); + expect(statusLog.indexOf('merging')).toBeLessThan(statusLog.indexOf('merged')); + }); + + test('sets status to failed on git merge error', async () => { + mockExeca.mockImplementation(async (...args: unknown[]) => { + const cmdArgs = args[1] as string[]; + if (cmdArgs[0] === 'merge') { + throw new Error('CONFLICT (content): Merge conflict in file.ts'); + } + return { stdout: 'main', stderr: '' }; + }); + + await expect( + performMerge({ + projectRoot: '/project', + worktreeRef: 'wt-abc123', + }), + ).rejects.toThrow(MergeFailedError); + + expect(latestManifest.worktrees['wt-abc123'].status).toBe('failed'); + }); + + test('throws AGENTS_RUNNING when agents still running', async () => { + latestManifest.worktrees['wt-abc123'].agents['ag-00000001'].status = 'running'; + + const err = await performMerge({ + projectRoot: '/project', + worktreeRef: 'wt-abc123', + }).catch((e) => e); + + expect(err).toBeInstanceOf(PpgError); + expect(err.code).toBe('AGENTS_RUNNING'); + }); + + test('force bypasses running agent check', async () => { + latestManifest.worktrees['wt-abc123'].agents['ag-00000001'].status = 'running'; + + const result = await performMerge({ + projectRoot: '/project', + worktreeRef: 'wt-abc123', + force: true, + }); + + expect(result.merged).toBe(true); + }); + + test('throws WorktreeNotFoundError for invalid ref', async () => { + await expect( + performMerge({ + projectRoot: '/project', + worktreeRef: 'wt-nonexistent', + }), + ).rejects.toThrow(WorktreeNotFoundError); + }); + + test('dry run returns early without modifying state', async () => { + const result = await performMerge({ + projectRoot: '/project', + worktreeRef: 'wt-abc123', + dryRun: true, + }); + + expect(result.dryRun).toBe(true); + expect(result.merged).toBe(false); + expect(result.cleaned).toBe(false); + // Should not have called git merge + expect(mockExeca).not.toHaveBeenCalledWith( + 'git', expect.arrayContaining(['merge']), + expect.anything(), + ); + // Worktree status unchanged + expect(latestManifest.worktrees['wt-abc123'].status).toBe('active'); + }); + + test('skips cleanup when cleanup=false', async () => { + const result = await performMerge({ + projectRoot: '/project', + worktreeRef: 'wt-abc123', + cleanup: false, + }); + + expect(result.merged).toBe(true); + expect(result.cleaned).toBe(false); + expect(cleanupWorktree).not.toHaveBeenCalled(); + }); + + test('switches to base branch if not already on it', async () => { + vi.mocked(getCurrentBranch).mockResolvedValueOnce('some-other-branch'); + + await performMerge({ + projectRoot: '/project', + worktreeRef: 'wt-abc123', + }); + + expect(mockExeca).toHaveBeenCalledWith( + 'git', ['checkout', 'main'], + expect.objectContaining({ cwd: '/project' }), + ); + }); + + test('skips checkout when already on base branch', async () => { + vi.mocked(getCurrentBranch).mockResolvedValueOnce('main'); + + await performMerge({ + projectRoot: '/project', + worktreeRef: 'wt-abc123', + }); + + expect(mockExeca).not.toHaveBeenCalledWith( + 'git', ['checkout', 'main'], + expect.anything(), + ); + }); + + test('passes self-protection context to cleanup', async () => { + vi.mocked(getCurrentPaneId).mockReturnValueOnce('%5'); + const paneMap = new Map(); + vi.mocked(listSessionPanes).mockResolvedValueOnce(paneMap); + + await performMerge({ + projectRoot: '/project', + worktreeRef: 'wt-abc123', + }); + + expect(listSessionPanes).toHaveBeenCalledWith('ppg'); + expect(cleanupWorktree).toHaveBeenCalledWith( + '/project', + expect.objectContaining({ id: 'wt-abc123' }), + { selfPaneId: '%5', paneMap }, + ); + }); + + test('reports selfProtected when cleanup skips targets', async () => { + vi.mocked(cleanupWorktree).mockResolvedValueOnce({ + worktreeId: 'wt-abc123', + manifestUpdated: true, + tmuxKilled: 0, + tmuxSkipped: 0, + tmuxFailed: 0, + selfProtected: true, + selfProtectedTargets: ['ppg:1'], + }); + + const result = await performMerge({ + projectRoot: '/project', + worktreeRef: 'wt-abc123', + }); + + expect(result.selfProtected).toBe(true); + }); + + test('sets mergedAt timestamp on successful merge', async () => { + await performMerge({ + projectRoot: '/project', + worktreeRef: 'wt-abc123', + }); + + expect(latestManifest.worktrees['wt-abc123'].mergedAt).toBeDefined(); + // Should be a valid ISO date + expect(() => new Date(latestManifest.worktrees['wt-abc123'].mergedAt!)).not.toThrow(); + }); +}); diff --git a/src/core/operations/merge.ts b/src/core/operations/merge.ts new file mode 100644 index 0000000..4997833 --- /dev/null +++ b/src/core/operations/merge.ts @@ -0,0 +1,152 @@ +import { execa } from 'execa'; +import { requireManifest, updateManifest, resolveWorktree } from '../manifest.js'; +import { refreshAllAgentStatuses } from '../agent.js'; +import { getCurrentBranch } from '../worktree.js'; +import { cleanupWorktree } from '../cleanup.js'; +import { getCurrentPaneId } from '../self.js'; +import { listSessionPanes, type PaneInfo } from '../tmux.js'; +import { PpgError, WorktreeNotFoundError, MergeFailedError } from '../../lib/errors.js'; +import { execaEnv } from '../../lib/env.js'; + +export type MergeStrategy = 'squash' | 'no-ff'; + +export interface MergeOptions { + projectRoot: string; + worktreeRef: string; + strategy?: MergeStrategy; + cleanup?: boolean; + dryRun?: boolean; + force?: boolean; +} + +export interface MergeResult { + worktreeId: string; + branch: string; + baseBranch: string; + strategy: MergeStrategy; + dryRun: boolean; + merged: boolean; + cleaned: boolean; + selfProtected: boolean; +} + +/** + * Perform a merge operation: resolve worktree, validate agents, run git merge, + * and optionally clean up. + * + * State machine: active → merging → merged → cleaned + * On failure: active → merging → failed + */ +export async function performMerge(options: MergeOptions): Promise { + const { projectRoot, worktreeRef, force = false, dryRun = false } = options; + const strategy = options.strategy ?? 'squash'; + const shouldCleanup = options.cleanup !== false; + + // Load and refresh manifest + await requireManifest(projectRoot); + const manifest = await updateManifest(projectRoot, async (m) => { + return refreshAllAgentStatuses(m, projectRoot); + }); + + const wt = resolveWorktree(manifest, worktreeRef); + if (!wt) throw new WorktreeNotFoundError(worktreeRef); + + // Validate: no running agents unless forced + const agents = Object.values(wt.agents); + const incomplete = agents.filter((a) => a.status === 'running'); + + if (incomplete.length > 0 && !force) { + const ids = incomplete.map((a) => a.id).join(', '); + throw new PpgError( + `${incomplete.length} agent(s) still running: ${ids}. Use --force to merge anyway.`, + 'AGENTS_RUNNING', + ); + } + + // Dry run: return early without changes + if (dryRun) { + return { + worktreeId: wt.id, + branch: wt.branch, + baseBranch: wt.baseBranch, + strategy, + dryRun: true, + merged: false, + cleaned: false, + selfProtected: false, + }; + } + + // Transition: active → merging + await updateManifest(projectRoot, (m) => { + if (m.worktrees[wt.id]) { + m.worktrees[wt.id].status = 'merging'; + } + return m; + }); + + // Perform git merge + try { + const currentBranch = await getCurrentBranch(projectRoot); + if (currentBranch !== wt.baseBranch) { + await execa('git', ['checkout', wt.baseBranch], { ...execaEnv, cwd: projectRoot }); + } + + if (strategy === 'squash') { + await execa('git', ['merge', '--squash', wt.branch], { ...execaEnv, cwd: projectRoot }); + await execa('git', ['commit', '-m', `ppg: merge ${wt.name} (${wt.branch})`], { + ...execaEnv, + cwd: projectRoot, + }); + } else { + await execa('git', ['merge', '--no-ff', wt.branch, '-m', `ppg: merge ${wt.name} (${wt.branch})`], { + ...execaEnv, + cwd: projectRoot, + }); + } + } catch (err) { + // Transition: merging → failed + await updateManifest(projectRoot, (m) => { + if (m.worktrees[wt.id]) { + m.worktrees[wt.id].status = 'failed'; + } + return m; + }); + throw new MergeFailedError( + `Merge failed: ${err instanceof Error ? err.message : err}`, + ); + } + + // Transition: merging → merged + await updateManifest(projectRoot, (m) => { + if (m.worktrees[wt.id]) { + m.worktrees[wt.id].status = 'merged'; + m.worktrees[wt.id].mergedAt = new Date().toISOString(); + } + return m; + }); + + // Cleanup (merged → cleaned) + let selfProtected = false; + if (shouldCleanup) { + const selfPaneId = getCurrentPaneId(); + let paneMap: Map | undefined; + if (selfPaneId) { + paneMap = await listSessionPanes(manifest.sessionName); + } + + const cleanupResult = await cleanupWorktree(projectRoot, wt, { selfPaneId, paneMap }); + selfProtected = cleanupResult.selfProtected; + } + + return { + worktreeId: wt.id, + branch: wt.branch, + baseBranch: wt.baseBranch, + strategy, + dryRun: false, + merged: true, + cleaned: shouldCleanup, + selfProtected, + }; +} diff --git a/src/core/operations/restart.test.ts b/src/core/operations/restart.test.ts new file mode 100644 index 0000000..43944a1 --- /dev/null +++ b/src/core/operations/restart.test.ts @@ -0,0 +1,277 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { makeAgent, makeWorktree } from '../../test-fixtures.js'; +import type { Manifest } from '../../types/manifest.js'; +import type { AgentStatus } from '../../types/manifest.js'; + +// Mock node:fs/promises +vi.mock('node:fs/promises', () => ({ + default: { + readFile: vi.fn(), + mkdir: vi.fn(), + writeFile: vi.fn(), + }, +})); + +// Mock core modules +vi.mock('../worktree.js', () => ({ + getRepoRoot: vi.fn().mockResolvedValue('/tmp/project'), +})); + +vi.mock('../config.js', () => ({ + loadConfig: vi.fn().mockResolvedValue({ + sessionName: 'ppg', + defaultAgent: 'claude', + agents: { + claude: { name: 'claude', command: 'claude --dangerously-skip-permissions', interactive: true }, + }, + }), + resolveAgentConfig: vi.fn().mockReturnValue({ + name: 'claude', + command: 'claude --dangerously-skip-permissions', + interactive: true, + }), +})); + +vi.mock('../manifest.js', () => ({ + requireManifest: vi.fn(), + updateManifest: vi.fn(), + findAgent: vi.fn(), +})); + +vi.mock('../agent.js', () => ({ + spawnAgent: vi.fn(), + killAgent: vi.fn(), +})); + +vi.mock('../tmux.js', () => ({ + ensureSession: vi.fn(), + createWindow: vi.fn(), +})); + +vi.mock('../template.js', () => ({ + renderTemplate: vi.fn((content: string) => content), +})); + +vi.mock('../../lib/id.js', () => ({ + agentId: vi.fn().mockReturnValue('ag-newagent'), + sessionId: vi.fn().mockReturnValue('sess-new123'), +})); + +vi.mock('../../lib/paths.js', () => ({ + agentPromptFile: vi.fn().mockReturnValue('/tmp/project/.ppg/agent-prompts/ag-test1234.md'), +})); + +vi.mock('../../lib/errors.js', async () => { + const actual = await vi.importActual('../../lib/errors.js'); + return actual; +}); + +import fs from 'node:fs/promises'; +import { requireManifest, updateManifest, findAgent } from '../manifest.js'; +import { spawnAgent, killAgent } from '../agent.js'; +import * as tmux from '../tmux.js'; +import { performRestart } from './restart.js'; + +const mockedFindAgent = vi.mocked(findAgent); +const mockedRequireManifest = vi.mocked(requireManifest); +const mockedUpdateManifest = vi.mocked(updateManifest); +const mockedSpawnAgent = vi.mocked(spawnAgent); +const mockedKillAgent = vi.mocked(killAgent); +const mockedEnsureSession = vi.mocked(tmux.ensureSession); +const mockedCreateWindow = vi.mocked(tmux.createWindow); +const mockedReadFile = vi.mocked(fs.readFile); + +const PROJECT_ROOT = '/tmp/project'; + +function makeManifest(overrides?: Partial): Manifest { + return { + version: 1, + projectRoot: PROJECT_ROOT, + sessionName: 'ppg', + worktrees: {}, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('performRestart', () => { + function setupDefaults(agentOverrides?: { status?: AgentStatus }) { + const status = agentOverrides?.status ?? 'running'; + const agent = makeAgent({ id: 'ag-oldagent', status }); + const wt = makeWorktree({ + id: 'wt-abc123', + name: 'feature-auth', + agents: { 'ag-oldagent': agent }, + }); + const manifest = makeManifest({ worktrees: { [wt.id]: wt } }); + mockedRequireManifest.mockResolvedValue(manifest); + mockedFindAgent.mockReturnValue({ worktree: wt, agent }); + mockedCreateWindow.mockResolvedValue('ppg:2'); + mockedReadFile.mockResolvedValue('original prompt' as unknown as never); + mockedSpawnAgent.mockResolvedValue(makeAgent({ + id: 'ag-newagent', + tmuxTarget: 'ppg:2', + sessionId: 'sess-new123', + })); + mockedUpdateManifest.mockImplementation(async (_root, updater) => { + const m = JSON.parse(JSON.stringify(manifest)) as Manifest; + return updater(m); + }); + return { agent, wt, manifest }; + } + + test('given running agent, should kill old agent before restarting', async () => { + const { agent } = setupDefaults({ status: 'running' }); + + await performRestart({ agentRef: 'ag-oldagent' }); + + expect(mockedKillAgent).toHaveBeenCalledWith(agent); + }); + + test('given running agent, should return killedOldAgent true', async () => { + setupDefaults({ status: 'running' }); + + const result = await performRestart({ agentRef: 'ag-oldagent' }); + + expect(result.killedOldAgent).toBe(true); + }); + + test('given idle agent, should not kill old agent', async () => { + setupDefaults({ status: 'idle' }); + + await performRestart({ agentRef: 'ag-oldagent' }); + + expect(mockedKillAgent).not.toHaveBeenCalled(); + }); + + test('given exited agent, should not kill old agent', async () => { + setupDefaults({ status: 'exited' }); + + await performRestart({ agentRef: 'ag-oldagent' }); + + expect(mockedKillAgent).not.toHaveBeenCalled(); + }); + + test('given gone agent, should not kill old agent', async () => { + setupDefaults({ status: 'gone' }); + + await performRestart({ agentRef: 'ag-oldagent' }); + + expect(mockedKillAgent).not.toHaveBeenCalled(); + }); + + test('given non-running agent, should return killedOldAgent false', async () => { + setupDefaults({ status: 'idle' }); + + const result = await performRestart({ agentRef: 'ag-oldagent' }); + + expect(result.killedOldAgent).toBe(false); + }); + + test('should create tmux window in same worktree', async () => { + const { wt } = setupDefaults(); + + await performRestart({ agentRef: 'ag-oldagent' }); + + expect(mockedEnsureSession).toHaveBeenCalledWith('ppg'); + expect(mockedCreateWindow).toHaveBeenCalledWith('ppg', 'feature-auth-restart', wt.path); + }); + + test('should spawn agent with correct options', async () => { + const { wt } = setupDefaults(); + + await performRestart({ agentRef: 'ag-oldagent' }); + + expect(mockedSpawnAgent).toHaveBeenCalledWith({ + agentId: 'ag-newagent', + agentConfig: { + name: 'claude', + command: 'claude --dangerously-skip-permissions', + interactive: true, + }, + prompt: 'original prompt', + worktreePath: wt.path, + tmuxTarget: 'ppg:2', + projectRoot: PROJECT_ROOT, + branch: wt.branch, + sessionId: 'sess-new123', + }); + }); + + test('should update manifest with new agent and mark old as gone', async () => { + const { wt } = setupDefaults(); + + await performRestart({ agentRef: 'ag-oldagent' }); + + expect(mockedUpdateManifest).toHaveBeenCalledWith(PROJECT_ROOT, expect.any(Function)); + + // Verify the updater function marks old agent gone and adds new agent + const updater = mockedUpdateManifest.mock.calls[0][1]; + const testManifest = makeManifest({ + worktrees: { + [wt.id]: { + ...wt, + agents: { + 'ag-oldagent': makeAgent({ id: 'ag-oldagent', status: 'running' }), + }, + }, + }, + }); + const updated = await updater(testManifest); + const updatedWt = updated.worktrees[wt.id]; + + expect(updatedWt.agents['ag-oldagent'].status).toBe('gone'); + expect(updatedWt.agents['ag-newagent']).toBeDefined(); + }); + + test('should return old and new agent info', async () => { + setupDefaults(); + + const result = await performRestart({ agentRef: 'ag-oldagent' }); + + expect(result.oldAgentId).toBe('ag-oldagent'); + expect(result.newAgent.id).toBe('ag-newagent'); + expect(result.newAgent.tmuxTarget).toBe('ppg:2'); + expect(result.newAgent.sessionId).toBe('sess-new123'); + expect(result.newAgent.worktreeId).toBe('wt-abc123'); + expect(result.newAgent.worktreeName).toBe('feature-auth'); + }); + + test('given prompt override, should use it instead of reading file', async () => { + setupDefaults(); + + await performRestart({ agentRef: 'ag-oldagent', prompt: 'custom prompt' }); + + expect(mockedReadFile).not.toHaveBeenCalled(); + }); + + test('given no prompt and missing prompt file, should throw PromptNotFoundError', async () => { + setupDefaults(); + mockedReadFile.mockRejectedValue(new Error('ENOENT')); + + await expect(performRestart({ agentRef: 'ag-oldagent' })).rejects.toThrow('Could not read original prompt'); + }); + + test('given unknown agent ref, should throw AgentNotFoundError', async () => { + const manifest = makeManifest(); + mockedRequireManifest.mockResolvedValue(manifest); + mockedFindAgent.mockReturnValue(undefined); + + await expect(performRestart({ agentRef: 'ag-nonexist' })).rejects.toThrow('Agent not found'); + }); + + test('given explicit projectRoot, should use it instead of getRepoRoot', async () => { + setupDefaults(); + + await performRestart({ agentRef: 'ag-oldagent', projectRoot: PROJECT_ROOT }); + + // getRepoRoot is mocked — if projectRoot is passed, the operation still works + // (verifiable because requireManifest receives the correct root) + expect(mockedUpdateManifest).toHaveBeenCalledWith(PROJECT_ROOT, expect.any(Function)); + }); +}); diff --git a/src/core/operations/restart.ts b/src/core/operations/restart.ts new file mode 100644 index 0000000..50ebcc8 --- /dev/null +++ b/src/core/operations/restart.ts @@ -0,0 +1,126 @@ +import fs from 'node:fs/promises'; +import { requireManifest, updateManifest, findAgent } from '../manifest.js'; +import { loadConfig, resolveAgentConfig } from '../config.js'; +import { spawnAgent, killAgent } from '../agent.js'; +import { getRepoRoot } from '../worktree.js'; +import * as tmux from '../tmux.js'; +import { agentId as genAgentId, sessionId as genSessionId } from '../../lib/id.js'; +import { agentPromptFile } from '../../lib/paths.js'; +import { AgentNotFoundError, PromptNotFoundError } from '../../lib/errors.js'; +import { renderTemplate, type TemplateContext } from '../template.js'; + +export interface RestartParams { + agentRef: string; + prompt?: string; + agentType?: string; + projectRoot?: string; +} + +export interface RestartResult { + oldAgentId: string; + killedOldAgent: boolean; + newAgent: { + id: string; + tmuxTarget: string; + sessionId: string; + worktreeId: string; + worktreeName: string; + branch: string; + path: string; + }; + sessionName: string; +} + +export async function performRestart(params: RestartParams): Promise { + const { agentRef, prompt: promptOverride, agentType } = params; + + const projectRoot = params.projectRoot ?? await getRepoRoot(); + const config = await loadConfig(projectRoot); + const manifest = await requireManifest(projectRoot); + + const found = findAgent(manifest, agentRef); + if (!found) throw new AgentNotFoundError(agentRef); + + const { worktree: wt, agent: oldAgent } = found; + + // Kill old agent if still running + let killedOldAgent = false; + if (oldAgent.status === 'running') { + await killAgent(oldAgent); + killedOldAgent = true; + } + + // Read original prompt from prompt file, or use override + let promptText: string; + if (promptOverride) { + promptText = promptOverride; + } else { + const pFile = agentPromptFile(projectRoot, oldAgent.id); + try { + promptText = await fs.readFile(pFile, 'utf-8'); + } catch { + throw new PromptNotFoundError(oldAgent.id); + } + } + + // Resolve agent config + const agentConfig = resolveAgentConfig(config, agentType ?? oldAgent.agentType); + + // Ensure tmux session + await tmux.ensureSession(manifest.sessionName); + + // Create new tmux window in same worktree + const newAgentId = genAgentId(); + const windowTarget = await tmux.createWindow(manifest.sessionName, `${wt.name}-restart`, wt.path); + + // Render template vars + const ctx: TemplateContext = { + WORKTREE_PATH: wt.path, + BRANCH: wt.branch, + AGENT_ID: newAgentId, + PROJECT_ROOT: projectRoot, + TASK_NAME: wt.name, + PROMPT: promptText, + }; + const renderedPrompt = renderTemplate(promptText, ctx); + + const newSessionId = genSessionId(); + const agentEntry = await spawnAgent({ + agentId: newAgentId, + agentConfig, + prompt: renderedPrompt, + worktreePath: wt.path, + tmuxTarget: windowTarget, + projectRoot, + branch: wt.branch, + sessionId: newSessionId, + }); + + // Update manifest: mark old agent as gone, add new agent + await updateManifest(projectRoot, (m) => { + const mWt = m.worktrees[wt.id]; + if (mWt) { + const mOldAgent = mWt.agents[oldAgent.id]; + if (mOldAgent && mOldAgent.status === 'running') { + mOldAgent.status = 'gone'; + } + mWt.agents[newAgentId] = agentEntry; + } + return m; + }); + + return { + oldAgentId: oldAgent.id, + killedOldAgent, + newAgent: { + id: newAgentId, + tmuxTarget: windowTarget, + sessionId: newSessionId, + worktreeId: wt.id, + worktreeName: wt.name, + branch: wt.branch, + path: wt.path, + }, + sessionName: manifest.sessionName, + }; +} diff --git a/src/core/operations/spawn.test.ts b/src/core/operations/spawn.test.ts new file mode 100644 index 0000000..1c81e33 --- /dev/null +++ b/src/core/operations/spawn.test.ts @@ -0,0 +1,446 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import type { Manifest, WorktreeEntry } from '../../types/manifest.js'; +import type { Config } from '../../types/config.js'; + +// --- Mocks --- + +vi.mock('node:fs/promises', () => ({ + default: { + access: vi.fn(), + readFile: vi.fn(), + mkdir: vi.fn(), + writeFile: vi.fn(), + }, +})); + +vi.mock('../config.js', () => ({ + loadConfig: vi.fn(), + resolveAgentConfig: vi.fn(), +})); + +vi.mock('../manifest.js', () => ({ + readManifest: vi.fn(), + updateManifest: vi.fn(), + resolveWorktree: vi.fn(), +})); + +vi.mock('../worktree.js', () => ({ + getRepoRoot: vi.fn(), + getCurrentBranch: vi.fn(), + createWorktree: vi.fn(), + adoptWorktree: vi.fn(), +})); + +vi.mock('../env.js', () => ({ + setupWorktreeEnv: vi.fn(), +})); + +vi.mock('../template.js', () => ({ + loadTemplate: vi.fn(), + renderTemplate: vi.fn((content: string) => content), +})); + +vi.mock('../agent.js', () => ({ + spawnAgent: vi.fn(), +})); + +vi.mock('../tmux.js', () => ({ + ensureSession: vi.fn(), + createWindow: vi.fn(), + splitPane: vi.fn(), + sendKeys: vi.fn(), +})); + +vi.mock('../terminal.js', () => ({ + openTerminalWindow: vi.fn(), +})); + +vi.mock('../../lib/id.js', () => ({ + worktreeId: vi.fn(), + agentId: vi.fn(), + sessionId: vi.fn(), +})); + +vi.mock('../../lib/paths.js', () => ({ + manifestPath: vi.fn((root: string) => `${root}/.ppg/manifest.json`), +})); + +vi.mock('../../lib/name.js', () => ({ + normalizeName: vi.fn((name: string) => name), +})); + +vi.mock('../../lib/vars.js', () => ({ + parseVars: vi.fn(() => ({})), +})); + +// --- Imports (after mocks) --- + +import fs from 'node:fs/promises'; +import { loadConfig, resolveAgentConfig } from '../config.js'; +import { readManifest, updateManifest, resolveWorktree } from '../manifest.js'; +import { getRepoRoot, getCurrentBranch, createWorktree, adoptWorktree } from '../worktree.js'; +import { setupWorktreeEnv } from '../env.js'; +import { loadTemplate } from '../template.js'; +import { spawnAgent } from '../agent.js'; +import * as tmux from '../tmux.js'; +import { openTerminalWindow } from '../terminal.js'; +import { worktreeId as genWorktreeId, agentId as genAgentId, sessionId as genSessionId } from '../../lib/id.js'; +import { performSpawn } from './spawn.js'; + +const mockedFs = vi.mocked(fs); +const mockedLoadConfig = vi.mocked(loadConfig); +const mockedResolveAgentConfig = vi.mocked(resolveAgentConfig); +const mockedReadManifest = vi.mocked(readManifest); +const mockedUpdateManifest = vi.mocked(updateManifest); +const mockedResolveWorktree = vi.mocked(resolveWorktree); +const mockedCreateWorktree = vi.mocked(createWorktree); +const mockedSpawnAgent = vi.mocked(spawnAgent); +const mockedEnsureSession = vi.mocked(tmux.ensureSession); +const mockedCreateWindow = vi.mocked(tmux.createWindow); +const mockedSplitPane = vi.mocked(tmux.splitPane); +const mockedLoadTemplate = vi.mocked(loadTemplate); + +const PROJECT_ROOT = '/tmp/project'; +const SESSION_NAME = 'ppg-test'; + +const DEFAULT_CONFIG: Config = { + sessionName: SESSION_NAME, + defaultAgent: 'claude', + agents: { + claude: { name: 'claude', command: 'claude', interactive: true }, + }, + envFiles: ['.env'], + symlinkNodeModules: true, +}; + +const AGENT_CONFIG = { name: 'claude', command: 'claude', interactive: true }; + +const DEFAULT_MANIFEST: Manifest = { + version: 1, + projectRoot: PROJECT_ROOT, + sessionName: SESSION_NAME, + worktrees: {}, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', +}; + +function makeManifestState(): Manifest { + return structuredClone(DEFAULT_MANIFEST); +} + +function setupDefaultMocks() { + vi.mocked(getRepoRoot).mockResolvedValue(PROJECT_ROOT); + mockedLoadConfig.mockResolvedValue(DEFAULT_CONFIG); + mockedResolveAgentConfig.mockReturnValue(AGENT_CONFIG); + mockedFs.access.mockResolvedValue(undefined); + mockedReadManifest.mockResolvedValue(makeManifestState()); + mockedUpdateManifest.mockImplementation(async (_root, updater) => { + return updater(makeManifestState()); + }); + vi.mocked(getCurrentBranch).mockResolvedValue('main'); + vi.mocked(genWorktreeId).mockReturnValue('wt-abc123'); + vi.mocked(genAgentId).mockReturnValue('ag-test0001'); + vi.mocked(genSessionId).mockReturnValue('session-uuid-1'); + mockedCreateWorktree.mockResolvedValue(`${PROJECT_ROOT}/.worktrees/wt-abc123`); + vi.mocked(adoptWorktree).mockResolvedValue(`${PROJECT_ROOT}/.worktrees/wt-abc123`); + mockedEnsureSession.mockResolvedValue(undefined); + mockedCreateWindow.mockResolvedValue(`${SESSION_NAME}:1`); + vi.mocked(setupWorktreeEnv).mockResolvedValue(undefined); + mockedSpawnAgent.mockResolvedValue({ + id: 'ag-test0001', + name: 'claude', + agentType: 'claude', + status: 'running', + tmuxTarget: `${SESSION_NAME}:1`, + prompt: 'Do the task', + startedAt: '2026-01-01T00:00:00.000Z', + sessionId: 'session-uuid-1', + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + setupDefaultMocks(); +}); + +describe('performSpawn', () => { + describe('new worktree (default path)', () => { + test('given prompt option, should create worktree, setup env, create tmux, spawn agent, return result', async () => { + const result = await performSpawn({ prompt: 'Do the task', name: 'feature-x' }); + + expect(mockedCreateWorktree).toHaveBeenCalledWith(PROJECT_ROOT, 'wt-abc123', { + branch: 'ppg/feature-x', + base: 'main', + }); + expect(vi.mocked(setupWorktreeEnv)).toHaveBeenCalledWith( + PROJECT_ROOT, + `${PROJECT_ROOT}/.worktrees/wt-abc123`, + DEFAULT_CONFIG, + ); + expect(mockedEnsureSession).toHaveBeenCalledWith(SESSION_NAME); + expect(mockedCreateWindow).toHaveBeenCalledWith( + SESSION_NAME, + 'feature-x', + `${PROJECT_ROOT}/.worktrees/wt-abc123`, + ); + expect(mockedSpawnAgent).toHaveBeenCalledWith(expect.objectContaining({ + agentId: 'ag-test0001', + agentConfig: AGENT_CONFIG, + projectRoot: PROJECT_ROOT, + })); + + expect(result).toEqual({ + worktree: { + id: 'wt-abc123', + name: 'feature-x', + branch: 'ppg/feature-x', + path: `${PROJECT_ROOT}/.worktrees/wt-abc123`, + tmuxWindow: `${SESSION_NAME}:1`, + }, + agents: [{ + id: 'ag-test0001', + tmuxTarget: `${SESSION_NAME}:1`, + sessionId: 'session-uuid-1', + }], + }); + }); + + test('given no name, should use worktree ID as name', async () => { + await performSpawn({ prompt: 'Do the task' }); + + expect(mockedCreateWorktree).toHaveBeenCalledWith(PROJECT_ROOT, 'wt-abc123', { + branch: 'ppg/wt-abc123', + base: 'main', + }); + }); + + test('given --base option, should use it instead of current branch', async () => { + await performSpawn({ prompt: 'Do the task', base: 'develop' }); + + expect(mockedCreateWorktree).toHaveBeenCalledWith(PROJECT_ROOT, 'wt-abc123', { + branch: 'ppg/wt-abc123', + base: 'develop', + }); + expect(vi.mocked(getCurrentBranch)).not.toHaveBeenCalled(); + }); + + test('given --open, should call openTerminalWindow', async () => { + vi.mocked(openTerminalWindow).mockResolvedValue(undefined); + + await performSpawn({ prompt: 'Do the task', open: true }); + + expect(vi.mocked(openTerminalWindow)).toHaveBeenCalledWith( + SESSION_NAME, + `${SESSION_NAME}:1`, + 'wt-abc123', + ); + }); + + test('given count=2 with --split, should split pane for second agent', async () => { + let agentCallCount = 0; + vi.mocked(genAgentId).mockImplementation(() => { + agentCallCount++; + return `ag-test000${agentCallCount}`; + }); + mockedSplitPane.mockResolvedValue({ paneId: '%2', target: `${SESSION_NAME}:1.1` }); + mockedSpawnAgent + .mockResolvedValueOnce({ + id: 'ag-test0001', name: 'claude', agentType: 'claude', status: 'running', + tmuxTarget: `${SESSION_NAME}:1`, prompt: 'Do the task', startedAt: '2026-01-01T00:00:00.000Z', + sessionId: 'session-uuid-1', + }) + .mockResolvedValueOnce({ + id: 'ag-test0002', name: 'claude', agentType: 'claude', status: 'running', + tmuxTarget: `${SESSION_NAME}:1.1`, prompt: 'Do the task', startedAt: '2026-01-01T00:00:00.000Z', + sessionId: 'session-uuid-1', + }); + + const result = await performSpawn({ prompt: 'Do the task', count: 2, split: true }); + + expect(mockedSplitPane).toHaveBeenCalledWith(`${SESSION_NAME}:1`, 'horizontal', expect.any(String)); + expect(result.agents).toHaveLength(2); + }); + + test('given new worktree, should register skeleton in manifest before spawning agents', async () => { + // Capture the updater functions to inspect what each one does in isolation + const updaters: Array<(m: Manifest) => Manifest | Promise> = []; + mockedUpdateManifest.mockImplementation(async (_root, updater) => { + updaters.push(updater); + const m = makeManifestState(); + return updater(m); + }); + + await performSpawn({ prompt: 'Do the task', name: 'feature-x' }); + + // First updater should register the skeleton worktree (no agents yet) + const skeletonResult = await updaters[0](makeManifestState()); + expect(skeletonResult.worktrees['wt-abc123']).toBeDefined(); + expect(Object.keys(skeletonResult.worktrees['wt-abc123'].agents)).toHaveLength(0); + + // Second updater should add agent to an existing worktree entry + const withWorktree = makeManifestState(); + withWorktree.worktrees['wt-abc123'] = structuredClone(skeletonResult.worktrees['wt-abc123']); + const agentResult = await updaters[1](withWorktree); + expect(agentResult.worktrees['wt-abc123'].agents['ag-test0001']).toBeDefined(); + }); + }); + + describe('existing branch (--branch)', () => { + test('given --branch, should adopt worktree from existing branch', async () => { + const result = await performSpawn({ prompt: 'Do the task', branch: 'ppg/fix-bug' }); + + expect(vi.mocked(adoptWorktree)).toHaveBeenCalledWith(PROJECT_ROOT, 'wt-abc123', 'ppg/fix-bug'); + expect(mockedCreateWorktree).not.toHaveBeenCalled(); + expect(result.worktree.branch).toBe('ppg/fix-bug'); + }); + }); + + describe('existing worktree (--worktree)', () => { + test('given --worktree, should add agent to existing worktree', async () => { + const existingWt: WorktreeEntry = { + id: 'wt-exist1', + name: 'existing', + path: `${PROJECT_ROOT}/.worktrees/wt-exist1`, + branch: 'ppg/existing', + baseBranch: 'main', + status: 'active', + tmuxWindow: `${SESSION_NAME}:2`, + agents: {}, + createdAt: '2026-01-01T00:00:00.000Z', + }; + mockedResolveWorktree.mockReturnValue(existingWt); + + mockedCreateWindow.mockResolvedValue(`${SESSION_NAME}:3`); + mockedSpawnAgent.mockResolvedValue({ + id: 'ag-test0001', name: 'claude', agentType: 'claude', status: 'running', + tmuxTarget: `${SESSION_NAME}:3`, prompt: 'Do the task', startedAt: '2026-01-01T00:00:00.000Z', + sessionId: 'session-uuid-1', + }); + + const result = await performSpawn({ prompt: 'Do the task', worktree: 'wt-exist1' }); + + expect(mockedCreateWorktree).not.toHaveBeenCalled(); + expect(vi.mocked(adoptWorktree)).not.toHaveBeenCalled(); + expect(result.worktree.id).toBe('wt-exist1'); + expect(result.agents).toHaveLength(1); + }); + + test('given --worktree with no tmux window, should lazily create one and persist before spawning', async () => { + const existingWt: WorktreeEntry = { + id: 'wt-exist1', + name: 'existing', + path: `${PROJECT_ROOT}/.worktrees/wt-exist1`, + branch: 'ppg/existing', + baseBranch: 'main', + status: 'active', + tmuxWindow: '', + agents: {}, + createdAt: '2026-01-01T00:00:00.000Z', + }; + mockedResolveWorktree.mockReturnValue(existingWt); + mockedCreateWindow.mockResolvedValue(`${SESSION_NAME}:5`); + + // Capture updater functions to verify ordering + const updaters: Array<(m: Manifest) => Manifest | Promise> = []; + mockedUpdateManifest.mockImplementation(async (_root, updater) => { + updaters.push(updater); + const m = makeManifestState(); + m.worktrees['wt-exist1'] = structuredClone(existingWt); + return updater(m); + }); + + mockedSpawnAgent.mockResolvedValue({ + id: 'ag-test0001', name: 'claude', agentType: 'claude', status: 'running', + tmuxTarget: `${SESSION_NAME}:5`, prompt: 'Do the task', startedAt: '2026-01-01T00:00:00.000Z', + sessionId: 'session-uuid-1', + }); + + const result = await performSpawn({ prompt: 'Do the task', worktree: 'wt-exist1' }); + + expect(mockedEnsureSession).toHaveBeenCalledWith(SESSION_NAME); + expect(mockedCreateWindow).toHaveBeenCalledWith(SESSION_NAME, 'existing', existingWt.path); + expect(result.worktree.tmuxWindow).toBe(`${SESSION_NAME}:5`); + + // First updater should persist the tmux window (before agent spawn) + const windowInput = makeManifestState(); + windowInput.worktrees['wt-exist1'] = structuredClone(existingWt); + const windowResult = await updaters[0](windowInput); + expect(windowResult.worktrees['wt-exist1'].tmuxWindow).toBe(`${SESSION_NAME}:5`); + expect(Object.keys(windowResult.worktrees['wt-exist1'].agents)).toHaveLength(0); + }); + + test('given spawn failure on existing worktree with lazy window, should persist tmux window but no agents', async () => { + const existingWt: WorktreeEntry = { + id: 'wt-exist1', + name: 'existing', + path: `${PROJECT_ROOT}/.worktrees/wt-exist1`, + branch: 'ppg/existing', + baseBranch: 'main', + status: 'active', + tmuxWindow: '', + agents: {}, + createdAt: '2026-01-01T00:00:00.000Z', + }; + mockedResolveWorktree.mockReturnValue(existingWt); + mockedCreateWindow.mockResolvedValue(`${SESSION_NAME}:7`); + + let persistedTmuxWindow = ''; + mockedUpdateManifest.mockImplementation(async (_root, updater) => { + const m = makeManifestState(); + m.worktrees['wt-exist1'] = structuredClone(existingWt); + const result = await updater(m); + persistedTmuxWindow = result.worktrees['wt-exist1']?.tmuxWindow ?? ''; + return result; + }); + + mockedSpawnAgent.mockRejectedValueOnce(new Error('spawn failed')); + + await expect(performSpawn({ prompt: 'Do work', worktree: 'wt-exist1' })) + .rejects.toThrow('spawn failed'); + + // tmux window should have been persisted before the spawn failure + expect(persistedTmuxWindow).toBe(`${SESSION_NAME}:7`); + expect(mockedUpdateManifest).toHaveBeenCalledTimes(1); + }); + + test('given unknown worktree ref, should throw WorktreeNotFoundError', async () => { + mockedResolveWorktree.mockReturnValue(undefined); + + await expect(performSpawn({ prompt: 'Do the task', worktree: 'nonexistent' })) + .rejects.toThrow('Worktree not found: nonexistent'); + }); + }); + + describe('prompt resolution', () => { + test('given --branch and --worktree, should throw INVALID_ARGS', async () => { + await expect(performSpawn({ prompt: 'Do the task', branch: 'foo', worktree: 'bar' })) + .rejects.toThrow('--branch and --worktree are mutually exclusive'); + }); + + test('given --branch and --base, should throw INVALID_ARGS', async () => { + await expect(performSpawn({ prompt: 'Do the task', branch: 'foo', base: 'bar' })) + .rejects.toThrow('--branch and --base are mutually exclusive'); + }); + + test('given no prompt/promptFile/template, should throw INVALID_ARGS', async () => { + await expect(performSpawn({})) + .rejects.toThrow('One of --prompt, --prompt-file, or --template is required'); + }); + + test('given --prompt-file, should read prompt from file', async () => { + mockedFs.readFile.mockResolvedValue('File prompt content'); + + await performSpawn({ promptFile: '/tmp/prompt.md' }); + + expect(mockedFs.readFile).toHaveBeenCalledWith('/tmp/prompt.md', 'utf-8'); + }); + + test('given --template, should load template by name', async () => { + mockedLoadTemplate.mockResolvedValue('Template content with {{BRANCH}}'); + + await performSpawn({ template: 'my-template' }); + + expect(mockedLoadTemplate).toHaveBeenCalledWith(PROJECT_ROOT, 'my-template'); + }); + }); +}); diff --git a/src/core/operations/spawn.ts b/src/core/operations/spawn.ts new file mode 100644 index 0000000..c4a3225 --- /dev/null +++ b/src/core/operations/spawn.ts @@ -0,0 +1,453 @@ +import fs from 'node:fs/promises'; +import { loadConfig, resolveAgentConfig } from '../config.js'; +import { readManifest, updateManifest, resolveWorktree } from '../manifest.js'; +import { getRepoRoot, getCurrentBranch, createWorktree, adoptWorktree } from '../worktree.js'; +import { setupWorktreeEnv } from '../env.js'; +import { loadTemplate, renderTemplate, type TemplateContext } from '../template.js'; +import { spawnAgent } from '../agent.js'; +import * as tmux from '../tmux.js'; +import { openTerminalWindow } from '../terminal.js'; +import { worktreeId as genWorktreeId, agentId as genAgentId, sessionId as genSessionId } from '../../lib/id.js'; +import { manifestPath } from '../../lib/paths.js'; +import { PpgError, NotInitializedError, WorktreeNotFoundError } from '../../lib/errors.js'; +import { normalizeName } from '../../lib/name.js'; +import { parseVars } from '../../lib/vars.js'; +import type { WorktreeEntry, AgentEntry } from '../../types/manifest.js'; +import type { Config, AgentConfig } from '../../types/config.js'; + +export interface PerformSpawnOptions { + name?: string; + agent?: string; + prompt?: string; + promptFile?: string; + template?: string; + var?: string[]; + base?: string; + branch?: string; + worktree?: string; + count?: number; + split?: boolean; + open?: boolean; +} + +export interface SpawnResult { + worktree: { + id: string; + name: string; + branch: string; + path: string; + tmuxWindow: string; + }; + agents: Array<{ + id: string; + tmuxTarget: string; + sessionId?: string; + }>; +} + +export async function performSpawn(options: PerformSpawnOptions): Promise { + const projectRoot = await getRepoRoot(); + const config = await loadConfig(projectRoot); + + // Verify initialized (lightweight file check instead of full manifest read) + try { + await fs.access(manifestPath(projectRoot)); + } catch { + throw new NotInitializedError(projectRoot); + } + + const agentConfig = resolveAgentConfig(config, options.agent); + const count = options.count ?? 1; + + // Validate vars early — before any side effects (worktree/tmux creation) + const userVars = parseVars(options.var ?? []); + + // Resolve prompt + const promptText = await resolvePrompt(options, projectRoot); + + // Validate conflicting flags + if (options.branch && options.worktree) { + throw new PpgError('--branch and --worktree are mutually exclusive', 'INVALID_ARGS'); + } + if (options.branch && options.base) { + throw new PpgError('--branch and --base are mutually exclusive (--base is for new branches)', 'INVALID_ARGS'); + } + + if (options.worktree) { + return spawnIntoExistingWorktree( + projectRoot, + agentConfig, + options.worktree, + promptText, + count, + options, + userVars, + ); + } else if (options.branch) { + return spawnOnExistingBranch( + projectRoot, + config, + agentConfig, + options.branch, + promptText, + count, + options, + userVars, + ); + } else { + return spawnNewWorktree( + projectRoot, + config, + agentConfig, + promptText, + count, + options, + userVars, + ); + } +} + +async function resolvePrompt(options: PerformSpawnOptions, projectRoot: string): Promise { + if (options.prompt) return options.prompt; + + if (options.promptFile) { + return fs.readFile(options.promptFile, 'utf-8'); + } + + if (options.template) { + return loadTemplate(projectRoot, options.template); + } + + throw new PpgError('One of --prompt, --prompt-file, or --template is required', 'INVALID_ARGS'); +} + +interface SpawnBatchOptions { + projectRoot: string; + agentConfig: AgentConfig; + promptText: string; + userVars: Record; + count: number; + split: boolean; + worktreePath: string; + branch: string; + taskName: string; + sessionName: string; + windowTarget: string; + windowNamePrefix: string; + reuseWindowForFirstAgent: boolean; + onAgentSpawned?: (agent: AgentEntry) => Promise; +} + +interface SpawnTargetOptions { + index: number; + split: boolean; + reuseWindowForFirstAgent: boolean; + windowTarget: string; + sessionName: string; + windowNamePrefix: string; + worktreePath: string; +} + +async function resolveAgentTarget(opts: SpawnTargetOptions): Promise { + if (opts.index === 0 && opts.reuseWindowForFirstAgent) { + return opts.windowTarget; + } + if (opts.split) { + const direction = opts.index % 2 === 1 ? 'horizontal' : 'vertical'; + const pane = await tmux.splitPane(opts.windowTarget, direction, opts.worktreePath); + return pane.target; + } + return tmux.createWindow(opts.sessionName, `${opts.windowNamePrefix}-${opts.index}`, opts.worktreePath); +} + +async function spawnAgentBatch(opts: SpawnBatchOptions): Promise { + const agents: AgentEntry[] = []; + for (let i = 0; i < opts.count; i++) { + const aId = genAgentId(); + const target = await resolveAgentTarget({ + index: i, + split: opts.split, + reuseWindowForFirstAgent: opts.reuseWindowForFirstAgent, + windowTarget: opts.windowTarget, + sessionName: opts.sessionName, + windowNamePrefix: opts.windowNamePrefix, + worktreePath: opts.worktreePath, + }); + + const ctx: TemplateContext = { + WORKTREE_PATH: opts.worktreePath, + BRANCH: opts.branch, + AGENT_ID: aId, + PROJECT_ROOT: opts.projectRoot, + TASK_NAME: opts.taskName, + PROMPT: opts.promptText, + ...opts.userVars, + }; + + const agentEntry = await spawnAgent({ + agentId: aId, + agentConfig: opts.agentConfig, + prompt: renderTemplate(opts.promptText, ctx), + worktreePath: opts.worktreePath, + tmuxTarget: target, + projectRoot: opts.projectRoot, + branch: opts.branch, + sessionId: genSessionId(), + }); + + agents.push(agentEntry); + if (opts.onAgentSpawned) { + await opts.onAgentSpawned(agentEntry); + } + } + + return agents; +} + +function toSpawnResult( + worktree: { id: string; name: string; branch: string; path: string; tmuxWindow: string }, + agents: AgentEntry[], +): SpawnResult { + return { + worktree, + agents: agents.map((a) => ({ + id: a.id, + tmuxTarget: a.tmuxTarget, + sessionId: a.sessionId, + })), + }; +} + +async function spawnNewWorktree( + projectRoot: string, + config: Config, + agentConfig: AgentConfig, + promptText: string, + count: number, + options: PerformSpawnOptions, + userVars: Record, +): Promise { + const baseBranch = options.base ?? await getCurrentBranch(projectRoot); + const wtId = genWorktreeId(); + const name = options.name ? normalizeName(options.name, wtId) : wtId; + const branchName = `ppg/${name}`; + + // Create git worktree + const wtPath = await createWorktree(projectRoot, wtId, { + branch: branchName, + base: baseBranch, + }); + + // Setup env + await setupWorktreeEnv(projectRoot, wtPath, config); + + // Ensure tmux session (manifest is the source of truth for session name) + const manifest = await readManifest(projectRoot); + const sessionName = manifest.sessionName; + await tmux.ensureSession(sessionName); + + // Create tmux window + const windowTarget = await tmux.createWindow(sessionName, name, wtPath); + + // Register skeleton worktree in manifest before spawning agents + // so partial failures leave a record for cleanup + const worktreeEntry: WorktreeEntry = { + id: wtId, + name, + path: wtPath, + branch: branchName, + baseBranch, + status: 'active', + tmuxWindow: windowTarget, + agents: {}, + createdAt: new Date().toISOString(), + }; + + await updateManifest(projectRoot, (m) => { + m.worktrees[wtId] = worktreeEntry; + return m; + }); + + // Spawn agents — one tmux window per agent (default), or split panes (--split) + const agents = await spawnAgentBatch({ + projectRoot, + agentConfig, + promptText, + userVars, + count, + split: options.split === true, + worktreePath: wtPath, + branch: branchName, + taskName: name, + sessionName, + windowTarget, + windowNamePrefix: name, + reuseWindowForFirstAgent: true, + onAgentSpawned: async (agentEntry) => { + await updateManifest(projectRoot, (m) => { + if (m.worktrees[wtId]) { + m.worktrees[wtId].agents[agentEntry.id] = agentEntry; + } + return m; + }); + }, + }); + + // Only open Terminal window when explicitly requested via --open (fire-and-forget) + if (options.open === true) { + openTerminalWindow(sessionName, windowTarget, name).catch(() => {}); + } + + return toSpawnResult( + { id: wtId, name, branch: branchName, path: wtPath, tmuxWindow: windowTarget }, + agents, + ); +} + +async function spawnOnExistingBranch( + projectRoot: string, + config: Config, + agentConfig: AgentConfig, + branch: string, + promptText: string, + count: number, + options: PerformSpawnOptions, + userVars: Record, +): Promise { + const baseBranch = await getCurrentBranch(projectRoot); + const wtId = genWorktreeId(); + + // Derive name from branch if --name not provided (strip ppg/ prefix if present) + const derivedName = branch.startsWith('ppg/') ? branch.slice(4) : branch; + const name = options.name ? normalizeName(options.name, wtId) : normalizeName(derivedName, wtId); + + // Create git worktree from existing branch (no -b flag) + const wtPath = await adoptWorktree(projectRoot, wtId, branch); + + // Setup env + await setupWorktreeEnv(projectRoot, wtPath, config); + + // Ensure tmux session + const manifest = await readManifest(projectRoot); + const sessionName = manifest.sessionName; + await tmux.ensureSession(sessionName); + + // Create tmux window + const windowTarget = await tmux.createWindow(sessionName, name, wtPath); + + // Register worktree in manifest + const worktreeEntry: WorktreeEntry = { + id: wtId, + name, + path: wtPath, + branch, + baseBranch, + status: 'active', + tmuxWindow: windowTarget, + agents: {}, + createdAt: new Date().toISOString(), + }; + + await updateManifest(projectRoot, (m) => { + m.worktrees[wtId] = worktreeEntry; + return m; + }); + + const agents = await spawnAgentBatch({ + projectRoot, + agentConfig, + promptText, + userVars, + count, + split: options.split === true, + worktreePath: wtPath, + branch, + taskName: name, + sessionName, + windowTarget, + windowNamePrefix: name, + reuseWindowForFirstAgent: true, + onAgentSpawned: async (agentEntry) => { + await updateManifest(projectRoot, (m) => { + if (m.worktrees[wtId]) { + m.worktrees[wtId].agents[agentEntry.id] = agentEntry; + } + return m; + }); + }, + }); + + if (options.open === true) { + openTerminalWindow(sessionName, windowTarget, name).catch(() => {}); + } + + return toSpawnResult( + { id: wtId, name, branch, path: wtPath, tmuxWindow: windowTarget }, + agents, + ); +} + +async function spawnIntoExistingWorktree( + projectRoot: string, + agentConfig: AgentConfig, + worktreeRef: string, + promptText: string, + count: number, + options: PerformSpawnOptions, + userVars: Record, +): Promise { + const manifest = await readManifest(projectRoot); + const wt = resolveWorktree(manifest, worktreeRef); + + if (!wt) throw new WorktreeNotFoundError(worktreeRef); + + // Lazily create tmux window if worktree has none (standalone worktree) + let windowTarget = wt.tmuxWindow; + if (!windowTarget) { + await tmux.ensureSession(manifest.sessionName); + windowTarget = await tmux.createWindow(manifest.sessionName, wt.name, wt.path); + + // Persist tmux window before spawning agents so partial failures are tracked. + await updateManifest(projectRoot, (m) => { + const mWt = m.worktrees[wt.id]; + if (!mWt) return m; + mWt.tmuxWindow = windowTarget; + return m; + }); + } + + const agents = await spawnAgentBatch({ + projectRoot, + agentConfig, + promptText, + userVars, + count, + split: options.split === true, + worktreePath: wt.path, + branch: wt.branch, + taskName: wt.name, + sessionName: manifest.sessionName, + windowTarget, + windowNamePrefix: `${wt.name}-agent`, + // For existing worktrees, only reuse the primary pane when explicitly splitting. + reuseWindowForFirstAgent: options.split === true, + onAgentSpawned: async (agentEntry) => { + await updateManifest(projectRoot, (m) => { + const mWt = m.worktrees[wt.id]; + if (!mWt) return m; + mWt.agents[agentEntry.id] = agentEntry; + return m; + }); + }, + }); + + // Only open Terminal window when explicitly requested via --open (fire-and-forget) + if (options.open === true) { + openTerminalWindow(manifest.sessionName, windowTarget, wt.name).catch(() => {}); + } + + return toSpawnResult( + { id: wt.id, name: wt.name, branch: wt.branch, path: wt.path, tmuxWindow: windowTarget }, + agents, + ); +} diff --git a/src/core/pr.ts b/src/core/pr.ts index 2849401..1411c43 100644 --- a/src/core/pr.ts +++ b/src/core/pr.ts @@ -1,8 +1,106 @@ import { execa } from 'execa'; import { execaEnv } from '../lib/env.js'; +import { PpgError, GhNotFoundError } from '../lib/errors.js'; +import { updateManifest } from './manifest.js'; +import type { WorktreeEntry } from '../types/manifest.js'; export type PrState = 'MERGED' | 'OPEN' | 'CLOSED' | 'UNKNOWN'; +// GitHub PR body limit is 65536 chars; leave room for truncation notice +const MAX_BODY_LENGTH = 60_000; + +/** Build PR body from agent prompts, with truncation. */ +export async function buildBodyFromResults(agents: { id: string; prompt: string }[]): Promise { + if (agents.length === 0) return ''; + const sections = agents.map((a) => `## Agent: ${a.id}\n\n${a.prompt}`); + return truncateBody(sections.join('\n\n---\n\n')); +} + +/** Truncate body to stay within GitHub's PR body size limit. */ +export function truncateBody(body: string): string { + if (body.length <= MAX_BODY_LENGTH) return body; + return body.slice(0, MAX_BODY_LENGTH) + '\n\n---\n\n*[Truncated — full results available in `.ppg/results/`]*'; +} + +export interface CreatePrOptions { + title?: string; + body?: string; + draft?: boolean; +} + +export interface CreatePrResult { + worktreeId: string; + branch: string; + baseBranch: string; + prUrl: string; +} + +/** Push branch and create a GitHub PR for a worktree. Stores prUrl in manifest. */ +export async function createWorktreePr( + projectRoot: string, + wt: WorktreeEntry, + options: CreatePrOptions = {}, +): Promise { + // Verify gh is available + try { + await execa('gh', ['--version'], execaEnv); + } catch { + throw new GhNotFoundError(); + } + + // Push the worktree branch + try { + await execa('git', ['push', '-u', 'origin', wt.branch], { ...execaEnv, cwd: projectRoot }); + } catch (err) { + throw new PpgError( + `Failed to push branch ${wt.branch}: ${err instanceof Error ? err.message : err}`, + 'INVALID_ARGS', + ); + } + + // Build PR title and body + const prTitle = options.title ?? wt.name; + const prBody = options.body ?? await buildBodyFromResults(Object.values(wt.agents)); + + // Build gh pr create args + const ghArgs = [ + 'pr', 'create', + '--head', wt.branch, + '--base', wt.baseBranch, + '--title', prTitle, + '--body', prBody, + ]; + if (options.draft) { + ghArgs.push('--draft'); + } + + let prUrl: string; + try { + const result = await execa('gh', ghArgs, { ...execaEnv, cwd: projectRoot }); + prUrl = result.stdout.trim(); + } catch (err) { + throw new PpgError( + `Failed to create PR: ${err instanceof Error ? err.message : err}`, + 'INVALID_ARGS', + ); + } + + // Store PR URL in manifest + await updateManifest(projectRoot, (m) => { + if (m.worktrees[wt.id]) { + m.worktrees[wt.id].prUrl = prUrl; + } + return m; + }); + + return { + worktreeId: wt.id, + branch: wt.branch, + baseBranch: wt.baseBranch, + prUrl, + }; +} + /** * Check the GitHub PR state for a given branch. * Uses `gh pr view` to query the PR associated with the branch. diff --git a/src/core/prompt.test.ts b/src/core/prompt.test.ts new file mode 100644 index 0000000..4857088 --- /dev/null +++ b/src/core/prompt.test.ts @@ -0,0 +1,127 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +let tmpDir: string; +let globalDir: string; + +vi.mock('../lib/paths.js', async () => { + const actual = await vi.importActual('../lib/paths.js'); + return { + ...actual, + globalPromptsDir: () => path.join(globalDir, 'prompts'), + }; +}); + +// Dynamic import after mock setup +const { listPromptsWithSource, enrichEntryMetadata } = await import('./prompt.js'); + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ppg-prompt-')); + globalDir = path.join(tmpDir, 'global'); + await fs.mkdir(path.join(globalDir, 'prompts'), { recursive: true }); +}); + +afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +describe('listPromptsWithSource', () => { + test('given no directories, should return empty array', async () => { + const entries = await listPromptsWithSource(tmpDir); + expect(entries).toEqual([]); + }); + + test('given local prompts, should return with local source', async () => { + const localDir = path.join(tmpDir, '.ppg', 'prompts'); + await fs.mkdir(localDir, { recursive: true }); + await fs.writeFile(path.join(localDir, 'review.md'), '# Review\n'); + await fs.writeFile(path.join(localDir, 'fix.md'), '# Fix\n'); + + const entries = await listPromptsWithSource(tmpDir); + expect(entries).toEqual([ + { name: 'fix', source: 'local' }, + { name: 'review', source: 'local' }, + ]); + }); + + test('given global prompts, should return with global source', async () => { + await fs.writeFile(path.join(globalDir, 'prompts', 'shared.md'), '# Shared\n'); + + const entries = await listPromptsWithSource(tmpDir); + expect(entries).toEqual([{ name: 'shared', source: 'global' }]); + }); + + test('given same name in local and global, should prefer local', async () => { + const localDir = path.join(tmpDir, '.ppg', 'prompts'); + await fs.mkdir(localDir, { recursive: true }); + await fs.writeFile(path.join(localDir, 'shared.md'), '# Local\n'); + await fs.writeFile(path.join(globalDir, 'prompts', 'shared.md'), '# Global\n'); + + const entries = await listPromptsWithSource(tmpDir); + expect(entries).toEqual([{ name: 'shared', source: 'local' }]); + }); + + test('given non-.md files, should ignore them', async () => { + const localDir = path.join(tmpDir, '.ppg', 'prompts'); + await fs.mkdir(localDir, { recursive: true }); + await fs.writeFile(path.join(localDir, 'valid.md'), '# Valid\n'); + await fs.writeFile(path.join(localDir, 'readme.txt'), 'not a prompt'); + + const entries = await listPromptsWithSource(tmpDir); + expect(entries).toEqual([{ name: 'valid', source: 'local' }]); + }); +}); + +describe('enrichEntryMetadata', () => { + test('given markdown file, should extract description from first line', async () => { + const dir = path.join(tmpDir, 'md'); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, 'task.md'), '# My Task\n\nBody here\n'); + + const result = await enrichEntryMetadata('task', 'local', dir, dir); + expect(result.description).toBe('My Task'); + }); + + test('given template variables, should extract unique vars', async () => { + const dir = path.join(tmpDir, 'md'); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile( + path.join(dir, 'task.md'), + '{{NAME}} and {{NAME}} and {{OTHER}}\n', + ); + + const result = await enrichEntryMetadata('task', 'local', dir, dir); + expect(result.variables).toEqual(['NAME', 'OTHER']); + }); + + test('given no variables, should return empty array', async () => { + const dir = path.join(tmpDir, 'md'); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, 'plain.md'), '# Plain text\n'); + + const result = await enrichEntryMetadata('plain', 'local', dir, dir); + expect(result.variables).toEqual([]); + }); + + test('given global source, should read from global dir', async () => { + const localDir = path.join(tmpDir, 'local'); + const gDir = path.join(tmpDir, 'gbl'); + await fs.mkdir(gDir, { recursive: true }); + await fs.writeFile(path.join(gDir, 'task.md'), '# Global Task\n'); + + const result = await enrichEntryMetadata('task', 'global', localDir, gDir); + expect(result.description).toBe('Global Task'); + expect(result.source).toBe('global'); + }); + + test('given empty first line, should skip to first non-empty line', async () => { + const dir = path.join(tmpDir, 'md'); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, 'task.md'), '\n\n# Actual Title\n'); + + const result = await enrichEntryMetadata('task', 'local', dir, dir); + expect(result.description).toBe('Actual Title'); + }); +}); diff --git a/src/core/prompt.ts b/src/core/prompt.ts new file mode 100644 index 0000000..8371fb1 --- /dev/null +++ b/src/core/prompt.ts @@ -0,0 +1,63 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { promptsDir, globalPromptsDir } from '../lib/paths.js'; + +export interface PromptEntry { + name: string; + source: 'local' | 'global'; +} + +export interface EnrichedEntry { + name: string; + description: string; + variables: string[]; + source: 'local' | 'global'; + [key: string]: unknown; +} + +async function readMdNames(dir: string): Promise { + try { + const files = await fs.readdir(dir); + return files.filter((f) => f.endsWith('.md')).map((f) => f.replace(/\.md$/, '')).sort(); + } catch { + return []; + } +} + +export async function listPromptsWithSource(projectRoot: string): Promise { + const localNames = await readMdNames(promptsDir(projectRoot)); + const globalNames = await readMdNames(globalPromptsDir()); + + const seen = new Set(); + const result: PromptEntry[] = []; + + for (const name of localNames) { + seen.add(name); + result.push({ name, source: 'local' }); + } + + for (const name of globalNames) { + if (!seen.has(name)) { + result.push({ name, source: 'global' }); + } + } + + return result; +} + +export async function enrichEntryMetadata( + name: string, + source: 'local' | 'global', + localDir: string, + globalDir: string, +): Promise { + const dir = source === 'local' ? localDir : globalDir; + const filePath = path.join(dir, `${name}.md`); + const content = await fs.readFile(filePath, 'utf-8'); + const firstLine = content.split('\n').find((l) => l.trim().length > 0) ?? ''; + const description = firstLine.replace(/^#+\s*/, '').trim(); + const vars = [...content.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]); + const uniqueVars = [...new Set(vars)]; + + return { name, description, variables: uniqueVars, source }; +} diff --git a/src/core/serve.test.ts b/src/core/serve.test.ts new file mode 100644 index 0000000..1cee2ce --- /dev/null +++ b/src/core/serve.test.ts @@ -0,0 +1,103 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +vi.mock('../lib/paths.js', async (importOriginal) => { + const actual = await importOriginal() as Record; + return { + ...actual, + servePidPath: vi.fn((root: string) => path.join(root, '.ppg', 'serve.pid')), + serveJsonPath: vi.fn((root: string) => path.join(root, '.ppg', 'serve.json')), + serveLogPath: vi.fn((root: string) => path.join(root, '.ppg', 'logs', 'serve.log')), + logsDir: vi.fn((root: string) => path.join(root, '.ppg', 'logs')), + }; +}); + +const { getServePid, isServeRunning, getServeInfo } = await import('./serve.js'); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('getServePid', () => { + test('given no PID file, should return null', async () => { + vi.spyOn(fs, 'readFile').mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + + const pid = await getServePid('/fake/project'); + expect(pid).toBeNull(); + }); + + test('given PID file with valid alive PID, should return the PID', async () => { + vi.spyOn(fs, 'readFile').mockResolvedValue(String(process.pid)); + + const pid = await getServePid('/fake/project'); + expect(pid).toBe(process.pid); + }); + + test('given PID file with dead process, should clean up and return null', async () => { + vi.spyOn(fs, 'readFile').mockResolvedValue('999999999'); + vi.spyOn(fs, 'unlink').mockResolvedValue(undefined); + + const pid = await getServePid('/fake/project'); + expect(pid).toBeNull(); + expect(fs.unlink).toHaveBeenCalledWith('/fake/project/.ppg/serve.pid'); + }); + + test('given PID file with non-numeric content, should clean up and return null', async () => { + vi.spyOn(fs, 'readFile').mockResolvedValue('not-a-number'); + vi.spyOn(fs, 'unlink').mockResolvedValue(undefined); + + const pid = await getServePid('/fake/project'); + expect(pid).toBeNull(); + expect(fs.unlink).toHaveBeenCalledWith('/fake/project/.ppg/serve.pid'); + }); +}); + +describe('isServeRunning', () => { + test('given no PID file, should return false', async () => { + vi.spyOn(fs, 'readFile').mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + + const running = await isServeRunning('/fake/project'); + expect(running).toBe(false); + }); + + test('given valid alive PID, should return true', async () => { + vi.spyOn(fs, 'readFile').mockResolvedValue(String(process.pid)); + + const running = await isServeRunning('/fake/project'); + expect(running).toBe(true); + }); +}); + +describe('getServeInfo', () => { + test('given no serve.json, should return null', async () => { + vi.spyOn(fs, 'readFile').mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + + const info = await getServeInfo('/fake/project'); + expect(info).toBeNull(); + }); + + test('given valid serve.json, should return parsed info', async () => { + const serveInfo = { + pid: 12345, + port: 3000, + host: 'localhost', + startedAt: '2026-01-01T00:00:00.000Z', + }; + vi.spyOn(fs, 'readFile').mockResolvedValue(JSON.stringify(serveInfo)); + + const info = await getServeInfo('/fake/project'); + expect(info).toEqual(serveInfo); + }); + + test('given malformed JSON in serve.json, should return null', async () => { + vi.spyOn(fs, 'readFile').mockResolvedValue('not valid json {{{'); + + const info = await getServeInfo('/fake/project'); + expect(info).toBeNull(); + }); +}); diff --git a/src/core/serve.ts b/src/core/serve.ts new file mode 100644 index 0000000..e167096 --- /dev/null +++ b/src/core/serve.ts @@ -0,0 +1,130 @@ +import fs from 'node:fs/promises'; +import { createReadStream } from 'node:fs'; +import path from 'node:path'; +import readline from 'node:readline'; +import { serveJsonPath, serveLogPath, servePidPath, logsDir } from '../lib/paths.js'; + +export interface ServeInfo { + pid: number; + port: number; + host: string; + startedAt: string; +} + +export async function runServeDaemon(projectRoot: string, port: number, host: string): Promise { + const pidPath = servePidPath(projectRoot); + const jsonPath = serveJsonPath(projectRoot); + + // Write PID file + await fs.mkdir(path.dirname(pidPath), { recursive: true }); + await fs.writeFile(pidPath, String(process.pid), 'utf-8'); + + // Write serve.json with connection info + const info: ServeInfo = { + pid: process.pid, + port, + host, + startedAt: new Date().toISOString(), + }; + await fs.writeFile(jsonPath, JSON.stringify(info, null, 2), 'utf-8'); + + // Ensure logs directory + await fs.mkdir(logsDir(projectRoot), { recursive: true }); + + await logServe(projectRoot, `Serve daemon starting (PID: ${process.pid})`); + await logServe(projectRoot, `Listening on ${host}:${port}`); + + // Clean shutdown on SIGTERM/SIGINT + const cleanup = async () => { + await logServe(projectRoot, 'Serve daemon stopping'); + try { await fs.unlink(pidPath); } catch { /* already gone */ } + try { await fs.unlink(jsonPath); } catch { /* already gone */ } + process.exit(0); + }; + process.on('SIGTERM', cleanup); + process.on('SIGINT', cleanup); + + // Placeholder: the actual HTTP server will be implemented by issue #63. + // For now, keep the daemon alive so the lifecycle works end-to-end. + await logServe(projectRoot, 'Serve daemon ready (waiting for server implementation)'); + + // Keep alive + await new Promise(() => {}); +} + +export async function isServeRunning(projectRoot: string): Promise { + return (await getServePid(projectRoot)) !== null; +} + +export async function getServePid(projectRoot: string): Promise { + const pidPath = servePidPath(projectRoot); + let raw: string; + try { + raw = await fs.readFile(pidPath, 'utf-8'); + } catch { + return null; + } + const pid = parseInt(raw, 10); + if (isNaN(pid)) { + await cleanupPidFile(pidPath); + return null; + } + try { + process.kill(pid, 0); + return pid; + } catch { + await cleanupPidFile(pidPath); + return null; + } +} + +export async function getServeInfo(projectRoot: string): Promise { + const jsonPath = serveJsonPath(projectRoot); + try { + const raw = await fs.readFile(jsonPath, 'utf-8'); + return JSON.parse(raw) as ServeInfo; + } catch { + return null; + } +} + +async function cleanupPidFile(pidPath: string): Promise { + try { await fs.unlink(pidPath); } catch { /* already gone */ } +} + +export async function logServe(projectRoot: string, message: string): Promise { + const logPath = serveLogPath(projectRoot); + const timestamp = new Date().toISOString(); + const line = `[${timestamp}] ${message}\n`; + + process.stdout.write(line); + + try { + await fs.appendFile(logPath, line, 'utf-8'); + } catch { + await fs.mkdir(logsDir(projectRoot), { recursive: true }); + await fs.appendFile(logPath, line, 'utf-8'); + } +} + +export async function readServeLog(projectRoot: string, lines: number = 20): Promise { + const logPath = serveLogPath(projectRoot); + try { + await fs.access(logPath); + } catch { + return []; + } + const result: string[] = []; + const rl = readline.createInterface({ + input: createReadStream(logPath, { encoding: 'utf-8' }), + crlfDelay: Infinity, + }); + for await (const line of rl) { + if (!line) continue; + result.push(line); + if (result.length > lines) { + result.shift(); + } + } + return result; +} diff --git a/src/core/spawn.ts b/src/core/spawn.ts new file mode 100644 index 0000000..16680b7 --- /dev/null +++ b/src/core/spawn.ts @@ -0,0 +1,227 @@ +import { loadConfig, resolveAgentConfig } from './config.js'; +import { requireManifest, updateManifest } from './manifest.js'; +import { getCurrentBranch, createWorktree } from './worktree.js'; +import { setupWorktreeEnv } from './env.js'; +import { loadTemplate, renderTemplate, type TemplateContext } from './template.js'; +import { spawnAgent } from './agent.js'; +import * as tmux from './tmux.js'; +import { worktreeId as genWorktreeId, agentId as genAgentId, sessionId as genSessionId } from '../lib/id.js'; +import { PpgError } from '../lib/errors.js'; +import { normalizeName } from '../lib/name.js'; +import type { WorktreeEntry, AgentEntry } from '../types/manifest.js'; +import type { AgentConfig } from '../types/config.js'; + +// ─── Agent Batch Spawning ──────────────────────────────────────────────────── + +export interface SpawnBatchOptions { + projectRoot: string; + agentConfig: AgentConfig; + promptText: string; + userVars: Record; + count: number; + split: boolean; + worktreePath: string; + branch: string; + taskName: string; + sessionName: string; + windowTarget: string; + windowNamePrefix: string; + reuseWindowForFirstAgent: boolean; + onAgentSpawned?: (agent: AgentEntry) => Promise; +} + +interface SpawnTargetOptions { + index: number; + split: boolean; + reuseWindowForFirstAgent: boolean; + windowTarget: string; + sessionName: string; + windowNamePrefix: string; + worktreePath: string; +} + +async function resolveAgentTarget(opts: SpawnTargetOptions): Promise { + if (opts.index === 0 && opts.reuseWindowForFirstAgent) { + return opts.windowTarget; + } + if (opts.split) { + const direction = opts.index % 2 === 1 ? 'horizontal' : 'vertical'; + const pane = await tmux.splitPane(opts.windowTarget, direction, opts.worktreePath); + return pane.target; + } + return tmux.createWindow(opts.sessionName, `${opts.windowNamePrefix}-${opts.index}`, opts.worktreePath); +} + +export async function spawnAgentBatch(opts: SpawnBatchOptions): Promise { + const agents: AgentEntry[] = []; + for (let i = 0; i < opts.count; i++) { + const aId = genAgentId(); + const target = await resolveAgentTarget({ + index: i, + split: opts.split, + reuseWindowForFirstAgent: opts.reuseWindowForFirstAgent, + windowTarget: opts.windowTarget, + sessionName: opts.sessionName, + windowNamePrefix: opts.windowNamePrefix, + worktreePath: opts.worktreePath, + }); + + const ctx: TemplateContext = { + WORKTREE_PATH: opts.worktreePath, + BRANCH: opts.branch, + AGENT_ID: aId, + PROJECT_ROOT: opts.projectRoot, + TASK_NAME: opts.taskName, + PROMPT: opts.promptText, + ...opts.userVars, + }; + + const agentEntry = await spawnAgent({ + agentId: aId, + agentConfig: opts.agentConfig, + prompt: renderTemplate(opts.promptText, ctx), + worktreePath: opts.worktreePath, + tmuxTarget: target, + projectRoot: opts.projectRoot, + branch: opts.branch, + sessionId: genSessionId(), + }); + + agents.push(agentEntry); + if (opts.onAgentSpawned) { + await opts.onAgentSpawned(agentEntry); + } + } + + return agents; +} + +// ─── New Worktree Spawn ────────────────────────────────────────────────────── + +export interface SpawnNewWorktreeOptions { + projectRoot: string; + name: string; + promptText: string; + userVars?: Record; + agentName?: string; + baseBranch?: string; + count?: number; + split?: boolean; +} + +export interface SpawnNewWorktreeResult { + worktreeId: string; + name: string; + branch: string; + path: string; + tmuxWindow: string; + agents: AgentEntry[]; +} + +export async function spawnNewWorktree( + opts: SpawnNewWorktreeOptions, +): Promise { + const { projectRoot } = opts; + const config = await loadConfig(projectRoot); + const agentConfig = resolveAgentConfig(config, opts.agentName); + const count = opts.count ?? 1; + const userVars = opts.userVars ?? {}; + const manifest = await requireManifest(projectRoot); + const sessionName = manifest.sessionName; + + const baseBranch = opts.baseBranch ?? await getCurrentBranch(projectRoot); + const wtId = genWorktreeId(); + const name = normalizeName(opts.name, wtId); + const branchName = `ppg/${name}`; + + // Create git worktree + const wtPath = await createWorktree(projectRoot, wtId, { + branch: branchName, + base: baseBranch, + }); + + // Setup env (copy .env, symlink node_modules) + await setupWorktreeEnv(projectRoot, wtPath, config); + + // Ensure tmux session (manifest is the source of truth for session name) + await tmux.ensureSession(sessionName); + + // Create tmux window + const windowTarget = await tmux.createWindow(sessionName, name, wtPath); + + // Register skeleton worktree in manifest before spawning agents + // so partial failures leave a record for cleanup + const worktreeEntry: WorktreeEntry = { + id: wtId, + name, + path: wtPath, + branch: branchName, + baseBranch, + status: 'active', + tmuxWindow: windowTarget, + agents: {}, + createdAt: new Date().toISOString(), + }; + + await updateManifest(projectRoot, (m) => { + m.worktrees[wtId] = worktreeEntry; + return m; + }); + + // Spawn agents + const agents = await spawnAgentBatch({ + projectRoot, + agentConfig, + promptText: opts.promptText, + userVars, + count, + split: opts.split === true, + worktreePath: wtPath, + branch: branchName, + taskName: name, + sessionName, + windowTarget, + windowNamePrefix: name, + reuseWindowForFirstAgent: true, + onAgentSpawned: async (agentEntry) => { + await updateManifest(projectRoot, (m) => { + if (m.worktrees[wtId]) { + m.worktrees[wtId].agents[agentEntry.id] = agentEntry; + } + return m; + }); + }, + }); + + return { + worktreeId: wtId, + name, + branch: branchName, + path: wtPath, + tmuxWindow: windowTarget, + agents, + }; +} + +// ─── Prompt Resolution ─────────────────────────────────────────────────────── + +export interface PromptSource { + prompt?: string; + template?: string; +} + +export async function resolvePromptText( + source: PromptSource, + projectRoot: string, +): Promise { + if (source.prompt) return source.prompt; + + if (source.template) { + return loadTemplate(projectRoot, source.template); + } + + throw new PpgError( + 'Either "prompt" or "template" is required', + 'INVALID_ARGS', + ); +} diff --git a/src/core/tls.ts b/src/core/tls.ts new file mode 100644 index 0000000..e405d00 --- /dev/null +++ b/src/core/tls.ts @@ -0,0 +1,100 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { generateKeyPairSync, X509Certificate } from 'node:crypto'; +import { execa } from 'execa'; +import { ppgDir } from '../lib/paths.js'; +import { execaEnv } from '../lib/env.js'; + +export interface TlsCredentials { + key: string; + cert: string; + fingerprint: string; +} + +export async function ensureTlsCerts(projectRoot: string): Promise { + const certsDir = path.join(ppgDir(projectRoot), 'certs'); + const keyPath = path.join(certsDir, 'server.key'); + const certPath = path.join(certsDir, 'server.crt'); + + try { + const [key, cert] = await Promise.all([ + fs.readFile(keyPath, 'utf-8'), + fs.readFile(certPath, 'utf-8'), + ]); + const fingerprint = getCertFingerprint(cert); + return { key, cert, fingerprint }; + } catch (error) { + if (!hasErrorCode(error, 'ENOENT')) { + throw error; + } + } + + await fs.mkdir(certsDir, { recursive: true }); + + const { privateKey } = generateKeyPairSync('ec', { + namedCurve: 'prime256v1', + }); + + const keyPem = privateKey.export({ type: 'sec1', format: 'pem' }) as string; + const certPem = await generateSelfSignedCert(keyPem, buildSubjectAltName()); + + await Promise.all([ + fs.writeFile(keyPath, keyPem, { mode: 0o600 }), + fs.writeFile(certPath, certPem), + ]); + + const fingerprint = getCertFingerprint(certPem); + return { key: keyPem, cert: certPem, fingerprint }; +} + +async function generateSelfSignedCert(keyPem: string, subjectAltName: string): Promise { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ppg-tls-')); + const tmpKey = path.join(tmpDir, 'server.key'); + const tmpCert = path.join(tmpDir, 'server.crt'); + + try { + await fs.writeFile(tmpKey, keyPem, { mode: 0o600 }); + await execa('openssl', [ + 'req', '-new', '-x509', + '-key', tmpKey, + '-out', tmpCert, + '-days', '365', + '-subj', '/CN=ppg-server', + '-addext', subjectAltName, + ], { ...execaEnv, stdio: 'pipe' }); + return await fs.readFile(tmpCert, 'utf-8'); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +} + +function buildSubjectAltName(): string { + const sanEntries = new Set([ + 'DNS:localhost', + 'IP:127.0.0.1', + 'IP:::1', + ]); + + for (const addresses of Object.values(os.networkInterfaces())) { + for (const iface of addresses ?? []) { + if (iface.internal) continue; + if (iface.family !== 'IPv4' && iface.family !== 'IPv6') continue; + sanEntries.add(`IP:${iface.address}`); + } + } + + return `subjectAltName=${Array.from(sanEntries).join(',')}`; +} + +function hasErrorCode(error: unknown, code: string): boolean { + return typeof error === 'object' + && error !== null + && 'code' in error + && (error as { code?: unknown }).code === code; +} + +export function getCertFingerprint(certPem: string): string { + const x509 = new X509Certificate(certPem); + return x509.fingerprint256; +} diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 0af4143..4cbf1dd 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -86,6 +86,36 @@ export class GhNotFoundError extends PpgError { } } +export class PromptNotFoundError extends PpgError { + constructor(agentId: string) { + super( + `Could not read original prompt for agent ${agentId}. Use --prompt to provide one.`, + 'PROMPT_NOT_FOUND', + ); + this.name = 'PromptNotFoundError'; + } +} + +export class DuplicateTokenError extends PpgError { + constructor(label: string) { + super( + `Token with label "${label}" already exists`, + 'DUPLICATE_TOKEN', + ); + this.name = 'DuplicateTokenError'; + } +} + +export class AuthCorruptError extends PpgError { + constructor(filePath: string) { + super( + `Auth data is corrupt or unreadable: ${filePath}`, + 'AUTH_CORRUPT', + ); + this.name = 'AuthCorruptError'; + } +} + export class UnmergedWorkError extends PpgError { constructor(names: string[]) { const list = names.map((n) => ` ${n}`).join('\n'); diff --git a/src/lib/paths.test.ts b/src/lib/paths.test.ts index 57a62b0..61ecf15 100644 --- a/src/lib/paths.test.ts +++ b/src/lib/paths.test.ts @@ -14,12 +14,20 @@ import { promptFile, agentPromptsDir, agentPromptFile, + serveDir, + tlsDir, + tlsCaKeyPath, + tlsCaCertPath, + tlsServerKeyPath, + tlsServerCertPath, worktreeBaseDir, worktreePath, globalPpgDir, globalPromptsDir, globalTemplatesDir, globalSwarmsDir, + serveStatePath, + servePidPath, } from './paths.js'; const ROOT = '/tmp/project'; @@ -79,6 +87,30 @@ describe('paths', () => { ); }); + test('serveDir', () => { + expect(serveDir(ROOT)).toBe(path.join(ROOT, '.ppg', 'serve')); + }); + + test('tlsDir', () => { + expect(tlsDir(ROOT)).toBe(path.join(ROOT, '.ppg', 'serve', 'tls')); + }); + + test('tlsCaKeyPath', () => { + expect(tlsCaKeyPath(ROOT)).toBe(path.join(ROOT, '.ppg', 'serve', 'tls', 'ca-key.pem')); + }); + + test('tlsCaCertPath', () => { + expect(tlsCaCertPath(ROOT)).toBe(path.join(ROOT, '.ppg', 'serve', 'tls', 'ca-cert.pem')); + }); + + test('tlsServerKeyPath', () => { + expect(tlsServerKeyPath(ROOT)).toBe(path.join(ROOT, '.ppg', 'serve', 'tls', 'server-key.pem')); + }); + + test('tlsServerCertPath', () => { + expect(tlsServerCertPath(ROOT)).toBe(path.join(ROOT, '.ppg', 'serve', 'tls', 'server-cert.pem')); + }); + test('worktreeBaseDir', () => { expect(worktreeBaseDir(ROOT)).toBe(path.join(ROOT, '.worktrees')); }); @@ -104,4 +136,12 @@ describe('paths', () => { test('globalSwarmsDir', () => { expect(globalSwarmsDir()).toBe(path.join(os.homedir(), '.ppg', 'swarms')); }); + + test('serveStatePath', () => { + expect(serveStatePath(ROOT)).toBe(path.join(ROOT, '.ppg', 'serve.json')); + }); + + test('servePidPath', () => { + expect(servePidPath(ROOT)).toBe(path.join(ROOT, '.ppg', 'serve.pid')); + }); }); diff --git a/src/lib/paths.ts b/src/lib/paths.ts index d456f5f..618a170 100644 --- a/src/lib/paths.ts +++ b/src/lib/paths.ts @@ -79,6 +79,42 @@ export function cronPidPath(projectRoot: string): string { return path.join(ppgDir(projectRoot), 'cron.pid'); } +export function serveDir(projectRoot: string): string { + return path.join(ppgDir(projectRoot), 'serve'); +} + +export function tlsDir(projectRoot: string): string { + return path.join(serveDir(projectRoot), 'tls'); +} + +export function tlsCaKeyPath(projectRoot: string): string { + return path.join(tlsDir(projectRoot), 'ca-key.pem'); +} + +export function tlsCaCertPath(projectRoot: string): string { + return path.join(tlsDir(projectRoot), 'ca-cert.pem'); +} + +export function tlsServerKeyPath(projectRoot: string): string { + return path.join(tlsDir(projectRoot), 'server-key.pem'); +} + +export function tlsServerCertPath(projectRoot: string): string { + return path.join(tlsDir(projectRoot), 'server-cert.pem'); +} + +export function servePidPath(projectRoot: string): string { + return path.join(ppgDir(projectRoot), 'serve.pid'); +} + +export function serveLogPath(projectRoot: string): string { + return path.join(logsDir(projectRoot), 'serve.log'); +} + +export function serveJsonPath(projectRoot: string): string { + return path.join(ppgDir(projectRoot), 'serve.json'); +} + export function worktreeBaseDir(projectRoot: string): string { return path.join(projectRoot, '.worktrees'); } @@ -86,3 +122,11 @@ export function worktreeBaseDir(projectRoot: string): string { export function worktreePath(projectRoot: string, id: string): string { return path.join(worktreeBaseDir(projectRoot), id); } + +export function serveStatePath(projectRoot: string): string { + return path.join(ppgDir(projectRoot), 'serve.json'); +} + +export function authPath(projectRoot: string): string { + return path.join(serveDir(projectRoot), 'auth.json'); +} diff --git a/src/server/auth.test.ts b/src/server/auth.test.ts new file mode 100644 index 0000000..325dfc7 --- /dev/null +++ b/src/server/auth.test.ts @@ -0,0 +1,544 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { DuplicateTokenError } from '../lib/errors.js'; +import { authPath } from '../lib/paths.js'; +import { + type AuthStore, + type AuthenticatedRequest, + type RateLimiter, + createAuthHook, + createAuthStore, + createRateLimiter, + generateToken, + hashToken, +} from './auth.js'; + +// --- Test Helpers --- + +function makeReply() { + let sentStatus: number | null = null; + let sentBody: unknown = null; + return { + reply: { + code(status: number) { + sentStatus = status; + return { + send(body: unknown) { + sentBody = body; + }, + }; + }, + }, + status: () => sentStatus, + body: () => sentBody, + }; +} + +function makeRequest(overrides: Partial<{ headers: Record; ip: string }> = {}): AuthenticatedRequest { + return { + headers: {}, + ip: '127.0.0.1', + ...overrides, + }; +} + +// --- Token Generation --- + +describe('generateToken', () => { + test('returns string with tk_ prefix', () => { + const token = generateToken(); + expect(token.startsWith('tk_')).toBe(true); + }); + + test('body is valid base64url (32 chars from 24 bytes)', () => { + const token = generateToken(); + const body = token.slice(3); + expect(body).toMatch(/^[A-Za-z0-9_-]+$/); + expect(body.length).toBe(32); + }); + + test('generates unique tokens', () => { + const tokens = new Set(Array.from({ length: 50 }, () => generateToken())); + expect(tokens.size).toBe(50); + }); +}); + +// --- Token Hashing --- + +describe('hashToken', () => { + test('returns a 64-char hex SHA-256 digest', () => { + const hash = hashToken('tk_test'); + expect(hash).toMatch(/^[a-f0-9]{64}$/); + }); + + test('same input produces same hash', () => { + const a = hashToken('tk_abc123'); + const b = hashToken('tk_abc123'); + expect(a).toBe(b); + }); + + test('different inputs produce different hashes', () => { + const a = hashToken('tk_abc'); + const b = hashToken('tk_xyz'); + expect(a).not.toBe(b); + }); +}); + +// --- Rate Limiter --- + +describe('createRateLimiter', () => { + let clock: number; + let limiter: RateLimiter; + + beforeEach(() => { + clock = 1000000; + limiter = createRateLimiter(() => clock); + }); + + test('allows first request from new IP', () => { + expect(limiter.check('1.2.3.4')).toBe(true); + }); + + test('allows up to 5 failures', () => { + const ip = '1.2.3.4'; + for (let i = 0; i < 4; i++) { + limiter.record(ip); + expect(limiter.check(ip)).toBe(true); + } + limiter.record(ip); + expect(limiter.check(ip)).toBe(false); + }); + + test('blocks after 5 failures within window', () => { + const ip = '10.0.0.1'; + for (let i = 0; i < 5; i++) limiter.record(ip); + expect(limiter.check(ip)).toBe(false); + }); + + test('resets after window expires', () => { + const ip = '10.0.0.2'; + for (let i = 0; i < 5; i++) limiter.record(ip); + expect(limiter.check(ip)).toBe(false); + + clock += 5 * 60 * 1000; // advance 5 minutes + expect(limiter.check(ip)).toBe(true); + }); + + test('starts new window after expiry', () => { + const ip = '10.0.0.3'; + for (let i = 0; i < 5; i++) limiter.record(ip); + expect(limiter.check(ip)).toBe(false); + + clock += 5 * 60 * 1000; + limiter.record(ip); // new window, failure count = 1 + expect(limiter.check(ip)).toBe(true); + }); + + test('tracks IPs independently', () => { + for (let i = 0; i < 5; i++) limiter.record('a'); + expect(limiter.check('a')).toBe(false); + expect(limiter.check('b')).toBe(true); + }); + + test('reset clears failure count for IP', () => { + const ip = '10.0.0.4'; + for (let i = 0; i < 5; i++) limiter.record(ip); + expect(limiter.check(ip)).toBe(false); + limiter.reset(ip); + expect(limiter.check(ip)).toBe(true); + }); + + test('prunes stale entries when map exceeds max size', () => { + // Fill with 10001 stale entries + for (let i = 0; i <= 10_000; i++) { + limiter.record(`stale-${i}`); + } + // Advance past the window so all are stale + clock += 5 * 60 * 1000; + // One more record triggers prune + limiter.record('fresh'); + // The fresh one should be tracked; stale ones should allow through + expect(limiter.check('stale-0')).toBe(true); + expect(limiter.check('fresh')).toBe(true); + }); + + test('evicts oldest entries when max size is exceeded without stale IPs', () => { + for (let i = 0; i <= 10_000; i++) { + const ip = `ip-${i}`; + for (let j = 0; j < 5; j++) limiter.record(ip); + } + + // Oldest entry should be evicted once capacity is exceeded. + expect(limiter.check('ip-0')).toBe(true); + expect(limiter.check('ip-10')).toBe(false); + expect(limiter.check('ip-10000')).toBe(false); + }); +}); + +// --- Auth Store --- + +describe('createAuthStore', () => { + let tmpDir: string; + let store: AuthStore; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ppg-auth-')); + store = await createAuthStore(tmpDir); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + describe('addToken', () => { + test('returns a token with tk_ prefix', async () => { + const token = await store.addToken('iphone'); + expect(token.startsWith('tk_')).toBe(true); + }); + + test('stores hash, not plaintext', async () => { + const token = await store.addToken('iphone'); + const raw = await fs.readFile(authPath(tmpDir), 'utf-8'); + const data = JSON.parse(raw); + expect(data.tokens[0].hash).toBe(hashToken(token)); + expect(raw).not.toContain(token); + }); + + test('rejects duplicate labels with DuplicateTokenError', async () => { + await store.addToken('ipad'); + await expect(store.addToken('ipad')).rejects.toThrow(DuplicateTokenError); + await expect(store.addToken('ipad')).rejects.toThrow( + 'Token with label "ipad" already exists', + ); + }); + + test('supports multiple tokens with different labels', async () => { + await store.addToken('iphone'); + await store.addToken('ipad'); + await store.addToken('macbook'); + const tokens = await store.listTokens(); + expect(tokens.length).toBe(3); + }); + + test('sets createdAt and null lastUsedAt', async () => { + await store.addToken('device'); + const tokens = await store.listTokens(); + expect(tokens[0].createdAt).toBeTruthy(); + expect(tokens[0].lastUsedAt).toBeNull(); + }); + }); + + describe('validateToken', () => { + test('validates correct token', async () => { + const token = await store.addToken('iphone'); + const entry = await store.validateToken(token); + expect(entry).not.toBeNull(); + expect(entry!.label).toBe('iphone'); + }); + + test('rejects invalid token', async () => { + await store.addToken('iphone'); + const entry = await store.validateToken('tk_wrong'); + expect(entry).toBeNull(); + }); + + test('rejects empty token', async () => { + await store.addToken('iphone'); + const entry = await store.validateToken(''); + expect(entry).toBeNull(); + }); + + test('updates lastUsedAt on successful validation', async () => { + const token = await store.addToken('iphone'); + const before = await store.listTokens(); + expect(before[0].lastUsedAt).toBeNull(); + + await store.validateToken(token); + const after = await store.listTokens(); + expect(after[0].lastUsedAt).not.toBeNull(); + }); + + test('returns defensive copy of token entry', async () => { + const token = await store.addToken('iphone'); + const entry = await store.validateToken(token); + expect(entry).not.toBeNull(); + entry!.label = 'tampered'; + + const tokens = await store.listTokens(); + expect(tokens[0].label).toBe('iphone'); + }); + + test('uses timing-safe comparison', async () => { + const spy = vi.spyOn(crypto, 'timingSafeEqual'); + const token = await store.addToken('iphone'); + await store.validateToken(token); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); + + test('validates correct token among multiple', async () => { + const token1 = await store.addToken('iphone'); + await store.addToken('ipad'); + const token3 = await store.addToken('macbook'); + + const entry1 = await store.validateToken(token1); + expect(entry1!.label).toBe('iphone'); + + const entry3 = await store.validateToken(token3); + expect(entry3!.label).toBe('macbook'); + }); + }); + + describe('revokeToken', () => { + test('removes token by label', async () => { + await store.addToken('iphone'); + const removed = await store.revokeToken('iphone'); + expect(removed).toBe(true); + const tokens = await store.listTokens(); + expect(tokens.length).toBe(0); + }); + + test('returns false for unknown label', async () => { + const removed = await store.revokeToken('nonexistent'); + expect(removed).toBe(false); + }); + + test('revoked token no longer validates', async () => { + const token = await store.addToken('iphone'); + await store.revokeToken('iphone'); + const entry = await store.validateToken(token); + expect(entry).toBeNull(); + }); + + test('does not affect other tokens', async () => { + const token1 = await store.addToken('iphone'); + await store.addToken('ipad'); + await store.revokeToken('ipad'); + + const entry = await store.validateToken(token1); + expect(entry!.label).toBe('iphone'); + const tokens = await store.listTokens(); + expect(tokens.length).toBe(1); + }); + }); + + describe('listTokens', () => { + test('returns empty array when no tokens', async () => { + const tokens = await store.listTokens(); + expect(tokens).toEqual([]); + }); + + test('returns all token entries', async () => { + await store.addToken('a'); + await store.addToken('b'); + const tokens = await store.listTokens(); + expect(tokens.map((t) => t.label)).toEqual(['a', 'b']); + }); + + test('returns defensive copies', async () => { + await store.addToken('a'); + const tokens = await store.listTokens(); + tokens[0].label = 'tampered'; + + const fresh = await store.listTokens(); + expect(fresh[0].label).toBe('a'); + }); + }); + + describe('persistence', () => { + test('auth.json has 0o600 permissions', async () => { + await store.addToken('iphone'); + const stat = await fs.stat(authPath(tmpDir)); + const mode = stat.mode & 0o777; + expect(mode).toBe(0o600); + }); + + test('survives store recreation', async () => { + const token = await store.addToken('iphone'); + const store2 = await createAuthStore(tmpDir); + const entry = await store2.validateToken(token); + expect(entry!.label).toBe('iphone'); + }); + + test('throws AuthCorruptError on corrupt auth.json', async () => { + await store.addToken('iphone'); + await fs.writeFile(authPath(tmpDir), '{{{invalid json'); + const store2 = await createAuthStore(tmpDir); + await expect(store2.listTokens()).rejects.toThrow('Auth data is corrupt'); + }); + + test('throws AuthCorruptError on invalid auth.json structure', async () => { + await fs.mkdir(path.dirname(authPath(tmpDir)), { recursive: true }); + await fs.writeFile( + authPath(tmpDir), + JSON.stringify({ tokens: [{ label: 'incomplete' }] }), + ); + const store2 = await createAuthStore(tmpDir); + await expect(store2.listTokens()).rejects.toThrow('Auth data is corrupt'); + }); + }); +}); + +// --- Fastify Auth Hook --- + +describe('createAuthHook', () => { + let store: AuthStore; + let limiter: RateLimiter; + let hook: ReturnType; + let tmpDir: string; + let token: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ppg-auth-hook-')); + store = await createAuthStore(tmpDir); + limiter = createRateLimiter(); + hook = createAuthHook({ store, rateLimiter: limiter }); + token = await store.addToken('test-device'); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + test('passes with valid Bearer token', async () => { + const { reply, status } = makeReply(); + await hook( + makeRequest({ headers: { authorization: `Bearer ${token}` } }), + reply, + ); + expect(status()).toBeNull(); + }); + + test('attaches tokenEntry to request on success', async () => { + const { reply } = makeReply(); + const request = makeRequest({ headers: { authorization: `Bearer ${token}` } }); + await hook(request, reply); + expect(request.tokenEntry).toBeDefined(); + expect(request.tokenEntry!.label).toBe('test-device'); + }); + + test('rejects missing Authorization header', async () => { + const { reply, status, body } = makeReply(); + await hook(makeRequest(), reply); + expect(status()).toBe(401); + expect(body()).toEqual({ error: 'Missing or malformed Authorization header' }); + }); + + test('rejects non-Bearer scheme', async () => { + const { reply, status } = makeReply(); + await hook( + makeRequest({ headers: { authorization: `Basic ${token}` } }), + reply, + ); + expect(status()).toBe(401); + }); + + test('rejects invalid token', async () => { + const { reply, status, body } = makeReply(); + await hook( + makeRequest({ headers: { authorization: 'Bearer tk_invalid' } }), + reply, + ); + expect(status()).toBe(401); + expect(body()).toEqual({ error: 'Invalid token' }); + }); + + test('returns 429 when rate limited', async () => { + for (let i = 0; i < 5; i++) { + limiter.record('127.0.0.1'); + } + const { reply, status, body } = makeReply(); + await hook( + makeRequest({ headers: { authorization: `Bearer ${token}` } }), + reply, + ); + expect(status()).toBe(429); + expect(body()).toEqual({ error: 'Too many failed attempts. Try again later.' }); + }); + + test('records failure on missing header', async () => { + for (let i = 0; i < 5; i++) { + await hook(makeRequest(), makeReply().reply); + } + const { reply, status } = makeReply(); + await hook(makeRequest(), reply); + expect(status()).toBe(429); + }); + + test('records failure on invalid token', async () => { + for (let i = 0; i < 5; i++) { + await hook( + makeRequest({ headers: { authorization: 'Bearer tk_bad' } }), + makeReply().reply, + ); + } + const { reply, status } = makeReply(); + await hook( + makeRequest({ headers: { authorization: `Bearer ${token}` } }), + reply, + ); + expect(status()).toBe(429); + }); + + test('resets rate limit on successful auth', async () => { + for (let i = 0; i < 4; i++) { + await hook( + makeRequest({ headers: { authorization: 'Bearer tk_bad' } }), + makeReply().reply, + ); + } + // Successful auth should reset + await hook( + makeRequest({ headers: { authorization: `Bearer ${token}` } }), + makeReply().reply, + ); + // Should not be rate limited now + const { reply, status } = makeReply(); + await hook( + makeRequest({ headers: { authorization: 'Bearer tk_bad' } }), + reply, + ); + expect(status()).toBe(401); // not 429 + }); + + test('rate limits per IP independently', async () => { + for (let i = 0; i < 5; i++) { + await hook( + makeRequest({ ip: '10.0.0.1', headers: { authorization: 'Bearer tk_bad' } }), + makeReply().reply, + ); + } + // Different IP should still work + const { reply, status } = makeReply(); + await hook( + makeRequest({ ip: '10.0.0.2', headers: { authorization: `Bearer ${token}` } }), + reply, + ); + expect(status()).toBeNull(); + }); + + test('returns 503 when token validation throws', async () => { + const brokenStore: AuthStore = { + addToken: async () => 'tk_unused', + validateToken: async () => { + throw new Error('disk error'); + }, + revokeToken: async () => false, + listTokens: async () => [], + }; + const brokenHook = createAuthHook({ + store: brokenStore, + rateLimiter: createRateLimiter(), + }); + const { reply, status, body } = makeReply(); + await brokenHook( + makeRequest({ headers: { authorization: 'Bearer tk_any' } }), + reply, + ); + expect(status()).toBe(503); + expect(body()).toEqual({ error: 'Authentication unavailable' }); + }); +}); diff --git a/src/server/auth.ts b/src/server/auth.ts new file mode 100644 index 0000000..0dc47cc --- /dev/null +++ b/src/server/auth.ts @@ -0,0 +1,286 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import { getWriteFileAtomic } from '../lib/cjs-compat.js'; +import { AuthCorruptError, DuplicateTokenError } from '../lib/errors.js'; +import { authPath, serveDir } from '../lib/paths.js'; + +// --- Types --- + +export interface TokenEntry { + label: string; + hash: string; + createdAt: string; + lastUsedAt: string | null; +} + +export interface AuthData { + tokens: TokenEntry[]; +} + +type UnknownRecord = Record; + +interface RateLimitEntry { + failures: number; + windowStart: number; +} + +// --- Constants --- + +const RATE_LIMIT_MAX_FAILURES = 5; +const RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000; // 5 minutes +const RATE_LIMIT_MAX_ENTRIES = 10_000; + +// --- Token Generation & Hashing --- + +export function generateToken(): string { + const bytes = crypto.randomBytes(24); + return `tk_${bytes.toString('base64url')}`; +} + +export function hashToken(token: string): string { + return crypto.createHash('sha256').update(token).digest('hex'); +} + +// --- Rate Limiter --- + +export interface RateLimiter { + check(ip: string): boolean; + record(ip: string): void; + reset(ip: string): void; +} + +export function createRateLimiter( + now: () => number = Date.now, +): RateLimiter { + const entries = new Map(); + + function prune(): void { + if (entries.size <= RATE_LIMIT_MAX_ENTRIES) return; + + const currentTime = now(); + for (const [ip, entry] of entries.entries()) { + if (currentTime - entry.windowStart >= RATE_LIMIT_WINDOW_MS) { + entries.delete(ip); + } + } + + while (entries.size > RATE_LIMIT_MAX_ENTRIES) { + const oldestIp = entries.keys().next().value; + if (oldestIp === undefined) break; + entries.delete(oldestIp); + } + } + + return { + check(ip: string): boolean { + const entry = entries.get(ip); + if (!entry) return true; + + if (now() - entry.windowStart >= RATE_LIMIT_WINDOW_MS) { + entries.delete(ip); + return true; + } + + return entry.failures < RATE_LIMIT_MAX_FAILURES; + }, + + record(ip: string): void { + const entry = entries.get(ip); + const currentTime = now(); + + if (!entry || currentTime - entry.windowStart >= RATE_LIMIT_WINDOW_MS) { + entries.set(ip, { failures: 1, windowStart: currentTime }); + prune(); + return; + } + + entry.failures += 1; + }, + + reset(ip: string): void { + entries.delete(ip); + }, + }; +} + +// --- Auth Store --- + +export interface AuthStore { + addToken(label: string): Promise; + validateToken(token: string): Promise; + revokeToken(label: string): Promise; + listTokens(): Promise; +} + +export async function createAuthStore(projectRoot: string): Promise { + const filePath = authPath(projectRoot); + let cache: AuthData | null = null; + + function isTokenEntry(value: unknown): value is TokenEntry { + if (!value || typeof value !== 'object') return false; + + const record = value as UnknownRecord; + return ( + typeof record.label === 'string' && + typeof record.hash === 'string' && + typeof record.createdAt === 'string' && + (record.lastUsedAt === null || typeof record.lastUsedAt === 'string') + ); + } + + function isAuthData(value: unknown): value is AuthData { + if (!value || typeof value !== 'object') return false; + const record = value as UnknownRecord; + return Array.isArray(record.tokens) && record.tokens.every(isTokenEntry); + } + + function cloneTokenEntry(entry: TokenEntry): TokenEntry { + return { ...entry }; + } + + async function readData(): Promise { + if (cache) return cache; + + let raw: string; + try { + raw = await fs.readFile(filePath, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + cache = { tokens: [] }; + return cache; + } + throw new AuthCorruptError(filePath); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new AuthCorruptError(filePath); + } + + if (!isAuthData(parsed)) { + throw new AuthCorruptError(filePath); + } + + cache = { tokens: parsed.tokens.map(cloneTokenEntry) }; + return cache; + } + + async function writeData(data: AuthData): Promise { + const dir = serveDir(projectRoot); + await fs.mkdir(dir, { recursive: true }); + const writeFileAtomic = await getWriteFileAtomic(); + await writeFileAtomic(filePath, JSON.stringify(data, null, 2), { + mode: 0o600, + }); + cache = data; + } + + return { + async addToken(label: string): Promise { + const data = await readData(); + const existing = data.tokens.find((t) => t.label === label); + if (existing) { + throw new DuplicateTokenError(label); + } + + const token = generateToken(); + const entry: TokenEntry = { + label, + hash: hashToken(token), + createdAt: new Date().toISOString(), + lastUsedAt: null, + }; + data.tokens.push(entry); + await writeData(data); + return token; + }, + + async validateToken(token: string): Promise { + if (!token) return null; + + const data = await readData(); + const incomingBuf = Buffer.from(hashToken(token), 'hex'); + + for (const entry of data.tokens) { + const storedBuf = Buffer.from(entry.hash, 'hex'); + if (incomingBuf.length === storedBuf.length && crypto.timingSafeEqual(incomingBuf, storedBuf)) { + entry.lastUsedAt = new Date().toISOString(); + await writeData(data); + return cloneTokenEntry(entry); + } + } + + return null; + }, + + async revokeToken(label: string): Promise { + const data = await readData(); + const idx = data.tokens.findIndex((t) => t.label === label); + if (idx === -1) return false; + data.tokens.splice(idx, 1); + await writeData(data); + return true; + }, + + async listTokens(): Promise { + const data = await readData(); + return data.tokens.map(cloneTokenEntry); + }, + }; +} + +// --- Fastify Auth Hook --- + +export interface AuthHookDeps { + store: AuthStore; + rateLimiter: RateLimiter; +} + +export interface AuthenticatedRequest { + headers: Record; + ip: string; + tokenEntry?: TokenEntry; +} + +export function createAuthHook(deps: AuthHookDeps) { + const { store, rateLimiter } = deps; + + return async function authHook( + request: AuthenticatedRequest, + reply: { code(statusCode: number): { send(body: unknown): void } }, + ): Promise { + const ip = request.ip; + + if (!rateLimiter.check(ip)) { + reply.code(429).send({ error: 'Too many failed attempts. Try again later.' }); + return; + } + + const authHeader = request.headers['authorization']; + if (typeof authHeader !== 'string' || !authHeader.startsWith('Bearer ')) { + rateLimiter.record(ip); + reply.code(401).send({ error: 'Missing or malformed Authorization header' }); + return; + } + + const token = authHeader.slice(7).trim(); + let entry: TokenEntry | null = null; + try { + entry = await store.validateToken(token); + } catch { + reply.code(503).send({ error: 'Authentication unavailable' }); + return; + } + + if (!entry) { + rateLimiter.record(ip); + reply.code(401).send({ error: 'Invalid token' }); + return; + } + + request.tokenEntry = entry; + rateLimiter.reset(ip); + }; +} diff --git a/src/server/error-handler.test.ts b/src/server/error-handler.test.ts new file mode 100644 index 0000000..07b42f2 --- /dev/null +++ b/src/server/error-handler.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, test, vi } from 'vitest'; +import { + AgentNotFoundError, + MergeFailedError, + ManifestLockError, + NotGitRepoError, + NotInitializedError, + PpgError, + TmuxNotFoundError, + WorktreeNotFoundError, + GhNotFoundError, + UnmergedWorkError, +} from '../lib/errors.js'; +import type { LogFn } from './error-handler.js'; +import { + buildErrorResponse, + errorHandler, + getHttpStatus, + registerErrorHandler, +} from './error-handler.js'; + +describe('getHttpStatus', () => { + test.each([ + ['INVALID_ARGS', 400], + ['NO_SESSION_ID', 400], + ['NOT_GIT_REPO', 400], + ['NOT_INITIALIZED', 409], + ['MANIFEST_LOCK', 409], + ['AGENTS_RUNNING', 409], + ['MERGE_FAILED', 409], + ['UNMERGED_WORK', 409], + ['WORKTREE_NOT_FOUND', 404], + ['AGENT_NOT_FOUND', 404], + ['PANE_NOT_FOUND', 404], + ['NO_TMUX_WINDOW', 404], + ['TARGET_NOT_FOUND', 404], + ['WAIT_TIMEOUT', 408], + ['AGENTS_FAILED', 500], + ['TMUX_NOT_FOUND', 500], + ['GH_NOT_FOUND', 500], + ['DOWNLOAD_FAILED', 502], + ['INSTALL_FAILED', 500], + ])('maps %s → %d', (code, expected) => { + expect(getHttpStatus(code)).toBe(expected); + }); + + test('returns 500 for unknown code', () => { + expect(getHttpStatus('SOME_UNKNOWN_CODE')).toBe(500); + }); +}); + +describe('buildErrorResponse', () => { + test.each<[string, PpgError, number, string]>([ + ['TmuxNotFoundError', new TmuxNotFoundError(), 500, 'TMUX_NOT_FOUND'], + ['NotGitRepoError', new NotGitRepoError('/tmp'), 400, 'NOT_GIT_REPO'], + ['NotInitializedError', new NotInitializedError('/tmp'), 409, 'NOT_INITIALIZED'], + ['ManifestLockError', new ManifestLockError(), 409, 'MANIFEST_LOCK'], + ['WorktreeNotFoundError', new WorktreeNotFoundError('wt-x'), 404, 'WORKTREE_NOT_FOUND'], + ['AgentNotFoundError', new AgentNotFoundError('ag-y'), 404, 'AGENT_NOT_FOUND'], + ['MergeFailedError', new MergeFailedError('conflict'), 409, 'MERGE_FAILED'], + ['GhNotFoundError', new GhNotFoundError(), 500, 'GH_NOT_FOUND'], + ['UnmergedWorkError', new UnmergedWorkError(['foo', 'bar']), 409, 'UNMERGED_WORK'], + ['INVALID_ARGS', new PpgError('bad args', 'INVALID_ARGS'), 400, 'INVALID_ARGS'], + ['NO_SESSION_ID', new PpgError('no session', 'NO_SESSION_ID'), 400, 'NO_SESSION_ID'], + ['AGENTS_RUNNING', new PpgError('running', 'AGENTS_RUNNING'), 409, 'AGENTS_RUNNING'], + ['WAIT_TIMEOUT', new PpgError('timeout', 'WAIT_TIMEOUT'), 408, 'WAIT_TIMEOUT'], + ['AGENTS_FAILED', new PpgError('failed', 'AGENTS_FAILED'), 500, 'AGENTS_FAILED'], + ['PANE_NOT_FOUND', new PpgError('pane gone', 'PANE_NOT_FOUND'), 404, 'PANE_NOT_FOUND'], + ['NO_TMUX_WINDOW', new PpgError('no window', 'NO_TMUX_WINDOW'), 404, 'NO_TMUX_WINDOW'], + ['TARGET_NOT_FOUND', new PpgError('no target', 'TARGET_NOT_FOUND'), 404, 'TARGET_NOT_FOUND'], + ['DOWNLOAD_FAILED', new PpgError('download err', 'DOWNLOAD_FAILED'), 502, 'DOWNLOAD_FAILED'], + ['INSTALL_FAILED', new PpgError('install err', 'INSTALL_FAILED'), 500, 'INSTALL_FAILED'], + ])('given %s, should return %d with code %s', (_label, error, expectedStatus, expectedCode) => { + const { status, body } = buildErrorResponse(error); + expect(status).toBe(expectedStatus); + expect(body.error.code).toBe(expectedCode); + expect(body.error.message).toBe(error.message); + }); + + test('given Fastify validation error, should return 400 with field details', () => { + const validationDetails = [ + { instancePath: '/name', message: 'must be string' }, + { instancePath: '/count', message: 'must be number' }, + ]; + const error = Object.assign(new Error('body/name must be string'), { + validation: validationDetails, + validationContext: 'body', + }); + + const { status, body } = buildErrorResponse(error); + + expect(status).toBe(400); + expect(body).toEqual({ + error: { + code: 'VALIDATION_ERROR', + message: 'body/name must be string', + details: validationDetails, + }, + }); + }); + + test('given unknown error, should return generic 500', () => { + const { status, body } = buildErrorResponse(new Error('something broke internally')); + + expect(status).toBe(500); + expect(body).toEqual({ + error: { + code: 'INTERNAL_ERROR', + message: 'An unexpected error occurred', + }, + }); + }); + + test('given unknown error, should not leak internal message', () => { + const { body } = buildErrorResponse(new TypeError('Cannot read property x of undefined')); + expect(body.error.message).toBe('An unexpected error occurred'); + }); + + test('given unknown error and log function, should log the original error', () => { + const log: LogFn = vi.fn(); + const error = new Error('db connection lost'); + + buildErrorResponse(error, log); + + expect(log).toHaveBeenCalledWith('Unhandled error', error); + }); + + test('given PpgError and log function, should not log', () => { + const log: LogFn = vi.fn(); + buildErrorResponse(new WorktreeNotFoundError('wt-x'), log); + expect(log).not.toHaveBeenCalled(); + }); +}); + +describe('errorHandler', () => { + const mockReply = () => ({ + status: vi.fn().mockReturnThis(), + send: vi.fn().mockReturnThis(), + }); + + const mockRequest = { log: { error: vi.fn() } } as unknown as Parameters[1]; + + test('given PpgError, should send structured response', () => { + const reply = mockReply(); + errorHandler(new AgentNotFoundError('ag-xyz'), mockRequest, reply as never); + + expect(reply.status).toHaveBeenCalledWith(404); + expect(reply.send).toHaveBeenCalledWith({ + error: { + code: 'AGENT_NOT_FOUND', + message: 'Agent not found: ag-xyz', + }, + }); + }); + + test('given unknown error, should send 500 and log via request.log', () => { + const request = { log: { error: vi.fn() } } as unknown as Parameters[1]; + const reply = mockReply(); + const error = new Error('oops'); + + errorHandler(error, request, reply as never); + + expect(reply.status).toHaveBeenCalledWith(500); + expect(reply.send).toHaveBeenCalledWith({ + error: { + code: 'INTERNAL_ERROR', + message: 'An unexpected error occurred', + }, + }); + expect(request.log.error).toHaveBeenCalledWith({ err: error }, 'Unhandled error'); + }); +}); + +describe('registerErrorHandler', () => { + test('given Fastify instance, should call setErrorHandler', () => { + const app = { setErrorHandler: vi.fn() }; + registerErrorHandler(app as never); + + expect(app.setErrorHandler).toHaveBeenCalledOnce(); + expect(app.setErrorHandler).toHaveBeenCalledWith(errorHandler); + }); +}); diff --git a/src/server/error-handler.ts b/src/server/error-handler.ts new file mode 100644 index 0000000..e872180 --- /dev/null +++ b/src/server/error-handler.ts @@ -0,0 +1,100 @@ +import type { FastifyError, FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { PpgError } from '../lib/errors.js'; + +export interface ErrorResponseBody { + error: { + code: string; + message: string; + details?: unknown; + }; +} + +export type LogFn = (message: string, error: Error) => void; + +const httpStatusByCode: Record = { + INVALID_ARGS: 400, + NO_SESSION_ID: 400, + NOT_GIT_REPO: 400, + NOT_INITIALIZED: 409, + MANIFEST_LOCK: 409, + AGENTS_RUNNING: 409, + MERGE_FAILED: 409, + UNMERGED_WORK: 409, + WORKTREE_NOT_FOUND: 404, + AGENT_NOT_FOUND: 404, + PANE_NOT_FOUND: 404, + NO_TMUX_WINDOW: 404, + TARGET_NOT_FOUND: 404, + WAIT_TIMEOUT: 408, + AGENTS_FAILED: 500, + TMUX_NOT_FOUND: 500, + GH_NOT_FOUND: 500, + DOWNLOAD_FAILED: 502, + INSTALL_FAILED: 500, +}; + +export function getHttpStatus(ppgCode: string): number { + return httpStatusByCode[ppgCode] ?? 500; +} + +function isFastifyValidationError( + error: Error | FastifyError, +): error is FastifyError & { validation: unknown[] } { + return 'validation' in error && Array.isArray((error as { validation: unknown }).validation); +} + +export function buildErrorResponse(error: Error, log?: LogFn): { + status: number; + body: ErrorResponseBody; +} { + if (error instanceof PpgError) { + return { + status: getHttpStatus(error.code), + body: { + error: { + code: error.code, + message: error.message, + }, + }, + }; + } + + if (isFastifyValidationError(error)) { + return { + status: 400, + body: { + error: { + code: 'VALIDATION_ERROR', + message: error.message, + details: error.validation, + }, + }, + }; + } + + log?.('Unhandled error', error); + + return { + status: 500, + body: { + error: { + code: 'INTERNAL_ERROR', + message: 'An unexpected error occurred', + }, + }, + }; +} + +export function errorHandler( + error: Error, + request: FastifyRequest, + reply: FastifyReply, +): void { + const log: LogFn = (message, err) => request.log.error({ err }, message); + const { status, body } = buildErrorResponse(error, log); + reply.status(status).send(body); +} + +export function registerErrorHandler(app: FastifyInstance): void { + app.setErrorHandler(errorHandler); +} diff --git a/src/server/index.test.ts b/src/server/index.test.ts new file mode 100644 index 0000000..bc10c3c --- /dev/null +++ b/src/server/index.test.ts @@ -0,0 +1,71 @@ +import { describe, test, expect, vi, afterEach } from 'vitest'; +import os from 'node:os'; +import { detectLanAddress, timingSafeTokenMatch } from './index.js'; + +describe('detectLanAddress', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('given interfaces with a non-internal IPv4 address, should return it', () => { + vi.spyOn(os, 'networkInterfaces').mockReturnValue({ + lo0: [ + { address: '127.0.0.1', family: 'IPv4', internal: true, netmask: '255.0.0.0', mac: '00:00:00:00:00:00', cidr: '127.0.0.1/8' }, + ], + en0: [ + { address: 'fe80::1', family: 'IPv6', internal: false, netmask: 'ffff:ffff:ffff:ffff::', mac: 'aa:bb:cc:dd:ee:ff', cidr: 'fe80::1/64', scopeid: 1 }, + { address: '192.168.1.42', family: 'IPv4', internal: false, netmask: '255.255.255.0', mac: 'aa:bb:cc:dd:ee:ff', cidr: '192.168.1.42/24' }, + ], + }); + expect(detectLanAddress()).toBe('192.168.1.42'); + }); + + test('given only internal interfaces, should return undefined', () => { + vi.spyOn(os, 'networkInterfaces').mockReturnValue({ + lo0: [ + { address: '127.0.0.1', family: 'IPv4', internal: true, netmask: '255.0.0.0', mac: '00:00:00:00:00:00', cidr: '127.0.0.1/8' }, + ], + }); + expect(detectLanAddress()).toBeUndefined(); + }); + + test('given empty interfaces, should return undefined', () => { + vi.spyOn(os, 'networkInterfaces').mockReturnValue({}); + expect(detectLanAddress()).toBeUndefined(); + }); +}); + +describe('timingSafeTokenMatch', () => { + const token = 'my-secret-token'; + + test('given matching bearer token, should return true', () => { + expect(timingSafeTokenMatch(`Bearer ${token}`, token)).toBe(true); + }); + + test('given wrong token, should return false', () => { + expect(timingSafeTokenMatch('Bearer wrong-token!', token)).toBe(false); + }); + + test('given missing header, should return false', () => { + expect(timingSafeTokenMatch(undefined, token)).toBe(false); + }); + + test('given empty header, should return false', () => { + expect(timingSafeTokenMatch('', token)).toBe(false); + }); + + test('given header with different length, should return false', () => { + expect(timingSafeTokenMatch('Bearer short', token)).toBe(false); + }); + + test('given header with same char length but different byte length, should return false', () => { + const unicodeHeader = `Bearer ${'é'.repeat(token.length)}`; + expect(() => timingSafeTokenMatch(unicodeHeader, token)).not.toThrow(); + expect(timingSafeTokenMatch(unicodeHeader, token)).toBe(false); + }); + + test('given raw token without Bearer prefix, should return false', () => { + const padded = token.padEnd(`Bearer ${token}`.length, 'x'); + expect(timingSafeTokenMatch(padded, token)).toBe(false); + }); +}); diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..01634f1 --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,170 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import { createRequire } from 'node:module'; +import Fastify from 'fastify'; +import cors from '@fastify/cors'; +import { agentRoutes } from './routes/agents.js'; +import { serveStatePath, servePidPath } from '../lib/paths.js'; +import { info, success } from '../lib/output.js'; + +const require = createRequire(import.meta.url); +const PACKAGE_JSON_PATHS = ['../../package.json', '../package.json'] as const; + +function getPackageVersion(): string { + for (const packageJsonPath of PACKAGE_JSON_PATHS) { + try { + const pkg = require(packageJsonPath) as { version?: unknown }; + if (typeof pkg.version === 'string') return pkg.version; + } catch { + // Fall through and try alternate path. + } + } + throw new Error('Unable to resolve package version'); +} + +const packageVersion = getPackageVersion(); + +export interface ServeOptions { + projectRoot: string; + port: number; + host: string; + token?: string; + json?: boolean; +} + +export interface ServeState { + pid: number; + port: number; + host: string; + lanAddress?: string; + startedAt: string; + version: string; +} + +export function detectLanAddress(): string | undefined { + const interfaces = os.networkInterfaces(); + for (const addrs of Object.values(interfaces)) { + if (!addrs) continue; + for (const addr of addrs) { + if (addr.family === 'IPv4' && !addr.internal) { + return addr.address; + } + } + } + return undefined; +} + +export function timingSafeTokenMatch(header: string | undefined, expected: string): boolean { + const expectedValue = `Bearer ${expected}`; + if (!header || header.length !== expectedValue.length) return false; + const headerBuffer = Buffer.from(header); + const expectedBuffer = Buffer.from(expectedValue); + if (headerBuffer.length !== expectedBuffer.length) return false; + return crypto.timingSafeEqual( + headerBuffer, + expectedBuffer, + ); +} + +async function writeStateFile(projectRoot: string, state: ServeState): Promise { + const statePath = serveStatePath(projectRoot); + await fs.writeFile(statePath, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 }); +} + +async function writePidFile(projectRoot: string, pid: number): Promise { + const pidPath = servePidPath(projectRoot); + await fs.writeFile(pidPath, String(pid) + '\n', { mode: 0o600 }); +} + +async function removeStateFiles(projectRoot: string): Promise { + for (const filePath of [serveStatePath(projectRoot), servePidPath(projectRoot)]) { + try { + await fs.unlink(filePath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + } +} + +export async function startServer(options: ServeOptions): Promise { + const { projectRoot, port, host, token, json } = options; + + const app = Fastify({ logger: false }); + + await app.register(cors, { origin: true }); + + if (token) { + app.addHook('onRequest', async (request, reply) => { + if (request.url === '/health') return; + if (!timingSafeTokenMatch(request.headers.authorization, token)) { + return reply.code(401).send({ error: 'Unauthorized' }); + } + }); + } + + // Decorate with projectRoot so routes can access it + app.decorate('projectRoot', projectRoot); + + app.get('/health', async () => { + return { + status: 'ok', + uptime: process.uptime(), + version: packageVersion, + }; + }); + + // GET /api/status — full manifest with live statuses + app.get('/api/status', async () => { + const { readManifest } = await import('../core/manifest.js'); + const manifest = await readManifest(projectRoot); + return manifest; + }); + + // Register route plugins + await app.register(agentRoutes, { prefix: '/api', projectRoot }); + const { worktreeRoutes } = await import('./routes/worktrees.js'); + await app.register(worktreeRoutes, { prefix: '/api' }); + const { configRoutes } = await import('./routes/config.js'); + await app.register(configRoutes, { projectRoot }); + const spawnRoute = (await import('./routes/spawn.js')).default; + await app.register(spawnRoute, { projectRoot }); + + const lanAddress = detectLanAddress(); + + const shutdown = async (signal: string) => { + if (!json) info(`Received ${signal}, shutting down...`); + await removeStateFiles(projectRoot); + await app.close(); + process.exit(0); + }; + + process.on('SIGTERM', () => { shutdown('SIGTERM').catch(() => process.exit(1)); }); + process.on('SIGINT', () => { shutdown('SIGINT').catch(() => process.exit(1)); }); + + await app.listen({ port, host }); + + const state: ServeState = { + pid: process.pid, + port, + host, + lanAddress, + startedAt: new Date().toISOString(), + version: packageVersion, + }; + + await writeStateFile(projectRoot, state); + await writePidFile(projectRoot, process.pid); + + if (json) { + console.log(JSON.stringify(state)); + } else { + success(`Server listening on http://${host}:${port}`); + if (lanAddress) { + info(`LAN address: http://${lanAddress}:${port}`); + } + if (token) { + info('Bearer token authentication enabled'); + } + } +} diff --git a/src/server/routes/agents.test.ts b/src/server/routes/agents.test.ts new file mode 100644 index 0000000..a4b3a0f --- /dev/null +++ b/src/server/routes/agents.test.ts @@ -0,0 +1,497 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import Fastify from 'fastify'; +import { agentRoutes } from './agents.js'; +import type { Manifest } from '../../types/manifest.js'; +import { makeAgent, makeWorktree } from '../../test-fixtures.js'; + +// ---- Mocks ---- + +function makeManifest(overrides?: Partial): Manifest { + return { + version: 1, + projectRoot: '/tmp/project', + sessionName: 'ppg', + worktrees: { 'wt-abc123': makeWorktree({ agents: { 'ag-test1234': makeAgent() } }) }, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ...overrides, + }; +} + +vi.mock('../../core/manifest.js', () => ({ + requireManifest: vi.fn(), + findAgent: vi.fn(), + updateManifest: vi.fn(), +})); + +vi.mock('../../core/agent.js', () => ({ + killAgent: vi.fn(), + checkAgentStatus: vi.fn(), + restartAgent: vi.fn(), +})); + +vi.mock('../../core/tmux.js', () => ({ + capturePane: vi.fn(), + sendKeys: vi.fn(), + sendLiteral: vi.fn(), + sendRawKeys: vi.fn(), +})); + +vi.mock('../../core/config.js', () => ({ + loadConfig: vi.fn(), + resolveAgentConfig: vi.fn(), +})); + +vi.mock('node:fs/promises', async () => { + const actual = await vi.importActual('node:fs/promises'); + return { + ...actual, + default: { + ...actual, + readFile: vi.fn(), + }, + }; +}); + +import { requireManifest, findAgent, updateManifest } from '../../core/manifest.js'; +import { killAgent, checkAgentStatus, restartAgent } from '../../core/agent.js'; +import * as tmux from '../../core/tmux.js'; +import { loadConfig, resolveAgentConfig } from '../../core/config.js'; +import fs from 'node:fs/promises'; + +const PROJECT_ROOT = '/tmp/project'; + +async function buildApp() { + const app = Fastify(); + await app.register(agentRoutes, { prefix: '/api', projectRoot: PROJECT_ROOT }); + return app; +} + +function setupAgentMocks(manifest?: Manifest) { + const m = manifest ?? makeManifest(); + vi.mocked(requireManifest).mockResolvedValue(m); + vi.mocked(findAgent).mockReturnValue({ + worktree: m.worktrees['wt-abc123'], + agent: m.worktrees['wt-abc123'].agents['ag-test1234'], + }); + return m; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// ---------- GET /api/agents/:id/logs ---------- + +describe('GET /api/agents/:id/logs', () => { + test('returns captured pane output with default 200 lines', async () => { + setupAgentMocks(); + vi.mocked(tmux.capturePane).mockResolvedValue('line1\nline2\nline3'); + + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: '/api/agents/ag-test1234/logs' }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.agentId).toBe('ag-test1234'); + expect(body.output).toBe('line1\nline2\nline3'); + expect(body.lines).toBe(200); + expect(tmux.capturePane).toHaveBeenCalledWith('ppg:1.0', 200); + }); + + test('respects custom lines parameter', async () => { + setupAgentMocks(); + vi.mocked(tmux.capturePane).mockResolvedValue('output'); + + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: '/api/agents/ag-test1234/logs?lines=50' }); + + expect(res.statusCode).toBe(200); + expect(res.json().lines).toBe(50); + expect(tmux.capturePane).toHaveBeenCalledWith('ppg:1.0', 50); + }); + + test('caps lines at 10000', async () => { + setupAgentMocks(); + vi.mocked(tmux.capturePane).mockResolvedValue('output'); + + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: '/api/agents/ag-test1234/logs?lines=999999' }); + + expect(res.statusCode).toBe(200); + expect(res.json().lines).toBe(10000); + expect(tmux.capturePane).toHaveBeenCalledWith('ppg:1.0', 10000); + }); + + test('returns 400 for invalid lines', async () => { + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: '/api/agents/ag-test1234/logs?lines=abc' }); + + expect(res.statusCode).toBe(400); + expect(res.json().code).toBe('INVALID_ARGS'); + }); + + test('returns 404 for unknown agent', async () => { + vi.mocked(requireManifest).mockResolvedValue(makeManifest()); + vi.mocked(findAgent).mockReturnValue(undefined); + + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: '/api/agents/ag-unknown/logs' }); + + expect(res.statusCode).toBe(404); + expect(res.json().code).toBe('AGENT_NOT_FOUND'); + }); + + test('returns 410 when pane no longer exists', async () => { + setupAgentMocks(); + vi.mocked(tmux.capturePane).mockRejectedValue(new Error('pane not found')); + + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: '/api/agents/ag-test1234/logs' }); + + expect(res.statusCode).toBe(410); + expect(res.json().code).toBe('PANE_NOT_FOUND'); + }); +}); + +// ---------- POST /api/agents/:id/send ---------- + +describe('POST /api/agents/:id/send', () => { + test('sends text with Enter by default', async () => { + setupAgentMocks(); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/agents/ag-test1234/send', + payload: { text: 'hello' }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().success).toBe(true); + expect(res.json().mode).toBe('with-enter'); + expect(tmux.sendKeys).toHaveBeenCalledWith('ppg:1.0', 'hello'); + }); + + test('sends literal text without Enter', async () => { + setupAgentMocks(); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/agents/ag-test1234/send', + payload: { text: 'hello', mode: 'literal' }, + }); + + expect(res.statusCode).toBe(200); + expect(tmux.sendLiteral).toHaveBeenCalledWith('ppg:1.0', 'hello'); + }); + + test('sends raw tmux keys', async () => { + setupAgentMocks(); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/agents/ag-test1234/send', + payload: { text: 'C-c', mode: 'raw' }, + }); + + expect(res.statusCode).toBe(200); + expect(tmux.sendRawKeys).toHaveBeenCalledWith('ppg:1.0', 'C-c'); + }); + + test('rejects invalid mode', async () => { + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/agents/ag-test1234/send', + payload: { text: 'hello', mode: 'invalid' }, + }); + + expect(res.statusCode).toBe(400); + }); + + test('rejects missing text field', async () => { + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/agents/ag-test1234/send', + payload: {}, + }); + + expect(res.statusCode).toBe(400); + }); + + test('returns 404 for unknown agent', async () => { + vi.mocked(requireManifest).mockResolvedValue(makeManifest()); + vi.mocked(findAgent).mockReturnValue(undefined); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/agents/ag-unknown/send', + payload: { text: 'hello' }, + }); + + expect(res.statusCode).toBe(404); + }); +}); + +// ---------- POST /api/agents/:id/kill ---------- + +describe('POST /api/agents/:id/kill', () => { + test('kills a running agent', async () => { + const manifest = makeManifest(); + vi.mocked(requireManifest).mockResolvedValue(manifest); + vi.mocked(findAgent) + .mockReturnValueOnce({ + worktree: manifest.worktrees['wt-abc123'], + agent: manifest.worktrees['wt-abc123'].agents['ag-test1234'], + }) + .mockReturnValueOnce({ + worktree: manifest.worktrees['wt-abc123'], + agent: manifest.worktrees['wt-abc123'].agents['ag-test1234'], + }); + vi.mocked(checkAgentStatus).mockResolvedValue({ status: 'running' }); + vi.mocked(killAgent).mockResolvedValue(undefined); + vi.mocked(updateManifest).mockImplementation(async (_root, updater) => { + const m = makeManifest(); + return updater(m); + }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/agents/ag-test1234/kill', + }); + + expect(res.statusCode).toBe(200); + expect(res.json().success).toBe(true); + expect(res.json().killed).toBe(true); + expect(checkAgentStatus).toHaveBeenCalled(); + expect(killAgent).toHaveBeenCalled(); + expect(updateManifest).toHaveBeenCalled(); + }); + + test('returns success without killing already-stopped agent', async () => { + const stoppedAgent = makeAgent({ status: 'gone' }); + const manifest = makeManifest({ + worktrees: { 'wt-abc123': makeWorktree({ agents: { 'ag-test1234': stoppedAgent } }) }, + }); + vi.mocked(requireManifest).mockResolvedValue(manifest); + vi.mocked(findAgent).mockReturnValue({ + worktree: manifest.worktrees['wt-abc123'], + agent: stoppedAgent, + }); + vi.mocked(checkAgentStatus).mockResolvedValue({ status: 'gone' }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/agents/ag-test1234/kill', + }); + + expect(res.statusCode).toBe(200); + expect(res.json().message).toMatch(/already gone/); + expect(killAgent).not.toHaveBeenCalled(); + }); + + test('uses live tmux status instead of stale manifest status', async () => { + // Agent shows "running" in manifest but tmux says "idle" + const agent = makeAgent({ status: 'running' }); + const manifest = makeManifest({ + worktrees: { 'wt-abc123': makeWorktree({ agents: { 'ag-test1234': agent } }) }, + }); + vi.mocked(requireManifest).mockResolvedValue(manifest); + vi.mocked(findAgent).mockReturnValue({ + worktree: manifest.worktrees['wt-abc123'], + agent, + }); + vi.mocked(checkAgentStatus).mockResolvedValue({ status: 'idle' }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/agents/ag-test1234/kill', + }); + + expect(res.statusCode).toBe(200); + expect(res.json().message).toMatch(/already idle/); + expect(killAgent).not.toHaveBeenCalled(); + }); + + test('returns 404 for unknown agent', async () => { + vi.mocked(requireManifest).mockResolvedValue(makeManifest()); + vi.mocked(findAgent).mockReturnValue(undefined); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/agents/ag-unknown/kill', + }); + + expect(res.statusCode).toBe(404); + }); +}); + +// ---------- POST /api/agents/:id/restart ---------- + +describe('POST /api/agents/:id/restart', () => { + function setupRestartMocks() { + const manifest = makeManifest(); + vi.mocked(requireManifest).mockResolvedValue(manifest); + vi.mocked(findAgent).mockReturnValue({ + worktree: manifest.worktrees['wt-abc123'], + agent: manifest.worktrees['wt-abc123'].agents['ag-test1234'], + }); + vi.mocked(loadConfig).mockResolvedValue({ + sessionName: 'ppg', + defaultAgent: 'claude', + agents: { claude: { name: 'claude', command: 'claude', interactive: true } }, + envFiles: [], + symlinkNodeModules: true, + }); + vi.mocked(resolveAgentConfig).mockReturnValue({ + name: 'claude', + command: 'claude', + interactive: true, + }); + vi.mocked(restartAgent).mockResolvedValue({ + oldAgentId: 'ag-test1234', + newAgentId: 'ag-new12345', + tmuxTarget: 'ppg:2', + sessionId: 'session-uuid-123', + worktreeId: 'wt-abc123', + worktreeName: 'feature-auth', + branch: 'ppg/feature-auth', + path: '/tmp/project/.worktrees/wt-abc123', + }); + return manifest; + } + + test('restarts a running agent with original prompt', async () => { + setupRestartMocks(); + vi.mocked(fs.readFile).mockResolvedValue('original prompt'); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/agents/ag-test1234/restart', + payload: {}, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.success).toBe(true); + expect(body.oldAgentId).toBe('ag-test1234'); + expect(body.newAgent.id).toBe('ag-new12345'); + expect(restartAgent).toHaveBeenCalled(); + }); + + test('uses prompt override when provided', async () => { + setupRestartMocks(); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/agents/ag-test1234/restart', + payload: { prompt: 'new task' }, + }); + + expect(res.statusCode).toBe(200); + expect(fs.readFile).not.toHaveBeenCalled(); + expect(restartAgent).toHaveBeenCalledWith( + expect.objectContaining({ promptText: 'new task' }), + ); + }); + + test('skips kill for non-running agent', async () => { + const idleAgent = makeAgent({ status: 'idle' }); + const manifest = makeManifest({ + worktrees: { 'wt-abc123': makeWorktree({ agents: { 'ag-test1234': idleAgent } }) }, + }); + vi.mocked(requireManifest).mockResolvedValue(manifest); + vi.mocked(findAgent).mockReturnValue({ + worktree: manifest.worktrees['wt-abc123'], + agent: idleAgent, + }); + vi.mocked(loadConfig).mockResolvedValue({ + sessionName: 'ppg', + defaultAgent: 'claude', + agents: { claude: { name: 'claude', command: 'claude', interactive: true } }, + envFiles: [], + symlinkNodeModules: true, + }); + vi.mocked(resolveAgentConfig).mockReturnValue({ + name: 'claude', + command: 'claude', + interactive: true, + }); + vi.mocked(fs.readFile).mockResolvedValue('original prompt'); + vi.mocked(restartAgent).mockResolvedValue({ + oldAgentId: 'ag-test1234', + newAgentId: 'ag-new12345', + tmuxTarget: 'ppg:2', + sessionId: 'session-uuid-123', + worktreeId: 'wt-abc123', + worktreeName: 'feature-auth', + branch: 'ppg/feature-auth', + path: '/tmp/project/.worktrees/wt-abc123', + }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/agents/ag-test1234/restart', + payload: {}, + }); + + expect(res.statusCode).toBe(200); + // restartAgent handles the kill-or-skip internally + expect(restartAgent).toHaveBeenCalledWith( + expect.objectContaining({ oldAgent: expect.objectContaining({ status: 'idle' }) }), + ); + }); + + test('returns 400 when prompt file missing and no override', async () => { + const manifest = makeManifest(); + vi.mocked(requireManifest).mockResolvedValue(manifest); + vi.mocked(findAgent).mockReturnValue({ + worktree: manifest.worktrees['wt-abc123'], + agent: manifest.worktrees['wt-abc123'].agents['ag-test1234'], + }); + vi.mocked(loadConfig).mockResolvedValue({ + sessionName: 'ppg', + defaultAgent: 'claude', + agents: { claude: { name: 'claude', command: 'claude', interactive: true } }, + envFiles: [], + symlinkNodeModules: true, + }); + vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT')); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/agents/ag-test1234/restart', + payload: {}, + }); + + expect(res.statusCode).toBe(400); + expect(res.json().code).toBe('PROMPT_NOT_FOUND'); + }); + + test('returns 404 for unknown agent', async () => { + vi.mocked(requireManifest).mockResolvedValue(makeManifest()); + vi.mocked(findAgent).mockReturnValue(undefined); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/agents/ag-unknown/restart', + payload: {}, + }); + + expect(res.statusCode).toBe(404); + }); +}); diff --git a/src/server/routes/agents.ts b/src/server/routes/agents.ts new file mode 100644 index 0000000..8ef30de --- /dev/null +++ b/src/server/routes/agents.ts @@ -0,0 +1,289 @@ +import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; +import { requireManifest, findAgent, updateManifest } from '../../core/manifest.js'; +import { killAgent, checkAgentStatus, restartAgent } from '../../core/agent.js'; +import { loadConfig, resolveAgentConfig } from '../../core/config.js'; +import * as tmux from '../../core/tmux.js'; +import { PpgError, AgentNotFoundError } from '../../lib/errors.js'; +import { agentPromptFile } from '../../lib/paths.js'; +import fs from 'node:fs/promises'; + +export interface AgentRoutesOptions extends FastifyPluginOptions { + projectRoot: string; +} + +const MAX_LINES = 10_000; + +function mapErrorToStatus(err: unknown): number { + if (err instanceof PpgError) { + switch (err.code) { + case 'AGENT_NOT_FOUND': return 404; + case 'PANE_NOT_FOUND': return 410; + case 'NOT_INITIALIZED': return 503; + case 'MANIFEST_LOCK': return 409; + case 'TMUX_NOT_FOUND': return 503; + case 'INVALID_ARGS': return 400; + case 'PROMPT_NOT_FOUND': return 400; + default: return 500; + } + } + return 500; +} + +function errorPayload(err: unknown): { error: string; code?: string } { + if (err instanceof PpgError) { + return { error: err.message, code: err.code }; + } + return { error: err instanceof Error ? err.message : String(err) }; +} + +export async function agentRoutes( + app: FastifyInstance, + opts: AgentRoutesOptions, +): Promise { + const { projectRoot } = opts; + + // ---------- GET /api/agents/:id/logs ---------- + app.get<{ + Params: { id: string }; + Querystring: { lines?: string }; + }>('/agents/:id/logs', { + schema: { + params: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, + }, + querystring: { + type: 'object', + properties: { lines: { type: 'string' } }, + }, + }, + }, async (request, reply) => { + try { + const { id } = request.params; + const lines = request.query.lines + ? Math.min(parseInt(request.query.lines, 10), MAX_LINES) + : 200; + + if (isNaN(lines) || lines < 1) { + return reply.code(400).send({ error: 'lines must be a positive integer', code: 'INVALID_ARGS' }); + } + + const manifest = await requireManifest(projectRoot); + const found = findAgent(manifest, id); + if (!found) throw new AgentNotFoundError(id); + + const { agent } = found; + + let content: string; + try { + content = await tmux.capturePane(agent.tmuxTarget, lines); + } catch { + throw new PpgError( + `Could not capture pane for agent ${id}. Pane may no longer exist.`, + 'PANE_NOT_FOUND', + ); + } + + return { + agentId: agent.id, + status: agent.status, + tmuxTarget: agent.tmuxTarget, + lines, + output: content, + }; + } catch (err) { + const status = mapErrorToStatus(err); + return reply.code(status).send(errorPayload(err)); + } + }); + + // ---------- POST /api/agents/:id/send ---------- + app.post<{ + Params: { id: string }; + Body: { text: string; mode?: 'raw' | 'literal' | 'with-enter' }; + }>('/agents/:id/send', { + schema: { + params: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, + }, + body: { + type: 'object', + required: ['text'], + properties: { + text: { type: 'string' }, + mode: { type: 'string', enum: ['raw', 'literal', 'with-enter'] }, + }, + }, + }, + }, async (request, reply) => { + try { + const { id } = request.params; + const { text, mode = 'with-enter' } = request.body; + + const manifest = await requireManifest(projectRoot); + const found = findAgent(manifest, id); + if (!found) throw new AgentNotFoundError(id); + + const { agent } = found; + + switch (mode) { + case 'raw': + await tmux.sendRawKeys(agent.tmuxTarget, text); + break; + case 'literal': + await tmux.sendLiteral(agent.tmuxTarget, text); + break; + case 'with-enter': + default: + await tmux.sendKeys(agent.tmuxTarget, text); + break; + } + + return { + success: true, + agentId: agent.id, + tmuxTarget: agent.tmuxTarget, + text, + mode, + }; + } catch (err) { + const status = mapErrorToStatus(err); + return reply.code(status).send(errorPayload(err)); + } + }); + + // ---------- POST /api/agents/:id/kill ---------- + app.post<{ + Params: { id: string }; + }>('/agents/:id/kill', { + schema: { + params: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, + }, + }, + }, async (request, reply) => { + try { + const { id } = request.params; + + const manifest = await requireManifest(projectRoot); + const found = findAgent(manifest, id); + if (!found) throw new AgentNotFoundError(id); + + const { agent } = found; + + // Refresh live status from tmux (manifest may be stale in long-lived server) + const { status: liveStatus } = await checkAgentStatus(agent, projectRoot); + + if (liveStatus !== 'running') { + return { + success: true, + agentId: agent.id, + message: `Agent already ${liveStatus}`, + }; + } + + await killAgent(agent); + + await updateManifest(projectRoot, (m) => { + const f = findAgent(m, id); + if (f) { + f.agent.status = 'gone'; + } + return m; + }); + + return { + success: true, + agentId: agent.id, + killed: true, + }; + } catch (err) { + const status = mapErrorToStatus(err); + return reply.code(status).send(errorPayload(err)); + } + }); + + // ---------- POST /api/agents/:id/restart ---------- + app.post<{ + Params: { id: string }; + Body: { prompt?: string; agent?: string }; + }>('/agents/:id/restart', { + schema: { + params: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, + }, + body: { + type: 'object', + properties: { + prompt: { type: 'string' }, + agent: { type: 'string' }, + }, + }, + }, + }, async (request, reply) => { + try { + const { id } = request.params; + const { prompt: promptOverride, agent: agentType } = request.body ?? {}; + + const manifest = await requireManifest(projectRoot); + const config = await loadConfig(projectRoot); + + const found = findAgent(manifest, id); + if (!found) throw new AgentNotFoundError(id); + + const { worktree: wt, agent: oldAgent } = found; + + // Read original prompt or use override + let promptText: string; + if (promptOverride) { + promptText = promptOverride; + } else { + const pFile = agentPromptFile(projectRoot, oldAgent.id); + try { + promptText = await fs.readFile(pFile, 'utf-8'); + } catch { + throw new PpgError( + `Could not read original prompt for agent ${oldAgent.id}. Provide a prompt in the request body.`, + 'PROMPT_NOT_FOUND', + ); + } + } + + const agentConfig = resolveAgentConfig(config, agentType ?? oldAgent.agentType); + + const result = await restartAgent({ + projectRoot, + agentId: oldAgent.id, + worktree: wt, + oldAgent, + sessionName: manifest.sessionName, + agentConfig, + promptText, + }); + + return { + success: true, + oldAgentId: result.oldAgentId, + newAgent: { + id: result.newAgentId, + tmuxTarget: result.tmuxTarget, + sessionId: result.sessionId, + worktreeId: result.worktreeId, + worktreeName: result.worktreeName, + branch: result.branch, + path: result.path, + }, + }; + } catch (err) { + const status = mapErrorToStatus(err); + return reply.code(status).send(errorPayload(err)); + } + }); +} diff --git a/src/server/routes/config.test.ts b/src/server/routes/config.test.ts new file mode 100644 index 0000000..9e1a551 --- /dev/null +++ b/src/server/routes/config.test.ts @@ -0,0 +1,252 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { configRoutes } from './config.js'; + +let tmpDir: string; +let globalDir: string; +let app: FastifyInstance; + +vi.mock('../../lib/paths.js', async () => { + const actual = await vi.importActual('../../lib/paths.js'); + return { + ...actual, + globalTemplatesDir: () => path.join(globalDir, 'templates'), + globalPromptsDir: () => path.join(globalDir, 'prompts'), + }; +}); + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ppg-config-routes-')); + globalDir = path.join(tmpDir, 'global'); + await fs.mkdir(path.join(globalDir, 'templates'), { recursive: true }); + await fs.mkdir(path.join(globalDir, 'prompts'), { recursive: true }); +}); + +afterEach(async () => { + await app?.close(); + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +function buildApp(projectRoot: string) { + app = Fastify({ logger: false }); + app.register(configRoutes, { projectRoot }); + return app; +} + +// --- GET /api/config --- + +describe('GET /api/config', () => { + test('given no config.yaml, should return default config', async () => { + const server = buildApp(tmpDir); + const res = await server.inject({ method: 'GET', url: '/api/config' }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.sessionName).toBe('ppg'); + expect(body.defaultAgent).toBe('claude'); + expect(body.agents).toBeInstanceOf(Array); + expect(body.agents.length).toBeGreaterThanOrEqual(3); + expect(body.agents.find((a: { name: string }) => a.name === 'claude')).toBeTruthy(); + expect(body.envFiles).toEqual(['.env', '.env.local']); + expect(body.symlinkNodeModules).toBe(true); + }); + + test('given user config.yaml, should merge with defaults', async () => { + const ppgDir = path.join(tmpDir, '.ppg'); + await fs.mkdir(ppgDir, { recursive: true }); + await fs.writeFile( + path.join(ppgDir, 'config.yaml'), + 'sessionName: custom\ndefaultAgent: codex\nagents:\n myagent:\n name: myagent\n command: myagent --fast\n interactive: false\n', + ); + + const server = buildApp(tmpDir); + const res = await server.inject({ method: 'GET', url: '/api/config' }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.sessionName).toBe('custom'); + expect(body.defaultAgent).toBe('codex'); + expect(body.agents.find((a: { name: string }) => a.name === 'claude')).toBeTruthy(); + const myagent = body.agents.find((a: { name: string }) => a.name === 'myagent'); + expect(myagent).toBeTruthy(); + expect(myagent.command).toBe('myagent --fast'); + expect(myagent.interactive).toBe(false); + }); + + test('given invalid YAML, should return 500 error', async () => { + const ppgDir = path.join(tmpDir, '.ppg'); + await fs.mkdir(ppgDir, { recursive: true }); + await fs.writeFile(path.join(ppgDir, 'config.yaml'), ':\n bad: [yaml\n'); + + const server = buildApp(tmpDir); + const res = await server.inject({ method: 'GET', url: '/api/config' }); + + expect(res.statusCode).toBe(500); + }); +}); + +// --- GET /api/templates --- + +describe('GET /api/templates', () => { + test('given no template dirs, should return empty array', async () => { + const server = buildApp(tmpDir); + const res = await server.inject({ method: 'GET', url: '/api/templates' }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.templates).toEqual([]); + }); + + test('given local template, should return source and metadata', async () => { + const tplDir = path.join(tmpDir, '.ppg', 'templates'); + await fs.mkdir(tplDir, { recursive: true }); + await fs.writeFile( + path.join(tplDir, 'task.md'), + '# Task Template\n\nDo {{TASK}} in {{WORKTREE_PATH}}\n', + ); + + const server = buildApp(tmpDir); + const res = await server.inject({ method: 'GET', url: '/api/templates' }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.templates).toHaveLength(1); + expect(body.templates[0]).toEqual({ + name: 'task', + description: 'Task Template', + variables: ['TASK', 'WORKTREE_PATH'], + source: 'local', + }); + }); + + test('given global template, should return with global source', async () => { + await fs.writeFile( + path.join(globalDir, 'templates', 'shared.md'), + '# Global Template\n\n{{VAR}}\n', + ); + + const server = buildApp(tmpDir); + const res = await server.inject({ method: 'GET', url: '/api/templates' }); + + const body = res.json(); + expect(body.templates).toHaveLength(1); + expect(body.templates[0].name).toBe('shared'); + expect(body.templates[0].source).toBe('global'); + }); + + test('given same name in local and global, should prefer local', async () => { + const tplDir = path.join(tmpDir, '.ppg', 'templates'); + await fs.mkdir(tplDir, { recursive: true }); + await fs.writeFile(path.join(tplDir, 'shared.md'), '# Local Version\n'); + await fs.writeFile(path.join(globalDir, 'templates', 'shared.md'), '# Global Version\n'); + + const server = buildApp(tmpDir); + const res = await server.inject({ method: 'GET', url: '/api/templates' }); + + const body = res.json(); + const shared = body.templates.filter((t: { name: string }) => t.name === 'shared'); + expect(shared).toHaveLength(1); + expect(shared[0].source).toBe('local'); + expect(shared[0].description).toBe('Local Version'); + }); + + test('given duplicate variables, should deduplicate', async () => { + const tplDir = path.join(tmpDir, '.ppg', 'templates'); + await fs.mkdir(tplDir, { recursive: true }); + await fs.writeFile( + path.join(tplDir, 'dupe.md'), + '{{NAME}} and {{NAME}} and {{OTHER}}\n', + ); + + const server = buildApp(tmpDir); + const res = await server.inject({ method: 'GET', url: '/api/templates' }); + + const body = res.json(); + expect(body.templates[0].variables).toEqual(['NAME', 'OTHER']); + }); +}); + +// --- GET /api/prompts --- + +describe('GET /api/prompts', () => { + test('given no prompt dirs, should return empty array', async () => { + const server = buildApp(tmpDir); + const res = await server.inject({ method: 'GET', url: '/api/prompts' }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.prompts).toEqual([]); + }); + + test('given local prompt, should return source and metadata', async () => { + const pDir = path.join(tmpDir, '.ppg', 'prompts'); + await fs.mkdir(pDir, { recursive: true }); + await fs.writeFile( + path.join(pDir, 'review.md'), + '# Code Review\n\nReview {{BRANCH}} for issues\n', + ); + + const server = buildApp(tmpDir); + const res = await server.inject({ method: 'GET', url: '/api/prompts' }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.prompts).toHaveLength(1); + expect(body.prompts[0]).toEqual({ + name: 'review', + description: 'Code Review', + variables: ['BRANCH'], + source: 'local', + }); + }); + + test('given same name in local and global, should prefer local', async () => { + const localDir = path.join(tmpDir, '.ppg', 'prompts'); + await fs.mkdir(localDir, { recursive: true }); + await fs.writeFile(path.join(localDir, 'shared.md'), '# Local Shared\n'); + await fs.writeFile(path.join(globalDir, 'prompts', 'shared.md'), '# Global Shared\n'); + + const server = buildApp(tmpDir); + const res = await server.inject({ method: 'GET', url: '/api/prompts' }); + + const body = res.json(); + const shared = body.prompts.filter((p: { name: string }) => p.name === 'shared'); + expect(shared).toHaveLength(1); + expect(shared[0].source).toBe('local'); + expect(shared[0].description).toBe('Local Shared'); + }); + + test('given global-only prompt, should return with global source', async () => { + await fs.writeFile( + path.join(globalDir, 'prompts', 'global-only.md'), + '# Global Prompt\n\n{{WHO}}\n', + ); + + const server = buildApp(tmpDir); + const res = await server.inject({ method: 'GET', url: '/api/prompts' }); + + const body = res.json(); + expect(body.prompts).toHaveLength(1); + expect(body.prompts[0].name).toBe('global-only'); + expect(body.prompts[0].source).toBe('global'); + expect(body.prompts[0].variables).toEqual(['WHO']); + }); + + test('given non-.md files, should ignore them', async () => { + const pDir = path.join(tmpDir, '.ppg', 'prompts'); + await fs.mkdir(pDir, { recursive: true }); + await fs.writeFile(path.join(pDir, 'valid.md'), '# Valid Prompt\n'); + await fs.writeFile(path.join(pDir, 'readme.txt'), 'not a prompt'); + await fs.writeFile(path.join(pDir, '.hidden'), 'hidden file'); + + const server = buildApp(tmpDir); + const res = await server.inject({ method: 'GET', url: '/api/prompts' }); + + const body = res.json(); + expect(body.prompts).toHaveLength(1); + expect(body.prompts[0].name).toBe('valid'); + }); +}); diff --git a/src/server/routes/config.ts b/src/server/routes/config.ts new file mode 100644 index 0000000..81d490d --- /dev/null +++ b/src/server/routes/config.ts @@ -0,0 +1,68 @@ +import type { FastifyInstance } from 'fastify'; +import { loadConfig } from '../../core/config.js'; +import { listTemplatesWithSource } from '../../core/template.js'; +import { listPromptsWithSource, enrichEntryMetadata } from '../../core/prompt.js'; +import { + templatesDir, + globalTemplatesDir, + promptsDir, + globalPromptsDir, +} from '../../lib/paths.js'; + +export interface ConfigRouteOptions { + projectRoot: string; +} + +// Auth note: these routes expect the parent server to register an onRequest +// auth hook before this plugin (e.g. Bearer token via createAuthHook). + +export async function configRoutes( + app: FastifyInstance, + opts: ConfigRouteOptions, +): Promise { + const { projectRoot } = opts; + + // GET /api/config — agent configuration from config.yaml + app.get('/api/config', async () => { + const config = await loadConfig(projectRoot); + return { + sessionName: config.sessionName, + defaultAgent: config.defaultAgent, + agents: Object.values(config.agents), + envFiles: config.envFiles, + symlinkNodeModules: config.symlinkNodeModules, + }; + }); + + // GET /api/templates — templates with source tracking + app.get('/api/templates', async () => { + const entries = await listTemplatesWithSource(projectRoot); + const templates = await Promise.all( + entries.map(({ name, source }) => + enrichEntryMetadata( + name, + source, + templatesDir(projectRoot), + globalTemplatesDir(), + ), + ), + ); + return { templates }; + }); + + // GET /api/prompts — prompts with deduplication across local/global + app.get('/api/prompts', async () => { + const entries = await listPromptsWithSource(projectRoot); + const prompts = await Promise.all( + entries.map(({ name, source }) => + enrichEntryMetadata( + name, + source, + promptsDir(projectRoot), + globalPromptsDir(), + ), + ), + ); + return { prompts }; + }); +} diff --git a/src/server/routes/spawn.test.ts b/src/server/routes/spawn.test.ts new file mode 100644 index 0000000..c84fc82 --- /dev/null +++ b/src/server/routes/spawn.test.ts @@ -0,0 +1,353 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import Fastify from 'fastify'; +import type { FastifyInstance } from 'fastify'; +import spawnRoute from './spawn.js'; +import type { SpawnRequestBody, SpawnResponseBody } from './spawn.js'; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('../../core/spawn.js', () => ({ + spawnNewWorktree: vi.fn().mockResolvedValue({ + worktreeId: 'wt-abc123', + name: 'my-task', + branch: 'ppg/my-task', + path: '/fake/project/.worktrees/wt-abc123', + tmuxWindow: 'ppg-test:my-task', + agents: [ + { + id: 'ag-agent001', + name: 'claude', + agentType: 'claude', + status: 'running', + tmuxTarget: 'ppg-test:my-task', + prompt: 'Fix the bug', + startedAt: '2025-01-01T00:00:00.000Z', + sessionId: 'sess-uuid-001', + }, + ], + }), + resolvePromptText: vi.fn().mockResolvedValue('Fix the bug'), +})); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const PROJECT_ROOT = '/fake/project'; + +async function buildApp(): Promise { + const app = Fastify(); + await app.register(spawnRoute, { projectRoot: PROJECT_ROOT }); + return app; +} + +function postSpawn(app: FastifyInstance, body: Partial) { + return app.inject({ + method: 'POST', + url: '/api/spawn', + payload: body, + }); +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('POST /api/spawn', () => { + let app: FastifyInstance; + + beforeEach(async () => { + vi.clearAllMocks(); + app = await buildApp(); + }); + + // ─── Happy Path ───────────────────────────────────────────────────────────── + + test('given valid name and prompt, should spawn worktree with 1 agent', async () => { + const res = await postSpawn(app, { + name: 'my-task', + prompt: 'Fix the bug', + }); + + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.worktreeId).toBe('wt-abc123'); + expect(body.name).toBe('my-task'); + expect(body.branch).toBe('ppg/my-task'); + expect(body.agents).toHaveLength(1); + expect(body.agents[0].id).toBe('ag-agent001'); + expect(body.agents[0].tmuxTarget).toBe('ppg-test:my-task'); + expect(body.agents[0].sessionId).toBe('sess-uuid-001'); + }); + + test('given all options, should pass them to spawnNewWorktree', async () => { + const { spawnNewWorktree } = await import('../../core/spawn.js'); + + await postSpawn(app, { + name: 'my-task', + prompt: 'Fix the bug', + agent: 'codex', + base: 'develop', + count: 3, + vars: { ISSUE: '42' }, + }); + + expect(vi.mocked(spawnNewWorktree)).toHaveBeenCalledWith({ + projectRoot: PROJECT_ROOT, + name: 'my-task', + promptText: 'Fix the bug', + userVars: { ISSUE: '42' }, + agentName: 'codex', + baseBranch: 'develop', + count: 3, + }); + }); + + test('given template name, should resolve prompt via resolvePromptText', async () => { + const { resolvePromptText } = await import('../../core/spawn.js'); + + await postSpawn(app, { + name: 'my-task', + template: 'review', + }); + + expect(vi.mocked(resolvePromptText)).toHaveBeenCalledWith( + { prompt: undefined, template: 'review' }, + PROJECT_ROOT, + ); + }); + + test('given prompt and template both provided, should use prompt (prompt wins)', async () => { + const { resolvePromptText } = await import('../../core/spawn.js'); + + await postSpawn(app, { + name: 'my-task', + prompt: 'Inline prompt', + template: 'review', + }); + + // resolvePromptText receives both — its implementation short-circuits on prompt + expect(vi.mocked(resolvePromptText)).toHaveBeenCalledWith( + expect.objectContaining({ prompt: 'Inline prompt', template: 'review' }), + PROJECT_ROOT, + ); + }); + + test('given no vars, should pass undefined userVars', async () => { + const { spawnNewWorktree } = await import('../../core/spawn.js'); + + await postSpawn(app, { + name: 'my-task', + prompt: 'Fix it', + }); + + expect(vi.mocked(spawnNewWorktree)).toHaveBeenCalledWith( + expect.objectContaining({ userVars: undefined }), + ); + }); + + // ─── Validation ───────────────────────────────────────────────────────────── + + test('given missing name, should return 400', async () => { + const res = await postSpawn(app, { + prompt: 'Fix the bug', + }); + + expect(res.statusCode).toBe(400); + const body = res.json<{ message: string }>(); + expect(body.message).toMatch(/name/i); + }); + + test('given empty name, should return 400', async () => { + const res = await postSpawn(app, { + name: '', + prompt: 'Fix the bug', + }); + + expect(res.statusCode).toBe(400); + }); + + test('given count below 1, should return 400', async () => { + const res = await postSpawn(app, { + name: 'my-task', + prompt: 'Fix the bug', + count: 0, + }); + + expect(res.statusCode).toBe(400); + }); + + test('given count above 20, should return 400', async () => { + const res = await postSpawn(app, { + name: 'my-task', + prompt: 'Fix the bug', + count: 21, + }); + + expect(res.statusCode).toBe(400); + }); + + test('given non-integer count, should return 400', async () => { + const res = await postSpawn(app, { + name: 'my-task', + prompt: 'Fix the bug', + count: 1.5, + }); + + expect(res.statusCode).toBe(400); + }); + + // ─── Input Sanitization ───────────────────────────────────────────────────── + + test('given vars with shell metacharacters in value, should return 400 INVALID_ARGS', async () => { + const res = await postSpawn(app, { + name: 'my-task', + prompt: 'Fix the bug', + vars: { ISSUE: '$(whoami)' }, + }); + + expect(res.statusCode).toBe(400); + const body = res.json<{ message: string; code: string }>(); + expect(body.message).toMatch(/shell metacharacters/i); + expect(body.code).toBe('INVALID_ARGS'); + }); + + test('given vars with shell metacharacters in key, should return 400 INVALID_ARGS', async () => { + const res = await postSpawn(app, { + name: 'my-task', + prompt: 'Fix the bug', + vars: { 'KEY;rm': 'value' }, + }); + + expect(res.statusCode).toBe(400); + const body = res.json<{ message: string; code: string }>(); + expect(body.message).toMatch(/shell metacharacters/i); + expect(body.code).toBe('INVALID_ARGS'); + }); + + test('given vars with backtick in value, should reject', async () => { + const res = await postSpawn(app, { + name: 'my-task', + prompt: 'Fix the bug', + vars: { CMD: '`whoami`' }, + }); + + expect(res.statusCode).toBe(400); + const body = res.json<{ message: string; code: string }>(); + expect(body.message).toMatch(/shell metacharacters/i); + expect(body.code).toBe('INVALID_ARGS'); + }); + + test('given safe vars, should pass through', async () => { + const { spawnNewWorktree } = await import('../../core/spawn.js'); + + const res = await postSpawn(app, { + name: 'my-task', + prompt: 'Fix the bug', + vars: { ISSUE: '42', REPO: 'ppg-cli', TAG: 'v1.0.0' }, + }); + + expect(res.statusCode).toBe(201); + expect(vi.mocked(spawnNewWorktree)).toHaveBeenCalledWith( + expect.objectContaining({ + userVars: { ISSUE: '42', REPO: 'ppg-cli', TAG: 'v1.0.0' }, + }), + ); + }); + + // ─── Error Paths ──────────────────────────────────────────────────────────── + + test('given neither prompt nor template, should return 400 with INVALID_ARGS', async () => { + const { resolvePromptText } = await import('../../core/spawn.js'); + const { PpgError } = await import('../../lib/errors.js'); + vi.mocked(resolvePromptText).mockRejectedValueOnce( + new PpgError('Either "prompt" or "template" is required', 'INVALID_ARGS'), + ); + + const res = await postSpawn(app, { + name: 'my-task', + }); + + expect(res.statusCode).toBe(400); + const body = res.json<{ message: string; code: string }>(); + expect(body.message).toMatch(/prompt.*template/i); + expect(body.code).toBe('INVALID_ARGS'); + }); + + test('given unknown agent type, should propagate error', async () => { + const { spawnNewWorktree } = await import('../../core/spawn.js'); + vi.mocked(spawnNewWorktree).mockRejectedValueOnce( + new Error('Unknown agent type: gpt. Available: claude, codex'), + ); + + const res = await postSpawn(app, { + name: 'my-task', + prompt: 'Fix it', + agent: 'gpt', + }); + + expect(res.statusCode).toBe(500); + const body = res.json<{ message: string }>(); + expect(body.message).toMatch(/Unknown agent type/); + }); + + test('given template not found, should propagate error', async () => { + const { resolvePromptText } = await import('../../core/spawn.js'); + vi.mocked(resolvePromptText).mockRejectedValueOnce( + new Error("ENOENT: no such file or directory, open '.ppg/templates/nonexistent.md'"), + ); + + const res = await postSpawn(app, { + name: 'my-task', + template: 'nonexistent', + }); + + expect(res.statusCode).toBe(500); + }); + + test('given not initialized error, should return 409', async () => { + const { spawnNewWorktree } = await import('../../core/spawn.js'); + const { PpgError } = await import('../../lib/errors.js'); + vi.mocked(spawnNewWorktree).mockRejectedValueOnce( + new PpgError('Point Guard not initialized in /fake/project', 'NOT_INITIALIZED'), + ); + + const res = await postSpawn(app, { + name: 'my-task', + prompt: 'Fix it', + }); + + expect(res.statusCode).toBe(409); + const body = res.json<{ message: string; code: string }>(); + expect(body.message).toMatch(/not initialized/i); + expect(body.code).toBe('NOT_INITIALIZED'); + }); + + test('given tmux not available, should propagate TmuxNotFoundError', async () => { + const { spawnNewWorktree } = await import('../../core/spawn.js'); + const { PpgError } = await import('../../lib/errors.js'); + vi.mocked(spawnNewWorktree).mockRejectedValueOnce( + new PpgError('tmux is not installed or not in PATH', 'TMUX_NOT_FOUND'), + ); + + const res = await postSpawn(app, { + name: 'my-task', + prompt: 'Fix it', + }); + + expect(res.statusCode).toBe(500); + const body = res.json<{ message: string }>(); + expect(body.message).toMatch(/tmux/i); + }); + + // ─── projectRoot Injection ────────────────────────────────────────────────── + + test('should use injected projectRoot, not process.cwd()', async () => { + const { spawnNewWorktree } = await import('../../core/spawn.js'); + + await postSpawn(app, { + name: 'my-task', + prompt: 'Fix it', + }); + + expect(vi.mocked(spawnNewWorktree)).toHaveBeenCalledWith( + expect.objectContaining({ projectRoot: '/fake/project' }), + ); + }); +}); diff --git a/src/server/routes/spawn.ts b/src/server/routes/spawn.ts new file mode 100644 index 0000000..587a17d --- /dev/null +++ b/src/server/routes/spawn.ts @@ -0,0 +1,141 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { spawnNewWorktree, resolvePromptText } from '../../core/spawn.js'; +import { PpgError } from '../../lib/errors.js'; + +export interface SpawnRequestBody { + name: string; + agent?: string; + prompt?: string; + template?: string; + vars?: Record; + base?: string; + count?: number; +} + +export interface SpawnResponseBody { + worktreeId: string; + name: string; + branch: string; + agents: Array<{ + id: string; + tmuxTarget: string; + sessionId?: string; + }>; +} + +const spawnBodySchema = { + type: 'object' as const, + required: ['name'], + properties: { + name: { type: 'string' as const, minLength: 1 }, + agent: { type: 'string' as const }, + prompt: { type: 'string' as const }, + template: { type: 'string' as const }, + vars: { + type: 'object' as const, + additionalProperties: { type: 'string' as const }, + }, + base: { type: 'string' as const }, + count: { type: 'integer' as const, minimum: 1, maximum: 20 }, + }, + additionalProperties: false, +}; + +// Shell metacharacters that could be injected via tmux send-keys +const SHELL_META_RE = /[`$\\!;|&()<>{}[\]"'\n\r]/; + +function validateVars(vars: Record): void { + for (const [key, value] of Object.entries(vars)) { + if (SHELL_META_RE.test(key)) { + throw new PpgError( + `Var key "${key}" contains shell metacharacters`, + 'INVALID_ARGS', + ); + } + if (SHELL_META_RE.test(value)) { + throw new PpgError( + `Var value for "${key}" contains shell metacharacters`, + 'INVALID_ARGS', + ); + } + } +} + +function statusForPpgError(code: string): number { + switch (code) { + case 'INVALID_ARGS': + return 400; + case 'NOT_INITIALIZED': + return 409; + default: + return 500; + } +} + +export interface SpawnRouteOptions { + projectRoot: string; +} + +export default async function spawnRoute( + app: FastifyInstance, + opts: SpawnRouteOptions, +): Promise { + const { projectRoot } = opts; + + app.post( + '/api/spawn', + { schema: { body: spawnBodySchema } }, + async ( + request: FastifyRequest<{ Body: SpawnRequestBody }>, + reply: FastifyReply, + ) => { + try { + const body = request.body; + + // Validate vars for shell safety before any side effects + if (body.vars) { + validateVars(body.vars); + } + + const promptText = await resolvePromptText( + { prompt: body.prompt, template: body.template }, + projectRoot, + ); + + const result = await spawnNewWorktree({ + projectRoot, + name: body.name, + promptText, + userVars: body.vars, + agentName: body.agent, + baseBranch: body.base, + count: body.count, + }); + + const response: SpawnResponseBody = { + worktreeId: result.worktreeId, + name: result.name, + branch: result.branch, + agents: result.agents.map((a) => ({ + id: a.id, + tmuxTarget: a.tmuxTarget, + sessionId: a.sessionId, + })), + }; + + return reply.status(201).send(response); + } catch (err) { + if (err instanceof PpgError) { + return reply.status(statusForPpgError(err.code)).send({ + message: err.message, + code: err.code, + }); + } + + return reply.status(500).send({ + message: err instanceof Error ? err.message : 'Internal server error', + }); + } + }, + ); +} diff --git a/src/server/routes/status.test.ts b/src/server/routes/status.test.ts new file mode 100644 index 0000000..d3575bf --- /dev/null +++ b/src/server/routes/status.test.ts @@ -0,0 +1,344 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import Fastify from 'fastify'; +import type { FastifyInstance } from 'fastify'; +import statusRoutes from './status.js'; +import { makeWorktree, makeAgent } from '../../test-fixtures.js'; +import type { Manifest } from '../../types/manifest.js'; +import { NotInitializedError, ManifestLockError } from '../../lib/errors.js'; + +const PROJECT_ROOT = '/tmp/project'; +const TOKEN = 'test-token-123'; + +const mockManifest: Manifest = { + version: 1, + projectRoot: PROJECT_ROOT, + sessionName: 'ppg-test', + worktrees: { + 'wt-abc123': makeWorktree({ + agents: { + 'ag-test1234': makeAgent(), + }, + }), + }, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', +}; + +vi.mock('../../core/manifest.js', () => ({ + readManifest: vi.fn(), + resolveWorktree: vi.fn(), + updateManifest: vi.fn(), +})); + +vi.mock('../../core/agent.js', () => ({ + refreshAllAgentStatuses: vi.fn((m: Manifest) => m), +})); + +vi.mock('execa', () => ({ + execa: vi.fn(), +})); + +import { readManifest, resolveWorktree, updateManifest } from '../../core/manifest.js'; +import { refreshAllAgentStatuses } from '../../core/agent.js'; +import { execa } from 'execa'; + +const mockedUpdateManifest = vi.mocked(updateManifest); +const mockedReadManifest = vi.mocked(readManifest); +const mockedResolveWorktree = vi.mocked(resolveWorktree); +const mockedRefreshAllAgentStatuses = vi.mocked(refreshAllAgentStatuses); +const mockedExeca = vi.mocked(execa); + +function buildApp(): FastifyInstance { + const app = Fastify(); + app.register(statusRoutes, { projectRoot: PROJECT_ROOT, bearerToken: TOKEN }); + return app; +} + +function authHeaders() { + return { authorization: `Bearer ${TOKEN}` }; +} + +describe('status routes', () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockedUpdateManifest.mockImplementation(async (_root, updater) => { + return updater(structuredClone(mockManifest)); + }); + mockedReadManifest.mockResolvedValue(structuredClone(mockManifest)); + mockedRefreshAllAgentStatuses.mockImplementation(async (m) => m); + }); + + describe('authentication', () => { + test('given no auth header, should return 401', async () => { + const app = buildApp(); + const res = await app.inject({ method: 'GET', url: '/api/status' }); + expect(res.statusCode).toBe(401); + expect(res.json()).toEqual({ error: 'Unauthorized' }); + }); + + test('given wrong token, should return 401', async () => { + const app = buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/status', + headers: { authorization: 'Bearer wrong-token' }, + }); + expect(res.statusCode).toBe(401); + }); + + test('given valid token, should return 200', async () => { + const app = buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/status', + headers: authHeaders(), + }); + expect(res.statusCode).toBe(200); + }); + + test('given failed auth, should not execute route handler', async () => { + const app = buildApp(); + await app.inject({ method: 'GET', url: '/api/status' }); + expect(mockedUpdateManifest).not.toHaveBeenCalled(); + }); + }); + + describe('GET /api/status', () => { + test('should return full manifest with lifecycle', async () => { + const app = buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/status', + headers: authHeaders(), + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.session).toBe('ppg-test'); + expect(body.worktrees['wt-abc123']).toBeDefined(); + expect(body.worktrees['wt-abc123'].lifecycle).toBe('busy'); + }); + + test('should call refreshAllAgentStatuses', async () => { + const app = buildApp(); + await app.inject({ + method: 'GET', + url: '/api/status', + headers: authHeaders(), + }); + + expect(mockedRefreshAllAgentStatuses).toHaveBeenCalled(); + }); + + test('given manifest lock error, should return 503', async () => { + mockedUpdateManifest.mockRejectedValue(new ManifestLockError()); + + const app = buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/status', + headers: authHeaders(), + }); + + expect(res.statusCode).toBe(503); + expect(res.json().code).toBe('MANIFEST_LOCK'); + }); + + test('given not initialized error, should return 503', async () => { + mockedUpdateManifest.mockRejectedValue(new NotInitializedError('/tmp/project')); + + const app = buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/status', + headers: authHeaders(), + }); + + expect(res.statusCode).toBe(503); + expect(res.json().code).toBe('NOT_INITIALIZED'); + }); + }); + + describe('GET /api/worktrees/:id', () => { + test('given valid worktree id, should return worktree detail', async () => { + mockedResolveWorktree.mockReturnValue(mockManifest.worktrees['wt-abc123']); + + const app = buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/worktrees/wt-abc123', + headers: authHeaders(), + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.id).toBe('wt-abc123'); + expect(body.name).toBe('feature-auth'); + expect(body.lifecycle).toBe('busy'); + }); + + test('given worktree name, should resolve by name', async () => { + mockedResolveWorktree.mockReturnValue(mockManifest.worktrees['wt-abc123']); + + const app = buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/worktrees/feature-auth', + headers: authHeaders(), + }); + + expect(res.statusCode).toBe(200); + expect(mockedResolveWorktree).toHaveBeenCalledWith(expect.anything(), 'feature-auth'); + }); + + test('given unknown worktree, should return 404', async () => { + mockedResolveWorktree.mockReturnValue(undefined); + + const app = buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/worktrees/wt-unknown', + headers: authHeaders(), + }); + + expect(res.statusCode).toBe(404); + expect(res.json()).toEqual({ error: 'Worktree not found: wt-unknown' }); + }); + }); + + describe('GET /api/worktrees/:id/diff', () => { + test('given valid worktree, should return numstat diff', async () => { + mockedResolveWorktree.mockReturnValue(mockManifest.worktrees['wt-abc123']); + mockedExeca.mockResolvedValue({ + stdout: '10\t2\tsrc/index.ts\n5\t0\tsrc/utils.ts', + } as never); + + const app = buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/worktrees/wt-abc123/diff', + headers: authHeaders(), + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.worktreeId).toBe('wt-abc123'); + expect(body.branch).toBe('ppg/feature-auth'); + expect(body.baseBranch).toBe('main'); + expect(body.files).toEqual([ + { file: 'src/index.ts', added: 10, removed: 2 }, + { file: 'src/utils.ts', added: 5, removed: 0 }, + ]); + }); + + test('given empty diff, should return empty files array', async () => { + mockedResolveWorktree.mockReturnValue(mockManifest.worktrees['wt-abc123']); + mockedExeca.mockResolvedValue({ stdout: '' } as never); + + const app = buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/worktrees/wt-abc123/diff', + headers: authHeaders(), + }); + + expect(res.statusCode).toBe(200); + expect(res.json().files).toEqual([]); + }); + + test('given unknown worktree, should return 404', async () => { + mockedResolveWorktree.mockReturnValue(undefined); + + const app = buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/worktrees/wt-unknown/diff', + headers: authHeaders(), + }); + + expect(res.statusCode).toBe(404); + expect(res.json()).toEqual({ error: 'Worktree not found: wt-unknown' }); + }); + + test('given missing manifest file, should return 503', async () => { + const enoentError = Object.assign(new Error('not found'), { code: 'ENOENT' }); + mockedReadManifest.mockRejectedValue(enoentError); + + const app = buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/worktrees/wt-abc123/diff', + headers: authHeaders(), + }); + + expect(res.statusCode).toBe(503); + expect(res.json().code).toBe('NOT_INITIALIZED'); + }); + + test('should call git diff with correct range', async () => { + mockedResolveWorktree.mockReturnValue(mockManifest.worktrees['wt-abc123']); + mockedExeca.mockResolvedValue({ stdout: '' } as never); + + const app = buildApp(); + await app.inject({ + method: 'GET', + url: '/api/worktrees/wt-abc123/diff', + headers: authHeaders(), + }); + + expect(mockedExeca).toHaveBeenCalledWith( + 'git', + ['diff', '--numstat', 'main...ppg/feature-auth'], + expect.objectContaining({ cwd: PROJECT_ROOT }), + ); + }); + + test('given binary files in diff, should treat dash counts as 0', async () => { + mockedResolveWorktree.mockReturnValue(mockManifest.worktrees['wt-abc123']); + mockedExeca.mockResolvedValue({ + stdout: '-\t-\timage.png', + } as never); + + const app = buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/worktrees/wt-abc123/diff', + headers: authHeaders(), + }); + + expect(res.json().files).toEqual([ + { file: 'image.png', added: 0, removed: 0 }, + ]); + }); + + test('given git diff failure, should return 500', async () => { + mockedResolveWorktree.mockReturnValue(mockManifest.worktrees['wt-abc123']); + mockedExeca.mockRejectedValue(new Error('git diff failed')); + + const app = buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/worktrees/wt-abc123/diff', + headers: authHeaders(), + }); + + expect(res.statusCode).toBe(500); + }); + + test('should use readManifest instead of updateManifest', async () => { + mockedResolveWorktree.mockReturnValue(mockManifest.worktrees['wt-abc123']); + mockedExeca.mockResolvedValue({ stdout: '' } as never); + + const app = buildApp(); + await app.inject({ + method: 'GET', + url: '/api/worktrees/wt-abc123/diff', + headers: authHeaders(), + }); + + expect(mockedReadManifest).toHaveBeenCalledWith(PROJECT_ROOT); + expect(mockedUpdateManifest).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/server/routes/status.ts b/src/server/routes/status.ts new file mode 100644 index 0000000..d1ee242 --- /dev/null +++ b/src/server/routes/status.ts @@ -0,0 +1,150 @@ +import crypto from 'node:crypto'; +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { execa } from 'execa'; +import { readManifest, resolveWorktree, updateManifest } from '../../core/manifest.js'; +import { refreshAllAgentStatuses } from '../../core/agent.js'; +import { computeLifecycle } from '../../core/lifecycle.js'; +import { NotInitializedError, PpgError } from '../../lib/errors.js'; +import { execaEnv } from '../../lib/env.js'; +import type { Manifest } from '../../types/manifest.js'; + +export interface StatusRouteOptions { + projectRoot: string; + bearerToken: string; +} + +function timingSafeEqual(a: string, b: string): boolean { + const aBuffer = Buffer.from(a); + const bBuffer = Buffer.from(b); + if (aBuffer.length !== bBuffer.length) return false; + return crypto.timingSafeEqual(aBuffer, bBuffer); +} + +function parseNumstatLine(line: string): { file: string; added: number; removed: number } { + const [addedRaw = '', removedRaw = '', ...fileParts] = line.split('\t'); + + const parseCount = (value: string): number => { + if (value === '-') return 0; + const parsed = Number.parseInt(value, 10); + return Number.isNaN(parsed) ? 0 : parsed; + }; + + return { + file: fileParts.join('\t'), + added: parseCount(addedRaw), + removed: parseCount(removedRaw), + }; +} + +function authenticate(token: string) { + const expected = `Bearer ${token}`; + return async (request: FastifyRequest, reply: FastifyReply) => { + const auth = request.headers.authorization ?? ''; + if (!timingSafeEqual(auth, expected)) { + return reply.code(401).send({ error: 'Unauthorized' }); + } + }; +} + +const ppgErrorToStatus: Record = { + NOT_INITIALIZED: 503, + MANIFEST_LOCK: 503, + WORKTREE_NOT_FOUND: 404, + AGENT_NOT_FOUND: 404, +}; + +export default async function statusRoutes( + fastify: FastifyInstance, + options: StatusRouteOptions, +): Promise { + const { projectRoot, bearerToken } = options; + + fastify.addHook('onRequest', authenticate(bearerToken)); + + fastify.setErrorHandler((error, _request, reply) => { + if (error instanceof PpgError) { + const status = ppgErrorToStatus[error.code] ?? 500; + reply.code(status).send({ error: error.message, code: error.code }); + return; + } + reply.code(500).send({ error: 'Internal server error' }); + }); + + // GET /api/status — full manifest with live agent statuses + fastify.get('/api/status', async (_request, reply) => { + const manifest = await updateManifest(projectRoot, async (m) => { + return refreshAllAgentStatuses(m, projectRoot); + }); + + const worktrees = Object.fromEntries( + Object.values(manifest.worktrees).map((wt) => [ + wt.id, + { ...wt, lifecycle: computeLifecycle(wt) }, + ]), + ); + + reply.send({ + session: manifest.sessionName, + worktrees, + }); + }); + + // GET /api/worktrees/:id — single worktree detail with refreshed statuses + fastify.get<{ Params: { id: string } }>( + '/api/worktrees/:id', + async (request, reply) => { + const manifest = await updateManifest(projectRoot, async (m) => { + return refreshAllAgentStatuses(m, projectRoot); + }); + + const wt = resolveWorktree(manifest, request.params.id); + if (!wt) { + reply.code(404).send({ error: `Worktree not found: ${request.params.id}` }); + return; + } + + reply.send({ ...wt, lifecycle: computeLifecycle(wt) }); + }, + ); + + // GET /api/worktrees/:id/diff — branch diff (numstat format) + fastify.get<{ Params: { id: string } }>( + '/api/worktrees/:id/diff', + async (request, reply) => { + let manifest: Manifest; + try { + manifest = await readManifest(projectRoot); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new NotInitializedError(projectRoot); + } + throw error; + } + + const wt = resolveWorktree(manifest, request.params.id); + if (!wt) { + reply.code(404).send({ error: `Worktree not found: ${request.params.id}` }); + return; + } + + const diffRange = `${wt.baseBranch}...${wt.branch}`; + const result = await execa('git', ['diff', '--numstat', diffRange], { + ...execaEnv, + cwd: projectRoot, + }); + + const files = result.stdout + .trim() + .split('\n') + .filter(Boolean) + .map((line) => parseNumstatLine(line)); + + reply.send({ + worktreeId: wt.id, + branch: wt.branch, + baseBranch: wt.baseBranch, + files, + }); + }, + ); +} diff --git a/src/server/routes/worktrees.test.ts b/src/server/routes/worktrees.test.ts new file mode 100644 index 0000000..dad0351 --- /dev/null +++ b/src/server/routes/worktrees.test.ts @@ -0,0 +1,383 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import Fastify from 'fastify'; +import type { FastifyInstance } from 'fastify'; +import { makeWorktree, makeAgent } from '../../test-fixtures.js'; +import type { Manifest } from '../../types/manifest.js'; +import type { WorktreeEntry } from '../../types/manifest.js'; + +// ---- Mocks ---- + +const mockManifest: Manifest = { + version: 1, + projectRoot: '/tmp/project', + sessionName: 'ppg', + worktrees: {}, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', +}; + +vi.mock('../../core/manifest.js', () => ({ + updateManifest: vi.fn(async (_root: string, updater: (m: Manifest) => Manifest | Promise) => { + return updater(structuredClone(mockManifest)); + }), + resolveWorktree: vi.fn(), +})); + +vi.mock('../../core/agent.js', () => ({ + refreshAllAgentStatuses: vi.fn((m: Manifest) => m), +})); + +vi.mock('../../core/merge.js', () => ({ + mergeWorktree: vi.fn(async (_root: string, wt: WorktreeEntry, opts: Record = {}) => ({ + worktreeId: wt.id, + branch: wt.branch, + baseBranch: wt.baseBranch, + strategy: (opts.strategy as string) ?? 'squash', + cleaned: opts.cleanup !== false, + selfProtected: false, + })), +})); + +vi.mock('../../core/kill.js', () => ({ + killWorktreeAgents: vi.fn(async (_root: string, wt: WorktreeEntry) => { + const killed = Object.values(wt.agents) + .filter((a) => a.status === 'running') + .map((a) => a.id); + return { worktreeId: wt.id, killed }; + }), +})); + +vi.mock('../../core/pr.js', () => ({ + createWorktreePr: vi.fn(async (_root: string, wt: WorktreeEntry) => ({ + worktreeId: wt.id, + branch: wt.branch, + baseBranch: wt.baseBranch, + prUrl: 'https://github.com/owner/repo/pull/1', + })), +})); + +// ---- Imports (after mocks) ---- + +import { resolveWorktree, updateManifest } from '../../core/manifest.js'; +import { mergeWorktree } from '../../core/merge.js'; +import { killWorktreeAgents } from '../../core/kill.js'; +import { createWorktreePr } from '../../core/pr.js'; +import { worktreeRoutes } from './worktrees.js'; + +const PROJECT_ROOT = '/tmp/project'; + +async function buildApp(): Promise { + const app = Fastify(); + app.decorate('projectRoot', PROJECT_ROOT); + await app.register(worktreeRoutes, { prefix: '/api' }); + await app.ready(); + return app; +} + +describe('worktreeRoutes', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockManifest.worktrees = {}; + }); + + // ================================================================== + // POST /api/worktrees/:id/merge + // ================================================================== + describe('POST /api/worktrees/:id/merge', () => { + test('given valid worktree, should merge with squash strategy by default', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + mockManifest.worktrees['wt-abc123'] = wt; + vi.mocked(resolveWorktree).mockReturnValue(wt); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-abc123/merge', + payload: {}, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.success).toBe(true); + expect(body.worktreeId).toBe('wt-abc123'); + expect(body.strategy).toBe('squash'); + expect(body.cleaned).toBe(true); + expect(vi.mocked(mergeWorktree)).toHaveBeenCalledWith( + PROJECT_ROOT, wt, { strategy: undefined, cleanup: undefined, force: undefined }, + ); + }); + + test('given strategy no-ff, should pass strategy to mergeWorktree', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + mockManifest.worktrees['wt-abc123'] = wt; + vi.mocked(resolveWorktree).mockReturnValue(wt); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-abc123/merge', + payload: { strategy: 'no-ff' }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().strategy).toBe('no-ff'); + expect(vi.mocked(mergeWorktree)).toHaveBeenCalledWith( + PROJECT_ROOT, wt, expect.objectContaining({ strategy: 'no-ff' }), + ); + }); + + test('given cleanup false, should pass cleanup false', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + mockManifest.worktrees['wt-abc123'] = wt; + vi.mocked(resolveWorktree).mockReturnValue(wt); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-abc123/merge', + payload: { cleanup: false }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().cleaned).toBe(false); + expect(vi.mocked(mergeWorktree)).toHaveBeenCalledWith( + PROJECT_ROOT, wt, expect.objectContaining({ cleanup: false }), + ); + }); + + test('given worktree not found, should return 404', async () => { + vi.mocked(resolveWorktree).mockReturnValue(undefined as unknown as ReturnType); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-nonexist/merge', + payload: {}, + }); + + expect(res.statusCode).toBe(404); + expect(res.json().code).toBe('WORKTREE_NOT_FOUND'); + }); + + test('given AGENTS_RUNNING error from core, should return 409', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + mockManifest.worktrees['wt-abc123'] = wt; + vi.mocked(resolveWorktree).mockReturnValue(wt); + + const { PpgError } = await import('../../lib/errors.js'); + vi.mocked(mergeWorktree).mockRejectedValueOnce( + new PpgError('1 agent(s) still running', 'AGENTS_RUNNING'), + ); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-abc123/merge', + payload: {}, + }); + + expect(res.statusCode).toBe(409); + expect(res.json().code).toBe('AGENTS_RUNNING'); + }); + + test('given force flag, should pass force to mergeWorktree', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + mockManifest.worktrees['wt-abc123'] = wt; + vi.mocked(resolveWorktree).mockReturnValue(wt); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-abc123/merge', + payload: { force: true }, + }); + + expect(res.statusCode).toBe(200); + expect(vi.mocked(mergeWorktree)).toHaveBeenCalledWith( + PROJECT_ROOT, wt, expect.objectContaining({ force: true }), + ); + }); + + test('given MERGE_FAILED error from core, should return 500', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + mockManifest.worktrees['wt-abc123'] = wt; + vi.mocked(resolveWorktree).mockReturnValue(wt); + + const { MergeFailedError } = await import('../../lib/errors.js'); + vi.mocked(mergeWorktree).mockRejectedValueOnce( + new MergeFailedError('Merge failed: conflict'), + ); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-abc123/merge', + payload: {}, + }); + + expect(res.statusCode).toBe(500); + expect(res.json().code).toBe('MERGE_FAILED'); + }); + }); + + // ================================================================== + // POST /api/worktrees/:id/kill + // ================================================================== + describe('POST /api/worktrees/:id/kill', () => { + test('given worktree with running agents, should kill via core and return killed list', async () => { + const agent1 = makeAgent({ id: 'ag-run00001', status: 'running' }); + const agent2 = makeAgent({ id: 'ag-idle0001', status: 'idle' }); + const wt = makeWorktree({ + id: 'wt-abc123', + agents: { 'ag-run00001': agent1, 'ag-idle0001': agent2 }, + }); + mockManifest.worktrees['wt-abc123'] = wt; + vi.mocked(resolveWorktree).mockReturnValue(wt); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-abc123/kill', + payload: {}, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.success).toBe(true); + expect(body.killed).toEqual(['ag-run00001']); + expect(vi.mocked(killWorktreeAgents)).toHaveBeenCalledWith(PROJECT_ROOT, wt); + }); + + test('given worktree with no running agents, should return empty killed list', async () => { + const agent = makeAgent({ id: 'ag-done0001', status: 'exited' }); + const wt = makeWorktree({ + id: 'wt-abc123', + agents: { 'ag-done0001': agent }, + }); + mockManifest.worktrees['wt-abc123'] = wt; + vi.mocked(resolveWorktree).mockReturnValue(wt); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-abc123/kill', + payload: {}, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().killed).toEqual([]); + }); + + test('given worktree not found, should return 404', async () => { + vi.mocked(resolveWorktree).mockReturnValue(undefined as unknown as ReturnType); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-nonexist/kill', + payload: {}, + }); + + expect(res.statusCode).toBe(404); + expect(res.json().code).toBe('WORKTREE_NOT_FOUND'); + }); + }); + + // ================================================================== + // POST /api/worktrees/:id/pr + // ================================================================== + describe('POST /api/worktrees/:id/pr', () => { + test('given valid worktree, should create PR and return URL', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + mockManifest.worktrees['wt-abc123'] = wt; + vi.mocked(resolveWorktree).mockReturnValue(wt); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-abc123/pr', + payload: { title: 'My PR', body: 'Description' }, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.success).toBe(true); + expect(body.prUrl).toBe('https://github.com/owner/repo/pull/1'); + expect(body.worktreeId).toBe('wt-abc123'); + expect(vi.mocked(createWorktreePr)).toHaveBeenCalledWith( + PROJECT_ROOT, wt, { title: 'My PR', body: 'Description', draft: undefined }, + ); + }); + + test('given draft flag, should pass draft to createWorktreePr', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + mockManifest.worktrees['wt-abc123'] = wt; + vi.mocked(resolveWorktree).mockReturnValue(wt); + + const app = await buildApp(); + await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-abc123/pr', + payload: { draft: true }, + }); + + expect(vi.mocked(createWorktreePr)).toHaveBeenCalledWith( + PROJECT_ROOT, wt, expect.objectContaining({ draft: true }), + ); + }); + + test('given worktree not found, should return 404', async () => { + vi.mocked(resolveWorktree).mockReturnValue(undefined as unknown as ReturnType); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-nonexist/pr', + payload: {}, + }); + + expect(res.statusCode).toBe(404); + expect(res.json().code).toBe('WORKTREE_NOT_FOUND'); + }); + + test('given GH_NOT_FOUND error from core, should return 502', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + mockManifest.worktrees['wt-abc123'] = wt; + vi.mocked(resolveWorktree).mockReturnValue(wt); + + const { GhNotFoundError } = await import('../../lib/errors.js'); + vi.mocked(createWorktreePr).mockRejectedValueOnce(new GhNotFoundError()); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-abc123/pr', + payload: {}, + }); + + expect(res.statusCode).toBe(502); + expect(res.json().code).toBe('GH_NOT_FOUND'); + }); + + test('given INVALID_ARGS error from core, should return 400', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + mockManifest.worktrees['wt-abc123'] = wt; + vi.mocked(resolveWorktree).mockReturnValue(wt); + + const { PpgError } = await import('../../lib/errors.js'); + vi.mocked(createWorktreePr).mockRejectedValueOnce( + new PpgError('Failed to push', 'INVALID_ARGS'), + ); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-abc123/pr', + payload: {}, + }); + + expect(res.statusCode).toBe(400); + expect(res.json().code).toBe('INVALID_ARGS'); + }); + }); +}); diff --git a/src/server/routes/worktrees.ts b/src/server/routes/worktrees.ts new file mode 100644 index 0000000..517da5a --- /dev/null +++ b/src/server/routes/worktrees.ts @@ -0,0 +1,124 @@ +import type { FastifyInstance, FastifyReply } from 'fastify'; +import { updateManifest, resolveWorktree } from '../../core/manifest.js'; +import { refreshAllAgentStatuses } from '../../core/agent.js'; +import { mergeWorktree } from '../../core/merge.js'; +import { killWorktreeAgents } from '../../core/kill.js'; +import { createWorktreePr } from '../../core/pr.js'; +import { PpgError, WorktreeNotFoundError } from '../../lib/errors.js'; + +// ------------------------------------------------------------------ +// Fastify plugin — worktree action routes +// ------------------------------------------------------------------ + +declare module 'fastify' { + interface FastifyInstance { + projectRoot: string; + } +} + +interface WorktreeParams { + id: string; +} + +interface MergeBody { + strategy?: 'squash' | 'no-ff'; + cleanup?: boolean; + force?: boolean; +} + +interface PrBody { + title?: string; + body?: string; + draft?: boolean; +} + +function errorReply(reply: FastifyReply, err: unknown): FastifyReply { + if (err instanceof PpgError) { + const statusMap: Record = { + WORKTREE_NOT_FOUND: 404, + AGENT_NOT_FOUND: 404, + NOT_INITIALIZED: 400, + AGENTS_RUNNING: 409, + MERGE_FAILED: 500, + GH_NOT_FOUND: 502, + INVALID_ARGS: 400, + }; + const status = statusMap[err.code] ?? 500; + return reply.code(status).send({ error: err.message, code: err.code }); + } + const message = err instanceof Error ? err.message : String(err); + return reply.code(500).send({ error: message }); +} + +async function resolveWorktreeFromRequest( + projectRoot: string, + id: string, +) { + const manifest = await updateManifest(projectRoot, async (m) => { + return refreshAllAgentStatuses(m, projectRoot); + }); + + const wt = resolveWorktree(manifest, id); + if (!wt) throw new WorktreeNotFoundError(id); + return wt; +} + +export async function worktreeRoutes(app: FastifyInstance): Promise { + const { projectRoot } = app; + + // ---------------------------------------------------------------- + // POST /api/worktrees/:id/merge + // ---------------------------------------------------------------- + app.post<{ Params: WorktreeParams; Body: MergeBody }>( + '/worktrees/:id/merge', + async (request, reply) => { + try { + const wt = await resolveWorktreeFromRequest(projectRoot, request.params.id); + const { strategy, cleanup, force } = request.body ?? {}; + + const result = await mergeWorktree(projectRoot, wt, { strategy, cleanup, force }); + + return { success: true, ...result }; + } catch (err) { + return errorReply(reply, err); + } + }, + ); + + // ---------------------------------------------------------------- + // POST /api/worktrees/:id/kill + // ---------------------------------------------------------------- + app.post<{ Params: WorktreeParams }>( + '/worktrees/:id/kill', + async (request, reply) => { + try { + const wt = await resolveWorktreeFromRequest(projectRoot, request.params.id); + + const result = await killWorktreeAgents(projectRoot, wt); + + return { success: true, ...result }; + } catch (err) { + return errorReply(reply, err); + } + }, + ); + + // ---------------------------------------------------------------- + // POST /api/worktrees/:id/pr + // ---------------------------------------------------------------- + app.post<{ Params: WorktreeParams; Body: PrBody }>( + '/worktrees/:id/pr', + async (request, reply) => { + try { + const wt = await resolveWorktreeFromRequest(projectRoot, request.params.id); + const { title, body, draft } = request.body ?? {}; + + const result = await createWorktreePr(projectRoot, wt, { title, body, draft }); + + return { success: true, ...result }; + } catch (err) { + return errorReply(reply, err); + } + }, + ); +} diff --git a/src/server/tls.test.ts b/src/server/tls.test.ts new file mode 100644 index 0000000..7bc050b --- /dev/null +++ b/src/server/tls.test.ts @@ -0,0 +1,253 @@ +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { ensureTls, getLanIps, buildPairingUrl } from './tls.js'; +import { + tlsCaKeyPath, + tlsCaCertPath, + tlsServerKeyPath, + tlsServerCertPath, +} from '../lib/paths.js'; + +vi.setConfig({ testTimeout: 30_000 }); + +let tmpDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ppg-tls-test-')); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('ensureTls', () => { + test('generates valid PEM certificates', () => { + const bundle = ensureTls(tmpDir); + + expect(bundle.caCert).toMatch(/^-----BEGIN CERTIFICATE-----/); + expect(bundle.caCert).toMatch(/-----END CERTIFICATE-----\n$/); + expect(bundle.caKey).toMatch(/^-----BEGIN PRIVATE KEY-----/); + expect(bundle.serverCert).toMatch(/^-----BEGIN CERTIFICATE-----/); + expect(bundle.serverKey).toMatch(/^-----BEGIN PRIVATE KEY-----/); + }); + + test('CA cert has cA:TRUE and ~10 year validity', () => { + const bundle = ensureTls(tmpDir); + const ca = new crypto.X509Certificate(bundle.caCert); + + expect(ca.subject).toBe('CN=ppg-ca'); + expect(ca.issuer).toBe('CN=ppg-ca'); + expect(ca.ca).toBe(true); + + const notAfter = new Date(ca.validTo); + const yearsFromNow = (notAfter.getTime() - Date.now()) / (1000 * 60 * 60 * 24 * 365); + expect(yearsFromNow).toBeGreaterThan(9); + expect(yearsFromNow).toBeLessThan(11); + }); + + test('server cert is signed by CA with ~1 year validity', () => { + const bundle = ensureTls(tmpDir); + const ca = new crypto.X509Certificate(bundle.caCert); + const server = new crypto.X509Certificate(bundle.serverCert); + + expect(server.subject).toBe('CN=ppg-server'); + expect(server.issuer).toBe('CN=ppg-ca'); + expect(server.verify(ca.publicKey)).toBe(true); + expect(server.ca).toBe(false); + + const notAfter = new Date(server.validTo); + const daysFromNow = (notAfter.getTime() - Date.now()) / (1000 * 60 * 60 * 24); + expect(daysFromNow).toBeGreaterThan(360); + expect(daysFromNow).toBeLessThan(370); + }); + + test('server cert includes correct SANs', () => { + const bundle = ensureTls(tmpDir); + const server = new crypto.X509Certificate(bundle.serverCert); + const sanStr = server.subjectAltName ?? ''; + + expect(sanStr).toContain('IP Address:127.0.0.1'); + + for (const ip of bundle.sans) { + expect(sanStr).toContain(`IP Address:${ip}`); + } + }); + + test('persists files with correct permissions', () => { + ensureTls(tmpDir); + + const files = [ + tlsCaKeyPath(tmpDir), + tlsCaCertPath(tmpDir), + tlsServerKeyPath(tmpDir), + tlsServerCertPath(tmpDir), + ]; + + for (const f of files) { + expect(fs.existsSync(f)).toBe(true); + const stat = fs.statSync(f); + expect(stat.mode & 0o777).toBe(0o600); + } + }); + + test('reuses valid certs without rewriting', async () => { + const bundle1 = ensureTls(tmpDir); + const mtime1 = fs.statSync(tlsCaCertPath(tmpDir)).mtimeMs; + + // Small delay to ensure mtime would differ if rewritten + await new Promise((r) => setTimeout(r, 50)); + + const bundle2 = ensureTls(tmpDir); + const mtime2 = fs.statSync(tlsCaCertPath(tmpDir)).mtimeMs; + + expect(bundle2.caFingerprint).toBe(bundle1.caFingerprint); + expect(bundle2.caCert).toBe(bundle1.caCert); + expect(bundle2.serverCert).toBe(bundle1.serverCert); + expect(mtime2).toBe(mtime1); + }); + + test('regenerates server cert when SAN is missing', () => { + const bundle1 = ensureTls(tmpDir); + + // Replace server cert with CA cert (has no SANs matching LAN IPs) + fs.writeFileSync(tlsServerCertPath(tmpDir), bundle1.caCert, { mode: 0o600 }); + + const bundle2 = ensureTls(tmpDir); + + // CA should be preserved + expect(bundle2.caCert).toBe(bundle1.caCert); + expect(bundle2.caFingerprint).toBe(bundle1.caFingerprint); + + // Server cert should be regenerated + expect(bundle2.serverCert).not.toBe(bundle1.caCert); + const server = new crypto.X509Certificate(bundle2.serverCert); + expect(server.subject).toBe('CN=ppg-server'); + }); + + test('regenerates server cert when signed by a different CA', () => { + const bundle1 = ensureTls(tmpDir); + const otherDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ppg-tls-test-other-')); + + try { + const otherBundle = ensureTls(otherDir); + fs.writeFileSync(tlsServerCertPath(tmpDir), otherBundle.serverCert, { mode: 0o600 }); + fs.writeFileSync(tlsServerKeyPath(tmpDir), otherBundle.serverKey, { mode: 0o600 }); + + const bundle2 = ensureTls(tmpDir); + const ca = new crypto.X509Certificate(bundle1.caCert); + const server = new crypto.X509Certificate(bundle2.serverCert); + + expect(bundle2.caFingerprint).toBe(bundle1.caFingerprint); + expect(server.verify(ca.publicKey)).toBe(true); + expect(bundle2.serverCert).not.toBe(otherBundle.serverCert); + } finally { + fs.rmSync(otherDir, { recursive: true, force: true }); + } + }); + + test('regenerates server cert when server key does not match cert', () => { + const bundle1 = ensureTls(tmpDir); + const { privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + const wrongKey = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string; + fs.writeFileSync(tlsServerKeyPath(tmpDir), wrongKey, { mode: 0o600 }); + + const bundle2 = ensureTls(tmpDir); + const server = new crypto.X509Certificate(bundle2.serverCert); + + expect(bundle2.caFingerprint).toBe(bundle1.caFingerprint); + expect(bundle2.serverKey).not.toBe(wrongKey); + expect(server.checkPrivateKey(crypto.createPrivateKey(bundle2.serverKey))).toBe(true); + }); + + test('regenerates everything when CA cert file is missing', () => { + const bundle1 = ensureTls(tmpDir); + + fs.unlinkSync(tlsCaCertPath(tmpDir)); + + const bundle2 = ensureTls(tmpDir); + + expect(bundle2.caFingerprint).not.toBe(bundle1.caFingerprint); + }); + + test('regenerates everything when CA key does not match CA cert', () => { + const bundle1 = ensureTls(tmpDir); + const { privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + const wrongCaKey = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string; + fs.writeFileSync(tlsCaKeyPath(tmpDir), wrongCaKey, { mode: 0o600 }); + + const bundle2 = ensureTls(tmpDir); + + expect(bundle2.caFingerprint).not.toBe(bundle1.caFingerprint); + }); + + test('regenerates everything when PEM files contain garbage', () => { + ensureTls(tmpDir); + + // Corrupt both cert files with garbage + fs.writeFileSync(tlsCaCertPath(tmpDir), 'not a cert', { mode: 0o600 }); + fs.writeFileSync(tlsServerCertPath(tmpDir), 'also garbage', { mode: 0o600 }); + + // Should regenerate without throwing + const bundle = ensureTls(tmpDir); + + expect(bundle.caCert).toMatch(/^-----BEGIN CERTIFICATE-----/); + const ca = new crypto.X509Certificate(bundle.caCert); + expect(ca.subject).toBe('CN=ppg-ca'); + }); + + test('CA fingerprint is colon-delimited SHA-256 hex', () => { + const bundle = ensureTls(tmpDir); + + // Format: XX:XX:XX:... (32 hex pairs with colons) + expect(bundle.caFingerprint).toMatch(/^([0-9A-F]{2}:){31}[0-9A-F]{2}$/); + }); + + test('CA fingerprint is stable across calls', () => { + const bundle1 = ensureTls(tmpDir); + const bundle2 = ensureTls(tmpDir); + + expect(bundle2.caFingerprint).toBe(bundle1.caFingerprint); + }); +}); + +describe('getLanIps', () => { + test('always includes 127.0.0.1', () => { + const ips = getLanIps(); + expect(ips).toContain('127.0.0.1'); + }); + + test('returns only IPv4 addresses', () => { + const ips = getLanIps(); + for (const ip of ips) { + expect(ip).toMatch(/^\d+\.\d+\.\d+\.\d+$/); + } + }); +}); + +describe('buildPairingUrl', () => { + test('formats ppg:// URL with query params', () => { + const url = buildPairingUrl({ + host: '192.168.1.5', + port: 3000, + caFingerprint: 'AA:BB:CC', + token: 'tok123', + }); + + expect(url).toBe('ppg://connect?host=192.168.1.5&port=3000&ca=AA%3ABB%3ACC&token=tok123'); + }); + + test('encodes special characters in params', () => { + const url = buildPairingUrl({ + host: '10.0.0.1', + port: 443, + caFingerprint: 'AA:BB', + token: 'a b+c', + }); + + expect(url).toContain('token=a+b%2Bc'); + }); +}); diff --git a/src/server/tls.ts b/src/server/tls.ts new file mode 100644 index 0000000..577b671 --- /dev/null +++ b/src/server/tls.ts @@ -0,0 +1,502 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; + +import { + tlsDir, + tlsCaKeyPath, + tlsCaCertPath, + tlsServerKeyPath, + tlsServerCertPath, +} from '../lib/paths.js'; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +export interface TlsBundle { + caCert: string; + caKey: string; + serverCert: string; + serverKey: string; + caFingerprint: string; + sans: string[]; +} + +// --------------------------------------------------------------------------- +// ASN.1 / DER primitives +// --------------------------------------------------------------------------- + +function derLength(len: number): Buffer { + if (len < 0x80) return Buffer.from([len]); + if (len < 0x100) return Buffer.from([0x81, len]); + if (len <= 0xffff) return Buffer.from([0x82, (len >> 8) & 0xff, len & 0xff]); + throw new Error(`DER length ${len} exceeds 2-byte encoding`); +} + +function derTlv(tag: number, value: Buffer): Buffer { + return Buffer.concat([Buffer.from([tag]), derLength(value.length), value]); +} + +function derSeq(items: Buffer[]): Buffer { + return derTlv(0x30, Buffer.concat(items)); +} + +function derSet(items: Buffer[]): Buffer { + return derTlv(0x31, Buffer.concat(items)); +} + +function derInteger(n: Buffer | number): Buffer { + let buf: Buffer; + if (typeof n === 'number') { + // Encode small integers — used for version field (0, 2) + if (n === 0) { + buf = Buffer.from([0]); + } else { + const hex = n.toString(16); + buf = Buffer.from(hex.length % 2 ? '0' + hex : hex, 'hex'); + if (buf[0] & 0x80) buf = Buffer.concat([Buffer.from([0]), buf]); + } + } else { + buf = n; + if (buf[0] & 0x80) buf = Buffer.concat([Buffer.from([0]), buf]); + } + return derTlv(0x02, buf); +} + +function derOid(encoded: number[]): Buffer { + return derTlv(0x06, Buffer.from(encoded)); +} + +function derUtf8(s: string): Buffer { + return derTlv(0x0c, Buffer.from(s, 'utf8')); +} + +function derUtcTime(d: Date): Buffer { + const s = + String(d.getUTCFullYear()).slice(2) + + String(d.getUTCMonth() + 1).padStart(2, '0') + + String(d.getUTCDate()).padStart(2, '0') + + String(d.getUTCHours()).padStart(2, '0') + + String(d.getUTCMinutes()).padStart(2, '0') + + String(d.getUTCSeconds()).padStart(2, '0') + + 'Z'; + return derTlv(0x17, Buffer.from(s, 'ascii')); +} + +function derGeneralizedTime(d: Date): Buffer { + const s = + String(d.getUTCFullYear()) + + String(d.getUTCMonth() + 1).padStart(2, '0') + + String(d.getUTCDate()).padStart(2, '0') + + String(d.getUTCHours()).padStart(2, '0') + + String(d.getUTCMinutes()).padStart(2, '0') + + String(d.getUTCSeconds()).padStart(2, '0') + + 'Z'; + return derTlv(0x18, Buffer.from(s, 'ascii')); +} + +function derBitString(data: Buffer): Buffer { + // Prepend 0x00 (unused-bits count) + return derTlv(0x03, Buffer.concat([Buffer.from([0]), data])); +} + +function derNull(): Buffer { + return Buffer.from([0x05, 0x00]); +} + +/** Context-tagged explicit wrapper: [tagNum] EXPLICIT */ +function derContextExplicit(tagNum: number, inner: Buffer): Buffer { + return derTlv(0xa0 | tagNum, inner); +} + +/** Context-tagged OCTET STRING wrapper */ +function derContextOctetString(tagNum: number, inner: Buffer): Buffer { + return derTlv(0x80 | tagNum, inner); +} + +// --------------------------------------------------------------------------- +// OIDs +// --------------------------------------------------------------------------- + +// sha256WithRSAEncryption 1.2.840.113549.1.1.11 +const OID_SHA256_RSA = [0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x0b]; +// commonName 2.5.4.3 +const OID_CN = [0x55, 0x04, 0x03]; +// basicConstraints 2.5.29.19 +const OID_BASIC_CONSTRAINTS = [0x55, 0x1d, 0x13]; +// keyUsage 2.5.29.15 +const OID_KEY_USAGE = [0x55, 0x1d, 0x0f]; +// subjectAltName 2.5.29.17 +const OID_SAN = [0x55, 0x1d, 0x11]; + +// --------------------------------------------------------------------------- +// Structural helpers +// --------------------------------------------------------------------------- + +function buildAlgorithmIdentifier(): Buffer { + return derSeq([derOid(OID_SHA256_RSA), derNull()]); +} + +function buildName(cn: string): Buffer { + const rdn = derSet([derSeq([derOid(OID_CN), derUtf8(cn)])]); + return derSeq([rdn]); +} + +function buildValidity(from: Date, to: Date): Buffer { + // Use UTCTime for dates before 2050, GeneralizedTime otherwise + const encodeTime = (d: Date) => + d.getUTCFullYear() < 2050 ? derUtcTime(d) : derGeneralizedTime(d); + return derSeq([encodeTime(from), encodeTime(to)]); +} + +function buildBasicConstraintsExt(isCA: boolean, critical: boolean): Buffer { + const value = derSeq(isCA ? [derTlv(0x01, Buffer.from([0xff]))] : []); + const octetValue = derTlv(0x04, value); + const parts: Buffer[] = [derOid(OID_BASIC_CONSTRAINTS)]; + if (critical) parts.push(derTlv(0x01, Buffer.from([0xff]))); + parts.push(octetValue); + return derSeq(parts); +} + +function buildKeyUsageExt(isCA: boolean, critical: boolean): Buffer { + let bits: number; + if (isCA) { + // keyCertSign (5) | cRLSign (6) → byte = 0x06, unused = 1 + bits = 0x06; + } else { + // digitalSignature (0) | keyEncipherment (2) → byte = 0xa0, unused = 5 + bits = 0xa0; + } + const unusedBits = isCA ? 1 : 5; + const bitStringContent = Buffer.from([unusedBits, bits]); + const bitString = derTlv(0x03, bitStringContent); + const octetValue = derTlv(0x04, bitString); + const parts: Buffer[] = [derOid(OID_KEY_USAGE)]; + if (critical) parts.push(derTlv(0x01, Buffer.from([0xff]))); + parts.push(octetValue); + return derSeq(parts); +} + +function buildSanExt(ips: string[]): Buffer { + const names = ips.map((ip) => { + const bytes = ip.split('.').map(Number); + return derContextOctetString(7, Buffer.from(bytes)); + }); + const sanValue = derSeq(names); + const octetValue = derTlv(0x04, sanValue); + return derSeq([derOid(OID_SAN), octetValue]); +} + +function buildExtensions(exts: Buffer[]): Buffer { + return derContextExplicit(3, derSeq(exts)); +} + +// --------------------------------------------------------------------------- +// Certificate generation +// --------------------------------------------------------------------------- + +function generateSerial(): Buffer { + const bytes = crypto.randomBytes(16); + // Ensure positive (clear high bit) + bytes[0] &= 0x7f; + // Ensure non-zero + if (bytes[0] === 0) bytes[0] = 1; + return bytes; +} + +function buildTbs(options: { + serial: Buffer; + issuer: Buffer; + subject: Buffer; + validity: Buffer; + publicKeyInfo: Buffer; + extensions: Buffer; +}): Buffer { + return derSeq([ + derContextExplicit(0, derInteger(2)), // v3 + derInteger(options.serial), + buildAlgorithmIdentifier(), + options.issuer, + options.validity, + options.subject, + options.publicKeyInfo, + options.extensions, + ]); +} + +function wrapCertificate(tbs: Buffer, signature: Buffer): Buffer { + return derSeq([tbs, buildAlgorithmIdentifier(), derBitString(signature)]); +} + +function toPem(tag: string, der: Buffer): string { + const b64 = der.toString('base64'); + const lines: string[] = []; + for (let i = 0; i < b64.length; i += 64) { + lines.push(b64.slice(i, i + 64)); + } + return `-----BEGIN ${tag}-----\n${lines.join('\n')}\n-----END ${tag}-----\n`; +} + +function wrapAndSign( + tbs: Buffer, + signingKey: crypto.KeyObject, + subjectKey: crypto.KeyObject, +): { cert: string; key: string } { + const signature = crypto.sign('sha256', tbs, signingKey); + return { + cert: toPem('CERTIFICATE', wrapCertificate(tbs, signature)), + key: subjectKey.export({ type: 'pkcs8', format: 'pem' }) as string, + }; +} + +function buildCertTbs(options: { + issuerCn: string; + subjectCn: string; + validityYears: number; + publicKeyDer: Buffer; + extensions: Buffer[]; +}): Buffer { + const now = new Date(); + const notAfter = new Date(now); + notAfter.setUTCFullYear(notAfter.getUTCFullYear() + options.validityYears); + + return buildTbs({ + serial: generateSerial(), + issuer: buildName(options.issuerCn), + subject: buildName(options.subjectCn), + validity: buildValidity(now, notAfter), + publicKeyInfo: Buffer.from(options.publicKeyDer), + extensions: buildExtensions(options.extensions), + }); +} + +function generateCaCert(): { cert: string; key: string } { + // Self-signed: same keypair for subject and signer + const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + + const tbs = buildCertTbs({ + issuerCn: 'ppg-ca', + subjectCn: 'ppg-ca', + validityYears: 10, + publicKeyDer: publicKey.export({ type: 'spki', format: 'der' }), + extensions: [ + buildBasicConstraintsExt(true, true), + buildKeyUsageExt(true, true), + ], + }); + + return wrapAndSign(tbs, privateKey, privateKey); +} + +function generateServerCert(caKey: string, sans: string[]): { cert: string; key: string } { + const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + + const tbs = buildCertTbs({ + issuerCn: 'ppg-ca', + subjectCn: 'ppg-server', + validityYears: 1, + publicKeyDer: publicKey.export({ type: 'spki', format: 'der' }), + extensions: [ + buildBasicConstraintsExt(false, false), + buildKeyUsageExt(false, false), + buildSanExt(sans), + ], + }); + + return wrapAndSign(tbs, crypto.createPrivateKey(caKey), privateKey); +} + +// --------------------------------------------------------------------------- +// LAN IP detection +// --------------------------------------------------------------------------- + +export function getLanIps(): string[] { + const interfaces = os.networkInterfaces(); + const ips = new Set(); + ips.add('127.0.0.1'); + + for (const infos of Object.values(interfaces)) { + if (!infos) continue; + for (const info of infos) { + if (info.family === 'IPv4' && !info.internal) { + ips.add(info.address); + } + } + } + + return [...ips]; +} + +// --------------------------------------------------------------------------- +// Pairing URL +// --------------------------------------------------------------------------- + +export function buildPairingUrl(params: { + host: string; + port: number; + caFingerprint: string; + token: string; +}): string { + const q = new URLSearchParams({ + host: params.host, + port: String(params.port), + ca: params.caFingerprint, + token: params.token, + }); + return `ppg://connect?${q.toString()}`; +} + +// --------------------------------------------------------------------------- +// File I/O and reuse logic +// --------------------------------------------------------------------------- + +function loadTlsBundle(projectRoot: string): TlsBundle | null { + const paths = [ + tlsCaKeyPath(projectRoot), + tlsCaCertPath(projectRoot), + tlsServerKeyPath(projectRoot), + tlsServerCertPath(projectRoot), + ]; + + const contents: string[] = []; + for (const p of paths) { + try { + contents.push(fs.readFileSync(p, 'utf8')); + } catch { + return null; + } + } + + const [caKey, caCert, serverKey, serverCert] = contents; + + try { + const x509 = new crypto.X509Certificate(caCert); + const serverX509 = new crypto.X509Certificate(serverCert); + const fingerprint = x509.fingerprint256; + const sans = parseIpSans(serverX509.subjectAltName); + + return { caCert, caKey, serverCert, serverKey, caFingerprint: fingerprint, sans }; + } catch { + return null; + } +} + +function isCaValid(caCert: string, caKey: string, minDaysRemaining: number): boolean { + try { + const x509 = new crypto.X509Certificate(caCert); + if (x509.subject !== 'CN=ppg-ca' || x509.issuer !== 'CN=ppg-ca' || !x509.ca) { + return false; + } + if (!x509.verify(x509.publicKey)) return false; + if (!x509.checkPrivateKey(crypto.createPrivateKey(caKey))) return false; + + const notAfter = new Date(x509.validTo); + const remaining = (notAfter.getTime() - Date.now()) / (1000 * 60 * 60 * 24); + return remaining > minDaysRemaining; + } catch { + return false; + } +} + +function isServerCertValid( + serverCert: string, + serverKey: string, + caCert: string, + requiredIps: string[], + minDaysRemaining: number, +): boolean { + try { + const caX509 = new crypto.X509Certificate(caCert); + const serverX509 = new crypto.X509Certificate(serverCert); + const notAfter = new Date(serverX509.validTo); + const remaining = (notAfter.getTime() - Date.now()) / (1000 * 60 * 60 * 24); + if (remaining <= minDaysRemaining) return false; + if (serverX509.subject !== 'CN=ppg-server' || serverX509.issuer !== caX509.subject) { + return false; + } + if (serverX509.ca) return false; + if (!serverX509.verify(caX509.publicKey)) return false; + if (!serverX509.checkPrivateKey(crypto.createPrivateKey(serverKey))) return false; + + const certIps = new Set(parseIpSans(serverX509.subjectAltName)); + + return requiredIps.every((ip) => certIps.has(ip)); + } catch { + return false; + } +} + +function writePemFile(filePath: string, content: string): void { + fs.writeFileSync(filePath, content, { mode: 0o600 }); +} + +function parseIpSans(subjectAltName: string | undefined): string[] { + const sanStr = subjectAltName ?? ''; + return [...sanStr.matchAll(/IP Address:(\d+\.\d+\.\d+\.\d+)/g)].map((m) => m[1]); +} + +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- + +export function ensureTls(projectRoot: string): TlsBundle { + const dir = tlsDir(projectRoot); + fs.mkdirSync(dir, { recursive: true }); + + const lanIps = getLanIps(); + const existing = loadTlsBundle(projectRoot); + + if (existing) { + // Check if everything is still valid + const caOk = isCaValid(existing.caCert, existing.caKey, 30); + const serverOk = isServerCertValid( + existing.serverCert, + existing.serverKey, + existing.caCert, + lanIps, + 7, + ); + + if (caOk && serverOk) { + return existing; + } + + // CA still valid — only regenerate server cert + if (caOk) { + const server = generateServerCert(existing.caKey, lanIps); + writePemFile(tlsServerKeyPath(projectRoot), server.key); + writePemFile(tlsServerCertPath(projectRoot), server.cert); + + const x509 = new crypto.X509Certificate(existing.caCert); + return { + caCert: existing.caCert, + caKey: existing.caKey, + serverCert: server.cert, + serverKey: server.key, + caFingerprint: x509.fingerprint256, + sans: lanIps, + }; + } + } + + // Generate everything fresh + const ca = generateCaCert(); + const server = generateServerCert(ca.key, lanIps); + + writePemFile(tlsCaKeyPath(projectRoot), ca.key); + writePemFile(tlsCaCertPath(projectRoot), ca.cert); + writePemFile(tlsServerKeyPath(projectRoot), server.key); + writePemFile(tlsServerCertPath(projectRoot), server.cert); + + const x509 = new crypto.X509Certificate(ca.cert); + + return { + caCert: ca.cert, + caKey: ca.key, + serverCert: server.cert, + serverKey: server.key, + caFingerprint: x509.fingerprint256, + sans: lanIps, + }; +} diff --git a/src/server/ws/events.ts b/src/server/ws/events.ts new file mode 100644 index 0000000..82878a6 --- /dev/null +++ b/src/server/ws/events.ts @@ -0,0 +1,110 @@ +import type { AgentStatus, Manifest, WorktreeStatus } from '../../types/manifest.js'; + +// --- Inbound Commands (client → server) --- + +export interface PingCommand { + type: 'ping'; +} + +export interface TerminalSubscribeCommand { + type: 'terminal:subscribe'; + agentId: string; +} + +export interface TerminalUnsubscribeCommand { + type: 'terminal:unsubscribe'; + agentId: string; +} + +export interface TerminalInputCommand { + type: 'terminal:input'; + agentId: string; + data: string; +} + +export type ClientCommand = + | PingCommand + | TerminalSubscribeCommand + | TerminalUnsubscribeCommand + | TerminalInputCommand; + +// --- Outbound Events (server → client) --- + +export interface PongEvent { + type: 'pong'; +} + +export interface ManifestUpdatedEvent { + type: 'manifest:updated'; + manifest: Manifest; +} + +export interface AgentStatusEvent { + type: 'agent:status'; + worktreeId: string; + agentId: string; + status: AgentStatus; + worktreeStatus: WorktreeStatus; +} + +export interface TerminalOutputEvent { + type: 'terminal:output'; + agentId: string; + data: string; +} + +export interface ErrorEvent { + type: 'error'; + code: string; + message: string; +} + +export type ServerEvent = + | PongEvent + | ManifestUpdatedEvent + | AgentStatusEvent + | TerminalOutputEvent + | ErrorEvent; + +// --- Parsing --- + +const VALID_COMMAND_TYPES = new Set([ + 'ping', + 'terminal:subscribe', + 'terminal:unsubscribe', + 'terminal:input', +]); + +export function parseCommand(raw: string): ClientCommand | null { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + + if (typeof parsed !== 'object' || parsed === null) return null; + + const obj = parsed as Record; + if (typeof obj.type !== 'string' || !VALID_COMMAND_TYPES.has(obj.type)) return null; + + if (obj.type === 'ping') { + return { type: 'ping' }; + } + + if (obj.type === 'terminal:subscribe' || obj.type === 'terminal:unsubscribe') { + if (typeof obj.agentId !== 'string') return null; + return { type: obj.type, agentId: obj.agentId }; + } + + if (obj.type === 'terminal:input') { + if (typeof obj.agentId !== 'string' || typeof obj.data !== 'string') return null; + return { type: 'terminal:input', agentId: obj.agentId, data: obj.data }; + } + + return null; +} + +export function serializeEvent(event: ServerEvent): string { + return JSON.stringify(event); +} diff --git a/src/server/ws/handler.test.ts b/src/server/ws/handler.test.ts new file mode 100644 index 0000000..532b81f --- /dev/null +++ b/src/server/ws/handler.test.ts @@ -0,0 +1,463 @@ +import { describe, test, expect, afterEach } from 'vitest'; +import http from 'node:http'; +import { WebSocket, type RawData } from 'ws'; +import { createWsHandler, type WsHandler } from './handler.js'; +import { parseCommand, serializeEvent, type ServerEvent } from './events.js'; + +// --- Helpers --- + +function createTestServer(): http.Server { + return http.createServer((_req, res) => { + res.writeHead(404); + res.end(); + }); +} + +function listen(server: http.Server): Promise { + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const addr = server.address(); + if (typeof addr === 'object' && addr !== null) { + resolve(addr.port); + } + }); + }); +} + +function closeServer(server: http.Server): Promise { + return new Promise((resolve) => { + server.close(() => resolve()); + }); +} + +function connectWs(port: number, token: string): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws?token=${token}`); + ws.on('open', () => resolve(ws)); + ws.on('error', reject); + }); +} + +function waitForMessage(ws: WebSocket): Promise { + return new Promise((resolve) => { + ws.once('message', (data: RawData) => { + const str = (() => { + if (typeof data === 'string') return data; + if (Buffer.isBuffer(data)) return data.toString('utf-8'); + if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf-8'); + if (Array.isArray(data)) return Buffer.concat(data).toString('utf-8'); + return ''; + })(); + resolve(JSON.parse(str) as ServerEvent); + }); + }); +} + +/** Wait for a ws client to close or error (rejected upgrades emit error then close) */ +function waitForDisconnect(ws: WebSocket): Promise { + return new Promise((resolve) => { + if (ws.readyState === WebSocket.CLOSED) { + resolve(); + return; + } + ws.on('close', () => resolve()); + ws.on('error', () => { + if (ws.readyState === WebSocket.CLOSED) resolve(); + }); + }); +} + +function send(ws: WebSocket, obj: Record): void { + ws.send(JSON.stringify(obj)); +} + +/** Send a ping and wait for pong — acts as a deterministic sync barrier. */ +async function roundTrip(ws: WebSocket): Promise { + const msg = waitForMessage(ws); + send(ws, { type: 'ping' }); + await msg; +} + +// --- Tests --- + +describe('WebSocket handler', () => { + let server: http.Server; + let handler: WsHandler; + const openSockets: WebSocket[] = []; + + async function setup( + opts: { + validateToken?: (token: string) => boolean | Promise; + onTerminalInput?: (agentId: string, data: string) => void | Promise; + } = {}, + ): Promise { + server = createTestServer(); + const port = await listen(server); + handler = createWsHandler({ + server, + validateToken: opts.validateToken ?? ((t) => t === 'valid-token'), + onTerminalInput: opts.onTerminalInput, + }); + return port; + } + + async function connect(port: number, token = 'valid-token'): Promise { + const ws = await connectWs(port, token); + openSockets.push(ws); + return ws; + } + + afterEach(async () => { + for (const ws of openSockets) { + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + ws.close(); + } + } + openSockets.length = 0; + + if (handler) { + await handler.close().catch(() => {}); + } + if (server?.listening) { + await closeServer(server); + } + }); + + describe('connection and auth', () => { + test('accepts connection with valid token', async () => { + const port = await setup(); + const ws = await connect(port); + expect(ws.readyState).toBe(WebSocket.OPEN); + expect(handler.clients.size).toBe(1); + }); + + test('rejects connection with invalid token', async () => { + const port = await setup(); + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws?token=bad-token`); + openSockets.push(ws); + + await waitForDisconnect(ws); + expect(handler.clients.size).toBe(0); + }); + + test('rejects connection with no token', async () => { + const port = await setup(); + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`); + openSockets.push(ws); + + await waitForDisconnect(ws); + expect(handler.clients.size).toBe(0); + }); + + test('rejects connection on wrong path', async () => { + const port = await setup(); + const ws = new WebSocket(`ws://127.0.0.1:${port}/other?token=valid-token`); + openSockets.push(ws); + + await waitForDisconnect(ws); + expect(handler.clients.size).toBe(0); + }); + + test('supports async token validation', async () => { + const port = await setup({ + validateToken: async (t) => t === 'async-token', + }); + const ws = await connect(port, 'async-token'); + expect(ws.readyState).toBe(WebSocket.OPEN); + }); + }); + + describe('command dispatch', () => { + test('responds to ping with pong', async () => { + const port = await setup(); + const ws = await connect(port); + + const msgPromise = waitForMessage(ws); + send(ws, { type: 'ping' }); + + const event = await msgPromise; + expect(event).toEqual({ type: 'pong' }); + }); + + test('sends error for invalid JSON', async () => { + const port = await setup(); + const ws = await connect(port); + + const msgPromise = waitForMessage(ws); + ws.send('not json'); + + const event = await msgPromise; + expect(event.type).toBe('error'); + expect((event as { code: string }).code).toBe('INVALID_COMMAND'); + }); + + test('sends error for unknown command type', async () => { + const port = await setup(); + const ws = await connect(port); + + const msgPromise = waitForMessage(ws); + send(ws, { type: 'unknown' }); + + const event = await msgPromise; + expect(event.type).toBe('error'); + expect((event as { code: string }).code).toBe('INVALID_COMMAND'); + }); + + test('handles terminal:subscribe', async () => { + const port = await setup(); + const ws = await connect(port); + + send(ws, { type: 'terminal:subscribe', agentId: 'ag-12345678' }); + await roundTrip(ws); + + const [client] = handler.clients; + expect(client.subscribedAgents.has('ag-12345678')).toBe(true); + }); + + test('handles terminal:unsubscribe', async () => { + const port = await setup(); + const ws = await connect(port); + + send(ws, { type: 'terminal:subscribe', agentId: 'ag-12345678' }); + await roundTrip(ws); + + send(ws, { type: 'terminal:unsubscribe', agentId: 'ag-12345678' }); + await roundTrip(ws); + + const [client] = handler.clients; + expect(client.subscribedAgents.has('ag-12345678')).toBe(false); + }); + + test('handles terminal:input and calls onTerminalInput', async () => { + let capturedAgentId = ''; + let capturedData = ''; + + const port = await setup({ + onTerminalInput: (agentId, data) => { + capturedAgentId = agentId; + capturedData = data; + }, + }); + const ws = await connect(port); + + send(ws, { type: 'terminal:input', agentId: 'ag-12345678', data: 'hello\n' }); + await roundTrip(ws); + + expect(capturedAgentId).toBe('ag-12345678'); + expect(capturedData).toBe('hello\n'); + }); + + test('terminal:input is a no-op when onTerminalInput is not provided', async () => { + const port = await setup(); // no onTerminalInput + const ws = await connect(port); + + send(ws, { type: 'terminal:input', agentId: 'ag-12345678', data: 'hello\n' }); + // Should not throw or send error — verify via round-trip + const msg = waitForMessage(ws); + send(ws, { type: 'ping' }); + const event = await msg; + expect(event).toEqual({ type: 'pong' }); + }); + + test('terminal:input sends error when onTerminalInput throws', async () => { + const port = await setup({ + onTerminalInput: () => { + throw new Error('tmux exploded'); + }, + }); + const ws = await connect(port); + + const msgPromise = waitForMessage(ws); + send(ws, { type: 'terminal:input', agentId: 'ag-12345678', data: 'hello\n' }); + + const event = await msgPromise; + expect(event.type).toBe('error'); + expect((event as { code: string }).code).toBe('TERMINAL_INPUT_FAILED'); + }); + + test('terminal:input sends error when async onTerminalInput rejects', async () => { + const port = await setup({ + onTerminalInput: async () => { + throw new Error('async tmux exploded'); + }, + }); + const ws = await connect(port); + + const msgPromise = waitForMessage(ws); + send(ws, { type: 'terminal:input', agentId: 'ag-12345678', data: 'hello\n' }); + + const event = await msgPromise; + expect(event.type).toBe('error'); + expect((event as { code: string }).code).toBe('TERMINAL_INPUT_FAILED'); + }); + }); + + describe('broadcast and sendEvent', () => { + test('broadcast sends to all connected clients', async () => { + const port = await setup(); + const ws1 = await connect(port); + const ws2 = await connect(port); + + expect(handler.clients.size).toBe(2); + + const msg1 = waitForMessage(ws1); + const msg2 = waitForMessage(ws2); + + handler.broadcast({ + type: 'manifest:updated', + manifest: { + version: 1, + projectRoot: '/tmp', + sessionName: 'test', + worktrees: {}, + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', + }, + }); + + const [event1, event2] = await Promise.all([msg1, msg2]); + expect(event1.type).toBe('manifest:updated'); + expect(event2.type).toBe('manifest:updated'); + }); + + test('sendEvent sends to specific client only', async () => { + const port = await setup(); + const ws1 = await connect(port); + const ws2 = await connect(port); + + const [client1] = handler.clients; + handler.sendEvent(client1, { type: 'pong' }); + + // ws1 should receive the pong + const event = await waitForMessage(ws1); + expect(event).toEqual({ type: 'pong' }); + + // ws2 should have no pending messages — verify by sending a ping + // and confirming the next message is the pong, not the earlier event + const msg2 = waitForMessage(ws2); + send(ws2, { type: 'ping' }); + const event2 = await msg2; + expect(event2).toEqual({ type: 'pong' }); + }); + + test('sendEvent skips client with closed socket', async () => { + const port = await setup(); + const ws = await connect(port); + + const [client] = handler.clients; + ws.close(); + await waitForDisconnect(ws); + + // Should not throw when sending to a closed client + handler.sendEvent(client, { type: 'pong' }); + }); + }); + + describe('cleanup', () => { + test('removes client on disconnect', async () => { + const port = await setup(); + const ws = await connect(port); + + expect(handler.clients.size).toBe(1); + + ws.close(); + await waitForDisconnect(ws); + // Use a round-trip on a second connection as a sync barrier + const ws2 = await connect(port); + await roundTrip(ws2); + + expect(handler.clients.size).toBe(1); // only ws2 remains + }); + + test('close() terminates all clients', async () => { + const port = await setup(); + const ws1 = await connect(port); + const ws2 = await connect(port); + + const close1 = waitForDisconnect(ws1); + const close2 = waitForDisconnect(ws2); + + await handler.close(); + await Promise.all([close1, close2]); + + expect(handler.clients.size).toBe(0); + }); + + test('close() removes upgrade listener from server', async () => { + const port = await setup(); + await handler.close(); + + // After close, a new WS connection attempt should not be handled + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws?token=valid-token`); + openSockets.push(ws); + + await waitForDisconnect(ws); + expect(handler.clients.size).toBe(0); + }); + }); +}); + +describe('parseCommand', () => { + test('parses ping command', () => { + expect(parseCommand('{"type":"ping"}')).toEqual({ type: 'ping' }); + }); + + test('parses terminal:subscribe', () => { + expect(parseCommand('{"type":"terminal:subscribe","agentId":"ag-123"}')).toEqual({ + type: 'terminal:subscribe', + agentId: 'ag-123', + }); + }); + + test('parses terminal:unsubscribe', () => { + expect(parseCommand('{"type":"terminal:unsubscribe","agentId":"ag-123"}')).toEqual({ + type: 'terminal:unsubscribe', + agentId: 'ag-123', + }); + }); + + test('parses terminal:input', () => { + expect(parseCommand('{"type":"terminal:input","agentId":"ag-123","data":"ls\\n"}')).toEqual({ + type: 'terminal:input', + agentId: 'ag-123', + data: 'ls\n', + }); + }); + + test('returns null for invalid JSON', () => { + expect(parseCommand('not json')).toBeNull(); + }); + + test('returns null for unknown type', () => { + expect(parseCommand('{"type":"unknown"}')).toBeNull(); + }); + + test('returns null for missing required fields', () => { + expect(parseCommand('{"type":"terminal:subscribe"}')).toBeNull(); + expect(parseCommand('{"type":"terminal:input","agentId":"ag-123"}')).toBeNull(); + }); + + test('returns null for non-object', () => { + expect(parseCommand('"string"')).toBeNull(); + expect(parseCommand('42')).toBeNull(); + expect(parseCommand('null')).toBeNull(); + }); +}); + +describe('serializeEvent', () => { + test('serializes pong event', () => { + expect(serializeEvent({ type: 'pong' })).toBe('{"type":"pong"}'); + }); + + test('serializes error event', () => { + const event: ServerEvent = { type: 'error', code: 'TEST', message: 'msg' }; + const parsed = JSON.parse(serializeEvent(event)); + expect(parsed).toEqual({ type: 'error', code: 'TEST', message: 'msg' }); + }); + + test('serializes terminal:output event', () => { + const event: ServerEvent = { type: 'terminal:output', agentId: 'ag-1', data: 'hello' }; + const parsed = JSON.parse(serializeEvent(event)); + expect(parsed).toEqual({ type: 'terminal:output', agentId: 'ag-1', data: 'hello' }); + }); +}); diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts new file mode 100644 index 0000000..757d690 --- /dev/null +++ b/src/server/ws/handler.ts @@ -0,0 +1,214 @@ +import { URL } from 'node:url'; +import type { Server as HttpServer, IncomingMessage } from 'node:http'; +import { WebSocketServer, WebSocket } from 'ws'; +import type { RawData } from 'ws'; +import type { Duplex } from 'node:stream'; +import { + parseCommand, + serializeEvent, + type ClientCommand, + type ServerEvent, +} from './events.js'; + +// --- Client State --- + +export interface ClientState { + ws: WebSocket; + subscribedAgents: Set; +} + +// --- Handler Options --- + +export interface WsHandlerOptions { + server: HttpServer; + validateToken: (token: string) => boolean | Promise; + onTerminalInput?: (agentId: string, data: string) => void | Promise; +} + +// --- WebSocket Handler --- + +export interface WsHandler { + wss: WebSocketServer; + clients: Set; + broadcast: (event: ServerEvent) => void; + sendEvent: (client: ClientState, event: ServerEvent) => void; + close: () => Promise; +} + +const MAX_PAYLOAD = 65_536; // 64 KB + +export function createWsHandler(options: WsHandlerOptions): WsHandler { + const { server, validateToken, onTerminalInput } = options; + + const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_PAYLOAD }); + const clients = new Set(); + + function sendData(ws: WebSocket, data: string): boolean { + if (ws.readyState !== WebSocket.OPEN) return false; + try { + ws.send(data); + return true; + } catch { + return false; + } + } + + function decodeRawData(raw: RawData): string { + if (typeof raw === 'string') return raw; + if (Buffer.isBuffer(raw)) return raw.toString('utf-8'); + if (raw instanceof ArrayBuffer) return Buffer.from(raw).toString('utf-8'); + if (Array.isArray(raw)) return Buffer.concat(raw).toString('utf-8'); + return ''; + } + + function rejectUpgrade(socket: Duplex, statusLine: string): void { + if (socket.destroyed) return; + try { + socket.write(`${statusLine}\r\nConnection: close\r\n\r\n`); + } catch { + // ignore write errors on broken sockets + } finally { + socket.destroy(); + } + } + + function sendEvent(client: ClientState, event: ServerEvent): void { + if (!sendData(client.ws, serializeEvent(event))) { + clients.delete(client); + } + } + + function broadcast(event: ServerEvent): void { + const data = serializeEvent(event); + for (const client of clients) { + if (!sendData(client.ws, data)) { + clients.delete(client); + } + } + } + + function handleCommand(client: ClientState, command: ClientCommand): void { + switch (command.type) { + case 'ping': + sendEvent(client, { type: 'pong' }); + break; + + case 'terminal:subscribe': + client.subscribedAgents.add(command.agentId); + break; + + case 'terminal:unsubscribe': + client.subscribedAgents.delete(command.agentId); + break; + + case 'terminal:input': + if (onTerminalInput) { + try { + Promise.resolve(onTerminalInput(command.agentId, command.data)).catch(() => { + sendEvent(client, { + type: 'error', + code: 'TERMINAL_INPUT_FAILED', + message: `Failed to send input to agent ${command.agentId}`, + }); + }); + } catch { + sendEvent(client, { + type: 'error', + code: 'TERMINAL_INPUT_FAILED', + message: `Failed to send input to agent ${command.agentId}`, + }); + } + } + break; + } + } + + function onUpgrade(request: IncomingMessage, socket: Duplex, head: Buffer): void { + let url: URL; + try { + // The path/query in request.url is all we need; avoid trusting Host header. + url = new URL(request.url ?? '/', 'http://localhost'); + } catch { + rejectUpgrade(socket, 'HTTP/1.1 400 Bad Request'); + return; + } + + if (url.pathname !== '/ws') { + rejectUpgrade(socket, 'HTTP/1.1 404 Not Found'); + return; + } + + const token = url.searchParams.get('token'); + if (!token) { + rejectUpgrade(socket, 'HTTP/1.1 401 Unauthorized'); + return; + } + + Promise.resolve(validateToken(token)) + .then((valid) => { + if (socket.destroyed) return; + if (!valid) { + rejectUpgrade(socket, 'HTTP/1.1 401 Unauthorized'); + return; + } + + try { + wss.handleUpgrade(request, socket, head, (ws) => { + wss.emit('connection', ws, request); + }); + } catch { + rejectUpgrade(socket, 'HTTP/1.1 500 Internal Server Error'); + } + }) + .catch(() => { + rejectUpgrade(socket, 'HTTP/1.1 500 Internal Server Error'); + }); + } + + server.on('upgrade', onUpgrade); + + wss.on('connection', (ws: WebSocket) => { + const client: ClientState = { + ws, + subscribedAgents: new Set(), + }; + clients.add(client); + + ws.on('message', (raw: RawData) => { + const data = decodeRawData(raw); + const command = parseCommand(data); + + if (!command) { + sendEvent(client, { + type: 'error', + code: 'INVALID_COMMAND', + message: 'Could not parse command', + }); + return; + } + + handleCommand(client, command); + }); + + ws.on('close', () => { + clients.delete(client); + }); + + ws.on('error', () => { + clients.delete(client); + }); + }); + + async function close(): Promise { + server.removeListener('upgrade', onUpgrade); + for (const client of clients) { + client.ws.close(1001, 'Server shutting down'); + } + await new Promise((resolve, reject) => { + wss.close((err) => (err ? reject(err) : resolve())); + }); + clients.clear(); + } + + return { wss, clients, broadcast, sendEvent, close }; +} diff --git a/src/server/ws/terminal.test.ts b/src/server/ws/terminal.test.ts new file mode 100644 index 0000000..125e022 --- /dev/null +++ b/src/server/ws/terminal.test.ts @@ -0,0 +1,347 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import { diffLines, TerminalStreamer } from './terminal.js'; +import type { TerminalData, TerminalError } from './terminal.js'; + +// --------------------------------------------------------------------------- +// diffLines — longest common suffix algorithm +// --------------------------------------------------------------------------- + +describe('diffLines', () => { + test('given empty prev, should return all of curr', () => { + const result = diffLines([], ['line1', 'line2']); + expect(result).toEqual(['line1', 'line2']); + }); + + test('given empty curr, should return empty', () => { + const result = diffLines(['line1', 'line2'], []); + expect(result).toEqual([]); + }); + + test('given identical buffers, should return empty', () => { + const lines = ['a', 'b', 'c']; + const result = diffLines(lines, [...lines]); + expect(result).toEqual([]); + }); + + test('given appended lines, should return only new lines', () => { + const prev = ['line1', 'line2']; + const curr = ['line1', 'line2', 'line3', 'line4']; + const result = diffLines(prev, curr); + expect(result).toEqual(['line3', 'line4']); + }); + + test('given scrolled buffer with new lines, should return new lines', () => { + // Terminal scrolled: line1 is gone, lines 2-3 remain, line4 is new + const prev = ['line1', 'line2', 'line3']; + const curr = ['line2', 'line3', 'line4']; + const result = diffLines(prev, curr); + expect(result).toEqual(['line4']); + }); + + test('given completely different content, should return all of curr', () => { + const prev = ['aaa', 'bbb']; + const curr = ['xxx', 'yyy']; + const result = diffLines(prev, curr); + expect(result).toEqual(['xxx', 'yyy']); + }); + + test('given partial overlap in scrolled buffer, should detect suffix match', () => { + const prev = ['a', 'b', 'c', 'd']; + const curr = ['c', 'd', 'e', 'f']; + const result = diffLines(prev, curr); + expect(result).toEqual(['e', 'f']); + }); + + test('given single line overlap, should return new lines after overlap', () => { + const prev = ['x', 'y', 'z']; + const curr = ['z', 'new1', 'new2']; + const result = diffLines(prev, curr); + expect(result).toEqual(['new1', 'new2']); + }); + + test('given prev longer than curr with overlap, should return new lines', () => { + const prev = ['a', 'b', 'c', 'd', 'e']; + const curr = ['d', 'e', 'f']; + const result = diffLines(prev, curr); + expect(result).toEqual(['f']); + }); + + test('given trailing empty lines from tmux, should handle correctly', () => { + // capturePane often returns "line1\nline2\n" → split gives trailing '' + const prev = ['line1', 'line2', '']; + const curr = ['line1', 'line2', '', 'line3', '']; + const result = diffLines(prev, curr); + expect(result).toEqual(['line3', '']); + }); +}); + +// --------------------------------------------------------------------------- +// TerminalStreamer +// --------------------------------------------------------------------------- + +describe('TerminalStreamer', () => { + let streamer: TerminalStreamer; + let mockCapture: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + mockCapture = vi.fn<(target: string, lines?: number) => Promise>(); + streamer = new TerminalStreamer({ + pollIntervalMs: 500, + capture: mockCapture, + }); + }); + + afterEach(() => { + streamer.destroy(); + vi.useRealTimers(); + }); + + // -- Subscription lifecycle ----------------------------------------------- + + describe('subscription lifecycle', () => { + test('given first subscriber, should start polling', () => { + mockCapture.mockResolvedValue('hello'); + const send = vi.fn(); + + streamer.subscribe('ag-001', 'ppg:1.0', send); + + expect(streamer.subscriberCount('ag-001')).toBe(1); + expect(streamer.isPolling('ag-001')).toBe(true); + }); + + test('given second subscriber, should share timer', () => { + mockCapture.mockResolvedValue('hello'); + const send1 = vi.fn(); + const send2 = vi.fn(); + + streamer.subscribe('ag-001', 'ppg:1.0', send1); + streamer.subscribe('ag-001', 'ppg:1.0', send2); + + expect(streamer.subscriberCount('ag-001')).toBe(2); + expect(streamer.isPolling('ag-001')).toBe(true); + }); + + test('given unsubscribe of one, should keep timer for remaining', () => { + mockCapture.mockResolvedValue('hello'); + const send1 = vi.fn(); + const send2 = vi.fn(); + + const unsub1 = streamer.subscribe('ag-001', 'ppg:1.0', send1); + streamer.subscribe('ag-001', 'ppg:1.0', send2); + + unsub1(); + + expect(streamer.subscriberCount('ag-001')).toBe(1); + expect(streamer.isPolling('ag-001')).toBe(true); + }); + + test('given all unsubscribed, should stop polling and cleanup', () => { + mockCapture.mockResolvedValue('hello'); + const send = vi.fn(); + + const unsub = streamer.subscribe('ag-001', 'ppg:1.0', send); + unsub(); + + expect(streamer.subscriberCount('ag-001')).toBe(0); + expect(streamer.isPolling('ag-001')).toBe(false); + }); + + test('given double unsubscribe, should be idempotent', () => { + mockCapture.mockResolvedValue('hello'); + const send = vi.fn(); + + const unsub = streamer.subscribe('ag-001', 'ppg:1.0', send); + unsub(); + unsub(); // second call should not throw + + expect(streamer.subscriberCount('ag-001')).toBe(0); + expect(streamer.isPolling('ag-001')).toBe(false); + }); + + test('given multiple agents, should track independently', () => { + mockCapture.mockResolvedValue('hello'); + const send1 = vi.fn(); + const send2 = vi.fn(); + + streamer.subscribe('ag-001', 'ppg:1.0', send1); + streamer.subscribe('ag-002', 'ppg:1.1', send2); + + expect(streamer.subscriberCount('ag-001')).toBe(1); + expect(streamer.subscriberCount('ag-002')).toBe(1); + expect(streamer.isPolling('ag-001')).toBe(true); + expect(streamer.isPolling('ag-002')).toBe(true); + }); + }); + + // -- Polling & diff ------------------------------------------------------- + + describe('polling and diff', () => { + test('given initial content, should send all lines on first poll', async () => { + mockCapture.mockResolvedValue('line1\nline2\nline3'); + const send = vi.fn(); + + streamer.subscribe('ag-001', 'ppg:1.0', send); + + await vi.advanceTimersByTimeAsync(500); + + expect(mockCapture).toHaveBeenCalledWith('ppg:1.0'); + expect(send).toHaveBeenCalledTimes(1); + + const msg: TerminalData = JSON.parse(send.mock.calls[0][0]); + expect(msg.type).toBe('terminal'); + expect(msg.agentId).toBe('ag-001'); + expect(msg.lines).toEqual(['line1', 'line2', 'line3']); + }); + + test('given unchanged content, should not send', async () => { + mockCapture.mockResolvedValue('line1\nline2'); + const send = vi.fn(); + + streamer.subscribe('ag-001', 'ppg:1.0', send); + + await vi.advanceTimersByTimeAsync(500); + expect(send).toHaveBeenCalledTimes(1); + + // Same content on next poll + await vi.advanceTimersByTimeAsync(500); + expect(send).toHaveBeenCalledTimes(1); // No new call + }); + + test('given new lines appended, should send only diff', async () => { + mockCapture.mockResolvedValueOnce('line1\nline2'); + const send = vi.fn(); + + streamer.subscribe('ag-001', 'ppg:1.0', send); + await vi.advanceTimersByTimeAsync(500); + + // New lines appended + mockCapture.mockResolvedValueOnce('line1\nline2\nline3\nline4'); + await vi.advanceTimersByTimeAsync(500); + + expect(send).toHaveBeenCalledTimes(2); + const msg: TerminalData = JSON.parse(send.mock.calls[1][0]); + expect(msg.lines).toEqual(['line3', 'line4']); + }); + + test('given content broadcast to multiple subscribers, should send to all', async () => { + mockCapture.mockResolvedValue('hello'); + const send1 = vi.fn(); + const send2 = vi.fn(); + + streamer.subscribe('ag-001', 'ppg:1.0', send1); + streamer.subscribe('ag-001', 'ppg:1.0', send2); + + await vi.advanceTimersByTimeAsync(500); + + expect(send1).toHaveBeenCalledTimes(1); + expect(send2).toHaveBeenCalledTimes(1); + expect(send1.mock.calls[0][0]).toBe(send2.mock.calls[0][0]); + }); + + test('given 500ms interval, should not poll before interval', async () => { + mockCapture.mockResolvedValue('hello'); + const send = vi.fn(); + + streamer.subscribe('ag-001', 'ppg:1.0', send); + + await vi.advanceTimersByTimeAsync(200); + expect(mockCapture).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(300); + expect(mockCapture).toHaveBeenCalledTimes(1); + }); + }); + + // -- Error handling ------------------------------------------------------- + + describe('error handling', () => { + test('given pane capture fails, should send error and cleanup', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + mockCapture.mockRejectedValue(new Error('pane not found')); + const send = vi.fn(); + + streamer.subscribe('ag-001', 'ppg:1.0', send); + + await vi.advanceTimersByTimeAsync(500); + + expect(send).toHaveBeenCalledTimes(1); + const msg: TerminalError = JSON.parse(send.mock.calls[0][0]); + expect(msg.type).toBe('terminal:error'); + expect(msg.agentId).toBe('ag-001'); + expect(msg.error).toBe('Pane no longer available'); + + // Original error should be logged + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('pane not found'), + ); + + // Stream should be cleaned up + expect(streamer.subscriberCount('ag-001')).toBe(0); + expect(streamer.isPolling('ag-001')).toBe(false); + consoleSpy.mockRestore(); + }); + + test('given dead subscriber send throws, should remove subscriber', async () => { + mockCapture.mockResolvedValue('line1'); + const goodSend = vi.fn(); + const badSend = vi.fn().mockImplementation(() => { + throw new Error('connection closed'); + }); + + streamer.subscribe('ag-001', 'ppg:1.0', badSend); + streamer.subscribe('ag-001', 'ppg:1.0', goodSend); + + await vi.advanceTimersByTimeAsync(500); + + // Good subscriber got the message + expect(goodSend).toHaveBeenCalledTimes(1); + // Bad subscriber was removed + expect(streamer.subscriberCount('ag-001')).toBe(1); + }); + }); + + // -- Shared timer --------------------------------------------------------- + + describe('shared timer', () => { + test('given shared timer, should only call capture once per interval', async () => { + mockCapture.mockResolvedValue('data'); + const send1 = vi.fn(); + const send2 = vi.fn(); + const send3 = vi.fn(); + + streamer.subscribe('ag-001', 'ppg:1.0', send1); + streamer.subscribe('ag-001', 'ppg:1.0', send2); + streamer.subscribe('ag-001', 'ppg:1.0', send3); + + await vi.advanceTimersByTimeAsync(500); + + // Only one capture call despite three subscribers + expect(mockCapture).toHaveBeenCalledTimes(1); + }); + }); + + // -- destroy -------------------------------------------------------------- + + describe('destroy', () => { + test('given active streams, should clean up everything', async () => { + mockCapture.mockResolvedValue('data'); + const send1 = vi.fn(); + const send2 = vi.fn(); + + streamer.subscribe('ag-001', 'ppg:1.0', send1); + streamer.subscribe('ag-002', 'ppg:1.1', send2); + + streamer.destroy(); + + expect(streamer.subscriberCount('ag-001')).toBe(0); + expect(streamer.subscriberCount('ag-002')).toBe(0); + expect(streamer.isPolling('ag-001')).toBe(false); + expect(streamer.isPolling('ag-002')).toBe(false); + + // No more polling after destroy + await vi.advanceTimersByTimeAsync(1000); + expect(mockCapture).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/server/ws/terminal.ts b/src/server/ws/terminal.ts new file mode 100644 index 0000000..1d9defd --- /dev/null +++ b/src/server/ws/terminal.ts @@ -0,0 +1,240 @@ +import { capturePane } from '../../core/tmux.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** A function that sends a message to a connected client. */ +export type SendFn = (message: string) => void; + +/** Wire format for terminal data pushed to subscribers. */ +export interface TerminalData { + type: 'terminal'; + agentId: string; + lines: string[]; +} + +/** Wire format for terminal errors pushed to subscribers. */ +export interface TerminalError { + type: 'terminal:error'; + agentId: string; + error: string; +} + +/** Internal state for a single subscriber. */ +interface Subscriber { + id: number; + send: SendFn; +} + +/** Shared polling state for all subscribers watching the same agent. */ +interface AgentStream { + tmuxTarget: string; + subscribers: Map; + timer: ReturnType | null; + /** Previous captured lines, used by the diff algorithm. */ + lastLines: string[]; +} + +// --------------------------------------------------------------------------- +// Diff algorithm — longest common suffix +// --------------------------------------------------------------------------- + +/** + * Given the previous set of lines and the current set, return only the new + * lines that were appended to the terminal buffer. + * + * Strategy: find the longest suffix of `prev` that is also a prefix of `curr`. + * Everything in `curr` after that shared region is new output. + * + * This handles the common terminal pattern where existing content scrolls up + * and new content appears at the bottom. It degrades gracefully when content + * is rewritten (e.g. TUI redraw) — in that case the full buffer is sent. + */ +export function diffLines(prev: string[], curr: string[]): string[] { + if (prev.length === 0) return curr; + if (curr.length === 0) return []; + + // Find the longest suffix of prev that matches a prefix of curr. + // We search from the longest possible overlap downward. + const maxOverlap = Math.min(prev.length, curr.length); + + for (let overlap = maxOverlap; overlap > 0; overlap--) { + const prevStart = prev.length - overlap; + let match = true; + for (let i = 0; i < overlap; i++) { + if (prev[prevStart + i] !== curr[i]) { + match = false; + break; + } + } + if (match) { + return curr.slice(overlap); + } + } + + // No shared suffix/prefix — full content is "new" + return curr; +} + +// --------------------------------------------------------------------------- +// TerminalStreamer — manages per-agent subscriptions and shared polling +// --------------------------------------------------------------------------- + +const POLL_INTERVAL_MS = 500; + +export class TerminalStreamer { + private streams = new Map(); + private nextSubscriberId = 1; + private readonly pollIntervalMs: number; + /** Injectable capture function — defaults to tmux capturePane. */ + private readonly capture: (target: string, lines?: number) => Promise; + + constructor(options?: { + pollIntervalMs?: number; + capture?: (target: string, lines?: number) => Promise; + }) { + this.pollIntervalMs = options?.pollIntervalMs ?? POLL_INTERVAL_MS; + this.capture = options?.capture ?? capturePane; + } + + /** + * Subscribe a client to terminal output for an agent. + * Returns an unsubscribe function. + */ + subscribe( + agentId: string, + tmuxTarget: string, + send: SendFn, + ): () => void { + const subId = this.nextSubscriberId++; + + let stream = this.streams.get(agentId); + if (!stream) { + stream = { + tmuxTarget, + subscribers: new Map(), + timer: null, + lastLines: [], + }; + this.streams.set(agentId, stream); + } + + stream.subscribers.set(subId, { id: subId, send }); + + // Lazy init: start polling only when the first subscriber arrives + if (stream.timer === null) { + this.scheduleNextPoll(agentId, stream); + } + + // Return unsubscribe function + return () => { + this.unsubscribe(agentId, subId); + }; + } + + /** Number of active subscribers for an agent. */ + subscriberCount(agentId: string): number { + return this.streams.get(agentId)?.subscribers.size ?? 0; + } + + /** Whether a polling timer is active for an agent. */ + isPolling(agentId: string): boolean { + const stream = this.streams.get(agentId); + return stream !== undefined && stream.timer !== null; + } + + /** Tear down all streams and timers. */ + destroy(): void { + for (const stream of this.streams.values()) { + if (stream.timer !== null) { + clearTimeout(stream.timer); + stream.timer = null; + } + stream.subscribers.clear(); + } + this.streams.clear(); + } + + // ----------------------------------------------------------------------- + // Private + // ----------------------------------------------------------------------- + + private unsubscribe(agentId: string, subId: number): void { + const stream = this.streams.get(agentId); + if (!stream) return; + + stream.subscribers.delete(subId); + + // Auto-cleanup: stop polling when no subscribers remain + if (stream.subscribers.size === 0) { + if (stream.timer !== null) { + clearTimeout(stream.timer); + stream.timer = null; + } + this.streams.delete(agentId); + } + } + + private scheduleNextPoll(agentId: string, stream: AgentStream): void { + stream.timer = setTimeout(() => { + void this.poll(agentId, stream); + }, this.pollIntervalMs); + } + + private async poll(agentId: string, stream: AgentStream): Promise { + try { + const raw = await this.capture(stream.tmuxTarget); + const currentLines = raw.split('\n'); + + const newLines = diffLines(stream.lastLines, currentLines); + stream.lastLines = currentLines; + + if (newLines.length > 0) { + const message = JSON.stringify({ + type: 'terminal', + agentId, + lines: newLines, + } satisfies TerminalData); + + for (const sub of stream.subscribers.values()) { + try { + sub.send(message); + } catch { + // Dead client — remove immediately + stream.subscribers.delete(sub.id); + } + } + } + + // Schedule next poll only after this one completes + if (stream.subscribers.size > 0) { + this.scheduleNextPoll(agentId, stream); + } + } catch (err) { + // Pane gone / tmux error — notify subscribers and clean up + const errorMsg = JSON.stringify({ + type: 'terminal:error', + agentId, + error: 'Pane no longer available', + } satisfies TerminalError); + + if (err instanceof Error) { + console.error(`[ppg] terminal poll failed for ${agentId}: ${err.message}`); + } + + for (const sub of stream.subscribers.values()) { + try { + sub.send(errorMsg); + } catch { + // ignore + } + } + + // Stop polling — pane is dead + stream.timer = null; + stream.subscribers.clear(); + this.streams.delete(agentId); + } + } +} diff --git a/src/server/ws/watcher.test.ts b/src/server/ws/watcher.test.ts new file mode 100644 index 0000000..f246ddc --- /dev/null +++ b/src/server/ws/watcher.test.ts @@ -0,0 +1,391 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import { makeAgent, makeManifest, makeWorktree } from '../../test-fixtures.js'; +import type { WsEvent } from './watcher.js'; + +// Mock fs (synchronous watch API) +vi.mock('node:fs', () => ({ + default: { + watch: vi.fn((_path: string, _cb: (...args: unknown[]) => void) => ({ + on: vi.fn(), + close: vi.fn(), + })), + }, +})); + +// Mock core modules +vi.mock('../../core/manifest.js', () => ({ + readManifest: vi.fn(), +})); + +vi.mock('../../core/agent.js', () => ({ + checkAgentStatus: vi.fn(), +})); + +vi.mock('../../core/tmux.js', () => ({ + listSessionPanes: vi.fn(), +})); + +vi.mock('../../lib/paths.js', () => ({ + manifestPath: vi.fn(() => '/tmp/project/.ppg/manifest.json'), + ppgDir: vi.fn(() => '/tmp/project/.ppg'), +})); + +import nodefs from 'node:fs'; +import { readManifest } from '../../core/manifest.js'; +import { checkAgentStatus } from '../../core/agent.js'; +import { listSessionPanes } from '../../core/tmux.js'; +import { startManifestWatcher } from './watcher.js'; + +const mockedReadManifest = vi.mocked(readManifest); +const mockedCheckAgentStatus = vi.mocked(checkAgentStatus); +const mockedListSessionPanes = vi.mocked(listSessionPanes); +const mockedFsWatch = vi.mocked(nodefs.watch); + +const PROJECT_ROOT = '/tmp/project'; + +/** Trigger the most recent fs.watch callback (simulates file change) */ +function triggerFsWatch(): void { + const calls = mockedFsWatch.mock.calls; + if (calls.length > 0) { + const cb = calls[calls.length - 1][1] as () => void; + cb(); + } +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + mockedListSessionPanes.mockResolvedValue(new Map()); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('startManifestWatcher', () => { + describe('fs.watch debounce', () => { + test('given file change, should broadcast manifest:updated after debounce', async () => { + const agent = makeAgent({ id: 'ag-aaa11111', status: 'running' }); + const wt = makeWorktree({ id: 'wt-abc123', agents: { [agent.id]: agent } }); + const manifest = makeManifest({ projectRoot: PROJECT_ROOT, worktrees: { [wt.id]: wt } }); + mockedReadManifest.mockResolvedValue(manifest); + mockedCheckAgentStatus.mockResolvedValue({ status: 'running' }); + + const events: WsEvent[] = []; + const watcher = startManifestWatcher(PROJECT_ROOT, (e) => events.push(e), { + debounceMs: 300, + pollIntervalMs: 60_000, // effectively disable polling for this test + }); + + triggerFsWatch(); + + // Before debounce fires — no event yet + expect(events).toHaveLength(0); + + // Advance past debounce + await vi.advanceTimersByTimeAsync(350); + + expect(events).toHaveLength(1); + expect(events[0].type).toBe('manifest:updated'); + expect(events[0].payload).toEqual(manifest); + + watcher.stop(); + }); + + test('given rapid file changes, should debounce to single broadcast', async () => { + const manifest = makeManifest({ projectRoot: PROJECT_ROOT }); + mockedReadManifest.mockResolvedValue(manifest); + + const events: WsEvent[] = []; + const watcher = startManifestWatcher(PROJECT_ROOT, (e) => events.push(e), { + debounceMs: 300, + pollIntervalMs: 60_000, + }); + + // Three rapid changes + triggerFsWatch(); + await vi.advanceTimersByTimeAsync(100); + triggerFsWatch(); + await vi.advanceTimersByTimeAsync(100); + triggerFsWatch(); + + // Advance past debounce from last trigger + await vi.advanceTimersByTimeAsync(350); + + expect(events).toHaveLength(1); + expect(events[0].type).toBe('manifest:updated'); + + watcher.stop(); + }); + + test('given manifest read error during file change, should not broadcast', async () => { + mockedReadManifest.mockRejectedValue(new SyntaxError('Unexpected end of JSON')); + + const errors: unknown[] = []; + const events: WsEvent[] = []; + const watcher = startManifestWatcher(PROJECT_ROOT, (e) => events.push(e), { + debounceMs: 300, + pollIntervalMs: 60_000, + onError: (err) => errors.push(err), + }); + + triggerFsWatch(); + await vi.advanceTimersByTimeAsync(350); + + expect(events).toHaveLength(0); + expect(errors).toHaveLength(1); + expect(errors[0]).toBeInstanceOf(SyntaxError); + + watcher.stop(); + }); + }); + + describe('status polling', () => { + test('given agent status change, should broadcast agent:status', async () => { + const agent = makeAgent({ id: 'ag-aaa11111', status: 'running' }); + const wt = makeWorktree({ id: 'wt-abc123', agents: { [agent.id]: agent } }); + const manifest = makeManifest({ projectRoot: PROJECT_ROOT, worktrees: { [wt.id]: wt } }); + mockedReadManifest.mockResolvedValue(manifest); + + // First poll: running, second poll: idle + mockedCheckAgentStatus + .mockResolvedValueOnce({ status: 'running' }) + .mockResolvedValueOnce({ status: 'idle' }); + + const events: WsEvent[] = []; + const watcher = startManifestWatcher(PROJECT_ROOT, (e) => events.push(e), { + debounceMs: 300, + pollIntervalMs: 1000, + }); + + // First poll — establishes baseline, no change event + await vi.advanceTimersByTimeAsync(1000); + expect(events).toHaveLength(0); + + // Second poll — status changed from running → idle + await vi.advanceTimersByTimeAsync(1000); + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + type: 'agent:status', + payload: { + agentId: 'ag-aaa11111', + worktreeId: 'wt-abc123', + status: 'idle', + previousStatus: 'running', + }, + }); + + watcher.stop(); + }); + + test('given multiple agents across worktrees, should broadcast each change', async () => { + const agent1 = makeAgent({ id: 'ag-aaa11111', status: 'running', tmuxTarget: 'ppg:1.0' }); + const agent2 = makeAgent({ id: 'ag-bbb22222', status: 'running', tmuxTarget: 'ppg:2.0' }); + const wt1 = makeWorktree({ id: 'wt-aaa111', name: 'auth', agents: { [agent1.id]: agent1 } }); + const wt2 = makeWorktree({ id: 'wt-bbb222', name: 'api', agents: { [agent2.id]: agent2 } }); + const manifest = makeManifest({ + projectRoot: PROJECT_ROOT, + worktrees: { [wt1.id]: wt1, [wt2.id]: wt2 }, + }); + mockedReadManifest.mockResolvedValue(manifest); + + // First poll: both running. Second poll: agent1 idle, agent2 gone + mockedCheckAgentStatus + .mockResolvedValueOnce({ status: 'running' }) + .mockResolvedValueOnce({ status: 'running' }) + .mockResolvedValueOnce({ status: 'idle' }) + .mockResolvedValueOnce({ status: 'gone' }); + + const events: WsEvent[] = []; + const watcher = startManifestWatcher(PROJECT_ROOT, (e) => events.push(e), { + debounceMs: 300, + pollIntervalMs: 1000, + }); + + // First poll — baseline + await vi.advanceTimersByTimeAsync(1000); + expect(events).toHaveLength(0); + + // Second poll — both changed + await vi.advanceTimersByTimeAsync(1000); + expect(events).toHaveLength(2); + + const statusEvents = events.filter((e) => e.type === 'agent:status'); + expect(statusEvents).toHaveLength(2); + + const payloads = statusEvents.map((e) => e.payload); + expect(payloads).toContainEqual({ + agentId: 'ag-aaa11111', + worktreeId: 'wt-aaa111', + status: 'idle', + previousStatus: 'running', + }); + expect(payloads).toContainEqual({ + agentId: 'ag-bbb22222', + worktreeId: 'wt-bbb222', + status: 'gone', + previousStatus: 'running', + }); + + watcher.stop(); + }); + + test('given agent removed between polls, should not emit stale event', async () => { + const agent = makeAgent({ id: 'ag-aaa11111', status: 'running' }); + const wt = makeWorktree({ id: 'wt-abc123', agents: { [agent.id]: agent } }); + const manifestWithAgent = makeManifest({ projectRoot: PROJECT_ROOT, worktrees: { [wt.id]: wt } }); + const manifestEmpty = makeManifest({ projectRoot: PROJECT_ROOT, worktrees: {} }); + + mockedCheckAgentStatus.mockResolvedValue({ status: 'running' }); + + // First poll sees agent, second poll agent's worktree is gone + mockedReadManifest + .mockResolvedValueOnce(manifestWithAgent) + .mockResolvedValueOnce(manifestEmpty); + + const events: WsEvent[] = []; + const watcher = startManifestWatcher(PROJECT_ROOT, (e) => events.push(e), { + debounceMs: 300, + pollIntervalMs: 1000, + }); + + // First poll — baseline with agent + await vi.advanceTimersByTimeAsync(1000); + expect(events).toHaveLength(0); + + // Second poll — agent gone from manifest, no stale event emitted + await vi.advanceTimersByTimeAsync(1000); + expect(events).toHaveLength(0); + + watcher.stop(); + }); + + test('given no status change, should not broadcast', async () => { + const agent = makeAgent({ id: 'ag-aaa11111', status: 'running' }); + const wt = makeWorktree({ id: 'wt-abc123', agents: { [agent.id]: agent } }); + const manifest = makeManifest({ projectRoot: PROJECT_ROOT, worktrees: { [wt.id]: wt } }); + mockedReadManifest.mockResolvedValue(manifest); + mockedCheckAgentStatus.mockResolvedValue({ status: 'running' }); + + const events: WsEvent[] = []; + const watcher = startManifestWatcher(PROJECT_ROOT, (e) => events.push(e), { + debounceMs: 300, + pollIntervalMs: 1000, + }); + + // Two polls — same status each time + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(1000); + + expect(events).toHaveLength(0); + + watcher.stop(); + }); + + test('given manifest read failure during poll, should skip cycle and report error', async () => { + const readError = new Error('ENOENT'); + mockedReadManifest.mockRejectedValue(readError); + + const errors: unknown[] = []; + const events: WsEvent[] = []; + const watcher = startManifestWatcher(PROJECT_ROOT, (e) => events.push(e), { + debounceMs: 300, + pollIntervalMs: 1000, + onError: (err) => errors.push(err), + }); + + await vi.advanceTimersByTimeAsync(1000); + expect(events).toHaveLength(0); + expect(errors).toHaveLength(1); + expect(errors[0]).toBe(readError); + + watcher.stop(); + }); + + test('given tmux unavailable during poll, should skip cycle and report error', async () => { + const manifest = makeManifest({ projectRoot: PROJECT_ROOT }); + mockedReadManifest.mockResolvedValue(manifest); + const tmuxError = new Error('tmux not found'); + mockedListSessionPanes.mockRejectedValue(tmuxError); + + const errors: unknown[] = []; + const events: WsEvent[] = []; + const watcher = startManifestWatcher(PROJECT_ROOT, (e) => events.push(e), { + debounceMs: 300, + pollIntervalMs: 1000, + onError: (err) => errors.push(err), + }); + + await vi.advanceTimersByTimeAsync(1000); + expect(events).toHaveLength(0); + expect(errors).toHaveLength(1); + expect(errors[0]).toBe(tmuxError); + + watcher.stop(); + }); + }); + + describe('overlap guard', () => { + test('given slow poll, should skip overlapping tick', async () => { + const agent = makeAgent({ id: 'ag-aaa11111', status: 'running' }); + const wt = makeWorktree({ id: 'wt-abc123', agents: { [agent.id]: agent } }); + const manifest = makeManifest({ projectRoot: PROJECT_ROOT, worktrees: { [wt.id]: wt } }); + + // readManifest takes 1500ms on first call (longer than pollInterval) + let callCount = 0; + mockedReadManifest.mockImplementation(() => { + callCount++; + if (callCount === 1) { + return new Promise((resolve) => setTimeout(() => resolve(manifest), 1500)); + } + return Promise.resolve(manifest); + }); + mockedCheckAgentStatus.mockResolvedValue({ status: 'running' }); + + const events: WsEvent[] = []; + const watcher = startManifestWatcher(PROJECT_ROOT, (e) => events.push(e), { + debounceMs: 300, + pollIntervalMs: 1000, + }); + + // First tick at 1000ms starts a slow poll + await vi.advanceTimersByTimeAsync(1000); + // Second tick at 2000ms — poll still running, should be skipped + await vi.advanceTimersByTimeAsync(1000); + // Finish slow poll at 2500ms + await vi.advanceTimersByTimeAsync(500); + + // readManifest called once for the slow poll, second tick was skipped + expect(callCount).toBe(1); + + watcher.stop(); + }); + }); + + describe('cleanup', () => { + test('stop should clear all timers and close watcher', async () => { + const manifest = makeManifest({ projectRoot: PROJECT_ROOT }); + mockedReadManifest.mockResolvedValue(manifest); + + const events: WsEvent[] = []; + const watcher = startManifestWatcher(PROJECT_ROOT, (e) => events.push(e), { + debounceMs: 300, + pollIntervalMs: 1000, + }); + + watcher.stop(); + + // Trigger fs.watch and advance timers — nothing should fire + triggerFsWatch(); + await vi.advanceTimersByTimeAsync(5000); + + expect(events).toHaveLength(0); + + // Verify fs.watch close was called + const watchResults = mockedFsWatch.mock.results; + expect(watchResults.length).toBeGreaterThan(0); + const fsWatcher = watchResults[0].value as { close: ReturnType }; + expect(fsWatcher.close).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/server/ws/watcher.ts b/src/server/ws/watcher.ts new file mode 100644 index 0000000..7e149dd --- /dev/null +++ b/src/server/ws/watcher.ts @@ -0,0 +1,172 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { readManifest } from '../../core/manifest.js'; +import { checkAgentStatus } from '../../core/agent.js'; +import { listSessionPanes, type PaneInfo } from '../../core/tmux.js'; +import { manifestPath, ppgDir } from '../../lib/paths.js'; +import type { AgentStatus, Manifest } from '../../types/manifest.js'; + +export type WsEvent = + | { type: 'manifest:updated'; payload: Manifest } + | { type: 'agent:status'; payload: { agentId: string; worktreeId: string; status: AgentStatus; previousStatus: AgentStatus } }; + +export type BroadcastFn = (event: WsEvent) => void; + +export type ErrorFn = (error: unknown) => void; + +export interface ManifestWatcher { + stop(): void; +} + +/** + * Start watching manifest.json for changes and polling agent statuses. + * + * Two sources of change: + * 1. `fs.watch` on manifest.json — fires `manifest:updated` (debounced 300ms) + * 2. Status poll at `pollIntervalMs` — fires `agent:status` per changed agent + * + * Note: `manifest:updated` and `agent:status` are independent streams. + * A file change that adds/removes agents won't produce `agent:status` events + * until the next poll cycle. Consumers needing immediate agent awareness + * should derive it from the `manifest:updated` payload. + * + * The watcher must start after `ppg init` — if manifest.json doesn't exist + * at startup, the parent directory is watched and the file watcher is + * established once the manifest appears. + */ +export function startManifestWatcher( + projectRoot: string, + broadcast: BroadcastFn, + options?: { debounceMs?: number; pollIntervalMs?: number; onError?: ErrorFn }, +): ManifestWatcher { + const debounceMs = options?.debounceMs ?? 300; + const pollIntervalMs = options?.pollIntervalMs ?? 3000; + const onError = options?.onError; + + let debounceTimer: ReturnType | null = null; + let previousStatuses = new Map(); + let polling = false; + let stopped = false; + + // --- fs.watch on manifest.json (with directory fallback) --- + const mPath = manifestPath(projectRoot); + let fileWatcher: fs.FSWatcher | null = null; + let dirWatcher: fs.FSWatcher | null = null; + + function onFsChange(): void { + if (stopped) return; + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + if (stopped) return; + onManifestFileChange().catch((err) => onError?.(err)); + }, debounceMs); + } + + function watchManifestFile(): boolean { + try { + fileWatcher = fs.watch(mPath, onFsChange); + fileWatcher.on('error', () => {}); + return true; + } catch { + return false; + } + } + + // Try to watch manifest directly; fall back to watching .ppg/ directory + if (!watchManifestFile()) { + try { + const dir = ppgDir(projectRoot); + dirWatcher = fs.watch(dir, (_event, filename) => { + if (filename === path.basename(mPath) && !fileWatcher) { + if (watchManifestFile()) { + dirWatcher?.close(); + dirWatcher = null; + } + onFsChange(); + } + }); + dirWatcher.on('error', () => {}); + } catch { + // .ppg/ doesn't exist yet either — polling still works + } + } + + async function onManifestFileChange(): Promise { + try { + const manifest = await readManifest(projectRoot); + broadcast({ type: 'manifest:updated', payload: manifest }); + } catch (err) { + onError?.(err); + } + } + + // --- Status polling --- + const pollTimer = setInterval(() => { + if (stopped) return; + pollStatuses().catch((err) => onError?.(err)); + }, pollIntervalMs); + + async function pollStatuses(): Promise { + if (polling) return; + polling = true; + try { + let manifest: Manifest; + try { + manifest = await readManifest(projectRoot); + } catch (err) { + onError?.(err); + return; + } + + let paneMap: Map; + try { + paneMap = await listSessionPanes(manifest.sessionName); + } catch (err) { + onError?.(err); + return; + } + + // Collect all agents with their worktree context + const agents = Object.values(manifest.worktrees).flatMap((wt) => + Object.values(wt.agents).map((agent) => ({ agent, worktreeId: wt.id })), + ); + + // Check statuses in parallel (checkAgentStatus does no I/O when paneMap is provided) + const results = await Promise.all( + agents.map(({ agent }) => + checkAgentStatus(agent, projectRoot, paneMap).catch(() => null), + ), + ); + + const nextStatuses = new Map(); + for (let i = 0; i < agents.length; i++) { + const result = results[i]; + if (!result) continue; + + const { agent, worktreeId } = agents[i]; + nextStatuses.set(agent.id, result.status); + + const prev = previousStatuses.get(agent.id); + if (prev !== undefined && prev !== result.status) { + broadcast({ + type: 'agent:status', + payload: { agentId: agent.id, worktreeId, status: result.status, previousStatus: prev }, + }); + } + } + previousStatuses = nextStatuses; + } finally { + polling = false; + } + } + + return { + stop() { + stopped = true; + if (debounceTimer) clearTimeout(debounceTimer); + if (fileWatcher) fileWatcher.close(); + if (dirWatcher) dirWatcher.close(); + clearInterval(pollTimer); + }, + }; +} diff --git a/src/test-fixtures.ts b/src/test-fixtures.ts index 3a4c12b..38c7c4b 100644 --- a/src/test-fixtures.ts +++ b/src/test-fixtures.ts @@ -1,4 +1,4 @@ -import type { AgentEntry, WorktreeEntry } from './types/manifest.js'; +import type { AgentEntry, Manifest, WorktreeEntry } from './types/manifest.js'; import type { PaneInfo } from './core/tmux.js'; export function makeAgent(overrides?: Partial): AgentEntry { @@ -38,3 +38,15 @@ export function makePaneInfo(overrides?: Partial): PaneInfo { ...overrides, }; } + +export function makeManifest(overrides?: Partial): Manifest { + return { + version: 1, + projectRoot: '/tmp/project', + sessionName: 'ppg', + worktrees: {}, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ...overrides, + }; +} From 244ebc12658eaa32c052b3f4b26e4ac8040af436 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 16:05:57 -0600 Subject: [PATCH 2/2] refactor: abstract tmux into ProcessManager interface Introduce a ProcessManager interface that abstracts tmux operations behind a backend-agnostic API, enabling future support for alternative process managers (e.g. ConPTY on Windows). - Add src/core/process-manager.ts with ProcessManager interface and types - Add TmuxBackend class to tmux.ts implementing ProcessManager - Add src/core/backend.ts factory with getBackend()/setBackend()/resetBackend() - Add src/core/conpty.ts experimental ConPTY stub using node-pty - Update all imports across 25+ production files and 10+ test files - Add platform branching to terminal.ts (macOS/Windows/Linux) --- package.json | 3 + src/commands/aggregate.ts | 4 +- src/commands/attach.ts | 11 +- src/commands/clean.ts | 5 +- src/commands/cron.ts | 12 +- src/commands/init.ts | 6 +- src/commands/kill.ts | 6 +- src/commands/logs.ts | 6 +- src/commands/reset.ts | 7 +- src/commands/send.ts | 8 +- src/commands/serve.test.ts | 36 ++- src/commands/serve.ts | 12 +- src/commands/swarm.ts | 18 +- src/core/agent.test.ts | 33 ++- src/core/agent.ts | 36 +-- src/core/backend.ts | 34 +++ src/core/cleanup.test.ts | 20 +- src/core/cleanup.ts | 6 +- src/core/conpty.ts | 418 ++++++++++++++++++++++++++++ src/core/operations/kill.test.ts | 12 +- src/core/operations/kill.ts | 5 +- src/core/operations/merge.test.ts | 12 +- src/core/operations/merge.ts | 5 +- src/core/operations/restart.test.ts | 15 +- src/core/operations/restart.ts | 6 +- src/core/operations/spawn.test.ts | 23 +- src/core/operations/spawn.ts | 18 +- src/core/process-manager.ts | 31 +++ src/core/self.test.ts | 2 +- src/core/self.ts | 2 +- src/core/spawn.ts | 10 +- src/core/terminal.ts | 78 +++++- src/core/tmux.ts | 23 ++ src/server/routes/agents.test.ts | 37 +-- src/server/routes/agents.ts | 10 +- src/server/ws/terminal.ts | 4 +- src/server/ws/watcher.test.ts | 10 +- src/server/ws/watcher.ts | 5 +- src/test-fixtures.ts | 2 +- 39 files changed, 809 insertions(+), 182 deletions(-) create mode 100644 src/core/backend.ts create mode 100644 src/core/conpty.ts create mode 100644 src/core/process-manager.ts diff --git a/package.json b/package.json index 229265e..43dc45e 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,9 @@ "typescript": "^5.7.3", "vitest": "^3.0.6" }, + "optionalDependencies": { + "node-pty": "^1.0.0" + }, "engines": { "node": ">=20" } diff --git a/src/commands/aggregate.ts b/src/commands/aggregate.ts index fe2793b..2ae81d8 100644 --- a/src/commands/aggregate.ts +++ b/src/commands/aggregate.ts @@ -2,7 +2,7 @@ import fs from 'node:fs/promises'; import { requireManifest, resolveWorktree, updateManifest } from '../core/manifest.js'; import { refreshAllAgentStatuses } from '../core/agent.js'; import { getRepoRoot } from '../core/worktree.js'; -import * as tmux from '../core/tmux.js'; +import { getBackend } from '../core/backend.js'; import { resultFile } from '../lib/paths.js'; import { WorktreeNotFoundError } from '../lib/errors.js'; import { output, success } from '../lib/output.js'; @@ -108,7 +108,7 @@ async function collectAgentResult( ): Promise { // Primary: capture pane content try { - const paneContent = await tmux.capturePane(agent.tmuxTarget, 500); + const paneContent = await getBackend().capturePane(agent.tmuxTarget, 500); return `\`\`\`\n${paneContent}\n\`\`\``; } catch { // Pane not available diff --git a/src/commands/attach.ts b/src/commands/attach.ts index 7669143..e716611 100644 --- a/src/commands/attach.ts +++ b/src/commands/attach.ts @@ -1,8 +1,7 @@ import { requireManifest, resolveWorktree, findAgent } from '../core/manifest.js'; import { getRepoRoot } from '../core/worktree.js'; -import { getPaneInfo } from '../core/tmux.js'; +import { getBackend } from '../core/backend.js'; import { resumeAgent } from '../core/agent.js'; -import * as tmux from '../core/tmux.js'; import { openTerminalWindow } from '../core/terminal.js'; import { PpgError } from '../lib/errors.js'; import { info, success } from '../lib/output.js'; @@ -43,7 +42,7 @@ export async function attachCommand(target: string): Promise { // Check if the pane is dead and agent has a sessionId — auto-resume if (agent?.sessionId && worktreeId) { - const paneInfo = await getPaneInfo(tmuxTarget); + const paneInfo = await getBackend().getPaneInfo(tmuxTarget); if (!paneInfo || paneInfo.isDead) { info(`Pane is dead. Resuming session ${agent.sessionId}...`); const resumeWt = manifest.worktrees[worktreeId]; @@ -62,11 +61,11 @@ export async function attachCommand(target: string): Promise { } } - const insideTmux = await tmux.isInsideTmux(); + const insideSession = getBackend().isInsideSession(); - if (insideTmux) { + if (insideSession) { // Agent targets are now window targets (e.g. "ppg:3"), so use selectWindow for both - await tmux.selectWindow(tmuxTarget); + await getBackend().selectWindow(tmuxTarget); info(`Switched to ${tmuxTarget}`); } else { // Open a new Terminal.app window attached to the target diff --git a/src/commands/clean.ts b/src/commands/clean.ts index 3409247..9aa6863 100644 --- a/src/commands/clean.ts +++ b/src/commands/clean.ts @@ -3,7 +3,8 @@ import { checkPrState } from '../core/pr.js'; import { getRepoRoot, pruneWorktrees } from '../core/worktree.js'; import { cleanupWorktree } from '../core/cleanup.js'; import { getCurrentPaneId, wouldCleanupAffectSelf } from '../core/self.js'; -import { listSessionPanes, type PaneInfo } from '../core/tmux.js'; +import { getBackend } from '../core/backend.js'; +import type { PaneInfo } from '../core/process-manager.js'; import { output, success, info, warn } from '../lib/output.js'; import type { WorktreeEntry } from '../types/manifest.js'; @@ -24,7 +25,7 @@ export async function cleanCommand(options: CleanOptions): Promise { const selfPaneId = getCurrentPaneId(); let paneMap: Map | undefined; if (selfPaneId) { - paneMap = await listSessionPanes(manifest.sessionName); + paneMap = await getBackend().listSessionPanes(manifest.sessionName); } // Find worktrees in terminal states diff --git a/src/commands/cron.ts b/src/commands/cron.ts index 2172804..5dda1e1 100644 --- a/src/commands/cron.ts +++ b/src/commands/cron.ts @@ -5,7 +5,7 @@ import { getRepoRoot } from '../core/worktree.js'; import { readManifest } from '../core/manifest.js'; import { loadSchedules, getNextRun, formatCronHuman, validateCronExpression } from '../core/schedule.js'; import { runCronDaemon, isCronRunning, getCronPid, readCronLog } from '../core/cron.js'; -import * as tmux from '../core/tmux.js'; +import { getBackend } from '../core/backend.js'; import { cronPidPath, manifestPath, schedulesPath } from '../lib/paths.js'; import { getLockfile, getWriteFileAtomic } from '../lib/cjs-compat.js'; import { PpgError, NotInitializedError } from '../lib/errors.js'; @@ -48,11 +48,11 @@ export async function cronStartCommand(options: CronOptions): Promise { // Start daemon in a tmux window const manifest = await readManifest(projectRoot); const sessionName = manifest.sessionName; - await tmux.ensureSession(sessionName); + await getBackend().ensureSession(sessionName); - const windowTarget = await tmux.createWindow(sessionName, CRON_WINDOW_NAME, projectRoot); + const windowTarget = await getBackend().createWindow(sessionName, CRON_WINDOW_NAME, projectRoot); const command = `ppg cron _daemon`; - await tmux.sendKeys(windowTarget, command); + await getBackend().sendKeys(windowTarget, command); if (options.json) { output({ @@ -96,10 +96,10 @@ export async function cronStopCommand(options: CronOptions): Promise { // Try to kill the tmux window too try { const manifest = await readManifest(projectRoot); - const windows = await tmux.listSessionWindows(manifest.sessionName); + const windows = await getBackend().listSessionWindows(manifest.sessionName); const cronWindow = windows.find((w) => w.name === CRON_WINDOW_NAME); if (cronWindow) { - await tmux.killWindow(`${manifest.sessionName}:${cronWindow.index}`); + await getBackend().killWindow(`${manifest.sessionName}:${cronWindow.index}`); } } catch { /* best effort */ } diff --git a/src/commands/init.ts b/src/commands/init.ts index 962fc94..1d45c37 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -10,7 +10,7 @@ import { createEmptyManifest, writeManifest } from '../core/manifest.js'; import { bundledPrompts } from '../bundled/prompts.js'; import { bundledSwarms } from '../bundled/swarms.js'; import { execaEnv } from '../lib/env.js'; -import { checkTmux, sanitizeTmuxName } from '../core/tmux.js'; +import { getBackend } from '../core/backend.js'; const CONDUCTOR_CONTEXT = `# PPG Conductor Context @@ -72,7 +72,7 @@ export async function initCommand(options: { json?: boolean }): Promise { } // 2. Check tmux available - await checkTmux(); + await getBackend().checkAvailable(); // 3. Create directories const dirs = [ @@ -110,7 +110,7 @@ export async function initCommand(options: { json?: boolean }): Promise { const rawSessionName = config.sessionName !== 'ppg' ? config.sessionName : `ppg-${dirName}`; - const sessionName = sanitizeTmuxName(rawSessionName); + const sessionName = getBackend().sanitizeName(rawSessionName); const manifest = createEmptyManifest(projectRoot, sessionName); await writeManifest(projectRoot, manifest); info('Wrote empty manifest.json'); diff --git a/src/commands/kill.ts b/src/commands/kill.ts index b703d5f..e4a6f37 100644 --- a/src/commands/kill.ts +++ b/src/commands/kill.ts @@ -2,7 +2,7 @@ import { performKill, type KillResult } from '../core/operations/kill.js'; import { getCurrentPaneId } from '../core/self.js'; import { readManifest } from '../core/manifest.js'; import { getRepoRoot } from '../core/worktree.js'; -import { listSessionPanes } from '../core/tmux.js'; +import { getBackend } from '../core/backend.js'; import { output, success, info, warn } from '../lib/output.js'; export interface KillOptions { @@ -20,10 +20,10 @@ export async function killCommand(options: KillOptions): Promise { // Capture self-identification once at the start const selfPaneId = getCurrentPaneId(); - let paneMap: Map | undefined; + let paneMap: Map | undefined; if (selfPaneId) { const manifest = await readManifest(projectRoot); - paneMap = await listSessionPanes(manifest.sessionName); + paneMap = await getBackend().listSessionPanes(manifest.sessionName); } const result = await performKill({ diff --git a/src/commands/logs.ts b/src/commands/logs.ts index b373582..97c6587 100644 --- a/src/commands/logs.ts +++ b/src/commands/logs.ts @@ -1,6 +1,6 @@ import { requireManifest, findAgent } from '../core/manifest.js'; import { getRepoRoot } from '../core/worktree.js'; -import * as tmux from '../core/tmux.js'; +import { getBackend } from '../core/backend.js'; import { PpgError, AgentNotFoundError } from '../lib/errors.js'; import { output, outputError } from '../lib/output.js'; @@ -27,7 +27,7 @@ export async function logsCommand(agentId: string, options: LogsOptions): Promis let lastOutput = ''; const interval = setInterval(async () => { try { - const content = await tmux.capturePane(agent.tmuxTarget, lines); + const content = await getBackend().capturePane(agent.tmuxTarget, lines); if (content !== lastOutput) { // Find new lines if (lastOutput) { @@ -57,7 +57,7 @@ export async function logsCommand(agentId: string, options: LogsOptions): Promis } else { // One-shot capture try { - const content = await tmux.capturePane(agent.tmuxTarget, lines); + const content = await getBackend().capturePane(agent.tmuxTarget, lines); if (options.json) { output({ agentId: agent.id, diff --git a/src/commands/reset.ts b/src/commands/reset.ts index 974158c..8a30775 100644 --- a/src/commands/reset.ts +++ b/src/commands/reset.ts @@ -4,7 +4,8 @@ import { checkPrState } from '../core/pr.js'; import { getRepoRoot, pruneWorktrees } from '../core/worktree.js'; import { cleanupWorktree } from '../core/cleanup.js'; import { getCurrentPaneId, excludeSelf, wouldCleanupAffectSelf } from '../core/self.js'; -import { listSessionPanes, killOrphanWindows, type PaneInfo } from '../core/tmux.js'; +import { getBackend } from '../core/backend.js'; +import type { PaneInfo } from '../core/process-manager.js'; import { NotInitializedError, UnmergedWorkError } from '../lib/errors.js'; import { output, success, info, warn } from '../lib/output.js'; import type { AgentEntry, WorktreeEntry } from '../types/manifest.js'; @@ -64,7 +65,7 @@ export async function resetCommand(options: ResetOptions): Promise { const selfPaneId = getCurrentPaneId(); let paneMap: Map | undefined; if (selfPaneId) { - paneMap = await listSessionPanes(manifest.sessionName); + paneMap = await getBackend().listSessionPanes(manifest.sessionName); } // Collect all running agents @@ -158,7 +159,7 @@ export async function resetCommand(options: ResetOptions): Promise { // Kill any orphaned tmux windows left in the session (e.g., from failed cleanups) // Pass selfPaneId so we don't kill the conductor's own window - const orphansKilled = await killOrphanWindows(manifest.sessionName, selfPaneId); + const orphansKilled = await getBackend().killOrphanWindows(manifest.sessionName, selfPaneId); if (orphansKilled > 0) { info(`Killed ${orphansKilled} orphaned tmux window(s)`); } diff --git a/src/commands/send.ts b/src/commands/send.ts index bc88b98..88eacce 100644 --- a/src/commands/send.ts +++ b/src/commands/send.ts @@ -1,6 +1,6 @@ import { requireManifest, findAgent } from '../core/manifest.js'; import { getRepoRoot } from '../core/worktree.js'; -import * as tmux from '../core/tmux.js'; +import { getBackend } from '../core/backend.js'; import { AgentNotFoundError } from '../lib/errors.js'; import { output, success } from '../lib/output.js'; @@ -22,13 +22,13 @@ export async function sendCommand(agentId: string, text: string, options: SendOp if (options.keys) { // Raw tmux key names (e.g., "C-c", "Enter", "Escape") - await tmux.sendRawKeys(agent.tmuxTarget, text); + await getBackend().sendRawKeys(agent.tmuxTarget, text); } else if (options.enter === false) { // Send literal text without Enter - await tmux.sendLiteral(agent.tmuxTarget, text); + await getBackend().sendLiteral(agent.tmuxTarget, text); } else { // Default: send literal text + Enter - await tmux.sendKeys(agent.tmuxTarget, text); + await getBackend().sendKeys(agent.tmuxTarget, text); } if (options.json) { diff --git a/src/commands/serve.test.ts b/src/commands/serve.test.ts index 51d7b1f..875b98f 100644 --- a/src/commands/serve.test.ts +++ b/src/commands/serve.test.ts @@ -12,12 +12,19 @@ vi.mock('../core/manifest.js', () => ({ readManifest: vi.fn(() => ({ sessionName: 'ppg-test' })), })); -vi.mock('../core/tmux.js', () => ({ - ensureSession: vi.fn(), - createWindow: vi.fn(() => 'ppg-test:1'), - sendKeys: vi.fn(), - listSessionWindows: vi.fn(() => []), - killWindow: vi.fn(), +const mockEnsureSession = vi.fn(); +const mockCreateWindow = vi.fn(() => 'ppg-test:1'); +const mockSendKeys = vi.fn(); +const mockListSessionWindows = vi.fn((): { index: number; name: string }[] => []); +const mockKillWindow = vi.fn(); +vi.mock('../core/backend.js', () => ({ + getBackend: () => ({ + ensureSession: mockEnsureSession, + createWindow: mockCreateWindow, + sendKeys: mockSendKeys, + listSessionWindows: mockListSessionWindows, + killWindow: mockKillWindow, + }), })); vi.mock('../lib/paths.js', async (importOriginal) => { @@ -59,7 +66,6 @@ const { serveStartCommand, serveStopCommand, serveStatusCommand, serveDaemonComm const { output, success, warn, info } = await import('../lib/output.js'); const { isServeRunning, getServePid, getServeInfo, readServeLog, runServeDaemon } = await import('../core/serve.js'); const { requireManifest } = await import('../core/manifest.js'); -const tmux = await import('../core/tmux.js'); beforeEach(() => { vi.clearAllMocks(); @@ -74,16 +80,16 @@ describe('serveStartCommand', () => { await serveStartCommand({ port: 3000, host: 'localhost' }); expect(requireManifest).toHaveBeenCalledWith('/fake/project'); - expect(tmux.ensureSession).toHaveBeenCalledWith('ppg-test'); - expect(tmux.createWindow).toHaveBeenCalledWith('ppg-test', 'ppg-serve', '/fake/project'); - expect(tmux.sendKeys).toHaveBeenCalledWith('ppg-test:1', 'ppg serve _daemon --port 3000 --host localhost'); + expect(mockEnsureSession).toHaveBeenCalledWith('ppg-test'); + expect(mockCreateWindow).toHaveBeenCalledWith('ppg-test', 'ppg-serve', '/fake/project'); + expect(mockSendKeys).toHaveBeenCalledWith('ppg-test:1', 'ppg serve _daemon --port 3000 --host localhost'); expect(success).toHaveBeenCalledWith('Serve daemon starting in tmux window: ppg-test:1'); }); test('given custom port and host, should pass them to daemon command', async () => { await serveStartCommand({ port: 8080, host: '0.0.0.0' }); - expect(tmux.sendKeys).toHaveBeenCalledWith('ppg-test:1', 'ppg serve _daemon --port 8080 --host 0.0.0.0'); + expect(mockSendKeys).toHaveBeenCalledWith('ppg-test:1', 'ppg serve _daemon --port 8080 --host 0.0.0.0'); }); test('given server already running, should warn and return', async () => { @@ -98,7 +104,7 @@ describe('serveStartCommand', () => { await serveStartCommand({ port: 3000, host: 'localhost' }); - expect(tmux.createWindow).not.toHaveBeenCalled(); + expect(mockCreateWindow).not.toHaveBeenCalled(); expect(warn).toHaveBeenCalledWith('Serve daemon is already running (PID: 12345)'); expect(info).toHaveBeenCalledWith('Listening on localhost:3000'); }); @@ -135,7 +141,7 @@ describe('serveStartCommand', () => { vi.mocked(requireManifest).mockRejectedValue(err); await expect(serveStartCommand({ port: 3000, host: 'localhost' })).rejects.toThrow('Not initialized'); - expect(tmux.createWindow).not.toHaveBeenCalled(); + expect(mockCreateWindow).not.toHaveBeenCalled(); }); test('given invalid host with shell metacharacters, should throw INVALID_ARGS', async () => { @@ -170,14 +176,14 @@ describe('serveStopCommand', () => { vi.mocked(getServePid).mockResolvedValue(99999); vi.spyOn(process, 'kill').mockImplementation(() => true); vi.spyOn(fs, 'unlink').mockResolvedValue(undefined); - vi.mocked(tmux.listSessionWindows).mockResolvedValue([ + mockListSessionWindows.mockResolvedValue([ { index: 0, name: 'bash' }, { index: 1, name: 'ppg-serve' }, ]); await serveStopCommand({}); - expect(tmux.killWindow).toHaveBeenCalledWith('ppg-test:1'); + expect(mockKillWindow).toHaveBeenCalledWith('ppg-test:1'); vi.mocked(process.kill).mockRestore(); }); diff --git a/src/commands/serve.ts b/src/commands/serve.ts index 320c87f..2b82eff 100644 --- a/src/commands/serve.ts +++ b/src/commands/serve.ts @@ -5,7 +5,7 @@ import { getRepoRoot } from '../core/worktree.js'; import { requireManifest, readManifest } from '../core/manifest.js'; import { runServeDaemon, isServeRunning, getServePid, getServeInfo, readServeLog } from '../core/serve.js'; import { startServer } from '../server/index.js'; -import * as tmux from '../core/tmux.js'; +import { getBackend } from '../core/backend.js'; import { servePidPath, serveJsonPath } from '../lib/paths.js'; import { PpgError } from '../lib/errors.js'; import { output, info, success, warn } from '../lib/output.js'; @@ -92,14 +92,14 @@ export async function serveStartCommand(options: ServeStartOptions): Promise { // Try to kill the tmux window too try { const manifest = await readManifest(projectRoot); - const windows = await tmux.listSessionWindows(manifest.sessionName); + const windows = await getBackend().listSessionWindows(manifest.sessionName); const serveWindow = windows.find((w) => w.name === SERVE_WINDOW_NAME); if (serveWindow) { - await tmux.killWindow(`${manifest.sessionName}:${serveWindow.index}`); + await getBackend().killWindow(`${manifest.sessionName}:${serveWindow.index}`); } } catch { /* best effort */ } diff --git a/src/commands/swarm.ts b/src/commands/swarm.ts index 10e83c8..2486dd6 100644 --- a/src/commands/swarm.ts +++ b/src/commands/swarm.ts @@ -7,7 +7,7 @@ import { setupWorktreeEnv } from '../core/env.js'; import { renderTemplate, type TemplateContext } from '../core/template.js'; import { loadSwarm, type SwarmAgentEntry, type SwarmTemplate } from '../core/swarm.js'; import { spawnAgent } from '../core/agent.js'; -import * as tmux from '../core/tmux.js'; +import { getBackend } from '../core/backend.js'; import { openTerminalWindow } from '../core/terminal.js'; import { worktreeId as genWorktreeId, agentId as genAgentId, sessionId as genSessionId } from '../lib/id.js'; import { promptsDir, globalPromptsDir, manifestPath } from '../lib/paths.js'; @@ -134,8 +134,8 @@ async function swarmShared( const manifest = await readManifest(projectRoot); const sessionName = manifest.sessionName; - await tmux.ensureSession(sessionName); - const windowTarget = await tmux.createWindow(sessionName, name, wtPath); + await getBackend().ensureSession(sessionName); + const windowTarget = await getBackend().createWindow(sessionName, name, wtPath); // Register worktree in manifest before spawning agents so partial failures are tracked await updateManifest(projectRoot, (m) => { @@ -157,7 +157,7 @@ async function swarmShared( for (let i = 0; i < swarm.agents.length; i++) { const target = i === 0 ? windowTarget - : await tmux.createWindow(sessionName, `${name}-${i}`, wtPath); + : await getBackend().createWindow(sessionName, `${name}-${i}`, wtPath); const agentEntry = await spawnSwarmAgent({ projectRoot, config, swarmAgent: swarm.agents[i], @@ -189,7 +189,7 @@ async function swarmIsolated( const baseName = options.name ? normalizeName(options.name, swarm.name) : swarm.name; const manifest = await readManifest(projectRoot); const sessionName = manifest.sessionName; - await tmux.ensureSession(sessionName); + await getBackend().ensureSession(sessionName); const worktrees: Array<{ id: string; name: string; branch: string; path: string; tmuxWindow: string }> = []; const allAgents: AgentEntry[] = []; @@ -213,7 +213,7 @@ async function swarmIsolated( }); await setupWorktreeEnv(projectRoot, wtPath, config); - const windowTarget = await tmux.createWindow(sessionName, wtName, wtPath); + const windowTarget = await getBackend().createWindow(sessionName, wtName, wtPath); const agentEntry = await spawnSwarmAgent({ projectRoot, config, swarmAgent, @@ -280,8 +280,8 @@ async function swarmIntoExistingWorktree( // Lazily create tmux window if worktree has none let windowTarget = wt.tmuxWindow; if (!windowTarget) { - await tmux.ensureSession(sessionName); - windowTarget = await tmux.createWindow(sessionName, wt.name, wt.path); + await getBackend().ensureSession(sessionName); + windowTarget = await getBackend().createWindow(sessionName, wt.name, wt.path); } // Update tmux window before spawning so partial failures are tracked @@ -296,7 +296,7 @@ async function swarmIntoExistingWorktree( for (let i = 0; i < swarm.agents.length; i++) { const target = i === 0 ? windowTarget - : await tmux.createWindow(sessionName, `${wt.name}-${swarm.name}-${i}`, wt.path); + : await getBackend().createWindow(sessionName, `${wt.name}-${swarm.name}-${i}`, wt.path); const agentEntry = await spawnSwarmAgent({ projectRoot, config, swarmAgent: swarm.agents[i], diff --git a/src/core/agent.test.ts b/src/core/agent.test.ts index c5ccf78..20ca774 100644 --- a/src/core/agent.test.ts +++ b/src/core/agent.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect, vi, beforeEach } from 'vitest'; import { makeAgent, makePaneInfo } from '../test-fixtures.js'; -import type { PaneInfo } from './tmux.js'; +import type { PaneInfo } from './process-manager.js'; // Mock node:fs/promises vi.mock('node:fs/promises', () => ({ @@ -11,15 +11,25 @@ vi.mock('node:fs/promises', () => ({ }, })); -// Mock tmux module -vi.mock('./tmux.js', () => ({ - getPaneInfo: vi.fn(), - listSessionPanes: vi.fn(), - sendKeys: vi.fn(), - sendCtrlC: vi.fn(), - killPane: vi.fn(), - ensureSession: vi.fn(), - createWindow: vi.fn(), +// Mock backend module +const mockGetPaneInfo = vi.fn(); +const mockListSessionPanes = vi.fn(); +const mockSendKeys = vi.fn(); +const mockSendCtrlC = vi.fn(); +const mockKillPane = vi.fn(); +const mockEnsureSession = vi.fn(); +const mockCreateWindow = vi.fn(); + +vi.mock('./backend.js', () => ({ + getBackend: () => ({ + getPaneInfo: mockGetPaneInfo, + listSessionPanes: mockListSessionPanes, + sendKeys: mockSendKeys, + sendCtrlC: mockSendCtrlC, + killPane: mockKillPane, + ensureSession: mockEnsureSession, + createWindow: mockCreateWindow, + }), })); // Mock manifest module @@ -27,10 +37,9 @@ vi.mock('./manifest.js', () => ({ updateManifest: vi.fn(), })); -import { getPaneInfo } from './tmux.js'; import { checkAgentStatus } from './agent.js'; -const mockedGetPaneInfo = vi.mocked(getPaneInfo); +const mockedGetPaneInfo = mockGetPaneInfo; const PROJECT_ROOT = '/tmp/project'; diff --git a/src/core/agent.ts b/src/core/agent.ts index def1c23..92504fc 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -1,13 +1,13 @@ import fs from 'node:fs/promises'; import { agentPromptFile, agentPromptsDir } from '../lib/paths.js'; -import { getPaneInfo, listSessionPanes, type PaneInfo } from './tmux.js'; +import { getBackend } from './backend.js'; +import type { PaneInfo } from './process-manager.js'; import { updateManifest } from './manifest.js'; import { PpgError } from '../lib/errors.js'; import { agentId as genAgentId, sessionId as genSessionId } from '../lib/id.js'; import { renderTemplate, type TemplateContext } from './template.js'; import type { AgentEntry, AgentStatus, WorktreeEntry } from '../types/manifest.js'; import type { AgentConfig } from '../types/config.js'; -import * as tmux from './tmux.js'; const SHELL_COMMANDS = new Set(['bash', 'zsh', 'sh', 'fish', 'dash', 'tcsh', 'csh']); @@ -38,7 +38,7 @@ export async function spawnAgent(options: SpawnAgentOptions): Promise { // Batch-fetch all pane info in a single tmux call - const paneMap = await listSessionPanes(manifest.sessionName); + const paneMap = await getBackend().listSessionPanes(manifest.sessionName); // Collect all agents that need checking const checks: Array<{ agent: AgentEntry; promise: Promise<{ status: AgentStatus; exitCode?: number }> }> = []; @@ -175,9 +175,9 @@ export async function resumeAgent(options: ResumeAgentOptions): Promise ); } - await tmux.ensureSession(sessionName); - const newTarget = await tmux.createWindow(sessionName, windowName, cwd); - await tmux.sendKeys(newTarget, `unset CLAUDECODE; claude --resume ${agent.sessionId}`); + await getBackend().ensureSession(sessionName); + const newTarget = await getBackend().createWindow(sessionName, windowName, cwd); + await getBackend().sendKeys(newTarget, `unset CLAUDECODE; claude --resume ${agent.sessionId}`); await updateManifest(projectRoot, (m) => { const mAgent = m.worktrees[worktreeId]?.agents[agent.id]; @@ -193,12 +193,12 @@ export async function resumeAgent(options: ResumeAgentOptions): Promise export async function killAgent(agent: AgentEntry): Promise { // Check if pane exists before attempting to kill - const initialInfo = await getPaneInfo(agent.tmuxTarget); + const initialInfo = await getBackend().getPaneInfo(agent.tmuxTarget); if (!initialInfo || initialInfo.isDead) return; // Send Ctrl-C first (pane may die between check and send) try { - await tmux.sendCtrlC(agent.tmuxTarget); + await getBackend().sendCtrlC(agent.tmuxTarget); } catch { // Pane died between check and send — already gone return; @@ -208,10 +208,10 @@ export async function killAgent(agent: AgentEntry): Promise { await new Promise((resolve) => setTimeout(resolve, 2000)); // Check if still alive - const paneInfo = await getPaneInfo(agent.tmuxTarget); + const paneInfo = await getBackend().getPaneInfo(agent.tmuxTarget); if (paneInfo && !paneInfo.isDead) { // Kill the pane - await tmux.killPane(agent.tmuxTarget); + await getBackend().killPane(agent.tmuxTarget); } } @@ -224,22 +224,22 @@ export async function killAgents(agents: AgentEntry[]): Promise { // Filter to only agents with live panes const alive: AgentEntry[] = []; await Promise.all(agents.map(async (a) => { - const info = await getPaneInfo(a.tmuxTarget); + const info = await getBackend().getPaneInfo(a.tmuxTarget); if (info && !info.isDead) alive.push(a); })); if (alive.length === 0) return; // Send Ctrl-C to all live agents in parallel (catch pane-died-between-check-and-send) - await Promise.all(alive.map((a) => tmux.sendCtrlC(a.tmuxTarget).catch(() => {}))); + await Promise.all(alive.map((a) => getBackend().sendCtrlC(a.tmuxTarget).catch(() => {}))); // Wait for graceful shutdown (Claude Code needs more time) await new Promise((resolve) => setTimeout(resolve, 2000)); // Check and force-kill survivors in parallel await Promise.all(alive.map(async (a) => { - const paneInfo = await getPaneInfo(a.tmuxTarget); + const paneInfo = await getBackend().getPaneInfo(a.tmuxTarget); if (paneInfo && !paneInfo.isDead) { - await tmux.killPane(a.tmuxTarget); + await getBackend().killPane(a.tmuxTarget); } })); } @@ -276,9 +276,9 @@ export async function restartAgent(opts: RestartAgentOptions): Promise ({ @@ -25,8 +25,11 @@ vi.mock('./env.js', () => ({ teardownWorktreeEnv: vi.fn(async () => {}), })); -vi.mock('./tmux.js', () => ({ - killWindow: vi.fn(async () => {}), +const mockKillWindow = vi.fn(async () => {}); +vi.mock('./backend.js', () => ({ + getBackend: () => ({ + killWindow: mockKillWindow, + }), })); vi.mock('../lib/output.js', () => ({ @@ -39,7 +42,6 @@ import { cleanupWorktree } from './cleanup.js'; import { updateManifest } from './manifest.js'; import { removeWorktree } from './worktree.js'; import { teardownWorktreeEnv } from './env.js'; -import * as tmux from './tmux.js'; import { warn } from '../lib/output.js'; function makePaneInfo(paneId: string): PaneInfo { @@ -82,7 +84,7 @@ describe('cleanupWorktree', () => { callOrder.push('manifest'); return {} as any; }); - vi.mocked(tmux.killWindow).mockImplementation(async () => { + mockKillWindow.mockImplementation(async () => { callOrder.push('tmux'); }); @@ -98,7 +100,7 @@ describe('cleanupWorktree', () => { const result = await cleanupWorktree('/project', wt); expect(updateManifest).toHaveBeenCalled(); - expect(tmux.killWindow).toHaveBeenCalled(); + expect(mockKillWindow).toHaveBeenCalled(); expect(teardownWorktreeEnv).toHaveBeenCalledWith(wt.path); expect(removeWorktree).toHaveBeenCalled(); expect(result.manifestUpdated).toBe(true); @@ -126,14 +128,14 @@ describe('cleanupWorktree', () => { const result = await cleanupWorktree('/project', wt); expect(result.manifestUpdated).toBe(false); - expect(tmux.killWindow).not.toHaveBeenCalled(); + expect(mockKillWindow).not.toHaveBeenCalled(); // Still attempts filesystem cleanup expect(teardownWorktreeEnv).toHaveBeenCalled(); expect(removeWorktree).toHaveBeenCalled(); }); test('handles tmux kill failures gracefully', async () => { - vi.mocked(tmux.killWindow).mockRejectedValueOnce(new Error('tmux server crash')); + mockKillWindow.mockRejectedValueOnce(new Error('tmux server crash')); const wt = makeWorktree(); const result = await cleanupWorktree('/project', wt); @@ -165,6 +167,6 @@ describe('cleanupWorktree', () => { await cleanupWorktree('/project', wt); // Should only kill once despite duplicate target - expect(tmux.killWindow).toHaveBeenCalledTimes(1); + expect(mockKillWindow).toHaveBeenCalledTimes(1); }); }); diff --git a/src/core/cleanup.ts b/src/core/cleanup.ts index 6d91ddf..5b17d57 100644 --- a/src/core/cleanup.ts +++ b/src/core/cleanup.ts @@ -2,11 +2,11 @@ import fs from 'node:fs/promises'; import { updateManifest } from './manifest.js'; import { removeWorktree } from './worktree.js'; import { teardownWorktreeEnv } from './env.js'; -import * as tmux from './tmux.js'; +import { getBackend } from './backend.js'; import { agentPromptFile } from '../lib/paths.js'; import { warn } from '../lib/output.js'; import { wouldAffectSelf } from './self.js'; -import type { PaneInfo } from './tmux.js'; +import type { PaneInfo } from './process-manager.js'; import type { WorktreeEntry } from '../types/manifest.js'; export interface CleanupOptions { @@ -92,7 +92,7 @@ export async function cleanupWorktree( } try { - await tmux.killWindow(target); + await getBackend().killWindow(target); result.tmuxKilled++; } catch (err) { // killWindow now only throws on unexpected errors (not "not found") diff --git a/src/core/conpty.ts b/src/core/conpty.ts new file mode 100644 index 0000000..b510be2 --- /dev/null +++ b/src/core/conpty.ts @@ -0,0 +1,418 @@ +/** + * ConPTY-based process manager backend. + * + * EXPERIMENTAL / WIP — This backend uses node-pty (which wraps ConPTY on Windows + * and forkpty on Unix) to manage pseudo-terminal processes without tmux. + * + * node-pty is an optional dependency. If it's not installed, this backend + * cannot be instantiated — the factory in backend.ts handles the fallback. + * + * TODO: Production hardening + * - Persist session registry to .ppg/pty-sessions.json on changes + * - Restore sessions on restart + * - Handle process crashes / unexpected exits + * - Ring buffer sizing and memory management + * - Window/pane layout management + * - Signal forwarding + */ + +import type { ProcessManager, WindowInfo } from './process-manager.js'; +import type { PaneInfo } from './tmux.js'; + +// --------------------------------------------------------------------------- +// Ring buffer for capturing terminal output +// --------------------------------------------------------------------------- + +class RingBuffer { + private buffer: string[] = []; + private readonly capacity: number; + + constructor(capacity = 5000) { + this.capacity = capacity; + } + + push(line: string): void { + this.buffer.push(line); + if (this.buffer.length > this.capacity) { + this.buffer.shift(); + } + } + + getLines(count?: number): string[] { + if (!count || count >= this.buffer.length) return [...this.buffer]; + return this.buffer.slice(-count); + } +} + +// --------------------------------------------------------------------------- +// In-memory registry types +// --------------------------------------------------------------------------- + +interface PtyPane { + id: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + pty: any; // IPty from node-pty (typed as any to avoid hard dependency) + output: RingBuffer; + pid: number; + currentCommand: string; + isDead: boolean; + exitCode?: number; +} + +interface PtyWindow { + index: number; + name: string; + panes: Map; +} + +interface PtySession { + name: string; + windows: Map; + nextWindowIndex: number; + nextPaneIndex: number; +} + +// --------------------------------------------------------------------------- +// ConPtyBackend +// --------------------------------------------------------------------------- + +let nodePty: typeof import('node-pty') | null = null; + +function requireNodePty(): typeof import('node-pty') { + if (!nodePty) { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + nodePty = require('node-pty') as typeof import('node-pty'); + } catch { + throw new Error( + 'node-pty is required for ConPtyBackend but is not installed. ' + + 'Install it with: npm install node-pty', + ); + } + } + return nodePty; +} + +export class ConPtyBackend implements ProcessManager { + private sessions = new Map(); + + async checkAvailable(): Promise { + requireNodePty(); + } + + async sessionExists(name: string): Promise { + return this.sessions.has(name); + } + + async ensureSession(name: string): Promise { + if (!this.sessions.has(name)) { + this.sessions.set(name, { + name, + windows: new Map(), + nextWindowIndex: 0, + nextPaneIndex: 0, + }); + } + } + + async createWindow(session: string, name: string, cwd: string): Promise { + const sess = this.sessions.get(session); + if (!sess) throw new Error(`Session "${session}" does not exist`); + + const pty = requireNodePty(); + const shell = process.platform === 'win32' ? 'cmd.exe' : (process.env.SHELL ?? 'bash'); + const windowIndex = sess.nextWindowIndex++; + const paneId = `%${sess.nextPaneIndex++}`; + + const ptyProcess = pty.spawn(shell, [], { + name: 'xterm-256color', + cols: 220, + rows: 50, + cwd, + }); + + const output = new RingBuffer(); + let lineBuffer = ''; + + ptyProcess.onData((data: string) => { + lineBuffer += data; + const lines = lineBuffer.split('\n'); + lineBuffer = lines.pop() ?? ''; + for (const line of lines) { + output.push(line); + } + }); + + const pane: PtyPane = { + id: paneId, + pty: ptyProcess, + output, + pid: ptyProcess.pid, + currentCommand: shell, + isDead: false, + }; + + ptyProcess.onExit(({ exitCode }: { exitCode: number }) => { + pane.isDead = true; + pane.exitCode = exitCode; + }); + + const win: PtyWindow = { + index: windowIndex, + name, + panes: new Map([[paneId, pane]]), + }; + sess.windows.set(windowIndex, win); + + return `${session}:${windowIndex}`; + } + + async splitPane(target: string, _direction: 'horizontal' | 'vertical', cwd: string): Promise<{ paneId: string; target: string }> { + const { session, windowIndex } = this.parseTarget(target); + const sess = this.sessions.get(session); + if (!sess) throw new Error(`Session "${session}" does not exist`); + + const win = sess.windows.get(windowIndex); + if (!win) throw new Error(`Window ${windowIndex} does not exist in session "${session}"`); + + const pty = requireNodePty(); + const shell = process.platform === 'win32' ? 'cmd.exe' : (process.env.SHELL ?? 'bash'); + const paneId = `%${sess.nextPaneIndex++}`; + const paneIndex = win.panes.size; + + const ptyProcess = pty.spawn(shell, [], { + name: 'xterm-256color', + cols: 110, // Half width for split + rows: 50, + cwd, + }); + + const output = new RingBuffer(); + let lineBuffer = ''; + + ptyProcess.onData((data: string) => { + lineBuffer += data; + const lines = lineBuffer.split('\n'); + lineBuffer = lines.pop() ?? ''; + for (const line of lines) { + output.push(line); + } + }); + + const pane: PtyPane = { + id: paneId, + pty: ptyProcess, + output, + pid: ptyProcess.pid, + currentCommand: shell, + isDead: false, + }; + + ptyProcess.onExit(({ exitCode }: { exitCode: number }) => { + pane.isDead = true; + pane.exitCode = exitCode; + }); + + win.panes.set(paneId, pane); + + return { + paneId, + target: `${session}:${windowIndex}.${paneIndex}`, + }; + } + + async sendKeys(target: string, command: string): Promise { + const pane = this.resolvePane(target); + if (!pane || pane.isDead) return; + pane.pty.write(command + '\r'); + } + + async sendLiteral(target: string, text: string): Promise { + const pane = this.resolvePane(target); + if (!pane || pane.isDead) return; + pane.pty.write(text); + } + + async sendRawKeys(target: string, keys: string): Promise { + const pane = this.resolvePane(target); + if (!pane || pane.isDead) return; + // TODO: Map tmux key names (C-c, Enter, etc.) to terminal sequences + pane.pty.write(keys); + } + + async sendCtrlC(target: string): Promise { + const pane = this.resolvePane(target); + if (!pane || pane.isDead) return; + pane.pty.write('\x03'); + } + + async capturePane(target: string, lines?: number): Promise { + const pane = this.resolvePane(target); + if (!pane) return ''; + return pane.output.getLines(lines).join('\n'); + } + + async killPane(target: string): Promise { + const pane = this.resolvePane(target); + if (!pane) return; + try { + pane.pty.kill(); + } catch { + // Already dead + } + pane.isDead = true; + } + + async killWindow(target: string): Promise { + const { session, windowIndex } = this.parseTarget(target); + const sess = this.sessions.get(session); + if (!sess) return; + + const win = sess.windows.get(windowIndex); + if (!win) return; + + for (const pane of win.panes.values()) { + try { + pane.pty.kill(); + } catch { + // Already dead + } + pane.isDead = true; + } + sess.windows.delete(windowIndex); + } + + async getPaneInfo(target: string): Promise { + const pane = this.resolvePane(target); + if (!pane) return null; + return { + paneId: pane.id, + panePid: String(pane.pid), + currentCommand: pane.currentCommand, + isDead: pane.isDead, + deadStatus: pane.exitCode, + }; + } + + async listSessionPanes(session: string): Promise> { + const map = new Map(); + const sess = this.sessions.get(session); + if (!sess) return map; + + for (const [winIdx, win] of sess.windows) { + let paneIdx = 0; + for (const [, pane] of win.panes) { + const info: PaneInfo = { + paneId: pane.id, + panePid: String(pane.pid), + currentCommand: pane.currentCommand, + isDead: pane.isDead, + deadStatus: pane.exitCode, + }; + const paneTarget = `${session}:${winIdx}.${paneIdx}`; + map.set(paneTarget, info); + map.set(pane.id, info); + map.set(`${session}:${winIdx}`, info); + paneIdx++; + } + } + return map; + } + + async listSessionWindows(session: string): Promise { + const sess = this.sessions.get(session); + if (!sess) return []; + return Array.from(sess.windows.values()).map((w) => ({ + index: w.index, + name: w.name, + })); + } + + async killOrphanWindows(session: string, selfPaneId?: string | null): Promise { + const sess = this.sessions.get(session); + if (!sess) return 0; + + let killed = 0; + for (const [idx, win] of sess.windows) { + if (idx === 0) continue; + + // Self-protection + if (selfPaneId) { + let containsSelf = false; + for (const pane of win.panes.values()) { + if (pane.id === selfPaneId) { + containsSelf = true; + break; + } + } + if (containsSelf) continue; + } + + for (const pane of win.panes.values()) { + try { + pane.pty.kill(); + } catch { + // Already dead + } + } + sess.windows.delete(idx); + killed++; + } + return killed; + } + + async selectWindow(_target: string): Promise { + // No-op for ConPTY — no concept of "selecting" a window in a terminal multiplexer + // TODO: Could bring a specific window to focus in Windows Terminal + } + + isInsideSession(): boolean { + // ConPTY sessions are in-process, so we're never "inside" one the way tmux works + return false; + } + + sanitizeName(name: string): string { + // Same rules as tmux for target format compatibility + return name.replace(/[.:]/g, '-'); + } + + // ----------------------------------------------------------------------- + // Private helpers + // ----------------------------------------------------------------------- + + private parseTarget(target: string): { session: string; windowIndex: number; paneIndex?: number } { + const colonIdx = target.indexOf(':'); + if (colonIdx === -1) { + return { session: target, windowIndex: 0 }; + } + const session = target.slice(0, colonIdx); + const rest = target.slice(colonIdx + 1); + const dotIdx = rest.indexOf('.'); + if (dotIdx === -1) { + return { session, windowIndex: parseInt(rest, 10) }; + } + return { + session, + windowIndex: parseInt(rest.slice(0, dotIdx), 10), + paneIndex: parseInt(rest.slice(dotIdx + 1), 10), + }; + } + + private resolvePane(target: string): PtyPane | null { + const { session, windowIndex, paneIndex } = this.parseTarget(target); + const sess = this.sessions.get(session); + if (!sess) return null; + + const win = sess.windows.get(windowIndex); + if (!win) return null; + + if (paneIndex !== undefined) { + const panes = Array.from(win.panes.values()); + return panes[paneIndex] ?? null; + } + + // Default to first pane in the window + const firstPane = win.panes.values().next(); + return firstPane.done ? null : firstPane.value; + } +} diff --git a/src/core/operations/kill.test.ts b/src/core/operations/kill.test.ts index d09fb64..065d999 100644 --- a/src/core/operations/kill.test.ts +++ b/src/core/operations/kill.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect, vi, beforeEach } from 'vitest'; import type { Manifest, AgentEntry, WorktreeEntry } from '../../types/manifest.js'; -import type { PaneInfo } from '../tmux.js'; +import type { PaneInfo } from '../process-manager.js'; // --- Mocks --- @@ -36,8 +36,11 @@ vi.mock('../self.js', () => ({ excludeSelf: vi.fn(), })); -vi.mock('../tmux.js', () => ({ - killPane: vi.fn(async () => {}), +const mockKillPane = vi.fn(async () => {}); +vi.mock('../backend.js', () => ({ + getBackend: () => ({ + killPane: mockKillPane, + }), })); import { performKill } from './kill.js'; @@ -46,9 +49,10 @@ import { killAgent, killAgents } from '../agent.js'; import { checkPrState } from '../pr.js'; import { cleanupWorktree } from '../cleanup.js'; import { excludeSelf } from '../self.js'; -import { killPane } from '../tmux.js'; import { PpgError } from '../../lib/errors.js'; +const killPane = mockKillPane; + // --- Helpers --- function makeAgent(id: string, overrides: Partial = {}): AgentEntry { diff --git a/src/core/operations/kill.ts b/src/core/operations/kill.ts index 4ad9433..c7eb66f 100644 --- a/src/core/operations/kill.ts +++ b/src/core/operations/kill.ts @@ -3,7 +3,8 @@ import { killAgent, killAgents } from '../agent.js'; import { checkPrState } from '../pr.js'; import { cleanupWorktree } from '../cleanup.js'; import { excludeSelf } from '../self.js'; -import { killPane, type PaneInfo } from '../tmux.js'; +import { getBackend } from '../backend.js'; +import type { PaneInfo } from '../process-manager.js'; import { PpgError, AgentNotFoundError, WorktreeNotFoundError } from '../../lib/errors.js'; import type { AgentEntry } from '../../types/manifest.js'; @@ -70,7 +71,7 @@ async function killSingleAgent( if (!isTerminal) { await killAgent(agent); } - await killPane(agent.tmuxTarget); + await getBackend().killPane(agent.tmuxTarget); await updateManifest(projectRoot, (m) => { const f = findAgent(m, agentId); diff --git a/src/core/operations/merge.test.ts b/src/core/operations/merge.test.ts index d3c21e8..79df6f8 100644 --- a/src/core/operations/merge.test.ts +++ b/src/core/operations/merge.test.ts @@ -57,8 +57,11 @@ vi.mock('../self.js', () => ({ getCurrentPaneId: vi.fn(() => null), })); -vi.mock('../tmux.js', () => ({ - listSessionPanes: vi.fn(async () => new Map()), +const mockListSessionPanes = vi.fn(async () => new Map()); +vi.mock('../backend.js', () => ({ + getBackend: () => ({ + listSessionPanes: mockListSessionPanes, + }), })); vi.mock('../../lib/env.js', () => ({ @@ -70,7 +73,6 @@ import { updateManifest } from '../manifest.js'; import { getCurrentBranch } from '../worktree.js'; import { cleanupWorktree } from '../cleanup.js'; import { getCurrentPaneId } from '../self.js'; -import { listSessionPanes } from '../tmux.js'; import { PpgError, MergeFailedError, WorktreeNotFoundError } from '../../lib/errors.js'; function makeWorktree(overrides: Partial = {}): WorktreeEntry { @@ -283,14 +285,14 @@ describe('performMerge', () => { test('passes self-protection context to cleanup', async () => { vi.mocked(getCurrentPaneId).mockReturnValueOnce('%5'); const paneMap = new Map(); - vi.mocked(listSessionPanes).mockResolvedValueOnce(paneMap); + mockListSessionPanes.mockResolvedValueOnce(paneMap); await performMerge({ projectRoot: '/project', worktreeRef: 'wt-abc123', }); - expect(listSessionPanes).toHaveBeenCalledWith('ppg'); + expect(mockListSessionPanes).toHaveBeenCalledWith('ppg'); expect(cleanupWorktree).toHaveBeenCalledWith( '/project', expect.objectContaining({ id: 'wt-abc123' }), diff --git a/src/core/operations/merge.ts b/src/core/operations/merge.ts index 4997833..d87f58d 100644 --- a/src/core/operations/merge.ts +++ b/src/core/operations/merge.ts @@ -4,7 +4,8 @@ import { refreshAllAgentStatuses } from '../agent.js'; import { getCurrentBranch } from '../worktree.js'; import { cleanupWorktree } from '../cleanup.js'; import { getCurrentPaneId } from '../self.js'; -import { listSessionPanes, type PaneInfo } from '../tmux.js'; +import { getBackend } from '../backend.js'; +import type { PaneInfo } from '../process-manager.js'; import { PpgError, WorktreeNotFoundError, MergeFailedError } from '../../lib/errors.js'; import { execaEnv } from '../../lib/env.js'; @@ -132,7 +133,7 @@ export async function performMerge(options: MergeOptions): Promise const selfPaneId = getCurrentPaneId(); let paneMap: Map | undefined; if (selfPaneId) { - paneMap = await listSessionPanes(manifest.sessionName); + paneMap = await getBackend().listSessionPanes(manifest.sessionName); } const cleanupResult = await cleanupWorktree(projectRoot, wt, { selfPaneId, paneMap }); diff --git a/src/core/operations/restart.test.ts b/src/core/operations/restart.test.ts index 43944a1..948baad 100644 --- a/src/core/operations/restart.test.ts +++ b/src/core/operations/restart.test.ts @@ -43,9 +43,13 @@ vi.mock('../agent.js', () => ({ killAgent: vi.fn(), })); -vi.mock('../tmux.js', () => ({ - ensureSession: vi.fn(), - createWindow: vi.fn(), +const mockEnsureSession = vi.fn(); +const mockCreateWindow = vi.fn(); +vi.mock('../backend.js', () => ({ + getBackend: () => ({ + ensureSession: mockEnsureSession, + createWindow: mockCreateWindow, + }), })); vi.mock('../template.js', () => ({ @@ -69,7 +73,6 @@ vi.mock('../../lib/errors.js', async () => { import fs from 'node:fs/promises'; import { requireManifest, updateManifest, findAgent } from '../manifest.js'; import { spawnAgent, killAgent } from '../agent.js'; -import * as tmux from '../tmux.js'; import { performRestart } from './restart.js'; const mockedFindAgent = vi.mocked(findAgent); @@ -77,8 +80,8 @@ const mockedRequireManifest = vi.mocked(requireManifest); const mockedUpdateManifest = vi.mocked(updateManifest); const mockedSpawnAgent = vi.mocked(spawnAgent); const mockedKillAgent = vi.mocked(killAgent); -const mockedEnsureSession = vi.mocked(tmux.ensureSession); -const mockedCreateWindow = vi.mocked(tmux.createWindow); +const mockedEnsureSession = mockEnsureSession; +const mockedCreateWindow = mockCreateWindow; const mockedReadFile = vi.mocked(fs.readFile); const PROJECT_ROOT = '/tmp/project'; diff --git a/src/core/operations/restart.ts b/src/core/operations/restart.ts index 50ebcc8..dec9e97 100644 --- a/src/core/operations/restart.ts +++ b/src/core/operations/restart.ts @@ -3,7 +3,7 @@ import { requireManifest, updateManifest, findAgent } from '../manifest.js'; import { loadConfig, resolveAgentConfig } from '../config.js'; import { spawnAgent, killAgent } from '../agent.js'; import { getRepoRoot } from '../worktree.js'; -import * as tmux from '../tmux.js'; +import { getBackend } from '../backend.js'; import { agentId as genAgentId, sessionId as genSessionId } from '../../lib/id.js'; import { agentPromptFile } from '../../lib/paths.js'; import { AgentNotFoundError, PromptNotFoundError } from '../../lib/errors.js'; @@ -67,11 +67,11 @@ export async function performRestart(params: RestartParams): Promise ({ spawnAgent: vi.fn(), })); -vi.mock('../tmux.js', () => ({ - ensureSession: vi.fn(), - createWindow: vi.fn(), - splitPane: vi.fn(), - sendKeys: vi.fn(), +const mockEnsureSession = vi.fn(); +const mockCreateWindow = vi.fn(); +const mockSplitPane = vi.fn(); + +vi.mock('../backend.js', () => ({ + getBackend: () => ({ + ensureSession: mockEnsureSession, + createWindow: mockCreateWindow, + splitPane: mockSplitPane, + sendKeys: vi.fn(), + }), })); vi.mock('../terminal.js', () => ({ @@ -82,7 +88,6 @@ import { getRepoRoot, getCurrentBranch, createWorktree, adoptWorktree } from '.. import { setupWorktreeEnv } from '../env.js'; import { loadTemplate } from '../template.js'; import { spawnAgent } from '../agent.js'; -import * as tmux from '../tmux.js'; import { openTerminalWindow } from '../terminal.js'; import { worktreeId as genWorktreeId, agentId as genAgentId, sessionId as genSessionId } from '../../lib/id.js'; import { performSpawn } from './spawn.js'; @@ -95,9 +100,9 @@ const mockedUpdateManifest = vi.mocked(updateManifest); const mockedResolveWorktree = vi.mocked(resolveWorktree); const mockedCreateWorktree = vi.mocked(createWorktree); const mockedSpawnAgent = vi.mocked(spawnAgent); -const mockedEnsureSession = vi.mocked(tmux.ensureSession); -const mockedCreateWindow = vi.mocked(tmux.createWindow); -const mockedSplitPane = vi.mocked(tmux.splitPane); +const mockedEnsureSession = mockEnsureSession; +const mockedCreateWindow = mockCreateWindow; +const mockedSplitPane = mockSplitPane; const mockedLoadTemplate = vi.mocked(loadTemplate); const PROJECT_ROOT = '/tmp/project'; diff --git a/src/core/operations/spawn.ts b/src/core/operations/spawn.ts index c4a3225..285ab8c 100644 --- a/src/core/operations/spawn.ts +++ b/src/core/operations/spawn.ts @@ -5,7 +5,7 @@ import { getRepoRoot, getCurrentBranch, createWorktree, adoptWorktree } from '.. import { setupWorktreeEnv } from '../env.js'; import { loadTemplate, renderTemplate, type TemplateContext } from '../template.js'; import { spawnAgent } from '../agent.js'; -import * as tmux from '../tmux.js'; +import { getBackend } from '../backend.js'; import { openTerminalWindow } from '../terminal.js'; import { worktreeId as genWorktreeId, agentId as genAgentId, sessionId as genSessionId } from '../../lib/id.js'; import { manifestPath } from '../../lib/paths.js'; @@ -154,10 +154,10 @@ async function resolveAgentTarget(opts: SpawnTargetOptions): Promise { } if (opts.split) { const direction = opts.index % 2 === 1 ? 'horizontal' : 'vertical'; - const pane = await tmux.splitPane(opts.windowTarget, direction, opts.worktreePath); + const pane = await getBackend().splitPane(opts.windowTarget, direction, opts.worktreePath); return pane.target; } - return tmux.createWindow(opts.sessionName, `${opts.windowNamePrefix}-${opts.index}`, opts.worktreePath); + return getBackend().createWindow(opts.sessionName, `${opts.windowNamePrefix}-${opts.index}`, opts.worktreePath); } async function spawnAgentBatch(opts: SpawnBatchOptions): Promise { @@ -244,10 +244,10 @@ async function spawnNewWorktree( // Ensure tmux session (manifest is the source of truth for session name) const manifest = await readManifest(projectRoot); const sessionName = manifest.sessionName; - await tmux.ensureSession(sessionName); + await getBackend().ensureSession(sessionName); // Create tmux window - const windowTarget = await tmux.createWindow(sessionName, name, wtPath); + const windowTarget = await getBackend().createWindow(sessionName, name, wtPath); // Register skeleton worktree in manifest before spawning agents // so partial failures leave a record for cleanup @@ -330,10 +330,10 @@ async function spawnOnExistingBranch( // Ensure tmux session const manifest = await readManifest(projectRoot); const sessionName = manifest.sessionName; - await tmux.ensureSession(sessionName); + await getBackend().ensureSession(sessionName); // Create tmux window - const windowTarget = await tmux.createWindow(sessionName, name, wtPath); + const windowTarget = await getBackend().createWindow(sessionName, name, wtPath); // Register worktree in manifest const worktreeEntry: WorktreeEntry = { @@ -404,8 +404,8 @@ async function spawnIntoExistingWorktree( // Lazily create tmux window if worktree has none (standalone worktree) let windowTarget = wt.tmuxWindow; if (!windowTarget) { - await tmux.ensureSession(manifest.sessionName); - windowTarget = await tmux.createWindow(manifest.sessionName, wt.name, wt.path); + await getBackend().ensureSession(manifest.sessionName); + windowTarget = await getBackend().createWindow(manifest.sessionName, wt.name, wt.path); // Persist tmux window before spawning agents so partial failures are tracked. await updateManifest(projectRoot, (m) => { diff --git a/src/core/process-manager.ts b/src/core/process-manager.ts new file mode 100644 index 0000000..be01a72 --- /dev/null +++ b/src/core/process-manager.ts @@ -0,0 +1,31 @@ +import type { PaneInfo } from './tmux.js'; + +export interface ProcessManager { + checkAvailable(): Promise; + sessionExists(name: string): Promise; + ensureSession(name: string): Promise; + createWindow(session: string, name: string, cwd: string): Promise; + splitPane(target: string, direction: 'horizontal' | 'vertical', cwd: string): Promise<{ paneId: string; target: string }>; + sendKeys(target: string, command: string): Promise; + sendLiteral(target: string, text: string): Promise; + sendRawKeys(target: string, keys: string): Promise; + sendCtrlC(target: string): Promise; + capturePane(target: string, lines?: number): Promise; + killPane(target: string): Promise; + killWindow(target: string): Promise; + getPaneInfo(target: string): Promise; + listSessionPanes(session: string): Promise>; + listSessionWindows(session: string): Promise; + killOrphanWindows(session: string, selfPaneId?: string | null): Promise; + selectWindow(target: string): Promise; + isInsideSession(): boolean; + sanitizeName(name: string): string; +} + +export interface WindowInfo { + index: number; + name: string; +} + +// Re-export PaneInfo so consumers can import from here +export type { PaneInfo } from './tmux.js'; diff --git a/src/core/self.test.ts b/src/core/self.test.ts index f063cca..208696e 100644 --- a/src/core/self.test.ts +++ b/src/core/self.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect, beforeEach } from 'vitest'; import { getCurrentPaneId, wouldAffectSelf, excludeSelf, wouldCleanupAffectSelf } from './self.js'; -import type { PaneInfo } from './tmux.js'; +import type { PaneInfo } from './process-manager.js'; import type { AgentEntry, WorktreeEntry } from '../types/manifest.js'; function makePaneInfo(paneId: string): PaneInfo { diff --git a/src/core/self.ts b/src/core/self.ts index 0c9e3a2..08b0cda 100644 --- a/src/core/self.ts +++ b/src/core/self.ts @@ -1,4 +1,4 @@ -import type { PaneInfo } from './tmux.js'; +import type { PaneInfo } from './process-manager.js'; import type { AgentEntry, WorktreeEntry } from '../types/manifest.js'; /** diff --git a/src/core/spawn.ts b/src/core/spawn.ts index 16680b7..12768a2 100644 --- a/src/core/spawn.ts +++ b/src/core/spawn.ts @@ -4,7 +4,7 @@ import { getCurrentBranch, createWorktree } from './worktree.js'; import { setupWorktreeEnv } from './env.js'; import { loadTemplate, renderTemplate, type TemplateContext } from './template.js'; import { spawnAgent } from './agent.js'; -import * as tmux from './tmux.js'; +import { getBackend } from './backend.js'; import { worktreeId as genWorktreeId, agentId as genAgentId, sessionId as genSessionId } from '../lib/id.js'; import { PpgError } from '../lib/errors.js'; import { normalizeName } from '../lib/name.js'; @@ -46,10 +46,10 @@ async function resolveAgentTarget(opts: SpawnTargetOptions): Promise { } if (opts.split) { const direction = opts.index % 2 === 1 ? 'horizontal' : 'vertical'; - const pane = await tmux.splitPane(opts.windowTarget, direction, opts.worktreePath); + const pane = await getBackend().splitPane(opts.windowTarget, direction, opts.worktreePath); return pane.target; } - return tmux.createWindow(opts.sessionName, `${opts.windowNamePrefix}-${opts.index}`, opts.worktreePath); + return getBackend().createWindow(opts.sessionName, `${opts.windowNamePrefix}-${opts.index}`, opts.worktreePath); } export async function spawnAgentBatch(opts: SpawnBatchOptions): Promise { @@ -144,10 +144,10 @@ export async function spawnNewWorktree( await setupWorktreeEnv(projectRoot, wtPath, config); // Ensure tmux session (manifest is the source of truth for session name) - await tmux.ensureSession(sessionName); + await getBackend().ensureSession(sessionName); // Create tmux window - const windowTarget = await tmux.createWindow(sessionName, name, wtPath); + const windowTarget = await getBackend().createWindow(sessionName, name, wtPath); // Register skeleton worktree in manifest before spawning agents // so partial failures leave a record for cleanup diff --git a/src/core/terminal.ts b/src/core/terminal.ts index d9fa501..eb91480 100644 --- a/src/core/terminal.ts +++ b/src/core/terminal.ts @@ -3,12 +3,33 @@ import { warn } from '../lib/output.js'; import { shellEscape } from '../lib/shell.js'; /** - * Open a new Terminal.app window that attaches to a specific tmux session/window. + * Open a new terminal window that attaches to a specific tmux session/window. + * Uses platform-appropriate terminal emulator: + * - macOS: Terminal.app via osascript + * - Windows: Windows Terminal (wt.exe) + * - Linux: $TERMINAL or falls back to common emulators */ export async function openTerminalWindow( sessionName: string, windowTarget: string, title: string, +): Promise { + const platform = process.platform; + + if (platform === 'darwin') { + await openTerminalMac(sessionName, windowTarget, title); + } else if (platform === 'win32') { + await openTerminalWindows(sessionName, windowTarget, title); + } else { + await openTerminalLinux(sessionName, windowTarget, title); + } +} + +/** macOS: Open Terminal.app via osascript */ +async function openTerminalMac( + sessionName: string, + windowTarget: string, + title: string, ): Promise { // Source shell profiles so tmux is found on M-series Macs where // /opt/homebrew/bin is not in the default GUI app / Terminal.app PATH. @@ -32,3 +53,58 @@ end tell warn(`Could not open Terminal window for "${title}": ${err instanceof Error ? err.message : err}`); } } + +/** Windows: Open Windows Terminal (wt.exe) */ +async function openTerminalWindows( + sessionName: string, + windowTarget: string, + title: string, +): Promise { + try { + await execa('wt.exe', [ + 'new-tab', + '--title', title, + 'tmux', 'attach-session', '-t', sessionName, ';', 'select-window', '-t', windowTarget, + ]); + } catch (err) { + warn(`Could not open Windows Terminal for "${title}": ${err instanceof Error ? err.message : err}`); + } +} + +/** Linux: Use $TERMINAL env var or fall back to common emulators */ +async function openTerminalLinux( + sessionName: string, + windowTarget: string, + title: string, +): Promise { + const tmuxCmd = `tmux attach-session -t ${shellEscape(sessionName)} \\; select-window -t ${shellEscape(windowTarget)}`; + + // Try $TERMINAL env var first + const terminalEnv = process.env.TERMINAL; + if (terminalEnv) { + try { + await execa(terminalEnv, ['-e', 'sh', '-c', tmuxCmd]); + return; + } catch { + // Fall through to common emulators + } + } + + // Try common terminal emulators in order + const emulators = [ + { cmd: 'gnome-terminal', args: ['--title', title, '--', 'sh', '-c', tmuxCmd] }, + { cmd: 'konsole', args: ['--title', title, '-e', 'sh', '-c', tmuxCmd] }, + { cmd: 'xterm', args: ['-title', title, '-e', 'sh', '-c', tmuxCmd] }, + ]; + + for (const { cmd, args } of emulators) { + try { + await execa(cmd, args); + return; + } catch { + continue; + } + } + + warn(`Could not open terminal window for "${title}": no supported terminal emulator found. Set $TERMINAL env var.`); +} diff --git a/src/core/tmux.ts b/src/core/tmux.ts index edd6dc8..7ccde1a 100644 --- a/src/core/tmux.ts +++ b/src/core/tmux.ts @@ -1,6 +1,7 @@ import { execa, ExecaError } from 'execa'; import { TmuxNotFoundError } from '../lib/errors.js'; import { execaEnv } from '../lib/env.js'; +import type { ProcessManager, WindowInfo as PMWindowInfo } from './process-manager.js'; /** * Sanitize a string for use as a tmux session name. @@ -320,3 +321,25 @@ export async function isInsideTmux(): Promise { export async function sendCtrlC(target: string): Promise { await execa('tmux', ['send-keys', '-t', target, 'C-c'], execaEnv); } + +export class TmuxBackend implements ProcessManager { + async checkAvailable(): Promise { return checkTmux(); } + async sessionExists(name: string): Promise { return sessionExists(name); } + async ensureSession(name: string): Promise { return ensureSession(name); } + async createWindow(session: string, name: string, cwd: string): Promise { return createWindow(session, name, cwd); } + async splitPane(target: string, direction: 'horizontal' | 'vertical', cwd: string): Promise<{ paneId: string; target: string }> { return splitPane(target, direction, cwd); } + async sendKeys(target: string, command: string): Promise { return sendKeys(target, command); } + async sendLiteral(target: string, text: string): Promise { return sendLiteral(target, text); } + async sendRawKeys(target: string, keys: string): Promise { return sendRawKeys(target, keys); } + async sendCtrlC(target: string): Promise { return sendCtrlC(target); } + async capturePane(target: string, lines?: number): Promise { return capturePane(target, lines); } + async killPane(target: string): Promise { return killPane(target); } + async killWindow(target: string): Promise { return killWindow(target); } + async getPaneInfo(target: string): Promise { return getPaneInfo(target); } + async listSessionPanes(session: string): Promise> { return listSessionPanes(session); } + async listSessionWindows(session: string): Promise { return listSessionWindows(session); } + async killOrphanWindows(session: string, selfPaneId?: string | null): Promise { return killOrphanWindows(session, selfPaneId); } + async selectWindow(target: string): Promise { return selectWindow(target); } + isInsideSession(): boolean { return !!process.env.TMUX; } + sanitizeName(name: string): string { return sanitizeTmuxName(name); } +} diff --git a/src/server/routes/agents.test.ts b/src/server/routes/agents.test.ts index a4b3a0f..494e428 100644 --- a/src/server/routes/agents.test.ts +++ b/src/server/routes/agents.test.ts @@ -30,11 +30,17 @@ vi.mock('../../core/agent.js', () => ({ restartAgent: vi.fn(), })); -vi.mock('../../core/tmux.js', () => ({ - capturePane: vi.fn(), - sendKeys: vi.fn(), - sendLiteral: vi.fn(), - sendRawKeys: vi.fn(), +const mockCapturePane = vi.fn(); +const mockSendKeys = vi.fn(); +const mockSendLiteral = vi.fn(); +const mockSendRawKeys = vi.fn(); +vi.mock('../../core/backend.js', () => ({ + getBackend: () => ({ + capturePane: mockCapturePane, + sendKeys: mockSendKeys, + sendLiteral: mockSendLiteral, + sendRawKeys: mockSendRawKeys, + }), })); vi.mock('../../core/config.js', () => ({ @@ -55,7 +61,6 @@ vi.mock('node:fs/promises', async () => { import { requireManifest, findAgent, updateManifest } from '../../core/manifest.js'; import { killAgent, checkAgentStatus, restartAgent } from '../../core/agent.js'; -import * as tmux from '../../core/tmux.js'; import { loadConfig, resolveAgentConfig } from '../../core/config.js'; import fs from 'node:fs/promises'; @@ -86,7 +91,7 @@ beforeEach(() => { describe('GET /api/agents/:id/logs', () => { test('returns captured pane output with default 200 lines', async () => { setupAgentMocks(); - vi.mocked(tmux.capturePane).mockResolvedValue('line1\nline2\nline3'); + vi.mocked(mockCapturePane).mockResolvedValue('line1\nline2\nline3'); const app = await buildApp(); const res = await app.inject({ method: 'GET', url: '/api/agents/ag-test1234/logs' }); @@ -96,31 +101,31 @@ describe('GET /api/agents/:id/logs', () => { expect(body.agentId).toBe('ag-test1234'); expect(body.output).toBe('line1\nline2\nline3'); expect(body.lines).toBe(200); - expect(tmux.capturePane).toHaveBeenCalledWith('ppg:1.0', 200); + expect(mockCapturePane).toHaveBeenCalledWith('ppg:1.0', 200); }); test('respects custom lines parameter', async () => { setupAgentMocks(); - vi.mocked(tmux.capturePane).mockResolvedValue('output'); + vi.mocked(mockCapturePane).mockResolvedValue('output'); const app = await buildApp(); const res = await app.inject({ method: 'GET', url: '/api/agents/ag-test1234/logs?lines=50' }); expect(res.statusCode).toBe(200); expect(res.json().lines).toBe(50); - expect(tmux.capturePane).toHaveBeenCalledWith('ppg:1.0', 50); + expect(mockCapturePane).toHaveBeenCalledWith('ppg:1.0', 50); }); test('caps lines at 10000', async () => { setupAgentMocks(); - vi.mocked(tmux.capturePane).mockResolvedValue('output'); + vi.mocked(mockCapturePane).mockResolvedValue('output'); const app = await buildApp(); const res = await app.inject({ method: 'GET', url: '/api/agents/ag-test1234/logs?lines=999999' }); expect(res.statusCode).toBe(200); expect(res.json().lines).toBe(10000); - expect(tmux.capturePane).toHaveBeenCalledWith('ppg:1.0', 10000); + expect(mockCapturePane).toHaveBeenCalledWith('ppg:1.0', 10000); }); test('returns 400 for invalid lines', async () => { @@ -144,7 +149,7 @@ describe('GET /api/agents/:id/logs', () => { test('returns 410 when pane no longer exists', async () => { setupAgentMocks(); - vi.mocked(tmux.capturePane).mockRejectedValue(new Error('pane not found')); + vi.mocked(mockCapturePane).mockRejectedValue(new Error('pane not found')); const app = await buildApp(); const res = await app.inject({ method: 'GET', url: '/api/agents/ag-test1234/logs' }); @@ -170,7 +175,7 @@ describe('POST /api/agents/:id/send', () => { expect(res.statusCode).toBe(200); expect(res.json().success).toBe(true); expect(res.json().mode).toBe('with-enter'); - expect(tmux.sendKeys).toHaveBeenCalledWith('ppg:1.0', 'hello'); + expect(mockSendKeys).toHaveBeenCalledWith('ppg:1.0', 'hello'); }); test('sends literal text without Enter', async () => { @@ -184,7 +189,7 @@ describe('POST /api/agents/:id/send', () => { }); expect(res.statusCode).toBe(200); - expect(tmux.sendLiteral).toHaveBeenCalledWith('ppg:1.0', 'hello'); + expect(mockSendLiteral).toHaveBeenCalledWith('ppg:1.0', 'hello'); }); test('sends raw tmux keys', async () => { @@ -198,7 +203,7 @@ describe('POST /api/agents/:id/send', () => { }); expect(res.statusCode).toBe(200); - expect(tmux.sendRawKeys).toHaveBeenCalledWith('ppg:1.0', 'C-c'); + expect(mockSendRawKeys).toHaveBeenCalledWith('ppg:1.0', 'C-c'); }); test('rejects invalid mode', async () => { diff --git a/src/server/routes/agents.ts b/src/server/routes/agents.ts index 8ef30de..3cf7281 100644 --- a/src/server/routes/agents.ts +++ b/src/server/routes/agents.ts @@ -2,7 +2,7 @@ import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; import { requireManifest, findAgent, updateManifest } from '../../core/manifest.js'; import { killAgent, checkAgentStatus, restartAgent } from '../../core/agent.js'; import { loadConfig, resolveAgentConfig } from '../../core/config.js'; -import * as tmux from '../../core/tmux.js'; +import { getBackend } from '../../core/backend.js'; import { PpgError, AgentNotFoundError } from '../../lib/errors.js'; import { agentPromptFile } from '../../lib/paths.js'; import fs from 'node:fs/promises'; @@ -77,7 +77,7 @@ export async function agentRoutes( let content: string; try { - content = await tmux.capturePane(agent.tmuxTarget, lines); + content = await getBackend().capturePane(agent.tmuxTarget, lines); } catch { throw new PpgError( `Could not capture pane for agent ${id}. Pane may no longer exist.`, @@ -131,14 +131,14 @@ export async function agentRoutes( switch (mode) { case 'raw': - await tmux.sendRawKeys(agent.tmuxTarget, text); + await getBackend().sendRawKeys(agent.tmuxTarget, text); break; case 'literal': - await tmux.sendLiteral(agent.tmuxTarget, text); + await getBackend().sendLiteral(agent.tmuxTarget, text); break; case 'with-enter': default: - await tmux.sendKeys(agent.tmuxTarget, text); + await getBackend().sendKeys(agent.tmuxTarget, text); break; } diff --git a/src/server/ws/terminal.ts b/src/server/ws/terminal.ts index 1d9defd..d09cbeb 100644 --- a/src/server/ws/terminal.ts +++ b/src/server/ws/terminal.ts @@ -1,4 +1,4 @@ -import { capturePane } from '../../core/tmux.js'; +import { getBackend } from '../../core/backend.js'; // --------------------------------------------------------------------------- // Types @@ -95,7 +95,7 @@ export class TerminalStreamer { capture?: (target: string, lines?: number) => Promise; }) { this.pollIntervalMs = options?.pollIntervalMs ?? POLL_INTERVAL_MS; - this.capture = options?.capture ?? capturePane; + this.capture = options?.capture ?? ((target, lines) => getBackend().capturePane(target, lines)); } /** diff --git a/src/server/ws/watcher.test.ts b/src/server/ws/watcher.test.ts index f246ddc..8415f6b 100644 --- a/src/server/ws/watcher.test.ts +++ b/src/server/ws/watcher.test.ts @@ -21,8 +21,11 @@ vi.mock('../../core/agent.js', () => ({ checkAgentStatus: vi.fn(), })); -vi.mock('../../core/tmux.js', () => ({ - listSessionPanes: vi.fn(), +const mockListSessionPanes = vi.fn(); +vi.mock('../../core/backend.js', () => ({ + getBackend: () => ({ + listSessionPanes: mockListSessionPanes, + }), })); vi.mock('../../lib/paths.js', () => ({ @@ -33,12 +36,11 @@ vi.mock('../../lib/paths.js', () => ({ import nodefs from 'node:fs'; import { readManifest } from '../../core/manifest.js'; import { checkAgentStatus } from '../../core/agent.js'; -import { listSessionPanes } from '../../core/tmux.js'; import { startManifestWatcher } from './watcher.js'; const mockedReadManifest = vi.mocked(readManifest); const mockedCheckAgentStatus = vi.mocked(checkAgentStatus); -const mockedListSessionPanes = vi.mocked(listSessionPanes); +const mockedListSessionPanes = mockListSessionPanes; const mockedFsWatch = vi.mocked(nodefs.watch); const PROJECT_ROOT = '/tmp/project'; diff --git a/src/server/ws/watcher.ts b/src/server/ws/watcher.ts index 7e149dd..36f43c5 100644 --- a/src/server/ws/watcher.ts +++ b/src/server/ws/watcher.ts @@ -2,7 +2,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { readManifest } from '../../core/manifest.js'; import { checkAgentStatus } from '../../core/agent.js'; -import { listSessionPanes, type PaneInfo } from '../../core/tmux.js'; +import { getBackend } from '../../core/backend.js'; +import type { PaneInfo } from '../../core/process-manager.js'; import { manifestPath, ppgDir } from '../../lib/paths.js'; import type { AgentStatus, Manifest } from '../../types/manifest.js'; @@ -120,7 +121,7 @@ export function startManifestWatcher( let paneMap: Map; try { - paneMap = await listSessionPanes(manifest.sessionName); + paneMap = await getBackend().listSessionPanes(manifest.sessionName); } catch (err) { onError?.(err); return; diff --git a/src/test-fixtures.ts b/src/test-fixtures.ts index 38c7c4b..6c30eb6 100644 --- a/src/test-fixtures.ts +++ b/src/test-fixtures.ts @@ -1,5 +1,5 @@ import type { AgentEntry, Manifest, WorktreeEntry } from './types/manifest.js'; -import type { PaneInfo } from './core/tmux.js'; +import type { PaneInfo } from './core/process-manager.js'; export function makeAgent(overrides?: Partial): AgentEntry { return {