feat: implement dynamic profile discovery and improve UI responsiveness for service management
This commit is contained in:
@@ -28,6 +28,21 @@ Install to `~/Applications/AIWorkspace.app`:
|
||||
apps/mac/AIWorkspace/scripts/package-app.sh --install
|
||||
```
|
||||
|
||||
The script defaults to the per-user install location because this workspace is a
|
||||
developer-local utility and frequent rebuilds should not require `sudo`. To
|
||||
install system-wide instead, pass an explicit install directory:
|
||||
|
||||
```bash
|
||||
INSTALL_DIR=/Applications apps/mac/AIWorkspace/scripts/package-app.sh --install
|
||||
APP_PATH=/Applications/AIWorkspace.app apps/mac/AIWorkspace/scripts/install-start-at-login.sh
|
||||
```
|
||||
|
||||
Avoid keeping both `~/Applications/AIWorkspace.app` and
|
||||
`/Applications/AIWorkspace.app` installed unless you are intentionally comparing
|
||||
builds. They share the same bundle identifier (`com.aiworkspace.menu`) and can
|
||||
confuse LaunchServices, login item registration, and which copy opens at login.
|
||||
If both exist, quit any running AI Workspace instances and remove the stale copy.
|
||||
|
||||
## Build a DMG
|
||||
|
||||
```bash
|
||||
|
||||
@@ -4,11 +4,51 @@ import ServiceManagement
|
||||
import SwiftUI
|
||||
|
||||
private let workspaceRoot = URL(fileURLWithPath: "/Users/david/Developer/fidelity-ai-workspace", isDirectory: true)
|
||||
private let defaultProfile = "fidelity"
|
||||
|
||||
struct ProfileConfig {
|
||||
let id: String
|
||||
let displayName: String
|
||||
let knowledgeDir: String
|
||||
|
||||
static func discoverDefault() -> ProfileConfig {
|
||||
let profilesRoot = workspaceRoot.appendingPathComponent("profiles", isDirectory: true)
|
||||
let candidates = (try? FileManager.default.contentsOfDirectory(at: profilesRoot, includingPropertiesForKeys: nil)) ?? []
|
||||
let configs = candidates.compactMap { directory -> ProfileConfig? in
|
||||
let configURL = directory.appendingPathComponent("workspace.json")
|
||||
guard let data = try? Data(contentsOf: configURL),
|
||||
let decoded = try? JSONDecoder().decode(ProfileWorkspaceConfig.self, from: data)
|
||||
else { return nil }
|
||||
return ProfileConfig(
|
||||
id: decoded.profile ?? directory.lastPathComponent,
|
||||
displayName: decoded.displayName ?? decoded.profile ?? directory.lastPathComponent,
|
||||
knowledgeDir: decoded.knowledgeDir ?? "workspaces/\(decoded.profile ?? directory.lastPathComponent)/project-knowledge"
|
||||
)
|
||||
}
|
||||
if let fidelity = configs.first(where: { $0.id == "fidelity" }) {
|
||||
return fidelity
|
||||
}
|
||||
if let first = configs.sorted(by: { $0.id < $1.id }).first {
|
||||
return first
|
||||
}
|
||||
return ProfileConfig(id: "fidelity", displayName: "Fidelity", knowledgeDir: "workspaces/fidelity/project-knowledge")
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProfileWorkspaceConfig: Decodable {
|
||||
let profile: String?
|
||||
let displayName: String?
|
||||
let knowledgeDir: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case profile
|
||||
case displayName = "display_name"
|
||||
case knowledgeDir = "knowledge_dir"
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct AIWorkspaceApp: App {
|
||||
@StateObject private var model = ServiceStatusModel(profile: defaultProfile)
|
||||
@StateObject private var model = ServiceStatusModel()
|
||||
|
||||
var body: some Scene {
|
||||
MenuBarExtra {
|
||||
@@ -30,11 +70,12 @@ final class ServiceStatusModel: ObservableObject {
|
||||
@Published private(set) var lanIP: String?
|
||||
@Published private(set) var loginItemStatus: SMAppService.Status = .notRegistered
|
||||
@Published private(set) var isRefreshing = false
|
||||
@Published private(set) var activeAction: String?
|
||||
|
||||
let profile: String
|
||||
let profile: ProfileConfig
|
||||
|
||||
init(profile: String) {
|
||||
self.profile = profile
|
||||
init() {
|
||||
self.profile = ProfileConfig.discoverDefault()
|
||||
}
|
||||
|
||||
var menuBarSymbol: String {
|
||||
@@ -52,7 +93,7 @@ final class ServiceStatusModel: ObservableObject {
|
||||
isRefreshing = true
|
||||
defer { isRefreshing = false }
|
||||
do {
|
||||
let data = try await ServiceManager.run(["status", "--profile", profile, "--json"])
|
||||
let data = try await ServiceManager.run(["status", "--profile", profile.id, "--json"])
|
||||
report = try JSONDecoder().decode(StatusReport.self, from: data)
|
||||
lanIP = await NetworkInfo.primaryLANIP()
|
||||
loginItemStatus = SMAppService.mainApp.status
|
||||
@@ -92,25 +133,33 @@ final class ServiceStatusModel: ObservableObject {
|
||||
}
|
||||
|
||||
func startProfile() {
|
||||
runAction(["start", "--profile", profile])
|
||||
runAction("Starting services…", ["start", "--profile", profile.id])
|
||||
}
|
||||
|
||||
func stopProfile() {
|
||||
runAction(["stop", "--profile", profile])
|
||||
runAction("Stopping services…", ["stop", "--profile", profile.id])
|
||||
}
|
||||
|
||||
func primaryServiceAction() {
|
||||
if allManagedServicesRunning {
|
||||
stopProfile()
|
||||
} else {
|
||||
startProfile()
|
||||
}
|
||||
}
|
||||
|
||||
func restartMCP() {
|
||||
runAction(["restart", "aiw-context-mcp", "--profile", profile])
|
||||
runAction("Restarting MCP…", ["restart", "aiw-context-mcp", "--profile", profile.id])
|
||||
}
|
||||
|
||||
func runDoctor() {
|
||||
runAction(["doctor", "--profile", profile])
|
||||
runAction("Running doctor…", ["doctor", "--profile", profile.id])
|
||||
}
|
||||
|
||||
func copyDoctorJSON() {
|
||||
Task {
|
||||
do {
|
||||
let data = try await ServiceManager.run(["doctor", "--profile", profile, "--json"])
|
||||
let data = try await ServiceManager.run(["doctor", "--profile", profile.id, "--json"])
|
||||
copyToPasteboard(String(data: data, encoding: .utf8) ?? "")
|
||||
} catch {
|
||||
lastError = String(describing: error)
|
||||
@@ -128,7 +177,7 @@ final class ServiceStatusModel: ObservableObject {
|
||||
do {
|
||||
var chunks: [String] = []
|
||||
for service in report?.services ?? [] {
|
||||
let data = try await ServiceManager.run(["logs", service.name, "--profile", profile, "--lines", "30"])
|
||||
let data = try await ServiceManager.run(["logs", service.name, "--profile", profile.id, "--lines", "30"])
|
||||
if let text = String(data: data, encoding: .utf8), !text.isEmpty {
|
||||
chunks.append(text)
|
||||
}
|
||||
@@ -147,15 +196,33 @@ final class ServiceStatusModel: ObservableObject {
|
||||
}
|
||||
|
||||
func openLogsFolder() {
|
||||
NSWorkspace.shared.open(workspaceRoot.appendingPathComponent(".aiw/runtime/logs", isDirectory: true))
|
||||
NSWorkspace.shared.open(workspaceRoot.appendingPathComponent(".aiw/runtime/logs/\(profile.id)", isDirectory: true))
|
||||
}
|
||||
|
||||
func openProjectKnowledge() {
|
||||
NSWorkspace.shared.open(workspaceRoot.appendingPathComponent("project-knowledge", isDirectory: true))
|
||||
NSWorkspace.shared.open(workspaceRoot.appendingPathComponent(profile.knowledgeDir, isDirectory: true))
|
||||
}
|
||||
|
||||
func openMattermost() {
|
||||
runAction(["start", "mattermost-desktop", "--profile", profile])
|
||||
runAction("Launching Mattermost…", ["start", "mattermost-desktop", "--profile", profile.id])
|
||||
}
|
||||
|
||||
var allManagedServicesRunning: Bool {
|
||||
guard let services = report?.services else { return false }
|
||||
let managed = services.filter { $0.enabled && $0.kind != "app-launcher" }
|
||||
return !managed.isEmpty && managed.allSatisfy { $0.status == "running" || $0.status == "externally running" }
|
||||
}
|
||||
|
||||
var primaryActionTitle: String {
|
||||
allManagedServicesRunning ? "Stop Services" : "Start Services"
|
||||
}
|
||||
|
||||
var primaryActionSymbol: String {
|
||||
allManagedServicesRunning ? "stop.fill" : "play.fill"
|
||||
}
|
||||
|
||||
var isBusy: Bool {
|
||||
isRefreshing || activeAction != nil
|
||||
}
|
||||
|
||||
private func copyToPasteboard(_ value: String) {
|
||||
@@ -163,8 +230,10 @@ final class ServiceStatusModel: ObservableObject {
|
||||
NSPasteboard.general.setString(value, forType: .string)
|
||||
}
|
||||
|
||||
private func runAction(_ arguments: [String]) {
|
||||
private func runAction(_ label: String, _ arguments: [String]) {
|
||||
Task {
|
||||
activeAction = label
|
||||
defer { activeAction = nil }
|
||||
do {
|
||||
_ = try await ServiceManager.run(arguments)
|
||||
await refresh()
|
||||
@@ -184,20 +253,31 @@ struct ServiceMenuView: View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("AI Workspace")
|
||||
.font(.title3.bold())
|
||||
Text("Profile: \(model.profile)")
|
||||
Text("Profile: \(model.profile.displayName)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if model.isBusy {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
Button {
|
||||
Task { await model.refresh() }
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.disabled(model.isBusy)
|
||||
.help("Refresh")
|
||||
}
|
||||
|
||||
if let activeAction = model.activeAction {
|
||||
Label(activeAction, systemImage: "hourglass")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
if let report = model.report {
|
||||
@@ -231,20 +311,26 @@ struct ServiceMenuView: View {
|
||||
|
||||
Divider()
|
||||
|
||||
Button(role: model.allManagedServicesRunning ? .destructive : nil, action: model.primaryServiceAction) {
|
||||
Label(model.primaryActionTitle, systemImage: model.primaryActionSymbol)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.disabled(model.isBusy)
|
||||
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
|
||||
ActionButton(title: "Start Fidelity", systemImage: "play.fill", action: model.startProfile)
|
||||
ActionButton(title: "Stop Fidelity", systemImage: "stop.fill", role: .destructive, action: model.stopProfile)
|
||||
ActionButton(title: "Restart MCP", systemImage: "arrow.triangle.2.circlepath", action: model.restartMCP)
|
||||
ActionButton(title: "Mattermost via Proxy", systemImage: "message.badge", action: model.openMattermost)
|
||||
ActionButton(title: "Restart MCP", systemImage: "arrow.triangle.2.circlepath", disabled: model.isBusy, action: model.restartMCP)
|
||||
ActionButton(title: "Mattermost via Proxy", systemImage: "message.badge", disabled: model.isBusy, action: model.openMattermost)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
|
||||
ActionButton(title: "Run Doctor", systemImage: "stethoscope", action: model.runDoctor)
|
||||
ActionButton(title: "Copy Doctor JSON", systemImage: "doc.on.doc", action: model.copyDoctorJSON)
|
||||
ActionButton(title: "Run Doctor", systemImage: "stethoscope", disabled: model.isBusy, action: model.runDoctor)
|
||||
ActionButton(title: "Copy Doctor JSON", systemImage: "doc.on.doc", disabled: model.isBusy, action: model.copyDoctorJSON)
|
||||
ActionButton(title: "Copy Photo URL", systemImage: "link", action: model.copyPhotoInboxURL)
|
||||
ActionButton(title: "Copy Logs", systemImage: "doc.text", action: model.copyRecentLogs)
|
||||
ActionButton(title: "Copy Logs", systemImage: "doc.text", disabled: model.isBusy, action: model.copyRecentLogs)
|
||||
ActionButton(title: "MCP Health", systemImage: "heart.text.square", action: model.openMCPHealth)
|
||||
ActionButton(title: "Logs Folder", systemImage: "folder", action: model.openLogsFolder)
|
||||
}
|
||||
@@ -290,6 +376,7 @@ struct ActionButton: View {
|
||||
let title: String
|
||||
let systemImage: String
|
||||
var role: ButtonRole?
|
||||
var disabled = false
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
@@ -299,6 +386,7 @@ struct ActionButton: View {
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(disabled)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,6 +457,15 @@ enum ServiceManager {
|
||||
process.currentDirectoryURL = workspaceRoot
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
|
||||
process.arguments = ["python3", "scripts/aiw/services.py"] + arguments
|
||||
var environment = ProcessInfo.processInfo.environment
|
||||
let guiSafePATH = "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
if let existingPATH = environment["PATH"], !existingPATH.isEmpty {
|
||||
environment["PATH"] = "\(guiSafePATH):\(existingPATH)"
|
||||
} else {
|
||||
environment["PATH"] = guiSafePATH
|
||||
}
|
||||
environment["AIW_WORKSPACE_ROOT"] = workspaceRoot.path
|
||||
process.environment = environment
|
||||
|
||||
let output = Pipe()
|
||||
let error = Pipe()
|
||||
|
||||
Reference in New Issue
Block a user