`upsert_builtin` replaced the role set with DELETE + reinsert. That looks
equivalent to an upsert and is not: `agent_template_link` carries a plain FK
on (template_id, role_slot), so the delete is rejected as soon as one agent
has been minted from the template, rolling back the whole transaction.
The failure mode was silent and self-targeting. The loader logs the error and
continues, so the on-disk TOML and the DB drifted apart — and only for the
templates someone had actually used. Running the smoke mission against
insight_research is what put it on the boot log:
failed to load insight_research.toml: violates foreign key constraint
"agent_template_link_template_id_role_slot_fkey"
which also means that template never received the skill-name fix.
- Upsert each role in place via ON CONFLICT (template_id, slot), the table's
primary key.
- Prune only slots the TOML dropped, and skip a slot still referenced by a
live agent with a log line. Keeping one stale role row is a smaller failure
than discarding every edit to the template.
- Regression test drives the real sequence — upsert, mint an agent, link it,
upsert again — and asserts both the prompt and skill edits land. Verified to
fail without the fix with the same 23503 the server logged.
Co-Authored-By: Claude Opus 5 <[email protected]>
309 lines
9.8 KiB
Rust
309 lines
9.8 KiB
Rust
use std::str::FromStr;
|
|
|
|
use cm_db::repo::{agent_template_link, agents, audit, credits, team_templates, users, workspaces};
|
|
use cm_db::DbError;
|
|
use cm_domain::{
|
|
AccessPolicy, Agent, AgentId, AgentScope, AgentStatus, HumanScope, Role, User, UserId,
|
|
Workspace, WorkspaceId,
|
|
};
|
|
|
|
fn workspace() -> Workspace {
|
|
Workspace {
|
|
id: WorkspaceId::new(),
|
|
name: "Acme".into(),
|
|
plan: "team".into(),
|
|
}
|
|
}
|
|
|
|
fn user_in(ws: &Workspace, role: Role) -> User {
|
|
User {
|
|
id: UserId::new(),
|
|
workspace_id: ws.id,
|
|
email: format!("{}@acme.test", UserId::new()),
|
|
role,
|
|
display_name: "Test User".into(),
|
|
created_at: time::OffsetDateTime::UNIX_EPOCH,
|
|
}
|
|
}
|
|
|
|
fn agent_in(ws: &Workspace, owner: &User) -> Agent {
|
|
Agent {
|
|
id: AgentId::new(),
|
|
workspace_id: ws.id,
|
|
name: "Scout".into(),
|
|
job_title: "Research Analyst".into(),
|
|
system_prompt: "You research things.".into(),
|
|
avatar: "scout-1".into(),
|
|
accent: "#f96565".into(),
|
|
wallpaper: "dunes".into(),
|
|
managed_by: owner.id,
|
|
status: AgentStatus::Provisioning,
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn workspace_round_trips() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let ws = workspace();
|
|
workspaces::insert(&pool, &ws).await.unwrap();
|
|
let found = workspaces::get(&pool, ws.id).await.unwrap();
|
|
assert_eq!(found, ws);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn missing_workspace_is_not_found() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let err = workspaces::get(&pool, WorkspaceId::new())
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, DbError::NotFound));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn user_round_trips_and_finds_by_email() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let ws = workspace();
|
|
workspaces::insert(&pool, &ws).await.unwrap();
|
|
let user = user_in(&ws, Role::Owner);
|
|
users::insert(&pool, &user).await.unwrap();
|
|
|
|
let by_id = users::get(&pool, user.id).await.unwrap();
|
|
assert_eq!(by_id.email, user.email);
|
|
assert_eq!(by_id.role, Role::Owner);
|
|
|
|
let by_email = users::find_by_email(&pool, &user.email).await.unwrap();
|
|
assert_eq!(by_email.id, user.id);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn duplicate_email_is_a_conflict() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let ws = workspace();
|
|
workspaces::insert(&pool, &ws).await.unwrap();
|
|
let mut a = user_in(&ws, Role::Member);
|
|
let mut b = user_in(&ws, Role::Member);
|
|
b.email = a.email.clone();
|
|
users::insert(&pool, &a).await.unwrap();
|
|
let err = users::insert(&pool, &b).await.unwrap_err();
|
|
assert!(matches!(err, DbError::Conflict(_)));
|
|
// Silence unused warnings for fields we only compare implicitly.
|
|
a.display_name.clear();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn workspace_members_lists_in_join_order() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let ws = workspace();
|
|
workspaces::insert(&pool, &ws).await.unwrap();
|
|
let owner = user_in(&ws, Role::Owner);
|
|
let member = user_in(&ws, Role::Member);
|
|
users::insert(&pool, &owner).await.unwrap();
|
|
users::insert(&pool, &member).await.unwrap();
|
|
|
|
let members = users::list_by_workspace(&pool, ws.id).await.unwrap();
|
|
assert_eq!(members.len(), 2);
|
|
assert_eq!(members[0].id, owner.id);
|
|
assert_eq!(members[1].id, member.id);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn agent_insert_creates_default_access_policy() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let ws = workspace();
|
|
workspaces::insert(&pool, &ws).await.unwrap();
|
|
let owner = user_in(&ws, Role::Owner);
|
|
users::insert(&pool, &owner).await.unwrap();
|
|
let agent = agent_in(&ws, &owner);
|
|
|
|
agents::insert(&pool, &agent, &AccessPolicy::default())
|
|
.await
|
|
.unwrap();
|
|
|
|
let policy = agents::access_policy(&pool, agent.id).await.unwrap();
|
|
assert_eq!(policy.humans, HumanScope::EntireTeam);
|
|
assert_eq!(policy.agents, AgentScope::Any);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn agent_roster_excludes_deleted_and_round_trips_fields() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let ws = workspace();
|
|
workspaces::insert(&pool, &ws).await.unwrap();
|
|
let owner = user_in(&ws, Role::Owner);
|
|
users::insert(&pool, &owner).await.unwrap();
|
|
|
|
let keep = agent_in(&ws, &owner);
|
|
let remove = agent_in(&ws, &owner);
|
|
agents::insert(&pool, &keep, &AccessPolicy::default())
|
|
.await
|
|
.unwrap();
|
|
agents::insert(&pool, &remove, &AccessPolicy::default())
|
|
.await
|
|
.unwrap();
|
|
agents::soft_delete(&pool, remove.id).await.unwrap();
|
|
|
|
let roster = agents::roster(&pool, ws.id).await.unwrap();
|
|
assert_eq!(roster.len(), 1);
|
|
assert_eq!(roster[0], keep);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn agent_status_updates() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let ws = workspace();
|
|
workspaces::insert(&pool, &ws).await.unwrap();
|
|
let owner = user_in(&ws, Role::Owner);
|
|
users::insert(&pool, &owner).await.unwrap();
|
|
let agent = agent_in(&ws, &owner);
|
|
agents::insert(&pool, &agent, &AccessPolicy::default())
|
|
.await
|
|
.unwrap();
|
|
|
|
agents::set_status(&pool, agent.id, AgentStatus::Online)
|
|
.await
|
|
.unwrap();
|
|
let roster = agents::roster(&pool, ws.id).await.unwrap();
|
|
assert_eq!(roster[0].status, AgentStatus::Online);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn specific_access_policy_round_trips() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let ws = workspace();
|
|
workspaces::insert(&pool, &ws).await.unwrap();
|
|
let owner = user_in(&ws, Role::Owner);
|
|
users::insert(&pool, &owner).await.unwrap();
|
|
let agent = agent_in(&ws, &owner);
|
|
|
|
let policy = AccessPolicy {
|
|
humans: HumanScope::Specific(vec![owner.id]),
|
|
agents: AgentScope::Specific(vec![AgentId::from_str(&agent.id.to_string()).unwrap()]),
|
|
};
|
|
agents::insert(&pool, &agent, &policy).await.unwrap();
|
|
let stored = agents::access_policy(&pool, agent.id).await.unwrap();
|
|
assert_eq!(stored, policy);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn credit_balance_sums_remaining_lots() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let ws = workspace();
|
|
workspaces::insert(&pool, &ws).await.unwrap();
|
|
|
|
assert_eq!(credits::balance(&pool, ws.id).await.unwrap(), 0);
|
|
credits::add_lot(&pool, ws.id, 1000, "purchase")
|
|
.await
|
|
.unwrap();
|
|
credits::add_lot(&pool, ws.id, 250, "promo").await.unwrap();
|
|
assert_eq!(credits::balance(&pool, ws.id).await.unwrap(), 1250);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn audit_log_appends_and_rejects_mutation() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let ws = workspace();
|
|
workspaces::insert(&pool, &ws).await.unwrap();
|
|
|
|
let entry_id = audit::append(
|
|
&pool,
|
|
ws.id,
|
|
audit::Actor::System,
|
|
"workspace.created",
|
|
"workspace",
|
|
&ws.id.to_string(),
|
|
serde_json::json!({"plan": "team"}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert!(entry_id > 0);
|
|
|
|
// Append-only is enforced by the database itself, not convention.
|
|
let update = sqlx::query("UPDATE audit_log SET event_type = 'tampered' WHERE id = $1")
|
|
.bind(entry_id)
|
|
.execute(&pool)
|
|
.await;
|
|
assert!(update.is_err());
|
|
let delete = sqlx::query("DELETE FROM audit_log WHERE id = $1")
|
|
.bind(entry_id)
|
|
.execute(&pool)
|
|
.await;
|
|
assert!(delete.is_err());
|
|
}
|
|
|
|
/// A template must stay editable after it has minted agents.
|
|
///
|
|
/// `agent_template_link` holds a plain FK on (template_id, role_slot), so the
|
|
/// old delete-then-reinsert upsert was rejected the moment a template had been
|
|
/// used — and because the loader logs and continues, the on-disk TOML and the
|
|
/// DB drifted apart silently, for exactly the templates anyone actually ran.
|
|
#[tokio::test]
|
|
async fn a_template_with_live_agents_still_accepts_edits() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let ws = workspace();
|
|
workspaces::insert(&pool, &ws).await.unwrap();
|
|
let owner = user_in(&ws, Role::Owner);
|
|
users::insert(&pool, &owner).await.unwrap();
|
|
|
|
let id = uuid::Uuid::now_v7();
|
|
let build = |prompt: &'static str, extra: Vec<String>| team_templates::UpsertBuiltin {
|
|
id,
|
|
key: "fixture_team",
|
|
name: "Fixture",
|
|
stack: vec!["rust".into()],
|
|
default_topology: "pipeline",
|
|
risk_profile: "medium",
|
|
mcp_bundles: vec![],
|
|
version: 1,
|
|
description: None,
|
|
config: serde_json::json!({}),
|
|
category: "development",
|
|
roles: vec![team_templates::UpsertBuiltinRole {
|
|
slot: "coder",
|
|
order_idx: 0,
|
|
system_prompt: prompt,
|
|
skills: extra,
|
|
brain_seed: None,
|
|
}],
|
|
};
|
|
team_templates::upsert_builtin(&pool, build("first", vec![]))
|
|
.await
|
|
.unwrap();
|
|
|
|
// Mint an agent against the template — this is what a mission does.
|
|
let agent = Agent {
|
|
id: AgentId::new(),
|
|
workspace_id: ws.id,
|
|
name: "Fixture · coder".into(),
|
|
job_title: "coder".into(),
|
|
system_prompt: "first".into(),
|
|
avatar: String::new(),
|
|
accent: "#fff".into(),
|
|
wallpaper: String::new(),
|
|
managed_by: owner.id,
|
|
status: AgentStatus::Online,
|
|
};
|
|
agents::insert(&pool, &agent, &AccessPolicy::default())
|
|
.await
|
|
.unwrap();
|
|
agent_template_link::upsert(&pool, agent.id.as_uuid(), id, 1, "coder")
|
|
.await
|
|
.unwrap();
|
|
|
|
// The edit that used to fail with a foreign-key violation.
|
|
team_templates::upsert_builtin(
|
|
&pool,
|
|
build("second", vec!["write-rust-current-edition".into()]),
|
|
)
|
|
.await
|
|
.expect("a used template must still accept edits");
|
|
|
|
let detail = team_templates::get(&pool, id).await.unwrap().unwrap();
|
|
let role = detail.roles.iter().find(|r| r.slot == "coder").unwrap();
|
|
assert_eq!(role.system_prompt, "second", "the prompt edit applied");
|
|
assert_eq!(
|
|
role.skills,
|
|
vec!["write-rust-current-edition".to_string()],
|
|
"the skill edit applied",
|
|
);
|
|
}
|