Skip to main content

kibana_sync/
sync.rs

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}