Files
clawmates/crates/cm-db/src/repo/agent_containers.rs
T
Omar SobhandClaude Opus 4.8 3554a3aaf2
ci / gates (push) Successful in 5s
ci / frontend (push) Successful in 23s
ci / rust (push) Failing after 27s
ci / e2e (push) Has been skipped
CI: remove k8s stages, fix the Docker-level pipeline green
Survey + fixes so the pipeline passes at the Docker level (no k8s).

- Remove k8s: drop the `sandbox-k8s` job (kind/Calico/--features k8s-tests) and the
  "Helm chart lints" gate step. release.yml was already k8s-clean.
- Rust job:
  - `cargo fmt --all` — fix pre-existing formatting drift (fmt --check was failing).
  - clippy -D warnings: fix 3 lib warnings (cm-brain sort_by_key→Reverse, cm-api
    fleet.rs doc list indentation, node_rules map_or→is_none_or).
  - Regenerate the .sqlx offline cache (was missing the cm-runtime run_loop test
    query → offline compile failed). DB-backed tests use testcontainers at runtime.
  - Set SQLX_OFFLINE=true on the rust + e2e jobs so query! macros compile against
    the committed cache deterministically (no DB needed at compile time).
- Frontend job:
  - Fix the 1 ESLint error (useAgentTelemetry: no setState-synchronously-in-effect;
    tag the slice with agentId + derive null on mismatch).
  - Fix 2 stale panel-params tests (`terminal` is a valid app id now; assert the
    current APP_IDS + use a genuinely-unknown id for the reject case).

Verified locally: fmt clean, clippy --all-targets -D warnings clean (offline),
frontend lint 0 errors, tsc clean, 86/86 frontend tests pass, build OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-26 18:15:31 -07:00

158 lines
4.8 KiB
Rust

//! Durable registry of the live container backing each agent, so any server
//! replica can find + reuse it (replacing the former in-process handle map).
use cm_domain::{AgentId, WorkspaceId};
use sqlx::{PgPool, Row};
use crate::DbError;
/// A registry row for one agent's container of a given kind.
#[derive(Debug, Clone)]
pub struct ContainerRow {
pub node_id: String,
pub container_id: String,
pub name: String,
pub session_count: i32,
}
/// One container to act on (idle reap / reconcile / shutdown).
#[derive(Debug, Clone)]
pub struct ManagedRow {
pub agent_id: AgentId,
pub node_id: String,
pub container_id: String,
pub name: String,
}
pub async fn get(
pool: &PgPool,
agent_id: AgentId,
kind: &str,
) -> Result<Option<ContainerRow>, DbError> {
let row = sqlx::query(
"SELECT node_id, container_id, name, session_count
FROM agent_containers WHERE agent_id = $1 AND kind = $2",
)
.bind(agent_id.as_uuid())
.bind(kind)
.fetch_optional(pool)
.await?;
Ok(row.map(|r| ContainerRow {
node_id: r.get("node_id"),
container_id: r.get("container_id"),
name: r.get("name"),
session_count: r.get("session_count"),
}))
}
/// Record the (new) container for `(agent, kind)`; resets the session count.
/// The workspace is derived from the agent row (single source of truth).
pub async fn upsert(
pool: &PgPool,
agent_id: AgentId,
kind: &str,
node_id: &str,
container_id: &str,
name: &str,
) -> Result<(), DbError> {
sqlx::query(
"INSERT INTO agent_containers
(agent_id, kind, node_id, container_id, name, workspace_id, session_count, last_seen)
SELECT $1, $2, $3, $4, $5, a.workspace_id, 0, now() FROM agents a WHERE a.id = $1
ON CONFLICT (agent_id, kind) DO UPDATE SET
node_id = excluded.node_id, container_id = excluded.container_id,
name = excluded.name, workspace_id = excluded.workspace_id,
session_count = 0, last_seen = now()",
)
.bind(agent_id.as_uuid())
.bind(kind)
.bind(node_id)
.bind(container_id)
.bind(name)
.execute(pool)
.await?;
Ok(())
}
pub async fn delete(pool: &PgPool, agent_id: AgentId, kind: &str) -> Result<(), DbError> {
sqlx::query("DELETE FROM agent_containers WHERE agent_id = $1 AND kind = $2")
.bind(agent_id.as_uuid())
.bind(kind)
.execute(pool)
.await?;
Ok(())
}
/// Adjust the live-session count (clamped at 0) and bump last_seen.
pub async fn add_session(
pool: &PgPool,
agent_id: AgentId,
kind: &str,
delta: i32,
) -> Result<(), DbError> {
sqlx::query(
"UPDATE agent_containers
SET session_count = GREATEST(0, session_count + $3), last_seen = now()
WHERE agent_id = $1 AND kind = $2",
)
.bind(agent_id.as_uuid())
.bind(kind)
.bind(delta)
.execute(pool)
.await?;
Ok(())
}
/// Zero the live-session counts for a kind (graceful shutdown: the WS sessions
/// are ending, so the containers become idle-reapable but are not destroyed).
pub async fn reset_sessions(pool: &PgPool, kind: &str) -> Result<(), DbError> {
sqlx::query("UPDATE agent_containers SET session_count = 0 WHERE kind = $1")
.bind(kind)
.execute(pool)
.await?;
Ok(())
}
/// Session-less containers untouched for longer than `idle_secs` (0 = all).
pub async fn idle(pool: &PgPool, kind: &str, idle_secs: i64) -> Result<Vec<ManagedRow>, DbError> {
let rows = sqlx::query(
"SELECT agent_id, node_id, container_id, name FROM agent_containers
WHERE kind = $1 AND session_count <= 0
AND last_seen < now() - ($2 * interval '1 second')",
)
.bind(kind)
.bind(idle_secs)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(map_managed).collect())
}
/// Every recorded container of a kind (for reconcile + shutdown).
pub async fn all(pool: &PgPool, kind: &str) -> Result<Vec<ManagedRow>, DbError> {
let rows = sqlx::query(
"SELECT agent_id, node_id, container_id, name FROM agent_containers WHERE kind = $1",
)
.bind(kind)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(map_managed).collect())
}
/// Count live containers in a workspace (any kind) — for quota checks.
pub async fn count_for_workspace(pool: &PgPool, workspace_id: WorkspaceId) -> Result<i64, DbError> {
let row = sqlx::query("SELECT count(*) AS n FROM agent_containers WHERE workspace_id = $1")
.bind(workspace_id.as_uuid())
.fetch_one(pool)
.await?;
Ok(row.get::<i64, _>("n"))
}
fn map_managed(r: sqlx::postgres::PgRow) -> ManagedRow {
ManagedRow {
agent_id: AgentId::from(r.get::<uuid::Uuid, _>("agent_id")),
node_id: r.get("node_id"),
container_id: r.get("container_id"),
name: r.get("name"),
}
}