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:
Omar Sobh
2026-06-10 12:31:25 -05:00
co-authored by Claude Fable 5
parent 8046853feb
commit add4f79fed
209 changed files with 1429 additions and 1422 deletions
+230
View File
@@ -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(())
}
+61
View File
@@ -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)
}
+108
View File
@@ -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(())
}
+37
View File
@@ -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(())
}
+132
View File
@@ -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(())
}
+116
View File
@@ -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)
}
+15
View File
@@ -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;
+106
View File
@@ -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(())
}
+61
View File
@@ -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())
}
+89
View File
@@ -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,
}))
}
+114
View File
@@ -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(())
}
+127
View File
@@ -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(())
}
+24
View File
@@ -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(())
}
+163
View File
@@ -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())
}
+99
View File
@@ -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())
}
+30
View File
@@ -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,
})
}