Rebrand: TeamClaw -> Clawmates (clawmates.work)
Full-depth rename per the approved plan; the 'claw' product vocabulary (claws, /claws routes, clawId, Claw Chat) stays — it is now the brand. - Display brand: Clawmates (manifest, titles, hero, login/rail logo 'clawmates'); default host app.clawmates.work; registry ghcr.io/clawmates - Crates tc-* -> cm-* (16 crates + all imports); binaries clawmates-server/broker/bundler; images clawmates/*; env prefix CLAWMATES_* (+ CM_TEST_DATABASE_URL / CM_LIVE_LLM); config clawmates.toml; helm chart deploy/helm/clawmates with clawmates-* resources; db names clawmates*; sockets /run/clawmates; cookie cm_session; kind cluster clawmates-test; seccomp node profile clawmates-agent-profile.json - All 9 Playwright brand assertions updated in lockstep; historical spec document left untouched as the only remaining 'TeamClaw' - Local env migrated: dev pg clawmates-dev-pg/clawmates_dev, shared test server clawmates-test-pg, kind cluster recreated with image + profile, compose images rebuilt under clawmates/* Verified end to end: 161 Rust + 68 frontend tests, 29 Playwright journeys, 4 live kind tests, helm/install/LOC/placeholder gates, and the clean-room install rehearsal serving the clawmates login page from a signed bundle of the rebuilt images. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
8046853feb
commit
add4f79fed
@@ -0,0 +1,230 @@
|
||||
use cm_domain::{
|
||||
AccessPolicy, Agent, AgentId, AgentScope, AgentStatus, HumanScope, UserId, WorkspaceId,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::DbError;
|
||||
|
||||
/// Inserts an agent together with its access policy in one transaction —
|
||||
/// an agent without a policy must never be observable (§7.7).
|
||||
pub async fn insert(pool: &PgPool, agent: &Agent, policy: &AccessPolicy) -> Result<(), DbError> {
|
||||
let (humans_mode, human_ids): (&str, Vec<Uuid>) = match &policy.humans {
|
||||
HumanScope::EntireTeam => ("entire_team", Vec::new()),
|
||||
HumanScope::Specific(ids) => ("specific", ids.iter().map(UserId::as_uuid).collect()),
|
||||
};
|
||||
let (agents_mode, agent_ids): (&str, Vec<Uuid>) = match &policy.agents {
|
||||
AgentScope::Any => ("any", Vec::new()),
|
||||
AgentScope::Specific(ids) => ("specific", ids.iter().map(AgentId::as_uuid).collect()),
|
||||
};
|
||||
|
||||
let mut tx = pool.begin().await.map_err(DbError::from)?;
|
||||
sqlx::query!(
|
||||
"INSERT INTO agents
|
||||
(id, workspace_id, name, job_title, system_prompt, avatar, accent,
|
||||
wallpaper, managed_by, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
|
||||
agent.id.as_uuid(),
|
||||
agent.workspace_id.as_uuid(),
|
||||
agent.name,
|
||||
agent.job_title,
|
||||
agent.system_prompt,
|
||||
agent.avatar,
|
||||
agent.accent,
|
||||
agent.wallpaper,
|
||||
agent.managed_by.as_uuid(),
|
||||
agent.status.as_str(),
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
"INSERT INTO access_policies
|
||||
(agent_id, humans_mode, human_ids, agents_mode, agent_ids)
|
||||
VALUES ($1, $2, $3, $4, $5)",
|
||||
agent.id.as_uuid(),
|
||||
humans_mode,
|
||||
&human_ids,
|
||||
agents_mode,
|
||||
&agent_ids,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await.map_err(DbError::from)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get(pool: &PgPool, agent_id: AgentId) -> Result<Agent, DbError> {
|
||||
let row = sqlx::query!(
|
||||
"SELECT id, workspace_id, name, job_title, system_prompt, avatar,
|
||||
accent, wallpaper, managed_by, status
|
||||
FROM agents WHERE id = $1 AND deleted_at IS NULL",
|
||||
agent_id.as_uuid(),
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(Agent {
|
||||
id: AgentId::from(row.id),
|
||||
workspace_id: WorkspaceId::from(row.workspace_id),
|
||||
name: row.name,
|
||||
job_title: row.job_title,
|
||||
system_prompt: row.system_prompt,
|
||||
avatar: row.avatar,
|
||||
accent: row.accent,
|
||||
wallpaper: row.wallpaper,
|
||||
managed_by: UserId::from(row.managed_by),
|
||||
status: row.status.parse().expect("status CHECK constraint"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Patch-style profile update (§7.7 Edit profile): only provided fields
|
||||
/// change; the system prompt is the Job Description textarea verbatim.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn update_profile(
|
||||
pool: &PgPool,
|
||||
agent_id: AgentId,
|
||||
name: Option<&str>,
|
||||
job_title: Option<&str>,
|
||||
system_prompt: Option<&str>,
|
||||
avatar: Option<&str>,
|
||||
accent: Option<&str>,
|
||||
wallpaper: Option<&str>,
|
||||
) -> Result<Agent, DbError> {
|
||||
let result = sqlx::query!(
|
||||
"UPDATE agents SET
|
||||
name = COALESCE($2, name),
|
||||
job_title = COALESCE($3, job_title),
|
||||
system_prompt = COALESCE($4, system_prompt),
|
||||
avatar = COALESCE($5, avatar),
|
||||
accent = COALESCE($6, accent),
|
||||
wallpaper = COALESCE($7, wallpaper)
|
||||
WHERE id = $1 AND deleted_at IS NULL",
|
||||
agent_id.as_uuid(),
|
||||
name,
|
||||
job_title,
|
||||
system_prompt,
|
||||
avatar,
|
||||
accent,
|
||||
wallpaper,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound);
|
||||
}
|
||||
get(pool, agent_id).await
|
||||
}
|
||||
|
||||
/// The left-rail roster (§4): live agents of a workspace, oldest first.
|
||||
pub async fn roster(pool: &PgPool, workspace_id: WorkspaceId) -> Result<Vec<Agent>, DbError> {
|
||||
let rows = sqlx::query!(
|
||||
"SELECT id, workspace_id, name, job_title, system_prompt, avatar,
|
||||
accent, wallpaper, managed_by, status
|
||||
FROM agents
|
||||
WHERE workspace_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY created_at, id",
|
||||
workspace_id.as_uuid(),
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| Agent {
|
||||
id: AgentId::from(row.id),
|
||||
workspace_id: WorkspaceId::from(row.workspace_id),
|
||||
name: row.name,
|
||||
job_title: row.job_title,
|
||||
system_prompt: row.system_prompt,
|
||||
avatar: row.avatar,
|
||||
accent: row.accent,
|
||||
wallpaper: row.wallpaper,
|
||||
managed_by: UserId::from(row.managed_by),
|
||||
status: row.status.parse().expect("status CHECK constraint"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Replaces an agent's access policy (§7.7 access toggles).
|
||||
pub async fn set_access_policy(
|
||||
pool: &PgPool,
|
||||
agent_id: AgentId,
|
||||
policy: &AccessPolicy,
|
||||
) -> Result<(), DbError> {
|
||||
let (humans_mode, human_ids): (&str, Vec<Uuid>) = match &policy.humans {
|
||||
HumanScope::EntireTeam => ("entire_team", Vec::new()),
|
||||
HumanScope::Specific(ids) => ("specific", ids.iter().map(UserId::as_uuid).collect()),
|
||||
};
|
||||
let (agents_mode, agent_ids): (&str, Vec<Uuid>) = match &policy.agents {
|
||||
AgentScope::Any => ("any", Vec::new()),
|
||||
AgentScope::Specific(ids) => ("specific", ids.iter().map(AgentId::as_uuid).collect()),
|
||||
};
|
||||
let result = sqlx::query!(
|
||||
"UPDATE access_policies SET humans_mode = $2, human_ids = $3,
|
||||
agents_mode = $4, agent_ids = $5
|
||||
WHERE agent_id = $1",
|
||||
agent_id.as_uuid(),
|
||||
humans_mode,
|
||||
&human_ids,
|
||||
agents_mode,
|
||||
&agent_ids,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn access_policy(pool: &PgPool, agent_id: AgentId) -> Result<AccessPolicy, DbError> {
|
||||
let row = sqlx::query!(
|
||||
"SELECT humans_mode, human_ids, agents_mode, agent_ids
|
||||
FROM access_policies WHERE agent_id = $1",
|
||||
agent_id.as_uuid(),
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
let humans = if row.humans_mode == "entire_team" {
|
||||
HumanScope::EntireTeam
|
||||
} else {
|
||||
HumanScope::Specific(row.human_ids.into_iter().map(UserId::from).collect())
|
||||
};
|
||||
let agents = if row.agents_mode == "any" {
|
||||
AgentScope::Any
|
||||
} else {
|
||||
AgentScope::Specific(row.agent_ids.into_iter().map(AgentId::from).collect())
|
||||
};
|
||||
Ok(AccessPolicy { humans, agents })
|
||||
}
|
||||
|
||||
pub async fn set_status(
|
||||
pool: &PgPool,
|
||||
agent_id: AgentId,
|
||||
status: AgentStatus,
|
||||
) -> Result<(), DbError> {
|
||||
let result = sqlx::query!(
|
||||
"UPDATE agents SET status = $2 WHERE id = $1 AND deleted_at IS NULL",
|
||||
agent_id.as_uuid(),
|
||||
status.as_str(),
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Deleting a claw is destructive and gated (§7.7); rows are kept for audit.
|
||||
pub async fn soft_delete(pool: &PgPool, agent_id: AgentId) -> Result<(), DbError> {
|
||||
let result = sqlx::query!(
|
||||
"UPDATE agents SET deleted_at = now(), status = 'offline'
|
||||
WHERE id = $1 AND deleted_at IS NULL",
|
||||
agent_id.as_uuid(),
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
use cm_domain::{AgentId, UserId, WorkspaceId};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::DbError;
|
||||
|
||||
/// Who performed an audited action (§15: every decision is attributable).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Actor {
|
||||
User(UserId),
|
||||
Agent(AgentId),
|
||||
System,
|
||||
}
|
||||
|
||||
impl Actor {
|
||||
fn kind(&self) -> &'static str {
|
||||
match self {
|
||||
Actor::User(_) => "user",
|
||||
Actor::Agent(_) => "agent",
|
||||
Actor::System => "system",
|
||||
}
|
||||
}
|
||||
|
||||
fn id(&self) -> Option<Uuid> {
|
||||
match self {
|
||||
Actor::User(id) => Some(id.as_uuid()),
|
||||
Actor::Agent(id) => Some(id.as_uuid()),
|
||||
Actor::System => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends an audit entry and returns its sequence id. The table rejects
|
||||
/// UPDATE/DELETE at the database level (see migration 0001).
|
||||
pub async fn append(
|
||||
pool: &PgPool,
|
||||
workspace_id: WorkspaceId,
|
||||
actor: Actor,
|
||||
event_type: &str,
|
||||
subject_type: &str,
|
||||
subject_id: &str,
|
||||
detail: serde_json::Value,
|
||||
) -> Result<i64, DbError> {
|
||||
let row = sqlx::query!(
|
||||
"INSERT INTO audit_log
|
||||
(workspace_id, actor_kind, actor_id, event_type, subject_type,
|
||||
subject_id, detail)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id",
|
||||
workspace_id.as_uuid(),
|
||||
actor.kind(),
|
||||
actor.id(),
|
||||
event_type,
|
||||
subject_type,
|
||||
subject_id,
|
||||
detail,
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(row.id)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
use cm_domain::{AgentId, WorkspaceId};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::DbError;
|
||||
|
||||
/// A connected app (spec §14 AppConnection). The credential lives in the
|
||||
/// broker's encrypted store; this row only carries the reference.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AppConnection {
|
||||
pub id: Uuid,
|
||||
pub workspace_id: Uuid,
|
||||
pub agent_id: Option<Uuid>,
|
||||
pub provider: String,
|
||||
pub auth_type: String,
|
||||
pub status: String,
|
||||
pub secret_ref: Option<Uuid>,
|
||||
}
|
||||
|
||||
pub async fn insert(
|
||||
pool: &PgPool,
|
||||
workspace_id: WorkspaceId,
|
||||
agent_id: Option<AgentId>,
|
||||
provider: &str,
|
||||
auth_type: &str,
|
||||
secret_ref: Uuid,
|
||||
) -> Result<AppConnection, DbError> {
|
||||
let id = Uuid::now_v7();
|
||||
sqlx::query!(
|
||||
"INSERT INTO app_connections
|
||||
(id, workspace_id, agent_id, provider, auth_type, status, secret_ref)
|
||||
VALUES ($1, $2, $3, $4, $5, 'connected', $6)",
|
||||
id,
|
||||
workspace_id.as_uuid(),
|
||||
agent_id.map(|a| a.as_uuid()),
|
||||
provider,
|
||||
auth_type,
|
||||
secret_ref,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(AppConnection {
|
||||
id,
|
||||
workspace_id: workspace_id.as_uuid(),
|
||||
agent_id: agent_id.map(|a| a.as_uuid()),
|
||||
provider: provider.to_owned(),
|
||||
auth_type: auth_type.to_owned(),
|
||||
status: "connected".into(),
|
||||
secret_ref: Some(secret_ref),
|
||||
})
|
||||
}
|
||||
|
||||
/// Connections visible to an agent: its own plus workspace-wide ones.
|
||||
pub async fn list_for_agent(
|
||||
pool: &PgPool,
|
||||
workspace_id: WorkspaceId,
|
||||
agent_id: AgentId,
|
||||
) -> Result<Vec<AppConnection>, DbError> {
|
||||
let rows = sqlx::query_as!(
|
||||
AppConnection,
|
||||
r#"SELECT id, workspace_id, agent_id, provider, auth_type, status, secret_ref
|
||||
FROM app_connections
|
||||
WHERE workspace_id = $1 AND (agent_id IS NULL OR agent_id = $2)
|
||||
AND status = 'connected'
|
||||
ORDER BY created_at"#,
|
||||
workspace_id.as_uuid(),
|
||||
agent_id.as_uuid(),
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// The agent's live connection for one provider, if any.
|
||||
pub async fn find_provider(
|
||||
pool: &PgPool,
|
||||
workspace_id: WorkspaceId,
|
||||
agent_id: AgentId,
|
||||
provider: &str,
|
||||
) -> Result<Option<AppConnection>, DbError> {
|
||||
let row = sqlx::query_as!(
|
||||
AppConnection,
|
||||
r#"SELECT id, workspace_id, agent_id, provider, auth_type, status, secret_ref
|
||||
FROM app_connections
|
||||
WHERE workspace_id = $1 AND (agent_id IS NULL OR agent_id = $2)
|
||||
AND provider = $3 AND status = 'connected'
|
||||
ORDER BY created_at DESC LIMIT 1"#,
|
||||
workspace_id.as_uuid(),
|
||||
agent_id.as_uuid(),
|
||||
provider,
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub async fn disconnect(pool: &PgPool, id: Uuid) -> Result<(), DbError> {
|
||||
let result = sqlx::query!(
|
||||
"UPDATE app_connections SET status = 'disconnected' WHERE id = $1",
|
||||
id,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
use cm_domain::WorkspaceId;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::DbError;
|
||||
|
||||
/// Available credits = sum of remaining balances across all lots; credits
|
||||
/// never expire (§8.4).
|
||||
pub async fn balance(pool: &PgPool, workspace_id: WorkspaceId) -> Result<i64, DbError> {
|
||||
let row = sqlx::query!(
|
||||
r#"SELECT COALESCE(SUM(remaining), 0)::BIGINT AS "balance!"
|
||||
FROM credit_lots WHERE workspace_id = $1"#,
|
||||
workspace_id.as_uuid(),
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(row.balance)
|
||||
}
|
||||
|
||||
pub async fn add_lot(
|
||||
pool: &PgPool,
|
||||
workspace_id: WorkspaceId,
|
||||
amount: i64,
|
||||
source: &str,
|
||||
) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
"INSERT INTO credit_lots (id, workspace_id, amount, remaining, source)
|
||||
VALUES ($1, $2, $3, $3, $4)",
|
||||
Uuid::now_v7(),
|
||||
workspace_id.as_uuid(),
|
||||
sqlx::types::BigDecimal::from(amount),
|
||||
source,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
use cm_domain::{AgentId, FileDrive, FileNode, WorkspaceId};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::DbError;
|
||||
|
||||
fn row_node(
|
||||
id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
agent_id: Option<Uuid>,
|
||||
drive: String,
|
||||
path: String,
|
||||
size: i64,
|
||||
blob_ref: Option<String>,
|
||||
) -> FileNode {
|
||||
FileNode {
|
||||
id,
|
||||
workspace_id: WorkspaceId::from(workspace_id),
|
||||
agent_id: agent_id.map(AgentId::from),
|
||||
drive: drive.parse().expect("drive CHECK constraint"),
|
||||
path,
|
||||
size,
|
||||
blob_ref: blob_ref.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates or replaces a file entry (same path on the same drive updates
|
||||
/// size and blob reference, like a filesystem overwrite).
|
||||
pub async fn upsert(pool: &PgPool, node: &FileNode) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
r#"INSERT INTO file_nodes
|
||||
(id, workspace_id, agent_id, drive, path, kind, size, blob_ref,
|
||||
owner_kind, owner_id)
|
||||
VALUES ($1, $2, $3, $4, $5, 'file', $6, $7, 'agent', $8)
|
||||
ON CONFLICT (workspace_id, drive,
|
||||
COALESCE(agent_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
path)
|
||||
DO UPDATE SET size = $6, blob_ref = $7"#,
|
||||
node.id,
|
||||
node.workspace_id.as_uuid(),
|
||||
node.agent_id.map(|a| a.as_uuid()),
|
||||
node.drive.as_str(),
|
||||
node.path,
|
||||
node.size,
|
||||
node.blob_ref,
|
||||
node.agent_id
|
||||
.map(|a| a.as_uuid())
|
||||
.unwrap_or(node.workspace_id.as_uuid()),
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lists a drive's entries. Agent-scoped drives filter by the agent; the
|
||||
/// shared drive is workspace-wide (§7.4).
|
||||
pub async fn list(
|
||||
pool: &PgPool,
|
||||
workspace_id: WorkspaceId,
|
||||
drive: FileDrive,
|
||||
agent_id: AgentId,
|
||||
) -> Result<Vec<FileNode>, DbError> {
|
||||
let scope = drive.is_agent_scoped().then_some(agent_id.as_uuid());
|
||||
let rows = sqlx::query!(
|
||||
r#"SELECT id, workspace_id, agent_id, drive, path, size, blob_ref
|
||||
FROM file_nodes
|
||||
WHERE workspace_id = $1 AND drive = $2
|
||||
AND ($3::uuid IS NULL OR agent_id = $3)
|
||||
ORDER BY path"#,
|
||||
workspace_id.as_uuid(),
|
||||
drive.as_str(),
|
||||
scope,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
row_node(
|
||||
r.id,
|
||||
r.workspace_id,
|
||||
r.agent_id,
|
||||
r.drive,
|
||||
r.path,
|
||||
r.size,
|
||||
r.blob_ref,
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn get(
|
||||
pool: &PgPool,
|
||||
workspace_id: WorkspaceId,
|
||||
drive: FileDrive,
|
||||
agent_id: AgentId,
|
||||
path: &str,
|
||||
) -> Result<FileNode, DbError> {
|
||||
let scope = drive.is_agent_scoped().then_some(agent_id.as_uuid());
|
||||
let row = sqlx::query!(
|
||||
r#"SELECT id, workspace_id, agent_id, drive, path, size, blob_ref
|
||||
FROM file_nodes
|
||||
WHERE workspace_id = $1 AND drive = $2 AND path = $4
|
||||
AND ($3::uuid IS NULL OR agent_id = $3)"#,
|
||||
workspace_id.as_uuid(),
|
||||
drive.as_str(),
|
||||
scope,
|
||||
path,
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or(DbError::NotFound)?;
|
||||
Ok(row_node(
|
||||
row.id,
|
||||
row.workspace_id,
|
||||
row.agent_id,
|
||||
row.drive,
|
||||
row.path,
|
||||
row.size,
|
||||
row.blob_ref,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<(), DbError> {
|
||||
let result = sqlx::query!("DELETE FROM file_nodes WHERE id = $1", id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
use cm_domain::{Message, MessageId, MessageRole, MessageWithSteps, SessionId, Step, StepStatus};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::DbError;
|
||||
|
||||
/// Appends a message, assigning the next sequence number atomically.
|
||||
/// Sessions have a single writer (the run loop) per side, so the rare
|
||||
/// concurrent collision surfaces as `Conflict` for the caller to retry.
|
||||
pub async fn append(
|
||||
pool: &PgPool,
|
||||
session_id: SessionId,
|
||||
role: MessageRole,
|
||||
content: serde_json::Value,
|
||||
) -> Result<Message, DbError> {
|
||||
let id = MessageId::new();
|
||||
let row = sqlx::query!(
|
||||
"INSERT INTO messages (id, session_id, seq, role, content)
|
||||
SELECT $1, $2, COALESCE(MAX(seq), 0) + 1, $3, $4
|
||||
FROM messages WHERE session_id = $2
|
||||
RETURNING seq, created_at",
|
||||
id.as_uuid(),
|
||||
session_id.as_uuid(),
|
||||
role.as_str(),
|
||||
content,
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(Message {
|
||||
id,
|
||||
session_id,
|
||||
seq: row.seq,
|
||||
role,
|
||||
content,
|
||||
created_at: row.created_at,
|
||||
})
|
||||
}
|
||||
|
||||
/// Replaces a message's content. The run loop creates the agent reply row
|
||||
/// before streaming (so steps can attach) and finalizes its text here.
|
||||
pub async fn set_content(
|
||||
pool: &PgPool,
|
||||
message_id: MessageId,
|
||||
content: serde_json::Value,
|
||||
) -> Result<(), DbError> {
|
||||
let result = sqlx::query!(
|
||||
"UPDATE messages SET content = $2 WHERE id = $1",
|
||||
message_id.as_uuid(),
|
||||
content,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Full transcript with step traces, oldest first (`?tools=true` shape).
|
||||
pub async fn history(
|
||||
pool: &PgPool,
|
||||
session_id: SessionId,
|
||||
) -> Result<Vec<MessageWithSteps>, DbError> {
|
||||
let message_rows = sqlx::query!(
|
||||
"SELECT id, session_id, seq, role, content, created_at
|
||||
FROM messages WHERE session_id = $1 ORDER BY seq",
|
||||
session_id.as_uuid(),
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let step_rows = sqlx::query!(
|
||||
"SELECT s.id, s.message_id, s.seq, s.kind, s.tool_name, s.input,
|
||||
s.output, s.taint, s.status
|
||||
FROM steps s
|
||||
JOIN messages m ON m.id = s.message_id
|
||||
WHERE m.session_id = $1
|
||||
ORDER BY m.seq, s.seq",
|
||||
session_id.as_uuid(),
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let mut result: Vec<MessageWithSteps> = message_rows
|
||||
.into_iter()
|
||||
.map(|row| MessageWithSteps {
|
||||
message: Message {
|
||||
id: MessageId::from(row.id),
|
||||
session_id: SessionId::from(row.session_id),
|
||||
seq: row.seq,
|
||||
role: row.role.parse().expect("role CHECK constraint"),
|
||||
content: row.content,
|
||||
created_at: row.created_at,
|
||||
},
|
||||
steps: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
for row in step_rows {
|
||||
let message_id = MessageId::from(row.message_id);
|
||||
let step = Step {
|
||||
id: row.id,
|
||||
message_id,
|
||||
seq: row.seq,
|
||||
kind: row.kind,
|
||||
tool_name: row.tool_name,
|
||||
input: row.input,
|
||||
output: row.output,
|
||||
taint: row.taint,
|
||||
status: row.status.parse::<StepStatus>().expect("status values"),
|
||||
};
|
||||
if let Some(entry) = result.iter_mut().find(|m| m.message.id == message_id) {
|
||||
entry.steps.push(step);
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
pub mod agents;
|
||||
pub mod audit;
|
||||
pub mod connections;
|
||||
pub mod credits;
|
||||
pub mod files;
|
||||
pub mod messages;
|
||||
pub mod routines;
|
||||
pub mod run_events;
|
||||
pub mod runs;
|
||||
pub mod sessions;
|
||||
pub mod skills;
|
||||
pub mod steps;
|
||||
pub mod threads;
|
||||
pub mod users;
|
||||
pub mod workspaces;
|
||||
@@ -0,0 +1,106 @@
|
||||
use cm_domain::AgentId;
|
||||
use sqlx::PgPool;
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::DbError;
|
||||
|
||||
/// A scheduled task an agent runs on a cron cadence (§7.6).
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Routine {
|
||||
pub id: Uuid,
|
||||
pub agent_id: Uuid,
|
||||
pub name: String,
|
||||
pub schedule_cron: String,
|
||||
/// `{"message": "..."}` — the prompt sent into the routine's session.
|
||||
pub action: serde_json::Value,
|
||||
pub status: String,
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub next_run_at: Option<OffsetDateTime>,
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub last_run_at: Option<OffsetDateTime>,
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
agent_id: AgentId,
|
||||
name: &str,
|
||||
schedule_cron: &str,
|
||||
action: serde_json::Value,
|
||||
next_run_at: OffsetDateTime,
|
||||
) -> Result<Routine, DbError> {
|
||||
let id = Uuid::now_v7();
|
||||
sqlx::query!(
|
||||
"INSERT INTO routines (id, agent_id, name, schedule_cron, action, next_run_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
id,
|
||||
agent_id.as_uuid(),
|
||||
name,
|
||||
schedule_cron,
|
||||
action,
|
||||
next_run_at,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(Routine {
|
||||
id,
|
||||
agent_id: agent_id.as_uuid(),
|
||||
name: name.to_owned(),
|
||||
schedule_cron: schedule_cron.to_owned(),
|
||||
action,
|
||||
status: "active".into(),
|
||||
next_run_at: Some(next_run_at),
|
||||
last_run_at: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list_by_agent(pool: &PgPool, agent_id: AgentId) -> Result<Vec<Routine>, DbError> {
|
||||
let rows = sqlx::query_as!(
|
||||
Routine,
|
||||
r#"SELECT id, agent_id, name, schedule_cron, action, status,
|
||||
next_run_at, last_run_at
|
||||
FROM routines WHERE agent_id = $1 ORDER BY created_at"#,
|
||||
agent_id.as_uuid(),
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Claims every due routine atomically (SKIP LOCKED: one firing per
|
||||
/// routine even with multiple scheduler replicas) and advances its clock.
|
||||
/// `next_runs` computes the following occurrence per claimed routine.
|
||||
pub async fn claim_due(pool: &PgPool, now: OffsetDateTime) -> Result<Vec<Routine>, DbError> {
|
||||
let rows = sqlx::query_as!(
|
||||
Routine,
|
||||
r#"UPDATE routines SET last_run_at = $1
|
||||
WHERE id IN (
|
||||
SELECT id FROM routines
|
||||
WHERE status = 'active' AND next_run_at IS NOT NULL
|
||||
AND next_run_at <= $1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
RETURNING id, agent_id, name, schedule_cron, action, status,
|
||||
next_run_at, last_run_at"#,
|
||||
now,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Schedules the next firing after a claim.
|
||||
pub async fn set_next_run(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
next_run_at: Option<OffsetDateTime>,
|
||||
) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
"UPDATE routines SET next_run_at = $2 WHERE id = $1",
|
||||
id,
|
||||
next_run_at,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::DbError;
|
||||
|
||||
/// One persisted gateway event (§13): the journal the SSE stream and
|
||||
/// reconnect replay both read from.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RunEvent {
|
||||
pub run_id: Uuid,
|
||||
pub seq: i64,
|
||||
pub event_type: String,
|
||||
pub payload: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Persists an event. Called BEFORE the event is emitted to any client so
|
||||
/// the journal is always at least as complete as what observers saw.
|
||||
pub async fn append(
|
||||
pool: &PgPool,
|
||||
run_id: Uuid,
|
||||
seq: i64,
|
||||
event_type: &str,
|
||||
payload: serde_json::Value,
|
||||
) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
"INSERT INTO run_events (run_id, seq, event_type, payload)
|
||||
VALUES ($1, $2, $3, $4)",
|
||||
run_id,
|
||||
seq,
|
||||
event_type,
|
||||
payload,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Events after a client's `resumeFrom` offset, in order.
|
||||
pub async fn list_after(
|
||||
pool: &PgPool,
|
||||
run_id: Uuid,
|
||||
after_seq: i64,
|
||||
) -> Result<Vec<RunEvent>, DbError> {
|
||||
let rows = sqlx::query!(
|
||||
"SELECT run_id, seq, event_type, payload
|
||||
FROM run_events WHERE run_id = $1 AND seq > $2 ORDER BY seq",
|
||||
run_id,
|
||||
after_seq,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| RunEvent {
|
||||
run_id: row.run_id,
|
||||
seq: row.seq,
|
||||
event_type: row.event_type,
|
||||
payload: row.payload,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
use cm_domain::{AgentRun, RunState, SessionId};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::DbError;
|
||||
|
||||
pub async fn create(pool: &PgPool, session_id: SessionId) -> Result<Uuid, DbError> {
|
||||
let id = Uuid::now_v7();
|
||||
sqlx::query!(
|
||||
"INSERT INTO agent_runs (id, session_id, state) VALUES ($1, $2, 'running')",
|
||||
id,
|
||||
session_id.as_uuid(),
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub async fn get(pool: &PgPool, id: Uuid) -> Result<AgentRun, DbError> {
|
||||
let row = sqlx::query!(
|
||||
"SELECT id, session_id, state, last_event_id, error FROM agent_runs WHERE id = $1",
|
||||
id,
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(AgentRun {
|
||||
id: row.id,
|
||||
session_id: SessionId::from(row.session_id),
|
||||
state: row.state.parse().expect("state CHECK constraint"),
|
||||
last_event_id: row.last_event_id,
|
||||
error: row.error,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn set_state(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
state: RunState,
|
||||
error: Option<&str>,
|
||||
) -> Result<(), DbError> {
|
||||
let result = sqlx::query!(
|
||||
"UPDATE agent_runs
|
||||
SET state = $2, error = COALESCE($3, error), updated_at = now()
|
||||
WHERE id = $1",
|
||||
id,
|
||||
state.as_str(),
|
||||
error,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Records the highest event seq persisted for this run (used by resume).
|
||||
pub async fn set_last_event(pool: &PgPool, id: Uuid, last_event_id: i64) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
"UPDATE agent_runs SET last_event_id = $2, updated_at = now() WHERE id = $1",
|
||||
id,
|
||||
last_event_id,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The most recent run for a session, if any (gateway re-attach).
|
||||
pub async fn latest_for_session(
|
||||
pool: &PgPool,
|
||||
session_id: SessionId,
|
||||
) -> Result<Option<AgentRun>, DbError> {
|
||||
let row = sqlx::query!(
|
||||
"SELECT id, session_id, state, last_event_id, error
|
||||
FROM agent_runs WHERE session_id = $1
|
||||
ORDER BY created_at DESC, id DESC LIMIT 1",
|
||||
session_id.as_uuid(),
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|row| AgentRun {
|
||||
id: row.id,
|
||||
session_id: SessionId::from(row.session_id),
|
||||
state: row.state.parse().expect("state CHECK constraint"),
|
||||
last_event_id: row.last_event_id,
|
||||
error: row.error,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
use cm_domain::{shard_of, AgentId, Session, SessionId, WorkspaceId};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::DbError;
|
||||
|
||||
fn row_to_session(
|
||||
id: uuid::Uuid,
|
||||
agent_id: uuid::Uuid,
|
||||
workspace_id: uuid::Uuid,
|
||||
title: String,
|
||||
shard: i16,
|
||||
created_at: time::OffsetDateTime,
|
||||
last_active_at: time::OffsetDateTime,
|
||||
) -> Session {
|
||||
Session {
|
||||
id: SessionId::from(id),
|
||||
agent_id: AgentId::from(agent_id),
|
||||
workspace_id: WorkspaceId::from(workspace_id),
|
||||
title,
|
||||
shard: shard as u16,
|
||||
created_at,
|
||||
last_active_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
agent_id: AgentId,
|
||||
workspace_id: WorkspaceId,
|
||||
title: &str,
|
||||
) -> Result<Session, DbError> {
|
||||
let id = SessionId::new();
|
||||
let shard = shard_of(agent_id);
|
||||
let row = sqlx::query!(
|
||||
"INSERT INTO sessions (id, agent_id, workspace_id, title, shard)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING created_at, last_active_at",
|
||||
id.as_uuid(),
|
||||
agent_id.as_uuid(),
|
||||
workspace_id.as_uuid(),
|
||||
title,
|
||||
shard as i16,
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(Session {
|
||||
id,
|
||||
agent_id,
|
||||
workspace_id,
|
||||
title: title.to_owned(),
|
||||
shard,
|
||||
created_at: row.created_at,
|
||||
last_active_at: row.last_active_at,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get(pool: &PgPool, id: SessionId) -> Result<Session, DbError> {
|
||||
let row = sqlx::query!(
|
||||
"SELECT id, agent_id, workspace_id, title, shard, created_at, last_active_at
|
||||
FROM sessions WHERE id = $1",
|
||||
id.as_uuid(),
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(row_to_session(
|
||||
row.id,
|
||||
row.agent_id,
|
||||
row.workspace_id,
|
||||
row.title,
|
||||
row.shard,
|
||||
row.created_at,
|
||||
row.last_active_at,
|
||||
))
|
||||
}
|
||||
|
||||
/// Sessions column ordering (§6): most recently active first.
|
||||
pub async fn list_by_agent(pool: &PgPool, agent_id: AgentId) -> Result<Vec<Session>, DbError> {
|
||||
let rows = sqlx::query!(
|
||||
"SELECT id, agent_id, workspace_id, title, shard, created_at, last_active_at
|
||||
FROM sessions WHERE agent_id = $1
|
||||
ORDER BY last_active_at DESC, id DESC",
|
||||
agent_id.as_uuid(),
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
row_to_session(
|
||||
row.id,
|
||||
row.agent_id,
|
||||
row.workspace_id,
|
||||
row.title,
|
||||
row.shard,
|
||||
row.created_at,
|
||||
row.last_active_at,
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Marks a session as just-used so it sorts to the top of the column.
|
||||
pub async fn touch(pool: &PgPool, id: SessionId) -> Result<(), DbError> {
|
||||
let result = sqlx::query!(
|
||||
"UPDATE sessions SET last_active_at = clock_timestamp() WHERE id = $1",
|
||||
id.as_uuid(),
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
use cm_domain::{AgentId, UserId, WorkspaceId};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::DbError;
|
||||
|
||||
/// A skill as listed in the library (§8.1) or installed on an agent (§7.5).
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Skill {
|
||||
pub id: Uuid,
|
||||
/// `None` = catalog skill visible to every workspace.
|
||||
pub workspace_id: Option<Uuid>,
|
||||
pub title: String,
|
||||
pub author: String,
|
||||
pub description: String,
|
||||
pub body: String,
|
||||
pub installs: i32,
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
workspace_id: Option<WorkspaceId>,
|
||||
title: &str,
|
||||
author: &str,
|
||||
description: &str,
|
||||
body: &str,
|
||||
) -> Result<Skill, DbError> {
|
||||
let id = Uuid::now_v7();
|
||||
sqlx::query!(
|
||||
"INSERT INTO skills (id, workspace_id, title, author, description, body)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
id,
|
||||
workspace_id.map(|w| w.as_uuid()),
|
||||
title,
|
||||
author,
|
||||
description,
|
||||
body,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(Skill {
|
||||
id,
|
||||
workspace_id: workspace_id.map(|w| w.as_uuid()),
|
||||
title: title.to_owned(),
|
||||
author: author.to_owned(),
|
||||
description: description.to_owned(),
|
||||
body: body.to_owned(),
|
||||
installs: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// The Skill Library (§8.1): catalog skills plus this workspace's own.
|
||||
pub async fn library(pool: &PgPool, workspace_id: WorkspaceId) -> Result<Vec<Skill>, DbError> {
|
||||
let rows = sqlx::query_as!(
|
||||
Skill,
|
||||
r#"SELECT id, workspace_id, title, author, description, body, installs
|
||||
FROM skills
|
||||
WHERE workspace_id IS NULL OR workspace_id = $1
|
||||
ORDER BY installs DESC, title"#,
|
||||
workspace_id.as_uuid(),
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Skills installed on one agent (§7.5).
|
||||
pub async fn installed(pool: &PgPool, agent_id: AgentId) -> Result<Vec<Skill>, DbError> {
|
||||
let rows = sqlx::query_as!(
|
||||
Skill,
|
||||
r#"SELECT s.id, s.workspace_id, s.title, s.author, s.description,
|
||||
s.body, s.installs
|
||||
FROM skills s
|
||||
JOIN installed_skills i ON i.skill_id = s.id
|
||||
WHERE i.agent_id = $1
|
||||
ORDER BY i.installed_at"#,
|
||||
agent_id.as_uuid(),
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Installs a skill on an agent and bumps the library counter. Repeat
|
||||
/// installs are idempotent (no double count).
|
||||
pub async fn install(
|
||||
pool: &PgPool,
|
||||
agent_id: AgentId,
|
||||
skill_id: Uuid,
|
||||
installed_by: UserId,
|
||||
) -> Result<(), DbError> {
|
||||
let mut tx = pool.begin().await.map_err(DbError::from)?;
|
||||
let inserted = sqlx::query!(
|
||||
"INSERT INTO installed_skills (agent_id, skill_id, installed_by)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (agent_id, skill_id) DO NOTHING",
|
||||
agent_id.as_uuid(),
|
||||
skill_id,
|
||||
installed_by.as_uuid(),
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if inserted.rows_affected() == 1 {
|
||||
sqlx::query!(
|
||||
"UPDATE skills SET installs = installs + 1 WHERE id = $1",
|
||||
skill_id,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await.map_err(DbError::from)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn uninstall(pool: &PgPool, agent_id: AgentId, skill_id: Uuid) -> Result<(), DbError> {
|
||||
let result = sqlx::query!(
|
||||
"DELETE FROM installed_skills WHERE agent_id = $1 AND skill_id = $2",
|
||||
agent_id.as_uuid(),
|
||||
skill_id,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
use cm_domain::Step;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::DbError;
|
||||
|
||||
pub async fn append(pool: &PgPool, step: &Step) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
"INSERT INTO steps
|
||||
(id, message_id, seq, kind, tool_name, input, output, taint, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
|
||||
step.id,
|
||||
step.message_id.as_uuid(),
|
||||
step.seq,
|
||||
step.kind,
|
||||
step.tool_name.as_deref(),
|
||||
step.input.clone(),
|
||||
step.output.clone(),
|
||||
&step.taint,
|
||||
step.status.as_str(),
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
use cm_domain::{AgentId, WorkspaceId};
|
||||
use sqlx::PgPool;
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::DbError;
|
||||
|
||||
/// An inter-agent conversation (§7.2 Claw Chat).
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Thread {
|
||||
pub id: Uuid,
|
||||
pub workspace_id: Uuid,
|
||||
pub subject: String,
|
||||
pub sensitivity: String,
|
||||
pub participants: Vec<Uuid>,
|
||||
pub last_preview: Option<String>,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ThreadMessage {
|
||||
pub id: Uuid,
|
||||
pub thread_id: Uuid,
|
||||
pub from_agent: Uuid,
|
||||
pub content: serde_json::Value,
|
||||
pub taint: Vec<String>,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
/// Finds the 1:1 thread between two agents, or creates it with the given
|
||||
/// subject.
|
||||
pub async fn find_or_create(
|
||||
pool: &PgPool,
|
||||
workspace_id: WorkspaceId,
|
||||
a: AgentId,
|
||||
b: AgentId,
|
||||
subject: &str,
|
||||
) -> Result<Uuid, DbError> {
|
||||
let existing = sqlx::query_scalar!(
|
||||
r#"SELECT t.id FROM threads t
|
||||
WHERE t.workspace_id = $1
|
||||
AND EXISTS (SELECT 1 FROM thread_participants p
|
||||
WHERE p.thread_id = t.id AND p.agent_id = $2)
|
||||
AND EXISTS (SELECT 1 FROM thread_participants p
|
||||
WHERE p.thread_id = t.id AND p.agent_id = $3)
|
||||
LIMIT 1"#,
|
||||
workspace_id.as_uuid(),
|
||||
a.as_uuid(),
|
||||
b.as_uuid(),
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
if let Some(id) = existing {
|
||||
return Ok(id);
|
||||
}
|
||||
|
||||
let id = Uuid::now_v7();
|
||||
let mut tx = pool.begin().await.map_err(DbError::from)?;
|
||||
sqlx::query!(
|
||||
"INSERT INTO threads (id, workspace_id, subject) VALUES ($1, $2, $3)",
|
||||
id,
|
||||
workspace_id.as_uuid(),
|
||||
subject,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
for agent in [a, b] {
|
||||
sqlx::query!(
|
||||
"INSERT INTO thread_participants (thread_id, agent_id) VALUES ($1, $2)",
|
||||
id,
|
||||
agent.as_uuid(),
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await.map_err(DbError::from)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub async fn add_message(
|
||||
pool: &PgPool,
|
||||
thread_id: Uuid,
|
||||
from_agent: AgentId,
|
||||
content: serde_json::Value,
|
||||
taint: &[String],
|
||||
) -> Result<Uuid, DbError> {
|
||||
let id = Uuid::now_v7();
|
||||
sqlx::query!(
|
||||
"INSERT INTO thread_messages (id, thread_id, from_agent, content, taint)
|
||||
VALUES ($1, $2, $3, $4, $5)",
|
||||
id,
|
||||
thread_id,
|
||||
from_agent.as_uuid(),
|
||||
content,
|
||||
taint,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Threads an agent participates in, most recent message first, with the
|
||||
/// last message preview (§7.2 thread list).
|
||||
pub async fn list_for_agent(pool: &PgPool, agent_id: AgentId) -> Result<Vec<Thread>, DbError> {
|
||||
let rows = sqlx::query!(
|
||||
r#"SELECT t.id, t.workspace_id, t.subject, t.sensitivity, t.created_at,
|
||||
ARRAY(SELECT p2.agent_id FROM thread_participants p2
|
||||
WHERE p2.thread_id = t.id) AS "participants!",
|
||||
(SELECT m.content->>'text' FROM thread_messages m
|
||||
WHERE m.thread_id = t.id
|
||||
ORDER BY m.created_at DESC LIMIT 1) AS last_preview
|
||||
FROM threads t
|
||||
JOIN thread_participants p ON p.thread_id = t.id
|
||||
WHERE p.agent_id = $1
|
||||
ORDER BY (SELECT max(m.created_at) FROM thread_messages m
|
||||
WHERE m.thread_id = t.id) DESC NULLS LAST"#,
|
||||
agent_id.as_uuid(),
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| Thread {
|
||||
id: r.id,
|
||||
workspace_id: r.workspace_id,
|
||||
subject: r.subject,
|
||||
sensitivity: r.sensitivity,
|
||||
participants: r.participants,
|
||||
last_preview: r.last_preview,
|
||||
created_at: r.created_at,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn messages(pool: &PgPool, thread_id: Uuid) -> Result<Vec<ThreadMessage>, DbError> {
|
||||
let rows = sqlx::query_as!(
|
||||
ThreadMessage,
|
||||
r#"SELECT id, thread_id, from_agent, content, taint, created_at
|
||||
FROM thread_messages WHERE thread_id = $1 ORDER BY created_at"#,
|
||||
thread_id,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Whether an agent participates in a thread (API scoping).
|
||||
pub async fn is_participant(
|
||||
pool: &PgPool,
|
||||
thread_id: Uuid,
|
||||
agent_id: AgentId,
|
||||
) -> Result<bool, DbError> {
|
||||
let row = sqlx::query_scalar!(
|
||||
"SELECT 1 AS x FROM thread_participants WHERE thread_id = $1 AND agent_id = $2",
|
||||
thread_id,
|
||||
agent_id.as_uuid(),
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.is_some())
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
use cm_domain::{Role, User, UserId, WorkspaceId};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::DbError;
|
||||
|
||||
fn role_to_str(role: Role) -> &'static str {
|
||||
match role {
|
||||
Role::Owner => "owner",
|
||||
Role::Member => "member",
|
||||
}
|
||||
}
|
||||
|
||||
fn role_from_str(s: &str) -> Role {
|
||||
// The CHECK constraint guarantees only these two values exist.
|
||||
if s == "owner" {
|
||||
Role::Owner
|
||||
} else {
|
||||
Role::Member
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts a user. `created_at` is assigned by the database; the value on
|
||||
/// the input struct is ignored.
|
||||
pub async fn insert(pool: &PgPool, user: &User) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
"INSERT INTO users (id, workspace_id, email, role, display_name)
|
||||
VALUES ($1, $2, $3, $4, $5)",
|
||||
user.id.as_uuid(),
|
||||
user.workspace_id.as_uuid(),
|
||||
user.email,
|
||||
role_to_str(user.role),
|
||||
user.display_name,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get(pool: &PgPool, id: UserId) -> Result<User, DbError> {
|
||||
let row = sqlx::query!(
|
||||
"SELECT id, workspace_id, email, role, display_name, created_at
|
||||
FROM users WHERE id = $1",
|
||||
id.as_uuid(),
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(User {
|
||||
id: UserId::from(row.id),
|
||||
workspace_id: WorkspaceId::from(row.workspace_id),
|
||||
email: row.email,
|
||||
role: role_from_str(&row.role),
|
||||
display_name: row.display_name,
|
||||
created_at: row.created_at,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn find_by_email(pool: &PgPool, email: &str) -> Result<User, DbError> {
|
||||
let row = sqlx::query!(
|
||||
"SELECT id, workspace_id, email, role, display_name, created_at
|
||||
FROM users WHERE email = $1",
|
||||
email,
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(User {
|
||||
id: UserId::from(row.id),
|
||||
workspace_id: WorkspaceId::from(row.workspace_id),
|
||||
email: row.email,
|
||||
role: role_from_str(&row.role),
|
||||
display_name: row.display_name,
|
||||
created_at: row.created_at,
|
||||
})
|
||||
}
|
||||
|
||||
/// Members table for the Team page (§8.3), in join order.
|
||||
pub async fn list_by_workspace(
|
||||
pool: &PgPool,
|
||||
workspace_id: WorkspaceId,
|
||||
) -> Result<Vec<User>, DbError> {
|
||||
let rows = sqlx::query!(
|
||||
"SELECT id, workspace_id, email, role, display_name, created_at
|
||||
FROM users WHERE workspace_id = $1
|
||||
ORDER BY created_at, id",
|
||||
workspace_id.as_uuid(),
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| User {
|
||||
id: UserId::from(row.id),
|
||||
workspace_id: WorkspaceId::from(row.workspace_id),
|
||||
email: row.email,
|
||||
role: role_from_str(&row.role),
|
||||
display_name: row.display_name,
|
||||
created_at: row.created_at,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
use cm_domain::{Workspace, WorkspaceId};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::DbError;
|
||||
|
||||
pub async fn insert(pool: &PgPool, workspace: &Workspace) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
"INSERT INTO workspaces (id, name, plan) VALUES ($1, $2, $3)",
|
||||
workspace.id.as_uuid(),
|
||||
workspace.name,
|
||||
workspace.plan,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get(pool: &PgPool, id: WorkspaceId) -> Result<Workspace, DbError> {
|
||||
let row = sqlx::query!(
|
||||
"SELECT id, name, plan FROM workspaces WHERE id = $1",
|
||||
id.as_uuid(),
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(Workspace {
|
||||
id: WorkspaceId::from(row.id),
|
||||
name: row.name,
|
||||
plan: row.plan,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user