feat(devoverlay,logging): live log stream — every swift-log line into the overlay

Slice 3 of dev-overlay-end-to-end. Adds a fourth DevOverlay tab that
tails every log record from every Jarvis subsystem in real time —
agent, voice, vision, memory, MCP, bus, replay, system — without
polling files or relying on Console.app.

How it works:

1. `LogBroadcaster` (new) — process-wide actor with a 500-line ring
   buffer + subscriber set. Borrowed shape from
   OrchestratorEventBroadcaster so the project's three broadcasters
   share a mental model. New subscribers replay the buffer in order,
   then go live.

2. `BroadcastLogHandler` (new) — swift-log LogHandler conformance.
   Formats each log call into a `LogLine` and schedules an unstructured
   `Task { await LogBroadcaster.shared.publish(...) }` so the logging
   caller never blocks on actor isolation. Synchronous handler contract
   preserved; ordering is best-effort under heavy load.

3. `LoggingBootstrap.make(label:)` now returns
   `MultiplexLogHandler([file, os, broadcast])` — the new handler is
   purely additive. File rotation + os.Logger streaming unchanged.

4. `DevOverlayLogsView` (new) — fourth tab in the overlay. Subscribes
   to `LogBroadcaster.shared` on appear, caps at 1000 lines, renders
   monospaced. Per-channel filter (Menu w/ checkboxes), per-level
   filter (debug → critical), substring search, Pause/Resume button
   (Cmd+P), auto-scroll-to-bottom while not paused. Color-coded by
   level (warn=orange, error/critical=red).

Tests:
- 5 new LogBroadcasterTests covering publish/cap/replay/live/handler.
- swift test --package-path packages/Logging → 22/22 pass.
- swift test --package-path packages/DevOverlay → 14/14 pass.
- bash scripts/check-app-builds.sh → PASS.
- 18/18 boundary gates green.

Redaction note: file handler still routes through Redact.apply for
disk writes; the broadcaster sees raw messages because the DevOverlay
is a developer-only in-process surface. If this ever becomes
user-facing, route through Redact before publishing.
This commit is contained in:
james.maes committed 2026-05-12 14:27:57 -05:00
1 parent 2a872fc597
commit 6ea2852af1
6 files changed
+698 -3

No files matched your search

