Compare commits

..
Author SHA1 Message Date
CodeWhale BotandClaude Sonnet 5.5 1f56b9b9cc feat(plugins): one-line install summary builder for plugins and DSH packages (CW-8182)
A pure function, install::build_summary, turns what an install already knows
into the single line a person reads afterward: name, version and source; on or
off; who approved; what it adds; which converted rows will not run and why
(manual ports and rows refused by the host version gate); that native rows need
the extension host while it is off; host services granted, shared, withheld or
not offered, with the pause-until-idle note; and that provider declarations
apply in a new session. An update names only what changed, plus a source
change and an allowed downgrade. Each clause carries a stable message id for
localization; the English text is the fallback. No caller yet: the shared
one-step function and the clients adopt it next.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 17:55:54 -08:00
38 changed files with 980 additions and 3116 deletions

No files matched your search

-1
View File
@@ -3049,7 +3049,6 @@ fn start_cli_telemetry(
telemetry::init(consent);
telemetry::record(Event::SessionStart {
source: SessionSource::Unknown,
install_age: telemetry::install_age(),
});
Some(CliTelemetrySession {
started: std::time::Instant::now(),
-16
View File
@@ -484,22 +484,6 @@ pub fn public_model_identifier(model: &str) -> bool {
})
}
/// Case-insensitive form of [`public_model_identifier`] for ids that were
/// lowercased before the lookup.
#[must_use]
pub fn public_model_identifier_ignore_case(model: &str) -> bool {
let catalog = super::bundled_models_dev_catalog();
let matches = |id: &str| id.eq_ignore_ascii_case(model);
catalog.reviewed.intrinsic.keys().any(|id| matches(id))
|| catalog.models.keys().any(|id| matches(id))
|| catalog.providers.values().any(|provider| {
provider
.models
.iter()
.any(|(id, row)| matches(id) || matches(&row.id))
})
}
#[must_use]
pub fn official_deepseek_model_id(model: &str) -> Option<&'static str> {
bundled_reviewed()
+1 -1
View File
@@ -45,7 +45,7 @@ pub const SETUP_STATE_FILE_NAME: &str = "setup_state.json";
/// when the collection policy, schema, or disclosure materially changes. Prior
/// declines remain off. Keying it to the app version would re-prompt every
/// release, which is nagging with extra steps.
pub const TELEMETRY_NOTICE_VERSION: &str = "8";
pub const TELEMETRY_NOTICE_VERSION: &str = "6";
/// Canonical setup step ids. The ordering matches the first-run spine so a
/// `BTreeMap<SetupStep, _>` renders in wizard order.
-1
View File
@@ -11793,7 +11793,6 @@ fn record_model_turn_telemetry(
{
return;
}
let model = turn.last_parent_model.as_deref().unwrap_or(model);
let outcome = match status {
TurnOutcomeStatus::Completed => codewhale_telemetry::TurnOutcome::Ok,
TurnOutcomeStatus::Failed => codewhale_telemetry::TurnOutcome::Failed,
-1
View File
@@ -73,7 +73,6 @@ include!("tests/test_cases_18.rs");
include!("tests/test_cases_19.rs");
mod admission_gates;
mod model_turn_telemetry;
mod redacted_thinking;
mod runtime_state;
mod sse_turn_recovery;
@@ -1,31 +0,0 @@
use super::*;
#[test]
fn model_turn_telemetry_names_the_model_the_last_request_used() {
let mut turn = crate::core::turn::TurnContext::new(4);
turn.add_parent_usage(&Usage {
input_tokens: 10,
output_tokens: 5,
..Usage::default()
});
turn.note_parent_model("deepseek-v4pro");
record_model_turn_telemetry(
codewhale_config::ProviderKind::Deepseek,
"auto",
&turn,
TurnOutcomeStatus::Completed,
);
let reported = codewhale_telemetry::session_counters()
.usage_events()
.into_iter()
.any(|event| {
matches!(
event,
codewhale_telemetry::Event::ModelUsage { provider, model, .. }
if provider == "deepseek" && model == "deepseek-v4-pro"
)
});
assert!(reported, "the resolved wire model, not `auto`, is reported");
}
@@ -641,7 +641,6 @@ impl Engine {
job.response_settled(!response_stream_failed);
}
turn.add_parent_usage(&usage);
turn.note_parent_model(&stream_request.model);
turn.note_parent_prompt_len(
self.session.messages.len(),
crate::compaction::estimate_request_input_tokens(
-8
View File
@@ -93,9 +93,6 @@ pub struct TurnContext {
/// their own routed receipts.
pub parent_route_usage: Usage,
/// Model the most recent parent-route request was dispatched with.
pub last_parent_model: Option<String>,
/// Provider calls whose usage became ambiguous after dispatch (for
/// example an RLM timeout). A non-zero value makes cost coverage
/// explicitly incomplete instead of inventing a zero-usage receipt.
@@ -202,7 +199,6 @@ impl TurnContext {
..Usage::default()
},
parent_route_usage: Usage::default(),
last_parent_model: None,
routed_usage_dropped_records: 0,
latest_parent_input_tokens: None,
messages_len_at_last_parent_prompt: None,
@@ -279,10 +275,6 @@ impl TurnContext {
add_usage_to(&mut self.parent_route_usage, usage);
}
pub fn note_parent_model(&mut self, model: &str) {
self.last_parent_model = Some(model.to_string());
}
pub fn add_routed_usage_dropped_records(&mut self, dropped_records: u64) {
self.routed_usage_dropped_records = self
.routed_usage_dropped_records
+3 -3
View File
@@ -226,10 +226,10 @@
"TranscriptReasoningExpand": "expand",
"TranscriptThought": "Thought",
"TranscriptThoughtFor": "Thought for {duration}",
"TelemetryNoticeDefaultOn": "Usage reporting is on by default. Share aggregate version/platform, session, feature and error counts with Codewhale and PostHog (processor). Also counts providers, public catalog models, token totals, request counts, model wait time, prompt-size ranges, built-in tool use, how an exec session started and a coarse install-age range. No content or IP. docs/TELEMETRY.md Turn off: codewhale config set telemetry false",
"TelemetryNoticeDefaultOn": "Usage reporting is on by default. Share aggregate version/platform, session, feature and error counts with Codewhale and PostHog (processor). Also counts providers, public catalog models, token totals and built-in tool use. No content or IP. docs/TELEMETRY.md Turn off: codewhale config set telemetry false",
"TelemetryNoticeHeadline": "Codewhale usage reporting",
"TelemetryNoticeBody": "Codewhale counts: which version you run, OS and CPU family, session\nduration and outcome, and aggregate feature and error counters.\n\nIt also counts the providers and public catalog models you use, their\ntoken totals, request counts, time spent waiting on the model and\nprompt-size ranges, how often each built-in tool runs, how an exec\nsession was started, and a coarse install-age range.\n\nIt never collects your conversations, code, prompts, files, repo or\nbranch names, model content, or credentials — and it never sends a\nper-turn or per-tool timeline of agent activity.\n\nYou are identified only by a random ID stored on this machine, replaced\nevery 90 days. Change your mind any time:\n codewhale config set telemetry false\n\nFull schema, field by field: docs/TELEMETRY.md\n\nUsage reporting is on by default. Codewhale and PostHog process these counts when delivery is configured. No IP is collected.",
"TelemetryNoticeCompactBody": "Counts: version, OS/CPU, session\ntime/result, feature/error totals.\nAlso: provider/model, tokens, tools,\nrequests, wait time, prompt-size and\ninstall-age ranges, exec start.\nRandom local ID rotates every 90d.\nNever: chat/code/prompts/files/names;\nmodel content/credentials; turn/tools.\nSchema: docs/TELEMETRY.md\nOff: codewhale config set telemetry\nfalse\nUsage reporting is on by default. Codewhale and PostHog process these counts when delivery is configured. No IP is collected.",
"TelemetryNoticeBody": "Codewhale counts: which version you run, OS and CPU family, session\nduration and outcome, and aggregate feature and error counters.\n\nIt also counts the providers and public catalog models you use, their\ntoken totals, and how often each built-in tool runs.\n\nIt never collects your conversations, code, prompts, files, repo or\nbranch names, model content, or credentials — and it never sends a\nper-turn or per-tool timeline of agent activity.\n\nYou are identified only by a random ID stored on this machine, replaced\nevery 90 days. Change your mind any time:\n codewhale config set telemetry false\n\nFull schema, field by field: docs/TELEMETRY.md\n\nUsage reporting is on by default. Codewhale and PostHog process these counts when delivery is configured. No IP is collected.",
"TelemetryNoticeCompactBody": "Counts: version, OS/CPU, session\ntime/result, feature/error totals.\nAlso: provider/model, tokens, tools.\nRandom local ID rotates every 90d.\nNever: chat/code/prompts/files/names;\nmodel content/credentials; turn/tools.\nSchema: docs/TELEMETRY.md\nOff: codewhale config set telemetry\nfalse\nUsage reporting is on by default. Codewhale and PostHog process these counts when delivery is configured. No IP is collected.",
"TelemetryNoticeChoiceKeep": "Yes, keep anonymous counts",
"TelemetryNoticeChoiceDisable": "No, turn off tracking",
"TelemetryNoticeActionChoose": "choose",
+2
View File
@@ -86,6 +86,7 @@ use codewhale_skill_install::install::{
pub mod dsh;
mod place;
mod stage;
mod summary;
mod tarball;
#[cfg(test)]
@@ -93,6 +94,7 @@ mod tests;
use place::{ensure_target_within_plugins_dir, finalize_install, plugin_target_path};
use stage::stage_local_copy;
pub use summary::{PluginSummary, PreviousInstall, SummaryClause, SummaryInput, build_summary};
use tarball::stage_tarball;
/// Default per-bundle size cap. Mirrors the skill installer; the runtime
+865
View File
@@ -0,0 +1,865 @@
//! One-line install summary shared by every install surface.
//!
//! A pure builder: everything it needs arrives in [`SummaryInput`], nothing is
//! read from disk, the registry or the environment. Each clause carries a
//! stable message id so a surface can localize the clause while the English
//! text here stays the fallback.
use std::collections::BTreeSet;
use super::dsh::DshConversion;
use crate::host_services::{HostService, HostServiceReview, PluginApprover, approver_label};
use crate::manifest::PluginInventory;
pub const MSG_HEAD: &str = "plugin.summary.head";
pub const MSG_STATE_ON: &str = "plugin.summary.state_on";
pub const MSG_STATE_OFF: &str = "plugin.summary.state_off";
pub const MSG_APPROVER: &str = "plugin.summary.approver";
pub const MSG_ADDS: &str = "plugin.summary.adds";
pub const MSG_ADDS_NOTHING_NEW: &str = "plugin.summary.adds_nothing_new";
pub const MSG_NOW_ALSO_ADDS: &str = "plugin.summary.now_also_adds";
pub const MSG_WONT_RUN: &str = "plugin.summary.wont_run";
pub const MSG_NEEDS_EXTENSION_HOST: &str = "plugin.summary.needs_extension_host";
pub const MSG_NEEDS_NODE: &str = "plugin.summary.needs_node";
pub const MSG_REPLACES: &str = "plugin.summary.replaces";
pub const MSG_DOWNGRADED: &str = "plugin.summary.downgraded";
pub const MSG_SERVICES: &str = "plugin.summary.services";
pub const MSG_SERVICES_NOW_ALSO: &str = "plugin.summary.services_now_also";
pub const MSG_SERVICES_WITHHELD: &str = "plugin.summary.services_withheld";
pub const MSG_SERVICES_UNOFFERED: &str = "plugin.summary.services_unoffered";
pub const MSG_SERVICES_PAUSE: &str = "plugin.summary.services_pause";
pub const MSG_PROVIDER_NEW_SESSION: &str = "plugin.summary.provider_new_session";
const HOST_LIST_LIMIT: usize = 3;
const VERSION_GATE_REASON: &str = "incompatible-version";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SummaryClause {
pub id: &'static str,
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginSummary {
pub clauses: Vec<SummaryClause>,
}
impl PluginSummary {
#[must_use]
pub fn line(&self) -> String {
let mut clauses = self.clauses.iter();
let Some(head) = clauses.next() else {
return String::new();
};
let rest: Vec<&str> = clauses.map(|clause| clause.text.as_str()).collect();
if rest.is_empty() {
head.text.clone()
} else {
format!("{}: {}", head.text, rest.join("; "))
}
}
#[must_use]
pub fn ids(&self) -> Vec<&'static str> {
self.clauses.iter().map(|clause| clause.id).collect()
}
}
#[derive(Debug, Clone)]
pub struct PreviousInstall<'a> {
pub name: &'a str,
pub version: &'a str,
pub source: Option<&'a str>,
pub inventory: &'a PluginInventory,
pub host_services: &'a [HostService],
}
#[derive(Debug, Clone)]
pub struct SummaryInput<'a> {
pub name: &'a str,
pub version: &'a str,
pub source: Option<&'a str>,
pub enabled: bool,
pub approver: Option<&'a PluginApprover>,
pub inventory: &'a PluginInventory,
pub dsh: Option<&'a DshConversion>,
pub host_services: Option<&'a HostServiceReview>,
pub extension_host_on: bool,
pub previous: Option<PreviousInstall<'a>>,
pub downgrade_allowed: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct Capabilities {
skills: usize,
local_mcp: usize,
remote_mcp: usize,
commands: usize,
agents: usize,
hooks: usize,
lsp: usize,
native: usize,
providers: usize,
panels: usize,
filesystem_roots: BTreeSet<String>,
network_hosts: BTreeSet<String>,
lifecycle_mutation: bool,
}
impl Capabilities {
fn from_inventory(inventory: &PluginInventory) -> Self {
let split = inventory.stdio_mcp_servers + inventory.remote_mcp_servers;
let (local_mcp, remote_mcp) = if split == 0 {
(inventory.mcp_servers, 0)
} else {
(
inventory.stdio_mcp_servers + inventory.mcp_servers.saturating_sub(split),
inventory.remote_mcp_servers,
)
};
Self {
skills: inventory.skills,
local_mcp,
remote_mcp,
commands: inventory.commands,
agents: inventory.agents,
hooks: inventory.hooks,
lsp: inventory.lsp,
native: inventory.native,
providers: inventory.providers,
panels: 0,
filesystem_roots: inventory.filesystem_roots.iter().cloned().collect(),
network_hosts: inventory.network_hosts.iter().cloned().collect(),
lifecycle_mutation: inventory.lifecycle_mutation,
}
}
fn with_dsh(mut self, dsh: &DshConversion) -> Self {
self.skills = self.skills.max(dsh.skills.len());
self.local_mcp = self.local_mcp.max(dsh.local_servers.len());
self.remote_mcp = self.remote_mcp.max(dsh.remote_servers.len());
self.native = self.native.max(dsh.native_rows.len());
if dsh.requires_native && self.native == 0 {
self.native = 1;
}
self.panels = self.panels.max(dsh.client_closures.len());
self.network_hosts.extend(dsh.network_hosts.iter().cloned());
self
}
fn since(&self, old: &Self) -> Self {
let more = |new: usize, old: usize| new.saturating_sub(old);
Self {
skills: more(self.skills, old.skills),
local_mcp: more(self.local_mcp, old.local_mcp),
remote_mcp: more(self.remote_mcp, old.remote_mcp),
commands: more(self.commands, old.commands),
agents: more(self.agents, old.agents),
hooks: more(self.hooks, old.hooks),
lsp: more(self.lsp, old.lsp),
native: more(self.native, old.native),
providers: more(self.providers, old.providers),
panels: more(self.panels, old.panels),
filesystem_roots: self
.filesystem_roots
.difference(&old.filesystem_roots)
.cloned()
.collect(),
network_hosts: self
.network_hosts
.difference(&old.network_hosts)
.cloned()
.collect(),
lifecycle_mutation: self.lifecycle_mutation && !old.lifecycle_mutation,
}
}
fn phrases(&self) -> Vec<String> {
let mut phrases = Vec::new();
let mut count = |n: usize, one: &str, many: &str| {
if n > 0 {
phrases.push(format!("{n} {}", if n == 1 { one } else { many }));
}
};
count(self.skills, "skill", "skills");
count(self.local_mcp, "local MCP server", "local MCP servers");
count(self.remote_mcp, "remote MCP server", "remote MCP servers");
count(self.commands, "command", "commands");
count(self.agents, "agent profile", "agent profiles");
count(self.hooks, "hook", "hooks");
count(self.lsp, "language server", "language servers");
count(self.native, "native extension", "native extensions");
count(self.providers, "provider", "providers");
count(self.panels, "panel", "panels");
if !self.filesystem_roots.is_empty() {
phrases.push(format!("file access ({})", limited(&self.filesystem_roots)));
}
if !self.network_hosts.is_empty() {
phrases.push(format!("network access ({})", limited(&self.network_hosts)));
}
if self.lifecycle_mutation {
phrases.push("permission to change plugins".to_string());
}
phrases
}
}
fn limited(items: &BTreeSet<String>) -> String {
let shown: Vec<&str> = items
.iter()
.take(HOST_LIST_LIMIT)
.map(String::as_str)
.collect();
let mut text = shown.join(", ");
if items.len() > HOST_LIST_LIMIT {
text.push_str(&format!(" and {} more", items.len() - HOST_LIST_LIMIT));
}
text
}
fn and_list(items: &[String]) -> String {
match items {
[] => String::new(),
[only] => only.clone(),
[init @ .., last] => format!("{} and {last}", init.join(", ")),
}
}
fn rows(n: usize) -> String {
format!("{n} {}", if n == 1 { "row" } else { "rows" })
}
fn quoted(peers: &[crate::host_services::ReviewPeer]) -> String {
peers
.iter()
.map(|peer| format!("`{}`", peer.name))
.collect::<Vec<_>>()
.join(", ")
}
fn push(clauses: &mut Vec<SummaryClause>, id: &'static str, text: String) {
clauses.push(SummaryClause { id, text });
}
#[must_use]
pub fn build_summary(input: &SummaryInput<'_>) -> PluginSummary {
let mut clauses = Vec::new();
let mut head = input.name.to_string();
if !input.version.is_empty() {
head.push(' ');
head.push_str(input.version);
}
if let Some(source) = input.source.filter(|source| !source.is_empty()) {
head.push_str(&format!(" from {source}"));
}
push(&mut clauses, MSG_HEAD, head);
if input.enabled {
push(&mut clauses, MSG_STATE_ON, "on".to_string());
} else {
push(&mut clauses, MSG_STATE_OFF, "off".to_string());
}
push(
&mut clauses,
MSG_APPROVER,
approver_label(input.approver).to_string(),
);
if let Some(previous) = &input.previous {
if input.downgrade_allowed && previous.version != input.version {
push(
&mut clauses,
MSG_DOWNGRADED,
format!("downgraded from {}", previous.version),
);
}
if previous.source != input.source {
let from = previous
.source
.filter(|source| !source.is_empty())
.map_or_else(|| "an unrecorded source".to_string(), str::to_string);
push(
&mut clauses,
MSG_REPLACES,
format!("replaces {} from {from}", previous.name),
);
}
}
let mut now = Capabilities::from_inventory(input.inventory);
if let Some(dsh) = input.dsh {
now = now.with_dsh(dsh);
}
match &input.previous {
None => {
let phrases = now.phrases();
if !phrases.is_empty() {
push(
&mut clauses,
MSG_ADDS,
format!("adds {}", and_list(&phrases)),
);
}
}
Some(previous) => {
let added = now
.since(&Capabilities::from_inventory(previous.inventory))
.phrases();
if added.is_empty() {
push(
&mut clauses,
MSG_ADDS_NOTHING_NEW,
"adds nothing new".to_string(),
);
} else {
push(
&mut clauses,
MSG_NOW_ALSO_ADDS,
format!("now also adds {}", and_list(&added)),
);
}
}
}
if let Some(dsh) = input.dsh {
let skipped: Vec<_> = dsh
.outcomes
.iter()
.filter(|outcome| outcome.needs_manual_port())
.collect();
if !skipped.is_empty() {
let gated = skipped
.iter()
.filter(|outcome| outcome.reason.starts_with(VERSION_GATE_REASON))
.count();
let manual = skipped.len() - gated;
let reason = match (gated, manual) {
(0, _) => "each needs a manual port".to_string(),
(_, 0) => "refused by the host version gate".to_string(),
_ => format!(
"{gated} refused by the host version gate, {manual} with no portable form"
),
};
push(
&mut clauses,
MSG_WONT_RUN,
format!("won't run: {} ({reason})", rows(skipped.len())),
);
}
if dsh.requires_node {
push(
&mut clauses,
MSG_NEEDS_NODE,
"needs Node.js for its local servers".to_string(),
);
}
}
if now.native > 0 && !input.extension_host_on {
push(
&mut clauses,
MSG_NEEDS_EXTENSION_HOST,
"needs the extension host (off)".to_string(),
);
}
if let Some(review) = input.host_services {
let granted = review.grants();
let previously: &[HostService] = input
.previous
.as_ref()
.map_or(&[], |previous| previous.host_services);
let names = |services: Vec<HostService>| -> Vec<String> {
services
.into_iter()
.map(|service| service.name().to_string())
.collect()
};
match &input.previous {
None => {
if !granted.is_empty() {
let mut text =
format!("uses host services {}", and_list(&names(granted.clone())));
let mut peers: Vec<_> = review
.lines
.iter()
.filter(|line| line.offered)
.flat_map(|line| line.shared_with.iter())
.collect();
peers.sort_by(|a, b| a.id.cmp(&b.id));
peers.dedup_by(|a, b| a.id == b.id);
if !peers.is_empty() {
let peers: Vec<_> = peers.into_iter().cloned().collect();
text.push_str(&format!(" (shared with {})", quoted(&peers)));
}
push(&mut clauses, MSG_SERVICES, text);
}
}
Some(_) => {
let fresh: Vec<_> = granted
.iter()
.copied()
.filter(|service| !previously.contains(service))
.collect();
if !fresh.is_empty() {
push(
&mut clauses,
MSG_SERVICES_NOW_ALSO,
format!("now also uses host services {}", and_list(&names(fresh))),
);
}
}
}
let withheld: Vec<String> = review
.lines
.iter()
.filter(|line| line.offered && !line.withheld_by.is_empty())
.map(|line| {
format!(
"{} ({} is turned on without it)",
line.service.name(),
quoted(&line.withheld_by)
)
})
.collect();
if !withheld.is_empty() {
push(
&mut clauses,
MSG_SERVICES_WITHHELD,
format!("off for now: {}", withheld.join(", ")),
);
}
let unoffered: Vec<String> = review
.lines
.iter()
.filter(|line| !line.offered)
.map(|line| line.service.name().to_string())
.collect();
if !unoffered.is_empty() {
push(
&mut clauses,
MSG_SERVICES_UNOFFERED,
format!(
"this Codewhale does not offer {} to plugins",
and_list(&unoffered)
),
);
}
if !granted.is_empty() {
push(
&mut clauses,
MSG_SERVICES_PAUSE,
"services pause for every plugin in its host until the host is idle".to_string(),
);
}
}
if now.providers > 0 {
push(
&mut clauses,
MSG_PROVIDER_NEW_SESSION,
"provider changes apply in a new session".to_string(),
);
}
PluginSummary { clauses }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::host_services::{
AgentApproval, AgentPosture, HostServiceLine, PersonVia, PluginCohort, ReviewPeer,
};
use crate::install::dsh::DshOutcome;
fn inventory() -> PluginInventory {
PluginInventory::default()
}
fn input<'a>(inventory: &'a PluginInventory, approver: &'a PluginApprover) -> SummaryInput<'a> {
SummaryInput {
name: "notes",
version: "1.2.0",
source: Some("github:acme/notes"),
enabled: true,
approver: Some(approver),
inventory,
dsh: None,
host_services: None,
extension_host_on: true,
previous: None,
downgrade_allowed: false,
}
}
fn person() -> PluginApprover {
PluginApprover::person(PersonVia::Terminal)
}
fn conversion() -> DshConversion {
DshConversion {
plugin_name: "dsh-whale-widget".into(),
source_package: Some("dsh-whale-widget".into()),
source_version: Some("0.4.2".into()),
manifest_sha256: "0".repeat(64),
layers: Vec::new(),
outcomes: Vec::new(),
diagnostics: Vec::new(),
skills: vec!["balance".into()],
remote_servers: vec!["account".into()],
local_servers: vec!["bridge".into(), "helper".into()],
network_hosts: vec!["api.example.com".into()],
requires_node: false,
requires_native: false,
native_rows: Vec::new(),
client_closures: Vec::new(),
}
}
fn skipped(kind: &str, reason: &str) -> DshOutcome {
DshOutcome {
row: Some("row".into()),
package: Some("pkg".into()),
kind: kind.into(),
outcome: "skipped".into(),
reason: reason.into(),
layer: None,
patch: None,
}
}
fn text_of(summary: &PluginSummary, id: &str) -> Option<String> {
summary
.clauses
.iter()
.find(|clause| clause.id == id)
.map(|clause| clause.text.clone())
}
#[test]
fn summary_plain_plugin_names_source_state_approver_and_adds() {
let inventory = PluginInventory {
skills: 2,
mcp_servers: 1,
stdio_mcp_servers: 1,
hooks: 1,
network_hosts: vec!["api.example.com".into()],
..inventory()
};
let approver = person();
let summary = build_summary(&input(&inventory, &approver));
assert_eq!(
summary.line(),
"notes 1.2.0 from github:acme/notes: on; approved by you; adds 2 skills, 1 local MCP server, 1 hook and network access (api.example.com)"
);
assert_eq!(
summary.ids(),
vec![MSG_HEAD, MSG_STATE_ON, MSG_APPROVER, MSG_ADDS]
);
}
#[test]
fn summary_off_plugin_and_each_approver_kind_read_distinctly() {
let inventory = inventory();
let agent = PluginApprover::Agent(AgentApproval {
posture: AgentPosture::AutoReview,
workspace: "/work".into(),
thread_id: None,
turn_id: None,
tool_call_id: None,
});
let mut request = input(&inventory, &agent);
request.enabled = false;
request.source = None;
let summary = build_summary(&request);
assert_eq!(
summary.line(),
"notes 1.2.0: off; approved by the agent in Auto-Review"
);
let mut request = input(&inventory, &agent);
request.approver = None;
assert!(
build_summary(&request)
.line()
.contains("approver not recorded")
);
}
#[test]
fn summary_dsh_package_counts_conversion_fields_and_panels() {
let mut dsh = conversion();
dsh.requires_node = true;
dsh.client_closures = vec![crate::install::dsh::DshClientClosure {
package: "dsh-whale-widget".into(),
root: String::new(),
entry: "source/client.js".into(),
platform: None,
inject: Vec::new(),
host_provided: Vec::new(),
files: Default::default(),
bytes: 10,
}];
let inventory = inventory();
let approver = person();
let mut request = input(&inventory, &approver);
request.dsh = Some(&dsh);
let summary = build_summary(&request);
assert_eq!(
text_of(&summary, MSG_ADDS).unwrap(),
"adds 1 skill, 2 local MCP servers, 1 remote MCP server, 1 panel and network access (api.example.com)"
);
assert_eq!(
text_of(&summary, MSG_NEEDS_NODE).unwrap(),
"needs Node.js for its local servers"
);
assert!(text_of(&summary, MSG_WONT_RUN).is_none());
}
#[test]
fn summary_refused_and_version_refused_rows_are_counted_with_reasons() {
let mut dsh = conversion();
dsh.outcomes = vec![
skipped("foreign", "no portable representation"),
skipped("mcp", "needs a manual port"),
DshOutcome {
outcome: "converted".into(),
..skipped("mcp", "")
},
];
let inventory = inventory();
let approver = person();
let mut request = input(&inventory, &approver);
request.dsh = Some(&dsh);
assert_eq!(
text_of(&build_summary(&request), MSG_WONT_RUN).unwrap(),
"won't run: 2 rows (each needs a manual port)"
);
dsh.outcomes = vec![skipped(
"patch",
"incompatible-version: this bundle requires DSH `^0.1.0-rc.6`",
)];
let mut request = input(&inventory, &approver);
request.dsh = Some(&dsh);
assert_eq!(
text_of(&build_summary(&request), MSG_WONT_RUN).unwrap(),
"won't run: 1 row (refused by the host version gate)"
);
dsh.outcomes
.push(skipped("foreign", "no portable representation"));
let mut request = input(&inventory, &approver);
request.dsh = Some(&dsh);
assert_eq!(
text_of(&build_summary(&request), MSG_WONT_RUN).unwrap(),
"won't run: 2 rows (1 refused by the host version gate, 1 with no portable form)"
);
}
#[test]
fn summary_native_rows_need_the_extension_host_only_while_it_is_off() {
let mut dsh = conversion();
dsh.requires_native = true;
dsh.native_rows = vec!["worker".into()];
let inventory = inventory();
let approver = person();
let mut request = input(&inventory, &approver);
request.dsh = Some(&dsh);
request.extension_host_on = false;
let off = build_summary(&request);
assert_eq!(
text_of(&off, MSG_NEEDS_EXTENSION_HOST).unwrap(),
"needs the extension host (off)"
);
assert!(
text_of(&off, MSG_ADDS)
.unwrap()
.contains("1 native extension")
);
request.extension_host_on = true;
assert!(text_of(&build_summary(&request), MSG_NEEDS_EXTENSION_HOST).is_none());
}
#[test]
fn summary_update_names_only_what_changed() {
let before = PluginInventory {
skills: 2,
..inventory()
};
let after = PluginInventory {
skills: 2,
hooks: 1,
network_hosts: vec!["api.example.com".into()],
..inventory()
};
let approver = person();
let mut request = input(&after, &approver);
request.previous = Some(PreviousInstall {
name: "notes",
version: "1.1.0",
source: Some("github:acme/notes"),
inventory: &before,
host_services: &[],
});
let summary = build_summary(&request);
assert_eq!(
text_of(&summary, MSG_NOW_ALSO_ADDS).unwrap(),
"now also adds 1 hook and network access (api.example.com)"
);
assert!(text_of(&summary, MSG_ADDS).is_none());
assert!(text_of(&summary, MSG_REPLACES).is_none());
assert!(text_of(&summary, MSG_DOWNGRADED).is_none());
request.previous = Some(PreviousInstall {
name: "notes",
version: "1.1.0",
source: Some("github:acme/notes"),
inventory: &after,
host_services: &[],
});
assert_eq!(
text_of(&build_summary(&request), MSG_ADDS_NOTHING_NEW).unwrap(),
"adds nothing new"
);
}
#[test]
fn summary_source_change_and_downgrade_are_stated() {
let inventory = inventory();
let approver = person();
let mut request = input(&inventory, &approver);
request.previous = Some(PreviousInstall {
name: "notes",
version: "1.4.0",
source: Some("github:old/notes"),
inventory: &inventory,
host_services: &[],
});
request.downgrade_allowed = true;
let summary = build_summary(&request);
assert_eq!(
text_of(&summary, MSG_REPLACES).unwrap(),
"replaces notes from github:old/notes"
);
assert_eq!(
text_of(&summary, MSG_DOWNGRADED).unwrap(),
"downgraded from 1.4.0"
);
request.downgrade_allowed = false;
assert!(text_of(&build_summary(&request), MSG_DOWNGRADED).is_none());
request.previous.as_mut().unwrap().source = None;
assert_eq!(
text_of(&build_summary(&request), MSG_REPLACES).unwrap(),
"replaces notes from an unrecorded source"
);
}
fn review(lines: Vec<HostServiceLine>) -> HostServiceReview {
HostServiceReview::new("user/0/notes", "c", "k", &PluginCohort::Reviewed, lines)
}
fn peer(name: &str) -> ReviewPeer {
ReviewPeer {
id: format!("user/0/{name}"),
name: name.into(),
}
}
#[test]
fn summary_host_services_state_sharing_withheld_and_the_pause() {
let inventory = inventory();
let approver = person();
let review = review(vec![
HostServiceLine {
service: HostService::Workspace,
offered: true,
shared_with: vec![peer("other")],
withheld_by: Vec::new(),
},
HostServiceLine {
service: HostService::Network,
offered: true,
shared_with: Vec::new(),
withheld_by: vec![peer("legacy")],
},
HostServiceLine {
service: HostService::Remote,
offered: false,
shared_with: Vec::new(),
withheld_by: Vec::new(),
},
]);
let mut request = input(&inventory, &approver);
request.host_services = Some(&review);
let summary = build_summary(&request);
assert_eq!(
text_of(&summary, MSG_SERVICES).unwrap(),
"uses host services workspace and network (shared with `other`)"
);
assert_eq!(
text_of(&summary, MSG_SERVICES_WITHHELD).unwrap(),
"off for now: network (`legacy` is turned on without it)"
);
assert_eq!(
text_of(&summary, MSG_SERVICES_UNOFFERED).unwrap(),
"this Codewhale does not offer remote to plugins"
);
assert_eq!(
text_of(&summary, MSG_SERVICES_PAUSE).unwrap(),
"services pause for every plugin in its host until the host is idle"
);
let before = [HostService::Workspace];
let prior = PluginInventory::default();
request.previous = Some(PreviousInstall {
name: "notes",
version: "1.1.0",
source: Some("github:acme/notes"),
inventory: &prior,
host_services: &before,
});
let update = build_summary(&request);
assert_eq!(
text_of(&update, MSG_SERVICES_NOW_ALSO).unwrap(),
"now also uses host services network"
);
assert!(text_of(&update, MSG_SERVICES).is_none());
let empty = self::review(Vec::new());
request.previous = None;
request.host_services = Some(&empty);
assert!(text_of(&build_summary(&request), MSG_SERVICES_PAUSE).is_none());
}
#[test]
fn summary_provider_declarations_say_they_apply_in_a_new_session() {
let inventory = PluginInventory {
providers: 1,
..inventory()
};
let approver = person();
let summary = build_summary(&input(&inventory, &approver));
assert_eq!(
text_of(&summary, MSG_PROVIDER_NEW_SESSION).unwrap(),
"provider changes apply in a new session"
);
assert!(text_of(&summary, MSG_ADDS).unwrap().contains("1 provider"));
}
#[test]
fn summary_long_host_lists_are_shortened() {
let inventory = PluginInventory {
network_hosts: ["a.io", "b.io", "c.io", "d.io", "e.io"]
.map(String::from)
.to_vec(),
..inventory()
};
let approver = person();
assert_eq!(
text_of(&build_summary(&input(&inventory, &approver)), MSG_ADDS).unwrap(),
"adds network access (a.io, b.io, c.io and 2 more)"
);
}
}
-1
View File
@@ -344,7 +344,6 @@ mod tests {
};
handle.record(Event::SessionStart {
source: crate::SessionSource::Unknown,
install_age: None,
});
let waiter = std::thread::spawn(move || handle.persist_local());
assert!(matches!(rx.try_recv(), Ok(Message::Event(_))));
+1 -4
View File
@@ -10,7 +10,7 @@
use std::sync::Mutex;
use std::sync::atomic::{AtomicU32, Ordering};
use crate::event::{ContextSize, Counters, Errors, Event, ModelCounters, TurnWall};
use crate::event::{Counters, Errors, Event, ModelCounters, TurnWall};
/// One session's accumulated counts.
#[derive(Debug, Default)]
@@ -48,7 +48,6 @@ struct ModelSlot {
model: String,
counters: ModelCounters,
turn_wall: TurnWall,
context: ContextSize,
}
/// At most this many distinct provider and model pairs per session; later
@@ -187,7 +186,6 @@ impl SessionCounters {
model,
counters: ModelCounters::default(),
turn_wall: TurnWall::default(),
context: ContextSize::default(),
});
models.len() - 1
}
@@ -240,7 +238,6 @@ impl SessionCounters {
model: slot.model.clone(),
counters: slot.counters,
turn_wall: slot.turn_wall,
context: slot.context,
}));
}
if let Ok(tools) = self.tools.lock() {
+1 -72
View File
@@ -6,7 +6,7 @@ use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::buffer;
use crate::event::{Arch, InstallAge, Libc, Os};
use crate::event::{Arch, Libc, Os};
/// How long an install id may live before it is replaced.
///
@@ -47,77 +47,6 @@ pub struct TelemetryState {
/// a permanently offline machine tries at most once per interval.
#[serde(default)]
pub last_flush: Option<String>,
/// The UTC day (`YYYY-MM-DD`) this state was first created. Local only: it
/// is read to pick an [`InstallAge`] bucket and is never sent.
#[serde(default)]
pub first_seen_day: Option<String>,
/// Set when this file predates `first_seen_day`, so its age is unknown and
/// is reported as the oldest bucket rather than as new.
#[serde(default)]
pub first_seen_unknown: bool,
}
enum StateRead {
Missing,
Unreadable,
Present(TelemetryState),
}
fn read_state_checked(root: &Path) -> StateRead {
match std::fs::read_to_string(buffer::state_path(root)) {
Ok(body) => serde_json::from_str::<TelemetryState>(&body)
.map_or(StateRead::Unreadable, StateRead::Present),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => StateRead::Missing,
Err(_) => StateRead::Unreadable,
}
}
/// Today's date in UTC.
#[must_use]
pub fn utc_today() -> chrono::NaiveDate {
chrono::Utc::now().date_naive()
}
/// Record the first-seen day if this state has none. A missing state file is a
/// new install; a file that has a recorded version but no day predates this
/// field and is marked unknown. An unreadable file is left untouched.
pub(crate) fn stamp_first_seen(root: &Path, today: chrono::NaiveDate) {
let mut state = match read_state_checked(root) {
StateRead::Unreadable => return,
StateRead::Missing => TelemetryState::default(),
StateRead::Present(state) => state,
};
if state.first_seen_day.is_some() || state.first_seen_unknown {
return;
}
if state.last_version.is_some() {
state.first_seen_unknown = true;
} else {
state.first_seen_day = Some(today.format("%Y-%m-%d").to_string());
}
let _ = write_state(root, &state);
}
/// The coarse age bucket for this installation, or `None` when the local state
/// cannot be read. A missing state is new.
pub(crate) fn install_age_at(root: &Path, today: chrono::NaiveDate) -> Option<InstallAge> {
let state = match read_state_checked(root) {
StateRead::Unreadable => return None,
StateRead::Missing => return Some(InstallAge::NewToday),
StateRead::Present(state) => state,
};
if state.first_seen_unknown {
return Some(InstallAge::Older);
}
let Some(day) = state.first_seen_day else {
return Some(InstallAge::NewToday);
};
let first = chrono::NaiveDate::parse_from_str(&day, "%Y-%m-%d").ok()?;
Some(match today.signed_duration_since(first).num_days() {
..=0 => InstallAge::NewToday,
1..=6 => InstallAge::UnderWeek,
_ => InstallAge::Older,
})
}
/// Read the install id, minting a fresh one if it is missing, unreadable,
+26 -122
View File
@@ -33,10 +33,10 @@ use serde::{Deserialize, Serialize};
/// Wire schema version. Bumped on any field add, remove, or retype; never
/// reused. `crates/telemetry/tests/golden/v1.json` pins what v1 was.
pub const SCHEMA_VERSION: u32 = 6;
pub const SCHEMA_VERSION: u32 = 4;
/// Default-on policy disclosure version. This is not human consent.
pub const NOTICE_VERSION: u32 = 8;
pub const NOTICE_VERSION: u32 = 6;
/// Which product surface produced a batch.
///
@@ -277,12 +277,6 @@ pub enum SessionSource {
/// Started under CI (`CI` or `GITHUB_ACTIONS` set), including the GitHub
/// Action, so automation is never counted as a person.
Ci,
/// `exec` started by Codewhale's own fleet dispatcher.
FleetWorker,
/// `exec` started from inside an agent's shell command.
Agent,
/// `exec` whose stdin or stdout is not a terminal.
Scripted,
}
impl SessionSource {
@@ -294,9 +288,6 @@ impl SessionSource {
Self::Api,
Self::Unknown,
Self::Ci,
Self::FleetWorker,
Self::Agent,
Self::Scripted,
];
/// The wire spelling.
@@ -309,37 +300,6 @@ impl SessionSource {
Self::Api => "api",
Self::Unknown => "unknown",
Self::Ci => "ci",
Self::FleetWorker => "fleet_worker",
Self::Agent => "agent",
Self::Scripted => "scripted",
}
}
}
/// How long this install has existed, as a coarse bucket. The first-seen date
/// itself never leaves the device.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InstallAge {
/// First seen on the current UTC day.
NewToday,
/// First seen 1 to 6 UTC days ago.
UnderWeek,
/// First seen 7 or more UTC days ago, or before the date was recorded.
Older,
}
impl InstallAge {
/// Every value, for exhaustive iteration.
pub const ALL: &'static [Self] = &[Self::NewToday, Self::UnderWeek, Self::Older];
/// The wire spelling.
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::NewToday => "new_today",
Self::UnderWeek => "under_week",
Self::Older => "older",
}
}
}
@@ -406,20 +366,11 @@ pub enum ExitClass {
Panic,
/// Exited non-successfully without a signal or a panic.
Error,
/// The process ended without its shutdown report; its aggregates were
/// recovered from a local checkpoint at a later start.
Unclean,
}
impl ExitClass {
/// Every value, for exhaustive iteration.
pub const ALL: &'static [Self] = &[
Self::Clean,
Self::Signal,
Self::Panic,
Self::Error,
Self::Unclean,
];
pub const ALL: &'static [Self] = &[Self::Clean, Self::Signal, Self::Panic, Self::Error];
/// The wire spelling.
#[must_use]
@@ -429,7 +380,6 @@ impl ExitClass {
Self::Signal => "signal",
Self::Panic => "panic",
Self::Error => "error",
Self::Unclean => "unclean",
}
}
@@ -441,7 +391,6 @@ impl ExitClass {
Self::Signal => 1,
Self::Panic => 2,
Self::Error => 3,
Self::Unclean => 4,
}
}
@@ -452,7 +401,6 @@ impl ExitClass {
1 => Self::Signal,
2 => Self::Panic,
3 => Self::Error,
4 => Self::Unclean,
_ => Self::Clean,
}
}
@@ -691,9 +639,6 @@ pub enum Event {
SessionStart {
/// How it was started.
source: SessionSource,
/// Coarse install age. `null` when the local state cannot be read.
#[serde(default)]
install_age: Option<InstallAge>,
},
/// A session ended. Everything the session accumulated ships here, once.
SessionEnd {
@@ -751,9 +696,6 @@ pub enum Event {
counters: ModelCounters,
/// Turn wall-clock buckets for these turns.
turn_wall: TurnWall,
/// Requests by input size.
#[serde(default)]
context: ContextSize,
},
/// One session's calls of one tool.
ToolUsage {
@@ -783,16 +725,6 @@ pub struct ModelCounters {
pub cache_read_tokens: u64,
/// Input tokens written to the provider's prompt cache.
pub cache_write_tokens: u64,
/// Provider requests on the session's own route for this model.
#[serde(default)]
pub requests: u32,
/// Reasoning tokens as the provider reports them; a subset of
/// `output_tokens`, which is not reduced.
#[serde(default)]
pub reasoning_tokens: u64,
/// Whole seconds spent waiting on the model, summed over the session.
#[serde(default)]
pub generation_secs: u32,
}
impl ModelCounters {
@@ -805,44 +737,9 @@ impl ModelCounters {
"output_tokens",
"cache_read_tokens",
"cache_write_tokens",
"requests",
"reasoning_tokens",
"generation_secs",
];
}
/// Per-session count of model requests by input size, in tokens including
/// cache reads. Half-open intervals; K is 1024. A histogram, never a
/// per-request series.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContextSize {
/// Requests with `[0, 32768)` input tokens.
pub lt_32k: u32,
/// Requests with `[32768, 131072)` input tokens.
#[serde(rename = "32k_128k")]
pub k32_to_k128: u32,
/// Requests with `[131072, 262144)` input tokens.
#[serde(rename = "128k_256k")]
pub k128_to_k256: u32,
/// Requests with 262144 or more input tokens.
pub gte_256k: u32,
}
impl ContextSize {
/// Field names in wire spelling, in declaration order.
pub const FIELDS: &'static [&'static str] = &["lt_32k", "32k_128k", "128k_256k", "gte_256k"];
/// Record one request with `tokens` input tokens.
pub fn observe(&mut self, tokens: u64) {
match tokens {
0..32_768 => self.lt_32k = self.lt_32k.saturating_add(1),
32_768..131_072 => self.k32_to_k128 = self.k32_to_k128.saturating_add(1),
131_072..262_144 => self.k128_to_k256 = self.k128_to_k256.saturating_add(1),
_ => self.gte_256k = self.gte_256k.saturating_add(1),
}
}
}
/// First-party tool names reported by `tool_usage`. Any other name is
/// reported as `mcp` or `other`, so a plugin's or server's tool name never
/// leaves the machine.
@@ -933,22 +830,17 @@ pub fn is_reported_tool_name(value: &str) -> bool {
value == "mcp" || value == "other" || TOOL_NAMES.contains(&value)
}
/// The `model` value for a model id: the lowercased canonical id when the
/// bundled model catalog lists it, otherwise `other`. Official aliases and
/// compatibility spellings are resolved first. Custom, local and fine-tuned
/// model names therefore never leave the machine.
/// The `model` value for a model id: the lowercased id when the bundled model
/// catalog lists it, otherwise `other`. Custom, local and fine-tuned model
/// names therefore never leave the machine.
#[must_use]
pub fn reported_model_id(model: &str) -> String {
use codewhale_config::catalog::reviewed::{compatibility_alias, official_deepseek_model_id};
let id = model.trim().to_ascii_lowercase();
let canonical = official_deepseek_model_id(&id)
.or_else(|| compatibility_alias("canonical_model_name", &id))
.map(str::to_ascii_lowercase);
canonical
.into_iter()
.chain(std::iter::once(id))
.find(|candidate| is_catalogued_model(candidate))
.unwrap_or_else(|| "other".to_string())
if is_catalogued_model(&id) {
id
} else {
"other".to_string()
}
}
/// Whether `value` may appear as `model_usage.model`.
@@ -958,9 +850,21 @@ pub fn is_reported_model_id(value: &str) -> bool {
}
fn is_catalogued_model(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 128
&& codewhale_config::catalog::reviewed::public_model_identifier_ignore_case(id)
if id.is_empty() || id.len() > 128 {
return false;
}
let catalog = codewhale_config::catalog::bundled_models_dev_catalog();
catalog.reviewed.intrinsic.contains_key(id)
|| catalog
.models
.keys()
.any(|key| key.eq_ignore_ascii_case(id))
|| catalog.providers.values().any(|provider| {
provider
.models
.keys()
.any(|key| key.eq_ignore_ascii_case(id))
})
}
impl Event {
+3 -12
View File
@@ -57,9 +57,9 @@ pub use decision::{
};
pub use envelope::reduce_panic_site;
pub use event::{
Arch, Batch, ColdStartBucket, ContextSize, Counters, DurationBucket, Errors, Event, ExitClass,
InstallAge, InstallKind, Libc, ModelCounters, NOTICE_VERSION, Os, ProductCounters,
SCHEMA_VERSION, SessionSource, Surface, TurnWall,
Arch, Batch, ColdStartBucket, Counters, DurationBucket, Errors, Event, ExitClass, InstallKind,
Libc, ModelCounters, NOTICE_VERSION, Os, ProductCounters, SCHEMA_VERSION, SessionSource,
Surface, TurnWall,
};
/// How long the shutdown flush may hold the process.
@@ -123,18 +123,9 @@ pub fn init(consent: TelemetryConsent) {
exit_class: AtomicU8::new(ExitClass::Clean.as_u8()),
});
envelope::stamp_first_seen(&root, envelope::utc_today());
record_install_or_upgrade(&root);
}
/// The coarse install-age bucket for this process's `session_start`, or `None`
/// when unarmed or when the local state cannot be read.
#[must_use]
pub fn install_age() -> Option<InstallAge> {
let armed = ARMED.get()?;
envelope::install_age_at(&armed.root, envelope::utc_today())
}
/// Note that this binary's version differs from the one last seen on this
/// machine, at most once per version.
///
+2 -4
View File
@@ -25,9 +25,7 @@ Codewhale counts: which version you run, OS and CPU family, session
duration and outcome, and aggregate feature and error counters.
It also counts the providers and public catalog models you use, their
token totals, request counts, time spent waiting on the model and
prompt-size ranges, how often each built-in tool runs, how an exec
session was started, and a coarse install-age range.
token totals, and how often each built-in tool runs.
It never collects your conversations, code, prompts, files, repo or
branch names, model content, or credentials — and it never sends a
@@ -43,7 +41,7 @@ Usage reporting is on by default. Codewhale and PostHog process these
counts when delivery is configured. No IP is collected.";
/// Concise disclosure for every armed runtime surface, including headless CLI.
pub const STARTUP_DISCLOSURE: &str = "Usage reporting is on by default: Codewhale and PostHog process aggregate version/platform, session, feature, error, model/token/request/wait-time/prompt-size, exec-start, install-age range and built-in tool counts when delivery is configured. No content or IP. Turn off: codewhale config set telemetry false. Details: codewhale config telemetry";
pub const STARTUP_DISCLOSURE: &str = "Usage reporting is on by default: Codewhale and PostHog process aggregate version/platform, session, feature, error, model/token and built-in tool counts when delivery is configured. No content or IP. Turn off: codewhale config set telemetry false. Details: codewhale config telemetry";
/// Present the policy once per revision without recording human acceptance.
/// Failure to save the display marker only causes a later repeat disclosure.
+11 -322
View File
@@ -121,12 +121,11 @@ fn every_event() -> Vec<Event> {
previous_version: Some("0.9.3-rc.1".to_string()),
},
Event::SessionStart {
source: SessionSource::FleetWorker,
install_age: Some(InstallAge::UnderWeek),
source: SessionSource::Resume,
},
Event::SessionEnd {
duration_bucket: DurationBucket::OneToTen,
exit_class: ExitClass::Unclean,
exit_class: ExitClass::Panic,
cold_start_bucket: Some(ColdStartBucket::Mid),
providers: vec!["custom".to_string(), "deepseek".to_string()],
counters: Counters {
@@ -184,9 +183,6 @@ fn every_event() -> Vec<Event> {
output_tokens: 9_120,
cache_read_tokens: 380_001,
cache_write_tokens: 0,
requests: 27,
reasoning_tokens: 2_310,
generation_secs: 412,
},
turn_wall: TurnWall {
lt_5s: 3,
@@ -194,12 +190,6 @@ fn every_event() -> Vec<Event> {
thirty_to_onetwenty: 2,
gte_120s: 0,
},
context: ContextSize {
lt_32k: 9,
k32_to_k128: 11,
k128_to_k256: 5,
gte_256k: 2,
},
},
Event::ToolUsage {
tool: "read_file".to_string(),
@@ -279,7 +269,6 @@ fn closed_enum_values() -> Vec<String> {
values.extend(Libc::ALL.iter().map(|v| v.as_str().to_string()));
values.extend(InstallKind::ALL.iter().map(|v| v.as_str().to_string()));
values.extend(SessionSource::ALL.iter().map(|v| v.as_str().to_string()));
values.extend(InstallAge::ALL.iter().map(|v| v.as_str().to_string()));
values.extend(DurationBucket::ALL.iter().map(|v| v.as_str().to_string()));
values.extend(ExitClass::ALL.iter().map(|v| v.as_str().to_string()));
values.extend(ColdStartBucket::ALL.iter().map(|v| v.as_str().to_string()));
@@ -1237,7 +1226,6 @@ fn probe_threshold_cannot_hide_an_over_cap_buffer() {
// only safe if `MAX_EVENTS` lines cannot fit under it.
let shortest = serde_json::to_string(&Event::SessionStart {
source: SessionSource::Api,
install_age: None,
})
.expect("serialize");
let floor = (shortest.len() as u64 + 1) * buffer::MAX_EVENTS as u64;
@@ -1458,7 +1446,6 @@ fn record_blocking_is_a_noop_when_unarmed() {
});
crate::record(Event::SessionStart {
source: SessionSource::Interactive,
install_age: None,
});
crate::set_exit_class(ExitClass::Panic);
assert_eq!(crate::exit_class(), ExitClass::Clean);
@@ -1672,7 +1659,6 @@ fn no_public_api_accepts_a_bare_bool() {
const TELEMETRY_DOC: &str = include_str!("../../../docs/TELEMETRY.md");
const GOLDEN_V4: &str = include_str!("../tests/golden/v4.json");
const GOLDEN_V6: &str = include_str!("../tests/golden/v6.json");
/// Extract the fenced ```jsonc blocks from the schema doc, in order.
fn jsonc_blocks(doc: &str) -> Vec<String> {
@@ -1878,12 +1864,13 @@ fn event_field_names_match_documented_schema() {
}
#[test]
fn golden_payload_v6() {
fn golden_payload_v4() {
assert_eq!(NOTICE_VERSION.to_string(), TELEMETRY_NOTICE_VERSION);
// `crates/telemetry/tests/golden/v6.json` is one fully-populated instance of
// the envelope and every event, and the batch the collector's own tests
// post. Any field add, remove, or retype fails here until the developer
// re-blesses it under a bumped `SCHEMA_VERSION`.
// `crates/telemetry/tests/golden/v4.json` is one fully-populated instance of
// the envelope and every event. Any field add, remove, or retype fails here
// until the developer re-blesses it under a bumped `SCHEMA_VERSION` — and it
// is also the artifact a future receiver author reads to know exactly what
// v1 was.
//
// Re-bless with: `CODEWHALE_BLESS_TELEMETRY_GOLDEN=1 cargo test -p codewhale-telemetry`
let batch = every_field_batch();
@@ -1891,81 +1878,22 @@ fn golden_payload_v6() {
batch.schema_version, SCHEMA_VERSION,
"the fixture must be built at the current schema version"
);
assert_eq!(SCHEMA_VERSION, 6);
let mut rendered = serde_json::to_string_pretty(&batch).expect("serialize");
rendered.push('\n');
if std::env::var("CODEWHALE_BLESS_TELEMETRY_GOLDEN").is_ok() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/golden/v6.json");
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/golden/v4.json");
std::fs::create_dir_all(path.parent().expect("parent")).expect("create golden dir");
std::fs::write(&path, &rendered).expect("write golden");
return;
}
assert_eq!(
rendered, GOLDEN_V6,
"the v6 payload changed; bump SCHEMA_VERSION and re-bless the golden file"
rendered, GOLDEN_V4,
"the v4 payload changed; bump SCHEMA_VERSION and re-bless the golden file"
);
}
#[test]
fn the_schema_4_golden_still_reads_back_with_the_new_fields_defaulted() {
let legacy: Batch = serde_json::from_str(GOLDEN_V4).expect("v4 golden parses");
assert_eq!(legacy.schema_version, 4);
for event in &legacy.events {
match event {
Event::SessionStart { install_age, .. } => assert_eq!(*install_age, None),
Event::ModelUsage {
counters, context, ..
} => {
assert_eq!(counters.requests, 0);
assert_eq!(counters.reasoning_tokens, 0);
assert_eq!(counters.generation_secs, 0);
assert_eq!(*context, ContextSize::default());
}
_ => {}
}
}
}
#[test]
fn context_buckets_are_half_open_at_32k_128k_and_256k() {
let mut size = ContextSize::default();
for tokens in [
0,
32_767,
32_768,
131_071,
131_072,
262_143,
262_144,
u64::MAX,
] {
size.observe(tokens);
}
assert_eq!(
size,
ContextSize {
lt_32k: 2,
k32_to_k128: 2,
k128_to_k256: 2,
gte_256k: 2,
}
);
let json = serde_json::to_value(size).expect("serialize");
let keys: Vec<&str> = json
.as_object()
.expect("object")
.keys()
.map(String::as_str)
.collect();
let mut expected: Vec<&str> = ContextSize::FIELDS.to_vec();
expected.sort_unstable();
let mut got = keys;
got.sort_unstable();
assert_eq!(got, expected);
}
#[test]
fn version_comparison_names_install_upgrade_and_downgrade() {
assert!(crate::version_is_older("0.9.3", "0.9.4"));
@@ -2066,242 +1994,3 @@ fn the_notice_summarizes_what_the_schema_collects_and_states_every_red_line() {
assert!(!body.contains("CODEWHALE_TELEMETRY=0"));
assert!(body.contains("docs/TELEMETRY.md"));
}
// ------------------------------------------------------------- model naming --
fn model_id_is_collector_shaped(id: &str) -> bool {
let bytes = id.as_bytes();
(1..=128).contains(&bytes.len())
&& bytes[0].is_ascii_lowercase() | bytes[0].is_ascii_digit()
&& bytes[1..].iter().all(|b| {
b.is_ascii_lowercase()
|| b.is_ascii_digit()
|| matches!(b, b'.' | b'_' | b':' | b'/' | b'@' | b'+' | b'-')
})
}
#[test]
fn reported_model_id_resolves_aliases_and_spellings() {
assert_eq!(reported_model_id("deepseek-v4-pro"), "deepseek-v4-pro");
assert_eq!(reported_model_id(" DeepSeek-V4-Pro "), "deepseek-v4-pro");
assert_eq!(reported_model_id("deepseek-v4pro"), "deepseek-v4-pro");
assert_eq!(
reported_model_id("deepseek-ai/deepseek-v4-pro"),
"deepseek-v4-pro"
);
assert_eq!(
reported_model_id("deepseek/deepseek-v4pro"),
"deepseek-v4-pro"
);
assert_eq!(reported_model_id("pro"), "deepseek-v4-pro");
assert_eq!(reported_model_id("flash"), "deepseek-flash");
assert_eq!(reported_model_id("auto"), "other");
assert_eq!(reported_model_id("my-private-finetune"), "other");
assert_eq!(reported_model_id(""), "other");
}
#[test]
fn reported_model_id_never_leaves_the_catalog() {
use codewhale_config::catalog::reviewed::{
bundled_reviewed, public_model_identifier_ignore_case,
};
let reviewed = bundled_reviewed();
let mut spellings: Vec<&str> = reviewed
.official_deepseek_aliases
.keys()
.chain(reviewed.go_aliases.keys())
.map(String::as_str)
.collect();
if let Some(group) = reviewed.compatibility_aliases.get("canonical_model_name") {
spellings.extend(group.keys().map(String::as_str));
}
assert!(spellings.len() > 20, "alias tables look empty");
for spelling in spellings {
let reported = reported_model_id(spelling);
assert!(is_reported_model_id(&reported), "{spelling} -> {reported}");
assert!(
model_id_is_collector_shaped(&reported),
"{spelling} -> {reported}"
);
}
for hostile in ["", "x/../y", "deepseek-v4-pro\n", "Ünï", &"a".repeat(300)] {
let reported = reported_model_id(hostile);
assert!(
reported == "other" || public_model_identifier_ignore_case(&reported),
"{hostile:?} -> {reported}"
);
}
}
#[test]
fn every_listed_model_and_provider_default_maps_to_a_catalogued_id() {
let catalog = codewhale_config::catalog::bundled_models_dev_catalog();
let mut missing = Vec::new();
let mut check = |origin: &str, id: &str| {
let reported = reported_model_id(id);
if reported == "other" || !model_id_is_collector_shaped(&reported) {
missing.push(format!("{origin}: {id}"));
}
};
for provider in catalog.providers.values() {
for (id, row) in &provider.models {
check("catalog", id);
check("catalog row", &row.id);
}
}
for id in catalog.models.keys() {
check("canonical", id);
}
let descriptors: Value = serde_json::from_str(
&std::fs::read_to_string(
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../config/assets/provider_descriptors.json"),
)
.expect("provider descriptors"),
)
.expect("descriptors json");
let exceptions: &[&str] = &["antigravity", "custom", "openai-compatible", "ollama"];
for provider in descriptors["providers"].as_array().expect("providers") {
let id = provider["id"].as_str().expect("provider id");
let selectable = provider["selectable"].as_bool().unwrap_or(false);
let default = provider["default_model"].as_str().unwrap_or_default();
if selectable && !default.is_empty() && !exceptions.contains(&id) {
check(&format!("default for {id}"), default);
}
}
assert!(
missing.is_empty(),
"ids the catalog does not name:\n{}",
missing.join("\n")
);
}
// -------------------------------------------------------------- install age --
fn day(text: &str) -> chrono::NaiveDate {
chrono::NaiveDate::parse_from_str(text, "%Y-%m-%d").expect("date")
}
fn root_with_state(state: &str) -> (tempfile::TempDir, PathBuf) {
let home = temp_home();
let root = root_of(&home);
buffer::ensure_dir(&root).expect("create telemetry dir");
std::fs::write(buffer::state_path(&root), state).expect("write state");
(home, root)
}
#[test]
fn a_fresh_home_is_new_today_and_ages_by_utc_day() {
let home = temp_home();
let root = root_of(&home);
buffer::ensure_dir(&root).expect("create telemetry dir");
assert_eq!(
envelope::install_age_at(&root, day("2026-11-13")),
Some(InstallAge::NewToday)
);
envelope::stamp_first_seen(&root, day("2026-11-13"));
for (today, expected) in [
("2026-11-13", InstallAge::NewToday),
("2026-11-14", InstallAge::UnderWeek),
("2026-11-16", InstallAge::UnderWeek),
("2026-11-19", InstallAge::UnderWeek),
("2026-11-20", InstallAge::Older),
("2027-06-01", InstallAge::Older),
("2026-11-12", InstallAge::NewToday),
] {
assert_eq!(
envelope::install_age_at(&root, day(today)),
Some(expected),
"{today}"
);
}
envelope::stamp_first_seen(&root, day("2026-12-25"));
assert_eq!(
envelope::read_state(&root).first_seen_day.as_deref(),
Some("2026-11-13"),
"a second stamp never moves the first-seen day"
);
}
#[test]
fn a_state_written_before_the_day_existed_reads_older_never_new() {
let (_home, root) = root_with_state(r#"{"schema_version":4,"last_version":"0.10.2"}"#);
envelope::stamp_first_seen(&root, day("2026-11-13"));
let state = envelope::read_state(&root);
assert_eq!(state.first_seen_day, None);
assert!(state.first_seen_unknown);
assert_eq!(state.last_version.as_deref(), Some("0.10.2"));
for today in ["2026-11-13", "2026-11-14", "2030-01-01"] {
assert_eq!(
envelope::install_age_at(&root, day(today)),
Some(InstallAge::Older),
"{today}"
);
}
}
#[test]
fn unreadable_or_hostile_state_gives_null_and_is_not_overwritten() {
for body in [
"not json",
r#"{"first_seen_day":"9999-99-99"}"#,
r#"{"first_seen_day":"yesterday"}"#,
] {
let (_home, root) = root_with_state(body);
envelope::stamp_first_seen(&root, day("2026-11-13"));
assert_eq!(
envelope::install_age_at(&root, day("2026-11-13")),
None,
"{body}"
);
assert_eq!(
std::fs::read_to_string(buffer::state_path(&root)).expect("state"),
body,
"{body}"
);
}
}
#[test]
fn opting_out_erases_the_age_and_rotating_the_install_id_keeps_it() {
let home = temp_home();
let root = root_of(&home);
buffer::ensure_dir(&root).expect("create telemetry dir");
envelope::stamp_first_seen(&root, day("2026-01-01"));
assert_eq!(
envelope::install_age_at(&root, day("2026-11-13")),
Some(InstallAge::Older)
);
std::fs::write(
buffer::install_id_path(&root),
r#"{"schema_version":1,"install_id":"3f2a9c1e-0000-4000-8000-000000000001","rotated_at":"2020-01-01T00:00:00Z"}"#,
)
.expect("write expired id");
let rotated = envelope::read_or_create_install_id(&root).expect("rotate");
assert_ne!(rotated.install_id, "3f2a9c1e-0000-4000-8000-000000000001");
assert_eq!(
envelope::read_state(&root).first_seen_day.as_deref(),
Some("2026-01-01"),
"rotation does not touch the age"
);
buffer::wipe(&root).expect("wipe");
std::fs::remove_file(buffer::tombstone_path(&root)).expect("re-enable");
assert_eq!(
envelope::install_age_at(&root, day("2026-11-13")),
Some(InstallAge::NewToday)
);
envelope::stamp_first_seen(&root, day("2026-11-13"));
assert_eq!(
envelope::read_state(&root).first_seen_day.as_deref(),
Some("2026-11-13")
);
}
#[test]
fn the_first_seen_day_never_reaches_the_wire() {
let wire = serde_json::to_string(&every_field_batch()).expect("serialize");
assert!(!wire.contains("first_seen"));
}
-127
View File
@@ -1,127 +0,0 @@
{
"schema_version": 6,
"notice_version": 8,
"sent_at": "2026-08-03T18:04:11Z",
"install_id": "3f2a9c1e-0000-4000-8000-000000000001",
"app_version": "0.9.4",
"git_sha": "abcdef012345",
"surface": "control-plane",
"os": "macos",
"arch": "aarch64",
"libc": "none",
"tty": true,
"events": [
{
"event": "install_or_upgrade",
"kind": "upgrade",
"previous_version": "0.9.3-rc.1"
},
{
"event": "session_start",
"source": "fleet_worker",
"install_age": "under_week"
},
{
"event": "session_end",
"duration_bucket": "1m_10m",
"exit_class": "unclean",
"cold_start_bucket": "250_1000",
"providers": [
"custom",
"deepseek"
],
"counters": {
"turns": 14,
"tool_calls": 61,
"fleet_dispatch": 0,
"workflow_run": 0,
"subagent_spawn": 2,
"mcp_server_connected": 0,
"memory_search": 0,
"approval_modal_shown": 0,
"approval_auto_allowed": 0,
"command_palette_open": 3
},
"errors": {
"auth_preflight_failed": 0,
"provider_http_4xx": 0,
"provider_http_5xx": 1,
"tool_denied_by_policy": 0,
"tool_timeout": 0,
"network_error": 0
},
"turn_wall": {
"lt_5s": 9,
"5_30s": 4,
"30_120s": 1,
"gte_120s": 0
}
},
{
"event": "panic",
"site": "crates/tui/src/tui/ui.rs:8801:17"
},
{
"event": "product_usage",
"counters": {
"page_view": 1,
"docs_view": 0,
"install_copy": 0,
"download": 0,
"signup": 0,
"login": 0,
"session_create": 0,
"session_resume": 0,
"turn_submit": 0,
"turn_complete": 0,
"settings_open": 0,
"integration_connect": 0,
"error_shown": 0
}
},
{
"event": "operations_summary",
"requests": 10,
"errors": 1,
"duration_ms_total": 1500,
"duration_ms_max": 300,
"probes": 2,
"probes_failed": 0
},
{
"event": "model_usage",
"provider": "deepseek",
"model": "other",
"counters": {
"turns_ok": 12,
"turns_failed": 1,
"turns_interrupted": 0,
"input_tokens": 418230,
"output_tokens": 9120,
"cache_read_tokens": 380001,
"cache_write_tokens": 0,
"requests": 27,
"reasoning_tokens": 2310,
"generation_secs": 412
},
"turn_wall": {
"lt_5s": 3,
"5_30s": 8,
"30_120s": 2,
"gte_120s": 0
},
"context": {
"lt_32k": 9,
"32k_128k": 11,
"128k_256k": 5,
"gte_256k": 2
}
},
{
"event": "tool_usage",
"tool": "read_file",
"calls": 41,
"errors": 2
}
]
}
+1 -4
View File
@@ -2515,10 +2515,7 @@ fn arm_telemetry_with_setup(
};
codewhale_telemetry::init(consent.with_config_path(Some(store.path().to_path_buf())));
let _ = TELEMETRY_SESSION_START.set(std::time::Instant::now());
codewhale_telemetry::record(codewhale_telemetry::Event::SessionStart {
source,
install_age: codewhale_telemetry::install_age(),
});
codewhale_telemetry::record(codewhale_telemetry::Event::SessionStart { source });
}
fn arm_telemetry(cli: &Cli, command: Option<&Commands>) {
+19 -59
View File
@@ -4,7 +4,7 @@
**Usage analytics are on by default in the current source.** The first
interactive launch gives a localized, nonblocking disclosure naming **Codewhale
and PostHog**, with a direct route to turn counting off. Notice version `8`
and PostHog**, with a direct route to turn counting off. Notice version `6`
describes this policy; displaying it is not recorded as human acceptance.
Missing an older acceptance record does not turn a fresh installation off.
Existing explicit opt-outs remain off across upgrades, and unreadable privacy
@@ -65,7 +65,7 @@ codewhale --telemetry false # the same kill switch, for one command
**`telemetry = false` in the config file is the opt-out.** It is a floor:
`--telemetry true` and `CODEWHALE_TELEMETRY=1` both lose to it, because a
setting you can undo by accident from a wrapper script is not a setting. It
deletes the random install id and the local first-seen day (so the install-age bucket starts over at `new_today` if you turn telemetry back on), truncates every buffered event and every dry-run
deletes the random install id, truncates every buffered event and every dry-run
record, and writes a tombstone. Appends, identity/state writes, and delivery all
share the wipe's ordering lock, so once opt-out returns no pre-opt-out write or
POST remains in flight. If any part of that wipe fails, the tombstone is still
@@ -104,7 +104,7 @@ Everything is under `$CODEWHALE_HOME/telemetry/` (`0700`), every file `0600`:
| `buffer.jsonl` | pending events, one JSON object per line |
| `buffer.jsonl.lock` | the sibling ordering lock shared by writes, delivery, arming, and wipe |
| `dryrun.jsonl` | where batches go when the endpoint is configured empty |
| `state.json` | the last app version seen, the last flush attempt, and the UTC day this state was first created. The date never leaves the device: only the `install_age` bucket is sent |
| `state.json` | the last app version seen and the last flush attempt |
| `install_id.json` | the random install id and when it was minted |
| `disabled` | the tombstone; present means nothing is appended or sent |
@@ -162,14 +162,10 @@ most once per flush point and never grows a queue.
## Event schema
`SCHEMA_VERSION = 6`. `notice_version = 8` identifies the disclosed default-on
policy; it does not assert human consent or that a notice was seen. Schema 6
adds, to `model_usage`, the request count, reasoning tokens, whole seconds spent
waiting on the model and a four-bucket input-size histogram; adds a coarse
install age and three exec start labels to `session_start`; and adds the
`unclean` exit class. Schema 4 added the `model_usage` and `tool_usage` events
and the `ci` session source. Schema-4 and schema-3 batches are still accepted
without the schema-6 fields. Schema-3 batches with `notice_version = 5` are still accepted without them, which
`SCHEMA_VERSION = 4`. `notice_version = 6` identifies the disclosed default-on
policy; it does not assert human consent or that a notice was seen. Schema 4
adds the `model_usage` and `tool_usage` events and the `ci` session source.
Schema-3 batches with `notice_version = 5` are still accepted without them, which
is what the web and desktop control-plane relay sends. The closed
v2 contract still accepts only `consent_version = 4`, retaining its original
explicit opt-in meaning. Fields cannot be mixed between versions. The unchanged
@@ -182,8 +178,8 @@ new fields, surfaces, and `product_usage` events.
```jsonc
{
"schema_version": 6,
"notice_version": 8,
"schema_version": 4,
"notice_version": 6,
"sent_at": "2026-08-03T18:04:11Z", // RFC3339 UTC, second precision
"install_id": "3f2a…", // uuid v4, rotates every 90 days
"app_version": "0.9.12",
@@ -200,7 +196,7 @@ new fields, surfaces, and `product_usage` events.
| Field | Type | Source anchor | Rule |
|---|---|---|---|
| `schema_version` | `u32` | const in `crates/telemetry/src/event.rs` | Bumped on any field add/remove/retype. Never reused. Pinned by a golden snapshot test. |
| `notice_version` | `u32` | policy constant in `crates/telemetry/src/event.rs` | Exactly `8` for schema 6; identifies the disclosed opt-out policy, not a human acceptance record. |
| `notice_version` | `u32` | policy constant in `crates/telemetry/src/event.rs` | Exactly `6` for schema 4; identifies the disclosed opt-out policy, not a human acceptance record. |
| `sent_at` | RFC3339 | `chrono::Utc::now()` | Second precision. Per-**batch** only — events carry no timestamps at all. |
| `install_id` | uuid v4 | `crates/telemetry/src/envelope.rs` | Random, never derived, rotated every 90 days. See "Where it lives" above. |
| `app_version` | string | `env!("CARGO_PKG_VERSION")`, as at `crates/telemetry/src/lib.rs:112` | Must match `^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$`. |
@@ -288,27 +284,14 @@ Emitted once when `state.json`'s `last_version` differs from `app_version`.
### Event: `session_start`
```jsonc
{ "event": "session_start", "source": "interactive", "install_age": "older" }
{ "event": "session_start", "source": "interactive" }
```
`source` is `SessionSource` (`crates/telemetry/src/event.rs`), a closed enum declared in the telemetry crate: `interactive | resume | fork | api | unknown | ci | fleet_worker | agent | scripted`.
`source` is `SessionSource` (`crates/state/src/lib.rs:34-41`) stringified by `session_source_to_str` (`:1909-1917`): `interactive | resume | fork | api | unknown | ci`.
`ci` replaces the others whenever `CI` or `GITHUB_ACTIONS` is set to a true
value, including the Codewhale GitHub Action, so automated runs are never
counted as people. Only that boolean environment fact is read.
`fleet_worker`, `agent` and `scripted` separate automated `exec` sessions from
people. `fleet_worker` is an `exec` that Codewhale's own fleet dispatcher
started, `agent` is an `exec` started from inside an agent's shell command (only
the presence of a Codewhale-owned environment variable is read, never its
value), and `scripted` is an `exec` whose standard input or output is not a
terminal. When more than one applies the order is `fleet_worker`, `agent`, `ci`,
`scripted`. Other surfaces are unchanged.
`install_age` is `new_today | under_week | older | null`: a coarse bucket of how
long this installation has existed. It is computed on your device and only the
bucket leaves it; the date it comes from is stored only in the local state file
and never leaves the device. The same UTC day is `new_today`, 1 to 6 days is `under_week`, and 7 or more is `older`. An installation whose state predates this field reports `older`, never `new_today`. `null` means the local state could not be read.
### Event: `session_end`
The workhorse. Everything a session accumulated ships here, once.
@@ -334,11 +317,11 @@ The workhorse. Everything a session accumulated ships here, once.
**`duration_bucket`** — `chrono` delta from `app.session_started_at` (`crates/tui/src/tui/app.rs:1889`). Half-open, seconds: `lt_1m` (`d < 60`), `1m_10m` (`60 ≤ d < 600`), `10m_60m` (`600 ≤ d < 3600`), `gt_60m` (`d ≥ 3600`).
**`exit_class`** — `clean | signal | panic | error | unclean`. `unclean` means the process ended without its own shutdown report and its totals were recovered from a local checkpoint at a later start. **Derived from an explicit `AtomicU8`, never from an exit code.** `RunTerminationReason::Canceled` maps to exit 130 (`crates/engine/src/core/runtime_contract/termination.rs:53`), the same value the signal task uses (`crates/tui/src/lib.rs:682`, 128+SIGINT), so a code-based derivation would report every Esc-cancelled turn as a signal. The atomic is set by the panic hook (`crates/tui/src/lib.rs:1582`), by the signal task (`:678-696`) before `std::process::exit`, and on the clean path from `RunTerminationReason::is_success()` (`crates/engine/src/core/runtime_contract/termination.rs:44-46`) — `error` otherwise. Exit codes remain insufficient: an approval-required exit (3) and a terminal exec failure (1) need their actual termination classification.
**`exit_class`** — `clean | signal | panic | error`. **Derived from an explicit `AtomicU8`, never from an exit code.** `RunTerminationReason::Canceled` maps to exit 130 (`crates/engine/src/core/runtime_contract/termination.rs:53`), the same value the signal task uses (`crates/tui/src/lib.rs:682`, 128+SIGINT), so a code-based derivation would report every Esc-cancelled turn as a signal. The atomic is set by the panic hook (`crates/tui/src/lib.rs:1582`), by the signal task (`:678-696`) before `std::process::exit`, and on the clean path from `RunTerminationReason::is_success()` (`crates/engine/src/core/runtime_contract/termination.rs:44-46`) — `error` otherwise. Exit codes remain insufficient: an approval-required exit (3) and a terminal exec failure (1) need their actual termination classification.
**`cold_start_bucket`** — from `startup_trace::elapsed_ms()`, which reads `PROCESS_START` directly and is independent of the startup summary's buffer clear (`crates/runtime/src/startup_trace.rs:39-45`). Boundaries: `lt_250`, `250_1000`, `1000_3000`, `gte_3000`. Absent on non-TUI surfaces.
**`providers`** — sorted, deduplicated array of `ProviderKind::as_str()` (`crates/config/src/provider_kind.rs:295`, a `&'static str` from a closed enum; `Custom` yields the literal `"custom"`). **The API takes `ProviderKind` by value, never `&str`.** Do not call `ProviderKind::parse` or `parse_config_identity` (`:300`, `:330`) — those are for config-table resolution. **Do not read** `provider_identity_for_persistence()` (`crates/tui/src/tui/app.rs:5049`), `provider_id_for_persistence()` (`:5058`), `ExecStreamMeta.provider_id` (`crates/app-server/src/exec_agent.rs`), or `PlannedTurnRoute.effective_provider_label` (`crates/engine/src/turn_route_plan.rs:189-193`) — all four return the customer's own `[providers.<name>]` table key when the route is Custom. This is the single most likely leak in the feature: it is one field away from the natural seam and `/status` already prints it (`crates/tui/src/commands/groups/config/status.rs:24-28`). **No model id outside the bundled public catalog is ever sent, for any provider** — `crates/runtime/src/safe_label.rs:11-15` documents that a model id can be a path, a URL, or a deployment id that is itself a credential. Those are sent as `other`; see `model_usage`.
**`providers`** — sorted, deduplicated array of `ProviderKind::as_str()` (`crates/config/src/provider_kind.rs:295`, a `&'static str` from a closed enum; `Custom` yields the literal `"custom"`). **The API takes `ProviderKind` by value, never `&str`.** Do not call `ProviderKind::parse` or `parse_config_identity` (`:300`, `:330`) — those are for config-table resolution. **Do not read** `provider_identity_for_persistence()` (`crates/tui/src/tui/app.rs:5049`), `provider_id_for_persistence()` (`:5058`), `ExecStreamMeta.provider_id` (`crates/app-server/src/exec_agent.rs`), or `PlannedTurnRoute.effective_provider_label` (`crates/engine/src/turn_route_plan.rs:189-193`) — all four return the customer's own `[providers.<name>]` table key when the route is Custom. This is the single most likely leak in the feature: it is one field away from the natural seam and `/status` already prints it (`crates/tui/src/commands/groups/config/status.rs:24-28`). **No model id is ever sent, for any provider** — `crates/runtime/src/safe_label.rs:11-15` documents that a model id can be a path, a URL, or a deployment id that is itself a credential.
**`counters`** — closed field set. Every bump happens at the **call site**, never inside a conditionally-entered handler:
@@ -460,13 +443,9 @@ to make defaults, and whether prompt caching is working.
"input_tokens": 418230,
"output_tokens": 9120,
"cache_read_tokens": 380001,
"cache_write_tokens": 0,
"requests": 27,
"reasoning_tokens": 2310,
"generation_secs": 412
"cache_write_tokens": 0
},
"turn_wall": { "lt_5s": 3, "5_30s": 8, "30_120s": 2, "gte_120s": 0 },
"context": { "lt_32k": 9, "32k_128k": 11, "128k_256k": 5, "gte_256k": 2 }
"turn_wall": { "lt_5s": 3, "5_30s": 8, "30_120s": 2, "gte_120s": 0 }
}
```
@@ -479,25 +458,6 @@ cache reads. Turn counts are by outcome, and `turn_wall` buckets each turn's
wall time as `session_end` does. A session reports at most 16 provider and model
pairs; later pairs are counted under `other`. No per-turn timeline is sent.
Schema 6 adds four aggregates to each row:
- `requests` counts the model requests this session made on its own route for
that provider and model.
- `reasoning_tokens` is the part of `output_tokens` the provider reports as
reasoning. `output_tokens` is not reduced, and the value is `0` when the
provider does not report it.
- `generation_secs` is the total whole seconds the session spent waiting on the
model for those requests. It is one sum for the session; no per-request
duration is ever sent.
- `context` counts requests by input size, in tokens including cache reads:
`lt_32k` is under 32768, `32k_128k` is 32768 up to 131072, `128k_256k` is
131072 up to 262144, and `gte_256k` is 262144 or more. The four buckets add up
to `requests`. Each edge belongs to the higher bucket. No per-request size is
sent.
Until a client fills them these values are `0`. A schema-4 row carries none of
these fields.
### Event: `tool_usage`
One event per tool a session called, sent with `session_end`. It is how
@@ -548,7 +508,7 @@ configured ingest may forward validated schema-v3 / notice-v5 batches and
legacy schema-v2 / consent-v4 batches to
PostHog's [batch capture API](https://posthog.com/docs/api/capture). The only
permitted origins are `https://us.i.posthog.com` and `https://eu.i.posthog.com`.
Schema-v1, schema-v4 and schema-v6 batches cannot reach that path in this source. PostHog receives the same bounded
Schema-v1 and schema-v4 batches cannot reach that path in this source. PostHog receives the same bounded
envelope and event fields, the batch timestamp as its event timestamp, and
`codewhale:<install_id>` as its anonymous `distinct_id`. Events are named
`codewhale_install_or_upgrade`, `codewhale_session_start`,
@@ -598,7 +558,7 @@ It prints the daily series, a 7-day trend over complete UTC days, the freshness
### What is never collected — the public red-line list
Prompts; completions; tool arguments; diffs; patches; file contents; filenames; absolute or relative paths; git remotes; repo names; branch names; workspace commit SHAs; memory entries; chat history; API keys, tokens, cookies, or `Authorization` headers (including any boolean asserting a key exists); model ids that are not in Codewhale's bundled public model catalog (custom, local, fine-tuned or private model names are sent only as `other`); custom provider table names; MCP server names, commands, URLs, or tool names; plugin and configured tool names; approval rule text; error message bodies; panic message text; per-event timestamps; per-request durations or sizes; environment variable values; CI vendor or job names; parent process names; hostnames; container ids; the date an installation was first seen (only the install-age bucket leaves the device); keystrokes; clipboard; screenshots; microphone; camera; location; and any third-party ad or analytics SDK — there are none in the runtime binary and none may be added.
Prompts; completions; tool arguments; diffs; patches; file contents; filenames; absolute or relative paths; git remotes; repo names; branch names; workspace commit SHAs; memory entries; chat history; API keys, tokens, cookies, or `Authorization` headers (including any boolean asserting a key exists); model ids that are not in Codewhale's bundled public model catalog (custom, local, fine-tuned or private model names are sent only as `other`); custom provider table names; MCP server names, commands, URLs, or tool names; plugin and configured tool names; approval rule text; error message bodies; panic message text; per-event timestamps; keystrokes; clipboard; screenshots; microphone; camera; location; and any third-party ad or analytics SDK — there are none in the runtime binary and none may be added.
Two named traps for the implementer. `crates/state/src/lib.rs` persists `git_sha`, `git_branch`, `git_origin_url`, `cwd`, and `path` on the threads table (`:93, :399, :653`): a payload builder that accepts a `Thread` or `ThreadMeta` and derives `Serialize` breaches the contract in one line. **Never derive `Serialize` over an existing state type** — build every telemetry struct from scratch with explicit fields. And `crates/core/src/lib.rs:1389-1398` is the one place in the tree where the word `telemetry` sits inside a JSON object next to `prompt`, `base_url`, and `has_api_key`. It is the object someone will copy. Do not.
@@ -607,7 +567,7 @@ Two named traps for the implementer. `crates/state/src/lib.rs` persists `git_sha
## Website funnel aggregates in the source candidate
The website extension uses `FUNNEL_SCHEMA_VERSION = 5` and notice version `7`.
The Rust runtime is on `SCHEMA_VERSION = 6`, notice `8`; existing browser
The Rust runtime remains on `SCHEMA_VERSION = 4`, notice `6`; existing browser
schema `3`, notice `5`, and legacy schemas `1` and `2` retain their original
contracts. Displaying a newer notice never overrides an opt-out or establishes
human acceptance. Schema 5 is first-party only and does not activate PostHog.
+7 -8
View File
@@ -15,9 +15,9 @@ hostname is the only way in.
The current Engine source defaults usage analytics on under notice version 6, with a
clear opt-out. Prior explicit declines remain off; a missing acceptance record
is not treated as consent. Schema 6 carries `notice_version: 8` and schema 4 carries `notice_version: 6`; schema 3
is not treated as consent. Schema 4 carries `notice_version: 6`; schema 3
uses notice 5, and legacy schema 2 retains its original explicit
`consent_version: 4` contract. Schema 1, 4 and 6 batches are first-party
`consent_version: 4` contract. Schema 1 and schema 4 batches are first-party
only in this source. Local validation is not proof this updated Worker has been deployed. A user who wants
to contact nobody sets `telemetry_endpoint = ""`, which writes batches to
`$CODEWHALE_HOME/telemetry/dryrun.jsonl` instead.
@@ -38,7 +38,7 @@ require separate operator approval; none is established by the local tests.
Validated schema 3 batches carrying `notice_version: 5` and legacy schema 2
batches carrying explicit `consent_version: 4` can reach the processor. Schema 1
and schema 4 and 6 batches remain first-party only. Capture uses
and schema 4 batches remain first-party only. Capture uses
`/batch/`, fixed anonymous/no-geo controls, no incoming headers, no cookies,
no redirects, no response parsing, no retries, and a 1.5-second timeout.
PostHog failure is isolated from the successful Analytics Engine write.
@@ -143,21 +143,20 @@ take the next free slot.
| `blob8` | `tty` — `'true'` \| `'false'` |
| `blob9` | `install_kind` (`install_or_upgrade` only) |
| `blob10` | `previous_version` (`install_or_upgrade` only) |
| `blob11` | `session_source` (`session_start` only; schema 6 adds `fleet_worker`, `agent`, `scripted`) |
| `blob12` | `duration_bucket` (`session_end`); `install_age` (`session_start`, schema 6 only: `new_today`, `under_week`, `older`, `''` for null or older schemas) |
| `blob13` | `exit_class` (`session_end` only; schema 6 adds `unclean`) |
| `blob11` | `session_source` (`session_start` only) |
| `blob12` | `duration_bucket` (`session_end` only) |
| `blob13` | `exit_class` (`session_end` only) |
| `blob14` | `cold_start_bucket` (`session_end` only; `''` on surfaces that do not measure it) |
| `blob15` | `providers`, comma-joined, already sorted and deduplicated; the one `provider` on `model_usage` |
| `blob16` | `panic_site` (`panic`) — a `crates/…` path or the literal `<dep>`; `model` (`model_usage`); `tool` (`tool_usage`) |
| `blob17` | `sent_at` — the *batch* timestamp. Events carry none. |
| `blob18` | `aggregate_counters` — closed JSON counts, for `product_usage` or `operations_summary` |
| `blob19` | `schema_version` |
| `blob20` | `privacy_version`: notice8 for v6, notice7 for the v5 website funnel, notice6 for v4, notice5 for v3, consent4 for v2, empty for v1; `blob19` keeps their meanings distinct |
| `blob20` | `privacy_version`: notice6 for v4, notice5 for v3, consent4 for v2, empty for v1; `blob19` keeps their meanings distinct |
| `double1..10` | `counters`: `turns`, `tool_calls`, `fleet_dispatch`, `workflow_run`, `subagent_spawn`, `mcp_server_connected`, `memory_search`, `approval_modal_shown`, `approval_auto_allowed`, `command_palette_open` |
| `double11..16` | `errors`: `auth_preflight_failed`, `provider_http_4xx`, `provider_http_5xx`, `tool_denied_by_policy`, `tool_timeout`, `network_error` |
| `double17..20` | `turn_wall`: `lt_5s`, `5_30s`, `30_120s`, `gte_120s` |
| `double1..11` on `model_usage` | `turns_ok`, `turns_failed`, `turns_interrupted`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, then `turn_wall` `lt_5s`, `5_30s`, `30_120s`, `gte_120s` |
| `double12..18` on `model_usage` | schema 6 only, `0` on v4 rows (`blob19` tells them apart): `requests`, `reasoning_tokens`, `generation_secs` (whole seconds, session sum, u32), then input-size request counts `lt_32k`, `32k_128k`, `128k_256k`, `gte_256k` |
| `double1..2` on `tool_usage` | `calls`, `errors` |
Columns not relevant to an event are `''` / `0`. `tty` is a blob because the 20
+5 -5
View File
@@ -13,7 +13,7 @@ export const CAVEATS = [
"Runtime install means first initialized enabled telemetry state, not all installations or new people. IDs rotate every 90 days and are removed on opt-out.",
"Native success is observed model turns_ok, not a first-ever primary or useful task. Missing usage, same-process child activity and late cancellation can affect it.",
"Dates are received batch dates rather than exact event times. Offline, opted-out and dropped deliveries are absent; ingest has no replay deduplication guarantee.",
"Native IDs with an observed CI, fleet worker, agent or scripted start are excluded in full because the aggregate schema cannot assign model counts to individual sessions.",
"Native IDs with an observed CI start are excluded in full because the aggregate schema cannot assign model counts to individual sessions.",
];
function instant(value, field) {
@@ -72,7 +72,7 @@ export function buildFunnelReport(input, { now = new Date() } = {}) {
const installs = new Map(); const successes = new Map(); const started = new Set(); const ci = new Set();
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
for (const row of input.native_rows) {
if (!uuid.test(row.install_id) || !NATIVE_SURFACES.includes(row.surface) || ![1, 2, 3, 4, 6].includes(count(row.schema_version, "schema_version"))) throw new Error("invalid native identity, surface or version");
if (!uuid.test(row.install_id) || !NATIVE_SURFACES.includes(row.surface) || ![1, 2, 3, 4].includes(count(row.schema_version, "schema_version"))) throw new Error("invalid native identity, surface or version");
const at = instant(fresh(row.received_at), "received_at");
if (!within(at, from, to)) throw new Error("native row outside coverage");
if (newest === null || at > newest) newest = at;
@@ -82,11 +82,11 @@ export function buildFunnelReport(input, { now = new Date() } = {}) {
if (!["install", "upgrade", "downgrade"].includes(row.install_kind)) throw new Error("invalid install kind");
if (row.install_kind === "install" && (!installs.has(row.install_id) || at < installs.get(row.install_id))) installs.set(row.install_id, at);
} else if (row.event === "session_start") {
if (!["interactive", "resume", "fork", "api", "unknown", "ci", "fleet_worker", "agent", "scripted"].includes(row.session_source)) throw new Error("invalid session source");
if (["ci", "fleet_worker", "agent", "scripted"].includes(row.session_source)) ci.add(row.install_id);
if (!["interactive", "resume", "fork", "api", "unknown", "ci"].includes(row.session_source)) throw new Error("invalid session source");
if (row.session_source === "ci") ci.add(row.install_id);
started.add(row.install_id);
} else if (row.event === "model_usage") {
if (![4, 6].includes(count(row.schema_version, "schema_version"))) throw new Error("model usage requires v4 or v6");
if (count(row.schema_version, "schema_version") !== 4) throw new Error("model usage requires v4");
if (count(row.turns_ok, "turns_ok") > 0) {
const observations = successes.get(row.install_id) ?? []; observations.push(at); successes.set(row.install_id, observations);
}
+4 -14
View File
@@ -24,14 +24,7 @@
*/
import type { Batch, Event } from "./schema";
import {
CONTEXT_FIELDS,
COUNTER_FIELDS,
ERROR_FIELDS,
MODEL_COUNTER_FIELDS,
MODEL_COUNTER_FIELDS_V6_EXTRA,
TURN_WALL_FIELDS,
} from "./schema";
import { COUNTER_FIELDS, ERROR_FIELDS, MODEL_COUNTER_FIELDS, TURN_WALL_FIELDS } from "./schema";
/** The subset of `AnalyticsEngineDataset` this Worker uses. */
export interface DataPointSink {
@@ -65,7 +58,7 @@ export const BLOB_COLUMNS = [
"install_kind", // blob9
"previous_version", // blob10
"session_source", // blob11
"duration_bucket", // blob12 session_end; install_age on session_start (schema 6)
"duration_bucket", // blob12
"exit_class", // blob13
"cold_start_bucket", // blob14
"providers", // blob15 comma-joined, already sorted and deduplicated
@@ -73,7 +66,7 @@ export const BLOB_COLUMNS = [
"sent_at", // blob17 the batch timestamp; events carry none
"aggregate_counters", // blob18 closed product/operations JSON count object
"schema_version", // blob19
"privacy_version", // blob20: v3 to v6 notice, v2 consent, empty on v1; blob19 disambiguates
"privacy_version", // blob20: v3/v4 notice, v2 consent, empty on v1; blob19 disambiguates
] as const;
/**
@@ -118,7 +111,6 @@ function toDataPoint(batch: Batch, event: Event): DataPoint {
break;
case "session_start":
blobs[10] = event.source;
blobs[11] = event.install_age ?? "";
break;
case "session_end":
blobs[11] = event.duration_bucket;
@@ -149,10 +141,8 @@ function toDataPoint(batch: Batch, event: Event): DataPoint {
doubles = [
...MODEL_COUNTER_FIELDS.map((field) => event.counters[field]),
...TURN_WALL_FIELDS.map((field) => event.turn_wall[field]),
...MODEL_COUNTER_FIELDS_V6_EXTRA.map((field) => event.counters[field] ?? 0),
...CONTEXT_FIELDS.map((field) => event.context?.[field] ?? 0),
...EMPTY_DOUBLES.slice(MODEL_COUNTER_FIELDS.length + TURN_WALL_FIELDS.length),
];
doubles = [...doubles, ...EMPTY_DOUBLES.slice(doubles.length)];
break;
case "tool_usage":
blobs[15] = event.tool;
+21 -77
View File
@@ -22,8 +22,6 @@ export const SCHEMA_VERSION = 4;
/** Notice-v5 batches without `model_usage`, `tool_usage` or the `ci` source. */
export const V3_SCHEMA_VERSION = 3;
export const V6_SCHEMA_VERSION = 6;
export const FUNNEL_SCHEMA_VERSION = 5;
export const FUNNEL_NOTICE_VERSION = 7;
export const FUNNEL_COUNTER_FIELDS = [
@@ -36,8 +34,6 @@ export const FUNNEL_COUNTER_FIELDS = [
/** Disclosed default-on policy, not a record of human acceptance. */
export const NOTICE_VERSION = 6;
export const V6_NOTICE_VERSION = 8;
/** The notice version schema v3 batches carry. */
export const V3_NOTICE_VERSION = 5;
@@ -150,11 +146,6 @@ export const SESSION_SOURCES = [
"ci",
] as const;
export const V6_ONLY_SESSION_SOURCES = ["fleet_worker", "agent", "scripted"] as const;
export const V6_SESSION_SOURCES = [...SESSION_SOURCES, ...V6_ONLY_SESSION_SOURCES] as const;
export const INSTALL_AGES = ["new_today", "under_week", "older"] as const;
/** `DurationBucket` wire spellings. */
export const DURATION_BUCKETS = [
"lt_1m",
@@ -166,8 +157,6 @@ export const DURATION_BUCKETS = [
/** `ExitClass::as_str`. */
export const EXIT_CLASSES = ["clean", "signal", "panic", "error"] as const;
export const V6_EXIT_CLASSES = [...EXIT_CLASSES, "unclean"] as const;
/** `ColdStartBucket` wire spellings. */
export const COLD_START_BUCKETS = [
"lt_250",
@@ -222,19 +211,6 @@ export const MODEL_COUNTER_FIELDS = [
"cache_write_tokens",
] as const;
export const MODEL_COUNTER_FIELDS_V6_EXTRA = [
"requests",
"reasoning_tokens",
"generation_secs",
] as const;
export const V6_MODEL_COUNTER_FIELDS = [
...MODEL_COUNTER_FIELDS,
...MODEL_COUNTER_FIELDS_V6_EXTRA,
] as const;
export const CONTEXT_FIELDS = ["lt_32k", "32k_128k", "128k_256k", "gte_256k"] as const;
/** `event::TOOL_NAMES` plus the `mcp` and `other` buckets. */
export const TOOL_NAMES = [
"agent", "apply_patch", "authenticate", "bash", "code_execution", "create_goal",
@@ -310,12 +286,6 @@ export const EVENT_FIELDS: Readonly<Record<string, readonly string[]>> = {
funnel_usage: ["event", "counters"],
};
export const V6_EVENT_FIELDS: Readonly<Record<string, readonly string[]>> = {
...EVENT_FIELDS,
session_start: ["event", "source", "install_age"],
model_usage: ["event", "provider", "model", "counters", "turn_wall", "context"],
};
/** Every event discriminant, for the doc-match test. */
export const EVENT_NAMES = Object.keys(EVENT_FIELDS);
@@ -460,7 +430,7 @@ export type FunnelCounters = Record<(typeof FUNNEL_COUNTER_FIELDS)[number], numb
export type Event =
| { event: "install_or_upgrade"; kind: string; previous_version: string | null }
| { event: "session_start"; source: string; install_age?: string | null }
| { event: "session_start"; source: string }
| {
event: "session_end";
duration_bucket: string;
@@ -478,10 +448,8 @@ export type Event =
event: "model_usage";
provider: string;
model: string;
counters: Record<(typeof MODEL_COUNTER_FIELDS)[number], number> &
Partial<Record<(typeof MODEL_COUNTER_FIELDS_V6_EXTRA)[number], number>>;
counters: Record<(typeof MODEL_COUNTER_FIELDS)[number], number>;
turn_wall: TurnWall;
context?: Record<(typeof CONTEXT_FIELDS)[number], number>;
}
| { event: "tool_usage"; tool: string; calls: number; errors: number }
| ({ event: "operations_summary" } & Record<(typeof OPERATIONS_FIELDS)[number], number>);
@@ -578,28 +546,13 @@ function u32Map(
return null;
}
function u32Fields(
value: Record<string, unknown>,
fields: readonly string[],
where: string,
): string | null {
for (const field of fields) {
const item = value[field];
if (typeof item !== "number" || !Number.isInteger(item) || item < 0 || item > U32_MAX) {
return `${where}.${field}: not a u32`;
}
}
return null;
}
function validateEvent(value: unknown, where: string, v6 = false): string | null {
function validateEvent(value: unknown, where: string): string | null {
if (!isPlainObject(value)) return `${where}: not an object`;
const name = value.event;
const eventFields = v6 ? V6_EVENT_FIELDS : EVENT_FIELDS;
if (typeof name !== "string" || !Object.hasOwn(eventFields, name)) {
if (typeof name !== "string" || !Object.hasOwn(EVENT_FIELDS, name)) {
return `${where}: unknown event discriminant`;
}
const keyError = keysExactly(value, eventFields[name], `${where}(${name})`);
const keyError = keysExactly(value, EVENT_FIELDS[name], `${where}(${name})`);
if (keyError) return keyError;
switch (name) {
@@ -622,15 +575,8 @@ function validateEvent(value: unknown, where: string, v6 = false): string | null
}
return null;
}
case "session_start": {
const sourceError = enumString(
value.source,
v6 ? V6_SESSION_SOURCES : SESSION_SOURCES,
`${where}.source`,
);
if (sourceError || !v6 || value.install_age === null) return sourceError;
return enumString(value.install_age, INSTALL_AGES, `${where}.install_age`);
}
case "session_start":
return enumString(value.source, SESSION_SOURCES, `${where}.source`);
case "session_end": {
const bucketError = enumString(
value.duration_bucket,
@@ -640,7 +586,7 @@ function validateEvent(value: unknown, where: string, v6 = false): string | null
if (bucketError) return bucketError;
const exitError = enumString(
value.exit_class,
v6 ? V6_EXIT_CLASSES : EXIT_CLASSES,
EXIT_CLASSES,
`${where}.exit_class`,
);
if (exitError) return exitError;
@@ -692,14 +638,9 @@ function validateEvent(value: unknown, where: string, v6 = false): string | null
if (typeof value.model !== "string" || !MODEL_RE.test(value.model)) {
return `${where}.model: not a catalogued model id`;
}
const counterError = v6
? countMap(value.counters, V6_MODEL_COUNTER_FIELDS, `${where}.counters`, Number.MAX_SAFE_INTEGER) ??
u32Fields(value.counters as Record<string, unknown>, ["requests", "generation_secs"], `${where}.counters`)
: countMap(value.counters, MODEL_COUNTER_FIELDS, `${where}.counters`, Number.MAX_SAFE_INTEGER);
return (
counterError ??
u32Map(value.turn_wall, TURN_WALL_FIELDS, `${where}.turn_wall`) ??
(v6 ? u32Map(value.context, CONTEXT_FIELDS, `${where}.context`) : null)
countMap(value.counters, MODEL_COUNTER_FIELDS, `${where}.counters`, Number.MAX_SAFE_INTEGER) ??
u32Map(value.turn_wall, TURN_WALL_FIELDS, `${where}.turn_wall`)
);
}
case "tool_usage":
@@ -734,12 +675,15 @@ export function validateBatch(value: unknown): Accepted | Rejection {
const legacy = value.schema_version === 1;
const explicitConsent = value.schema_version === 2;
const v3 = value.schema_version === V3_SCHEMA_VERSION;
const v4 = value.schema_version === SCHEMA_VERSION;
const funnel = value.schema_version === FUNNEL_SCHEMA_VERSION;
const v6 = value.schema_version === V6_SCHEMA_VERSION;
const preUsage = legacy || explicitConsent || v3;
if (!legacy && !explicitConsent && !v3 && !v4 && !funnel && !v6) {
const preUsage = value.schema_version !== SCHEMA_VERSION && !funnel;
if (
!legacy &&
!explicitConsent &&
value.schema_version !== SCHEMA_VERSION &&
!funnel &&
value.schema_version !== V3_SCHEMA_VERSION
) {
return { ok: false, reason: "batch.schema_version: unsupported" };
}
const fields = legacy ? LEGACY_ENVELOPE_FIELDS : explicitConsent ? V2_ENVELOPE_FIELDS : ENVELOPE_FIELDS;
@@ -751,7 +695,7 @@ export function validateBatch(value: unknown): Accepted | Rejection {
if (
!legacy &&
!explicitConsent &&
value.notice_version !== (funnel ? FUNNEL_NOTICE_VERSION : v6 ? V6_NOTICE_VERSION : preUsage ? V3_NOTICE_VERSION : NOTICE_VERSION)
value.notice_version !== (funnel ? FUNNEL_NOTICE_VERSION : preUsage ? V3_NOTICE_VERSION : NOTICE_VERSION)
) {
return { ok: false, reason: "batch.notice_version: current policy required" };
}
@@ -809,7 +753,7 @@ export function validateBatch(value: unknown): Accepted | Rejection {
return { ok: false, reason: "events: aggregate product/operations events require schema v2 or v3" };
}
if (preUsage && (event.event === "model_usage" || event.event === "tool_usage")) {
return { ok: false, reason: "events: model_usage and tool_usage require schema v4 or later" };
return { ok: false, reason: "events: model_usage and tool_usage require schema v4" };
}
if (preUsage && event.event === "session_start" && event.source === "ci") {
return { ok: false, reason: "events: the ci session source requires schema v4" };
@@ -818,7 +762,7 @@ export function validateBatch(value: unknown): Accepted | Rejection {
return { ok: false, reason: "events: operations_summary requires control-plane surface" };
}
}
const eventError = validateEvent(event, `events[${index}]`, v6);
const eventError = validateEvent(event, `events[${index}]`);
if (eventError) return { ok: false, reason: eventError };
}
+3 -3
View File
@@ -97,12 +97,12 @@ describe("additive website funnel contract", () => {
}
});
it("keeps the historical goldens and v6 accepted, with runtime v4 and v6 first-party only", async () => {
it("keeps all four historical goldens accepted and runtime v4 first-party only", async () => {
const fetch = vi.fn(); vi.stubGlobal("fetch", fetch);
for (const version of [1, 2, 3, 4, 6]) {
for (const version of [1, 2, 3, 4]) {
const batch = JSON.parse(readFileSync(new URL(`../../crates/telemetry/tests/golden/v${version}.json`, import.meta.url), "utf8"));
expect(validateBatch(batch).ok, `v${version}`).toBe(true);
if (version !== 4 && version !== 6) continue;
if (version !== 4) continue;
const { env, written } = harness();
expect((await worker.fetch(postJson(batch), { ...env, POSTHOG_HOST: "https://us.i.posthog.com", POSTHOG_PROJECT_TOKEN: "phc_local_test_fixture", POSTHOG_IP_SAFE_EGRESS_VERIFIED: "true" })).status).toBe(204);
expect(written).toHaveLength(batch.events.length);
+1 -147
View File
@@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest";
import worker from "../src/index";
import { INGEST_PATH } from "../src/route";
import { BLOB_COLUMNS, DOUBLE_COLUMNS } from "../src/datapoint";
import { MAX_BODY_BYTES, SURFACES, validateBatch } from "../src/schema";
import { MAX_BODY_BYTES, SURFACES } from "../src/schema";
import { goldenBatch, harness, post, postJson } from "./support";
describe("method and route", () => {
@@ -473,149 +473,3 @@ describe("failing closed and quiet", () => {
}
});
});
describe("schema 6", () => {
const golden = (version: number): Record<string, any> =>
JSON.parse(
readFileSync(
new URL(`../../crates/telemetry/tests/golden/v${version}.json`, import.meta.url),
"utf8",
),
);
const event = (batch: Record<string, any>, name: string) =>
batch.events.find((item: Record<string, any>) => item.event === name);
it("accepts every historical golden and the v6 golden", () => {
for (const version of [1, 2, 3, 4, 6]) {
expect(validateBatch(golden(version)).ok, `v${version}`).toBe(true);
}
});
it("answers 204 to the v4 and v6 goldens", async () => {
for (const version of [4, 6]) {
const { env, written } = harness();
expect((await worker.fetch(postJson(golden(version)), env)).status).toBe(204);
expect(written).toHaveLength(golden(version).events.length);
}
});
it("requires notice 8 on v6 and refuses the v4 notice", () => {
const batch = golden(6);
batch.notice_version = 6;
expect(validateBatch(batch).ok).toBe(false);
expect(validateBatch({ ...golden(4), notice_version: 8 }).ok).toBe(false);
});
it("refuses a v4 batch carrying any v6 key or value", () => {
const mutations: Array<(batch: Record<string, any>) => void> = [
(batch) => { event(batch, "model_usage").context = event(golden(6), "model_usage").context; },
(batch) => { event(batch, "model_usage").counters.requests = 1; },
(batch) => { event(batch, "model_usage").counters.reasoning_tokens = 1; },
(batch) => { event(batch, "model_usage").counters.generation_secs = 1; },
(batch) => { event(batch, "session_start").install_age = "older"; },
(batch) => { event(batch, "session_start").source = "fleet_worker"; },
(batch) => { event(batch, "session_start").source = "agent"; },
(batch) => { event(batch, "session_start").source = "scripted"; },
(batch) => { event(batch, "session_end").exit_class = "unclean"; },
];
for (const [index, mutate] of mutations.entries()) {
const batch = golden(4);
mutate(batch);
expect(validateBatch(batch).ok, `mutation ${index}`).toBe(false);
}
});
it("refuses a v6 batch missing or adding a key", () => {
const mutations: Array<(batch: Record<string, any>) => void> = [
(batch) => { delete event(batch, "model_usage").context; },
(batch) => { delete event(batch, "model_usage").context["32k_128k"]; },
(batch) => { event(batch, "model_usage").context.gte_512k = 0; },
(batch) => { delete event(batch, "model_usage").counters.requests; },
(batch) => { delete event(batch, "model_usage").counters.reasoning_tokens; },
(batch) => { delete event(batch, "model_usage").counters.generation_secs; },
(batch) => { event(batch, "model_usage").counters.generation_ms = 1; },
(batch) => { event(batch, "model_usage").model_family = "x"; },
(batch) => { delete event(batch, "session_start").install_age; },
(batch) => { event(batch, "session_start").extra = "x"; },
(batch) => { event(batch, "session_end").counters.requests = 1; },
];
for (const [index, mutate] of mutations.entries()) {
const batch = golden(6);
mutate(batch);
expect(validateBatch(batch).ok, `mutation ${index}`).toBe(false);
}
});
it("refuses open values where v6 has a closed set or a count", () => {
const mutations: Array<(batch: Record<string, any>) => void> = [
(batch) => { event(batch, "session_start").install_age = "2026-11-01"; },
(batch) => { event(batch, "session_start").install_age = "OLDER"; },
(batch) => { event(batch, "session_start").source = "github-actions"; },
(batch) => { event(batch, "session_end").exit_class = "killed"; },
(batch) => { event(batch, "model_usage").counters.requests = 4294967296; },
(batch) => { event(batch, "model_usage").counters.requests = 1.5; },
(batch) => { event(batch, "model_usage").counters.generation_secs = -1; },
(batch) => { event(batch, "model_usage").counters.generation_secs = 4294967296; },
(batch) => { event(batch, "model_usage").counters.generation_secs = 1794528251123; },
(batch) => { event(batch, "model_usage").counters.reasoning_tokens = "7"; },
(batch) => { event(batch, "model_usage").context.lt_32k = 4294967296; },
(batch) => { event(batch, "model_usage").context.lt_32k = "1"; },
(batch) => { event(batch, "model_usage").model = "/Users/someone/model"; },
];
for (const [index, mutate] of mutations.entries()) {
const batch = golden(6);
mutate(batch);
expect(validateBatch(batch).ok, `mutation ${index}`).toBe(false);
}
});
it("accepts a null install_age and every v6 source and exit class", () => {
for (const source of ["interactive", "resume", "fork", "api", "unknown", "ci", "fleet_worker", "agent", "scripted"]) {
for (const age of ["new_today", "under_week", "older", null]) {
const batch = golden(6);
event(batch, "session_start").source = source;
event(batch, "session_start").install_age = age;
expect(validateBatch(batch).ok, `${source}/${age}`).toBe(true);
}
}
for (const exit of ["clean", "signal", "panic", "error", "unclean"]) {
const batch = golden(6);
event(batch, "session_end").exit_class = exit;
expect(validateBatch(batch).ok, exit).toBe(true);
}
});
it("writes v6 model_usage after the unchanged eleven doubles, and leaves v4 rows zero there", async () => {
const v6 = harness();
await worker.fetch(postJson(golden(6)), v6.env);
const row = v6.written.find((point) => point.blobs[0] === "model_usage")!;
expect(row.doubles).toEqual([
12, 1, 0, 418230, 9120, 380001, 0,
3, 8, 2, 0,
27, 2310, 412,
9, 11, 5, 2,
0, 0,
]);
expect(row.blobs[BLOB_COLUMNS.indexOf("schema_version")]).toBe("6");
const v4 = harness();
await worker.fetch(postJson(golden(4)), v4.env);
const old = v4.written.find((point) => point.blobs[0] === "model_usage")!;
expect(old.doubles).toEqual([
12, 1, 0, 418230, 9120, 380001, 0,
3, 8, 2, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0,
]);
});
it("writes install_age to blob12 of session_start rows only", async () => {
const { env, written } = harness();
await worker.fetch(postJson(golden(6)), env);
const start = written.find((point) => point.blobs[0] === "session_start")!;
expect(start.blobs[BLOB_COLUMNS.indexOf("session_source")]).toBe("fleet_worker");
expect(start.blobs[BLOB_COLUMNS.indexOf("duration_bucket")]).toBe("under_week");
const end = written.find((point) => point.blobs[0] === "session_end")!;
expect(end.blobs[BLOB_COLUMNS.indexOf("exit_class")]).toBe("unclean");
expect(end.blobs[BLOB_COLUMNS.indexOf("duration_bucket")]).toBe("1m_10m");
});
});
@@ -49,19 +49,6 @@ describe("existing-pipeline owner funnel report", () => {
expect(buildFunnelReport(input, { now: NOW }).native.seven_day_initialization_cohort.unavailable_reason).toContain("incomplete");
});
it("excludes fleet worker, agent and scripted starts like CI, and accepts v6 model usage", () => {
for (const source of ["fleet_worker", "agent", "scripted"]) {
const input = receipt();
input.native_rows.push({ ...native("session_start", "2026-10-04T00:00:00Z"), session_source: source });
input.native_rows.push({ ...native("model_usage", "2026-10-04T00:00:00Z"), schema_version: 6 });
const report = buildFunnelReport(input, { now: NOW });
expect(report.native.excluded_ci_ids, source).toBe(1);
expect(report.native.observed_completed_ids, source).toBe(0);
}
const input = receipt(); input.native_rows.push({ ...native("model_usage", "2026-10-04T00:00:00Z"), schema_version: 5 });
expect(() => buildFunnelReport(input, { now: NOW })).toThrow(/version/);
});
it("does not compute a cohort fraction from sampled native records", () => {
const input = receipt(); input.native_rows[0].weight = 4;
expect(buildFunnelReport(input, { now: NOW }).native.seven_day_initialization_cohort).toMatchObject({ fraction: null, sampled: true });
+4 -21
View File
@@ -20,27 +20,23 @@ import {
BATCH_MAX_BYTES,
BATCH_MAX_EVENTS,
COLD_START_BUCKETS,
CONTEXT_FIELDS,
COUNTER_FIELDS,
DURATION_BUCKETS,
ENVELOPE_FIELDS,
ERROR_FIELDS,
EVENT_FIELDS,
EVENT_NAMES,
INSTALL_AGES,
EXIT_CLASSES,
INSTALL_KINDS,
LIBCS,
MAX_BODY_BYTES,
OSES,
PRODUCT_COUNTER_FIELDS,
OPERATIONS_FIELDS,
SCHEMA_VERSION,
SESSION_SOURCES,
SURFACES,
TURN_WALL_FIELDS,
V6_EVENT_FIELDS as EVENT_FIELDS,
V6_EXIT_CLASSES as EXIT_CLASSES,
V6_MODEL_COUNTER_FIELDS,
V6_NOTICE_VERSION,
V6_SCHEMA_VERSION as SCHEMA_VERSION,
V6_SESSION_SOURCES as SESSION_SOURCES,
} from "../src/schema";
import { DOC, goldenBatch } from "./support";
@@ -172,16 +168,6 @@ describe("the doc and the validator publish the same fields", () => {
expect([...documentedKeys(BLOCKS[3])].sort()).toEqual(expected);
});
it("agrees on the model_usage field set, nested objects included", () => {
const expected = [
...EVENT_FIELDS.model_usage,
...V6_MODEL_COUNTER_FIELDS,
...TURN_WALL_FIELDS,
...CONTEXT_FIELDS,
].sort();
expect([...documentedKeys(BLOCKS[7])].sort()).toEqual([...new Set(expected)].sort());
});
it("agrees on the panic field set", () => {
expect([...documentedKeys(BLOCKS[4])].sort()).toEqual(
[...EVENT_FIELDS.panic].sort(),
@@ -230,7 +216,6 @@ describe("the doc and the validator publish the same enums", () => {
["session source", SESSION_SOURCES],
["duration bucket", DURATION_BUCKETS],
["exit class", EXIT_CLASSES],
["install age", INSTALL_AGES],
["cold start bucket", COLD_START_BUCKETS],
] as const)("publishes every %s value it accepts", (_label, values) => {
for (const value of values) {
@@ -247,8 +232,6 @@ describe("the doc and the validator publish the same limits", () => {
expect(match).not.toBeNull();
expect(Number((match as RegExpMatchArray)[1])).toBe(SCHEMA_VERSION);
expect(goldenBatch().schema_version).toBe(1); // preserved legacy golden
const notice = DOC.match(/`notice_version\s*=\s*(\d+)`/);
expect(Number((notice as RegExpMatchArray)[1])).toBe(V6_NOTICE_VERSION);
});
it("agrees on the per-batch caps", () => {
-10
View File
@@ -2,16 +2,6 @@
## Unreleased — 0.2.0
- Add data displays: `DataSparkline`, `DataBars` and `DataTrend` (a braille
line chart). The words carry every value; the ink draws in from a caller
progress (`MotionStep::drawn`) and is at its end state under reduced or
still motion. Bars glide from their previous values.
- Add `DataDisplay`, the same displays as JSON an agent can send:
`DataDisplay::from_json` validates it (finite numbers, at most 1024 points
or 24 bars, labels up to 120 characters), `describe` says the same facts as
plain text, and `DATA_DISPLAY_SCHEMA` is its tool schema. Try
`cargo run --example data`.
- Reexport the shared avatar contract from Codewhale Protocol. Rendering and
artwork stay in this library. Protocol 0.10.3 registry proof must precede
this package publication.
-1
View File
@@ -57,7 +57,6 @@ Gallery names are the exact names accepted by the gallery example; the
| Engine `agent_roster.rs::render_agent_roster`, `widgets/workflow_panel.rs::row_receipt_text`, `gate_receipts.rs`; GPUI `usage.rs::TurnRow`, `workbar.rs::Readout`, `working_context.rs::ExecutionReceipt` | `Receipt`, `ReceiptTable`, `ReceiptValue`, `Cost`, formatting helpers | Primitive/composite | `receipt-row`, `receipt-table`, `receipt-table-compact`, `receipt-table-minimal`, `receipt-table-clipped` |
| Engine `diff_render.rs::BoundedDiffRender`, `render_diff_bounded`, `history/file_mutation.rs`; GPUI `review.rs::DiffRowKind`, `workspace/render/review.rs::ReviewRows` | `Diff`, `DiffLine`, `DiffKind`, `DiffGutter`, `DiffHighlight`, `parse_unified` | Primitive | `diff`, `diff-wrapped`, `diff-no-numbers`, `diff-highlighted`, `review-scene` |
| Engine `widgets/workflow_panel.rs::WorkflowPanel`, `WorkflowPanelRow`, `history/checklist.rs`; GPUI `plans.rs`, `workspace/render/workers.rs` | `WorkflowTree`, `TreeNode`, `TreeState`, `CountBar` | Primitive/composite | `workflow-tree`, `workflow-tree-selected`, `workflow-tree-clipped`, `workflow-tree-long`, `count-bars` |
| Kit-native; data from the host or an agent's JSON (CW-8202) | `DataSparkline`, `DataBars`, `DataBarItem`, `DataTrend`, `DataDisplay`, `DataDisplayView`, `DataWords`, `DATA_DISPLAY_SCHEMA`, `MotionStep::drawn` | Primitive | `data-sparkline`, `data-bars`, `data-bars-growing`, `data-trend`, `data-trend-drawing`, `data-from-agent` |
| Engine `approval/view.rs::ApprovalView`, `approval/elevation.rs::ElevationView`, `auto_review.rs`; GPUI `workspace/render/conversation.rs::Workspace::render_attention`, `workspace/render/review.rs::Workspace::render_restore_confirmation` | `ApprovalCard`, `ApprovalSubject`, `ApprovalState`, `ApprovalChoice`, `ApprovalPaint`, `DecisionBand`, `DecisionBandPlan`, `DecisionBandAction`, `DecisionBandSave`, `ReviewVerdict`, `ReviewAggregate` | Composite | `approval-command`, `approval-outside`, `approval-patch`, `approval-elevation`, `approval-clipped`, `approval-spoofed`, `approval-native-band`, `approval-native-band-collapsed`, `review-verdicts`, `review-aggregate` |
| GPUI `workspace/render/conversation.rs::Workspace::render_attention`, `workspace/render/files.rs::Workspace::render_artifact_column` | `AttentionQueue`, `AttentionItem`, `ArtifactShelf`, `Artifact` | Composite | `attention-queue`, `attention-focused`, `attention-narrow`, `attention-empty`, `artifact`, `artifact-shelf`, `artifact-narrow`, `artifact-unknown`, `artifact-empty` |
| Engine `views/mod.rs::ConfigView`, `ConfigRow`, `ConfigView::render_setting_detail`; GPUI `settings.rs::Spec`, `Applies`, `workspace/settings/mod.rs::Settings` | `SettingRow`, `SettingDetail`, `SettingWords` | Composite | `setting-row`, `setting-detail` |
-276
View File
@@ -1,276 +0,0 @@
//! Live data displays: `cargo run --example data`.
//!
//! A sparkline takes a new value every 400 ms, the trend draws in, and the
//! bars glide to new values every three seconds. r replays, m switches
//! between full and reduced motion, q or Escape exits.
//!
//! `--json FILE` shows one agent-sent [`DataDisplay`] instead.
//! `--text` prints the final frame and each display's plain description.
//! `--svg DIR` writes frames at fixed draw-in points.
use std::{
io,
path::PathBuf,
time::{Duration, Instant},
};
use codewhale_ratatui::{
DataBarItem, DataDisplay, DataWords, MotionEasing, MotionMode, MotionPolicy, MotionStep,
MotionTiming, Paint, Role, Theme,
testing::{self, Profile},
};
use crossterm::{
event::{self, Event, KeyCode, KeyEventKind},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{Terminal, backend::CrosstermBackend, buffer::Buffer, layout::Rect};
const TICK: Duration = Duration::from_millis(400);
const RESHUFFLE: Duration = Duration::from_secs(3);
const DRAW_IN: MotionTiming =
MotionTiming::space(Duration::from_millis(1_400), MotionEasing::EaseOut);
const GLIDE: MotionTiming = MotionTiming::PANEL;
struct Noise(u64);
impl Noise {
fn next(&mut self) -> f64 {
self.0 = self
.0
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1);
(self.0 >> 33) as f64 / f64::from(1u32 << 31)
}
}
struct Board {
noise: Noise,
requests: Vec<f64>,
latency: Vec<f64>,
bars: DataDisplay,
bars_before: DataDisplay,
draw: MotionStep,
glide: MotionStep,
agent: Option<DataDisplay>,
}
fn crate_bars(noise: &mut Noise) -> DataDisplay {
DataDisplay::Bars {
label: "Tests by crate".into(),
unit: "tests".into(),
items: ["engine", "tui", "runtime", "protocol", "config"]
.into_iter()
.map(|name| DataBarItem::new(name, (40.0 + noise.next() * 400.0).round()))
.collect(),
}
}
impl Board {
fn new(agent: Option<DataDisplay>) -> Self {
let mut noise = Noise(7);
let latency = (0..96)
.map(|i| 80.0 + 45.0 * (f64::from(i) / 9.0).sin() + noise.next() * 30.0)
.collect();
let requests = (0..40).map(|_| 20.0 + noise.next() * 60.0).collect();
let bars = crate_bars(&mut noise);
Self {
noise,
requests,
latency,
bars_before: DataDisplay::Bars {
label: String::new(),
unit: String::new(),
items: Vec::new(),
},
bars,
draw: MotionStep::at_rest(0.0),
glide: MotionStep::at_rest(1.0),
agent,
}
}
fn replay(&mut self, now: Instant, policy: MotionPolicy) {
self.draw = MotionStep::at_rest(0.0);
self.draw.go(1.0, now, DRAW_IN, policy);
}
fn tick(&mut self) {
let last = self.requests.last().copied().unwrap_or(50.0);
let next = (last + (self.noise.next() - 0.5) * 30.0).clamp(5.0, 120.0);
self.requests.push(next.round());
if self.requests.len() > 200 {
self.requests.remove(0);
}
}
fn reshuffle(&mut self, now: Instant, policy: MotionPolicy) {
self.bars_before = std::mem::replace(&mut self.bars, crate_bars(&mut self.noise));
self.glide = MotionStep::at_rest(0.0);
self.glide.go(1.0, now, GLIDE, policy);
}
fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme, draw: f32, glide: f32) {
buf.set_style(area, theme.bg(Role::Background));
let inner = Rect::new(
area.x + 2,
area.y + 1,
area.width.saturating_sub(4),
area.height.saturating_sub(2),
);
if let Some(agent) = &self.agent {
agent.view().drawn(draw).paint(inner, buf, theme);
return;
}
let row =
|y: u16, h: u16| Rect::new(inner.x, inner.y + y, inner.width, h).intersection(inner);
let requests = DataDisplay::Sparkline {
label: "Requests".into(),
unit: "rpm".into(),
values: self.requests.clone(),
};
let latency = DataDisplay::Trend {
label: "Latency".into(),
unit: "ms".into(),
values: self.latency.clone(),
rows: Some(9),
};
requests.view().drawn(draw).paint(row(0, 1), buf, theme);
latency.view().drawn(draw).paint(row(2, 9), buf, theme);
self.bars
.view()
.from(&self.bars_before)
.drawn(glide.min(draw))
.paint(row(12, 6), buf, theme);
}
fn displays(&self) -> Vec<DataDisplay> {
if let Some(agent) = &self.agent {
return vec![agent.clone()];
}
vec![
DataDisplay::Sparkline {
label: "Requests".into(),
unit: "rpm".into(),
values: self.requests.clone(),
},
DataDisplay::Trend {
label: "Latency".into(),
unit: "ms".into(),
values: self.latency.clone(),
rows: Some(9),
},
self.bars.clone(),
]
}
}
fn main() -> io::Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
let value = |flag: &str| {
args.iter()
.position(|a| a == flag)
.and_then(|i| args.get(i + 1))
.cloned()
};
let agent = match value("--json") {
Some(path) => {
let json = std::fs::read_to_string(&path)?;
match DataDisplay::from_json(&json) {
Ok(shown) => Some(shown),
Err(e) => {
eprintln!("{path}: {e}");
std::process::exit(2);
}
}
}
None => None,
};
let board = Board::new(agent);
if args.iter().any(|a| a == "--text") {
let buf = testing::render(84, 20, |area, buf| {
board.paint(area, buf, &Profile::DarkTrue.theme(), 1.0, 1.0);
});
println!("{}", testing::text(&buf));
for shown in board.displays() {
println!("{}", shown.describe(&DataWords::default()));
}
return Ok(());
}
if let Some(dir) = value("--svg") {
let dir = PathBuf::from(dir);
std::fs::create_dir_all(&dir)?;
let theme = Profile::DarkTrue.theme();
for (i, at) in [0.2_f32, 0.5, 0.8, 1.0].into_iter().enumerate() {
let buf = testing::render(84, 20, |area, buf| board.paint(area, buf, &theme, at, at));
let path = dir.join(format!("data-{i}.svg"));
std::fs::write(&path, testing::svg(&buf, &theme))?;
println!("{}", path.display());
}
return Ok(());
}
run(board)
}
fn run(mut board: Board) -> io::Result<()> {
enable_raw_mode()?;
let mut out = io::stdout();
execute!(out, EnterAlternateScreen)?;
let mut terminal = Terminal::new(CrosstermBackend::new(out))?;
let theme = Theme::detect().tui();
let mut mode = MotionMode::Full;
let mut policy = MotionPolicy::new(mode, &theme);
let start = Instant::now();
board.replay(start, policy);
let (mut next_tick, mut next_shuffle) = (start + TICK, start + RESHUFFLE);
let result = loop {
let now = Instant::now();
if now >= next_tick {
board.tick();
next_tick = now + TICK;
}
if now >= next_shuffle {
board.reshuffle(now, policy);
next_shuffle = now + RESHUFFLE;
}
let (draw, glide) = (
board.draw.drawn(now, policy),
board.glide.drawn(now, policy),
);
terminal.draw(|f| board.paint(f.area(), f.buffer_mut(), &theme, draw, glide))?;
let due = [
board.draw.next_frame_at(now, policy),
board.glide.next_frame_at(now, policy),
]
.into_iter()
.flatten()
.chain([next_tick, next_shuffle])
.min()
.unwrap_or(next_tick);
if event::poll(due.saturating_duration_since(Instant::now()))?
&& let Event::Key(key) = event::read()?
&& key.kind != KeyEventKind::Release
{
match key.code {
KeyCode::Char('q') | KeyCode::Esc => break Ok(()),
KeyCode::Char('r') => board.replay(Instant::now(), policy),
KeyCode::Char('m') => {
mode = if mode == MotionMode::Full {
MotionMode::Reduced
} else {
MotionMode::Full
};
policy = MotionPolicy::new(mode, &theme);
}
_ => {}
}
}
};
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
result
}
File diff suppressed because it is too large. Load diff
-2
View File
@@ -59,12 +59,10 @@ pub use tabs::*;
pub use toggle::*;
// Package Display.
mod chart;
mod diff;
mod progress;
mod receipt;
mod tree;
pub use chart::*;
pub use diff::*;
pub use progress::*;
pub use receipt::*;
-8
View File
@@ -366,14 +366,6 @@ impl MotionStep {
}
}
/// Progress for a draw-in, in `0..=1`: eased where `policy` lets space
/// move, the target where it does not. Feed it to a data display's
/// `drawn`.
#[must_use]
pub fn drawn(&self, now: Instant, policy: MotionPolicy) -> f32 {
self.shown(now, policy, MotionChannel::Space)
}
/// A column between `from` and `to`: slide a marker, nudge a panel.
#[must_use]
pub fn offset(&self, now: Instant, policy: MotionPolicy, from: u16, to: u16) -> u16 {
@@ -1,360 +0,0 @@
---
source: src/components/chart.rs
assertion_line: 1242
expression: "testing::snapshot(12, |area, buf, theme|\n{\n let row = |y: u16, h: u16|\n { Rect::new(area.x, area.y + y, area.width, h).intersection(area) };\n DataSparkline::new(\"Requests\",\n &values).unit(\"rpm\").paint(row(0, 1), buf, theme);\n DataBars::new(\"Tokens by model\",\n &items).unit(\"tokens\").paint(row(2, 5), buf, theme);\n DataTrend::new(\"Latency\",\n &values).unit(\"ms\").rows(5).paint(row(7, 5), buf, theme);\n})"
---
== dark-truecolor · 40x12
Requests ▁▃▅▄▅▆▇███▆▅▄▃▂▁ now 45.6 rpm
Tokens by model
opus ██████████████████ 12.4k tokens
sonnet ███████████▌ 7900 tokens
haiku ████▌ 3100 tokens
cache 0 tokens
Latency now 45.6 ms
69.7 ms │ ⡠⠒⠑⠒⠒⠢⣀ ⡀⡠⠔⠉⠉⢢⡀
│⢀⠔⠁ ⢇ ⢀⠔⠉ ⠈⠑⢄
│⠁ ⠑⠢⡀ ⡠⠊
17.2 ms │ ⠈⠱⣀⣀⠤⠊⠊
---
r0 c0..8 fg=Foreground bg=Background bold
r0 c8..10 bg=Background
r0 c10..26 fg=Accent bg=Background
r0 c26..28 bg=Background
r0 c28..32 fg=Muted bg=Background
r0 c32..40 fg=Foreground bg=Background
r1 c0..40 bg=Background
r2 c0..15 fg=Foreground bg=Background bold
r2 c15..40 bg=Background
r3 c0..6 fg=Foreground bg=Background
r3 c6..8 bg=Background
r3 c8..26 fg=Accent bg=Background
r3 c26..28 bg=Background
r3 c28..40 fg=Muted bg=Background
r4 c0..6 fg=Foreground bg=Background
r4 c6..8 bg=Background
r4 c8..20 fg=Accent bg=Background
r4 c20..28 bg=Background
r4 c28..39 fg=Muted bg=Background
r4 c39..40 bg=Background
r5 c0..6 fg=Foreground bg=Background
r5 c6..8 bg=Background
r5 c8..13 fg=Accent bg=Background
r5 c13..28 bg=Background
r5 c28..39 fg=Muted bg=Background
r5 c39..40 bg=Background
r6 c0..6 fg=Foreground bg=Background
r6 c6..28 bg=Background
r6 c28..36 fg=Muted bg=Background
r6 c36..40 bg=Background
r7 c0..7 fg=Foreground bg=Background bold
r7 c7..9 bg=Background
r7 c9..13 fg=Muted bg=Background
r7 c13..20 fg=Foreground bg=Background
r7 c20..40 bg=Background
r8 c0..8 fg=Muted bg=Background
r8 c8..9 fg=Dim bg=Background
r8 c9..11 bg=Background
r8 c11..18 fg=Accent bg=Background
r8 c18..31 bg=Background
r8 c31..38 fg=Accent bg=Background
r8 c38..40 bg=Background
r9 c0..8 fg=Muted bg=Background
r9 c8..9 fg=Dim bg=Background
r9 c9..12 fg=Accent bg=Background
r9 c12..18 bg=Background
r9 c18..19 fg=Accent bg=Background
r9 c19..29 bg=Background
r9 c29..32 fg=Accent bg=Background
r9 c32..37 bg=Background
r9 c37..40 fg=Accent bg=Background
r10 c0..8 fg=Muted bg=Background
r10 c8..9 fg=Dim bg=Background
r10 c9..10 fg=Accent bg=Background
r10 c10..19 bg=Background
r10 c19..22 fg=Accent bg=Background
r10 c22..28 bg=Background
r10 c28..30 fg=Accent bg=Background
r10 c30..40 bg=Background
r11 c0..8 fg=Muted bg=Background
r11 c8..9 fg=Dim bg=Background
r11 c9..21 bg=Background
r11 c21..28 fg=Accent bg=Background
r11 c28..40 bg=Background
== dark-truecolor · 80x12
Requests ▂▁▁▁▁▁▂▂▂▃▃▄▅▆▆▆▇▇████▇▇▆▆▅▅ now 45.6 rpm min 17.2 rpm max 69.7 rpm
Tokens by model
opus ██████████████████████████████████████████████████████████ 12.4k tokens
sonnet █████████████████████████████████████ 7900 tokens
haiku ██████████████▌ 3100 tokens
cache 0 tokens
Latency now 45.6 ms min 17.2 ms max 69.7 ms
69.7 ms │ ⣀⠤⠒⠊⠑⠒⠒⠒⠒⠒⠒⠢⠤⢄⣀ ⣀ ⣀⡠⠤⠒⠒⠉⠉⠉⠉⠢⢄⣀⣀
│ ⣀⠤⠒⠉ ⠑⠤⣀ ⢀⡠⠒⠉ ⠉ ⠉⠑⠒⠤⣀
│⠉ ⠉⠑⠒⠤⢄⣀ ⡠⠔⠊⠁
17.2 ms │ ⠉⠑⢄⣀⣀⣀⣀⣀⠤⠤⠒⠊⠒⠊⠉
---
r0 c0..8 fg=Foreground bg=Background bold
r0 c8..10 bg=Background
r0 c10..38 fg=Accent bg=Background
r0 c38..40 bg=Background
r0 c40..44 fg=Muted bg=Background
r0 c44..52 fg=Foreground bg=Background
r0 c52..54 bg=Background
r0 c54..58 fg=Muted bg=Background
r0 c58..66 fg=Foreground bg=Background
r0 c66..68 bg=Background
r0 c68..72 fg=Muted bg=Background
r0 c72..80 fg=Foreground bg=Background
r1 c0..80 bg=Background
r2 c0..15 fg=Foreground bg=Background bold
r2 c15..80 bg=Background
r3 c0..6 fg=Foreground bg=Background
r3 c6..8 bg=Background
r3 c8..66 fg=Accent bg=Background
r3 c66..68 bg=Background
r3 c68..80 fg=Muted bg=Background
r4 c0..6 fg=Foreground bg=Background
r4 c6..8 bg=Background
r4 c8..45 fg=Accent bg=Background
r4 c45..68 bg=Background
r4 c68..79 fg=Muted bg=Background
r4 c79..80 bg=Background
r5 c0..6 fg=Foreground bg=Background
r5 c6..8 bg=Background
r5 c8..23 fg=Accent bg=Background
r5 c23..68 bg=Background
r5 c68..79 fg=Muted bg=Background
r5 c79..80 bg=Background
r6 c0..6 fg=Foreground bg=Background
r6 c6..68 bg=Background
r6 c68..76 fg=Muted bg=Background
r6 c76..80 bg=Background
r7 c0..7 fg=Foreground bg=Background bold
r7 c7..9 bg=Background
r7 c9..13 fg=Muted bg=Background
r7 c13..20 fg=Foreground bg=Background
r7 c20..22 bg=Background
r7 c22..26 fg=Muted bg=Background
r7 c26..33 fg=Foreground bg=Background
r7 c33..35 bg=Background
r7 c35..39 fg=Muted bg=Background
r7 c39..46 fg=Foreground bg=Background
r7 c46..80 bg=Background
r8 c0..8 fg=Muted bg=Background
r8 c8..9 fg=Dim bg=Background
r8 c9..14 bg=Background
r8 c14..29 fg=Accent bg=Background
r8 c29..60 bg=Background
r8 c60..61 fg=Accent bg=Background
r8 c61..62 bg=Background
r8 c62..75 fg=Accent bg=Background
r8 c75..80 bg=Background
r9 c0..8 fg=Muted bg=Background
r9 c8..9 fg=Dim bg=Background
r9 c9..10 bg=Background
r9 c10..14 fg=Accent bg=Background
r9 c14..29 bg=Background
r9 c29..32 fg=Accent bg=Background
r9 c32..56 bg=Background
r9 c56..60 fg=Accent bg=Background
r9 c60..61 bg=Background
r9 c61..62 fg=Accent bg=Background
r9 c62..75 bg=Background
r9 c75..80 fg=Accent bg=Background
r10 c0..8 fg=Muted bg=Background
r10 c8..9 fg=Dim bg=Background
r10 c9..10 fg=Accent bg=Background
r10 c10..32 bg=Background
r10 c32..38 fg=Accent bg=Background
r10 c38..53 bg=Background
r10 c53..57 fg=Accent bg=Background
r10 c57..80 bg=Background
r11 c0..8 fg=Muted bg=Background
r11 c8..9 fg=Dim bg=Background
r11 c9..38 bg=Background
r11 c38..53 fg=Accent bg=Background
r11 c53..80 bg=Background
== dark-truecolor · 120x12
Requests ▄▅▆▆▇██▇██▇▇▇▇▅▅▄▄▃▃▂▁▁▁▁▁▂▂▂▃▃▄▅▆▆▆▇▇████▇▇▆▆▅▅ now 45.6 rpm min 17.2 rpm max 69.7 rpm
Tokens by model
opus ██████████████████████████████████████████████████████████████████████████████████████████████████ 12.4k tokens
sonnet ██████████████████████████████████████████████████████████████▍ 7900 tokens
haiku ████████████████████████▌ 3100 tokens
cache 0 tokens
Latency now 45.6 ms min 17.2 ms max 69.7 ms
69.7 ms │ ⢀⣀⠤⠤⠒⠒⠊⠉⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠢⠤⠤⠤⣀⣀ ⣀⡀ ⣀⣀⠤⠤⠒⠒⠒⠊⠉⠉⠉⠉⠉⠉⠒⠤⢄⣀⣀⣀
│ ⢀⣀⠤⠔⠒⠉⠁ ⠉⠢⠤⣀⣀ ⣀⡠⠤⠒⠊⠉ ⠈⠉ ⠉⠉⠑⠒⠒⠤⠤⣀
│⠉⠁ ⠉⠉⠒⠒⠢⠤⢄⣀⣀ ⢀⣀⠤⠔⠊⠉
17.2 ms │ ⠉⠉⠑⠢⢄⣀⣀⣀⣀⣀⣀⣀⡠⠤⠤⠤⠒⠒⠊⠑⠒⠒⠉⠁
---
r0 c0..8 fg=Foreground bg=Background bold
r0 c8..10 bg=Background
r0 c10..58 fg=Accent bg=Background
r0 c58..60 bg=Background
r0 c60..64 fg=Muted bg=Background
r0 c64..72 fg=Foreground bg=Background
r0 c72..74 bg=Background
r0 c74..78 fg=Muted bg=Background
r0 c78..86 fg=Foreground bg=Background
r0 c86..88 bg=Background
r0 c88..92 fg=Muted bg=Background
r0 c92..100 fg=Foreground bg=Background
r0 c100..120 bg=Background
r1 c0..120 bg=Background
r2 c0..15 fg=Foreground bg=Background bold
r2 c15..120 bg=Background
r3 c0..6 fg=Foreground bg=Background
r3 c6..8 bg=Background
r3 c8..106 fg=Accent bg=Background
r3 c106..108 bg=Background
r3 c108..120 fg=Muted bg=Background
r4 c0..6 fg=Foreground bg=Background
r4 c6..8 bg=Background
r4 c8..71 fg=Accent bg=Background
r4 c71..108 bg=Background
r4 c108..119 fg=Muted bg=Background
r4 c119..120 bg=Background
r5 c0..6 fg=Foreground bg=Background
r5 c6..8 bg=Background
r5 c8..33 fg=Accent bg=Background
r5 c33..108 bg=Background
r5 c108..119 fg=Muted bg=Background
r5 c119..120 bg=Background
r6 c0..6 fg=Foreground bg=Background
r6 c6..108 bg=Background
r6 c108..116 fg=Muted bg=Background
r6 c116..120 bg=Background
r7 c0..7 fg=Foreground bg=Background bold
r7 c7..9 bg=Background
r7 c9..13 fg=Muted bg=Background
r7 c13..20 fg=Foreground bg=Background
r7 c20..22 bg=Background
r7 c22..26 fg=Muted bg=Background
r7 c26..33 fg=Foreground bg=Background
r7 c33..35 bg=Background
r7 c35..39 fg=Muted bg=Background
r7 c39..46 fg=Foreground bg=Background
r7 c46..120 bg=Background
r8 c0..8 fg=Muted bg=Background
r8 c8..9 fg=Dim bg=Background
r8 c9..16 bg=Background
r8 c16..40 fg=Accent bg=Background
r8 c40..89 bg=Background
r8 c89..91 fg=Accent bg=Background
r8 c91..92 bg=Background
r8 c92..112 fg=Accent bg=Background
r8 c112..120 bg=Background
r9 c0..8 fg=Muted bg=Background
r9 c8..9 fg=Dim bg=Background
r9 c9..10 bg=Background
r9 c10..17 fg=Accent bg=Background
r9 c17..40 bg=Background
r9 c40..45 fg=Accent bg=Background
r9 c45..83 bg=Background
r9 c83..89 fg=Accent bg=Background
r9 c89..90 bg=Background
r9 c90..92 fg=Accent bg=Background
r9 c92..112 bg=Background
r9 c112..120 fg=Accent bg=Background
r10 c0..8 fg=Muted bg=Background
r10 c8..9 fg=Dim bg=Background
r10 c9..11 fg=Accent bg=Background
r10 c11..45 bg=Background
r10 c45..54 fg=Accent bg=Background
r10 c54..77 bg=Background
r10 c77..83 fg=Accent bg=Background
r10 c83..120 bg=Background
r11 c0..8 fg=Muted bg=Background
r11 c8..9 fg=Dim bg=Background
r11 c9..54 bg=Background
r11 c54..78 fg=Accent bg=Background
r11 c78..120 bg=Background
== no-color · 40x12
Requests ▁▃▅▄▅▆▇███▆▅▄▃▂▁ now 45.6 rpm
Tokens by model
opus ██████████████████ 12.4k tokens
sonnet ███████████▌ 7900 tokens
haiku ████▌ 3100 tokens
cache 0 tokens
Latency now 45.6 ms
69.7 ms │ ⡠⠒⠑⠒⠒⠢⣀ ⡀⡠⠔⠉⠉⢢⡀
│⢀⠔⠁ ⢇ ⢀⠔⠉ ⠈⠑⢄
│⠁ ⠑⠢⡀ ⡠⠊
17.2 ms │ ⠈⠱⣀⣀⠤⠊⠊
== no-color · 80x12
Requests ▂▁▁▁▁▁▂▂▂▃▃▄▅▆▆▆▇▇████▇▇▆▆▅▅ now 45.6 rpm min 17.2 rpm max 69.7 rpm
Tokens by model
opus ██████████████████████████████████████████████████████████ 12.4k tokens
sonnet █████████████████████████████████████ 7900 tokens
haiku ██████████████▌ 3100 tokens
cache 0 tokens
Latency now 45.6 ms min 17.2 ms max 69.7 ms
69.7 ms │ ⣀⠤⠒⠊⠑⠒⠒⠒⠒⠒⠒⠢⠤⢄⣀ ⣀ ⣀⡠⠤⠒⠒⠉⠉⠉⠉⠢⢄⣀⣀
│ ⣀⠤⠒⠉ ⠑⠤⣀ ⢀⡠⠒⠉ ⠉ ⠉⠑⠒⠤⣀
│⠉ ⠉⠑⠒⠤⢄⣀ ⡠⠔⠊⠁
17.2 ms │ ⠉⠑⢄⣀⣀⣀⣀⣀⠤⠤⠒⠊⠒⠊⠉
== no-color · 120x12
Requests ▄▅▆▆▇██▇██▇▇▇▇▅▅▄▄▃▃▂▁▁▁▁▁▂▂▂▃▃▄▅▆▆▆▇▇████▇▇▆▆▅▅ now 45.6 rpm min 17.2 rpm max 69.7 rpm
Tokens by model
opus ██████████████████████████████████████████████████████████████████████████████████████████████████ 12.4k tokens
sonnet ██████████████████████████████████████████████████████████████▍ 7900 tokens
haiku ████████████████████████▌ 3100 tokens
cache 0 tokens
Latency now 45.6 ms min 17.2 ms max 69.7 ms
69.7 ms │ ⢀⣀⠤⠤⠒⠒⠊⠉⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠢⠤⠤⠤⣀⣀ ⣀⡀ ⣀⣀⠤⠤⠒⠒⠒⠊⠉⠉⠉⠉⠉⠉⠒⠤⢄⣀⣀⣀
│ ⢀⣀⠤⠔⠒⠉⠁ ⠉⠢⠤⣀⣀ ⣀⡠⠤⠒⠊⠉ ⠈⠉ ⠉⠉⠑⠒⠒⠤⠤⣀
│⠉⠁ ⠉⠉⠒⠒⠢⠤⢄⣀⣀ ⢀⣀⠤⠔⠊⠉
17.2 ms │ ⠉⠉⠑⠢⢄⣀⣀⣀⣀⣀⣀⣀⡠⠤⠤⠤⠒⠒⠊⠑⠒⠒⠉⠁
== ascii · 40x12
Requests _.---=####=--.__ now 45.6 rpm
Tokens by model
opus ################## 12.4k tokens
sonnet ############ 7900 tokens
haiku ##### 3100 tokens
cache 0 tokens
Latency now 45.6 ms
69.7 ms | ....... .....:.
|... : ... ...
|. ... ..
17.2 ms | .:.....
== ascii · 80x12
Requests _________..--===########==-- now 45.6 rpm min 17.2 rpm max 69.7 rpm
Tokens by model
opus ########################################################## 12.4k tokens
sonnet ##################################### 7900 tokens
haiku ############### 3100 tokens
cache 0 tokens
Latency now 45.6 ms min 17.2 ms max 69.7 ms
69.7 ms | ............... . .............
| .... ... .... . .....
|. ...... ....
17.2 ms | ...............
== ascii · 120x12
Requests --==##########----.._________..--===########==-- now 45.6 rpm min 17.2 rpm max 69.7 rpm
Tokens by model
opus ################################################################################################## 12.4k tokens
sonnet ############################################################### 7900 tokens
haiku ######################### 3100 tokens
cache 0 tokens
Latency now 45.6 ms min 17.2 ms max 69.7 ms
69.7 ms | ........................ .. ....................
| ....... ..... ...... .. ........
|.. ......... ......
17.2 ms | ........................
-118
View File
@@ -1,118 +0,0 @@
//! Gallery: data displays fully drawn, mid draw-in, empty and from agent JSON.
use ratatui::{buffer::Buffer, layout::Rect};
use super::Entry;
use crate::{DataBarItem, DataBars, DataDisplay, DataSparkline, DataTrend, Paint, Theme};
fn series() -> Vec<f64> {
(0..64)
.map(|i| 120.0 + 60.0 * (f64::from(i) / 6.0).sin() + f64::from((i * 7) % 11) * 3.0)
.collect()
}
fn items() -> Vec<DataBarItem> {
vec![
DataBarItem::new("engine", 412.0),
DataBarItem::new("tui", 268.0),
DataBarItem::new("runtime", 131.0),
DataBarItem::new("protocol", 44.0),
]
}
fn row(area: Rect, y: u16, h: u16) -> Rect {
Rect::new(area.x, area.y.saturating_add(y), area.width, h).intersection(area)
}
fn sparklines(area: Rect, buf: &mut Buffer, theme: &Theme) {
let values = series();
DataSparkline::new("Requests", &values)
.unit("rpm")
.paint(row(area, 0, 1), buf, theme);
DataSparkline::new("Requests", &values)
.unit("rpm")
.drawn(0.45)
.paint(row(area, 1, 1), buf, theme);
DataSparkline::new("Requests", &[]).paint(row(area, 2, 1), buf, theme);
}
fn bars(area: Rect, buf: &mut Buffer, theme: &Theme) {
let items = items();
DataBars::new("Tests by crate", &items).paint(row(area, 0, 5), buf, theme);
}
fn bars_growing(area: Rect, buf: &mut Buffer, theme: &Theme) {
let items = items();
DataBars::new("Tests by crate", &items)
.previous(&[300.0, 300.0, 300.0, 300.0])
.drawn(0.5)
.paint(row(area, 0, 5), buf, theme);
}
fn trend(area: Rect, buf: &mut Buffer, theme: &Theme) {
let values = series();
DataTrend::new("Latency", &values)
.unit("ms")
.rows(7)
.paint(area, buf, theme);
}
fn trend_drawing(area: Rect, buf: &mut Buffer, theme: &Theme) {
let values = series();
DataTrend::new("Latency", &values)
.unit("ms")
.rows(7)
.drawn(0.6)
.paint(area, buf, theme);
}
fn from_agent(area: Rect, buf: &mut Buffer, theme: &Theme) {
let shown = DataDisplay::from_json(
r#"{"kind":"bars","label":"Tokens this turn","unit":"tokens",
"items":[{"label":"read","value":18200},{"label":"wrote","value":2400},
{"label":"cached","value":61000}]}"#,
)
.expect("gallery fixture is a valid data display");
shown.view().paint(area, buf, theme);
}
pub(crate) fn entries() -> Vec<Entry> {
vec![
Entry {
name: "data-sparkline",
width: 80,
height: 3,
draw: sparklines,
},
Entry {
name: "data-bars",
width: 60,
height: 5,
draw: bars,
},
Entry {
name: "data-bars-growing",
width: 60,
height: 5,
draw: bars_growing,
},
Entry {
name: "data-trend",
width: 72,
height: 7,
draw: trend,
},
Entry {
name: "data-trend-drawing",
width: 72,
height: 7,
draw: trend_drawing,
},
Entry {
name: "data-from-agent",
width: 60,
height: 4,
draw: from_agent,
},
]
}
-2
View File
@@ -31,7 +31,6 @@ mod approval;
mod artifacts;
mod atmosphere;
mod attention;
mod chart;
mod chrome;
mod display;
mod habitat;
@@ -105,7 +104,6 @@ pub fn entries() -> Vec<Entry> {
lists::entries(),
chrome::entries(),
display::entries(),
chart::entries(),
approval::entries(),
motion::entries(),
workspace::entries(),