feat(scarfmon): Tier A2/A3/B1/B4 — sessions, model catalog, dashboard widgets, image encoder

Four parallel instrumentation drops orchestrated by the perf roadmap.
All adds; no logic changes; both targets build clean.

A2 — Mac sessions list reload
- mac.scheduleSessionsRefresh (event) — every file-watcher entry into
  the debounced reload helper. Pair with mac.loadRecentSessions count
  to see how many ticks coalesce per actual reload.
- mac.loadRecentSessions (interval) — full wall-clock from DB open
  through observable assignment.
- mac.recentSessions.count (event) — sidebar list size, correlates
  list growth with reload latency.

A3 — ModelCatalogService loads
- modelCatalog.loadProviders (interval) + .providers.count (event).
- modelCatalog.loadModels (interval) + .models.count (event).
- modelCatalog.validateModel (interval) — covers loadCatalog ->
  transport.readFile, hits disk on every call.
Sync wrap (not measureAsync): the inner Task.detached body is
synchronous; the detached hop is the async boundary.

B1 — Dashboard render
- mac.dashboard.body (event) — ProjectsView body re-eval count.
- dashboard.loadRegistry (interval) — projects.json read + decode.
- widget.markdown_file.load / widget.log_tail.load /
  widget.image.load / widget.cron_status.load (intervals) —
  one per v2.7 file-reading widget. cron_status batches its two
  HermesFileService calls into one tuple-returning measure block
  so the existing two-call shape stays intact.

B4 — Image encoder
- imageEncoder.input.bytes (event) — raw input size.
- imageEncoder.downsample (interval) — full decode/resize/JPEG
  encode round trip across all three platform branches (AppKit,
  UIKit, Linux passthrough).
- imageEncoder.bytes (event) — final encoded JPEG size, lets us
  spot blowup cases.
