You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The snapshot pipeline — sample → patch → validate → persist → query — is smeared across seven files, and its one schema is co-owned by four of them:
Sampler spawns mo status --json on a utility queue, hands the JSON to LocalMetrics.patched() (which mutates the parsed dictionary to fill Apple-Silicon gaps from IOKit/SMC), decodes to validate, then inserts the string into SQLite — and also caches lastSnapshot in memory, which the UI reads across threads with no synchronization.
SnapshotStore re-decodes that JSON per row on every chart redraw, silently skipping malformed rows — so when mo's schema drifts, charts just go blank with no visible cause.
IOMonitor is a second, parallel sampler: a @mainactor singleton reading net/disk at 1 Hz into an in-memory ring that never reaches the DB (QueryServer and MCP can't see it), duplicating the IOBlockStorageDriver walker that already exists in LocalMetrics.
Retention is split three ways: Store (config), Maintenance (hourly timing), DB (delete mechanics).
Consumers choose between three inconsistent read paths (sampler.lastSnapshot, SnapshotStore.range, IOMonitor.shared), and the per-process aggregation loop (peaks/averages/CPU-time) is re-implemented in HistoryLoader, MCP.callTopProcesses, MCP.callProcessUsage, and Explain.
The --mcp process opens the same DB file; today nothing distinguishes its read-only role, so a reader could run the destructive corruption-recovery ladder against the writer's live WAL.
The patching step is the riskiest untested seam in the app: a silent schema drift means rows decode-fail, SnapshotStore skips them, and the dashboard quietly empties.
Proposed Interface
Two entry points with a hard topology split — an engine that only the GUI process constructs, and a standalone-openable reader shared by both processes. (DB.swift survives unchanged as the hidden SQLite engine; MoleStatus stays the schema vocabulary; on-disk format (prefix, ts, json) is unchanged so old/new binaries interoperate.)
@MainActorpublicfinalclassMetrics{ // GUI process only — owns the whole write side
publicinit(configuration:Configuration=.standard)throwspublicfunc start() // sampler + 1 Hz live feed + retention janitor
public func stop()
public func setLiveCadence(_ enabled:Bool) // foreground cadence survives (min(liveInterval, configured))
@discardableResult public func sampleNow()->Result<Snapshot,SampleFailure> // deterministic tick
@discardableResult public func runMaintenanceNow()->MaintenanceReport
public let reader: MetricsReader // the query surface (below)
public let live: LiveFeed // @MainActor ObservableObject — absorbs IOMonitor
public var health: PipelineHealth { get } // freshness, consecutive failures, drift counters
publicstructConfiguration{ // every impure edge, injected; .standard wires production
publicvarstatusSource:@Sendable()throws->String // default: engine capture of `mo status --json` (RFC #48)
publicvarnative:anyNativeMetricsProviding // ONE IOKit/SMC provider (dedupes the walker)
publicvardatabaseURL:URLpublicvarpolicy:@Sendable()->Policy // re-read each tick; Settings apply within a cycle
publicvarclock:@Sendable()->Date}publicstructPolicy:Equatable,Sendable{ // ALL cadence + retention knobs in one value
publicvarsampleInterval:TimeIntervalpublicvarliveSampleInterval:TimeIntervalpublicvarretention:TimeIntervalpublicvarautoVacuum:BoolpublicvarmaintenanceInterval:TimeIntervalpublicstaticvarstandard:Policy{get} // the one Store→Policy mapping
}}@MainActorpublicfinalclassLiveFeed:ObservableObject{ // replaces IOMonitor.shared AND cross-thread lastSnapshot
@Publishedpublicprivate(set)varsnapshot:MoleStatus? // latest decoded, main-actor published
@Publishedpublicprivate(set)varrates:Rates // 1 Hz net/disk
publicprivate(set)varring:[RateSample] // ≤1 h, never persisted
}publicstructMetricsReader:Sendable{ // both processes; sync; thread-safe (WAL + FULLMUTEX)
/// Standalone handle for `burrow --mcp`: same file, NO destructive
/// recovery ladder (a reader must never quarantine the writer's live WAL).
publicstaticfunc openDefault()throws-> MetricsReader
public static func open(at url:URL)throws-> MetricsReader
public func latest()->Snapshot? // falls back through up to 5 rows on drift — HUD never blanks silently
public func snapshots(in window: Window, maxPoints: Int =720)-> SnapshotSlice // skipped rows COUNTED
public func series(of metrics:[Metric], in window:Window, maxPoints:Int=720)-> SeriesBundle // ONE decode pass
public func processUsage(in window:Window, rankedBy:ProcessRank, limit:Int=10)->ProcessUsageReport
public func latestRaw()->RawRow? // frozen wire surfaces embed stored JSON verbatim
public func rawRows(prefix:String, in window:Window, sampledTo maxPoints:Int?)->[RawRow]
public func info()->Info // prefixes, staleness, cumulative decode-skip counter
}
public enum Metric: String, CaseIterable, Sendable { // THE projection table, once
case cpuUsage, cpuLoad1, memoryUsedPercent, gpuUsage,
diskRead, diskWrite, networkRx, networkTx,
thermalCPU, thermalGPU, thermalBattery, fanSpeed, batteryPercent, healthScore
public func value(in status:MoleStatus)->Double? // nil = no honest value (gpu<0, thermal 0s, fanCount gating)
}publicstructSnapshot:Sendable{publicletts:Int; publicletstatus:MoleStatus; publicletrawJSON:String; publicvarage:TimeInterval{get}}publicstructSnapshotSlice:Sendable{publicletsnapshots:[Snapshot]; publicletdroppedRows:Int; publicletfirstSkip:DriftReport?}publicstructDriftReport:Sendable{ // coding-path-precise: "missing key 'usage' at path 'cpu'"
publicenumKind{case missingKey(key:String, path:String), typeMismatch(expected:String, path:String), dataCorrupted(path:String, detail:String), notJSON }publicletkind:Kind; publicletsnippet:String; publicletat:Date}publicprotocolNativeMetricsProviding:AnyObject,Sendable{ // stateless counters out; engine owns delta math
func diskByteCounters()->(read:UInt64, write:UInt64)?func netByteCounters()->(rx:UInt64, tx:UInt64)?func gpuUtilization()->Double?func fans()->(count:Int, maxRPM:Int)?func temperatures()->(cpu:Double?, gpu:Double?)}
Representative usage:
// AppDelegate (replaces DB + Sampler + IOMonitor + Maintenance juggling):
letmetrics=tryMetrics(); metrics.start()
queryServer =QueryServer(reader: metrics.reader, port: port)
// HistoryView, 24 h — one decode pass, splice + drift included:
letbundle=await metrics.series(of: charts, in:.last(hours:24), maxPoints:720)
// MCP process (no engine, no timers, non-destructive open):
letreader=tryMetricsReader.openDefault()letreport= reader.processUsage(in:.last(minutes:60), rankedBy:.peakCPU, limit:10)
// QueryServer /metrics — wire format byte-identical (raw rows embedded verbatim).
IOMonitor verdict (all three design panels converged): unify the 1 Hz sampler into the engine's live feed, never persist it (86 k rows/day would wreck retention), don't splice inside the reader (cross-process answers must not differ) — engine-level series(of:) does the splice for the GUI, with an opt-out. The asymmetry (the --mcp process has no ring) is documented, not papered over.
What it hides: subprocess mechanics; the hole-filling patch policy ("only fill 0/−1/absent, never synthesize objects, skip counter resets") plus all IOKit/SMC traffic; decode-before-insert validation with path-precise drift reporting; all of SQLite (WAL, recovery, stride sampling, prune/vacuum); retention config+timing+mechanics as one janitor; cadence policy incl. foreground floor; the cross-thread snapshot race (now a main-actor @Published); the aggregation semantics implemented once instead of four times.
Dependency Strategy
Local-substitutable. Tests run the real pipeline against a temp-file SQLite DB with fakes injected via Configuration — no mocking framework:
Seam
Production
Test substitute
statusSource
engine capture of mo status --json (8 s)
closure returning canned/drifted JSON fixtures
native
IOKit + SMC provider
FakeNative with scripted cumulative counters (engine owns delta math, so fakes are tables)
databaseURL
Application Support path
temp file per test (real SQLite, as DBTests already does)
policy
.standard (reads Store)
fixed literal (no UserDefaults)
clock
Date.init
stepped clock
Testing Strategy
New boundary tests:
Patch end-to-end with zero IOKit: scripted Apple-Silicon fixture (disk_io: 0/0, gpu[0].usage: -1) + fake counters → sampleNow() → reader.latest() shows patched values; second tick after advancing counters asserts rate math and reset-skips.
Write-side drift: source emits JSON missing a required key → .rejected(DriftReport), nothing inserted, health counters advance.
Read-side drift: insert a malformed row directly → snapshots(in:).droppedRows == 1 with the coding path; info().decodeSkippedTotal accumulates; latest() falls back to the previous good row.
Retention: policy with short horizon → runMaintenanceNow() prunes and reports; auto-vacuum gating.
Foreground cadence: setLiveCadence(true) takes an immediate sample and tightens the interval.
Reader in a second handle (simulating the MCP process) sees the writer's rows; read-only open never mutates the file.
Old tests to delete/migrate:SnapshotStoreTests (subsumed by reader tests), the Store-coupling halves of MaintenanceTests and StoreTests (retention moves behind Policy). DBTests survive as hidden-engine tests. MCPTests' top-processes/process-usage assertions retarget reader.processUsage.
Test environment needs: temp-dir SQLite files only.
Implementation Recommendations
The engine owns the full snapshot lifecycle (spawn, patch, validate, persist, publish, prune) and the 1 Hz live feed; the reader owns every query and aggregation. Nothing else in the app touches DB, prefix strings, JSONDecoder for snapshots, or IOKit.
Make drift loud at both edges: reject-and-count on write (a malformed payload never reaches disk), skip-and-count on read, with coding-path detail. Surface the counters through health, /info, and burrow_info so a blank chart always has a visible cause.
Keep the wire formats frozen: /snapshot, /metrics, and MCP passthrough embed stored JSON verbatim (no parse→re-encode). The raw-row methods exist for exactly that; don't "clean them up".
Type the topology: only the engine type can sample; the reader is all the MCP process can construct. Read-only opens must skip the destructive recovery ladder.
Centralize the "0/−1 means missing" projection rules in Metric.value(in:) — they are currently re-implemented in three view models and drift independently.
Problem
The snapshot pipeline — sample → patch → validate → persist → query — is smeared across seven files, and its one schema is co-owned by four of them:
Samplerspawnsmo status --jsonon a utility queue, hands the JSON toLocalMetrics.patched()(which mutates the parsed dictionary to fill Apple-Silicon gaps from IOKit/SMC), decodes to validate, then inserts the string into SQLite — and also cacheslastSnapshotin memory, which the UI reads across threads with no synchronization.SnapshotStorere-decodes that JSON per row on every chart redraw, silently skipping malformed rows — so when mo's schema drifts, charts just go blank with no visible cause.IOMonitoris a second, parallel sampler: a @mainactor singleton reading net/disk at 1 Hz into an in-memory ring that never reaches the DB (QueryServer and MCP can't see it), duplicating the IOBlockStorageDriver walker that already exists inLocalMetrics.Store(config),Maintenance(hourly timing),DB(delete mechanics).sampler.lastSnapshot,SnapshotStore.range,IOMonitor.shared), and the per-process aggregation loop (peaks/averages/CPU-time) is re-implemented inHistoryLoader,MCP.callTopProcesses,MCP.callProcessUsage, andExplain.--mcpprocess opens the same DB file; today nothing distinguishes its read-only role, so a reader could run the destructive corruption-recovery ladder against the writer's live WAL.The patching step is the riskiest untested seam in the app: a silent schema drift means rows decode-fail,
SnapshotStoreskips them, and the dashboard quietly empties.Proposed Interface
Two entry points with a hard topology split — an engine that only the GUI process constructs, and a standalone-openable reader shared by both processes. (
DB.swiftsurvives unchanged as the hidden SQLite engine;MoleStatusstays the schema vocabulary; on-disk format(prefix, ts, json)is unchanged so old/new binaries interoperate.)Representative usage:
IOMonitor verdict (all three design panels converged): unify the 1 Hz sampler into the engine's
livefeed, never persist it (86 k rows/day would wreck retention), don't splice inside the reader (cross-process answers must not differ) — engine-levelseries(of:)does the splice for the GUI, with an opt-out. The asymmetry (the--mcpprocess has no ring) is documented, not papered over.What it hides: subprocess mechanics; the hole-filling patch policy ("only fill 0/−1/absent, never synthesize objects, skip counter resets") plus all IOKit/SMC traffic; decode-before-insert validation with path-precise drift reporting; all of SQLite (WAL, recovery, stride sampling, prune/vacuum); retention config+timing+mechanics as one janitor; cadence policy incl. foreground floor; the cross-thread snapshot race (now a main-actor
@Published); the aggregation semantics implemented once instead of four times.Dependency Strategy
Local-substitutable. Tests run the real pipeline against a temp-file SQLite DB with fakes injected via
Configuration— no mocking framework:statusSourcemo status --json(8 s)nativeFakeNativewith scripted cumulative counters (engine owns delta math, so fakes are tables)databaseURLDBTestsalready does)policy.standard(reads Store)clockDate.initTesting Strategy
New boundary tests:
disk_io: 0/0,gpu[0].usage: -1) + fake counters →sampleNow()→reader.latest()shows patched values; second tick after advancing counters asserts rate math and reset-skips..rejected(DriftReport), nothing inserted,healthcounters advance.snapshots(in:).droppedRows == 1with the coding path;info().decodeSkippedTotalaccumulates;latest()falls back to the previous good row.runMaintenanceNow()prunes and reports; auto-vacuum gating.setLiveCadence(true)takes an immediate sample and tightens the interval.Old tests to delete/migrate:
SnapshotStoreTests(subsumed by reader tests), the Store-coupling halves ofMaintenanceTestsandStoreTests(retention moves behindPolicy).DBTestssurvive as hidden-engine tests.MCPTests' top-processes/process-usage assertions retargetreader.processUsage.Test environment needs: temp-dir SQLite files only.
Implementation Recommendations
DB, prefix strings,JSONDecoderfor snapshots, or IOKit.health,/info, andburrow_infoso a blank chart always has a visible cause./snapshot,/metrics, and MCP passthrough embed stored JSON verbatim (no parse→re-encode). The raw-row methods exist for exactly that; don't "clean them up".Metric.value(in:)— they are currently re-implemented in three view models and drift independently.(db, sampler)pairs formetrics/reader); land the module first, then move consumers one surface per commit. Depends on the execution engine RFC (RFC: deepen mo execution into one engine (capture / stream / PTY / elevated behind ports) #48) only for its defaultstatusSource; an interimMoleCLI.runclosure works.