mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-12 18:18:10 -10:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f99cdab9e | ||
|
|
ae18591d10 | ||
|
|
4e20c2ba06 | ||
|
|
b87bd535c2 | ||
|
|
969059fb83 | ||
|
|
09797fa316 | ||
|
|
242938a572 | ||
|
|
9e68dcc886 |
No files matched your search
@@ -13,6 +13,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Blank environment variables:** exporting a variable empty (for example `CODEWHALE_MODEL=` from a compose `${VAR:-}` template) no longer hides its legacy alias. `DEEPSEEK_MODEL`, `DEEPSEEK_BASE_URL`, `KIMI_BASE_URL`, `DEEPSEEK_FORCE_HTTP1`, `DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS` and the other provider alias variables now apply as they do when the primary is unset. Thanks [@luochen211](https://github.com/luochen211) (PR 7315).
|
||||
- **MCP and language servers that send requests mid-call:** a tool call no longer fails with "neither a result nor an error" when an MCP server sends a `ping` or other request that reuses the call's id while the call is running, and a language server's `workspace/configuration` or similar request no longer ends a pending LSP request. Codewhale now answers `ping`, answers other server requests with "Method not found" (it never advertised them), keeps waiting for the real reply, and a refused answer over Streamable HTTP no longer drops the connection to legacy SSE. Thanks [@luochen211](https://github.com/luochen211) (PR 7319).
|
||||
- **Qwen Code as an external agent:** Codewhale now finds an installed Qwen Code CLI (`qwen`, 0.6.1 or newer) and can run it as a session over ACP with `qwen --acp`. `GET /v1/agents/external` lists it, an older install is reported as needing `npm i -g @qwen-code/qwen-code@latest`, and sign-in stays inside Qwen Code (`/auth`). Thanks [@MannXo](https://github.com/MannXo) (#7293).
|
||||
- **Qwen Code as an external agent:** Codewhale now finds an installed Qwen Code CLI (`qwen`, 0.6.1 or newer) and can run it as a session over ACP with `qwen --acp`. `GET /v1/agents/external` lists it, an older install is reported as needing `npm i -g @qwen-code/qwen-code@latest`, and sign-in stays inside Qwen Code (`/auth`). Thanks [@MannXo](https://github.com/MannXo) (PR 7293).
|
||||
- **Telemetry setting default:** the settings schema API reports telemetry's default as on, matching what Codewhale does when you have not chosen.
|
||||
- **Auto routing abstention:** when the decision model marks a tier, effort or shadow answer `abstained: true`, Auto no longer acts on it, even with `min_confidence = 0`: an abstained tier takes the local fallback, an abstained effort keeps the tier and drops only the effort, and the routing receipt lists the abstained questions.
|
||||
- **Plugin index (Runtime API):** a new `GET /v1/apps/plugin-index` route lists plugins, skills, connectors, hooks and hand-written commands with kind, source and state, and `/v1/commands` rows say which plugin (`plugin_id`) added a command and whether a command came from the project (`workspace_sourced`) (CW-8092).
|
||||
- **Model commands and routing:** `codewhale model list` now lists every custom endpoint you have configured, under its own name. `codewhale model resolve` no longer refuses a model on a custom endpoint, router or local server whose model list has not been fetched yet; once a list is fetched, a model missing from it is still refused. Automatic routing marks a context window as verified only when the route itself supplies it, and no longer reports a guessed output limit.
|
||||
- **ACP:** when a plugin tool asks the core to run a tool that needs approval, the editor now gets a `session/request_permission` on the plugin tool's call that names the nested tool and its input; before, the prompt ended with an internal error (CW-8050).
|
||||
- **README and contributor credit:** the repository front page now opens the way the website does: the line "The model can be anyone's. The agent should be yours.", one sentence on what Codewhale is, the install command and one picture, with reference detail moved into the linked guides; the 18 translated READMEs follow the same structure and say they are machine-drafted. The contributor wall and the website now count the same 433 people (407 GitHub accounts and the commit authors whose email is not linked to an account), where the website had counted accounts only. `DEPENDENCY_SECURITY.md` moved into `docs/dependency-maintenance.md`, and `.github/SUPPORT.md` says where to ask for help.
|
||||
- **Website Plugins page:** The website's Plugins page now describes what the released version does, and a labelled section says what 0.10.3 adds: one Plugins place, Standard and Creator mode, and the Permissions control.
|
||||
- **First-time guide on the website:** a new docs page, "Use Codewhale for the first time" (`/docs/first-time`, English and Simplified Chinese), walks someone who has never used a terminal through opening one, installing, choosing the folder Codewhale works in and when that choice matters, opening a terminal in a folder from Finder or File Explorer, connecting a model, a first task, and stopping, undoing and quitting. The install page gains a short "New to the terminal?" block for macOS and Windows that links to it, and `llms.txt` carries the same steps.
|
||||
|
||||
@@ -47,6 +47,18 @@ impl Drop for TurnClaim {
|
||||
}
|
||||
}
|
||||
}
|
||||
fn admitted_call<'a>(
|
||||
calls: &'a HashMap<String, PendingToolCall>,
|
||||
execution: &str,
|
||||
) -> Option<(&'a str, &'a PendingToolCall)> {
|
||||
let mut id = execution;
|
||||
loop {
|
||||
if let Some((key, call)) = calls.get_key_value(id) {
|
||||
return Some((key.as_str(), call));
|
||||
}
|
||||
id = id.rsplit_once('.')?.0;
|
||||
}
|
||||
}
|
||||
fn update(session: &str, update: acp::SessionUpdate) -> Outgoing {
|
||||
Outgoing::Update(acp::SessionNotification::new(session.to_string(), update))
|
||||
}
|
||||
@@ -419,27 +431,50 @@ impl PromptRun {
|
||||
&& pending.turn_id == turn
|
||||
&& pending.tool_call_id.as_deref() == Some(execution)
|
||||
}) {
|
||||
let call = calls
|
||||
.get(execution)
|
||||
let (shown, call) = admitted_call(calls, execution)
|
||||
.ok_or_else(|| anyhow!("Approval projection lacks admitted tool start"))?;
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let title = tool_call_title(call);
|
||||
let text = p
|
||||
let nested = shown != execution;
|
||||
let nested_call;
|
||||
let shown_call = if nested {
|
||||
nested_call = PendingToolCall {
|
||||
execution_id: execution.to_string(),
|
||||
name: p
|
||||
.get("tool_name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(&call.name)
|
||||
.to_string(),
|
||||
input: p.get("input").cloned().unwrap_or(Value::Null),
|
||||
};
|
||||
&nested_call
|
||||
} else {
|
||||
call
|
||||
};
|
||||
let title = tool_call_title(shown_call);
|
||||
let description = p
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
.map_or_else(|| title.clone(), str::to_string);
|
||||
.filter(|text| !text.trim().is_empty());
|
||||
let summary = p
|
||||
.get("summary")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|text| nested && !text.trim().is_empty());
|
||||
let text = match (summary, description) {
|
||||
(Some(summary), Some(description)) => format!("{summary}\n{description}"),
|
||||
(Some(text), None) | (None, Some(text)) => text.to_string(),
|
||||
(None, None) => title.clone(),
|
||||
};
|
||||
let mut fields = acp::ToolCallUpdateFields::new()
|
||||
.title(title)
|
||||
.kind(tool_call_kind(shown_call))
|
||||
.raw_input((!shown_call.input.is_null()).then(|| shown_call.input.clone()))
|
||||
.content(vec![acp::ToolCallContent::from(text)]);
|
||||
if !nested {
|
||||
fields = fields.status(acp::ToolCallStatus::Pending);
|
||||
}
|
||||
let request = acp::RequestPermissionRequest::new(
|
||||
session.to_string(),
|
||||
acp::ToolCallUpdate::new(
|
||||
execution.to_string(),
|
||||
acp::ToolCallUpdateFields::new()
|
||||
.title(title)
|
||||
.kind(tool_call_kind(call))
|
||||
.status(acp::ToolCallStatus::Pending)
|
||||
.raw_input((!call.input.is_null()).then(|| call.input.clone()))
|
||||
.content(vec![acp::ToolCallContent::from(text)]),
|
||||
),
|
||||
acp::ToolCallUpdate::new(shown.to_string(), fields),
|
||||
vec![
|
||||
acp::PermissionOption::new(
|
||||
"allow-once",
|
||||
|
||||
@@ -129,6 +129,7 @@ mod members;
|
||||
mod memory_lens;
|
||||
mod mobile;
|
||||
mod plans;
|
||||
mod plugin_index;
|
||||
mod plugins;
|
||||
pub(crate) mod review;
|
||||
mod secrets;
|
||||
@@ -3372,6 +3373,7 @@ pub fn build_router(state: RuntimeApiState) -> Router {
|
||||
.route("/v1/skills/{name}/audit", get(audit_skill_api))
|
||||
.route("/v1/apps/mcp/tools", get(list_mcp_tools))
|
||||
.route("/v1/apps/plugins", get(plugins::list_plugins))
|
||||
.route("/v1/apps/plugin-index", get(plugin_index::get_plugin_index))
|
||||
.route("/v1/apps/avatars", get(avatars::list_avatars))
|
||||
.route(
|
||||
"/v1/apps/avatars/{handle}/atlases/{page}",
|
||||
@@ -5875,6 +5877,10 @@ struct CommandCatalogEntry {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
origin: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
plugin_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
workspace_sourced: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
registration_id: Option<String>,
|
||||
}
|
||||
|
||||
@@ -5933,6 +5939,8 @@ fn command_catalog(
|
||||
shadowed_by,
|
||||
shadowed_aliases,
|
||||
origin: None,
|
||||
plugin_id: None,
|
||||
workspace_sourced: false,
|
||||
registration_id: None,
|
||||
});
|
||||
}
|
||||
@@ -5956,6 +5964,8 @@ fn command_catalog(
|
||||
shadowed_by: None,
|
||||
shadowed_aliases: Vec::new(),
|
||||
origin: None,
|
||||
plugin_id: command.plugin_id.clone(),
|
||||
workspace_sourced: command.workspace_sourced,
|
||||
registration_id: None,
|
||||
});
|
||||
}
|
||||
@@ -6038,6 +6048,8 @@ async fn list_commands(
|
||||
shadowed_by: None,
|
||||
shadowed_aliases: Vec::new(),
|
||||
origin: Some(reference.origin.clone()),
|
||||
plugin_id: Some(reference.plugin_id.clone()),
|
||||
workspace_sourced: false,
|
||||
registration_id: query
|
||||
.thread_id
|
||||
.as_deref()
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::path::{Path as FsPath, PathBuf};
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::{Query, State};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::plugins::{self, PluginSummaryEntry};
|
||||
use super::{ApiError, CommandsQuery, HooksQuery, RuntimeApiState};
|
||||
|
||||
const MAX_ADDED_NAMES: usize = 512;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct PluginIndexResponse {
|
||||
workspace: String,
|
||||
items: Vec<IndexItem>,
|
||||
unavailable: Vec<IndexNote>,
|
||||
problems: Vec<IndexNote>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct IndexNote {
|
||||
kind: &'static str,
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct IndexItem {
|
||||
id: String,
|
||||
kind: &'static str,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
source: &'static str,
|
||||
state: &'static str,
|
||||
labels: Vec<&'static str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
plugin: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
adds: Option<PluginAdds>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tier: Option<&'static str>,
|
||||
owner: &'static str,
|
||||
row: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
struct PluginAdds {
|
||||
skills: Vec<String>,
|
||||
connectors: Vec<String>,
|
||||
hooks: Vec<String>,
|
||||
commands: Vec<String>,
|
||||
profiles: Vec<String>,
|
||||
}
|
||||
|
||||
impl PluginAdds {
|
||||
fn labels(&self, entry: &PluginSummaryEntry) -> Vec<&'static str> {
|
||||
[
|
||||
("skill", !self.skills.is_empty()),
|
||||
("connector", !self.connectors.is_empty()),
|
||||
("hook", !self.hooks.is_empty()),
|
||||
("command", !self.commands.is_empty()),
|
||||
(
|
||||
"tool",
|
||||
entry.inventory.native > 0 || entry.inventory.lsp > 0,
|
||||
),
|
||||
("profile", !self.profiles.is_empty()),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(label, present)| present.then_some(label))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn row_value<T: Serialize>(row: &T) -> Value {
|
||||
serde_json::to_value(row).unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
fn note_failure(unavailable: &mut Vec<IndexNote>, kind: &'static str, error: &ApiError) {
|
||||
unavailable.push(IndexNote {
|
||||
kind,
|
||||
message: error.message.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
fn plugin_source(origin: &str) -> &'static str {
|
||||
match origin {
|
||||
"codewhale-builtin" => "built_in",
|
||||
"workspace-codewhale" => "project",
|
||||
_ => "user",
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_state(entry: &PluginSummaryEntry) -> &'static str {
|
||||
if matches!(
|
||||
entry.trust_status,
|
||||
"content-changed" | "capabilities-changed"
|
||||
) || entry.state == "enabled-untrusted"
|
||||
{
|
||||
return "needs_review";
|
||||
}
|
||||
match entry.state {
|
||||
"active" => "enabled",
|
||||
"disabled" => "disabled",
|
||||
_ => "unavailable",
|
||||
}
|
||||
}
|
||||
|
||||
fn add_stem(names: &mut BTreeSet<String>, path: &FsPath, extension: Option<&str>, lowercase: bool) {
|
||||
if let Some(wanted) = extension
|
||||
&& path.extension().and_then(|value| value.to_str()) != Some(wanted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Some(stem) = path.file_stem().and_then(|value| value.to_str()) {
|
||||
names.insert(if lowercase {
|
||||
stem.to_lowercase()
|
||||
} else {
|
||||
stem.to_string()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn component_names(
|
||||
paths: &[PathBuf],
|
||||
extension: Option<&str>,
|
||||
lowercase: bool,
|
||||
omitted: &mut bool,
|
||||
) -> Vec<String> {
|
||||
let mut names = BTreeSet::new();
|
||||
for path in paths {
|
||||
let Ok(meta) = tokio::fs::symlink_metadata(path).await else {
|
||||
continue;
|
||||
};
|
||||
if meta.is_file() {
|
||||
add_stem(&mut names, path, extension, lowercase);
|
||||
} else if meta.is_dir()
|
||||
&& let Ok(mut entries) = tokio::fs::read_dir(path).await
|
||||
{
|
||||
let mut seen = 0;
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
seen += 1;
|
||||
if seen > MAX_ADDED_NAMES {
|
||||
*omitted = true;
|
||||
break;
|
||||
}
|
||||
if entry.file_type().await.is_ok_and(|kind| kind.is_file()) {
|
||||
add_stem(&mut names, &entry.path(), extension, lowercase);
|
||||
}
|
||||
}
|
||||
}
|
||||
if names.len() >= MAX_ADDED_NAMES {
|
||||
*omitted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if names.iter().any(|name| name.chars().any(char::is_control)) {
|
||||
*omitted = true;
|
||||
names.retain(|name| !name.chars().any(char::is_control));
|
||||
}
|
||||
names.into_iter().take(MAX_ADDED_NAMES).collect()
|
||||
}
|
||||
|
||||
async fn plugin_adds(
|
||||
plugin: &codewhale_plugins::types::LoadedPlugin,
|
||||
omitted: &mut bool,
|
||||
) -> PluginAdds {
|
||||
let mut skills: Vec<String> = plugin
|
||||
.skill_snapshots
|
||||
.iter()
|
||||
.map(|skill| skill.name.clone())
|
||||
.collect();
|
||||
skills.sort();
|
||||
let mut connectors: Vec<String> = plugin
|
||||
.manifest
|
||||
.mcp_servers
|
||||
.as_ref()
|
||||
.map(|servers| servers.keys().cloned().collect())
|
||||
.unwrap_or_default();
|
||||
connectors.sort();
|
||||
PluginAdds {
|
||||
skills,
|
||||
connectors,
|
||||
hooks: component_names(&plugin.components.hooks, None, false, omitted).await,
|
||||
commands: component_names(&plugin.components.commands, Some("md"), true, omitted).await,
|
||||
profiles: component_names(&plugin.components.agents, Some("toml"), false, omitted).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn plugin_adds_by_id(
|
||||
state: &RuntimeApiState,
|
||||
omitted: &mut BTreeSet<String>,
|
||||
) -> HashMap<String, PluginAdds> {
|
||||
let registry = state
|
||||
.plugin_discovery
|
||||
.registry_for_workspace(&state.workspace);
|
||||
let mut adds = HashMap::new();
|
||||
for plugin in registry.list() {
|
||||
let mut cut = false;
|
||||
adds.insert(
|
||||
plugin.id.as_str().to_string(),
|
||||
plugin_adds(plugin, &mut cut).await,
|
||||
);
|
||||
if cut {
|
||||
omitted.insert(plugin.name().to_string());
|
||||
}
|
||||
}
|
||||
adds
|
||||
}
|
||||
|
||||
fn skill_source(
|
||||
path: Option<&PathBuf>,
|
||||
is_bundled: bool,
|
||||
skills_dir: &FsPath,
|
||||
workspace: &FsPath,
|
||||
) -> &'static str {
|
||||
if is_bundled {
|
||||
return "built_in";
|
||||
}
|
||||
match path {
|
||||
Some(path) if path.starts_with(skills_dir) => "user",
|
||||
Some(path) if path.starts_with(workspace) => "project",
|
||||
Some(_) => "other_app",
|
||||
None => "plugin",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn get_plugin_index(
|
||||
State(state): State<RuntimeApiState>,
|
||||
) -> Result<Json<PluginIndexResponse>, ApiError> {
|
||||
let mut items = Vec::new();
|
||||
let mut unavailable = Vec::new();
|
||||
let mut problems = Vec::new();
|
||||
|
||||
let commands = super::list_commands(
|
||||
State(state.clone()),
|
||||
Query(CommandsQuery { thread_id: None }),
|
||||
)
|
||||
.await;
|
||||
let mut plugin_commands: HashMap<String, BTreeSet<String>> = HashMap::new();
|
||||
if let Ok(Json(response)) = &commands {
|
||||
for command in &response.commands {
|
||||
if let (Some(plugin_id), "user") = (command.plugin_id.as_ref(), command.kind) {
|
||||
plugin_commands
|
||||
.entry(plugin_id.clone())
|
||||
.or_default()
|
||||
.insert(command.name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match plugins::list_plugins(State(state.clone())).await {
|
||||
Ok(Json(response)) => {
|
||||
let mut omitted = BTreeSet::new();
|
||||
let mut adds_by_id = plugin_adds_by_id(&state, &mut omitted).await;
|
||||
for name in &omitted {
|
||||
problems.push(IndexNote {
|
||||
kind: "plugin",
|
||||
message: format!(
|
||||
"plugin '{name}' adds more files than are listed, or a file name with control characters; some names are left out of `adds`"
|
||||
),
|
||||
});
|
||||
}
|
||||
for diagnostic in &response.registry_diagnostics {
|
||||
problems.push(IndexNote {
|
||||
kind: "plugin",
|
||||
message: diagnostic.message.clone(),
|
||||
});
|
||||
}
|
||||
for entry in &response.plugins {
|
||||
let mut adds = adds_by_id.remove(&entry.id);
|
||||
if entry.state == "active"
|
||||
&& commands.is_ok()
|
||||
&& let Some(adds) = adds.as_mut()
|
||||
{
|
||||
adds.commands = plugin_commands
|
||||
.remove(&entry.id)
|
||||
.map(|names| names.into_iter().collect())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
items.push(IndexItem {
|
||||
id: format!("plugin:{}", entry.id),
|
||||
kind: "plugin",
|
||||
name: entry
|
||||
.display_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| entry.name.clone()),
|
||||
description: entry.description.clone(),
|
||||
source: plugin_source(entry.origin),
|
||||
state: plugin_state(entry),
|
||||
labels: adds
|
||||
.as_ref()
|
||||
.map(|adds| adds.labels(entry))
|
||||
.unwrap_or_default(),
|
||||
plugin: None,
|
||||
adds,
|
||||
tier: None,
|
||||
owner: "/v1/apps/plugins",
|
||||
row: row_value(entry),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(error) => note_failure(&mut unavailable, "plugin", &error),
|
||||
}
|
||||
|
||||
match plugins::list_plugin_catalog(State(state.clone())).await {
|
||||
Ok(Json(catalog)) => {
|
||||
for (tier, rows) in [
|
||||
("builtin", &catalog.builtin),
|
||||
("experimental", &catalog.experimental),
|
||||
("more", &catalog.more),
|
||||
] {
|
||||
for row in rows {
|
||||
items.push(IndexItem {
|
||||
id: format!("plugin:catalog:{}", row.id),
|
||||
kind: "plugin",
|
||||
name: row.title.clone(),
|
||||
description: row.description.clone(),
|
||||
source: "built_in",
|
||||
state: if !row.available {
|
||||
"unavailable"
|
||||
} else if row.enabled {
|
||||
"enabled"
|
||||
} else {
|
||||
"disabled"
|
||||
},
|
||||
labels: Vec::new(),
|
||||
plugin: None,
|
||||
adds: None,
|
||||
tier: Some(tier),
|
||||
owner: "/v1/apps/plugins/catalog",
|
||||
row: row_value(row),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => note_failure(&mut unavailable, "plugin", &error),
|
||||
}
|
||||
|
||||
match super::list_skills(State(state.clone())).await {
|
||||
Ok(Json(response)) => {
|
||||
for warning in &response.warnings {
|
||||
problems.push(IndexNote {
|
||||
kind: "skill",
|
||||
message: warning.clone(),
|
||||
});
|
||||
}
|
||||
for skill in &response.skills {
|
||||
if skill.plugin_id.is_some() {
|
||||
continue;
|
||||
}
|
||||
items.push(IndexItem {
|
||||
id: format!("skill:{}", skill.name),
|
||||
kind: "skill",
|
||||
name: skill.name.clone(),
|
||||
description: Some(skill.description.clone()),
|
||||
source: skill_source(
|
||||
skill.path.as_ref(),
|
||||
skill.is_bundled,
|
||||
&response.directory,
|
||||
&state.workspace,
|
||||
),
|
||||
state: if skill.enabled { "enabled" } else { "disabled" },
|
||||
labels: vec!["skill"],
|
||||
plugin: None,
|
||||
adds: None,
|
||||
tier: None,
|
||||
owner: "/v1/skills",
|
||||
row: row_value(skill),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(error) => note_failure(&mut unavailable, "skill", &error),
|
||||
}
|
||||
|
||||
match super::list_mcp_servers(State(state.clone())).await {
|
||||
Ok(Json(response)) => {
|
||||
for server in &response.servers {
|
||||
let source = match server.origin {
|
||||
"plugin" => continue,
|
||||
"project" => "project",
|
||||
"runtime" => "runtime",
|
||||
_ => "user",
|
||||
};
|
||||
items.push(IndexItem {
|
||||
id: format!("connector:{}", server.name),
|
||||
kind: "connector",
|
||||
name: server.name.clone(),
|
||||
description: None,
|
||||
source,
|
||||
state: if !server.enabled {
|
||||
"disabled"
|
||||
} else if server.auth_required {
|
||||
"needs_sign_in"
|
||||
} else {
|
||||
"enabled"
|
||||
},
|
||||
labels: vec!["connector"],
|
||||
plugin: None,
|
||||
adds: None,
|
||||
tier: None,
|
||||
owner: "/v1/apps/mcp/servers",
|
||||
row: row_value(server),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(error) => note_failure(&mut unavailable, "connector", &error),
|
||||
}
|
||||
|
||||
match super::list_hooks(State(state.clone()), Query(HooksQuery { thread_id: None })).await {
|
||||
Ok(Json(response)) => {
|
||||
for problem in &response.problems {
|
||||
problems.push(IndexNote {
|
||||
kind: "hook",
|
||||
message: problem.clone(),
|
||||
});
|
||||
}
|
||||
let mut ordinals: HashMap<&'static str, usize> = HashMap::new();
|
||||
for hook in &response.hooks {
|
||||
let ordinal = ordinals.entry(hook.event).or_default();
|
||||
*ordinal += 1;
|
||||
let source = match hook.source {
|
||||
"plugin" => continue,
|
||||
"project" => "project",
|
||||
_ => "user",
|
||||
};
|
||||
let name = hook
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{} {}", hook.event, ordinal));
|
||||
items.push(IndexItem {
|
||||
id: format!("hook:{source}:{}:{name}", hook.event),
|
||||
kind: "hook",
|
||||
name,
|
||||
description: None,
|
||||
source,
|
||||
state: if response.enabled {
|
||||
"enabled"
|
||||
} else {
|
||||
"disabled"
|
||||
},
|
||||
labels: vec!["hook"],
|
||||
plugin: None,
|
||||
adds: None,
|
||||
tier: None,
|
||||
owner: "/v1/hooks",
|
||||
row: row_value(hook),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(error) => note_failure(&mut unavailable, "hook", &error),
|
||||
}
|
||||
|
||||
match commands {
|
||||
Ok(Json(response)) => {
|
||||
for command in &response.commands {
|
||||
let (source, plugin) = match (command.kind, command.plugin_id.as_ref()) {
|
||||
("builtin", _) => continue,
|
||||
("user", Some(_)) => continue,
|
||||
("extension", plugin_id) => ("plugin", plugin_id.cloned()),
|
||||
_ if command.workspace_sourced => ("project", None),
|
||||
_ => ("user", None),
|
||||
};
|
||||
items.push(IndexItem {
|
||||
id: format!("command:{}", command.name),
|
||||
kind: "command",
|
||||
name: command.name.clone(),
|
||||
description: command.summary.clone(),
|
||||
source,
|
||||
state: "enabled",
|
||||
labels: vec!["command"],
|
||||
plugin,
|
||||
adds: None,
|
||||
tier: None,
|
||||
owner: "/v1/commands",
|
||||
row: row_value(command),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(error) => note_failure(&mut unavailable, "command", &error),
|
||||
}
|
||||
|
||||
Ok(Json(PluginIndexResponse {
|
||||
workspace: state.workspace.display().to_string(),
|
||||
items,
|
||||
unavailable,
|
||||
problems,
|
||||
}))
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use tokio::sync::{Mutex, mpsc, oneshot};
|
||||
use tokio::time::sleep;
|
||||
use uuid::Uuid;
|
||||
|
||||
mod catalog_parity;
|
||||
mod command_catalog;
|
||||
#[cfg(unix)]
|
||||
mod control_socket_selection;
|
||||
@@ -28,6 +29,7 @@ mod headless_catalog;
|
||||
mod member_operations;
|
||||
#[cfg(unix)]
|
||||
mod owner_bootstrap;
|
||||
mod plugin_index;
|
||||
mod plugin_ui;
|
||||
mod profile_constitution;
|
||||
#[cfg(any(unix, windows))]
|
||||
|
||||
@@ -0,0 +1,600 @@
|
||||
use super::*;
|
||||
use codewhale_config::catalog::{
|
||||
CatalogOffering, CatalogSource, CatalogStatus, ProviderCatalogDelta, base_url_fingerprint,
|
||||
now_unix,
|
||||
};
|
||||
use codewhale_engine::cost_status::EffectiveRouteEnvelope;
|
||||
use codewhale_engine::model_inventory::ModelInventory;
|
||||
use codewhale_runtime::config::ProviderIdentity;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const SINGULAR: &str = "model list / model resolve";
|
||||
const PLURAL: &str = "models";
|
||||
const RUNTIME: &str = "runtime api";
|
||||
const ROUTING: &str = "routing inventory";
|
||||
const COST: &str = "cost";
|
||||
const UNKNOWN: &str = "unknown";
|
||||
|
||||
const ROSTER: &[&str] = &["claude-opus-5", "claude-haiku-4-5", "claude-parity-probe-1"];
|
||||
|
||||
type Readings = BTreeMap<(String, String, &'static str), BTreeMap<&'static str, String>>;
|
||||
|
||||
struct Route {
|
||||
label: &'static str,
|
||||
api_path: &'static str,
|
||||
identity: ProviderIdentity,
|
||||
base_url: String,
|
||||
models: &'static [&'static str],
|
||||
}
|
||||
|
||||
fn cold_process() {
|
||||
codewhale_engine::provider_catalog_live::reset_cache_for_test();
|
||||
codewhale_engine::provider_lake::clear_live_snapshot();
|
||||
}
|
||||
|
||||
fn known<T: ToString>(value: Option<T>) -> String {
|
||||
value.map_or_else(|| UNKNOWN.to_string(), |value| value.to_string())
|
||||
}
|
||||
|
||||
fn rate(value: &Value) -> String {
|
||||
let number = match value {
|
||||
Value::Number(number) => number.as_f64(),
|
||||
Value::String(text) => text.parse::<f64>().ok(),
|
||||
_ => None,
|
||||
};
|
||||
known(number)
|
||||
}
|
||||
|
||||
fn roster(mut models: Vec<String>) -> String {
|
||||
models.sort();
|
||||
models.dedup();
|
||||
models.join(",")
|
||||
}
|
||||
|
||||
fn read(
|
||||
readings: &mut Readings,
|
||||
route: &Route,
|
||||
model: &str,
|
||||
field: &'static str,
|
||||
surface: &'static str,
|
||||
value: String,
|
||||
) {
|
||||
readings
|
||||
.entry((route.label.to_string(), model.to_string(), field))
|
||||
.or_default()
|
||||
.insert(surface, value);
|
||||
}
|
||||
|
||||
fn persist_id_only_roster(identity: &ProviderIdentity, base_url: &str) {
|
||||
let fingerprint = base_url_fingerprint(base_url);
|
||||
let fetched_at = now_unix();
|
||||
let ticket = codewhale_engine::provider_catalog_live::begin_refresh_for_identity(
|
||||
identity.provider,
|
||||
identity.key.as_str(),
|
||||
base_url,
|
||||
);
|
||||
assert_eq!(
|
||||
codewhale_engine::provider_catalog_live::record_success_if_current(
|
||||
&ticket,
|
||||
ProviderCatalogDelta {
|
||||
provider: identity.key.to_string(),
|
||||
base_url_fingerprint: fingerprint.clone(),
|
||||
fetched_at,
|
||||
offerings: ROSTER
|
||||
.iter()
|
||||
.map(|id| CatalogOffering {
|
||||
provider: identity.key.to_string(),
|
||||
wire_model_id: (*id).to_string(),
|
||||
endpoint_key: "chat".into(),
|
||||
source: CatalogSource::Live {
|
||||
base_url_fingerprint: fingerprint.clone(),
|
||||
fetched_at,
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
),
|
||||
Some(CatalogStatus::Fresh)
|
||||
);
|
||||
}
|
||||
|
||||
fn read_singular(readings: &mut Readings, config_path: &Path, route: &Route) -> Result<()> {
|
||||
cold_process();
|
||||
let store = codewhale_config::ConfigStore::load(Some(config_path.to_path_buf()))?;
|
||||
let listed =
|
||||
codewhale_engine::route_preferences::list_models(&store, Some(route.identity.provider))?
|
||||
.into_iter()
|
||||
.find(|(listed, _)| listed == route.identity.key.as_str())
|
||||
.map(|(_, models)| roster(models));
|
||||
read(
|
||||
readings,
|
||||
route,
|
||||
"*",
|
||||
"roster",
|
||||
SINGULAR,
|
||||
listed.unwrap_or_else(|| "route not listed".into()),
|
||||
);
|
||||
for model in route.models {
|
||||
let limits = codewhale_engine::route_preferences::resolve_model(
|
||||
config_path,
|
||||
route.identity.key.as_str(),
|
||||
&route.base_url,
|
||||
Some(model),
|
||||
)
|
||||
.map(|candidate| candidate.limits());
|
||||
let (context, output) = match limits {
|
||||
Ok(limits) => (known(limits.context_tokens), known(limits.output_tokens)),
|
||||
Err(error) => (format!("refused: {error}"), format!("refused: {error}")),
|
||||
};
|
||||
read(readings, route, model, "limit.context", SINGULAR, context);
|
||||
read(readings, route, model, "limit.output", SINGULAR, output);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_plural(readings: &mut Readings, config: &Config, route: &Route) -> Result<()> {
|
||||
cold_process();
|
||||
codewhale_engine::models_dev_live::maybe_load_persisted_cache();
|
||||
let mut route_config = config.clone();
|
||||
route_config
|
||||
.scope_to_provider_identity(&route.identity)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
read(
|
||||
readings,
|
||||
route,
|
||||
"*",
|
||||
"roster",
|
||||
PLURAL,
|
||||
roster(codewhale_engine::provider_lake::listed_models_for_route(
|
||||
&route_config,
|
||||
&route.identity,
|
||||
)),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_runtime(
|
||||
readings: &mut Readings,
|
||||
client: &reqwest::Client,
|
||||
addr: &SocketAddr,
|
||||
route: &Route,
|
||||
) -> Result<()> {
|
||||
let body: Value = client
|
||||
.get(format!("http://{addr}/v1/providers/{}", route.api_path))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
let models = body["models"].as_array().context("models array")?;
|
||||
read(
|
||||
readings,
|
||||
route,
|
||||
"*",
|
||||
"roster",
|
||||
RUNTIME,
|
||||
roster(
|
||||
models
|
||||
.iter()
|
||||
.filter_map(|model| model["id"].as_str().map(str::to_string))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
for model in route.models {
|
||||
let facts = models
|
||||
.iter()
|
||||
.find(|entry| entry["id"] == *model)
|
||||
.map_or(&Value::Null, |entry| &entry["facts"]);
|
||||
read(
|
||||
readings,
|
||||
route,
|
||||
model,
|
||||
"limit.context",
|
||||
RUNTIME,
|
||||
known(facts["effective_limits"]["context_tokens"].as_u64()),
|
||||
);
|
||||
read(
|
||||
readings,
|
||||
route,
|
||||
model,
|
||||
"limit.output",
|
||||
RUNTIME,
|
||||
known(facts["effective_limits"]["output_tokens"].as_u64()),
|
||||
);
|
||||
read(
|
||||
readings,
|
||||
route,
|
||||
model,
|
||||
"window",
|
||||
RUNTIME,
|
||||
known(facts["effective_context"]["tokens"].as_u64()),
|
||||
);
|
||||
read(
|
||||
readings,
|
||||
route,
|
||||
model,
|
||||
"window.source",
|
||||
RUNTIME,
|
||||
known(facts["effective_context"]["source"].as_str()),
|
||||
);
|
||||
read(
|
||||
readings,
|
||||
route,
|
||||
model,
|
||||
"price.input",
|
||||
RUNTIME,
|
||||
rate(&facts["pricing"]["input_per_million"]),
|
||||
);
|
||||
read(
|
||||
readings,
|
||||
route,
|
||||
model,
|
||||
"price.output",
|
||||
RUNTIME,
|
||||
rate(&facts["pricing"]["output_per_million"]),
|
||||
);
|
||||
read(
|
||||
readings,
|
||||
route,
|
||||
model,
|
||||
"price.basis",
|
||||
RUNTIME,
|
||||
format!(
|
||||
"provenance={} status={} source={}",
|
||||
facts["pricing"]["provenance"], facts["pricing_status"], facts["source"]
|
||||
),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_routing(readings: &mut Readings, config: &Config, route: &Route) -> Result<()> {
|
||||
let inventory = ModelInventory::from_config(config).map_err(anyhow::Error::msg)?;
|
||||
let candidates: Vec<_> = inventory
|
||||
.candidates
|
||||
.iter()
|
||||
.filter(|candidate| candidate.identity == route.identity)
|
||||
.collect();
|
||||
read(
|
||||
readings,
|
||||
route,
|
||||
"*",
|
||||
"roster",
|
||||
ROUTING,
|
||||
roster(
|
||||
candidates
|
||||
.iter()
|
||||
.map(|candidate| candidate.model.clone())
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
for model in route.models {
|
||||
let candidate = candidates
|
||||
.iter()
|
||||
.find(|candidate| candidate.model == *model);
|
||||
read(
|
||||
readings,
|
||||
route,
|
||||
model,
|
||||
"window",
|
||||
ROUTING,
|
||||
known(candidate.map(|candidate| candidate.context_window)),
|
||||
);
|
||||
read(
|
||||
readings,
|
||||
route,
|
||||
model,
|
||||
"window.source",
|
||||
ROUTING,
|
||||
known(candidate.map(|candidate| {
|
||||
if candidate.context_window_unverified {
|
||||
"unverified"
|
||||
} else {
|
||||
"verified"
|
||||
}
|
||||
})),
|
||||
);
|
||||
read(
|
||||
readings,
|
||||
route,
|
||||
model,
|
||||
"limit.output",
|
||||
ROUTING,
|
||||
known(candidate.and_then(|candidate| candidate.max_output)),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_cost(readings: &mut Readings, config: &Config, route: &Route) {
|
||||
let usage = Usage {
|
||||
input_tokens: 100_000,
|
||||
output_tokens: 10_000,
|
||||
..Default::default()
|
||||
};
|
||||
for model in route.models {
|
||||
let envelope = EffectiveRouteEnvelope::from_admitted(
|
||||
Some(config),
|
||||
&route.identity,
|
||||
*model,
|
||||
Some(&route.base_url),
|
||||
chrono::Utc::now(),
|
||||
);
|
||||
let quote = envelope.provider_live_pricing.as_ref();
|
||||
let audit = envelope.audit(&usage);
|
||||
read(
|
||||
readings,
|
||||
route,
|
||||
model,
|
||||
"price.input",
|
||||
COST,
|
||||
quote
|
||||
.and_then(|quote| quote.input_per_million.as_deref())
|
||||
.map_or_else(|| UNKNOWN.to_string(), |text| rate(&json!(text))),
|
||||
);
|
||||
read(
|
||||
readings,
|
||||
route,
|
||||
model,
|
||||
"price.output",
|
||||
COST,
|
||||
quote
|
||||
.and_then(|quote| quote.output_per_million.as_deref())
|
||||
.map_or_else(|| UNKNOWN.to_string(), |text| rate(&json!(text))),
|
||||
);
|
||||
read(
|
||||
readings,
|
||||
route,
|
||||
model,
|
||||
"price.basis",
|
||||
COST,
|
||||
format!(
|
||||
"provenance={:?} billing={:?} charge_usd={} unpriced={:?}",
|
||||
quote.map(|quote| quote.provenance.clone()),
|
||||
envelope.billing_mode,
|
||||
known(audit.exact_usd.as_deref()),
|
||||
audit.unpriced_reason,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn one_captured_catalog_reads_the_same_on_every_model_surface() -> Result<()> {
|
||||
let _env = lock_test_env();
|
||||
let _live = codewhale_engine::provider_lake::lock_live_snapshot();
|
||||
let root = tempfile::tempdir()?;
|
||||
let captured = codewhale_config::catalog::BUNDLED_MODELS_DEV_JSON;
|
||||
let catalog_path = root.path().join("captured-models-dev.json");
|
||||
fs::write(&catalog_path, captured)?;
|
||||
let _catalog = EnvVarGuard::set("CODEWHALE_MODELS_DEV_PATH", &catalog_path);
|
||||
let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path());
|
||||
let _config_path = EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
|
||||
let _legacy_config_path = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
|
||||
let _cli = EnvVarGuard::remove(codewhale_config::CLI_API_KEY_ENV);
|
||||
cold_process();
|
||||
|
||||
let config_path = root.path().join("config.toml");
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"provider = "anthropic"
|
||||
|
||||
[providers.anthropic]
|
||||
api_key = "parity-fixture-key"
|
||||
model = "claude-opus-5"
|
||||
|
||||
[providers.zai]
|
||||
api_key = "parity-fixture-key"
|
||||
model = "GLM-5.3"
|
||||
|
||||
[providers.lab]
|
||||
kind = "openai-compatible"
|
||||
base_url = "http://127.0.0.1:9/v1"
|
||||
api_key = "parity-fixture-key"
|
||||
model = "claude-haiku-4-5"
|
||||
"#,
|
||||
)?;
|
||||
let config = Config::load(Some(config_path.clone()), None)?;
|
||||
let route = |label: &'static str,
|
||||
api_path: &'static str,
|
||||
key: &str,
|
||||
models: &'static [&'static str]|
|
||||
-> Result<Route> {
|
||||
let identity = config
|
||||
.resolve_provider_pin_identity(key)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
Ok(Route {
|
||||
label,
|
||||
api_path,
|
||||
base_url: config.base_url_for_route(&identity),
|
||||
identity,
|
||||
models,
|
||||
})
|
||||
};
|
||||
let routes = [
|
||||
route(
|
||||
"anthropic (official, id-only roster)",
|
||||
"anthropic/models",
|
||||
"anthropic",
|
||||
ROSTER,
|
||||
)?,
|
||||
route(
|
||||
"lab (custom endpoint, same spelling)",
|
||||
"custom/models?model_provider_id=lab",
|
||||
"lab",
|
||||
&["claude-haiku-4-5"],
|
||||
)?,
|
||||
route(
|
||||
"zai (plan endpoint)",
|
||||
"zai/models",
|
||||
"zai",
|
||||
&["GLM-5.3", "GLM-5.2"],
|
||||
)?,
|
||||
];
|
||||
|
||||
assert!(
|
||||
codewhale_engine::models_dev_live::refresh(false)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))?
|
||||
> 0
|
||||
);
|
||||
persist_id_only_roster(&routes[0].identity, &routes[0].base_url);
|
||||
|
||||
let mut readings = Readings::new();
|
||||
for route in &routes {
|
||||
read_singular(&mut readings, &config_path, route)?;
|
||||
read_plural(&mut readings, &config, route)?;
|
||||
}
|
||||
cold_process();
|
||||
let (addr, _, handle) = spawn_test_server_with_config_path(config_path.clone())
|
||||
.await?
|
||||
.expect("loopback server required");
|
||||
let client = codewhale_release::tls::reqwest_client();
|
||||
for route in &routes {
|
||||
read_runtime(&mut readings, &client, &addr, route).await?;
|
||||
read_routing(&mut readings, &config, route)?;
|
||||
read_cost(&mut readings, &config, route);
|
||||
}
|
||||
handle.abort();
|
||||
cold_process();
|
||||
|
||||
println!(
|
||||
"PARITY captured_catalog sha256={} bytes={}",
|
||||
Sha256::digest(captured.as_bytes())
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>(),
|
||||
captured.len()
|
||||
);
|
||||
let mut differing = Vec::new();
|
||||
let mut known_differences: Vec<String> = Vec::new();
|
||||
for ((route, model, field), surfaces) in &readings {
|
||||
let mut values: Vec<String> = surfaces
|
||||
.values()
|
||||
.map(|value| {
|
||||
if *field == "roster" {
|
||||
roster(
|
||||
value
|
||||
.to_ascii_lowercase()
|
||||
.split(',')
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
)
|
||||
} else {
|
||||
value.clone()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
values.sort();
|
||||
values.dedup();
|
||||
let compared = !matches!(*field, "price.basis" | "window.source");
|
||||
let borrowed_window = *field == "window"
|
||||
&& surfaces.get(RUNTIME).map(String::as_str) == Some(UNKNOWN)
|
||||
&& readings
|
||||
.get(&(route.clone(), model.clone(), "window.source"))
|
||||
.and_then(|sources| sources.get(ROUTING))
|
||||
.map(String::as_str)
|
||||
== Some("unverified");
|
||||
let verdict = if !compared {
|
||||
"info"
|
||||
} else if borrowed_window {
|
||||
known_differences.push(format!("{route} | {model} | {surfaces:?}"));
|
||||
"KNOWN-DIFFERENCE"
|
||||
} else if values.len() == 1 {
|
||||
"agree"
|
||||
} else {
|
||||
differing.push(format!("{route} | {model} | {field} | {surfaces:?}"));
|
||||
"DIFFER"
|
||||
};
|
||||
println!("PARITY {verdict} | {route} | {model} | {field} | {surfaces:?}");
|
||||
}
|
||||
let value = |route: &Route, model: &str, field: &'static str, surface: &'static str| {
|
||||
readings
|
||||
.get(&(route.label.to_string(), model.to_string(), field))
|
||||
.and_then(|surfaces| surfaces.get(surface))
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
};
|
||||
assert_eq!(
|
||||
value(&routes[0], "*", "roster", SINGULAR),
|
||||
"claude-haiku-4-5,claude-opus-5,claude-parity-probe-1",
|
||||
"the singular command lists the route's own roster, not every bundled row"
|
||||
);
|
||||
assert_eq!(
|
||||
value(&routes[0], "claude-opus-5", "limit.context", RUNTIME),
|
||||
"1000000"
|
||||
);
|
||||
assert_eq!(
|
||||
value(&routes[0], "claude-haiku-4-5", "limit.context", RUNTIME),
|
||||
"200000"
|
||||
);
|
||||
assert_eq!(value(&routes[0], "claude-opus-5", "price.input", COST), "5");
|
||||
assert_eq!(
|
||||
value(&routes[0], "claude-haiku-4-5", "price.output", COST),
|
||||
"5"
|
||||
);
|
||||
for field in ["limit.context", "limit.output", "price.input"] {
|
||||
assert_eq!(
|
||||
value(&routes[1], "claude-haiku-4-5", field, RUNTIME),
|
||||
UNKNOWN,
|
||||
"a custom endpoint borrows no fact from a same-named model: {field}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
value(&routes[1], "claude-haiku-4-5", "window.source", ROUTING),
|
||||
"unverified",
|
||||
"routing never presents a same-named model's window as this endpoint's"
|
||||
);
|
||||
assert_eq!(
|
||||
value(&routes[1], "claude-haiku-4-5", "limit.output", ROUTING),
|
||||
UNKNOWN
|
||||
);
|
||||
for model in ["GLM-5.3", "GLM-5.2"] {
|
||||
let basis = value(&routes[2], model, "price.basis", COST);
|
||||
assert!(
|
||||
basis.contains("NotMoneyMetered") && basis.contains("charge_usd=unknown"),
|
||||
"a plan route is never billed per token: {model}: {basis}"
|
||||
);
|
||||
}
|
||||
for (model, rate) in [("GLM-5.3", UNKNOWN), ("GLM-5.2", "1.4")] {
|
||||
for surface in [COST, RUNTIME] {
|
||||
assert_eq!(
|
||||
value(&routes[2], model, "price.input", surface),
|
||||
rate,
|
||||
"the list rate is shown the same everywhere and never turned into a charge: {model} {surface}"
|
||||
);
|
||||
}
|
||||
}
|
||||
let basis = value(&routes[1], "claude-haiku-4-5", "price.basis", COST);
|
||||
assert!(
|
||||
basis.contains("UnknownBillingBasis") && basis.contains("charge_usd=unknown"),
|
||||
"a custom endpoint is never given a charge: {basis}"
|
||||
);
|
||||
for surface in [COST, RUNTIME] {
|
||||
assert_eq!(
|
||||
value(&routes[0], "claude-parity-probe-1", "price.input", surface),
|
||||
UNKNOWN,
|
||||
"a metered first-party model with no price row reads unknown: {surface}"
|
||||
);
|
||||
}
|
||||
let basis = value(&routes[0], "claude-parity-probe-1", "price.basis", COST);
|
||||
assert!(
|
||||
basis.contains("charge_usd=unknown") && !basis.contains("unpriced=None"),
|
||||
"an unpriced model produces no charge and says why: {basis}"
|
||||
);
|
||||
assert_eq!(
|
||||
known_differences,
|
||||
vec![
|
||||
"lab (custom endpoint, same spelling) | claude-haiku-4-5 | {\"routing inventory\": \"200000\", \"runtime api\": \"unknown\"}"
|
||||
],
|
||||
"the only named difference is the custom endpoint's borrowed routing window"
|
||||
);
|
||||
assert!(
|
||||
differing.is_empty(),
|
||||
"surfaces disagree on the same captured catalog:\n{}",
|
||||
differing.join("\n")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -28,6 +28,8 @@ fn user(name: &str) -> UserCommand {
|
||||
takes_arguments: false,
|
||||
hidden: false,
|
||||
extension: false,
|
||||
plugin_id: None,
|
||||
workspace_sourced: false,
|
||||
builtin_bindings: vec![name.to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
use super::*;
|
||||
use codewhale_engine::command_catalog::{BuiltinCommand, CommandCatalog, UserCommand};
|
||||
|
||||
struct Catalog(Arc<std::sync::Mutex<Option<String>>>);
|
||||
|
||||
impl CommandCatalog for Catalog {
|
||||
fn answers_to(&self, _name: &str) -> bool {
|
||||
false
|
||||
}
|
||||
fn builtins(&self) -> Vec<BuiltinCommand> {
|
||||
Vec::new()
|
||||
}
|
||||
fn user_commands(&self, _workspace: &Path) -> Vec<UserCommand> {
|
||||
let plugin_id = self.0.lock().expect("plugin id").clone();
|
||||
let command = |name: &str, plugin: Option<String>| UserCommand {
|
||||
name: name.to_string(),
|
||||
aliases: Vec::new(),
|
||||
description: Some("A command.".to_string()),
|
||||
usage: None,
|
||||
takes_arguments: false,
|
||||
hidden: false,
|
||||
extension: false,
|
||||
plugin_id: plugin,
|
||||
workspace_sourced: false,
|
||||
builtin_bindings: Vec::new(),
|
||||
};
|
||||
vec![command("mine", None), command("ship", plugin_id)]
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_index_lists_every_kind_once_and_a_plugin_carries_what_it_adds() -> Result<()> {
|
||||
let _env = lock_test_env();
|
||||
let temp = tempfile::tempdir()?;
|
||||
let root = temp.path().join("index-route");
|
||||
let workspace = root.join("workspace");
|
||||
fs::create_dir_all(&workspace)?;
|
||||
let plugin_id = Arc::new(std::sync::Mutex::new(None));
|
||||
let _catalog = codewhale_engine::command_catalog::CatalogGuard::install(Arc::new(Catalog(
|
||||
plugin_id.clone(),
|
||||
)));
|
||||
let sources = root.join("sources");
|
||||
fs::create_dir_all(&sources)?;
|
||||
let bundle = write_plugin_source_bundle(&sources, "indexed")?;
|
||||
fs::create_dir_all(bundle.join("commands"))?;
|
||||
fs::write(bundle.join("commands/ship.md"), "Ship it.\n")?;
|
||||
fs::create_dir_all(bundle.join("agents"))?;
|
||||
fs::write(bundle.join("agents/reviewer.toml"), "name = \"reviewer\"\n")?;
|
||||
fs::write(
|
||||
bundle.join("plugin.toml"),
|
||||
"schema_version = 1\n[plugin]\nname = \"indexed\"\nversion = \"1.0.0\"\n\
|
||||
description = \"test bundle\"\n\n[skills]\npath = \"skills\"\n\
|
||||
[commands]\npath = \"commands\"\n[agents]\npath = \"agents\"\n",
|
||||
)?;
|
||||
|
||||
let Some((addr, handle)) = spawn_plugin_api_server(root, workspace).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = codewhale_release::tls::reqwest_client();
|
||||
plugin_ui::install_active(
|
||||
&client,
|
||||
&format!("http://{addr}/v1/apps/plugins"),
|
||||
bundle.display().to_string(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let listed: Value = client
|
||||
.get(format!("http://{addr}/v1/apps/plugins"))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
let id = listed["plugins"]
|
||||
.as_array()
|
||||
.and_then(|plugins| plugins.iter().find(|plugin| plugin["name"] == "indexed"))
|
||||
.and_then(|plugin| plugin["id"].as_str())
|
||||
.expect("installed plugin id")
|
||||
.to_string();
|
||||
*plugin_id.lock().expect("plugin id") = Some(id.clone());
|
||||
|
||||
let commands: Value = client
|
||||
.get(format!("http://{addr}/v1/commands"))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
let ship = commands["commands"]
|
||||
.as_array()
|
||||
.and_then(|rows| rows.iter().find(|row| row["name"] == "ship"))
|
||||
.expect("ship row");
|
||||
assert_eq!(ship["plugin_id"], id.as_str());
|
||||
|
||||
let index: Value = client
|
||||
.get(format!("http://{addr}/v1/apps/plugin-index"))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
let items = index["items"].as_array().expect("items");
|
||||
let find = |id: &str| items.iter().find(|item| item["id"] == id);
|
||||
|
||||
let plugin = items
|
||||
.iter()
|
||||
.find(|item| item["kind"] == "plugin" && item["row"]["name"] == "indexed")
|
||||
.unwrap_or_else(|| panic!("plugin row in {index}"));
|
||||
assert_eq!(plugin["kind"], "plugin");
|
||||
assert_eq!(plugin["state"], "enabled");
|
||||
assert_eq!(plugin["adds"]["skills"], serde_json::json!(["greet"]));
|
||||
assert_eq!(plugin["adds"]["commands"], serde_json::json!(["ship"]));
|
||||
assert_eq!(plugin["adds"]["profiles"], serde_json::json!(["reviewer"]));
|
||||
assert_eq!(
|
||||
plugin["labels"],
|
||||
serde_json::json!(["skill", "command", "profile"])
|
||||
);
|
||||
assert_eq!(plugin["row"]["name"], "indexed");
|
||||
|
||||
assert!(find("skill:greet").is_none());
|
||||
assert!(find("command:ship").is_none());
|
||||
let mine = find("command:mine").expect("a hand-written command is a row");
|
||||
assert_eq!(mine["source"], "user");
|
||||
assert_eq!(mine["labels"], serde_json::json!(["command"]));
|
||||
assert!(
|
||||
items
|
||||
.iter()
|
||||
.any(|item| item["kind"] == "plugin" && item["tier"] == "builtin"),
|
||||
"built-in switches are rows"
|
||||
);
|
||||
assert_eq!(
|
||||
index["unavailable"].as_array().map(Vec::len),
|
||||
Some(0),
|
||||
"{index}"
|
||||
);
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
@@ -79,7 +79,7 @@ fn write_native_ui_bundle(dir: &Path, name: &str) -> Result<PathBuf> {
|
||||
Ok(bundle)
|
||||
}
|
||||
|
||||
async fn install_active(
|
||||
pub(super) async fn install_active(
|
||||
client: &reqwest::Client,
|
||||
base: &str,
|
||||
source: String,
|
||||
|
||||
@@ -3,13 +3,13 @@ mod acp_support;
|
||||
use acp_support::{Client, fixture_config, text_of, updates};
|
||||
use anyhow::{Result, anyhow};
|
||||
use codewhale_app_server::acp_server::capture_frontend;
|
||||
use codewhale_engine::llm_client::mock::{MockLlmClient, canned};
|
||||
use codewhale_engine::llm_client::mock::{CannedTurn, MockLlmClient, canned};
|
||||
use codewhale_engine::runtime_threads::RuntimeTurnStatus;
|
||||
use codewhale_engine::runtime_threads::manager::{
|
||||
RuntimeThreadManager, RuntimeThreadManagerConfig,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncWriteExt, BufReader};
|
||||
@@ -497,3 +497,346 @@ async fn extension_journey() -> Result<()> {
|
||||
host.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const GATES_MCP_FIXTURE: &str = r#"import readline from 'node:readline';
|
||||
readline.createInterface({input:process.stdin}).on('line', line => {
|
||||
const r = JSON.parse(line); if (r.id === undefined) return;
|
||||
let result = {};
|
||||
if (r.method === 'initialize') result = {protocolVersion:'2024-11-05',capabilities:{tools:{}},serverInfo:{name:'fixture',version:'1'}};
|
||||
if (r.method === 'tools/list') result = {tools:['echo','hidden'].map(name => ({name,description:'Echo text',inputSchema:{type:'object',properties:{text:{type:'string'}},required:['text']}}))};
|
||||
if (r.method === 'tools/call') result = {content:[{type:'text',text:'mcp-ran:' + r.params.name + ':' + r.params.arguments.text}]};
|
||||
process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:r.id,result}) + '\n');
|
||||
});
|
||||
"#;
|
||||
|
||||
struct Outcome {
|
||||
response: Value,
|
||||
frames: Vec<Value>,
|
||||
asked: Vec<Value>,
|
||||
}
|
||||
|
||||
impl Outcome {
|
||||
fn rendered(&self) -> String {
|
||||
serde_json::to_string(&self.frames).unwrap_or_default()
|
||||
}
|
||||
fn completed(&self) -> usize {
|
||||
updates(&self.frames, "tool_call_update")
|
||||
.iter()
|
||||
.filter(|frame| frame.pointer("/params/update/status") == Some(&json!("completed")))
|
||||
.count()
|
||||
}
|
||||
fn titles(&self) -> Vec<String> {
|
||||
self.asked
|
||||
.iter()
|
||||
.map(|frame| {
|
||||
frame["params"]["toolCall"]["title"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn announced_ids(&self) -> Vec<String> {
|
||||
updates(&self.frames, "tool_call")
|
||||
.iter()
|
||||
.filter_map(|frame| frame.pointer("/params/update/toolCallId")?.as_str())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_prompt(
|
||||
client: &mut Client,
|
||||
session: &str,
|
||||
allow: &dyn Fn(usize) -> bool,
|
||||
) -> Result<Outcome> {
|
||||
client.next_id += 1;
|
||||
let prompt_id = client.next_id;
|
||||
client
|
||||
.send(
|
||||
json!({"jsonrpc":"2.0","id":prompt_id,"method":"session/prompt","params":{
|
||||
"sessionId":session,"prompt":[{"type":"text","text":"go"}]}}),
|
||||
)
|
||||
.await?;
|
||||
let mut frames = Vec::new();
|
||||
let mut asked = Vec::new();
|
||||
let response = loop {
|
||||
let frame = client.recv().await?;
|
||||
if frame["method"] == "session/request_permission" {
|
||||
let option = if allow(asked.len()) {
|
||||
"allow-once"
|
||||
} else {
|
||||
"reject-once"
|
||||
};
|
||||
client
|
||||
.send(json!({"jsonrpc":"2.0","id":frame["id"],
|
||||
"result":{"outcome":{"outcome":"selected","optionId":option}}}))
|
||||
.await?;
|
||||
asked.push(frame);
|
||||
} else if frame["id"] == json!(prompt_id) && frame.get("method").is_none() {
|
||||
break frame;
|
||||
} else {
|
||||
frames.push(frame);
|
||||
}
|
||||
};
|
||||
Ok(Outcome {
|
||||
response,
|
||||
frames,
|
||||
asked,
|
||||
})
|
||||
}
|
||||
|
||||
async fn new_session(
|
||||
client: &mut Client,
|
||||
manager: &RuntimeThreadManager,
|
||||
workspace: &Path,
|
||||
turns: Vec<CannedTurn>,
|
||||
plan: bool,
|
||||
) -> Result<String> {
|
||||
manager.set_test_model_client(Arc::new(MockLlmClient::new(turns)));
|
||||
let (created, _) = client
|
||||
.request("session/new", json!({"cwd":workspace}))
|
||||
.await?;
|
||||
let session = created["result"]["sessionId"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("session/new returned no sessionId: {created}"))?
|
||||
.to_string();
|
||||
if plan {
|
||||
let (set, _) = client
|
||||
.request(
|
||||
"session/set_mode",
|
||||
json!({"sessionId":session,"modeId":"plan"}),
|
||||
)
|
||||
.await?;
|
||||
assert!(set.get("error").is_none(), "{set}");
|
||||
}
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_tools_keep_the_permission_boundary_in_an_acp_session() -> Result<()> {
|
||||
std::thread::Builder::new()
|
||||
.stack_size(64 << 20)
|
||||
.spawn(|| {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?
|
||||
.block_on(async {
|
||||
tokio::time::timeout(Duration::from_secs(180), gates())
|
||||
.await
|
||||
.map_err(|_| anyhow!("extension gates exceeded 180s"))?
|
||||
})
|
||||
})?
|
||||
.join()
|
||||
.map_err(|_| anyhow!("extension gates thread panicked"))?
|
||||
}
|
||||
|
||||
async fn gates() -> Result<()> {
|
||||
use codewhale_engine::extension_host::test_support::{FixturePlugins, node_for_tests};
|
||||
let Some(node) = node_for_tests("extension_tools_keep_the_permission_boundary") else {
|
||||
return Ok(());
|
||||
};
|
||||
let _policy = codewhale_plugins::activation::TestPolicyGuard::extension_host(true);
|
||||
let fixture = FixturePlugins::new(&["plugin-context", "core-call"]).await;
|
||||
let host = fixture.manager(node.clone());
|
||||
let _host = codewhale_engine::extension_host::TestManagerGuard::install(Arc::clone(&host));
|
||||
let dir = tempfile::tempdir()?;
|
||||
let _home = codewhale_engine::test_support::SealedHome::at(dir.path());
|
||||
let workspace = fixture.workspace().to_path_buf();
|
||||
std::fs::write(workspace.join("seed.txt"), "seed-content-xyz")?;
|
||||
let server = dir.path().join("fixture-mcp.mjs");
|
||||
std::fs::write(&server, GATES_MCP_FIXTURE)?;
|
||||
let mcp_config = dir.path().join("mcp.json");
|
||||
std::fs::write(
|
||||
&mcp_config,
|
||||
serde_json::to_vec(&json!({"servers":{
|
||||
"fixture":{"command":node,"args":[server],"disabled_tools":["hidden"]},
|
||||
"off":{"command":node,"args":[server],"disabled":true}
|
||||
}}))?,
|
||||
)?;
|
||||
let runtime_dir = dir.path().join("runtime");
|
||||
let mut config = fixture_config();
|
||||
config.set_feature("mcp", true).unwrap();
|
||||
config.set_feature("extension_host", true).unwrap();
|
||||
config.mcp_config_path = Some(mcp_config.to_string_lossy().into_owned());
|
||||
let manager = Arc::new(RuntimeThreadManager::open_host(
|
||||
config.clone(),
|
||||
workspace.clone(),
|
||||
RuntimeThreadManagerConfig {
|
||||
mcp_auth_context: Default::default(),
|
||||
data_dir: runtime_dir.clone(),
|
||||
task_data_dir: runtime_dir,
|
||||
sessions_dir: None,
|
||||
max_active_threads: 16,
|
||||
},
|
||||
fixture.registry(),
|
||||
codewhale_engine::core::engine::EngineHostProfile::Acp,
|
||||
None,
|
||||
None,
|
||||
)?);
|
||||
let frontend = capture_frontend(
|
||||
config,
|
||||
"fixture-model".into(),
|
||||
workspace.clone(),
|
||||
Arc::clone(&manager),
|
||||
codewhale_engine::session_manager::default_sessions_dir()?,
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
let (mut client, agent_read, agent_write) = Client::connect(false);
|
||||
let connection = frontend.serve(Box::new(BufReader::new(agent_read)), Box::new(agent_write));
|
||||
let manager_for_script = Arc::clone(&manager);
|
||||
let script =
|
||||
async {
|
||||
let manager = manager_for_script;
|
||||
let result = async {
|
||||
client
|
||||
.request(
|
||||
"initialize",
|
||||
json!({"protocolVersion":1,"clientCapabilities":{}}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let session = new_session(
|
||||
&mut client,
|
||||
&manager,
|
||||
&workspace,
|
||||
vec![
|
||||
canned::tool_call_turn(
|
||||
"n1",
|
||||
"cc_call",
|
||||
r#"{"name":"write","input":{"path":"nested.txt","content":"via-plugin"}}"#,
|
||||
),
|
||||
canned::simple_text_turn("nested allowed"),
|
||||
],
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
let allowed = run_prompt(&mut client, &session, &|_| true).await?;
|
||||
assert!(allowed.response.get("error").is_none(), "{}", allowed.response);
|
||||
assert_eq!(allowed.response["result"]["stopReason"], "end_turn");
|
||||
assert_eq!(allowed.asked.len(), 2, "{:?}", allowed.titles());
|
||||
assert!(allowed.titles()[0].starts_with("cc_call"));
|
||||
let announced = allowed.announced_ids();
|
||||
for request in &allowed.asked {
|
||||
let id = request["params"]["toolCall"]["toolCallId"]
|
||||
.as_str()
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
announced.iter().any(|known| known == id),
|
||||
"a nested approval is shown on a tool call the editor knows: {id} {announced:?}"
|
||||
);
|
||||
}
|
||||
let nested = &allowed.asked[1]["params"]["toolCall"];
|
||||
assert_eq!(nested["title"], "write: nested.txt", "{nested}");
|
||||
assert_eq!(nested["kind"], "edit", "{nested}");
|
||||
assert_eq!(nested["rawInput"]["path"], "nested.txt", "{nested}");
|
||||
assert!(nested.get("status").is_none(), "{nested}");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(workspace.join("nested.txt"))?,
|
||||
"via-plugin"
|
||||
);
|
||||
|
||||
std::fs::remove_file(workspace.join("nested.txt"))?;
|
||||
let session = new_session(
|
||||
&mut client,
|
||||
&manager,
|
||||
&workspace,
|
||||
vec![
|
||||
canned::tool_call_turn(
|
||||
"n2",
|
||||
"cc_call",
|
||||
r#"{"name":"write","input":{"path":"nested.txt","content":"via-plugin"}}"#,
|
||||
),
|
||||
canned::simple_text_turn("nested denied"),
|
||||
],
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
let denied = run_prompt(&mut client, &session, &|index| index == 0).await?;
|
||||
assert!(denied.response.get("error").is_none(), "{}", denied.response);
|
||||
assert_eq!(denied.response["result"]["stopReason"], "end_turn");
|
||||
assert_eq!(denied.asked.len(), 2, "{:?}", denied.titles());
|
||||
assert!(
|
||||
!workspace.join("nested.txt").exists(),
|
||||
"a rejected nested write must not run"
|
||||
);
|
||||
|
||||
let session = new_session(
|
||||
&mut client,
|
||||
&manager,
|
||||
&workspace,
|
||||
vec![
|
||||
canned::tool_call_turn("r1", "ctx_probe", "{}"),
|
||||
canned::tool_call_turn("r2", "mcp_fixture_echo", r#"{"text":"control"}"#),
|
||||
canned::tool_call_turn("r3", "ctx_note", r#"{"text":"must-not-be-written"}"#),
|
||||
canned::tool_call_turn("r4", "mcp_fixture_echo", r#"{"text":"rejected"}"#),
|
||||
canned::simple_text_turn("rejections done"),
|
||||
],
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
let rejected = run_prompt(&mut client, &session, &|index| index < 2).await?;
|
||||
assert_eq!(rejected.asked.len(), 4, "{:?}", rejected.titles());
|
||||
let rendered = rejected.rendered();
|
||||
assert!(rendered.contains("mcp-ran:echo:control"), "{rendered}");
|
||||
assert!(!rendered.contains("mcp-ran:echo:rejected"), "{rendered}");
|
||||
assert!(!rendered.contains("note.txt"), "{rendered}");
|
||||
|
||||
let session = new_session(
|
||||
&mut client,
|
||||
&manager,
|
||||
&workspace,
|
||||
vec![
|
||||
canned::tool_call_turn("p1", "ctx_probe", "{}"),
|
||||
canned::tool_call_turn("p2", "mcp_fixture_echo", r#"{"text":"plan"}"#),
|
||||
canned::simple_text_turn("plan done"),
|
||||
],
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
let plan = run_prompt(&mut client, &session, &|_| true).await?;
|
||||
assert!(plan.asked.is_empty(), "{:?}", plan.titles());
|
||||
assert_eq!(plan.completed(), 0, "{}", plan.rendered());
|
||||
assert!(!plan.rendered().contains("mcp-ran:"));
|
||||
|
||||
let session = new_session(
|
||||
&mut client,
|
||||
&manager,
|
||||
&workspace,
|
||||
vec![
|
||||
canned::tool_call_turn("f1", "mcp_fixture_hidden", r#"{"text":"hid"}"#),
|
||||
canned::tool_call_turn("f2", "mcp_off_echo", r#"{"text":"off"}"#),
|
||||
canned::tool_call_turn("f3", "mcp_evil_echo", r#"{"text":"evil"}"#),
|
||||
canned::tool_call_turn(
|
||||
"f4",
|
||||
"start_mcp_server",
|
||||
r#"{"name":"evil","command":"node","args":["-e","0"]}"#,
|
||||
),
|
||||
canned::tool_call_turn("f5", "agent", r#"{"action":"start","prompt":"p"}"#),
|
||||
canned::simple_text_turn("fabricated done"),
|
||||
],
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
let fabricated = run_prompt(&mut client, &session, &|_| true).await?;
|
||||
assert!(fabricated.asked.is_empty(), "{:?}", fabricated.titles());
|
||||
assert_eq!(fabricated.completed(), 0, "{}", fabricated.rendered());
|
||||
assert!(!fabricated.rendered().contains("mcp-ran:"));
|
||||
|
||||
client.request("shutdown", json!({})).await?;
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
if result.is_err() {
|
||||
let _ = client.write.shutdown().await;
|
||||
}
|
||||
result
|
||||
};
|
||||
let (served, scripted) = tokio::join!(connection, script);
|
||||
served?;
|
||||
scripted?;
|
||||
manager.shutdown_and_wait().await?;
|
||||
host.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1313,7 +1313,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
|
||||
def(
|
||||
"telemetry",
|
||||
SettingKind::Bool(TELEMETRY),
|
||||
"false",
|
||||
"true",
|
||||
ui(
|
||||
TAB_TRUST,
|
||||
"network",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -138,6 +138,16 @@ pub struct SystemOneResponse {
|
||||
pub answers_validated: Option<bool>,
|
||||
}
|
||||
|
||||
impl SystemOneResponse {
|
||||
pub fn abstained_questions(&self, names: &[&str]) -> Vec<String> {
|
||||
names
|
||||
.iter()
|
||||
.filter(|name| self.answers.get(**name).is_some_and(|a| a.abstained))
|
||||
.map(|name| (*name).to_string())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for SystemOneResponse {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
@@ -201,6 +211,8 @@ pub struct SystemOneAnswer {
|
||||
pub probabilities: BTreeMap<String, Option<f64>>,
|
||||
#[serde(default)]
|
||||
pub confidence: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub abstained: bool,
|
||||
}
|
||||
|
||||
/// Provider usage. OpenRouter adds `cost`; the token-count casing differs
|
||||
|
||||
@@ -29,6 +29,8 @@ pub struct UserCommand {
|
||||
pub takes_arguments: bool,
|
||||
pub hidden: bool,
|
||||
pub extension: bool,
|
||||
pub plugin_id: Option<String>,
|
||||
pub workspace_sourced: bool,
|
||||
pub builtin_bindings: Vec<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -456,7 +456,7 @@ async fn captured_identity_cancel_and_task_bounds_refuse_before_any_provider() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn dropping_an_actual_dispatched_core_call_keeps_unknown_usage_without_retaining_authority() {
|
||||
struct Pending;
|
||||
struct Pending(Arc<tokio::sync::Notify>);
|
||||
impl Replies for Pending {
|
||||
fn effective_route_envelope(
|
||||
&self,
|
||||
@@ -478,24 +478,42 @@ async fn dropping_an_actual_dispatched_core_call_keeps_unknown_usage_without_ret
|
||||
&self,
|
||||
_: MessageRequest,
|
||||
) -> Pin<Box<dyn Future<Output = anyhow::Result<MessageResponse>> + Send + '_>> {
|
||||
self.0.notify_one();
|
||||
Box::pin(std::future::pending())
|
||||
}
|
||||
}
|
||||
let caller = Arc::new(caller_for(Arc::new(Pending), "captured-model"));
|
||||
let entered = Arc::new(tokio::sync::Notify::new());
|
||||
let caller = Arc::new(caller_for(
|
||||
Arc::new(Pending(entered.clone())),
|
||||
"captured-model",
|
||||
));
|
||||
let weak_caller = Arc::downgrade(&caller);
|
||||
let usage = RlmUsageAccumulator::new();
|
||||
let result = caller
|
||||
.dispatch(RlmInvocation {
|
||||
prompt: "pending".into(),
|
||||
mode: RlmMode::Completion,
|
||||
max_tokens: None,
|
||||
task_instructions: None,
|
||||
deadline: Some(tokio::time::Instant::now() + Duration::from_millis(100)),
|
||||
gate: None,
|
||||
events: None,
|
||||
usage: usage.clone(),
|
||||
})
|
||||
.await;
|
||||
let mut dispatch = caller.dispatch(RlmInvocation {
|
||||
prompt: "pending".into(),
|
||||
mode: RlmMode::Completion,
|
||||
max_tokens: None,
|
||||
task_instructions: None,
|
||||
deadline: Some(tokio::time::Instant::now() + Duration::from_secs(600)),
|
||||
gate: None,
|
||||
events: None,
|
||||
usage: usage.clone(),
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = entered.notified() => {}
|
||||
result = &mut dispatch => panic!(
|
||||
"dropped dispatch never reached the client: {:?}",
|
||||
result.error
|
||||
),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("the dispatched call must reach the client");
|
||||
tokio::time::pause();
|
||||
tokio::time::advance(Duration::from_secs(601)).await;
|
||||
let result = dispatch.await;
|
||||
assert!(
|
||||
result
|
||||
.error
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2643,6 +2643,7 @@ pub fn decision_receipt_fixture(source_id: &str) -> RuntimeDecisionReceipt {
|
||||
probabilities_bp: Default::default(),
|
||||
confidence_bp: 0,
|
||||
min_confidence_bp: 5_000,
|
||||
abstained_questions: Vec::new(),
|
||||
cost_saving_kept_fast: false,
|
||||
thinking: None,
|
||||
provider_reported_cost_usd: Some("0.000012054".to_string()),
|
||||
|
||||
@@ -282,31 +282,20 @@ impl ModelInventory {
|
||||
// vendor-family heuristic) carries the number *and* the fact
|
||||
// that nobody verified it — the auto-router must not read a
|
||||
// guessed window as a route capability.
|
||||
let mut context_window_unverified =
|
||||
codewhale_config::catalog::reviewed::intrinsic_model(&model)
|
||||
.and_then(|row| row.context_window)
|
||||
.is_none();
|
||||
let mut context_window_unverified = true;
|
||||
if let Ok(route) = crate::route_runtime::resolve_runtime_route_for_identity(
|
||||
config,
|
||||
&identity,
|
||||
Some(&model),
|
||||
) {
|
||||
if let Some(context_window) = route.candidate.limits().context_tokens {
|
||||
capability.context_window = context_window.min(u64::from(u32::MAX)) as u32;
|
||||
context_window_unverified = !route.context_window.source.is_verified();
|
||||
}
|
||||
// A concrete offering maximum is a stronger fact than the
|
||||
// static compatibility matrix — and is the only way a
|
||||
// membership route (no static cap) gets a known ceiling.
|
||||
if let Some(max_output) = route
|
||||
capability.context_window = route.context_window.tokens;
|
||||
context_window_unverified = !route.context_window.source.is_verified();
|
||||
capability.max_output = route
|
||||
.candidate
|
||||
.limits()
|
||||
.output_tokens
|
||||
.and_then(|tokens| u32::try_from(tokens).ok())
|
||||
.filter(|tokens| *tokens > 0)
|
||||
{
|
||||
capability.max_output = Some(max_output);
|
||||
}
|
||||
.filter(|tokens| *tokens > 0);
|
||||
user_declared = route
|
||||
.candidate
|
||||
.applied_limit_overrides()
|
||||
@@ -316,13 +305,6 @@ impl ModelInventory {
|
||||
== codewhale_config::route::OverrideSource::UserModelMetadata
|
||||
});
|
||||
if user_declared {
|
||||
context_window_unverified = !route.context_window.source.is_verified();
|
||||
capability.context_window = route.context_window.tokens;
|
||||
capability.max_output = route
|
||||
.candidate
|
||||
.limits()
|
||||
.output_tokens
|
||||
.and_then(|value| u32::try_from(value).ok());
|
||||
capability.thinking_supported = route.candidate.capabilities().reasoning
|
||||
== codewhale_config::route::CapabilityState::Supported;
|
||||
}
|
||||
|
||||
@@ -1852,6 +1852,7 @@ pub struct ValidChoice {
|
||||
pub choice: String,
|
||||
pub probabilities_bp: BTreeMap<String, u16>,
|
||||
pub confidence_bp: u16,
|
||||
pub abstained: bool,
|
||||
}
|
||||
|
||||
/// Validate one `choice` answer against the offered options. Any violation
|
||||
@@ -1894,6 +1895,7 @@ pub fn validated_choice(answer: Option<&SystemOneAnswer>, options: &[&str]) -> O
|
||||
choice: choice.to_string(),
|
||||
probabilities_bp,
|
||||
confidence_bp: probability_bp(confidence),
|
||||
abstained: answer.abstained,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1982,6 +1984,7 @@ fn decision_attempt_from_response(
|
||||
probabilities_bp: Default::default(),
|
||||
confidence_bp: 0,
|
||||
min_confidence_bp: inventory.router_min_confidence_bp,
|
||||
abstained_questions: response.abstained_questions(&["tier", "thinking"]),
|
||||
cost_saving_kept_fast: false,
|
||||
thinking: None,
|
||||
provider_reported_cost_usd: response
|
||||
@@ -2005,17 +2008,18 @@ fn decision_attempt_from_response(
|
||||
let min_confidence_bp = inventory.router_min_confidence_bp;
|
||||
// An invalid or unsure `thinking` answer drops only the effort.
|
||||
let thinking = validated_choice(response.answers.get("thinking"), &DECISION_THINKING_OPTIONS)
|
||||
.filter(|thinking| thinking.confidence_bp >= min_confidence_bp)
|
||||
.filter(|thinking| !thinking.abstained && thinking.confidence_bp >= min_confidence_bp)
|
||||
.map(|thinking| thinking.choice);
|
||||
let strong_bp = tier.probabilities_bp.get("strong").copied().unwrap_or(0);
|
||||
let cost_saving_kept_fast =
|
||||
cost_saving && tier.choice == "strong" && strong_bp < COST_SAVING_STRONG_MIN_BP;
|
||||
let acted = tier.confidence_bp >= min_confidence_bp;
|
||||
let acted = !tier.abstained && tier.confidence_bp >= min_confidence_bp;
|
||||
attempt.decision = Some(AutoRouteDecisionEvidence {
|
||||
choice: tier.choice.clone(),
|
||||
probabilities_bp: tier.probabilities_bp,
|
||||
confidence_bp: tier.confidence_bp,
|
||||
min_confidence_bp,
|
||||
abstained_questions: response.abstained_questions(&["tier", "thinking"]),
|
||||
cost_saving_kept_fast: acted && cost_saving_kept_fast,
|
||||
thinking: thinking.clone().filter(|_| acted),
|
||||
provider_reported_cost_usd: response
|
||||
@@ -4187,6 +4191,60 @@ mod decision_router_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_abstention_survives_decode_and_never_authorizes_auto_at_floor_zero() {
|
||||
let _env = hermetic_env();
|
||||
let mut config = decision_config("https://openrouter.invalid/api/v1", false, 2);
|
||||
config
|
||||
.auto
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.router
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.min_confidence = Some(0.0);
|
||||
let inventory = ModelInventory::from_config(&config).unwrap();
|
||||
let attempt = |body: serde_json::Value| {
|
||||
decision_attempt_from_response(false, &inventory, &pair(), None, &parsed(body), 120)
|
||||
};
|
||||
let mut body = answer_body(0.1, 0.9);
|
||||
body["answers"]["thinking"]["abstained"] = true.into();
|
||||
let effort_abstained = attempt(body.clone());
|
||||
let recommendation = effort_abstained
|
||||
.recommendation
|
||||
.expect("valid tier survives effort abstention");
|
||||
assert_eq!(recommendation.model, "deepseek-v4-flash");
|
||||
assert_eq!(recommendation.reasoning_effort, None);
|
||||
assert_eq!(
|
||||
effort_abstained.decision.unwrap().abstained_questions,
|
||||
vec!["thinking"]
|
||||
);
|
||||
body["answers"]["tier"]["abstained"] = true.into();
|
||||
let tier_abstained = attempt(body);
|
||||
assert!(tier_abstained.recommendation.is_none());
|
||||
assert_eq!(
|
||||
tier_abstained.fallback_reason,
|
||||
Some(AutoRouteHeuristicReason::LowConfidence)
|
||||
);
|
||||
assert_eq!(tier_abstained.failure, None);
|
||||
let decision = tier_abstained.decision.unwrap();
|
||||
assert_eq!(decision.abstained_questions, vec!["tier", "thinking"]);
|
||||
assert_eq!(decision.choice, "fast");
|
||||
assert_eq!(
|
||||
decision.provider_reported_cost_usd.as_deref(),
|
||||
Some("0.000019992")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_boolean_abstention_is_refused_not_ignored() {
|
||||
let mut body = answer_body(0.1, 0.9);
|
||||
body["answers"]["tier"]["abstained"] = "yes".into();
|
||||
let attempt = policy(false, &parsed(body));
|
||||
assert!(attempt.recommendation.is_none());
|
||||
assert_eq!(attempt.failure, Some(AutoRouterFailure::InvalidAnswer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_answers_are_rejected_not_repaired() {
|
||||
let mut outside = answer_body(0.8, 0.6);
|
||||
|
||||
@@ -1455,6 +1455,24 @@ pub fn configured_catalog_models_for_route(
|
||||
ids
|
||||
}
|
||||
|
||||
pub fn listed_models_for_route(route_config: &Config, identity: &ProviderIdentity) -> Vec<String> {
|
||||
let mut models = configured_catalog_models_for_route(
|
||||
route_config,
|
||||
identity.provider,
|
||||
identity.key.as_str(),
|
||||
&route_config.active_route_base_url(),
|
||||
);
|
||||
if identity.provider != ProviderKind::OpenaiCodex {
|
||||
let default_model = route_config.default_model();
|
||||
if !default_model.is_empty() && !default_model.eq_ignore_ascii_case("auto") {
|
||||
push_unique_model(&mut models, &default_model);
|
||||
}
|
||||
models.sort();
|
||||
models.dedup();
|
||||
}
|
||||
models
|
||||
}
|
||||
|
||||
/// Look up the **bundled-snapshot** offering for `(provider, wire_model_id)`,
|
||||
/// ignoring any live rows merged over it.
|
||||
///
|
||||
@@ -2219,23 +2237,8 @@ pub async fn run_models(
|
||||
route_config
|
||||
.scope_to_provider_identity(identity)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let mut models = configured_catalog_models_for_route(
|
||||
&route_config,
|
||||
identity.provider,
|
||||
identity.key.as_str(),
|
||||
&route_config.active_route_base_url(),
|
||||
);
|
||||
let models = listed_models_for_route(&route_config, identity);
|
||||
let default_model = route_config.default_model();
|
||||
if identity.provider != ProviderKind::OpenaiCodex
|
||||
&& !default_model.is_empty()
|
||||
&& !default_model.eq_ignore_ascii_case("auto")
|
||||
{
|
||||
push_unique_model(&mut models, &default_model);
|
||||
}
|
||||
if identity.provider != ProviderKind::OpenaiCodex {
|
||||
models.sort();
|
||||
models.dedup();
|
||||
}
|
||||
if json {
|
||||
// Preserve the existing array + AvailableModel field shape.
|
||||
let chatgpt_roster = (identity.provider == ProviderKind::OpenaiCodex)
|
||||
|
||||
@@ -325,7 +325,7 @@ pub fn selected_route(path: &Path) -> Result<(String, String, codewhale_config::
|
||||
pub fn list_models(
|
||||
store: &codewhale_config::ConfigStore,
|
||||
filter: Option<ProviderKind>,
|
||||
) -> Result<Vec<(ProviderKind, Vec<String>)>> {
|
||||
) -> Result<Vec<(String, Vec<String>)>> {
|
||||
crate::models_dev_live::maybe_load_persisted_cache();
|
||||
let mut config = saved_config(store)?;
|
||||
config.base_url_env_receipt = config
|
||||
@@ -333,42 +333,33 @@ pub fn list_models(
|
||||
.map_or(BaseUrlEnvReceipt::NoOwner, |identity| {
|
||||
BaseUrlEnvReceipt::Route(identity.provider, identity.key.to_string())
|
||||
});
|
||||
let mut custom_routes: Vec<String> = config
|
||||
.providers
|
||||
.as_ref()
|
||||
.map(|providers| providers.custom.keys().cloned().collect())
|
||||
.unwrap_or_default();
|
||||
custom_routes.sort();
|
||||
let providers = filter
|
||||
.as_ref()
|
||||
.map_or(ProviderKind::all(), std::slice::from_ref);
|
||||
let mut catalogs = Vec::with_capacity(providers.len());
|
||||
for provider in providers {
|
||||
if *provider == ProviderKind::Custom
|
||||
&& config
|
||||
.providers
|
||||
.as_ref()
|
||||
.and_then(|providers| providers.custom_provider_config(provider.as_str()))
|
||||
.is_none()
|
||||
{
|
||||
continue;
|
||||
let routes = if *provider == ProviderKind::Custom {
|
||||
custom_routes.clone()
|
||||
} else {
|
||||
vec![provider.as_str().to_string()]
|
||||
};
|
||||
for route in routes {
|
||||
let identity = config
|
||||
.legacy_selection_identity(&route)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let mut route_config = config.clone();
|
||||
route_config
|
||||
.scope_to_provider_identity(&identity)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let models = crate::provider_lake::listed_models_for_route(&route_config, &identity);
|
||||
catalogs.push((route, models));
|
||||
}
|
||||
let identity = config
|
||||
.legacy_selection_identity(provider.as_str())
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let mut route_config = config.clone();
|
||||
route_config
|
||||
.scope_to_provider_identity(&identity)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let mut models = crate::provider_lake::configured_catalog_models_for_route(
|
||||
&route_config,
|
||||
identity.provider,
|
||||
identity.key.as_str(),
|
||||
&route_config.active_route_base_url(),
|
||||
);
|
||||
if identity.provider != ProviderKind::OpenaiCodex {
|
||||
let default_model = route_config.default_model();
|
||||
if !default_model.is_empty() && !default_model.eq_ignore_ascii_case("auto") {
|
||||
models.push(default_model);
|
||||
}
|
||||
models.sort();
|
||||
models.dedup();
|
||||
}
|
||||
catalogs.push((*provider, models));
|
||||
}
|
||||
Ok(catalogs)
|
||||
}
|
||||
@@ -418,10 +409,12 @@ pub fn resolve_model(
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
codewhale_agent::ModelRegistry::default().resolve(
|
||||
model.filter(|model| *model != "auto"),
|
||||
Some(identity.provider),
|
||||
)?;
|
||||
if !crate::provider_catalog_live::provider_owns_live_catalog(identity.provider, base_url) {
|
||||
codewhale_agent::ModelRegistry::default().resolve(
|
||||
model.filter(|model| *model != "auto"),
|
||||
Some(identity.provider),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(route?.candidate)
|
||||
}
|
||||
|
||||
@@ -451,6 +451,11 @@ async fn evaluate(
|
||||
.map_or_else(Default::default, |v| v.probabilities_bp.clone()),
|
||||
confidence_bp: intent.as_ref().map_or(0, |v| v.confidence_bp),
|
||||
min_confidence_bp: 5_000,
|
||||
abstained_questions: response.abstained_questions(&[
|
||||
"intent",
|
||||
"needs_tool",
|
||||
"answerable_from_context",
|
||||
]),
|
||||
cost_saving_kept_fast: false,
|
||||
thinking: None,
|
||||
provider_reported_cost_usd: response
|
||||
@@ -521,7 +526,7 @@ fn unit_interval(value: Option<f64>) -> Option<f64> {
|
||||
/// fast route.
|
||||
fn read_noul(response: &SystemOneResponse, key: &str) -> Option<f64> {
|
||||
let answer = response.answers.get(key)?;
|
||||
(answer.kind == "noul")
|
||||
(answer.kind == "noul" && !answer.abstained)
|
||||
.then_some(answer.noul)
|
||||
.and_then(unit_interval)
|
||||
}
|
||||
@@ -548,7 +553,9 @@ fn derive_route(response: &SystemOneResponse) -> Route {
|
||||
response.answers.get("intent"),
|
||||
&["code_change", "code_question", "command", "chat", "other"],
|
||||
)
|
||||
.is_some_and(|intent| intent.choice == "chat" && intent.confidence_bp >= 5_000);
|
||||
.is_some_and(|intent| {
|
||||
!intent.abstained && intent.choice == "chat" && intent.confidence_bp >= 5_000
|
||||
});
|
||||
if intent_is_calibrated_chat && needs_tool.is_some_and(|nt| nt <= 0.2) {
|
||||
return Route::PlainChat;
|
||||
}
|
||||
@@ -564,6 +571,23 @@ mod tests {
|
||||
|
||||
const TYPESAFE_TEST_KEY: &str = "sf-typesafe-test-key-0123456789";
|
||||
|
||||
#[test]
|
||||
fn explicit_abstention_never_authorizes_a_shadow_route() {
|
||||
let mut value = json!({"answers":{
|
||||
"intent":{"type":"choice","choice":"chat","confidence":0.9,
|
||||
"probabilities":{"chat":0.9,"code_change":0.1,"code_question":0.0,"command":0.0,"other":0.0}},
|
||||
"needs_tool":{"type":"noul","noul":0.1},
|
||||
"answerable_from_context":{"type":"noul","noul":0.1}}});
|
||||
let decoded =
|
||||
|value: Value| serde_json::from_value::<SystemOneResponse>(value).expect("response");
|
||||
assert_eq!(derive_route(&decoded(value.clone())), Route::PlainChat);
|
||||
value["answers"]["intent"]["abstained"] = true.into();
|
||||
assert_eq!(derive_route(&decoded(value.clone())), Route::Unknown);
|
||||
value["answers"]["needs_tool"]["noul"] = 0.95.into();
|
||||
value["answers"]["needs_tool"]["abstained"] = true.into();
|
||||
assert_eq!(derive_route(&decoded(value)), Route::Unknown);
|
||||
}
|
||||
|
||||
/// Fields drop in declaration order: restore the environment before the
|
||||
/// lock is released, or another test observes our overrides.
|
||||
struct Env {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -11,6 +11,8 @@ pub struct AutoRouteDecisionEvidence {
|
||||
pub probabilities_bp: BTreeMap<String, u16>,
|
||||
pub confidence_bp: u16,
|
||||
pub min_confidence_bp: u16,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub abstained_questions: Vec<String>,
|
||||
/// `[auto] cost_saving` kept the fast tier because the strong tier's
|
||||
/// probability was below the cost-saving floor.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
|
||||
@@ -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(_))));
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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
@@ -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"));
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -13,6 +13,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Blank environment variables:** exporting a variable empty (for example `CODEWHALE_MODEL=` from a compose `${VAR:-}` template) no longer hides its legacy alias. `DEEPSEEK_MODEL`, `DEEPSEEK_BASE_URL`, `KIMI_BASE_URL`, `DEEPSEEK_FORCE_HTTP1`, `DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS` and the other provider alias variables now apply as they do when the primary is unset. Thanks [@luochen211](https://github.com/luochen211) (PR 7315).
|
||||
- **MCP and language servers that send requests mid-call:** a tool call no longer fails with "neither a result nor an error" when an MCP server sends a `ping` or other request that reuses the call's id while the call is running, and a language server's `workspace/configuration` or similar request no longer ends a pending LSP request. Codewhale now answers `ping`, answers other server requests with "Method not found" (it never advertised them), keeps waiting for the real reply, and a refused answer over Streamable HTTP no longer drops the connection to legacy SSE. Thanks [@luochen211](https://github.com/luochen211) (PR 7319).
|
||||
- **Qwen Code as an external agent:** Codewhale now finds an installed Qwen Code CLI (`qwen`, 0.6.1 or newer) and can run it as a session over ACP with `qwen --acp`. `GET /v1/agents/external` lists it, an older install is reported as needing `npm i -g @qwen-code/qwen-code@latest`, and sign-in stays inside Qwen Code (`/auth`). Thanks [@MannXo](https://github.com/MannXo) (#7293).
|
||||
- **Qwen Code as an external agent:** Codewhale now finds an installed Qwen Code CLI (`qwen`, 0.6.1 or newer) and can run it as a session over ACP with `qwen --acp`. `GET /v1/agents/external` lists it, an older install is reported as needing `npm i -g @qwen-code/qwen-code@latest`, and sign-in stays inside Qwen Code (`/auth`). Thanks [@MannXo](https://github.com/MannXo) (PR 7293).
|
||||
- **Telemetry setting default:** the settings schema API reports telemetry's default as on, matching what Codewhale does when you have not chosen.
|
||||
- **Auto routing abstention:** when the decision model marks a tier, effort or shadow answer `abstained: true`, Auto no longer acts on it, even with `min_confidence = 0`: an abstained tier takes the local fallback, an abstained effort keeps the tier and drops only the effort, and the routing receipt lists the abstained questions.
|
||||
- **Plugin index (Runtime API):** a new `GET /v1/apps/plugin-index` route lists plugins, skills, connectors, hooks and hand-written commands with kind, source and state, and `/v1/commands` rows say which plugin (`plugin_id`) added a command and whether a command came from the project (`workspace_sourced`) (CW-8092).
|
||||
- **Model commands and routing:** `codewhale model list` now lists every custom endpoint you have configured, under its own name. `codewhale model resolve` no longer refuses a model on a custom endpoint, router or local server whose model list has not been fetched yet; once a list is fetched, a model missing from it is still refused. Automatic routing marks a context window as verified only when the route itself supplies it, and no longer reports a guessed output limit.
|
||||
- **ACP:** when a plugin tool asks the core to run a tool that needs approval, the editor now gets a `session/request_permission` on the plugin tool's call that names the nested tool and its input; before, the prompt ended with an internal error (CW-8050).
|
||||
- **README and contributor credit:** the repository front page now opens the way the website does: the line "The model can be anyone's. The agent should be yours.", one sentence on what Codewhale is, the install command and one picture, with reference detail moved into the linked guides; the 18 translated READMEs follow the same structure and say they are machine-drafted. The contributor wall and the website now count the same 433 people (407 GitHub accounts and the commit authors whose email is not linked to an account), where the website had counted accounts only. `DEPENDENCY_SECURITY.md` moved into `docs/dependency-maintenance.md`, and `.github/SUPPORT.md` says where to ask for help.
|
||||
- **Website Plugins page:** The website's Plugins page now describes what the released version does, and a labelled section says what 0.10.3 adds: one Plugins place, Standard and Creator mode, and the Permissions control.
|
||||
- **First-time guide on the website:** a new docs page, "Use Codewhale for the first time" (`/docs/first-time`, English and Simplified Chinese), walks someone who has never used a terminal through opening one, installing, choosing the folder Codewhale works in and when that choice matters, opening a terminal in a folder from Finder or File Explorer, connecting a model, a first task, and stopping, undoing and quitting. The install page gains a short "New to the terminal?" block for macOS and Windows that links to it, and `llms.txt` carries the same steps.
|
||||
|
||||
@@ -85,6 +85,7 @@ mod tests {
|
||||
aliases: aliases.iter().map(|s| s.to_string()).collect(),
|
||||
hidden,
|
||||
plugin_authority: None,
|
||||
workspace_sourced: false,
|
||||
extension: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,6 +296,11 @@ impl codewhale_engine::command_catalog::CommandCatalog for CommandTable {
|
||||
takes_arguments: metadata.takes_arguments(),
|
||||
hidden: metadata.hidden,
|
||||
extension: metadata.extension.is_some(),
|
||||
plugin_id: metadata
|
||||
.plugin_authority
|
||||
.as_ref()
|
||||
.map(|authority| authority.plugin_id.as_str().to_string()),
|
||||
workspace_sourced: metadata.workspace_sourced,
|
||||
builtin_bindings: bindings.remove(&metadata.name).unwrap_or_default(),
|
||||
})
|
||||
.collect()
|
||||
|
||||
@@ -80,6 +80,7 @@ pub struct UserCommandMetadata {
|
||||
pub aliases: Vec<String>,
|
||||
pub hidden: bool,
|
||||
pub plugin_authority: Option<codewhale_config::PluginAuthority>,
|
||||
pub workspace_sourced: bool,
|
||||
/// Set for a command contributed by an extension-host plugin: it runs in
|
||||
/// the host (`command/run`) instead of expanding `body`.
|
||||
pub extension: Option<codewhale_engine::extension_host::command::ExtensionCommandRef>,
|
||||
@@ -246,6 +247,7 @@ impl UserCommandRegistry {
|
||||
let (mut metadata, errors) =
|
||||
parse_metadata(entry.name, &entry.content, &entry.path);
|
||||
metadata.plugin_authority = entry.plugin_authority;
|
||||
metadata.workspace_sourced = entry.workspace_sourced;
|
||||
(metadata, errors, entry.path, entry.workspace_sourced)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
@@ -395,6 +397,7 @@ impl UserCommandRegistry {
|
||||
aliases: Vec::new(),
|
||||
hidden: false,
|
||||
plugin_authority: Some(entry.authority),
|
||||
workspace_sourced: false,
|
||||
extension: Some(reference),
|
||||
},
|
||||
);
|
||||
@@ -482,6 +485,7 @@ fn parse_metadata(
|
||||
aliases: Vec::new(),
|
||||
hidden: false,
|
||||
plugin_authority: None,
|
||||
workspace_sourced: false,
|
||||
extension: None,
|
||||
};
|
||||
let mut configured_name = None;
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -1771,6 +1771,7 @@ mod tests {
|
||||
.collect(),
|
||||
confidence_bp: 6400,
|
||||
min_confidence_bp: 5000,
|
||||
abstained_questions: Vec::new(),
|
||||
cost_saving_kept_fast: false,
|
||||
thinking: Some("max".to_string()),
|
||||
provider_reported_cost_usd: Some("0.000019992".to_string()),
|
||||
|
||||
+1
-1
@@ -545,7 +545,7 @@ Return `{kind:'deny', reason}`, `{kind:'ask', reason?}`, `{kind:'revise', input}
|
||||
|
||||
Native hooks run first, then mod listeners in core registration order. Denial always wins; the last accepted input revision wins. Rust re-prepares revised arguments and reruns the existing authority, policy, and approval checks. Input revisions use the existing 32 KiB hook limit; context and reasons use the existing sanitizers. Unloading a fiber removes its listener, and disabling or revoking an owner retires its admitted handles before host teardown. Other workspaces do not receive the call.
|
||||
|
||||
This bridge runs on Engine sessions, including model tools, gated code-mode calls, and `exec.core` calls. Sessions an editor drives over ACP (`codewhale serve --acp`) get the same extension host, under the same `[features]` flags (`extension_host`, `mcp`): reviewed plugin tools (TypeScript and DSH) and the MCP servers configured in Codewhale appear as ACP tool calls and ask through the session's permission setting; Plan stays read-only. The ACP server's capabilities say so (`agentCapabilities._meta.codewhale`). Servers an ACP client passes in `mcpServers` and configured shell hooks are not attached. Prepend/global listener ordering, DSH runtime/agent handles, around-execution wrappers, and post-result rewriting are not provided. Native `tool_call_after` remains an observer contract.
|
||||
This bridge runs on Engine sessions, including model tools, gated code-mode calls, and `exec.core` calls. Sessions an editor drives over ACP (`codewhale serve --acp`) get the same extension host, under the same `[features]` flags (`extension_host`, `mcp`): reviewed plugin tools (TypeScript and DSH) and the MCP servers configured in Codewhale appear as ACP tool calls and ask through the session's permission setting; Plan stays read-only. When a plugin tool asks the core to run a tool that needs approval (`exec.core`), the editor's permission request is shown on the plugin tool's call and names the nested tool and its input. The ACP server's capabilities say so (`agentCapabilities._meta.codewhale`). Servers an ACP client passes in `mcpServers` and configured shell hooks are not attached. Prepend/global listener ordering, DSH runtime/agent handles, around-execution wrappers, and post-result rewriting are not provided. Native `tool_call_after` remains an observer contract.
|
||||
|
||||
## Asking the core to run a tool
|
||||
|
||||
|
||||
+41
-1
@@ -886,6 +886,13 @@ Plugin tools and configured MCP servers on this transport are on main after
|
||||
0.10.2; 0.10.2 itself does not have them.
|
||||
Client-supplied MCP servers, dynamic tools, task/PTY/background shell,
|
||||
interpreter, subagent and RLM lifecycles are unavailable on this transport.
|
||||
ACP has no tool search, so every connected MCP tool and its schema is sent with
|
||||
every request, and a server marked `lazy = true` never starts. A large MCP set
|
||||
therefore costs tokens on each turn and can exceed a provider's tool limit.
|
||||
MCP servers a model starts at run time stay out of an ACP-profile host. When
|
||||
`serve --acp` instead attaches to a running Runtime owner (see below), its turns
|
||||
use that owner's MCP pool, which includes servers started at run time in other
|
||||
sessions of the same workspace.
|
||||
Built-in overrides remove the
|
||||
whole compatibility alias family. Final dispatch rechecks the profile, so a
|
||||
fabricated alias, hook rewrite or omitted catalog entry cannot bypass it. Plan
|
||||
@@ -906,7 +913,11 @@ produced.
|
||||
Tool updates begin `pending`. `in_progress` means Core reached final dispatch;
|
||||
`completed`/`failed` and typed image blocks come from the actual Core result.
|
||||
`session/request_permission` correlates a private JSON-RPC ID to the same
|
||||
Runtime-minted pending approval and Core execution ID. Only an exact live
|
||||
Runtime-minted pending approval and Core execution ID. When a plugin tool asks
|
||||
the core to run a tool that needs approval (`exec.core`), the request is shown
|
||||
on the plugin tool's call, names the nested tool and carries its input, and the
|
||||
answer releases the nested call; the nested call is decided under the session's
|
||||
permission setting. Only an exact live
|
||||
`allow-once` response can release that waiter; wrong IDs are ignored, invalid
|
||||
options deny, and cancellation withdraws it. ACP grants neither remembered
|
||||
permissions nor Native capabilities. Core typed rules, strict hooks, repo law,
|
||||
@@ -2672,6 +2683,35 @@ thread deletion.
|
||||
pure over the snapshot — so a client must not look for one. Anything that
|
||||
mutates the session stays a terminal-side command.
|
||||
|
||||
**Plugin index**
|
||||
- `GET /v1/apps/plugin-index` — `{workspace, items: [...], unavailable: [...],
|
||||
problems: [...]}`: one list across every plugin, skill, connector, hook and
|
||||
hand-written command. It is a projection: each item's `row` is the row its
|
||||
owner's own route returns (`/v1/apps/plugins`, `/v1/apps/plugins/catalog`,
|
||||
`/v1/skills`, `/v1/apps/mcp/servers`, `/v1/hooks`, `/v1/commands`), and
|
||||
`owner` names that route. Per item: `id`, `kind` (`plugin`, `skill`,
|
||||
`connector`, `hook`, `command`), `name`, `description`, `source`
|
||||
(`built_in`, `user`, `project`, `other_app`, `runtime`, `plugin`), `state`
|
||||
(`enabled`, `disabled`, `needs_review`, `needs_sign_in`, `unavailable`) and
|
||||
`labels` (what the item adds: `skill`, `connector`, `hook`, `command`,
|
||||
`tool`, `profile`). A plugin item also has `adds` (`skills`, `connectors`,
|
||||
`hooks`, `commands`, `profiles`: names, not rows), and what a plugin adds is
|
||||
not listed a second time as an item of its own. A built-in switch is a
|
||||
`plugin` item with a `tier` and no labels. `/v1/commands` rows carry
|
||||
`plugin_id` (the same id as `/v1/apps/plugins`, and the `plugin:<id>` item)
|
||||
for a command a plugin adds, and `workspace_sourced` for one read from the
|
||||
project; a command item's `plugin` is that id. An owner that fails is named in
|
||||
`unavailable` and the other kinds still list; `problems` carries the owners'
|
||||
load warnings. Names under `adds` are file names, so a profile's `id` inside its
|
||||
file can differ; a command another plugin registers through the extension host
|
||||
is an item with `source` `plugin`. For an active plugin `adds.commands` comes
|
||||
from the `/v1/commands` rows; for any other plugin, and for `hooks` and
|
||||
`profiles`, it is the file names found in the bundle, at most 512 per kind,
|
||||
with names holding control characters left out; a cut list is reported
|
||||
under `problems`. A hook's row names no plugin, so `adds.hooks` stays file
|
||||
names. `tool` means the plugin declares native or language-server components;
|
||||
a screen a plugin draws is not labelled yet.
|
||||
|
||||
**Hooks**
|
||||
- `GET /v1/hooks[?thread_id=...]` — `{workspace, enabled, hooks: [...],
|
||||
problems: [...]}`: the hook set Runtime API threads run for that workspace
|
||||
|
||||
@@ -54,6 +54,7 @@ registration index.
|
||||
| POST | `/v1/apps/mcp/servers/{name}/enable` |
|
||||
| POST | `/v1/apps/mcp/servers/{name}/reconnect` |
|
||||
| GET | `/v1/apps/mcp/tools` |
|
||||
| GET | `/v1/apps/plugin-index` |
|
||||
| GET | `/v1/apps/plugins` |
|
||||
| DELETE / GET | `/v1/apps/plugins/{selector}` |
|
||||
| POST | `/v1/apps/plugins/{selector}/disable` |
|
||||
@@ -263,4 +264,4 @@ registration index.
|
||||
| GET / POST | `/v1/worktrees` |
|
||||
| POST | `/v1/worktrees/remove` |
|
||||
|
||||
The source contains 243 distinct literal paths in this table.
|
||||
The source contains 244 distinct literal paths in this table.
|
||||
+19
-59
@@ -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.
|
||||
|
||||
@@ -252,6 +252,8 @@ DSH 的 `allow` 被视为不干预,每个所有者提示一次;不能预先
|
||||
调用。编辑器通过 ACP(`codewhale serve --acp`)驱动的会话获得同一个扩展宿主,并受相同的
|
||||
`[features]` 开关(`extension_host`、`mcp`)约束:已审查的插件工具(TypeScript 与 DSH)和
|
||||
Codewhale 中配置的 MCP 服务器会作为 ACP 工具调用出现,并通过会话的权限设置询问;Plan 保持只读。
|
||||
插件工具通过 `exec.core` 请求核心执行需要审批的工具时,审批请求显示在该插件
|
||||
工具的调用上,并写明嵌套工具及其输入。
|
||||
ACP 服务器的能力声明会说明这一点(`agentCapabilities._meta.codewhale`)。ACP 客户端在
|
||||
`mcpServers` 中传入的服务器和已配置的 shell hooks 不会挂载。`prepend`/`global` 顺序
|
||||
覆盖、包裹实际执行的中间件、DSH 运行时句柄和执行后结果改写均不支持。Native
|
||||
|
||||
@@ -660,7 +660,11 @@ shell 工具,外加已审查的插件工具(`extension_host` 功能)和 Co
|
||||
MCP 服务器的工具(`mcp` 功能),均受同一权限设置约束;这一点见于 0.10.2 之后的 main,0.10.2 本身不含。Shell 同时要求编辑器声明
|
||||
terminal 支持、操作者允许 `allow_shell`;指定的外部沙箱不可用时不提供 shell。此接口
|
||||
不提供客户端传入的 MCP 服务器、动态工具、任务、PTY、后台 shell、解释器、子智能体
|
||||
或 RLM 生命周期。内置工具覆盖会移除整个兼容别名族。
|
||||
或 RLM 生命周期。ACP 没有工具搜索,因此每个已连接的 MCP 工具及其 schema 都会随每个请求
|
||||
发送,标记为 `lazy = true` 的服务器永不启动;MCP 集合很大时每回合都会消耗令牌,并可能超出
|
||||
提供商的工具数量上限。模型在运行时启动的 MCP 服务器不会进入 ACP profile 宿主;但当
|
||||
`serve --acp` 附着到正在运行的 Runtime owner 时(见下文),其回合使用该 owner 的 MCP 池,
|
||||
其中包含同一工作区其他会话在运行时启动的服务器。内置工具覆盖会移除整个兼容别名族。
|
||||
最终派发再次校验 profile;伪造别名、hook 改写或目录中缺失的工具不能绕过限制。
|
||||
Full Access 下 Plan 仍只读。Full Access 与普通审批姿态是服务端拥有的只读选项,
|
||||
编辑器不能放宽。`approval_policy = "never"` 不是 ACP 审批姿态,服务端会拒绝
|
||||
@@ -669,7 +673,9 @@ Full Access 下 Plan 仍只读。Full Access 与普通审批姿态是服务端
|
||||
|
||||
工具首先显示 `pending`;只有 Core 到达最终派发才显示 `in_progress`。
|
||||
`completed`/`failed` 和类型化图片块来自实际 Core 结果。审批请求的私有 JSON-RPC ID
|
||||
绑定同一个 Runtime 铸造的待决审批与 Core 执行 ID。只有精确匹配、仍有效的
|
||||
绑定同一个 Runtime 铸造的待决审批与 Core 执行 ID。插件工具通过 `exec.core` 请求核心运行
|
||||
需要审批的工具时,请求显示在该插件工具的调用上,写明嵌套工具及其输入,答复释放嵌套调用,嵌套调用在会话的
|
||||
权限设置下决定。只有精确匹配、仍有效的
|
||||
`allow-once` 响应能释放等待器;错误 ID 被忽略,无效选项拒绝,取消会撤销等待器。
|
||||
ACP 不授予记忆权限或 Native 能力。
|
||||
|
||||
@@ -1987,6 +1993,19 @@ POST 前保留预期指纹,并在响应及按键 GET 恢复时核对,之后
|
||||
保留命令回执——每个提供的处理器对快照都是纯函数——因此客户端不应寻找回执。
|
||||
任何会修改会话的操作仍是终端侧命令。
|
||||
|
||||
**插件索引**
|
||||
- `GET /v1/apps/plugin-index` — `{workspace, items: [...], unavailable: [...],
|
||||
problems: [...]}`:跨所有插件、技能、连接器、钩子和手写命令的统一列表。它只是投影:每个条目的
|
||||
`row` 就是其所属路由(`/v1/apps/plugins`、`/v1/apps/plugins/catalog`、`/v1/skills`、
|
||||
`/v1/apps/mcp/servers`、`/v1/hooks`、`/v1/commands`)返回的那一行,`owner` 给出该路由。
|
||||
每个条目有 `id`、`kind`(`plugin`、`skill`、`connector`、`hook`、`command`)、`name`、
|
||||
`description`、`source`(`built_in`、`user`、`project`、`other_app`、`runtime`、`plugin`)、
|
||||
`state`(`enabled`、`disabled`、`needs_review`、`needs_sign_in`、`unavailable`)和
|
||||
`labels`(它添加的内容:`skill`、`connector`、`hook`、`command`、`tool`、`profile`)。插件条目另有 `adds`(`skills`、`connectors`、`hooks`、`commands`、
|
||||
`profiles`:只含名称),插件所添加的内容不会再作为独立条目重复列出。内置开关是带 `tier`、无 `labels` 的
|
||||
`plugin` 条目。`/v1/commands` 的行对插件添加的命令带有 `plugin_id`(与 `/v1/apps/plugins` 的 id 相同,即 `plugin:<id>` 条目),对从项目读取的命令带有 `workspace_sourced`;命令条目的 `plugin` 即该 id。某个所属路由失败时记入 `unavailable`,其余种类照常列出;`problems`
|
||||
汇总各所属路由的加载警告。`adds` 中的名称是文件名,因此档案文件内部的 `id` 可能不同;通过扩展宿主注册的命令是 `source` 为 `plugin` 的条目。对已启用的插件,`adds.commands` 取自 `/v1/commands` 的行;其他插件以及 `hooks`、`profiles` 取自包内的文件名,每类最多 512 个,含控制字符的名称被略去,列表被截断时在 `problems` 中说明。钩子的行不带插件名,因此 `adds.hooks` 仍是文件名。`tool` 表示插件声明了原生或语言服务器组件;插件绘制的界面暂无标签。
|
||||
|
||||
**钩子**
|
||||
- `GET /v1/hooks[?thread_id=...]` — `{workspace, enabled, hooks: [...],
|
||||
problems: [...]}`:Runtime API 线程为该工作区运行的钩子集合
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
-1261
File diff suppressed because it is too large.
Load diff
@@ -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::*;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
-360
@@ -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
@@ -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
@@ -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(),
|
||||
|
||||
@@ -354,8 +354,8 @@
|
||||
},
|
||||
{
|
||||
"path": "docs/EXTENSIONS.md",
|
||||
"sha256": "45dc87bc8293156cc221a0d1181ddcdfe6970caef3874d2127a5b86ba2b32d9d",
|
||||
"content": "# Writing an extension tool or command\n\nThe experimental TypeScript extension host runs reviewed plugin code in a\nshared Node process (or, as an opt-in, Bun). Rust still owns sessions, tool admission, approval and\nexecution. Extensions currently contribute tools and slash commands; they\ncannot provide an approval service, run a second agent loop or replace\nbuilt-in tools or commands.\n\n## Quick start\n\nStart with [hello-extension](examples/plugins/hello-extension/hello.mts) and its\n[plugin.json](examples/plugins/hello-extension/plugin.json). The example is\nexecuted unchanged by the host tests. It returns a greeting and does not read\nfiles or use the network.\n\n1. Explicitly enable `[features] extension_host = true` in your configuration,\n or start Codewhale with `codewhale --enable extension_host` (the flag goes\n before any subcommand).\n2. Install the example's directory using `/plugin install <local-directory>`.\n3. Run `/plugin validate hello-extension`, then `/plugin show hello-extension`.\n4. Run `/plugin enable hello-extension` to open its content/capability review.\n Read the review and personally run its exact `/plugin trust <token>` command,\n then enable the plugin. Trust alone does not execute the code.\n5. Ask Codewhale to use `hello_greet`. The tool follows the normal approval gate.\n6. Run `/hello-greet Codewhale`. The command runs when you type it, and shows\n its answer in the transcript with its origin, `extension:hello-extension`.\n\nAn authoring agent should stop after installation and validation and present\nthe review to the person, unless it runs in Full Access and the plugin is one\nit wrote and installed from a folder in its project: then it may trust and\nenable it for that project with `codewhale plugins trust` and `enable`. Those\nshell commands go through the session's approval like any other: Ask asks the\nperson, Auto-Review's reviewer judges, and Full Access runs them.\n\n## Locations, installation and trust\n\nUser bundles live under `~/.codewhale/plugins/`; workspace bundles live under\n`<workspace>/.codewhale/plugins/`. New bundles use Agent Plugins v1.0.0\n`plugin.json`. Declare `extensions[\"net.codewhale\"].native.path` as one regular\n`.mjs`, `.js` or `.mts` file inside the bundle. Directories and other extensions\nfail validation when the host is enabled. With the flag off, native entries\nremain inventory-only.\n\nA plugin may split its code across several entries with `native.paths` (up to\n64, alone or beside `path`). They are activated in the order declared, as the\nfibers of one plugin: they share one owner, so one disable, review change or\ncrash tears all of them down together. Each entry is a separate module with its\nown `apply`, and tool and command names must be unique across them. If an entry\nfails to activate (throws, requires a service the host does not provide, or has\na registration refused), only that entry is withdrawn: entries that already\nactivated stay registered, later entries still activate, and the plugin fails\nonly when no entry activates. Listing the same file twice is one entry.\n\n## Runtime\n\n`[extension_host] runtime` selects what runs the host. Node is the default.\nBun is an opt-in: it is qualified on macOS only, and becomes the default only\nafter an explicit, recorded cutover.\n\n```toml\n[extension_host]\nruntime = \"node\" # default: Node only\n# runtime = \"bun\" # Bun only; fails rather than falling back to Node\n# runtime = \"auto\" # Bun >= 1.4.0 if one is found and starts, otherwise Node\n# bun = \"~/.bun/bin/bun\" # the only Bun tried when set\n# node = \"/opt/homebrew/bin/node\" # the only Node tried when set\n# mcp_backend = \"rust\" # temporary opt-out; default is \"host\"\n```\n\nThe default `mcp_backend = \"host\"` uses the pinned MCP SDK independently of\n`[features] extension_host`, which controls optional Native extensions. Existing\nstdio and HTTP servers use modern discovery when supported; explicitly configured\nlegacy SSE servers use their legacy handshake. Rust retains process and network\nauthority, credential resolution, catalog admission, tool approval and cancellation.\nThe SDK receives opaque session selectors and exact operation grants. Modern\nrequests carry Rust-approved protocol metadata and headers without a session id.\n\nA typed discovery refusal can admit a legacy handshake. Only an explicit HTTP\ntransport refusal during that handshake can admit legacy SSE with a fresh exact\nRust grant. Legacy HTTP retains its GET preflight and bounded OAuth refresh;\nmodern HTTP does not inherit those session or resend behaviors. Incomplete result\nflows are reported without automatically repeating the operation. Credentials,\nconfigured URLs, OAuth browser/login operations and Computer Use decision keys\nstay in Rust.\n\nBoth backends share recorded semantic MCP goldens and retain separate transport\ngoldens, including discovery, authentication and sessioLine truncated
|
||||
"sha256": "8582de93c01083314119639678c1ea1ab4ba46317c5a400fb9dd139ffdcb232a",
|
||||
"content": "# Writing an extension tool or command\n\nThe experimental TypeScript extension host runs reviewed plugin code in a\nshared Node process (or, as an opt-in, Bun). Rust still owns sessions, tool admission, approval and\nexecution. Extensions currently contribute tools and slash commands; they\ncannot provide an approval service, run a second agent loop or replace\nbuilt-in tools or commands.\n\n## Quick start\n\nStart with [hello-extension](examples/plugins/hello-extension/hello.mts) and its\n[plugin.json](examples/plugins/hello-extension/plugin.json). The example is\nexecuted unchanged by the host tests. It returns a greeting and does not read\nfiles or use the network.\n\n1. Explicitly enable `[features] extension_host = true` in your configuration,\n or start Codewhale with `codewhale --enable extension_host` (the flag goes\n before any subcommand).\n2. Install the example's directory using `/plugin install <local-directory>`.\n3. Run `/plugin validate hello-extension`, then `/plugin show hello-extension`.\n4. Run `/plugin enable hello-extension` to open its content/capability review.\n Read the review and personally run its exact `/plugin trust <token>` command,\n then enable the plugin. Trust alone does not execute the code.\n5. Ask Codewhale to use `hello_greet`. The tool follows the normal approval gate.\n6. Run `/hello-greet Codewhale`. The command runs when you type it, and shows\n its answer in the transcript with its origin, `extension:hello-extension`.\n\nAn authoring agent should stop after installation and validation and present\nthe review to the person, unless it runs in Full Access and the plugin is one\nit wrote and installed from a folder in its project: then it may trust and\nenable it for that project with `codewhale plugins trust` and `enable`. Those\nshell commands go through the session's approval like any other: Ask asks the\nperson, Auto-Review's reviewer judges, and Full Access runs them.\n\n## Locations, installation and trust\n\nUser bundles live under `~/.codewhale/plugins/`; workspace bundles live under\n`<workspace>/.codewhale/plugins/`. New bundles use Agent Plugins v1.0.0\n`plugin.json`. Declare `extensions[\"net.codewhale\"].native.path` as one regular\n`.mjs`, `.js` or `.mts` file inside the bundle. Directories and other extensions\nfail validation when the host is enabled. With the flag off, native entries\nremain inventory-only.\n\nA plugin may split its code across several entries with `native.paths` (up to\n64, alone or beside `path`). They are activated in the order declared, as the\nfibers of one plugin: they share one owner, so one disable, review change or\ncrash tears all of them down together. Each entry is a separate module with its\nown `apply`, and tool and command names must be unique across them. If an entry\nfails to activate (throws, requires a service the host does not provide, or has\na registration refused), only that entry is withdrawn: entries that already\nactivated stay registered, later entries still activate, and the plugin fails\nonly when no entry activates. Listing the same file twice is one entry.\n\n## Runtime\n\n`[extension_host] runtime` selects what runs the host. Node is the default.\nBun is an opt-in: it is qualified on macOS only, and becomes the default only\nafter an explicit, recorded cutover.\n\n```toml\n[extension_host]\nruntime = \"node\" # default: Node only\n# runtime = \"bun\" # Bun only; fails rather than falling back to Node\n# runtime = \"auto\" # Bun >= 1.4.0 if one is found and starts, otherwise Node\n# bun = \"~/.bun/bin/bun\" # the only Bun tried when set\n# node = \"/opt/homebrew/bin/node\" # the only Node tried when set\n# mcp_backend = \"rust\" # temporary opt-out; default is \"host\"\n```\n\nThe default `mcp_backend = \"host\"` uses the pinned MCP SDK independently of\n`[features] extension_host`, which controls optional Native extensions. Existing\nstdio and HTTP servers use modern discovery when supported; explicitly configured\nlegacy SSE servers use their legacy handshake. Rust retains process and network\nauthority, credential resolution, catalog admission, tool approval and cancellation.\nThe SDK receives opaque session selectors and exact operation grants. Modern\nrequests carry Rust-approved protocol metadata and headers without a session id.\n\nA typed discovery refusal can admit a legacy handshake. Only an explicit HTTP\ntransport refusal during that handshake can admit legacy SSE with a fresh exact\nRust grant. Legacy HTTP retains its GET preflight and bounded OAuth refresh;\nmodern HTTP does not inherit those session or resend behaviors. Incomplete result\nflows are reported without automatically repeating the operation. Credentials,\nconfigured URLs, OAuth browser/login operations and Computer Use decision keys\nstay in Rust.\n\nBoth backends share recorded semantic MCP goldens and retain separate transport\ngoldens, including discovery, authentication and sessioLine truncated
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -365,8 +365,8 @@
|
||||
"sources": [
|
||||
{
|
||||
"path": "docs/RUNTIME_API.md",
|
||||
"sha256": "6f3b8885978ef2e1b33d0fdf66ad6ccf5c25464c1951b59658aec00745390db2",
|
||||
"content": "# Codewhale Engine Runtime API & Integration Contract\n\n> 阅读简体中文版:[zh_hans/RUNTIME_API.md](zh_hans/RUNTIME_API.md)。\n\n`codewhale app-server --http` exposes the [Codewhale Engine](ARCHITECTURE.md) through\nits canonical local Runtime API and control plane.\nLocal SDKs, mobile/remote-control clients, and editor integrations talk to it\ninstead of screen-scraping terminal output. It serves the full HTTP/SSE runtime\nAPI (`/v1/*`), a JSON-RPC control transport over stdio, and the phone-friendly\nmobile page. `codewhale doctor --json` provides machine-readable health, and\n`codewhale serve --acp` speaks the Agent Client Protocol over stdio for editors\nsuch as Zed.\n\n`codewhale serve --http` / `serve --mobile` remain as **compatibility aliases**\nfor `codewhale app-server --http` / `--mobile`; both launch the identical\nserver. Desktop, web, SDK and custom integrations should target\n`app-server --http`. `--stdio` and `--socket` expose compatibility controls.\nBare `app-server` selects initialized JSONL control over stdio. Local stdio,\nsocket and ACP owners do not bind a hidden TCP listener. Explicit `--http`\nretains HTTP/SSE and compatibility routes on the same Runtime owner.\n\n`codewhale exec` is the separate one-shot headless worker path (stream-json,\nfleet worker subprocess, CI primitive). It is not part of this API, but it\nshares the same runtime, provider/model resolution, permission profiles, and\nevent vocabulary.\n\nThis document is the stable integration contract for native workbench\napplications (and other local supervisors) that embed the Codewhale Engine.\n\n## Architecture\n\n```\nlocal supervisor / SDK / automation harness\n │\n ├─ codewhale app-server --http → HTTP/SSE runtime API (/v1/*) [canonical]\n ├─ codewhale app-server --mobile → runtime API + mobile control page\n ├─ codewhale app-server --stdio → JSON-RPC control transport over stdio\n ├─ codewhale app-server --socket → same JSON-RPC over a unix domain socket (desktop daemon)\n ├─ codewhale doctor --json → machine-readable health & capability\n ├─ codewhale serve --acp → ACP stdio agent for editors such as Zed\n ├─ codewhale serve --mcp → MCP stdio server\n ├─ codewhale serve --http/--mobile → legacy aliases for `app-server --http/--mobile`\n └─ codewhale exec [args] → one-shot headless worker (stream-json)\n```\n\nThe engine runs as a local-only process. All APIs bind to `localhost` by\ndefault. No hosted relay, no provider-token custody, no secret leakage.\n\nFor the read-only record of what a thread or turn did, see\n[`docs/RECEIPTS.md`](RECEIPTS.md): `codewhale receipts` on the CLI and the\n`/receipt` routes under **Threads** below.\n\n## Runtime API entrypoints\n\n| Entry | Transport | Use |\n|---|---|---|\n| `codewhale web [--port 7878]` | HTTP/SSE on `127.0.0.1:7878` + embedded client | First-class loopback-only browser client; opens the default browser |\n| `codewhale app-server --http` | HTTP/SSE on `127.0.0.1:7878` | Full `/v1/*` runtime API (canonical) |\n| `codewhale app-server --mobile` | HTTP/SSE on loopback + `/mobile` | Runtime API + local mobile control page |\n| `codewhale app-server --stdio` | Initialized JSONL over stdio | Compatibility controls and explicit tool-free turns through the held manager; no hidden TCP listener or full Codex interaction channel. |\n| `codewhale app-server --socket [--socket-path P]` | RPC text messages over WebSocket on a `0600` Unix domain socket (macOS/Linux) or an owner-only named pipe (Windows) | Desktop daemon: multi-client, kernel peer identity checked, `daemon/attach` claim handshake. Windows native-app qualification remains open. |\n| `codewhale app-server` | Initialized JSONL over stdio | Same selection as `--stdio`; HTTP requires explicit `--http`. |\n| `codewhale serve --http` / `--mobile` | same server as `app-server --http`/`--mobile` | Compatibility aliases |\n\n`app-server --http` and `--mobile` launch the same mature runtime API server\nhistorically reached through `serve --http` — no routes or behavior changed, so\nevery endpoint documented below is identical across both entrypoints. The\nruntime API token is read from `--auth-token`, then `CODEWHALE_RUNTIME_TOKEN`,\nthen `DEEPSEEK_RUNTIME_TOKEN`; use `--insecure-no-auth` only with a loopback\nbind. The `serve` compatibility aliases keep their `--insecure` flag.\nBare `app-server` does not open an HTTP port. Selected attached HTTP listeners\nretain their explicit bearer and non-loopback security checks.\n\nInitialized stdio/private-socket controls support `thread/loaded/list` with\n`{cursor?: string | null, limit?: number | null}`. The result is\n`{data: string[], nextCursor: string | null}` from the held manager's loaded\nengine snapshot, including idle and archived engines still in memory. It dLine truncated
|
||||
"sha256": "e3040635df0cf62717f5acbe9aacf4909b6ad60db8468b06e90a5e9be1d76d1e",
|
||||
"content": "# Codewhale Engine Runtime API & Integration Contract\n\n> 阅读简体中文版:[zh_hans/RUNTIME_API.md](zh_hans/RUNTIME_API.md)。\n\n`codewhale app-server --http` exposes the [Codewhale Engine](ARCHITECTURE.md) through\nits canonical local Runtime API and control plane.\nLocal SDKs, mobile/remote-control clients, and editor integrations talk to it\ninstead of screen-scraping terminal output. It serves the full HTTP/SSE runtime\nAPI (`/v1/*`), a JSON-RPC control transport over stdio, and the phone-friendly\nmobile page. `codewhale doctor --json` provides machine-readable health, and\n`codewhale serve --acp` speaks the Agent Client Protocol over stdio for editors\nsuch as Zed.\n\n`codewhale serve --http` / `serve --mobile` remain as **compatibility aliases**\nfor `codewhale app-server --http` / `--mobile`; both launch the identical\nserver. Desktop, web, SDK and custom integrations should target\n`app-server --http`. `--stdio` and `--socket` expose compatibility controls.\nBare `app-server` selects initialized JSONL control over stdio. Local stdio,\nsocket and ACP owners do not bind a hidden TCP listener. Explicit `--http`\nretains HTTP/SSE and compatibility routes on the same Runtime owner.\n\n`codewhale exec` is the separate one-shot headless worker path (stream-json,\nfleet worker subprocess, CI primitive). It is not part of this API, but it\nshares the same runtime, provider/model resolution, permission profiles, and\nevent vocabulary.\n\nThis document is the stable integration contract for native workbench\napplications (and other local supervisors) that embed the Codewhale Engine.\n\n## Architecture\n\n```\nlocal supervisor / SDK / automation harness\n │\n ├─ codewhale app-server --http → HTTP/SSE runtime API (/v1/*) [canonical]\n ├─ codewhale app-server --mobile → runtime API + mobile control page\n ├─ codewhale app-server --stdio → JSON-RPC control transport over stdio\n ├─ codewhale app-server --socket → same JSON-RPC over a unix domain socket (desktop daemon)\n ├─ codewhale doctor --json → machine-readable health & capability\n ├─ codewhale serve --acp → ACP stdio agent for editors such as Zed\n ├─ codewhale serve --mcp → MCP stdio server\n ├─ codewhale serve --http/--mobile → legacy aliases for `app-server --http/--mobile`\n └─ codewhale exec [args] → one-shot headless worker (stream-json)\n```\n\nThe engine runs as a local-only process. All APIs bind to `localhost` by\ndefault. No hosted relay, no provider-token custody, no secret leakage.\n\nFor the read-only record of what a thread or turn did, see\n[`docs/RECEIPTS.md`](RECEIPTS.md): `codewhale receipts` on the CLI and the\n`/receipt` routes under **Threads** below.\n\n## Runtime API entrypoints\n\n| Entry | Transport | Use |\n|---|---|---|\n| `codewhale web [--port 7878]` | HTTP/SSE on `127.0.0.1:7878` + embedded client | First-class loopback-only browser client; opens the default browser |\n| `codewhale app-server --http` | HTTP/SSE on `127.0.0.1:7878` | Full `/v1/*` runtime API (canonical) |\n| `codewhale app-server --mobile` | HTTP/SSE on loopback + `/mobile` | Runtime API + local mobile control page |\n| `codewhale app-server --stdio` | Initialized JSONL over stdio | Compatibility controls and explicit tool-free turns through the held manager; no hidden TCP listener or full Codex interaction channel. |\n| `codewhale app-server --socket [--socket-path P]` | RPC text messages over WebSocket on a `0600` Unix domain socket (macOS/Linux) or an owner-only named pipe (Windows) | Desktop daemon: multi-client, kernel peer identity checked, `daemon/attach` claim handshake. Windows native-app qualification remains open. |\n| `codewhale app-server` | Initialized JSONL over stdio | Same selection as `--stdio`; HTTP requires explicit `--http`. |\n| `codewhale serve --http` / `--mobile` | same server as `app-server --http`/`--mobile` | Compatibility aliases |\n\n`app-server --http` and `--mobile` launch the same mature runtime API server\nhistorically reached through `serve --http` — no routes or behavior changed, so\nevery endpoint documented below is identical across both entrypoints. The\nruntime API token is read from `--auth-token`, then `CODEWHALE_RUNTIME_TOKEN`,\nthen `DEEPSEEK_RUNTIME_TOKEN`; use `--insecure-no-auth` only with a loopback\nbind. The `serve` compatibility aliases keep their `--insecure` flag.\nBare `app-server` does not open an HTTP port. Selected attached HTTP listeners\nretain their explicit bearer and non-loopback security checks.\n\nInitialized stdio/private-socket controls support `thread/loaded/list` with\n`{cursor?: string | null, limit?: number | null}`. The result is\n`{data: string[], nextCursor: string | null}` from the held manager's loaded\nengine snapshot, including idle and archived engines still in memory. It dLine truncated
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -434,13 +434,13 @@
|
||||
"sources": [
|
||||
{
|
||||
"path": "docs/RUNTIME_API.md",
|
||||
"sha256": "6f3b8885978ef2e1b33d0fdf66ad6ccf5c25464c1951b59658aec00745390db2",
|
||||
"content": "# Codewhale Engine Runtime API & Integration Contract\n\n> 阅读简体中文版:[zh_hans/RUNTIME_API.md](zh_hans/RUNTIME_API.md)。\n\n`codewhale app-server --http` exposes the [Codewhale Engine](ARCHITECTURE.md) through\nits canonical local Runtime API and control plane.\nLocal SDKs, mobile/remote-control clients, and editor integrations talk to it\ninstead of screen-scraping terminal output. It serves the full HTTP/SSE runtime\nAPI (`/v1/*`), a JSON-RPC control transport over stdio, and the phone-friendly\nmobile page. `codewhale doctor --json` provides machine-readable health, and\n`codewhale serve --acp` speaks the Agent Client Protocol over stdio for editors\nsuch as Zed.\n\n`codewhale serve --http` / `serve --mobile` remain as **compatibility aliases**\nfor `codewhale app-server --http` / `--mobile`; both launch the identical\nserver. Desktop, web, SDK and custom integrations should target\n`app-server --http`. `--stdio` and `--socket` expose compatibility controls.\nBare `app-server` selects initialized JSONL control over stdio. Local stdio,\nsocket and ACP owners do not bind a hidden TCP listener. Explicit `--http`\nretains HTTP/SSE and compatibility routes on the same Runtime owner.\n\n`codewhale exec` is the separate one-shot headless worker path (stream-json,\nfleet worker subprocess, CI primitive). It is not part of this API, but it\nshares the same runtime, provider/model resolution, permission profiles, and\nevent vocabulary.\n\nThis document is the stable integration contract for native workbench\napplications (and other local supervisors) that embed the Codewhale Engine.\n\n## Architecture\n\n```\nlocal supervisor / SDK / automation harness\n │\n ├─ codewhale app-server --http → HTTP/SSE runtime API (/v1/*) [canonical]\n ├─ codewhale app-server --mobile → runtime API + mobile control page\n ├─ codewhale app-server --stdio → JSON-RPC control transport over stdio\n ├─ codewhale app-server --socket → same JSON-RPC over a unix domain socket (desktop daemon)\n ├─ codewhale doctor --json → machine-readable health & capability\n ├─ codewhale serve --acp → ACP stdio agent for editors such as Zed\n ├─ codewhale serve --mcp → MCP stdio server\n ├─ codewhale serve --http/--mobile → legacy aliases for `app-server --http/--mobile`\n └─ codewhale exec [args] → one-shot headless worker (stream-json)\n```\n\nThe engine runs as a local-only process. All APIs bind to `localhost` by\ndefault. No hosted relay, no provider-token custody, no secret leakage.\n\nFor the read-only record of what a thread or turn did, see\n[`docs/RECEIPTS.md`](RECEIPTS.md): `codewhale receipts` on the CLI and the\n`/receipt` routes under **Threads** below.\n\n## Runtime API entrypoints\n\n| Entry | Transport | Use |\n|---|---|---|\n| `codewhale web [--port 7878]` | HTTP/SSE on `127.0.0.1:7878` + embedded client | First-class loopback-only browser client; opens the default browser |\n| `codewhale app-server --http` | HTTP/SSE on `127.0.0.1:7878` | Full `/v1/*` runtime API (canonical) |\n| `codewhale app-server --mobile` | HTTP/SSE on loopback + `/mobile` | Runtime API + local mobile control page |\n| `codewhale app-server --stdio` | Initialized JSONL over stdio | Compatibility controls and explicit tool-free turns through the held manager; no hidden TCP listener or full Codex interaction channel. |\n| `codewhale app-server --socket [--socket-path P]` | RPC text messages over WebSocket on a `0600` Unix domain socket (macOS/Linux) or an owner-only named pipe (Windows) | Desktop daemon: multi-client, kernel peer identity checked, `daemon/attach` claim handshake. Windows native-app qualification remains open. |\n| `codewhale app-server` | Initialized JSONL over stdio | Same selection as `--stdio`; HTTP requires explicit `--http`. |\n| `codewhale serve --http` / `--mobile` | same server as `app-server --http`/`--mobile` | Compatibility aliases |\n\n`app-server --http` and `--mobile` launch the same mature runtime API server\nhistorically reached through `serve --http` — no routes or behavior changed, so\nevery endpoint documented below is identical across both entrypoints. The\nruntime API token is read from `--auth-token`, then `CODEWHALE_RUNTIME_TOKEN`,\nthen `DEEPSEEK_RUNTIME_TOKEN`; use `--insecure-no-auth` only with a loopback\nbind. The `serve` compatibility aliases keep their `--insecure` flag.\nBare `app-server` does not open an HTTP port. Selected attached HTTP listeners\nretain their explicit bearer and non-loopback security checks.\n\nInitialized stdio/private-socket controls support `thread/loaded/list` with\n`{cursor?: string | null, limit?: number | null}`. The result is\n`{data: string[], nextCursor: string | null}` from the held manager's loaded\nengine snapshot, including idle and archived engines still in memory. It dLine truncated
|
||||
"sha256": "e3040635df0cf62717f5acbe9aacf4909b6ad60db8468b06e90a5e9be1d76d1e",
|
||||
"content": "# Codewhale Engine Runtime API & Integration Contract\n\n> 阅读简体中文版:[zh_hans/RUNTIME_API.md](zh_hans/RUNTIME_API.md)。\n\n`codewhale app-server --http` exposes the [Codewhale Engine](ARCHITECTURE.md) through\nits canonical local Runtime API and control plane.\nLocal SDKs, mobile/remote-control clients, and editor integrations talk to it\ninstead of screen-scraping terminal output. It serves the full HTTP/SSE runtime\nAPI (`/v1/*`), a JSON-RPC control transport over stdio, and the phone-friendly\nmobile page. `codewhale doctor --json` provides machine-readable health, and\n`codewhale serve --acp` speaks the Agent Client Protocol over stdio for editors\nsuch as Zed.\n\n`codewhale serve --http` / `serve --mobile` remain as **compatibility aliases**\nfor `codewhale app-server --http` / `--mobile`; both launch the identical\nserver. Desktop, web, SDK and custom integrations should target\n`app-server --http`. `--stdio` and `--socket` expose compatibility controls.\nBare `app-server` selects initialized JSONL control over stdio. Local stdio,\nsocket and ACP owners do not bind a hidden TCP listener. Explicit `--http`\nretains HTTP/SSE and compatibility routes on the same Runtime owner.\n\n`codewhale exec` is the separate one-shot headless worker path (stream-json,\nfleet worker subprocess, CI primitive). It is not part of this API, but it\nshares the same runtime, provider/model resolution, permission profiles, and\nevent vocabulary.\n\nThis document is the stable integration contract for native workbench\napplications (and other local supervisors) that embed the Codewhale Engine.\n\n## Architecture\n\n```\nlocal supervisor / SDK / automation harness\n │\n ├─ codewhale app-server --http → HTTP/SSE runtime API (/v1/*) [canonical]\n ├─ codewhale app-server --mobile → runtime API + mobile control page\n ├─ codewhale app-server --stdio → JSON-RPC control transport over stdio\n ├─ codewhale app-server --socket → same JSON-RPC over a unix domain socket (desktop daemon)\n ├─ codewhale doctor --json → machine-readable health & capability\n ├─ codewhale serve --acp → ACP stdio agent for editors such as Zed\n ├─ codewhale serve --mcp → MCP stdio server\n ├─ codewhale serve --http/--mobile → legacy aliases for `app-server --http/--mobile`\n └─ codewhale exec [args] → one-shot headless worker (stream-json)\n```\n\nThe engine runs as a local-only process. All APIs bind to `localhost` by\ndefault. No hosted relay, no provider-token custody, no secret leakage.\n\nFor the read-only record of what a thread or turn did, see\n[`docs/RECEIPTS.md`](RECEIPTS.md): `codewhale receipts` on the CLI and the\n`/receipt` routes under **Threads** below.\n\n## Runtime API entrypoints\n\n| Entry | Transport | Use |\n|---|---|---|\n| `codewhale web [--port 7878]` | HTTP/SSE on `127.0.0.1:7878` + embedded client | First-class loopback-only browser client; opens the default browser |\n| `codewhale app-server --http` | HTTP/SSE on `127.0.0.1:7878` | Full `/v1/*` runtime API (canonical) |\n| `codewhale app-server --mobile` | HTTP/SSE on loopback + `/mobile` | Runtime API + local mobile control page |\n| `codewhale app-server --stdio` | Initialized JSONL over stdio | Compatibility controls and explicit tool-free turns through the held manager; no hidden TCP listener or full Codex interaction channel. |\n| `codewhale app-server --socket [--socket-path P]` | RPC text messages over WebSocket on a `0600` Unix domain socket (macOS/Linux) or an owner-only named pipe (Windows) | Desktop daemon: multi-client, kernel peer identity checked, `daemon/attach` claim handshake. Windows native-app qualification remains open. |\n| `codewhale app-server` | Initialized JSONL over stdio | Same selection as `--stdio`; HTTP requires explicit `--http`. |\n| `codewhale serve --http` / `--mobile` | same server as `app-server --http`/`--mobile` | Compatibility aliases |\n\n`app-server --http` and `--mobile` launch the same mature runtime API server\nhistorically reached through `serve --http` — no routes or behavior changed, so\nevery endpoint documented below is identical across both entrypoints. The\nruntime API token is read from `--auth-token`, then `CODEWHALE_RUNTIME_TOKEN`,\nthen `DEEPSEEK_RUNTIME_TOKEN`; use `--insecure-no-auth` only with a loopback\nbind. The `serve` compatibility aliases keep their `--insecure` flag.\nBare `app-server` does not open an HTTP port. Selected attached HTTP listeners\nretain their explicit bearer and non-loopback security checks.\n\nInitialized stdio/private-socket controls support `thread/loaded/list` with\n`{cursor?: string | null, limit?: number | null}`. The result is\n`{data: string[], nextCursor: string | null}` from the held manager's loaded\nengine snapshot, including idle and archived engines still in memory. It dLine truncated
|
||||
},
|
||||
{
|
||||
"path": "docs/RUNTIME_ROUTES.md",
|
||||
"sha256": "1a21c05b0249c2ff225f7b554e1e610fc12122ab3da52e6ac7ceb06c90448984",
|
||||
"content": "# Runtime HTTP route inventory\n\nThis table is generated from literal route registrations in\n`crates/app-server/src/runtime_api.rs`. It names the paths and methods that\nthe current source registers, including native-client and compatibility\nsurfaces. A registered path is not a grant of permission or a promise that\na feature is enabled in your installed version.\n\nThe [Runtime API contract](RUNTIME_API.md) explains request fields, events,\nauthorization and compatibility. This inventory is a navigation index, not\na replacement for those schemas; routes used by the native client can have\nclient-specific contracts. Provider calls, file mutations and connected-service\nactions remain subject to their documented authority boundaries.\n\nRun `node web/scripts/check-runtime-routes.mjs --write` after changing a\nregistration; `npm run check:docs` refuses a stale inventory. Dynamic nested\nrouters, fallback handlers and static-asset mounts are outside this literal\nregistration index.\n\n| HTTP method | Path |\n| --- | --- |\n| GET | `/` |\n| GET | `/__codewhale/bootstrap/{nonce}` |\n| GET | `/__codewhale/mobile/bootstrap/{nonce}` |\n| POST | `/__codewhale/mobile/session` |\n| POST | `/__codewhale/mobile/stream-ticket` |\n| POST | `/__codewhale/web/stream-ticket` |\n| GET | `/assets/codewhale-192.png` |\n| GET | `/assets/codewhale-web.css` |\n| GET | `/assets/codewhale-web.js` |\n| GET | `/health` |\n| GET | `/mobile` |\n| GET | `/mobile/` |\n| DELETE / GET / PUT | `/v1/account/model-access` |\n| POST | `/v1/agent-mail` |\n| GET | `/v1/agent-runs` |\n| GET | `/v1/agent-runs/{run_id}` |\n| POST | `/v1/agent-runs/{run_id}/cancel` |\n| GET | `/v1/agents/external` |\n| GET | `/v1/agents/external/{id}/options` |\n| GET | `/v1/agents/external/quota` |\n| GET | `/v1/approvals` |\n| POST | `/v1/approvals/{approval_id}` |\n| GET | `/v1/apps/avatars` |\n| GET | `/v1/apps/avatars/{handle}/atlases/{page}` |\n| GET / POST | `/v1/apps/marketplaces` |\n| DELETE / GET | `/v1/apps/marketplaces/{name}` |\n| POST | `/v1/apps/marketplaces/{name}/install` |\n| POST | `/v1/apps/mcp/desktop-browser` |\n| GET / POST | `/v1/apps/mcp/imports` |\n| GET / POST | `/v1/apps/mcp/servers` |\n| DELETE / GET / PATCH | `/v1/apps/mcp/servers/{name}` |\n| POST | `/v1/apps/mcp/servers/{name}/disable` |\n| POST | `/v1/apps/mcp/servers/{name}/enable` |\n| POST | `/v1/apps/mcp/servers/{name}/reconnect` |\n| GET | `/v1/apps/mcp/tools` |\n| GET | `/v1/apps/plugins` |\n| DELETE / GET | `/v1/apps/plugins/{selector}` |\n| POST | `/v1/apps/plugins/{selector}/disable` |\n| POST | `/v1/apps/plugins/{selector}/enable` |\n| POST | `/v1/apps/plugins/{selector}/restage` |\n| POST | `/v1/apps/plugins/{selector}/revoke` |\n| ANY | `/v1/apps/plugins/{selector}/routes/{*path}` |\n| POST | `/v1/apps/plugins/{selector}/trust` |\n| GET | `/v1/apps/plugins/{selector}/ui` |\n| GET | `/v1/apps/plugins/{selector}/ui/{*path}` |\n| POST | `/v1/apps/plugins/{selector}/update` |\n| GET | `/v1/apps/plugins/catalog` |\n| POST | `/v1/apps/plugins/catalog/{id}/disable` |\n| POST | `/v1/apps/plugins/catalog/{id}/enable` |\n| PUT | `/v1/apps/plugins/catalog/{id}/settings` |\n| POST | `/v1/apps/plugins/import/dsh/preview` |\n| POST | `/v1/apps/plugins/install` |\n| GET / POST | `/v1/automations` |\n| DELETE / GET / PATCH | `/v1/automations/{id}` |\n| GET | `/v1/automations/{id}/missed` |\n| PUT | `/v1/automations/{id}/missed/policy` |\n| POST | `/v1/automations/{id}/missed/run` |\n| POST | `/v1/automations/{id}/missed/skip` |\n| POST | `/v1/automations/{id}/pause` |\n| POST | `/v1/automations/{id}/resume` |\n| POST | `/v1/automations/{id}/run` |\n| GET | `/v1/automations/{id}/runs` |\n| GET | `/v1/automations/missed` |\n| GET | `/v1/changes` |\n| GET / POST | `/v1/cloud` |\n| POST | `/v1/cloud/attach` |\n| GET | `/v1/commands` |\n| GET / POST | `/v1/config` |\n| POST | `/v1/config/reload` |\n| GET | `/v1/constitution` |\n| POST | `/v1/constitution/preview` |\n| GET | `/v1/crashes` |\n| GET | `/v1/crashes/{name}` |\n| GET | `/v1/definition` |\n| GET | `/v1/diagnostics` |\n| GET | `/v1/diff` |\n| POST | `/v1/fleet/placement` |\n| GET | `/v1/fleet/profiles` |\n| GET | `/v1/fleet/profiles/{id}/document` |\n| GET / POST | `/v1/fleet/runs` |\n| GET | `/v1/fleet/runs/{run_id}` |\n| GET | `/v1/fleet/runs/{run_id}/events` |\n| GET | `/v1/fleet/runs/{run_id}/events/replay` |\n| GET | `/v1/fleet/runs/{run_id}/receipts` |\n| GET | `/v1/fleet/runs/{run_id}/receipts/{task_id}` |\n| GET | `/v1/fleet/runs/{run_id}/receipts/{task_id}/evidence` |\n| POST | `/v1/fleet/runs/{run_id}/start` |\n| POST | `/v1/fleet/runs/{run_id}/stop` |\n| GET | `/v1/fleet/runs/{run_id}/workers` |\n| GET | `/v1/fleet/workers/{worker_id}` |\n| POST | `/v1/fleet/workers/{worker_id}/interrupt` |\n| POST | `/v1/fleet/workers/{worker_id}/restart` |\n| POST | `/v1/fleet/workers/{worker_id}/stop` |\n| GET | `/v1/git` |\n| POST | `/v1/git/branch` |\n| POST | `/v1/git/commit` |\n| POST | `/v1/git/discard` |\nLine truncated
|
||||
"sha256": "eae9cc4778f7c6f4ae2db5fe0d2d39d757521337a0818b2c2bf4a4f5dd686e0e",
|
||||
"content": "# Runtime HTTP route inventory\n\nThis table is generated from literal route registrations in\n`crates/app-server/src/runtime_api.rs`. It names the paths and methods that\nthe current source registers, including native-client and compatibility\nsurfaces. A registered path is not a grant of permission or a promise that\na feature is enabled in your installed version.\n\nThe [Runtime API contract](RUNTIME_API.md) explains request fields, events,\nauthorization and compatibility. This inventory is a navigation index, not\na replacement for those schemas; routes used by the native client can have\nclient-specific contracts. Provider calls, file mutations and connected-service\nactions remain subject to their documented authority boundaries.\n\nRun `node web/scripts/check-runtime-routes.mjs --write` after changing a\nregistration; `npm run check:docs` refuses a stale inventory. Dynamic nested\nrouters, fallback handlers and static-asset mounts are outside this literal\nregistration index.\n\n| HTTP method | Path |\n| --- | --- |\n| GET | `/` |\n| GET | `/__codewhale/bootstrap/{nonce}` |\n| GET | `/__codewhale/mobile/bootstrap/{nonce}` |\n| POST | `/__codewhale/mobile/session` |\n| POST | `/__codewhale/mobile/stream-ticket` |\n| POST | `/__codewhale/web/stream-ticket` |\n| GET | `/assets/codewhale-192.png` |\n| GET | `/assets/codewhale-web.css` |\n| GET | `/assets/codewhale-web.js` |\n| GET | `/health` |\n| GET | `/mobile` |\n| GET | `/mobile/` |\n| DELETE / GET / PUT | `/v1/account/model-access` |\n| POST | `/v1/agent-mail` |\n| GET | `/v1/agent-runs` |\n| GET | `/v1/agent-runs/{run_id}` |\n| POST | `/v1/agent-runs/{run_id}/cancel` |\n| GET | `/v1/agents/external` |\n| GET | `/v1/agents/external/{id}/options` |\n| GET | `/v1/agents/external/quota` |\n| GET | `/v1/approvals` |\n| POST | `/v1/approvals/{approval_id}` |\n| GET | `/v1/apps/avatars` |\n| GET | `/v1/apps/avatars/{handle}/atlases/{page}` |\n| GET / POST | `/v1/apps/marketplaces` |\n| DELETE / GET | `/v1/apps/marketplaces/{name}` |\n| POST | `/v1/apps/marketplaces/{name}/install` |\n| POST | `/v1/apps/mcp/desktop-browser` |\n| GET / POST | `/v1/apps/mcp/imports` |\n| GET / POST | `/v1/apps/mcp/servers` |\n| DELETE / GET / PATCH | `/v1/apps/mcp/servers/{name}` |\n| POST | `/v1/apps/mcp/servers/{name}/disable` |\n| POST | `/v1/apps/mcp/servers/{name}/enable` |\n| POST | `/v1/apps/mcp/servers/{name}/reconnect` |\n| GET | `/v1/apps/mcp/tools` |\n| GET | `/v1/apps/plugin-index` |\n| GET | `/v1/apps/plugins` |\n| DELETE / GET | `/v1/apps/plugins/{selector}` |\n| POST | `/v1/apps/plugins/{selector}/disable` |\n| POST | `/v1/apps/plugins/{selector}/enable` |\n| POST | `/v1/apps/plugins/{selector}/restage` |\n| POST | `/v1/apps/plugins/{selector}/revoke` |\n| ANY | `/v1/apps/plugins/{selector}/routes/{*path}` |\n| POST | `/v1/apps/plugins/{selector}/trust` |\n| GET | `/v1/apps/plugins/{selector}/ui` |\n| GET | `/v1/apps/plugins/{selector}/ui/{*path}` |\n| POST | `/v1/apps/plugins/{selector}/update` |\n| GET | `/v1/apps/plugins/catalog` |\n| POST | `/v1/apps/plugins/catalog/{id}/disable` |\n| POST | `/v1/apps/plugins/catalog/{id}/enable` |\n| PUT | `/v1/apps/plugins/catalog/{id}/settings` |\n| POST | `/v1/apps/plugins/import/dsh/preview` |\n| POST | `/v1/apps/plugins/install` |\n| GET / POST | `/v1/automations` |\n| DELETE / GET / PATCH | `/v1/automations/{id}` |\n| GET | `/v1/automations/{id}/missed` |\n| PUT | `/v1/automations/{id}/missed/policy` |\n| POST | `/v1/automations/{id}/missed/run` |\n| POST | `/v1/automations/{id}/missed/skip` |\n| POST | `/v1/automations/{id}/pause` |\n| POST | `/v1/automations/{id}/resume` |\n| POST | `/v1/automations/{id}/run` |\n| GET | `/v1/automations/{id}/runs` |\n| GET | `/v1/automations/missed` |\n| GET | `/v1/changes` |\n| GET / POST | `/v1/cloud` |\n| POST | `/v1/cloud/attach` |\n| GET | `/v1/commands` |\n| GET / POST | `/v1/config` |\n| POST | `/v1/config/reload` |\n| GET | `/v1/constitution` |\n| POST | `/v1/constitution/preview` |\n| GET | `/v1/crashes` |\n| GET | `/v1/crashes/{name}` |\n| GET | `/v1/definition` |\n| GET | `/v1/diagnostics` |\n| GET | `/v1/diff` |\n| POST | `/v1/fleet/placement` |\n| GET | `/v1/fleet/profiles` |\n| GET | `/v1/fleet/profiles/{id}/document` |\n| GET / POST | `/v1/fleet/runs` |\n| GET | `/v1/fleet/runs/{run_id}` |\n| GET | `/v1/fleet/runs/{run_id}/events` |\n| GET | `/v1/fleet/runs/{run_id}/events/replay` |\n| GET | `/v1/fleet/runs/{run_id}/receipts` |\n| GET | `/v1/fleet/runs/{run_id}/receipts/{task_id}` |\n| GET | `/v1/fleet/runs/{run_id}/receipts/{task_id}/evidence` |\n| POST | `/v1/fleet/runs/{run_id}/start` |\n| POST | `/v1/fleet/runs/{run_id}/stop` |\n| GET | `/v1/fleet/runs/{run_id}/workers` |\n| GET | `/v1/fleet/workers/{worker_id}` |\n| POST | `/v1/fleet/workers/{worker_id}/interrupt` |\n| POST | `/v1/fleet/workers/{worker_id}/restart` |\n| POST | `/v1/fleet/workers/{worker_id}/stop` |\n| GET | `/v1/git` |\n| POST | `/v1/git/branch` |\n| POST | `/v1/git/commitLine truncated
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user