From 07d542e3ff02ff6d62d97377376e4afe143827e0 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Wed, 1 Apr 2026 17:05:12 +0800 Subject: [PATCH] feat(daemon): add menu bar app showing running tasks - Track active tasks on daemon struct with start time and metadata - Expose active tasks via /health endpoint (active_tasks field) - Show running tasks in `multica daemon status` output - Create native SwiftUI menu bar app (apps/menubar/) that: - Polls daemon health endpoint every 3s - Shows daemon status (running/stopped, uptime, agents, workspaces) - Lists currently running tasks with agent name, issue ID, duration - Allows starting the daemon if stopped - Add `make menubar` build target --- Makefile | 6 +- apps/menubar/.gitignore | 1 + apps/menubar/Package.swift | 13 ++ apps/menubar/Sources/MulticaMenuBar/App.swift | 106 ++++++++++++++ .../Sources/MulticaMenuBar/DaemonClient.swift | 129 ++++++++++++++++++ server/cmd/multica/cmd_daemon.go | 22 +++ server/internal/daemon/daemon.go | 42 +++++- server/internal/daemon/health.go | 36 ++--- 8 files changed, 337 insertions(+), 18 deletions(-) create mode 100644 apps/menubar/.gitignore create mode 100644 apps/menubar/Package.swift create mode 100644 apps/menubar/Sources/MulticaMenuBar/App.swift create mode 100644 apps/menubar/Sources/MulticaMenuBar/DaemonClient.swift diff --git a/Makefile b/Makefile index 95da23152..c82bacde8 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: dev daemon cli multica build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down +.PHONY: dev daemon cli multica build menubar test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down MAIN_ENV_FILE ?= .env WORKTREE_ENV_FILE ?= .env.worktree @@ -135,6 +135,10 @@ build: cd server && go build -o bin/server ./cmd/server cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o bin/multica ./cmd/multica +menubar: + cd apps/menubar && swift build -c release + @echo "✓ Menu bar app built: apps/menubar/.build/release/MulticaMenuBar" + test: $(REQUIRE_ENV) @bash scripts/ensure-postgres.sh "$(ENV_FILE)" diff --git a/apps/menubar/.gitignore b/apps/menubar/.gitignore new file mode 100644 index 000000000..30bcfa4ed --- /dev/null +++ b/apps/menubar/.gitignore @@ -0,0 +1 @@ +.build/ diff --git a/apps/menubar/Package.swift b/apps/menubar/Package.swift new file mode 100644 index 000000000..8b19ac098 --- /dev/null +++ b/apps/menubar/Package.swift @@ -0,0 +1,13 @@ +// swift-tools-version: 5.8 +import PackageDescription + +let package = Package( + name: "MulticaMenuBar", + platforms: [.macOS(.v13)], + targets: [ + .executableTarget( + name: "MulticaMenuBar", + path: "Sources/MulticaMenuBar" + ) + ] +) diff --git a/apps/menubar/Sources/MulticaMenuBar/App.swift b/apps/menubar/Sources/MulticaMenuBar/App.swift new file mode 100644 index 000000000..d80ecf707 --- /dev/null +++ b/apps/menubar/Sources/MulticaMenuBar/App.swift @@ -0,0 +1,106 @@ +import SwiftUI + +@main +struct MulticaMenuBarApp: App { + @StateObject private var client = DaemonClient(autoStart: true) + + var body: some Scene { + MenuBarExtra { + MenuBarView(client: client) + } label: { + MenuBarLabel(client: client) + } + } +} + +/// The menu bar icon and badge. +struct MenuBarLabel: View { + @ObservedObject var client: DaemonClient + + var body: some View { + let taskCount = client.activeTasks.count + if client.isRunning && taskCount > 0 { + Label("\(taskCount)", systemImage: "bolt.fill") + } else if client.isRunning { + Image(systemName: "bolt.fill") + } else { + Image(systemName: "bolt.slash") + } + } +} + +/// The dropdown menu content. +struct MenuBarView: View { + @ObservedObject var client: DaemonClient + + var body: some View { + if client.isRunning, let health = client.health { + Section("Daemon") { + Label("Running (pid \(health.pid))", systemImage: "checkmark.circle.fill") + Label("Uptime: \(health.uptime)", systemImage: "clock") + Label("Agents: \(health.agents.joined(separator: ", "))", systemImage: "cpu") + Label("Workspaces: \(health.workspaces.count)", systemImage: "folder") + } + + Divider() + + if client.activeTasks.isEmpty { + Section("Tasks") { + Label("No running tasks", systemImage: "tray") + .foregroundStyle(.secondary) + } + } else { + Section("Running Tasks (\(client.activeTasks.count))") { + ForEach(client.activeTasks) { task in + VStack(alignment: .leading, spacing: 2) { + Label { + Text("\(task.agentName) / \(task.provider)") + .fontWeight(.medium) + } icon: { + Image(systemName: "gearshape.2.fill") + } + Text("Issue: \(task.shortIssueID) · \(task.duration)") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 2) + } + } + } + } else { + Section("Daemon") { + Label("Stopped", systemImage: "xmark.circle") + .foregroundStyle(.secondary) + } + + Divider() + + Button("Start Daemon") { + startDaemon() + } + } + + Divider() + + Button("Refresh") { + // Force a poll + client.stopPolling() + client.startPolling() + } + .keyboardShortcut("r") + + Button("Quit") { + NSApplication.shared.terminate(nil) + } + .keyboardShortcut("q") + } + + private func startDaemon() { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["multica", "daemon", "start"] + process.standardOutput = nil + process.standardError = nil + try? process.run() + } +} diff --git a/apps/menubar/Sources/MulticaMenuBar/DaemonClient.swift b/apps/menubar/Sources/MulticaMenuBar/DaemonClient.swift new file mode 100644 index 000000000..b269cc380 --- /dev/null +++ b/apps/menubar/Sources/MulticaMenuBar/DaemonClient.swift @@ -0,0 +1,129 @@ +import Foundation + +/// Represents a task currently being executed by the daemon. +struct ActiveTask: Codable, Identifiable { + let id: String + let issueID: String + let workspaceID: String + let agentName: String + let provider: String + let startedAt: Date + + enum CodingKeys: String, CodingKey { + case id + case issueID = "issue_id" + case workspaceID = "workspace_id" + case agentName = "agent_name" + case provider + case startedAt = "started_at" + } + + var shortID: String { + String(id.prefix(8)) + } + + var shortIssueID: String { + String(issueID.prefix(8)) + } + + var duration: String { + let elapsed = Date().timeIntervalSince(startedAt) + let minutes = Int(elapsed) / 60 + let seconds = Int(elapsed) % 60 + if minutes > 0 { + return "\(minutes)m \(seconds)s" + } + return "\(seconds)s" + } +} + +/// Health response from the daemon's local endpoint. +struct HealthResponse: Codable { + let status: String + let pid: Int + let uptime: String + let daemonID: String + let deviceName: String + let serverURL: String + let agents: [String] + let workspaces: [HealthWorkspace] + let activeTasks: [ActiveTask]? + + enum CodingKeys: String, CodingKey { + case status, pid, uptime, agents, workspaces + case daemonID = "daemon_id" + case deviceName = "device_name" + case serverURL = "server_url" + case activeTasks = "active_tasks" + } +} + +struct HealthWorkspace: Codable { + let id: String + let runtimes: [String] +} + +/// Client for polling the daemon's local health endpoint. +@MainActor +final class DaemonClient: ObservableObject { + @Published var isRunning = false + @Published var health: HealthResponse? + @Published var activeTasks: [ActiveTask] = [] + + private let port: Int + private var timer: Timer? + + private lazy var decoder: JSONDecoder = { + let d = JSONDecoder() + d.dateDecodingStrategy = .iso8601 + return d + }() + + init(port: Int = 19514, autoStart: Bool = false) { + self.port = port + if autoStart { + // Defer to next run loop to allow MainActor context. + DispatchQueue.main.async { [weak self] in + self?.startPolling() + } + } + } + + func startPolling(interval: TimeInterval = 3.0) { + poll() + timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + self?.poll() + } + } + } + + func stopPolling() { + timer?.invalidate() + timer = nil + } + + private func poll() { + let url = URL(string: "http://127.0.0.1:\(port)/health")! + var request = URLRequest(url: url) + request.timeoutInterval = 2 + + URLSession.shared.dataTask(with: request) { [weak self] data, response, error in + Task { @MainActor [weak self] in + guard let self else { return } + + guard let data, error == nil, + let resp = try? self.decoder.decode(HealthResponse.self, from: data) else { + self.isRunning = false + self.health = nil + self.activeTasks = [] + return + } + + self.isRunning = resp.status == "running" + self.health = resp + self.activeTasks = resp.activeTasks ?? [] + } + }.resume() + } +} diff --git a/server/cmd/multica/cmd_daemon.go b/server/cmd/multica/cmd_daemon.go index 6c70f40c1..e52c57ed2 100644 --- a/server/cmd/multica/cmd_daemon.go +++ b/server/cmd/multica/cmd_daemon.go @@ -372,6 +372,20 @@ func runDaemonStatus(cmd *cobra.Command, _ []string) error { if ws, ok := health["workspaces"].([]any); ok { fmt.Fprintf(os.Stdout, "Workspaces: %d\n", len(ws)) } + if tasks, ok := health["active_tasks"].([]any); ok && len(tasks) > 0 { + fmt.Fprintf(os.Stdout, "Tasks: %d running\n", len(tasks)) + for _, t := range tasks { + if tm, ok := t.(map[string]any); ok { + agentName, _ := tm["agent_name"].(string) + issueID, _ := tm["issue_id"].(string) + provider, _ := tm["provider"].(string) + if agentName == "" { + agentName = provider + } + fmt.Fprintf(os.Stdout, " • %s working on issue %s\n", agentName, shortIssueID(issueID)) + } + } + } return nil } @@ -421,6 +435,14 @@ func checkDaemonHealthOnPort(ctx context.Context, port int) map[string]any { return result } +// shortIssueID returns the first 8 characters of an issue ID. +func shortIssueID(id string) string { + if len(id) <= 8 { + return id + } + return id[:8] +} + // flagString returns a string flag value or empty string. func flagString(cmd *cobra.Command, name string) string { val, _ := cmd.Flags().GetString(name) diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index 13f0c5da9..55f6d8796 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -24,6 +24,16 @@ type workspaceState struct { runtimeIDs []string } +// ActiveTask represents a task currently being executed by the daemon. +type ActiveTask struct { + ID string `json:"id"` + IssueID string `json:"issue_id"` + WorkspaceID string `json:"workspace_id"` + AgentName string `json:"agent_name"` + Provider string `json:"provider"` + StartedAt time.Time `json:"started_at"` +} + // Daemon is the local agent runtime that polls for and executes tasks. type Daemon struct { cfg Config @@ -34,7 +44,8 @@ type Daemon struct { mu sync.Mutex workspaces map[string]*workspaceState runtimeIndex map[string]Runtime // runtimeID -> Runtime for provider lookups - reloading sync.Mutex // prevents concurrent reloadWorkspaces + activeTasks map[string]*ActiveTask // taskID -> ActiveTask + reloading sync.Mutex // prevents concurrent reloadWorkspaces } // New creates a new Daemon instance. @@ -47,6 +58,7 @@ func New(cfg Config, logger *slog.Logger) *Daemon { logger: logger, workspaces: make(map[string]*workspaceState), runtimeIndex: make(map[string]Runtime), + activeTasks: make(map[string]*ActiveTask), } } @@ -658,6 +670,17 @@ func (d *Daemon) pollLoop(ctx context.Context) error { } } +// GetActiveTasks returns a snapshot of currently running tasks. +func (d *Daemon) GetActiveTasks() []*ActiveTask { + d.mu.Lock() + defer d.mu.Unlock() + tasks := make([]*ActiveTask, 0, len(d.activeTasks)) + for _, t := range d.activeTasks { + tasks = append(tasks, t) + } + return tasks +} + func (d *Daemon) handleTask(ctx context.Context, task Task) { d.mu.Lock() rt := d.runtimeIndex[task.RuntimeID] @@ -672,6 +695,23 @@ func (d *Daemon) handleTask(ctx context.Context, task Task) { } taskLog.Info("picked task", "issue", task.IssueID, "agent", agentName, "provider", provider) + // Track active task. + d.mu.Lock() + d.activeTasks[task.ID] = &ActiveTask{ + ID: task.ID, + IssueID: task.IssueID, + WorkspaceID: task.WorkspaceID, + AgentName: agentName, + Provider: provider, + StartedAt: time.Now(), + } + d.mu.Unlock() + defer func() { + d.mu.Lock() + delete(d.activeTasks, task.ID) + d.mu.Unlock() + }() + if err := d.client.StartTask(ctx, task.ID); err != nil { taskLog.Error("start task failed", "error", err) if failErr := d.client.FailTask(ctx, task.ID, fmt.Sprintf("start task failed: %s", err.Error())); failErr != nil { diff --git a/server/internal/daemon/health.go b/server/internal/daemon/health.go index 4f06d6e78..ace0f67b4 100644 --- a/server/internal/daemon/health.go +++ b/server/internal/daemon/health.go @@ -14,14 +14,15 @@ import ( // HealthResponse is returned by the daemon's local health endpoint. type HealthResponse struct { - Status string `json:"status"` - PID int `json:"pid"` - Uptime string `json:"uptime"` - DaemonID string `json:"daemon_id"` - DeviceName string `json:"device_name"` - ServerURL string `json:"server_url"` - Agents []string `json:"agents"` - Workspaces []healthWorkspace `json:"workspaces"` + Status string `json:"status"` + PID int `json:"pid"` + Uptime string `json:"uptime"` + DaemonID string `json:"daemon_id"` + DeviceName string `json:"device_name"` + ServerURL string `json:"server_url"` + Agents []string `json:"agents"` + Workspaces []healthWorkspace `json:"workspaces"` + ActiveTasks []*ActiveTask `json:"active_tasks"` } type healthWorkspace struct { @@ -69,15 +70,18 @@ func (d *Daemon) serveHealth(ctx context.Context, ln net.Listener, startedAt tim agents = append(agents, name) } + activeTasks := d.GetActiveTasks() + resp := HealthResponse{ - Status: "running", - PID: os.Getpid(), - Uptime: time.Since(startedAt).Truncate(time.Second).String(), - DaemonID: d.cfg.DaemonID, - DeviceName: d.cfg.DeviceName, - ServerURL: d.cfg.ServerBaseURL, - Agents: agents, - Workspaces: wsList, + Status: "running", + PID: os.Getpid(), + Uptime: time.Since(startedAt).Truncate(time.Second).String(), + DaemonID: d.cfg.DaemonID, + DeviceName: d.cfg.DeviceName, + ServerURL: d.cfg.ServerBaseURL, + Agents: agents, + Workspaces: wsList, + ActiveTasks: activeTasks, } w.Header().Set("Content-Type", "application/json")