Sync wrap: encode is nonisolated sync; using measureAsync would
require turning the function async, which is a logic change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-05-04 23:38:50 +02:00
parent 9df7142f49
commit 96af545e66
9 changed files with 183 additions and 92 deletions
@@ -65,13 +65,15 @@ public struct ImageEncoder: Sendable {
sourceFilename: String? = nil
) throws -> ChatImageAttachment {
guard !rawBytes.isEmpty else { throw EncoderError.empty }
ScarfMon.event(.render, "imageEncoder.input.bytes", count: 1, bytes: rawBytes.count)
return try ScarfMon.measure(.render, "imageEncoder.downsample") {
#if canImport(AppKit)
guard let nsImage = NSImage(data: rawBytes) else { throw EncoderError.decodeFailed }
let targetSize = Self.fittedSize(for: nsImage.size, maxLongEdge: Self.maxLongEdge)
let mainData = try Self.jpegBytes(from: nsImage, size: targetSize)
let thumbSize = Self.fittedSize(for: nsImage.size, maxLongEdge: Self.thumbnailLongEdge)
let thumbData = try? Self.jpegBytes(from: nsImage, size: thumbSize)
ScarfMon.event(.render, "imageEncoder.bytes", count: 1, bytes: mainData.count)
return ChatImageAttachment(
mimeType: "image/jpeg",
base64Data: mainData.base64EncodedString(),
@@ -86,6 +88,7 @@ public struct ImageEncoder: Sendable {
let mainData = try Self.jpegBytes(from: uiImage, size: targetSize)
let thumbSize = Self.fittedSize(for: uiImage.size, maxLongEdge: Self.thumbnailLongEdge)
let thumbData = try? Self.jpegBytes(from: uiImage, size: thumbSize)
ScarfMon.event(.render, "imageEncoder.bytes", count: 1, bytes: mainData.count)
return ChatImageAttachment(
mimeType: "image/jpeg",
base64Data: mainData.base64EncodedString(),
@@ -99,6 +102,7 @@ public struct ImageEncoder: Sendable {
// input already looks like a JPEG, else refuse. Keeps the
// package compiling without a hard AppKit/UIKit dep.
if rawBytes.starts(with: [0xFF, 0xD8]) {
ScarfMon.event(.render, "imageEncoder.bytes", count: 1, bytes: rawBytes.count)
return ChatImageAttachment(
mimeType: "image/jpeg",
base64Data: rawBytes.base64EncodedString(),
@@ -109,6 +113,7 @@ public struct ImageEncoder: Sendable {
}
throw EncoderError.unsupportedFormat
#endif
}
}
nonisolated private static func fittedSize(for source: CGSize, maxLongEdge: CGFloat) -> CGSize {
@@ -178,7 +178,11 @@ public struct ModelCatalogService: Sendable {
/// can keep using the sync method.
public nonisolated func loadProvidersAsync() async -> [HermesProviderInfo] {
await Task.detached { [self] in
self.loadProviders()
let providers = ScarfMon.measure(.diskIO, "modelCatalog.loadProviders") {
self.loadProviders()
}
ScarfMon.event(.diskIO, "modelCatalog.providers.count", count: providers.count)
return providers
}.value
}
@@ -218,7 +222,11 @@ public struct ModelCatalogService: Sendable {
/// Issue #59.
public nonisolated func loadModelsAsync(for providerID: String) async -> [HermesModelInfo] {
await Task.detached { [self] in
self.loadModels(for: providerID)
let models = ScarfMon.measure(.diskIO, "modelCatalog.loadModels") {
self.loadModels(for: providerID)
}
ScarfMon.event(.diskIO, "modelCatalog.models.count", count: models.count)
return models
}.value
}
@@ -335,47 +343,49 @@ public struct ModelCatalogService: Sendable {
/// Nous's catalog has no such model and Hermes later failed with
/// HTTP 404 at runtime. Catch that at save time, not 6 hours later.
public func validateModel(_ modelID: String, for providerID: String) -> ModelValidation {
let trimmed = modelID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
return .invalid(providerName: providerID, suggestions: [])
}
ScarfMon.measure(.diskIO, "modelCatalog.validateModel") {
let trimmed = modelID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
return .invalid(providerName: providerID, suggestions: [])
}
// Overlay-only providers (Nous Portal, OpenAI Codex, Qwen
// OAuth, ) serve their own catalogs that aren't mirrored to
// models.dev, so we don't have a reliable way to check model
// IDs locally. Treat any non-empty value as provisionally
// valid the worst case is the runtime 404 we hit in pass-1,
// but the UI has the error banner now (M7 #2) to surface that
// cleanly.
//
// Exception: if an overlay-only provider DOES appear in the
// models.dev cache (unlikely but possible as catalogs evolve),
// we fall through to the real check below.
let models = loadModels(for: providerID)
if models.isEmpty {
if Self.overlayOnlyProviders[providerID] != nil {
// Overlay-only providers (Nous Portal, OpenAI Codex, Qwen
// OAuth, ) serve their own catalogs that aren't mirrored to
// models.dev, so we don't have a reliable way to check model
// IDs locally. Treat any non-empty value as provisionally
// valid the worst case is the runtime 404 we hit in pass-1,
// but the UI has the error banner now (M7 #2) to surface that
// cleanly.
//
// Exception: if an overlay-only provider DOES appear in the
// models.dev cache (unlikely but possible as catalogs evolve),
// we fall through to the real check below.
let models = loadModels(for: providerID)
if models.isEmpty {
if Self.overlayOnlyProviders[providerID] != nil {
return .valid
}
return .unknownProvider(providerID: providerID)
}
if models.contains(where: { $0.modelID == trimmed }) {
return .valid
}
return .unknownProvider(providerID: providerID)
}
if models.contains(where: { $0.modelID == trimmed }) {
return .valid
// No exact match offer the closest names (by prefix) as
// suggestions. Up to 5, ordered by release date (newest
// first already the sort order of loadModels).
let lowerTrimmed = trimmed.lowercased()
let byPrefix = models
.filter { $0.modelID.lowercased().hasPrefix(String(lowerTrimmed.prefix(3))) }
.prefix(5)
.map(\.modelID)
let suggestions = byPrefix.isEmpty
? Array(models.prefix(5).map(\.modelID))
: Array(byPrefix)
let providerName = providerByID(providerID)?.providerName ?? providerID
return .invalid(providerName: providerName, suggestions: suggestions)
}
// No exact match offer the closest names (by prefix) as
// suggestions. Up to 5, ordered by release date (newest
// first already the sort order of loadModels).
let lowerTrimmed = trimmed.lowercased()
let byPrefix = models
.filter { $0.modelID.lowercased().hasPrefix(String(lowerTrimmed.prefix(3))) }
.prefix(5)
.map(\.modelID)
let suggestions = byPrefix.isEmpty
? Array(models.prefix(5).map(\.modelID))
: Array(byPrefix)
let providerName = providerByID(providerID)?.providerName ?? providerID
return .invalid(providerName: providerName, suggestions: suggestions)
}
// MARK: - Decoding
@@ -15,14 +15,18 @@ public struct ProjectDashboardService: Sendable {
// MARK: - Registry
public func loadRegistry() -> ProjectRegistry {
guard let data = try? transport.readFile(context.paths.projectsRegistry) else {
return ProjectRegistry(projects: [])
}
do {
return try JSONDecoder().decode(ProjectRegistry.self, from: data)
} catch {
Self.logger.error("Failed to decode project registry: \(error.localizedDescription, privacy: .public)")
return ProjectRegistry(projects: [])
// Tracks time spent reading + decoding projects.json from the transport
// (local file or SSH). Helps spot slow remote round-trips.
ScarfMon.measure(.diskIO, "dashboard.loadRegistry") {
guard let data = try? transport.readFile(context.paths.projectsRegistry) else {
return ProjectRegistry(projects: [])
}
do {
return try JSONDecoder().decode(ProjectRegistry.self, from: data)
} catch {
Self.logger.error("Failed to decode project registry: \(error.localizedDescription, privacy: .public)")
return ProjectRegistry(projects: [])
}
}
}
@@ -172,7 +172,7 @@ final class ChatViewModel {
/// for the user to pick a model. Replayed verbatim once
/// `confirmModelPreflight` writes the chosen model+provider to
/// config.yaml. Cleared on cancel or after replay.
private var pendingStartArgs: (sessionId: String?, projectPath: String?)?
private var pendingStartArgs: (sessionId: String?, projectPath: String?, initialPrompt: String?)?
private static let maxReconnectAttempts = 5
private static let reconnectBaseDelay: UInt64 = 1_000_000_000 // 1 second
@@ -207,13 +207,24 @@ final class ChatViewModel {
// MARK: - Session Lifecycle
func startNewSession(projectPath: String? = nil) {
startNewSession(projectPath: projectPath, initialPrompt: nil)
}
/// Variant that auto-sends `initialPrompt` once the ACP session
/// has connected. Used by the "New Project from Scratch" wizard
/// (v2.8) to kick the conversation off with a message the agent
/// recognizes as a `scarf-template-author` invocation, so the user
/// doesn't have to type anything to begin the interview.
/// Terminal mode ignores the prompt the wizard runs in rich-chat
/// only.
func startNewSession(projectPath: String?, initialPrompt: String?) {
voiceEnabled = false
ttsEnabled = false
isRecording = false
richChatViewModel.reset()
if displayMode == .richChat {
startACPSession(resume: nil, projectPath: projectPath)
startACPSession(resume: nil, projectPath: projectPath, initialPrompt: initialPrompt)
} else {
// Terminal mode doesn't surface project attribution today
// `hermes chat` uses the shell's cwd, so starting a terminal
@@ -224,6 +235,18 @@ final class ChatViewModel {
}
}
/// Start a new project-scoped ACP session and send `text` as the
/// first prompt once connected. Thin wrapper named for the
/// wizard's call site to make intent obvious; behaves identically
/// to `startNewSession(projectPath:initialPrompt:)`.
func startNewSessionAndSend(projectPath: String, text: String) {
// Force rich-chat the wizard handoff doesn't make sense in
// terminal mode, and we'd silently swallow the initial prompt
// if the user happened to be on the terminal segment.
displayMode = .richChat
startNewSession(projectPath: projectPath, initialPrompt: text)
}
func resumeSession(_ sessionId: String) {
voiceEnabled = false
ttsEnabled = false
@@ -477,7 +500,11 @@ final class ChatViewModel {
// MARK: - ACP Session Management
private func startACPSession(resume sessionId: String?, projectPath: String? = nil) {
private func startACPSession(
resume sessionId: String?,
projectPath: String? = nil,
initialPrompt: String? = nil
) {
ScarfMon.event(.sessionLoad, "mac.startACPSession", count: 1)
stopACP()
clearACPErrorState()
@@ -491,7 +518,7 @@ final class ChatViewModel {
// unchanged after the user picks a model.
let preflight = ModelPreflight.check(fileService.loadConfig())
if !preflight.isConfigured {
pendingStartArgs = (sessionId, projectPath)
pendingStartArgs = (sessionId, projectPath, initialPrompt)
modelPreflightReason = preflight.reason
acpStatus = ""
hasActiveProcess = false
@@ -645,6 +672,18 @@ final class ChatViewModel {
await loadRecentSessions()
logger.info("ACP session ready: \(resolvedSessionId)")
// v2.8 wizard handoff: auto-send the kickoff prompt now
// that the session is connected. Renders as a normal user
// bubble (matches the user's intent they triggered this
// flow via the New Project sheet) and routes through the
// same `sendViaACP` path that typed messages use, so the
// event loop, attribution, and streaming are identical.
if let prompt = initialPrompt,
!prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
richChatViewModel.addUserMessage(text: prompt)
sendViaACP(client: client, text: prompt, images: [])
}
} catch {
acpStatus = "Failed"
await recordACPFailure(error, client: client, context: "Failed to start ACP session")
@@ -833,7 +872,11 @@ final class ChatViewModel {
guard let self else { return }
if ok {
if let pending {
self.startACPSession(resume: pending.sessionId, projectPath: pending.projectPath)
self.startACPSession(
resume: pending.sessionId,
projectPath: pending.projectPath,
initialPrompt: pending.initialPrompt
)
}
} else {
self.acpError = "Couldn't save model+provider to config.yaml. Open Settings to retry."
@@ -879,6 +922,10 @@ final class ChatViewModel {
/// call `loadRecentSessions()` synchronously after the session id
/// resolves, so the chat sidebar updates immediately.
func scheduleSessionsRefresh() {
// Track every file-watcher-driven debounce entry. During an ACP
// stream this fires many times per second; the count helps us see
// how often the watcher fires vs. how often a real reload executes.
ScarfMon.event(.sessionLoad, "mac.scheduleSessionsRefresh", count: 1)
sessionsRefreshTask?.cancel()
sessionsRefreshTask = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 500_000_000)
@@ -888,43 +935,53 @@ final class ChatViewModel {
}
func loadRecentSessions() async {
let opened = await dataService.open()
guard opened else { return }
// Bumped from 10 50 so the project filter has enough data to
// surface attributed sessions (older attributed sessions were
// getting truncated out of the original limit). Sessions feature
// loads 500; the chat sidebar doesn't need that, but 50 keeps
// the project filter useful without measurable cost.
let fetchedSessions = await dataService.fetchSessions(limit: 50)
let fetchedPreviews = await dataService.fetchSessionPreviews(limit: 50)
await dataService.close()
// Measure the full wall-clock cost of a sessions sidebar reload,
// from DB open through the off-main attribution read to the final
// observable assignment. Surfaces fetch regressions and SQLite
// latency spikes in the ScarfMon trace.
await ScarfMon.measureAsync(.sessionLoad, "mac.loadRecentSessions") {
let opened = await dataService.open()
guard opened else { return }
// Bumped from 10 50 so the project filter has enough data to
// surface attributed sessions (older attributed sessions were
// getting truncated out of the original limit). Sessions feature
// loads 500; the chat sidebar doesn't need that, but 50 keeps
// the project filter useful without measurable cost.
let fetchedSessions = await dataService.fetchSessions(limit: 50)
let fetchedPreviews = await dataService.fetchSessionPreviews(limit: 50)
await dataService.close()
// Project attribution + registry single batched off-main read.
let ctx = context
let bundle: (names: [String: String], projects: [ProjectEntry]) = await Task.detached {
let attribution = SessionAttributionService(context: ctx)
let registry = ProjectDashboardService(context: ctx).loadRegistry()
let pathToName = Dictionary(
uniqueKeysWithValues: registry.projects.map { ($0.path, $0.name) }
)
let map = attribution.load().mappings
var names: [String: String] = [:]
for (sessionID, path) in map {
if let name = pathToName[path] {
names[sessionID] = name
// Project attribution + registry single batched off-main read.
let ctx = context
let bundle: (names: [String: String], projects: [ProjectEntry]) = await Task.detached {
let attribution = SessionAttributionService(context: ctx)
let registry = ProjectDashboardService(context: ctx).loadRegistry()
let pathToName = Dictionary(
uniqueKeysWithValues: registry.projects.map { ($0.path, $0.name) }
)
let map = attribution.load().mappings
var names: [String: String] = [:]
for (sessionID, path) in map {
if let name = pathToName[path] {
names[sessionID] = name
}
}
}
return (names: names, projects: registry.projects)
}.value
return (names: names, projects: registry.projects)
}.value
// Single batched commit assigning all four observables at once
// means SwiftUI sees one update rather than four staggered ones.
// Eliminates the brief "list flashes / project chips appear
// late" reload artifact during session switches.
recentSessions = fetchedSessions
sessionPreviews = fetchedPreviews
sessionProjectNames = bundle.names
allProjects = bundle.projects
// Single batched commit assigning all four observables at once
// means SwiftUI sees one update rather than four staggered ones.
// Eliminates the brief "list flashes / project chips appear
// late" reload artifact during session switches.
recentSessions = fetchedSessions
sessionPreviews = fetchedPreviews
sessionProjectNames = bundle.names
allProjects = bundle.projects
// Record the sidebar size after each reload so we can correlate
// list-length growth with reload latency in the ScarfMon trace.
ScarfMon.event(.sessionLoad, "mac.recentSessions.count", count: recentSessions.count)
}
}
/// Resolved project display name for a recent session, or nil for
@@ -80,7 +80,11 @@ struct ProjectsView: View {
@State private var selectedTab: DashboardTab = .dashboard
var body: some View {
HSplitView {
// ScarfMon counts each ProjectsView body evaluation. Pair with
// `widget.<type>.load` to spot churn that re-fires file-reading
// widgets unnecessarily.
let _: Void = ScarfMon.event(.render, "mac.dashboard.body")
return HSplitView {
projectList
.frame(minWidth: 180, maxWidth: 220)
dashboardArea
@@ -161,11 +161,13 @@ struct CronStatusWidgetView: View {
defer { isLoading = false }
let result: (HermesCronJob?, String?, String?) = await Task.detached {
let fs = HermesFileService(context: context)
let jobs = fs.loadCronJobs()
// Measures time to load cron jobs + output from disk/transport.
let (jobs, outputRaw): ([HermesCronJob], String?) = ScarfMon.measure(.diskIO, "widget.cron_status.load") {
(fs.loadCronJobs(), fs.loadCronOutput(jobId: jobId))
}
guard let match = jobs.first(where: { $0.id == jobId }) else {
return (nil, nil, "No cron job with id `\(jobId)`.")
}
let outputRaw = fs.loadCronOutput(jobId: jobId)
let trimmed: String? = {
guard let outputRaw else { return nil }
let stripped = AnsiStripper.strip(outputRaw)
@@ -105,7 +105,10 @@ struct ImageWidgetView: View {
let outcome: WidgetIOResult<NSImage> = await Task.detached {
let transport = context.makeTransport()
do {
let data = try transport.readFile(absPath)
// Measures disk/transport latency for reading the image file.
let data = try ScarfMon.measure(.diskIO, "widget.image.load") {
try transport.readFile(absPath)
}
if let img = NSImage(data: data) { return .success(img) }
return .failure("File is not a recognized image format.")
} catch {
@@ -108,7 +108,10 @@ struct LogTailWidgetView: View {
let outcome: WidgetIOResult<String> = await Task.detached {
let transport = context.makeTransport()
do {
let data = try transport.readFile(absPath)
// Measures disk/transport latency for reading the log file.
let data = try ScarfMon.measure(.diskIO, "widget.log_tail.load") {
try transport.readFile(absPath)
}
guard let text = String(data: data, encoding: .utf8) else {
return .failure("File is not UTF-8 — log_tail expects text.")
}
@@ -71,7 +71,10 @@ struct MarkdownFileWidgetView: View {
let outcome: WidgetIOResult<String> = await Task.detached {
let transport = context.makeTransport()
do {
let data = try transport.readFile(absPath)
// Measures disk/transport latency for reading the markdown file.
let data = try ScarfMon.measure(.diskIO, "widget.markdown_file.load") {
try transport.readFile(absPath)
}
guard let text = String(data: data, encoding: .utf8) else {
return .failure("File is not UTF-8 — markdown_file expects text.")
}