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,212 @@
|
||||
use cm_domain::{
|
||||
AccessPolicy, Agent, AgentId, AgentStatus, GatedCategory, Role, User, UserId, Workspace,
|
||||
WorkspaceId,
|
||||
};
|
||||
use cm_safety::{
|
||||
approvals, checkpoint, grants, ApprovalStatus, Decision, NewApproval, SafetyError,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
struct Seed {
|
||||
workspace: Workspace,
|
||||
owner: User,
|
||||
agent: Agent,
|
||||
run_id: uuid::Uuid,
|
||||
}
|
||||
|
||||
async fn seeded(pool: &sqlx::PgPool) -> Seed {
|
||||
let workspace = Workspace {
|
||||
id: WorkspaceId::new(),
|
||||
name: "Acme".into(),
|
||||
plan: "team".into(),
|
||||
};
|
||||
cm_db::repo::workspaces::insert(pool, &workspace)
|
||||
.await
|
||||
.unwrap();
|
||||
let owner = User {
|
||||
id: UserId::new(),
|
||||
workspace_id: workspace.id,
|
||||
email: format!("{}@acme.test", UserId::new()),
|
||||
role: Role::Owner,
|
||||
display_name: "Owner".into(),
|
||||
created_at: time::OffsetDateTime::UNIX_EPOCH,
|
||||
};
|
||||
cm_db::repo::users::insert(pool, &owner).await.unwrap();
|
||||
let agent = Agent {
|
||||
id: AgentId::new(),
|
||||
workspace_id: workspace.id,
|
||||
name: "Scout".into(),
|
||||
job_title: "Analyst".into(),
|
||||
system_prompt: String::new(),
|
||||
avatar: String::new(),
|
||||
accent: String::new(),
|
||||
wallpaper: String::new(),
|
||||
managed_by: owner.id,
|
||||
status: AgentStatus::Online,
|
||||
};
|
||||
cm_db::repo::agents::insert(pool, &agent, &AccessPolicy::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let session = cm_db::repo::sessions::create(pool, agent.id, workspace.id, "Chat")
|
||||
.await
|
||||
.unwrap();
|
||||
let run_id = cm_db::repo::runs::create(pool, session.id).await.unwrap();
|
||||
Seed {
|
||||
workspace,
|
||||
owner,
|
||||
agent,
|
||||
run_id,
|
||||
}
|
||||
}
|
||||
|
||||
fn new_approval(seed: &Seed) -> NewApproval {
|
||||
NewApproval {
|
||||
workspace_id: seed.workspace.id,
|
||||
run_id: seed.run_id,
|
||||
session_key: "agent:x-claw-0:session:y:z".into(),
|
||||
action_type: "email.send".into(),
|
||||
category: GatedCategory::OutboundMessage,
|
||||
payload: json!({"to": "[email protected]", "subject": "Q2", "body": "Numbers"}),
|
||||
preview: json!({"summary": "Send email to [email protected]", "body": "Numbers"}),
|
||||
requested_by_agent: seed.agent.id,
|
||||
taint_sources: vec![],
|
||||
expires_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn created_approvals_are_listed_pending_with_exact_preview() {
|
||||
let pool = cm_testkit::test_pool().await;
|
||||
let seed = seeded(&pool).await;
|
||||
let approval = approvals::create(&pool, new_approval(&seed)).await.unwrap();
|
||||
assert_eq!(approval.status, ApprovalStatus::Pending);
|
||||
|
||||
let pending = approvals::list_pending(&pool, seed.workspace.id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].id, approval.id);
|
||||
assert_eq!(
|
||||
pending[0].preview["summary"],
|
||||
"Send email to [email protected]"
|
||||
);
|
||||
assert_eq!(pending[0].category, GatedCategory::OutboundMessage);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approve_is_a_cas_that_mints_one_grant_and_audits() {
|
||||
let pool = cm_testkit::test_pool().await;
|
||||
let seed = seeded(&pool).await;
|
||||
let approval = approvals::create(&pool, new_approval(&seed)).await.unwrap();
|
||||
|
||||
let decided = approvals::decide(&pool, approval.id, seed.owner.id, Decision::Approve)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(decided.status, ApprovalStatus::Approved);
|
||||
assert_eq!(decided.decided_by, Some(seed.owner.id));
|
||||
|
||||
// Double-decide is rejected, not silently absorbed.
|
||||
let again = approvals::decide(&pool, approval.id, seed.owner.id, Decision::Approve).await;
|
||||
assert!(matches!(again, Err(SafetyError::AlreadyDecided)));
|
||||
|
||||
// Decision is audit-logged.
|
||||
let audited = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT count(*) FROM audit_log WHERE event_type = 'approval.approved'",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(audited, 1);
|
||||
|
||||
// The grant is single-use: first consumption succeeds, second refuses.
|
||||
grants::consume(&pool, approval.id).await.unwrap();
|
||||
let reused = grants::consume(&pool, approval.id).await;
|
||||
assert!(matches!(reused, Err(SafetyError::GrantUnavailable)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reject_mints_no_grant() {
|
||||
let pool = cm_testkit::test_pool().await;
|
||||
let seed = seeded(&pool).await;
|
||||
let approval = approvals::create(&pool, new_approval(&seed)).await.unwrap();
|
||||
|
||||
let decided = approvals::decide(&pool, approval.id, seed.owner.id, Decision::Reject)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(decided.status, ApprovalStatus::Rejected);
|
||||
|
||||
let no_grant = grants::consume(&pool, approval.id).await;
|
||||
assert!(matches!(no_grant, Err(SafetyError::GrantUnavailable)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_approvals_cannot_be_consumed() {
|
||||
let pool = cm_testkit::test_pool().await;
|
||||
let seed = seeded(&pool).await;
|
||||
let approval = approvals::create(&pool, new_approval(&seed)).await.unwrap();
|
||||
let blocked = grants::consume(&pool, approval.id).await;
|
||||
assert!(matches!(blocked, Err(SafetyError::GrantUnavailable)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkpoint_round_trips_and_resume_claim_is_exclusive() {
|
||||
let pool = cm_testkit::test_pool().await;
|
||||
let seed = seeded(&pool).await;
|
||||
|
||||
let state = json!({"request": {"model": "scripted"}, "step_seq": 3});
|
||||
checkpoint::suspend(&pool, seed.run_id, &state)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let run = cm_db::repo::runs::get(&pool, seed.run_id).await.unwrap();
|
||||
assert_eq!(run.state, cm_domain::RunState::AwaitingApproval);
|
||||
|
||||
let loaded = checkpoint::load(&pool, seed.run_id).await.unwrap();
|
||||
assert_eq!(loaded, state);
|
||||
|
||||
// Exactly one resumer can claim the run.
|
||||
assert!(checkpoint::claim_resume(&pool, seed.run_id).await.unwrap());
|
||||
assert!(!checkpoint::claim_resume(&pool, seed.run_id).await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expiry_sweep_expires_overdue_pending_approvals() {
|
||||
let pool = cm_testkit::test_pool().await;
|
||||
let seed = seeded(&pool).await;
|
||||
let mut overdue = new_approval(&seed);
|
||||
overdue.expires_at = Some(time::OffsetDateTime::now_utc() - time::Duration::minutes(5));
|
||||
let approval = approvals::create(&pool, overdue).await.unwrap();
|
||||
|
||||
let expired = approvals::sweep_expired(&pool).await.unwrap();
|
||||
assert_eq!(expired, 1);
|
||||
let after = approvals::get(&pool, approval.id).await.unwrap();
|
||||
assert_eq!(after.status, ApprovalStatus::Expired);
|
||||
|
||||
// Expired approvals cannot be decided.
|
||||
let late = approvals::decide(&pool, approval.id, seed.owner.id, Decision::Approve).await;
|
||||
assert!(matches!(late, Err(SafetyError::AlreadyDecided)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn decided_unresumed_finds_runs_awaiting_resume() {
|
||||
let pool = cm_testkit::test_pool().await;
|
||||
let seed = seeded(&pool).await;
|
||||
let approval = approvals::create(&pool, new_approval(&seed)).await.unwrap();
|
||||
checkpoint::suspend(&pool, seed.run_id, &json!({"s": 1}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Nothing decided yet.
|
||||
assert!(approvals::decided_unresumed(&pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
|
||||
approvals::decide(&pool, approval.id, seed.owner.id, Decision::Approve)
|
||||
.await
|
||||
.unwrap();
|
||||
let ready = approvals::decided_unresumed(&pool).await.unwrap();
|
||||
assert_eq!(ready.len(), 1);
|
||||
assert_eq!(ready[0].run_id, seed.run_id);
|
||||
assert_eq!(ready[0].approval_id, approval.id);
|
||||
}
|
||||
Reference in New Issue
Block a user