@@ -0,0 +1,287 @@
// DevOverlayLogsView.swift
//
// Live log tail rendered as the fourth DevOverlay tab. Subscribes to
// `LogBroadcaster.shared` at view-appear, accumulates into a bounded
// `@Observable` model (1000-line cap), and renders a filterable,
// pause-able, monospaced scroll view.
//
// Filtering happens client-side because the broadcaster is the shared
// fan-out point for every Jarvis subsystem — channels + levels are
// emergent from whoever calls into swift-log, not enumerated up-front.
// Filtering server-side would push topology into the broadcaster and
// couple it to every consumer.
//
// Why bounded? The broadcaster's ring is 500 lines; the UI's model
// caps at 1000 so a user pausing for ~minute can scroll the history
// without falling off the back. Both bounds are generous defaults;
// neither is hot-loaded from config because the DevOverlay is dev-only.
//
// See also: LogBroadcaster.swift, DevOverlayView.swift.
#if canImport(SwiftUI)
import SwiftUI
import Observation
import Logging
import JarvisLogging
/// Bounded buffer of recent log lines plus user filter state.
///
/// ## Threading
///
/// `@MainActor` — all mutations happen on the SwiftUI executor. The
/// background subscriber Task hops to the main actor before calling
/// `append(_:)`.
@available(macOS 14.0, *)
@MainActor
@Observable
public final class DevOverlayLogsModel {
public private(set) var lines: [LogLine] = []
public var isPaused: Bool = false
/// Search substring (case-insensitive). Empty = match all.
public var searchText: String = ""
/// Active channel filter. Empty set = no channel filter (show all).
public var enabledChannels: Set<String> = []
/// Active level filter. Empty set = no level filter (show all).
public var enabledLevels: Set<Logger.Level> = []
/// Cap so a long-running session doesn't grow unbounded.
public let cap: Int
public init(cap: Int = 1000) {
self.cap = cap
}
/// Append a new log line unless the user has paused the tail. We
/// still cap the buffer regardless of pause so an unbounded backlog
/// never accumulates — paused just means "don't show me new ones
/// flowing in," not "stockpile them."
public func append(_ line: LogLine) {
guard !isPaused else { return }
lines.append(line)
if lines.count > cap {
lines.removeFirst(lines.count - cap)
}
}
/// Discovered channels across all currently-held lines. Drives the
/// channel-filter dropdown — emergent rather than enumerated.
public var availableChannels: [String] {
Array(Set(lines.map(\.channel))).sorted()
}
/// Apply the active filters. Returns the filtered slice for rendering.
public func filtered() -> [LogLine] {
let needle = searchText.lowercased()
return lines.filter { line in
if !enabledChannels.isEmpty && !enabledChannels.contains(line.channel) {
return false
}
if !enabledLevels.isEmpty && !enabledLevels.contains(line.level) {
return false
}
if !needle.isEmpty && !line.message.lowercased().contains(needle) {
return false
}
return true
}
}
}
/// SwiftUI surface for the logs tab.
///
/// ## Overview
///
/// Top bar: search field + channel popover + level toggles + pause/resume.
/// Body: scrollable, monospaced log list with auto-scroll-to-bottom while
/// not paused. Color-coded by level.
@available(macOS 14.0, *)
public struct DevOverlayLogsView: View {
@State private var model = DevOverlayLogsModel()
@State private var subscriberTask: Task<Void, Never>?
public init() {}
public var body: some View {
VStack(alignment: .leading, spacing: 6) {
controlBar
Divider()
logList
}
.padding(8)
.onAppear {
startTail()
}
.onDisappear {
subscriberTask?.cancel()
subscriberTask = nil
}
}
// MARK: - Control bar
private var controlBar: some View {
HStack(spacing: 8) {
TextField("Search…", text: $model.searchText)
.textFieldStyle(.roundedBorder)
.frame(minWidth: 160, maxWidth: 240)
Menu("Channels (\(model.enabledChannels.isEmpty ? "all" : "\(model.enabledChannels.count)"))") {
Button("Show all") { model.enabledChannels = [] }
Divider()
ForEach(model.availableChannels, id: \.self) { ch in
Toggle(ch, isOn: Binding(
get: { model.enabledChannels.contains(ch) },
set: { on in
if on { model.enabledChannels.insert(ch) }
else { model.enabledChannels.remove(ch) }
}
))
}
}
.frame(maxWidth: 180)
Menu("Levels (\(model.enabledLevels.isEmpty ? "all" : "\(model.enabledLevels.count)"))") {
Button("Show all") { model.enabledLevels = [] }
Divider()
ForEach(allLevels, id: \.self) { lvl in
Toggle(label(for: lvl), isOn: Binding(
get: { model.enabledLevels.contains(lvl) },
set: { on in
if on { model.enabledLevels.insert(lvl) }
else { model.enabledLevels.remove(lvl) }
}
))
}
}
.frame(maxWidth: 140)
Spacer()
Button(model.isPaused ? "Resume" : "Pause") {
model.isPaused.toggle()
}
.keyboardShortcut("p", modifiers: [.command])
Text("\(model.lines.count) lines")
.font(.caption.monospaced())
.foregroundColor(.secondary)
}
}
// MARK: - Log list
private var logList: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 1) {
ForEach(model.filtered()) { line in
LogLineRow(line: line)
.id(line.id)
}
}
.padding(.horizontal, 4)
}
.onChange(of: model.lines.count) { _, _ in
// Auto-scroll to bottom on every new line — unless the
// user has paused, in which case the view freezes.
guard !model.isPaused, let last = model.lines.last else { return }
withAnimation(.linear(duration: 0.05)) {
proxy.scrollTo(last.id, anchor: .bottom)
}
}
}
}
// MARK: - Subscription
/// Spawn the live-tail subscriber. Each line is appended on the main
/// actor so SwiftUI's Observation re-render happens on its expected
/// executor.
private func startTail() {
subscriberTask?.cancel()
let modelRef = model
subscriberTask = Task { @MainActor in
let stream = await LogBroadcaster.shared.subscribe()
for await line in stream {
if Task.isCancelled { break }
modelRef.append(line)
}
}
}
private var allLevels: [Logger.Level] {
[.trace, .debug, .info, .notice, .warning, .error, .critical]
}
private func label(for level: Logger.Level) -> String {
switch level {
case .trace: return "trace"
case .debug: return "debug"
case .info: return "info"
case .notice: return "notice"
case .warning: return "warning"
case .error: return "error"
case .critical: return "critical"
}
}
}
// MARK: - Row
@available(macOS 14.0, *)
private struct LogLineRow: View {
let line: LogLine
var body: some View {
HStack(alignment: .top, spacing: 6) {
Text(timeFormatter.string(from: line.timestamp))
.foregroundColor(.secondary)
Text(levelLabel)
.foregroundColor(levelColor)
.frame(width: 56, alignment: .leading)
Text(line.channel)
.foregroundColor(.secondary)
.frame(width: 110, alignment: .leading)
.lineLimit(1)
.truncationMode(.middle)
Text(line.message)
.foregroundColor(levelColor)
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: 0)
}
.font(.system(size: 11, design: .monospaced))
}
private var levelLabel: String {
switch line.level {
case .trace: return "TRACE"
case .debug: return "DEBUG"
case .info: return "INFO"
case .notice: return "NOTE"
case .warning: return "WARN"
case .error: return "ERROR"
case .critical: return "CRIT"
}
}
private var levelColor: Color {
switch line.level {
case .trace, .debug, .info, .notice: return .primary
case .warning: return .orange
case .error, .critical: return .red
}
}
}
// Module-private formatter — configured once at module-init. ISO with
// millisecond precision is enough for log tails.
private let timeFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "HH:mm:ss.SSS"
return f
}()
#endif
@@ -57,9 +57,13 @@ public struct DevOverlayView: View {
HealthPane(viewModel: viewModel)
.tabItem { Label("Health", systemImage: "stethoscope") }
.tag(2)
DevOverlayLogsView()
.tabItem { Label("Logs", systemImage: "text.alignleft") }
.tag(3)
}
.padding(8)
.frame(minWidth: 520, minHeight: 480)
.frame(minWidth: 640, minHeight: 520)
.background(.regularMaterial)
}
}
@@ -78,7 +78,7 @@ public final class DevOverlayWindow {
self.bootHealthOrchestrator = bootHealthOrchestrator
self.hudStateReader = hudStateReader
let contentRect = NSRect(x: 0, y: 0, width: 560, height: 520)
let contentRect = NSRect(x: 0, y: 0, width: 680, height: 560)
let styleMask: NSWindow.StyleMask = [
.titled,
.closable,
@@ -0,0 +1,237 @@
// LogBroadcaster.swift
//
// Process-wide fan-out of swift-log records to in-app subscribers. The
// file + os.Logger handlers already cover persistence and the system
// log; this third handler exists exclusively so the DevOverlay (and any
// future in-process diagnostic surface) can tail every log line live
// without polling files or relying on Console.app.
//
// Design:
// - `BroadcastLogHandler` (LogHandler conformance) formats each log
// call into a `LogLine` value and hands it to `LogBroadcaster.shared`.
// - `LogBroadcaster` is an actor that owns a bounded ring buffer
// (default 500) plus a set of subscriber continuations. New
// subscribers receive the buffer's contents replayed in order, then
// all subsequent live lines.
// - The shape is borrowed from `OrchestratorEventBroadcaster` so the
// mental model is consistent across the project's three broadcasters:
// subscriber stream + bounded back-pressure + drop-oldest under load.
//
// Threading:
// - `LogBroadcaster` is a global actor (`shared`). All mutating work
// (`publish`, `subscribe`, `unsubscribe`) hops to the actor.
// - `BroadcastLogHandler.log(...)` is synchronous and reentrancy-safe;
// it schedules an unstructured `Task { await broadcaster.publish(...) }`
// so the caller never blocks on the actor. Drop-oldest under load on
// the subscriber side means a slow consumer never pressures the
// emitter.
//
// Why not just write to a file the UI tails? Because the UI would have
// to re-parse the file, the file goes through the redaction pipeline
// (good for disk, lossy for live UX), and a third subscriber surface
// (DevOverlay) is the same architectural shape the project already
// uses for OrchestratorEvents — keeping it consistent beats inventing.
import Foundation
import Logging
/// One log entry as it appears in the broadcaster. Equatable + Sendable
/// so SwiftUI can store an array of these in a `@Published` slot and
/// re-diff cheaply.
///
/// ## Topics
///
/// - ``timestamp``
/// - ``channel``
/// - ``level``
/// - ``message``
/// - ``metadata``
public struct LogLine: Sendable, Equatable, Identifiable {
/// Deterministic identity for SwiftUI's `ForEach`. Composed from
/// timestamp + a monotonic sequence so identical messages a
/// millisecond apart still render as distinct rows.
public let id: UInt64
public let timestamp: Date
public let channel: String
public let level: Logger.Level
public let message: String
public let metadata: [String: String]
public init(
id: UInt64,
timestamp: Date,
channel: String,
level: Logger.Level,
message: String,
metadata: [String: String]
) {
self.id = id
self.timestamp = timestamp
self.channel = channel
self.level = level
self.message = message
self.metadata = metadata
}
}
/// Process-wide actor that fans out `LogLine`s to in-app subscribers.
///
/// ## Overview
///
/// Each subscriber gets the current buffer replayed at subscribe time
/// (so a DevOverlay opened mid-run shows the last 500 lines immediately),
/// followed by live lines as they're published. Subscribers are
/// identified by `UUID`; cancelling the AsyncStream's terminator removes
/// them from the broadcaster set.
///
/// ## Threading
///
/// Global actor. Every mutating method must be `await`-called. The
/// `BroadcastLogHandler` side schedules unstructured `Task { … }` writes
/// rather than blocking the logging caller — swift-log handlers must be
/// synchronous, so we trade strict ordering for non-blocking semantics.
/// Under heavy logging the ring buffer's drop-oldest keeps memory bounded.
public actor LogBroadcaster {
public static let shared = LogBroadcaster()
/// Bounded ring of the most recent lines. Subscribers joining late
/// replay this slice in order before going live.
private var buffer: [LogLine] = []
private let bufferCapacity: Int
/// Live subscribers. Continuations finish on `unsubscribe`.
private var subscribers: [UUID: AsyncStream<LogLine>.Continuation] = [:]
/// Monotonic sequence used to give each line a unique `id`.
private var sequence: UInt64 = 0
public init(bufferCapacity: Int = 500) {
precondition(bufferCapacity > 0)
self.bufferCapacity = bufferCapacity
}
/// Append a log line to the ring + fan out to every live subscriber.
public func publish(
timestamp: Date,
channel: String,
level: Logger.Level,
message: String,
metadata: [String: String]
) {
sequence &+= 1
let line = LogLine(
id: sequence,
timestamp: timestamp,
channel: channel,
level: level,
message: message,
metadata: metadata
)
buffer.append(line)
if buffer.count > bufferCapacity {
buffer.removeFirst(buffer.count - bufferCapacity)
}
for cont in subscribers.values {
cont.yield(line)
}
}
/// Subscribe to the live stream. The current buffer is replayed first,
/// then new lines arrive as they're published. Cancel the consumer's
/// task or break out of the for-await to terminate.
///
/// The stream is `.bufferingNewest(256)` — a slow consumer drops the
/// oldest in-flight lines rather than back-pressuring the publisher.
public func subscribe() -> AsyncStream<LogLine> {
let id = UUID()
let snapshot = buffer
let (stream, cont) = AsyncStream<LogLine>.makeStream(
bufferingPolicy: .bufferingNewest(256)
)
// Replay buffer in order so the UI shows historical context the
// instant it opens.
for line in snapshot {
cont.yield(line)
}
subscribers[id] = cont
cont.onTermination = { @Sendable [weak self] _ in
Task { await self?.removeSubscriber(id: id) }
}
return stream
}
/// Internal helper for `onTermination` cleanup.
fileprivate func removeSubscriber(id: UUID) {
if let cont = subscribers.removeValue(forKey: id) {
cont.finish()
}
}
/// Test seam — count of live subscribers.
public var subscriberCount: Int { subscribers.count }
/// Test seam — current buffer size.
public var bufferedCount: Int { buffer.count }
}
/// `swift-log` LogHandler that forwards every record to `LogBroadcaster.shared`.
///
/// Constructed by `LoggingBootstrap.make(label:)` alongside the file +
/// os.Logger handlers. The three handlers are wrapped in a
/// `MultiplexLogHandler`; this one is purely additive — disabling it
/// (removing it from the multiplex) leaves persistence + os logging
/// untouched.
///
/// Redaction note: the file handler's `Redact.apply(_:)` strips secrets
/// from disk writes. The broadcaster sees the **raw** message because
/// the DevOverlay is a developer-only surface running in-process; a
/// developer trying to debug their own session needs to see what
/// they actually logged. If this ever becomes user-facing, route through
/// `Redact.apply(_:)` before publishing.
public struct BroadcastLogHandler: LogHandler {
public let label: String
public var logLevel: Logger.Level = .trace
public var metadata: Logger.Metadata = [:]
public var metadataProvider: Logger.MetadataProvider?
public init(label: String) {
self.label = label
}
public func log(
level: Logger.Level,
message: Logger.Message,
metadata: Logger.Metadata?,
source: String,
file: String,
function: String,
line: UInt
) {
let merged = self.metadata.merging(metadata ?? [:]) { _, new in new }
var flat: [String: String] = [:]
flat.reserveCapacity(merged.count)
for (k, v) in merged {
flat[k] = "\(v)"
}
// Snapshot values into Sendable locals before hopping to the actor.
let ts = Date()
let channelCopy = label
let levelCopy = level
let msgCopy = "\(message)"
let metaCopy = flat
Task {
await LogBroadcaster.shared.publish(
timestamp: ts,
channel: channelCopy,
level: levelCopy,
message: msgCopy,
metadata: metaCopy
)
}
}
public subscript(metadataKey key: String) -> Logger.Metadata.Value? {
get { metadata[key] }
set { metadata[key] = newValue }
}
}
@@ -1,3 +1,17 @@
// LoggingBootstrap.swift
//
// Single entry point for `swift-log` `LoggingSystem.bootstrap`. Builds
// the three-way MultiplexLogHandler that every Jarvis logger funnels
// through:
//
// 1. FileLogHandler — rotating disk persistence with redaction.
// 2. OSLogHandler — Console.app + system log streaming.
// 3. BroadcastLogHandler — in-process fan-out to DevOverlay et al.
//
// The third handler was added 2026-05-12 (dev-overlay-end-to-end Slice 3)
// so the DevOverlay can tail every log line from every module live. It is
// additive — removing it leaves persistence + os logging intact.
import Foundation
import Logging // swift-log
@@ -13,7 +27,8 @@ public enum JarvisLogHandlerFactory {
dateProvider: SystemDateProvider()
)
let os = OSLogHandler(subsystem: subsystem, category: label, label: label)
return MultiplexLogHandler([file, os])
let broadcast = BroadcastLogHandler(label: label)
return MultiplexLogHandler([file, os, broadcast])
}
/// Call exactly ONCE at `AppDelegate.applicationWillFinishLaunching`.
@@ -0,0 +1,152 @@
// LogBroadcasterTests.swift
//
// Exercises the buffer/replay/fan-out semantics of LogBroadcaster +
// BroadcastLogHandler. Coverage:
// - LB1: publish appends to the buffer.
// - LB2: buffer caps at `bufferCapacity` (drop-oldest).
// - LB3: subscribers receive the buffer replay in order on join.
// - LB4: subscribers receive live lines after the replay.
// - LB5: BroadcastLogHandler.log eventually publishes via the actor.
import XCTest
import Logging
@testable import JarvisLogging
final class LogBroadcasterTests: XCTestCase {
// LB1: publish appends to the buffer.
func test_LB1_publishAppendsToBuffer() async {
let bc = LogBroadcaster(bufferCapacity: 10)
await bc.publish(
timestamp: Date(),
channel: "test",
level: .info,
message: "hello",
metadata: [:]
)
let count = await bc.bufferedCount
XCTAssertEqual(count, 1)
}
// LB2: buffer drops oldest when capacity is exceeded.
func test_LB2_bufferDropsOldestAtCapacity() async {
let bc = LogBroadcaster(bufferCapacity: 3)
for i in 0..<5 {
await bc.publish(
timestamp: Date(),
channel: "test",
level: .info,
message: "msg-\(i)",
metadata: [:]
)
}
let count = await bc.bufferedCount
XCTAssertEqual(count, 3)
// The newest 3 lines should survive. Verify by subscribing — the
// replay yields the surviving slice in order.
let stream = await bc.subscribe()
let collector = Task { () -> [String] in
var out: [String] = []
for await line in stream {
out.append(line.message)
if out.count == 3 { break }
}
return out
}
let observed = await collector.value
XCTAssertEqual(observed, ["msg-2", "msg-3", "msg-4"])
}
// LB3: subscribers see buffer replay on join.
func test_LB3_subscriberReceivesReplay() async {
let bc = LogBroadcaster(bufferCapacity: 10)
for i in 0..<3 {
await bc.publish(
timestamp: Date(),
channel: "ch",
level: .info,
message: "pre-\(i)",
metadata: [:]
)
}
let stream = await bc.subscribe()
let collector = Task { () -> [String] in
var out: [String] = []
for await line in stream {
out.append(line.message)
if out.count == 3 { break }
}
return out
}
let observed = await collector.value
XCTAssertEqual(observed, ["pre-0", "pre-1", "pre-2"])
}
// LB4: live lines arrive after replay.
func test_LB4_subscriberReceivesLiveAfterReplay() async {
let bc = LogBroadcaster(bufferCapacity: 10)
await bc.publish(
timestamp: Date(), channel: "ch", level: .info,
message: "replay-1", metadata: [:]
)
let stream = await bc.subscribe()
let collector = Task { () -> [String] in
var out: [String] = []
for await line in stream {
out.append(line.message)
if out.count == 3 { break }
}
return out
}
// Give the subscriber a moment to consume the replay before
// publishing the live lines — otherwise the test is racy on
// fast machines (publishes can land before the consumer's
// for-await starts).
try? await Task.sleep(nanoseconds: 10_000_000)
await bc.publish(
timestamp: Date(), channel: "ch", level: .info,
message: "live-1", metadata: [:]
)
await bc.publish(
timestamp: Date(), channel: "ch", level: .info,
message: "live-2", metadata: [:]
)
let observed = await collector.value
XCTAssertEqual(observed, ["replay-1", "live-1", "live-2"])
}
// LB5: BroadcastLogHandler.log eventually publishes via the actor.
// Synchronous-handler → unstructured-Task path; we poll the buffer
// count rather than racing the await.
func test_LB5_handlerPublishesAsynchronously() async {
// Use a private broadcaster so this test doesn't see lines from
// any other code logging into `LogBroadcaster.shared`.
// BroadcastLogHandler currently writes only to .shared, so we
// assert against that instance and tolerate concurrent noise by
// checking presence rather than exact count.
let handler = BroadcastLogHandler(label: "lb5-test")
let needle = "lb5-needle-\(UUID().uuidString)"
handler.log(
level: .notice,
message: "\(needle)",
metadata: nil,
source: "test",
file: #file,
function: #function,
line: #line
)
// Subscribe + scan for the needle within a bounded window.
let stream = await LogBroadcaster.shared.subscribe()
let collector = Task { () -> Bool in
let deadline = Date().addingTimeInterval(1.0)
for await line in stream {
if line.message.contains(needle) { return true }
if Date() > deadline { break }
}
return false
}
let found = await collector.value
XCTAssertTrue(found, "BroadcastLogHandler.log did not deliver to LogBroadcaster.shared")
}
}