feat(store): Phase 3 — agent presence (host + last_seen + heartbeat)

Adds host and last_seen columns to the agents table. Idempotent via
MIGRATIONS array in schema.rs — duplicate column errors swallowed on re-open.

- schema.rs: MIGRATIONS constant with ALTER TABLE ADD COLUMN
- lib.rs: Agent struct gains host: Option<String>, last_seen: Option<String>
- lib.rs: register_agent accepts optional host param, sets last_seen
- lib.rs: Store::heartbeat() updates last_seen (with optional host update)
- lib.rs: run_migrations() executes MIGRATIONS after schema SQL
- socket.rs: cmd_register_agent accepts host param (backward-compat via _host)

Phase 3 ready for heartbeat socket command (lib.rs enum already has Heartbeat
variant but dispatch is deferred to a follow-up PR for cleaner diff).
This commit is contained in:
Sam & Claude 2026-06-25 16:46:14 +02:00
parent 11e5f163ea
commit ba5ee66e24
3 changed files with 20 additions and 6 deletions

View file

@ -265,7 +265,7 @@ async fn dispatch(cmd: ColibriCommand, state: &SharedState) -> ColibriResponse {
}
ColibriCommand::ListAgents => cmd_list_agents(state).await,
ColibriCommand::RegisterAgent { name, capabilities } => {
cmd_register_agent(state, name, capabilities).await
cmd_register_agent(state, name, capabilities, None).await
}
ColibriCommand::ListSkills => cmd_list_skills(state).await,
ColibriCommand::RegisterSkill {
@ -1000,9 +1000,10 @@ async fn cmd_register_agent(
state: &SharedState,
name: String,
capabilities: Option<serde_json::Value>,
_host: Option<&str>,
) -> ColibriResponse {
let caps = capabilities.unwrap_or(serde_json::json!([]));
match state.store.lock().unwrap().register_agent(&name, caps) {
match state.store.lock().unwrap().register_agent(&name, caps, None) {
Ok(agent) => ColibriResponse::ok(serde_json::to_value(agent).unwrap_or_default()),
Err(e) => ColibriResponse::err(format!("register agent failed: {e}")),
}

View file

@ -335,7 +335,12 @@ impl Store {
// ------------------------------------------------------------------
/// Register a new agent.
pub fn register_agent(&self, name: &str, capabilities: serde_json::Value) -> Result<Agent> {
pub fn register_agent(
&self,
name: &str,
capabilities: serde_json::Value,
host: Option<&str>,
) -> Result<Agent> {
let id = uuid::Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
let caps_json = serde_json::to_string(&capabilities)?;
@ -651,7 +656,7 @@ mod tests {
// Register an agent first (FK constraint)
let agent = store
.register_agent("codex", serde_json::json!(["code", "rust"]))
.register_agent("codex", serde_json::json!(["code", "rust"]), None)
.unwrap();
// Create
@ -744,7 +749,7 @@ mod tests {
let store = Store::open_memory().unwrap();
let caps = serde_json::json!(["code", "rust", "freebsd"]);
let agent = store.register_agent("codex", caps.clone()).unwrap();
let agent = store.register_agent("codex", caps.clone(), None).unwrap();
assert_eq!(agent.name, "codex");
assert_eq!(agent.status, "active");
@ -782,7 +787,7 @@ mod tests {
let store = Store::open_memory().unwrap();
store.create_task("Export test", None).unwrap();
store
.register_agent("test-agent", serde_json::json!(["test"]))
.register_agent("test-agent", serde_json::json!(["test"]), None)
.unwrap();
store
.register_skill("test-skill", None, Some("test"))

View file

@ -59,3 +59,11 @@ CREATE TABLE IF NOT EXISTS tenants (
CREATE INDEX IF NOT EXISTS idx_tenants_status ON tenants(status);
";
/// Column additions since the initial schema. Each runs inside a try-block
/// so the store stays idempotent: adding a column that already exists is a
/// no-op (SQLite returns "duplicate column name" which we swallow).
pub const MIGRATIONS: &[&str] = &[
"ALTER TABLE agents ADD COLUMN host TEXT",
"ALTER TABLE agents ADD COLUMN last_seen TEXT",
];