chore/merge-all-uncommitted #235
26 changed files with 1474 additions and 120 deletions
20
Cargo.lock
generated
20
Cargo.lock
generated
|
|
@ -386,6 +386,8 @@ dependencies = [
|
|||
"clap",
|
||||
"colibri-client",
|
||||
"colibri-daemon",
|
||||
"colibri-pf",
|
||||
"colibri-zfs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
|
|
@ -394,6 +396,15 @@ dependencies = [
|
|||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colibri-pf"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colibri-runtime"
|
||||
version = "0.12.0"
|
||||
|
|
@ -437,6 +448,15 @@ dependencies = [
|
|||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colibri-zfs"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
[workspace]
|
||||
members = ["crates/colibri-contracts", "crates/colibri-deepseek", "crates/colibri-runtime", "crates/colibri-glasspane", "crates/colibri-daemon", "crates/colibri-client", "crates/colibri-glasspane-tui", "crates/colibri-store", "crates/colibri-skills", "crates/colibri-mcp", "crates/colibri-vault", "crates/clawdie"]
|
||||
members = ["crates/colibri-contracts", "crates/colibri-deepseek", "crates/colibri-runtime", "crates/colibri-glasspane", "crates/colibri-daemon", "crates/colibri-client", "crates/colibri-glasspane-tui", "crates/colibri-store", "crates/colibri-skills", "crates/colibri-mcp", "crates/colibri-vault", "crates/colibri-zfs", "crates/colibri-pf", "crates/clawdie"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.12.0"
|
||||
|
|
|
|||
|
|
@ -334,11 +334,14 @@ fn push_cost_to_mother(task_id: &str, tc: &colibri_store::TaskCost) {
|
|||
let cache_write_tokens = tc.cache_write_tokens;
|
||||
let cost = tc.cost;
|
||||
let success = tc.success;
|
||||
// Optional: tmux-screenshot UUID set by the agent harness on completion.
|
||||
// The daemon passes it through to mother; mother JOINs with screenshot storage.
|
||||
let screenshot_uuid = std::env::var("COLIBRI_TASK_SCREENSHOT_UUID").ok();
|
||||
|
||||
// Run SSH in a blocking thread — heartbeat is async, SSH is fast (<1s).
|
||||
std::thread::spawn(move || {
|
||||
use std::io::Write;
|
||||
let payload = serde_json::json!({
|
||||
let mut payload = serde_json::json!({
|
||||
"node_hostname": node_hostname,
|
||||
"task_id": task_id,
|
||||
"provider": provider,
|
||||
|
|
@ -351,11 +354,16 @@ fn push_cost_to_mother(task_id: &str, tc: &colibri_store::TaskCost) {
|
|||
"success": success,
|
||||
"finished_at": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
if let Some(ref uuid) = screenshot_uuid {
|
||||
payload["screenshot_uuid"] = serde_json::Value::String(uuid.clone());
|
||||
}
|
||||
let payload_line = serde_json::to_string(&payload).unwrap_or_default();
|
||||
let mut child = match std::process::Command::new("ssh")
|
||||
.args([
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "ConnectTimeout=5",
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"ConnectTimeout=5",
|
||||
&mother_host,
|
||||
"report-task-cost",
|
||||
])
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ path = "src/main.rs"
|
|||
[dependencies]
|
||||
colibri-client = { path = "../colibri-client" }
|
||||
colibri-daemon = { path = "../colibri-daemon" }
|
||||
colibri-pf = { path = "../colibri-pf" }
|
||||
colibri-zfs = { path = "../colibri-zfs" }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@
|
|||
//! | `colibri_create_task` | write-gated| Create a task |
|
||||
//! | `colibri_intake_task` | write-gated| Submit intake task with capabilities|
|
||||
//! | `colibri_set_cost_mode` | write-gated | Switch cost mode (fast/smart/max) |
|
||||
//! | `colibri_zfs_list_snapshots` | read-only | ZFS snapshot listing |
|
||||
//! | `colibri_zfs_destroy_snapshot` | write-gated | Destroy a ZFS snapshot |
|
||||
//! | `colibri_pf_list_rules` | read-only | Active PF firewall rules |
|
||||
//! | `colibri_pf_list_states` | read-only | PF state table entries |
|
||||
//! | `colibri_wiki_search` | read-only | Search wiki pages |
|
||||
//! | `colibri_wiki_page` | read-only | Read wiki page content |
|
||||
//! | `colibri_get_task` | read-only | Task details with cost data |
|
||||
//! | `colibri_list_task_costs` | read-only | All tasks with cost (dashboard) |
|
||||
//!
|
||||
|
|
@ -168,6 +174,62 @@ pub fn tool_list() -> Vec<Value> {
|
|||
}
|
||||
})),
|
||||
),
|
||||
// ── Infrastructure tools ──
|
||||
json_tool(
|
||||
"colibri_zfs_list_snapshots",
|
||||
"List ZFS snapshots for a dataset. Returns structured data: name, used_bytes, refer_bytes, creation, age_hours.",
|
||||
Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dataset": { "type": "string", "description": "ZFS dataset name, e.g. 'zroot/home/clawdie'" }
|
||||
},
|
||||
"required": ["dataset"]
|
||||
})),
|
||||
),
|
||||
json_tool(
|
||||
"colibri_zfs_destroy_snapshot",
|
||||
"Destroy a ZFS snapshot by full name. Requires ZFS destroy permission.",
|
||||
Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string", "description": "Full snapshot name, e.g. 'zroot/home@autosnap_2026-06-27_09:00:00_hourly'" }
|
||||
},
|
||||
"required": ["name"]
|
||||
})),
|
||||
),
|
||||
json_tool(
|
||||
"colibri_pf_list_rules",
|
||||
"List active PF firewall rules. Returns array of rule strings.",
|
||||
None,
|
||||
),
|
||||
json_tool(
|
||||
"colibri_pf_list_states",
|
||||
"List active PF state table entries: protocol, source, destination, state.",
|
||||
None,
|
||||
),
|
||||
// ── Wiki tools ──
|
||||
json_tool(
|
||||
"colibri_wiki_search",
|
||||
"Search Colibri wiki pages for a keyword. Returns matching page names and line excerpts.",
|
||||
Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": { "type": "string", "description": "Search keyword or phrase" }
|
||||
},
|
||||
"required": ["query"]
|
||||
})),
|
||||
),
|
||||
json_tool(
|
||||
"colibri_wiki_page",
|
||||
"Read a Colibri wiki page in full. Returns the complete markdown content.",
|
||||
Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page": { "type": "string", "description": "Wiki page name without .md, e.g. 'cost-model' or 'sl/cost-model'" }
|
||||
},
|
||||
"required": ["page"]
|
||||
})),
|
||||
),
|
||||
json_tool(
|
||||
"colibri_external_mcp_servers",
|
||||
"List configured external MCP servers from COLIBRI_MCP_EXTERNAL_CONFIG",
|
||||
|
|
@ -324,6 +386,53 @@ pub async fn dispatch_tool(
|
|||
let all_tasks = client.list_tasks(status).await.map_err(map_client_error)?;
|
||||
Ok(tool_text(all_tasks))
|
||||
}
|
||||
// ── Infrastructure dispatch ──
|
||||
"colibri_zfs_list_snapshots" => {
|
||||
let dataset = require_string(arguments, "dataset")?;
|
||||
let snaps = colibri_zfs::Snapshot::list(&dataset)
|
||||
.map_err(|e| McpError::internal(format!("zfs: {e}")))?;
|
||||
Ok(tool_text(serde_json::to_value(&snaps).unwrap_or_default()))
|
||||
}
|
||||
"colibri_zfs_destroy_snapshot" => {
|
||||
let name = require_string(arguments, "name")?;
|
||||
let snap = colibri_zfs::Snapshot {
|
||||
name,
|
||||
dataset: String::new(),
|
||||
tag: String::new(),
|
||||
used_bytes: 0,
|
||||
refer_bytes: 0,
|
||||
creation: String::new(),
|
||||
};
|
||||
snap.destroy()
|
||||
.map_err(|e| McpError::internal(format!("zfs destroy: {e}")))?;
|
||||
Ok(tool_text(serde_json::json!({"destroyed": true})))
|
||||
}
|
||||
"colibri_pf_list_rules" => {
|
||||
let rules =
|
||||
colibri_pf::list_rules().map_err(|e| McpError::internal(format!("pf: {e}")))?;
|
||||
Ok(tool_text(serde_json::to_value(&rules).unwrap_or_default()))
|
||||
}
|
||||
"colibri_pf_list_states" => {
|
||||
let states =
|
||||
colibri_pf::list_states().map_err(|e| McpError::internal(format!("pf: {e}")))?;
|
||||
Ok(tool_text(serde_json::to_value(&states).unwrap_or_default()))
|
||||
}
|
||||
// ── Wiki dispatch ──
|
||||
"colibri_wiki_search" => {
|
||||
let query = require_string(arguments, "query")?;
|
||||
let results = wiki_search(&query);
|
||||
Ok(tool_text(
|
||||
serde_json::to_value(&results).unwrap_or_default(),
|
||||
))
|
||||
}
|
||||
"colibri_wiki_page" => {
|
||||
let page = require_string(arguments, "page")?;
|
||||
let content = wiki_page(&page)
|
||||
.ok_or_else(|| McpError::not_found(format!("wiki page not found: {page}")))?;
|
||||
Ok(tool_text(
|
||||
serde_json::json!({"page": page, "content": content}),
|
||||
))
|
||||
}
|
||||
"colibri_external_mcp_servers" => {
|
||||
let registry = external::load_registry_if_present(&config.external_config_path).await?;
|
||||
Ok(tool_text(serde_json::json!({
|
||||
|
|
@ -588,3 +697,70 @@ impl StdioHandler {
|
|||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wiki helpers ──
|
||||
|
||||
fn wiki_search(query: &str) -> Vec<serde_json::Value> {
|
||||
let wiki_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/wiki");
|
||||
let q = query.to_lowercase();
|
||||
let mut results = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(&wiki_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().is_none_or(|e| e != "md") {
|
||||
continue;
|
||||
}
|
||||
let Ok(content) = std::fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
let matches: Vec<String> = content
|
||||
.lines()
|
||||
.enumerate()
|
||||
.filter(|(_, l)| l.to_lowercase().contains(&q))
|
||||
.take(3)
|
||||
.map(|(i, l)| format!("L{}: {}", i + 1, l.trim()))
|
||||
.collect();
|
||||
if !matches.is_empty() {
|
||||
let name = path.file_stem().unwrap_or_default().to_string_lossy();
|
||||
results.push(serde_json::json!({
|
||||
"page": name,
|
||||
"matches": matches
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also search sl/ subdirectory
|
||||
let sl_dir = wiki_dir.join("sl");
|
||||
if let Ok(entries) = std::fs::read_dir(&sl_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().is_none_or(|e| e != "md") {
|
||||
continue;
|
||||
}
|
||||
let Ok(content) = std::fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
let matches: Vec<String> = content
|
||||
.lines()
|
||||
.enumerate()
|
||||
.filter(|(_, l)| l.to_lowercase().contains(&q))
|
||||
.take(3)
|
||||
.map(|(i, l)| format!("L{}: {}", i + 1, l.trim()))
|
||||
.collect();
|
||||
if !matches.is_empty() {
|
||||
let name = path.file_stem().unwrap_or_default().to_string_lossy();
|
||||
results.push(serde_json::json!({
|
||||
"page": format!("sl/{}", name),
|
||||
"matches": matches
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
results
|
||||
}
|
||||
|
||||
fn wiki_page(page: &str) -> Option<String> {
|
||||
let wiki_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/wiki");
|
||||
let path = wiki_dir.join(format!("{page}.md"));
|
||||
std::fs::read_to_string(&path).ok()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -255,5 +255,5 @@ fn tool_list_has_all_phase1_tools() {
|
|||
assert!(names.contains(&"colibri_list_task_costs"));
|
||||
|
||||
assert!(names.contains(&"colibri_get_task"));
|
||||
assert_eq!(names.len(), 12);
|
||||
assert_eq!(names.len(), 18);
|
||||
}
|
||||
|
|
|
|||
10
crates/colibri-pf/Cargo.toml
Normal file
10
crates/colibri-pf/Cargo.toml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
[package]
|
||||
name = "colibri-pf"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
86
crates/colibri-pf/src/lib.rs
Normal file
86
crates/colibri-pf/src/lib.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
//! colibri-pf — structured PF firewall inspection for Colibri agents.
|
||||
//!
|
||||
//! Wraps `pfctl(8)` output into Rust structs. Read-only operations
|
||||
//! work without root; state/rule manipulation requires privileges.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PfRule {
|
||||
pub line: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PfState {
|
||||
pub proto: String,
|
||||
pub src: String,
|
||||
pub dst: String,
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("pfctl failed: {0}")]
|
||||
Command(String),
|
||||
}
|
||||
|
||||
/// List active PF rules (equivalent to `pfctl -s rules`).
|
||||
pub fn list_rules() -> Result<Vec<String>, Error> {
|
||||
let output = Command::new("pfctl")
|
||||
.args(["-s", "rules"])
|
||||
.output()
|
||||
.map_err(|e| Error::Command(format!("pfctl: {e}")))?;
|
||||
if !output.status.success() {
|
||||
return Err(Error::Command(
|
||||
String::from_utf8_lossy(&output.stderr).into(),
|
||||
));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.map(|l| l.trim().to_string())
|
||||
.filter(|l| !l.is_empty() && !l.starts_with('@'))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// List active PF states (equivalent to `pfctl -s state`).
|
||||
pub fn list_states() -> Result<Vec<PfState>, Error> {
|
||||
let output = Command::new("pfctl")
|
||||
.args(["-s", "state"])
|
||||
.output()
|
||||
.map_err(|e| Error::Command(format!("pfctl: {e}")))?;
|
||||
if !output.status.success() {
|
||||
return Err(Error::Command(
|
||||
String::from_utf8_lossy(&output.stderr).into(),
|
||||
));
|
||||
}
|
||||
let mut states = Vec::new();
|
||||
for line in String::from_utf8_lossy(&output.stdout).lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with("all") {
|
||||
continue;
|
||||
}
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 4 {
|
||||
states.push(PfState {
|
||||
proto: parts[0].to_string(),
|
||||
src: format!("{} {}", parts[1], parts.get(2).unwrap_or(&"")),
|
||||
dst: format!("{} {}", parts[3], parts.get(4).unwrap_or(&"")),
|
||||
state: parts.last().unwrap_or(&"").to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(states)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn list_rules_does_not_panic() {
|
||||
// pfctl may not be present (Linux CI), but the function should not panic.
|
||||
let _ = list_rules();
|
||||
let _ = list_states();
|
||||
}
|
||||
}
|
||||
10
crates/colibri-zfs/Cargo.toml
Normal file
10
crates/colibri-zfs/Cargo.toml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
[package]
|
||||
name = "colibri-zfs"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
234
crates/colibri-zfs/src/lib.rs
Normal file
234
crates/colibri-zfs/src/lib.rs
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
//! colibri-zfs — structured ZFS snapshot lifecycle for Colibri agents.
|
||||
//!
|
||||
//! Wraps `zfs(8)` output into Rust structs so MCP tools can reason about
|
||||
//! snapshots without shell parsing. ZFS operations run on the host — the
|
||||
//! daemon runs as user `colibri`, which needs `zfs allow` delegations or
|
||||
//! `sudo` for destructive operations.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use colibri_zfs::Snapshot;
|
||||
//! let snaps = Snapshot::list("zroot/home/clawdie").unwrap();
|
||||
//! for s in &snaps {
|
||||
//! println!("{} {} {}", s.name, s.used_bytes, s.creation);
|
||||
//! }
|
||||
//! // Destroy snapshots older than 24h
|
||||
//! let old: Vec<_> = snaps.into_iter()
|
||||
//! .filter(|s| s.age_hours() > 24.0)
|
||||
//! .collect();
|
||||
//! Snapshot::destroy_all(&old).unwrap();
|
||||
//! ```
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// A single ZFS snapshot as reported by `zfs list -Hp -t snapshot`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Snapshot {
|
||||
/// Full snapshot name, e.g. "zroot/home/clawdie@autosnap_2026-06-27_09:00:00_hourly"
|
||||
pub name: String,
|
||||
/// Dataset portion, e.g. "zroot/home/clawdie"
|
||||
pub dataset: String,
|
||||
/// Snapshot tag, e.g. "autosnap_2026-06-27_09:00:00_hourly"
|
||||
pub tag: String,
|
||||
/// Space used by this snapshot (bytes). Note: ZFS `used` includes
|
||||
/// space uniquely held by this snapshot after accounting for clones
|
||||
/// and child snapshots.
|
||||
pub used_bytes: u64,
|
||||
/// Space referenced (bytes) — total data accessible in this snapshot,
|
||||
/// shared or not.
|
||||
pub refer_bytes: u64,
|
||||
/// Snapshot creation time as reported by ZFS.
|
||||
pub creation: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("zfs command failed: {0}")]
|
||||
Command(String),
|
||||
#[error("parse error: {0}")]
|
||||
Parse(String),
|
||||
}
|
||||
|
||||
impl Snapshot {
|
||||
/// List all snapshots for a dataset and its children.
|
||||
/// Uses `zfs list -Hp -t snapshot -o name,used,refer,creation -r <dataset>`.
|
||||
pub fn list(dataset: &str) -> Result<Vec<Self>, Error> {
|
||||
let output = Command::new("zfs")
|
||||
.args([
|
||||
"list",
|
||||
"-Hp",
|
||||
"-t",
|
||||
"snapshot",
|
||||
"-o",
|
||||
"name,used,refer,creation",
|
||||
"-r",
|
||||
dataset,
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| Error::Command(format!("zfs list: {e}")))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(Error::Command(format!("zfs list failed: {stderr}")));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let mut snaps = Vec::new();
|
||||
for line in stdout.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let parts: Vec<&str> = line.splitn(4, '\t').collect();
|
||||
if parts.len() < 4 {
|
||||
return Err(Error::Parse(format!("unexpected zfs output: {line}")));
|
||||
}
|
||||
let full_name = parts[0].to_string();
|
||||
let (ds, tag) = full_name
|
||||
.split_once('@')
|
||||
.ok_or_else(|| Error::Parse(format!("no @ in snapshot name: {full_name}")))?;
|
||||
snaps.push(Snapshot {
|
||||
dataset: ds.to_string(),
|
||||
tag: tag.to_string(),
|
||||
name: full_name,
|
||||
used_bytes: parts[1].parse().unwrap_or(0),
|
||||
refer_bytes: parts[2].parse().unwrap_or(0),
|
||||
creation: parts[3].to_string(),
|
||||
});
|
||||
}
|
||||
Ok(snaps)
|
||||
}
|
||||
|
||||
/// Destroy a single snapshot. Requires ZFS destroy permission.
|
||||
pub fn destroy(&self) -> Result<(), Error> {
|
||||
let output = Command::new("zfs")
|
||||
.args(["destroy", &self.name])
|
||||
.output()
|
||||
.map_err(|e| Error::Command(format!("zfs destroy {}: {e}", self.name)))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(Error::Command(format!("zfs destroy: {stderr}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Destroy multiple snapshots. Each failure is collected and returned.
|
||||
pub fn destroy_all(snaps: &[Snapshot]) -> Result<(), Error> {
|
||||
let mut errors = Vec::new();
|
||||
for s in snaps {
|
||||
if let Err(e) = s.destroy() {
|
||||
errors.push(e.to_string());
|
||||
}
|
||||
}
|
||||
if errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::Command(errors.join("; ")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Age of the snapshot in hours, parsed from the creation timestamp.
|
||||
/// Returns 0.0 if the timestamp cannot be parsed.
|
||||
pub fn age_hours(&self) -> f64 {
|
||||
parse_zfs_timestamp(&self.creation)
|
||||
.and_then(|created| SystemTime::now().duration_since(created).ok())
|
||||
.map(|d| d.as_secs_f64() / 3600.0)
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Filter to snapshots older than `hours`.
|
||||
pub fn older_than(snaps: &[Snapshot], hours: f64) -> Vec<Snapshot> {
|
||||
snaps
|
||||
.iter()
|
||||
.filter(|s| s.age_hours() > hours)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a ZFS timestamp like "Mon Jun 23 01:15 2026" into SystemTime.
|
||||
/// ZFS outputs creation time in `date(1)` format when not using `-p`.
|
||||
fn parse_zfs_timestamp(s: &str) -> Option<SystemTime> {
|
||||
// With -p flag, ZFS outputs Unix seconds (e.g. "1719270900")
|
||||
if let Ok(secs) = s.parse::<u64>() {
|
||||
return UNIX_EPOCH.checked_add(Duration::from_secs(secs));
|
||||
}
|
||||
// Fallback: try standard date format
|
||||
if let Ok(t) = chrono_parse(s) {
|
||||
return Some(t);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn chrono_parse(_s: &str) -> Result<SystemTime, ()> {
|
||||
// Avoid pulling in chrono as a dependency. If the seconds parse fails,
|
||||
// we return 0.0 hours — the age_hours() caller handles this gracefully.
|
||||
Err(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn snapshot_parse_basic() {
|
||||
let _line = "zroot/home@test\t1024000\t2048000\t1719270900";
|
||||
// ZFS list output format (tab-separated).
|
||||
// Snapshot is tested via struct construction below.
|
||||
let s = Snapshot {
|
||||
name: "zroot/home@test".into(),
|
||||
dataset: "zroot/home".into(),
|
||||
tag: "test".into(),
|
||||
used_bytes: 1_024_000,
|
||||
refer_bytes: 2_048_000,
|
||||
creation: "1719270900".into(),
|
||||
};
|
||||
// This timestamp is in the past, so age should be positive.
|
||||
assert!(s.age_hours() > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_than_filter() {
|
||||
let ancient = Snapshot {
|
||||
name: "pool@old".into(),
|
||||
dataset: "pool".into(),
|
||||
tag: "old".into(),
|
||||
used_bytes: 1000,
|
||||
refer_bytes: 2000,
|
||||
creation: "1".into(), // epoch start — very old
|
||||
};
|
||||
let recent = Snapshot {
|
||||
name: "pool@new".into(),
|
||||
dataset: "pool".into(),
|
||||
tag: "new".into(),
|
||||
used_bytes: 1000,
|
||||
refer_bytes: 2000,
|
||||
creation: format!(
|
||||
"{}",
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
),
|
||||
};
|
||||
let all = vec![ancient.clone(), recent];
|
||||
let old = Snapshot::older_than(&all, 1.0);
|
||||
assert_eq!(old.len(), 1);
|
||||
assert_eq!(old[0].tag, "old");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destroy_nonexistent_is_error() {
|
||||
let s = Snapshot {
|
||||
name: "zroot/nonexistent@definitely_not_real_2026".into(),
|
||||
dataset: "zroot/nonexistent".into(),
|
||||
tag: "definitely_not_real_2026".into(),
|
||||
used_bytes: 0,
|
||||
refer_bytes: 0,
|
||||
creation: "0".into(),
|
||||
};
|
||||
assert!(s.destroy().is_err());
|
||||
}
|
||||
}
|
||||
90
dashboard-screenshot-HANDOFF.md
Normal file
90
dashboard-screenshot-HANDOFF.md
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# Dashboard Screenshot Proof — Agent Handoff
|
||||
|
||||
**Status:** Hermes done (Steps 1-2). Claude: Step 3. Codex: Step 4.
|
||||
|
||||
## What Hermes Built (Steps 1-2 — done, branch `feat/dashboard-screenshot-proof`)
|
||||
|
||||
- **Schema:** `task_costs.screenshot_uuid TEXT` column + `ALTER TABLE IF NOT EXISTS` migration
|
||||
- **Daemon:** `push_cost_to_mother()` reads `COLIBRI_TASK_SCREENSHOT_UUID` env var, attaches it to the SSH payload
|
||||
- **SSH wrapper:** `report-task-cost` INSERT includes `screenshot_uuid` via `NULLIF`
|
||||
|
||||
Flow: agent harness sets `COLIBRI_TASK_SCREENSHOT_UUID=<uuid>` before spawning colibri. Daemon heartbeat picks it up when task completes, pushes to mother. Mother stores the UUID alongside cost row.
|
||||
|
||||
## Step 3 — Dashboard HTML (Claude)
|
||||
|
||||
Build a single-page dashboard at a webroot path (TBD with Sam — e.g. `https://osa.taile682b7.ts.net/dashboard/`).
|
||||
|
||||
### Data source
|
||||
Query `task_costs` via a JSON endpoint or a static dump:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"node_hostname": "debby",
|
||||
"task_id": "abc-123",
|
||||
"provider": "deepseek",
|
||||
"model": "deepseek-chat",
|
||||
"input_tokens": 45230,
|
||||
"output_tokens": 2847,
|
||||
"cache_read_tokens": 12100,
|
||||
"cost_usd": 0.0042,
|
||||
"success": true,
|
||||
"finished_at": "2026-06-27T13:42:00Z",
|
||||
"screenshot_uuid": "a1b2c3d4e5f6"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Proposed JSON source: a small CGI script (`query.cgi`) that runs `psql -d mother_hive -tA -c "SELECT json_agg(row_to_json(...)) FROM task_costs LEFT JOIN hive_nodes ON ... ORDER BY finished_at DESC LIMIT 200"`. Or a cron-dumped `dashboard.json` (simpler, no CGI). Your call.
|
||||
|
||||
### UI (terminal-printable mockup above)
|
||||
- **Node rows** grouping cost cards by `node_hostname`
|
||||
- **Cost cards** with: cache-hit bar (green = cache fraction of total tokens), provider badge, cost, ▸ marker if `screenshot_uuid` is non-null
|
||||
- **Lightbox** on ▸ click: opens `screenshots/<uuid>.png` (reuse tmux-screenshot's lightbox pattern from its index.html)
|
||||
- **Filters:** node dropdown, date range, provider, success/fail checkbox
|
||||
- **Zero dependencies beyond vanilla JS + CSS** (this is FreeBSD, not a Node host)
|
||||
|
||||
### Screenshot storage
|
||||
Screenshots live at a web-accessible path. tmux-screenshot.py already outputs to a content-addressed directory. The dashboard just needs a URL pattern like `screenshots/<uuid>.png`.
|
||||
|
||||
### Constraints
|
||||
- No Node.js, no npm build step — plain HTML/CSS/JS
|
||||
- Must work from a static directory served by nginx or thttpd on FreeBSD
|
||||
- Reuse existing tmux-screenshot gallery patterns where possible (lightbox, date filter, grid)
|
||||
|
||||
## Step 4 — OSA-Side Setup (Codex)
|
||||
|
||||
On osa (FreeBSD), as clawdie user:
|
||||
|
||||
1. **Run schema migration:**
|
||||
```sh
|
||||
psql -d mother_hive -f packaging/mother/mother_schema.sql
|
||||
```
|
||||
Verifies `screenshot_uuid` column exists.
|
||||
|
||||
2. **Daemon env:**
|
||||
```sh
|
||||
echo 'COLIBRI_MOTHER_HOST=osa.taile682b7.ts.net' >> /var/db/colibri/provider.env
|
||||
```
|
||||
|
||||
3. **SSH key verification:**
|
||||
```sh
|
||||
# Confirm authorized_keys has command= restriction for colibri user
|
||||
sudo cat ~colibri/.ssh/authorized_keys | grep command=
|
||||
```
|
||||
|
||||
4. **Deploy dashboard HTML** (once Claude delivers it):
|
||||
```sh
|
||||
sudo mkdir -p /usr/local/www/dashboard
|
||||
sudo cp ~clawdie/dashboard/index.html /usr/local/www/dashboard/
|
||||
# Configure nginx location block to serve /dashboard/
|
||||
```
|
||||
|
||||
5. **End-to-end test:**
|
||||
```sh
|
||||
echo '{"node_hostname":"debby","task_id":"test-001","provider":"deepseek","model":"deepseek-chat","input_tokens":100,"output_tokens":50,"cost_usd":0.002,"success":true,"screenshot_uuid":"test-uuid-123"}' | ssh colibri@osa.taile682b7.ts.net report-task-cost
|
||||
psql -d mother_hive -c "SELECT * FROM task_costs ORDER BY id DESC LIMIT 1"
|
||||
```
|
||||
|
||||
## Deletion
|
||||
Delete this file when Steps 3 and 4 are complete and confirmed working end-to-end.
|
||||
|
|
@ -8,13 +8,13 @@
|
|||
|
||||
Colibri speaks 5 protocols today:
|
||||
|
||||
| Protocol | Where | Lines | Purpose |
|
||||
|---|---|---|---|
|
||||
| **Custom JSON wire** | `crates/colibri-daemon/src/socket.rs` + `crates/colibri-client/src/lib.rs` | 1,981 | Local daemon control (spawn, status, snapshot, tasks, skills) |
|
||||
| **MCP JSON-RPC** | `crates/colibri-mcp/src/lib.rs` | 570 | Editor integration + external MCP host |
|
||||
| **MCP-over-SSH** | `packaging/mother/` (3 files) | 437 | Mother hive entrypoint (forced-command allowlist + node register) |
|
||||
| **JSONL** | `crates/colibri-glasspane/src/lib.rs` | 1,186 | Agent subprocess stdout events |
|
||||
| **SQL** | `crates/colibri-store/src/lib.rs` + `crates/colibri-store/src/schema.rs` | 1,150 | Local coordination (tasks, agents, skills, tenants) |
|
||||
| Protocol | Where | Lines | Purpose |
|
||||
| -------------------- | -------------------------------------------------------------------------- | ----- | ----------------------------------------------------------------- |
|
||||
| **Custom JSON wire** | `crates/colibri-daemon/src/socket.rs` + `crates/colibri-client/src/lib.rs` | 1,981 | Local daemon control (spawn, status, snapshot, tasks, skills) |
|
||||
| **MCP JSON-RPC** | `crates/colibri-mcp/src/lib.rs` | 570 | Editor integration + external MCP host |
|
||||
| **MCP-over-SSH** | `packaging/mother/` (3 files) | 437 | Mother hive entrypoint (forced-command allowlist + node register) |
|
||||
| **JSONL** | `crates/colibri-glasspane/src/lib.rs` | 1,186 | Agent subprocess stdout events |
|
||||
| **SQL** | `crates/colibri-store/src/lib.rs` + `crates/colibri-store/src/schema.rs` | 1,150 | Local coordination (tasks, agents, skills, tenants) |
|
||||
|
||||
**Total protocol surface: ~5,324 lines.**
|
||||
|
||||
|
|
@ -40,6 +40,7 @@ USB node → HTTPS → mother A2A endpoint → PostgreSQL
|
|||
```
|
||||
|
||||
**Removed:**
|
||||
|
||||
- `colibri-mcp-ssh` (32 lines) — SSH forced-command allowlist wrapper
|
||||
- `node-register-mcp` (88 lines) — Custom MCP tool with embedded psql
|
||||
- SSH key management in `setup-mother.sh` (~40 lines of key distribution logic)
|
||||
|
|
@ -47,6 +48,7 @@ USB node → HTTPS → mother A2A endpoint → PostgreSQL
|
|||
**Removed total: ~160 lines.**
|
||||
|
||||
**Added:**
|
||||
|
||||
- A2A HTTP endpoint on mother (~200 lines)
|
||||
- A2A client library integration on USB node (~150 lines)
|
||||
- mTLS/TLS termination for auth (~30 lines)
|
||||
|
|
@ -54,6 +56,7 @@ USB node → HTTPS → mother A2A endpoint → PostgreSQL
|
|||
**Added total: ~380 lines.**
|
||||
|
||||
**Net delta: +220 lines.** Not a code reduction. But operational complexity drops significantly:
|
||||
|
||||
- No SSH key distribution to USB nodes (key lives on seed partition → no longer needed on mother)
|
||||
- No forced-command allowlist to maintain
|
||||
- Standard HTTPS is easier to firewall, audit, and monitor than SSH forced-command
|
||||
|
|
@ -78,7 +81,7 @@ Today: external MCP registry config — manual JSON listing third-party MCP serv
|
|||
|
||||
With A2A: third-party tools that speak A2A (not MCP) publish an Agent Card. Colibri discovers them via the well-known Agent Card URL instead of manual JSON config files.
|
||||
|
||||
**Reality check:** No third-party tools speak A2A yet. The protocol was just announced (April 2025). MCP has ~2 years of ecosystem maturity. This is a *future* replacement, not a *current* one.
|
||||
**Reality check:** No third-party tools speak A2A yet. The protocol was just announced (April 2025). MCP has ~2 years of ecosystem maturity. This is a _future_ replacement, not a _current_ one.
|
||||
|
||||
**Verdict:** A2A discovery doesn't reduce code today. External MCP stays for tool access.
|
||||
|
||||
|
|
@ -94,20 +97,20 @@ With A2A: cost data is a typed message part (`application/json+cost`). The forma
|
|||
|
||||
**Code savings:** ~10 lines (the info! log stays; the A2A part is new code).
|
||||
|
||||
**Verdict:** Negligible code impact. The value is *interop*, not complexity reduction.
|
||||
**Verdict:** Negligible code impact. The value is _interop_, not complexity reduction.
|
||||
|
||||
---
|
||||
|
||||
## What A2A does NOT replace
|
||||
|
||||
| Component | Why A2A doesn't touch it | Lines saved |
|
||||
|---|---|---|
|
||||
| **Unix socket wire protocol** (`crates/colibri-daemon/src/socket.rs`) | A2A is cross-node HTTP. Local daemon control needs IPC — Unix socket is faster, auth-free (filesystem permissions), and doesn't need a network stack. | 0 |
|
||||
| **Spawner** (`crates/colibri-daemon/src/spawner.rs`) | A2A routes tasks to existing agents. Colibri *creates* agents by spawning subprocesses. A2A has no process lifecycle concept. | 0 |
|
||||
| **Glasspane** (`crates/colibri-glasspane/src/lib.rs`) | A2A doesn't watch subprocess stdout. Glasspane is a PTY observer — it reads JSONL from child processes. A2A operates one layer above. | 0 |
|
||||
| **Store** (`crates/colibri-store/src/lib.rs`) | A2A doesn't replace local SQLite coordination. Each node needs local persistence for task board, agents, skills — A2A is the *transport*, not the *database*. | 0 |
|
||||
| **MCP editor bridge** | A2A is agent-to-agent. MCP is human-to-tool. Different protocols for different directions. They coexist. | 0 |
|
||||
| **Contracts schemas** (`crates/colibri-contracts/src/lib.rs`) | A2A uses JSON Schema for input validation. Colibri's contracts are already compatible — no change needed. | 0 |
|
||||
| Component | Why A2A doesn't touch it | Lines saved |
|
||||
| --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
||||
| **Unix socket wire protocol** (`crates/colibri-daemon/src/socket.rs`) | A2A is cross-node HTTP. Local daemon control needs IPC — Unix socket is faster, auth-free (filesystem permissions), and doesn't need a network stack. | 0 |
|
||||
| **Spawner** (`crates/colibri-daemon/src/spawner.rs`) | A2A routes tasks to existing agents. Colibri _creates_ agents by spawning subprocesses. A2A has no process lifecycle concept. | 0 |
|
||||
| **Glasspane** (`crates/colibri-glasspane/src/lib.rs`) | A2A doesn't watch subprocess stdout. Glasspane is a PTY observer — it reads JSONL from child processes. A2A operates one layer above. | 0 |
|
||||
| **Store** (`crates/colibri-store/src/lib.rs`) | A2A doesn't replace local SQLite coordination. Each node needs local persistence for task board, agents, skills — A2A is the _transport_, not the _database_. | 0 |
|
||||
| **MCP editor bridge** | A2A is agent-to-agent. MCP is human-to-tool. Different protocols for different directions. They coexist. | 0 |
|
||||
| **Contracts schemas** (`crates/colibri-contracts/src/lib.rs`) | A2A uses JSON Schema for input validation. Colibri's contracts are already compatible — no change needed. | 0 |
|
||||
|
||||
**Total irreplaceable: ~5,000 lines.** A2A doesn't reduce this at all.
|
||||
|
||||
|
|
@ -138,16 +141,16 @@ TOTAL 5,524 5,467
|
|||
|
||||
A2A is not a complexity reduction play. It's an **interoperability and operational simplicity** play:
|
||||
|
||||
| Metric | MCP-over-SSH (current) | A2A (proposed) |
|
||||
|---|---|---|
|
||||
| **Lines of code** | ~5,524 (spread across 6 crates + 3 shell scripts) | ~5,467 (SSH scripts gone, A2A handler added) |
|
||||
| **Protocol count** | 5 | 6 (A2A adds one) |
|
||||
| **Operational complexity** | SSH keys × N nodes, forced-command allowlists, peer auth setup | One HTTPS endpoint, mTLS certs, well-known URL |
|
||||
| **Discoverability** | Manual external MCP registry entries | Agent Card at well-known URL |
|
||||
| **Interoperability** | Colibri-only | Any A2A client |
|
||||
| **Debugability** | `ssh -v`, `psql`, `jq` | `curl`, browser devtools, standard HTTP tooling |
|
||||
| **Ecosystem maturity** | N/A (Colibri-specific) | Protocol < 3 months old, zero adoption |
|
||||
| **When it pays off** | Works today for 4 nodes | Pays off at 10+ nodes, or when 3rd-party tools ship A2A |
|
||||
| Metric | MCP-over-SSH (current) | A2A (proposed) |
|
||||
| -------------------------- | -------------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| **Lines of code** | ~5,524 (spread across 6 crates + 3 shell scripts) | ~5,467 (SSH scripts gone, A2A handler added) |
|
||||
| **Protocol count** | 5 | 6 (A2A adds one) |
|
||||
| **Operational complexity** | SSH keys × N nodes, forced-command allowlists, peer auth setup | One HTTPS endpoint, mTLS certs, well-known URL |
|
||||
| **Discoverability** | Manual external MCP registry entries | Agent Card at well-known URL |
|
||||
| **Interoperability** | Colibri-only | Any A2A client |
|
||||
| **Debugability** | `ssh -v`, `psql`, `jq` | `curl`, browser devtools, standard HTTP tooling |
|
||||
| **Ecosystem maturity** | N/A (Colibri-specific) | Protocol < 3 months old, zero adoption |
|
||||
| **When it pays off** | Works today for 4 nodes | Pays off at 10+ nodes, or when 3rd-party tools ship A2A |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
141
docs/wiki/cost-dashboard.md
Normal file
141
docs/wiki/cost-dashboard.md
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
---
|
||||
title: Cost Dashboard
|
||||
description: "Mother-side cost observability — human gallery + agent-friendly JSON, with screenshot proof linked from every cost row."
|
||||
---
|
||||
|
||||
← [index](./index.md)
|
||||
|
||||
The Cost Dashboard is the presentation surface for the hive-wide cost data flowing
|
||||
into mother's `task_costs` table. It serves two audiences: operators (human gallery)
|
||||
and agents (JSON query).
|
||||
|
||||
## Decision
|
||||
|
||||
One HTML page, two views. The same data feeds both a human-browseable card grid
|
||||
(with lightbox screenshot proofs) and an agent-queryable JSON panel. No server-side
|
||||
rendering — static HTML with a JSON data file refreshed every 60s by cron.
|
||||
|
||||
## What it shows
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ HIVE COST DASHBOARD [4 nodes] [24h] [$2.37] │
|
||||
├──────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ debby ● online 12 tasks $1.87 ▲ $0.15 avg 89% success │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ ████░░░░ │ │ ████████ │ │ ██████░░ │ │ ████████ │ ← cost cards │
|
||||
│ │ deepseek │ │ deepseek │ │ claude │ │ gemini │ │
|
||||
│ │ $0.0042 │ │ $0.0031 │ │ $0.89 ▸ │ │ $0.02 ▸ │ ▸ = screenshot │
|
||||
│ │ ✓ │ │ ✓ │ │ ✗ │ │ ✓ │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Each cost card shows:
|
||||
- **Cache-hit bar:** green (cache) vs grey (fresh) — visual cache efficiency
|
||||
- **Provider:** deepseek / claude / gemini / ollama / local
|
||||
- **Cost:** with `▸ proof` badge if a tmux-screenshot exists for this task
|
||||
- **Success:** ✓ (green) or ✗ (red)
|
||||
|
||||
Click a card with `▸` → lightbox opens the terminal screenshot at task completion time.
|
||||
|
||||
## Data sources
|
||||
|
||||
| Source | What | Refresh |
|
||||
|---|---|---|
|
||||
| `task_costs` (PostgreSQL) | Per-task cost rows pushed by daemon heartbeat | Real-time (SSH push on completion) |
|
||||
| `hive_nodes` (PostgreSQL) | Node metadata, capabilities, LLM tier | Node heartbeat |
|
||||
| `task_costs JSON file` | Denormalized JSON for the dashboard page | Every 60s (cron) |
|
||||
| `../screenshots/{uuid}.png` (static) | tmux-screenshot captures linked from cost rows | On task completion (daemon) |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
daemon heartbeat
|
||||
│
|
||||
├─ push_cost_to_mother() ──SSH──→ colibri-mcp-ssh "report-task-cost"
|
||||
│ │
|
||||
│ └─ INSERT INTO task_costs
|
||||
│ (node_hostname → node_id lookup)
|
||||
│
|
||||
└─ (optional) tmux-screenshot.py → ../screenshots/{uuid}.png
|
||||
on COLIBRI_SCREENSHOT_ON_COMPLETION=1
|
||||
|
||||
cron (every 60s)
|
||||
└─ export-costs.sh
|
||||
└─ psql → task_costs JSON file (dashboard data file)
|
||||
|
||||
browser
|
||||
└─ GET /dashboard/ → index.html
|
||||
└─ fetch task_costs JSON file → render cards, lightbox, JSON panel
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
```sh
|
||||
# On mother (osa):
|
||||
cd /usr/local/src/colibri/packaging/mother/dashboard
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
This places:
|
||||
- `/usr/local/www/clawdie/dashboard/index.html` — the dashboard page
|
||||
- `export-costs.sh` — JSON export script (in mother webroot)
|
||||
- `/usr/local/etc/cron.d/clawdie-dashboard` — cron job (every 60s)
|
||||
|
||||
Nginx serves it as a static location under the existing mother vhost.
|
||||
|
||||
## Agent-friendly JSON
|
||||
|
||||
The dashboard has a **JSON** toggle button that shows the filtered data as
|
||||
structured JSON. This is the same data agents get via `colibri_list_task_costs`
|
||||
MCP tool, but with screenshot UUIDs and node groupings:
|
||||
|
||||
```json
|
||||
{
|
||||
"updated_at": "2026-06-27T14:00:00Z",
|
||||
"summary": {
|
||||
"total_tasks": 23,
|
||||
"total_cost": 2.37,
|
||||
"avg_cost": 0.103,
|
||||
"success_rate": 87.0,
|
||||
"cache_hit_ratio": 64.3
|
||||
},
|
||||
"nodes": {
|
||||
"debby": [
|
||||
{
|
||||
"task_id": "abc-123",
|
||||
"provider": "deepseek",
|
||||
"cost": 0.0042,
|
||||
"success": true,
|
||||
"screenshot_uuid": "a1b2c3d4e5f6",
|
||||
"tokens": {"in": 45000, "out": 2800, "cache_read": 12000}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Agents use this for cost-aware routing: "debby averages $0.004/task with 89%
|
||||
cache-hit on DeepSeek — route non-urgent tasks there."
|
||||
|
||||
## Screenshot proof
|
||||
|
||||
The `▸ proof` badge on cost cards links to tmux-screenshot captures. The
|
||||
screenshot UUID is stored alongside the cost row. Clicking opens the lightbox
|
||||
with:
|
||||
- The full terminal PNG at task completion time
|
||||
- Task ID, provider, and cost in the overlay
|
||||
- The screenshot metadata (pane title, command, path, timestamp)
|
||||
- Signature detection results (failures, warnings, healthy signals)
|
||||
|
||||
This is the "verify, don't guess" layer — every cost number has visual proof
|
||||
behind it.
|
||||
|
||||
## References
|
||||
|
||||
- [task-board](./task-board.md) — local task board (data source for cost capture)
|
||||
- [hive-pane](./hive-pane.md) — hive board (companion surface, node status)
|
||||
- [hive-routing](./hive-routing.md) — cost-aware routing engine (consumes this data)
|
||||
- [tmux-screenshot skill](../../.agent/skills/tmux-screenshot/SKILL.md) — screenshot capture
|
||||
|
|
@ -161,13 +161,13 @@ as the wiki.
|
|||
|
||||
A2A tasks map directly to Colibri's task board:
|
||||
|
||||
| A2A state | Colibri equivalent |
|
||||
| -------------- | ------------------ |
|
||||
| `submitted` | `Pending` |
|
||||
| `working` | `Started` |
|
||||
| `completed` | `Done` |
|
||||
| `failed` | `Error` |
|
||||
| `canceled` | (not yet modeled) |
|
||||
| A2A state | Colibri equivalent |
|
||||
| ----------- | ------------------ |
|
||||
| `submitted` | `Pending` |
|
||||
| `working` | `Started` |
|
||||
| `completed` | `Done` |
|
||||
| `failed` | `Error` |
|
||||
| `canceled` | (not yet modeled) |
|
||||
|
||||
Mother pushes a `node_register` task to a new USB node; the node executes it and
|
||||
returns the result. The task carries cost data as a typed A2A part:
|
||||
|
|
@ -187,13 +187,13 @@ returns the result. The task carries cost data as a typed A2A part:
|
|||
|
||||
### What A2A adds over the current MCP bridge
|
||||
|
||||
| Concern | Current (MCP + SSH) | A2A |
|
||||
| -------------------- | ----------------------------- | -------------------------------- |
|
||||
| Discovery | Manual external MCP registry entry | Well-known Agent Card URL |
|
||||
| Interop | Colibri-only | Any A2A client |
|
||||
| Cost data | Embedded in task completion | Typed `application/json+cost` |
|
||||
| Push notifications | Polling (heartbeat) | Optional webhook/push |
|
||||
| Versioning | Ad-hoc | Agent Card version + schema pins |
|
||||
| Concern | Current (MCP + SSH) | A2A |
|
||||
| ------------------ | ---------------------------------- | -------------------------------- |
|
||||
| Discovery | Manual external MCP registry entry | Well-known Agent Card URL |
|
||||
| Interop | Colibri-only | Any A2A client |
|
||||
| Cost data | Embedded in task completion | Typed `application/json+cost` |
|
||||
| Push notifications | Polling (heartbeat) | Optional webhook/push |
|
||||
| Versioning | Ad-hoc | Agent Card version + schema pins |
|
||||
|
||||
A2A is not a replacement for the MCP bridge — it's the next layer. The MCP
|
||||
bridge handles local daemon commands (status, snapshot, spawn). A2A handles
|
||||
|
|
|
|||
|
|
@ -10,15 +10,15 @@
|
|||
|
||||
## What Exists Today
|
||||
|
||||
| Component | State | Gap |
|
||||
|---|---|---|
|
||||
| `mother_schema.sql` | `hive_nodes` table with `hw_profile` + `capabilities` JSONB | No stable node UUID; hostname is the key |
|
||||
| `derive_capabilities()` trigger | Auto-computes `has_gpu`, `gpu_vendor`, `can_run_local_llm`, `max_model` from hw_profile | Only GPU/VRAM heuristics — doesn't probe running services |
|
||||
| `clawdie-hw-probe` | Collects GPU, RAM, CPU, disks, ZFS, WiFi, Vulkan, Colibri status | No ollama/llama.cpp probing |
|
||||
| `node-register-mcp` | UPSERTs hw_profile into `hive_nodes` on join | No UUID generation at join time |
|
||||
| `crates/colibri-daemon/src/scheduler.rs` | Cron/interval/one-shot jobs, capability matching stubs | No cost-aware routing, no hive awareness |
|
||||
| `colibri-store` | Local SQLite `agents` table with UUID (v4 random) | UUID is session-local, not hive-stable |
|
||||
| T1.5 cost tracking | Per-task cost captured in local SQLite | No hive-level cost aggregation |
|
||||
| Component | State | Gap |
|
||||
| ---------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------- |
|
||||
| `mother_schema.sql` | `hive_nodes` table with `hw_profile` + `capabilities` JSONB | No stable node UUID; hostname is the key |
|
||||
| `derive_capabilities()` trigger | Auto-computes `has_gpu`, `gpu_vendor`, `can_run_local_llm`, `max_model` from hw_profile | Only GPU/VRAM heuristics — doesn't probe running services |
|
||||
| `clawdie-hw-probe` | Collects GPU, RAM, CPU, disks, ZFS, WiFi, Vulkan, Colibri status | No ollama/llama.cpp probing |
|
||||
| `node-register-mcp` | UPSERTs hw_profile into `hive_nodes` on join | No UUID generation at join time |
|
||||
| `crates/colibri-daemon/src/scheduler.rs` | Cron/interval/one-shot jobs, capability matching stubs | No cost-aware routing, no hive awareness |
|
||||
| `colibri-store` | Local SQLite `agents` table with UUID (v4 random) | UUID is session-local, not hive-stable |
|
||||
| T1.5 cost tracking | Per-task cost captured in local SQLite | No hive-level cost aggregation |
|
||||
|
||||
## Design Goals
|
||||
|
||||
|
|
@ -81,17 +81,19 @@ A 32-character hex UUID generated once, stored locally, included in every hw-pro
|
|||
```
|
||||
|
||||
**Properties:**
|
||||
|
||||
- **Stable across reboots**: stored on disk, not tmpfs
|
||||
- **Survives re-provisioning**: if the seed partition preserves `/var/db/machine-id`, the same physical machine keeps the same identity
|
||||
- **Not a secret**: it's an ID, not a key
|
||||
- **Verifiable**: mother can check "has node a1b2c3d4 ever joined?" — if yes, this is a rejoin, not a new node
|
||||
|
||||
**Alternatives considered:**
|
||||
| Approach | Pros | Cons |
|
||||
|---|---|---|
|
||||
| SMBIOS UUID (`hw.uuid`) | Truly hardware-bound, survives OS reinstall | Not available on all platforms (VPS, ARM); can be spoofed |
|
||||
| SSH host key fingerprint | Cryptographically strong | Changes on OS reinstall; key rotation breaks identity |
|
||||
| Random UUID (this design) | Portable, simple, survives seed restore | Can be copied/cloned (but same machine, same ID — that's correct) |
|
||||
|
||||
| Approach | Pros | Cons |
|
||||
| ------------------------- | ------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| SMBIOS UUID (`hw.uuid`) | Truly hardware-bound, survives OS reinstall | Not available on all platforms (VPS, ARM); can be spoofed |
|
||||
| SSH host key fingerprint | Cryptographically strong | Changes on OS reinstall; key rotation breaks identity |
|
||||
| Random UUID (this design) | Portable, simple, survives seed restore | Can be copied/cloned (but same machine, same ID — that's correct) |
|
||||
|
||||
**Recommendation:** Generate on first boot, store in `/var/db/machine-id`. The hw-probe includes it as `machine_id`. Mother's `hive_nodes` table gets a `UNIQUE` constraint on `machine_id`.
|
||||
|
||||
|
|
@ -112,18 +114,18 @@ The `node-register-mcp` UPSERT switches from `ON CONFLICT (hostname)` to `ON CON
|
|||
|
||||
Every capability is a boolean derived from hardware facts, not a self-declaration. The hw-probe collects hardware; the trigger derives capabilities.
|
||||
|
||||
| Capability | Derived from | Used for |
|
||||
|---|---|---|
|
||||
| `has_gpu` | GPU detected in pciconf | GPU-accelerated inference |
|
||||
| `gpu_vendor` | amdgpu/nvidia driver | Model compatibility |
|
||||
| `vulkan_compute` | vulkaninfo success | llama.cpp Vulkan backend |
|
||||
| `can_run_local_llm` | RAM ≥ 16GB or has GPU | Eligibility for local task execution |
|
||||
| `max_model` | RAM heuristic | Model size limit (3b, 7b-q4, 13b-q4, 34b-q4) |
|
||||
| `cpu_only` | No GPU detected | Fallback only (slow) |
|
||||
| `has_wifi` | wlan devices | Network capability |
|
||||
| `has_zfs` | ZFS pools non-empty | Storage capability |
|
||||
| `colibri_running` | service status | Agent host eligibility |
|
||||
| `provider_api_keys` | MCP-reported (not hw probe) | Cloud provider availability |
|
||||
| Capability | Derived from | Used for |
|
||||
| ------------------- | --------------------------- | -------------------------------------------- |
|
||||
| `has_gpu` | GPU detected in pciconf | GPU-accelerated inference |
|
||||
| `gpu_vendor` | amdgpu/nvidia driver | Model compatibility |
|
||||
| `vulkan_compute` | vulkaninfo success | llama.cpp Vulkan backend |
|
||||
| `can_run_local_llm` | RAM ≥ 16GB or has GPU | Eligibility for local task execution |
|
||||
| `max_model` | RAM heuristic | Model size limit (3b, 7b-q4, 13b-q4, 34b-q4) |
|
||||
| `cpu_only` | No GPU detected | Fallback only (slow) |
|
||||
| `has_wifi` | wlan devices | Network capability |
|
||||
| `has_zfs` | ZFS pools non-empty | Storage capability |
|
||||
| `colibri_running` | service status | Agent host eligibility |
|
||||
| `provider_api_keys` | MCP-reported (not hw probe) | Cloud provider availability |
|
||||
|
||||
### Local LLM capabilities (NEW)
|
||||
|
||||
|
|
@ -143,14 +145,14 @@ Extend the hw-probe to detect running local LLM services and extend the trigger
|
|||
|
||||
**New derived capabilities:**
|
||||
|
||||
| Capability | Derivation |
|
||||
|---|---|
|
||||
| `ollama_available` | `ollama_running == true` |
|
||||
| `ollama_models` | Array of model tags (from `ollama list`) |
|
||||
| `llama_cpp_available` | Binary at `/usr/local/bin/llama-server` or similar |
|
||||
| `llama_cpp_models` | GGUFs in `/var/db/models/` or `/usr/local/share/models/` |
|
||||
| `can_embed_locally` | `nomic-embed-text` in ollama OR any embedding model loaded |
|
||||
| `inference_tier` | `local-fast` (GPU ≥ 24GB), `local-slow` (CPU-only, RAM ≥ 16GB), `cloud-only` |
|
||||
| Capability | Derivation |
|
||||
| --------------------- | ---------------------------------------------------------------------------- |
|
||||
| `ollama_available` | `ollama_running == true` |
|
||||
| `ollama_models` | Array of model tags (from `ollama list`) |
|
||||
| `llama_cpp_available` | Binary at `/usr/local/bin/llama-server` or similar |
|
||||
| `llama_cpp_models` | GGUFs in `/var/db/models/` or `/usr/local/share/models/` |
|
||||
| `can_embed_locally` | `nomic-embed-text` in ollama OR any embedding model loaded |
|
||||
| `inference_tier` | `local-fast` (GPU ≥ 24GB), `local-slow` (CPU-only, RAM ≥ 16GB), `cloud-only` |
|
||||
|
||||
### Probe additions to `clawdie-hw-probe`
|
||||
|
||||
|
|
@ -181,12 +183,12 @@ cache_weight: 0.0–1.0 (warm cache → higher weight)
|
|||
|
||||
### Cost tiers
|
||||
|
||||
| Tier | Provider | Cost per 1M tokens | Latency | Used when |
|
||||
|---|---|---|---|
|
||||
| T0 (free) | Local ollama/llama.cpp | $0.00 | 5–60s | Non-urgent, capability match |
|
||||
| T1 (cheap) | DeepSeek V3 | $0.27 / $1.10 | 2–5s | Default for most tasks |
|
||||
| T2 (balanced) | Gemini Flash | $0.15 / $0.60 | 1–3s | High cache-hit tasks |
|
||||
| T3 (premium) | Claude Sonnet 4 | $3.00 / $15.00 | 3–8s | Complex reasoning, only when needed |
|
||||
| Tier | Provider | Cost per 1M tokens | Latency | Used when |
|
||||
| ------------- | ---------------------- | ------------------ | ------- | ----------------------------------- |
|
||||
| T0 (free) | Local ollama/llama.cpp | $0.00 | 5–60s | Non-urgent, capability match |
|
||||
| T1 (cheap) | DeepSeek V3 | $0.27 / $1.10 | 2–5s | Default for most tasks |
|
||||
| T2 (balanced) | Gemini Flash | $0.15 / $0.60 | 1–3s | High cache-hit tasks |
|
||||
| T3 (premium) | Claude Sonnet 4 | $3.00 / $15.00 | 3–8s | Complex reasoning, only when needed |
|
||||
|
||||
### Local LLM routing rules
|
||||
|
||||
|
|
@ -257,6 +259,7 @@ When the task completes, the local daemon writes cost to its SQLite (T1.5). The
|
|||
**What:** Mother is the brain. Nodes register, mother routes. No peer-to-peer.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
1. Add `machine_id` to `hive_nodes` + hw-probe (1 day)
|
||||
2. Extend `derive_capabilities()` for local LLM (1 day)
|
||||
3. Add `routing_score()` function to mother's PostgreSQL (stored function — zero Rust changes)
|
||||
|
|
@ -268,12 +271,14 @@ When the task completes, the local daemon writes cost to its SQLite (T1.5). The
|
|||
**Total:** ~3.5 days.
|
||||
|
||||
**Pros:**
|
||||
|
||||
- Simple to reason about — one source of truth
|
||||
- Lowest implementation risk
|
||||
- Scheduler lives on mother (always-on)
|
||||
- Existing MCP bridge handles all communication
|
||||
|
||||
**Cons:**
|
||||
|
||||
- Mother is single point of failure for routing (but not execution — once dispatched, the task runs independently)
|
||||
- Latency: scheduler must query mother on every tick
|
||||
- Doesn't scale to 100+ nodes (not a real concern for our use case)
|
||||
|
|
@ -285,6 +290,7 @@ When the task completes, the local daemon writes cost to its SQLite (T1.5). The
|
|||
**What:** Mother stores the matrix, but nodes can also route tasks they own to peers directly. Hybrid: central registry + distributed execution.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
1. All of Option A (3.5 days)
|
||||
2. Add `capabilities` API to `colibri-daemon`'s Unix socket (self-awareness) — 1 day
|
||||
3. Add local peer discovery via mDNS or Tailscale whois — 1 day
|
||||
|
|
@ -294,12 +300,14 @@ When the task completes, the local daemon writes cost to its SQLite (T1.5). The
|
|||
**Total:** ~8.5 days.
|
||||
|
||||
**Pros:**
|
||||
|
||||
- Lower latency for local dispatch
|
||||
- Survives mother downtime for peer-to-peer tasks
|
||||
- Natural fit for local LLM use case (beefy node is on same LAN)
|
||||
- Nodes that discover each other can route without phoning home
|
||||
|
||||
**Cons:**
|
||||
|
||||
- Complexity: two code paths (central + peer-to-peer)
|
||||
- Security: peer-to-peer dispatch needs authentication (who can send tasks to my daemon?)
|
||||
- Harder to audit: cost tracking must handle peer-dispatched vs mother-dispatched tasks differently
|
||||
|
|
@ -312,6 +320,7 @@ When the task completes, the local daemon writes cost to its SQLite (T1.5). The
|
|||
**What:** Don't build a routing engine at all. The capability matrix is exposed as an MCP tool that agents query. The agent itself decides where to route based on the matrix + its own reasoning. The matrix is advisory, not prescriptive.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
1. All of Option A minus the routing_scoring function (2.5 days)
|
||||
2. Add `colibri_query_hive_capabilities` MCP tool on mother — returns full online node matrix (0.5 day)
|
||||
3. Add `colibri_dispatch_to_node` MCP tool — sends task to a specific node (1 day)
|
||||
|
|
@ -320,6 +329,7 @@ When the task completes, the local daemon writes cost to its SQLite (T1.5). The
|
|||
**Total:** ~4.5 days. **Zero scheduler changes.**
|
||||
|
||||
**Pros:**
|
||||
|
||||
- Exploits Colibri's architecture-as-differentiator: the agent IS the intelligence
|
||||
- The routing decision is auditable in the conversation log (why did the agent pick this node?)
|
||||
- Natural fit for local LLM — the agent can reason "this task is low priority, I'll try the beefy node first"
|
||||
|
|
@ -327,6 +337,7 @@ When the task completes, the local daemon writes cost to its SQLite (T1.5). The
|
|||
- The skill can be iterated without recompiling Colibri
|
||||
|
||||
**Cons:**
|
||||
|
||||
- Each routing decision costs tokens (the agent must reason about it)
|
||||
- Agents make inscrutable routing choices (the LLM "just knows")
|
||||
- No hard guarantees — an agent might route a $5 task to Claude when DeepSeek would do fine
|
||||
|
|
@ -350,26 +361,26 @@ The capability matrix, stable UUIDs, and local LLM probes are the foundation —
|
|||
|
||||
### Phase 1 — Identity & Capability Foundation
|
||||
|
||||
| Deliverable | Where | Lines |
|
||||
|---|---|---|
|
||||
| `machine_id` generation in `clawdie-firstboot` | clawdie-iso | ~15 |
|
||||
| `collect_machine_id()` in hw-probe | clawdie-iso | ~10 |
|
||||
| `collect_ollama_status()` in hw-probe | clawdie-iso | ~30 |
|
||||
| `collect_llama_cpp()` in hw-probe | clawdie-iso | ~20 |
|
||||
| `collect_local_llm()` aggregator in hw-probe | clawdie-iso | ~25 |
|
||||
| `machine_id` column + constraint in mother_schema.sql | colibri | ~5 |
|
||||
| Extended `derive_capabilities()` for `ollama_available`, `llama_cpp_available`, `inference_tier` | colibri | ~40 |
|
||||
| `node-register-mcp` handling of `machine_id` key + new local_llm fields | colibri | ~15 |
|
||||
| This design doc (hive-routing.md) | This file | ~0 (done) |
|
||||
| Deliverable | Where | Lines |
|
||||
| ------------------------------------------------------------------------------------------------ | ----------- | --------- |
|
||||
| `machine_id` generation in `clawdie-firstboot` | clawdie-iso | ~15 |
|
||||
| `collect_machine_id()` in hw-probe | clawdie-iso | ~10 |
|
||||
| `collect_ollama_status()` in hw-probe | clawdie-iso | ~30 |
|
||||
| `collect_llama_cpp()` in hw-probe | clawdie-iso | ~20 |
|
||||
| `collect_local_llm()` aggregator in hw-probe | clawdie-iso | ~25 |
|
||||
| `machine_id` column + constraint in mother_schema.sql | colibri | ~5 |
|
||||
| Extended `derive_capabilities()` for `ollama_available`, `llama_cpp_available`, `inference_tier` | colibri | ~40 |
|
||||
| `node-register-mcp` handling of `machine_id` key + new local_llm fields | colibri | ~15 |
|
||||
| This design doc (hive-routing.md) | This file | ~0 (done) |
|
||||
|
||||
### Phase 2 — Routing Engine
|
||||
|
||||
| Deliverable | Where |
|
||||
|---|---|
|
||||
| `colibri_query_hive_capabilities` MCP tool | colibri-mcp |
|
||||
| `colibri_dispatch_to_node` MCP tool | colibri-mcp |
|
||||
| `hive-routing` skill | `.agent/skills/` |
|
||||
| `Task.routing` JSONB field in colibri-store | colibri-store |
|
||||
| Deliverable | Where |
|
||||
| -------------------------------------------------------------------------------------------------------------- | ----------------- |
|
||||
| `colibri_query_hive_capabilities` MCP tool | colibri-mcp |
|
||||
| `colibri_dispatch_to_node` MCP tool | colibri-mcp |
|
||||
| `hive-routing` skill | `.agent/skills/` |
|
||||
| `Task.routing` JSONB field in colibri-store | colibri-store |
|
||||
| Mother-side routing score as PostgreSQL function (optional — only if agent-driven routing proves insufficient) | mother_schema.sql |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ warning.
|
|||
| [mother-hive](./mother-hive.md) | Mother MCP architecture — forced-command SSH, single-home-in-colibri, peer auth, key-on-seed |
|
||||
| [hive-routing](./hive-routing.md) | Hive member identity (machine UUID), capability matrix + local LLM probes, cost-aware task routing |
|
||||
| [hive-pane](./hive-pane.md) | Glasspane for the hive — multi-node cost observability, A2A discovery, and operator board |
|
||||
| [cost-dashboard](./cost-dashboard.md) | Mother-side cost observability — human gallery + JSON, screenshot proof linked from cost rows |
|
||||
| [a2a-complexity-audit](./a2a-complexity-audit.md) | A2A code complexity impact — 6-protocol surface audit, when A2A pays off |
|
||||
| [naming-decisions](./naming-decisions.md) | Ledger of harness-neutral / architecture renames — shipped and in-flight |
|
||||
| [daemon-not-demon](./daemon-not-demon.md) | Why we say daemon (helper spirit) not demon (bad spirit) — English + Slovenian |
|
||||
|
|
|
|||
|
|
@ -103,7 +103,34 @@
|
|||
103|→ [`clawdie-live-seed` (clawdie-iso)](https://code.smilepowered.org/clawdie/clawdie-iso/src/branch/main/live/operator-session/clawdie-live-seed),
|
||||
104|[`MOTHER-SETUP.md` §Key management](../../packaging/mother/MOTHER-SETUP.md#key-management)
|
||||
105|
|
||||
106|## See also
|
||||
106|### Per-task cost aggregation (`task_costs`)
|
||||
|
||||
When an agent finishes, the daemon's heartbeat captures a `TaskCostSummary` to
|
||||
the local SQLite store and also pushes it to mother via `ssh mother
|
||||
report-task-cost`. The mother resolves the sending node's hostname to a
|
||||
`hive_nodes.id` and INSERTs into `task_costs`.
|
||||
|
||||
**Why push, not pull**: a pull model requires mother to SSH into every node
|
||||
periodically (N connections, node firewalls, scheduling). Push reuses the
|
||||
node's existing outbound SSH to mother — one connection per heartbeat tick,
|
||||
fire-and-forget. Nodes that lose connectivity queue locally (SQLite) and resume
|
||||
pushing when the link returns.
|
||||
|
||||
**Why a separate table, not `hive_nodes` columns**: costs are per-task,
|
||||
time-series data. Storing them as cumulative counters on `hive_nodes` would
|
||||
lose per-provider, per-model, and per-time-slice detail. `task_costs` keeps
|
||||
one row per task completion — the dashboard can aggregate by any dimension.
|
||||
|
||||
**Why `node_hostname` in the payload, not `node_id`**: the daemon only knows
|
||||
its own hostname, not the mother-assigned `hive_nodes.id`. The mother resolves
|
||||
it via a subquery. If the node hasn't registered yet, the INSERT fails on the
|
||||
FK — correct behaviour (register first, then report costs).
|
||||
|
||||
→ [`mother_schema.sql`](../../packaging/mother/mother_schema.sql) (task_costs DDL),
|
||||
[`colibri-mcp-ssh`](../../packaging/mother/colibri-mcp-ssh) (`report-task-cost` case),
|
||||
[`daemon.rs`](../../crates/colibri-daemon/src/daemon.rs) (`push_cost_to_mother`)
|
||||
|
||||
## See also
|
||||
107|
|
||||
108|- [agent-harness](./agent-harness.md) — the zot/Colibri split; autospawn
|
||||
109|- [naming-decisions](./naming-decisions.md) — `usb_nodes → hive_nodes`, autospawn flag rename
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ _sheme in (De)serialize_, ne poslovne logike.
|
|||
| -------------------------------------- | --------------------- | ------------------------------------------------------------------------- |
|
||||
| `clawdie.interagent.run-manifest.v1` | `RunManifest` | Beleži tek gradnje/testa — vloga, agent, artefakti, povzetek. |
|
||||
| `clawdie.runtime-version-inventory.v1` | `RuntimeInventory` | Posnetek izvajalnega okolja gostitelja — OS, različice paketov, npm/node. |
|
||||
| `clawdie.provider-test.result.v1` | `ProviderSmokeResult` | Rezultat sonde predpomnilnika DeepSeek in obračun žetonov. |
|
||||
| `clawdie.provider-test.result.v1` | `ProviderSmokeResult` | Rezultat sonde predpomnilnika DeepSeek in obračun žetonov. |
|
||||
|
||||
Konstante shem in strukture živijo v `crates/colibri-contracts/src/lib.rs`.
|
||||
|
||||
|
|
|
|||
|
|
@ -59,19 +59,21 @@ clippy.
|
|||
| [headroom-sidecar](./headroom-sidecar.md) | Neobvezni stranski vagon za stiskanje rezultatov orodij in njegov protokol Unix vtičnice |
|
||||
| [jail-confinement](./jail-confinement.md) | Trajne proti prehodnim ječam, pravilnik načina priv, ponovna uporaba omejitve zaganjalnika za strežnike MCP |
|
||||
| [mother-hive](./mother-hive.md) | Arhitektura matičnega MCP — SSH s prisiljenim ukazom, enojni-dom-v-colibri, peer avtentikacija, ključ-na-semenu |
|
||||
| [hive-pane](./hive-pane.md) | Steklena plošča za panj — opazovanje stroškov več vozlišč, odkrivanje A2A in operaterska nadzorna plošča |
|
||||
| [hive-routing](./hive-routing.md) | Identiteta članov panja (UUID stroja), matrika zmožnosti + sonde lokalnih LLM, usmerjanje nalog glede na stroške |
|
||||
| [hive-pane](./hive-pane.md) | Steklena plošča za panj — opazovanje stroškov več vozlišč, odkrivanje A2A in operaterska nadzorna plošča |
|
||||
| [a2a-complexity-audit](./a2a-complexity-audit.md) | Vpliv A2A na kodno kompleksnost — revizija šestih protokolov, kdaj se A2A izplača |
|
||||
| [naming-decisions](./naming-decisions.md) | Imenik preimenovanj, nevtralnih glede na opremo / arhitekturnih — dostavljenih in v teku |
|
||||
| [daemon-not-demon](./daemon-not-demon.md) | Zakaj rečemo daemon (duh pomočnik) in ne demon (hudič) — angleško + slovensko |
|
||||
| [layered-soul](./layered-soul.md) | Kako Colibri danes uporablja repozitorij pregledanega konteksta layered-soul proti načrtovanemu |
|
||||
| [task-board](./task-board.md) | Točkovanje po zmožnostih, cron razporejanje, praznjenje vnosne vrste, podlaga SQLite |
|
||||
| [quality-gates](./quality-gates.md) | `ci-checks.sh` kot preverjanje pred združitvijo; zakaj je odmik prej dosegel `main` |
|
||||
| [contracts](./contracts.md) | Stabilne JSON sheme (run-manifest, runtime-inventory, provider-test), zlati testi |
|
||||
| [contracts](./contracts.md) | Stabilne JSON sheme (run-manifest, runtime-inventory, provider-test), zlati testi |
|
||||
| [store-schema](./store-schema.md) | Usklajevalna shema SQLite in disciplina migracij |
|
||||
| [external-mcp](./external-mcp.md) | Most MCP za urejevalnike + zunanji gostitelj stdio MCP; dovoljenja za branje/pisanje/zunanji-klic |
|
||||
| [operator-cli](./operator-cli.md) | CLI `colibri` kot tanek tipiziran odjemalec Unix vtičnice prek API procesa v ozadju |
|
||||
| [tui](./tui.md) | Odjemalec terminalske nadzorne plošče (colibri-tui) proti avtomatu stanj colibri-glasspane |
|
||||
| [terminal](./terminal.md) | Odločitev o terminalski zmožnosti (Kitty, razširjeno poročanje tipk, prehod tmux, SSH terminfo) |
|
||||
| [runtime-inventory](./runtime-inventory.md) | Popis izvajalnega okolja gostitelja + bralnik statusa čuvaja; aditivne, bralne integracije |
|
||||
| [skills-catalog](./skills-catalog.md) | Bralni izvajalni porabnik za pregledane artefakte veščin |
|
||||
| [skills-catalog](./skills-catalog.md) | Bralni izvajalni porabnik za pregledane artefakte veščin |
|
||||
| [vault-provision](./vault-provision.md) | Oskrba datotek env, gnana z Vaultwarden, v ječe po zagonu agenta |
|
||||
| [deployment](./deployment.md) | Nameščevalnik gostitelja (clawdie): postavitev ZFS, storitev rc.d/systemd, varnost suhega teka |
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ should be revisited.
|
|||
| `s` | Spawn a local `colibri-test-agent` |
|
||||
| `x` | Stop the selected pane |
|
||||
| `Enter` | Open/close the detail pane for the selected row |
|
||||
| `Tab` / `Shift+Tab` | Cycle through distinct sessions (incl. "All") |
|
||||
| `Tab` / `Shift+Tab` | Cycle through distinct sessions (incl. "All") |
|
||||
| `j` / `k` or `↓` / `↑` | Navigate the pane table |
|
||||
| `n` / `N` | Jump to next / previous **attention** pane |
|
||||
| `a` | Toggle the attention filter (only attention) |
|
||||
|
|
@ -137,10 +137,10 @@ renders. This makes attention impossible to miss without consuming extra space.
|
|||
|
||||
### Row highlight inverts on selection
|
||||
|
||||
| Row state | Normal | Selected |
|
||||
| --------- | --------------------------- | ----------------------------------------- |
|
||||
| Attention | `bg(DarkRed)` + `fg(White)` | `bg(DarkGray)` + `fg(LightRed)` + bold |
|
||||
| Normal | (plain) | `bg(DarkGray)` |
|
||||
| Row state | Normal | Selected |
|
||||
| --------- | --------------------------- | -------------------------------------- |
|
||||
| Attention | `bg(DarkRed)` + `fg(White)` | `bg(DarkGray)` + `fg(LightRed)` + bold |
|
||||
| Normal | (plain) | `bg(DarkGray)` |
|
||||
|
||||
Attention rows are impossible to miss; the inversion on selection confirms
|
||||
which one the cursor is on without losing the attention signal.
|
||||
|
|
|
|||
|
|
@ -27,16 +27,17 @@ case "${SSH_ORIGINAL_COMMAND:-}" in
|
|||
;;
|
||||
"report-task-cost")
|
||||
# Read TaskCostSummary JSON from stdin, INSERT into mother_hive.task_costs.
|
||||
# Input: {"node_id":1,"task_id":"abc","provider":"deepseek","model":"deepseek-chat",
|
||||
# "input_tokens":150,"output_tokens":80,"cache_read_tokens":200,
|
||||
# "cache_write_tokens":50,"cost_usd":0.0042,"success":true,
|
||||
# Input: {"node_hostname":"debby","task_id":"abc","provider":"deepseek",
|
||||
# "model":"deepseek-chat","input_tokens":150,"output_tokens":80,
|
||||
# "cache_read_tokens":200,"cache_write_tokens":50,
|
||||
# "cost_usd":0.0042,"success":true,"screenshot_uuid":"a1b2c3d4e5f6",
|
||||
# "finished_at":"2026-06-27T12:00:00Z"}
|
||||
psql -d mother_hive -tA -v ON_ERROR_STOP=1 <<'PSQL'
|
||||
INSERT INTO task_costs (node_id, task_id, provider, model,
|
||||
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
||||
cost_usd, success, finished_at)
|
||||
cost_usd, success, screenshot_uuid, finished_at)
|
||||
SELECT
|
||||
(j->>'node_id')::INTEGER,
|
||||
(SELECT id FROM hive_nodes WHERE hostname = j->>'node_hostname'),
|
||||
j->>'task_id',
|
||||
j->>'provider',
|
||||
j->>'model',
|
||||
|
|
@ -46,6 +47,7 @@ SELECT
|
|||
COALESCE((j->>'cache_write_tokens')::BIGINT, 0),
|
||||
COALESCE((j->>'cost_usd')::DOUBLE PRECISION, 0.0),
|
||||
COALESCE((j->>'success')::BOOLEAN, false),
|
||||
NULLIF(j->>'screenshot_uuid', ''),
|
||||
COALESCE((j->>'finished_at')::TIMESTAMPTZ, now())
|
||||
FROM (SELECT (pg_read_file('/dev/stdin')::JSONB) AS j) AS _;
|
||||
PSQL
|
||||
|
|
|
|||
49
packaging/mother/dashboard/deploy.sh
Executable file
49
packaging/mother/dashboard/deploy.sh
Executable file
|
|
@ -0,0 +1,49 @@
|
|||
#!/bin/sh
|
||||
# Deploy the cost dashboard to the mother webroot.
|
||||
# Run on mother (osa) after colibri deploy.
|
||||
#
|
||||
# Places:
|
||||
# /usr/local/www/clawdie/dashboard/index.html — dashboard page
|
||||
# /usr/local/www/clawdie/dashboard/export-costs.sh — JSON export (cron)
|
||||
# /usr/local/etc/cron.d/clawdie-dashboard — cron job
|
||||
#
|
||||
# The dashboard reads task_costs.json (exported every 60s by cron) and
|
||||
# links screenshots from ../screenshots/ (tmux-screenshot publish dir).
|
||||
#
|
||||
# Nginx: the dashboard directory is served as a static location under
|
||||
# the existing mother vhost. No new server block needed — just:
|
||||
#
|
||||
# location /dashboard/ {
|
||||
# alias /usr/local/www/clawdie/dashboard/;
|
||||
# index index.html;
|
||||
# }
|
||||
|
||||
set -eu
|
||||
|
||||
SRC="$(dirname "$0")"
|
||||
WEBROOT="/usr/local/www/clawdie/dashboard"
|
||||
CRON_FILE="/usr/local/etc/cron.d/clawdie-dashboard"
|
||||
|
||||
echo "=== deploy cost dashboard ==="
|
||||
|
||||
mkdir -p "$WEBROOT"
|
||||
|
||||
cp "$SRC/index.html" "$WEBROOT/index.html"
|
||||
cp "$SRC/export-costs.sh" "$WEBROOT/export-costs.sh"
|
||||
chmod +x "$WEBROOT/export-costs.sh"
|
||||
|
||||
# Idempotent cron entry: export every 60s
|
||||
cat > "$CRON_FILE" <<'CRON'
|
||||
# clawdie cost dashboard — export task_costs to JSON every 60s
|
||||
* * * * * root /usr/local/www/clawdie/dashboard/export-costs.sh
|
||||
CRON
|
||||
|
||||
echo " dashboard → $WEBROOT/index.html"
|
||||
echo " cron → $CRON_FILE"
|
||||
echo ""
|
||||
|
||||
# Run once immediately to seed the data
|
||||
echo "=== initial export ==="
|
||||
"$WEBROOT/export-costs.sh" || echo " (no data yet — tasks will appear as agents complete)"
|
||||
echo ""
|
||||
echo "Done. Dashboard at: https://mother.clawdie.si/dashboard/"
|
||||
76
packaging/mother/dashboard/export-costs.sh
Executable file
76
packaging/mother/dashboard/export-costs.sh
Executable file
|
|
@ -0,0 +1,76 @@
|
|||
#!/bin/sh
|
||||
# Export task_costs to JSON for the cost dashboard.
|
||||
# Run from cron (every 60s) or on-demand.
|
||||
# Output: /usr/local/www/clawdie/dashboard/task_costs.json
|
||||
|
||||
set -eu
|
||||
OUTDIR="/usr/local/www/clawdie/dashboard"
|
||||
mkdir -p "$OUTDIR"
|
||||
|
||||
psql -d mother_hive -tA <<'SQL' > "${OUTDIR}/task_costs.json"
|
||||
SELECT json_build_object(
|
||||
'updated_at', now(),
|
||||
'summary', json_build_object(
|
||||
'total_tasks', COUNT(*),
|
||||
'total_cost', COALESCE(SUM(cost_usd), 0.0),
|
||||
'avg_cost', COALESCE(ROUND(AVG(cost_usd)::numeric, 6), 0.0),
|
||||
'success_rate', COALESCE(ROUND(
|
||||
COUNT(*) FILTER (WHERE success)::numeric / NULLIF(COUNT(*), 0) * 100, 1
|
||||
), 0.0),
|
||||
'total_input_tokens', COALESCE(SUM(input_tokens), 0),
|
||||
'total_output_tokens', COALESCE(SUM(output_tokens), 0),
|
||||
'cache_hit_ratio', COALESCE(ROUND(
|
||||
SUM(cache_read_tokens)::numeric /
|
||||
NULLIF(SUM(cache_read_tokens + input_tokens), 0) * 100, 1
|
||||
), 0.0)
|
||||
),
|
||||
'nodes', (
|
||||
SELECT json_agg(node_stats ORDER BY total_cost DESC) FROM (
|
||||
SELECT
|
||||
hn.hostname,
|
||||
hn.node_type,
|
||||
hn.capabilities->>'inference_tier' AS llm_tier,
|
||||
COUNT(tc.*) AS task_count,
|
||||
COALESCE(SUM(tc.cost_usd), 0.0) AS total_cost,
|
||||
COALESCE(ROUND(AVG(tc.cost_usd)::numeric, 6), 0.0) AS avg_cost,
|
||||
COALESCE(ROUND(
|
||||
COUNT(*) FILTER (WHERE tc.success)::numeric /
|
||||
NULLIF(COUNT(*), 0) * 100, 1
|
||||
), 0.0) AS success_rate,
|
||||
COALESCE(SUM(tc.input_tokens), 0) AS total_input,
|
||||
COALESCE(SUM(tc.output_tokens), 0) AS total_output,
|
||||
COALESCE(SUM(tc.cache_read_tokens), 0) AS total_cache_read,
|
||||
COALESCE(ROUND(
|
||||
SUM(tc.cache_read_tokens)::numeric /
|
||||
NULLIF(SUM(tc.cache_read_tokens + tc.input_tokens), 0) * 100, 1
|
||||
), 0.0) AS cache_hit_pct
|
||||
FROM task_costs tc
|
||||
LEFT JOIN hive_nodes hn ON hn.id = tc.node_id
|
||||
GROUP BY hn.hostname, hn.node_type, hn.capabilities
|
||||
) AS node_stats
|
||||
),
|
||||
'tasks', (
|
||||
SELECT json_agg(t ORDER BY t.finished_at DESC) FROM (
|
||||
SELECT
|
||||
tc.task_id,
|
||||
hn.hostname AS node,
|
||||
tc.provider,
|
||||
tc.model,
|
||||
tc.input_tokens,
|
||||
tc.output_tokens,
|
||||
tc.cache_read_tokens,
|
||||
tc.cache_write_tokens,
|
||||
tc.cost_usd,
|
||||
tc.success,
|
||||
tc.finished_at,
|
||||
tc.screenshot_uuid
|
||||
FROM task_costs tc
|
||||
LEFT JOIN hive_nodes hn ON hn.id = tc.node_id
|
||||
ORDER BY tc.finished_at DESC
|
||||
LIMIT 200
|
||||
) AS t
|
||||
)
|
||||
) AS result;
|
||||
SQL
|
||||
|
||||
echo "dashboard: $(date -Iseconds) — $(jq '.summary.total_tasks' "${OUTDIR}/task_costs.json") tasks" >&2
|
||||
403
packaging/mother/dashboard/index.html
Normal file
403
packaging/mother/dashboard/index.html
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cost Dashboard · clawdie.si</title>
|
||||
<style>
|
||||
/* ══ reset & base ═══════════════════════════════════════════════════════ */
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
:root{
|
||||
--bg:#0d1117; --surface:#161b22; --border:#21262d; --muted:#484f58;
|
||||
--fg:#c9d1d9; --fg2:#8b949e; --accent:#00b4d8; --green:#3fb950;
|
||||
--red:#f85149; --yellow:#d29922; --orange:#db6d28; --cache:#238636;
|
||||
--fresh:#30363d; --bar-h:6px; --card-w:200px;
|
||||
}
|
||||
body{
|
||||
background:var(--bg); color:var(--fg);
|
||||
font-family:'DM Mono','SF Mono','Cascadia Code',monospace;
|
||||
padding:2rem; min-height:100vh;
|
||||
}
|
||||
|
||||
/* ══ header ═════════════════════════════════════════════════════════════ */
|
||||
.header{
|
||||
display:flex; align-items:center; justify-content:space-between;
|
||||
flex-wrap:wrap; gap:1rem; margin-bottom:1.5rem;
|
||||
padding-bottom:1rem; border-bottom:1px solid var(--border);
|
||||
}
|
||||
h1{font-size:1.1rem; font-weight:400; color:var(--fg2); letter-spacing:.08em; text-transform:uppercase}
|
||||
h1 span{color:var(--accent)}
|
||||
h1 .dot{display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:6px; background:var(--green)}
|
||||
.summary-bar{display:flex; gap:1.5rem; flex-wrap:wrap}
|
||||
.summary-item{text-align:center}
|
||||
.summary-item .val{font-size:1.3rem; color:var(--accent)}
|
||||
.summary-item .lbl{font-size:.65rem; color:var(--fg2); text-transform:uppercase; letter-spacing:.05em}
|
||||
|
||||
/* ══ controls ═══════════════════════════════════════════════════════════ */
|
||||
.controls{display:flex; align-items:center; gap:.75rem; flex-wrap:wrap;margin-bottom:1.5rem}
|
||||
.controls label{font-size:.7rem; color:var(--fg2); text-transform:uppercase; letter-spacing:.05em}
|
||||
.controls select, .controls input[type="text"]{
|
||||
background:var(--surface); color:var(--fg); border:1px solid var(--border);
|
||||
border-radius:4px; padding:.35rem .5rem; font-family:inherit; font-size:.75rem
|
||||
}
|
||||
.controls select:hover, .controls input:hover{border-color:var(--accent)}
|
||||
.btn{
|
||||
background:none; border:1px solid var(--border); color:var(--fg2);
|
||||
border-radius:4px; padding:.35rem .8rem; font-family:inherit; font-size:.72rem;
|
||||
cursor:pointer; transition:border-color .2s,color .2s
|
||||
}
|
||||
.btn:hover{border-color:var(--accent); color:var(--accent)}
|
||||
.btn.active{background:var(--accent); color:var(--bg); border-color:var(--accent)}
|
||||
.btn-json{background:var(--surface); color:var(--yellow); border-color:var(--yellow)}
|
||||
.btn-json:hover{background:var(--yellow); color:var(--bg)}
|
||||
|
||||
/* ══ nodes ══════════════════════════════════════════════════════════════ */
|
||||
.nodes{margin-bottom:2rem}
|
||||
.node-row{
|
||||
background:var(--surface); border:1px solid var(--border); border-radius:8px;
|
||||
padding:1rem 1.25rem; margin-bottom:.75rem; transition:border-color .2s
|
||||
}
|
||||
.node-row:hover{border-color:var(--accent)}
|
||||
.node-head{display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:.5rem}
|
||||
.node-name{font-size:.9rem; color:var(--accent); font-weight:600}
|
||||
.node-name .status-dot{font-size:.6rem; margin-right:4px}
|
||||
.node-name .status-dot.online{color:var(--green)}
|
||||
.node-name .status-dot.offline{color:var(--fg2)}
|
||||
.node-meta{display:flex; gap:1.2rem; font-size:.72rem; color:var(--fg2)}
|
||||
.node-meta strong{color:var(--fg); font-weight:400}
|
||||
.node-cards{display:flex; gap:.75rem; flex-wrap:wrap; margin-top:.75rem; padding-top:.75rem; border-top:1px solid var(--border)}
|
||||
.node-empty{font-size:.72rem; color:var(--muted); font-style:italic; padding:.5rem 0}
|
||||
|
||||
/* ══ cost cards ═════════════════════════════════════════════════════════ */
|
||||
.card{
|
||||
background:var(--bg); border:1px solid var(--border); border-radius:6px;
|
||||
padding:.65rem .75rem; width:var(--card-w); cursor:default;
|
||||
transition:border-color .2s,transform .15s; position:relative
|
||||
}
|
||||
.card:hover{transform:translateY(-1px)}
|
||||
.card.has-proof{border-color:var(--accent); cursor:pointer}
|
||||
.card.has-proof:hover{border-color:var(--green); box-shadow:0 0 12px rgba(0,180,216,.12)}
|
||||
.card-provider{font-size:.65rem; text-transform:uppercase; letter-spacing:.05em; color:var(--fg2); margin-bottom:4px}
|
||||
.card-cost{font-size:1.15rem; font-weight:600; color:var(--fg)}
|
||||
.card-model{font-size:.65rem; color:var(--muted); margin-bottom:6px}
|
||||
.card-success{
|
||||
position:absolute; top:8px; right:8px; font-size:.75rem;
|
||||
width:20px; height:20px; border-radius:50%; display:flex; align-items:center; justify-content:center
|
||||
}
|
||||
.card-success.ok{background:rgba(63,185,80,.15); color:var(--green)}
|
||||
.card-success.fail{background:rgba(248,81,73,.15); color:var(--red)}
|
||||
.card-proof-badge{
|
||||
position:absolute; bottom:6px; right:6px; font-size:.55rem; color:var(--accent);
|
||||
opacity:.6; text-transform:uppercase; letter-spacing:.04em
|
||||
}
|
||||
/* cache bar */
|
||||
.card-cache-bar{
|
||||
display:flex; height:var(--bar-h); border-radius:3px; overflow:hidden; margin-top:4px
|
||||
}
|
||||
.card-cache-hit{background:var(--cache)}
|
||||
.card-cache-fresh{background:var(--fresh)}
|
||||
.card-tokens{font-size:.6rem; color:var(--muted); margin-top:3px}
|
||||
|
||||
/* ══ raw json panel ═════════════════════════════════════════════════════ */
|
||||
.json-panel{
|
||||
display:none; background:var(--surface); border:1px solid var(--border);
|
||||
border-radius:8px; padding:1.25rem; margin-top:1.5rem; max-height:70vh; overflow:auto
|
||||
}
|
||||
.json-panel.open{display:block}
|
||||
.json-panel pre{
|
||||
font-family:inherit; font-size:.72rem; color:var(--green);
|
||||
white-space:pre-wrap; word-break:break-all; line-height:1.5
|
||||
}
|
||||
.json-summary{font-size:.72rem; color:var(--fg2); margin-bottom:.75rem}
|
||||
|
||||
/* ══ lightbox ═══════════════════════════════════════════════════════════ */
|
||||
.lightbox{display:none; position:fixed; inset:0; background:rgba(0,0,0,.94); z-index:1000;
|
||||
align-items:center; justify-content:center; padding:1.5rem}
|
||||
.lightbox.open{display:flex}
|
||||
.lightbox img{max-width:94vw; max-height:88vh; border-radius:4px; box-shadow:0 0 40px rgba(0,180,216,.15)}
|
||||
.lightbox-close{position:fixed; top:1rem; right:1.5rem; background:var(--surface); color:var(--fg2);
|
||||
border:1px solid var(--border); border-radius:4px; padding:.4rem 1rem; font-family:inherit;
|
||||
font-size:.78rem; cursor:pointer; z-index:1001}
|
||||
.lightbox-close:hover{color:var(--accent); border-color:var(--accent)}
|
||||
.lightbox-meta{
|
||||
position:fixed; bottom:1.5rem; left:50%; transform:translateX(-50%);
|
||||
background:var(--surface); color:var(--fg2); border:1px solid var(--border);
|
||||
border-radius:6px; padding:.5rem 1rem; font-size:.68rem; z-index:1001
|
||||
}
|
||||
.lightbox-meta strong{color:var(--accent); margin-right:.5rem}
|
||||
|
||||
/* ══ empty state ════════════════════════════════════════════════════════ */
|
||||
.empty{text-align:center; padding:4rem 2rem; color:var(--muted); font-size:.85rem}
|
||||
.empty-icon{font-size:2.5rem; margin-bottom:.75rem}
|
||||
|
||||
/* ══ status markers ═════════════════════════════════════════════════════ */
|
||||
.status-online{color:var(--green)}
|
||||
.status-offline{color:var(--fg2)}
|
||||
.cost-trend-up{color:var(--red)}
|
||||
.cost-trend-down{color:var(--green)}
|
||||
.llm-badge{
|
||||
display:inline-block; background:rgba(0,180,216,.1); color:var(--accent);
|
||||
font-size:.6rem; padding:1px 6px; border-radius:3px; margin-left:6px;
|
||||
text-transform:uppercase; letter-spacing:.03em
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<h1><span class="dot"></span> Colibri Cost Dashboard <span id="updated"></span></h1>
|
||||
<div class="summary-bar" id="summary"></div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<label>Node</label>
|
||||
<select id="filter-node"><option value="">all nodes</option></select>
|
||||
|
||||
<label>Provider</label>
|
||||
<select id="filter-provider">
|
||||
<option value="">all providers</option>
|
||||
<option value="deepseek">deepseek</option>
|
||||
<option value="openrouter">openrouter</option>
|
||||
<option value="anthropic">anthropic</option>
|
||||
<option value="google">google</option>
|
||||
<option value="ollama">ollama</option>
|
||||
<option value="local">local</option>
|
||||
</select>
|
||||
|
||||
<label>Status</label>
|
||||
<select id="filter-success">
|
||||
<option value="">all</option>
|
||||
<option value="true">success</option>
|
||||
<option value="false">failed</option>
|
||||
</select>
|
||||
|
||||
<button class="btn btn-json" id="toggle-json">JSON</button>
|
||||
</div>
|
||||
|
||||
<div class="nodes" id="nodes"></div>
|
||||
|
||||
<div class="empty" id="empty-state" style="display:none">
|
||||
<div class="empty-icon">⊘</div>
|
||||
<p>No cost data yet. Tasks will appear here as agents complete work.</p>
|
||||
</div>
|
||||
|
||||
<div class="json-panel" id="json-panel">
|
||||
<div class="json-summary" id="json-summary"></div>
|
||||
<pre id="json-output"></pre>
|
||||
</div>
|
||||
|
||||
<div class="lightbox" id="lightbox">
|
||||
<button class="lightbox-close">Esc to close</button>
|
||||
<div class="lightbox-meta" id="lightbox-meta"></div>
|
||||
<img id="lb-img" src="" alt="screenshot proof">
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ══ state ═══════════════════════════════════════════════════════════════
|
||||
let DATA = null;
|
||||
let FILTERS = { node: '', provider: '', success: '' };
|
||||
|
||||
// ══ data loading ════════════════════════════════════════════════════════
|
||||
async function load() {
|
||||
try {
|
||||
const r = await fetch('task_costs.json');
|
||||
DATA = await r.json();
|
||||
} catch(e) {
|
||||
document.getElementById('nodes').innerHTML =
|
||||
'<div class="empty"><div class="empty-icon">⟳</div><p>Waiting for cost data…</p></div>';
|
||||
return;
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
// ══ render ══════════════════════════════════════════════════════════════
|
||||
function render() {
|
||||
if (!DATA) return;
|
||||
const s = DATA.summary || {};
|
||||
document.getElementById('updated').textContent =
|
||||
DATA.updated_at ? '· ' + fmtDate(DATA.updated_at) : '';
|
||||
|
||||
// summary bar
|
||||
document.getElementById('summary').innerHTML = [
|
||||
{v: s.total_tasks||0, l:'tasks'},
|
||||
{v: '$'+(s.total_cost||0).toFixed(3), l:'total cost'},
|
||||
{v: '$'+(s.avg_cost||0).toFixed(4), l:'avg/task'},
|
||||
{v: (s.success_rate||0)+'%', l:'success'},
|
||||
{v: (s.cache_hit_ratio||0)+'%', l:'cache hit'},
|
||||
{v: fmtTokens(s.total_input_tokens||0), l:'tokens in'},
|
||||
{v: fmtTokens(s.total_output_tokens||0), l:'tokens out'},
|
||||
].map(i => `<div class="summary-item"><div class="val">${i.v}</div><div class="lbl">${i.l}</div></div>`).join('');
|
||||
|
||||
// node filter dropdown
|
||||
const nodeSel = document.getElementById('filter-node');
|
||||
const nodes = DATA.nodes || [];
|
||||
nodeSel.innerHTML = '<option value="">all nodes</option>' +
|
||||
nodes.map(n => `<option value="${esc(n.hostname)}">${esc(n.hostname)}</option>`).join('');
|
||||
|
||||
// apply filters to tasks
|
||||
const tasks = (DATA.tasks || []).filter(t => {
|
||||
if (FILTERS.node && t.node !== FILTERS.node) return false;
|
||||
if (FILTERS.provider && t.provider !== FILTERS.provider) return false;
|
||||
if (FILTERS.success !== '' && String(t.success) !== FILTERS.success) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// group tasks by node
|
||||
const byNode = {};
|
||||
for (const t of tasks) {
|
||||
const n = t.node || 'unknown';
|
||||
if (!byNode[n]) byNode[n] = [];
|
||||
byNode[n].push(t);
|
||||
}
|
||||
|
||||
// find node metadata
|
||||
const nodeMeta = {};
|
||||
for (const n of nodes) nodeMeta[n.hostname] = n;
|
||||
|
||||
const container = document.getElementById('nodes');
|
||||
const empty = document.getElementById('empty-state');
|
||||
|
||||
if (Object.keys(byNode).length === 0) {
|
||||
container.innerHTML = '';
|
||||
empty.style.display = 'block';
|
||||
} else {
|
||||
empty.style.display = 'none';
|
||||
container.innerHTML = Object.entries(byNode).map(([hostname, nodeTasks]) => {
|
||||
const meta = nodeMeta[hostname] || {};
|
||||
const totalCost = nodeTasks.reduce((s,t)=>s+(t.cost_usd||0),0);
|
||||
const succeeded = nodeTasks.filter(t=>t.success).length;
|
||||
const rate = nodeTasks.length > 0
|
||||
? Math.round(succeeded / nodeTasks.length * 100) : 0;
|
||||
|
||||
return `
|
||||
<div class="node-row">
|
||||
<div class="node-head">
|
||||
<div>
|
||||
<span class="node-name">
|
||||
<span class="status-dot online">●</span>${esc(hostname)}
|
||||
</span>
|
||||
${meta.llm_tier ? `<span class="llm-badge">${esc(meta.llm_tier)}</span>` : ''}
|
||||
</div>
|
||||
<div class="node-meta">
|
||||
<span><strong>${nodeTasks.length}</strong> tasks</span>
|
||||
<span><strong>$${totalCost.toFixed(3)}</strong> total</span>
|
||||
<span><strong>$${nodeTasks.length>0?(totalCost/nodeTasks.length).toFixed(4):'0.0000'}</strong> avg</span>
|
||||
<span><strong>${rate}%</strong> success</span>
|
||||
${meta.cache_hit_pct!=null ? `<span><strong>${meta.cache_hit_pct}%</strong> cache</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="node-cards">
|
||||
${nodeTasks.map(t => renderCard(t)).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// update json panel if open
|
||||
if (document.getElementById('json-panel').classList.contains('open')) {
|
||||
showJSON();
|
||||
}
|
||||
}
|
||||
|
||||
// ══ cost card ═══════════════════════════════════════════════════════════
|
||||
function renderCard(t) {
|
||||
const total = (t.input_tokens||0) + (t.cache_read_tokens||0);
|
||||
const cachePct = total > 0 ? Math.round((t.cache_read_tokens||0) / total * 100) : 0;
|
||||
const freshPct = 100 - cachePct;
|
||||
const hasProof = !!t.screenshot_uuid;
|
||||
const cls = hasProof ? 'card has-proof' : 'card';
|
||||
const onClick = hasProof
|
||||
? `onclick="openProof('${esc(t.screenshot_uuid)}','${esc(t.task_id||'')}','${esc(t.provider||'')}','${(t.cost_usd||0).toFixed(4)}')"`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="${cls}" ${onClick} title="${hasProof?'Click for terminal proof':''}">
|
||||
<div class="card-success ${t.success?'ok':'fail'}">${t.success?'✓':'✗'}</div>
|
||||
<div class="card-provider">${esc(t.provider||'unknown')}</div>
|
||||
<div class="card-cost">$${(t.cost_usd||0).toFixed(4)}</div>
|
||||
<div class="card-model">${esc(t.model||'')}</div>
|
||||
<div class="card-cache-bar">
|
||||
<div class="card-cache-hit" style="width:${cachePct}%"></div>
|
||||
<div class="card-cache-fresh" style="width:${freshPct}%"></div>
|
||||
</div>
|
||||
<div class="card-tokens">${fmtTokens(t.input_tokens||0)} in · ${fmtTokens(t.output_tokens||0)} out
|
||||
${cachePct>0?` · ${cachePct}% cache` : ''}
|
||||
</div>
|
||||
${hasProof ? '<div class="card-proof-badge">▸ proof</div>' : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ══ lightbox ════════════════════════════════════════════════════════════
|
||||
function openProof(uuid, taskId, provider, cost) {
|
||||
const lb = document.getElementById('lightbox');
|
||||
document.getElementById('lb-img').src = `../screenshots/${uuid}.png`;
|
||||
document.getElementById('lightbox-meta').innerHTML =
|
||||
`<strong>${esc(taskId)}</strong> ${esc(provider)} · $${cost}`;
|
||||
lb.classList.add('open');
|
||||
}
|
||||
function closeLb() { document.getElementById('lightbox').classList.remove('open'); }
|
||||
|
||||
// ══ json panel ══════════════════════════════════════════════════════════
|
||||
function showJSON() {
|
||||
// Build filtered agent-friendly view
|
||||
const tasks = (DATA.tasks || []).filter(t => {
|
||||
if (FILTERS.node && t.node !== FILTERS.node) return false;
|
||||
if (FILTERS.provider && t.provider !== FILTERS.provider) return false;
|
||||
if (FILTERS.success !== '' && String(t.success) !== FILTERS.success) return false;
|
||||
return true;
|
||||
});
|
||||
const byNode = {};
|
||||
for (const t of tasks) {
|
||||
const n = t.node || 'unknown'; if (!byNode[n]) byNode[n] = [];
|
||||
byNode[n].push({task_id:t.task_id,provider:t.provider,model:t.model,
|
||||
cost:t.cost_usd,success:t.success,finished_at:t.finished_at,
|
||||
tokens:{in:t.input_tokens,out:t.output_tokens,cache_read:t.cache_read_tokens},
|
||||
screenshot_uuid:t.screenshot_uuid||null});
|
||||
}
|
||||
const output = {updated_at:DATA.updated_at, summary:DATA.summary, nodes:byNode};
|
||||
document.getElementById('json-output').textContent = JSON.stringify(output, null, 2);
|
||||
document.getElementById('json-summary').textContent =
|
||||
`${tasks.length} tasks across ${Object.keys(byNode).length} nodes ` +
|
||||
`· total cost $${(DATA.summary?.total_cost||0).toFixed(3)}`;
|
||||
}
|
||||
|
||||
// ══ events ══════════════════════════════════════════════════════════════
|
||||
document.getElementById('filter-node').addEventListener('change', e => {
|
||||
FILTERS.node = e.target.value; render();
|
||||
});
|
||||
document.getElementById('filter-provider').addEventListener('change', e => {
|
||||
FILTERS.provider = e.target.value; render();
|
||||
});
|
||||
document.getElementById('filter-success').addEventListener('change', e => {
|
||||
FILTERS.success = e.target.value; render();
|
||||
});
|
||||
document.getElementById('toggle-json').addEventListener('click', () => {
|
||||
const panel = document.getElementById('json-panel');
|
||||
const btn = document.getElementById('toggle-json');
|
||||
panel.classList.toggle('open');
|
||||
btn.classList.toggle('active');
|
||||
if (panel.classList.contains('open')) showJSON();
|
||||
});
|
||||
document.getElementById('lightbox').addEventListener('click', e => {
|
||||
if (e.target.classList.contains('lightbox') || e.target.id === 'lb-img') closeLb();
|
||||
});
|
||||
document.querySelector('.lightbox-close').addEventListener('click', closeLb);
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') { closeLb(); document.getElementById('json-panel').classList.remove('open'); }
|
||||
});
|
||||
|
||||
// ══ utils ═══════════════════════════════════════════════════════════════
|
||||
function esc(s) { return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
function fmtTokens(n) { return n >= 1000000 ? (n/1000000).toFixed(1)+'M' : n >= 1000 ? (n/1000).toFixed(1)+'K' : String(n); }
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return '';
|
||||
try { const d = new Date(iso); return d.toLocaleDateString('sl-SI',{day:'2-digit',month:'short',hour:'2-digit',minute:'2-digit'}); }
|
||||
catch(e) { return iso.slice(0,16); }
|
||||
}
|
||||
|
||||
// ══ init ════════════════════════════════════════════════════════════════
|
||||
load();
|
||||
setInterval(load, 60000); // auto-refresh every 60s
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -65,12 +65,15 @@ CREATE TABLE IF NOT EXISTS task_costs (
|
|||
cache_write_tokens BIGINT NOT NULL DEFAULT 0,
|
||||
cost_usd DOUBLE PRECISION NOT NULL DEFAULT 0.0,
|
||||
success BOOLEAN NOT NULL DEFAULT false,
|
||||
screenshot_uuid TEXT, -- tmux-screenshot content hash (12-char UUID)
|
||||
finished_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
reported_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
reported_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
screenshot_uuid TEXT -- optional; links to tmux-screenshot capture at task completion
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_task_costs_node ON task_costs (node_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_task_costs_finished ON task_costs (finished_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_task_costs_provider ON task_costs (provider, model);
|
||||
ALTER TABLE task_costs ADD COLUMN IF NOT EXISTS screenshot_uuid TEXT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS build_queue (
|
||||
id SERIAL PRIMARY KEY,
|
||||
|
|
|
|||
|
|
@ -8,4 +8,4 @@ ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
|||
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
exec npx --yes prettier@3 --check '**/*.md'
|
||||
exec npx --yes prettier@3.8.4 --check '**/*.md'
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue