1use crate::client::{ApiCapability, KibanaClient, KibanaVersion};
2use crate::etl::{Extractor, Loader};
3use crate::kibana::agents::{AgentsExtractor, AgentsLoader};
4use crate::kibana::dependencies::{
5 Dependency, find_agent_dependencies, find_skill_dependencies, find_tool_dependencies,
6 find_workflow_dependencies,
7};
8use crate::kibana::saved_objects::{
9 SavedObjectsExtractor, SavedObjectsLoader, SavedObjectsManifest,
10};
11use crate::kibana::skills::{SkillsExtractor, SkillsLoader};
12use crate::kibana::spaces::{SpacesExtractor, SpacesLoader};
13use crate::kibana::tools::{ToolsExtractor, ToolsLoader};
14use crate::kibana::workflows::{WorkflowsExtractor, WorkflowsLoader, workflow_resource_path};
15use crate::{Error, Result};
16use serde_json::Value;
17use std::collections::{HashMap, HashSet};
18use tokio::task::JoinSet;
19
20const SKILL_FETCH_BATCH_SIZE: usize = 16;
21
22#[derive(Clone, Debug, Eq, PartialEq)]
23pub enum UnsupportedApiPolicy {
24 Skip,
25 Warn,
26 Force,
27}
28
29#[derive(Clone, Debug)]
30pub struct SyncSelection {
31 pub spaces: Vec<String>,
32 pub saved_objects: Option<SavedObjectsManifest>,
33 pub include_spaces: bool,
34 pub include_workflows: bool,
35 pub include_agents: bool,
36 pub include_tools: bool,
37 pub include_skills: bool,
38}
39
40impl Default for SyncSelection {
41 fn default() -> Self {
42 Self {
43 spaces: vec!["default".to_string()],
44 saved_objects: None,
45 include_spaces: false,
46 include_workflows: false,
47 include_agents: false,
48 include_tools: false,
49 include_skills: false,
50 }
51 }
52}
53
54#[derive(Clone, Debug)]
55pub struct SyncOptions {
56 pub expand_dependencies: bool,
57 pub overwrite: bool,
58 pub unsupported_api_policy: UnsupportedApiPolicy,
59}
60
61impl Default for SyncOptions {
62 fn default() -> Self {
63 Self {
64 expand_dependencies: true,
65 overwrite: true,
66 unsupported_api_policy: UnsupportedApiPolicy::Warn,
67 }
68 }
69}
70
71#[derive(Clone, Debug, Default, PartialEq)]
72pub struct SpaceBundle {
73 pub saved_objects: Vec<Value>,
74 pub workflows: Vec<Value>,
75 pub agents: Vec<Value>,
76 pub tools: Vec<Value>,
77 pub skills: Vec<Value>,
78}
79
80#[derive(Clone, Debug, Default, PartialEq)]
81pub struct SyncBundle {
82 pub spaces: Vec<Value>,
83 pub by_space: HashMap<String, SpaceBundle>,
84}
85
86#[derive(Clone, Debug, Default, Eq, PartialEq)]
87pub struct SyncSummary {
88 pub spaces_attempted: usize,
89 pub spaces_applied: usize,
90 pub saved_objects_attempted: usize,
91 pub saved_objects_applied: usize,
92 pub workflows_attempted: usize,
93 pub workflows_applied: usize,
94 pub agents_attempted: usize,
95 pub agents_applied: usize,
96 pub tools_attempted: usize,
97 pub tools_applied: usize,
98 pub skills_attempted: usize,
99 pub skills_applied: usize,
100}
101
102#[derive(Clone, Debug, Eq, PartialEq)]
103pub struct CapabilityPlan {
104 pub supported: Vec<ApiCapability>,
105 pub unsupported: Vec<ApiCapabilityWarning>,
106}
107
108#[derive(Clone, Debug, Eq, PartialEq)]
109pub struct ApiCapabilityWarning {
110 pub capability: ApiCapability,
111 pub message: String,
112}
113
114#[derive(Clone, Copy, Debug, Eq, PartialEq)]
115pub struct DependencyExpansionCapabilities {
116 pub agents: bool,
117 pub skills: bool,
118 pub tools: bool,
119 pub workflows: bool,
120}
121
122impl DependencyExpansionCapabilities {
123 pub fn all() -> Self {
124 Self {
125 agents: true,
126 skills: true,
127 tools: true,
128 workflows: true,
129 }
130 }
131}
132
133impl Default for DependencyExpansionCapabilities {
134 fn default() -> Self {
135 Self::all()
136 }
137}
138
139pub fn plan_capabilities(
140 version: &KibanaVersion,
141 capabilities: impl IntoIterator<Item = ApiCapability>,
142) -> CapabilityPlan {
143 let mut supported = Vec::new();
144 let mut unsupported = Vec::new();
145
146 for capability in capabilities {
147 if KibanaClient::supports_capability(version, capability) {
148 supported.push(capability);
149 } else {
150 unsupported.push(ApiCapabilityWarning {
151 capability,
152 message: KibanaClient::unsupported_capability_reason(version, capability),
153 });
154 }
155 }
156
157 CapabilityPlan {
158 supported,
159 unsupported,
160 }
161}
162
163pub async fn pull_sync(
164 client: &KibanaClient,
165 selection: &SyncSelection,
166 options: &SyncOptions,
167) -> Result<SyncBundle> {
168 let mut bundle = SyncBundle::default();
169 let include_spaces = selection.include_spaces
170 && capability_allowed(
171 client,
172 ApiCapability::Spaces,
173 &options.unsupported_api_policy,
174 )
175 .await?;
176 let include_saved_objects = selection.saved_objects.is_some()
177 && capability_allowed(
178 client,
179 ApiCapability::SavedObjects,
180 &options.unsupported_api_policy,
181 )
182 .await?;
183 let include_workflows = selection.include_workflows
184 && capability_allowed(
185 client,
186 ApiCapability::Workflows,
187 &options.unsupported_api_policy,
188 )
189 .await?;
190 let include_agents = selection.include_agents
191 && capability_allowed(
192 client,
193 ApiCapability::Agents,
194 &options.unsupported_api_policy,
195 )
196 .await?;
197 let include_tools = selection.include_tools
198 && capability_allowed(
199 client,
200 ApiCapability::Tools,
201 &options.unsupported_api_policy,
202 )
203 .await?;
204 let include_skills = selection.include_skills
205 && capability_allowed(
206 client,
207 ApiCapability::Skills,
208 &options.unsupported_api_policy,
209 )
210 .await?;
211
212 if include_spaces {
213 bundle.spaces = SpacesExtractor::all(client.clone()).extract().await?;
214 }
215
216 for space_id in &selection.spaces {
217 let space_client = client.space(space_id)?;
218 let mut space_bundle = SpaceBundle::default();
219
220 if include_saved_objects && let Some(manifest) = &selection.saved_objects {
221 space_bundle.saved_objects =
222 SavedObjectsExtractor::new(space_client.clone(), manifest.clone())
223 .extract()
224 .await?;
225 }
226
227 if include_workflows {
228 space_bundle.workflows = WorkflowsExtractor::new(space_client.clone(), None)
229 .search_workflows(None, None)
230 .await?;
231 }
232
233 if include_agents {
234 space_bundle.agents = AgentsExtractor::new(space_client.clone(), None)
235 .search_agents(None)
236 .await?;
237 }
238
239 if include_tools {
240 space_bundle.tools = ToolsExtractor::new(space_client.clone(), None)
241 .search_tools(None)
242 .await?;
243 }
244
245 if include_skills {
246 let extractor = SkillsExtractor::new(space_client.clone(), None);
247 let skills = extractor.search_skills(false).await?;
248 let skill_ids = skills
249 .iter()
250 .filter(|skill| !is_readonly(skill))
251 .enumerate()
252 .filter_map(|(index, skill)| {
253 skill
254 .get("id")
255 .and_then(|id| id.as_str())
256 .map(|skill_id| (index, skill_id.to_string()))
257 })
258 .collect::<Vec<_>>();
259
260 let mut fetched_skills = Vec::new();
261 for chunk in skill_ids.chunks(SKILL_FETCH_BATCH_SIZE) {
262 let mut set = JoinSet::new();
263 for (index, skill_id) in chunk {
264 let extractor = SkillsExtractor::new(space_client.clone(), None);
265 let index = *index;
266 let skill_id = skill_id.clone();
267 set.spawn(async move {
268 let skill = extractor.fetch_skill(&skill_id).await?;
269 Ok::<_, Error>((index, skill))
270 });
271 }
272
273 while let Some(result) = set.join_next().await {
274 match result {
275 Ok(Ok((index, skill))) if !is_readonly(&skill) => {
276 fetched_skills.push((index, skill));
277 }
278 Ok(Ok(_)) => {}
279 Ok(Err(err)) => return Err(err),
280 Err(err) => return Err(Error::message(format!("Task panicked: {err}"))),
281 }
282 }
283 }
284
285 fetched_skills.sort_by_key(|(index, _)| *index);
286 space_bundle
287 .skills
288 .extend(fetched_skills.into_iter().map(|(_, skill)| skill));
289 }
290
291 if options.expand_dependencies
292 && (include_agents || include_skills || include_tools || include_workflows)
293 {
294 expand_dependencies(
295 client,
296 space_id,
297 &mut space_bundle,
298 DependencyExpansionCapabilities {
299 agents: include_agents,
300 skills: include_skills,
301 tools: include_tools,
302 workflows: include_workflows,
303 },
304 )
305 .await?;
306 }
307
308 bundle.by_space.insert(space_id.clone(), space_bundle);
309 }
310
311 Ok(bundle)
312}
313
314pub async fn push_sync(
315 client: &KibanaClient,
316 bundle: &SyncBundle,
317 options: &SyncOptions,
318) -> Result<SyncSummary> {
319 let mut summary = SyncSummary::default();
320 let include_spaces = !bundle.spaces.is_empty()
321 && capability_allowed(
322 client,
323 ApiCapability::Spaces,
324 &options.unsupported_api_policy,
325 )
326 .await?;
327 let include_saved_objects = bundle
328 .by_space
329 .values()
330 .any(|space_bundle| !space_bundle.saved_objects.is_empty())
331 && capability_allowed(
332 client,
333 ApiCapability::SavedObjects,
334 &options.unsupported_api_policy,
335 )
336 .await?;
337 let include_workflows = bundle
338 .by_space
339 .values()
340 .any(|space_bundle| !space_bundle.workflows.is_empty())
341 && capability_allowed(
342 client,
343 ApiCapability::Workflows,
344 &options.unsupported_api_policy,
345 )
346 .await?;
347 let include_agents = bundle
348 .by_space
349 .values()
350 .any(|space_bundle| !space_bundle.agents.is_empty())
351 && capability_allowed(
352 client,
353 ApiCapability::Agents,
354 &options.unsupported_api_policy,
355 )
356 .await?;
357 let include_tools = bundle
358 .by_space
359 .values()
360 .any(|space_bundle| !space_bundle.tools.is_empty())
361 && capability_allowed(
362 client,
363 ApiCapability::Tools,
364 &options.unsupported_api_policy,
365 )
366 .await?;
367 let include_skills = bundle
368 .by_space
369 .values()
370 .any(|space_bundle| !space_bundle.skills.is_empty())
371 && capability_allowed(
372 client,
373 ApiCapability::Skills,
374 &options.unsupported_api_policy,
375 )
376 .await?;
377
378 if include_spaces {
379 summary.spaces_attempted = bundle.spaces.len();
380 summary.spaces_applied = SpacesLoader::new(client.clone())
381 .with_overwrite(options.overwrite)
382 .load(bundle.spaces.clone())
383 .await?;
384 }
385
386 for (space_id, space_bundle) in &bundle.by_space {
387 let space_client = client.space(space_id)?;
388
389 if include_saved_objects {
390 summary.saved_objects_attempted += space_bundle.saved_objects.len();
391 summary.saved_objects_applied += SavedObjectsLoader::new(space_client.clone())
392 .with_overwrite(options.overwrite)
393 .load(space_bundle.saved_objects.clone())
394 .await?;
395 }
396
397 if include_workflows {
398 summary.workflows_attempted += space_bundle.workflows.len();
399 summary.workflows_applied += WorkflowsLoader::new(space_client.clone())
400 .load(space_bundle.workflows.clone())
401 .await?;
402 }
403
404 if include_tools {
405 summary.tools_attempted += space_bundle.tools.len();
406 summary.tools_applied += ToolsLoader::new(space_client.clone())
407 .load(space_bundle.tools.clone())
408 .await?;
409 }
410
411 if include_skills {
412 summary.skills_attempted += space_bundle.skills.len();
413 summary.skills_applied += SkillsLoader::new(space_client.clone())
414 .load(space_bundle.skills.clone())
415 .await?;
416 }
417
418 if include_agents {
419 summary.agents_attempted += space_bundle.agents.len();
420 summary.agents_applied += AgentsLoader::new(space_client.clone())
421 .load(space_bundle.agents.clone())
422 .await?;
423 }
424 }
425
426 Ok(summary)
427}
428
429async fn capability_allowed(
430 client: &KibanaClient,
431 capability: ApiCapability,
432 policy: &UnsupportedApiPolicy,
433) -> Result<bool> {
434 if *policy == UnsupportedApiPolicy::Force {
435 return Ok(true);
436 }
437
438 let version = client.server_version().await?;
439 if KibanaClient::supports_capability(&version, capability) {
440 return Ok(true);
441 }
442
443 let reason = KibanaClient::unsupported_capability_reason(&version, capability);
444 match policy {
445 UnsupportedApiPolicy::Skip => tracing::debug!("{reason}; skipping"),
446 UnsupportedApiPolicy::Warn => tracing::warn!("{reason}; skipping"),
447 UnsupportedApiPolicy::Force => {}
448 }
449
450 Ok(false)
451}
452
453pub async fn expand_dependencies(
454 client: &KibanaClient,
455 space_id: &str,
456 bundle: &mut SpaceBundle,
457 capabilities: DependencyExpansionCapabilities,
458) -> Result<()> {
459 let space_client = client.space(space_id)?;
460 let mut existing_agents = ids(&bundle.agents);
461 let mut existing_skills = ids(&bundle.skills);
462 let mut existing_tools = ids(&bundle.tools);
463 let mut existing_workflows = ids(&bundle.workflows);
464 let mut processed = HashSet::new();
465 let mut pending = Vec::new();
466
467 for agent in &bundle.agents {
468 pending.extend(find_agent_dependencies(agent));
469 }
470 for skill in &bundle.skills {
471 pending.extend(find_skill_dependencies(skill));
472 }
473 for tool in &bundle.tools {
474 pending.extend(find_tool_dependencies(tool));
475 }
476 for workflow in &bundle.workflows {
477 pending.extend(find_workflow_dependencies(workflow));
478 }
479
480 while let Some(dependency) = pending.pop() {
481 if !processed.insert(dependency.clone()) {
482 continue;
483 }
484
485 match dependency {
486 Dependency::Agent(id) if !existing_agents.contains(&id) && capabilities.agents => {
487 let fetched =
488 fetch_dependency(&space_client, "api/agent_builder/agents", &id).await?;
489 existing_agents.insert(id);
490 pending.extend(find_agent_dependencies(&fetched));
491 bundle.agents.push(fetched);
492 }
493 Dependency::Tool(id) if !existing_tools.contains(&id) && capabilities.tools => {
494 let fetched =
495 fetch_dependency(&space_client, "api/agent_builder/tools", &id).await?;
496 existing_tools.insert(id);
497 pending.extend(find_tool_dependencies(&fetched));
498 bundle.tools.push(fetched);
499 }
500 Dependency::Skill(id) if !existing_skills.contains(&id) && capabilities.skills => {
501 let fetched =
502 fetch_dependency(&space_client, "api/agent_builder/skills", &id).await?;
503 existing_skills.insert(id);
504 if is_readonly(&fetched) {
505 continue;
506 }
507 pending.extend(find_skill_dependencies(&fetched));
508 bundle.skills.push(fetched);
509 }
510 Dependency::Workflow(id)
511 if !existing_workflows.contains(&id) && capabilities.workflows =>
512 {
513 let path = workflow_resource_path(&id);
514 let response = space_client.get_internal(&path).await?;
515 if !response.status().is_success() {
516 let status = response.status();
517 let body = response.text().await.unwrap_or_default();
518 return Err(Error::api_response(status, body));
519 }
520 let fetched = response.json().await?;
521 existing_workflows.insert(id);
522 pending.extend(find_workflow_dependencies(&fetched));
523 bundle.workflows.push(fetched);
524 }
525 Dependency::Agent(id) if !capabilities.agents => {
526 tracing::debug!("skipping dependent agent {id}; agent API is not enabled")
527 }
528 Dependency::Tool(id) if !capabilities.tools => {
529 tracing::debug!("skipping dependent tool {id}; tool API is not enabled")
530 }
531 Dependency::Skill(id) if !capabilities.skills => {
532 tracing::debug!("skipping dependent skill {id}; skill API is not enabled")
533 }
534 Dependency::Workflow(id) if !capabilities.workflows => {
535 tracing::debug!("skipping dependent workflow {id}; workflow API is not enabled")
536 }
537 _ => {}
538 }
539 }
540
541 Ok(())
542}
543
544fn is_readonly(value: &Value) -> bool {
545 value.get("readonly").and_then(|value| value.as_bool()) == Some(true)
546}
547
548fn ids(values: &[Value]) -> HashSet<String> {
549 values
550 .iter()
551 .filter_map(|value| value.get("id").and_then(|id| id.as_str()))
552 .map(ToOwned::to_owned)
553 .collect()
554}
555
556async fn fetch_dependency(client: &KibanaClient, prefix: &str, id: &str) -> Result<Value> {
557 let path = format!("{prefix}/{id}");
558 let response = client.get(&path).await?;
559 if !response.status().is_success() {
560 let status = response.status();
561 let body = response.text().await.unwrap_or_default();
562 return Err(Error::api_response(status, body));
563 }
564
565 Ok(response.json().await?)
566}
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571 use crate::test_support::{MockResponse, TestServer};
572 use serde_json::json;
573
574 #[test]
575 fn sync_bundle_is_storage_neutral() {
576 let mut bundle = SyncBundle::default();
577 bundle.by_space.insert(
578 "default".to_string(),
579 SpaceBundle {
580 saved_objects: vec![json!({"type": "dashboard", "id": "one"})],
581 ..SpaceBundle::default()
582 },
583 );
584
585 assert_eq!(bundle.by_space["default"].saved_objects.len(), 1);
586 }
587
588 #[test]
589 fn capability_plan_reports_boundaries() {
590 let version = crate::parse_kibana_version("9.2.0").unwrap();
591 let plan = plan_capabilities(
592 &version,
593 [
594 ApiCapability::Agents,
595 ApiCapability::Tools,
596 ApiCapability::Skills,
597 ApiCapability::Workflows,
598 ],
599 );
600
601 assert!(plan.supported.contains(&ApiCapability::Agents));
602 assert!(plan.supported.contains(&ApiCapability::Tools));
603 assert!(
604 plan.unsupported
605 .iter()
606 .any(|warning| warning.capability == ApiCapability::Skills)
607 );
608 assert!(
609 plan.unsupported
610 .iter()
611 .any(|warning| warning.capability == ApiCapability::Workflows)
612 );
613 }
614
615 #[test]
616 fn capability_plan_supports_skills_at_94() {
617 let version = crate::parse_kibana_version("9.4.0").unwrap();
618 let plan = plan_capabilities(&version, [ApiCapability::Skills]);
619
620 assert_eq!(plan.supported, vec![ApiCapability::Skills]);
621 assert!(plan.unsupported.is_empty());
622 }
623
624 #[tokio::test]
625 async fn push_sync_loads_local_dependencies_before_dependents() {
626 let server = TestServer::new(vec![
627 MockResponse {
628 method: "HEAD",
629 path: "/api/workflows/workflow/workflow-w",
630 status: 404,
631 body: json!({}),
632 },
633 MockResponse {
634 method: "POST",
635 path: "/api/workflows/workflow",
636 status: 200,
637 body: json!({}),
638 },
639 MockResponse {
640 method: "HEAD",
641 path: "/api/agent_builder/tools/tool-t",
642 status: 404,
643 body: json!({}),
644 },
645 MockResponse {
646 method: "POST",
647 path: "/api/agent_builder/tools",
648 status: 200,
649 body: json!({}),
650 },
651 MockResponse {
652 method: "GET",
653 path: "/api/agent_builder/skills/skill-s",
654 status: 404,
655 body: json!({}),
656 },
657 MockResponse {
658 method: "POST",
659 path: "/api/agent_builder/skills",
660 status: 200,
661 body: json!({}),
662 },
663 MockResponse {
664 method: "HEAD",
665 path: "/api/agent_builder/agents/agent-a",
666 status: 404,
667 body: json!({}),
668 },
669 MockResponse {
670 method: "POST",
671 path: "/api/agent_builder/agents",
672 status: 200,
673 body: json!({}),
674 },
675 ]);
676 let bundle = SyncBundle {
677 by_space: HashMap::from([(
678 "default".to_string(),
679 SpaceBundle {
680 workflows: vec![json!({"id": "workflow-w", "name": "Workflow W"})],
681 tools: vec![json!({
682 "id": "tool-t",
683 "configuration": {"workflow_id": "workflow-w"}
684 })],
685 skills: vec![json!({"id": "skill-s", "tool_ids": ["tool-t"]})],
686 agents: vec![json!({
687 "id": "agent-a",
688 "configuration": {"skill_id": "skill-s"}
689 })],
690 ..SpaceBundle::default()
691 },
692 )]),
693 ..SyncBundle::default()
694 };
695 let options = SyncOptions {
696 unsupported_api_policy: UnsupportedApiPolicy::Force,
697 ..SyncOptions::default()
698 };
699
700 let summary = push_sync(&server.client().unwrap(), &bundle, &options)
701 .await
702 .unwrap();
703
704 assert_eq!(summary.workflows_applied, 1);
705 assert_eq!(summary.tools_applied, 1);
706 assert_eq!(summary.skills_applied, 1);
707 assert_eq!(summary.agents_applied, 1);
708 let paths = server
709 .requests()
710 .into_iter()
711 .map(|request| request.path)
712 .collect::<Vec<_>>();
713 assert_eq!(
714 paths,
715 vec![
716 "/api/workflows/workflow/workflow-w",
717 "/api/workflows/workflow",
718 "/api/agent_builder/tools/tool-t",
719 "/api/agent_builder/tools",
720 "/api/agent_builder/skills/skill-s",
721 "/api/agent_builder/skills",
722 "/api/agent_builder/agents/agent-a",
723 "/api/agent_builder/agents",
724 ]
725 );
726 }
727
728 #[tokio::test]
729 async fn expands_agent_skill_tool_workflow_dependencies_transitively() {
730 let server = TestServer::new(vec![
731 MockResponse {
732 method: "GET",
733 path: "/api/agent_builder/skills/skill-s",
734 status: 200,
735 body: json!({
736 "id": "skill-s",
737 "name": "Skill S",
738 "tool_ids": ["tool-t"]
739 }),
740 },
741 MockResponse {
742 method: "GET",
743 path: "/api/agent_builder/tools/tool-t",
744 status: 200,
745 body: json!({
746 "id": "tool-t",
747 "name": "Tool T",
748 "configuration": {
749 "workflow_id": "workflow-w"
750 }
751 }),
752 },
753 MockResponse {
754 method: "GET",
755 path: "/api/workflows/workflow/workflow-w",
756 status: 200,
757 body: json!({
758 "id": "workflow-w",
759 "name": "Workflow W"
760 }),
761 },
762 ]);
763 let client = server.client().unwrap();
764 let mut bundle = SpaceBundle {
765 agents: vec![json!({
766 "id": "agent-a",
767 "name": "Agent A",
768 "configuration": {
769 "skill_id": "skill-s"
770 }
771 })],
772 ..SpaceBundle::default()
773 };
774
775 expand_dependencies(
776 &client,
777 "default",
778 &mut bundle,
779 DependencyExpansionCapabilities {
780 agents: true,
781 skills: true,
782 tools: true,
783 workflows: true,
784 },
785 )
786 .await
787 .unwrap();
788
789 assert_eq!(bundle.agents.len(), 1);
790 assert_eq!(bundle.skills.len(), 1);
791 assert_eq!(bundle.skills[0]["id"], "skill-s");
792 assert_eq!(bundle.tools.len(), 1);
793 assert_eq!(bundle.tools[0]["id"], "tool-t");
794 assert_eq!(bundle.workflows.len(), 1);
795 assert_eq!(bundle.workflows[0]["id"], "workflow-w");
796
797 let paths = server
798 .requests()
799 .into_iter()
800 .map(|request| request.path)
801 .collect::<Vec<_>>();
802 assert_eq!(
803 paths,
804 vec![
805 "/api/agent_builder/skills/skill-s",
806 "/api/agent_builder/tools/tool-t",
807 "/api/workflows/workflow/workflow-w"
808 ]
809 );
810 }
811
812 #[tokio::test]
813 async fn skips_readonly_skill_dependencies() {
814 let server = TestServer::new(vec![MockResponse {
815 method: "GET",
816 path: "/api/agent_builder/skills/system-skill",
817 status: 200,
818 body: json!({
819 "id": "system-skill",
820 "name": "System Skill",
821 "readonly": true,
822 "tool_ids": ["tool-t"]
823 }),
824 }]);
825 let client = server.client().unwrap();
826 let mut bundle = SpaceBundle {
827 agents: vec![json!({
828 "id": "agent-a",
829 "name": "Agent A",
830 "configuration": {
831 "skill_id": "system-skill"
832 }
833 })],
834 ..SpaceBundle::default()
835 };
836
837 expand_dependencies(
838 &client,
839 "default",
840 &mut bundle,
841 DependencyExpansionCapabilities {
842 agents: true,
843 skills: true,
844 tools: true,
845 workflows: true,
846 },
847 )
848 .await
849 .unwrap();
850
851 assert!(bundle.skills.is_empty());
852 assert!(bundle.tools.is_empty());
853
854 let paths = server
855 .requests()
856 .into_iter()
857 .map(|request| request.path)
858 .collect::<Vec<_>>();
859 assert_eq!(paths, vec!["/api/agent_builder/skills/system-skill"]);
860 }
861}