feat(@applications/@claire-tray): ✨ initial commit — native menu-bar tray for Claire
Client + watchdog menu-bar app: polls the claire daemon (status/fleet/ budget/health), shows NEEDS-YOU, and auto-recovers the daemon on silent DB-write failure via launchctl kickstart. Built on LilithMenuBar / LilithTrayResources. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
13283c6a60
13 changed files with 658 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
.build/
|
||||
22
Makefile
Normal file
22
Makefile
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
.PHONY: build release run install uninstall clean
|
||||
|
||||
build:
|
||||
swift build
|
||||
|
||||
release:
|
||||
swift build -c release --product ClaireTrayApp
|
||||
|
||||
# Run the debug binary in the foreground (Ctrl-C to quit). Useful for
|
||||
# iterating on the menu without going through the launchd install.
|
||||
run: build
|
||||
./.build/debug/ClaireTrayApp
|
||||
|
||||
install:
|
||||
./deploy/install.sh
|
||||
|
||||
uninstall:
|
||||
./deploy/install.sh --uninstall
|
||||
|
||||
clean:
|
||||
swift package clean
|
||||
rm -rf .build
|
||||
29
Package.swift
Normal file
29
Package.swift
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// swift-tools-version: 5.9
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "ClaireTray",
|
||||
platforms: [
|
||||
.macOS(.v13)
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../../@packages/@swift/@macos/menu-bar"),
|
||||
.package(path: "../../@packages/@tray/tray-resources"),
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "ClaireTrayApp",
|
||||
dependencies: [
|
||||
.product(name: "LilithMenuBar", package: "menu-bar"),
|
||||
.product(name: "LilithTrayResources", package: "tray-resources"),
|
||||
],
|
||||
path: "src/client",
|
||||
exclude: ["Resources"]
|
||||
),
|
||||
.testTarget(
|
||||
name: "ClaireTrayAppTests",
|
||||
dependencies: ["ClaireTrayApp"],
|
||||
path: "Tests/ClaireTrayAppTests"
|
||||
),
|
||||
]
|
||||
)
|
||||
48
Tests/ClaireTrayAppTests/WatchdogTests.swift
Normal file
48
Tests/ClaireTrayAppTests/WatchdogTests.swift
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import XCTest
|
||||
@testable import ClaireTrayApp
|
||||
|
||||
final class WatchdogTests: XCTestCase {
|
||||
private let t0 = Date(timeIntervalSince1970: 1_000_000)
|
||||
|
||||
func testHealthyStaysOK() {
|
||||
var w = Watchdog(failureThreshold: 3, cooldown: 120, maxAttempts: 5)
|
||||
XCTAssertEqual(w.record(healthy: true, now: t0), .ok)
|
||||
XCTAssertEqual(w.record(healthy: true, now: t0.addingTimeInterval(25)), .ok)
|
||||
}
|
||||
|
||||
func testRecoversOnlyAfterThreshold() {
|
||||
var w = Watchdog(failureThreshold: 3, cooldown: 120, maxAttempts: 5)
|
||||
XCTAssertEqual(w.record(healthy: false, now: t0), .watching) // 1
|
||||
XCTAssertEqual(w.record(healthy: false, now: t0.addingTimeInterval(25)), .watching) // 2
|
||||
XCTAssertEqual(w.record(healthy: false, now: t0.addingTimeInterval(50)), .recover) // 3 → kick
|
||||
}
|
||||
|
||||
func testCooldownSuppressesRepeatKicks() {
|
||||
var w = Watchdog(failureThreshold: 1, cooldown: 120, maxAttempts: 5)
|
||||
XCTAssertEqual(w.record(healthy: false, now: t0), .recover) // kick at t0
|
||||
// Within cooldown: no second kick.
|
||||
XCTAssertEqual(w.record(healthy: false, now: t0.addingTimeInterval(60)), .watching)
|
||||
// After cooldown: kick again.
|
||||
XCTAssertEqual(w.record(healthy: false, now: t0.addingTimeInterval(130)), .recover)
|
||||
}
|
||||
|
||||
func testGivesUpAfterMaxAttempts() {
|
||||
var w = Watchdog(failureThreshold: 1, cooldown: 0, maxAttempts: 2)
|
||||
XCTAssertEqual(w.record(healthy: false, now: t0), .recover) // attempt 1
|
||||
XCTAssertEqual(w.record(healthy: false, now: t0.addingTimeInterval(1)), .recover) // attempt 2
|
||||
XCTAssertEqual(w.record(healthy: false, now: t0.addingTimeInterval(2)), .gaveUp) // ceiling hit
|
||||
XCTAssertTrue(w.gaveUp)
|
||||
}
|
||||
|
||||
func testRecoveryResetsAfterDaemonHealthyAgain() {
|
||||
var w = Watchdog(failureThreshold: 1, cooldown: 0, maxAttempts: 2)
|
||||
_ = w.record(healthy: false, now: t0) // recover
|
||||
_ = w.record(healthy: false, now: t0.addingTimeInterval(1)) // recover
|
||||
_ = w.record(healthy: false, now: t0.addingTimeInterval(2)) // gaveUp
|
||||
XCTAssertTrue(w.gaveUp)
|
||||
XCTAssertEqual(w.record(healthy: true, now: t0.addingTimeInterval(3)), .ok) // recovered
|
||||
XCTAssertFalse(w.gaveUp)
|
||||
// Counters reset → a fresh failure run can recover again.
|
||||
XCTAssertEqual(w.record(healthy: false, now: t0.addingTimeInterval(4)), .recover)
|
||||
}
|
||||
}
|
||||
6
VERSION.json
Normal file
6
VERSION.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"version": "0.1.0",
|
||||
"major": 0,
|
||||
"merges": 0,
|
||||
"builds": 1
|
||||
}
|
||||
130
deploy/install.sh
Executable file
130
deploy/install.sh
Executable file
|
|
@ -0,0 +1,130 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Build, sign, bundle, and install the ClaireTray menu-bar app as a LaunchAgent
|
||||
# on this machine (plum). Local-only — no remote rsync. Idempotent.
|
||||
#
|
||||
# ./deploy/install.sh build + install + (re)load the LaunchAgent
|
||||
# ./deploy/install.sh --uninstall bootout + remove the LaunchAgent and bundle
|
||||
#
|
||||
# Signing: a personal Apple Development cert (override with
|
||||
# CLAIRE_TRAY_SIGNING_IDENTITY). The tray holds NO TCC permissions, so the
|
||||
# stable-keychain dance mac-sync uses (to preserve permission grants) is
|
||||
# unnecessary here.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
APP_NAME="ClaireTrayApp" # executable (SwiftPM product)
|
||||
DISPLAY_NAME="Claire" # CFBundleName
|
||||
BUNDLE_ID="com.lilith.claire-tray"
|
||||
LABEL="com.lilith.claire-tray"
|
||||
APP_BUNDLE="$HOME/Applications/ClaireTray.app"
|
||||
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
|
||||
LOG_DIR="$HOME/Library/Application Support/ClaireTray"
|
||||
CONFIG_DIR="$HOME/.config/$BUNDLE_ID"
|
||||
CONFIG_FILE="$CONFIG_DIR/config.json"
|
||||
CLAIRE_VENV="${CLAIRE_VENV:-$HOME/Code/@projects/@claire/.venv}"
|
||||
SIGNING_IDENTITY="${CLAIRE_TRAY_SIGNING_IDENTITY:-Apple Development: hinataliesterling@icloud.com (X8424J5CTB)}"
|
||||
|
||||
say() { printf '\033[1;35m▸\033[0m %s\n' "$*"; }
|
||||
|
||||
uninstall() {
|
||||
say "Uninstalling $LABEL"
|
||||
launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null || true
|
||||
rm -f "$PLIST"
|
||||
rm -rf "$APP_BUNDLE"
|
||||
say "Removed bundle + LaunchAgent (config + logs left at $CONFIG_DIR, $LOG_DIR)"
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "--uninstall" ]]; then
|
||||
uninstall
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- 1. build --------------------------------------------------------------
|
||||
say "Building release binary"
|
||||
( cd "$ROOT" && swift build -c release --product "$APP_NAME" )
|
||||
BIN="$ROOT/.build/release/$APP_NAME"
|
||||
[[ -f "$BIN" ]] || { echo "binary not found: $BIN" >&2; exit 1; }
|
||||
|
||||
# --- 2. assemble bundle ----------------------------------------------------
|
||||
say "Assembling $APP_BUNDLE"
|
||||
MACOS_DIR="$APP_BUNDLE/Contents/MacOS"
|
||||
RES_DIR="$APP_BUNDLE/Contents/Resources"
|
||||
mkdir -p "$MACOS_DIR" "$RES_DIR"
|
||||
rm -rf "$MACOS_DIR"/*.bundle "$RES_DIR"/*.bundle
|
||||
cp "$BIN" "$MACOS_DIR/$APP_NAME"
|
||||
chmod +x "$MACOS_DIR/$APP_NAME"
|
||||
# SPM resource bundles (the generated tray-resources icons).
|
||||
for b in "$ROOT/.build/release/"*.bundle; do
|
||||
[[ -d "$b" ]] || continue
|
||||
cp -R "$b" "$RES_DIR/$(basename "$b")"
|
||||
say " bundled $(basename "$b")"
|
||||
done
|
||||
|
||||
# --- 3. Info.plist from template + VERSION.json ----------------------------
|
||||
VERSION="0.1.0"; BUILD="1"
|
||||
if [[ -f "$ROOT/VERSION.json" ]]; then
|
||||
VERSION="$(python3 -c "import json;print(json.load(open('$ROOT/VERSION.json')).get('version','0.1.0'))")"
|
||||
BUILD="$(python3 -c "import json;print(json.load(open('$ROOT/VERSION.json')).get('builds',1))")"
|
||||
fi
|
||||
sed -e "s/{{VERSION}}/$VERSION/g" -e "s/{{BUILD}}/$BUILD/g" \
|
||||
"$ROOT/src/client/Resources/Info.plist.template" \
|
||||
> "$APP_BUNDLE/Contents/Info.plist"
|
||||
|
||||
# --- 4. codesign -----------------------------------------------------------
|
||||
say "Signing as $BUNDLE_ID"
|
||||
if ! codesign --force --identifier "$BUNDLE_ID" -s "$SIGNING_IDENTITY" "$APP_BUNDLE" 2>/dev/null; then
|
||||
say " signing identity unavailable — falling back to ad-hoc"
|
||||
codesign --force --identifier "$BUNDLE_ID" -s - "$APP_BUNDLE"
|
||||
fi
|
||||
codesign --verify --strict "$APP_BUNDLE"
|
||||
|
||||
# --- 5. config.json: base URL from claire's OWN config loader --------------
|
||||
# Reuse claire's config + `_client_host` (wildcard→loopback) rather than
|
||||
# re-parsing the TOML or guessing its path — single source of truth.
|
||||
say "Writing $CONFIG_FILE"
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
BASE_URL="$("$CLAIRE_VENV/bin/python" -c "
|
||||
from claire.config import load_or_init
|
||||
from claire.orchestrator.bootstrap import _client_host
|
||||
c = load_or_init()
|
||||
print(f'http://{_client_host(c.web.host)}:{c.web.port}')
|
||||
" 2>/dev/null || echo "http://127.0.0.1:8765")"
|
||||
printf '{\n "baseURL": "%s",\n "daemonLabel": "com.lilith.clare-serve"\n}\n' "$BASE_URL" > "$CONFIG_FILE"
|
||||
say " baseURL = $BASE_URL"
|
||||
|
||||
# --- 6. LaunchAgent --------------------------------------------------------
|
||||
say "Installing LaunchAgent $LABEL"
|
||||
mkdir -p "$LOG_DIR" "$(dirname "$PLIST")"
|
||||
cat > "$PLIST" <<PLISTEOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key><string>$LABEL</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array><string>$MACOS_DIR/$APP_NAME</string></array>
|
||||
<key>RunAtLoad</key><true/>
|
||||
<key>KeepAlive</key>
|
||||
<dict>
|
||||
<key>SuccessfulExit</key><false/>
|
||||
<key>Crashed</key><true/>
|
||||
</dict>
|
||||
<key>StandardOutPath</key><string>$LOG_DIR/stdout.log</string>
|
||||
<key>StandardErrorPath</key><string>$LOG_DIR/stderr.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
PLISTEOF
|
||||
|
||||
DOMAIN="gui/$(id -u)"
|
||||
launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || true
|
||||
# Wait for full unload — bootstrap races bootout and returns EIO (5) if the
|
||||
# label is still registered.
|
||||
for _ in $(seq 1 20); do
|
||||
launchctl print "$DOMAIN/$LABEL" >/dev/null 2>&1 || break
|
||||
sleep 0.2
|
||||
done
|
||||
# One retry covers the residual transient EIO.
|
||||
launchctl bootstrap "$DOMAIN" "$PLIST" 2>/dev/null \
|
||||
|| { sleep 1; launchctl bootstrap "$DOMAIN" "$PLIST"; }
|
||||
say "Done — Claire menu-bar icon should appear shortly. Logs: $LOG_DIR"
|
||||
128
src/client/AppDelegate.swift
Normal file
128
src/client/AppDelegate.swift
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import AppKit
|
||||
import LilithMenuBar
|
||||
import LilithTrayResources
|
||||
|
||||
/// Menu-bar agent: polls the claire daemon, renders status, offers controls,
|
||||
/// and runs the recovery watchdog. All state is `@MainActor`-confined; network
|
||||
/// work hops off via async `ClaireClient`, and blocking OS calls (launchctl)
|
||||
/// run in detached tasks.
|
||||
@MainActor
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
private let config = TrayConfig.load()
|
||||
private let client: ClaireClient?
|
||||
private var agent: MenuBarAgent?
|
||||
private var watchdog = Watchdog()
|
||||
private var timer: Timer?
|
||||
private var latest = FleetSnapshot(
|
||||
healthy: false, needsYou: 0, liveSessions: 0, totalCap: 0,
|
||||
budgetFraction: 0, budgetCapped: false, reachable: false
|
||||
)
|
||||
|
||||
override init() {
|
||||
client = ClaireClient(baseURLString: config.baseURL)
|
||||
super.init()
|
||||
}
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
let agent = MenuBarAgent(icon: icon(for: latest), menu: menuItems())
|
||||
agent.install(activationPolicy: .accessory)
|
||||
self.agent = agent
|
||||
NSLog("ClaireTray: status item installed (baseURL=\(config.baseURL))")
|
||||
|
||||
let t = Timer.scheduledTimer(withTimeInterval: 25, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in await self?.refresh() }
|
||||
}
|
||||
t.tolerance = 5
|
||||
timer = t
|
||||
Task { @MainActor [weak self] in await self?.refresh() } // immediate first poll
|
||||
}
|
||||
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false }
|
||||
|
||||
// MARK: - Poll + render
|
||||
|
||||
private func refresh() async {
|
||||
guard let client else { return }
|
||||
latest = await client.snapshot()
|
||||
agent?.updateIcon(icon(for: latest))
|
||||
agent?.updateMenu(menuItems())
|
||||
|
||||
if case .recover = watchdog.record(healthy: latest.healthy, now: Date()) {
|
||||
NSLog("ClaireTray: watchdog recovering \(config.daemonLabel)")
|
||||
let label = config.daemonLabel
|
||||
Task.detached { DaemonControl.restartDaemon(label: label) }
|
||||
}
|
||||
}
|
||||
|
||||
private func icon(for s: FleetSnapshot) -> MenuBarIcon {
|
||||
let color: TrayColor
|
||||
if !s.reachable || !s.healthy {
|
||||
color = .red
|
||||
} else if s.needsYou > 0 || s.budgetCapped {
|
||||
color = .yellow
|
||||
} else {
|
||||
color = .green
|
||||
}
|
||||
// coloredImage (not .image) so the green/yellow/red status tint is kept —
|
||||
// .image renders as a monochrome template and would discard the color.
|
||||
return .coloredImage(TrayResources.image(template: .claireMark, color: color, pointSize: 18))
|
||||
}
|
||||
|
||||
private func menuItems() -> [MenuBarItem] {
|
||||
let s = latest
|
||||
var items: [MenuBarItem] = []
|
||||
if !s.reachable {
|
||||
items.append(.label(title: "Claire — unreachable"))
|
||||
} else {
|
||||
items.append(.label(title: s.healthy ? "Claire — healthy" : "Claire — DB unwritable ⚠"))
|
||||
items.append(.label(title: "fleet: \(s.liveSessions)/\(s.totalCap) sessions"))
|
||||
let pct = Int((s.budgetFraction * 100).rounded())
|
||||
items.append(.label(title: s.budgetCapped ? "budget: \(pct)% — OVER CAP" : "budget: \(pct)% of cap"))
|
||||
items.append(.label(title: s.needsYou > 0 ? "⚠ NEEDS YOU: \(s.needsYou)" : "needs you: 0"))
|
||||
}
|
||||
if watchdog.gaveUp {
|
||||
items.append(.label(title: "watchdog: gave up — see logs"))
|
||||
}
|
||||
items.append(.separator)
|
||||
items.append(.action(title: "Open Dashboard") { [weak self] in
|
||||
Task { @MainActor [weak self] in self?.openDashboard() }
|
||||
})
|
||||
items.append(.action(title: "Force Rounds Tick") { [weak self] in
|
||||
Task { @MainActor [weak self] in self?.forceRounds() }
|
||||
})
|
||||
items.append(.action(title: "Restart Claire Daemon") { [weak self] in
|
||||
Task { @MainActor [weak self] in self?.restartDaemon() }
|
||||
})
|
||||
items.append(.separator)
|
||||
items.append(.action(title: "Refresh Now") { [weak self] in
|
||||
Task { @MainActor [weak self] in await self?.refresh() }
|
||||
})
|
||||
items.append(.action(title: "Quit ClaireTray", key: "q") {
|
||||
Task { @MainActor in NSApp.terminate(nil) }
|
||||
})
|
||||
return items
|
||||
}
|
||||
|
||||
// MARK: - Actions (MainActor; offload blocking/async work)
|
||||
|
||||
private func openDashboard() {
|
||||
DaemonControl.openDashboard(baseURL: config.baseURL)
|
||||
}
|
||||
|
||||
private func restartDaemon() {
|
||||
let label = config.daemonLabel
|
||||
Task.detached { DaemonControl.restartDaemon(label: label) }
|
||||
}
|
||||
|
||||
private func forceRounds() {
|
||||
guard let client else { return }
|
||||
Task {
|
||||
do {
|
||||
try await client.postRoundsTick()
|
||||
NSLog("ClaireTray: rounds tick posted")
|
||||
} catch {
|
||||
NSLog("ClaireTray: rounds tick failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
128
src/client/ClaireClient.swift
Normal file
128
src/client/ClaireClient.swift
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import Foundation
|
||||
|
||||
// MARK: - Wire models (decode only the fields the menu uses; extra keys ignored)
|
||||
|
||||
struct StatusResponse: Codable, Sendable {
|
||||
struct Project: Codable, Sendable {
|
||||
struct Counts: Codable, Sendable {
|
||||
let userReview: Int
|
||||
enum CodingKeys: String, CodingKey { case userReview = "user_review" }
|
||||
}
|
||||
let name: String
|
||||
let counts: Counts
|
||||
}
|
||||
let projects: [Project]
|
||||
}
|
||||
|
||||
struct FleetLoad: Codable, Sendable {
|
||||
struct Host: Codable, Sendable {
|
||||
let host: String
|
||||
let liveSessions: Int
|
||||
let cap: Int
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case host
|
||||
case liveSessions = "live_sessions"
|
||||
case cap
|
||||
}
|
||||
}
|
||||
let hosts: [Host]
|
||||
}
|
||||
|
||||
struct Budget: Codable, Sendable {
|
||||
let fractionUsed: Double
|
||||
let dailyTokenCap: Int
|
||||
let overCap: Bool
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case fractionUsed = "fraction_used"
|
||||
case dailyTokenCap = "daily_token_cap"
|
||||
case overCap = "over_cap"
|
||||
}
|
||||
}
|
||||
|
||||
/// Aggregated view of one poll cycle — what the menu + icon render from.
|
||||
struct FleetSnapshot: Sendable {
|
||||
var healthy: Bool
|
||||
var needsYou: Int
|
||||
var liveSessions: Int
|
||||
var totalCap: Int
|
||||
var budgetFraction: Double
|
||||
var budgetCapped: Bool
|
||||
var reachable: Bool
|
||||
}
|
||||
|
||||
// MARK: - Client
|
||||
|
||||
/// Thin async HTTP client for the local claire daemon. No auth — the API is
|
||||
/// unauthenticated on the WireGuard LAN, so `URLSession` + `Codable` is all we
|
||||
/// need (no reason to drag in agent-core's bearer/keychain machinery). Sendable
|
||||
/// (immutable), so it's safe to call from any task.
|
||||
struct ClaireClient: Sendable {
|
||||
let baseURL: URL
|
||||
private let session: URLSession
|
||||
|
||||
init?(baseURLString: String) {
|
||||
guard let url = URL(string: baseURLString) else { return nil }
|
||||
self.baseURL = url
|
||||
let cfg = URLSessionConfiguration.ephemeral
|
||||
cfg.timeoutIntervalForRequest = 5 // a wedged daemon must fail fast
|
||||
cfg.waitsForConnectivity = false
|
||||
self.session = URLSession(configuration: cfg)
|
||||
}
|
||||
|
||||
private func get<T: Decodable>(_ path: String, as _: T.Type) async throws -> T {
|
||||
let (data, resp) = try await session.data(from: baseURL.appendingPathComponent(path))
|
||||
try Self.check(resp)
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
}
|
||||
|
||||
private static func check(_ resp: URLResponse) throws {
|
||||
guard let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
|
||||
let code = (resp as? HTTPURLResponse)?.statusCode ?? -1
|
||||
throw URLError(.badServerResponse, userInfo: ["status": code])
|
||||
}
|
||||
}
|
||||
|
||||
/// Deep health: real DB round-trip. Returns false on 503 or unreachable.
|
||||
func deepHealthy() async -> Bool {
|
||||
do {
|
||||
let (_, resp) = try await session.data(
|
||||
from: baseURL.appendingPathComponent("api/v1/health/deep"))
|
||||
try Self.check(resp)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire one rounds tick (best-effort; the endpoint returns 202 immediately).
|
||||
func postRoundsTick() async throws {
|
||||
var req = URLRequest(url: baseURL.appendingPathComponent("api/v1/rounds/tick"))
|
||||
req.httpMethod = "POST"
|
||||
let (_, resp) = try await session.data(for: req)
|
||||
try Self.check(resp)
|
||||
}
|
||||
|
||||
/// One poll cycle. Health is authoritative for reachability; the other
|
||||
/// fetches degrade gracefully so a single slow endpoint can't blank the menu.
|
||||
func snapshot() async -> FleetSnapshot {
|
||||
async let healthy = deepHealthy()
|
||||
async let status = try? get("api/v1/status", as: StatusResponse.self)
|
||||
async let fleet = try? get("api/v1/fleet/load", as: FleetLoad.self)
|
||||
async let budget = try? get("api/v1/budget", as: Budget.self)
|
||||
|
||||
let s = await status
|
||||
let f = await fleet
|
||||
let b = await budget
|
||||
let h = await healthy
|
||||
|
||||
return FleetSnapshot(
|
||||
healthy: h,
|
||||
needsYou: s?.projects.reduce(0) { $0 + $1.counts.userReview } ?? 0,
|
||||
liveSessions: f?.hosts.reduce(0) { $0 + $1.liveSessions } ?? 0,
|
||||
totalCap: f?.hosts.reduce(0) { $0 + $1.cap } ?? 0,
|
||||
budgetFraction: b?.fractionUsed ?? 0,
|
||||
budgetCapped: b?.overCap ?? false,
|
||||
reachable: h || s != nil || f != nil || b != nil
|
||||
)
|
||||
}
|
||||
}
|
||||
15
src/client/ClaireTrayApp.swift
Normal file
15
src/client/ClaireTrayApp.swift
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import AppKit
|
||||
|
||||
/// Entry point. A pure menu-bar (`.accessory`) agent — no Dock icon, no main
|
||||
/// window. Wiring + menu live in `AppDelegate`.
|
||||
@main
|
||||
enum ClaireTrayApp {
|
||||
static func main() {
|
||||
let app = NSApplication.shared
|
||||
let delegate = AppDelegate()
|
||||
app.delegate = delegate
|
||||
app.setActivationPolicy(.accessory)
|
||||
NSLog("ClaireTray: starting")
|
||||
app.run()
|
||||
}
|
||||
}
|
||||
35
src/client/DaemonControl.swift
Normal file
35
src/client/DaemonControl.swift
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import AppKit
|
||||
import Foundation
|
||||
|
||||
/// OS-level control of the `claire web` daemon and dashboard. All entry points
|
||||
/// are nonisolated and safe to call from a detached task — they shell out to
|
||||
/// `launchctl` / open a URL and don't touch UI state.
|
||||
enum DaemonControl {
|
||||
/// Restart the daemon: `launchctl kickstart -k gui/<uid>/<label>`.
|
||||
/// `-k` kills the running instance first, then relaunches it under launchd.
|
||||
/// This is the watchdog's recovery action and the "Restart" menu item.
|
||||
static func restartDaemon(label: String) {
|
||||
let status = run(["/bin/launchctl", "kickstart", "-k", "gui/\(getuid())/\(label)"])
|
||||
NSLog("ClaireTray: kickstart \(label) -> exit \(status)")
|
||||
}
|
||||
|
||||
static func openDashboard(baseURL: String) {
|
||||
guard let url = URL(string: baseURL) else { return }
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private static func run(_ argv: [String]) -> Int32 {
|
||||
let p = Process()
|
||||
p.executableURL = URL(fileURLWithPath: argv[0])
|
||||
p.arguments = Array(argv.dropFirst())
|
||||
do {
|
||||
try p.run()
|
||||
p.waitUntilExit()
|
||||
return p.terminationStatus
|
||||
} catch {
|
||||
NSLog("ClaireTray: process \(argv) failed: \(error)")
|
||||
return -1
|
||||
}
|
||||
}
|
||||
}
|
||||
35
src/client/Resources/Info.plist.template
Normal file
35
src/client/Resources/Info.plist.template
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>ClaireTrayApp</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.lilith.claire-tray</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Claire</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Claire</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>{{BUILD}}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>{{VERSION}}</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>13.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<!-- The tray polls the daemon over plain HTTP on the WireGuard LAN;
|
||||
ATS blocks cleartext by default, so allow local networking. -->
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
28
src/client/TrayConfig.swift
Normal file
28
src/client/TrayConfig.swift
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import Foundation
|
||||
|
||||
/// Runtime config written by `deploy/install.sh` to
|
||||
/// `~/.config/com.lilith.claire-tray/config.json`. Derived from claire's own
|
||||
/// config so the tray always points at the daemon's real bind address.
|
||||
struct TrayConfig: Codable, Sendable {
|
||||
let baseURL: String
|
||||
let daemonLabel: String
|
||||
|
||||
/// Fallback if the file is missing (fresh checkout / pre-install run).
|
||||
static let fallback = TrayConfig(
|
||||
baseURL: "http://127.0.0.1:8765",
|
||||
daemonLabel: "com.lilith.clare-serve"
|
||||
)
|
||||
|
||||
static func load() -> TrayConfig {
|
||||
let path = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".config/com.lilith.claire-tray/config.json")
|
||||
guard
|
||||
let data = try? Data(contentsOf: path),
|
||||
let cfg = try? JSONDecoder().decode(TrayConfig.self, from: data)
|
||||
else {
|
||||
NSLog("ClaireTray: config.json missing/unreadable — using fallback")
|
||||
return .fallback
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
}
|
||||
53
src/client/Watchdog.swift
Normal file
53
src/client/Watchdog.swift
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import Foundation
|
||||
|
||||
/// Decides when to recover a wedged daemon, with a thrash guard.
|
||||
///
|
||||
/// Recovery fires only after `failureThreshold` consecutive unhealthy polls,
|
||||
/// then enters a `cooldown` (no further kicks until it elapses). After
|
||||
/// `maxAttempts` kicks without recovery it **gives up** and logs, rather than
|
||||
/// fighting launchd's own restart throttle forever.
|
||||
///
|
||||
/// IMPORTANT: `kickstart` is *mitigation*, not a fix. It gives the daemon a
|
||||
/// fresh process (hence a fresh DB fd, which resolves the 2026-05-30 silent
|
||||
/// outage), but it does not address whatever rewrote `claire.db` underneath
|
||||
/// the long-lived server. If that recurs, the tray will keep restarting it.
|
||||
struct Watchdog {
|
||||
let failureThreshold: Int
|
||||
let cooldown: TimeInterval
|
||||
let maxAttempts: Int
|
||||
|
||||
private var consecutiveFailures = 0
|
||||
private var attempts = 0
|
||||
private var lastKickAt: Date?
|
||||
private(set) var gaveUp = false
|
||||
|
||||
init(failureThreshold: Int = 3, cooldown: TimeInterval = 120, maxAttempts: Int = 5) {
|
||||
self.failureThreshold = failureThreshold
|
||||
self.cooldown = cooldown
|
||||
self.maxAttempts = maxAttempts
|
||||
}
|
||||
|
||||
enum Decision: Equatable { case ok, watching, recover, gaveUp }
|
||||
|
||||
/// Feed each poll's health verdict. Returns whether to kick the daemon now.
|
||||
mutating func record(healthy: Bool, now: Date) -> Decision {
|
||||
if healthy {
|
||||
consecutiveFailures = 0
|
||||
attempts = 0
|
||||
gaveUp = false
|
||||
return .ok
|
||||
}
|
||||
consecutiveFailures += 1
|
||||
if consecutiveFailures < failureThreshold { return .watching }
|
||||
if gaveUp { return .gaveUp }
|
||||
if let last = lastKickAt, now.timeIntervalSince(last) < cooldown { return .watching }
|
||||
if attempts >= maxAttempts {
|
||||
gaveUp = true
|
||||
NSLog("ClaireTray: watchdog giving up after \(attempts) kicks — daemon still unhealthy")
|
||||
return .gaveUp
|
||||
}
|
||||
attempts += 1
|
||||
lastKickAt = now
|
||||
return .recover
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue