diff --git a/Resources/zh-Hans.lproj/Localizable.strings b/Resources/zh-Hans.lproj/Localizable.strings index da56bde3..778ae3d8 100644 --- a/Resources/zh-Hans.lproj/Localizable.strings +++ b/Resources/zh-Hans.lproj/Localizable.strings @@ -288,8 +288,48 @@ "Close" = "关闭"; "Mole CLI not found" = "未找到 Mole CLI"; -/* Home (Overview / History / Activity) + Explain */ +/* Home (Overview / History / Activity / Report) + Explain */ "Overview" = "概览"; +"Report" = "报告"; +"Doctor" = "诊断"; +"Diagnostics" = "系统诊断"; +"Dev hygiene" = "开发清理"; +"No developer caches found." = "未发现开发者缓存。"; +"Clear" = "清理"; +"Move this cache to the Trash?" = "将此缓存移到废纸篓?"; +"ports" = "端口"; +"Ports" = "端口"; +"Listening ports" = "监听端口"; +"See who's listening." = "看看谁在监听。"; +"Quit this process?" = "退出此进程?"; +"tuneup" = "调优"; +"Tune-Up" = "调优"; +"One pass, a tidier den." = "一次整理,洞穴更整洁。"; +"Safe to run" = "可安全执行"; +"Needs review" = "需要审查"; +"Run safe set" = "执行安全项"; +"Clear %@ cache" = "清理 %@ 缓存"; +"Review startup item: %@" = "审查启动项:%@"; +"Nothing to tune up — you're clean." = "没有需要调优的内容——很干净。"; +"restore" = "恢复"; +"Restore" = "恢复"; +"Put back what the last clean moved." = "把上次清理移走的放回原处。"; +"Restore last cleanup" = "恢复上次清理"; +"Only Trash-based removals can be restored — cache deletions are permanent." = "仅可恢复移到废纸篓的项目——缓存删除是永久性的。"; +"No restorable items found." = "未找到可恢复的项目。"; +"Top processes in selection" = "所选区间内的主要进程"; +"No process samples in that window." = "该区间内没有进程采样。"; +"Full in ~%@" = "约 %@ 后写满"; +"%d days" = "%d 天"; +"%d weeks" = "%d 周"; +"%d months" = "%d 个月"; +"Uncommitted or unpushed git changes in this repo" = "此仓库有未提交或未推送的 git 更改"; +"A new startup item appeared" = "出现了新的启动项"; +"“%@” now launches automatically. If you didn't add it, review it." = "“%@” 现在会自动启动。如果不是你添加的,请检查它。"; +"CPU usage is high" = "CPU 使用率过高"; +"CPU has been pegged at %.0f%%." = "CPU 已持续满载于 %.0f%%。"; +"Memory pressure is high" = "内存压力过高"; +"Memory is at %.0f%%." = "内存使用率为 %.0f%%。"; "Explain" = "解读"; /* Language switch */ diff --git a/Resources/zh-Hant.lproj/Localizable.strings b/Resources/zh-Hant.lproj/Localizable.strings index f702209d..181dcf0e 100644 --- a/Resources/zh-Hant.lproj/Localizable.strings +++ b/Resources/zh-Hant.lproj/Localizable.strings @@ -288,8 +288,48 @@ "Close" = "關閉"; "Mole CLI not found" = "找不到 Mole CLI"; -/* Home (Overview / History / Activity) + Explain */ +/* Home (Overview / History / Activity / Report) + Explain */ "Overview" = "總覽"; +"Report" = "報告"; +"Doctor" = "診斷"; +"Diagnostics" = "系統診斷"; +"Dev hygiene" = "開發清理"; +"No developer caches found." = "未發現開發者快取。"; +"Clear" = "清理"; +"Move this cache to the Trash?" = "將此快取移到垃圾桶?"; +"ports" = "連接埠"; +"Ports" = "連接埠"; +"Listening ports" = "監聽連接埠"; +"See who's listening." = "看看誰在監聽。"; +"Quit this process?" = "退出此程序?"; +"tuneup" = "調校"; +"Tune-Up" = "調校"; +"One pass, a tidier den." = "一次整理,洞穴更整潔。"; +"Safe to run" = "可安全執行"; +"Needs review" = "需要審查"; +"Run safe set" = "執行安全項"; +"Clear %@ cache" = "清理 %@ 快取"; +"Review startup item: %@" = "審查啟動項:%@"; +"Nothing to tune up — you're clean." = "沒有需要調校的內容——很乾淨。"; +"restore" = "復原"; +"Restore" = "復原"; +"Put back what the last clean moved." = "把上次清理移走的放回原處。"; +"Restore last cleanup" = "復原上次清理"; +"Only Trash-based removals can be restored — cache deletions are permanent." = "僅可復原移到垃圾桶的項目——快取刪除是永久性的。"; +"No restorable items found." = "未找到可復原的項目。"; +"Top processes in selection" = "所選區間內的主要程序"; +"No process samples in that window." = "該區間內沒有程序取樣。"; +"Full in ~%@" = "約 %@ 後寫滿"; +"%d days" = "%d 天"; +"%d weeks" = "%d 週"; +"%d months" = "%d 個月"; +"Uncommitted or unpushed git changes in this repo" = "此儲存庫有未提交或未推送的 git 變更"; +"A new startup item appeared" = "出現了新的啟動項"; +"“%@” now launches automatically. If you didn't add it, review it." = "“%@” 現在會自動啟動。如果不是你新增的,請檢查它。"; +"CPU usage is high" = "CPU 使用率過高"; +"CPU has been pegged at %.0f%%." = "CPU 已持續滿載於 %.0f%%。"; +"Memory pressure is high" = "記憶體壓力過高"; +"Memory is at %.0f%%." = "記憶體使用率為 %.0f%%。"; "Explain" = "解讀"; /* Language switch */ diff --git a/Sources/AgentAudit.swift b/Sources/AgentAudit.swift new file mode 100644 index 00000000..0a46ece0 --- /dev/null +++ b/Sources/AgentAudit.swift @@ -0,0 +1,43 @@ +// +// AgentAudit.swift +// Burrow +// +// Agent-action audit records (roadmap B.5): the trust feature that makes +// "let agents act" adoptable — every MCP tool dispatch leaves a row humans +// can read. This is the record shape + its stable JSON encoding (one DB row +// under `prefix`). Writing rows from the MCP process through a serialized +// writer, and the Activity-view pane, are integration. +// +// Args are NOT redacted — they're local, and the whole point is to show +// exactly what an agent asked for. +// + +import Foundation + +enum AgentAudit { + static let prefix = "burrow.agent_audit" + + struct Entry: Codable, Equatable { + var tool: String + var client: String + var dryRun: Bool + var durationMs: Int + var ok: Bool + var summary: String + /// The tool's arguments, pre-serialized to a JSON string by the caller. + var argsJSON: String + } + + /// Canonical, key-sorted JSON for one row — deterministic so tests and + /// the GUI read the same bytes the MCP process wrote. + static func encode(_ e: Entry) -> String { + let enc = JSONEncoder() + enc.outputFormatting = [.sortedKeys] + guard let d = try? enc.encode(e), let s = String(data: d, encoding: .utf8) else { return "{}" } + return s + } + + static func decode(_ json: String) -> Entry? { + try? JSONDecoder().decode(Entry.self, from: Data(json.utf8)) + } +} diff --git a/Sources/AlertEngine.swift b/Sources/AlertEngine.swift new file mode 100644 index 00000000..dc253548 --- /dev/null +++ b/Sources/AlertEngine.swift @@ -0,0 +1,53 @@ +// +// AlertEngine.swift +// Burrow +// +// Threshold-alert evaluation (roadmap D.12). Pure and stateful-by-value: a +// rule + the latest reading + the prior state → the next state and whether +// to fire. Hysteresis (fire on crossing `high`, don't re-arm until the value +// recovers below `low`) plus a cooldown mean an alert fires once per +// *episode*, not once per sample — the difference between a useful nudge and +// alert fatigue. The same evaluator backs notifications (D.12), the SSE +// stream (B.6), and the report (A.4); the rules' thresholds, the Sampler +// wiring, and UserNotifications delivery are integration. +// + +import Foundation + +struct ThresholdRule: Equatable { + let id: String + /// Fire when the reading reaches `high`… + let high: Double + /// …and don't re-arm until it falls back below `low` (hysteresis). + let low: Double + /// Minimum seconds between fires, across episodes. + let cooldownSeconds: Int +} + +struct AlertState: Equatable { + /// In an active episode: above `high` and not yet recovered below `low`. + var firing = false + var lastFiredTS: Int? +} + +enum AlertEngine { + /// Fold one reading into the alert state. Returns the next state and + /// whether this tick should emit an alert. + static func step(rule: ThresholdRule, value: Double, ts: Int, + state: AlertState) -> (state: AlertState, fired: Bool) { + var s = state + if s.firing { + // Episode continues until the value recovers below `low`; dips + // that stay above `low` do not re-fire. + if value < rule.low { s.firing = false } + return (s, false) + } + guard value >= rule.high else { return (s, false) } + s.firing = true + if let last = s.lastFiredTS, ts - last < rule.cooldownSeconds { + return (s, false) // armed, but still cooling down from the last fire + } + s.lastFiredTS = ts + return (s, true) + } +} diff --git a/Sources/Anomaly.swift b/Sources/Anomaly.swift new file mode 100644 index 00000000..c1259556 --- /dev/null +++ b/Sources/Anomaly.swift @@ -0,0 +1,77 @@ +// +// Anomaly.swift +// Burrow +// +// Baseline-vs-recent regression detection (roadmap A.2). Pure statistics: +// arrays in, a verdict out — so the "WindowServer baseline doubled" and +// "battery drains faster than two weeks ago" findings are computed the same +// way wherever they're surfaced (Home card, notifications, the Explain lens, +// a future MCP tool), and the credibility tuning lives in one place. +// +// The rules are deliberately conservative: a finding needs a clear effect +// size on top of statistical exceedance, because a false "your Mac is +// degrading" alert costs more trust than a missed one. +// + +import Foundation + +enum Anomaly { + // MARK: Statistical primitives + + /// Linear-interpolated percentile (the numpy/"type 7" convention), so + /// p95 of a short baseline is stable and well-defined. `p` is 0...100. + static func percentile(_ xs: [Double], _ p: Double) -> Double { + guard !xs.isEmpty else { return 0 } + let s = xs.sorted() + if s.count == 1 { return s[0] } + let rank = (p / 100.0) * Double(s.count - 1) + let lo = Int(rank.rounded(.down)) + let hi = Int(rank.rounded(.up)) + let frac = rank - Double(lo) + return s[lo] + (s[hi] - s[lo]) * frac + } + + static func median(_ xs: [Double]) -> Double { percentile(xs, 50) } + + // MARK: Process CPU regression + + /// True when a process's recent CPU is *sustainedly* above its own + /// baseline: the typical recent value clears the baseline's 95th + /// percentile AND beats the baseline median by at least `minDelta` points. + /// The effect-size floor stops near-idle processes (0.5% → 2%) from + /// tripping a "doubled!" alert that's technically true but meaningless. + static func processCPUExceedsBaseline(baseline: [Double], recent: [Double], + minSamples: Int = 5, minDelta: Double = 10) -> Bool { + guard baseline.count >= minSamples, recent.count >= minSamples else { return false } + let recentMid = median(recent) + return recentMid > percentile(baseline, 95) + && (recentMid - median(baseline)) >= minDelta + } + + // MARK: Battery drain regression + + /// Discharge rate of one session in percent-per-hour (positive = losing + /// charge), via least-squares slope over elapsed hours. Robust to a + /// noisy reading or two. nil when the session is too short to have a rate. + static func batteryDrainRate(_ session: [(ts: Int, percent: Double)]) -> Double? { + guard session.count >= 2 else { return nil } + let pts = session.map { (h: Double($0.ts) / 3600.0, p: $0.percent) } + let n = Double(pts.count) + let meanH = pts.reduce(0) { $0 + $1.h } / n + let meanP = pts.reduce(0) { $0 + $1.p } / n + var num = 0.0, den = 0.0 + for pt in pts { num += (pt.h - meanH) * (pt.p - meanP); den += (pt.h - meanH) * (pt.h - meanH) } + guard den > 0 else { return nil } + return -(num / den) // negate: a falling % is a positive drain rate + } + + /// True when the recent discharge drains meaningfully faster than the + /// baseline sessions: at least `factor`× the median baseline rate and at + /// least `minDelta` %/hr faster in absolute terms. + static func batteryDrainRegressed(baselineRates: [Double], recentRate: Double, + factor: Double = 1.25, minDelta: Double = 1) -> Bool { + guard !baselineRates.isEmpty else { return false } + let base = median(baselineRates) + return base > 0 && recentRate >= base * factor && (recentRate - base) >= minDelta + } +} diff --git a/Sources/AnomalyScan.swift b/Sources/AnomalyScan.swift new file mode 100644 index 00000000..8cfae7d8 --- /dev/null +++ b/Sources/AnomalyScan.swift @@ -0,0 +1,46 @@ +// +// AnomalyScan.swift +// Burrow +// +// Connects the Anomaly rules (A.2) to real history: per-process CPU samples +// for a recent window vs a baseline window → the processes whose usage has +// regressed. Pure over the two sample maps (which MetricsStore.processCPUSamples +// produces), so it's testable without a DB. Persisting findings under +// `burrow.findings` (Maintenance pass) and the Home "Changes" card are +// integration. +// + +import Foundation + +enum AnomalyScan { + struct Finding: Equatable { + let process: String + let recentMedian: Double + let baselineMedian: Double + } + + /// Convenience: pull recent (last 24h) vs baseline (prior 14d) per-process + /// CPU from the store and flag regressions. The window split the roadmap + /// specifies for A.2. + static func scan(metrics: MetricsStore, now: Int) -> [Finding] { + let day = 86_400 + let recent = metrics.processCPUSamples(.init(since: now - day, until: now)) + let baseline = metrics.processCPUSamples(.init(since: now - 15 * day, until: now - day)) + return cpuFindings(baseline: baseline, recent: recent) + } + + /// Flag every process whose recent CPU clears its own baseline per the + /// Anomaly rule, worst (highest recent median) first. + static func cpuFindings(baseline: [String: [Double]], + recent: [String: [Double]]) -> [Finding] { + var out: [Finding] = [] + for (name, recentSamples) in recent { + guard let baseSamples = baseline[name] else { continue } + guard Anomaly.processCPUExceedsBaseline(baseline: baseSamples, recent: recentSamples) else { continue } + out.append(Finding(process: name, + recentMedian: Anomaly.median(recentSamples), + baselineMedian: Anomaly.median(baseSamples))) + } + return out.sorted { $0.recentMedian > $1.recentMedian } + } +} diff --git a/Sources/BackupStatus.swift b/Sources/BackupStatus.swift new file mode 100644 index 00000000..4e2f683c --- /dev/null +++ b/Sources/BackupStatus.swift @@ -0,0 +1,31 @@ +// +// BackupStatus.swift +// Burrow +// +// Backup awareness (roadmap D.14, backup half): how stale is Time Machine? +// Runs `tmutil latestbackup` (fast, unprivileged) and reduces it to a +// days-ago count via the tested TimeMachine parser. Used by the Doctor check +// (GUI + burrow_doctor). The SMART/IOKit half of D.14 is separate native work. +// + +import Foundation + +enum BackupStatus { + /// Whole days since the most recent Time Machine backup, or nil when there + /// are no backups / Time Machine is unavailable. + static func lastBackupDaysAgo(now: Date = Date()) -> Int? { + let p = Process() + p.executableURL = URL(fileURLWithPath: "/usr/bin/tmutil") + p.arguments = ["latestbackup"] + let out = Pipe() + p.standardOutput = out + p.standardError = Pipe() + do { try p.run() } catch { return nil } + p.waitUntilExit() + guard p.terminationStatus == 0 else { return nil } + let text = String(decoding: out.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + guard let token = TimeMachine.latestBackupToken(text), + let date = TimeMachine.date(fromToken: token) else { return nil } + return max(0, Int(now.timeIntervalSince(date) / 86_400)) + } +} diff --git a/Sources/BrewProgress.swift b/Sources/BrewProgress.swift new file mode 100644 index 00000000..fd162c3e --- /dev/null +++ b/Sources/BrewProgress.swift @@ -0,0 +1,23 @@ +// +// BrewProgress.swift +// Burrow +// +// Brew-upgrade progress parsing (roadmap H deferral). Pure: a line of +// `brew upgrade` output → a human progress phrase, or nil for noise — so the +// Updates flow can show live progress ("Pouring foo…") instead of a blocking +// spinner. Wiring this into the streaming upgrade run is integration. +// + +import Foundation + +enum BrewProgress { + /// Brew prints its step headers as `==> `. Those are exactly the + /// progress beats worth surfacing; everything else (download bars, blank + /// lines, bottle hashes) is noise. + static func phrase(_ line: String) -> String? { + let t = line.trimmingCharacters(in: .whitespaces) + guard t.hasPrefix("==> ") else { return nil } + let phrase = String(t.dropFirst(4)).trimmingCharacters(in: .whitespaces) + return phrase.isEmpty ? nil : phrase + } +} diff --git a/Sources/DevHygiene.swift b/Sources/DevHygiene.swift new file mode 100644 index 00000000..3a0f7533 --- /dev/null +++ b/Sources/DevHygiene.swift @@ -0,0 +1,59 @@ +// +// DevHygiene.swift +// Burrow +// +// Dev hygiene page (roadmap C.9): break out what generic cleaners lump +// together — Xcode, containers, package-manager caches, toolchains — each +// with size and a per-item action. This is the catalog (which ecosystem owns +// which paths) plus aggregation. The on-disk size scan, the "hide what isn't +// installed" existence check, and the confirm-gated actions are integration. +// + +import Foundation + +enum DevHygiene { + struct Ecosystem: Equatable { + let name: String + /// Absolute paths this ecosystem's reclaimable data lives in. + let paths: [String] + } + + /// The known ecosystems and their cache/artifact roots, resolved under + /// `home`. Stage 1 surfaces these read-only; integration hides any whose + /// paths don't exist and attaches sizes. + static func catalog(home: String) -> [Ecosystem] { + func p(_ suffix: String) -> String { home + "/" + suffix } + return [ + Ecosystem(name: "Xcode", paths: [ + p("Library/Developer/Xcode/DerivedData"), + p("Library/Developer/Xcode/iOS DeviceSupport"), + p("Library/Developer/CoreSimulator/Caches"), + ]), + Ecosystem(name: "Homebrew", paths: [p("Library/Caches/Homebrew")]), + Ecosystem(name: "npm", paths: [p(".npm")]), + Ecosystem(name: "pnpm", paths: [p("Library/pnpm/store"), p(".local/share/pnpm")]), + Ecosystem(name: "Yarn", paths: [p("Library/Caches/Yarn")]), + Ecosystem(name: "Cargo", paths: [p(".cargo/registry")]), + Ecosystem(name: "Go", paths: [p("go/pkg/mod"), p("Library/Caches/go-build")]), + Ecosystem(name: "pip", paths: [p("Library/Caches/pip")]), + Ecosystem(name: "Gradle", paths: [p(".gradle/caches")]), + Ecosystem(name: "Docker", paths: [p("Library/Containers/com.docker.docker/Data/vms")]), + ] + } + + static func total(_ sizes: [Int64]) -> Int64 { sizes.reduce(0, +) } + + /// Recursive allocated size of a directory (0 if absent/unreadable). FS + /// work — call off the main thread. Shared by the hygiene + Tune-Up panes. + static func directorySize(_ path: String) -> Int64 { + let url = URL(fileURLWithPath: path) + let keys: Set = [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey] + guard let en = FileManager.default.enumerator(at: url, includingPropertiesForKeys: Array(keys)) else { return 0 } + var total: Int64 = 0 + for case let file as URL in en { + let v = try? file.resourceValues(forKeys: keys) + total += Int64(v?.totalFileAllocatedSize ?? v?.fileAllocatedSize ?? 0) + } + return total + } +} diff --git a/Sources/DevHygieneView.swift b/Sources/DevHygieneView.swift new file mode 100644 index 00000000..43893077 --- /dev/null +++ b/Sources/DevHygieneView.swift @@ -0,0 +1,95 @@ +// +// DevHygieneView.swift +// Burrow +// +// Dev hygiene Home section (roadmap C.9, stage 1 = read-only). Lists each +// developer ecosystem's cache/artifact roots (from DevHygiene.catalog) that +// exist on disk, with their size, biggest first, and a reveal-in-Finder +// affordance. Stage 2 (per-item delete via the ecosystem's own tool) is a +// follow-up. +// +// NOTE (hand-test): compile-verified only. Verify sizes look right and the +// scan stays off the main thread on a machine with large caches. +// + +import SwiftUI +import AppKit + +struct DevHygieneView: View { + private struct Row: Identifiable { + let id = UUID() + let ecosystem: String + let path: String + let bytes: Int64 + } + + @State private var rows: [Row] = [] + @State private var scanning = true + @State private var clearTarget: Row? + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text(NSLocalizedString("Dev hygiene", comment: "")).font(.title2.bold()) + if scanning { ProgressView().controlSize(.small).padding(.leading, 6) } + } + if !scanning, rows.isEmpty { + Text(NSLocalizedString("No developer caches found.", comment: "")) + .foregroundStyle(.secondary) + } + ForEach(rows) { r in + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(r.ecosystem).font(.headline) + Text(r.path).font(.caption).foregroundStyle(.secondary) + .lineLimit(1).truncationMode(.middle) + } + Spacer() + Text(Fmt.bytes(r.bytes)).font(.body.monospacedDigit()) + Button { + NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: r.path)]) + } label: { Image(systemName: "magnifyingglass") } + .buttonStyle(.plain) + .help(NSLocalizedString("Reveal in Finder", comment: "")) + Button(NSLocalizedString("Clear", comment: "")) { clearTarget = r } + .buttonStyle(.bordered) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(20) + } + .task { await scan() } + .confirmationDialog( + NSLocalizedString("Move this cache to the Trash?", comment: ""), + isPresented: Binding(get: { clearTarget != nil }, + set: { if !$0 { clearTarget = nil } }), + presenting: clearTarget + ) { r in + Button(NSLocalizedString("Move to Trash", comment: ""), role: .destructive) { + try? FileManager.default.trashItem(at: URL(fileURLWithPath: r.path), resultingItemURL: nil) + Task { await scan() } + } + Button(NSLocalizedString("Cancel", comment: ""), role: .cancel) {} + } message: { r in + Text("\(r.ecosystem) — \(Fmt.bytes(r.bytes))\n\(r.path)") + } + } + + private func scan() async { + let home = FileManager.default.homeDirectoryForCurrentUser.path + let found = await Task.detached(priority: .utility) { () -> [Row] in + var out: [Row] = [] + for eco in DevHygiene.catalog(home: home) { + for path in eco.paths where FileManager.default.fileExists(atPath: path) { + let bytes = DevHygiene.directorySize(path) + if bytes > 0 { out.append(Row(ecosystem: eco.name, path: path, bytes: bytes)) } + } + } + return out.sorted { $0.bytes > $1.bytes } + }.value + rows = found + scanning = false + } +} diff --git a/Sources/DiskForecast.swift b/Sources/DiskForecast.swift new file mode 100644 index 00000000..f6aea964 --- /dev/null +++ b/Sources/DiskForecast.swift @@ -0,0 +1,84 @@ +// +// DiskForecast.swift +// Burrow +// +// Disk-full forecasting from free-space history (roadmap A.3). Pure: a +// series of (timestamp, free-bytes) in, a projection out — so the Home disk +// tile and the History disk chart can both say "full in ~3 weeks" from the +// same honest estimate. +// +// Honest by construction. It refuses to name a date when the trend is flat, +// when free space is *growing*, or when there's less than a week of history, +// and it resists single-sample cliffs (a temp file briefly eating space) by +// fitting a robust median (Theil–Sen) slope rather than least-squares, which +// one outlier can drag arbitrarily far. +// + +import Foundation + +enum DiskForecast { + struct Projection: Equatable { + /// Estimated days until free space reaches zero, or nil when not + /// forecastable — the UI shows the basis instead of a bare date. + let daysUntilFull: Double? + /// Trend in bytes/day (negative = filling up). Always present. + let slopeBytesPerDay: Double + /// Span of history the fit used, in days — surfaced so the UI can say + /// "based on N days" and never present precision it doesn't have. + let basisDays: Double + } + + /// Below this much history, a date is noise dressed as precision. + static let minBasisDays = 7.0 + /// Forecasts past this horizon mean the slope is effectively flat; we + /// suppress the date rather than tell someone their disk fills in 80 years. + static let maxHorizonDays = 365.0 * 5 + /// Pairwise Theil–Sen is O(n²); bound the input so a pathological caller + /// can't stall. Snapshots already arrive ≤720, so this rarely bites. + static let maxSamples = 1500 + + static func forecast(_ samples: [(ts: Int, freeBytes: Double)], now: Int) -> Projection { + let sorted = samples.sorted { $0.ts < $1.ts } + let pts = sorted.count > maxSamples ? stride(from: 0, to: sorted.count, + by: sorted.count / maxSamples + 1).map { sorted[$0] } : sorted + guard pts.count >= 2, let first = pts.first, let last = pts.last, + last.ts > first.ts else { + return Projection(daysUntilFull: nil, slopeBytesPerDay: 0, basisDays: 0) + } + let basisDays = Double(last.ts - first.ts) / 86_400.0 + + // Theil–Sen slope: median of all pairwise slopes (bytes/day). + var slopes: [Double] = [] + slopes.reserveCapacity(pts.count * (pts.count - 1) / 2) + for i in 0.. 0 { + slopes.append((pts[j].freeBytes - pts[i].freeBytes) / dtDays) + } + } + } + let slope = median(slopes) + // Robust "free right now": the Theil–Sen line (median intercept + + // slope) evaluated at `now`, so one cliff sample can't move the start. + let intercepts = pts.map { $0.freeBytes - slope * (Double($0.ts) / 86_400.0) } + let currentFree = slope * (Double(now) / 86_400.0) + median(intercepts) + + guard basisDays >= minBasisDays else { + return Projection(daysUntilFull: nil, slopeBytesPerDay: slope, basisDays: basisDays) + } + guard slope < 0 else { // flat or growing → it never fills + return Projection(daysUntilFull: nil, slopeBytesPerDay: slope, basisDays: basisDays) + } + let days = max(0, currentFree) / (-slope) + return Projection(daysUntilFull: days <= maxHorizonDays ? days : nil, + slopeBytesPerDay: slope, basisDays: basisDays) + } + + private static func median(_ xs: [Double]) -> Double { + guard !xs.isEmpty else { return 0 } + let s = xs.sorted() + let n = s.count + return n % 2 == 1 ? s[n / 2] : (s[n / 2 - 1] + s[n / 2]) / 2 + } +} diff --git a/Sources/DiskHealth.swift b/Sources/DiskHealth.swift new file mode 100644 index 00000000..5c4dd17f --- /dev/null +++ b/Sources/DiskHealth.swift @@ -0,0 +1,34 @@ +// +// DiskHealth.swift +// Burrow +// +// SMART status (roadmap D.14, health half). The full wear%/temperature/hours +// need the private IONVMeSMARTUserClient log; the pass/fail SMART verdict, +// though, is readable from `system_profiler SPNVMeDataType` without elevation +// or private API — so that's what we surface (as a Doctor check). +// + +import Foundation + +enum DiskHealth { + /// true = SMART "Verified", false = any other status, nil = unreadable + /// (no internal NVMe, or system_profiler failed). + static func smartVerified() -> Bool? { + let p = Process() + p.executableURL = URL(fileURLWithPath: "/usr/sbin/system_profiler") + p.arguments = ["SPNVMeDataType"] + let out = Pipe() + p.standardOutput = out + p.standardError = Pipe() + do { try p.run() } catch { return nil } + p.waitUntilExit() + guard p.terminationStatus == 0 else { return nil } + let text = String(decoding: out.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + guard let line = text.split(separator: "\n").first(where: { $0.contains("SMART Status:") }) else { + return nil + } + let status = line.split(separator: ":", maxSplits: 1).last + .map { $0.trimmingCharacters(in: .whitespaces) } ?? "" + return status.lowercased().contains("verified") + } +} diff --git a/Sources/Doctor.swift b/Sources/Doctor.swift new file mode 100644 index 00000000..2f60c562 --- /dev/null +++ b/Sources/Doctor.swift @@ -0,0 +1,100 @@ +// +// Doctor.swift +// Burrow +// +// Diagnostics report (roadmap I / appendix parity gap): a one-glance health +// check from the Help menu — permissions, memory pressure, disk headroom, +// the engine, recent errors. This is the pure half: system facts in, ranked +// verdicts out, so the same logic backs the Help-menu sheet and (later) a +// `burrow_doctor` MCP tool. Gathering the facts (TCC state, pressure, log +// scan) is the integration half. +// + +import Foundation + +enum Doctor { + enum Level: Int { case ok, warn, fail } // Int so callers can sort worst-first + enum MemoryPressure { case normal, warning, critical } + + struct Check: Equatable { + let name: String + let level: Level + let detail: String + } + + struct Input { + var fullDiskAccess: Bool + var moInstalled: Bool + var pressure: MemoryPressure + var diskFreeBytes: Int64 + var diskTotalBytes: Int64 + var recentErrorCount: Int + /// Days since the last Time Machine backup; nil = none found / unknown. + var lastBackupDaysAgo: Int? = nil + /// SMART verdict: true = verified, false = failing, nil = unreadable. + var smartVerified: Bool? = nil + } + + /// One `Check` per facet, in a stable order. Each verdict is independent; + /// callers can sort by `level` to surface failures first. + static func report(_ i: Input) -> [Check] { + [engine(i), permissions(i), memory(i), disk(i), diskHealth(i), backup(i), errors(i)] + } + + private static func engine(_ i: Input) -> Check { + i.moInstalled + ? Check(name: "Engine", level: .ok, detail: "mo is installed") + : Check(name: "Engine", level: .fail, detail: "mo is not installed — core features are unavailable") + } + + private static func permissions(_ i: Input) -> Check { + i.fullDiskAccess + ? Check(name: "Full Disk Access", level: .ok, detail: "granted") + : Check(name: "Full Disk Access", level: .warn, detail: "off — some scans and cleanups are limited") + } + + private static func memory(_ i: Input) -> Check { + switch i.pressure { + case .normal: return Check(name: "Memory pressure", level: .ok, detail: "normal") + case .warning: return Check(name: "Memory pressure", level: .warn, detail: "elevated") + case .critical: return Check(name: "Memory pressure", level: .fail, detail: "critical — the system is low on memory") + } + } + + private static func disk(_ i: Input) -> Check { + guard i.diskTotalBytes > 0 else { + return Check(name: "Disk space", level: .warn, detail: "unknown") + } + let freePct = Double(i.diskFreeBytes) / Double(i.diskTotalBytes) * 100 + if freePct < 5 { + return Check(name: "Disk space", level: .fail, detail: "under 5% free") + } else if freePct < 10 { + return Check(name: "Disk space", level: .warn, detail: "under 10% free") + } + return Check(name: "Disk space", level: .ok, detail: "\(Int(freePct.rounded()))% free") + } + + private static func diskHealth(_ i: Input) -> Check { + switch i.smartVerified { + case .some(true): return Check(name: "Disk health", level: .ok, detail: "SMART status: verified") + case .some(false): return Check(name: "Disk health", level: .fail, detail: "SMART status: failing — back up now") + case .none: return Check(name: "Disk health", level: .ok, detail: "SMART not reported") + } + } + + private static func backup(_ i: Input) -> Check { + guard let days = i.lastBackupDaysAgo else { + return Check(name: "Backups", level: .warn, detail: "no recent Time Machine backup found") + } + if days > 14 { + return Check(name: "Backups", level: .warn, detail: "last backup \(days) days ago") + } + return Check(name: "Backups", level: .ok, detail: "last backup \(days) day\(days == 1 ? "" : "s") ago") + } + + private static func errors(_ i: Input) -> Check { + i.recentErrorCount == 0 + ? Check(name: "Recent errors", level: .ok, detail: "none logged") + : Check(name: "Recent errors", level: .warn, detail: "\(i.recentErrorCount) in recent logs") + } +} diff --git a/Sources/DoctorView.swift b/Sources/DoctorView.swift new file mode 100644 index 00000000..9e724c4a --- /dev/null +++ b/Sources/DoctorView.swift @@ -0,0 +1,78 @@ +// +// DoctorView.swift +// Burrow +// +// Diagnostics Home section (roadmap I). Renders Doctor.report — engine, +// Full Disk Access, memory pressure, disk headroom, recent errors — from the +// latest snapshot + live permission/engine checks. Same verdict logic as the +// burrow_doctor MCP tool. +// +// NOTE (hand-test): compile-verified only. Verify the checks populate and the +// ok/warn/fail colours read correctly against a real machine. +// + +import SwiftUI + +struct DoctorView: View { + let db: DB + @State private var checks: [Doctor.Check] = [] + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text(NSLocalizedString("Diagnostics", comment: "")).font(.title2.bold()) + ForEach(Array(checks.enumerated()), id: \.offset) { _, c in + HStack(alignment: .top, spacing: 10) { + Image(systemName: glyph(c.level)).foregroundStyle(tint(c.level)) + .frame(width: 20) + VStack(alignment: .leading, spacing: 2) { + Text(c.name).font(.headline) + Text(c.detail).font(.caption).foregroundStyle(.secondary) + } + Spacer() + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(20) + } + .task { reload() } + } + + private func glyph(_ l: Doctor.Level) -> String { + switch l { + case .ok: return "checkmark.circle.fill" + case .warn: return "exclamationmark.triangle.fill" + case .fail: return "xmark.octagon.fill" + } + } + + private func tint(_ l: Doctor.Level) -> Color { + switch l { + case .ok: return .green + case .warn: return .yellow + case .fail: return .red + } + } + + private func reload() { + let latest = MetricsStore(db: db).latest()?.status + var free: Int64 = 0, total: Int64 = 0 + if let d = latest?.disks.max(by: { $0.total < $1.total }) { + total = Int64(d.total) + free = Int64(d.total > d.used ? d.total - d.used : 0) + } + let moInstalled: Bool + if case .installed = MoEngine.shared.availability() { moInstalled = true } else { moInstalled = false } + let p = (latest?.memory.pressure ?? "").lowercased() + let pressure: Doctor.MemoryPressure = p.contains("critical") ? .critical + : (p.contains("warn") ? .warning : .normal) + checks = Doctor.report(.init( + fullDiskAccess: Privacy.hasFullDiskAccess(), + moInstalled: moInstalled, pressure: pressure, + diskFreeBytes: free, diskTotalBytes: total, + recentErrorCount: MetricsStore.driftCounters.decodeSkippedTotal, + lastBackupDaysAgo: BackupStatus.lastBackupDaysAgo(), + smartVerified: DiskHealth.smartVerified())) + } +} diff --git a/Sources/EventHub.swift b/Sources/EventHub.swift new file mode 100644 index 00000000..eab1e5f8 --- /dev/null +++ b/Sources/EventHub.swift @@ -0,0 +1,65 @@ +// +// EventHub.swift +// Burrow +// +// The fan-out behind the SSE /events stream (roadmap B.6). Holds the open +// event-stream connections and broadcasts already-encoded SSE frames to them; +// dead connections self-evict on the next failed write or keep-alive tick, so +// there's no per-connection teardown plumbing to leak. Thread-safe. +// + +import Foundation +import Network + +final class EventHub { + static let shared = EventHub() + + private let lock = NSLock() + private var conns: [ObjectIdentifier: NWConnection] = [:] + private var keepAlive: Timer? + + private init() {} + + func register(_ conn: NWConnection) { + lock.lock(); conns[ObjectIdentifier(conn)] = conn; lock.unlock() + startKeepAlive() + } + + func deregister(_ conn: NWConnection) { + lock.lock(); conns[ObjectIdentifier(conn)] = nil; lock.unlock() + } + + /// Is this connection an active event stream? The query server uses this + /// to exempt /events connections from its short idle-cancel timeout. + func isStreaming(_ conn: NWConnection) -> Bool { + lock.lock(); defer { lock.unlock() } + return conns[ObjectIdentifier(conn)] != nil + } + + /// Fan an SSE-encoded frame out to every open connection, evicting any that + /// error on write. + func broadcast(_ frame: String) { + let data = Data(frame.utf8) + lock.lock(); let all = conns; lock.unlock() + for (id, c) in all { + c.send(content: data, completion: .contentProcessed { [weak self] err in + if err != nil { self?.deregisterID(id) } + }) + } + } + + private func deregisterID(_ id: ObjectIdentifier) { + lock.lock(); conns[id] = nil; lock.unlock() + } + + private func startKeepAlive() { + DispatchQueue.main.async { [weak self] in + guard let self, self.keepAlive == nil else { return } + let t = Timer(timeInterval: 15, repeats: true) { _ in + EventHub.shared.broadcast(SSEFrame.comment("keep-alive")) + } + RunLoop.main.add(t, forMode: .common) + self.keepAlive = t + } + } +} diff --git a/Sources/FolderGrowth.swift b/Sources/FolderGrowth.swift new file mode 100644 index 00000000..5c59d78a --- /dev/null +++ b/Sources/FolderGrowth.swift @@ -0,0 +1,31 @@ +// +// FolderGrowth.swift +// Burrow +// +// Growth attribution (roadmap A.3): diff two per-folder size scans to answer +// "Downloads grew 11 GB this month." Pure — two {path: bytes} maps in, the +// movers out, biggest growth first. The weekly `mo analyze` scan that +// produces the maps, persisting them, and surfacing in the report are +// integration. +// + +import Foundation + +enum FolderGrowth { + struct Change: Equatable { + let path: String + let deltaBytes: Int64 + } + + /// Per-folder size delta (new − old). Folders only in `new` count their + /// full size as growth; unchanged folders are dropped. Sorted by delta + /// descending so the biggest growers lead (shrinkers sort last). + static func diff(old: [String: Int64], new: [String: Int64]) -> [Change] { + var out: [Change] = [] + for (path, size) in new { + let delta = size - (old[path] ?? 0) + if delta != 0 { out.append(Change(path: path, deltaBytes: delta)) } + } + return out.sorted { $0.deltaBytes > $1.deltaBytes } + } +} diff --git a/Sources/GitRepoStatus.swift b/Sources/GitRepoStatus.swift new file mode 100644 index 00000000..d1767e2f --- /dev/null +++ b/Sources/GitRepoStatus.swift @@ -0,0 +1,51 @@ +// +// GitRepoStatus.swift +// Burrow +// +// Purge-safety check for project folders (roadmap C.11): before Burrow +// offers to delete a directory, it walks up to the containing repo and asks +// "would this lose work?" This is the pure half — parsing +// `git status --porcelain=v1 -b` into a verdict. Running `git` (with a short +// timeout + concurrency cap) and badging the purge checklist is the +// integration half. Conservative on purpose: untracked files count as work, +// and a branch that was never pushed is treated as unpushed — the whole +// point is to be safer than deleting by hand. +// + +import Foundation + +enum GitRepoStatus { + struct Status: Equatable { + /// Any working-tree change, including untracked files. + let dirty: Bool + /// Commits ahead of the upstream (0 when synced or no upstream). + let ahead: Int + let hasUpstream: Bool + let detached: Bool + + /// Work that exists only locally: commits ahead of upstream, or a + /// real branch with no upstream at all (never pushed anywhere). + var unpushed: Bool { ahead > 0 || (!hasUpstream && !detached) } + /// The badge condition — purging this would risk losing something. + var needsAttention: Bool { dirty || unpushed } + } + + static func parse(_ porcelain: String) -> Status { + var dirty = false, ahead = 0, hasUpstream = false, detached = false + for raw in porcelain.split(separator: "\n", omittingEmptySubsequences: false) { + let line = String(raw) + if line.hasPrefix("## ") { + let branch = String(line.dropFirst(3)) + detached = branch.hasPrefix("HEAD (no branch)") + hasUpstream = branch.contains("...") + if let r = branch.range(of: "[ahead "), + let end = branch[r.upperBound...].firstIndex(where: { $0 == "," || $0 == "]" }) { + ahead = Int(branch[r.upperBound.. String? { + var url = URL(fileURLWithPath: path) + let fm = FileManager.default + for _ in 0..<64 { + if fm.fileExists(atPath: url.appendingPathComponent(".git").path) { return url.path } + let parent = url.deletingLastPathComponent() + if parent.path == url.path { break } + url = parent + } + return nil + } + + /// `git -C status --porcelain=v1 -b`, bounded by `timeout`, parsed + /// into a verdict. nil when git is unavailable, times out, or errors. + static func status(repo: String, timeout: TimeInterval = 3) -> GitRepoStatus.Status? { + let p = Process() + p.executableURL = URL(fileURLWithPath: "/usr/bin/git") + p.arguments = ["-C", repo, "status", "--porcelain=v1", "-b"] + let out = Pipe() + p.standardOutput = out + p.standardError = Pipe() + do { try p.run() } catch { return nil } + + let sem = DispatchSemaphore(value: 0) + DispatchQueue.global(qos: .utility).async { p.waitUntilExit(); sem.signal() } + if sem.wait(timeout: .now() + timeout) == .timedOut { + p.terminate() + return nil + } + guard p.terminationStatus == 0 else { return nil } + let data = out.fileHandleForReading.readDataToEndOfFile() + return GitRepoStatus.parse(String(decoding: data, as: UTF8.self)) + } +} diff --git a/Sources/HistoryView.swift b/Sources/HistoryView.swift index 019f7835..a771c8b8 100644 --- a/Sources/HistoryView.swift +++ b/Sources/HistoryView.swift @@ -216,6 +216,10 @@ struct HistoryView: View { @State private var snapshot: HistorySnapshot = HistorySnapshot() @State private var loading: Bool = false @State private var procMetric: ProcMetric = .cpu + // Spike forensics (A.1): drag-select on the line chart → top processes. + @State private var spikeDragStart: CGFloat? + @State private var spikeDragCurrent: CGFloat? + @State private var spikeWindow: SpikeWindow? /// The currently-subscribed board feed — held so the toolbar's manual /// refresh can poke it; lifecycle belongs to `.task(id: range)` below. @State private var board: Feed? @@ -430,6 +434,38 @@ struct HistoryView: View { } } .chartXScale(domain: snapshot.windowSince...snapshot.windowUntil) + .chartOverlay { proxy in + GeometryReader { geo in + if let anchor = proxy.plotFrame { + let plot = geo[anchor] + Rectangle().fill(Color.clear).contentShape(Rectangle()) + .gesture(DragGesture(minimumDistance: 4) + .onChanged { v in + if spikeDragStart == nil { spikeDragStart = v.startLocation.x - plot.minX } + spikeDragCurrent = v.location.x - plot.minX + } + .onEnded { v in + let x0 = spikeDragStart ?? 0 + let x1 = v.location.x - plot.minX + spikeDragStart = nil; spikeDragCurrent = nil + let lo = min(x0, x1), hi = max(x0, x1) + guard hi - lo > 4, + let d0 = proxy.value(atX: lo, as: Date.self), + let d1 = proxy.value(atX: hi, as: Date.self) else { return } + spikeWindow = SpikeWindow(since: Int(d0.timeIntervalSince1970), + until: Int(d1.timeIntervalSince1970)) + }) + if let s = spikeDragStart, let c = spikeDragCurrent { + Rectangle().fill(Brand.textSecondary.opacity(0.18)) + .frame(width: abs(c - s), height: plot.height) + .position(x: plot.minX + min(s, c) + abs(c - s) / 2, y: plot.midY) + } + } + } + } + .sheet(item: $spikeWindow) { w in + SpikeSheet(db: db, window: w, onClose: { spikeWindow = nil }) + } .chartXAxis { AxisMarks(values: .automatic(desiredCount: style.desiredCount)) { _ in AxisGridLine().foregroundStyle(Brand.hairline) diff --git a/Sources/HomeView.swift b/Sources/HomeView.swift index bb95f477..d2027508 100644 --- a/Sources/HomeView.swift +++ b/Sources/HomeView.swift @@ -25,6 +25,9 @@ struct HomeView: View { case overview = "Overview" case history = "History" case activity = "Activity" + case report = "Report" + case doctor = "Doctor" + case hygiene = "Dev hygiene" var id: String { rawValue } } @@ -91,6 +94,9 @@ struct HomeView: View { case .overview: StatusView(db: db, live: live, feeds: feeds) case .history: HistoryView(db: db, live: live, feeds: feeds) case .activity: ActivityView(feeds: feeds) + case .report: ReportView(db: db) + case .doctor: DoctorView(db: db) + case .hygiene: DevHygieneView() } } } diff --git a/Sources/InstallerView.swift b/Sources/InstallerView.swift index 7f12398a..38b5b531 100644 --- a/Sources/InstallerView.swift +++ b/Sources/InstallerView.swift @@ -410,6 +410,7 @@ struct MoItemRow: View { let selected: Bool let onToggle: () -> Void @State private var hover = false + @State private var gitWarn = false var body: some View { HStack(spacing: 12) { @@ -420,6 +421,11 @@ struct MoItemRow: View { .font(Brand.mono(10)).foregroundStyle(Brand.textTertiary).lineLimit(1) } Spacer(minLength: 8) + if gitWarn { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 11)).foregroundStyle(.yellow) + .help(NSLocalizedString("Uncommitted or unpushed git changes in this repo", comment: "")) + } Image(systemName: selected ? "checkmark.circle.fill" : "circle") .font(.system(size: 17)).foregroundStyle(selected ? accent : Brand.textTertiary) } @@ -428,5 +434,18 @@ struct MoItemRow: View { .contentShape(Rectangle()) .onHover { hover = $0 } .onTapGesture { onToggle() } + .task { await checkGit() } + } + + /// Purge-safety badge (C.11): flag rows whose folder sits in a repo with + /// uncommitted/unpushed work. Read-only — never changes what's selected. + private func checkGit() async { + let loc = item.location + guard loc.hasPrefix("/") || loc.hasPrefix("~") else { return } + let path = (loc as NSString).expandingTildeInPath + gitWarn = await Task.detached(priority: .utility) { () -> Bool in + guard let repo = GitSweep.repoRoot(for: path) else { return false } + return GitSweep.status(repo: repo)?.needsAttention ?? false + }.value } } diff --git a/Sources/InventoryDiff.swift b/Sources/InventoryDiff.swift new file mode 100644 index 00000000..06749161 --- /dev/null +++ b/Sources/InventoryDiff.swift @@ -0,0 +1,32 @@ +// +// InventoryDiff.swift +// Burrow +// +// Set-membership diffing for periodic inventories (roadmap B.8 + D.12). +// Pure: two lists of identifiers in, what was added/removed out. Apps, +// login items, LaunchAgents, listening ports, and top-process membership +// all diff the same way, so `burrow_diff` ("what changed since