Freeze the schema as v4 !7

merged merged by cmc on 2026-09-04 14:23 UTC · krz/ambient-companions:feat/schema-v4-freeze into main

10 files changed, +256 −144

README.md +3 −2
@@ -45,8 +45,9 @@ Made true by construction:
4545- **No content channel exists in the wire format.** The only payload channel is
4646 `Value`, a newtype over `f64`. There is no `text`, `bytes`, or `payload`
4747 field — a collector *cannot* emit typed content because the record has
48 nowhere to put it. The two audited exceptions are non-content identifiers in
49 `tag` (bundle id, repo path, ssh host), allow-listed per metric name.
48 nowhere to put it. The one audited exception is a non-content identifier in
49 `tag` — an absolute repo path, allow-listed to the four git metric names,
50 and confined by the daemon to the roots it was told to watch.
5051- **The key counter never stores the key**, there is **no input tap anywhere**
5152 (`CGEventTap`, `IOHIDManager` keyboard usage, accessibility observation are
5253 all forbidden), and **aggregation happens before transport** (the shell emits
crates/signal-schema/src/lib.rs +109 −111
@@ -26,12 +26,18 @@
2626//! `tests/privacy_invariant.rs`, which fails the build if a content-carrying
2727//! field is ever added.
2828
29/// Bump on **any** field change to [`Signal`]. Renderers negotiate on connect
30/// and ignore records they do not understand.
29/// Bump on **any** field change to [`Signal`]. A reader skips records it does
30/// not understand rather than failing on them; see [`wire::Frame::Skipped`].
3131///
3232/// v3 (0.3.0): added the aggregate [`SignalName::CpuLoad`] emitted by the macOS
3333/// IOKit hardware collector (`macos-collector/`, a sibling Swift package).
34pub const SCHEMA_VERSION: u16 = 3;
34///
35/// **v4 (0.5.0) is the 1.0 contract.** [`SignalName`] was cut to the eleven
36/// metrics that have a producer and its discriminants renumbered from zero.
37/// From 1.0 onward discriminants are append-only and removing one is a
38/// breaking change, so this is the last version in which renumbering is
39/// possible.
40pub const SCHEMA_VERSION: u16 = 4;
3541
3642/// The collector domain a signal originated from.
3743///
@@ -76,18 +82,11 @@ impl Source {
7682#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7783pub enum SignalName {
7884 // --- terminal (aggregates only) ---
85 /// Keystrokes per minute, summed across active shell sessions.
7986 KeysPerMin,
80 InterArrivalVariance,
87 /// Seconds of the longest currently active shell session.
8188 SessionSeconds,
82 CommandsPerMin,
83 ExitCodeRatio,
84 TodBucket,
8589 // --- git ---
86 Commits5m,
87 LinesAdded,
88 LinesRemoved,
89 BranchLastCommitAge,
90 DirtyWorktree,
9190 /// Commits within the collector's rolling window (last N days).
9291 CommitsWindow,
9392 /// Commits since local midnight.
@@ -96,25 +95,19 @@ pub enum SignalName {
9695 BranchCount,
9796 /// Whole days since the most recent commit on `HEAD`.
9897 DaysSinceLastCommit,
99 // --- macos system ---
100 AppForegroundSeconds,
101 IdleSeconds,
98 // --- macos ---
99 /// Battery charge percentage in `[0.0, 100.0]`.
102100 BatteryPct,
101 /// `1.0` charging, `0.0` not.
103102 Charging,
103 /// `ProcessInfo` thermal state, `0.0` nominal through `3.0` critical.
104104 ThermalState,
105105 // --- hardware ---
106 /// Instantaneous battery draw in watts, from IORegistry `AppleSmartBattery`.
107 BatteryDrawW,
106108 /// Aggregate CPU busy fraction across all cores in `[0.0, 1.0]`, from Mach
107 /// `host_processor_info` tick deltas. Emitted by the macOS IOKit collector
108 /// as an untagged scalar (the per-core, tagged variant is [`CpuCoreLoad`]).
109 /// `host_processor_info` tick deltas.
109110 CpuLoad,
110 CpuCoreLoad,
111 NetRxBps,
112 NetTxBps,
113 FanRpm,
114 DiskReadBps,
115 DiskWriteBps,
116 GpuUtil,
117 BatteryDrawW,
118111}
119112
120113impl SignalName {
@@ -123,86 +116,49 @@ impl SignalName {
123116 pub fn allows_tag(self) -> bool {
124117 matches!(
125118 self,
126 SignalName::BranchLastCommitAge // tag: repo/branch identifier
127 | SignalName::CommitsWindow // tag: repo path (audited, spec §1.2)
128 | SignalName::CommitsToday // tag: repo path
129 | SignalName::BranchCount // tag: repo path
130 | SignalName::DaysSinceLastCommit // tag: repo path
131 | SignalName::AppForegroundSeconds // tag: bundle id
132 | SignalName::CpuCoreLoad // tag: core index
119 // All four carry a repo path, the only audited identifier left in
120 // the v4 contract.
121 SignalName::CommitsWindow
122 | SignalName::CommitsToday
123 | SignalName::BranchCount
124 | SignalName::DaysSinceLastCommit
133125 )
134126 }
135127
136 /// Stable wire discriminant. New metrics append here (a reviewed change
137 /// that also bumps [`SCHEMA_VERSION`]).
128 /// Stable wire discriminant. Renumbered from zero for v4; from 1.0 onward
129 /// new metrics append here (a reviewed change that also bumps
130 /// [`SCHEMA_VERSION`]) and no discriminant is ever reused.
138131 pub fn to_u8(self) -> u8 {
139132 match self {
140133 SignalName::KeysPerMin => 0,
141 SignalName::InterArrivalVariance => 1,
142 SignalName::SessionSeconds => 2,
143 SignalName::CommandsPerMin => 3,
144 SignalName::ExitCodeRatio => 4,
145 SignalName::TodBucket => 5,
146 SignalName::Commits5m => 6,
147 SignalName::LinesAdded => 7,
148 SignalName::LinesRemoved => 8,
149 SignalName::BranchLastCommitAge => 9,
150 SignalName::DirtyWorktree => 10,
151 SignalName::CommitsWindow => 11,
152 SignalName::CommitsToday => 12,
153 SignalName::BranchCount => 13,
154 SignalName::DaysSinceLastCommit => 14,
155 SignalName::AppForegroundSeconds => 15,
156 SignalName::IdleSeconds => 16,
157 SignalName::BatteryPct => 17,
158 SignalName::Charging => 18,
159 SignalName::ThermalState => 19,
160 SignalName::CpuCoreLoad => 20,
161 SignalName::NetRxBps => 21,
162 SignalName::NetTxBps => 22,
163 SignalName::FanRpm => 23,
164 SignalName::DiskReadBps => 24,
165 SignalName::DiskWriteBps => 25,
166 SignalName::GpuUtil => 26,
167 SignalName::BatteryDrawW => 27,
168 // Appended in v3 (0.3.0); discriminants are append-only.
169 SignalName::CpuLoad => 28,
134 SignalName::SessionSeconds => 1,
135 SignalName::CommitsWindow => 2,
136 SignalName::CommitsToday => 3,
137 SignalName::BranchCount => 4,
138 SignalName::DaysSinceLastCommit => 5,
139 SignalName::BatteryPct => 6,
140 SignalName::Charging => 7,
141 SignalName::ThermalState => 8,
142 SignalName::BatteryDrawW => 9,
143 SignalName::CpuLoad => 10,
170144 }
171145 }
172146
173147 /// Inverse of [`SignalName::to_u8`]. An unknown discriminant returns `None`
174 /// so the daemon can drop names it does not understand at the boundary.
148 /// so a reader can skip names it does not understand.
175149 pub fn from_u8(v: u8) -> Option<SignalName> {
176150 Some(match v {
177151 0 => SignalName::KeysPerMin,
178 1 => SignalName::InterArrivalVariance,
179 2 => SignalName::SessionSeconds,
180 3 => SignalName::CommandsPerMin,
181 4 => SignalName::ExitCodeRatio,
182 5 => SignalName::TodBucket,
183 6 => SignalName::Commits5m,
184 7 => SignalName::LinesAdded,
185 8 => SignalName::LinesRemoved,
186 9 => SignalName::BranchLastCommitAge,
187 10 => SignalName::DirtyWorktree,
188 11 => SignalName::CommitsWindow,
189 12 => SignalName::CommitsToday,
190 13 => SignalName::BranchCount,
191 14 => SignalName::DaysSinceLastCommit,
192 15 => SignalName::AppForegroundSeconds,
193 16 => SignalName::IdleSeconds,
194 17 => SignalName::BatteryPct,
195 18 => SignalName::Charging,
196 19 => SignalName::ThermalState,
197 20 => SignalName::CpuCoreLoad,
198 21 => SignalName::NetRxBps,
199 22 => SignalName::NetTxBps,
200 23 => SignalName::FanRpm,
201 24 => SignalName::DiskReadBps,
202 25 => SignalName::DiskWriteBps,
203 26 => SignalName::GpuUtil,
204 27 => SignalName::BatteryDrawW,
205 28 => SignalName::CpuLoad,
152 1 => SignalName::SessionSeconds,
153 2 => SignalName::CommitsWindow,
154 3 => SignalName::CommitsToday,
155 4 => SignalName::BranchCount,
156 5 => SignalName::DaysSinceLastCommit,
157 6 => SignalName::BatteryPct,
158 7 => SignalName::Charging,
159 8 => SignalName::ThermalState,
160 9 => SignalName::BatteryDrawW,
161 10 => SignalName::CpuLoad,
206162 _ => return None,
207163 })
208164 }
@@ -217,24 +173,22 @@ impl SignalName {
217173#[derive(Debug, Clone, Copy, PartialEq)]
218174pub struct Value(pub f64);
219175
220/// A structurally validated, non-content identifier (spec §1.2).
176/// A structurally validated, non-content identifier.
221177///
222/// Never free text. Constructed only via [`Tag::bundle_id`],
223/// [`Tag::repo_path`], or [`Tag::ssh_host`], each of which validates shape.
224/// M0: validation is stubbed.
178/// Never free text. In the v4 contract the only identifier on the wire is an
179/// absolute repo path, so [`Tag::repo_path`] is the only constructor. Adding
180/// another means a validating constructor here, a [`SignalName`] that
181/// [`SignalName::allows_tag`] permits, and a schema bump.
225182#[derive(Debug, Clone, PartialEq, Eq)]
226183pub struct Tag(String);
227184
228185impl Tag {
229 /// A macOS bundle id, e.g. `com.apple.Terminal`. Validated against a
230 /// bundle-id shape at the daemon boundary.
231 pub fn bundle_id(_s: &str) -> Option<Tag> {
232 todo!("validate bundle-id shape (reverse-DNS); reject anything else")
233 }
234
235 /// An absolute repo path. Validated as a shape (absolute, non-empty, no
236 /// interior NUL) — a structural identifier, never free text. Per-watched-
237 /// root prefix enforcement is layered on at the daemon boundary later.
186 /// An absolute repo path. Validated as a shape — absolute, non-empty, no
187 /// interior NUL — a structural identifier, never free text.
188 ///
189 /// Shape is all this crate can check. Confining a tag to the roots the
190 /// daemon was told to watch needs those roots, which only the daemon
191 /// knows; `signald` enforces that as signals are collected.
238192 pub fn repo_path(s: &str) -> Option<Tag> {
239193 if s.starts_with('/') && !s.is_empty() && !s.contains('\0') {
240194 Some(Tag(s.to_string()))
@@ -243,11 +197,6 @@ impl Tag {
243197 }
244198 }
245199
246 /// An SSH host present in `known_hosts`.
247 pub fn ssh_host(_s: &str) -> Option<Tag> {
248 todo!("validate against ~/.ssh/known_hosts")
249 }
250
251200 /// Read-only view of the validated identifier.
252201 pub fn as_str(&self) -> &str {
253202 &self.0
@@ -453,7 +402,7 @@ mod wire_tests {
453402 let tag = Tag::repo_path("/Users/x/git/repo").expect("valid repo path");
454403 let s = Signal {
455404 source: Source::Git,
456 ..sig(SignalName::BranchLastCommitAge, 3.0, Some(tag))
405 ..sig(SignalName::CommitsWindow, 3.0, Some(tag))
457406 };
458407 let bytes = wire::encode(&s);
459408 let back = wire::decode(&bytes).expect("decodes");
@@ -541,4 +490,53 @@ mod wire_tests {
541490 fn decode_rejects_short_buffer() {
542491 assert!(wire::decode(&[0, 1, 2]).is_none());
543492 }
493
494 /// The v4 contract: eleven names, discriminants 0..=10, each round-tripping
495 /// through the wire byte. Adding a variant without a discriminant, or
496 /// reusing one, fails here.
497 #[test]
498 fn v4_names_are_exactly_zero_through_ten() {
499 const NAMES: [SignalName; 11] = [
500 SignalName::KeysPerMin,
501 SignalName::SessionSeconds,
502 SignalName::CommitsWindow,
503 SignalName::CommitsToday,
504 SignalName::BranchCount,
505 SignalName::DaysSinceLastCommit,
506 SignalName::BatteryPct,
507 SignalName::Charging,
508 SignalName::ThermalState,
509 SignalName::BatteryDrawW,
510 SignalName::CpuLoad,
511 ];
512 for (i, name) in NAMES.iter().enumerate() {
513 assert_eq!(name.to_u8(), i as u8, "{name:?} discriminant");
514 assert_eq!(SignalName::from_u8(i as u8), Some(*name));
515 }
516 assert_eq!(SignalName::from_u8(11), None, "11 is past the frozen set");
517 }
518
519 /// Only the four git metrics may carry a tag in v4.
520 #[test]
521 fn only_git_repo_path_names_allow_a_tag() {
522 for name in [
523 SignalName::CommitsWindow,
524 SignalName::CommitsToday,
525 SignalName::BranchCount,
526 SignalName::DaysSinceLastCommit,
527 ] {
528 assert!(name.allows_tag(), "{name:?} should allow a tag");
529 }
530 for name in [
531 SignalName::KeysPerMin,
532 SignalName::SessionSeconds,
533 SignalName::BatteryPct,
534 SignalName::Charging,
535 SignalName::ThermalState,
536 SignalName::BatteryDrawW,
537 SignalName::CpuLoad,
538 ] {
539 assert!(!name.allows_tag(), "{name:?} must not allow a tag");
540 }
541 }
544542}
crates/signal-schema/tests/hardware_wire.rs +9 −9
@@ -1,4 +1,4 @@
1//! # The Swift↔Rust hardware wire contract (v0.3)
1//! # The Swift↔Rust hardware wire contract (v4)
22//!
33//! The macOS IOKit collector is a sibling Swift package (`macos-collector/`)
44//! that speaks this crate's wire format. Swift and Rust are two independent
@@ -26,25 +26,25 @@ use signal_schema::{wire, Signal, SignalName, Source, Value, SCHEMA_VERSION};
2626
2727/// The canonical hardware test vector, shared verbatim with the Swift side.
2828///
29/// Signal: schema_version=3, ts=0, source=Hardware(3), name=CpuLoad(28),
29/// Signal: schema_version=4, ts=0, source=Hardware(3), name=CpuLoad(10),
3030/// value=0.5 (`f64`), tag=None.
3131///
3232/// Frame bytes (little-endian throughout):
3333/// ```text
3434/// 15 00 00 00 body_len = 21 (u32)
35/// 03 00 schema_version = 3 (u16)
35/// 04 00 schema_version = 4 (u16)
3636/// 00 00 00 00 00 00 00 00 ts = 0 (u64)
3737/// 03 source = Hardware (u8)
38/// 1C name = CpuLoad = 28 (u8)
38/// 0A name = CpuLoad = 10 (u8)
3939/// 00 00 00 00 00 00 E0 3F value = 0.5 (f64)
4040/// 00 tag_present = 0 (u8)
4141/// ```
4242const CANONICAL_FRAME: [u8; 25] = [
4343 0x15, 0x00, 0x00, 0x00, // body_len = 21
44 0x03, 0x00, // schema_version = 3
44 0x04, 0x00, // schema_version = 4
4545 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // ts = 0
4646 0x03, // source = Hardware
47 0x1C, // name = CpuLoad (28)
47 0x0A, // name = CpuLoad (10)
4848 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xE0, 0x3F, // value = 0.5
4949 0x00, // tag_present = 0
5050];
@@ -76,10 +76,10 @@ fn rust_encoder_matches_the_shared_byte_vector() {
7676 assert_eq!(wire::encode(&canonical_signal()), CANONICAL_FRAME.to_vec());
7777}
7878
79/// The new aggregate CPU metric is untagged and stable at discriminant 28.
79/// The aggregate CPU metric is untagged and frozen at discriminant 10.
8080#[test]
8181fn cpu_load_is_untagged_and_stable() {
82 assert_eq!(SignalName::CpuLoad.to_u8(), 28);
83 assert_eq!(SignalName::from_u8(28), Some(SignalName::CpuLoad));
82 assert_eq!(SignalName::CpuLoad.to_u8(), 10);
83 assert_eq!(SignalName::from_u8(10), Some(SignalName::CpuLoad));
8484 assert!(!SignalName::CpuLoad.allows_tag(), "aggregate CPU load is never tagged");
8585}
crates/signald/src/history.rs +53
@@ -59,6 +59,7 @@ impl History {
5959 // WAL: concurrent reads while the daemon writes; survives restarts.
6060 conn.pragma_update(None, "journal_mode", "WAL")?;
6161 let h = Self::init(conn, Some(retention.as_millis() as u64))?;
62 h.drop_foreign_schema_rows()?;
6263 h.prune()?;
6364 Ok(h)
6465 }
@@ -97,6 +98,21 @@ impl History {
9798 })
9899 }
99100
101 /// Delete rows written by a different schema version. Returns the number
102 /// deleted.
103 ///
104 /// `query` rebuilds each row from its stored `frame`, and `wire::decode`
105 /// refuses a frame whose version is not [`signal_schema::SCHEMA_VERSION`].
106 /// Rows from an older daemon would therefore linger, unreadable and
107 /// silently skipped, until retention caught up with them. Dropping them on
108 /// open keeps the store to rows it can actually return.
109 pub fn drop_foreign_schema_rows(&self) -> rusqlite::Result<usize> {
110 self.conn.execute(
111 "DELETE FROM signals WHERE schema_version != ?1",
112 params![signal_schema::SCHEMA_VERSION],
113 )
114 }
115
100116 /// Delete rows older than the retention. Returns the number deleted.
101117 pub fn prune(&self) -> rusqlite::Result<usize> {
102118 let Some(retention_ms) = self.retention_ms else {
@@ -252,4 +268,41 @@ mod tests {
252268 assert_eq!(rows[0].value, Value(2.0));
253269 let _ = std::fs::remove_dir_all(&dir);
254270 }
271
272 /// A row written under an older schema cannot be rebuilt from its frame, so
273 /// reopening drops it rather than leaving it to be skipped silently.
274 #[test]
275 fn reopen_drops_rows_from_a_foreign_schema_version() {
276 let dir = std::env::temp_dir().join(format!("signald-schema-{}", std::process::id()));
277 std::fs::create_dir_all(&dir).unwrap();
278 let db = dir.join("history.db");
279
280 let old_version = signal_schema::SCHEMA_VERSION - 1;
281 {
282 let h = History::open(&db).unwrap();
283 // A row exactly as an older daemon left it: the version in the
284 // column and in the stored frame both predate this build.
285 let s = sig(SignalName::KeysPerMin, 1.0, None);
286 let mut frame = wire::encode(&s);
287 frame[4..6].copy_from_slice(&old_version.to_le_bytes());
288 h.conn
289 .execute(
290 "INSERT INTO signals (schema_version, ts, source, name, value, tag, frame)
291 VALUES (?1, ?2, ?3, ?4, ?5, NULL, ?6)",
292 params![old_version, s.ts as i64, s.source.to_u8(), s.name.to_u8(), s.value.0, frame],
293 )
294 .unwrap();
295 assert_eq!(h.recent(10).unwrap().len(), 0, "unreadable while present");
296 }
297
298 let h = History::open(&db).unwrap();
299 assert_eq!(
300 h.conn
301 .query_row("SELECT COUNT(*) FROM signals", [], |r| r.get::<_, i64>(0))
302 .unwrap(),
303 0,
304 "the foreign-schema row is gone, not merely unreadable"
305 );
306 let _ = std::fs::remove_dir_all(&dir);
307 }
255308}
crates/signald/src/lib.rs +56
@@ -12,11 +12,28 @@
1212//! v0.4 adds the **hardware path**: the daemon spawns the sibling
1313//! `macos-collector` and ingests its wire frames ([`collectors::hardware`]).
1414
15use std::path::{Path, PathBuf};
1516use std::time::{SystemTime, UNIX_EPOCH};
1617
18use signal_schema::Signal;
19
1720pub mod history;
1821pub mod hub;
1922
23/// Whether `sig` may be published: any tag it carries must name a path under
24/// one of the roots the daemon was told to watch.
25///
26/// `Tag::repo_path` validates shape and nothing else — the watched roots live
27/// here, not in the schema crate. Comparison is by path component, so `/a/bc`
28/// is not under `/a/b`.
29pub fn tag_within_roots(sig: &Signal, roots: &[PathBuf]) -> bool {
30 let Some(tag) = &sig.tag else {
31 return true;
32 };
33 let path = Path::new(tag.as_str());
34 roots.iter().any(|root| path.starts_with(root))
35}
36
2037/// Collectors: each reduces its domain to schema scalars (spec §1.1, §1.4).
2138pub mod collectors {
2239 /// Git collector: shell out to `git` for counts and ages. Counts and
@@ -492,3 +509,42 @@ pub fn now_millis() -> u64 {
492509 .map(|d| d.as_millis() as u64)
493510 .unwrap_or(0)
494511}
512
513#[cfg(test)]
514mod root_tests {
515 use super::*;
516 use signal_schema::{SignalName, Source, Tag, Value, SCHEMA_VERSION};
517
518 fn tagged(path: &str) -> Signal {
519 Signal {
520 schema_version: SCHEMA_VERSION,
521 ts: 0,
522 source: Source::Git,
523 name: SignalName::CommitsToday,
524 value: Value(1.0),
525 tag: Tag::repo_path(path),
526 }
527 }
528
529 #[test]
530 fn a_tag_under_a_watched_root_is_allowed() {
531 let roots = vec![PathBuf::from("/home/x/git")];
532 assert!(tag_within_roots(&tagged("/home/x/git/repo"), &roots));
533 assert!(tag_within_roots(&tagged("/home/x/git"), &roots));
534 }
535
536 #[test]
537 fn a_tag_outside_every_watched_root_is_refused() {
538 let roots = vec![PathBuf::from("/home/x/git")];
539 assert!(!tag_within_roots(&tagged("/etc/passwd"), &roots));
540 // Component-wise, so a shared string prefix is not a shared path.
541 assert!(!tag_within_roots(&tagged("/home/x/gitsecrets"), &roots));
542 }
543
544 #[test]
545 fn an_untagged_signal_needs_no_root() {
546 let mut s = tagged("/home/x/git/repo");
547 s.tag = None;
548 assert!(tag_within_roots(&s, &[]));
549 }
550}
crates/signald/src/main.rs +4
@@ -93,6 +93,10 @@ fn main() {
9393 loop {
9494 for repo in &repos {
9595 for sig in collectors::git::collect(repo) {
96 // The audited repo-path tag never leaves the watched roots.
97 if !signald::tag_within_roots(&sig, &repos) {
98 continue;
99 }
96100 producer.publish(sig);
97101 }
98102 }
crates/terminal-garden/src/lib.rs +2 −2
@@ -122,8 +122,8 @@ impl Plot {
122122}
123123
124124/// Fold a snapshot of signals into one plot per repo (keyed by the audited repo
125/// tag; untagged signals collapse into a single unnamed plot). Later git names
126/// (`Commits5m`, diff-line counts, dirty-worktree) are simply ignored here.
125/// tag; untagged signals collapse into a single unnamed plot). Names this
126/// renderer does not draw are ignored here.
127127pub fn plots_from_signals(signals: &[Signal]) -> Vec<Plot> {
128128 let mut by_repo: BTreeMap<String, Plot> = BTreeMap::new();
129129
macos-collector/README.md +9 −9
@@ -20,11 +20,11 @@ swift run macos-collector --once # one real IOKit read (summary on stderr)
2020
2121| Metric | `SignalName` | Source | How |
2222|---|---|---|---|
23| Aggregate CPU load `[0,1]` | `cpu_load` (28) | hardware | Mach `host_processor_info(PROCESSOR_CPU_LOAD_INFO)` tick deltas |
24| Battery percentage `[0,100]` | `battery_pct` (17) | macos | IOKit power sources (`IOPSCopyPowerSourcesInfo`) |
25| Charging (1/0) | `charging` (18) | macos | IOKit power sources |
26| Battery draw (W) | `battery_draw_w` (27) | hardware | IORegistry `AppleSmartBattery` Amperage×Voltage |
27| Thermal state `0..3` | `thermal_state` (19) | macos | `ProcessInfo.thermalState` |
23| Aggregate CPU load `[0,1]` | `cpu_load` (10) | hardware | Mach `host_processor_info(PROCESSOR_CPU_LOAD_INFO)` tick deltas |
24| Battery percentage `[0,100]` | `battery_pct` (6) | macos | IOKit power sources (`IOPSCopyPowerSourcesInfo`) |
25| Charging (1/0) | `charging` (7) | macos | IOKit power sources |
26| Battery draw (W) | `battery_draw_w` (9) | hardware | IORegistry `AppleSmartBattery` Amperage×Voltage |
27| Thermal state `0..3` | `thermal_state` (8) | macos | `ProcessInfo.thermalState` |
2828
2929**Intentionally omitted:** GPU utilization and fan RPM. They are reachable in
3030principle via `IOReport`/AppleSMC, but only through chip-generation-specific,
@@ -62,7 +62,7 @@ multi-byte integers and the `f64` (via its IEEE-754 bit pattern) are
6262frame:
6363 [u32 body_len] little-endian length of body
6464 body:
65 [u16 schema_version] must equal 3 (v0.3); a daemon drops other versions
65 [u16 schema_version] must equal 4 (v4); a reader skips other versions
6666 [u64 ts] unix milliseconds
6767 [u8 source] 0=terminal 1=git 2=macos 3=hardware
6868 [u8 name] SignalName discriminant (see table above)
@@ -74,15 +74,15 @@ frame:
7474
7575### Canonical frame (pinned by tests on both sides)
7676
77`schema_version=3, ts=0, source=Hardware(3), name=CpuLoad(28), value=0.5,
77`schema_version=4, ts=0, source=Hardware(3), name=CpuLoad(10), value=0.5,
7878tag=none` encodes to these exact 25 bytes:
7979
8080```text
818115 00 00 00 body_len = 21
8203 00 schema_version = 3
8204 00 schema_version = 4
838300 00 00 00 00 00 00 00 ts = 0
848403 source = Hardware
851C name = CpuLoad (28)
850A name = CpuLoad (10)
868600 00 00 00 00 00 E0 3F value = 0.5 (f64 LE)
878700 tag_present = 0
8888```
macos-collector/Sources/CollectorCore/Wire.swift +8 −8
@@ -1,9 +1,9 @@
11import Foundation
22
33/// The wire schema version. **Must equal** `signal_schema::SCHEMA_VERSION` on the
4/// Rust side (v3 as of 0.3.0). A daemon rejects frames whose version it does not
5/// understand, so this is a hard cross-language contract.
6public let SCHEMA_VERSION: UInt16 = 3
4/// Rust side (v4 as of 0.5.0, the 1.0 contract). A reader skips frames whose
5/// version it does not understand, so this is a hard cross-language contract.
6public let SCHEMA_VERSION: UInt16 = 4
77
88/// The collector domain. Discriminants **must match** Rust `Source::to_u8`.
99public enum Source: UInt8 {
@@ -20,11 +20,11 @@ public enum Source: UInt8 {
2020/// `f64` scalar, and none of them carries a tag (all are emitted with
2121/// `tag == nil`), exactly as `SignalName::allows_tag` is `false` for them.
2222public enum SignalName: UInt8 {
23 case batteryPct = 17 // macos: battery charge percentage [0, 100]
24 case charging = 18 // macos: 1.0 charging, 0.0 not
25 case thermalState = 19 // macos: ProcessInfo thermal state 0..3
26 case batteryDrawW = 27 // hardware: instantaneous battery draw, watts
27 case cpuLoad = 28 // hardware: aggregate CPU busy fraction [0, 1]
23 case batteryPct = 6 // macos: battery charge percentage [0, 100]
24 case charging = 7 // macos: 1.0 charging, 0.0 not
25 case thermalState = 8 // macos: ProcessInfo thermal state 0..3
26 case batteryDrawW = 9 // hardware: instantaneous battery draw, watts
27 case cpuLoad = 10 // hardware: aggregate CPU busy fraction [0, 1]
2828
2929 /// Human-readable name, matching the Rust metric names (for `--once`).
3030 public var label: String {
macos-collector/Tests/CollectorCoreTests/WireTests.swift +3 −3
@@ -14,10 +14,10 @@ final class WireTests: XCTestCase {
1414 let signal = Signal(ts: 0, source: .hardware, name: .cpuLoad, value: 0.5)
1515 let expected: [UInt8] = [
1616 0x15, 0x00, 0x00, 0x00, // body_len = 21
17 0x03, 0x00, // schema_version = 3
17 0x04, 0x00, // schema_version = 4
1818 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // ts = 0
1919 0x03, // source = Hardware
20 0x1C, // name = CpuLoad (28)
20 0x0A, // name = CpuLoad (10)
2121 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xE0, 0x3F, // value = 0.5 (f64 LE)
2222 0x00, // tag_present = 0
2323 ]
@@ -27,7 +27,7 @@ final class WireTests: XCTestCase {
2727 /// The schema version must equal the Rust `SCHEMA_VERSION`, or the daemon
2828 /// drops every frame this collector sends.
2929 func testSchemaVersionMatchesRust() {
30 XCTAssertEqual(SCHEMA_VERSION, 3)
30 XCTAssertEqual(SCHEMA_VERSION, 4)
3131 }
3232
3333 /// The tagged-frame layout matches Rust too (hardware signals are untagged,