diff --git a/apps/purepoint-macos/purepoint-macos/Services/DaemonProjectsSubscription.swift b/apps/purepoint-macos/purepoint-macos/Services/DaemonProjectsSubscription.swift new file mode 100644 index 00000000..47fb4a24 --- /dev/null +++ b/apps/purepoint-macos/purepoint-macos/Services/DaemonProjectsSubscription.swift @@ -0,0 +1,69 @@ +import Foundation + +/// Follows a daemon's registered projects (protocol 13). The daemon sends the full list of +/// roots when the stream opens and again after every add or remove, so a project added or +/// removed from any device, or with `pu projects`, shows up here without polling. +/// Modeled after DaemonStatusSubscription; unlike it, it never gives up while running: a +/// restarted daemon is simply subscribed to again. +actor DaemonProjectsSubscription { + private var connection: (any DaemonByteStream)? + private var stopped = false + private let client: DaemonClient + + private static let initialBackoff: UInt64 = 500_000_000 // 0.5s + + init(client: DaemonClient) { + self.client = client + } + + /// Delivers each list of roots until `stop()`. Reconnects with backoff after a failure. + func start(onChange: @escaping @MainActor ([String]) -> Void) async { + var backoff = Self.initialBackoff + let maxBackoff: UInt64 = 10_000_000_000 // 10s + while !stopped { + do { + try await run(onChange: onChange) + backoff = Self.initialBackoff + } catch is CancellationError { + return + } catch { + #if os(iOS) + if await AppActivity.shared.isInBackground { + await AppActivity.shared.untilForeground() + backoff = Self.initialBackoff + continue + } + #endif + } + guard !stopped else { return } + do { try await Task.sleep(nanoseconds: backoff) } catch { return } + backoff = min(backoff * 2, maxBackoff) + } + } + + func stop() { + stopped = true + connection?.close() + connection = nil + } + + private func run(onChange: @escaping @MainActor ([String]) -> Void) async throws { + let (conn, reader) = try await client.connect() + // Stopped while connecting: nothing else would ever close this connection. + guard !stopped else { + conn.close() + return + } + connection?.close() + connection = conn + try await DaemonClient.write(.subscribeProjects, to: conn) + guard case .projectsSubscribed = DaemonClient.parse(try await reader.readLine()) else { + throw DaemonStatusError.unexpectedResponse + } + while !stopped { + if case .projectsChanged(let projects) = DaemonClient.parse(try await reader.readLine()) { + await onChange(projects) + } + } + } +} diff --git a/apps/purepoint-macos/purepoint-macos/Services/DaemonProtocol.swift b/apps/purepoint-macos/purepoint-macos/Services/DaemonProtocol.swift index f2000c24..0cf8e814 100644 --- a/apps/purepoint-macos/purepoint-macos/Services/DaemonProtocol.swift +++ b/apps/purepoint-macos/purepoint-macos/Services/DaemonProtocol.swift @@ -299,7 +299,12 @@ nonisolated enum DaemonRequest: Encodable { case channelEdit(projectRoot: String, messageId: String, text: String) case channelReact(projectRoot: String, messageId: String, active: Bool) case health + /// Adds the folder to the daemon's projects, initializing it if needed. case initProject(projectRoot: String) + /// Forgets a project on the daemon for every device; its files are untouched (protocol 13). + case removeProject(projectRoot: String, suspendAgents: Bool = false) + /// Streams the daemon's registered project roots, now and on every change (protocol 13). + case subscribeProjects case status(projectRoot: String, agentId: String? = nil) case attach(agentId: String, clientId: String? = nil) case input(agentId: String, data: Data) @@ -405,6 +410,12 @@ nonisolated enum DaemonRequest: Encodable { case .initProject(let projectRoot): try container.encode("init", forKey: .key("type")) try container.encode(projectRoot, forKey: .key("project_root")) + case .removeProject(let projectRoot, let suspendAgents): + try container.encode("remove_project", forKey: .key("type")) + try container.encode(projectRoot, forKey: .key("project_root")) + try container.encode(suspendAgents, forKey: .key("suspend_agents")) + case .subscribeProjects: + try container.encode("subscribe_projects", forKey: .key("type")) case .status(let projectRoot, let agentId): try container.encode("status", forKey: .key("type")) try container.encode(projectRoot, forKey: .key("project_root")) @@ -665,6 +676,9 @@ nonisolated enum DaemonResponse: Decodable { case channelMessage(ChannelMutation) case healthReport(pid: Int, uptimeSeconds: Int, protocolVersion: Int, agentCount: Int) case initResult(created: Bool) + case projectRemoved(projectRoot: String, removed: Bool, suspended: [String], killed: [String]) + case projectsSubscribed + case projectsChanged(projects: [String]) case statusReport(worktrees: [WorktreeEntry], agents: [AgentStatusReport]) case resync case ptySize(cols: Int, rows: Int, ownerClientId: String?) @@ -729,6 +743,14 @@ nonisolated enum DaemonResponse: Decodable { case "init_result": let p = try InitResultPayload(from: decoder) self = .initResult(created: p.created) + case "project_removed": + let p = try ProjectRemovedPayload(from: decoder) + self = .projectRemoved( + projectRoot: p.projectRoot, removed: p.removed, suspended: p.suspended, killed: p.killed ?? []) + case "projects_subscribed": + self = .projectsSubscribed + case "projects_changed": + self = .projectsChanged(projects: try ProjectsChangedPayload(from: decoder).projects) case "status_report": let p = try StatusReportPayload(from: decoder) self = .statusReport(worktrees: p.worktrees, agents: p.agents) @@ -1030,6 +1052,25 @@ private nonisolated struct InitResultPayload: Decodable { let created: Bool } +private nonisolated struct ProjectRemovedPayload: Decodable { + let projectRoot: String + let removed: Bool + let suspended: [String] + /// Sessions the manifest did not list, stopped rather than suspended. + let killed: [String]? + + enum CodingKeys: String, CodingKey { + case projectRoot = "project_root" + case removed + case suspended + case killed + } +} + +private nonisolated struct ProjectsChangedPayload: Decodable { + let projects: [String] +} + private nonisolated struct HealthReportPayload: Decodable { let pid: Int let uptimeSeconds: Int @@ -1212,7 +1253,7 @@ private nonisolated struct DynamicCodingKey: CodingKey { nonisolated enum DaemonProtocol { /// The protocol this app speaks. A daemon reporting another version is incompatible. - static let version = 12 + static let version = 13 } nonisolated struct InventoryProjectPayload: Decodable, Sendable, Equatable { diff --git a/apps/purepoint-macos/purepoint-macos/State/AppState.swift b/apps/purepoint-macos/purepoint-macos/State/AppState.swift index 65916026..1d7a1c11 100644 --- a/apps/purepoint-macos/purepoint-macos/State/AppState.swift +++ b/apps/purepoint-macos/purepoint-macos/State/AppState.swift @@ -108,19 +108,40 @@ final class AppState { @ObservationIgnored private let service: any WorkspaceService @ObservationIgnored private let startProject: @MainActor (ProjectState) -> Void + /// Whether `restoreProjects()` follows the local daemon's project list (off in tests). + @ObservationIgnored private let followsLocalDaemon: Bool #if os(macOS) @ObservationIgnored private var binaryWatcher: ManifestWatcher? #endif @ObservationIgnored private var machineRegistryWatcher: MachineRegistryWatcher? - + #if os(macOS) + /// Follows the local daemon's registered projects, which decide this Mac's sidebar. + @ObservationIgnored private var localProjectsSubscription: DaemonProjectsSubscription? + /// The local daemon's projects as last reported, to tell a removal from a project + /// whose `init` has not reached the daemon yet; nil until the first report. + @ObservationIgnored private var lastLocalProjects: Set? + /// Each opened root's canonical path, resolved once rather than on every report. + @ObservationIgnored private var canonicalRoots: [String: String] = [:] + #endif + /// Point Guard folders the user also opened as sidebar projects. Other Point Guard + /// folders stay out of the sidebar even though the daemon has them registered. + @ObservationIgnored private var shownPointGuardFolders = Set() + /// Whether the first local project list reopens last session's sidebar, whose agents + /// this app suspended on quit and resumes. Projects that join later keep theirs paused. + @ObservationIgnored private var resumesOnFirstLocalList = false + + /// The sidebar list from before the daemon owned it; read once, then deleted. private static let openProjectsKey = "PurePointOpenProjects" + private static let shownPointGuardFoldersKey = "PurePointShownPointGuardFolders" init( service: any WorkspaceService = DaemonWorkspaceService(), - startProject: @escaping @MainActor (ProjectState) -> Void = { $0.startWatching() } + startProject: @escaping @MainActor (ProjectState) -> Void = { $0.startWatching() }, + followsLocalDaemon: Bool = true ) { self.service = service self.startProject = startProject + self.followsLocalDaemon = followsLocalDaemon #if os(iOS) installLifecycleHooks() #endif @@ -228,14 +249,16 @@ final class AppState { /// Open a project on this Mac. iOS has no local machine: every iPad project is /// machine-backed and arrives through its `MachineState`, so a local root never opens. - func openProject(_ root: String) { + func openProject(_ root: String, resumingSuspendedAgents: Bool = true) { #if os(macOS) guard !projects.contains(where: { $0.projectRoot == root }) else { return } let project = pointGuardProjects.removeValue(forKey: root) ?? ProjectState(projectRoot: root, service: service, registry: registry) project.appState = self + if !resumingSuspendedAgents { project.keepsSuspendedAgentsOnNextOpen = true } projects.append(project) + if pointGuardFolders.contains(root) { shownPointGuardFolders.insert(root) } startProject(project) initializeActiveProjectIfNeeded(root: root) @@ -258,10 +281,9 @@ final class AppState { } guard let index = projects.firstIndex(where: { $0.projectRoot == root }) else { return false } let project = projects.remove(at: index) - if root == pointGuardRoot || pointGuardFolders.contains(root) - || registry?.workspacesByProject[root]?.contains(where: \.isPointGuard) == true - { + if isPointGuardFolder(root) { pointGuardFolders.insert(root) + shownPointGuardFolders.remove(root) pointGuardProjects[root] = project } else { project.stopWatching() @@ -274,6 +296,33 @@ final class AppState { return projectState(forRoot: root) == nil } + /// Whether Point Guard uses this local folder, so it must stay registered and followed + /// even when it is not in the sidebar. + func isPointGuardFolder(_ root: String) -> Bool { + root == pointGuardRoot || pointGuardFolders.contains(root) + || registry?.workspacesByProject[root]?.contains(where: \.isPointGuard) == true + } + + /// Remove a project for every device that shows it: its machine's daemon forgets it + /// (`remove_project`), suspending its running agents and keeping its files, and each + /// device's sidebar follows. A local folder Point Guard still uses only leaves this + /// Mac's sidebar, since Point Guard needs it registered. + func removeProject(_ project: ProjectState) async throws { + if project.isRemote { + guard let machine = machine(for: project) else { return } + try await machine.removeProject(root: project.key.root, suspendAgents: true) + if activeProjectRoot == project.projectId { activeProjectRoot = projects.first?.projectRoot } + return + } + let root = project.projectRoot + if !isPointGuardFolder(root) { + // The daemon registered the canonical path; a symlinked one may name a deleted folder. + try await DaemonEndpoint.local.makeClient().removeProject( + root: Self.canonicalPath(root), suspendAgents: true, daemonName: "The daemon") + } + closeProject(root) + } + /// Whether a path is too broad to open as a project: `/`, `$HOME`, or an ancestor /// of `$HOME`. Mirrors the daemon's `is_too_broad_project_root` validation so the /// app doesn't attempt to open roots the daemon will reject. @@ -293,26 +342,138 @@ final class AppState { return false } + /// Open the projects this Mac shows. With a root (`--project-root`) only that one; otherwise + /// the local daemon's registered projects, followed from then on, so a project added or + /// removed on any device, or with `pu projects`, is added or removed here too. func restoreProjects(projectRoot: String? = nil) { + // The list saved before the daemon owned it is opened once, which registers each + // project with the daemon, and then deleted. let paths = projectRoot.map { [$0] } ?? UserDefaults.standard.stringArray(forKey: Self.openProjectsKey) ?? [] - // Opening projects persists current state, so snapshot both saved lists first. + // Opening projects persists current state, so snapshot the saved lists first. let savedPointGuardFolders = UserDefaults.standard.stringArray(forKey: "PurePointPointGuardFolders") ?? [] pointGuardFolders.formUnion(savedPointGuardFolders) + shownPointGuardFolders.formUnion( + UserDefaults.standard.stringArray(forKey: Self.shownPointGuardFoldersKey) ?? []) + var notImported: [String] = [] for path in paths { - guard projectRoot != nil || FileManager.default.fileExists(atPath: path) else { continue } + guard projectRoot != nil || FileManager.default.fileExists(atPath: path) else { + // On a volume that is not mounted yet: try again next launch. + notImported.append(path) + continue + } guard !isTooBroadProjectRoot(path) else { continue } openProject(path) } + // The first list reopens last session's sidebar, unless that came from the legacy list. + resumesOnFirstLocalList = paths.count == notImported.count + if projectRoot == nil { + if notImported.isEmpty { + UserDefaults.standard.removeObject(forKey: Self.openProjectsKey) + } else { + UserDefaults.standard.set(notImported, forKey: Self.openProjectsKey) + } + } for root in savedPointGuardFolders { guard FileManager.default.fileExists(atPath: root) else { continue } guard !isTooBroadProjectRoot(root) else { continue } ensurePointGuardProject(root: root) } + #if os(macOS) + if projectRoot == nil, followsLocalDaemon { followLocalProjects() } + #endif // Workspace layouts load lazily in WorkspaceRegistry.reconcile, on the first // manifest read for each project. } + #if os(macOS) + private func followLocalProjects() { + guard localProjectsSubscription == nil else { return } + let subscription = DaemonProjectsSubscription(client: DaemonEndpoint.local.makeClient()) + localProjectsSubscription = subscription + Task { + // The daemon holds the project list, so it runs even before any project opens. + try? await DaemonLifecycle.ensureDaemon() + await subscription.start { [weak self] roots in self?.applyLocalProjects(roots) } + } + } + #endif + + /// Match the sidebar to the local daemon's projects. A project that left the list was + /// removed (on another device, or with `pu projects remove`) and closes here; one that + /// joined opens, unless it is a Point Guard folder the user has not opened. + func applyLocalProjects(_ roots: [String]) { + #if os(macOS) + let registered = Set(roots) + // The daemon reports canonical roots; a project may have been opened by another path. + let canonical = Dictionary( + projects.map { (canonicalRoot(of: $0.projectRoot), $0.projectRoot) }, uniquingKeysWith: { first, _ in first }) + let pointGuard = Dictionary( + pointGuardProjects.map { (canonicalRoot(of: $0.key), $0.value) }, uniquingKeysWith: { first, _ in first }) + for root in (lastLocalProjects ?? []).subtracting(registered) { + if let owner = canonical[root].flatMap({ projectState(forRoot: $0) }) ?? pointGuard[root], + isPointGuardFolder(owner.projectRoot) + { + // Removed elsewhere, but Point Guard works in this folder: register it again, + // leaving the agents the removal suspended paused. + owner.keepsSuspendedAgentsOnNextOpen = true + startProject(owner) + } else if let open = canonical[root] { + closeProject(open) + } + } + let resuming = resumesOnFirstLocalList + resumesOnFirstLocalList = false + for root in roots where canonical[root] == nil { + // A Point Guard folder the user has not opened stays out of the sidebar. + if let owner = pointGuard[root], !shownPointGuardFolders.contains(owner.projectRoot) { continue } + guard FileManager.default.fileExists(atPath: root), !isTooBroadProjectRoot(root) else { continue } + openProject(root, resumingSuspendedAgents: resuming) + } + lastLocalProjects = registered + #endif + } + + #if os(macOS) + private func canonicalRoot(of root: String) -> String { + if let known = canonicalRoots[root] { return known } + guard let resolved = Self.resolvedPath(root) else { + // Not resolvable yet (an unmounted volume): try again on the next report. + return root + } + canonicalRoots[root] = resolved + return resolved + } + #endif + + /// Until the local daemon first reports its projects, a saved workspace selection may + /// belong to one that has not opened yet. + private var awaitingLocalProjects: Bool { + #if os(macOS) + localProjectsSubscription != nil && lastLocalProjects == nil + #else + false + #endif + } + + /// The machine a remote project lives on; nil for a local one. + func machine(for project: ProjectState) -> MachineState? { + machines.first { $0.machine.id == project.key.machineId } + } + + /// The path with symlinks resolved, as the daemon's `canonicalize` reports it (unlike + /// `URL.resolvingSymlinksInPath`, which drops `/private`). + nonisolated static func canonicalPath(_ path: String) -> String { + resolvedPath(path) ?? path + } + + /// The path with symlinks resolved, or nil when it does not resolve (yet). + nonisolated private static func resolvedPath(_ path: String) -> String? { + guard let resolved = realpath(path, nil) else { return nil } + defer { free(resolved) } + return String(cString: resolved) + } + // MARK: - Machines /// Read `~/.pu/machines.json` and connect every machine the user has not disconnected. @@ -451,7 +612,8 @@ final class AppState { private func ensurePointGuardProject(root: String) { pointGuardFolders.insert(root) - UserDefaults.standard.set(Array(pointGuardFolders), forKey: "PurePointPointGuardFolders") + if projects.contains(where: { $0.projectRoot == root }) { shownPointGuardFolders.insert(root) } + persistOpenProjects() #if os(macOS) guard projectState(forRoot: root) == nil else { return } let project = ProjectState(projectRoot: root, service: service, registry: registry) @@ -671,7 +833,9 @@ final class AppState { return pendingId } // An unreachable machine may never load its layouts; it must not hold the selection. - if localManagedProjects.allSatisfy({ registry.workspacesByProject[$0.projectId] != nil }) { + if !awaitingLocalProjects, + localManagedProjects.allSatisfy({ registry.workspacesByProject[$0.projectId] != nil }) + { pendingSelectWorkspaceId = nil } return nil @@ -679,9 +843,10 @@ final class AppState { // MARK: - Private + /// The daemon keeps which projects exist; this Mac only keeps which Point Guard folders + /// it shows. private func persistOpenProjects() { - let paths = projects.map(\.projectRoot) - UserDefaults.standard.set(paths, forKey: Self.openProjectsKey) + UserDefaults.standard.set(Array(shownPointGuardFolders), forKey: Self.shownPointGuardFoldersKey) UserDefaults.standard.set(Array(pointGuardFolders), forKey: "PurePointPointGuardFolders") } diff --git a/apps/purepoint-macos/purepoint-macos/State/MachineState+OpenProject.swift b/apps/purepoint-macos/purepoint-macos/State/MachineState+OpenProject.swift index cab1a33e..40680edc 100644 --- a/apps/purepoint-macos/purepoint-macos/State/MachineState+OpenProject.swift +++ b/apps/purepoint-macos/purepoint-macos/State/MachineState+OpenProject.swift @@ -33,6 +33,34 @@ extension MachineState { } } +extension MachineState { + /// Remove a project from the machine for every device (`remove_project`). Its folder and + /// `.pu/` are kept, so opening it again brings it back as it was. The daemon refuses while + /// agents run unless `suspendAgents`, which suspends them so they can be resumed once it is added again. + func removeProject(root: String, suspendAgents: Bool, client: DaemonClient? = nil) async throws { + try await (client ?? endpoint.makeClient()).removeProject( + root: root, suspendAgents: suspendAgents, daemonName: machine.name) + // Nothing left to hide: a later open shows it again everywhere. + unhideProject(root: root) + if state.isDaemonRunning { await refreshProjects() } + } +} + +extension DaemonClient { + /// Ask this daemon to forget a project (`remove_project`), throwing its refusal. + func removeProject(root: String, suspendAgents: Bool, daemonName: String) async throws { + switch try await send(.removeProject(projectRoot: root, suspendAgents: suspendAgents)) { + case .projectRemoved: + return + case .error(_, let message): + throw MachineOpenProjectError(message: message) + default: + throw MachineOpenProjectError(message: "\(daemonName) did not remove \(root).") + } + } +} + +/// A project action the machine's daemon refused, with its message. struct MachineOpenProjectError: LocalizedError, Equatable { let message: String var errorDescription: String? { message } diff --git a/apps/purepoint-macos/purepoint-macos/State/MachineState.swift b/apps/purepoint-macos/purepoint-macos/State/MachineState.swift index ae985186..40d3b105 100644 --- a/apps/purepoint-macos/purepoint-macos/State/MachineState.swift +++ b/apps/purepoint-macos/purepoint-macos/State/MachineState.swift @@ -41,12 +41,15 @@ final class MachineState: Identifiable { @ObservationIgnored private var connection: MachineConnection! @ObservationIgnored private var connectionGeneration = 0 @ObservationIgnored private var refreshTask: Task? + /// Pushes the daemon's project list on every add or remove; the poll is the fallback. + @ObservationIgnored private var projectsSubscription: DaemonProjectsSubscription? @ObservationIgnored private var hiddenRoots: Set /// Closing the forward after pending layout saves were flushed; a reconnect waits for it. @ObservationIgnored private var disconnectTask: Task? @ObservationIgnored private var isUserDisconnecting = false - /// How often the project list is re-read while the daemon is up. + /// How often the project list is re-read while the daemon is up, in case a pushed + /// change was missed. private static let refreshInterval: Duration = .seconds(30) var endpoint: DaemonEndpoint { .machine(machine.id) } @@ -147,8 +150,7 @@ final class MachineState: Identifiable { func disconnect(remember: Bool = false, keepUnsaved: Bool = false) { if remember { Self.setUserDisconnected(machine.id, true) } isUserDisconnecting = true - refreshTask?.cancel() - refreshTask = nil + stopRefreshing() let savers = removeAllProjects(keepingUnsaved: keepUnsaved) state = .disconnected let connection = self.connection! @@ -166,8 +168,7 @@ final class MachineState: Identifiable { /// Quit: write pending layout saves synchronously (bounded), then close the forward. func disconnectForTermination(deadline: DispatchTime) { isUserDisconnecting = true - refreshTask?.cancel() - refreshTask = nil + stopRefreshing() for saver in removeAllProjects(keepingUnsaved: false) { saver.flush(until: deadline) } state = .disconnected let connection = self.connection! @@ -291,8 +292,7 @@ final class MachineState: Identifiable { if newState.isDaemonRunning { if !wasRunning { startRefreshing() } } else { - refreshTask?.cancel() - refreshTask = nil + stopRefreshing() // An incompatible daemon gets no further requests; a stopped one (after several // failed probes) has nothing to stream. Projects come back with the daemon. While // only the SSH link is down (connecting/offline) they stay, as do projects with @@ -305,7 +305,15 @@ final class MachineState: Identifiable { // MARK: - Projects private func startRefreshing() { - refreshTask?.cancel() + stopRefreshing() + let subscription = DaemonProjectsSubscription(client: endpoint.makeClient()) + projectsSubscription = subscription + Task { + await subscription.start { [weak self] roots in + guard let self, self.projectsSubscription === subscription, self.state.isDaemonRunning else { return } + self.setProjects(roots: roots) + } + } refreshTask = Task { [weak self] in while !Task.isCancelled { #if os(iOS) @@ -319,6 +327,14 @@ final class MachineState: Identifiable { } } + private func stopRefreshing() { + refreshTask?.cancel() + refreshTask = nil + let subscription = projectsSubscription + projectsSubscription = nil + Task { await subscription?.stop() } + } + /// Discover the daemon's registered projects (`inventory`, kind projects, all roots). func refreshProjects() async { let client = endpoint.makeClient() @@ -389,7 +405,8 @@ final class MachineState: Identifiable { return project } - /// Remove a project from this app only. Nothing on the machine is deleted or stopped. + /// Hide a project on this device only. Nothing on the machine is deleted or stopped, and + /// other devices still show it; `removeProject` removes it from the machine instead. /// Pending layout saves are written, not dropped. Callers confirm discarding unsaved file /// tabs first. func hideProject(root: String) { diff --git a/apps/purepoint-macos/purepoint-macos/State/ProjectState.swift b/apps/purepoint-macos/purepoint-macos/State/ProjectState.swift index aa3623cf..bd1a0279 100644 --- a/apps/purepoint-macos/purepoint-macos/State/ProjectState.swift +++ b/apps/purepoint-macos/purepoint-macos/State/ProjectState.swift @@ -27,6 +27,9 @@ final class ProjectState: Identifiable { /// A remote project kept in the sidebar while its machine is unreachable, because it has /// file tabs with unsaved edits. Nothing is watched until the daemon is back. var isUnavailable = false + /// Leaves suspended agents paused on the next open: a project another device or the CLI + /// added was not suspended by this app's quit, so its benched agents stay benched. + @ObservationIgnored var keepsSuspendedAgentsOnNextOpen = false let channel: ChannelState @@ -129,6 +132,9 @@ final class ProjectState: Identifiable { let root = projectRoot let svc = service let client = self.client + // One open only, whether or not it succeeds. + let keepsSuspendedAgents = keepsSuspendedAgentsOnNextOpen + keepsSuspendedAgentsOnNextOpen = false openTask?.cancel() refreshTask?.cancel() @@ -194,7 +200,7 @@ final class ProjectState: Identifiable { self.appState?.daemonError = error.localizedDescription return } - self.resumeSuspendedAgents() + if !keepsSuspendedAgents { self.resumeSuspendedAgents() } } #else startWatchingRemote() diff --git a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarMenuModel.swift b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarMenuModel.swift index 021bc323..801f865b 100644 --- a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarMenuModel.swift +++ b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarMenuModel.swift @@ -19,6 +19,7 @@ enum SidebarMenuAction: Equatable { case deleteWorktree case killAllProjectAgents case retryLayout + case hideProject case removeProject case newAgentInWorktree case newTerminalInWorktree @@ -125,9 +126,28 @@ enum SidebarMenus { if project.isRemote, project.registry?.layoutErrorsByProject[project.projectId] != nil { entries.append(.action("Retry Layout", .retryLayout)) } - entries.append(.action("Remove Project\u{2026}", .removeProject)) + if project.isRemote { + // Hiding is this device's choice; removing changes the machine for every device. + entries.append(.action("Hide on This \(sidebarDevice)\u{2026}", .hideProject)) + entries.append(.action("Remove from \(machineName(of: project))\u{2026}", .removeProject)) + } else { + entries.append(.action("Remove Project\u{2026}", .removeProject)) + } return entries } + + static func machineName(of project: ProjectState) -> String { + project.appState?.machine(for: project)?.machine.name ?? "Machine" + } +} + +/// The device a hidden project stays hidden on. +private var sidebarDevice: String { + #if os(macOS) + "Mac" + #else + "iPad" + #endif } // MARK: - Confirmations @@ -191,29 +211,40 @@ extension SidebarConfirmation { message: "This will kill \(aliveCount) running agent\(aliveCount == 1 ? "" : "s") in this project.") } + static func hideProject(_ project: ProjectState) -> SidebarConfirmation { + SidebarConfirmation( + title: "Hide \(project.projectName)?", + message: "This hides the project in PurePoint on this \(sidebarDevice). Nothing on the remote machine is changed." + + unsavedWarning(unsavedFileNames(in: project)), + confirmTitle: "Hide") + } + static func removeProject(_ project: ProjectState) -> SidebarConfirmation { - let unsaved = - project.isRemote - ? project.appState?.machines.first { $0.machine.id == project.key.machineId }?.unsavedFileNames(in: project) - ?? [] - : [] + if !project.isRemote, project.appState?.isPointGuardFolder(project.projectRoot) == true { + return SidebarConfirmation( + title: "Remove \(project.projectName)?", + message: "Point Guard still uses this folder, so it only leaves the sidebar on this Mac.", + confirmTitle: "Remove") + } + let machine = project.isRemote ? SidebarMenus.machineName(of: project) : nil + let aliveCount = project.allAgents.filter { $0.status.isAlive && !$0.suspended }.count + let agents = + aliveCount == 0 + ? "" + : " Its \(aliveCount) running agent" + + (aliveCount == 1 ? " is suspended" : "s are suspended") + + " and can be resumed once the project is added again." return SidebarConfirmation( - title: "Remove \(project.projectName)?", - message: project.isRemote - ? "This hides the project in PurePoint on \(deviceName). Nothing on the remote machine is changed." - + unsavedWarning(unsaved) - : "This will close the project in PurePoint and delete .pu/manifest.json and .pu/agents/. " - + "The project folder will not be deleted.", + title: machine.map { "Remove \(project.projectName) from \($0)?" } ?? "Remove \(project.projectName)?", + message: "It disappears from PurePoint on \(machine ?? "this Mac") and every device connected to it." + + agents + " Any other session in the folder is stopped. The folder and .pu/ are kept." + + unsavedWarning(unsavedFileNames(in: project)), confirmTitle: "Remove") } - /// The device a hidden project stays hidden on. - private static var deviceName: String { - #if os(macOS) - "this Mac" - #else - "this iPad" - #endif + private static func unsavedFileNames(in project: ProjectState) -> [String] { + guard project.isRemote else { return [] } + return project.appState?.machine(for: project)?.unsavedFileNames(in: project) ?? [] } /// Closing a remote project drops its file tabs, edits included. diff --git a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineView+Callbacks.swift b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineView+Callbacks.swift index c786d31f..9b0fe1a7 100644 --- a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineView+Callbacks.swift +++ b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineView+Callbacks.swift @@ -80,33 +80,37 @@ extension SidebarOutlineView { project.killAllAgents() } - vc.onRemoveProject = { [viewCache] project in - let projectRoot = project.projectRoot - - // A remote project is only hidden here: its files and agents stay on the machine. - if project.isRemote { - for agent in project.allAgents { - viewCache.remove(agentId: agent.id) - } - appState.closeProject(project.projectId) - return - } - - // Point Guard can still own this folder after it leaves the project sidebar. - // Preserve both daemon files and attached terminal views in that case. - guard appState.closeProject(projectRoot) else { return } - + let dropTerminalViews = { [viewCache] (project: ProjectState) in for agent in project.allAgents { viewCache.remove(agentId: agent.id) } + } - // Clean up .pu directory: delete manifest.json and agents/ - let puDir = URL(fileURLWithPath: projectRoot).appendingPathComponent(".pu") - let manifestURL = puDir.appendingPathComponent("manifest.json") - let agentsDirURL = puDir.appendingPathComponent("agents") + // Hiding is this device's choice: the project and its agents stay on the machine. + vc.onHideProject = { project in + dropTerminalViews(project) + appState.closeProject(project.projectId) + } - try? FileManager.default.removeItem(at: manifestURL) - try? FileManager.default.removeItem(at: agentsDirURL) + // The machine's daemon forgets the project, so it leaves every device's sidebar. Its + // files, including .pu/, stay: adding the folder again brings the project back. + vc.onRemoveProject = { project in + Task { @MainActor in + do { + try await appState.removeProject(project) + } catch { + let message = "Could not remove \(project.projectName): \(error.localizedDescription)" + if project.isRemote, let machine = appState.machine(for: project) { + machine.actionError = message + } else { + appState.daemonError = message + } + return + } + // Point Guard may still own a local folder; its terminal views stay attached. + guard appState.projectState(forRoot: project.projectId) == nil else { return } + dropTerminalViews(project) + } } vc.onRemoveMachine = { [viewCache] machine in diff --git a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController+Actions.swift b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController+Actions.swift index 54321ba5..eaab01c5 100644 --- a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController+Actions.swift +++ b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController+Actions.swift @@ -41,6 +41,10 @@ extension SidebarOutlineViewController { } case (.retryLayout, .project(let project)): project.retryRemoteLayout() + case (.hideProject, .project(let project)): + showConfirmation(.hideProject(project)) { + self.onHideProject?(project) + } case (.removeProject, .project(let project)): showConfirmation(.removeProject(project)) { self.onRemoveProject?(project) diff --git a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController+iOS.swift b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController+iOS.swift index c5d07e4e..129bf614 100644 --- a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController+iOS.swift +++ b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController+iOS.swift @@ -39,9 +39,12 @@ class SidebarOutlineViewController: UIViewController { /// Callback for killing all agents in a project. var onKillAllProjectAgents: ((ProjectState) -> Void)? - /// Callback for removing a project from the sidebar. + /// Callback for removing a project from its machine, for every device. var onRemoveProject: ((ProjectState) -> Void)? + /// Callback for hiding a remote project on this device only. + var onHideProject: ((ProjectState) -> Void)? + /// Callback for forgetting a machine (its forward closes; nothing on it changes). var onRemoveMachine: ((MachineState) -> Void)? diff --git a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController.swift b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController.swift index 7d12abab..b16c79d2 100644 --- a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController.swift +++ b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController.swift @@ -36,9 +36,12 @@ class SidebarOutlineViewController: NSViewController, NSOutlineViewDataSource, N /// Callback for killing all agents in a project. var onKillAllProjectAgents: ((ProjectState) -> Void)? - /// Callback for removing a project from the sidebar. + /// Callback for removing a project from its machine, for every device. var onRemoveProject: ((ProjectState) -> Void)? + /// Callback for hiding a remote project on this device only. + var onHideProject: ((ProjectState) -> Void)? + /// Callback for forgetting a machine (its forward closes; nothing on it changes). var onRemoveMachine: ((MachineState) -> Void)? diff --git a/apps/purepoint-macos/purepoint-macosTests/MachinesTests.swift b/apps/purepoint-macos/purepoint-macosTests/MachinesTests.swift index 6eb313c4..dbbb03f6 100644 --- a/apps/purepoint-macos/purepoint-macosTests/MachinesTests.swift +++ b/apps/purepoint-macos/purepoint-macosTests/MachinesTests.swift @@ -315,7 +315,32 @@ struct HealthProbeTests { struct DaemonProjectHostRequestTests { @Test func givenProtocolShouldBeCurrentVersion() { - #expect(DaemonProtocol.version == 12) + #expect(DaemonProtocol.version == 13) + } + + @Test func givenRemoveProjectShouldNameTheRootAndWhetherToSuspendAgents() throws { + let object = try json(.removeProject(projectRoot: "/srv/app", suspendAgents: true)) + #expect(object["type"] as? String == "remove_project") + #expect(object["project_root"] as? String == "/srv/app") + #expect(object["suspend_agents"] as? Bool == true) + #expect(try json(.subscribeProjects)["type"] as? String == "subscribe_projects") + } + + @Test func givenProjectRegistryRepliesShouldDecodeRootsAndRemoval() { + guard case .projectsChanged(let projects) = decode(#"{"type":"projects_changed","projects":["/a","/b"]}"#) + else { + Issue.record("projects_changed did not decode") + return + } + #expect(projects == ["/a", "/b"]) + guard + case .projectRemoved(let root, let removed, let suspended, let killed) = decode( + #"{"type":"project_removed","project_root":"/a","removed":true,"suspended":["ag-1"],"killed":["sh-2"]}"#) + else { + Issue.record("project_removed did not decode") + return + } + #expect(root == "/a" && removed && suspended == ["ag-1"] && killed == ["sh-2"]) } @Test func givenInventoryShouldAskForEveryRegisteredProject() throws { diff --git a/apps/purepoint-macos/purepoint-macosTests/PointGuardRoutingTests.swift b/apps/purepoint-macos/purepoint-macosTests/PointGuardRoutingTests.swift index 6dc59959..e5464162 100644 --- a/apps/purepoint-macos/purepoint-macosTests/PointGuardRoutingTests.swift +++ b/apps/purepoint-macos/purepoint-macosTests/PointGuardRoutingTests.swift @@ -13,10 +13,10 @@ private struct PointGuardRoutingService: WorkspaceService { private func withState(_ body: (AppState, WorkspaceRegistry, String) throws -> Void) rethrows { let root = NSTemporaryDirectory() + "point-guard-routing-" + UUID().uuidString let defaults = UserDefaults.standard - let keys = ["PurePointPointGuardFolders", "PurePointOpenProjects"] + let keys = ["PurePointPointGuardFolders", "PurePointOpenProjects", "PurePointShownPointGuardFolders"] let saved = keys.map { defaults.object(forKey: $0) } let registry = WorkspaceRegistry() - let state = AppState(service: PointGuardRoutingService(), startProject: { _ in }) + let state = AppState(service: PointGuardRoutingService(), startProject: { _ in }, followsLocalDaemon: false) state.registry = registry state.updatePointGuardRoot(root) defer { @@ -225,4 +225,114 @@ private struct PointGuardRoutingService: WorkspaceService { #expect(state.projectState(forRoot: root) === owner) } } + + // MARK: - The local daemon's projects decide the sidebar + + @Test func registeredProjectsOpenAndOnesRemovedElsewhereClose() throws { + try withState { state, _, root in + for path in [root + "-project", root + "-next"] { + try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true) + } + // The daemon reports canonical roots (`/private/var/...` for a temporary folder). + let project = AppState.canonicalPath(root + "-project"), next = AppState.canonicalPath(root + "-next") + state.applyLocalProjects([next, project]) + #expect(Set(state.projects.map(\.projectRoot)) == [project, next]) + // Removed on another device, or with `pu projects remove`. + state.applyLocalProjects([project]) + #expect(state.projects.map(\.projectRoot) == [project]) + } + } + + @Test func projectOpenedBeforeTheDaemonListsItStaysOpen() throws { + try withState { state, _, root in + try FileManager.default.createDirectory(atPath: root + "-project", withIntermediateDirectories: true) + // Its `init` has not reached the daemon yet: absent from a list it was never in. + state.openProject(root + "-project") + state.applyLocalProjects([]) + #expect(state.projects.map(\.projectRoot) == [root + "-project"]) + // Opened by another path, it is the same project once the daemon lists it. + state.applyLocalProjects([AppState.canonicalPath(root + "-project")]) + #expect(state.projects.map(\.projectRoot) == [root + "-project"]) + } + } + + @Test func pointGuardFolderStaysOutOfTheSidebarUnlessOpened() throws { + try withState { state, _, root in + try FileManager.default.createDirectory(atPath: root, withIntermediateDirectories: true) + UserDefaults.standard.removeObject(forKey: "PurePointOpenProjects") + UserDefaults.standard.set([root], forKey: "PurePointPointGuardFolders") + UserDefaults.standard.removeObject(forKey: "PurePointShownPointGuardFolders") + state.restoreProjects() + state.applyLocalProjects([AppState.canonicalPath(root)]) + #expect(state.projects.isEmpty) + #expect(state.projectState(forRoot: root) != nil) + state.openProject(root) + #expect(UserDefaults.standard.stringArray(forKey: "PurePointShownPointGuardFolders") == [root]) + } + } + + @Test func legacySidebarListIsImportedOnceThenDeleted() throws { + try withState { state, _, root in + try FileManager.default.createDirectory(atPath: root + "-project", withIntermediateDirectories: true) + UserDefaults.standard.set([root + "-project"], forKey: "PurePointOpenProjects") + state.restoreProjects() + #expect(state.projects.map(\.projectRoot) == [root + "-project"]) + #expect(UserDefaults.standard.stringArray(forKey: "PurePointOpenProjects") == nil) + } + } + + @Test func projectsJoiningAfterLaunchKeepTheirSuspendedAgentsPaused() throws { + try withState { state, _, root in + for path in [root + "-project", root + "-next"] { + try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true) + } + UserDefaults.standard.removeObject(forKey: "PurePointOpenProjects") + state.restoreProjects() + // Last session's sidebar: this app suspended its agents on quit and resumes them. + let project = AppState.canonicalPath(root + "-project"), next = AppState.canonicalPath(root + "-next") + state.applyLocalProjects([project]) + #expect(state.projectState(forRoot: project)?.keepsSuspendedAgentsOnNextOpen == false) + // Added later, by another device or the CLI. + state.applyLocalProjects([next, project]) + #expect(state.projectState(forRoot: next)?.keepsSuspendedAgentsOnNextOpen == true) + } + } + + @Test func legacyEntryOnAnUnmountedVolumeStillResumesLastSession() throws { + try withState { state, _, root in + try FileManager.default.createDirectory(atPath: root + "-project", withIntermediateDirectories: true) + UserDefaults.standard.set([root + "-unmounted"], forKey: "PurePointOpenProjects") + state.restoreProjects() + #expect(UserDefaults.standard.stringArray(forKey: "PurePointOpenProjects") == [root + "-unmounted"]) + let project = AppState.canonicalPath(root + "-project") + state.applyLocalProjects([project]) + #expect(state.projectState(forRoot: project)?.keepsSuspendedAgentsOnNextOpen == false) + } + } + + @Test func pointGuardFolderRemovedElsewhereIsRegisteredAgain() throws { + var started: [String] = [] + let root = NSTemporaryDirectory() + "point-guard-reregister-" + UUID().uuidString + try FileManager.default.createDirectory(atPath: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(atPath: root) } + let saved = UserDefaults.standard.object(forKey: "PurePointPointGuardFolders") + let savedShown = UserDefaults.standard.object(forKey: "PurePointShownPointGuardFolders") + defer { + UserDefaults.standard.set(saved, forKey: "PurePointPointGuardFolders") + UserDefaults.standard.set(savedShown, forKey: "PurePointShownPointGuardFolders") + } + let state = AppState( + service: PointGuardRoutingService(), startProject: { started.append($0.projectRoot) }, + followsLocalDaemon: false) + state.updatePointGuardRoot(root) + state.openProject(root) + let canonical = AppState.canonicalPath(root) + state.applyLocalProjects([canonical]) + started.removeAll() + state.applyLocalProjects([]) + #expect(started == [root]) + #expect(state.projects.map(\.projectRoot) == [root]) + // The agents the removal suspended stay paused. + #expect(state.projectState(forRoot: root)?.keepsSuspendedAgentsOnNextOpen == true) + } } diff --git a/apps/purepoint-macos/purepoint-macosTests/RemoteFolderPickerTests.swift b/apps/purepoint-macos/purepoint-macosTests/RemoteFolderPickerTests.swift index 034e365a..55825fb8 100644 --- a/apps/purepoint-macos/purepoint-macosTests/RemoteFolderPickerTests.swift +++ b/apps/purepoint-macos/purepoint-macosTests/RemoteFolderPickerTests.swift @@ -302,4 +302,35 @@ struct MachineOpenProjectTests { } } +@MainActor +struct MachineRemoveProjectTests { + private let machine = Machine( + id: UUID(uuidString: "6F1C2B3A-1111-4222-8333-944455556667")!, name: "studio", sshHost: "me@studio") + + @Test func givenAProjectShouldAskTheMachineDaemonToForgetItAndSuspendItsAgents() async throws { + let state = MachineState(machine: machine, appState: nil, registry: nil) + state.hideProject(root: "/home/me/code/app") + let daemon = CannedDaemon(replies: [ + #"{"type":"project_removed","project_root":"/home/me/code/app","removed":true,"suspended":[]}"# + ]) + try await state.removeProject( + root: "/home/me/code/app", suspendAgents: true, client: DaemonClient(transport: daemon)) + #expect(daemon.requests.first?["type"] as? String == "remove_project") + #expect(daemon.requests.first?["project_root"] as? String == "/home/me/code/app") + #expect(daemon.requests.first?["suspend_agents"] as? Bool == true) + // Removed from the machine, it no longer needs hiding on this device. + #expect(state.hiddenProjectCount == 0) + } + + @Test func givenTheDaemonRefusesShouldThrowItsMessage() async { + let state = MachineState(machine: machine, appState: nil, registry: nil) + let daemon = CannedDaemon(replies: [ + #"{"type":"error","code":"PROJECT_HAS_LIVE_AGENTS","message":"/srv/app has 1 live agent"}"# + ]) + await #expect(throws: MachineOpenProjectError(message: "/srv/app has 1 live agent")) { + try await state.removeProject(root: "/srv/app", suspendAgents: false, client: DaemonClient(transport: daemon)) + } + } +} + #endif diff --git a/apps/purepoint-macos/purepoint-macosTests/SidebarMenuModelTests.swift b/apps/purepoint-macos/purepoint-macosTests/SidebarMenuModelTests.swift index ffb2e6cb..ccf987d1 100644 --- a/apps/purepoint-macos/purepoint-macosTests/SidebarMenuModelTests.swift +++ b/apps/purepoint-macos/purepoint-macosTests/SidebarMenuModelTests.swift @@ -35,6 +35,14 @@ private func makeProject(root: String, worktreeCount: Int, status: AgentStatus = return project } +/// A machine's project offers hiding on this device and removing from the machine. +#if os(macOS) +private let hideTitle = "Hide on This Mac\u{2026}" +#else +private let hideTitle = "Hide on This iPad\u{2026}" +#endif +private let remoteProjectActions = [hideTitle, "Remove from Machine\u{2026}"] + @Suite(.serialized) @MainActor struct SidebarMenuModelTests { @@ -57,7 +65,7 @@ struct SidebarMenuModelTests { let nodes = SidebarTree.build( projects: [project], machines: [machine], workspacesByProject: [project.projectId: [single]]) - #expect(titles(SidebarMenus.contextMenu(for: nodes[0])) == ["Kill All Agents (1)", "-", "Remove Project\u{2026}"]) + #expect(titles(SidebarMenus.contextMenu(for: nodes[0])) == ["Kill All Agents (1)", "-"] + remoteProjectActions) #expect( titles(SidebarMenus.contextMenu(for: nodes[0].children[0])) == ["Kill All Agents (1)", "-", "Delete Worktree\u{2026}"]) @@ -74,7 +82,7 @@ struct SidebarMenuModelTests { @Test func givenDeadAgentsShouldOfferOnlyTheDestructiveRowActions() { let project = makeProject(root: "/tmp/dead", worktreeCount: 1, status: .broken) let nodes = SidebarTree.build(projects: [project], machines: [], workspacesByProject: [:]) - #expect(titles(SidebarMenus.contextMenu(for: nodes[0])) == ["Remove Project\u{2026}"]) + #expect(titles(SidebarMenus.contextMenu(for: nodes[0])) == remoteProjectActions) #expect(titles(SidebarMenus.contextMenu(for: nodes[0].children[0])) == ["Delete Worktree\u{2026}"]) } @@ -97,9 +105,18 @@ struct SidebarMenuModelTests { confirmTitle: "Delete")) #expect(SidebarConfirmation.killWorktreeAgents(worktree).confirmTitle == "Kill All") #expect(SidebarConfirmation.closeWorkspace(agentCount: 3).message == "This will kill 3 agents across its panes.") - // A machine's project is only hidden; nothing on the machine changes. + // Hiding changes nothing on the machine. #expect( - SidebarConfirmation.removeProject(project).message.hasSuffix( + SidebarConfirmation.hideProject(project).message.hasSuffix( "Nothing on the remote machine is changed.")) + // Removing does, for every device, but keeps the files and suspends running agents. + #expect( + SidebarConfirmation.removeProject(project) + == SidebarConfirmation( + title: "Remove confirm from Machine?", + message: "It disappears from PurePoint on Machine and every device connected to it." + + " Its 1 running agent is suspended and can be resumed once the project is added again." + + " Any other session in the folder is stopped. The folder and .pu/ are kept.", + confirmTitle: "Remove")) } } diff --git a/apps/purepoint-macos/verification/PointGuardRoutingStubs.swift b/apps/purepoint-macos/verification/PointGuardRoutingStubs.swift index f31f2b3d..d76f01b9 100644 --- a/apps/purepoint-macos/verification/PointGuardRoutingStubs.swift +++ b/apps/purepoint-macos/verification/PointGuardRoutingStubs.swift @@ -25,6 +25,7 @@ struct DaemonWorkspaceService: WorkspaceService { struct DaemonClient { init(socketPath: String? = nil) {} func send(_ request: DaemonRequest) async throws -> DaemonResponse { .ok } + func removeProject(root: String, suspendAgents: Bool, daemonName: String) async throws {} } // Project hosts: only the local, file-free surface AppState and persistence name. struct HostFileData { let data: Data; let modifiedMs: Int64; let truncated: Bool } @@ -62,12 +63,22 @@ enum FileIOService { func disconnectForTermination(deadline: DispatchTime) {} func update(from machine: Machine) {} func hideProject(root: String) {} + func removeProject(root: String, suspendAgents: Bool) async throws {} static func isUserDisconnected(_ id: UUID) -> Bool { true } } enum DaemonLifecycle { static func findBinary() -> String? { nil } static func didLaunchDaemon() async -> Bool { false } static func restartDaemon() async throws {} + static func ensureDaemon() async throws {} +} +struct MachineOpenProjectError: LocalizedError { + let message: String +} +actor DaemonProjectsSubscription { + init(client: DaemonClient) {} + func start(onChange: @escaping @MainActor ([String]) -> Void) async {} + func stop() {} } @MainActor final class ManifestWatcher { init(path: String, onChange: @escaping () -> Void) {} @@ -82,6 +93,7 @@ enum DaemonLifecycle { @MainActor final class ProjectState { let projectRoot: String var projectId: String { projectRoot } + var key: ProjectKey { ProjectKey(id: projectRoot) } var isRemote: Bool { false } let host: any ProjectHost = LocalProjectHost.shared let client = DaemonClient() @@ -94,6 +106,7 @@ enum DaemonLifecycle { self.projectRoot = projectRoot self.registry = registry } + var keepsSuspendedAgentsOnNextOpen = false func startWatching() {} func stopWatching() {} func agent(byId id: String) -> AgentModel? { allAgents.first { $0.id == id } } diff --git a/apps/purepoint-mobile/PurePoint/AgentsView.swift b/apps/purepoint-mobile/PurePoint/AgentsView.swift index f8f3acfd..5f53213c 100644 --- a/apps/purepoint-mobile/PurePoint/AgentsView.swift +++ b/apps/purepoint-mobile/PurePoint/AgentsView.swift @@ -171,6 +171,7 @@ struct QuickLaunch: Codable, Identifiable, Equatable { case "createWorktree": return "Creating \(name ?? "worktree")" case "rename": return "Renaming \(label)" case "stop": return "Stopping \(label)" + case "removeProject": return "Removing \(label)" default: return label } } @@ -186,7 +187,7 @@ struct QuickLaunch: Codable, Identifiable, Equatable { } if let name, kind == "createWorktree" || kind == "rename" { fields["name"] = name } if let agentId { fields["agentId"] = agentId } - if kind == "stop" { fields["confirm"] = true } + if kind == "stop" || kind == "removeProject" { fields["confirm"] = true } return fields } } @@ -523,6 +524,7 @@ struct HomeView: View { @State private var creatingWorktree: WorkspaceTarget? @State private var renaming: AgentShortcut? @State private var stopping: AgentShortcut? + @State private var removing: WorkspaceTarget? var body: some View { ScrollView { @@ -551,7 +553,17 @@ struct HomeView: View { newWorktree: target.worktree == nil ? { creatingWorktree = target } : nil) } .modifier(AgentManagement(model: model, actions: actions, renaming: $renaming, stopping: $stopping, creatingWorktree: $creatingWorktree, open: openAgent)) - .onChange(of: model.bridgeIdentity) { _, _ in collapsed = []; launchTarget = nil; renaming = nil; stopping = nil } + .confirmationDialog(removing.map { "Remove \($0.projectName) from \($0.machineName)?" } ?? "", isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }), titleVisibility: .visible) { + Button("Remove from \(removing?.machineName ?? "machine")", role: .destructive) { + if let target = removing { + actions.run(QuickLaunch(id: UUID().uuidString, host: target.host, machine: target.machine, machineName: target.machineName, project: target.project, projectName: target.projectName, worktree: nil, branch: nil, kind: "removeProject", label: target.projectName), model: model, open: openAgent) + } + removing = nil + } + } message: { + Text(removing.map { "It disappears from PurePoint on every device connected to \($0.machineName). Its agents are suspended and can be resumed once the project is added again; a session the project does not record is stopped. The folder and .pu/ are kept." } ?? "") + } + .onChange(of: model.bridgeIdentity) { _, _ in collapsed = []; launchTarget = nil; renaming = nil; stopping = nil; removing = nil } } // MARK: Rows @@ -648,6 +660,10 @@ struct HomeView: View { .contextMenu { Button("New…", systemImage: "plus") { launchTarget = target }.disabled(!available || !model.canManage) Button("New worktree…", systemImage: "arrow.triangle.branch") { creatingWorktree = target }.disabled(!available || !model.canManage) + Divider() + // Removes it on the machine for every device; older daemons cannot. + Button("Remove from \(machine.name)…", systemImage: "trash", role: .destructive) { removing = target } + .disabled(!online || !model.canManage || (machine.protocolVersion ?? 0) < 13) } if let error = project.error { Text(error).font(.caption).foregroundStyle(Theme.warning).padding(.leading, 58).padding(.bottom, 4) } if !collapsed.contains(projectKey) { diff --git a/apps/purepoint-mobile/PurePoint/ChatDomain.swift b/apps/purepoint-mobile/PurePoint/ChatDomain.swift index 1395b88c..18ebeffc 100644 --- a/apps/purepoint-mobile/PurePoint/ChatDomain.swift +++ b/apps/purepoint-mobile/PurePoint/ChatDomain.swift @@ -427,6 +427,7 @@ enum MobileRPCTiming { case "workspace.createWorktree": return 100_000_000_000 case "workspace.rename": return 100_000_000_000 case "workspace.stop": return 100_000_000_000 + case "workspace.removeProject": return 100_000_000_000 default: return 35_000_000_000 } } diff --git a/apps/purepoint-mobile/bridge/workspace.js b/apps/purepoint-mobile/bridge/workspace.js index 4c4eb6b0..0dbd5d6d 100644 --- a/apps/purepoint-mobile/bridge/workspace.js +++ b/apps/purepoint-mobile/bridge/workspace.js @@ -14,6 +14,7 @@ export const workspaceOperations = [ "workspace.terminal", "workspace.rename", "workspace.stop", + "workspace.removeProject", ]; const fail = (code, message) => { throw new Error(`workspace_${code}: ${message}`); @@ -114,6 +115,7 @@ const fields = { "workspace.terminal": ["target", "worktreeId"], "workspace.rename": ["agentId", "name"], "workspace.stop": ["agentId", "confirm"], + "workspace.removeProject": ["confirm"], }; const fingerprint = (request) => JSON.stringify( @@ -159,10 +161,19 @@ export function workspaceConnection({ check(); const metadata = await projectMetadata(machine, { signal }); check(); - if (metadata.metadataStatus !== "complete") + // Removal needs only the project list: a registered project whose manifest + // cannot be read leaves worktree metadata partial, and must stay removable. + if ( + metadata.metadataStatus !== "complete" && + !( + r.op === "workspace.removeProject" && + metadata.metadataStatus === "partial" + ) + ) fail("unsupported", "Complete authoritative project metadata required"); const project = metadata.projects.find((p) => p.id === r.projectId); - if (!project || !project.available) + // An unavailable project can still be removed; nothing else runs in one. + if (!project || (!project.available && r.op !== "workspace.removeProject")) fail("target", "Registered available project required"); return { machine, metadata, project }; }; @@ -341,6 +352,28 @@ export function workspaceConnection({ exclude: [], }; } + } else if (r.op === "workspace.removeProject") { + if (r.confirm !== true) + fail("invalid", "Explicit removal confirmation required"); + // Health alone carries the version; inspect would also read every manifest. + const health = await daemonRequest( + machine.socket, + { type: "health" }, + { signal: abort.signal }, + ); + check(); + if ( + !Number.isInteger(health.protocol_version) || + health.protocol_version < 13 + ) + fail("unsupported", "Update the machine's daemon to remove projects"); + // The project's agents are suspended so they can be resumed once it is added again; only a + // session its manifest does not record is stopped (reported as `killed`). + ipc = { + type: "remove_project", + project_root: project.id, + suspend_agents: true, + }; } else if (r.op === "workspace.createWorktree") { if (!bounded(r.name, 80)) fail("invalid", "Worktree name required"); ipc = { @@ -442,6 +475,7 @@ export function workspaceConnection({ "workspace.createWorktree": "create_worktree_result", "workspace.rename": "rename_result", "workspace.stop": "kill_result", + "workspace.removeProject": "project_removed", }[r.op]; if (reply.type !== expected) throw new Error("Unexpected daemon receipt"); entry.result = { diff --git a/apps/purepoint-mobile/bridge/workspace.test.js b/apps/purepoint-mobile/bridge/workspace.test.js index 709494a4..13d3adce 100644 --- a/apps/purepoint-mobile/bridge/workspace.test.js +++ b/apps/purepoint-mobile/bridge/workspace.test.js @@ -25,6 +25,7 @@ async function fixture(t) { legacy = false, drop = false, holdMetadata = false, + protocolVersion = 10, catalogKeys = ["custom"], presetType = "custom"; const server = net.createServer((socket) => { @@ -53,7 +54,7 @@ async function fixture(t) { requests.push(r); events.emit(r.type, r); if (r.type === "health") - send({ type: "health_report", protocol_version: 10 }); + send({ type: "health_report", protocol_version: protocolVersion }); else if (r.type === "inventory") { if (holdMetadata) continue; if (partial && r.kind === "worktrees") @@ -141,6 +142,13 @@ async function fixture(t) { send({ type: "kill_result", killed: [r.target.agent] }); else if (r.type === "create_worktree") send({ type: "create_worktree_result", worktree_id: "wt-created" }); + else if (r.type === "remove_project") + send({ + type: "project_removed", + project_root: r.project_root, + removed: true, + suspended: [], + }); } }); }); @@ -179,6 +187,9 @@ async function fixture(t) { holdMetadata(v) { holdMetadata = v; }, + protocolVersion(v) { + protocolVersion = v; + }, partial(v) { partial = v; }, @@ -914,3 +925,40 @@ test("terminal, worktree, rename and stop retain their 65-second bridge caps and 4, ); }); + +test("project removal needs confirmation and a daemon that can forget projects for every device", async (t) => { + const f = await fixture(t), + c = f.connect(); + t.after(c.dispose); + const remove = (operationId, extra = {}) => + c.request({ + op: "workspace.removeProject", + operationId, + machine: "local", + projectId: "/registered", + ...extra, + }); + assert.equal((await remove("remove-0001")).state, "failed"); + const old = await remove("remove-0002", { confirm: true }); + assert.equal(old.state, "failed"); + assert.match(old.message, /Update the machine's daemon/); + assert.equal(f.requests.filter((r) => r.type === "remove_project").length, 0); + + f.protocolVersion(13); + const removed = await remove("remove-0003", { confirm: true }); + assert.equal(removed.state, "succeeded"); + // Partial worktree metadata (an unreadable manifest) does not block removal. + f.partial(true); + assert.equal( + (await remove("remove-0004", { confirm: true })).state, + "succeeded", + ); + f.partial(false); + const sent = f.requests.filter((r) => r.type === "remove_project"); + const expected = { + type: "remove_project", + project_root: "/registered", + suspend_agents: true, + }; + assert.deepEqual(sent, [expected, expected]); +}); diff --git a/apps/purepoint-mobile/docs/contract.md b/apps/purepoint-mobile/docs/contract.md index c39df862..b03ea107 100644 --- a/apps/purepoint-mobile/docs/contract.md +++ b/apps/purepoint-mobile/docs/contract.md @@ -209,10 +209,11 @@ Management fields are strict allowlists. Common `{operationId:<8..128 ASCII alnu - `workspace.terminal`: common + `{target:"root"|"worktree",worktreeId?:string}`. Uses scoped Spawn terminal without command, ensuring manifest association. Result detail `{agentId,worktreeId:string|null}`. - `workspace.rename`: common + `{agentId,name}`. Membership must match exact project. Result detail `{agentId}`. - `workspace.stop`: common + `{agentId,confirm:true}`. Only Kill Agent target. Result detail `{agentId}`. +- `workspace.removeProject`: common + `{confirm:true}`. Sends daemon `remove_project` with `suspend_agents:true`, so the project leaves every device's list; the project's running agents are suspended (they can be resumed once it is added again), any live session the manifest does not record is stopped and cannot be resumed, and the folder and `.pu/` are kept (suspension only updates the manifest). Allowed for an unavailable registered project. Requires the machine's daemon at protocol 13 or later: an older one gives the mutation result `state:"failed"` with an update-the-daemon message, and the phone disables the action below 13. Result detail `{}`. Mutation result `{operationId,state:"succeeded"|"failed"|"uncertain",detail?:,message?:string}`. `workspace.operation {operationId}` is read-authorized and returns `{operationId,state:"pending"|"succeeded"|"failed"|"uncertain"|"unknown",detail?,message?}`. No mutation events beyond existing permissions; clients explicitly refresh status and agents. Errors before dispatch are failed; errors after dispatch conservatively uncertain (even daemon errors can follow a committed spawn). Duplicate pending ID rejected; identical completed ID returns ledger status without dispatch; different payload same ID rejected. Ledger retains max 2048 operations for bridge process lifetime, does not evict pending; full ledger rejects new mutations. Restart loses ledger: unknown NEVER means safe to retry. Inventory refresh may locate committed work but absent inventory is not proof of non-commit. User must review authoritative inventory before explicitly initiating any new operation. No automatic replay/retry on reconnect or timeout. A committed-but-unacknowledged operation stays uncertain; no rollback claimed. -Errors use existing receipt error strings with stable prefixes `workspace_permission`, `workspace_invalid`, `workspace_target`, `workspace_unsupported`, `workspace_duplicate`, `workspace_capacity`. Status requires current read grant and only reveals caller's operations. Downgrade suppresses detail; revoke closes connection. Legacy clients ignore additive fields. Names reject control characters; machine/agent/worktree IDs max128. No bulk target, removal, arbitrary path, shell command, config or SSH APIs. +Errors use existing receipt error strings with stable prefixes `workspace_permission`, `workspace_invalid`, `workspace_target`, `workspace_unsupported`, `workspace_duplicate`, `workspace_capacity`. Status requires current read grant and only reveals caller's operations. Downgrade suppresses detail; revoke closes connection. Legacy clients ignore additive fields. Names reject control characters; machine/agent/worktree IDs max128. No bulk target, arbitrary path, shell command, config or SSH APIs; the only removal is `workspace.removeProject` of one exact registered project, which deletes no files. Clarifications (reviewed schema): name/base limits are UTF-8 bytes; preset-expanded final prompt must fit 65536 UTF-8 bytes. After management loss, while terminal view/control remains, status returns only operationId/state and a fixed permission-loss message, no detail or original error. Off denies status. Pending duplicate gives workspace_duplicate; query status with same operationId and a fresh transport id. Ledger never evicts entries in-process; restart clears it. Re-pair creates a new device scope, whose old operation IDs are unknown. Unknown after restart/re-pair must NEVER resend even the original mutation ID; inventory review precedes any explicit new launch. Optional daemon ConfigReport agents[].id/available plus terminal_available are capability detection; protocol version alone cannot establish catalog support. Availability checks the daemon launch executable with login PATH and registered project-root cwd without executing it; wrappers/arguments/runtime behavior may still fail at launch. diff --git a/apps/purepoint-mobile/verification/check-rpc-budgets.py b/apps/purepoint-mobile/verification/check-rpc-budgets.py index 5c4a95e3..020ab46c 100644 --- a/apps/purepoint-mobile/verification/check-rpc-budgets.py +++ b/apps/purepoint-mobile/verification/check-rpc-budgets.py @@ -46,6 +46,7 @@ "workspace.createWorktree": (metadata_ms + short_dispatch_ms, 100_000), "workspace.rename": (metadata_ms + inspect_ms + short_dispatch_ms, 100_000), "workspace.stop": (metadata_ms + inspect_ms + short_dispatch_ms, 100_000), + "workspace.removeProject": (metadata_ms + inspect_ms + short_dispatch_ms, 100_000), } for operation, (bridge_ms, expected_phone_ms) in matrix.items(): assert phone_ms.get(operation, 35_000) == expected_phone_ms, (operation, "Exact receipt deadline mismatch") diff --git a/crates/pu-cli/src/commands/init.rs b/crates/pu-cli/src/commands/init.rs index 1340c75d..e2313d6e 100644 --- a/crates/pu-cli/src/commands/init.rs +++ b/crates/pu-cli/src/commands/init.rs @@ -4,8 +4,11 @@ use crate::error::CliError; use crate::skill; pub async fn run(socket: &std::path::Path, json: bool) -> Result<(), CliError> { + run_at(socket, crate::commands::project_root_string()?, json).await +} + +pub async fn run_at(socket: &std::path::Path, root: String, json: bool) -> Result<(), CliError> { crate::daemon_ctrl::ensure_daemon(socket).await?; - let root = crate::commands::project_root_string()?; let response = crate::client::send_request( socket, &pu_core::protocol::Request::Init { diff --git a/crates/pu-cli/src/commands/mod.rs b/crates/pu-cli/src/commands/mod.rs index 44768423..733c3c75 100644 --- a/crates/pu-cli/src/commands/mod.rs +++ b/crates/pu-cli/src/commands/mod.rs @@ -15,6 +15,7 @@ pub mod kill; pub mod logs; pub mod machine; pub mod phone; +pub mod projects; pub mod prompt; pub mod pulse; pub mod read; diff --git a/crates/pu-cli/src/commands/projects.rs b/crates/pu-cli/src/commands/projects.rs new file mode 100644 index 00000000..e62492ec --- /dev/null +++ b/crates/pu-cli/src/commands/projects.rs @@ -0,0 +1,79 @@ +use std::path::Path; + +use pu_core::protocol::{InventoryKind, Request}; + +use crate::error::CliError; +use crate::{ProjectsAction, client, daemon_ctrl, output}; + +pub async fn run(socket: &Path, action: ProjectsAction) -> Result<(), CliError> { + match action { + ProjectsAction::List { json, .. } => { + crate::commands::inventory::run(socket, InventoryKind::Projects, None, json).await + } + ProjectsAction::Add { path, json } => { + let root = resolve(path, RootDefault::Init)?; + if crate::machine_target::remote_machine().is_none() { + // Locally, adding is `pu init`: it also writes the agent context file. + return crate::commands::init::run_at(socket, root, json).await; + } + send(socket, Request::Init { project_root: root }, json).await + } + ProjectsAction::Remove { + path, + suspend_agents, + json, + } => { + let project_root = resolve(path, RootDefault::Explicit)?; + send( + socket, + Request::RemoveProject { + project_root, + suspend_agents, + }, + json, + ) + .await + } + } +} + +async fn send(socket: &Path, request: Request, json: bool) -> Result<(), CliError> { + daemon_ctrl::ensure_daemon(socket).await?; + let response = client::send_request(socket, &request).await?; + let response = output::check_response(response, json)?; + output::print_response(&response, json) +} + +/// Where a command without a path argument looks for its project. +enum RootDefault { + /// As `pu init`: --project, then PU_PROJECT_ROOT (set for agents), then cwd. + Init, + /// --project, then cwd. Removal ignores an inherited PU_PROJECT_ROOT, so it + /// never removes a project for every device that the caller did not name. + Explicit, +} + +/// A path argument wins over --project and the current directory. On another +/// machine the root must be absolute: a local relative path means nothing there. +fn resolve(path: Option, default: RootDefault) -> Result { + let path = match (path, default) { + (Some(path), _) => path, + (None, RootDefault::Init) => crate::commands::project_root_string()?, + (None, RootDefault::Explicit) => match crate::commands::explicit_project() { + Some(root) => root, + None => match crate::machine_target::remote_machine() { + Some(machine) => return Err(crate::commands::remote_project_required(machine)), + None => crate::commands::cwd_string()?, + }, + }, + }; + if crate::machine_target::remote_machine().is_some() { + if !Path::new(&path).is_absolute() { + return Err(CliError::Other(format!( + "{path} must be an absolute path on the machine" + ))); + } + return Ok(path); + } + Ok(std::path::absolute(&path)?.to_string_lossy().into_owned()) +} diff --git a/crates/pu-cli/src/main.rs b/crates/pu-cli/src/main.rs index 2e07e9dc..e844d24f 100644 --- a/crates/pu-cli/src/main.rs +++ b/crates/pu-cli/src/main.rs @@ -104,10 +104,10 @@ enum Commands { #[arg(long)] json: bool, }, - /// List projects known to the daemon + /// List, add or remove the projects the daemon serves Projects { #[command(subcommand)] - action: InventoryAction, + action: ProjectsAction, }, /// List agent instances (saved definitions use `pu agent`) Agents { @@ -586,7 +586,7 @@ enum TabAction { #[derive(Subcommand)] enum InventoryAction { - /// List all registered projects by default; --project narrows the scope + /// List across all registered projects by default; --project narrows the scope List { #[arg(long, conflicts_with = "project")] global: bool, @@ -598,6 +598,34 @@ enum InventoryAction { }, } +#[derive(Subcommand)] +enum ProjectsAction { + /// List all registered projects by default; --project narrows the scope + List { + #[arg(long, conflicts_with = "project")] + global: bool, + #[arg(long)] + json: bool, + }, + /// Add a folder as a project, initializing it if needed (same as `pu init`) + Add { + /// Project folder (default: --project, then the current directory) + path: Option, + #[arg(long)] + json: bool, + }, + /// Remove a project from this daemon on every device; its files are kept + Remove { + /// Project folder (default: --project, then the current directory) + path: Option, + /// Suspend the project's running agents instead of refusing + #[arg(long)] + suspend_agents: bool, + #[arg(long)] + json: bool, + }, +} + #[derive(Subcommand)] enum ScheduleAction { /// List schedules @@ -860,14 +888,7 @@ async fn async_main() { commands::status::run(&socket, agent, json).await } } - Commands::Projects { action } => { - commands::inventory::run_list( - &socket, - pu_core::protocol::InventoryKind::Projects, - action, - ) - .await - } + Commands::Projects { action } => commands::projects::run(&socket, action).await, Commands::Agents { action } => { commands::inventory::run_list(&socket, pu_core::protocol::InventoryKind::Agents, action) .await @@ -1177,6 +1198,16 @@ mod tests { for args in [ vec!["pu", "status", "--global", "--json"], vec!["pu", "projects", "list", "--json"], + vec!["pu", "projects", "add", "--json"], + vec!["pu", "projects", "add", "/project"], + vec![ + "pu", + "projects", + "remove", + "/project", + "--suspend-agents", + "--json", + ], vec!["pu", "agents", "list", "--state", "running", "--json"], vec!["pu", "worktrees", "list", "--global", "--json"], ] { diff --git a/crates/pu-cli/src/output/mod.rs b/crates/pu-cli/src/output/mod.rs index 30dfd46d..d3f9a291 100644 --- a/crates/pu-cli/src/output/mod.rs +++ b/crates/pu-cli/src/output/mod.rs @@ -113,6 +113,22 @@ pub fn print_response(response: &Response, json_mode: bool) -> Result<(), CliErr Response::InitResult { created } => { system::print_init_result(*created); } + Response::ProjectRemoved { + project_root, + removed, + suspended, + killed, + } => { + system::print_project_removed(project_root, *removed, suspended, killed); + } + Response::ProjectsSubscribed => { + println!("Projects subscription active"); + } + Response::ProjectsChanged { projects } => { + for root in projects { + println!("{root}"); + } + } Response::SpawnResult { worktree_id, agent_id, diff --git a/crates/pu-cli/src/output/system.rs b/crates/pu-cli/src/output/system.rs index b4fef9f3..2c4d1279 100644 --- a/crates/pu-cli/src/output/system.rs +++ b/crates/pu-cli/src/output/system.rs @@ -24,6 +24,32 @@ pub(crate) fn print_init_result(created: bool) { } } +pub(crate) fn print_project_removed( + project_root: &str, + removed: bool, + suspended: &[String], + killed: &[String], +) { + if !removed { + println!("{project_root} was not a registered project"); + return; + } + println!("{} {project_root}", "Removed".green()); + if !suspended.is_empty() { + println!( + " Suspended {} agent(s); they can be resumed once the project is added again", + suspended.len() + ); + } + if !killed.is_empty() { + println!( + " Stopped {} session(s) the project did not list", + killed.len() + ); + } + println!(" Project files and .pu/ were kept (suspending agents only updates the manifest)"); +} + pub(crate) fn print_config_report(default_agent: &str, agents: &[AgentConfigInfo]) { println!("{}", "Agent Configuration".bold()); println!(" Default agent: {}", default_agent.green()); diff --git a/crates/pu-cli/tests/global_inventory.rs b/crates/pu-cli/tests/global_inventory.rs index 6c4d7247..6e38a1d5 100644 --- a/crates/pu-cli/tests/global_inventory.rs +++ b/crates/pu-cli/tests/global_inventory.rs @@ -336,3 +336,67 @@ async fn given_cli_outside_projects_should_query_and_control_agents_globally_wit wire(&socket, Request::Shutdown).await; handle.await.unwrap(); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn given_projects_add_and_remove_should_change_what_every_client_lists() { + let tmp = tempfile::TempDir::new().unwrap(); + let socket = tmp.path().join("test.sock"); + let registry = pu_core::paths::project_registry_path(&socket); + let engine = pu_engine::engine::Engine::with_project_registry(registry).unwrap(); + let server = pu_engine::ipc_server::IpcServer::bind(&socket, engine).unwrap(); + let handle = tokio::spawn(async move { + server.run().await.unwrap(); + }); + let project = tmp.path().join("project"); + std::fs::create_dir_all(&project).unwrap(); + let root = project.canonicalize().unwrap(); + let root_s = root.to_str().unwrap(); + + // A relative path resolves against the current directory. + let added = json( + &socket, + tmp.path(), + &["projects", "add", "project", "--json"], + ) + .await; + assert_eq!(added["created"], true); + let listed = json(&socket, tmp.path(), &["projects", "list", "--json"]).await; + assert_eq!(listed["projects"][0]["project_root"], root_s); + + // An agent's inherited PU_PROJECT_ROOT never chooses what to remove. + let inherited = tokio::process::Command::new(env!("CARGO_BIN_EXE_pu")) + .arg("--socket") + .arg(&socket) + .args(["projects", "remove", "--json"]) + .current_dir(tmp.path()) + .env("PU_PROJECT_ROOT", root_s) + .env_remove("PU_AGENT_ID") + .output() + .await + .unwrap(); + assert!( + inherited.status.success(), + "{}", + String::from_utf8_lossy(&inherited.stderr) + ); + let inherited: serde_json::Value = serde_json::from_slice(&inherited.stdout).unwrap(); + assert_eq!(inherited["removed"], false); + let listed = json(&socket, tmp.path(), &["projects", "list", "--json"]).await; + assert_eq!(listed["projects"][0]["project_root"], root_s); + + let removed = json( + &socket, + tmp.path(), + &["projects", "remove", root_s, "--json"], + ) + .await; + assert_eq!(removed["removed"], true); + assert!(root.join(".pu/manifest.json").exists()); + let listed = json(&socket, tmp.path(), &["projects", "list", "--json"]).await; + assert_eq!(listed["projects"], serde_json::json!([])); + match wire(&socket, Request::Health).await { + Response::HealthReport { projects, .. } => assert!(projects.is_empty()), + other => panic!("unexpected {other:?}"), + } + handle.abort(); +} diff --git a/crates/pu-core/src/protocol/mod.rs b/crates/pu-core/src/protocol/mod.rs index 3d463147..48e28f8b 100644 --- a/crates/pu-core/src/protocol/mod.rs +++ b/crates/pu-core/src/protocol/mod.rs @@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize}; use crate::types::{AgentStatus, WorktreeEntry}; use indexmap::IndexMap; -pub const PROTOCOL_VERSION: u32 = 12; +pub const PROTOCOL_VERSION: u32 = 13; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] @@ -93,6 +93,17 @@ pub enum Request { Init { project_root: String, }, + /// Forget a registered project on this daemon. Its files and `.pu/` are kept + /// (suspending agents only updates the manifest), so a later `Init` brings + /// the project back as it was. + RemoveProject { + project_root: String, + /// Suspend the project's live agents instead of refusing the removal. + #[serde(default)] + suspend_agents: bool, + }, + /// Stream the registered project roots: once now, then on every change. + SubscribeProjects, Spawn { project_root: String, prompt: String, @@ -508,6 +519,20 @@ pub enum Response { InitResult { created: bool, }, + ProjectRemoved { + project_root: String, + /// False when the root was not registered. + removed: bool, + /// Agents suspended so they can be resumed once the project is added again. + suspended: Vec, + /// Live sessions the manifest did not list, which cannot be resumed. + #[serde(default)] + killed: Vec, + }, + ProjectsSubscribed, + ProjectsChanged { + projects: Vec, + }, SpawnResult { worktree_id: Option, agent_id: String, diff --git a/crates/pu-core/src/protocol/tests.rs b/crates/pu-core/src/protocol/tests.rs index 0e5c1af8..f3f0a9d2 100644 --- a/crates/pu-core/src/protocol/tests.rs +++ b/crates/pu-core/src/protocol/tests.rs @@ -23,6 +23,36 @@ fn given_init_request_should_round_trip() { } } +#[test] +fn given_remove_project_wire_form_should_default_to_refusing_live_agents() { + let req: Request = + serde_json::from_str(r#"{"type":"remove_project","project_root":"/test"}"#).unwrap(); + match req { + Request::RemoveProject { + project_root, + suspend_agents, + } => { + assert_eq!(project_root, "/test"); + assert!(!suspend_agents); + } + _ => panic!("expected RemoveProject"), + } +} + +#[test] +fn given_project_list_change_should_serialize_with_its_roots() { + let json = serde_json::to_value(Response::ProjectsChanged { + projects: vec!["/a".into()], + }) + .unwrap(); + assert_eq!( + json, + serde_json::json!({"type": "projects_changed", "projects": ["/a"]}) + ); + let req: Request = serde_json::from_str(r#"{"type":"subscribe_projects"}"#).unwrap(); + assert!(matches!(req, Request::SubscribeProjects)); +} + #[test] fn given_spawn_request_should_default_agent_to_claude() { let json = r#"{"type":"spawn","project_root":"/test","prompt":"fix bug"}"#; @@ -491,7 +521,7 @@ fn given_kill_request_without_exclude_should_default_empty() { #[test] fn given_protocol_version_should_be_current() { // Intentional hardcoded check — update when the protocol version is bumped. - assert_eq!(PROTOCOL_VERSION, 12); + assert_eq!(PROTOCOL_VERSION, 13); } // --- GridCommand round-trips --- diff --git a/crates/pu-engine/src/engine/inventory.rs b/crates/pu-engine/src/engine/inventory.rs index 3bb2515f..e2c52720 100644 --- a/crates/pu-engine/src/engine/inventory.rs +++ b/crates/pu-engine/src/engine/inventory.rs @@ -39,11 +39,67 @@ impl Engine { } engine.project_registry_path = Some(path); *engine.registered_projects.lock().unwrap() = projects.into_iter().collect(); + // Until `start_registry_prune` has run, the project stream waits: a client + // following it must not open a project an older client removed. + engine.registry_ready.send_replace(false); Ok(engine) } + /// Drops, in the background, registered folders whose manifest older clients + /// deleted to remove the project. Reads are bounded so a stale network mount + /// delays nothing but itself; the project stream opens once this finishes. + /// Does nothing for an engine without a loaded registry. + pub fn start_registry_prune(self: &std::sync::Arc) { + if *self.registry_ready.borrow() { + return; + } + let engine = std::sync::Arc::clone(self); + tokio::spawn(async move { engine.prune_removed_projects().await }); + } + + pub(crate) async fn prune_removed_projects(&self) { + let mut reads = tokio::task::JoinSet::new(); + for root in self.registered_projects() { + reads.spawn(async move { + let path = PathBuf::from(&root); + let read = tokio::task::spawn_blocking(move || was_removed(&path)); + let removed = matches!( + tokio::time::timeout(Duration::from_secs(2), read).await, + Ok(Ok(true)) + ); + removed.then_some(root) + }); + } + let mut removed = Vec::new(); + while let Some(result) = reads.join_next().await { + if let Ok(Some(root)) = result { + removed.push(root); + } + } + // The removal re-checks each manifest under the update lock; a mount that went + // stale since must not keep the projects stream closed. + match tokio::time::timeout( + Duration::from_secs(5), + self.unregister_projects(removed, Unregister::IfRemoved), + ) + .await + { + Ok(Ok(_)) => {} + Ok(Err(e)) => tracing::warn!("failed to drop removed projects from the registry: {e}"), + Err(_) => tracing::warn!("dropping removed projects from the registry timed out"), + } + self.registry_ready.send_replace(true); + } + + /// Resolves once registered projects removed by older clients are dropped. + pub(crate) async fn registry_ready(&self) { + let mut ready = self.registry_ready.subscribe(); + let _ = ready.wait_for(|ready| *ready).await; + } + /// Registers an initialized project, except one too broad to be a project /// (`/`, `$HOME` or an ancestor of it), which is skipped without error. + /// Only `Init` registers: using a project never adds it back. pub(super) async fn register_project(&self, root: &str) -> Result<(), Response> { let home = self.home.clone(); // Serialize durable updates without holding the inventory mutex over @@ -52,12 +108,12 @@ impl Engine { let root = root.to_string(); let projects = self.registered_projects.clone(); let registry = self.project_registry_path.clone(); - tokio::task::spawn_blocking(move || -> std::io::Result<()> { + let added = tokio::task::spawn_blocking(move || -> std::io::Result { // Keep serialization even if the awaiting request is cancelled. let _update = update; let root = Path::new(&root).canonicalize()?; if is_too_broad_project_root(&root, home.as_deref()) { - return Ok(()); + return Ok(false); } // Registration is only valid for readable, initialized projects. manifest::read_manifest(&root).map_err(std::io::Error::other)?; @@ -67,7 +123,7 @@ impl Engine { .lock() .map_err(|_| std::io::Error::other("project registry lock poisoned"))?; if current.contains(&root) { - return Ok(()); + return Ok(false); } current.iter().cloned().collect() }; @@ -80,11 +136,131 @@ impl Engine { .lock() .map_err(|_| std::io::Error::other("project registry lock poisoned"))? .insert(root); - Ok(()) + Ok(true) }) .await .map_err(|e| Self::inventory_error("IO_ERROR", e.to_string()))? - .map_err(|e| Self::inventory_error("IO_ERROR", e.to_string())) + .map_err(|e| Self::inventory_error("IO_ERROR", e.to_string()))?; + if added { + self.notify_projects_changed(); + } + Ok(()) + } + + /// Removes registered roots and returns the ones that were registered. + async fn unregister_projects( + &self, + roots: Vec, + mode: Unregister, + ) -> std::io::Result> { + let roots: HashSet = roots.into_iter().collect(); + let update = self.project_registry_updates.clone().lock_owned().await; + let projects = self.registered_projects.clone(); + let registry = self.project_registry_path.clone(); + let removed = tokio::task::spawn_blocking(move || -> std::io::Result> { + let _update = update; + // Snapshot under the registry mutex, then stat without it: a stale + // mount must not block readers. The update lock keeps writers out. + let current: Vec = projects + .lock() + .map_err(|_| std::io::Error::other("project registry lock poisoned"))? + .iter() + .cloned() + .collect(); + let (removed, mut next): (Vec<_>, Vec<_>) = current.into_iter().partition(|root| { + roots.contains(root) && (mode == Unregister::Always || was_removed(Path::new(root))) + }); + if removed.is_empty() { + return Ok(removed); + } + next.sort(); + if let Some(path) = registry { + write_registry(&path, &next)?; + } + let mut current = projects + .lock() + .map_err(|_| std::io::Error::other("project registry lock poisoned"))?; + for root in &removed { + current.remove(root); + } + Ok(removed) + }) + .await + .map_err(std::io::Error::other)??; + if !removed.is_empty() { + self.notify_projects_changed(); + } + Ok(removed) + } + + /// Forgets a project on this daemon without touching its files. Live + /// agents block the removal unless the caller asks to suspend them, so + /// they can be resumed if the project is added again. + pub(super) async fn handle_remove_project( + &self, + project_root: &str, + suspend_agents: bool, + ) -> Response { + // A deleted folder can no longer be canonicalized; match it as given. + let path = PathBuf::from(project_root); + let root = tokio::task::spawn_blocking(move || path.canonicalize()) + .await + .ok() + .and_then(Result::ok) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|| project_root.to_string()); + let live: Vec = self + .sessions + .lock() + .await + .iter() + .filter(|(_, h)| { + h.project_root.as_deref() == Some(root.as_str()) && h.exit_rx.borrow().is_none() + }) + .map(|(id, _)| id.clone()) + .collect(); + let (mut suspended, mut killed) = (Vec::new(), Vec::new()); + if !live.is_empty() { + if !suspend_agents { + return Self::inventory_error( + "PROJECT_HAS_LIVE_AGENTS", + format!( + "{root} has {} live agent{}; suspend them first or remove with suspend_agents", + live.len(), + if live.len() == 1 { "" } else { "s" } + ), + ); + } + match self.handle_suspend(&root, SuspendTarget::All).await { + Response::SuspendResult { suspended: ids } => suspended = ids, + // Without a manifest nothing can be suspended; its sessions are stopped below. + Response::Error { code, .. } if code == "NOT_INITIALIZED" => {} + // Never kill agents a readable project would have suspended. + error => return error, + } + // Sessions the manifest does not list (or a missing manifest) still + // belong to the project; nothing may keep running in a removed one. + let rest: Vec = live + .into_iter() + .filter(|id| !suspended.contains(id)) + .collect(); + if !rest.is_empty() { + self.kill_agents(&rest).await; + killed = rest; + } + } + match self + .unregister_projects(vec![root.clone()], Unregister::Always) + .await + { + Ok(removed) => Response::ProjectRemoved { + removed: !removed.is_empty(), + project_root: root, + suspended, + killed, + }, + Err(e) => Self::inventory_error("IO_ERROR", e.to_string()), + } } pub(super) fn inventory_error(code: &str, message: String) -> Response { @@ -115,7 +291,6 @@ impl Engine { } => Some(id.clone()), _ => None, }; - let is_init = matches!(request, Request::Init { .. }); let legacy_root_use = allowed_on_too_broad_root(request); if let Some(root) = request.project_root_mut() { if root.is_empty() { @@ -142,11 +317,6 @@ impl Engine { if let Some(id) = targeted.as_deref() { self.validate_live_owner(id, root).await?; } - // Init may create the manifest; every other successful project use - // registers an already-initialized project without inventing one. - if !is_init && self.read_manifest_async(root).await.is_ok() { - self.register_project(root).await?; - } } Ok(()) } @@ -206,6 +376,12 @@ impl Engine { } } manifests.sort_by(|a, b| a.0.cmp(&b.0)); + // A global query read every registered root; a scoped one needs the registry. + let registered: HashSet = if report.project_root.is_none() { + manifests.iter().map(|(root, _)| root.clone()).collect() + } else { + self.registered_projects().into_iter().collect() + }; let sessions = self.sessions.lock().await; let mut observed_sessions = HashSet::new(); for (root, result) in manifests { @@ -335,7 +511,12 @@ impl Engine { { &mut project.counts } else { - report.complete = false; + // A registered project whose manifest could not be read is + // missing from the report. An agent in a folder that is not + // a registered project counts as standalone. + if registered.contains(root) { + report.complete = false; + } &mut standalone } } else { @@ -567,6 +748,22 @@ pub(super) fn project_root_not_allowed(root: &str) -> Response { } } +/// A project folder whose manifest is gone was removed by an older client. A +/// missing folder is only unavailable: it may be on an unmounted volume. +/// Only a manifest known to be absent counts: one that cannot be checked (a +/// permission error, a stale network mount) keeps the project. +fn was_removed(root: &Path) -> bool { + root.is_dir() && matches!(pu_core::paths::manifest_path(root).try_exists(), Ok(false)) +} + +/// Whether `unregister_projects` re-checks, under the update lock, that each +/// root is still a removed folder: one reopened since it was seen stays. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Unregister { + Always, + IfRemoved, +} + fn write_registry(path: &Path, projects: &[String]) -> std::io::Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; @@ -1033,4 +1230,321 @@ mod tests { Response::HealthReport { agent_count: 0, .. } )); } + + #[tokio::test] + async fn given_manifest_deleted_while_running_should_report_it_unavailable_until_the_next_prune() + { + let tmp = tempfile::TempDir::new().unwrap(); + let path = tmp.path().join("registry.json"); + let engine = Engine::with_project_registry(path.clone()).unwrap(); + let kept = init(&engine, &tmp.path().join("kept")).await; + let removed = init(&engine, &tmp.path().join("removed")).await; + // What the app does when a project is removed from the sidebar. + std::fs::remove_file(pu_core::paths::manifest_path(Path::new(&removed))).unwrap(); + + // Inventory is a read: it never changes the registry. + let report = inventory(&engine, InventoryKind::Projects).await; + assert!(!report.complete); + let unavailable: Vec<_> = report.projects.iter().filter(|p| !p.available).collect(); + assert_eq!(unavailable.len(), 1); + assert_eq!(unavailable[0].project_root, removed); + assert_eq!(engine.registered_projects().len(), 2); + engine.prune_removed_projects().await; + assert_eq!(engine.registered_projects(), vec![kept.clone()]); + drop(engine); + assert_eq!( + Engine::with_project_registry(path) + .unwrap() + .registered_projects(), + vec![kept] + ); + } + + #[tokio::test] + async fn given_registry_listing_a_project_removed_by_an_older_client_should_drop_it_after_load() + { + let tmp = tempfile::TempDir::new().unwrap(); + let path = tmp.path().join("registry.json"); + let engine = Engine::with_project_registry(path.clone()).unwrap(); + let kept = init(&engine, &tmp.path().join("kept")).await; + let removed = init(&engine, &tmp.path().join("removed")).await; + // A missing folder may be an unmounted volume, so it stays. + let unmounted = tmp.path().join("unmounted").to_string_lossy().into_owned(); + drop(engine); + std::fs::remove_file(pu_core::paths::manifest_path(Path::new(&removed))).unwrap(); + let mut stored = vec![kept.clone(), removed, unmounted.clone()]; + stored.sort(); + std::fs::write(&path, serde_json::to_vec(&stored).unwrap()).unwrap(); + + let engine = Engine::with_project_registry(path.clone()).unwrap(); + engine.prune_removed_projects().await; + let mut expected = vec![kept, unmounted]; + expected.sort(); + assert_eq!(engine.registered_projects(), expected); + let on_disk: Vec = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(on_disk, expected); + } + + #[tokio::test] + async fn given_corrupt_manifest_should_keep_project_registered() { + let tmp = tempfile::TempDir::new().unwrap(); + let engine = Engine::with_project_registry(tmp.path().join("registry.json")).unwrap(); + let root = init(&engine, &tmp.path().join("project")).await; + std::fs::write(pu_core::paths::manifest_path(Path::new(&root)), b"corrupt").unwrap(); + + let report = inventory(&engine, InventoryKind::Projects).await; + assert!(!report.complete); + assert_eq!(report.projects.len(), 1); + assert!(!report.projects[0].available); + assert_eq!(engine.registered_projects(), vec![root]); + } + + #[tokio::test] + async fn given_live_session_in_project_without_manifest_should_keep_it() { + let (engine, id, tmp) = crate::test_helpers::init_and_spawn().await; + let root = tmp.path().join("project").canonicalize().unwrap(); + std::fs::remove_file(pu_core::paths::manifest_path(&root)).unwrap(); + + let report = inventory(&engine, InventoryKind::Agents).await; + let root = root.to_string_lossy().into_owned(); + assert_eq!(report.projects.len(), 1); + assert_eq!(report.projects[0].project_root, root); + assert!(report.agents.iter().any(|a| a.id == id)); + assert_eq!(engine.registered_projects(), vec![root]); + engine.kill_all_sessions(Duration::from_millis(10)).await; + } + + #[tokio::test] + async fn given_removed_project_reopened_should_register_again() { + let tmp = tempfile::TempDir::new().unwrap(); + let engine = Engine::with_project_registry(tmp.path().join("registry.json")).unwrap(); + let root = init(&engine, &tmp.path().join("project")).await; + std::fs::remove_file(pu_core::paths::manifest_path(Path::new(&root))).unwrap(); + engine.prune_removed_projects().await; + assert!(engine.registered_projects().is_empty()); + + init(&engine, Path::new(&root)).await; + assert_eq!(engine.registered_projects(), vec![root.clone()]); + let report = inventory(&engine, InventoryKind::Projects).await; + assert_eq!(report.projects[0].project_root, root); + } + + #[tokio::test] + async fn given_removed_project_should_leave_registry_and_keep_its_files() { + let tmp = tempfile::TempDir::new().unwrap(); + let path = tmp.path().join("registry.json"); + let engine = Engine::with_project_registry(path.clone()).unwrap(); + let root = init(&engine, &tmp.path().join("project")).await; + + let response = engine + .handle_request(Request::RemoveProject { + project_root: root.clone(), + suspend_agents: false, + }) + .await; + assert!( + matches!(&response, Response::ProjectRemoved { removed: true, project_root, .. } if *project_root == root), + "{response:?}" + ); + assert!(pu_core::paths::manifest_path(Path::new(&root)).exists()); + assert!( + inventory(&engine, InventoryKind::Projects) + .await + .projects + .is_empty() + ); + drop(engine); + assert!( + Engine::with_project_registry(path) + .unwrap() + .registered_projects() + .is_empty() + ); + } + + #[tokio::test] + async fn given_unregistered_root_should_report_nothing_removed() { + let tmp = tempfile::TempDir::new().unwrap(); + let response = Engine::new() + .handle_request(Request::RemoveProject { + project_root: tmp.path().join("gone").to_string_lossy().into_owned(), + suspend_agents: false, + }) + .await; + assert!( + matches!(response, Response::ProjectRemoved { removed: false, .. }), + "{response:?}" + ); + } + + #[tokio::test] + async fn given_removed_project_should_not_come_back_when_used() { + let tmp = tempfile::TempDir::new().unwrap(); + let engine = Engine::new(); + let root = init(&engine, &tmp.path().join("project")).await; + engine + .handle_request(Request::RemoveProject { + project_root: root.clone(), + suspend_agents: false, + }) + .await; + // Another device still watching it must not add it back. + engine + .handle_request(Request::Status { + project_root: root.clone(), + agent_id: None, + }) + .await; + assert!(engine.registered_projects().is_empty()); + + init(&engine, Path::new(&root)).await; + assert_eq!(engine.registered_projects(), vec![root]); + } + + #[tokio::test] + async fn given_live_agents_should_refuse_removal_unless_asked_to_suspend_them() { + let (engine, id, tmp) = crate::test_helpers::init_and_spawn().await; + let root = tmp + .path() + .join("project") + .canonicalize() + .unwrap() + .to_string_lossy() + .into_owned(); + let refused = engine + .handle_request(Request::RemoveProject { + project_root: root.clone(), + suspend_agents: false, + }) + .await; + assert!( + matches!(&refused, Response::Error { code, .. } if code == "PROJECT_HAS_LIVE_AGENTS"), + "{refused:?}" + ); + assert_eq!(engine.registered_projects(), vec![root.clone()]); + + let removed = engine + .handle_request(Request::RemoveProject { + project_root: root.clone(), + suspend_agents: true, + }) + .await; + assert!( + matches!(&removed, Response::ProjectRemoved { removed: true, suspended, .. } if *suspended == vec![id.clone()]), + "{removed:?}" + ); + assert!(engine.registered_projects().is_empty()); + let agent = manifest::read_manifest(Path::new(&root)).unwrap().agents[&id].clone(); + assert!( + agent.suspended, + "suspended agents can be resumed once the project is added again" + ); + engine.kill_all_sessions(Duration::from_millis(10)).await; + } + + #[tokio::test] + async fn given_agent_in_an_unregistered_folder_should_count_as_standalone_without_partial_inventory() + { + let (engine, id, tmp) = crate::test_helpers::init_and_spawn().await; + let root = tmp + .path() + .join("project") + .canonicalize() + .unwrap() + .to_string_lossy() + .into_owned(); + engine + .unregister_projects(vec![root.clone()], Unregister::Always) + .await + .unwrap(); + let report = inventory(&engine, InventoryKind::Agents).await; + assert!( + report.complete, + "an unregistered folder is not a missing project" + ); + assert!(report.projects.is_empty()); + assert_eq!(report.summary.running, 1); + assert!(report.agents.iter().any(|a| a.id == id)); + engine.kill_all_sessions(Duration::from_millis(10)).await; + } + + #[tokio::test] + async fn given_loaded_registry_should_hold_project_followers_until_removed_projects_are_dropped() + { + let tmp = tempfile::TempDir::new().unwrap(); + let engine = Engine::with_project_registry(tmp.path().join("registry.json")).unwrap(); + let wait = Duration::from_millis(50); + assert!( + tokio::time::timeout(wait, engine.registry_ready()) + .await + .is_err() + ); + engine.prune_removed_projects().await; + assert!( + tokio::time::timeout(wait, engine.registry_ready()) + .await + .is_ok() + ); + // An in-memory engine has nothing to prune. + assert!( + tokio::time::timeout(wait, Engine::new().registry_ready()) + .await + .is_ok() + ); + } + + #[tokio::test] + async fn given_unreadable_manifest_should_refuse_removal_rather_than_kill_suspendable_agents() { + let (engine, id, tmp) = crate::test_helpers::init_and_spawn().await; + let root = tmp.path().join("project").canonicalize().unwrap(); + std::fs::write(pu_core::paths::manifest_path(&root), b"corrupt").unwrap(); + let root = root.to_string_lossy().into_owned(); + let response = engine + .handle_request(Request::RemoveProject { + project_root: root.clone(), + suspend_agents: true, + }) + .await; + assert!(matches!(response, Response::Error { .. }), "{response:?}"); + assert_eq!(engine.registered_projects(), vec![root]); + assert!(engine.sessions.lock().await.contains_key(&id)); + engine.kill_all_sessions(Duration::from_millis(10)).await; + } + + #[tokio::test] + async fn given_missing_manifest_should_stop_live_sessions_and_report_them_killed() { + let (engine, id, tmp) = crate::test_helpers::init_and_spawn().await; + let root = tmp.path().join("project").canonicalize().unwrap(); + std::fs::remove_file(pu_core::paths::manifest_path(&root)).unwrap(); + let response = engine + .handle_request(Request::RemoveProject { + project_root: root.to_string_lossy().into_owned(), + suspend_agents: true, + }) + .await; + assert!( + matches!(&response, Response::ProjectRemoved { removed: true, suspended, killed, .. } + if suspended.is_empty() && *killed == vec![id.clone()]), + "{response:?}" + ); + engine.kill_all_sessions(Duration::from_millis(10)).await; + } + + #[tokio::test] + async fn given_manifest_that_cannot_be_checked_should_keep_the_project_on_prune() { + use std::os::unix::fs::PermissionsExt; + // Permissions do not stop root, so the stat would succeed and prove nothing. + if unsafe { libc::geteuid() } == 0 { + return; + } + let tmp = tempfile::TempDir::new().unwrap(); + let engine = Engine::with_project_registry(tmp.path().join("registry.json")).unwrap(); + let root = init(&engine, &tmp.path().join("project")).await; + let pu = pu_core::paths::pu_dir(Path::new(&root)); + // An unreadable `.pu/` is not a removed project, whatever the manifest stat says. + std::fs::set_permissions(&pu, std::fs::Permissions::from_mode(0o000)).unwrap(); + engine.prune_removed_projects().await; + std::fs::set_permissions(&pu, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!(engine.registered_projects(), vec![root]); + } } diff --git a/crates/pu-engine/src/engine/mod.rs b/crates/pu-engine/src/engine/mod.rs index 1ac163df..884997cb 100644 --- a/crates/pu-engine/src/engine/mod.rs +++ b/crates/pu-engine/src/engine/mod.rs @@ -89,8 +89,12 @@ pub struct Engine { grid_channels: Arc>>>, /// Per-project broadcast channels for status push updates. status_channels: Arc>>>, - /// Projects that have been initialized or used — scheduler scans these. + /// Projects added with `Init` and not removed since — scheduler scans these. registered_projects: Arc>>, + /// Signalled whenever a project is added to or removed from the registry. + projects_changed: tokio::sync::broadcast::Sender<()>, + /// False from loading a registry until its removed projects are dropped. + registry_ready: tokio::sync::watch::Sender, /// Agents with a resume in progress, so two resumes of one agent can never /// both spawn `claude --resume` on the same transcript. resuming: Arc>>, @@ -204,6 +208,8 @@ impl Engine { grid_channels: Arc::new(Mutex::new(HashMap::new())), status_channels: Arc::new(Mutex::new(HashMap::new())), registered_projects: Arc::new(std::sync::Mutex::new(HashSet::new())), + projects_changed: tokio::sync::broadcast::channel(16).0, + registry_ready: tokio::sync::watch::Sender::new(true), resuming: Arc::new(std::sync::Mutex::new(HashSet::new())), shutdown_killed: Arc::new(std::sync::Mutex::new(HashSet::new())), } @@ -435,6 +441,14 @@ impl Engine { } response } + Request::RemoveProject { + project_root, + suspend_agents, + } => { + self.handle_remove_project(&project_root, suspend_agents) + .await + } + Request::SubscribeProjects => Response::ProjectsSubscribed, Request::Rename { project_root, agent_id, diff --git a/crates/pu-engine/src/engine/subscriptions.rs b/crates/pu-engine/src/engine/subscriptions.rs index 25f4cad8..8778db45 100644 --- a/crates/pu-engine/src/engine/subscriptions.rs +++ b/crates/pu-engine/src/engine/subscriptions.rs @@ -67,6 +67,17 @@ impl Engine { tx.subscribe() } + // --- Project Registry Push --- + + /// Receiver signalled on every registry change (used by the IPC server). + pub fn subscribe_projects(&self) -> tokio::sync::broadcast::Receiver<()> { + self.projects_changed.subscribe() + } + + pub(super) fn notify_projects_changed(&self) { + let _ = self.projects_changed.send(()); + } + // --- Status Push --- pub(super) async fn handle_subscribe_status(&self, project_root: &str) -> Response { diff --git a/crates/pu-engine/src/ipc_server/mod.rs b/crates/pu-engine/src/ipc_server/mod.rs index b4c1ddfe..a5dc7a11 100644 --- a/crates/pu-engine/src/ipc_server/mod.rs +++ b/crates/pu-engine/src/ipc_server/mod.rs @@ -50,6 +50,7 @@ enum StreamMode { Attach(String, Option, Option), Grid(String), Status(String), + Projects, Fs { root: String, recursive: bool }, } @@ -63,6 +64,7 @@ impl StreamMode { } => Self::Attach(agent_id.clone(), *replay, client_id.clone()), Request::SubscribeGrid { project_root } => Self::Grid(project_root.clone()), Request::SubscribeStatus { project_root } => Self::Status(project_root.clone()), + Request::SubscribeProjects => Self::Projects, Request::SubscribeFs { root, recursive } => Self::Fs { root: root.clone(), recursive: *recursive, @@ -144,9 +146,12 @@ impl IpcServer { // Owner-only whatever the umask. Connections accepted before this are // still held to the peer check in `run`. std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(0o600))?; + let engine = Arc::new(engine); + // Every served registry is pruned, so the projects stream always opens. + engine.start_registry_prune(); Ok(Self { listener, - engine: Arc::new(engine), + engine, shutdown: Arc::new(Notify::new()), conn_limit: Arc::new(Semaphore::new(MAX_CONNECTIONS)), first_line_timeout: FIRST_LINE_TIMEOUT, @@ -371,6 +376,11 @@ impl IpcServer { .await; } } + StreamMode::Projects => { + if matches!(response, Response::ProjectsSubscribed) { + return streams::handle_projects_stream(reader, pending, writer, engine).await; + } + } StreamMode::Fs { root, recursive } => { if matches!(response, Response::FsSubscribed) { // A watch stream never returns to request mode: if the diff --git a/crates/pu-engine/src/ipc_server/streams.rs b/crates/pu-engine/src/ipc_server/streams.rs index 29b53c46..a8f77fa8 100644 --- a/crates/pu-engine/src/ipc_server/streams.rs +++ b/crates/pu-engine/src/ipc_server/streams.rs @@ -306,6 +306,56 @@ pub(super) async fn handle_status_stream( true } +async fn send_projects(writer: &mut IpcWriter, engine: &Engine) -> bool { + let projects = engine.registered_projects(); + write_response(writer, &Response::ProjectsChanged { projects }) + .await + .is_ok() +} + +/// Streaming project registry: pushes the full list of registered roots now +/// and after every add or remove, so every client shows the same projects. +pub(super) async fn handle_projects_stream( + reader: &mut IpcReader, + partial: &mut Vec, + writer: &mut IpcWriter, + engine: &Engine, +) -> bool { + // Subscribe before the first send so no change between them is missed. + let mut rx = engine.subscribe_projects(); + engine.registry_ready().await; + if !send_projects(writer, engine).await { + return true; + } + loop { + tokio::select! { + result = rx.recv() => { + match result { + Ok(()) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + while rx.try_recv().is_ok() {} + if !send_projects(writer, engine).await { + break; + } + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + frame = read_frame(reader, partial) => { + match frame { + // Any request ends the projects stream. + Ok(Frame::Line(_)) | Ok(Frame::Eof) => break, + Ok(Frame::TooLarge) => { + reject_frame_too_large(writer).await; + return false; + } + Err(_) => return false, + } + } + } + } + true +} + async fn write_output_chunks( writer: &mut IpcWriter, agent_id: &str, diff --git a/crates/pu-engine/src/ipc_server/tests.rs b/crates/pu-engine/src/ipc_server/tests.rs index d4d46c74..5bd826d0 100644 --- a/crates/pu-engine/src/ipc_server/tests.rs +++ b/crates/pu-engine/src/ipc_server/tests.rs @@ -622,6 +622,70 @@ async fn given_subscribe_fs_should_stream_coalesced_file_events() { handle.abort(); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn given_subscribe_projects_should_push_the_list_on_add_and_remove() { + let tmp = TempDir::new().unwrap(); + let sock_path = tmp.path().join("test.sock"); + let project_root = tmp.path().canonicalize().unwrap().join("project"); + std::fs::create_dir_all(&project_root).unwrap(); + let root = project_root.to_string_lossy().into_owned(); + // A loaded registry is pruned once served, which opens the stream. + let engine = Engine::with_project_registry(tmp.path().join("registry.json")).unwrap(); + let server = IpcServer::bind(&sock_path, engine).unwrap(); + let handle = tokio::spawn(async move { server.run().await }); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + let stream = UnixStream::connect(&sock_path).await.unwrap(); + let (reader, mut writer) = stream.into_split(); + let mut reader = BufReader::new(reader); + let req = serde_json::to_string(&Request::SubscribeProjects).unwrap(); + writer + .write_all(format!("{req}\n").as_bytes()) + .await + .unwrap(); + assert!(matches!( + next_response(&mut reader).await, + Response::ProjectsSubscribed + )); + assert!(matches!( + next_response(&mut reader).await, + Response::ProjectsChanged { projects } if projects.is_empty() + )); + + let send = |request: Request| { + let sock_path = sock_path.clone(); + async move { + let stream = UnixStream::connect(&sock_path).await.unwrap(); + let (reader, mut writer) = stream.into_split(); + let json = serde_json::to_string(&request).unwrap(); + writer + .write_all(format!("{json}\n").as_bytes()) + .await + .unwrap(); + next_response(&mut BufReader::new(reader)).await + } + }; + send(Request::Init { + project_root: root.clone(), + }) + .await; + assert!(matches!( + next_response(&mut reader).await, + Response::ProjectsChanged { projects } if projects == vec![root.clone()] + )); + send(Request::RemoveProject { + project_root: root.clone(), + suspend_agents: false, + }) + .await; + assert!(matches!( + next_response(&mut reader).await, + Response::ProjectsChanged { projects } if projects.is_empty() + )); + + handle.abort(); +} + async fn attachment_pair( engine: std::sync::Arc, agent_id: String, @@ -1395,8 +1459,11 @@ async fn assert_invalid_bytes_close_stream(sock_path: &std::path::Path, subscrib write_line(&mut writer, &request).await; let first = read_response(&mut reader).await; assert!(subscribed(&first), "{first:?}"); - writer.write_all(b"\xff{}\n").await.unwrap(); - write_line(&mut writer, &Request::Health).await; + // One write: the daemon may close as soon as it reads the bad line, and a + // second write would then race it and fail with a broken pipe. + let mut bytes = b"\xff{}\n".to_vec(); + bytes.extend_from_slice((serde_json::to_string(&Request::Health).unwrap() + "\n").as_bytes()); + writer.write_all(&bytes).await.unwrap(); let responses = responses_until_close(&mut reader).await; assert!( !responses diff --git a/docs/product/cli.md b/docs/product/cli.md index 2cad706b..50d04864 100644 --- a/docs/product/cli.md +++ b/docs/product/cli.md @@ -33,10 +33,12 @@ Key behaviors: | Command | Args/Flags | Description | |---|---|---| -| `pu init` | `--json` | Register current project with daemon | +| `pu init` | `--json` | Add the current project to the daemon (same as `pu projects add`) | | `pu spawn [prompt]` | `--agent`, `--name`, `--base`, `--root`, `--worktree`, `--template`, `--file`, `--command`, `--var KEY=VALUE`, `--no-auto`, `--agent-args`, `--plan`, `--no-trigger`, `--trigger`, `--json` | Spawn an agent (in worktree or root) | | `pu status` | `--global`, `--agent `, `--json` | Show project, global summary, or agent status | | `pu projects list` | `--global`, `--json` | List daemon-known projects | +| `pu projects add [path]` | `--json` | Add a folder as a project, initializing it if needed; path defaults to `--project`, then cwd. Works with `--machine` given an absolute path | +| `pu projects remove [path]` | `--suspend-agents`, `--json` | Remove a project from the daemon for every device; files, including `.pu/`, are kept. Refuses while agents run unless `--suspend-agents` | | `pu agents list` | `--global`, `--state running\|suspended\|broken\|unknown`, `--json` | List agent instances with ownership | | `pu worktrees list` | `--global`, `--json` | List worktrees with ownership | | `pu bench [agent_id]` | `--all`, `--json` | Suspend (bench) agents | diff --git a/docs/product/daemon.md b/docs/product/daemon.md index 38ea76c6..a89e6f7b 100644 --- a/docs/product/daemon.md +++ b/docs/product/daemon.md @@ -23,7 +23,9 @@ Daemon (pu-engine binary) **Single daemon, per-project state keyed by project root.** The daemon is a single process serving all projects. Each request includes a `project_root` parameter to scope operations. `Engine` maintains per-project state internally. `Request::Init { project_root }` registers a project; subsequent `Spawn`/`Status`/`Kill` requests reference it. `Response::HealthReport` includes a `projects: Vec` listing all registered project roots and `agent_count: usize` across all projects. -**Global inventory (implemented 2026-10-10):** Production startup loads canonical project roots from `.projects.json`; successful Init and validated project use register roots atomically. Alternate sockets have independent registries. Invalid registrations do not enter the set, unavailable registered projects remain visible with errors, and restart does not spawn agents. A derived Inventory query combines manifests with live ownership metadata; no duplicate global agent database is stored. Global summary and flat agent/worktree records share count semantics, and Health now returns known projects and the count of live session handles rather than all retained handles. In-memory Engine::new remains available for isolated embedding/tests. +**Global inventory (implemented 2026-10-10):** Production startup loads canonical project roots from `.projects.json`; successful Init registers roots atomically (see explicit project membership below). Alternate sockets have independent registries. Invalid registrations do not enter the set, unavailable registered projects remain visible with errors, and restart does not spawn agents. A derived Inventory query combines manifests with live ownership metadata; no duplicate global agent database is stored. Global summary and flat agent/worktree records share count semantics, and Health now returns known projects and the count of live session handles rather than all retained handles. In-memory Engine::new remains available for isolated embedding/tests. + +**Explicit project membership (implemented 2026-10-21, protocol 13):** The registry is each machine's one project list, and only `Init` adds to it and `RemoveProject` removes from it. Using a project (status, spawn, subscriptions) no longer registers it — that made a project removed on one device reappear as soon as another device still watching it sent a request. Removal keeps the project's files and `.pu/` so adding it again restores it; live agents block removal unless the caller asks to suspend them. `SubscribeProjects` pushes the full list on every change, and the macOS app's sidebar, each machine's project list and the phone bridge all follow the registry instead of keeping their own lists. Registered roots whose folder exists without a manifest (removed by clients that deleted `.pu/manifest.json`) are dropped in the background when the daemon starts; a manifest deleted while it runs leaves the project listed as unavailable until it is removed or the daemon restarts. Inventory never changes the registry. **Daemon binary:** `pu-engine` (separate from `pu-cli`). Both modes take an exclusive lock on `daemon.sock.lock` (one daemon per socket) and write the PID file; managed mode (`--managed`) additionally exits when its parent app dies. Socket path configurable via `--socket `, defaults to `~/.pu/daemon.sock`. diff --git a/docs/reference/cli-commands-map.csv b/docs/reference/cli-commands-map.csv index 1636efdc..6057ebb5 100644 --- a/docs/reference/cli-commands-map.csv +++ b/docs/reference/cli-commands-map.csv @@ -1,5 +1,7 @@ Command,IPC Request,Tier,Notes pu init,Init,1,Creates .pu/ directory and pu.db +pu projects add,Init,1,Adds a folder as a project (same as pu init) +pu projects remove,RemoveProject,1,Removes a project for every device; files are kept pu spawn,Spawn,1,Most complex command — worktree + agents pu status,Status,1,Returns full workspace state from daemon pu kill,Kill,1,Stop agents; optionally remove worktrees diff --git a/docs/reference/ipc-api.md b/docs/reference/ipc-api.md index 583b9cf2..e5fe51d2 100644 --- a/docs/reference/ipc-api.md +++ b/docs/reference/ipc-api.md @@ -8,7 +8,7 @@ The PurePoint daemon communicates with clients (CLI, macOS app) via NDJSON over |---|---| | Socket path | `~/.pu/daemon.sock` (mode 0600; the daemon keeps `~/.pu` at 0700) | | Access | Only processes running as the daemon's user: every accepted connection's peer uid must equal the daemon's effective uid, or it is closed | -| Protocol version | 11 | +| Protocol version | 13 | | Framing | Newline-delimited JSON (one JSON object per line) | | Max message size | 1 MB (1,048,576 bytes); a longer line gets one `FRAME_TOO_LARGE` error and the connection is closed | | First request | Must arrive within 30 seconds of connecting, or the connection is closed | @@ -44,6 +44,7 @@ Responses use the same `"type"` field convention. Error responses have `"type": | `NOT_FOUND` | Template, agent def, swarm def, schedule, or trigger not found | | `NOT_INITIALIZED` | Project not initialized (run `pu init`) | | `ALREADY_INITIALIZED` | Project already initialized | +| `PROJECT_HAS_LIVE_AGENTS` | `remove_project` without `suspend_agents` while the project has live agents | | `DAEMON_NOT_RUNNING` | Daemon is not running | | `DAEMON_CONNECTION_FAILED` | Connection to daemon failed | | `PROTOCOL_MISMATCH` | Client/daemon protocol version mismatch | @@ -74,7 +75,7 @@ Inventory returns `type: inventory`, observation time, optional project scope (n ResolveAgent returns `type: agent_resolved`, agent_id, project_root and worktree_id. Legacy Status (with agent_id), Kill (single agent), Suspend (single agent), Resume, Rename and AssignTrigger accept an empty project_root for daemon ownership resolution. Explicit roots constrain ownership. Bulk requests require a root. Duplicate durable IDs return AMBIGUOUS_AGENT; a live session owned by another project returns AGENT_OWNERSHIP_CONFLICT; an incomplete durable inventory returns INVENTORY_INCOMPLETE when a known live owner cannot resolve the ID. Standalone shells support status/logs/input/attach/kill and reject resumable project operations with UNSUPPORTED_AGENT_OPERATION. -Production registry: `.projects.json`, loaded on startup and atomically updated after successful initialization or validated use. `/`, `$HOME` and ancestors of `$HOME` are never registered, even when `~/.pu/manifest.json` makes `$HOME` look initialized; such entries left by older daemons are removed when the registry loads. Requests naming such a root fail with `project_root_not_allowed`, except `status`, `subscribe_status`, `read`, `channel_read` and `kill`, which stay available so agents an older version recorded there can be seen and stopped. Roots are canonical, registration survives restart, and startup does not resume agents. Health returns registered roots and counts only session handles without observed exits. +Production registry: `.projects.json`, loaded on startup and atomically updated by `init` (add) and `remove_project` (remove) — nothing else adds a project, so a removed project stays removed even while other clients still use it. A registered root whose folder still exists but has no `.pu/manifest.json` (removed by an older client) is dropped in the background when the daemon starts (inventory itself never changes the registry); a root whose folder is missing stays, reported unavailable, since it may be on an unmounted volume. `/`, `$HOME` and ancestors of `$HOME` are never registered, even when `~/.pu/manifest.json` makes `$HOME` look initialized; such entries left by older daemons are removed when the registry loads. Requests naming such a root fail with `project_root_not_allowed`, except `status`, `subscribe_status`, `read`, `channel_read` and `kill`, which stay available so agents an older version recorded there can be seen and stopped. Roots are canonical, registration survives restart, and startup does not resume agents. Health returns registered roots and counts only session handles without observed exits. ### Health @@ -88,7 +89,7 @@ Check daemon liveness and protocol version. "type": "health_report", "pid": 12345, "uptime_seconds": 3600, - "protocol_version": 11, + "protocol_version": 13, "projects": ["/path/to/project"], "agent_count": 3 } @@ -96,12 +97,22 @@ Check daemon liveness and protocol version. ### Init -Register a project and create `.pu/manifest.json`. +Add a project: create `.pu/manifest.json` if needed and register the root. This is the only request that registers a project. **Request**: `{"type": "init", "project_root": "/path/to/project"}` **Response**: `{"type": "init_result", "created": true}` +### RemoveProject + +Forget a project on this daemon, for every client. Its files and `.pu/` are kept (suspending agents only updates the manifest), so a later `init` brings it back with its worktrees and agents. Live agents refuse the removal with `PROJECT_HAS_LIVE_AGENTS` unless `suspend_agents` is true (default false), which suspends them so they can be resumed once the project is added again. An unregistered root answers `removed: false`. + +**Request**: `{"type": "remove_project", "project_root": "/path/to/project", "suspend_agents": true}` + +**Response**: `{"type": "project_removed", "project_root": "/path/to/project", "removed": true, "suspended": ["ag-abc"], "killed": []}` + +`killed` lists live sessions in the folder that the manifest did not record; they are stopped and cannot be resumed. A daemon loading its registry drops, in the background, registered folders that exist without a manifest; `subscribe_projects` sends its first list only after that. + ### Shutdown Gracefully shut down the daemon. @@ -393,6 +404,16 @@ Subscribe to workspace status changes. Then streams: `{"type": "status_event", "worktrees": [...], "agents": [...]}` +### SubscribeProjects + +Follow the registered projects. The full sorted list of roots is sent once on subscribing and again after every `init` that adds a project and every removal, so every client shows the same projects without polling. Any request line ends the stream. + +**Request**: `{"type": "subscribe_projects"}` + +**Response**: `{"type": "projects_subscribed"}` + +Then streams: `{"type": "projects_changed", "projects": ["/path/a", "/path/b"]}` + ## Grid commands **Request**: `{"type": "grid_command", "project_root": "/path", "command": {...}}`