Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,55 +20,61 @@ nonisolated private let flickerLogger = Logger(subsystem: "com.cmuxterm.app.next
/// `<Application Support>/<bundle id>/diagnostics/agent-page-flicker.ndjson`, which becomes
/// `agent-page-flicker.1.ndjson` when a write would take it past ``maximumBytes``. Nothing in a line is the
/// person's text: a page name (`chat:<session>` or `new-tab`), a pid, numbers and short words.
/// Writes run on a utility queue, never the main thread.
nonisolated final class AgentPageFlickerLog: Sendable {
static let fileName = "agent-page-flicker.ndjson"
static let olderName = "agent-page-flicker.1.ndjson"
static let maximumBytes = 256 * 1024
/// Writes run on a utility queue, never the main thread: a caller's turn only builds the line.
///
/// The composition root makes the app's one (``forAppRun()``) and hands it to every pane through
/// ``AgentPaneShared/flickerLog``; a pane without one (tests, tools) writes nothing.
@MainActor public final class AgentPageFlickerLog {
nonisolated static let fileName = "agent-page-flicker.ndjson"
nonisolated static let olderName = "agent-page-flicker.1.ndjson"
nonisolated static let maximumBytes = 256 * 1024
/// The page's method; answered in ``AgentPaneBridge`` before ``AgentPaneRequest``, never logged.
static let method = "diag.flicker"
nonisolated static let method = "diag.flicker"
/// The kinds a page may write, and the most events one call carries.
static let pageKinds: Set<String> = ["page-load", "visibility", "transcript-mount", "long-frame"]
static let maximumPageEvents = 32
nonisolated static let pageKinds: Set<String> = ["page-load", "visibility", "transcript-mount", "long-frame"]
nonisolated static let maximumPageEvents = 32

/// The app's ring; nil outside an app bundle (package tests, tools), so they never write it.
static let shared: AgentPageFlickerLog? = {
/// The app run's ring, for the composition root to make once; nil outside an app bundle
/// (package tests, tools), so they never write it.
public nonisolated static func forAppRun() -> AgentPageFlickerLog? {
guard Bundle.main.bundleURL.pathExtension == "app", let bundle = Bundle.main.bundleIdentifier, !bundle.isEmpty,
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil }
return AgentPageFlickerLog(directory: support.appending(path: bundle).appending(path: "diagnostics", directoryHint: .isDirectory))
}()
}

let directory: URL
private let queue = DispatchQueue(label: "com.cmuxterm.app.next.agent-page.flicker-log", qos: .utility)
nonisolated let directory: URL
private nonisolated let queue = DispatchQueue(label: "com.cmuxterm.app.next.agent-page.flicker-log", qos: .utility)
/// The window occlusion watch (``watchWindows()``), started by the first pane.
private var occlusion: MainActorObservation?

init(directory: URL) {
nonisolated init(directory: URL) {
self.directory = directory
}

var url: URL { directory.appending(path: Self.fileName) }
nonisolated var url: URL { directory.appending(path: Self.fileName) }

/// Appends one line: `fields` with `at` (UTC, milliseconds) first.
func record(_ kind: String, at date: Date = Date(), _ fields: [String: Any] = [:]) {
nonisolated func record(_ kind: String, at date: Date = Date(), _ fields: [String: Any] = [:]) {
guard let line = Self.line(kind: kind, at: date, fields: fields) else { return }
append([line])
}

/// The page's events (`{events: [{kind, at, ...}]}`) for the pane showing `page`: only known
/// kinds and short scalar fields, at most ``maximumPageEvents``.
func recordPage(_ params: [String: Any]?, page: String, pid: Int32) {
nonisolated func recordPage(_ params: [String: Any]?, page: String, pid: Int32) {
let lines = Self.pageLines(params, page: page, pid: pid)
if !lines.isEmpty { append(lines) }
}

/// A pane's page name in a line: `chat:<session>`, or `new-tab`.
@MainActor static func pageName(_ view: AgentPaneView) -> String {
static func pageName(_ view: AgentPaneView) -> String {
view.model.sessionId.map { "chat:\($0)" } ?? "new-tab"
}

/// Waits for every queued write (tests).
func flush() { queue.sync {} }
nonisolated func flush() { queue.sync {} }

static func pageLines(_ params: [String: Any]?, page: String, pid: Int32) -> [Data] {
nonisolated static func pageLines(_ params: [String: Any]?, page: String, pid: Int32) -> [Data] {
guard let events = params?["events"] as? [Any] else { return [] }
return events.prefix(maximumPageEvents).compactMap { item -> Data? in
guard let event = item as? [String: Any], let kind = event["kind"] as? String, pageKinds.contains(kind) else { return nil }
Expand All @@ -89,7 +95,7 @@ nonisolated final class AgentPageFlickerLog: Sendable {
}
}

static func line(kind: String, at date: Date, fields: [String: Any]) -> Data? {
nonisolated static func line(kind: String, at date: Date, fields: [String: Any]) -> Data? {
var object = fields
object["kind"] = kind
object["at"] = Date.ISO8601FormatStyle(includingFractionalSeconds: true).format(date)
Expand All @@ -99,7 +105,7 @@ nonisolated final class AgentPageFlickerLog: Sendable {
return data
}

private func append(_ lines: [Data]) {
private nonisolated func append(_ lines: [Data]) {
let directory = directory, url = url, older = directory.appending(path: Self.olderName)
queue.async {
do {
Expand Down Expand Up @@ -127,19 +133,17 @@ nonisolated final class AgentPageFlickerLog: Sendable {
}
}

/// Records every window's occlusion flip, once per app run (the first agent pane calls it).
@MainActor static func watchWindows() {
guard occlusion == nil, let log = shared else { return }
occlusion = MainActorObservation(NSWindow.didChangeOcclusionStateNotification, read: { $0.object as? NSWindow }) { window in
guard let window else { return }
log.record("window-occlusion", [
/// Records every window's occlusion flip from now on, once per log (each agent pane calls it; the first starts it).
/// - Parameter center: Where windows post their flips (tests pass their own).
func watchWindows(center: NotificationCenter = .default) {
guard occlusion == nil else { return }
occlusion = MainActorObservation(NSWindow.didChangeOcclusionStateNotification, center: center, read: { $0.object as? NSWindow }) { [weak self] window in
guard let self, let window else { return }
record("window-occlusion", [
"window": window.windowNumber,
"visible": window.occlusionState.contains(.visible),
"type": String(describing: type(of: window)),
])
}
}

/// App-wide for the app run.
@MainActor private static var occlusion: MainActorObservation?
}
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ public final class AgentPageTerminations {
// An event none of whose pages still shows the notice is done (the pages closed or reloaded).
events.removeAll { $0 !== event && $0.waiting.isEmpty }
let page = event.pages.last
AgentPageFlickerLog.shared?.record("web-content-ended", [
view.flickerLog?.record("web-content-ended", [
"reason": termination.reason.rawValue, "pid": Int(termination.pid), "page": page?.page ?? "-",
"engine": page?.engine ?? "-", "ended_with_it": event.pages.count, "retries": retries,
])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ final class AgentPaneBridge: NSObject, WKScriptMessageHandlerWithReply {
// The page's flicker detector (``AgentPageFlickerLog``): short words and numbers for the
// ring file, answered at once and not logged here.
if let body = message.body as? [String: Any], body["method"] as? String == AgentPageFlickerLog.method, let view {
AgentPageFlickerLog.shared?.recordPage(body["params"] as? [String: Any], page: AgentPageFlickerLog.pageName(view),
pid: view.knownWebContentPid)
view.flickerLog?.recordPage(body["params"] as? [String: Any], page: AgentPageFlickerLog.pageName(view),
pid: view.knownWebContentPid)
return replyHandler(AgentPaneReply.success(), nil)
}
// Where a chat rests belongs to the chat, so it is answered here from the one store both
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ final class AgentPaneNavigation: NSObject, WKNavigationDelegate {
/// page with no `newTab.context` round trip (`injectedNewTabContext`, bd cx-z9kz0). The host
/// gives what is true at this load: a reloaded page that became a chat gets none.
func webView(_ webView: WKWebView, didCommit navigation: WKNavigation?) {
if let view {
AgentPageFlickerLog.shared?.record("navigation", ["page": AgentPageFlickerLog.pageName(view), "engine": view.engineName])
if let view, let log = view.flickerLog {
log.record("navigation", ["page": AgentPageFlickerLog.pageName(view), "engine": view.engineName])
}
guard let model = view?.model, model.sessionId == nil, var page = model.newTab else { return }
// Its start folder too, so the first frame draws the project or no project (``AgentPaneNewTab/start``).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,22 @@ public import CmuxNextDesign
/// - `apps`: the app supervisor's client; each page gets its own ``AppsPanelsPageProvider`` over it (Session Info
/// app sections). Nil (tests): the page has no `cmux.apps.panels.*` ops.
/// - `actAsMe`: the app's one "Computer use can act as me" grant (``AgentPaneActAsMe``); nil (tests) grants nothing.
/// - `flickerLog`: the app run's flicker ring file (``AgentPageFlickerLog/forAppRun()``); nil (tests) writes none.
public struct AgentPaneShared {
public let openPanel: MadeAheadOpenPanel?
public let eventSources: EventSourceProcesses
public let apps: AppsClient?
public let actAsMe: AgentPaneActAsMe?
public let flickerLog: AgentPageFlickerLog?

/// The defaults (tests): no open panel, a posting-process table of the pane's own, no apps and no grant.
/// The defaults (tests): no open panel, a posting-process table of the pane's own, no apps, no grant and no flicker log.
public init(openPanel: MadeAheadOpenPanel? = nil, eventSources: EventSourceProcesses = EventSourceProcesses(), apps: AppsClient? = nil,
actAsMe: AgentPaneActAsMe? = nil) {
actAsMe: AgentPaneActAsMe? = nil, flickerLog: AgentPageFlickerLog? = nil) {
self.openPanel = openPanel
self.eventSources = eventSources
self.apps = apps
self.actAsMe = actAsMe
self.flickerLog = flickerLog
}
}

Expand All @@ -32,7 +35,7 @@ extension AgentPaneView {
public convenience init?(model: AgentPaneModel, source: AgentPaneSource? = nil, renderRate: AgentPaneRenderRate = .capped,
pageHost: Bool = false, shared: AgentPaneShared) {
self.init(model: model, source: source, renderRate: renderRate, pageHost: pageHost, panelProvider: shared.apps.map(AppsPanelsPageProvider.init(client:)),
openPanel: shared.openPanel, eventSources: shared.eventSources)
openPanel: shared.openPanel, eventSources: shared.eventSources, flickerLog: shared.flickerLog)
model.transport.gestures.actAsMe = shared.actAsMe
showActAsMe(shared.actAsMe)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ public final class AgentPaneView: NSView {
let crashRetry = AgentPageCrashRetry()
/// The page ends of the app's agent panes, grouped by the termination that ended them.
let terminations: AgentPageTerminations
/// The app's flicker ring file (``AgentPageFlickerLog``, made by the composition root); nil writes nothing (tests).
let flickerLog: AgentPageFlickerLog?
/// The line that says the chat runs on another cmux install's acpmux (cx-fcaq).
var otherInstallNotice: AgentPaneNotice?
/// The pane's daemon is another cmux install's (cx-fcaq); the line shows only while a request waits.
Expand Down Expand Up @@ -183,12 +185,14 @@ public final class AgentPaneView: NSView {
/// - openPanel: The app's made-ahead open panel (`AppServices.openPanel`) for Add Files, Attach and a page's
/// file input, warmed when the pane reaches a window; nil (tests) shows no file picker and warms none.
/// - eventSources: The app's table of processes that post input (`AppServices.personInput.sources`); tests get their own.
/// - flickerLog: The app's flicker ring file (``AgentPaneShared/flickerLog``); nil (tests) writes none.
public init?(model: AgentPaneModel, source: AgentPaneSource? = nil, renderRate: AgentPaneRenderRate = .capped,
pageHost: Bool = false, testRoot: Bool = false, chromium: (any PageChromiumHosting)? = PageWebView.chromiumHosting,
panelProvider: (any PageProvider)? = nil,
openPanel: MadeAheadOpenPanel? = nil, terminations: AgentPageTerminations? = nil,
eventSources: EventSourceProcesses = EventSourceProcesses()) {
eventSources: EventSourceProcesses = EventSourceProcesses(), flickerLog: AgentPageFlickerLog? = nil) {
self.terminations = terminations ?? Self.processGroups.terminations
self.flickerLog = flickerLog
guard let source = source ?? Self.bundledPage.map({ AgentPaneSource.bundled($0) }) else { return nil }
self.openPanel = openPanel
self.eventSources = eventSources
Expand Down Expand Up @@ -268,7 +272,7 @@ public final class AgentPaneView: NSView {
if let self { LinkRouter.shared.open(url, modifiers: modifiers, from: self) } else { Self.routedOpenURL(url, modifiers) }
}
inputReadiness.attach(webView)
AgentPageFlickerLog.watchWindows()
flickerLog?.watchWindows()
if let page {
attachPage(page)
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ extension AgentTabStore {
/// The agent tabs' view store of `services`, wired to every machine's tree and daemon.
static func wired(to services: AppServices) -> AgentTabStore {
let tabs = AgentTabStore(build: services.environment.build, registry: services.registry, environment: services.agentPaneEnvironment ?? ProcessInfo.processInfo.environment, showcase: services.environment.showcase,
linkScheme: services.linkScheme, settings: services.settings, paneShared: AgentPaneShared(openPanel: services.openPanel, eventSources: services.personInput.sources, apps: services.apps.client, actAsMe: services.personInput.actAsMe),
linkScheme: services.linkScheme, settings: services.settings, paneShared: AgentPaneShared(openPanel: services.openPanel, eventSources: services.personInput.sources, apps: services.apps.client, actAsMe: services.personInput.actAsMe, flickerLog: AgentPageFlickerLog.forAppRun()),
attentionTabs: services.attentionTabs,
daemons: services.acpmuxDaemons, updates: services.daemonUpdates, peerApprover: services.peerApprover)
tabs.onRemoteChatStarted = { [weak services] host, session, cwd, harness in
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
#if DEBUG
import AppKit
import CmuxNextTestSupport
import Foundation
import Testing
@testable import CmuxNextAgentPane
Expand Down Expand Up @@ -63,5 +65,22 @@ struct AgentPageFlickerLogTests {
let olderSize = try #require(try FileManager.default.attributesOfItem(atPath: older.path)[.size] as? Int)
#expect(olderSize <= AgentPageFlickerLog.maximumBytes)
}

/// The window watch is the log's own (the composition root makes the one log every pane shares), not
/// app-wide static state: each pane asks for it and a window's occlusion flip is one line, not one per pane.
@MainActor @Test func everyPaneAsksForTheWindowWatchAndAFlipIsOneLine() throws {
let log = AgentPageFlickerLog(directory: directory())
defer { try? FileManager.default.removeItem(at: log.directory) }
let center = NotificationCenter()
log.watchWindows(center: center)
log.watchWindows(center: center)
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 10, height: 10), styleMask: [.borderless], backing: .buffered, defer: false)
defer { window.closeAfterTest() }
center.post(name: NSWindow.didChangeOcclusionStateNotification, object: window)
log.flush()
let flips = try lines(log.url)
#expect(flips.map { $0["kind"] as? String } == ["window-occlusion"])
#expect(flips.first?["window"] as? Int == window.windowNumber)
}
}
#endif
Loading