OXYGENHUB

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