diff --git a/CHANGELOG.md b/CHANGELOG.md index fd8fe950..ce70ce20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,11 @@ All notable changes to PurePoint are documented here. Format follows [Keep a Cha ## [Unreleased] ### Added +- Projects can be reordered, and every device shows the same order. Each machine's daemon saves the order of its projects. On the Mac, drag a project within its section or use Move Up / Move Down in its menu; on the iPad, use Move Up / Move Down; on the phone, long-press a machine and choose Reorder Projects…. Changes reach every device following that machine right away. `pu projects` lists projects in the same order. New projects are added at the end. - Paste an image (Cmd+V) into any agent's terminal, or drop files on a remote agent's terminal, and the macOS app uploads it to the agent's machine and types its path there, so remote agents can see screenshots. Uploads are kept in `~/.pu/attachments` on that machine for 7 days. ### Changed +- The daemon protocol is now version 14, for the new `reorder_projects` request. Update PurePoint on every machine and restart its daemon. - Phones and iPads now pair with full access (agents, terminals, workspace management and Point Guard chat). Point Guard setup shows each device on one line with a single Access menu (Full access, Terminals only, View only, No access) and a Revoke button. Revoked devices are removed instead of lingering as "Revoked", and the redundant "Require re-pair" button is gone. Devices that are already paired keep their current access. - The daemon protocol is now version 12, for the new `attach_file` request. Update PurePoint on every machine and restart its daemon. - **Breaking: `scripts/install.sh` installs a Linux release only after verifying who built it.** Linux tarballs are signed with a PurePoint minisign key whose public half is embedded in the script, and releases carry GitHub build provenance attestations. The script checks the signature (with `minisign`, or OpenSSL 3 when minisign isn't installed) and, when the GitHub CLI is installed and signed in, runs `gh attestation verify`. If neither check can run, for example for releases published before signing started, it stops; set `PU_INSTALL_SKIP_VERIFY=1` to install anyway with a warning. A check that fails always stops the install. diff --git a/apps/purepoint-macos/purepoint-macos/Models/ProjectOrder.swift b/apps/purepoint-macos/purepoint-macos/Models/ProjectOrder.swift new file mode 100644 index 00000000..86078ae2 --- /dev/null +++ b/apps/purepoint-macos/purepoint-macos/Models/ProjectOrder.swift @@ -0,0 +1,67 @@ +import Foundation + +/// The sidebar's project order. Each machine's daemon saves the order of its projects so +/// every device lists them the same way; these helpers apply it to the rows on screen. +nonisolated enum ProjectOrder { + /// `items` with the one rooted at `root` moved to `index`, or nil when nothing moves. + static func moving( + _ root: String, to index: Int, in items: [T], root rootOf: (T) -> String + ) -> [T]? { + guard let from = items.firstIndex(where: { rootOf($0) == root }) else { return nil } + var next = items + let item = next.remove(at: from) + let to = min(max(index, 0), next.count) + guard to != from else { return nil } + next.insert(item, at: to) + return next + } + + /// `items` sorted into the order of `roots`. Items `roots` doesn't list keep their relative + /// order after the ones it does. + static func sorted(_ items: [T], by roots: [String], root rootOf: (T) -> String) -> [T] { + var rank: [String: Int] = [:] + for (index, root) in roots.enumerated() where rank[root] == nil { + rank[root] = index + } + return items.enumerated().sorted { left, right in + let l = rank[rootOf(left.element)] ?? Int.max + let r = rank[rootOf(right.element)] ?? Int.max + return l != r ? l < r : left.offset < right.offset + }.map(\.element) + } +} + +@MainActor +extension ProjectOrder { + static func moving(_ root: String, to index: Int, in projects: [ProjectState]) -> [ProjectState]? { + moving(root, to: index, in: projects, root: { $0.projectRoot }) + } +} + +/// Saves one daemon's project order. Moves are sent one at a time, in the order they were +/// made, so the daemon always ends up with the last one; each send carries the full order. +@MainActor +final class ProjectOrderSaver { + private let endpoint: DaemonEndpoint + private var last: Task? + /// Moves sent but not yet answered. While any are, a project list pushed or fetched from + /// the daemon may predate them, so its order must not replace the one on screen. + private(set) var pending = 0 + + init(endpoint: DaemonEndpoint) { + self.endpoint = endpoint + } + + func save(_ roots: [String], failed: @escaping @MainActor () -> Void) { + let previous = last + let endpoint = endpoint + pending += 1 + last = Task { [weak self] in + await previous?.value + let response = try? await endpoint.makeClient().send(.reorderProjects(projectRoots: roots)) + self?.pending -= 1 + if case .projectOrder = response { return } + failed() + } + } +} diff --git a/apps/purepoint-macos/purepoint-macos/Services/DaemonProtocol.swift b/apps/purepoint-macos/purepoint-macos/Services/DaemonProtocol.swift index 0cf8e814..bca6a5ce 100644 --- a/apps/purepoint-macos/purepoint-macos/Services/DaemonProtocol.swift +++ b/apps/purepoint-macos/purepoint-macos/Services/DaemonProtocol.swift @@ -362,6 +362,8 @@ nonisolated enum DaemonRequest: Encodable { case shutdown /// The daemon's registered projects; no project root means all of them. case inventory(kind: String, projectRoot: String? = nil) + /// Save the order of the daemon's registered projects, which every client shows (protocol 14). + case reorderProjects(projectRoots: [String]) // File and git access confined to registered projects (machines.md, protocol 9). case fsList(path: String, recursive: Bool = false, includeHidden: Bool = true, maxEntries: Int = 20_000) /// `fs_list` with `directories_only`: the subdirectories of any directory on the machine, @@ -621,6 +623,9 @@ nonisolated enum DaemonRequest: Encodable { try container.encode("inventory", forKey: .key("type")) try container.encode(kind, forKey: .key("kind")) try container.encodeIfPresent(projectRoot, forKey: .key("project_root")) + case .reorderProjects(let projectRoots): + try container.encode("reorder_projects", forKey: .key("type")) + try container.encode(projectRoots, forKey: .key("project_roots")) case .fsList(let path, let recursive, let includeHidden, let maxEntries): try container.encode("fs_list", forKey: .key("type")) try container.encode(path, forKey: .key("path")) @@ -711,6 +716,8 @@ nonisolated enum DaemonResponse: Decodable { /// `launchArgs` is nil when the daemon predates machine-level launch args (protocol < 11). case globalAgentSettingsReport(agentStatusTracking: Bool, launchArgs: [String: String]? = nil) case inventory(InventoryReportPayload) + /// The registered projects in their saved order. + case projectOrder(projectRoots: [String]) case fsListResult(path: String, entries: [FsEntryPayload], truncated: Bool) case fsStatResult(FsStatPayload) case fsReadResult(path: String, data: Data, size: UInt64, modifiedMs: Int64, truncated: Bool) @@ -837,6 +844,8 @@ nonisolated enum DaemonResponse: Decodable { agentStatusTracking: p.agentStatusTracking ?? true, launchArgs: p.launchArgs) case "inventory": self = .inventory(try InventoryReportPayload(from: decoder)) + case "project_order": + self = .projectOrder(projectRoots: try ProjectOrderPayload(from: decoder).projectRoots) case "fs_list_result": let p = try FsListResultPayload(from: decoder) self = .fsListResult(path: p.path, entries: p.entries, truncated: p.truncated) @@ -1253,7 +1262,7 @@ private nonisolated struct DynamicCodingKey: CodingKey { nonisolated enum DaemonProtocol { /// The protocol this app speaks. A daemon reporting another version is incompatible. - static let version = 13 + static let version = 14 } nonisolated struct InventoryProjectPayload: Decodable, Sendable, Equatable { @@ -1287,6 +1296,14 @@ nonisolated struct InventoryReportPayload: Decodable, Sendable { } } +nonisolated struct ProjectOrderPayload: Decodable, Sendable { + let projectRoots: [String] + + enum CodingKeys: String, CodingKey { + case projectRoots = "project_roots" + } +} + nonisolated struct FsEntryPayload: Decodable, Sendable, Equatable { let name: String let path: String diff --git a/apps/purepoint-macos/purepoint-macos/State/AppState.swift b/apps/purepoint-macos/purepoint-macos/State/AppState.swift index 1d7a1c11..a5f282a6 100644 --- a/apps/purepoint-macos/purepoint-macos/State/AppState.swift +++ b/apps/purepoint-macos/purepoint-macos/State/AppState.swift @@ -122,6 +122,9 @@ final class AppState { @ObservationIgnored private var lastLocalProjects: Set? /// Each opened root's canonical path, resolved once rather than on every report. @ObservationIgnored private var canonicalRoots: [String: String] = [:] + /// The local daemon's project order as last reported. + @ObservationIgnored private var localProjectOrder: [String] = [] + @ObservationIgnored private lazy var localOrderSaver = ProjectOrderSaver(endpoint: .local) #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. @@ -342,6 +345,33 @@ final class AppState { return false } + // MARK: - Project Order + + /// The projects listed beside `project`: the local ones, or its machine's. + func siblingProjects(of project: ProjectState) -> [ProjectState] { + guard let machineId = project.key.machineId else { return projects } + return machines.first { $0.machine.id == machineId }?.projects ?? [] + } + + /// Move a project to `index` among its siblings. The order is saved on the machine that + /// owns the project, so every device lists it the same way. + func moveProject(_ project: ProjectState, to index: Int) { + if let machine = machine(for: project) { + machine.moveProject(root: project.projectRoot, to: index) + return + } + #if os(macOS) + guard let next = ProjectOrder.moving(project.projectRoot, to: index, in: projects) else { return } + projects = next + // On success the daemon pushes the new order to every device following it. + localOrderSaver.save(next.map(\.projectRoot)) { [weak self] in + guard let self else { return } + self.daemonError = "Could not save the project order." + self.sortLocalProjects(by: self.localProjectOrder) + } + #endif + } + /// 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. @@ -430,10 +460,21 @@ final class AppState { guard FileManager.default.fileExists(atPath: root), !isTooBroadProjectRoot(root) else { continue } openProject(root, resumingSuspendedAgents: resuming) } + // The daemon's list is in its saved order, which every device shows, unless it may + // predate a move this Mac is still saving. + localProjectOrder = roots + if localOrderSaver.pending == 0 { sortLocalProjects(by: roots) } lastLocalProjects = registered #endif } + #if os(macOS) + private func sortLocalProjects(by roots: [String]) { + let ordered = ProjectOrder.sorted(projects, by: roots, root: { self.canonicalRoot(of: $0.projectRoot) }) + if ordered.map(\.projectRoot) != projects.map(\.projectRoot) { projects = ordered } + } + #endif + #if os(macOS) private func canonicalRoot(of root: String) -> String { if let known = canonicalRoots[root] { return known } diff --git a/apps/purepoint-macos/purepoint-macos/State/MachineState.swift b/apps/purepoint-macos/purepoint-macos/State/MachineState.swift index 40d3b105..943efaf5 100644 --- a/apps/purepoint-macos/purepoint-macos/State/MachineState.swift +++ b/apps/purepoint-macos/purepoint-macos/State/MachineState.swift @@ -51,6 +51,9 @@ final class MachineState: Identifiable { /// 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) + /// Bumped by each local move, so a project list fetched before it can't undo it. + @ObservationIgnored private var orderRevision = 0 + @ObservationIgnored private lazy var orderSaver = ProjectOrderSaver(endpoint: endpoint) var endpoint: DaemonEndpoint { .machine(machine.id) } var isConnectedOrConnecting: Bool { state != .disconnected } @@ -338,6 +341,7 @@ final class MachineState: Identifiable { /// Discover the daemon's registered projects (`inventory`, kind projects, all roots). func refreshProjects() async { let client = endpoint.makeClient() + let revision = orderRevision let response: DaemonResponse do { response = try await client.send(.inventory(kind: "projects")) @@ -351,6 +355,8 @@ final class MachineState: Identifiable { switch response { case .inventory(let report): actionError = nil + // Listed before a move this app made: the next refresh brings the saved order. + guard revision == orderRevision else { return } setProjects(roots: report.projects.map(\.projectRoot)) case .error(_, let message): actionError = "Could not list projects on \(machine.name): \(message)" @@ -360,7 +366,11 @@ final class MachineState: Identifiable { } private func setProjects(roots: [String]) { - let visible = roots.filter { !hiddenRoots.contains($0) } + var visible = roots.filter { !hiddenRoots.contains($0) } + if orderSaver.pending > 0 { + // The list may predate a move still being saved: keep the order on screen. + visible = ProjectOrder.sorted(visible, by: projects.map(\.projectRoot), root: { $0 }) + } let wanted = Set(visible) var kept: [ProjectState] = [] for project in projects where !wanted.contains(project.projectRoot) { @@ -395,6 +405,22 @@ final class MachineState: Identifiable { } } + /// Move a project to `index` among this machine's projects and save the order on the + /// machine, so every device lists its projects the same way. + func moveProject(root: String, to index: Int) { + guard let next = ProjectOrder.moving(root, to: index, in: projects) else { return } + projects = next + orderRevision += 1 + orderSaver.save(next.map(\.projectRoot)) { [weak self] in + guard let self else { return } + Task { + // Show the order the machine kept. + await self.refreshProjects() + self.actionError = "Could not save the project order on \(self.machine.name)." + } + } + } + private func makeProject(root: String) -> ProjectState { let endpoint = self.endpoint let client = endpoint.makeClient() diff --git a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarMenuModel.swift b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarMenuModel.swift index 801f865b..b639e94c 100644 --- a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarMenuModel.swift +++ b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarMenuModel.swift @@ -21,6 +21,8 @@ enum SidebarMenuAction: Equatable { case retryLayout case hideProject case removeProject + case moveProjectUp + case moveProjectDown case newAgentInWorktree case newTerminalInWorktree } @@ -126,6 +128,13 @@ enum SidebarMenus { if project.isRemote, project.registry?.layoutErrorsByProject[project.projectId] != nil { entries.append(.action("Retry Layout", .retryLayout)) } + if let siblings = project.appState?.siblingProjects(of: project), siblings.count > 1, + let index = siblings.firstIndex(where: { $0 === project }) + { + entries.append(.action("Move Up", .moveProjectUp, isEnabled: index > 0)) + entries.append(.action("Move Down", .moveProjectDown, isEnabled: index < siblings.count - 1)) + entries.append(.separator) + } 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)) diff --git a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineModel.swift b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineModel.swift index 855d6d0e..34c2b187 100644 --- a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineModel.swift +++ b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineModel.swift @@ -151,6 +151,32 @@ enum SidebarTree { return nodes } + /// Where a dragged project lands when dropped at `childIndex` under `parent` (nil for the + /// top level): the project and its index among its siblings once it's moved, or nil when + /// the drop is outside its own section or would leave it where it is. A project only + /// moves among its own section: the local projects, or one machine's. + static func projectDrop( + projectId: String, parent: SidebarNode?, childIndex: Int, roots: [SidebarNode] + ) -> (project: ProjectState, index: Int)? { + let siblings: [SidebarNode] + if let parent { + guard case .machine = parent.kind else { return nil } + siblings = parent.children + } else { + // Local projects lead the top level, ahead of the MACHINES section. + siblings = Array(roots.prefix { if case .project = $0.kind { true } else { false } }) + } + let projects = siblings.compactMap { node -> ProjectState? in + if case .project(let project) = node.kind { project } else { nil } + } + guard childIndex >= 0, childIndex <= projects.count, + let from = projects.firstIndex(where: { $0.projectId == projectId }) + else { return nil } + // `childIndex` counts the dragged row, which leaves its old place first. + let to = childIndex > from ? childIndex - 1 : childIndex + return to == from ? nil : (projects[from], to) + } + /// Whether every row in the tree holds all the rows it will ever hold. `false` while a /// project has yet to read its snapshot and a machine has yet to answer its inventory, /// which is how the sidebar looks for the first moments after a relaunch or a machine 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 eaab01c5..4fb2c9a3 100644 --- a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController+Actions.swift +++ b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController+Actions.swift @@ -49,6 +49,11 @@ extension SidebarOutlineViewController { showConfirmation(.removeProject(project)) { self.onRemoveProject?(project) } + case (.moveProjectUp, .project(let project)), (.moveProjectDown, .project(let project)): + guard let appState = project.appState, + let index = appState.siblingProjects(of: project).firstIndex(where: { $0 === project }) + else { return } + appState.moveProject(project, to: index + (action == .moveProjectUp ? -1 : 1)) case (.newAgentInWorktree, .worktree(let worktree)): guard let project = findProject(forWorktreeId: worktree.id) else { return } onShowCommandPalette?(project, .worktree(worktree.id), false) diff --git a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController+ProjectDrag.swift b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController+ProjectDrag.swift new file mode 100644 index 00000000..c18905fb --- /dev/null +++ b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController+ProjectDrag.swift @@ -0,0 +1,54 @@ +#if os(macOS) +import AppKit + +// Dragging a project row reorders it within its section. The order is saved on the machine +// that owns the project, so every device lists it the same way. + +@MainActor +extension SidebarOutlineViewController { + static let projectDragType = NSPasteboard.PasteboardType("com.purepoint.sidebar-project") + + func registerProjectDrag() { + outlineView.registerForDraggedTypes([Self.projectDragType]) + outlineView.setDraggingSourceOperationMask(.move, forLocal: true) + } + + func outlineView(_ outlineView: NSOutlineView, pasteboardWriterForItem item: Any) -> NSPasteboardWriting? { + guard let node = item as? SidebarNode, case .project(let project) = node.kind else { return nil } + let pasteboardItem = NSPasteboardItem() + pasteboardItem.setString(project.projectId, forType: Self.projectDragType) + return pasteboardItem + } + + func outlineView( + _ outlineView: NSOutlineView, validateDrop info: NSDraggingInfo, proposedItem item: Any?, + proposedChildIndex index: Int + ) -> NSDragOperation { + guard let projectId = info.draggingPasteboard.string(forType: Self.projectDragType) else { return [] } + var parent = item as? SidebarNode + var childIndex = index + // Hovering on or inside a project means "before that project", among its siblings. + if let node = parent, case .project = node.kind { + parent = outlineView.parent(forItem: node) as? SidebarNode + childIndex = outlineView.childIndex(forItem: node) + outlineView.setDropItem(parent, dropChildIndex: childIndex) + } + guard childIndex != NSOutlineViewDropOnItemIndex, + SidebarTree.projectDrop(projectId: projectId, parent: parent, childIndex: childIndex, roots: projectNodes) + != nil + else { return [] } + return .move + } + + func outlineView( + _ outlineView: NSOutlineView, acceptDrop info: NSDraggingInfo, item: Any?, childIndex index: Int + ) -> Bool { + guard let projectId = info.draggingPasteboard.string(forType: Self.projectDragType), + let drop = SidebarTree.projectDrop( + projectId: projectId, parent: item as? SidebarNode, childIndex: index, roots: projectNodes) + else { return false } + drop.project.appState?.moveProject(drop.project, to: drop.index) + return true + } +} +#endif diff --git a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController.swift b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController.swift index b16c79d2..d334b884 100644 --- a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController.swift +++ b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController.swift @@ -103,6 +103,7 @@ class SidebarOutlineViewController: NSViewController, NSOutlineViewDataSource, N let contextMenu = NSMenu() contextMenu.delegate = self outlineView.menu = contextMenu + registerProjectDrag() scrollView.documentView = outlineView scrollView.hasVerticalScroller = true diff --git a/apps/purepoint-macos/purepoint-macosTests/MachinesTests.swift b/apps/purepoint-macos/purepoint-macosTests/MachinesTests.swift index dbbb03f6..d577c8b6 100644 --- a/apps/purepoint-macos/purepoint-macosTests/MachinesTests.swift +++ b/apps/purepoint-macos/purepoint-macosTests/MachinesTests.swift @@ -315,7 +315,22 @@ struct HealthProbeTests { struct DaemonProjectHostRequestTests { @Test func givenProtocolShouldBeCurrentVersion() { - #expect(DaemonProtocol.version == 13) + #expect(DaemonProtocol.version == 14) + } + + @Test func givenReorderProjectsShouldSendTheWholeOrder() throws { + let object = try json(.reorderProjects(projectRoots: ["/b", "/a"])) + #expect(object["type"] as? String == "reorder_projects") + #expect(object["project_roots"] as? [String] == ["/b", "/a"]) + } + + @Test func givenProjectOrderReplyShouldParse() { + let response = DaemonClient.parse(Data(#"{"type":"project_order","project_roots":["/b","/a"]}"#.utf8)) + guard case .projectOrder(let roots) = response else { + Issue.record("expected projectOrder, got \(response)") + return + } + #expect(roots == ["/b", "/a"]) } @Test func givenRemoveProjectShouldNameTheRootAndWhetherToSuspendAgents() throws { diff --git a/apps/purepoint-macos/purepoint-macosTests/ProjectOrderTests.swift b/apps/purepoint-macos/purepoint-macosTests/ProjectOrderTests.swift new file mode 100644 index 00000000..62765fca --- /dev/null +++ b/apps/purepoint-macos/purepoint-macosTests/ProjectOrderTests.swift @@ -0,0 +1,29 @@ +import Foundation +import Testing + +@testable import PurePoint + +// The sidebar follows the order each machine's daemon saves, and moves send it back. + +struct ProjectOrderTests { + private func moved(_ root: String, to index: Int, in roots: [String]) -> [String]? { + ProjectOrder.moving(root, to: index, in: roots, root: { $0 }) + } + + @Test func givenAMoveShouldPlaceTheProjectAtTheIndex() { + #expect(moved("/c", to: 0, in: ["/a", "/b", "/c"]) == ["/c", "/a", "/b"]) + #expect(moved("/a", to: 1, in: ["/a", "/b", "/c"]) == ["/b", "/a", "/c"]) + #expect(moved("/a", to: 99, in: ["/a", "/b", "/c"]) == ["/b", "/c", "/a"]) + } + + @Test func givenAMoveThatChangesNothingShouldReturnNil() { + #expect(moved("/a", to: 0, in: ["/a", "/b"]) == nil) + #expect(moved("/a", to: -1, in: ["/a", "/b"]) == nil) + #expect(moved("/missing", to: 0, in: ["/a", "/b"]) == nil) + } + + @Test func givenTheDaemonOrderShouldSortListedProjectsFirstAndKeepTheRest() { + let sorted = ProjectOrder.sorted(["/a", "/x", "/b", "/y"], by: ["/b", "/gone", "/a"], root: { $0 }) + #expect(sorted == ["/b", "/a", "/x", "/y"]) + } +} diff --git a/apps/purepoint-macos/purepoint-macosTests/SidebarMenuModelTests.swift b/apps/purepoint-macos/purepoint-macosTests/SidebarMenuModelTests.swift index ccf987d1..26ca8662 100644 --- a/apps/purepoint-macos/purepoint-macosTests/SidebarMenuModelTests.swift +++ b/apps/purepoint-macos/purepoint-macosTests/SidebarMenuModelTests.swift @@ -86,6 +86,28 @@ struct SidebarMenuModelTests { #expect(titles(SidebarMenus.contextMenu(for: nodes[0].children[0])) == ["Delete Worktree\u{2026}"]) } + @Test func givenAProjectDropShouldMoveItOnlyWithinItsOwnSection() { + let a = makeProject(root: "/tmp/a", worktreeCount: 0) + let b = makeProject(root: "/tmp/b", worktreeCount: 0) + let c = makeProject(root: "/tmp/c", worktreeCount: 0) + let machine = MachineState(machine: Machine(name: "m", sshHost: "m"), appState: nil, registry: nil) + let roots = SidebarTree.build(projects: [a, b, c], machines: [machine], workspacesByProject: [:]) + let drop = { (id: String, parent: SidebarNode?, index: Int) in + SidebarTree.projectDrop(projectId: id, parent: parent, childIndex: index, roots: roots) + .map { "\($0.project.projectRoot)@\($0.index)" } + } + + // Dropping before the first row moves to the top; past the last local row moves to the end. + #expect(drop(c.projectId, nil, 0) == "/tmp/c@0") + #expect(drop(a.projectId, nil, 3) == "/tmp/a@2") + #expect(drop(a.projectId, nil, 2) == "/tmp/a@1") + // Its own place, below the local projects, or under a machine it isn't on: no move. + #expect(drop(a.projectId, nil, 0) == nil) + #expect(drop(a.projectId, nil, 1) == nil) + #expect(drop(a.projectId, nil, 4) == nil) + #expect(drop(a.projectId, roots[4], 0) == nil) + } + @Test func givenAWorktreeAddButtonShouldOfferANewAgentOrTerminal() { #expect(titles(SidebarMenus.worktreeAddMenu) == ["New Agent", "New Terminal"]) } diff --git a/apps/purepoint-mobile/PurePoint/AgentControlDomain.swift b/apps/purepoint-mobile/PurePoint/AgentControlDomain.swift index 0bce3b82..247f8ebd 100644 --- a/apps/purepoint-mobile/PurePoint/AgentControlDomain.swift +++ b/apps/purepoint-mobile/PurePoint/AgentControlDomain.swift @@ -276,7 +276,7 @@ struct WorkspaceCatalog: Decodable, Sendable { let complete: Bool } struct WorkspaceOperation: Codable, Sendable { - struct Detail: Codable, Sendable { let agentId: String?; let worktreeId: String? } + struct Detail: Codable, Sendable { let agentId: String?; let worktreeId: String?; var projectIds: [String]? } let operationId: String let state: String let message: String? diff --git a/apps/purepoint-mobile/PurePoint/AgentControlModel.swift b/apps/purepoint-mobile/PurePoint/AgentControlModel.swift index fd2c3fdf..33a4d190 100644 --- a/apps/purepoint-mobile/PurePoint/AgentControlModel.swift +++ b/apps/purepoint-mobile/PurePoint/AgentControlModel.swift @@ -8,6 +8,8 @@ import UserNotifications typealias Request = @MainActor (String, [String: Any]) async throws -> JSONValue @Published private(set) var workspaceManagement = false @Published private(set) var workspaceSupported = false + /// Whether the bridge can save a machine's project order. + @Published private(set) var projectOrderSupported = false @Published private(set) var permission: AgentTerminalPermission = .off @Published private(set) var machines: [AgentMachine] = [] @Published private(set) var connected = false @@ -34,6 +36,10 @@ import UserNotifications // Workspace grant changes must not invalidate an authorized terminal receipt. private var terminalPermissionRevision = 0 private var refreshRevision = 0 + /// Bumped by each project reorder; an inventory fetched across one keeps the order on screen. + private var projectOrderRevision = 0 + /// Project reorders sent but not yet answered. + private var projectOrdersPending = 0 private var pendingOpen: AgentPendingOpen? // The agent a transport drop closed, reopened once the same host reconnects. private var resumeScope: AgentScope? @@ -215,7 +221,7 @@ import UserNotifications let sameHost = self.host == host resumeScope = sameHost ? resume : nil self.request = request; self.connected = connected - self.host = host; if !sameHost { machines = []; permission = .off }; workspaceManagement = false; workspaceSupported = false; permissionRevision += 1; terminalPermissionRevision += 1 + self.host = host; if !sameHost { machines = []; permission = .off }; workspaceManagement = false; workspaceSupported = false; projectOrderSupported = false; permissionRevision += 1; terminalPermissionRevision += 1 loading = false; error = nil; discovering = connected guard connected else { return } let token = generation @@ -239,6 +245,7 @@ import UserNotifications if envelope.type == "permissions" { workspaceManagement = envelope.workspaceManagement ?? false workspaceSupported = (try? envelope.workspace?["management"].decode(Bool.self)) ?? false + projectOrderSupported = (try? envelope.workspace?["projectOrder"].decode(Bool.self)) ?? false applyPermission(AgentTerminalPermission(rawValue: envelope.terminals ?? "") ?? .off) return } @@ -252,6 +259,7 @@ import UserNotifications private func applyWorkspacePermission(_ data: JSONValue) { workspaceManagement = (try? data["workspaceManagement"].decode(Bool.self)) ?? false workspaceSupported = (try? data["workspace"]["management"].decode(Bool.self)) ?? false + projectOrderSupported = (try? data["workspace"]["projectOrder"].decode(Bool.self)) ?? false } var canManage: Bool { connected && permission == .control && workspaceManagement && workspaceSupported } var bridgeIdentity: String { host } @@ -270,6 +278,35 @@ import UserNotifications guard token == generation, revision == permissionRevision, canManage else { throw AgentControlFailure(message: "Connection or permission changed while refreshing. Operation may already have committed; check status.") } return value } + var canReorderProjects: Bool { canManage && projectOrderSupported } + /// Saves a machine's project order on that machine, so every device lists it the same way. + func reorderProjects(machine: String, projectIds: [String]) async throws { + guard canReorderProjects else { throw AgentControlFailure(message: "Reordering projects requires workspace management and an updated bridge.") } + projectOrderRevision += 1 + projectOrdersPending += 1 + defer { projectOrdersPending -= 1 } + applyProjectOrder(projectIds, machine: machine) + let receipt = try await workspaceRequest( + "workspace.reorderProjects", + fields: ["operationId": UUID().uuidString, "machine": machine, "projectIds": projectIds]) + guard receipt.state == "succeeded" else { + throw AgentControlFailure(message: receipt.message ?? "Project order wasn't saved. Refresh and try again.") + } + // The machine's saved order, in case a refresh that predates the move was skipped. + if let saved = receipt.detail?.projectIds { applyProjectOrder(saved, machine: machine) } + } + private func applyProjectOrder(_ projectIds: [String], machine: String) { + guard let index = machines.firstIndex(where: { $0.id == machine }), + let projects = machines[index].registeredProjects else { return } + machines[index].registeredProjects = Self.ordered(projects, by: projectIds) + } + /// `projects` in the order of `ids`; projects not listed keep their order after them. + static func ordered(_ projects: [WorkspaceProject], by ids: [String]) -> [WorkspaceProject] { + projects.enumerated().sorted { left, right in + let l = ids.firstIndex(of: left.element.id) ?? Int.max, r = ids.firstIndex(of: right.element.id) ?? Int.max + return l != r ? l < r : left.offset < right.offset + }.map(\.element) + } func refreshWorkspaceForReview(machine: String, project: String) async -> Bool { let token = generation, revision = permissionRevision guard canManage, await refresh(), token == generation, revision == permissionRevision, canManage, @@ -314,6 +351,7 @@ import UserNotifications let owningGeneration = generation defer { if generation == owningGeneration { inventoryInFlight = false } } refreshRevision += 1; let revision = refreshRevision; let token = generation + let orderRevision = projectOrderRevision loading = true do { let data = try await request("agents", [:]) @@ -323,6 +361,14 @@ import UserNotifications machines = inventory.machines.map { machine in guard (!machine.online || machine.metadataStatus == "error"), let cached = prior.first(where: { $0.id == machine.id }) else { return machine } return AgentMachine(id: machine.id, name: machine.name, online: machine.online, protocolVersion: machine.protocolVersion, screenMayBeInexact: machine.screenMayBeInexact, error: machine.error, complete: false, agents: cached.agents, registeredProjects: cached.registeredProjects, worktrees: cached.worktrees, metadataStatus: machine.metadataStatus ?? "offline", metadataError: machine.metadataError ?? machine.error) + }.map { machine in + // Fetched across a reorder: its project order may predate it, so keep the one on screen. + guard orderRevision != projectOrderRevision || projectOrdersPending > 0, + let shown = prior.first(where: { $0.id == machine.id })?.registeredProjects?.map(\.id), + let projects = machine.registeredProjects else { return machine } + var kept = machine + kept.registeredProjects = Self.ordered(projects, by: shown) + return kept }.sorted { let left = $0.agents.contains { $0.phase == "dialog" }, right = $1.agents.contains { $0.phase == "dialog" } diff --git a/apps/purepoint-mobile/PurePoint/AgentsView.swift b/apps/purepoint-mobile/PurePoint/AgentsView.swift index 5f53213c..e2fbc030 100644 --- a/apps/purepoint-mobile/PurePoint/AgentsView.swift +++ b/apps/purepoint-mobile/PurePoint/AgentsView.swift @@ -509,6 +509,46 @@ struct LaunchStatusSheet: View { } } +// MARK: - Project order + +/// Reorders one machine's projects. The order is saved on that machine, so every device shows it. +struct ProjectOrderSheet: View { + @ObservedObject var model: AgentControlModel + @ObservedObject var actions: WorkspaceActions + let machine: AgentMachine + @Environment(\.dismiss) private var dismiss + @State private var projects: [WorkspaceProject] = [] + + var body: some View { + NavigationStack { + List { + ForEach(projects) { project in + Label(project.name, systemImage: "folder") + }.onMove { projects.move(fromOffsets: $0, toOffset: $1) } + } + .environment(\.editMode, .constant(.active)) + .navigationTitle("Projects on \(machine.name)").navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + let ids = projects.map(\.id) + dismiss() + guard ids != machine.registeredProjects?.map(\.id) else { return } + Task { + do { try await model.reorderProjects(machine: machine.id, projectIds: ids) } catch { + actions.failure = error.localizedDescription + await model.refresh() + } + } + } + } + } + } + .onAppear { projects = machine.registeredProjects ?? [] } + } +} + // MARK: - Home (the desktop sidebar) struct HomeView: View { @@ -524,6 +564,7 @@ struct HomeView: View { @State private var creatingWorktree: WorkspaceTarget? @State private var renaming: AgentShortcut? @State private var stopping: AgentShortcut? + @State private var reordering: AgentMachine? @State private var removing: WorkspaceTarget? var body: some View { @@ -553,6 +594,7 @@ struct HomeView: View { newWorktree: target.worktree == nil ? { creatingWorktree = target } : nil) } .modifier(AgentManagement(model: model, actions: actions, renaming: $renaming, stopping: $stopping, creatingWorktree: $creatingWorktree, open: openAgent)) + .sheet(item: $reordering) { machine in ProjectOrderSheet(model: model, actions: actions, machine: machine) } .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 { @@ -563,7 +605,7 @@ struct HomeView: View { } 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 } + .onChange(of: model.bridgeIdentity) { _, _ in collapsed = []; launchTarget = nil; renaming = nil; stopping = nil; removing = nil; reordering = nil } } // MARK: Rows @@ -626,6 +668,11 @@ struct HomeView: View { Spacer() Text(!model.connected ? "Unavailable" : machine.online ? "Online" : "Offline").font(.caption).foregroundStyle(Theme.muted) }.padding(.trailing, 12).frame(minHeight: 40).contentShape(Rectangle()).onTapGesture { toggle(machineKey) } + .contextMenu { + Button("Reorder Projects…", systemImage: "arrow.up.arrow.down") { reordering = machine } + .disabled(!online || !model.canReorderProjects || (machine.protocolVersion ?? 0) < 14 + || (machine.registeredProjects?.count ?? 0) < 2) + } .opacity(model.connected ? 1 : 0.5).accessibilityElement(children: .combine).accessibilityAddTraits(.isButton) if !collapsed.contains(machineKey) { if !machine.online, let error = machine.error { diff --git a/apps/purepoint-mobile/PurePoint/ChatDomain.swift b/apps/purepoint-mobile/PurePoint/ChatDomain.swift index 18ebeffc..f7b279dd 100644 --- a/apps/purepoint-mobile/PurePoint/ChatDomain.swift +++ b/apps/purepoint-mobile/PurePoint/ChatDomain.swift @@ -428,6 +428,7 @@ enum MobileRPCTiming { case "workspace.rename": return 100_000_000_000 case "workspace.stop": return 100_000_000_000 case "workspace.removeProject": return 100_000_000_000 + case "workspace.reorderProjects": return 100_000_000_000 default: return 35_000_000_000 } } diff --git a/apps/purepoint-mobile/PurePointTests/AgentControlTests.swift b/apps/purepoint-mobile/PurePointTests/AgentControlTests.swift index e43d46f8..fc092b1a 100644 --- a/apps/purepoint-mobile/PurePointTests/AgentControlTests.swift +++ b/apps/purepoint-mobile/PurePointTests/AgentControlTests.swift @@ -19,6 +19,20 @@ final class AgentControlTests: XCTestCase { } } + @MainActor + func testGivenAProjectOrderShouldListThoseProjectsFirstAndKeepTheRest() throws { + let projects = try ["/a", "/b", "/c", "/d"].map { id in + try JSONDecoder().decode( + WorkspaceProject.self, from: Data(#"{"id":"\#(id)","name":"n","available":true,"error":null}"#.utf8)) + } + let ordered = AgentControlModel.ordered(projects, by: ["/c", "/gone", "/a"]) + XCTAssertEqual(ordered.map(\.id), ["/c", "/a", "/b", "/d"]) + let receipt = try JSONDecoder().decode( + WorkspaceOperation.self, + from: Data(#"{"operationId":"op-0001","state":"succeeded","detail":{"projectIds":["/c","/a"]}}"#.utf8)) + XCTAssertEqual(receipt.detail?.projectIds, ["/c", "/a"]) + } + func testGivenUnknownScreenAwarePhaseShouldBlockPromptWhileLegacyStaysUnconfirmed() throws { for phase in ["null", "\"unknown\""] { let wire = diff --git a/apps/purepoint-mobile/bridge/workspace.js b/apps/purepoint-mobile/bridge/workspace.js index 0dbd5d6d..d8856221 100644 --- a/apps/purepoint-mobile/bridge/workspace.js +++ b/apps/purepoint-mobile/bridge/workspace.js @@ -5,6 +5,7 @@ export const workspaceFeatures = Object.freeze({ inventory: true, management: true, operationStatus: true, + projectOrder: true, }); export const workspaceOperations = [ "workspace.catalog", @@ -15,6 +16,7 @@ export const workspaceOperations = [ "workspace.rename", "workspace.stop", "workspace.removeProject", + "workspace.reorderProjects", ]; const fail = (code, message) => { throw new Error(`workspace_${code}: ${message}`); @@ -116,6 +118,7 @@ const fields = { "workspace.rename": ["agentId", "name"], "workspace.stop": ["agentId", "confirm"], "workspace.removeProject": ["confirm"], + "workspace.reorderProjects": ["projectIds"], }; const fingerprint = (request) => JSON.stringify( @@ -177,6 +180,28 @@ export function workspaceConnection({ fail("target", "Registered available project required"); return { machine, metadata, project }; }; + // Project order is per machine: every listed root must be registered there. + const resolveOrder = async (r, signal, check) => { + if ( + !identifier(r.machine) || + !Array.isArray(r.projectIds) || + r.projectIds.length === 0 || + r.projectIds.length > 4096 || + r.projectIds.some((id) => !bounded(id, 4096)) || + new Set(r.projectIds).size !== r.projectIds.length + ) + fail( + "invalid", + "Exact machine and distinct registered projects required", + ); + const machine = await daemons.resolve(r.machine); + check(); + const metadata = await projectMetadata(machine, { signal }); + check(); + if (!r.projectIds.every((id) => metadata.projects.some((p) => p.id === id))) + fail("target", "Every project must be registered on this machine"); + return { machine, metadata, project: null }; + }; const catalog = async (machine, projectId, signal, check) => { const config = await daemonRequest( machine.socket, @@ -301,11 +326,9 @@ export function workspaceConnection({ if (abort.signal.aborted) fail("permission", "Operation fenced"); }; try { - const { machine, metadata, project } = await resolve( - r, - abort.signal, - check, - ); + const { machine, metadata, project } = await ( + r.op === "workspace.reorderProjects" ? resolveOrder : resolve + )(r, abort.signal, check); if (!mutation) return await catalog(machine, project.id, abort.signal, check); if (r.name !== undefined && !bounded(r.name, 80)) @@ -318,7 +341,24 @@ export function workspaceConnection({ ) fail("invalid", "Base requires a bounded git reference"); let ipc; - if (["workspace.rename", "workspace.stop"].includes(r.op)) { + if (r.op === "workspace.reorderProjects") { + // Health alone carries the version; an older daemon can't parse the request. + const health = await daemonRequest( + machine.socket, + { type: "health" }, + { signal: abort.signal }, + ); + check(); + if ( + !Number.isInteger(health.protocol_version) || + health.protocol_version < 14 + ) + fail( + "unsupported", + "Update the machine's daemon to reorder projects", + ); + ipc = { type: "reorder_projects", project_roots: r.projectIds }; + } else if (["workspace.rename", "workspace.stop"].includes(r.op)) { if (!identifier(r.agentId)) fail("invalid", "Exact agent required"); const { inventory } = await daemons.inspect(machine, { signal: abort.signal, @@ -476,6 +516,7 @@ export function workspaceConnection({ "workspace.rename": "rename_result", "workspace.stop": "kill_result", "workspace.removeProject": "project_removed", + "workspace.reorderProjects": "project_order", }[r.op]; if (reply.type !== expected) throw new Error("Unexpected daemon receipt"); entry.result = { @@ -494,6 +535,9 @@ export function workspaceConnection({ ].includes(r.op) ? { worktreeId: reply.worktree_id ?? null } : {}), + ...(r.op === "workspace.reorderProjects" + ? { projectIds: reply.project_roots } + : {}), }, }; } catch (error) { diff --git a/apps/purepoint-mobile/bridge/workspace.test.js b/apps/purepoint-mobile/bridge/workspace.test.js index 13d3adce..0c654b1f 100644 --- a/apps/purepoint-mobile/bridge/workspace.test.js +++ b/apps/purepoint-mobile/bridge/workspace.test.js @@ -149,6 +149,8 @@ async function fixture(t) { removed: true, suspended: [], }); + else if (r.type === "reorder_projects") + send({ type: "project_order", project_roots: r.project_roots }); } }); }); @@ -542,6 +544,48 @@ test("expanded presets bounded, validated rename and exact single-agent stop", a }); }); +test("project reorder forwards registered roots, rejects unknown or repeated ones and older daemons", async (t) => { + const f = await fixture(t), + c = f.connect(); + t.after(c.dispose); + const reorder = (operationId, projectIds) => + c.request({ + op: "workspace.reorderProjects", + operationId, + machine: "local", + projectIds, + }); + let r = await reorder("order-0001", ["/registered", "/registered"]); + assert.equal(r.state, "failed"); + r = await reorder("order-0002", ["/registered", "/elsewhere"]); + assert.equal(r.state, "failed"); + r = await reorder("order-0003", []); + assert.equal(r.state, "failed"); + assert.equal( + f.requests.filter((q) => q.type === "reorder_projects").length, + 0, + ); + // A daemon older than protocol 14 can't parse the request: fail before dispatch. + r = await reorder("order-0005", ["/registered"]); + assert.equal(r.state, "failed"); + assert.match(r.message, /Update the machine's daemon/); + f.protocolVersion(14); + r = await reorder("order-0004", ["/registered"]); + assert.equal(r.state, "succeeded"); + assert.deepEqual(r.detail.projectIds, ["/registered"]); + assert.deepEqual(f.requests.at(-1), { + type: "reorder_projects", + project_roots: ["/registered"], + }); + // A replay with the same operation never dispatches twice. + r = await reorder("order-0004", ["/registered"]); + assert.equal(r.state, "succeeded"); + assert.equal( + f.requests.filter((q) => q.type === "reorder_projects").length, + 1, + ); +}); + test("spawn instructions are optional; blank launches bare and plan mode needs instructions or a preset", async (t) => { const f = await fixture(t), c = f.connect(); diff --git a/apps/purepoint-mobile/docs/contract.md b/apps/purepoint-mobile/docs/contract.md index b03ea107..2416b7e7 100644 --- a/apps/purepoint-mobile/docs/contract.md +++ b/apps/purepoint-mobile/docs/contract.md @@ -196,7 +196,7 @@ Events are `{type:terminal.output,streamId,data}` (UTF-8 ANSI), `{type:terminal. Additive to `apps/purepoint-mobile/docs/contract.md`. All requests use existing authenticated WSS envelope `{version:1,id,clientId,op,...}` and receipt `{type:"receipt",id,ok:true,data}` or `{type:"receipt",id,ok:false,error:}`. Request id remains transport correlation; `operationId` below is durable-form identity. No Pi readiness dependency. -Permissions discovery (`permissions` result and `permissions` event): `{terminals:"off"|"view"|"control",workspaceManagement:false,chat:"off"|"read"|"send" (derived),workspace:{version:1,inventory:true,management:true,operationStatus:true}}`. Missing workspace means unsupported; missing grant means false. All mutations require terminals control AND workspaceManagement true; all fleet reads require terminals view/control. Local owner admin `devices.workspaceManagement {deviceId,workspaceManagement:}`. Existing grants default false. Permission loss fences dispatch and late responses; cancellation cannot roll back committed work. +Permissions discovery (`permissions` result and `permissions` event): `{terminals:"off"|"view"|"control",workspaceManagement:false,chat:"off"|"read"|"send" (derived),workspace:{version:1,inventory:true,management:true,operationStatus:true,projectOrder:true}}`. Missing workspace means unsupported; missing projectOrder means `workspace.reorderProjects` is unsupported; missing grant means false. All mutations require terminals control AND workspaceManagement true; all fleet reads require terminals view/control. Local owner admin `devices.workspaceManagement {deviceId,workspaceManagement:}`. Existing grants default false. Permission loss fences dispatch and late responses; cancellation cannot roll back committed work. `agents` retains machines and agent fields, adds each machine `metadataStatus:"complete"|"partial"|"offline"|"error"`, `metadataError:string|null`, `projects:[{id:,name,available,error,counts:}]`, `worktrees:[{projectId,id,name,path,branch,status,agentIds:[string]}]`. Agent projectRoot is project ID; worktreeId null means root only when project metadata confirms association. Machine id scopes every project/worktree/agent. Off returns no metadata (request denied). Projects and worktrees queried separately; unsupported metadata produces partial with usable agents. No projects inferred from agents. Machine offline differs from incomplete metadata. Queries bounded to 32 machines, 4096 entries per kind, 5s each, four machines concurrently. @@ -210,6 +210,7 @@ Management fields are strict allowlists. Common `{operationId:<8..128 ASCII alnu - `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 `{}`. +- `workspace.reorderProjects`: `{operationId,machine,projectIds:string[]}` with no `projectId`. 1..4096 distinct project IDs, each registered on that machine, in the order the machine's daemon saves (`reorder_projects`). Registered projects not listed keep their order after the listed ones. The saved order is what every client lists. Requires the machine's daemon at protocol 14 or later: an older one gives the mutation result `state:"failed"` with an update-the-daemon message, and the phone disables the action below 14. Result detail `{projectIds}`, the machine's full new order. 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. @@ -237,6 +238,7 @@ Inventory timing budget: at most32 machines in sequential batches of4; each mach | workspace.spawn (root/existing/new worktree) | metadata5s + catalog/presets10s |360s |390s | | workspace.terminal | metadata5s + catalog/presets10s |65s |100s | | workspace.rename / workspace.stop | metadata5s + health/agents10s |65s |100s | +| workspace.reorderProjects | metadata5s |65s |100s | | workspace.createWorktree | metadata5s |65s |100s | | workspace.catalog / workspace.operation | metadata/catalog as applicable; status is local | existing read caps |35s | diff --git a/apps/purepoint-mobile/verification/check-rpc-budgets.py b/apps/purepoint-mobile/verification/check-rpc-budgets.py index 020ab46c..23e9518b 100644 --- a/apps/purepoint-mobile/verification/check-rpc-budgets.py +++ b/apps/purepoint-mobile/verification/check-rpc-budgets.py @@ -47,6 +47,7 @@ "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), + "workspace.reorderProjects": (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/output/mod.rs b/crates/pu-cli/src/output/mod.rs index d3f9a291..58b8c6a3 100644 --- a/crates/pu-cli/src/output/mod.rs +++ b/crates/pu-cli/src/output/mod.rs @@ -95,6 +95,11 @@ pub fn print_response(response: &Response, json_mode: bool) -> Result<(), CliErr Response::ChannelHistory { .. } | Response::ChannelMessage { .. } => { print!("{}", crate::commands::channel::format_response(response)); } + Response::ProjectOrder { project_roots } => { + for root in project_roots { + println!("{root}"); + } + } Response::HealthReport { pid, uptime_seconds, diff --git a/crates/pu-core/src/protocol/mod.rs b/crates/pu-core/src/protocol/mod.rs index 48e28f8b..215d963e 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 = 13; +pub const PROTOCOL_VERSION: u32 = 14; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] @@ -89,6 +89,11 @@ pub enum Request { #[serde(default)] project_root: Option, }, + /// Set the registered projects' order, which every client shows. Registered + /// roots left out follow in their current order; unknown roots are ignored. + ReorderProjects { + project_roots: Vec, + }, Health, Init { project_root: String, @@ -509,6 +514,10 @@ pub enum Response { project_root: Option, worktree_id: Option, }, + /// The registered projects in their new order. + ProjectOrder { + project_roots: Vec, + }, HealthReport { pid: u32, uptime_seconds: u64, diff --git a/crates/pu-core/src/protocol/tests.rs b/crates/pu-core/src/protocol/tests.rs index f3f0a9d2..aaf40993 100644 --- a/crates/pu-core/src/protocol/tests.rs +++ b/crates/pu-core/src/protocol/tests.rs @@ -362,8 +362,40 @@ fn given_empty_diff_result_should_round_trip() { } } +#[test] +fn given_reorder_projects_request_should_round_trip() { + let req = Request::ReorderProjects { + project_roots: vec!["/b".into(), "/a".into()], + }; + let json = serde_json::to_string(&req).unwrap(); + assert_eq!( + json, + r#"{"type":"reorder_projects","project_roots":["/b","/a"]}"# + ); + match serde_json::from_str(&json).unwrap() { + Request::ReorderProjects { project_roots } => assert_eq!(project_roots, ["/b", "/a"]), + other => panic!("expected ReorderProjects, got {other:?}"), + } +} + // --- Response round-trips --- +#[test] +fn given_project_order_should_round_trip() { + let resp = Response::ProjectOrder { + project_roots: vec!["/b".into(), "/a".into()], + }; + let json = serde_json::to_string(&resp).unwrap(); + assert_eq!( + json, + r#"{"type":"project_order","project_roots":["/b","/a"]}"# + ); + match serde_json::from_str(&json).unwrap() { + Response::ProjectOrder { project_roots } => assert_eq!(project_roots, ["/b", "/a"]), + other => panic!("expected ProjectOrder, got {other:?}"), + } +} + #[test] fn given_health_report_should_round_trip() { let resp = Response::HealthReport { @@ -521,7 +553,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, 13); + assert_eq!(PROTOCOL_VERSION, 14); } // --- GridCommand round-trips --- diff --git a/crates/pu-engine/src/engine/inventory.rs b/crates/pu-engine/src/engine/inventory.rs index e2c52720..f711e93d 100644 --- a/crates/pu-engine/src/engine/inventory.rs +++ b/crates/pu-engine/src/engine/inventory.rs @@ -38,7 +38,14 @@ impl Engine { tracing::warn!("failed to rewrite project registry: {e}"); } engine.project_registry_path = Some(path); - *engine.registered_projects.lock().unwrap() = projects.into_iter().collect(); + // Keep the stored order; drop repeats an older daemon may have written. + let mut ordered = Vec::with_capacity(projects.len()); + for root in projects { + if !ordered.contains(&root) { + ordered.push(root); + } + } + *engine.registered_projects.lock().unwrap() = ordered; // 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); @@ -125,17 +132,16 @@ impl Engine { if current.contains(&root) { return Ok(false); } - current.iter().cloned().collect() + current.clone() }; - next.push(root.clone()); - next.sort(); + // New projects go last; the registry order is the order clients show. + next.push(root); if let Some(path) = registry { write_registry(&path, &next)?; } - projects + *projects .lock() - .map_err(|_| std::io::Error::other("project registry lock poisoned"))? - .insert(root); + .map_err(|_| std::io::Error::other("project registry lock poisoned"))? = next; Ok(true) }) .await @@ -147,6 +153,61 @@ impl Engine { Ok(()) } + /// Moves the given registered roots to the front, in the given order. Registered + /// roots left out keep their relative order after them and unknown roots are + /// ignored, so a client's stale list can reorder but never drop a project. + pub(super) async fn reorder_projects( + &self, + roots: Vec, + ) -> Result, Response> { + let update = self.project_registry_updates.clone().lock_owned().await; + let projects = self.registered_projects.clone(); + let registry = self.project_registry_path.clone(); + let (next, changed) = + tokio::task::spawn_blocking(move || -> std::io::Result<(Vec, bool)> { + let _update = update; + let current = projects + .lock() + .map_err(|_| std::io::Error::other("project registry lock poisoned"))? + .clone(); + let mut next = Vec::with_capacity(current.len()); + for root in roots { + // Accept any spelling of a registered root, as registration does. + let root = match Path::new(&root).canonicalize() { + Ok(path) => path.to_string_lossy().into_owned(), + Err(_) => root, + }; + if current.contains(&root) && !next.contains(&root) { + next.push(root); + } + } + for root in ¤t { + if !next.contains(root) { + next.push(root.clone()); + } + } + let changed = next != current; + if changed { + if let Some(path) = registry { + write_registry(&path, &next)?; + } + *projects + .lock() + .map_err(|_| std::io::Error::other("project registry lock poisoned"))? = + next.clone(); + } + Ok((next, changed)) + }) + .await + .map_err(|e| Self::inventory_error("IO_ERROR", e.to_string()))? + .map_err(|e| Self::inventory_error("IO_ERROR", e.to_string()))?; + // Clients following the project stream pick up the new order. + if changed { + self.notify_projects_changed(); + } + Ok(next) + } + /// Removes registered roots and returns the ones that were registered. async fn unregister_projects( &self, @@ -167,22 +228,20 @@ impl Engine { .iter() .cloned() .collect(); - let (removed, mut next): (Vec<_>, Vec<_>) = current.into_iter().partition(|root| { + let (removed, 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(); + // `partition` keeps the remaining projects in their order. if let Some(path) = registry { write_registry(&path, &next)?; } - let mut current = projects + projects .lock() - .map_err(|_| std::io::Error::other("project registry lock poisoned"))?; - for root in &removed { - current.remove(root); - } + .map_err(|_| std::io::Error::other("project registry lock poisoned"))? + .retain(|root| !removed.contains(root)); Ok(removed) }) .await @@ -356,7 +415,7 @@ impl Engine { }; // Read independently so one slow project cannot block the whole query. let mut reads = tokio::task::JoinSet::new(); - for root in roots { + for root in roots.iter().cloned() { reads.spawn(async move { let path = PathBuf::from(&root); let read = tokio::task::spawn_blocking(move || manifest::read_manifest(&path)); @@ -375,7 +434,8 @@ impl Engine { Err(_) => report.complete = false, } } - manifests.sort_by(|a, b| a.0.cmp(&b.0)); + // Reads finish in any order; report projects in registry order. + manifests.sort_by_key(|(root, _)| roots.iter().position(|r| r == root)); // 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() @@ -822,11 +882,83 @@ mod tests { let first = tmp.path().join("first"); let second = tmp.path().join("second"); let (first, second) = tokio::join!(init(&engine, &first), init(&engine, &second)); + // Concurrent registrations land in completion order. + let mut registered = Engine::with_project_registry(path) + .unwrap() + .registered_projects(); + registered.sort(); + assert_eq!(registered, vec![first, second]); + } + + async fn reorder(engine: &Engine, roots: &[&str]) -> Vec { + match engine + .handle_request(Request::ReorderProjects { + project_roots: roots.iter().map(|r| r.to_string()).collect(), + }) + .await + { + Response::ProjectOrder { project_roots } => project_roots, + other => panic!("unexpected response: {other:?}"), + } + } + + #[tokio::test] + async fn given_registration_should_keep_insertion_order_not_path_order() { + let tmp = tempfile::TempDir::new().unwrap(); + let engine = Engine::with_project_registry(tmp.path().join("registry.json")).unwrap(); + let zeta = init(&engine, &tmp.path().join("zeta")).await; + let alpha = init(&engine, &tmp.path().join("alpha")).await; assert_eq!( - Engine::with_project_registry(path) - .unwrap() - .registered_projects(), - vec![first, second] + engine.registered_projects(), + vec![zeta.clone(), alpha.clone()] + ); + let names: Vec<_> = inventory(&engine, InventoryKind::Projects) + .await + .projects + .into_iter() + .map(|p| p.project_root) + .collect(); + assert_eq!(names, vec![zeta, alpha]); + } + + #[tokio::test] + async fn given_reorder_should_persist_the_order_across_restart() { + let tmp = tempfile::TempDir::new().unwrap(); + let path = tmp.path().join("registry.json"); + let engine = Engine::with_project_registry(path.clone()).unwrap(); + let a = init(&engine, &tmp.path().join("a")).await; + let b = init(&engine, &tmp.path().join("b")).await; + let c = init(&engine, &tmp.path().join("c")).await; + let order = reorder(&engine, &[&c, &a, &b]).await; + assert_eq!(order, vec![c.clone(), a.clone(), b.clone()]); + assert_eq!(engine.registered_projects(), order); + drop(engine); + let engine = Engine::with_project_registry(path).unwrap(); + assert_eq!(engine.registered_projects(), vec![c, a, b]); + } + + #[tokio::test] + async fn given_partial_or_unknown_reorder_should_keep_every_registered_project() { + let tmp = tempfile::TempDir::new().unwrap(); + let engine = Engine::with_project_registry(tmp.path().join("registry.json")).unwrap(); + let a = init(&engine, &tmp.path().join("a")).await; + let b = init(&engine, &tmp.path().join("b")).await; + let c = init(&engine, &tmp.path().join("c")).await; + let alias = tmp.path().join("alias"); + std::os::unix::fs::symlink(&c, &alias).unwrap(); + let order = reorder(&engine, &["/nowhere", alias.to_str().unwrap(), &c, &b]).await; + assert_eq!(order, vec![c, b, a]); + } + + #[tokio::test] + async fn given_an_existing_registry_file_should_load_in_its_stored_order() { + let tmp = tempfile::TempDir::new().unwrap(); + let path = tmp.path().join("registry.json"); + std::fs::write(&path, br#"["/b/project", "/a/project", "/b/project"]"#).unwrap(); + let engine = Engine::with_project_registry(path).unwrap(); + assert_eq!( + engine.registered_projects(), + vec!["/b/project", "/a/project"] ); } diff --git a/crates/pu-engine/src/engine/mod.rs b/crates/pu-engine/src/engine/mod.rs index 884997cb..80c74bc5 100644 --- a/crates/pu-engine/src/engine/mod.rs +++ b/crates/pu-engine/src/engine/mod.rs @@ -89,9 +89,10 @@ pub struct Engine { grid_channels: Arc>>>, /// Per-project broadcast channels for status push updates. status_channels: Arc>>>, - /// 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 added with `Init` and not removed since — scheduler scans these — + /// in the order clients show them. + registered_projects: Arc>>, + /// Signalled whenever a project is added, removed or reordered in 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, @@ -207,7 +208,7 @@ impl Engine { reaped_projects: Arc::new(std::sync::Mutex::new(HashSet::new())), 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())), + registered_projects: Arc::new(std::sync::Mutex::new(Vec::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())), @@ -302,11 +303,7 @@ impl Engine { pub fn registered_projects(&self) -> Vec { self.registered_projects .lock() - .map(|p| { - let mut roots: Vec<_> = p.iter().cloned().collect(); - roots.sort(); - roots - }) + .map(|p| p.clone()) .unwrap_or_default() } @@ -414,6 +411,12 @@ impl Engine { kind, state, } => Response::Inventory(self.inventory(project_root, kind, state).await), + Request::ReorderProjects { project_roots } => { + match self.reorder_projects(project_roots).await { + Ok(project_roots) => Response::ProjectOrder { project_roots }, + Err(response) => response, + } + } Request::ResolveAgent { agent_id, project_root, diff --git a/docs/product/daemon.md b/docs/product/daemon.md index a89e6f7b..04e9a6f9 100644 --- a/docs/product/daemon.md +++ b/docs/product/daemon.md @@ -1,6 +1,6 @@ # Daemon -**Maturity: EXPLORING** | ID Prefix: DMN | Dependencies: `architecture/daemon-engine.md`, `architecture/ipc-api.md` +**Maturity: CONVERGING** | ID Prefix: DMN | Dependencies: `architecture/daemon-engine.md`, `architecture/ipc-api.md` ## Purpose @@ -27,12 +27,62 @@ Daemon (pu-engine binary) **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. +**Project order (implemented 2026-10-22, protocol 14):** The registry is an ordered list and the single source of truth for a machine's project order. New projects are appended; removing one keeps the rest in order. `Request::ReorderProjects { project_roots }` (protocol 14) moves the listed roots to the front and keeps the rest in their current order. Inventory, Health and the `SubscribeProjects` stream report roots in registry order, and a reorder is pushed to every subscriber. The Mac sidebar (local and machine sections), the iPad and, through the bridge's `workspace.reorderProjects`, the phone all show this order and save reorders through it. + **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`. ## Open Questions ? [DMN-001] Should the daemon support multiple concurrent projects, or one daemon instance per project? -(Research note above shows current implementation is single-daemon multi-project, but the tradeoffs haven't been fully evaluated.) +(Narrowed to two options below; the current implementation is Option A.) ? [DMN-002] How should the daemon handle version mismatches between CLI and daemon (e.g., after an update)? -(Protocol version is in HealthReport but no mismatch handling is implemented yet.) +(Narrowed to two options below. Clients now detect a mismatch and refuse to proceed, which is Option A.) + +## Research Notes: Open Questions + +#### [DMN-001] One daemon for all projects, or one per project? +**Researched: 2026-10-22** + +The constraints come from what already ships: +- One daemon per socket, enforced by an exclusive `flock` on `daemon.sock.lock` (`architecture/daemon-engine.md`, Design Directions). +- Each socket has its own durable project registry (`.projects.json`, `pu-core/src/paths.rs`), which now also holds the project order. +- The global inventory Point Guard relies on was designed as a daemon-owned registry. DAEMON-002 chose this over clients aggregating per-project queries (`architecture/daemon-engine.md`, Research Notes). +- A machine is reached through one forwarded daemon socket (`product/machines.md`), and the phone bridge queries one socket per machine (`apps/purepoint-mobile/bridge/daemon.js`). + +Option A: one daemon per socket serving every project, scoped by `project_root` on each request (current). +- Pro: one process to start, probe and upgrade per machine. Global inventory, agent ownership resolution and project order are single queries against one registry. +- Pro: remote machines, the phone bridge and the Mac sidebar all address a machine as one endpoint. +- Pro: isolation is still available when it's wanted: a separate `--socket` gets its own daemon, lock and registry. +- Con: a daemon crash or restart affects every project on that socket. Crash recovery (DAEMON-003) therefore matters more. +- Con: one event loop and one connection limit (`BUSY`) are shared by all projects. + +Option B: one daemon instance per project. +- Pro: a fault or restart in one project's daemon can't touch another project's agents. +- Pro: each project could upgrade independently. +- Con: global views (Point Guard inventory, `pu projects`, the phone, a machine's project list and its order) need discovery and aggregation across N sockets. DAEMON-002 rejected that as Option A: observations at different times, ownership routing left to callers. +- Con: N processes, PID files and auto-starts per machine, and N sockets to forward for each remote machine. + +Recommendation: Option A. Every cross-project feature built so far depends on one registry per machine. Per-project isolation is better served by Option A's separate sockets plus a stronger DAEMON-003 crash-recovery model than by splitting the daemon. + +#### [DMN-002] Handling CLI/app and daemon version mismatches +**Researched: 2026-10-22** + +What happens today: +- `Request::Health` reports `protocol_version`. +- The CLI probes health before every command. On a mismatch it refuses with a message to update `pu` and `pu-engine` together, and it never restarts a live daemon, which would kill its agents (`pu-cli/src/daemon_ctrl.rs`, `check_daemon_health`). +- A remote machine on another version is reported with "update PurePoint on both machines" (`ensure_remote_daemon`). +- The Mac app marks such a machine "Incompatible (protocol N)" and sends it no further requests (`MachineState.swift`, `MachineConnection.swift`). +- The phone bridge reports each machine's `protocolVersion` (`bridge/daemon.js`). +- Compatibility is strict equality on `PROTOCOL_VERSION`. + +Option A: strict equality; refuse and explain, never auto-restart (current). +- Pro: simple. A client never sends a request shape the daemon can't parse, and agents are never killed by an automatic restart. +- Con: every protocol bump, including purely additive ones such as protocol 14's `reorder_projects`, forces every machine and client to upgrade in lockstep. Until then, remote machines show as incompatible. + +Option B: compatibility ranges or capability flags. The daemon advertises a minimum supported version or a feature list, and clients hide features the daemon lacks. The phone bridge already does this with its `workspace` feature flags (`projectOrder`). +- Pro: additive operations stop breaking mixed-version fleets. An older remote machine stays usable without the new features. +- Con: clients need a test matrix across supported versions. Breaking shape changes still need a hard bump, and someone has to decide which changes are additive. + +Recommendation: Option A until more than one machine per user is common; then Option B, with the bridge's feature flags as the pattern. + diff --git a/docs/reference/ipc-api.md b/docs/reference/ipc-api.md index e5fe51d2..09c092e1 100644 --- a/docs/reference/ipc-api.md +++ b/docs/reference/ipc-api.md @@ -77,6 +77,18 @@ ResolveAgent returns `type: agent_resolved`, agent_id, project_root and worktree 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. +The registry is ordered, and that order is the one every client shows for the machine. New projects are appended; `inventory` (projects) and Health list roots in registry order. Registries written by older daemons, which kept roots sorted by path, load in that order. + +### Reorder projects + +Set the order of the registered projects (protocol 14). A change is pushed to `subscribe_projects` streams. + +**Request**: `{"type": "reorder_projects", "project_roots": ["/b", "/a"]}` + +**Response**: `{"type": "project_order", "project_roots": ["/b", "/a", "/c"]}` + +The listed roots move to the front in the given order. Registered roots not listed follow in their current order, and unknown roots are ignored, so a client with a stale list can reorder projects but never drop one. Roots are canonicalized like registration. The new order is written atomically to the registry before the reply. + ### Health Check daemon liveness and protocol version. @@ -89,7 +101,7 @@ Check daemon liveness and protocol version. "type": "health_report", "pid": 12345, "uptime_seconds": 3600, - "protocol_version": 13, + "protocol_version": 14, "projects": ["/path/to/project"], "agent_count": 3 } @@ -406,7 +418,7 @@ 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. +Follow the registered projects. The full list of roots, in registry order, is sent once on subscribing and again after every `init` that adds a project, every removal and every `reorder_projects` that changes the order, so every client shows the same projects without polling. Any request line ends the stream. **Request**: `{"type": "subscribe_projects"}` diff --git a/docs/spec-system.md b/docs/spec-system.md index 608793d2..e63774f2 100644 --- a/docs/spec-system.md +++ b/docs/spec-system.md @@ -60,7 +60,7 @@ What PurePoint does. Each domain captures behavior, requirements, and interfaces |---|---|---| | Data Model | DM | EXPLORING | | CLI | CLI | DECIDED | -| Daemon | DMN | EXPLORING | +| Daemon | DMN | CONVERGING | | Agent Lifecycle | AL | CONVERGING | | Worktree Management | WT | SEED | | Orchestration | ORCH | SEED |