mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-08 02:14:37 +00:00
feat(scarfmon): chat + transport + sqlite measure points (Phase 2)
Wires ScarfMon measure points into the chat hot path on both targets, plus the underlying SSH transport and remote-SQLite backend. All callsites are surgical adds — no behavior change. Cost when ScarfMon is in `.signpostOnly` (default) is one os_signpost emit per call, elided by the runtime outside an Instruments session. In `.full` mode the same callsites also push samples into the in-memory ring buffer. Render counters (event): - mac.ChatView.body / ios.ChatView.body — full transcript pane re-evals - mac.RichMessageBubble.body / ios.MessageBubble.body — per-bubble re-evals Stream + session (event + interval): - mac.sendViaACP, mac.sendPrompt — user tap → first-byte - mac.acpEvent, mac.handleACPEvent — per-event delivery + handle cost - mac.startACPSession — session boot - ios.send, ios.startResuming — same shape on iOS - ios.acpEvent, ios.handleACPEvent — same per-event split on iOS Transport + SQLite (interval, with byte counts on rows): - ssh.streamScript (Citadel iOS) — SSH round-trip - ssh.run (SSHScriptRunner Mac) — SSH round-trip - sqlite.query, sqlite.queryBatch — Remote SQLite per-call - sqlite.query.rows — row count + stdout bytes per query Disk I/O (interval): - diskIO.loadConfig — config.yaml read + parse - diskIO.loadCronJobs — cron jobs.json decode Body counters use the `let _: Void = ScarfMon.event(...)` pattern at the top of `body` — works inside `@ViewBuilder` and fires on every re-eval, which is exactly the signal we want. To use: Mac: Settings → Advanced → Performance Diagnostics → Full iOS: Settings → Diagnostics → Performance → Full Both panels auto-aggregate by (category, name), surface top 20 by p95, and offer Copy as JSON for sharing in feedback threads. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+27
-17
@@ -149,27 +149,37 @@ public actor RemoteSQLiteBackend: HermesQueryBackend {
|
||||
// MARK: - Queries
|
||||
|
||||
public func query(_ sql: String, params: [SQLValue]) async throws -> [Row] {
|
||||
guard isOpen else { throw BackendError.notOpen }
|
||||
let inlined = SQLValueInliner.inline(sql, params: params)
|
||||
let dbPath = context.paths.stateDB
|
||||
let script = """
|
||||
sqlite3 -readonly -json \(quoteForRemoteShell(dbPath)) <<'__SCARF_SQL__'
|
||||
\(inlined)
|
||||
__SCARF_SQL__
|
||||
"""
|
||||
let result: ProcessResult
|
||||
do {
|
||||
result = try await transport.streamScript(script, timeout: queryTimeout)
|
||||
} catch {
|
||||
throw BackendError.transport(error.localizedDescription)
|
||||
try await ScarfMon.measureAsync(.sqlite, "query") {
|
||||
guard isOpen else { throw BackendError.notOpen }
|
||||
let inlined = SQLValueInliner.inline(sql, params: params)
|
||||
let dbPath = context.paths.stateDB
|
||||
let script = """
|
||||
sqlite3 -readonly -json \(quoteForRemoteShell(dbPath)) <<'__SCARF_SQL__'
|
||||
\(inlined)
|
||||
__SCARF_SQL__
|
||||
"""
|
||||
let result: ProcessResult
|
||||
do {
|
||||
result = try await transport.streamScript(script, timeout: queryTimeout)
|
||||
} catch {
|
||||
throw BackendError.transport(error.localizedDescription)
|
||||
}
|
||||
if result.exitCode != 0 {
|
||||
throw BackendError.sqlite(exitCode: result.exitCode, stderr: result.stderrString)
|
||||
}
|
||||
let rows = try parseSingleResultSet(result.stdoutString)
|
||||
ScarfMon.event(.sqlite, "query.rows", count: rows.count, bytes: result.stdout.count)
|
||||
return rows
|
||||
}
|
||||
if result.exitCode != 0 {
|
||||
throw BackendError.sqlite(exitCode: result.exitCode, stderr: result.stderrString)
|
||||
}
|
||||
return try parseSingleResultSet(result.stdoutString)
|
||||
}
|
||||
|
||||
public func queryBatch(_ statements: [(sql: String, params: [SQLValue])]) async throws -> [[Row]] {
|
||||
try await ScarfMon.measureAsync(.sqlite, "queryBatch") {
|
||||
try await _queryBatchImpl(statements)
|
||||
}
|
||||
}
|
||||
|
||||
private func _queryBatchImpl(_ statements: [(sql: String, params: [SQLValue])]) async throws -> [[Row]] {
|
||||
guard isOpen else { throw BackendError.notOpen }
|
||||
if statements.isEmpty { return [] }
|
||||
// Build one sqlite3 invocation with marker SELECTs separating
|
||||
|
||||
@@ -46,16 +46,18 @@ public enum SSHScriptRunner {
|
||||
/// cross-platform we return a connect failure on non-macOS so
|
||||
/// the file compiles everywhere.
|
||||
public static func run(script: String, context: ServerContext, timeout: TimeInterval = 30) async -> Outcome {
|
||||
#if os(macOS)
|
||||
switch context.kind {
|
||||
case .local:
|
||||
return await runLocally(script: script, timeout: timeout)
|
||||
case .ssh(let config):
|
||||
return await runOverSSH(script: script, config: config, timeout: timeout)
|
||||
await ScarfMon.measureAsync(.transport, "ssh.run") {
|
||||
#if os(macOS)
|
||||
switch context.kind {
|
||||
case .local:
|
||||
return await runLocally(script: script, timeout: timeout)
|
||||
case .ssh(let config):
|
||||
return await runOverSSH(script: script, config: config, timeout: timeout)
|
||||
}
|
||||
#else
|
||||
return .connectFailure("SSHScriptRunner is only available on macOS")
|
||||
#endif
|
||||
}
|
||||
#else
|
||||
return .connectFailure("SSHScriptRunner is only available on macOS")
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - SSH path
|
||||
|
||||
@@ -179,6 +179,12 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable
|
||||
/// untouched — same correctness guarantee as `SSHScriptRunner`'s
|
||||
/// stdin-pipe approach.
|
||||
public func streamScript(_ script: String, timeout: TimeInterval) async throws -> ProcessResult {
|
||||
try await ScarfMon.measureAsync(.transport, "ssh.streamScript") {
|
||||
try await _streamScriptImpl(script, timeout: timeout)
|
||||
}
|
||||
}
|
||||
|
||||
private func _streamScriptImpl(_ script: String, timeout: TimeInterval) async throws -> ProcessResult {
|
||||
let scriptBytes = Data(script.utf8)
|
||||
let b64 = scriptBytes.base64EncodedString()
|
||||
// Prepend the same PATH guard that `asyncRunProcess` uses so
|
||||
|
||||
@@ -66,7 +66,12 @@ struct ChatView: View {
|
||||
)!
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// ScarfMon body-evaluation counter. Re-render churn during
|
||||
// streaming is one of the load-bearing perf signals; rendering
|
||||
// here costs ~one signpost emit + ring-buffer append (off the
|
||||
// hot path otherwise).
|
||||
let _: Void = ScarfMon.event(.chatRender, "ios.ChatView.body")
|
||||
return VStack(spacing: 0) {
|
||||
connectionBanner
|
||||
errorBanner
|
||||
projectContextBar
|
||||
@@ -1254,6 +1259,12 @@ final class ChatController {
|
||||
/// assistant reply streams back as ACP notifications handled by
|
||||
/// the event task.
|
||||
func send() async {
|
||||
await ScarfMon.measureAsync(.chatStream, "ios.send") {
|
||||
await _sendImpl()
|
||||
}
|
||||
}
|
||||
|
||||
private func _sendImpl() async {
|
||||
guard state == .ready, let client else { return }
|
||||
let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
// v0.12+ allows image-only sends — vision models accept "describe
|
||||
@@ -1358,7 +1369,10 @@ final class ChatController {
|
||||
let stream = await client.events
|
||||
for await event in stream {
|
||||
guard !Task.isCancelled else { break }
|
||||
self?.vm.handleACPEvent(event)
|
||||
ScarfMon.event(.chatStream, "ios.acpEvent", count: 1)
|
||||
ScarfMon.measure(.chatStream, "ios.handleACPEvent") {
|
||||
self?.vm.handleACPEvent(event)
|
||||
}
|
||||
}
|
||||
// Stream ended — if we weren't explicitly cancelled the
|
||||
// channel died (EOF on stdin/out, write to dead pipe,
|
||||
@@ -1788,6 +1802,12 @@ final class ChatController {
|
||||
/// to `session/load` if the remote doesn't support `session/resume`
|
||||
/// (Hermes < 0.9.x).
|
||||
func startResuming(sessionID: String) async {
|
||||
await ScarfMon.measureAsync(.sessionLoad, "ios.startResuming") {
|
||||
await _startResumingImpl(sessionID: sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
private func _startResumingImpl(sessionID: String) async {
|
||||
guard await passModelPreflight(intent: .resume(sessionID: sessionID)) else { return }
|
||||
await stop()
|
||||
vm.reset()
|
||||
@@ -1952,6 +1972,11 @@ private struct MessageBubble: View, Equatable {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// Per-bubble render counter. The streaming bubble
|
||||
// (`message.id == 0`) re-renders on every chunk; tracking the
|
||||
// count here is what tells us if a slow chat is bottlenecked
|
||||
// on body re-eval vs. event-loop delivery.
|
||||
let _: Void = ScarfMon.event(.chatRender, "ios.MessageBubble.body")
|
||||
if message.isToolResult {
|
||||
ToolResultRow(message: message)
|
||||
} else {
|
||||
|
||||
@@ -17,8 +17,10 @@ struct HermesFileService: Sendable {
|
||||
// MARK: - Config
|
||||
|
||||
nonisolated func loadConfig() -> HermesConfig {
|
||||
guard let content = readFile(context.paths.configYAML) else { return .empty }
|
||||
return parseConfig(content)
|
||||
ScarfMon.measure(.diskIO, "loadConfig") {
|
||||
guard let content = readFile(context.paths.configYAML) else { return .empty }
|
||||
return parseConfig(content)
|
||||
}
|
||||
}
|
||||
|
||||
/// Error-surfacing config load. Used by Dashboard to show the user a
|
||||
@@ -480,13 +482,15 @@ struct HermesFileService: Sendable {
|
||||
// MARK: - Cron
|
||||
|
||||
nonisolated func loadCronJobs() -> [HermesCronJob] {
|
||||
guard let data = readFileData(context.paths.cronJobsJSON) else { return [] }
|
||||
do {
|
||||
let file = try JSONDecoder().decode(CronJobsFile.self, from: data)
|
||||
return file.jobs
|
||||
} catch {
|
||||
print("[Scarf] Failed to decode cron jobs: \(error.localizedDescription)")
|
||||
return []
|
||||
ScarfMon.measure(.diskIO, "loadCronJobs") {
|
||||
guard let data = readFileData(context.paths.cronJobsJSON) else { return [] }
|
||||
do {
|
||||
let file = try JSONDecoder().decode(CronJobsFile.self, from: data)
|
||||
return file.jobs
|
||||
} catch {
|
||||
print("[Scarf] Failed to decode cron jobs: \(error.localizedDescription)")
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -395,6 +395,7 @@ final class ChatViewModel {
|
||||
}
|
||||
|
||||
private func sendViaACP(client: ACPClient, text: String, images: [ChatImageAttachment] = []) {
|
||||
ScarfMon.event(.chatStream, "mac.sendViaACP", count: 1, bytes: text.utf8.count)
|
||||
guard let sessionId = richChatViewModel.sessionId else {
|
||||
clearACPErrorState()
|
||||
acpError = "No session ID — cannot send"
|
||||
@@ -434,7 +435,9 @@ final class ChatViewModel {
|
||||
}
|
||||
acpPromptTask = Task { @MainActor in
|
||||
do {
|
||||
let result = try await client.sendPrompt(sessionId: sessionId, text: wireText, images: images)
|
||||
let result = try await ScarfMon.measureAsync(.chatStream, "mac.sendPrompt") {
|
||||
try await client.sendPrompt(sessionId: sessionId, text: wireText, images: images)
|
||||
}
|
||||
acpStatus = "Ready"
|
||||
richChatViewModel.handleACPEvent(
|
||||
.promptComplete(sessionId: sessionId, response: result)
|
||||
@@ -475,6 +478,7 @@ final class ChatViewModel {
|
||||
// MARK: - ACP Session Management
|
||||
|
||||
private func startACPSession(resume sessionId: String?, projectPath: String? = nil) {
|
||||
ScarfMon.event(.sessionLoad, "mac.startACPSession", count: 1)
|
||||
stopACP()
|
||||
clearACPErrorState()
|
||||
|
||||
@@ -655,7 +659,10 @@ final class ChatViewModel {
|
||||
let eventStream = await client.events
|
||||
for await event in eventStream {
|
||||
guard !Task.isCancelled else { break }
|
||||
self?.richChatViewModel.handleACPEvent(event)
|
||||
ScarfMon.event(.chatStream, "mac.acpEvent", count: 1)
|
||||
ScarfMon.measure(.chatStream, "mac.handleACPEvent") {
|
||||
self?.richChatViewModel.handleACPEvent(event)
|
||||
}
|
||||
self?.acpStatus = await client.statusMessage
|
||||
}
|
||||
// Stream ended — if we weren't cancelled, the connection died
|
||||
|
||||
@@ -17,6 +17,12 @@ struct ChatView: View {
|
||||
private var showInspector: Bool = true
|
||||
|
||||
var body: some View {
|
||||
// ScarfMon body-evaluation counter — tracks how many times
|
||||
// SwiftUI re-evaluates this view per second during streaming.
|
||||
// High counts here usually mean state is fanning out further
|
||||
// than necessary; pair with `mac.RichMessageBubble.body` to
|
||||
// see whether the churn lives in the parent or the bubbles.
|
||||
let _: Void = ScarfMon.event(.chatRender, "mac.ChatView.body")
|
||||
@Bindable var vm = viewModel
|
||||
@Bindable var coord = coordinator
|
||||
VStack(spacing: 0) {
|
||||
|
||||
@@ -58,6 +58,11 @@ struct RichMessageBubble: View, Equatable {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// Per-bubble render counter. The streaming bubble re-renders
|
||||
// per token; cross-reference with `mac.ChatView.body` and
|
||||
// `chatStream.handleACPEvent` to see whether streaming churn
|
||||
// lives in the parent, the bubble, or the event handler.
|
||||
let _: Void = ScarfMon.event(.chatRender, "mac.RichMessageBubble.body")
|
||||
if message.isUser {
|
||||
userBubble
|
||||
} else if message.isAssistant {
|
||||
|
||||
Reference in New Issue
Block a user