Projects with relations from .oxymodules, presence and sessions; projections skip malformed payloads (#5) 2fc88a7b3a5c
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 9aa1da43b849
- When
- · 2026-11-02 22:08 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
2fc88a7b3a5cdf17d0360297de971fd262a6c2a165df4db414b6448336cfa436- Depends on
- 09752034b5a8 258bb3824813 36537112fcac 5d695d869fbf
- Depended on by
- 862f0db224aa 8d803d858c8c 98a0fb9f0948 67bfa06a894f b576d05cf862 98a16830a3d3 da84e98f104e 39f5c913f617 eb675dfa5e18 571572f5fb2c 5eb57578f4ef
- In bookmarks
- main
- In tags
- v0.1.0
- Edits
- 32 atoms, 5 tree operations, 16 files
Browse the tree at this change
added crates/spectra-core/migrations/0003_projects_presence.sql +34 −0
| 1 | + | -- Projects and how they depend on one another (spec §23.1), and agents' |
|
| 2 | + | -- presence and sessions (spec §8.1, §8.2). |
|
| 3 | + | CREATE TABLE projects ( |
|
| 4 | + | name TEXT PRIMARY KEY, |
|
| 5 | + | path TEXT NOT NULL, |
|
| 6 | + | repo_id TEXT, |
|
| 7 | + | remote TEXT, |
|
| 8 | + | modules TEXT NOT NULL, -- JSON array of the URLs in .oxymodules |
|
| 9 | + | seq INTEGER NOT NULL |
|
| 10 | + | ); |
|
| 11 | + | ||
| 12 | + | CREATE TABLE project_relations ( |
|
| 13 | + | project TEXT NOT NULL, |
|
| 14 | + | depends_on TEXT NOT NULL, |
|
| 15 | + | PRIMARY KEY (project, depends_on) |
|
| 16 | + | ); |
|
| 17 | + | ||
| 18 | + | CREATE TABLE presence ( |
|
| 19 | + | agent TEXT PRIMARY KEY, |
|
| 20 | + | state TEXT NOT NULL, |
|
| 21 | + | since_ms INTEGER NOT NULL, |
|
| 22 | + | status_state TEXT, |
|
| 23 | + | status_text TEXT, |
|
| 24 | + | status_task TEXT, |
|
| 25 | + | status_ms INTEGER |
|
| 26 | + | ); |
|
| 27 | + | ||
| 28 | + | CREATE TABLE sessions ( |
|
| 29 | + | id TEXT PRIMARY KEY, |
|
| 30 | + | agent TEXT NOT NULL, |
|
| 31 | + | started_ms INTEGER NOT NULL, |
|
| 32 | + | ended_ms INTEGER |
|
| 33 | + | ); |
|
| 34 | + | CREATE INDEX sessions_agent ON sessions(agent); |
added crates/spectra-core/src/presence.rs +231 −0
| 1 | + | //! Presence and sessions (spec §8.1, §8.2), kept in memory by the daemon |
|
| 2 | + | //! from what agents do, and recorded as events when they change, so the |
|
| 3 | + | //! ledger says when an agent went quiet. |
|
| 4 | + | ||
| 5 | + | use crate::event::NewEvent; |
|
| 6 | + | use std::collections::HashMap; |
|
| 7 | + | ||
| 8 | + | /// A request within this long makes an agent online. |
|
| 9 | + | pub const ONLINE_MS: u64 = 60_000; |
|
| 10 | + | /// Silence this long ends a session and makes an agent offline. |
|
| 11 | + | pub const IDLE_AFTER_MS: u64 = 10 * 60_000; |
|
| 12 | + | ||
| 13 | + | #[derive(Clone, Debug, Default)] |
|
| 14 | + | struct Activity { |
|
| 15 | + | last_request_ms: Option<u64>, |
|
| 16 | + | waiting: u32, |
|
| 17 | + | shims: u32, |
|
| 18 | + | status_ms: Option<u64>, |
|
| 19 | + | blocked: bool, |
|
| 20 | + | paused: bool, |
|
| 21 | + | session: Option<String>, |
|
| 22 | + | state: Option<&'static str>, |
|
| 23 | + | } |
|
| 24 | + | ||
| 25 | + | /// Every agent's activity, and the state last recorded for each. |
|
| 26 | + | #[derive(Debug, Default)] |
|
| 27 | + | pub struct Presence { |
|
| 28 | + | agents: HashMap<String, Activity>, |
|
| 29 | + | } |
|
| 30 | + | ||
| 31 | + | impl Presence { |
|
| 32 | + | /// A request from `agent`: returns its session id, and a |
|
| 33 | + | /// `agent.session_started` event when this begins a new one. |
|
| 34 | + | pub fn request(&mut self, agent: &str, now: u64, new_session: bool) -> (String, Option<NewEvent>) { |
|
| 35 | + | let a = self.agents.entry(agent.to_string()).or_default(); |
|
| 36 | + | let lapsed = a.last_request_ms.is_none_or(|t| now.saturating_sub(t) >= IDLE_AFTER_MS); |
|
| 37 | + | a.last_request_ms = Some(now); |
|
| 38 | + | if a.session.is_some() && !lapsed && !new_session { |
|
| 39 | + | return (a.session.clone().unwrap_or_default(), None); |
|
| 40 | + | } |
|
| 41 | + | let id = crate::ids::ulid(); |
|
| 42 | + | a.session = Some(id.clone()); |
|
| 43 | + | let e = NewEvent::system("agent.session_started", serde_json::json!({ "agent": agent, "session": id })) |
|
| 44 | + | .subject(agent) |
|
| 45 | + | .session(id.clone()); |
|
| 46 | + | (id, Some(e)) |
|
| 47 | + | } |
|
| 48 | + | pub fn wait_started(&mut self, agent: &str) { |
|
| 49 | + | self.agents.entry(agent.to_string()).or_default().waiting += 1; |
|
| 50 | + | } |
|
| 51 | + | pub fn wait_ended(&mut self, agent: &str) { |
|
| 52 | + | let a = self.agents.entry(agent.to_string()).or_default(); |
|
| 53 | + | a.waiting = a.waiting.saturating_sub(1); |
|
| 54 | + | } |
|
| 55 | + | pub fn shim_connected(&mut self, agent: &str) { |
|
| 56 | + | self.agents.entry(agent.to_string()).or_default().shims += 1; |
|
| 57 | + | } |
|
| 58 | + | pub fn shim_gone(&mut self, agent: &str) { |
|
| 59 | + | let a = self.agents.entry(agent.to_string()).or_default(); |
|
| 60 | + | a.shims = a.shims.saturating_sub(1); |
|
| 61 | + | } |
|
| 62 | + | /// The agent set its status (the `status` tool); `None` clears it. |
|
| 63 | + | pub fn status(&mut self, agent: &str, at: Option<u64>) { |
|
| 64 | + | self.agents.entry(agent.to_string()).or_default().status_ms = at; |
|
| 65 | + | } |
|
| 66 | + | pub fn blocked(&mut self, agent: &str, blocked: bool) { |
|
| 67 | + | self.agents.entry(agent.to_string()).or_default().blocked = blocked; |
|
| 68 | + | } |
|
| 69 | + | pub fn paused(&mut self, agent: &str, paused: bool) { |
|
| 70 | + | self.agents.entry(agent.to_string()).or_default().paused = paused; |
|
| 71 | + | } |
|
| 72 | + | ||
| 73 | + | /// The state `agent` is in at `now`. |
|
| 74 | + | pub fn state(&self, agent: &str, now: u64) -> &'static str { |
|
| 75 | + | self.agents.get(agent).map(|a| compute(a, now)).unwrap_or("offline") |
|
| 76 | + | } |
|
| 77 | + | ||
| 78 | + | /// Events for every change since the last call: `agent.presence` for a |
|
| 79 | + | /// new state, `agent.session_ended` for a session gone quiet. |
|
| 80 | + | pub fn tick(&mut self, now: u64) -> Vec<NewEvent> { |
|
| 81 | + | let mut out = Vec::new(); |
|
| 82 | + | let mut names: Vec<&String> = self.agents.keys().collect(); |
|
| 83 | + | names.sort(); |
|
| 84 | + | let names: Vec<String> = names.into_iter().cloned().collect(); |
|
| 85 | + | for name in names { |
|
| 86 | + | let a = self.agents.get_mut(&name).expect("listed"); |
|
| 87 | + | let s = compute(a, now); |
|
| 88 | + | if a.state != Some(s) { |
|
| 89 | + | let from = a.state.unwrap_or("offline"); |
|
| 90 | + | a.state = Some(s); |
|
| 91 | + | if from != s { |
|
| 92 | + | out.push( |
|
| 93 | + | NewEvent::system("agent.presence", serde_json::json!({ "agent": name, "state": s, "from": from })) |
|
| 94 | + | .subject(name.clone()), |
|
| 95 | + | ); |
|
| 96 | + | } |
|
| 97 | + | } |
|
| 98 | + | let quiet = a.last_request_ms.is_none_or(|t| now.saturating_sub(t) >= IDLE_AFTER_MS); |
|
| 99 | + | if quiet && a.waiting == 0 { |
|
| 100 | + | if let Some(id) = a.session.take() { |
|
| 101 | + | out.push( |
|
| 102 | + | NewEvent::system("agent.session_ended", serde_json::json!({ "agent": name, "session": id })) |
|
| 103 | + | .subject(name.clone()) |
|
| 104 | + | .session(id), |
|
| 105 | + | ); |
|
| 106 | + | } |
|
| 107 | + | } |
|
| 108 | + | } |
|
| 109 | + | out |
|
| 110 | + | } |
|
| 111 | + | } |
|
| 112 | + | ||
| 113 | + | /// Paused, then blocked, then busy, then online, then idle, then offline. |
|
| 114 | + | fn compute(a: &Activity, now: u64) -> &'static str { |
|
| 115 | + | let within = |t: Option<u64>, ms: u64| t.is_some_and(|t| now.saturating_sub(t) < ms); |
|
| 116 | + | if a.paused { |
|
| 117 | + | "paused" |
|
| 118 | + | } else if a.blocked { |
|
| 119 | + | "blocked" |
|
| 120 | + | } else if within(a.status_ms, IDLE_AFTER_MS) { |
|
| 121 | + | "busy" |
|
| 122 | + | } else if a.waiting > 0 || within(a.last_request_ms, ONLINE_MS) { |
|
| 123 | + | "online" |
|
| 124 | + | } else if a.shims > 0 || within(a.last_request_ms, IDLE_AFTER_MS) { |
|
| 125 | + | "idle" |
|
| 126 | + | } else { |
|
| 127 | + | "offline" |
|
| 128 | + | } |
|
| 129 | + | } |
|
| 130 | + | ||
| 131 | + | /// Apply presence, status and session events to their tables. |
|
| 132 | + | pub async fn project(conn: &mut sqlx::SqliteConnection, e: &crate::event::Event) -> sqlx::Result<()> { |
|
| 133 | + | let p = e.json(); |
|
| 134 | + | let s = |k: &str| p.get(k).and_then(|v| v.as_str()).map(String::from); |
|
| 135 | + | // A projection skips what it cannot use: a malformed payload must never |
|
| 136 | + | // stop the ledger from taking events. |
|
| 137 | + | match e.kind.as_str() { |
|
| 138 | + | "agent.presence" if s("agent").is_some() && s("state").is_some() => { |
|
| 139 | + | sqlx::query("INSERT INTO presence (agent, state, since_ms) VALUES (?, ?, ?) ON CONFLICT(agent) DO UPDATE SET state = excluded.state, since_ms = excluded.since_ms") |
|
| 140 | + | .bind(s("agent")) |
|
| 141 | + | .bind(s("state")) |
|
| 142 | + | .bind(e.at_ms as i64) |
|
| 143 | + | .execute(&mut *conn) |
|
| 144 | + | .await?; |
|
| 145 | + | } |
|
| 146 | + | "agent.status" if e.actor.starts_with("agent:") => { |
|
| 147 | + | let agent = e.actor.strip_prefix("agent:").map(String::from); |
|
| 148 | + | sqlx::query( |
|
| 149 | + | "INSERT INTO presence (agent, state, since_ms, status_state, status_text, status_task, status_ms) VALUES (?, 'online', ?, ?, ?, ?, ?) \ |
|
| 150 | + | ON CONFLICT(agent) DO UPDATE SET status_state = excluded.status_state, status_text = excluded.status_text, status_task = excluded.status_task, status_ms = excluded.status_ms", |
|
| 151 | + | ) |
|
| 152 | + | .bind(agent) |
|
| 153 | + | .bind(e.at_ms as i64) |
|
| 154 | + | .bind(s("state")) |
|
| 155 | + | .bind(s("text")) |
|
| 156 | + | .bind(s("task")) |
|
| 157 | + | .bind(e.at_ms as i64) |
|
| 158 | + | .execute(&mut *conn) |
|
| 159 | + | .await?; |
|
| 160 | + | } |
|
| 161 | + | "agent.session_started" if s("agent").is_some() && s("session").is_some() => { |
|
| 162 | + | sqlx::query("INSERT OR IGNORE INTO sessions (id, agent, started_ms) VALUES (?, ?, ?)") |
|
| 163 | + | .bind(s("session")) |
|
| 164 | + | .bind(s("agent")) |
|
| 165 | + | .bind(e.at_ms as i64) |
|
| 166 | + | .execute(&mut *conn) |
|
| 167 | + | .await?; |
|
| 168 | + | } |
|
| 169 | + | "agent.session_ended" => { |
|
| 170 | + | sqlx::query("UPDATE sessions SET ended_ms = ? WHERE id = ?") |
|
| 171 | + | .bind(e.at_ms as i64) |
|
| 172 | + | .bind(s("session")) |
|
| 173 | + | .execute(&mut *conn) |
|
| 174 | + | .await?; |
|
| 175 | + | } |
|
| 176 | + | _ => {} |
|
| 177 | + | } |
|
| 178 | + | Ok(()) |
|
| 179 | + | } |
|
| 180 | + | ||
| 181 | + | #[cfg(test)] |
|
| 182 | + | mod tests { |
|
| 183 | + | use super::*; |
|
| 184 | + | ||
| 185 | + | fn kinds(v: &[NewEvent]) -> Vec<String> { |
|
| 186 | + | v.iter().map(|e| format!("{} {}", e.kind, e.payload.get("state").and_then(|s| s.as_str()).unwrap_or(""))).collect() |
|
| 187 | + | } |
|
| 188 | + | ||
| 189 | + | #[test] |
|
| 190 | + | fn presence_moves_through_its_states_and_records_each_change() { |
|
| 191 | + | let mut p = Presence::default(); |
|
| 192 | + | let t = 1_000_000; |
|
| 193 | + | let (s1, started) = p.request("claude", t, false); |
|
| 194 | + | assert!(started.is_some()); |
|
| 195 | + | assert_eq!(kinds(&p.tick(t)), ["agent.presence online"]); |
|
| 196 | + | assert!(p.tick(t + 1).is_empty(), "no change, no event"); |
|
| 197 | + | assert_eq!(p.state("claude", t + ONLINE_MS + 1), "idle"); |
|
| 198 | + | assert_eq!(kinds(&p.tick(t + ONLINE_MS + 1)), ["agent.presence idle"]); |
|
| 199 | + | p.status("claude", Some(t + ONLINE_MS + 2)); |
|
| 200 | + | assert_eq!(p.state("claude", t + ONLINE_MS + 3), "busy"); |
|
| 201 | + | p.blocked("claude", true); |
|
| 202 | + | assert_eq!(p.state("claude", t + ONLINE_MS + 3), "blocked"); |
|
| 203 | + | p.blocked("claude", false); |
|
| 204 | + | p.status("claude", None); |
|
| 205 | + | p.paused("claude", true); |
|
| 206 | + | assert_eq!(p.state("claude", t + ONLINE_MS + 3), "paused"); |
|
| 207 | + | p.paused("claude", false); |
|
| 208 | + | let late = t + IDLE_AFTER_MS + 5; |
|
| 209 | + | let ev = p.tick(late); |
|
| 210 | + | assert_eq!(kinds(&ev), ["agent.presence offline", "agent.session_ended "]); |
|
| 211 | + | let (s2, started) = p.request("claude", late + 1, false); |
|
| 212 | + | assert!(started.is_some() && s2 != s1, "a new session after ten minutes' silence"); |
|
| 213 | + | let (s3, none) = p.request("claude", late + 2, false); |
|
| 214 | + | assert!(none.is_none() && s3 == s2); |
|
| 215 | + | let (s4, again) = p.request("claude", late + 3, true); |
|
| 216 | + | assert!(again.is_some() && s4 != s3, "brief(new_session: true)"); |
|
| 217 | + | } |
|
| 218 | + | ||
| 219 | + | #[test] |
|
| 220 | + | fn a_waiting_or_connected_agent_is_not_offline() { |
|
| 221 | + | let mut p = Presence::default(); |
|
| 222 | + | p.request("kimi", 0, false); |
|
| 223 | + | p.wait_started("kimi"); |
|
| 224 | + | assert_eq!(p.state("kimi", IDLE_AFTER_MS * 3), "online"); |
|
| 225 | + | p.wait_ended("kimi"); |
|
| 226 | + | p.shim_connected("kimi"); |
|
| 227 | + | assert_eq!(p.state("kimi", IDLE_AFTER_MS * 3), "idle"); |
|
| 228 | + | p.shim_gone("kimi"); |
|
| 229 | + | assert_eq!(p.state("kimi", IDLE_AFTER_MS * 3), "offline"); |
|
| 230 | + | } |
|
| 231 | + | } |
added crates/spectra-core/src/projects.rs +127 −0
| 1 | + | //! Projects (spec §23.1): checkouts the agents work in, with the |
|
| 2 | + | //! repository id and remote read from `.oxy/`, and dependencies read from |
|
| 3 | + | //! `.oxymodules`, matched to other registered projects by remote URL. |
|
| 4 | + | ||
| 5 | + | use crate::event::Event; |
|
| 6 | + | use anyhow::{Context, Result}; |
|
| 7 | + | use serde_json::Value as Json; |
|
| 8 | + | use sqlx::SqliteConnection; |
|
| 9 | + | use std::path::Path; |
|
| 10 | + | ||
| 11 | + | /// What a checkout says about itself. |
|
| 12 | + | #[derive(Clone, Debug, Default, PartialEq, Eq)] |
|
| 13 | + | pub struct Checkout { |
|
| 14 | + | pub repo_id: Option<String>, |
|
| 15 | + | pub remote: Option<String>, |
|
| 16 | + | /// The URLs of the subrepos it pins (`.oxymodules`). |
|
| 17 | + | pub modules: Vec<String>, |
|
| 18 | + | } |
|
| 19 | + | ||
| 20 | + | /// Read `.oxy/id`, the remote (`origin`, else the first) and `.oxymodules`. |
|
| 21 | + | pub fn inspect(path: &Path) -> Result<Checkout> { |
|
| 22 | + | if !path.is_dir() { |
|
| 23 | + | anyhow::bail!("{} is not a directory", path.display()); |
|
| 24 | + | } |
|
| 25 | + | let oxy = path.join(".oxy"); |
|
| 26 | + | let repo_id = std::fs::read_to_string(oxy.join("id")).ok().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); |
|
| 27 | + | let mut remotes: Vec<(String, String)> = Vec::new(); |
|
| 28 | + | if let Ok(text) = std::fs::read_to_string(oxy.join("config")) { |
|
| 29 | + | for line in text.lines() { |
|
| 30 | + | if let Some((k, v)) = line.split_once('=') { |
|
| 31 | + | let k = k.trim(); |
|
| 32 | + | if let Some(name) = k.strip_prefix("remote.").and_then(|r| r.strip_suffix(".url")) { |
|
| 33 | + | remotes.push((name.to_string(), v.trim().trim_matches('"').to_string())); |
|
| 34 | + | } |
|
| 35 | + | } |
|
| 36 | + | } |
|
| 37 | + | } |
|
| 38 | + | let remote = remotes.iter().find(|(n, _)| n == "origin").or(remotes.first()).map(|(_, u)| u.clone()); |
|
| 39 | + | let modules = std::fs::read_to_string(path.join(".oxymodules")) |
|
| 40 | + | .map(|t| { |
|
| 41 | + | t.lines() |
|
| 42 | + | .filter_map(|l| l.trim().strip_prefix("url").map(|r| r.trim_start().trim_start_matches('=').trim().to_string())) |
|
| 43 | + | .collect() |
|
| 44 | + | }) |
|
| 45 | + | .unwrap_or_default(); |
|
| 46 | + | Ok(Checkout { repo_id, remote, modules }) |
|
| 47 | + | } |
|
| 48 | + | ||
| 49 | + | /// A project's name: the directory's, lower-cased, made valid. |
|
| 50 | + | pub fn default_name(path: &Path) -> Result<String> { |
|
| 51 | + | let base = path.canonicalize()?.file_name().and_then(|n| n.to_str()).map(str::to_lowercase).context("the path has no name")?; |
|
| 52 | + | Ok(base.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' }).collect()) |
|
| 53 | + | } |
|
| 54 | + | ||
| 55 | + | fn same_repo(a: &str, b: &str) -> bool { |
|
| 56 | + | let n = |s: &str| s.trim_end_matches('/').trim_end_matches(".git").to_lowercase(); |
|
| 57 | + | n(a) == n(b) |
|
| 58 | + | } |
|
| 59 | + | ||
| 60 | + | /// Apply `project.added` to the tables, deriving relations both ways. |
|
| 61 | + | pub async fn project(conn: &mut SqliteConnection, e: &Event) -> sqlx::Result<()> { |
|
| 62 | + | if e.kind != "project.added" { |
|
| 63 | + | return Ok(()); |
|
| 64 | + | } |
|
| 65 | + | let p = e.json(); |
|
| 66 | + | let s = |k: &str| p.get(k).and_then(Json::as_str).map(String::from); |
|
| 67 | + | let Some(name) = s("name") else { return Ok(()) }; |
|
| 68 | + | let modules: Vec<String> = p |
|
| 69 | + | .get("modules") |
|
| 70 | + | .and_then(Json::as_array) |
|
| 71 | + | .map(|a| a.iter().filter_map(|m| m.as_str().map(String::from)).collect()) |
|
| 72 | + | .unwrap_or_default(); |
|
| 73 | + | let remote = s("remote"); |
|
| 74 | + | sqlx::query("INSERT OR REPLACE INTO projects (name, path, repo_id, remote, modules, seq) VALUES (?, ?, ?, ?, ?, ?)") |
|
| 75 | + | .bind(&name) |
|
| 76 | + | .bind(s("path").unwrap_or_default()) |
|
| 77 | + | .bind(s("repo_id")) |
|
| 78 | + | .bind(&remote) |
|
| 79 | + | .bind(serde_json::to_string(&modules).unwrap_or_default()) |
|
| 80 | + | .bind(e.seq as i64) |
|
| 81 | + | .execute(&mut *conn) |
|
| 82 | + | .await?; |
|
| 83 | + | let others: Vec<(String, Option<String>, String)> = |
|
| 84 | + | sqlx::query_as("SELECT name, remote, modules FROM projects WHERE name != ?").bind(&name).fetch_all(&mut *conn).await?; |
|
| 85 | + | for (other, other_remote, other_modules) in others { |
|
| 86 | + | if other_remote.as_deref().is_some_and(|r| modules.iter().any(|m| same_repo(m, r))) { |
|
| 87 | + | sqlx::query("INSERT OR IGNORE INTO project_relations (project, depends_on) VALUES (?, ?)") |
|
| 88 | + | .bind(&name) |
|
| 89 | + | .bind(&other) |
|
| 90 | + | .execute(&mut *conn) |
|
| 91 | + | .await?; |
|
| 92 | + | } |
|
| 93 | + | let theirs: Vec<String> = serde_json::from_str(&other_modules).unwrap_or_default(); |
|
| 94 | + | if remote.as_deref().is_some_and(|r| theirs.iter().any(|m| same_repo(m, r))) { |
|
| 95 | + | sqlx::query("INSERT OR IGNORE INTO project_relations (project, depends_on) VALUES (?, ?)") |
|
| 96 | + | .bind(&other) |
|
| 97 | + | .bind(&name) |
|
| 98 | + | .execute(&mut *conn) |
|
| 99 | + | .await?; |
|
| 100 | + | } |
|
| 101 | + | } |
|
| 102 | + | Ok(()) |
|
| 103 | + | } |
|
| 104 | + | ||
| 105 | + | #[cfg(test)] |
|
| 106 | + | mod tests { |
|
| 107 | + | use super::*; |
|
| 108 | + | ||
| 109 | + | #[test] |
|
| 110 | + | fn a_checkout_reports_its_id_remote_and_subrepos() { |
|
| 111 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 112 | + | std::fs::create_dir_all(dir.path().join(".oxy")).unwrap(); |
|
| 113 | + | std::fs::write(dir.path().join(".oxy/id"), "97e6").unwrap(); |
|
| 114 | + | std::fs::write( |
|
| 115 | + | dir.path().join(".oxy/config"), |
|
| 116 | + | "remote.earth.url = \"https://hub/a/hub\"\nremote.origin.url = \"https://hub/a/x\"\n", |
|
| 117 | + | ) |
|
| 118 | + | .unwrap(); |
|
| 119 | + | std::fs::write(dir.path().join(".oxymodules"), "[deps/flow]\nurl = https://hub/a/flow\nbookmark = main\n").unwrap(); |
|
| 120 | + | let c = inspect(dir.path()).unwrap(); |
|
| 121 | + | assert_eq!( |
|
| 122 | + | c, |
|
| 123 | + | Checkout { repo_id: Some("97e6".into()), remote: Some("https://hub/a/x".into()), modules: vec!["https://hub/a/flow".into()] } |
|
| 124 | + | ); |
|
| 125 | + | assert!(same_repo("https://hub/a/flow/", "https://HUB/a/flow.git")); |
|
| 126 | + | } |
|
| 127 | + | } |
added src/cli/project.rs +109 −0
| 1 | + | //! `spectra project`: the checkouts agents work in (spec §23.1). |
|
| 2 | + | ||
| 3 | + | use super::identity::{owner_home, refresh_owner}; |
|
| 4 | + | use super::{ui, Ctx}; |
|
| 5 | + | use anyhow::{bail, Context, Result}; |
|
| 6 | + | use spectra_core::keys::KeyPair; |
|
| 7 | + | use spectra_core::ledger::Ledger; |
|
| 8 | + | use spectra_core::principals::signed_event; |
|
| 9 | + | use spectra_core::projects; |
|
| 10 | + | use std::path::{Path, PathBuf}; |
|
| 11 | + | ||
| 12 | + | pub async fn add(ctx: &Ctx, path: &Path, name: Option<String>, remote: Option<String>, owner_home_flag: Option<PathBuf>) -> Result<i32> { |
|
| 13 | + | let path = path.canonicalize().with_context(|| format!("{} does not exist", path.display()))?; |
|
| 14 | + | let name = match name { |
|
| 15 | + | Some(n) => n, |
|
| 16 | + | None => projects::default_name(&path)?, |
|
| 17 | + | }; |
|
| 18 | + | if !super::agent::valid_name(&name) { |
|
| 19 | + | bail!("a project's name is lower-case letters, digits and dashes (--name)"); |
|
| 20 | + | } |
|
| 21 | + | let checkout = projects::inspect(&path)?; |
|
| 22 | + | let ledger = ctx.writer().await?; |
|
| 23 | + | let exists: Option<String> = sqlx::query_scalar("SELECT name FROM projects WHERE name = ? OR path = ?") |
|
| 24 | + | .bind(&name) |
|
| 25 | + | .bind(path.display().to_string()) |
|
| 26 | + | .fetch_optional(ledger.pool()) |
|
| 27 | + | .await?; |
|
| 28 | + | if let Some(e) = exists { |
|
| 29 | + | bail!("{} is already a project ({e})", path.display()); |
|
| 30 | + | } |
|
| 31 | + | let home = owner_home(ctx, owner_home_flag); |
|
| 32 | + | refresh_owner(&ledger, &home).await?; |
|
| 33 | + | let owner = KeyPair::load_home(&home)?; |
|
| 34 | + | let remote = remote.or(checkout.remote.clone()); |
|
| 35 | + | let payload = serde_json::json!({ |
|
| 36 | + | "name": name, "path": path.display().to_string(), "repo_id": checkout.repo_id, "remote": remote, "modules": checkout.modules, |
|
| 37 | + | }); |
|
| 38 | + | ledger.append_one(signed_event(&owner, "human", "project.added", payload).project(name.clone()).subject(name.clone())).await?; |
|
| 39 | + | ledger.checkpoint().await?; |
|
| 40 | + | println!("{} {} {}", ui::done("Added"), ui::subject(&name), ui::path(path.display())); |
|
| 41 | + | if let Some(r) = &remote { |
|
| 42 | + | println!("{}", ui::kv("remote", r)); |
|
| 43 | + | } |
|
| 44 | + | if let Some(id) = &checkout.repo_id { |
|
| 45 | + | println!("{}", ui::kv("repo", ui::hash(id))); |
|
| 46 | + | } |
|
| 47 | + | let (deps, users) = relations(&ledger, &name).await?; |
|
| 48 | + | if !deps.is_empty() { |
|
| 49 | + | println!("{}", ui::kv("pins", deps.join(", "))); |
|
| 50 | + | } |
|
| 51 | + | if !users.is_empty() { |
|
| 52 | + | println!("{}", ui::kv("pinned by", users.join(", "))); |
|
| 53 | + | } |
|
| 54 | + | if checkout.repo_id.is_none() { |
|
| 55 | + | println!(" {} {}", ui::note(), ui::dim("not an Oxygen checkout: relations come from .oxymodules, so it has none")); |
|
| 56 | + | } |
|
| 57 | + | Ok(0) |
|
| 58 | + | } |
|
| 59 | + | ||
| 60 | + | async fn relations(ledger: &Ledger, name: &str) -> Result<(Vec<String>, Vec<String>)> { |
|
| 61 | + | let deps = sqlx::query_scalar("SELECT depends_on FROM project_relations WHERE project = ? ORDER BY 1") |
|
| 62 | + | .bind(name) |
|
| 63 | + | .fetch_all(ledger.pool()) |
|
| 64 | + | .await?; |
|
| 65 | + | let users = sqlx::query_scalar("SELECT project FROM project_relations WHERE depends_on = ? ORDER BY 1") |
|
| 66 | + | .bind(name) |
|
| 67 | + | .fetch_all(ledger.pool()) |
|
| 68 | + | .await?; |
|
| 69 | + | Ok((deps, users)) |
|
| 70 | + | } |
|
| 71 | + | ||
| 72 | + | pub async fn list(ctx: &Ctx, json: bool) -> Result<i32> { |
|
| 73 | + | let ledger = Ledger::open_read_only(&ctx.db()?).await?; |
|
| 74 | + | let rows: Vec<(String, String, Option<String>)> = |
|
| 75 | + | sqlx::query_as("SELECT name, path, remote FROM projects ORDER BY name").fetch_all(ledger.pool()).await?; |
|
| 76 | + | let mut out = Vec::new(); |
|
| 77 | + | for (name, path, remote) in rows { |
|
| 78 | + | let (deps, users) = relations(&ledger, &name).await?; |
|
| 79 | + | let agents: Vec<String> = sqlx::query_scalar("SELECT name FROM agents WHERE project = ? AND disabled_ms IS NULL ORDER BY name") |
|
| 80 | + | .bind(&name) |
|
| 81 | + | .fetch_all(ledger.pool()) |
|
| 82 | + | .await?; |
|
| 83 | + | out.push(serde_json::json!({ "name": name, "path": path, "remote": remote, "depends_on": deps, "depended_on_by": users, "agents": agents })); |
|
| 84 | + | } |
|
| 85 | + | if json { |
|
| 86 | + | println!("{}", serde_json::Value::Array(out)); |
|
| 87 | + | return Ok(0); |
|
| 88 | + | } |
|
| 89 | + | if out.is_empty() { |
|
| 90 | + | println!("{} {}", ui::note(), ui::dim("no projects yet: spectra project add PATH")); |
|
| 91 | + | } |
|
| 92 | + | for p in out { |
|
| 93 | + | let list = |
|
| 94 | + | |k: &str| p[k].as_array().map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(", ")).unwrap_or_default(); |
|
| 95 | + | println!( |
|
| 96 | + | "{} {} {}", |
|
| 97 | + | ui::glyph("▸", ">"), |
|
| 98 | + | ui::bookmark(p["name"].as_str().unwrap_or("")), |
|
| 99 | + | ui::path(p["path"].as_str().unwrap_or("")) |
|
| 100 | + | ); |
|
| 101 | + | for (k, label) in [("depends_on", "pins"), ("depended_on_by", "pinned by"), ("agents", "agents")] { |
|
| 102 | + | let v = list(k); |
|
| 103 | + | if !v.is_empty() { |
|
| 104 | + | println!("{}", ui::kv(label, v)); |
|
| 105 | + | } |
|
| 106 | + | } |
|
| 107 | + | } |
|
| 108 | + | Ok(0) |
|
| 109 | + | } |
added tests/cli_project.rs +83 −0
| 1 | + | //! Projects from the command line (spec §23.1): relations come from |
|
| 2 | + | //! `.oxymodules`, matched by remote, whichever is registered first; an agent |
|
| 3 | + | //! needs a registered project. |
|
| 4 | + | ||
| 5 | + | use spectra_core::keys::KeyPair; |
|
| 6 | + | use std::path::Path; |
|
| 7 | + | use std::process::{Command, Output}; |
|
| 8 | + | ||
| 9 | + | fn hermetic(dir: &Path) -> Command { |
|
| 10 | + | let mut c = Command::new(env!("CARGO_BIN_EXE_spectra")); |
|
| 11 | + | c.env("SPECTRA_DATA", dir.join("data")) |
|
| 12 | + | .env("OXY_HOME", dir.join("owner")) |
|
| 13 | + | .env("HOME", dir) |
|
| 14 | + | .env("XDG_DATA_HOME", dir.join("xdg")) |
|
| 15 | + | .env("NO_COLOR", "1"); |
|
| 16 | + | c |
|
| 17 | + | } |
|
| 18 | + | ||
| 19 | + | fn run(dir: &Path, args: &[&str]) -> Output { |
|
| 20 | + | hermetic(dir).args(args).output().unwrap() |
|
| 21 | + | } |
|
| 22 | + | ||
| 23 | + | fn text(o: &Output) -> String { |
|
| 24 | + | format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)) |
|
| 25 | + | } |
|
| 26 | + | ||
| 27 | + | /// A checkout as `oxy` leaves one: an id, a remote, and the subrepos it pins. |
|
| 28 | + | fn checkout(at: &Path, remote: &str, pins: &[&str]) { |
|
| 29 | + | std::fs::create_dir_all(at.join(".oxy")).unwrap(); |
|
| 30 | + | std::fs::write(at.join(".oxy/id"), "0123456789abcdef0123456789abcdef").unwrap(); |
|
| 31 | + | std::fs::write(at.join(".oxy/config"), format!("remote.origin.url = \"{remote}\"\n")).unwrap(); |
|
| 32 | + | let modules: String = pins.iter().enumerate().map(|(i, u)| format!("[deps/{i}]\nurl = {u}\nbookmark = main\n")).collect(); |
|
| 33 | + | std::fs::write(at.join(".oxymodules"), modules).unwrap(); |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | fn setup() -> tempfile::TempDir { |
|
| 37 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 38 | + | KeyPair::create_home(&dir.path().join("owner"), "Andy McCall", "").unwrap(); |
|
| 39 | + | assert!(run(dir.path(), &["init"]).status.success()); |
|
| 40 | + | dir |
|
| 41 | + | } |
|
| 42 | + | ||
| 43 | + | fn projects(d: &Path) -> serde_json::Value { |
|
| 44 | + | serde_json::from_slice(&run(d, &["project", "list", "--json"]).stdout).unwrap() |
|
| 45 | + | } |
|
| 46 | + | ||
| 47 | + | #[test] |
|
| 48 | + | fn relations_are_found_whichever_project_comes_first() { |
|
| 49 | + | for hub_first in [false, true] { |
|
| 50 | + | let dir = setup(); |
|
| 51 | + | let d = dir.path(); |
|
| 52 | + | checkout(&d.join("oxygen"), "https://hub.example/andy/oxygen", &[]); |
|
| 53 | + | checkout(&d.join("oxygenhub"), "https://hub.example/andy/oxygenhub", &["https://hub.example/andy/oxygen"]); |
|
| 54 | + | let order = if hub_first { ["oxygenhub", "oxygen"] } else { ["oxygen", "oxygenhub"] }; |
|
| 55 | + | for p in order { |
|
| 56 | + | let o = run(d, &["project", "add", d.join(p).to_str().unwrap()]); |
|
| 57 | + | assert!(o.status.success(), "{}", text(&o)); |
|
| 58 | + | } |
|
| 59 | + | let list = projects(d); |
|
| 60 | + | let hub = list.as_array().unwrap().iter().find(|p| p["name"] == "oxygenhub").unwrap(); |
|
| 61 | + | let oxy = list.as_array().unwrap().iter().find(|p| p["name"] == "oxygen").unwrap(); |
|
| 62 | + | assert_eq!(hub["depends_on"], serde_json::json!(["oxygen"]), "hub first: {hub_first}"); |
|
| 63 | + | assert_eq!(oxy["depended_on_by"], serde_json::json!(["oxygenhub"])); |
|
| 64 | + | assert!(run(d, &["admin", "check"]).status.success()); |
|
| 65 | + | assert!(run(d, &["admin", "verify"]).status.success()); |
|
| 66 | + | } |
|
| 67 | + | } |
|
| 68 | + | ||
| 69 | + | #[test] |
|
| 70 | + | fn a_project_is_registered_once_and_an_agent_needs_one() { |
|
| 71 | + | let dir = setup(); |
|
| 72 | + | let d = dir.path(); |
|
| 73 | + | checkout(&d.join("oxygen"), "https://hub.example/andy/oxygen", &[]); |
|
| 74 | + | assert!(run(d, &["project", "add", d.join("oxygen").to_str().unwrap()]).status.success()); |
|
| 75 | + | let again = run(d, &["project", "add", d.join("oxygen").to_str().unwrap(), "--name", "other"]); |
|
| 76 | + | assert!(!again.status.success() && text(&again).contains("already a project"), "{}", text(&again)); |
|
| 77 | + | let o = run( |
|
| 78 | + | d, |
|
| 79 | + | &["agent", "add", "kimi", "--kind", "kimi-code", "--project", "nowhere", "--home", d.join("kimi").to_str().unwrap(), "--wait", "0"], |
|
| 80 | + | ); |
|
| 81 | + | assert!(!o.status.success() && text(&o).contains("no project named nowhere"), "{}", text(&o)); |
|
| 82 | + | assert!(!d.join("kimi").exists(), "no key is made for an agent that cannot be added"); |
|
| 83 | + | } |
modified CHANGELOG.md +1 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **Added: projects, presence and sessions (#5).** `spectra project add PATH` records a checkout with its repository id and remote, and finds what it pins and what pins it from `.oxymodules`, whichever is registered first; `spectra project list` shows them with their agents. An agent needs a registered project. Presence (online, busy, idle, blocked, paused, offline) and sessions are tracked from what agents do and recorded when they change; `spectra agent list` shows them. |
|
| 5 | 6 | - **Changed: the command line looks like oxy's.** Every command reports in `oxy`'s style, through `oxy`'s own `ui` module: a green tick and verb, dimmed key-value rows, section bars, hints and warnings, author names and separators, and a spinner while it works; plain ASCII when piped, with `--plain` or with `NO_COLOR`. |
|
| 6 | 7 | - **Added: identities instead of tokens (#4).** `spectra init` records the owner's Oxygen identity, proven by signing with it, and makes the instance key. `spectra agent add` makes an agent's key pair in its own home and records the agent only once the owner's identity delegates to it (`oxy identity delegate add`), so every agent is somebody's: `claude-oxygen · Andy McCall's agent`. Expiry and `spectra agent disable` stop an agent's key. `spectra admin verify` replays identities and delegations and checks every signed event, refusing an event whose actor is not its signer. Requests are authenticated by signature, a five-minute window, single-use nonces and an optional Unix-user binding. One process writes a ledger at a time. |
|
| 7 | 8 | - **Added: the ledger (#3).** Every event is deterministic CBOR (through `oxy-core`) hashed with a domain-separated BLAKE3 and chained to the one before; the envelope, with its hybrid logical clock, is frozen. The instance key signs a checkpoint every 256 events, every ten minutes while writing, and on demand. `spectra admin verify [--json]` recomputes the chain and checks every checkpoint, catching an edited, removed or re-signed event; `spectra admin check` and `rebuild` keep projections equal to the ledger. A single append takes 0.15 ms. |
|
| … | 2 unchanged lines |
modified crates/spectra-core/src/lib.rs +2 −0
| … | 10 unchanged lines |
||
| 11 | 11 | pub mod ids; |
|
| 12 | 12 | pub mod keys; |
|
| 13 | 13 | pub mod ledger; |
|
| 14 | + | pub mod presence; |
|
| 14 | 15 | pub mod principals; |
|
| 15 | 16 | pub mod projections; |
|
| 17 | + | pub mod projects; |
|
| 16 | 18 | pub mod proof; |
|
| 17 | 19 | ||
| 18 | 20 | /// The ledger's format version: the frozen event envelope (spec §9.1). A |
|
| … | 2 unchanged lines |
modified crates/spectra-core/src/principals.rs +7 −0
| … | 264 unchanged lines |
||
| 265 | 265 | ||
| 266 | 266 | /// Apply identity and agent events to the projection tables. |
|
| 267 | 267 | pub async fn project(conn: &mut SqliteConnection, e: &Event) -> sqlx::Result<()> { |
|
| 268 | + | // Who is who is only ever said by a signature; `verify` checks which. |
|
| 269 | + | if e.auth.method != Method::Signature { |
|
| 270 | + | return Ok(()); |
|
| 271 | + | } |
|
| 268 | 272 | let p = e.json(); |
|
| 269 | 273 | let id = |p: &Json| identity_of(p).ok(); |
|
| 270 | 274 | match e.kind.as_str() { |
|
| … | 15 unchanged lines |
||
| 286 | 290 | } |
|
| 287 | 291 | "agent.added" => { |
|
| 288 | 292 | let Some(identity) = id(&p) else { return Ok(()) }; |
|
| 293 | + | if p.get("name").and_then(Json::as_str).is_none_or(str::is_empty) { |
|
| 294 | + | return Ok(()); |
|
| 295 | + | } |
|
| 289 | 296 | let s = |k: &str| p.get(k).and_then(Json::as_str).unwrap_or("").to_string(); |
|
| 290 | 297 | sqlx::query( |
|
| 291 | 298 | "INSERT OR REPLACE INTO agents (name, key, owner, owner_name, kind, project, home, uid, learn_uid, scopes, created_ms, disabled_ms, seq) \ |
|
| … | 105 unchanged lines |
modified crates/spectra-core/src/projections.rs +3 −1
| … | 6 unchanged lines |
||
| 7 | 7 | use sqlx::SqliteConnection; |
|
| 8 | 8 | ||
| 9 | 9 | /// The projection tables, in the order they are cleared and compared. |
|
| 10 | - | pub const TABLES: &[&str] = &["event_counts", "identities", "agents"]; |
|
| 10 | + | pub const TABLES: &[&str] = &["event_counts", "identities", "agents", "projects", "project_relations", "presence", "sessions"]; |
|
| 11 | 11 | ||
| 12 | 12 | /// Apply one event to every projection. |
|
| 13 | 13 | pub async fn apply(conn: &mut SqliteConnection, e: &Event) -> sqlx::Result<()> { |
|
| … | 2 unchanged lines |
||
| 16 | 16 | .execute(&mut *conn) |
|
| 17 | 17 | .await?; |
|
| 18 | 18 | crate::principals::project(conn, e).await?; |
|
| 19 | + | crate::projects::project(conn, e).await?; |
|
| 20 | + | crate::presence::project(conn, e).await?; |
|
| 19 | 21 | Ok(()) |
|
| 20 | 22 | } |
modified crates/spectra-core/tests/ledger.rs +16 −1
| … | 122 unchanged lines |
||
| 123 | 123 | let dir = tempfile::tempdir().unwrap(); |
|
| 124 | 124 | let ledger = open(dir.path()).await; |
|
| 125 | 125 | let mut rng = rand::rngs::StdRng::seed_from_u64(7); |
|
| 126 | - | let kinds = ["message.posted", "agent.presence", "task.created", "claim.taken", "tool.called"]; |
|
| 126 | + | // Kinds that projections read, with payloads that are often malformed: |
|
| 127 | + | // a projection must skip what it cannot use, never refuse the append. |
|
| 128 | + | let kinds = [ |
|
| 129 | + | "message.posted", |
|
| 130 | + | "agent.presence", |
|
| 131 | + | "agent.status", |
|
| 132 | + | "agent.session_started", |
|
| 133 | + | "agent.session_ended", |
|
| 134 | + | "agent.added", |
|
| 135 | + | "agent.disabled", |
|
| 136 | + | "identity.owner", |
|
| 137 | + | "identity.rotated", |
|
| 138 | + | "project.added", |
|
| 139 | + | "task.created", |
|
| 140 | + | "tool.called", |
|
| 141 | + | ]; |
|
| 127 | 142 | let started = std::time::Instant::now(); |
|
| 128 | 143 | let mut appended = 0; |
|
| 129 | 144 | while appended < 10_000 { |
|
| … | 65 unchanged lines |
modified crates/spectra-core/tests/principals.rs +18 −0
| … | 221 unchanged lines |
||
| 222 | 222 | w.ledger.append_one(signed_event(&w.owner, "human", "agent.added", p)).await.unwrap(); |
|
| 223 | 223 | agent |
|
| 224 | 224 | } |
|
| 225 | + | ||
| 226 | + | #[tokio::test] |
|
| 227 | + | async fn an_unsigned_claim_to_be_the_owner_or_an_agent_never_reaches_the_tables() { |
|
| 228 | + | let w = world().await; |
|
| 229 | + | let fake = KeyPair::generate("mallory", ""); |
|
| 230 | + | let mut p = identity_payload(&fake.identity); |
|
| 231 | + | p["name"] = "mallory".into(); |
|
| 232 | + | p["project"] = "oxygen".into(); |
|
| 233 | + | w.ledger.append_one(NewEvent::system("agent.added", p)).await.unwrap(); |
|
| 234 | + | w.ledger.append_one(NewEvent::system("identity.rotated", identity_payload(&fake.identity))).await.unwrap(); |
|
| 235 | + | let agents: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM agents").fetch_one(w.ledger.pool()).await.unwrap(); |
|
| 236 | + | let owners: Vec<String> = |
|
| 237 | + | sqlx::query_scalar("SELECT name FROM identities WHERE role = 'owner' AND current = 1").fetch_all(w.ledger.pool()).await.unwrap(); |
|
| 238 | + | assert_eq!(agents, 0); |
|
| 239 | + | assert_eq!(owners, ["Andy McCall"]); |
|
| 240 | + | let v = w.ledger.verify(&mut Principals::default()).await.unwrap(); |
|
| 241 | + | assert_eq!(v.findings.len(), 2, "both are reported: {:?}", v.findings); |
|
| 242 | + | } |
modified docs/plan.md +2 −1
| … | 17 unchanged lines |
||
| 18 | 18 | - [x] #2 CI on OxygenFlow — done 2026-11-02: run #1 passed fmt, clippy, test, build with no image rebuild. |
|
| 19 | 19 | - [x] #3 The ledger — done 2026-11-02: chain, frozen envelope, HLC, signed checkpoints every 256 events, verify/check/rebuild; append 0.15 ms (release), 10,000 random events with rebuild and verify 1.3 s. |
|
| 20 | 20 | - [x] #4 Identities instead of tokens — done 2026-11-02: init, agent add (waits for the delegation), list, disable, identity show/refresh; Principals replay in verify; Authenticator (window, nonce, uid); write lock. Checked against the real `oxy identity delegate add`. The socket and its SO_PEERCRED arrive with #7, which uses the Authenticator. |
|
| 21 | - | - [ ] #5 Projects, presence and sessions |
|
| 21 | + | - [x] #5 Projects, presence and sessions — done 2026-11-02: project add/list with relations from .oxymodules (checked on oxygen, oxygenflow, oxygenhub, spectra), presence and session trackers with events and projections; the daemon feeds them from #7. |
|
| 22 | 22 | - [ ] #6 Channels, threads, messages, inbox and search |
|
| 23 | 23 | - [ ] #7 Tuner: MCP over Streamable HTTP |
|
| 24 | 24 | - [ ] #8 The 0.1 tools |
|
| … | 8 unchanged lines |
||
| 33 | 33 | - 2026-11-02 plan created; sprint adjusted to spec Draft 0.3 (identities and the ledger). |
|
| 34 | 34 | - 2026-11-02 #1 and #2 done (CI green on run #1); #3 done. Root disk was full: build trees go to /mnt/development/spectra-target (CARGO_TARGET_DIR). |
|
| 35 | 35 | - 2026-11-02 #4 done. Found and fixed while testing: a signed event's actor was not checked against its signer. |
|
| 36 | + | - 2026-11-02 Andy asked for an oxy-style CLI: every command now reports through oxy's ui module. #5 done. |
modified src/cli/agent.rs +36 −5
| … | 39 unchanged lines |
||
| 40 | 40 | if p.agent_key(&a.name).is_some() { |
|
| 41 | 41 | bail!("an agent named {} already exists (spectra agent list)", a.name); |
|
| 42 | 42 | } |
|
| 43 | + | let known: Option<String> = |
|
| 44 | + | sqlx::query_scalar("SELECT name FROM projects WHERE name = ?").bind(&a.project).fetch_optional(ledger.pool()).await?; |
|
| 45 | + | if known.is_none() { |
|
| 46 | + | bail!("no project named {} (spectra project add PATH --name {})", a.project, a.project); |
|
| 47 | + | } |
|
| 43 | 48 | } |
|
| 44 | 49 | let home = owner_home(ctx, a.owner_home.clone()); |
|
| 45 | 50 | // The agent's key, made once in its own home and reused if `add` is run again. |
|
| … | 56 unchanged lines |
||
| 102 | 107 | println!("{}", ui::kv("delegated", format!("{}{until}", d.scope.names().join(",")))); |
|
| 103 | 108 | println!(" {} {} {}", ui::note(), ui::dim("connect its client with"), ui::id(format!("spectra mcp --agent {}", a.name))); |
|
| 104 | 109 | Ok(0) |
|
| 110 | + | } |
|
| 111 | + | ||
| 112 | + | /// An agent as `agent list` reads it, with its presence. |
|
| 113 | + | #[derive(sqlx::FromRow)] |
|
| 114 | + | struct AgentRow { |
|
| 115 | + | name: String, |
|
| 116 | + | key: String, |
|
| 117 | + | kind: String, |
|
| 118 | + | project: String, |
|
| 119 | + | disabled_ms: Option<i64>, |
|
| 120 | + | presence: Option<String>, |
|
| 121 | + | status: Option<String>, |
|
| 105 | 122 | } |
|
| 106 | 123 | ||
| 107 | 124 | pub async fn list(ctx: &Ctx, json: bool) -> Result<i32> { |
|
| 108 | 125 | let ledger = Ledger::open_read_only(&ctx.db()?).await?; |
|
| 109 | 126 | let p = Principals::load(&ledger).await?; |
|
| 110 | - | let rows: Vec<(String, String, String, String, Option<i64>)> = |
|
| 111 | - | sqlx::query_as("SELECT name, key, kind, project, disabled_ms FROM agents ORDER BY name").fetch_all(ledger.pool()).await?; |
|
| 127 | + | let rows: Vec<AgentRow> = sqlx::query_as( |
|
| 128 | + | "SELECT a.name, a.key, a.kind, a.project, a.disabled_ms, p.state AS presence, p.status_text AS status FROM agents a LEFT JOIN presence p ON p.agent = a.name ORDER BY a.name", |
|
| 129 | + | ) |
|
| 130 | + | .fetch_all(ledger.pool()) |
|
| 131 | + | .await?; |
|
| 112 | 132 | let owner = p.owner(); |
|
| 113 | 133 | let now = now_ms(); |
|
| 114 | 134 | let mut out = Vec::new(); |
|
| 115 | - | for (name, key, kind, project, disabled) in rows { |
|
| 135 | + | for AgentRow { name, key, kind, project, disabled_ms: disabled, presence, status } in rows { |
|
| 116 | 136 | let k = oxy_core::hash::ObjectRef::from_hex(&key).context("a stored key")?; |
|
| 117 | 137 | let state = match p.principal(&k, now) { |
|
| 118 | 138 | Ok(_) => "ok".to_string(), |
|
| … | 4 unchanged lines |
||
| 123 | 143 | Ok(pr @ Principal::Agent { .. }) => pr.display(), |
|
| 124 | 144 | _ => format!("{name} · {}'s agent", owner.map(|o| o.name.as_str()).unwrap_or("?")), |
|
| 125 | 145 | }; |
|
| 126 | - | out.push(serde_json::json!({ "name": name, "display": who, "key": key, "kind": kind, "project": project, "state": state, "delegation_expires": expires, "disabled": disabled.is_some() })); |
|
| 146 | + | let presence = if disabled.is_some() { "offline".to_string() } else { presence.unwrap_or_else(|| "offline".into()) }; |
|
| 147 | + | out.push(serde_json::json!({ "name": name, "display": who, "key": key, "kind": kind, "project": project, "state": state, "presence": presence, "status": status, "delegation_expires": expires, "disabled": disabled.is_some() })); |
|
| 127 | 148 | } |
|
| 128 | 149 | if json { |
|
| 129 | 150 | println!("{}", serde_json::Value::Array(out)); |
|
| … | 7 unchanged lines |
||
| 137 | 158 | let until = a["delegation_expires"].as_u64().map(|e| format!("{}until {}", ui::sep(), super::format::when(e))).unwrap_or_default(); |
|
| 138 | 159 | let display = a["display"].as_str().unwrap_or(""); |
|
| 139 | 160 | let (name, owner) = display.split_once(" · ").unwrap_or((display, "")); |
|
| 140 | - | println!("{} {}{}{}", marks::health(state), ui::subject(name), ui::sep(), ui::author(owner)); |
|
| 161 | + | println!( |
|
| 162 | + | "{} {} {}{}{}", |
|
| 163 | + | marks::health(state), |
|
| 164 | + | marks::presence(a["presence"].as_str().unwrap_or("offline")), |
|
| 165 | + | ui::subject(name), |
|
| 166 | + | ui::sep(), |
|
| 167 | + | ui::author(owner) |
|
| 168 | + | ); |
|
| 169 | + | if let Some(st) = a["status"].as_str() { |
|
| 170 | + | println!(" {} {}", ui::note(), st); |
|
| 171 | + | } |
|
| 141 | 172 | println!( |
|
| 142 | 173 | " {}", |
|
| 143 | 174 | ui::dim(format!( |
|
| … | 35 unchanged lines |
modified src/cli/marks.rs +0 −1
| … | 4 unchanged lines |
||
| 5 | 5 | use console::style; |
|
| 6 | 6 | ||
| 7 | 7 | /// An agent's presence (spec §8.1) as a coloured glyph and word. |
|
| 8 | - | #[allow(dead_code)] // Used by agent list once presence lands (#5). |
|
| 9 | 8 | pub fn presence(state: &str) -> String { |
|
| 10 | 9 | let (g, a) = match state { |
|
| 11 | 10 | "online" => ("●", "*"), |
|
| … | 23 unchanged lines |
modified src/cli/mod.rs +33 −0
| … | 7 unchanged lines |
||
| 8 | 8 | pub mod identity; |
|
| 9 | 9 | pub mod json; |
|
| 10 | 10 | pub mod marks; |
|
| 11 | + | pub mod project; |
|
| 11 | 12 | pub mod ui; |
|
| 12 | 13 | ||
| 13 | 14 | use anyhow::{bail, Result}; |
|
| … | 36 unchanged lines |
||
| 50 | 51 | #[command(subcommand)] |
|
| 51 | 52 | command: IdentityCmd, |
|
| 52 | 53 | }, |
|
| 54 | + | /// The checkouts agents work in: add, list |
|
| 55 | + | Project { |
|
| 56 | + | #[command(subcommand)] |
|
| 57 | + | command: ProjectCmd, |
|
| 58 | + | }, |
|
| 53 | 59 | /// Agents, each its owner's delegate: add, list, disable |
|
| 54 | 60 | Agent { |
|
| 55 | 61 | #[command(subcommand)] |
|
| … | 21 unchanged lines |
||
| 77 | 83 | } |
|
| 78 | 84 | ||
| 79 | 85 | #[derive(Subcommand)] |
|
| 86 | + | pub enum ProjectCmd { |
|
| 87 | + | /// Register a checkout: its repository id, remote and pinned subrepos |
|
| 88 | + | Add { |
|
| 89 | + | path: PathBuf, |
|
| 90 | + | /// Its name (default: the directory's) |
|
| 91 | + | #[arg(long)] |
|
| 92 | + | name: Option<String>, |
|
| 93 | + | /// Its remote URL (default: the checkout's origin) |
|
| 94 | + | #[arg(long)] |
|
| 95 | + | remote: Option<String>, |
|
| 96 | + | #[arg(long)] |
|
| 97 | + | owner_home: Option<PathBuf>, |
|
| 98 | + | }, |
|
| 99 | + | /// Every project with what it pins, what pins it, and its agents |
|
| 100 | + | List { |
|
| 101 | + | #[arg(long)] |
|
| 102 | + | json: bool, |
|
| 103 | + | }, |
|
| 104 | + | } |
|
| 105 | + | ||
| 106 | + | #[derive(Subcommand)] |
|
| 80 | 107 | pub enum AgentCmd { |
|
| 81 | 108 | /// Make an agent's key in its own home and record it once you delegate to it |
|
| 82 | 109 | Add { |
|
| … | 102 unchanged lines |
||
| 185 | 212 | match command { |
|
| 186 | 213 | IdentityCmd::Show { json } => identity::show(&ctx, json).await, |
|
| 187 | 214 | IdentityCmd::Refresh { owner_home } => identity::refresh(&ctx, owner_home).await, |
|
| 215 | + | } |
|
| 216 | + | }), |
|
| 217 | + | Command::Project { command } => runtime()?.block_on(async { |
|
| 218 | + | match command { |
|
| 219 | + | ProjectCmd::Add { path, name, remote, owner_home } => project::add(&ctx, &path, name, remote, owner_home).await, |
|
| 220 | + | ProjectCmd::List { json } => project::list(&ctx, json).await, |
|
| 188 | 221 | } |
|
| 189 | 222 | }), |
|
| 190 | 223 | Command::Agent { command } => runtime()?.block_on(async { |
|
| … | 34 unchanged lines |
modified tests/cli_identity.rs +4 −0
| … | 59 unchanged lines |
||
| 60 | 60 | assert!(!run(d, &["init"]).status.success(), "a second init is refused"); |
|
| 61 | 61 | verify_clean(d); |
|
| 62 | 62 | ||
| 63 | + | let checkout = d.join("oxygen"); |
|
| 64 | + | std::fs::create_dir_all(&checkout).unwrap(); |
|
| 65 | + | let o = run(d, &["project", "add", checkout.to_str().unwrap()]); |
|
| 66 | + | assert!(o.status.success(), "{}", text(&o)); |
|
| 63 | 67 | let home = d.join("agents/claude-oxygen"); |
|
| 64 | 68 | let home_s = home.to_str().unwrap(); |
|
| 65 | 69 | let args = ["agent", "add", "claude-oxygen", "--kind", "claude-code", "--project", "oxygen", "--home", home_s, "--wait", "0"]; |
|
| … | 49 unchanged lines |