feat: implement dynamic profile discovery and improve UI responsiveness for service management

This commit is contained in:
2026-05-21 14:49:52 -06:00
parent e03518e507
commit cc716f8f7e
3 changed files with 179 additions and 34 deletions

View File

@@ -28,6 +28,21 @@ Install to `~/Applications/AIWorkspace.app`:
apps/mac/AIWorkspace/scripts/package-app.sh --install 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 ## Build a DMG
```bash ```bash

View File

@@ -4,11 +4,51 @@ import ServiceManagement
import SwiftUI import SwiftUI
private let workspaceRoot = URL(fileURLWithPath: "/Users/david/Developer/fidelity-ai-workspace", isDirectory: true) 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 @main
struct AIWorkspaceApp: App { struct AIWorkspaceApp: App {
@StateObject private var model = ServiceStatusModel(profile: defaultProfile) @StateObject private var model = ServiceStatusModel()
var body: some Scene { var body: some Scene {
MenuBarExtra { MenuBarExtra {
@@ -30,11 +70,12 @@ final class ServiceStatusModel: ObservableObject {
@Published private(set) var lanIP: String? @Published private(set) var lanIP: String?
@Published private(set) var loginItemStatus: SMAppService.Status = .notRegistered @Published private(set) var loginItemStatus: SMAppService.Status = .notRegistered
@Published private(set) var isRefreshing = false @Published private(set) var isRefreshing = false
@Published private(set) var activeAction: String?
let profile: String let profile: ProfileConfig
init(profile: String) { init() {
self.profile = profile self.profile = ProfileConfig.discoverDefault()
} }
var menuBarSymbol: String { var menuBarSymbol: String {
@@ -52,7 +93,7 @@ final class ServiceStatusModel: ObservableObject {
isRefreshing = true isRefreshing = true
defer { isRefreshing = false } defer { isRefreshing = false }
do { 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) report = try JSONDecoder().decode(StatusReport.self, from: data)
lanIP = await NetworkInfo.primaryLANIP() lanIP = await NetworkInfo.primaryLANIP()
loginItemStatus = SMAppService.mainApp.status loginItemStatus = SMAppService.mainApp.status
@@ -92,25 +133,33 @@ final class ServiceStatusModel: ObservableObject {
} }
func startProfile() { func startProfile() {
runAction(["start", "--profile", profile]) runAction("Starting services…", ["start", "--profile", profile.id])
} }
func stopProfile() { func stopProfile() {
runAction(["stop", "--profile", profile]) runAction("Stopping services…", ["stop", "--profile", profile.id])
}
func primaryServiceAction() {
if allManagedServicesRunning {
stopProfile()
} else {
startProfile()
}
} }
func restartMCP() { func restartMCP() {
runAction(["restart", "aiw-context-mcp", "--profile", profile]) runAction("Restarting MCP…", ["restart", "aiw-context-mcp", "--profile", profile.id])
} }
func runDoctor() { func runDoctor() {
runAction(["doctor", "--profile", profile]) runAction("Running doctor…", ["doctor", "--profile", profile.id])
} }
func copyDoctorJSON() { func copyDoctorJSON() {
Task { Task {
do { 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) ?? "") copyToPasteboard(String(data: data, encoding: .utf8) ?? "")
} catch { } catch {
lastError = String(describing: error) lastError = String(describing: error)
@@ -128,7 +177,7 @@ final class ServiceStatusModel: ObservableObject {
do { do {
var chunks: [String] = [] var chunks: [String] = []
for service in report?.services ?? [] { 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 { if let text = String(data: data, encoding: .utf8), !text.isEmpty {
chunks.append(text) chunks.append(text)
} }
@@ -147,15 +196,33 @@ final class ServiceStatusModel: ObservableObject {
} }
func openLogsFolder() { 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() { func openProjectKnowledge() {
NSWorkspace.shared.open(workspaceRoot.appendingPathComponent("project-knowledge", isDirectory: true)) NSWorkspace.shared.open(workspaceRoot.appendingPathComponent(profile.knowledgeDir, isDirectory: true))
} }
func openMattermost() { 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) { private func copyToPasteboard(_ value: String) {
@@ -163,8 +230,10 @@ final class ServiceStatusModel: ObservableObject {
NSPasteboard.general.setString(value, forType: .string) NSPasteboard.general.setString(value, forType: .string)
} }
private func runAction(_ arguments: [String]) { private func runAction(_ label: String, _ arguments: [String]) {
Task { Task {
activeAction = label
defer { activeAction = nil }
do { do {
_ = try await ServiceManager.run(arguments) _ = try await ServiceManager.run(arguments)
await refresh() await refresh()
@@ -184,20 +253,31 @@ struct ServiceMenuView: View {
VStack(alignment: .leading, spacing: 3) { VStack(alignment: .leading, spacing: 3) {
Text("AI Workspace") Text("AI Workspace")
.font(.title3.bold()) .font(.title3.bold())
Text("Profile: \(model.profile)") Text("Profile: \(model.profile.displayName)")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Spacer() Spacer()
if model.isBusy {
ProgressView()
.controlSize(.small)
}
Button { Button {
Task { await model.refresh() } Task { await model.refresh() }
} label: { } label: {
Image(systemName: "arrow.clockwise") Image(systemName: "arrow.clockwise")
} }
.buttonStyle(.borderless) .buttonStyle(.borderless)
.disabled(model.isBusy)
.help("Refresh") .help("Refresh")
} }
if let activeAction = model.activeAction {
Label(activeAction, systemImage: "hourglass")
.font(.caption)
.foregroundStyle(.secondary)
}
Divider() Divider()
if let report = model.report { if let report = model.report {
@@ -231,20 +311,26 @@ struct ServiceMenuView: View {
Divider() 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) { LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
ActionButton(title: "Start Fidelity", systemImage: "play.fill", action: model.startProfile) ActionButton(title: "Restart MCP", systemImage: "arrow.triangle.2.circlepath", disabled: model.isBusy, action: model.restartMCP)
ActionButton(title: "Stop Fidelity", systemImage: "stop.fill", role: .destructive, action: model.stopProfile) ActionButton(title: "Mattermost via Proxy", systemImage: "message.badge", disabled: model.isBusy, action: model.openMattermost)
ActionButton(title: "Restart MCP", systemImage: "arrow.triangle.2.circlepath", action: model.restartMCP)
ActionButton(title: "Mattermost via Proxy", systemImage: "message.badge", action: model.openMattermost)
} }
Divider() Divider()
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
ActionButton(title: "Run Doctor", systemImage: "stethoscope", action: model.runDoctor) ActionButton(title: "Run Doctor", systemImage: "stethoscope", disabled: model.isBusy, action: model.runDoctor)
ActionButton(title: "Copy Doctor JSON", systemImage: "doc.on.doc", action: model.copyDoctorJSON) 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 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: "MCP Health", systemImage: "heart.text.square", action: model.openMCPHealth)
ActionButton(title: "Logs Folder", systemImage: "folder", action: model.openLogsFolder) ActionButton(title: "Logs Folder", systemImage: "folder", action: model.openLogsFolder)
} }
@@ -290,6 +376,7 @@ struct ActionButton: View {
let title: String let title: String
let systemImage: String let systemImage: String
var role: ButtonRole? var role: ButtonRole?
var disabled = false
let action: () -> Void let action: () -> Void
var body: some View { var body: some View {
@@ -299,6 +386,7 @@ struct ActionButton: View {
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.controlSize(.small) .controlSize(.small)
.disabled(disabled)
} }
} }
@@ -369,6 +457,15 @@ enum ServiceManager {
process.currentDirectoryURL = workspaceRoot process.currentDirectoryURL = workspaceRoot
process.executableURL = URL(fileURLWithPath: "/usr/bin/env") process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = ["python3", "scripts/aiw/services.py"] + arguments 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 output = Pipe()
let error = Pipe() let error = Pipe()

View File

@@ -31,6 +31,16 @@ STATE_DIR = RUNTIME_DIR / "state"
DEFAULT_LOG_MAX_BYTES = 5 * 1024 * 1024 DEFAULT_LOG_MAX_BYTES = 5 * 1024 * 1024
DEFAULT_LOG_BACKUPS = 3 DEFAULT_LOG_BACKUPS = 3
DEFAULT_STOP_TIMEOUT_SECONDS = 5.0 DEFAULT_STOP_TIMEOUT_SECONDS = 5.0
DEFAULT_SERVICE_PATHS = [
"/opt/homebrew/bin",
"/opt/homebrew/sbin",
"/usr/local/bin",
"/usr/local/sbin",
"/usr/bin",
"/bin",
"/usr/sbin",
"/sbin",
]
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -126,14 +136,35 @@ def resolve_workspace_path(raw: str) -> Path:
return path if path.is_absolute() else ROOT / path return path if path.is_absolute() else ROOT / path
def command_exists(command: str) -> bool: def service_env(profile: str | None = None) -> dict[str, str]:
"""Return a robust environment for services launched from shells or GUI apps.
macOS login items do not reliably inherit the user's interactive shell PATH.
Homebrew-installed tools such as mitmdump commonly live under /opt/homebrew/bin
or /usr/local/bin, so make those paths available to both direct service
commands and nested scripts.
"""
env = os.environ.copy()
existing = [part for part in env.get("PATH", "").split(os.pathsep) if part]
merged: list[str] = []
for part in [*existing, *DEFAULT_SERVICE_PATHS]:
if part not in merged:
merged.append(part)
env["PATH"] = os.pathsep.join(merged)
env.setdefault("AIW_WORKSPACE_ROOT", str(ROOT))
if profile:
env.setdefault("AIW_PROJECT_PROFILE", profile)
return env
def command_exists(command: str, env: dict[str, str] | None = None) -> bool:
if not command: if not command:
return False return False
path = Path(command) path = Path(command)
if path.is_absolute() or "/" in command: if path.is_absolute() or "/" in command:
resolved = resolve_workspace_path(command) resolved = resolve_workspace_path(command)
return resolved.exists() and os.access(resolved, os.X_OK) return resolved.exists() and os.access(resolved, os.X_OK)
return shutil_which(command) is not None return shutil_which(command, env=env) is not None
def rotate_log_if_needed(path: Path, max_bytes: int = DEFAULT_LOG_MAX_BYTES, backups: int = DEFAULT_LOG_BACKUPS) -> None: def rotate_log_if_needed(path: Path, max_bytes: int = DEFAULT_LOG_MAX_BYTES, backups: int = DEFAULT_LOG_BACKUPS) -> None:
@@ -273,9 +304,10 @@ def start_service(profile: str, ref: ServiceRef, manifest: dict[str, Any], start
kind = ref.config.get("kind", "process") kind = ref.config.get("kind", "process")
command = ref.config.get("command") or [] command = ref.config.get("command") or []
env = service_env(profile)
if not command: if not command:
raise SystemExit(f"{ref.name} has no command") raise SystemExit(f"{ref.name} has no command")
if not command_exists(str(command[0])): if not command_exists(str(command[0]), env=env):
raise SystemExit(f"{ref.name} command is not executable or not found: {command[0]}") raise SystemExit(f"{ref.name} command is not executable or not found: {command[0]}")
if kind != "app-launcher": if kind != "app-launcher":
@@ -298,11 +330,11 @@ def start_service(profile: str, ref: ServiceRef, manifest: dict[str, Any], start
with path.open("ab") as log_file: with path.open("ab") as log_file:
log_file.write(f"\n--- start {time.strftime('%Y-%m-%d %H:%M:%S')} ---\n".encode("utf-8")) log_file.write(f"\n--- start {time.strftime('%Y-%m-%d %H:%M:%S')} ---\n".encode("utf-8"))
if kind == "app-launcher": if kind == "app-launcher":
result = subprocess.run(command, cwd=ROOT, stdout=log_file, stderr=subprocess.STDOUT, check=False) result = subprocess.run(command, cwd=ROOT, env=env, stdout=log_file, stderr=subprocess.STDOUT, check=False)
write_state(profile, ref.name, {"last_launch_exit": result.returncode, "launched_at": time.time()}) write_state(profile, ref.name, {"last_launch_exit": result.returncode, "launched_at": time.time()})
print(f"{ref.name}: launched (exit {result.returncode})") print(f"{ref.name}: launched (exit {result.returncode})")
else: else:
process = subprocess.Popen(command, cwd=ROOT, stdout=log_file, stderr=subprocess.STDOUT, start_new_session=True) process = subprocess.Popen(command, cwd=ROOT, env=env, stdout=log_file, stderr=subprocess.STDOUT, start_new_session=True)
pid_file = pid_path(profile, ref.name) pid_file = pid_path(profile, ref.name)
pid_file.parent.mkdir(parents=True, exist_ok=True) pid_file.parent.mkdir(parents=True, exist_ok=True)
pid_file.write_text(str(process.pid) + "\n", encoding="utf-8") pid_file.write_text(str(process.pid) + "\n", encoding="utf-8")
@@ -385,6 +417,7 @@ def tail_log(profile: str, service: str, lines: int) -> None:
def doctor_report(profile: str, manifest: dict[str, Any]) -> dict[str, Any]: def doctor_report(profile: str, manifest: dict[str, Any]) -> dict[str, Any]:
errors = validate_manifest(manifest) errors = validate_manifest(manifest)
env = service_env(profile)
service_reports = [] service_reports = []
for ref in service_items(manifest, include_disabled=True): for ref in service_items(manifest, include_disabled=True):
command = ref.config.get("command") or [] command = ref.config.get("command") or []
@@ -392,15 +425,15 @@ def doctor_report(profile: str, manifest: dict[str, Any]) -> dict[str, Any]:
doctor = ref.config.get("doctor") or {} doctor = ref.config.get("doctor") or {}
checks = [] checks = []
for command_name in doctor.get("required_commands") or []: for command_name in doctor.get("required_commands") or []:
checks.append({"type": "required_command", "name": command_name, "ok": command_exists(command_name)}) checks.append({"type": "required_command", "name": command_name, "ok": command_exists(command_name, env=env)})
for command_name in doctor.get("optional_commands") or []: for command_name in doctor.get("optional_commands") or []:
checks.append({"type": "optional_command", "name": command_name, "ok": command_exists(command_name)}) checks.append({"type": "optional_command", "name": command_name, "ok": command_exists(command_name, env=env)})
for raw_path in doctor.get("required_paths") or []: for raw_path in doctor.get("required_paths") or []:
checks.append({"type": "required_path", "name": str(raw_path), "ok": resolve_workspace_path(str(raw_path)).exists()}) checks.append({"type": "required_path", "name": str(raw_path), "ok": resolve_workspace_path(str(raw_path)).exists()})
for raw_path in doctor.get("optional_paths") or []: for raw_path in doctor.get("optional_paths") or []:
checks.append({"type": "optional_path", "name": str(raw_path), "ok": resolve_workspace_path(str(raw_path)).exists()}) checks.append({"type": "optional_path", "name": str(raw_path), "ok": resolve_workspace_path(str(raw_path)).exists()})
status = service_status(profile, ref) status = service_status(profile, ref)
status["command_ok"] = command_exists(first) if first else False status["command_ok"] = command_exists(first, env=env) if first else False
status["checks"] = checks status["checks"] = checks
service_reports.append(status) service_reports.append(status)
return { return {
@@ -443,8 +476,8 @@ def run_doctor(profile: str, manifest: dict[str, Any], json_output: bool = False
print(f" {label} {check['name']}: {'ok' if check['ok'] else 'missing'}") print(f" {label} {check['name']}: {'ok' if check['ok'] else 'missing'}")
def shutil_which(command: str) -> str | None: def shutil_which(command: str, env: dict[str, str] | None = None) -> str | None:
paths = os.environ.get("PATH", "").split(os.pathsep) paths = (env or os.environ).get("PATH", "").split(os.pathsep)
for directory in paths: for directory in paths:
candidate = Path(directory) / command candidate = Path(directory) / command
if candidate.exists() and os.access(candidate, os.X_OK): if candidate.exists() and os.access(candidate, os.X_OK):