use crate::client::KibanaClient;
use crate::etl::Loader;
use crate::{Error, Result};
use reqwest::Method;
use serde_json::Value;
use std::collections::HashMap;
use tokio::task::JoinSet;
pub struct SkillsLoader {
client: KibanaClient,
}
impl SkillsLoader {
pub fn new(client: KibanaClient) -> Self {
Self { client }
}
pub async fn delete_skill(&self, skill_id: &str, force: bool) -> Result<()> {
let path = if force {
format!("api/agent_builder/skills/{skill_id}?force=true")
} else {
format!("api/agent_builder/skills/{skill_id}")
};
let response = self
.client
.request(Method::DELETE, &HashMap::new(), &path, None)
.await?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(Error::api_response(status, body));
}
Ok(())
}
}
impl Loader for SkillsLoader {
type Item = Value;
async fn load(&self, items: Vec<Self::Item>) -> Result<usize> {
let mut count = 0;
let mut set = JoinSet::new();
for skill in items {
let client = self.client.clone();
set.spawn(async move { upsert_skill(client, skill).await });
}
while let Some(result) = set.join_next().await {
match result {
Ok(Ok(loaded)) => {
if loaded {
count += 1;
}
}
Ok(Err(err)) => tracing::error!("Failed to load skill: {}", err),
Err(err) => tracing::error!("Task panicked: {}", err),
}
}
Ok(count)
}
}
async fn upsert_skill(client: KibanaClient, skill: Value) -> Result<bool> {
let skill_id = skill
.get("id")
.and_then(|value| value.as_str())
.ok_or(Error::MissingResourceId { resource: "skill" })?;
if is_readonly(&skill) {
tracing::debug!("Skipping readonly skill: {}", skill_id);
return Ok(false);
}
let existing = existing_skill(&client, skill_id).await?;
if let Some(existing) = existing {
if is_readonly(&existing) {
tracing::debug!("Skipping server-side readonly skill: {}", skill_id);
return Ok(false);
}
let body = sanitized_skill_body(&skill, false);
let path = format!("api/agent_builder/skills/{skill_id}");
let response = client.put_json_value(&path, &body).await?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(Error::api_response(status, body));
}
tracing::info!("Updated skill: {}", skill_id);
} else {
let body = sanitized_skill_body(&skill, true);
let response = client
.post_json_value("api/agent_builder/skills", &body)
.await?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(Error::api_response(status, body));
}
tracing::info!("Created skill: {}", skill_id);
}
Ok(true)
}
async fn existing_skill(client: &KibanaClient, skill_id: &str) -> Result<Option<Value>> {
let path = format!("api/agent_builder/skills/{skill_id}");
let response = client.get(&path).await?;
match response.status().as_u16() {
200 => Ok(Some(response.json().await?)),
404 => Ok(None),
_ => {
let status = response.status();
let body = response.text().await.unwrap_or_default();
Err(Error::api_response(status, body))
}
}
}
fn is_readonly(skill: &Value) -> bool {
skill.get("readonly").and_then(|value| value.as_bool()) == Some(true)
}
fn sanitized_skill_body(skill: &Value, include_id: bool) -> Value {
let mut body = skill.clone();
let Some(object) = body.as_object_mut() else {
return body;
};
if !include_id {
object.remove("id");
}
for field in [
"readonly",
"schema",
"type",
"built_in",
"source",
"created_at",
"updated_at",
"experimental",
] {
object.remove(field);
}
if !matches!(object.get("tool_ids"), Some(Value::Array(_))) {
object.insert("tool_ids".to_string(), Value::Array(Vec::new()));
}
if !matches!(object.get("referenced_content"), Some(Value::Array(_))) {
object.insert("referenced_content".to_string(), Value::Array(Vec::new()));
}
body
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{MockResponse, TestServer};
use serde_json::json;
#[test]
fn update_body_removes_id_and_readonly() {
let body = sanitized_skill_body(
&json!({
"id": "skill-a",
"name": "Skill A",
"readonly": true,
"experimental": true,
"tool_ids": [],
"referenced_content": []
}),
false,
);
assert!(body.get("id").is_none());
assert!(body.get("readonly").is_none());
assert!(body.get("experimental").is_none());
assert_eq!(body["tool_ids"], json!([]));
assert_eq!(body["referenced_content"], json!([]));
}
#[test]
fn create_body_keeps_id_and_adds_empty_arrays() {
let body = sanitized_skill_body(
&json!({
"id": "skill-a",
"name": "Skill A"
}),
true,
);
assert_eq!(body["id"], "skill-a");
assert_eq!(body["tool_ids"], json!([]));
assert_eq!(body["referenced_content"], json!([]));
}
#[test]
fn body_replaces_non_array_fields_with_empty_arrays() {
let body = sanitized_skill_body(
&json!({
"id": "skill-a",
"name": "Skill A",
"tool_ids": null,
"referenced_content": null
}),
true,
);
assert_eq!(body["tool_ids"], json!([]));
assert_eq!(body["referenced_content"], json!([]));
}
#[tokio::test]
async fn creates_missing_skill_with_post_body() {
let server = TestServer::new(vec![
MockResponse {
method: "GET",
path: "/s/esdiag/api/agent_builder/skills/skill-a",
status: 404,
body: json!({"error": "not found"}),
},
MockResponse {
method: "POST",
path: "/s/esdiag/api/agent_builder/skills",
status: 200,
body: json!({"id": "skill-a"}),
},
]);
let client = server.client().unwrap().space("esdiag").unwrap();
let loader = SkillsLoader::new(client);
let count = loader
.load(vec![json!({
"id": "skill-a",
"name": "Skill A",
"readonly": false,
"schema": "system"
})])
.await
.unwrap();
assert_eq!(count, 1);
let requests = server.requests();
assert_eq!(requests[0].method, "GET");
assert_eq!(requests[1].method, "POST");
assert_eq!(
requests[1].headers.get("kbn-xsrf").map(String::as_str),
Some("true")
);
let body: Value = serde_json::from_str(&requests[1].body).unwrap();
assert_eq!(body["id"], "skill-a");
assert!(body.get("readonly").is_none());
assert!(body.get("schema").is_none());
assert_eq!(body["tool_ids"], json!([]));
assert_eq!(body["referenced_content"], json!([]));
}
#[tokio::test]
async fn updates_existing_skill_without_id() {
let server = TestServer::new(vec![
MockResponse {
method: "GET",
path: "/s/esdiag/api/agent_builder/skills/skill-a",
status: 200,
body: json!({"id": "skill-a"}),
},
MockResponse {
method: "PUT",
path: "/s/esdiag/api/agent_builder/skills/skill-a",
status: 200,
body: json!({"id": "skill-a"}),
},
]);
let client = server.client().unwrap().space("esdiag").unwrap();
let loader = SkillsLoader::new(client);
let count = loader
.load(vec![json!({
"id": "skill-a",
"name": "Skill A",
"readonly": false
})])
.await
.unwrap();
assert_eq!(count, 1);
let requests = server.requests();
assert_eq!(requests[1].method, "PUT");
let body: Value = serde_json::from_str(&requests[1].body).unwrap();
assert!(body.get("id").is_none());
assert!(body.get("readonly").is_none());
assert_eq!(body["tool_ids"], json!([]));
assert_eq!(body["referenced_content"], json!([]));
}
#[tokio::test]
async fn skips_existing_server_side_readonly_skill() {
let server = TestServer::new(vec![MockResponse {
method: "GET",
path: "/s/esdiag/api/agent_builder/skills/system-skill",
status: 200,
body: json!({"id": "system-skill", "readonly": true}),
}]);
let client = server.client().unwrap().space("esdiag").unwrap();
let loader = SkillsLoader::new(client);
let count = loader
.load(vec![json!({
"id": "system-skill",
"name": "System Skill"
})])
.await
.unwrap();
assert_eq!(count, 0);
let requests = server.requests();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].method, "GET");
}
#[tokio::test]
async fn deletes_skill_without_force() {
let server = TestServer::new(vec![MockResponse {
method: "DELETE",
path: "/s/esdiag/api/agent_builder/skills/skill-a",
status: 200,
body: json!({}),
}]);
let client = server.client().unwrap().space("esdiag").unwrap();
let loader = SkillsLoader::new(client);
loader.delete_skill("skill-a", false).await.unwrap();
assert_eq!(server.requests()[0].method, "DELETE");
}
#[tokio::test]
async fn deletes_skill_with_force_query() {
let server = TestServer::new(vec![MockResponse {
method: "DELETE",
path: "/s/esdiag/api/agent_builder/skills/skill-a?force=true",
status: 200,
body: json!({}),
}]);
let client = server.client().unwrap().space("esdiag").unwrap();
let loader = SkillsLoader::new(client);
loader.delete_skill("skill-a", true).await.unwrap();
assert_eq!(
server.requests()[0].path,
"/s/esdiag/api/agent_builder/skills/skill-a?force=true"
);
}
#[tokio::test]
async fn delete_conflict_preserves_response_details() {
let server = TestServer::new(vec![MockResponse {
method: "DELETE",
path: "/s/esdiag/api/agent_builder/skills/skill-a",
status: 409,
body: json!({"message": "skill is referenced by agents"}),
}]);
let client = server.client().unwrap().space("esdiag").unwrap();
let loader = SkillsLoader::new(client);
let err = loader.delete_skill("skill-a", false).await.unwrap_err();
let message = err.to_string();
assert!(message.contains("409 Conflict"));
assert!(message.contains("skill is referenced by agents"));
}
}