diff --git a/README.md b/README.md index c3174de..2b606f8 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,14 @@ Native macOS app, with a focused iOS companion built on the same core. bars — GPU (CPU where there is no GPU to read) and memory — mint with headroom, amber busy, coral flat out; temperature and GPU watts under the name; hover for details, click a remote to open an ssh tile. A tile running `ssh` is named after its machine and says so in its footer, in place of the local folder. +- **Fleet: shared hardware and agents' jobs.** Point Settings → Fleet at a + [LLooM](https://github.com/Enntity/lloom) head and its leases appear in a band over the board: + one chip per resource, showing who holds it, how long they have left and who is waiting. Jobs + that agents report (`lloom job start`, `progress`, `done`) become tiles, with their latest note, + progress bar and metrics on the card and their notes as the transcript. A finished, failed or + silent job lands in the Needs-you lane and notifies you, and so does a lease that is running out + or whose restore failed. Opening a job goes to the agent's own conversation when it reported one + (`--session`). The Jobs chip filters them. The admin key stays in your Keychain. - **Shut down & resume.** Quit Tessera (or shut tiles down) and every agent comes back in the same conversation — including ones you typed into a shell yourself, and through your own launchers (`codex-work` resumes as `codex-work resume `). A tile that is shut down, exited or failed diff --git a/Sources/TesseraHost/AgentAppWatcher.swift b/Sources/TesseraHost/AgentAppWatcher.swift index 2137306..39e7954 100644 --- a/Sources/TesseraHost/AgentAppWatcher.swift +++ b/Sources/TesseraHost/AgentAppWatcher.swift @@ -17,6 +17,11 @@ public struct AgentAppSession: Identifiable, Equatable, Sendable { /// The desktop app's own one-line turn summary (Claude writes these). public var summary: String? public var lastActivityAt: Date + /// 0...1, for work that reports how far along it is (LLooM jobs). + public var progress: Double? = nil + /// The conversation this one reports for (a LLooM job's `claude:` / `codex:`): opening + /// it goes there. + public var link: String? = nil } /// One session's latest state, observed on its own so a busy session re-renders only its tile. @@ -46,6 +51,9 @@ public final class AgentAppWatcher { @ObservationIgnored private let scanner = TranscriptScanner() @ObservationIgnored private var scanning = false + /// What the last scan found, and what other sources publish (LLooM jobs): `sessions` is both. + @ObservationIgnored private var scanned: [String: AgentAppSession] = [:] + @ObservationIgnored private var external: [String: AgentAppSession] = [:] public init() {} @@ -72,13 +80,26 @@ public final class AgentAppWatcher { } } - /// Writes only what changed: rewriting unchanged data would re-render the whole board every scan. + /// Sessions from another source, shown beside the ones found on disk. They join the board + /// with the first scan, so what had finished before Tessera opened doesn't light up. + public func setExternal(_ sessions: [AgentAppSession]) { + external = Dictionary(sessions.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) + if hasScanned { publish(rateLimits: nil) } + } + private func apply(_ result: TranscriptScanner.Result) { scanning = false hasScanned = true + scanned = result.sessions + publish(rateLimits: result.rateLimits) + } + + /// Writes only what changed: rewriting unchanged data would re-render the whole board every scan. + private func publish(rateLimits: CodexRateLimits?) { + let found = scanned.merging(external) { disk, _ in disk } var changed = false, cameOrWent = false var tiles = sessions - for (id, session) in result.sessions { + for (id, session) in found { if let tile = tiles[id] { if tile.session != session { tile.session = session; changed = true } } else { @@ -86,7 +107,7 @@ public final class AgentAppWatcher { cameOrWent = true } } - for id in tiles.keys where result.sessions[id] == nil { + for id in tiles.keys where found[id] == nil { tiles[id] = nil cameOrWent = true } @@ -94,7 +115,7 @@ public final class AgentAppWatcher { sessions = tiles changed = true } - if let limits = result.rateLimits, limits != codexRateLimits { + if let limits = rateLimits, limits != codexRateLimits { codexRateLimits = limits changed = true } diff --git a/Sources/TesseraHost/FleetService.swift b/Sources/TesseraHost/FleetService.swift new file mode 100644 index 0000000..e2b75e4 --- /dev/null +++ b/Sources/TesseraHost/FleetService.swift @@ -0,0 +1,141 @@ +import Foundation +import Observation +import TesseraKit + +/// Follows a LLooM head's schedule: who holds which shared hardware (the Fleet band) and the jobs +/// agents report (tiles on the board). The head's address is saved with the app's state; its admin +/// key lives in the Keychain and is only ever sent to that address. +@Observable +@MainActor +public final class FleetService { + public struct Config: Codable, Equatable, Sendable { + public var url: String + } + + public enum Status: Equatable, Sendable { + case off, loading, ok + case failed(String) + } + + public private(set) var config: Config? + public private(set) var lanes: [LLoomSchedule.Lane] = [] + public private(set) var status: Status = .off + /// When the head last answered. + public private(set) var updatedAt: Date? + /// Called when the jobs or leases the board shows may have changed. + @ObservationIgnored public var onChange: (() -> Void)? + + @ObservationIgnored public private(set) var board: LLoomSchedule.Board? + @ObservationIgnored private let store: URL + @ObservationIgnored private var timer: Timer? + @ObservationIgnored private var inFlight = false + @ObservationIgnored private var retryAt = Date.distantPast + @ObservationIgnored private let session: URLSession = { + let c = URLSessionConfiguration.ephemeral + c.timeoutIntervalForRequest = 15 + return URLSession(configuration: c) + }() + + static let account = "lloom.head" + public static let refreshInterval: TimeInterval = 10 + + public init(directory: URL) { + store = directory.appendingPathComponent("lloom.json") + config = StateFile.load(Config.self, from: store).flatMap { LLoomSchedule.baseURL($0.url) == nil ? nil : $0 } + } + + public func start() { + poll() + let t = Timer(timeInterval: Self.refreshInterval, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { self?.poll() } + } + t.tolerance = 2 + RunLoop.main.add(t, forMode: .common) + timer = t + } + + public var hasKey: Bool { Keychain.contains(account: Self.account) } + + /// Follows the head at `url`; a non-empty `key` replaces the saved one. False for an address + /// Tessera won't use. + @discardableResult + public func connect(url: String, key: String?) -> Bool { + guard let base = LLoomSchedule.baseURL(url) else { return false } + config = Config(url: base.absoluteString) + StateFile.save(config, to: store) + if let key, !key.isEmpty { Keychain.set(key, account: Self.account) } + retryAt = .distantPast + status = .loading + poll() + return true + } + + /// Stops following the head and forgets its key; its jobs leave the board. + public func disconnect() { + config = nil + try? FileManager.default.removeItem(at: store) + Keychain.set(nil, account: Self.account) + board = nil + lanes = [] + status = .off + updatedAt = nil + onChange?() + } + + /// The jobs and leases to show as tiles: running ones, and those that ended within `lookback`. + public func items(lookback: TimeInterval) -> [LLoomSchedule.Item] { + board.map { LLoomSchedule.items($0, lookback: lookback) } ?? [] + } + + /// A job or lease as an app-session tile, so it joins the board, the Needs-you lane, alerts and + /// the phone like any conversation. Its notes read as its transcript. + public nonisolated static func session(_ item: LLoomSchedule.Item) -> AgentAppSession { + AgentAppSession(id: item.id, flavor: .lloom, title: item.title, cwd: item.subtitle, openURL: nil, resumeCommand: nil, + snapshot: ConversationSnapshot(items: item.notes, activity: item.activity, detail: item.detail, + lastEventAt: item.changedAt), + summary: nil, lastActivityAt: item.lastActivityAt, progress: item.progress, link: item.session) + } + + private func poll() { + guard let config, let base = LLoomSchedule.baseURL(config.url), !inFlight, Date() >= retryAt else { return } + inFlight = true + if board == nil, status == .off { status = .loading } + let account = Self.account + Task { [session] in + defer { self.inFlight = false } + // Reading a secret can raise a Keychain prompt; keep it off the main thread. + let key = await Task.detached(priority: .utility) { Keychain.get(account: account) }.value + do { + let (data, response) = try await session.data(for: LLoomSchedule.request(base: base, token: key)) + let code = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard self.config == config else { return } // reconfigured while asking + guard (200..<300).contains(code) else { + throw Failure(code == 401 || code == 403 ? "The head refused the key (HTTP \(code))" : "The head answered HTTP \(code)") + } + self.apply(try LLoomSchedule.parse(data)) + } catch { + guard self.config == config else { return } + // Keep showing the last schedule, and ask less often until the head is back. + self.retryAt = Date().addingTimeInterval(30) + let message = (error as? Failure)?.message ?? error.localizedDescription + if self.status != .failed(message) { self.status = .failed(message) } + } + } + } + + private struct Failure: Error { + let message: String + init(_ message: String) { self.message = message } + } + + /// Writes only what changed, so an unchanged poll re-renders nothing (the tiles' own writes are + /// diffed by `AgentAppWatcher`; ages in their text move on with every poll). + private func apply(_ next: LLoomSchedule.Board) { + board = next + updatedAt = Date() + if status != .ok { status = .ok } + let nextLanes = LLoomSchedule.lanes(next) + if lanes != nextLanes { lanes = nextLanes } + onChange?() + } +} diff --git a/Sources/TesseraHost/Workspace.swift b/Sources/TesseraHost/Workspace.swift index 95971b8..72c8627 100644 --- a/Sources/TesseraHost/Workspace.swift +++ b/Sources/TesseraHost/Workspace.swift @@ -20,6 +20,8 @@ public final class Workspace { public let dsh = DshWebServer() public let usage: UsageService public let machines: MachineMonitor + /// A LLooM head's leases and agents' jobs (the Fleet band; jobs are tiles). + public let fleet: FleetService public private(set) var presets: [LaunchPreset] = LaunchCatalog.known /// Desktop agent apps on this Mac that can start conversations from Tessera. public let installedApps: [AgentApp] = AgentApp.allCases.filter { @@ -92,6 +94,7 @@ public final class Workspace { boardHolder = BoardLock.take(in: directory) usage = UsageService(directory: directory) machines = MachineMonitor(directory: directory) + fleet = FleetService(directory: directory) } public func start() { @@ -111,6 +114,8 @@ public final class Workspace { agents.start() usage.start() machines.start() + fleet.onChange = { [weak self] in self?.fleetChanged() } + fleet.start() DispatchQueue.global(qos: .utility).async { let found = LaunchCatalog.detectInstalled() DispatchQueue.main.async { MainActor.assumeIsolated { self.presets = found } } @@ -415,6 +420,11 @@ public final class Workspace { /// keeps such a conversation in Tessera, as its transcript. An id not on the board opens nothing. public func open(_ id: String, inApp: Bool = true, nativeAt rect: CGRect? = nil) { guard exists(id) else { return } + // A LLooM job opens the conversation of the agent reporting it, when that is on the board. + if inApp, let conversation = linkedConversation(id) { + agentAcknowledged[id] = Date() + return open(conversation, inApp: true, nativeAt: rect) + } opened.note(id) if app(opening: id, inApp: inApp) != nil { collapse() @@ -636,7 +646,22 @@ public final class Workspace { return TileInfo(id: a.id, kind: .agentSession, flavor: a.flavor, title: agentTitles[a.id] ?? a.title, subtitle: a.cwd.abbreviatingHome, activity: activity, attention: unseen, - lastActivityAt: a.lastActivityAt, detail: detail) + lastActivityAt: a.lastActivityAt, detail: detail, progress: a.progress) + } + + private func fleetChanged() { + agents.setExternal(fleet.items(lookback: agents.lookback).map(FleetService.session)) + } + + /// The tile of the conversation a LLooM job or lease names (`claude:`, `codex:`): by its + /// tile id, else by the session id a terminal or a Claude app conversation carries. + public func linkedConversation(_ id: String) -> String? { + guard let link = agents.session(id)?.link, let colon = link.firstIndex(of: ":") else { return nil } + let sessionId = String(link[link.index(after: colon)...]) + guard SessionResume.isSafeId(sessionId) else { return nil } + let candidates = [link] + terminals.filter { $0.value.sessionId == sessionId }.map(\.key) + + agents.sessions.filter { $0.value.session.resumeCommand?.split(separator: " ").contains(Substring(sessionId)) == true }.map(\.key) + return candidates.first { $0 != id && exists($0) && agents.session($0)?.flavor != .lloom } } private func syncAgents() { diff --git a/Sources/TesseraKit/Core/BoardQuery.swift b/Sources/TesseraKit/Core/BoardQuery.swift index 213f3a6..602e66e 100644 --- a/Sources/TesseraKit/Core/BoardQuery.swift +++ b/Sources/TesseraKit/Core/BoardQuery.swift @@ -5,7 +5,7 @@ public struct BoardQuery: Equatable, Sendable { /// A filter chip: what a tile is doing (a state), or what it is (a kind). public enum Chip: String, CaseIterable, Identifiable, Sendable { case needsYou, working - case terminals, claude, codex, dsh, web + case terminals, claude, codex, dsh, jobs, web public var id: String { rawValue } @@ -14,7 +14,7 @@ public struct BoardQuery: Equatable, Sendable { switch self { case .needsYou: .needsInput case .working: .working - case .terminals, .claude, .codex, .dsh, .web: nil + case .terminals, .claude, .codex, .dsh, .jobs, .web: nil } } @@ -26,6 +26,7 @@ public struct BoardQuery: Equatable, Sendable { case .claude: .claudeDesktop case .codex: .codexDesktop case .dsh: .dsh + case .jobs: .lloom case .web: .web } } @@ -36,6 +37,7 @@ public struct BoardQuery: Equatable, Sendable { case .needsYou: TileActivity.needsYouLabel case .terminals: "Terminals" case .dsh: "dsh" + case .jobs: "Jobs" default: activity?.label ?? flavor?.displayName ?? rawValue } } @@ -48,6 +50,7 @@ public struct BoardQuery: Equatable, Sendable { case .claude: tile.flavor == .claude || tile.flavor == .claudeDesktop case .codex: tile.flavor == .codex || tile.flavor == .codexDesktop case .dsh: tile.flavor == .dsh + case .jobs: tile.flavor == .lloom case .web: tile.kind == .browser } } diff --git a/Sources/TesseraKit/Core/LLoomSchedule.swift b/Sources/TesseraKit/Core/LLoomSchedule.swift new file mode 100644 index 0000000..a414ba9 --- /dev/null +++ b/Sources/TesseraKit/Core/LLoomSchedule.swift @@ -0,0 +1,243 @@ +import Foundation + +/// A LLooM head's schedule (`GET /gateway/schedule`): the leases agents hold on shared hardware and +/// the jobs they report. Building the request, reading the reply and turning it into tiles are pure, +/// so they are tested without a network. +public enum LLoomSchedule { + public struct Board: Decodable, Equatable, Sendable { + /// The head's clock, in milliseconds since 1970; every age here is measured from it. + public var now: Double + public var resources: [Resource] + public var reservations: [Reservation] + public var jobs: [Job] + } + + public struct Resource: Decodable, Equatable, Sendable, Identifiable { + public var id: String + public var description: String? + public var hosts: [String]? + public var capacity: Int? + public var holders: [String]? + public var queue: [String]? + /// The head can't use this resource's configuration. + public var error: String? + } + + public struct Reservation: Decodable, Equatable, Sendable, Identifiable { + public var id: String + public var resource: String + public var owner: String + public var purpose: String? + public var session: String? + /// queued, acquiring, active, releasing, stuck, released or failed. + public var state: String + public var startedAt: Double? + public var expiresAt: Double? + /// When the head warned that the lease is running out (cleared by a renewal). + public var warnedAt: Double? + public var updatedAt: Double? + public var error: String? + } + + public struct Job: Decodable, Equatable, Sendable, Identifiable { + public struct Note: Decodable, Equatable, Sendable { + public var at: Double + public var text: String + } + + public struct Progress: Decodable, Equatable, Sendable { + public var done: Double + public var total: Double? + public var unit: String? + } + + public var id: String + public var title: String + public var owner: String + public var session: String? + public var resource: String? + /// running, done, failed or cancelled. + public var state: String + public var startedAt: Double + public var heartbeatAt: Double + public var endedAt: Double? + /// When a running job went quiet for longer than it said it would. + public var stalledAt: Double? + public var note: String? + public var notes: [Note] + public var progress: Progress? + public var metrics: [String: Metric] + public var receipt: String? + } + + public enum Metric: Decodable, Equatable, Sendable, CustomStringConvertible { + case number(Double), text(String) + + public init(from decoder: Decoder) throws { + let value = try decoder.singleValueContainer() + if let number = try? value.decode(Double.self) { self = .number(number) } else { self = .text(try value.decode(String.self)) } + } + + public var description: String { + switch self { + case .number(let n): String(format: "%g", n) + case .text(let s): s + } + } + } + + /// The head's address, if it is one Tessera will talk to: http(s) with a host. + public static func baseURL(_ raw: String) -> URL? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: trimmed), ["http", "https"].contains(url.scheme?.lowercased() ?? ""), + url.host?.isEmpty == false else { return nil } + return url + } + + public static func request(base: URL, token: String?) -> URLRequest { + var request = URLRequest(url: base.appendingPathComponent("gateway/schedule")) + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let token, !token.isEmpty { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } + return request + } + + public static func parse(_ data: Data) throws -> Board { + try JSONDecoder().decode(Board.self, from: data) + } + + // MARK: Tiles + + /// A job, or a lease that needs the user, as the board shows it. + public struct Item: Equatable, Sendable { + public var id: String + public var title: String + public var subtitle: String + public var activity: TileActivity + public var detail: String? + /// When it last changed in a way the user would care about: a report, its end, or the moment + /// it began to need them (so a job seen before it stalled lights up again). + public var changedAt: Date + public var lastActivityAt: Date + /// 0...1 when the job reports a total. + public var progress: Double? + public var notes: [ConversationItem] + /// The reporting agent's conversation (`claude:`, `codex:`), when it said. + public var session: String? + } + + /// Tile ids are `lloom:` and the job or reservation id, so they never collide with other tiles. + public static let idPrefix = "lloom:" + + /// Running jobs and those that ended within `lookback`, then leases that need the user: one + /// running out, or one whose restore failed. + public static func items(_ board: Board, lookback: TimeInterval) -> [Item] { + let now = date(board.now) + var out: [Item] = [] + for job in board.jobs { + let ended = job.endedAt.map(date) + if let ended, now.timeIntervalSince(ended) > lookback { continue } + out.append(item(job, now: now)) + } + for lease in board.reservations { + if let alert = item(lease, now: now) { out.append(alert) } + } + return out + } + + static func item(_ job: Job, now: Date) -> Item { + let heartbeat = date(job.heartbeatAt) + let stalled = job.state == "running" ? job.stalledAt.map(date) : nil + let activity: TileActivity = switch job.state { + case "running": stalled == nil ? .working : .failed + case "done": .done + case "failed": .failed + default: .idle + } + var detail = [job.note, progressText(job.progress), metricsText(job.metrics)].compactMap { $0 }.joined(separator: " · ") + if stalled != nil { detail = "No report for \(heartbeat.shortAge(now: now))" + (detail.isEmpty ? "" : " · \(detail)") } + if detail.isEmpty, let receipt = job.receipt { detail = receipt } + let changed = stalled ?? job.endedAt.map(date) ?? heartbeat + let fraction = job.progress.flatMap { p in p.total.map { min(1, max(0, p.done / $0)) } } + return Item(id: idPrefix + job.id, title: job.title, + subtitle: [job.owner, job.resource].compactMap { $0 }.joined(separator: " · "), + activity: activity, detail: detail.isEmpty ? nil : detail, changedAt: changed, + lastActivityAt: job.endedAt.map(date) ?? heartbeat, progress: fraction, + notes: job.notes.enumerated().map { i, note in + ConversationItem(id: "\(job.id)-\(i)", role: .assistant, text: note.text, timestamp: date(note.at)) + }, + session: job.session) + } + + static func item(_ lease: Reservation, now: Date) -> Item? { + let changed = date(lease.warnedAt ?? lease.updatedAt ?? lease.startedAt ?? 0) + switch lease.state { + case "stuck": + return Item(id: idPrefix + lease.id, title: "\(lease.resource) restore failed", subtitle: "\(lease.owner) · lease", + activity: .failed, detail: lease.error ?? "Restore failed; release it again to retry", + changedAt: changed, lastActivityAt: changed, progress: nil, notes: [], session: lease.session) + case "active" where lease.warnedAt != nil: + let expires = lease.expiresAt.map(date) + let overdue = expires.map { $0 <= now } ?? false + let left = expires.map { overdue ? "overdue" : "\(now.shortAge(now: $0)) left" } ?? "running out" + return Item(id: idPrefix + lease.id, title: "\(lease.resource) lease \(overdue ? "overdue" : "expiring")", + subtitle: "\(lease.owner) · lease", activity: .needsInput, + detail: "\(left) · lloom renew \(lease.resource)", changedAt: changed, lastActivityAt: changed, + progress: nil, notes: [], session: lease.session) + default: + return nil + } + } + + // MARK: The band + + /// One resource as the Fleet band shows it. + public struct Lane: Equatable, Sendable, Identifiable { + public enum State: Sendable { case free, held, attention, unusable } + public var id: String + public var state: State + /// Who holds it, the soonest-ending first. + public var holders: [String] + public var purpose: String? + public var remaining: TimeInterval? + public var queued: Int + public var capacity: Int + public var help: String + } + + public static func lanes(_ board: Board) -> [Lane] { + let now = date(board.now) + let byId = Dictionary(board.reservations.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) + return board.resources.map { resource in + let held = (resource.holders ?? []).compactMap { byId[$0] } + let queued = (resource.queue ?? []).compactMap { byId[$0] } + let capacity = resource.capacity ?? 1 + let remaining = held.compactMap { $0.expiresAt.map { date($0).timeIntervalSince(now) } }.min() + let state: Lane.State = resource.error != nil ? .unusable + : held.contains { $0.state == "stuck" || $0.warnedAt != nil } ? .attention + : held.isEmpty ? .free : .held + var help = [resource.description, resource.hosts.map { $0.joined(separator: ", ") }].compactMap { $0 } + if let error = resource.error { help.append(error) } + for lease in held { + help.append("\(lease.owner) (\(lease.state))" + (lease.purpose.map { ": \($0)" } ?? "") + (lease.error.map { " — \($0)" } ?? "")) + } + if !queued.isEmpty { help.append("Waiting: " + queued.map(\.owner).joined(separator: ", ")) } + return Lane(id: resource.id, state: state, holders: held.map(\.owner), purpose: held.first?.purpose, + remaining: remaining, queued: queued.count, capacity: capacity, help: help.joined(separator: "\n")) + } + } + + // MARK: Text + + static func progressText(_ progress: Job.Progress?) -> String? { + guard let progress else { return nil } + let done = Metric.number(progress.done).description + let unit = progress.unit.map { " \($0)" } ?? "" + return progress.total.map { "\(done)/\(Metric.number($0))\(unit)" } ?? "\(done)\(unit)" + } + + static func metricsText(_ metrics: [String: Metric]) -> String? { + metrics.isEmpty ? nil : metrics.sorted { $0.key < $1.key }.map { "\($0.key) \($0.value)" }.joined(separator: " ") + } + + static func date(_ ms: Double) -> Date { Date(timeIntervalSince1970: ms / 1000) } +} diff --git a/Sources/TesseraKit/Core/Models.swift b/Sources/TesseraKit/Core/Models.swift index 39cc703..945f126 100644 --- a/Sources/TesseraKit/Core/Models.swift +++ b/Sources/TesseraKit/Core/Models.swift @@ -21,6 +21,8 @@ public enum AgentFlavor: String, Codable, Sendable, CaseIterable { /// DeepSeek Harness (`dsh`) sessions. case dsh case web + /// Jobs agents report to a LLooM head, and its leases that need the user. + case lloom public var displayName: String { switch self { @@ -37,6 +39,7 @@ public enum AgentFlavor: String, Codable, Sendable, CaseIterable { case .codexDesktop: "Codex" case .dsh: "DeepSeek" case .web: "Web" + case .lloom: "LLooM" } } @@ -54,6 +57,7 @@ public enum AgentFlavor: String, Codable, Sendable, CaseIterable { case .custom: "gearshape.2" case .dsh: "water.waves" case .web: "globe" + case .lloom: "square.stack.3d.up" } } diff --git a/Sources/TesseraKit/UI/Style.swift b/Sources/TesseraKit/UI/Style.swift index e87d9d5..c1c66bf 100644 --- a/Sources/TesseraKit/UI/Style.swift +++ b/Sources/TesseraKit/UI/Style.swift @@ -55,6 +55,7 @@ public enum Style { case .dsh: Color(red: 0.36, green: 0.49, blue: 1.00) case .omp: Color(red: 0.70, green: 0.52, blue: 1.00) case .opencode, .aider: Color(red: 0.95, green: 0.60, blue: 0.85) + case .lloom: Color(red: 0.55, green: 0.80, blue: 0.70) case .shell, .custom, .web: dim } } diff --git a/Sources/TesseraMac/DashboardView.swift b/Sources/TesseraMac/DashboardView.swift index 90495c2..0612235 100644 --- a/Sources/TesseraMac/DashboardView.swift +++ b/Sources/TesseraMac/DashboardView.swift @@ -24,6 +24,7 @@ struct DashboardView: View { } VStack(spacing: 0) { TabStrip() + if model.workspace.fleet.config != nil { FleetBand() } BoardView() } .overlay(alignment: .bottom) { diff --git a/Sources/TesseraMac/FleetBand.swift b/Sources/TesseraMac/FleetBand.swift new file mode 100644 index 0000000..75673e0 --- /dev/null +++ b/Sources/TesseraMac/FleetBand.swift @@ -0,0 +1,134 @@ +import SwiftUI +import TesseraHost +import TesseraKit + +/// Who holds the shared hardware a LLooM head schedules, one chip per resource, over the board. +/// Jobs are tiles; this is the lease view: free, held (by whom, for how long, who waits), or +/// needing someone (a lease running out, a restore that failed). +struct FleetBand: View { + @Environment(\.openSettings) private var openSettings + @Environment(AppModel.self) private var model + + var body: some View { + let fleet = model.workspace.fleet + HStack(spacing: Style.Space.s) { + Image(systemName: AgentFlavor.lloom.symbol) + .font(Style.ui(.caption, .semibold)) + .foregroundStyle(Style.accent(.lloom)) + .help("Shared hardware on the LLooM head") + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: Style.Space.xxs) { + ForEach(fleet.lanes) { FleetLaneChip(lane: $0) } + } + } + Spacer(minLength: 0) + status(fleet) + } + .padding(.horizontal, Style.Space.gutter) + .frame(height: Style.Metrics.strip) + .chromeSurface(rule: .bottom) + .contextMenu { Button("Fleet Settings…") { openSettings() } } + } + + @ViewBuilder private func status(_ fleet: FleetService) -> some View { + switch fleet.status { + case .failed(let why): + Label(why, systemImage: "exclamationmark.triangle") + .font(Style.ui(.caption)) + .foregroundStyle(Style.coral) + .lineLimit(1) + .help(fleet.updatedAt.map { "Showing the schedule from \($0.shortAge()) ago" } ?? why) + case .loading: + Text("Connecting…").font(Style.ui(.caption)).foregroundStyle(Style.dim) + case .ok, .off: + EmptyView() + } + } +} + +struct FleetLaneChip: View { + let lane: LLoomSchedule.Lane + + var body: some View { + HStack(spacing: Style.Space.s) { + Dot(color, lit: lane.state != .free) + Text(lane.id).font(Style.ui(.label, .semibold)).foregroundStyle(Style.ink) + Text(summary).font(Style.caption).foregroundStyle(Style.dim) + } + .lineLimit(1) + .padding(.horizontal, Style.Space.gutter) + .frame(height: Style.Metrics.control) + .fixedSize() + .overlay(Capsule().strokeBorder(Style.Neutral.border)) + .contentShape(Capsule()) + .help(lane.help) + } + + private var color: Color { + switch lane.state { + case .free: Style.muted + case .held: Style.cyan + case .attention: Style.amber + case .unusable: Style.coral + } + } + + private var summary: String { + guard !lane.holders.isEmpty else { + return lane.state == .unusable ? "config error" : lane.capacity > 1 ? "free · 0/\(lane.capacity)" : "free" + } + var parts = [lane.holders.joined(separator: ", ")] + if lane.capacity > 1 { parts.append("\(lane.holders.count)/\(lane.capacity)") } + if let left = lane.remaining { parts.append(left > 0 ? "\(Date().addingTimeInterval(-left).shortAge()) left" : "overdue") } + if lane.queued > 0 { parts.append("+\(lane.queued) waiting") } + return parts.joined(separator: " · ") + } +} + +/// The LLooM head Tessera follows for the Fleet band and job tiles. +struct FleetSettings: View { + @Environment(AppModel.self) private var model + @State private var url = "" + @State private var key = "" + + var body: some View { + let fleet = model.workspace.fleet + VStack(alignment: .leading, spacing: Style.Space.l) { + Form { + TextField("LLooM head", text: $url, prompt: Text("http://lloom.example:8100")) + SecureField("Admin key", text: $key, prompt: Text(fleet.hasKey ? "saved in Keychain" : "required")) + } + .formStyle(.columns) + HStack { + status(fleet) + Spacer() + if fleet.config != nil { + Button("Disconnect", role: .destructive) { + fleet.disconnect() + url = "" + key = "" + } + } + Button(fleet.config == nil ? "Connect" : "Save") { + if fleet.connect(url: url, key: key) { key = "" } + } + .keyboardShortcut(.defaultAction) + .disabled(LLoomSchedule.baseURL(url) == nil || (key.isEmpty && !fleet.hasKey)) + } + Text("Agents lease shared machines and report long-running jobs to a LLooM head (lloom reserve, lloom job). Jobs show up as tiles, and the band over the board shows who holds what. The key is stored in your login Keychain and only sent to this address.") + .font(Style.ui(.label)).foregroundStyle(Style.dim) + Spacer() + } + .padding(Style.Space.xl) + .onAppear { url = fleet.config?.url ?? "" } + } + + @ViewBuilder private func status(_ fleet: FleetService) -> some View { + switch fleet.status { + case .off: Text("Not connected").font(Style.ui(.label)).foregroundStyle(Style.dim) + case .loading: Text("Connecting…").font(Style.ui(.label)).foregroundStyle(Style.dim) + case .ok: Text("Connected · \(fleet.lanes.count) resources").font(Style.ui(.label)).foregroundStyle(Style.mint) + case .failed(let why): Text(why).font(Style.ui(.label)).foregroundStyle(Style.coral).lineLimit(2) + } + } +} diff --git a/Sources/TesseraMac/SettingsView.swift b/Sources/TesseraMac/SettingsView.swift index b33e1f8..0e1db37 100644 --- a/Sources/TesseraMac/SettingsView.swift +++ b/Sources/TesseraMac/SettingsView.swift @@ -8,6 +8,7 @@ struct SettingsView: View { TabView { ProvidersSettings().tabItem { Label("Accounts", systemImage: "creditcard") } MachinesSettings().tabItem { Label("Machines", systemImage: "server.rack") } + FleetSettings().tabItem { Label("Fleet", systemImage: AgentFlavor.lloom.symbol) } RemoteSettings().tabItem { Label("iPhone", systemImage: "iphone") } GeneralSettings().tabItem { Label("General", systemImage: "gearshape") } } diff --git a/Tests/TesseraKitTests/LLoomScheduleTests.swift b/Tests/TesseraKitTests/LLoomScheduleTests.swift new file mode 100644 index 0000000..fdf2dbb --- /dev/null +++ b/Tests/TesseraKitTests/LLoomScheduleTests.swift @@ -0,0 +1,389 @@ +import XCTest +@testable import TesseraHost +@testable import TesseraKit + +/// A LLooM head's `GET /gateway/schedule` reply, as LLooM's own schedule module produced it: +/// a pair lease running out with a job reporting on it and an agent waiting, a stuck restore, +/// a build slot in use, a resource with a bad configuration, and jobs in every state. +private let board = #""" +{ + "schemaVersion": 1, + "now": 1800001920000, + "seq": 21, + "resources": [ + { + "id": "pair", + "description": "two-node pair", + "hosts": [ + "gpu-box-1", + "gpu-box-2" + ], + "capacity": 1, + "ttlMs": 1800000, + "maxTtlMs": 28800000, + "allowIndefinite": false, + "onExpiry": "release", + "actions": [ + { + "kind": "route", + "alias": "chat", + "profile": "cloud", + "node": null + }, + { + "kind": "suspend", + "model": "pair-model", + "node": null + } + ], + "holders": [ + "r-8642106d" + ], + "queue": [ + "r-a65af390" + ] + }, + { + "id": "bench", + "description": "bench box", + "hosts": [ + "gpu-box-3" + ], + "capacity": 1, + "ttlMs": 1800000, + "maxTtlMs": 28800000, + "allowIndefinite": false, + "onExpiry": "release", + "actions": [ + { + "kind": "suspend", + "model": "bench-model", + "node": null + } + ], + "holders": [ + "r-c60ba4d9" + ], + "queue": [] + }, + { + "id": "builds", + "description": null, + "hosts": [], + "capacity": 2, + "ttlMs": 1800000, + "maxTtlMs": 28800000, + "allowIndefinite": false, + "onExpiry": "release", + "actions": [], + "holders": [ + "r-2d5806cb" + ], + "queue": [] + }, + { + "id": "broken", + "error": "reservation resource broken: suspend/routes require capacity 1", + "holders": [], + "queue": [] + } + ], + "reservations": [ + { + "id": "r-8642106d", + "resource": "pair", + "owner": "main", + "purpose": "decode A/B", + "session": "claude:cli-1234", + "state": "active", + "ttlMs": 1200000, + "createdAt": 1800000000000, + "updatedAt": 1800000960000, + "startedAt": 1800000000000, + "expiresAt": 1800002160000, + "endedAt": null, + "endReason": null, + "warnedAt": 1800001920000, + "error": null, + "steps": [ + { + "kind": "route", + "alias": "chat", + "profile": "cloud", + "node": null, + "previous": "local", + "status": "done" + }, + { + "kind": "suspend", + "model": "pair-model", + "node": null, + "status": "done" + } + ], + "remainingMs": 240000 + }, + { + "id": "r-a65af390", + "resource": "pair", + "owner": "buildopt", + "purpose": null, + "session": null, + "state": "queued", + "ttlMs": 1800000, + "createdAt": 1800000000000, + "updatedAt": 1800000960000, + "startedAt": null, + "expiresAt": null, + "endedAt": null, + "endReason": null, + "warnedAt": null, + "error": null, + "steps": [], + "queuedUntil": 1800002760000, + "position": 1 + }, + { + "id": "r-c60ba4d9", + "resource": "bench", + "owner": "drafter", + "purpose": null, + "session": null, + "state": "stuck", + "ttlMs": 7200000, + "createdAt": 1800000000000, + "updatedAt": 1800001920000, + "startedAt": 1800000000000, + "expiresAt": 1800007200000, + "endedAt": null, + "endReason": "released", + "warnedAt": null, + "error": "restore failed: suspend bench-model: runtime pair-model failed health check after resume", + "steps": [ + { + "kind": "suspend", + "model": "bench-model", + "node": null, + "status": "done", + "error": "runtime pair-model failed health check after resume" + } + ] + }, + { + "id": "r-2d5806cb", + "resource": "builds", + "owner": "ci", + "purpose": null, + "session": null, + "state": "active", + "ttlMs": 7200000, + "createdAt": 1800000000000, + "updatedAt": 1800000000000, + "startedAt": 1800000000000, + "expiresAt": 1800007200000, + "endedAt": null, + "endReason": null, + "warnedAt": null, + "error": null, + "steps": [], + "remainingMs": 5280000 + } + ], + "jobs": [ + { + "id": "j-d9c3ff54", + "title": "decode A/B", + "owner": "main", + "session": "claude:cli-1234", + "reservationId": "r-8642106d", + "resource": "pair", + "state": "running", + "startedAt": 1800000000000, + "heartbeatAt": 1800000960000, + "endedAt": null, + "staleAfterMs": 1800000, + "stalledAt": null, + "note": "arm 3", + "notes": [ + { + "at": 1800000000000, + "text": "building" + }, + { + "at": 1800000000000, + "text": "arm 2" + }, + { + "at": 1800000960000, + "text": "arm 3" + } + ], + "progress": { + "done": 2, + "total": 5, + "unit": "arms" + }, + "metrics": { + "tok_s": 58.2, + "image": "f399caa" + }, + "receipt": null, + "heartbeatAgeMs": 960000, + "health": "ok" + }, + { + "id": "j-a5cc8f2e", + "title": "NLL receipts", + "owner": "drafter", + "session": null, + "reservationId": null, + "resource": null, + "state": "running", + "startedAt": 1800000000000, + "heartbeatAt": 1800000000000, + "endedAt": null, + "staleAfterMs": 600000, + "stalledAt": 1800001920000, + "note": null, + "notes": [], + "progress": null, + "metrics": {}, + "receipt": null, + "heartbeatAgeMs": 1920000, + "health": "stalled" + }, + { + "id": "j-48a46346", + "title": "kernel sweep", + "owner": "ci", + "session": null, + "reservationId": null, + "resource": null, + "state": "done", + "startedAt": 1800000000000, + "heartbeatAt": 1800000000000, + "endedAt": 1800000000000, + "staleAfterMs": 900000, + "stalledAt": null, + "note": "flat vs baseline", + "notes": [ + { + "at": 1800000000000, + "text": "flat vs baseline" + } + ], + "progress": null, + "metrics": {}, + "receipt": "receipts/sweep.json", + "heartbeatAgeMs": 1920000, + "health": "done" + }, + { + "id": "j-1e0e9ca1", + "title": "image build", + "owner": "ci", + "session": null, + "reservationId": null, + "resource": null, + "state": "failed", + "startedAt": 1800000000000, + "heartbeatAt": 1800000000000, + "endedAt": 1800000000000, + "staleAfterMs": 900000, + "stalledAt": null, + "note": "out of disk", + "notes": [ + { + "at": 1800000000000, + "text": "out of disk" + } + ], + "progress": null, + "metrics": {}, + "receipt": null, + "heartbeatAgeMs": 1920000, + "health": "failed" + } + ] +} +"""# + +final class LLoomScheduleTests: XCTestCase { + private func parsed() throws -> LLoomSchedule.Board { try LLoomSchedule.parse(Data(board.utf8)) } + private func item(_ title: String, in items: [LLoomSchedule.Item]) -> LLoomSchedule.Item? { items.first { $0.title == title } } + + func testRequestAsksTheHeadWithItsKey() throws { + let base = try XCTUnwrap(LLoomSchedule.baseURL(" http://lloom.example:8100 ")) + let request = LLoomSchedule.request(base: base, token: "admin-key") + XCTAssertEqual(request.url?.absoluteString, "http://lloom.example:8100/gateway/schedule") + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer admin-key") + XCTAssertNil(LLoomSchedule.request(base: base, token: nil).value(forHTTPHeaderField: "Authorization")) + XCTAssertNil(LLoomSchedule.baseURL("ftp://lloom.example")) + XCTAssertNil(LLoomSchedule.baseURL("lloom.example:8100")) + } + + func testJobsBecomeTilesThatSayHowTheyAreGoing() throws { + let items = LLoomSchedule.items(try parsed(), lookback: 36 * 3600) + let running = try XCTUnwrap(item("decode A/B", in: items)) + XCTAssertTrue(running.id.hasPrefix(LLoomSchedule.idPrefix)) + XCTAssertEqual(running.activity, .working) + XCTAssertEqual(running.subtitle, "main · pair") + XCTAssertEqual(running.detail, "arm 3 · 2/5 arms · image f399caa tok_s 58.2") + XCTAssertEqual(try XCTUnwrap(running.progress), 0.4, accuracy: 0.001) + XCTAssertEqual(running.notes.map(\.text), ["building", "arm 2", "arm 3"]) + XCTAssertEqual(running.session, "claude:cli-1234") + + let quiet = try XCTUnwrap(item("NLL receipts", in: items)) + XCTAssertEqual(quiet.activity, .failed) + XCTAssertEqual(quiet.detail, "No report for 32m") + // It needs the user from the moment it went quiet, not from its last report. + XCTAssertEqual(quiet.changedAt.timeIntervalSince1970, 1_800_001_920, accuracy: 0.5) + + XCTAssertEqual(item("kernel sweep", in: items)?.activity, .done) + XCTAssertEqual(item("kernel sweep", in: items)?.detail, "flat vs baseline") + XCTAssertEqual(item("image build", in: items)?.activity, .failed) + // Ended work leaves once it is older than the board's lookback. + XCTAssertNil(item("kernel sweep", in: LLoomSchedule.items(try parsed(), lookback: 60))) + XCTAssertNotNil(item("decode A/B", in: LLoomSchedule.items(try parsed(), lookback: 60))) + } + + func testLeasesThatNeedSomeoneJoinTheQueue() throws { + let items = LLoomSchedule.items(try parsed(), lookback: 36 * 3600) + let expiring = try XCTUnwrap(item("pair lease expiring", in: items)) + XCTAssertEqual(expiring.activity, .needsInput) + XCTAssertEqual(expiring.detail, "4m left · lloom renew pair") + XCTAssertEqual(expiring.session, "claude:cli-1234") + let stuck = try XCTUnwrap(item("bench restore failed", in: items)) + XCTAssertEqual(stuck.activity, .failed) + XCTAssertTrue(stuck.detail?.contains("failed health check") == true) + // A lease that is simply held, or waiting, is the band's business, not a tile. + XCTAssertEqual(items.filter { $0.subtitle.hasSuffix("· lease") }.count, 2) + } + + func testLanesSayWhoHoldsWhat() throws { + let lanes = LLoomSchedule.lanes(try parsed()) + XCTAssertEqual(lanes.map(\.id), ["pair", "bench", "builds", "broken"]) + let pair = lanes[0] + XCTAssertEqual(pair.state, .attention) + XCTAssertEqual(pair.holders, ["main"]) + XCTAssertEqual(pair.queued, 1) + XCTAssertEqual(try XCTUnwrap(pair.remaining), 240, accuracy: 1) + XCTAssertTrue(pair.help.contains("gpu-box-1, gpu-box-2")) + XCTAssertTrue(pair.help.contains("Waiting: buildopt")) + XCTAssertEqual(lanes[1].state, .attention) + XCTAssertEqual(lanes[2].state, .held) + XCTAssertEqual(lanes[2].capacity, 2) + XCTAssertEqual(lanes[3].state, .unusable) + } + + func testAJobTileOpensItsAgentsConversation() throws { + let job = try XCTUnwrap(item("decode A/B", in: LLoomSchedule.items(try parsed(), lookback: 3600))) + let session = FleetService.session(job) + XCTAssertEqual(session.flavor, .lloom) + XCTAssertEqual(session.link, "claude:cli-1234") + XCTAssertEqual(session.progress, job.progress) + XCTAssertEqual(session.snapshot.items.count, 3) + XCTAssertEqual(session.snapshot.lastEventAt, job.changedAt) + XCTAssertNil(session.openURL) + } +} diff --git a/Tests/TesseraKitTests/TriageTests.swift b/Tests/TesseraKitTests/TriageTests.swift index f5e8989..25446d8 100644 --- a/Tests/TesseraKitTests/TriageTests.swift +++ b/Tests/TesseraKitTests/TriageTests.swift @@ -166,7 +166,7 @@ final class BoardQueryTests: XCTestCase { XCTAssertEqual(holdings[.codex], ["codex", "codex-done"]) XCTAssertEqual(holdings[.dsh], ["dsh"]) XCTAssertEqual(holdings[.web], ["page"]) - XCTAssertEqual(BoardQuery.Chip.allCases.map(\.label), ["Needs you", "Working", "Terminals", "Claude", "Codex", "dsh", "Web"]) + XCTAssertEqual(BoardQuery.Chip.allCases.map(\.label), ["Needs you", "Working", "Terminals", "Claude", "Codex", "dsh", "Jobs", "Web"]) } func testAnEmptyQueryShowsEverythingInOrder() {