From 0a21329ada7e35f7d2e1e6835b0434a2c404c546 Mon Sep 17 00:00:00 2001 From: Jono Minh <2witstudios@gmail.com> Date: Thu, 22 Oct 2026 00:17:29 -0500 Subject: [PATCH 1/5] feat(projects)!: one project order per machine, kept by the daemon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Projects showed up in different orders on each device and couldn't be reordered. The daemon kept registered projects in a HashSet and listed them sorted by path, while the Mac's local sidebar used its own per-device open order. The daemon registry is now an ordered list and the single source of truth for a machine's project order. New projects are appended, and a new reorder_projects op (protocol 13) saves an order: listed roots move to the front, unlisted roots keep their order after them, and unknown roots are ignored. - Mac: drag project rows within their section; Move Up/Down in the project menu. The local section follows the local daemon's order. - iPad: Move Up/Down in the project menu. - iPhone: "Reorder Projects…" on a machine, through the bridge's new workspace.reorderProjects operation. BREAKING CHANGE: protocol version 13; update pu, pu-engine and the apps together on every machine. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NekGvm22FjcPtWx2YR4VLA --- .../purepoint-macos/Models/ProjectOrder.swift | 52 ++++++ .../Services/DaemonProtocol.swift | 19 ++- .../purepoint-macos/State/AppState.swift | 55 +++++++ .../purepoint-macos/State/MachineState.swift | 18 ++- .../Views/Sidebar/SidebarMenuModel.swift | 9 ++ .../Views/Sidebar/SidebarOutlineModel.swift | 26 +++ ...SidebarOutlineViewController+Actions.swift | 5 + ...barOutlineViewController+ProjectDrag.swift | 54 +++++++ .../SidebarOutlineViewController.swift | 1 + .../purepoint-macos/purepoint_macosApp.swift | 1 + .../purepoint-macosTests/MachinesTests.swift | 17 +- .../ProjectOrderTests.swift | 41 +++++ .../SidebarMenuModelTests.swift | 22 +++ .../PurePoint/AgentControlModel.swift | 21 ++- .../PurePoint/AgentsView.swift | 48 +++++- apps/purepoint-mobile/bridge/workspace.js | 41 ++++- .../purepoint-mobile/bridge/workspace.test.js | 39 +++++ apps/purepoint-mobile/docs/contract.md | 4 +- crates/pu-cli/src/output/mod.rs | 5 + crates/pu-core/src/protocol/mod.rs | 11 +- crates/pu-core/src/protocol/tests.rs | 34 +++- crates/pu-engine/src/engine/inventory.rs | 153 ++++++++++++++++-- crates/pu-engine/src/engine/mod.rs | 17 +- docs/product/daemon.md | 2 + docs/reference/ipc-api.md | 14 +- 25 files changed, 674 insertions(+), 35 deletions(-) create mode 100644 apps/purepoint-macos/purepoint-macos/Models/ProjectOrder.swift create mode 100644 apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController+ProjectDrag.swift create mode 100644 apps/purepoint-macos/purepoint-macosTests/ProjectOrderTests.swift 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..e21a07c1 --- /dev/null +++ b/apps/purepoint-macos/purepoint-macos/Models/ProjectOrder.swift @@ -0,0 +1,52 @@ +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. Roots compare by their resolved path, since the daemon + /// reports canonical roots and a project may have been opened through a symlink. + 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[canonical(root)] == nil { + rank[canonical(root)] = index + } + return items.enumerated().sorted { left, right in + let l = rank[canonical(rootOf(left.element))] ?? Int.max + let r = rank[canonical(rootOf(right.element))] ?? Int.max + return l != r ? l < r : left.offset < right.offset + }.map(\.element) + } + + /// The path with symlinks resolved, as the daemon stores it. + static func canonical(_ path: String) -> String { + // `realpath`, not `resolvingSymlinksInPath`: Foundation strips `/private`, the daemon doesn't. + guard let resolved = realpath(path, nil) else { return path } + defer { free(resolved) } + return String(cString: resolved) + } +} + +@MainActor +extension ProjectOrder { + static func moving(_ root: String, to index: Int, in projects: [ProjectState]) -> [ProjectState]? { + moving(root, to: index, in: projects, root: { $0.projectRoot }) + } + + static func sorted(_ projects: [ProjectState], by roots: [String]) -> [ProjectState] { + sorted(projects, by: roots, root: { $0.projectRoot }) + } +} diff --git a/apps/purepoint-macos/purepoint-macos/Services/DaemonProtocol.swift b/apps/purepoint-macos/purepoint-macos/Services/DaemonProtocol.swift index f2000c24..441a5173 100644 --- a/apps/purepoint-macos/purepoint-macos/Services/DaemonProtocol.swift +++ b/apps/purepoint-macos/purepoint-macos/Services/DaemonProtocol.swift @@ -357,6 +357,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 13). + 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, @@ -610,6 +612,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")) @@ -697,6 +702,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) @@ -815,6 +822,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) @@ -1212,7 +1221,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 { @@ -1246,6 +1255,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 65916026..8ac189bc 100644 --- a/apps/purepoint-macos/purepoint-macos/State/AppState.swift +++ b/apps/purepoint-macos/purepoint-macos/State/AppState.swift @@ -112,6 +112,9 @@ final class AppState { @ObservationIgnored private var binaryWatcher: ManifestWatcher? #endif @ObservationIgnored private var machineRegistryWatcher: MachineRegistryWatcher? + #if os(macOS) + @ObservationIgnored private var localProjectOrderTask: Task? + #endif private static let openProjectsKey = "PurePointOpenProjects" @@ -293,6 +296,58 @@ 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 machineId = project.key.machineId { + machines.first { $0.machine.id == machineId }?.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 + persistOpenProjects() + let roots = next.map(\.projectRoot) + Task { [weak self] in + let response = try? await DaemonEndpoint.local.makeClient().send(.reorderProjects(projectRoots: roots)) + if case .projectOrder(let saved) = response { self?.applyProjectOrder(saved) } + } + #endif + } + + /// Sort the local projects into the local daemon's saved order. Projects it doesn't list + /// keep their relative order after the ones it does. + func applyProjectOrder(_ roots: [String]) { + let next = ProjectOrder.sorted(projects, by: roots) + guard next.map(\.projectRoot) != projects.map(\.projectRoot) else { return } + projects = next + persistOpenProjects() + } + + #if os(macOS) + /// Follow the local daemon's project order, which other devices can change. + func startLocalProjectOrderRefresh() { + guard localProjectOrderTask == nil else { return } + localProjectOrderTask = Task { [weak self] in + while !Task.isCancelled { + let response = try? await DaemonEndpoint.local.makeClient().send(.inventory(kind: "projects")) + if case .inventory(let report) = response { + self?.applyProjectOrder(report.projects.map(\.projectRoot)) + } + do { try await Task.sleep(for: MachineState.refreshInterval) } catch { return } + } + } + } + #endif + func restoreProjects(projectRoot: String? = nil) { let paths = projectRoot.map { [$0] } ?? UserDefaults.standard.stringArray(forKey: Self.openProjectsKey) ?? [] diff --git a/apps/purepoint-macos/purepoint-macos/State/MachineState.swift b/apps/purepoint-macos/purepoint-macos/State/MachineState.swift index ae985186..66b5619a 100644 --- a/apps/purepoint-macos/purepoint-macos/State/MachineState.swift +++ b/apps/purepoint-macos/purepoint-macos/State/MachineState.swift @@ -47,7 +47,7 @@ final class MachineState: Identifiable { @ObservationIgnored private var isUserDisconnecting = false /// How often the project list is re-read while the daemon is up. - private static let refreshInterval: Duration = .seconds(30) + static let refreshInterval: Duration = .seconds(30) var endpoint: DaemonEndpoint { .machine(machine.id) } var isConnectedOrConnecting: Bool { state != .disconnected } @@ -379,6 +379,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 + let roots = next.map(\.projectRoot) + let client = endpoint.makeClient() + Task { [weak self] in + let response = try? await client.send(.reorderProjects(projectRoots: roots)) + guard let self else { return } + if case .projectOrder = response { return } + 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 021bc323..ac4489d1 100644 --- a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarMenuModel.swift +++ b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarMenuModel.swift @@ -20,6 +20,8 @@ enum SidebarMenuAction: Equatable { case killAllProjectAgents case retryLayout case removeProject + case moveProjectUp + case moveProjectDown case newAgentInWorktree case newTerminalInWorktree } @@ -125,6 +127,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) + } entries.append(.action("Remove Project\u{2026}", .removeProject)) return entries } 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 54321ba5..359776fb 100644 --- a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController+Actions.swift +++ b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController+Actions.swift @@ -45,6 +45,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 7d12abab..285752c1 100644 --- a/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController.swift +++ b/apps/purepoint-macos/purepoint-macos/Views/Sidebar/SidebarOutlineViewController.swift @@ -100,6 +100,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-macos/purepoint_macosApp.swift b/apps/purepoint-macos/purepoint-macos/purepoint_macosApp.swift index 22e92f0b..d76ab731 100644 --- a/apps/purepoint-macos/purepoint-macos/purepoint_macosApp.swift +++ b/apps/purepoint-macos/purepoint-macos/purepoint_macosApp.swift @@ -46,6 +46,7 @@ struct purepoint_macosApp: App { hotkeyMonitor.start() CLIInstaller.installIfNeeded() openProjectFromArguments() + appState.startLocalProjectOrderRefresh() appState.loadMachines() appState.pointGuardService.start(chat: appState.pointGuardChat) applicationDelegate.pointGuardService = appState.pointGuardService diff --git a/apps/purepoint-macos/purepoint-macosTests/MachinesTests.swift b/apps/purepoint-macos/purepoint-macosTests/MachinesTests.swift index 6eb313c4..d6e0a038 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 == 12) + #expect(DaemonProtocol.version == 13) + } + + @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 givenInventoryShouldAskForEveryRegisteredProject() 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..4484687d --- /dev/null +++ b/apps/purepoint-macos/purepoint-macosTests/ProjectOrderTests.swift @@ -0,0 +1,41 @@ +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"]) + } + + @Test func givenASymlinkedRootShouldMatchTheDaemonsCanonicalRoot() throws { + let base = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: base) } + let real = base.appendingPathComponent("real") + let link = base.appendingPathComponent("link") + try FileManager.default.createDirectory(at: real, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: real) + let other = base.appendingPathComponent("other").path + let sorted = ProjectOrder.sorted([other, link.path], by: [ProjectOrder.canonical(real.path)], root: { $0 }) + #expect(sorted == [link.path, other]) + } +} diff --git a/apps/purepoint-macos/purepoint-macosTests/SidebarMenuModelTests.swift b/apps/purepoint-macos/purepoint-macosTests/SidebarMenuModelTests.swift index ffb2e6cb..c00d9ecf 100644 --- a/apps/purepoint-macos/purepoint-macosTests/SidebarMenuModelTests.swift +++ b/apps/purepoint-macos/purepoint-macosTests/SidebarMenuModelTests.swift @@ -78,6 +78,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/AgentControlModel.swift b/apps/purepoint-mobile/PurePoint/AgentControlModel.swift index fd2c3fdf..a129d8cd 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 @@ -215,7 +217,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 +241,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 +255,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 +274,21 @@ 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.") } + if let index = machines.firstIndex(where: { $0.id == machine }), let projects = machines[index].registeredProjects { + machines[index].registeredProjects = projectIds.compactMap { id in projects.first { $0.id == id } } + + projects.filter { !projectIds.contains($0.id) } + } + 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.") + } + } func refreshWorkspaceForReview(machine: String, project: String) async -> Bool { let token = generation, revision = permissionRevision guard canManage, await refresh(), token == generation, revision == permissionRevision, canManage, diff --git a/apps/purepoint-mobile/PurePoint/AgentsView.swift b/apps/purepoint-mobile/PurePoint/AgentsView.swift index b5df13d6..b3f803d3 100644 --- a/apps/purepoint-mobile/PurePoint/AgentsView.swift +++ b/apps/purepoint-mobile/PurePoint/AgentsView.swift @@ -508,6 +508,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 { @@ -523,6 +563,7 @@ struct HomeView: View { @State private var creatingWorktree: WorkspaceTarget? @State private var renaming: AgentShortcut? @State private var stopping: AgentShortcut? + @State private var reordering: AgentMachine? var body: some View { ScrollView { @@ -551,7 +592,8 @@ 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 } + .sheet(item: $reordering) { machine in ProjectOrderSheet(model: model, actions: actions, machine: machine) } + .onChange(of: model.bridgeIdentity) { _, _ in collapsed = []; launchTarget = nil; renaming = nil; stopping = nil; reordering = nil } } // MARK: Rows @@ -614,6 +656,10 @@ 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.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/bridge/workspace.js b/apps/purepoint-mobile/bridge/workspace.js index 4c4eb6b0..dc8d20ac 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", @@ -14,6 +15,7 @@ export const workspaceOperations = [ "workspace.terminal", "workspace.rename", "workspace.stop", + "workspace.reorderProjects", ]; const fail = (code, message) => { throw new Error(`workspace_${code}: ${message}`); @@ -114,6 +116,7 @@ const fields = { "workspace.terminal": ["target", "worktreeId"], "workspace.rename": ["agentId", "name"], "workspace.stop": ["agentId", "confirm"], + "workspace.reorderProjects": ["projectIds"], }; const fingerprint = (request) => JSON.stringify( @@ -166,6 +169,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, @@ -290,11 +315,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)) @@ -307,7 +330,9 @@ 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") { + 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, @@ -442,6 +467,7 @@ export function workspaceConnection({ "workspace.createWorktree": "create_worktree_result", "workspace.rename": "rename_result", "workspace.stop": "kill_result", + "workspace.reorderProjects": "project_order", }[r.op]; if (reply.type !== expected) throw new Error("Unexpected daemon receipt"); entry.result = { @@ -460,6 +486,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 709494a4..6caed5fa 100644 --- a/apps/purepoint-mobile/bridge/workspace.test.js +++ b/apps/purepoint-mobile/bridge/workspace.test.js @@ -141,6 +141,8 @@ 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 === "reorder_projects") + send({ type: "project_order", project_roots: r.project_roots }); } }); }); @@ -531,6 +533,43 @@ test("expanded presets bounded, validated rename and exact single-agent stop", a }); }); +test("project reorder forwards registered roots and rejects unknown or repeated ones", 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, + ); + 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 c39df862..25f26e58 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. @@ -209,6 +209,7 @@ 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.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. 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. @@ -236,6 +237,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/crates/pu-cli/src/output/mod.rs b/crates/pu-cli/src/output/mod.rs index 30dfd46d..3296b465 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 3d463147..d19fcef9 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")] @@ -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, @@ -498,6 +503,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 0e5c1af8..3d05d20e 100644 --- a/crates/pu-core/src/protocol/tests.rs +++ b/crates/pu-core/src/protocol/tests.rs @@ -332,8 +332,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 { @@ -491,7 +523,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..7b97ff9a 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; Ok(engine) } @@ -69,17 +76,16 @@ impl Engine { if current.contains(&root) { return Ok(()); } - 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(()) }) .await @@ -87,6 +93,54 @@ impl Engine { .map_err(|e| Self::inventory_error("IO_ERROR", e.to_string())) } + /// 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(); + tokio::task::spawn_blocking(move || -> std::io::Result> { + 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()); + } + } + if next != current { + 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) + }) + .await + .map_err(|e| Self::inventory_error("IO_ERROR", e.to_string()))? + .map_err(|e| Self::inventory_error("IO_ERROR", e.to_string())) + } + pub(super) fn inventory_error(code: &str, message: String) -> Response { Response::Error { code: code.into(), @@ -186,7 +240,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)); @@ -205,7 +259,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)); let sessions = self.sessions.lock().await; let mut observed_sessions = HashSet::new(); for (root, result) in manifests { @@ -625,11 +680,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 1ac163df..4f962978 100644 --- a/crates/pu-engine/src/engine/mod.rs +++ b/crates/pu-engine/src/engine/mod.rs @@ -90,7 +90,8 @@ pub struct Engine { /// Per-project broadcast channels for status push updates. status_channels: Arc>>>, /// Projects that have been initialized or used — scheduler scans these. - registered_projects: Arc>>, + /// Registered roots in the order clients show them. + registered_projects: Arc>>, /// Agents with a resume in progress, so two resumes of one agent can never /// both spawn `claude --resume` on the same transcript. resuming: Arc>>, @@ -203,7 +204,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())), resuming: Arc::new(std::sync::Mutex::new(HashSet::new())), shutdown_killed: Arc::new(std::sync::Mutex::new(HashSet::new())), } @@ -296,11 +297,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() } @@ -408,6 +405,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 38ea76c6..2546e3ca 100644 --- a/docs/product/daemon.md +++ b/docs/product/daemon.md @@ -25,6 +25,8 @@ Daemon (pu-engine binary) **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. +**Project order (implemented 2026-10-21):** The registry is an ordered list and the single source of truth for a machine's project order. New projects are appended. `Request::ReorderProjects { project_roots }` (protocol 13) moves the listed roots to the front and keeps the rest in their current order. Inventory and Health report roots in registry order. 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 diff --git a/docs/reference/ipc-api.md b/docs/reference/ipc-api.md index 583b9cf2..45aa8f0a 100644 --- a/docs/reference/ipc-api.md +++ b/docs/reference/ipc-api.md @@ -76,6 +76,18 @@ ResolveAgent returns `type: agent_resolved`, agent_id, project_root and worktree 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. +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 13). + +**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. @@ -88,7 +100,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 } From 88779ca39d225c65ccfd4fd5d6f6b07fc848266f Mon Sep 17 00:00:00 2001 From: Jono Minh <2witstudios@gmail.com> Date: Thu, 22 Oct 2026 00:20:12 -0500 Subject: [PATCH 2/5] fix(macos): don't let a stale project list undo a move A project list fetched before a local move could land after it and put the old order back until the next refresh. Each move now bumps a revision, and a refresh that started before the move is skipped. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NekGvm22FjcPtWx2YR4VLA --- apps/purepoint-macos/purepoint-macos/State/AppState.swift | 8 ++++++-- .../purepoint-macos/State/MachineState.swift | 6 ++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/purepoint-macos/purepoint-macos/State/AppState.swift b/apps/purepoint-macos/purepoint-macos/State/AppState.swift index 8ac189bc..65cb115e 100644 --- a/apps/purepoint-macos/purepoint-macos/State/AppState.swift +++ b/apps/purepoint-macos/purepoint-macos/State/AppState.swift @@ -114,6 +114,8 @@ final class AppState { @ObservationIgnored private var machineRegistryWatcher: MachineRegistryWatcher? #if os(macOS) @ObservationIgnored private var localProjectOrderTask: Task? + /// Bumped by each local move, so an order fetched before it can't undo it. + @ObservationIgnored private var localOrderRevision = 0 #endif private static let openProjectsKey = "PurePointOpenProjects" @@ -315,6 +317,7 @@ final class AppState { guard let next = ProjectOrder.moving(project.projectRoot, to: index, in: projects) else { return } projects = next persistOpenProjects() + localOrderRevision += 1 let roots = next.map(\.projectRoot) Task { [weak self] in let response = try? await DaemonEndpoint.local.makeClient().send(.reorderProjects(projectRoots: roots)) @@ -338,9 +341,10 @@ final class AppState { guard localProjectOrderTask == nil else { return } localProjectOrderTask = Task { [weak self] in while !Task.isCancelled { + let revision = self?.localOrderRevision let response = try? await DaemonEndpoint.local.makeClient().send(.inventory(kind: "projects")) - if case .inventory(let report) = response { - self?.applyProjectOrder(report.projects.map(\.projectRoot)) + if case .inventory(let report) = response, let self, self.localOrderRevision == revision { + self.applyProjectOrder(report.projects.map(\.projectRoot)) } do { try await Task.sleep(for: MachineState.refreshInterval) } catch { return } } diff --git a/apps/purepoint-macos/purepoint-macos/State/MachineState.swift b/apps/purepoint-macos/purepoint-macos/State/MachineState.swift index 66b5619a..3470d36b 100644 --- a/apps/purepoint-macos/purepoint-macos/State/MachineState.swift +++ b/apps/purepoint-macos/purepoint-macos/State/MachineState.swift @@ -48,6 +48,8 @@ final class MachineState: Identifiable { /// How often the project list is re-read while the daemon is up. 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 var endpoint: DaemonEndpoint { .machine(machine.id) } var isConnectedOrConnecting: Bool { state != .disconnected } @@ -322,6 +324,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")) @@ -335,6 +338,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)" @@ -384,6 +389,7 @@ final class MachineState: Identifiable { func moveProject(root: String, to index: Int) { guard let next = ProjectOrder.moving(root, to: index, in: projects) else { return } projects = next + orderRevision += 1 let roots = next.map(\.projectRoot) let client = endpoint.makeClient() Task { [weak self] in From db77209d953ad407647c8bc266138f1945b6f0f6 Mon Sep 17 00:00:00 2001 From: Jono Minh <2witstudios@gmail.com> Date: Thu, 22 Oct 2026 06:11:22 -0500 Subject: [PATCH 3/5] docs(daemon): advance the daemon spec to CONVERGING Research both open questions with options and trade-offs: - DMN-001 (one daemon for all projects vs one per project) recommends the shipped one-daemon-per-socket model that the global inventory, machines and project order build on. - DMN-002 (version mismatches) records the strict-equality handling the CLI, Mac app and bridge already have, and capability flags as the next option. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NekGvm22FjcPtWx2YR4VLA --- docs/product/daemon.md | 54 +++++++++++++++++++++++++++++++++++++++--- docs/spec-system.md | 2 +- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/docs/product/daemon.md b/docs/product/daemon.md index 2546e3ca..91a4c7ae 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 @@ -32,7 +32,55 @@ Daemon (pu-engine binary) ## 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 13'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/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 | From 55128c791a7fb0b9fd09f928c010ffe503d41f36 Mon Sep 17 00:00:00 2001 From: Jono Minh <2witstudios@gmail.com> Date: Thu, 22 Oct 2026 06:20:13 -0500 Subject: [PATCH 4/5] docs(changelog): note project ordering and protocol 14 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NekGvm22FjcPtWx2YR4VLA --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) 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. From 74b2b13c0be355ac7fbc4bdd9fd0387011c33242 Mon Sep 17 00:00:00 2001 From: Jono Minh <2witstudios@gmail.com> Date: Thu, 22 Oct 2026 06:24:11 -0500 Subject: [PATCH 5/5] fix(projects): save quick successive reorders in the order they were made Two moves made before the first was answered could reach the daemon out of order and save the older one, and a project list fetched or pushed before a move could put the old order back on screen. - macOS/iPad: ProjectOrderSaver sends a daemon's reorders one at a time, in user order. While one is unanswered, a pushed or fetched list keeps the order on screen (adds and removals still apply). A failure shows the daemon's order again. - iPhone: an inventory fetched across a reorder keeps the order on screen, and the order in the reorder receipt is applied once it succeeds. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NekGvm22FjcPtWx2YR4VLA --- .../purepoint-macos/Models/ProjectOrder.swift | 28 +++++++++++++++ .../purepoint-macos/State/AppState.swift | 28 ++++++++++----- .../purepoint-macos/State/MachineState.swift | 20 ++++++----- .../PurePoint/AgentControlDomain.swift | 2 +- .../PurePoint/AgentControlModel.swift | 35 ++++++++++++++++--- .../PurePointTests/AgentControlTests.swift | 14 ++++++++ 6 files changed, 105 insertions(+), 22 deletions(-) diff --git a/apps/purepoint-macos/purepoint-macos/Models/ProjectOrder.swift b/apps/purepoint-macos/purepoint-macos/Models/ProjectOrder.swift index fd588380..86078ae2 100644 --- a/apps/purepoint-macos/purepoint-macos/Models/ProjectOrder.swift +++ b/apps/purepoint-macos/purepoint-macos/Models/ProjectOrder.swift @@ -37,3 +37,31 @@ extension ProjectOrder { 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/State/AppState.swift b/apps/purepoint-macos/purepoint-macos/State/AppState.swift index 4632ca83..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. @@ -360,12 +363,11 @@ final class AppState { #if os(macOS) guard let next = ProjectOrder.moving(project.projectRoot, to: index, in: projects) else { return } projects = next - let roots = next.map(\.projectRoot) - Task { [weak self] in - // On success the daemon pushes the new order to every device following it. - let response = try? await DaemonEndpoint.local.makeClient().send(.reorderProjects(projectRoots: roots)) - if case .projectOrder = response { return } - self?.daemonError = "Could not save the project order." + // 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 } @@ -458,13 +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. - let ordered = ProjectOrder.sorted(projects, by: roots, root: { self.canonicalRoot(of: $0.projectRoot) }) - if ordered.map(\.projectRoot) != projects.map(\.projectRoot) { projects = ordered } + // 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 2043f1f0..943efaf5 100644 --- a/apps/purepoint-macos/purepoint-macos/State/MachineState.swift +++ b/apps/purepoint-macos/purepoint-macos/State/MachineState.swift @@ -53,6 +53,7 @@ final class MachineState: Identifiable { 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 } @@ -365,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) { @@ -406,14 +411,13 @@ final class MachineState: Identifiable { guard let next = ProjectOrder.moving(root, to: index, in: projects) else { return } projects = next orderRevision += 1 - let roots = next.map(\.projectRoot) - let client = endpoint.makeClient() - Task { [weak self] in - let response = try? await client.send(.reorderProjects(projectRoots: roots)) + orderSaver.save(next.map(\.projectRoot)) { [weak self] in guard let self else { return } - if case .projectOrder = response { return } - await self.refreshProjects() - self.actionError = "Could not save the project order on \(self.machine.name)." + Task { + // Show the order the machine kept. + await self.refreshProjects() + self.actionError = "Could not save the project order on \(self.machine.name)." + } } } 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 a129d8cd..33a4d190 100644 --- a/apps/purepoint-mobile/PurePoint/AgentControlModel.swift +++ b/apps/purepoint-mobile/PurePoint/AgentControlModel.swift @@ -36,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? @@ -278,16 +282,30 @@ import UserNotifications /// 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.") } - if let index = machines.firstIndex(where: { $0.id == machine }), let projects = machines[index].registeredProjects { - machines[index].registeredProjects = projectIds.compactMap { id in projects.first { $0.id == id } } - + projects.filter { !projectIds.contains($0.id) } - } + 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 @@ -333,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", [:]) @@ -342,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/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 =