mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
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
This commit is contained in:
6
Makefile
6
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)"
|
||||
|
||||
1
apps/menubar/.gitignore
vendored
Normal file
1
apps/menubar/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.build/
|
||||
13
apps/menubar/Package.swift
Normal file
13
apps/menubar/Package.swift
Normal file
@@ -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"
|
||||
)
|
||||
]
|
||||
)
|
||||
106
apps/menubar/Sources/MulticaMenuBar/App.swift
Normal file
106
apps/menubar/Sources/MulticaMenuBar/App.swift
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
129
apps/menubar/Sources/MulticaMenuBar/DaemonClient.swift
Normal file
129
apps/menubar/Sources/MulticaMenuBar/DaemonClient.swift
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user