use crate::sql::{Auto, ExecError, Pool};
#[derive(crate::Model, Debug, Clone)]
#[rustango(
table = "rustango_agents",
display = "name",
admin(
list_display = "name, active, secret_prefix, created_at",
search_fields = "name",
ordering = "name",
readonly_fields = "secret_prefix, secret_hash, created_at, secret_rotated_at",
)
)]
pub struct Agent {
#[rustango(primary_key)]
pub id: Auto<i64>,
#[rustango(max_length = 150, unique)]
pub name: String,
#[rustango(max_length = 16, unique)]
pub secret_prefix: String,
#[rustango(max_length = 255)]
pub secret_hash: String,
pub active: bool,
#[rustango(auto_now_add)]
pub created_at: Auto<chrono::DateTime<chrono::Utc>>,
pub secret_rotated_at: Option<chrono::DateTime<chrono::Utc>>,
#[rustango(default = "'{}'")]
pub data: serde_json::Value,
}
const AGENT_ENSURE_SQL: &str = r#"
CREATE TABLE IF NOT EXISTS "rustango_agents" (
"id" BIGSERIAL PRIMARY KEY,
"name" VARCHAR(150) NOT NULL,
"secret_prefix" VARCHAR(16) NOT NULL,
"secret_hash" VARCHAR(255) NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT TRUE,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"secret_rotated_at" TIMESTAMPTZ,
"data" JSONB NOT NULL DEFAULT '{}',
CONSTRAINT "rustango_agents_name_uq" UNIQUE ("name"),
CONSTRAINT "rustango_agents_prefix_uq" UNIQUE ("secret_prefix")
);"#;
const AGENT_ENSURE_SQL_SQLITE: &str = r#"
CREATE TABLE IF NOT EXISTS "rustango_agents" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL,
"secret_prefix" TEXT NOT NULL,
"secret_hash" TEXT NOT NULL,
"active" INTEGER NOT NULL DEFAULT 1,
"created_at" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
"secret_rotated_at" TEXT,
"data" TEXT NOT NULL DEFAULT '{}',
CONSTRAINT "rustango_agents_name_uq" UNIQUE ("name"),
CONSTRAINT "rustango_agents_prefix_uq" UNIQUE ("secret_prefix")
);"#;
const AGENT_ENSURE_SQL_MYSQL: &str = r#"
CREATE TABLE IF NOT EXISTS `rustango_agents` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR(150) NOT NULL,
`secret_prefix` VARCHAR(16) NOT NULL,
`secret_hash` VARCHAR(255) NOT NULL,
`active` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
`secret_rotated_at` DATETIME(6),
`data` JSON,
CONSTRAINT `rustango_agents_name_uq` UNIQUE (`name`),
CONSTRAINT `rustango_agents_prefix_uq` UNIQUE (`secret_prefix`)
);"#;
pub async fn ensure_agents_table_pool(pool: &Pool) -> Result<(), sqlx::Error> {
let ddl = match pool.dialect().name() {
"sqlite" => AGENT_ENSURE_SQL_SQLITE,
"mysql" => AGENT_ENSURE_SQL_MYSQL,
_ => AGENT_ENSURE_SQL,
};
for stmt in ddl.split(';').map(str::trim).filter(|s| !s.is_empty()) {
crate::sql::raw_execute_pool(pool, stmt, Vec::new())
.await
.map_err(|e| match e {
ExecError::Driver(err) => err,
other => sqlx::Error::Protocol(format!("{other}")),
})?;
}
Ok(())
}
pub struct AgentSecret {
pub agent: Agent,
pub token: String,
}
#[derive(Debug, thiserror::Error)]
pub enum AgentError {
#[error("agent `{0}` already exists in this tenant")]
Duplicate(String),
#[error("agent `{0}` not found in this tenant")]
NotFound(String),
#[error("secret generation failed: {0}")]
Secret(String),
#[error(transparent)]
Db(#[from] ExecError),
#[error(transparent)]
Driver(#[from] sqlx::Error),
}
fn generate_credential() -> Result<(String, String, String), AgentError> {
use rand::rngs::OsRng;
use rand::RngCore;
let mut prefix_bytes = [0u8; 4];
OsRng.fill_bytes(&mut prefix_bytes);
let prefix = to_hex(&prefix_bytes);
let mut secret_bytes = [0u8; 16];
OsRng.fill_bytes(&mut secret_bytes);
let secret = to_hex(&secret_bytes);
let hash = super::password::hash(&secret).map_err(|e| AgentError::Secret(e.to_string()))?;
Ok((format!("{prefix}.{secret}"), prefix, hash))
}
fn to_hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
pub async fn create_agent_pool(pool: &Pool, name: &str) -> Result<AgentSecret, AgentError> {
use crate::core::Column as _;
use crate::sql::FetcherPool as _;
ensure_agents_table_pool(pool).await?;
let existing: Vec<Agent> = Agent::objects()
.where_(Agent::name.eq(name))
.limit(1)
.fetch(pool)
.await?;
if !existing.is_empty() {
return Err(AgentError::Duplicate(name.to_owned()));
}
let (token, prefix, hash) = generate_credential()?;
let mut agent = Agent {
id: Auto::default(),
name: name.to_owned(),
secret_prefix: prefix,
secret_hash: hash,
active: true,
created_at: Auto::default(),
secret_rotated_at: None,
data: serde_json::json!({}),
};
agent.insert_pool(pool).await?;
Ok(AgentSecret { agent, token })
}
pub async fn rotate_agent_secret_pool(pool: &Pool, name: &str) -> Result<AgentSecret, AgentError> {
use crate::core::Column as _;
use crate::sql::FetcherPool as _;
ensure_agents_table_pool(pool).await?;
let mut agent: Agent = Agent::objects()
.where_(Agent::name.eq(name))
.limit(1)
.fetch(pool)
.await?
.into_iter()
.next()
.ok_or_else(|| AgentError::NotFound(name.to_owned()))?;
let (token, prefix, hash) = generate_credential()?;
agent.secret_prefix = prefix;
agent.secret_hash = hash;
agent.secret_rotated_at = Some(chrono::Utc::now());
agent.save_pool(pool).await?;
Ok(AgentSecret { agent, token })
}
pub async fn list_agents_pool(pool: &Pool) -> Result<Vec<Agent>, AgentError> {
use crate::sql::FetcherPool as _;
ensure_agents_table_pool(pool).await?;
let agents = Agent::objects()
.order_by(&[("name", false)])
.fetch(pool)
.await?;
Ok(agents)
}
pub async fn authenticate_agent_pool(
pool: &Pool,
name: &str,
secret: &str,
) -> Result<Option<Agent>, AgentError> {
use crate::core::Column as _;
use crate::sql::FetcherPool as _;
ensure_agents_table_pool(pool).await?;
let secret_half = secret.rsplit('.').next().unwrap_or(secret);
let Some(agent) = Agent::objects()
.where_(Agent::name.eq(name))
.limit(1)
.fetch(pool)
.await?
.into_iter()
.next()
.filter(|a| a.active)
else {
super::password::verify_dummy(secret_half);
return Ok(None);
};
match super::password::verify(secret_half, &agent.secret_hash) {
Ok(true) => Ok(Some(agent)),
_ => Ok(None),
}
}
#[derive(crate::Model, Debug, Clone)]
#[rustango(
table = "rustango_agent_skills",
display = "codename",
admin(
list_display = "codename, name, description",
search_fields = "codename, name",
ordering = "codename",
)
)]
pub struct AgentSkill {
#[rustango(primary_key)]
pub id: Auto<i64>,
#[rustango(max_length = 100, unique)]
pub codename: String,
#[rustango(max_length = 150)]
pub name: String,
#[rustango(max_length = 500)]
pub description: String,
pub instructions: String,
#[rustango(default = "'{}'")]
pub data: serde_json::Value,
}
#[derive(crate::Model, Debug, Clone)]
#[rustango(table = "rustango_agent_skill_tools")]
pub struct AgentSkillTool {
#[rustango(primary_key)]
pub id: Auto<i64>,
#[rustango(fk = "rustango_agent_skills", on = "id", on_delete = "cascade")]
pub skill_id: i64,
#[rustango(max_length = 150)]
pub tool_name: String,
}
#[derive(crate::Model, Debug, Clone)]
#[rustango(
table = "rustango_agent_grants",
admin(list_display = "agent_id, skill_id", ordering = "agent_id")
)]
pub struct AgentGrant {
#[rustango(primary_key)]
pub id: Auto<i64>,
#[rustango(fk = "rustango_agents", on = "id", on_delete = "cascade")]
pub agent_id: i64,
#[rustango(fk = "rustango_agent_skills", on = "id", on_delete = "cascade")]
pub skill_id: i64,
#[rustango(default = "'{}'")]
pub data: serde_json::Value,
}
const SKILLS_ENSURE_SQL: &str = r#"
CREATE TABLE IF NOT EXISTS "rustango_agent_skills" (
"id" BIGSERIAL PRIMARY KEY,
"codename" VARCHAR(100) NOT NULL,
"name" VARCHAR(150) NOT NULL DEFAULT '',
"description" VARCHAR(500) NOT NULL DEFAULT '',
"instructions" TEXT NOT NULL DEFAULT '',
"data" JSONB NOT NULL DEFAULT '{}',
CONSTRAINT "rustango_agent_skills_codename_uq" UNIQUE ("codename")
);
CREATE TABLE IF NOT EXISTS "rustango_agent_skill_tools" (
"id" BIGSERIAL PRIMARY KEY,
"skill_id" BIGINT NOT NULL REFERENCES "rustango_agent_skills"("id") ON DELETE CASCADE,
"tool_name" VARCHAR(150) NOT NULL
);
CREATE TABLE IF NOT EXISTS "rustango_agent_grants" (
"id" BIGSERIAL PRIMARY KEY,
"agent_id" BIGINT NOT NULL REFERENCES "rustango_agents"("id") ON DELETE CASCADE,
"skill_id" BIGINT NOT NULL REFERENCES "rustango_agent_skills"("id") ON DELETE CASCADE,
"data" JSONB NOT NULL DEFAULT '{}',
CONSTRAINT "rustango_agent_grants_uq" UNIQUE ("agent_id", "skill_id")
);"#;
const SKILLS_ENSURE_SQL_SQLITE: &str = r#"
CREATE TABLE IF NOT EXISTS "rustango_agent_skills" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"codename" TEXT NOT NULL,
"name" TEXT NOT NULL DEFAULT '',
"description" TEXT NOT NULL DEFAULT '',
"instructions" TEXT NOT NULL DEFAULT '',
"data" TEXT NOT NULL DEFAULT '{}',
CONSTRAINT "rustango_agent_skills_codename_uq" UNIQUE ("codename")
);
CREATE TABLE IF NOT EXISTS "rustango_agent_skill_tools" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"skill_id" INTEGER NOT NULL REFERENCES "rustango_agent_skills"("id") ON DELETE CASCADE,
"tool_name" TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS "rustango_agent_grants" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"agent_id" INTEGER NOT NULL REFERENCES "rustango_agents"("id") ON DELETE CASCADE,
"skill_id" INTEGER NOT NULL REFERENCES "rustango_agent_skills"("id") ON DELETE CASCADE,
"data" TEXT NOT NULL DEFAULT '{}',
CONSTRAINT "rustango_agent_grants_uq" UNIQUE ("agent_id", "skill_id")
);"#;
const SKILLS_ENSURE_SQL_MYSQL: &str = r#"
CREATE TABLE IF NOT EXISTS `rustango_agent_skills` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY,
`codename` VARCHAR(100) NOT NULL,
`name` VARCHAR(150) NOT NULL DEFAULT '',
`description` VARCHAR(500) NOT NULL DEFAULT '',
`instructions` TEXT NOT NULL,
`data` JSON,
CONSTRAINT `rustango_agent_skills_codename_uq` UNIQUE (`codename`)
);
CREATE TABLE IF NOT EXISTS `rustango_agent_skill_tools` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY,
`skill_id` BIGINT NOT NULL,
`tool_name` VARCHAR(150) NOT NULL,
CONSTRAINT `rustango_agent_skill_tools_fk` FOREIGN KEY (`skill_id`)
REFERENCES `rustango_agent_skills`(`id`) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS `rustango_agent_grants` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY,
`agent_id` BIGINT NOT NULL,
`skill_id` BIGINT NOT NULL,
`data` JSON,
CONSTRAINT `rustango_agent_grants_uq` UNIQUE (`agent_id`, `skill_id`),
CONSTRAINT `rustango_agent_grants_fk_agent` FOREIGN KEY (`agent_id`)
REFERENCES `rustango_agents`(`id`) ON DELETE CASCADE,
CONSTRAINT `rustango_agent_grants_fk_skill` FOREIGN KEY (`skill_id`)
REFERENCES `rustango_agent_skills`(`id`) ON DELETE CASCADE
);"#;
pub async fn ensure_skill_tables_pool(pool: &Pool) -> Result<(), sqlx::Error> {
let ddl = match pool.dialect().name() {
"sqlite" => SKILLS_ENSURE_SQL_SQLITE,
"mysql" => SKILLS_ENSURE_SQL_MYSQL,
_ => SKILLS_ENSURE_SQL,
};
for stmt in ddl.split(';').map(str::trim).filter(|s| !s.is_empty()) {
crate::sql::raw_execute_pool(pool, stmt, Vec::new())
.await
.map_err(|e| match e {
ExecError::Driver(err) => err,
other => sqlx::Error::Protocol(format!("{other}")),
})?;
}
Ok(())
}
pub async fn create_skill_pool(
pool: &Pool,
codename: &str,
name: &str,
description: &str,
instructions: &str,
tools: &[String],
) -> Result<AgentSkill, AgentError> {
use crate::core::Column as _;
use crate::sql::FetcherPool as _;
ensure_skill_tables_pool(pool).await?;
let existing: Vec<AgentSkill> = AgentSkill::objects()
.where_(AgentSkill::codename.eq(codename))
.limit(1)
.fetch(pool)
.await?;
if !existing.is_empty() {
return Err(AgentError::Duplicate(codename.to_owned()));
}
let mut skill = AgentSkill {
id: Auto::default(),
codename: codename.to_owned(),
name: name.to_owned(),
description: description.to_owned(),
instructions: instructions.to_owned(),
data: serde_json::json!({}),
};
skill.insert_pool(pool).await?;
let skill_id = skill.id.get().copied().unwrap_or_default();
for tool in tools {
let mut row = AgentSkillTool {
id: Auto::default(),
skill_id,
tool_name: tool.clone(),
};
row.insert_pool(pool).await?;
}
Ok(skill)
}
pub async fn list_skills_pool(pool: &Pool) -> Result<Vec<AgentSkill>, AgentError> {
use crate::sql::FetcherPool as _;
ensure_skill_tables_pool(pool).await?;
Ok(AgentSkill::objects()
.order_by(&[("codename", false)])
.fetch(pool)
.await?)
}
pub async fn grant_skill_pool(
pool: &Pool,
slug: &str,
agent_name: &str,
skill_codename: &str,
) -> Result<(), AgentError> {
use crate::core::Column as _;
use crate::sql::FetcherPool as _;
ensure_agents_table_pool(pool).await?;
ensure_skill_tables_pool(pool).await?;
let (agent_id, skill_id) = resolve_ids(pool, agent_name, skill_codename).await?;
let existing: Vec<AgentGrant> = AgentGrant::objects()
.where_(AgentGrant::agent_id.eq(agent_id))
.where_(AgentGrant::skill_id.eq(skill_id))
.limit(1)
.fetch(pool)
.await?;
if existing.is_empty() {
let mut grant = AgentGrant {
id: Auto::default(),
agent_id,
skill_id,
data: serde_json::json!({}),
};
grant.insert_pool(pool).await?;
notify_grants_changed(slug, agent_id);
}
Ok(())
}
pub async fn revoke_skill_pool(
pool: &Pool,
slug: &str,
agent_name: &str,
skill_codename: &str,
) -> Result<(), AgentError> {
use crate::core::Column as _;
use crate::sql::FetcherPool as _;
ensure_agents_table_pool(pool).await?;
ensure_skill_tables_pool(pool).await?;
let (agent_id, skill_id) = resolve_ids(pool, agent_name, skill_codename).await?;
let grants: Vec<AgentGrant> = AgentGrant::objects()
.where_(AgentGrant::agent_id.eq(agent_id))
.where_(AgentGrant::skill_id.eq(skill_id))
.fetch(pool)
.await?;
let had_grant = !grants.is_empty();
for grant in grants {
grant.delete_pool(pool).await?;
}
if had_grant {
notify_grants_changed(slug, agent_id);
}
Ok(())
}
#[allow(unused_variables)]
fn notify_grants_changed(slug: &str, agent_id: i64) {
#[cfg(feature = "mcp")]
{
crate::mcp::notify_tools_list_changed(slug, Some(agent_id));
crate::mcp::notify_prompts_list_changed(slug, Some(agent_id));
crate::mcp::notify_resources_list_changed(slug, Some(agent_id));
}
}
async fn resolve_ids(
pool: &Pool,
agent_name: &str,
skill_codename: &str,
) -> Result<(i64, i64), AgentError> {
use crate::core::Column as _;
use crate::sql::FetcherPool as _;
let agent = Agent::objects()
.where_(Agent::name.eq(agent_name))
.limit(1)
.fetch(pool)
.await?
.into_iter()
.next()
.ok_or_else(|| AgentError::NotFound(agent_name.to_owned()))?;
let skill = AgentSkill::objects()
.where_(AgentSkill::codename.eq(skill_codename))
.limit(1)
.fetch(pool)
.await?
.into_iter()
.next()
.ok_or_else(|| AgentError::NotFound(skill_codename.to_owned()))?;
Ok((
agent.id.get().copied().unwrap_or_default(),
skill.id.get().copied().unwrap_or_default(),
))
}
pub async fn resolve_agent_grants_pool(
pool: &Pool,
agent_id: i64,
) -> Result<(Vec<String>, Vec<String>), AgentError> {
use crate::core::Column as _;
use crate::sql::FetcherPool as _;
ensure_skill_tables_pool(pool).await?;
let grants: Vec<AgentGrant> = AgentGrant::objects()
.where_(AgentGrant::agent_id.eq(agent_id))
.fetch(pool)
.await?;
let skill_ids: Vec<i64> = grants.iter().map(|g| g.skill_id).collect();
if skill_ids.is_empty() {
return Ok((vec![], vec![]));
}
let skills: Vec<AgentSkill> = AgentSkill::objects()
.where_(AgentSkill::id.is_in(skill_ids.clone()))
.fetch(pool)
.await?;
let skill_codenames: Vec<String> = skills.into_iter().map(|s| s.codename).collect();
let skill_tools: Vec<AgentSkillTool> = AgentSkillTool::objects()
.where_(AgentSkillTool::skill_id.is_in(skill_ids))
.fetch(pool)
.await?;
let mut tools: Vec<String> = Vec::new();
for st in skill_tools {
if !tools.contains(&st.tool_name) {
tools.push(st.tool_name);
}
}
Ok((skill_codenames, tools))
}
#[derive(crate::Model, Debug, Clone)]
#[rustango(table = "rustango_agent_skill_resources")]
pub struct AgentSkillResource {
#[rustango(primary_key)]
pub id: Auto<i64>,
#[rustango(fk = "rustango_agent_skills", on = "id", on_delete = "cascade")]
pub skill_id: i64,
#[rustango(max_length = 500)]
pub resource_uri: String,
#[rustango(max_length = 100)]
pub mime: String,
#[rustango(default = "'{}'")]
pub data: serde_json::Value,
}
const RESOURCES_ENSURE_SQL: &str = r#"
CREATE TABLE IF NOT EXISTS "rustango_agent_skill_resources" (
"id" BIGSERIAL PRIMARY KEY,
"skill_id" BIGINT NOT NULL REFERENCES "rustango_agent_skills"("id") ON DELETE CASCADE,
"resource_uri" VARCHAR(500) NOT NULL,
"mime" VARCHAR(100) NOT NULL DEFAULT 'text/plain',
"data" JSONB NOT NULL DEFAULT '{}'
);"#;
const RESOURCES_ENSURE_SQL_SQLITE: &str = r#"
CREATE TABLE IF NOT EXISTS "rustango_agent_skill_resources" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"skill_id" INTEGER NOT NULL REFERENCES "rustango_agent_skills"("id") ON DELETE CASCADE,
"resource_uri" TEXT NOT NULL,
"mime" TEXT NOT NULL DEFAULT 'text/plain',
"data" TEXT NOT NULL DEFAULT '{}'
);"#;
const RESOURCES_ENSURE_SQL_MYSQL: &str = r#"
CREATE TABLE IF NOT EXISTS `rustango_agent_skill_resources` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY,
`skill_id` BIGINT NOT NULL,
`resource_uri` VARCHAR(500) NOT NULL,
`mime` VARCHAR(100) NOT NULL DEFAULT 'text/plain',
`data` JSON,
CONSTRAINT `rustango_agent_skill_resources_fk` FOREIGN KEY (`skill_id`)
REFERENCES `rustango_agent_skills`(`id`) ON DELETE CASCADE
);"#;
async fn ensure_resources_table_pool(pool: &Pool) -> Result<(), sqlx::Error> {
let ddl = match pool.dialect().name() {
"sqlite" => RESOURCES_ENSURE_SQL_SQLITE,
"mysql" => RESOURCES_ENSURE_SQL_MYSQL,
_ => RESOURCES_ENSURE_SQL,
};
for stmt in ddl.split(';').map(str::trim).filter(|s| !s.is_empty()) {
crate::sql::raw_execute_pool(pool, stmt, Vec::new())
.await
.map_err(|e| match e {
ExecError::Driver(err) => err,
other => sqlx::Error::Protocol(format!("{other}")),
})?;
}
Ok(())
}
pub async fn add_skill_resource_pool(
pool: &Pool,
skill_codename: &str,
uri: &str,
mime: &str,
body: &str,
) -> Result<AgentSkillResource, AgentError> {
use crate::core::Column as _;
use crate::sql::FetcherPool as _;
ensure_skill_tables_pool(pool).await?;
ensure_resources_table_pool(pool).await?;
let skill_id = AgentSkill::objects()
.where_(AgentSkill::codename.eq(skill_codename))
.limit(1)
.fetch(pool)
.await?
.into_iter()
.next()
.ok_or_else(|| AgentError::NotFound(skill_codename.to_owned()))?
.id
.get()
.copied()
.unwrap_or_default();
let mut res = AgentSkillResource {
id: Auto::default(),
skill_id,
resource_uri: uri.to_owned(),
mime: mime.to_owned(),
data: serde_json::json!({ "text": body }),
};
res.insert_pool(pool).await?;
Ok(res)
}
pub async fn skills_by_codenames_pool(
pool: &Pool,
codenames: &[String],
) -> Result<Vec<AgentSkill>, AgentError> {
use crate::core::Column as _;
use crate::sql::FetcherPool as _;
ensure_skill_tables_pool(pool).await?;
if codenames.is_empty() {
return Ok(vec![]);
}
Ok(AgentSkill::objects()
.where_(AgentSkill::codename.is_in(codenames.to_vec()))
.order_by(&[("codename", false)])
.fetch(pool)
.await?)
}
pub async fn resources_for_skills_pool(
pool: &Pool,
codenames: &[String],
) -> Result<Vec<AgentSkillResource>, AgentError> {
use crate::core::Column as _;
use crate::sql::FetcherPool as _;
ensure_skill_tables_pool(pool).await?;
ensure_resources_table_pool(pool).await?;
if codenames.is_empty() {
return Ok(vec![]);
}
let skill_ids: Vec<i64> = AgentSkill::objects()
.where_(AgentSkill::codename.is_in(codenames.to_vec()))
.fetch(pool)
.await?
.into_iter()
.map(|s| s.id.get().copied().unwrap_or_default())
.collect();
if skill_ids.is_empty() {
return Ok(vec![]);
}
Ok(AgentSkillResource::objects()
.where_(AgentSkillResource::skill_id.is_in(skill_ids))
.fetch(pool)
.await?)
}