From fde8b05bb3fe3d2149d125c3327ecba54e645aa5 Mon Sep 17 00:00:00 2001 From: DonsWayo Date: Wed, 17 Jun 2026 12:18:14 +0200 Subject: [PATCH] Don't invent stacks from name prefixes by default Found in persona QA on a live daemon: qa-web + qa-cache (unrelated) were grouped into a fake 'qa' stack purely from the shared prefix. Root cause: container-compose sets no labels, so name-prefix is the only signal and it inevitably over-groups. - ProjectRegistry.assemble gains inferStacks (default false): only Consai-launched (known) stacks group reliably; everything else is standalone unless the user opts in. - AppState reads @AppStorage groupByNamePrefix (default off) and passes it through. - Settings: 'Group external containers by name prefix' toggle + explanation. - Tests: opt-in inference + default-off behavior (24 tests pass). Closes #12 --- App/AppState.swift | 5 +- App/ConsaiApp.swift | 9 ++++ App/Shots/ShotRenderer.swift | 12 +++++ App/Views/SettingsWindow.swift | 7 +++ .../ConsaiCore/Engines/ProjectRegistry.swift | 50 +++++++++++-------- .../ProjectRegistryTests.swift | 13 ++++- 6 files changed, 73 insertions(+), 23 deletions(-) diff --git a/App/AppState.swift b/App/AppState.swift index 3fe762b..abc0717 100644 --- a/App/AppState.swift +++ b/App/AppState.swift @@ -143,8 +143,11 @@ final class AppState { } /// Recompute stacks + standalone from the current container list and known projects. + /// Name-prefix inference for externally-launched containers is opt-in (default off) so + /// unrelated containers that merely share a prefix aren't grouped into a fake stack (#12). private func reassemble() { - let result = registry.assemble(containers: containers) + let infer = UserDefaults.standard.bool(forKey: "groupByNamePrefix") + let result = registry.assemble(containers: containers, inferStacks: infer) stacks = result.stacks standalone = result.standalone } diff --git a/App/ConsaiApp.swift b/App/ConsaiApp.swift index 559b5f6..ecd82e9 100644 --- a/App/ConsaiApp.swift +++ b/App/ConsaiApp.swift @@ -16,6 +16,15 @@ enum ConsaiEntry { exit(0) } app.run() + } else if let i = args.firstIndex(of: "--render-live") { + let dir = URL(fileURLWithPath: i + 1 < args.count ? args[i + 1] : "shots") + let app = NSApplication.shared + app.setActivationPolicy(.accessory) + Task { @MainActor in + await ShotRenderer.renderLive(to: dir) + exit(0) + } + app.run() } else if let i = args.firstIndex(of: "--render-icon") { let out = URL(fileURLWithPath: i + 1 < args.count ? args[i + 1] : "icon.png") let app = NSApplication.shared diff --git a/App/Shots/ShotRenderer.swift b/App/Shots/ShotRenderer.swift index 478cdde..404a58b 100644 --- a/App/Shots/ShotRenderer.swift +++ b/App/Shots/ShotRenderer.swift @@ -45,6 +45,18 @@ enum ShotRenderer { FileHandle.standardError.write(Data("rendered shots to \(dir.path)\n".utf8)) } + /// Capture the panel backed by the REAL AppState (live daemon), for QA/authentic shots. + /// Refreshes twice with a gap so CPU% (needs two samples) populates. + static func renderLive(to dir: URL) async { + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let state = AppState() + await state.refresh() + try? await Task.sleep(for: .seconds(2.5)) + await state.refresh() + await shoot(PanelView().environment(state), width: 360, height: 600, name: "live-panel", dir: dir) + FileHandle.standardError.write(Data("rendered live panel to \(dir.path)\n".utf8)) + } + private static func shoot(_ view: V, width: CGFloat, height: CGFloat, name: String, dir: URL) async { let themed = view.frame(width: width, height: height) .preferredColorScheme(.dark).tint(Theme.jade) diff --git a/App/Views/SettingsWindow.swift b/App/Views/SettingsWindow.swift index 8dea079..d6b075f 100644 --- a/App/Views/SettingsWindow.swift +++ b/App/Views/SettingsWindow.swift @@ -7,6 +7,7 @@ struct SettingsWindow: View { @Environment(AppState.self) private var appState @AppStorage("pollOpen") private var pollOpen = 2.0 @AppStorage("pollClosed") private var pollClosed = 15.0 + @AppStorage("groupByNamePrefix") private var groupByNamePrefix = false @State private var working = false var body: some View { @@ -38,6 +39,12 @@ struct SettingsWindow: View { } } + Section("Stacks") { + Toggle("Group external containers by name prefix", isOn: $groupByNamePrefix) + Text("Off: only stacks you launch through Consai are grouped (reliable). On: containers that share a `name-` prefix are grouped as inferred stacks — can mis-group unrelated containers.") + .font(.caption).foregroundStyle(.secondary) + } + Section("Refresh cadence") { LabeledContent("Panel open") { Stepper("\(pollOpen, specifier: "%.0f")s", value: $pollOpen, in: 1...10) diff --git a/ConsaiCore/Sources/ConsaiCore/Engines/ProjectRegistry.swift b/ConsaiCore/Sources/ConsaiCore/Engines/ProjectRegistry.swift index d1ae926..28b3c47 100644 --- a/ConsaiCore/Sources/ConsaiCore/Engines/ProjectRegistry.swift +++ b/ConsaiCore/Sources/ConsaiCore/Engines/ProjectRegistry.swift @@ -45,7 +45,16 @@ public struct ProjectRegistry: Codable, Sendable, Equatable { // MARK: - Assembly /// Fold containers into stacks + standalone leftovers. - public func assemble(containers: [Container]) -> (stacks: [Stack], standalone: [Container]) { + /// + /// - Parameter inferStacks: when true, containers Consai didn't launch are grouped by + /// `-` name prefix (best-effort, marked `.inferred`). This can + /// over-group unrelated containers that merely share a prefix (e.g. `qa-web` + `qa-cache`), + /// so it defaults to **false** — only Consai-launched stacks group reliably. Callers + /// opt in via a user setting. + public func assemble( + containers: [Container], + inferStacks: Bool = false + ) -> (stacks: [Stack], standalone: [Container]) { var remaining = containers var stacks: [Stack] = [] @@ -64,29 +73,30 @@ public struct ProjectRegistry: Codable, Sendable, Equatable { ) } - // 2. Inferred stacks: group leftovers by candidate prefix (before the last "-"). - var byCandidate: [String: [Container]] = [:] + // 2. Inferred stacks (opt-in): group leftovers by candidate prefix (before the last + // "-"). Off by default — see issue #12 — so unrelated containers sharing a prefix + // aren't grouped into a fake stack. var standalone: [Container] = [] - for container in remaining { - if let candidate = Self.inferredProject(from: container.name) { - byCandidate[candidate, default: []].append(container) - } else { - standalone.append(container) + if inferStacks { + var byCandidate: [String: [Container]] = [:] + for container in remaining { + if let candidate = Self.inferredProject(from: container.name) { + byCandidate[candidate, default: []].append(container) + } else { + standalone.append(container) + } } - } - for (candidate, members) in byCandidate { - if members.count >= 2 { - stacks.append( - Stack( - projectName: candidate, - composeFilePath: nil, - services: members, - origin: .inferred + for (candidate, members) in byCandidate { + if members.count >= 2 { + stacks.append( + Stack(projectName: candidate, composeFilePath: nil, services: members, origin: .inferred) ) - ) - } else { - standalone.append(contentsOf: members) + } else { + standalone.append(contentsOf: members) + } } + } else { + standalone = remaining } // Stable ordering: stacks by name, standalone by name. diff --git a/ConsaiCore/Tests/ConsaiCoreTests/ProjectRegistryTests.swift b/ConsaiCore/Tests/ConsaiCoreTests/ProjectRegistryTests.swift index 7950274..2ae917e 100644 --- a/ConsaiCore/Tests/ConsaiCoreTests/ProjectRegistryTests.swift +++ b/ConsaiCore/Tests/ConsaiCoreTests/ProjectRegistryTests.swift @@ -33,10 +33,10 @@ import Foundation #expect(result.standalone.count == 1) } - @Test func infersStackFromSharedPrefixWhenTwoOrMore() { + @Test func infersStackFromSharedPrefixWhenOptedIn() { let result = ProjectRegistry().assemble(containers: [ c("1", "shop-web"), c("2", "shop-db"), c("3", "shop-cache"), - ]) + ], inferStacks: true) #expect(result.stacks.count == 1) let stack = try! #require(result.stacks.first) #expect(stack.projectName == "shop") @@ -46,6 +46,15 @@ import Foundation #expect(result.standalone.isEmpty) } + @Test func doesNotInferStacksByDefault() { + // Default (inference off): unrelated containers sharing a prefix stay standalone (#12). + let result = ProjectRegistry().assemble(containers: [ + c("1", "qa-web"), c("2", "qa-cache"), + ]) + #expect(result.stacks.isEmpty) + #expect(result.standalone.map(\.name).sorted() == ["qa-cache", "qa-web"]) + } + @Test func singleHyphenatedContainerIsNotAStack() { let result = ProjectRegistry().assemble(containers: [c("1", "alone-web")]) #expect(result.stacks.isEmpty)