From 6c534bba3c06e4f699a7fe6cb88cfc0b16ba2bb4 Mon Sep 17 00:00:00 2001 From: Byron Guina Date: Sat, 14 Mar 2026 17:42:22 -0500 Subject: [PATCH 1/2] feat: build session dashboard with resilient live state handling The macOS control center needed a dashboard that could stay useful while the daemon reconnects and workloads churn, but the existing monitoring view did not preserve ordering or selection when an agent was rekeyed and it surfaced too little session context to replace the TUI overview. This change anchors selection to the session when possible, preserves the existing workload order while appending new entries, enriches the session rows and detail pane with daemon-backed identifiers and lifecycle data, adds regression coverage for workload rekeys and order preservation, and includes MonitoringStore.swift in the checked-in Xcode project so the app build stays green. --- .../project.pbxproj | 20 +++ .../ControlCenterRootView.swift | 55 ++++++-- .../MonitoringStore.swift | 85 ++++++++++-- .../MonitoringStoreTests.swift | 128 +++++++++++++++++- 4 files changed, 264 insertions(+), 24 deletions(-) diff --git a/macos/ControlCenter/AetherflowControlCenter.xcodeproj/project.pbxproj b/macos/ControlCenter/AetherflowControlCenter.xcodeproj/project.pbxproj index 30fa3ba..6d3e638 100644 --- a/macos/ControlCenter/AetherflowControlCenter.xcodeproj/project.pbxproj +++ b/macos/ControlCenter/AetherflowControlCenter.xcodeproj/project.pbxproj @@ -52,6 +52,13 @@ fileRef B00000000000000000000007 + A00000000000000000000007 + + isa + PBXBuildFile + fileRef + B00000000000000000000008 + B00000000000000000000001 isa @@ -118,6 +125,17 @@ sourceTree <group> + B00000000000000000000008 + + isa + PBXFileReference + lastKnownFileType + sourcecode.swift + path + MonitoringStore.swift + sourceTree + <group> + B00000000000000000000006 isa @@ -156,6 +174,7 @@ A00000000000000000000004 A00000000000000000000005 A00000000000000000000006 + A00000000000000000000007 runOnlyForDeploymentPostprocessing 0 @@ -221,6 +240,7 @@ B00000000000000000000004 B00000000000000000000005 B00000000000000000000007 + B00000000000000000000008 path AetherflowControlCenter diff --git a/macos/ControlCenter/Sources/AetherflowControlCenter/ControlCenterRootView.swift b/macos/ControlCenter/Sources/AetherflowControlCenter/ControlCenterRootView.swift index 326515e..ab1f5fd 100644 --- a/macos/ControlCenter/Sources/AetherflowControlCenter/ControlCenterRootView.swift +++ b/macos/ControlCenter/Sources/AetherflowControlCenter/ControlCenterRootView.swift @@ -453,7 +453,8 @@ private struct DetailColumn: View { private var headerTitle: String { switch selectedSection { case .sessions: - return monitoring.selectedDetail?.session.workRef.nonEmptyValue + return monitoring.selectedDetail?.agent.taskTitle.nonEmptyValue + ?? monitoring.selectedDetail?.session.workRef.nonEmptyValue ?? monitoring.selectedDetail?.workloadID ?? "Live session monitor" case .queue: @@ -466,9 +467,12 @@ private struct DetailColumn: View { private var headerSummary: String { switch selectedSection { case .sessions: - return monitoring.selectedDetail?.session.sessionID.nonEmptyValue.map { - "Showing live status, tool activity, and recent events for \($0)." - } ?? monitoring.note + if let detail = monitoring.selectedDetail { + let sessionRoute = detail.session.sessionID.nonEmptyValue ?? "pending session" + let lifecycle = detail.agent.lifecycleState.nonEmptyValue ?? detail.session.status.nonEmptyValue ?? "unknown" + return "Showing live status, tool activity, and recent events for \(sessionRoute) in \(lifecycle) state." + } + return monitoring.note case .queue: return monitoring.queue.isEmpty ? monitoring.note : "The queue lane mirrors the daemon's waiting work without leaving the monitoring shell." case .overview, .diagnostics: @@ -520,8 +524,9 @@ private struct DetailHighlightsPanel: View { case .sessions: if let detail = monitoring.selectedDetail { return [ - "Selected workload: \(detail.workloadID).", + "Selected workload: \(detail.workloadID) on pid \(detail.agent.pid).", "Session route: \(detail.session.serverRef.nonEmptyValue ?? "pending server") / \(detail.session.sessionID.nonEmptyValue ?? "pending session").", + "Task lane: \(detail.agent.taskID.nonEmptyValue ?? detail.session.workRef.nonEmptyValue ?? "manual spawn"). Lifecycle: \(detail.agent.lifecycleState.nonEmptyValue ?? detail.agent.state.nonEmptyValue ?? "unknown").", "Recent tool calls: \(detail.toolCalls.count). Event lines cached: \(detail.eventLines.count).", detail.errors.isEmpty ? "No daemon-reported errors on the selected workload." : "Daemon reported \(detail.errors.count) error\(detail.errors.count == 1 ? "" : "s") for this workload." ] @@ -529,7 +534,7 @@ private struct DetailHighlightsPanel: View { return [ "Monitoring phase: \(monitoring.phase.rawValue).", "Visible workloads: \(monitoring.workloads.count).", - "Selection will load session metadata, tool calls, and recent daemon events in place." + "Selection follows the same session when the daemon rekeys a workload after reconnect." ] case .queue: if monitoring.queue.isEmpty { @@ -926,6 +931,12 @@ private struct MonitoringSummaryRow: View { var body: some View { HStack(spacing: 12) { MiniChip(text: snapshot.phase.rawValue.capitalized, tone: phaseTone) + if let poolMode = snapshot.poolMode.nonEmptyValue { + MiniChip(text: "Pool \(poolMode)", tone: ShellPalette.brass) + } + if let spawnPolicy = snapshot.spawnPolicy.nonEmptyValue { + MiniChip(text: "Policy \(spawnPolicy)", tone: ShellPalette.moss) + } MiniChip(text: "Workloads \(snapshot.workloads.count)", tone: ShellPalette.ember) MiniChip(text: "Queue \(snapshot.queue.count)", tone: ShellPalette.moss) Spacer(minLength: 0) @@ -967,6 +978,9 @@ private struct MonitoringWorkloadRow: View { Text(workload.kind == .poolAgent ? "POOL" : "SPAWN") .font(.system(size: 10, weight: .bold, design: .monospaced)) .foregroundStyle(workload.kind == .poolAgent ? ShellPalette.ember : ShellPalette.moss) + Text(workload.workRef) + .font(.system(size: 10, weight: .bold, design: .monospaced)) + .foregroundStyle(ShellPalette.mutedInk) if workload.attentionNeeded { Text("ATTN") .font(.system(size: 10, weight: .bold, design: .monospaced)) @@ -990,7 +1004,8 @@ private struct MonitoringWorkloadRow: View { HStack(spacing: 10) { DetailPill(label: "role", value: workload.role) - DetailPill(label: "session", value: workload.sessionID.nonEmptyValue ?? "pending") + DetailPill(label: "session", value: workload.sessionID.nonEmptyValue ?? "claiming") + DetailPill(label: "pid", value: workload.pid > 0 ? String(workload.pid) : "pending") DetailPill(label: "activity", value: workload.lastActivityAt.map(Self.relativeTimestamp) ?? "waiting") } } @@ -1077,10 +1092,26 @@ private struct SessionsDetailPanel: View { if let detail = snapshot.selectedDetail { VStack(alignment: .leading, spacing: 14) { HStack(spacing: 12) { - DetailPill(label: "work", value: detail.session.workRef.nonEmptyValue ?? detail.workloadID) + DetailPill(label: "work", value: detail.agent.taskID.nonEmptyValue ?? detail.session.workRef.nonEmptyValue ?? detail.workloadID) DetailPill(label: "session", value: detail.session.sessionID.nonEmptyValue ?? "pending") DetailPill(label: "origin", value: detail.session.originType.nonEmptyValue ?? "unknown") - DetailPill(label: "status", value: detail.session.status.nonEmptyValue ?? "unknown") + DetailPill(label: "status", value: detail.agent.lifecycleState.nonEmptyValue ?? detail.session.status.nonEmptyValue ?? "unknown") + } + + HStack(spacing: 12) { + DetailPill(label: "agent", value: detail.workloadID) + DetailPill(label: "pid", value: detail.agent.pid > 0 ? String(detail.agent.pid) : "pending") + DetailPill(label: "server", value: detail.session.serverRef.nonEmptyValue ?? "pending") + DetailPill(label: "attach", value: detail.session.attachable ? "ready" : "pending") + } + + if let lastLog = detail.agent.lastLog.nonEmptyValue { + VStack(alignment: .leading, spacing: 8) { + Text("Latest daemon note") + .font(.system(size: 12, weight: .bold, design: .monospaced)) + .foregroundStyle(ShellPalette.mutedInk) + EventLineRow(line: lastLog, tone: ShellPalette.brass) + } } if !detail.toolCalls.isEmpty { @@ -1122,8 +1153,10 @@ private struct SessionsDetailPanel: View { } } else { MonitoringEmptyRow( - title: snapshot.workloads.isEmpty ? "No session selected" : "Select a workload", - detail: snapshot.note + title: snapshot.workloads.isEmpty ? "No session selected" : "Select a session lane", + detail: snapshot.workloads.isEmpty + ? snapshot.note + : "The detail pane will hold session route, tool activity, and recent daemon events without changing selection." ) } } diff --git a/macos/ControlCenter/Sources/AetherflowControlCenter/MonitoringStore.swift b/macos/ControlCenter/Sources/AetherflowControlCenter/MonitoringStore.swift index cd3e9ff..854069e 100644 --- a/macos/ControlCenter/Sources/AetherflowControlCenter/MonitoringStore.swift +++ b/macos/ControlCenter/Sources/AetherflowControlCenter/MonitoringStore.swift @@ -17,11 +17,14 @@ struct MonitoringWorkloadSummary: Identifiable, Equatable, Sendable { let id: String let kind: MonitoringWorkloadKind let role: String + let workRef: String let title: String let subtitle: String let sessionID: String let lifecycleState: String + let pid: Int let attentionNeeded: Bool + let spawnedAt: Date let lastActivityAt: Date? } @@ -34,6 +37,7 @@ struct MonitoringQueueItem: Identifiable, Equatable, Sendable { struct MonitoringSelectionDetail: Equatable, Sendable { let workloadID: String let session: DaemonSessionMetadataPayload + let agent: DaemonAgentStatusPayload let toolCalls: [DaemonToolCallPayload] let eventLines: [String] let lastEventTimestamp: Int64 @@ -74,6 +78,11 @@ struct MonitoringSnapshot: Equatable, Sendable { final class MonitoringStore: ObservableObject { @Published private(set) var snapshot: MonitoringSnapshot + private struct SelectionAnchor: Equatable { + let workloadID: String + let sessionID: String? + } + private let context: ShellBootstrapContext private let controller: DaemonControlling private let isDaemonAbsent: (Error) -> Bool @@ -82,6 +91,7 @@ final class MonitoringStore: ObservableObject { private var monitorTask: Task? private var hasConnected = false private var needsAuthoritativeReload = true + private var selectionAnchor: SelectionAnchor? init( context: ShellBootstrapContext, @@ -132,6 +142,12 @@ final class MonitoringStore: ObservableObject { return } needsAuthoritativeReload = true + selectionAnchor = id.flatMap { workloadID in + guard let workload = snapshot.workloads.first(where: { $0.id == workloadID }) else { + return SelectionAnchor(workloadID: workloadID, sessionID: nil) + } + return SelectionAnchor(workloadID: workloadID, sessionID: workload.sessionID.nonEmptyValue) + } snapshot = MonitoringSnapshot( phase: snapshot.phase, project: snapshot.project, @@ -150,8 +166,8 @@ final class MonitoringStore: ObservableObject { func refresh() async { do { let status = try await controller.fetchStatus(daemonURL: context.daemonURL) - let workloads = buildWorkloads(status: status) - let selectedWorkloadID = resolvedSelectionID(workloads: workloads, preferredID: snapshot.selectedWorkloadID) + let workloads = orderedWorkloads(incoming: buildWorkloads(status: status), previous: snapshot.workloads) + let selectedWorkloadID = resolvedSelectionID(workloads: workloads) var selectedDetail = snapshot.selectedDetail let reconnected = needsAuthoritativeReload || snapshot.phase != .connected @@ -177,6 +193,12 @@ final class MonitoringStore: ObservableObject { lastError: status.errors.last?.nonEmptyValue, updatedAt: .now ) + selectionAnchor = selectedWorkloadID.flatMap { workloadID in + guard let workload = workloads.first(where: { $0.id == workloadID }) else { + return SelectionAnchor(workloadID: workloadID, sessionID: selectedDetail?.session.sessionID.nonEmptyValue) + } + return SelectionAnchor(workloadID: workloadID, sessionID: workload.sessionID.nonEmptyValue) + } hasConnected = true needsAuthoritativeReload = false } catch { @@ -214,6 +236,7 @@ final class MonitoringStore: ObservableObject { return MonitoringSelectionDetail( workloadID: workloadID, session: events.session.sessionID.nonEmptyValue == nil ? detail.session : events.session, + agent: detail.agent, toolCalls: detail.toolCalls, eventLines: lines, lastEventTimestamp: lastEventTimestamp, @@ -247,11 +270,14 @@ final class MonitoringStore: ObservableObject { id: $0.id, kind: .poolAgent, role: $0.role, + workRef: $0.taskID.nonEmptyValue ?? $0.id, title: $0.taskTitle.nonEmptyValue ?? $0.id, subtitle: $0.lastLog.nonEmptyValue ?? $0.taskID.nonEmptyValue ?? "Pool agent", sessionID: $0.sessionID, lifecycleState: $0.lifecycleState.nonEmptyValue ?? $0.state, + pid: $0.pid, attentionNeeded: $0.attentionNeeded, + spawnedAt: $0.spawnTime, lastActivityAt: $0.lastActivityAt ) } @@ -260,25 +286,64 @@ final class MonitoringStore: ObservableObject { id: $0.spawnID, kind: .spawn, role: "spawn", + workRef: $0.spawnID, title: $0.prompt.nonEmptyValue ?? $0.spawnID, - subtitle: $0.state.nonEmptyValue ?? "Spawned session", + subtitle: $0.sessionID.nonEmptyValue ?? ($0.state.nonEmptyValue ?? "Spawned session"), sessionID: $0.sessionID, lifecycleState: $0.lifecycleState.nonEmptyValue ?? $0.state, + pid: $0.pid, attentionNeeded: $0.attentionNeeded, + spawnedAt: $0.spawnTime, lastActivityAt: $0.lastActivityAt ?? $0.exitedAt ) } return agents + spawns } - private func resolvedSelectionID( - workloads: [MonitoringWorkloadSummary], - preferredID: String? - ) -> String? { - if let preferredID, workloads.contains(where: { $0.id == preferredID }) { - return preferredID + private func orderedWorkloads( + incoming: [MonitoringWorkloadSummary], + previous: [MonitoringWorkloadSummary] + ) -> [MonitoringWorkloadSummary] { + let previousIndexes = Dictionary(uniqueKeysWithValues: previous.enumerated().map { ($1.id, $0) }) + + return incoming.sorted { lhs, rhs in + let lhsIndex = previousIndexes[lhs.id] + let rhsIndex = previousIndexes[rhs.id] + switch (lhsIndex, rhsIndex) { + case let (lhsIndex?, rhsIndex?): + return lhsIndex < rhsIndex + case (.some, nil): + return true + case (nil, .some): + return false + case (nil, nil): + if lhs.attentionNeeded != rhs.attentionNeeded { + return lhs.attentionNeeded && !rhs.attentionNeeded + } + if lhs.kind != rhs.kind { + return lhs.kind == .poolAgent + } + if lhs.spawnedAt != rhs.spawnedAt { + return lhs.spawnedAt > rhs.spawnedAt + } + return lhs.id.localizedStandardCompare(rhs.id) == .orderedAscending + } + } + } + + private func resolvedSelectionID(workloads: [MonitoringWorkloadSummary]) -> String? { + if let sessionID = selectionAnchor?.sessionID, + let match = workloads.first(where: { $0.sessionID == sessionID }) { + return match.id + } + if let workloadID = selectionAnchor?.workloadID, + workloads.contains(where: { $0.id == workloadID }) { + return workloadID + } + guard let preferred = workloads.first else { + return nil } - return workloads.first?.id + return preferred.id } private func monitoringNote(for status: DaemonStatusPayload, workloadCount: Int) -> String { diff --git a/macos/ControlCenter/Tests/AetherflowControlCenterTests/MonitoringStoreTests.swift b/macos/ControlCenter/Tests/AetherflowControlCenterTests/MonitoringStoreTests.swift index 3c28759..1fde437 100644 --- a/macos/ControlCenter/Tests/AetherflowControlCenterTests/MonitoringStoreTests.swift +++ b/macos/ControlCenter/Tests/AetherflowControlCenterTests/MonitoringStoreTests.swift @@ -171,6 +171,69 @@ final class MonitoringStoreTests: XCTestCase { XCTAssertEqual(eventAgentNames, ["agent-1", "spawn-1"]) } + func testSelectionTracksSameSessionWhenWorkloadIDChanges() async throws { + let bootstrap = Self.bootstrap + let controller = RecordingDaemonController( + statusResults: [ + .success(Self.status(taskTitle: "Initial task")), + .success(Self.status(agentID: "agent-2", taskTitle: "Recovered task", sessionID: "ses-1")), + ], + detailResults: [ + .success(Self.detail(agentID: "agent-1", workRef: "ts-c9cdd2", sessionID: "ses-1")), + .success(Self.detail(agentID: "agent-2", workRef: "ts-c9cdd2", sessionID: "ses-1")), + ], + eventResults: [ + .success(Self.events(lines: ["session.created"], sessionID: "ses-1", workRef: "ts-c9cdd2", agentID: "agent-1", lastTS: 101)), + .success(Self.events(lines: ["session.reclaimed"], sessionID: "ses-1", workRef: "ts-c9cdd2", agentID: "agent-2", lastTS: 202)), + ] + ) + let store = MonitoringStore( + context: bootstrap, + controller: controller, + isDaemonAbsent: { _ in false }, + autoStartMonitoring: false + ) + + await store.refresh() + await store.refresh() + + XCTAssertEqual(store.snapshot.selectedWorkloadID, "agent-2") + XCTAssertEqual(store.snapshot.selectedDetail?.workloadID, "agent-2") + XCTAssertEqual(store.snapshot.selectedDetail?.session.sessionID, "ses-1") + + let detailAgentNames = await controller.recordedDetailAgentNames() + XCTAssertEqual(detailAgentNames, ["agent-1", "agent-2"]) + } + + func testRefreshPreservesExistingOrderAndAppendsNewWorkloads() async throws { + let bootstrap = Self.bootstrap + let controller = RecordingDaemonController( + statusResults: [ + .success(Self.statusWithAgentAndSpawn()), + .success(Self.statusWithAdditionalAgent()), + ], + detailResults: [ + .success(Self.detail(agentID: "agent-1", workRef: "ts-c9cdd2", sessionID: "ses-1")), + .success(Self.detail(agentID: "agent-1", workRef: "ts-c9cdd2", sessionID: "ses-1")), + ], + eventResults: [ + .success(Self.events(lines: ["session.created"], sessionID: "ses-1", workRef: "ts-c9cdd2", agentID: "agent-1", lastTS: 101)), + .success(Self.events(lines: ["task.updated"], sessionID: "ses-1", workRef: "ts-c9cdd2", agentID: "agent-1", lastTS: 120)), + ] + ) + let store = MonitoringStore( + context: bootstrap, + controller: controller, + isDaemonAbsent: { _ in false }, + autoStartMonitoring: false + ) + + await store.refresh() + await store.refresh() + + XCTAssertEqual(store.snapshot.workloads.map(\.id), ["agent-1", "spawn-1", "agent-2"]) + } + private static var bootstrap: ShellBootstrapContext { ShellBootstrapContext( projectName: "aetherflow", @@ -180,7 +243,11 @@ final class MonitoringStoreTests: XCTestCase { ) } - private static func status(taskTitle: String) -> DaemonStatusPayload { + private static func status( + agentID: String = "agent-1", + taskTitle: String, + sessionID: String = "ses-1" + ) -> DaemonStatusPayload { DaemonStatusPayload( poolSize: 1, poolMode: "active", @@ -188,14 +255,14 @@ final class MonitoringStoreTests: XCTestCase { spawnPolicy: "manual", agents: [ DaemonAgentStatusPayload( - id: "agent-1", + id: agentID, taskID: "ts-c9cdd2", role: "worker", pid: 42, spawnTime: .now, taskTitle: taskTitle, lastLog: "working", - sessionID: "ses-1", + sessionID: sessionID, state: "running", lifecycleState: "running", lastActivityAt: .now, @@ -210,6 +277,61 @@ final class MonitoringStoreTests: XCTestCase { ) } + private static func statusWithAdditionalAgent() -> DaemonStatusPayload { + DaemonStatusPayload( + poolSize: 2, + poolMode: "active", + project: "aetherflow", + spawnPolicy: "manual", + agents: [ + DaemonAgentStatusPayload( + id: "agent-1", + taskID: "ts-c9cdd2", + role: "worker", + pid: 42, + spawnTime: .now.addingTimeInterval(-120), + taskTitle: "Implement HTTP transport", + lastLog: "working", + sessionID: "ses-1", + state: "running", + lifecycleState: "running", + lastActivityAt: .now, + attentionNeeded: false + ), + DaemonAgentStatusPayload( + id: "agent-2", + taskID: "ts-next", + role: "worker", + pid: 43, + spawnTime: .now, + taskTitle: "Follow-up task", + lastLog: "queued for handoff", + sessionID: "ses-3", + state: "running", + lifecycleState: "running", + lastActivityAt: .now, + attentionNeeded: true + ), + ], + spawns: [ + DaemonSpawnStatusPayload( + spawnID: "spawn-1", + pid: 99, + sessionID: "ses-2", + state: "running", + lifecycleState: "running", + lastActivityAt: .now, + attentionNeeded: false, + prompt: "Manual validation run", + spawnTime: .now.addingTimeInterval(-60), + exitedAt: nil + ) + ], + queue: [], + errors: [] + ) + } + private static func statusWithAgentAndSpawn() -> DaemonStatusPayload { DaemonStatusPayload( poolSize: 1, From c0a7e28460ff4caede1d316bb048ce2b6291cf59 Mon Sep 17 00:00:00 2001 From: Byron Guina Date: Sat, 14 Mar 2026 18:01:08 -0500 Subject: [PATCH 2/2] fix: address session dashboard review feedback The session dashboard branch still had a few review issues after the main implementation landed. The workload ordering helper had become harder to reason about, the highlights panel could show pid 0 for pending work, and the fallback ordering branches were not covered by a focused regression test. This change splits retained-order handling from new-workload sorting, collapses the duplicated event merge path, normalizes pending pid display in the highlights panel, and adds a regression test that exercises the attention, kind, spawn time, and id ordering rules for newly discovered workloads. --- .../ControlCenterRootView.swift | 3 +- .../MonitoringStore.swift | 55 ++++----- .../MonitoringStoreTests.swift | 111 ++++++++++++++++++ 3 files changed, 136 insertions(+), 33 deletions(-) diff --git a/macos/ControlCenter/Sources/AetherflowControlCenter/ControlCenterRootView.swift b/macos/ControlCenter/Sources/AetherflowControlCenter/ControlCenterRootView.swift index ab1f5fd..4a54a02 100644 --- a/macos/ControlCenter/Sources/AetherflowControlCenter/ControlCenterRootView.swift +++ b/macos/ControlCenter/Sources/AetherflowControlCenter/ControlCenterRootView.swift @@ -523,8 +523,9 @@ private struct DetailHighlightsPanel: View { switch section { case .sessions: if let detail = monitoring.selectedDetail { + let pidLabel = detail.agent.pid > 0 ? String(detail.agent.pid) : "pending" return [ - "Selected workload: \(detail.workloadID) on pid \(detail.agent.pid).", + "Selected workload: \(detail.workloadID) on pid \(pidLabel).", "Session route: \(detail.session.serverRef.nonEmptyValue ?? "pending server") / \(detail.session.sessionID.nonEmptyValue ?? "pending session").", "Task lane: \(detail.agent.taskID.nonEmptyValue ?? detail.session.workRef.nonEmptyValue ?? "manual spawn"). Lifecycle: \(detail.agent.lifecycleState.nonEmptyValue ?? detail.agent.state.nonEmptyValue ?? "unknown").", "Recent tool calls: \(detail.toolCalls.count). Event lines cached: \(detail.eventLines.count).", diff --git a/macos/ControlCenter/Sources/AetherflowControlCenter/MonitoringStore.swift b/macos/ControlCenter/Sources/AetherflowControlCenter/MonitoringStore.swift index 854069e..835f211 100644 --- a/macos/ControlCenter/Sources/AetherflowControlCenter/MonitoringStore.swift +++ b/macos/ControlCenter/Sources/AetherflowControlCenter/MonitoringStore.swift @@ -223,15 +223,8 @@ final class MonitoringStore: ObservableObject { afterTimestamp: afterTimestamp ) - let lines: [String] - let lastEventTimestamp: Int64 - if previous == nil { - lines = events.lines - lastEventTimestamp = events.lastTS - } else { - lines = previous!.eventLines + events.lines - lastEventTimestamp = max(previous!.lastEventTimestamp, events.lastTS) - } + let lines = (previous?.eventLines ?? []) + events.lines + let lastEventTimestamp = max(previous?.lastEventTimestamp ?? 0, events.lastTS) return MonitoringSelectionDetail( workloadID: workloadID, @@ -304,31 +297,29 @@ final class MonitoringStore: ObservableObject { incoming: [MonitoringWorkloadSummary], previous: [MonitoringWorkloadSummary] ) -> [MonitoringWorkloadSummary] { - let previousIndexes = Dictionary(uniqueKeysWithValues: previous.enumerated().map { ($1.id, $0) }) + let incomingByID = Dictionary(uniqueKeysWithValues: incoming.map { ($0.id, $0) }) + let retained = previous.compactMap { incomingByID[$0.id] } + let retainedIDs = Set(retained.map(\.id)) + let appended = incoming + .filter { !retainedIDs.contains($0.id) } + .sorted(by: compareNewWorkloads) + return retained + appended + } - return incoming.sorted { lhs, rhs in - let lhsIndex = previousIndexes[lhs.id] - let rhsIndex = previousIndexes[rhs.id] - switch (lhsIndex, rhsIndex) { - case let (lhsIndex?, rhsIndex?): - return lhsIndex < rhsIndex - case (.some, nil): - return true - case (nil, .some): - return false - case (nil, nil): - if lhs.attentionNeeded != rhs.attentionNeeded { - return lhs.attentionNeeded && !rhs.attentionNeeded - } - if lhs.kind != rhs.kind { - return lhs.kind == .poolAgent - } - if lhs.spawnedAt != rhs.spawnedAt { - return lhs.spawnedAt > rhs.spawnedAt - } - return lhs.id.localizedStandardCompare(rhs.id) == .orderedAscending - } + private func compareNewWorkloads( + _ lhs: MonitoringWorkloadSummary, + _ rhs: MonitoringWorkloadSummary + ) -> Bool { + if lhs.attentionNeeded != rhs.attentionNeeded { + return lhs.attentionNeeded && !rhs.attentionNeeded + } + if lhs.kind != rhs.kind { + return lhs.kind == .poolAgent + } + if lhs.spawnedAt != rhs.spawnedAt { + return lhs.spawnedAt > rhs.spawnedAt } + return lhs.id.localizedStandardCompare(rhs.id) == .orderedAscending } private func resolvedSelectionID(workloads: [MonitoringWorkloadSummary]) -> String? { diff --git a/macos/ControlCenter/Tests/AetherflowControlCenterTests/MonitoringStoreTests.swift b/macos/ControlCenter/Tests/AetherflowControlCenterTests/MonitoringStoreTests.swift index 1fde437..c43e6c4 100644 --- a/macos/ControlCenter/Tests/AetherflowControlCenterTests/MonitoringStoreTests.swift +++ b/macos/ControlCenter/Tests/AetherflowControlCenterTests/MonitoringStoreTests.swift @@ -234,6 +234,34 @@ final class MonitoringStoreTests: XCTestCase { XCTAssertEqual(store.snapshot.workloads.map(\.id), ["agent-1", "spawn-1", "agent-2"]) } + func testRefreshOrdersNewWorkloadsByAttentionKindSpawnTimeAndID() async throws { + let bootstrap = Self.bootstrap + let controller = RecordingDaemonController( + statusResults: [ + .success(Self.statusForNewWorkloadOrdering()), + ], + detailResults: [ + .success(Self.detail(agentID: "agent-attn", workRef: "ts-attn", sessionID: "ses-attn")), + ], + eventResults: [ + .success(Self.events(lines: ["session.created"], sessionID: "ses-attn", workRef: "ts-attn", agentID: "agent-attn", lastTS: 101)), + ] + ) + let store = MonitoringStore( + context: bootstrap, + controller: controller, + isDaemonAbsent: { _ in false }, + autoStartMonitoring: false + ) + + await store.refresh() + + XCTAssertEqual( + store.snapshot.workloads.map(\.id), + ["agent-attn", "agent-newer", "agent-older", "spawn-a", "spawn-b"] + ) + } + private static var bootstrap: ShellBootstrapContext { ShellBootstrapContext( projectName: "aetherflow", @@ -373,6 +401,89 @@ final class MonitoringStoreTests: XCTestCase { ) } + private static func statusForNewWorkloadOrdering() -> DaemonStatusPayload { + let baseTime = Date(timeIntervalSince1970: 1_700_000_000) + + return DaemonStatusPayload( + poolSize: 3, + poolMode: "active", + project: "aetherflow", + spawnPolicy: "manual", + agents: [ + DaemonAgentStatusPayload( + id: "agent-attn", + taskID: "ts-attn", + role: "worker", + pid: 0, + spawnTime: baseTime.addingTimeInterval(-300), + taskTitle: "Needs attention", + lastLog: "waiting for reconnect", + sessionID: "ses-attn", + state: "pending", + lifecycleState: "starting", + lastActivityAt: nil, + attentionNeeded: true + ), + DaemonAgentStatusPayload( + id: "agent-newer", + taskID: "ts-newer", + role: "worker", + pid: 41, + spawnTime: baseTime.addingTimeInterval(-60), + taskTitle: "Newest agent", + lastLog: "running", + sessionID: "ses-newer", + state: "running", + lifecycleState: "running", + lastActivityAt: baseTime, + attentionNeeded: false + ), + DaemonAgentStatusPayload( + id: "agent-older", + taskID: "ts-older", + role: "worker", + pid: 40, + spawnTime: baseTime.addingTimeInterval(-120), + taskTitle: "Older agent", + lastLog: "running", + sessionID: "ses-older", + state: "running", + lifecycleState: "running", + lastActivityAt: baseTime.addingTimeInterval(-10), + attentionNeeded: false + ), + ], + spawns: [ + DaemonSpawnStatusPayload( + spawnID: "spawn-b", + pid: 51, + sessionID: "ses-spawn-b", + state: "running", + lifecycleState: "running", + lastActivityAt: baseTime, + attentionNeeded: false, + prompt: "Spawn B", + spawnTime: baseTime.addingTimeInterval(-180), + exitedAt: nil + ), + DaemonSpawnStatusPayload( + spawnID: "spawn-a", + pid: 50, + sessionID: "ses-spawn-a", + state: "running", + lifecycleState: "running", + lastActivityAt: baseTime, + attentionNeeded: false, + prompt: "Spawn A", + spawnTime: baseTime.addingTimeInterval(-180), + exitedAt: nil + ) + ], + queue: [], + errors: [] + ) + } + private static func detail( agentID: String = "agent-1", workRef: String = "ts-c9cdd2",