1pub mod agent_wiring;
2
3use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context, Result};
7use greentic_distributor_client::{
8 CachePolicy, DistClient, DistOptions, OciPackFetcher, PackFetchOptions, ResolvePolicy,
9 oci_packs::DefaultRegistryClient,
10};
11use serde::{Deserialize, Serialize};
12use tokio::runtime::Runtime;
13
14pub const WORKSPACE_ROOT_FILE: &str = "bundle.yaml";
15pub const LOCK_FILE: &str = "bundle.lock.json";
16pub const ARTIFACT_LOCK_FILE: &str = "bundle-lock.json";
20pub const LOCK_SCHEMA_VERSION: u32 = 1;
21
22const DEFAULT_GMAP: &str = "_ = forbidden\n";
23const GREENTIC_GTPACK_TAR_MEDIA_TYPE: &str = "application/vnd.greentic.gtpack.layer.v1+tar";
24const GREENTIC_GTPACK_TAR_GZIP_MEDIA_TYPE: &str =
25 "application/vnd.greentic.gtpack.layer.v1.tar+gzip";
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct BundleWorkspaceDefinition {
29 #[serde(default = "default_schema_version")]
30 pub schema_version: u32,
31 pub bundle_id: String,
32 pub bundle_name: String,
33 #[serde(default = "default_locale")]
34 pub locale: String,
35 #[serde(default = "default_mode")]
36 pub mode: String,
37 #[serde(default)]
38 pub advanced_setup: bool,
39 #[serde(default)]
40 pub app_packs: Vec<String>,
41 #[serde(default)]
45 pub agent_packs: BTreeMap<String, String>,
46 #[serde(default)]
47 pub app_pack_mappings: Vec<AppPackMapping>,
48 #[serde(default)]
49 pub extension_providers: Vec<String>,
50 #[serde(default)]
51 pub remote_catalogs: Vec<String>,
52 #[serde(default)]
53 pub hooks: Vec<String>,
54 #[serde(default)]
55 pub subscriptions: Vec<String>,
56 #[serde(default)]
57 pub capabilities: Vec<String>,
58 #[serde(default)]
59 pub setup_execution_intent: bool,
60 #[serde(default)]
61 pub export_intent: bool,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct AppPackMapping {
66 pub reference: String,
67 pub scope: MappingScope,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub tenant: Option<String>,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub team: Option<String>,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum MappingScope {
77 Global,
78 Tenant,
79 Team,
80}
81
82#[derive(Debug, Serialize)]
83struct ResolvedManifest {
84 version: String,
85 tenant: String,
86 #[serde(skip_serializing_if = "Option::is_none")]
87 team: Option<String>,
88 project_root: String,
89 bundle: BundleSummary,
90 policy: PolicySection,
91 catalogs: Vec<String>,
92 app_packs: Vec<ResolvedReferencePolicy>,
93 extension_providers: Vec<String>,
94 hooks: Vec<String>,
95 subscriptions: Vec<String>,
96 capabilities: Vec<String>,
97}
98
99#[derive(Debug, Serialize)]
100struct BundleSummary {
101 bundle_id: String,
102 bundle_name: String,
103 locale: String,
104 mode: String,
105 advanced_setup: bool,
106 setup_execution_intent: bool,
107 export_intent: bool,
108}
109
110#[derive(Debug, Serialize)]
111struct PolicySection {
112 source: PolicySource,
113 default: String,
114}
115
116#[derive(Debug, Serialize)]
117struct PolicySource {
118 tenant_gmap: String,
119 #[serde(skip_serializing_if = "Option::is_none")]
120 team_gmap: Option<String>,
121}
122
123#[derive(Debug, Serialize)]
124struct ResolvedReferencePolicy {
125 reference: String,
126 policy: String,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct BundleLock {
131 pub schema_version: u32,
132 pub bundle_id: String,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub env_id: Option<String>,
140 pub requested_mode: String,
141 pub execution: String,
142 pub cache_policy: String,
143 pub tool_version: String,
144 pub build_format_version: String,
145 pub workspace_root: String,
146 pub lock_file: String,
147 pub catalogs: Vec<crate::catalog::resolve::CatalogLockEntry>,
148 pub app_packs: Vec<DependencyLock>,
149 pub extension_providers: Vec<DependencyLock>,
150 pub setup_state_files: Vec<String>,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154pub struct DependencyLock {
155 pub reference: String,
156 #[serde(skip_serializing_if = "Option::is_none")]
157 pub digest: Option<String>,
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub enum ReferenceField {
162 AppPack,
163 ExtensionProvider,
164}
165
166impl BundleWorkspaceDefinition {
167 pub fn new(bundle_name: String, bundle_id: String, locale: String, mode: String) -> Self {
168 Self {
169 schema_version: default_schema_version(),
170 bundle_id,
171 bundle_name,
172 locale,
173 mode,
174 advanced_setup: false,
175 app_packs: Vec::new(),
176 agent_packs: BTreeMap::new(),
177 app_pack_mappings: Vec::new(),
178 extension_providers: Vec::new(),
179 remote_catalogs: Vec::new(),
180 hooks: Vec::new(),
181 subscriptions: Vec::new(),
182 capabilities: Vec::new(),
183 setup_execution_intent: false,
184 export_intent: false,
185 }
186 }
187
188 pub fn canonicalize(&mut self) {
189 canonicalize_mappings(&mut self.app_pack_mappings);
190 self.app_packs.extend(
191 self.app_pack_mappings
192 .iter()
193 .map(|entry| entry.reference.clone()),
194 );
195 sort_unique(&mut self.app_packs);
196 sort_unique(&mut self.extension_providers);
197 sort_unique(&mut self.remote_catalogs);
198 sort_unique(&mut self.hooks);
199 sort_unique(&mut self.subscriptions);
200 sort_unique(&mut self.capabilities);
201 }
202
203 pub fn references(&self, field: ReferenceField) -> &[String] {
204 match field {
205 ReferenceField::AppPack => &self.app_packs,
206 ReferenceField::ExtensionProvider => &self.extension_providers,
207 }
208 }
209
210 pub fn references_mut(&mut self, field: ReferenceField) -> &mut Vec<String> {
211 match field {
212 ReferenceField::AppPack => &mut self.app_packs,
213 ReferenceField::ExtensionProvider => &mut self.extension_providers,
214 }
215 }
216}
217
218pub fn ensure_layout(root: &Path) -> Result<()> {
219 ensure_dir(&root.join("tenants"))?;
220 ensure_dir(&root.join("tenants").join("default"))?;
221 ensure_dir(&root.join("tenants").join("default").join("teams"))?;
222 ensure_dir(&root.join("resolved"))?;
223 ensure_dir(&root.join("state").join("resolved"))?;
224 write_if_missing(&root.join(WORKSPACE_ROOT_FILE), "schema_version: 1\n")?;
225 write_if_missing(
226 &root.join("tenants").join("default").join("tenant.gmap"),
227 DEFAULT_GMAP,
228 )?;
229 Ok(())
230}
231
232pub const CAP_BUNDLE_ASSETS_READ_V1: &str = "greentic.cap.bundle_assets.read.v1";
234
235pub fn ensure_assets_dir(root: &Path) -> Result<()> {
237 ensure_dir(&root.join("assets"))
238}
239
240pub fn read_bundle_workspace(root: &Path) -> Result<BundleWorkspaceDefinition> {
241 let raw = std::fs::read_to_string(root.join(WORKSPACE_ROOT_FILE))?;
242 let mut definition = serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(&raw)?;
243 definition.canonicalize();
244 Ok(definition)
245}
246
247pub fn write_bundle_workspace(root: &Path, workspace: &BundleWorkspaceDefinition) -> Result<()> {
248 let mut workspace = workspace.clone();
249 workspace.canonicalize();
250 let path = root.join(WORKSPACE_ROOT_FILE);
251 if let Some(parent) = path.parent() {
252 ensure_dir(parent)?;
253 }
254 std::fs::write(path, render_bundle_workspace(&workspace))?;
255 Ok(())
256}
257
258pub fn init_bundle_workspace(
259 root: &Path,
260 workspace: &BundleWorkspaceDefinition,
261) -> Result<Vec<PathBuf>> {
262 ensure_layout(root)?;
263 let has_bundle_assets = workspace
264 .capabilities
265 .iter()
266 .any(|c| c == CAP_BUNDLE_ASSETS_READ_V1);
267 if has_bundle_assets {
268 ensure_assets_dir(root)?;
269 }
270 write_bundle_workspace(root, workspace)?;
271 let lock = empty_bundle_lock(workspace);
272 write_bundle_lock(root, &lock)?;
273 sync_project(root)?;
274 let mut files = vec![
275 root.join(WORKSPACE_ROOT_FILE),
276 root.join(LOCK_FILE),
277 root.join("tenants/default/tenant.gmap"),
278 root.join("resolved/default.yaml"),
279 root.join("state/resolved/default.yaml"),
280 ];
281 if has_bundle_assets {
282 files.push(root.join("assets"));
283 }
284 Ok(files)
285}
286
287pub fn sync_lock_with_workspace(root: &Path, workspace: &BundleWorkspaceDefinition) -> Result<()> {
288 let mut lock = if root.join(LOCK_FILE).exists() {
289 read_bundle_lock(root)?
290 } else {
291 empty_bundle_lock(workspace)
292 };
293 lock.bundle_id = workspace.bundle_id.clone();
294 lock.requested_mode = workspace.mode.clone();
295 lock.workspace_root = WORKSPACE_ROOT_FILE.to_string();
296 lock.lock_file = LOCK_FILE.to_string();
297 lock.app_packs = workspace
298 .app_packs
299 .iter()
300 .map(|reference| DependencyLock {
301 reference: reference.clone(),
302 digest: None,
303 })
304 .collect();
305 lock.extension_providers = workspace
306 .extension_providers
307 .iter()
308 .map(|reference| DependencyLock {
309 reference: reference.clone(),
310 digest: None,
311 })
312 .collect();
313 write_bundle_lock(root, &lock)
314}
315
316pub fn ensure_tenant(root: &Path, tenant: &str) -> Result<()> {
317 let tenant_dir = root.join("tenants").join(tenant);
318 ensure_dir(&tenant_dir.join("teams"))?;
319 write_if_missing(&tenant_dir.join("tenant.gmap"), DEFAULT_GMAP)?;
320 Ok(())
321}
322
323pub fn ensure_team(root: &Path, tenant: &str, team: &str) -> Result<()> {
324 ensure_tenant(root, tenant)?;
325 let team_dir = root.join("tenants").join(tenant).join("teams").join(team);
326 ensure_dir(&team_dir)?;
327 write_if_missing(&team_dir.join("team.gmap"), DEFAULT_GMAP)?;
328 Ok(())
329}
330
331pub fn gmap_path(root: &Path, target: &crate::access::GmapTarget) -> PathBuf {
332 if let Some(team) = &target.team {
333 root.join("tenants")
334 .join(&target.tenant)
335 .join("teams")
336 .join(team)
337 .join("team.gmap")
338 } else {
339 root.join("tenants")
340 .join(&target.tenant)
341 .join("tenant.gmap")
342 }
343}
344
345pub fn resolved_output_paths(root: &Path, tenant: &str, team: Option<&str>) -> Vec<PathBuf> {
346 let filename = match team {
347 Some(team) => format!("{tenant}.{team}.yaml"),
348 None => format!("{tenant}.yaml"),
349 };
350 vec![
351 root.join("resolved").join(&filename),
352 root.join("state").join("resolved").join(filename),
353 ]
354}
355
356pub fn sync_project(root: &Path) -> Result<()> {
357 sync_project_with_reference_roots(root, &[])
358}
359
360pub fn sync_project_with_reference_roots(root: &Path, reference_roots: &[PathBuf]) -> Result<()> {
361 ensure_layout(root)?;
362 if let Ok(workspace) = read_bundle_workspace(root) {
363 materialize_workspace_dependencies(root, &workspace, reference_roots)?;
364 }
365 for tenant in list_tenants(root)? {
366 let teams = list_teams(root, &tenant)?;
367 if teams.is_empty() {
368 let manifest = build_manifest(root, &tenant, None);
369 write_resolved_outputs(root, &tenant, None, &manifest)?;
370 } else {
371 let tenant_manifest = build_manifest(root, &tenant, None);
372 write_resolved_outputs(root, &tenant, None, &tenant_manifest)?;
373 for team in teams {
374 let manifest = build_manifest(root, &tenant, Some(&team));
375 write_resolved_outputs(root, &tenant, Some(&team), &manifest)?;
376 }
377 }
378 }
379 Ok(())
380}
381
382fn materialize_workspace_dependencies(
383 root: &Path,
384 workspace: &BundleWorkspaceDefinition,
385 reference_roots: &[PathBuf],
386) -> Result<()> {
387 let app_targets = app_pack_copy_targets(workspace);
388 let provider_targets: Vec<_> = workspace
389 .extension_providers
390 .iter()
391 .filter(|p| !should_skip_extension_provider_materialization(p))
392 .collect();
393 let total = app_targets.len() + provider_targets.len();
394 let mut current = 0usize;
395 let force_refresh = crate::runtime::refresh();
396
397 for mapping in &app_targets {
398 current += 1;
399 let dest = root.join(&mapping.destination);
400 if dest.exists() {
401 if force_refresh {
402 eprintln!(
403 " [{current}/{total}] Refreshing app pack: {}",
404 mapping.reference
405 );
406 } else {
407 eprintln!(
408 " [{current}/{total}] Reused (local file exists): {}",
409 mapping.reference
410 );
411 }
412 } else {
413 eprintln!(
414 " [{current}/{total}] Resolving app pack: {}",
415 mapping.reference
416 );
417 }
418 materialize_reference_into(
419 root,
420 reference_roots,
421 &mapping.reference,
422 &mapping.destination,
423 )?;
424 }
425 for provider in &provider_targets {
426 current += 1;
427 let destination = provider_destination_path(provider);
428 let dest = root.join(&destination);
429 if dest.exists() {
430 if force_refresh {
431 eprintln!(" [{current}/{total}] Refreshing provider: {provider}");
432 } else {
433 eprintln!(" [{current}/{total}] Reused (local file exists): {provider}");
434 }
435 } else {
436 eprintln!(" [{current}/{total}] Resolving provider: {provider}");
437 }
438 materialize_reference_into(root, reference_roots, provider, &destination)?;
439 }
440 if total > 0 {
441 eprintln!(" [done] Resolved {total} package(s)");
442 }
443
444 run_agent_pack_auto_wiring(root, workspace, &app_targets)?;
449
450 Ok(())
451}
452
453fn read_gtpack_entry(pack_path: &Path, entry_name: &str) -> Option<Vec<u8>> {
458 use std::io::Read;
459 let file = std::fs::File::open(pack_path).ok()?;
460 let mut archive = zip::ZipArchive::new(file).ok()?;
461 let mut entry = archive.by_name(entry_name).ok()?;
462 let mut buf = Vec::new();
463 entry.read_to_end(&mut buf).ok()?;
464 Some(buf)
465}
466
467fn run_agent_pack_auto_wiring(
470 root: &Path,
471 workspace: &BundleWorkspaceDefinition,
472 app_targets: &[MaterializedCopyTarget],
473) -> Result<()> {
474 if workspace.agent_packs.is_empty() {
477 return Ok(());
478 }
479
480 let mut flow_manifests: Vec<Vec<u8>> = Vec::new();
481 let mut provided_sidecars: Vec<Vec<u8>> = Vec::new();
482
483 for target in app_targets {
484 let pack_path = root.join(&target.destination);
485 if !pack_path.exists() {
486 continue;
487 }
488 if let Some(cbor) = read_gtpack_entry(&pack_path, "manifest.cbor") {
489 flow_manifests.push(cbor);
490 }
491 if let Some(sidecar) = read_gtpack_entry(&pack_path, "dw-agents.json") {
492 provided_sidecars.push(sidecar);
493 }
494 }
495
496 let manifest_refs: Vec<&[u8]> = flow_manifests.iter().map(Vec::as_slice).collect();
497 let sidecar_refs: Vec<&[u8]> = provided_sidecars.iter().map(Vec::as_slice).collect();
498
499 let packs_dir = root.join("packs");
500 let cache_dir = root.join(crate::catalog::CACHE_ROOT_DIR).join("artifacts");
501 let trust = greentic_distributor_client::signing::TrustRoot::default();
509
510 let materialized = agent_wiring::auto_wire_agent_packs(
511 workspace,
512 &manifest_refs,
513 &sidecar_refs,
514 &packs_dir,
515 &cache_dir,
516 crate::runtime::offline(),
517 &trust,
518 )?;
519
520 if !materialized.is_empty() {
521 eprintln!(
522 " [agent-packs] Auto-wired {} agent pack(s): {}",
523 materialized.len(),
524 materialized.join(", ")
525 );
526 }
527 Ok(())
528}
529
530fn should_skip_extension_provider_materialization(reference: &str) -> bool {
531 bundled_catalog_mode()
532 && (reference.starts_with("oci://")
533 || reference.starts_with("repo://")
534 || reference.starts_with("store://")
535 || reference.starts_with("https://"))
536}
537
538fn bundled_catalog_mode() -> bool {
539 std::env::var("GREENTIC_BUNDLE_USE_BUNDLED_CATALOG")
540 .map(|value| value == "1" || value.eq_ignore_ascii_case("true"))
541 .unwrap_or(false)
542}
543
544struct MaterializedCopyTarget {
545 reference: String,
546 destination: PathBuf,
547}
548
549fn app_pack_copy_targets(workspace: &BundleWorkspaceDefinition) -> Vec<MaterializedCopyTarget> {
550 if workspace.app_pack_mappings.is_empty() {
551 return workspace
552 .app_packs
553 .iter()
554 .map(|reference| MaterializedCopyTarget {
555 reference: reference.clone(),
556 destination: PathBuf::from("packs")
557 .join(format!("{}.gtpack", inferred_access_pack_id(reference))),
558 })
559 .collect();
560 }
561
562 workspace
563 .app_pack_mappings
564 .iter()
565 .map(|mapping| {
566 let filename = format!("{}.gtpack", inferred_access_pack_id(&mapping.reference));
567 let destination = match mapping.scope {
568 MappingScope::Global => PathBuf::from("packs").join(filename),
569 MappingScope::Tenant => PathBuf::from("tenants")
570 .join(mapping.tenant.as_deref().unwrap_or("default"))
571 .join("packs")
572 .join(filename),
573 MappingScope::Team => PathBuf::from("tenants")
574 .join(mapping.tenant.as_deref().unwrap_or("default"))
575 .join("teams")
576 .join(mapping.team.as_deref().unwrap_or("default"))
577 .join("packs")
578 .join(filename),
579 };
580 MaterializedCopyTarget {
581 reference: mapping.reference.clone(),
582 destination,
583 }
584 })
585 .collect()
586}
587
588pub fn missing_app_pack_destinations(root: &Path) -> Result<Vec<(String, PathBuf)>> {
600 let workspace = read_bundle_workspace(root)?;
601 Ok(app_pack_copy_targets(&workspace)
602 .into_iter()
603 .filter(|target| !root.join(&target.destination).exists())
604 .map(|target| (target.reference, target.destination))
605 .collect())
606}
607
608fn provider_destination_path(reference: &str) -> PathBuf {
609 let provider_type = inferred_provider_type(reference);
610 let provider_name = inferred_provider_filename(reference);
611 PathBuf::from("providers")
612 .join(provider_type)
613 .join(format!("{provider_name}.gtpack"))
614}
615
616fn materialize_reference_into(
617 root: &Path,
618 reference_roots: &[PathBuf],
619 reference: &str,
620 relative_destination: &Path,
621) -> Result<()> {
622 let destination = root.join(relative_destination);
623 if destination.exists() {
624 if !crate::runtime::refresh() {
625 return Ok(());
626 }
627 std::fs::remove_file(&destination)
628 .with_context(|| format!("remove existing {} before refresh", destination.display()))?;
629 }
630 if let Some(parent) = destination.parent() {
631 ensure_dir(parent)?;
632 }
633
634 if let Some(local_path) = parse_local_pack_reference(root, reference_roots, reference) {
635 if local_path.is_dir() {
636 return Ok(());
637 }
638 std::fs::copy(&local_path, &destination).with_context(|| {
639 format!("copy {} to {}", local_path.display(), destination.display())
640 })?;
641 return Ok(());
642 }
643
644 if !(reference.starts_with("oci://")
645 || reference.starts_with("repo://")
646 || reference.starts_with("store://")
647 || reference.starts_with("https://"))
648 {
649 return Ok(());
650 }
651
652 let path = resolve_remote_pack_path(root, reference)?;
653 std::fs::copy(&path, &destination)
654 .with_context(|| format!("copy {} to {}", path.display(), destination.display()))?;
655
656 Ok(())
657}
658
659fn parse_local_pack_reference(
660 root: &Path,
661 reference_roots: &[PathBuf],
662 reference: &str,
663) -> Option<PathBuf> {
664 if let Some(path) = reference.strip_prefix("file://") {
665 let path = PathBuf::from(path.trim());
666 if path.is_absolute() {
667 return path.exists().then_some(path);
668 }
669 for base in reference_roots
670 .iter()
671 .map(PathBuf::as_path)
672 .chain(std::iter::once(root))
673 {
674 let candidate = base.join(&path);
675 if candidate.exists() {
676 return Some(candidate);
677 }
678 }
679 return None;
680 }
681 if reference.contains("://") {
682 return None;
683 }
684 let candidate = PathBuf::from(reference);
685 if candidate.is_absolute() {
686 return candidate.exists().then_some(candidate);
687 }
688 for base in reference_roots
689 .iter()
690 .map(PathBuf::as_path)
691 .chain(std::iter::once(root))
692 {
693 let joined = base.join(&candidate);
694 if joined.exists() {
695 return Some(joined);
696 }
697 }
698 None
699}
700
701fn resolve_remote_pack_path(root: &Path, reference: &str) -> Result<PathBuf> {
702 if let Some(oci_reference) = reference.strip_prefix("oci://") {
703 let mut options = PackFetchOptions {
704 allow_tags: true,
705 offline: crate::runtime::offline(),
706 cache_dir: root.join(crate::catalog::CACHE_ROOT_DIR).join("artifacts"),
707 ..PackFetchOptions::default()
708 };
709 options.accepted_layer_media_types.extend([
710 GREENTIC_GTPACK_TAR_MEDIA_TYPE.to_string(),
711 GREENTIC_GTPACK_TAR_GZIP_MEDIA_TYPE.to_string(),
712 ]);
713 options.preferred_layer_media_types.splice(
714 0..0,
715 [
716 GREENTIC_GTPACK_TAR_MEDIA_TYPE.to_string(),
717 GREENTIC_GTPACK_TAR_GZIP_MEDIA_TYPE.to_string(),
718 ],
719 );
720 let fetcher: OciPackFetcher<DefaultRegistryClient> = OciPackFetcher::new(options);
721 let runtime = Runtime::new().context("create OCI pack resolver runtime")?;
722 let resolved = runtime
723 .block_on(fetcher.fetch_pack_to_cache(oci_reference))
724 .with_context(|| format!("resolve OCI pack ref {reference}"))?;
725 return Ok(resolved.path);
726 }
727
728 let options = DistOptions {
729 allow_tags: true,
730 offline: crate::runtime::offline(),
731 cache_dir: root.join(crate::catalog::CACHE_ROOT_DIR).join("artifacts"),
732 ..DistOptions::default()
733 };
734 let client = DistClient::new(options);
735 let runtime = Runtime::new().context("create artifact resolver runtime")?;
736 let source = client
737 .parse_source(reference)
738 .with_context(|| format!("parse artifact ref {reference}"))?;
739 let descriptor = runtime
740 .block_on(client.resolve(source, ResolvePolicy))
741 .with_context(|| format!("resolve artifact ref {reference}"))?;
742 let resolved = runtime
743 .block_on(client.fetch(&descriptor, CachePolicy))
744 .with_context(|| format!("fetch artifact ref {reference}"))?;
745 if let Some(path) = resolved.wasm_path {
746 return Ok(path);
747 }
748 if let Some(bytes) = resolved.wasm_bytes {
749 let digest = resolved.resolved_digest.trim_start_matches("sha256:");
750 let temp_path = root
751 .join(crate::catalog::CACHE_ROOT_DIR)
752 .join("artifacts")
753 .join("inline")
754 .join(format!("{digest}.gtpack"));
755 if let Some(parent) = temp_path.parent() {
756 ensure_dir(parent)?;
757 }
758 std::fs::write(&temp_path, bytes)
759 .with_context(|| format!("write cached inline artifact {}", temp_path.display()))?;
760 return Ok(temp_path);
761 }
762 anyhow::bail!("artifact ref {reference} resolved without file payload");
763}
764
765pub fn list_tenants(root: &Path) -> Result<Vec<String>> {
766 let tenants_dir = root.join("tenants");
767 let mut tenants = Vec::new();
768 if !tenants_dir.exists() {
769 return Ok(tenants);
770 }
771 for entry in std::fs::read_dir(tenants_dir)? {
772 let entry = entry?;
773 if entry.file_type()?.is_dir() {
774 tenants.push(entry.file_name().to_string_lossy().to_string());
775 }
776 }
777 tenants.sort();
778 Ok(tenants)
779}
780
781pub fn list_teams(root: &Path, tenant: &str) -> Result<Vec<String>> {
782 let teams_dir = root.join("tenants").join(tenant).join("teams");
783 let mut teams = Vec::new();
784 if !teams_dir.exists() {
785 return Ok(teams);
786 }
787 for entry in std::fs::read_dir(teams_dir)? {
788 let entry = entry?;
789 if entry.file_type()?.is_dir() {
790 teams.push(entry.file_name().to_string_lossy().to_string());
791 }
792 }
793 teams.sort();
794 Ok(teams)
795}
796
797pub fn resolve_lock_path(root: &Path) -> Option<PathBuf> {
806 let workspace = root.join(LOCK_FILE);
807 if workspace.is_file() {
808 return Some(workspace);
809 }
810 let artifact = root.join(ARTIFACT_LOCK_FILE);
811 if artifact.is_file() {
812 return Some(artifact);
813 }
814 None
815}
816
817pub fn write_bundle_lock(root: &Path, lock: &BundleLock) -> Result<()> {
818 let path = resolve_lock_path(root).unwrap_or_else(|| root.join(LOCK_FILE));
821 if let Some(parent) = path.parent() {
822 ensure_dir(parent)?;
823 }
824 std::fs::write(&path, format!("{}\n", serde_json::to_string_pretty(lock)?))?;
825 Ok(())
826}
827
828pub fn read_bundle_lock(root: &Path) -> Result<BundleLock> {
829 let path = resolve_lock_path(root).ok_or_else(|| {
830 anyhow::anyhow!(
831 "no bundle lock in {}: expected `{LOCK_FILE}` (workspace layout) or \
832 `{ARTIFACT_LOCK_FILE}` (artifact layout)",
833 root.display()
834 )
835 })?;
836 let raw = std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
837 serde_json::from_str(&raw).with_context(|| format!("parse {}", path.display()))
838}
839
840fn build_manifest(root: &Path, tenant: &str, team: Option<&str>) -> ResolvedManifest {
841 let workspace = read_workspace_or_default(root);
842 let tenant_gmap = relative_path(root, &root.join("tenants").join(tenant).join("tenant.gmap"));
843 let team_gmap = team.map(|team| {
844 relative_path(
845 root,
846 &root
847 .join("tenants")
848 .join(tenant)
849 .join("teams")
850 .join(team)
851 .join("team.gmap"),
852 )
853 });
854
855 let app_packs = evaluate_app_pack_policies(root, tenant, team, &workspace.app_packs);
856
857 ResolvedManifest {
858 version: "1".to_string(),
859 tenant: tenant.to_string(),
860 team: team.map(ToOwned::to_owned),
861 project_root: root.display().to_string(),
862 bundle: BundleSummary {
863 bundle_id: workspace.bundle_id,
864 bundle_name: workspace.bundle_name,
865 locale: workspace.locale,
866 mode: workspace.mode,
867 advanced_setup: workspace.advanced_setup,
868 setup_execution_intent: workspace.setup_execution_intent,
869 export_intent: workspace.export_intent,
870 },
871 policy: PolicySection {
872 source: PolicySource {
873 tenant_gmap,
874 team_gmap,
875 },
876 default: "forbidden".to_string(),
877 },
878 catalogs: workspace.remote_catalogs,
879 app_packs,
880 extension_providers: workspace.extension_providers,
881 hooks: workspace.hooks,
882 subscriptions: workspace.subscriptions,
883 capabilities: workspace.capabilities,
884 }
885}
886
887fn render_bundle_workspace(workspace: &BundleWorkspaceDefinition) -> String {
888 format!(
891 concat!(
892 "schema_version: {}\n",
893 "bundle_id: {}\n",
894 "bundle_name: {}\n",
895 "locale: {}\n",
896 "mode: {}\n",
897 "advanced_setup: {}\n",
898 "agent_packs:{}\n",
899 "app_packs:{}\n",
900 "app_pack_mappings:{}\n",
901 "extension_providers:{}\n",
902 "remote_catalogs:{}\n",
903 "hooks:{}\n",
904 "subscriptions:{}\n",
905 "capabilities:{}\n",
906 "setup_execution_intent: {}\n",
907 "export_intent: {}\n"
908 ),
909 workspace.schema_version,
910 workspace.bundle_id,
911 workspace.bundle_name,
912 workspace.locale,
913 workspace.mode,
914 workspace.advanced_setup,
915 yaml_sorted_string_map(&workspace.agent_packs),
916 yaml_list(&workspace.app_packs),
917 yaml_mapping_list(&workspace.app_pack_mappings),
918 yaml_list(&workspace.extension_providers),
919 yaml_list(&workspace.remote_catalogs),
920 yaml_list(&workspace.hooks),
921 yaml_list(&workspace.subscriptions),
922 yaml_list(&workspace.capabilities),
923 workspace.setup_execution_intent,
924 workspace.export_intent
925 )
926}
927
928fn yaml_mapping_list(values: &[AppPackMapping]) -> String {
929 if values.is_empty() {
930 " []".to_string()
931 } else {
932 values
933 .iter()
934 .map(|value| {
935 let mut out = format!(
936 "\n - reference: {}\n scope: {}",
937 value.reference,
938 match value.scope {
939 MappingScope::Global => "global",
940 MappingScope::Tenant => "tenant",
941 MappingScope::Team => "team",
942 }
943 );
944 if let Some(tenant) = &value.tenant {
945 out.push_str(&format!("\n tenant: {tenant}"));
946 }
947 if let Some(team) = &value.team {
948 out.push_str(&format!("\n team: {team}"));
949 }
950 out
951 })
952 .collect::<String>()
953 }
954}
955
956fn empty_bundle_lock(workspace: &BundleWorkspaceDefinition) -> BundleLock {
957 BundleLock {
958 schema_version: LOCK_SCHEMA_VERSION,
959 bundle_id: workspace.bundle_id.clone(),
960 env_id: None,
961 requested_mode: workspace.mode.clone(),
962 execution: "execute".to_string(),
963 cache_policy: "workspace-local".to_string(),
964 tool_version: env!("CARGO_PKG_VERSION").to_string(),
965 build_format_version: "bundle-lock-v1".to_string(),
966 workspace_root: WORKSPACE_ROOT_FILE.to_string(),
967 lock_file: LOCK_FILE.to_string(),
968 catalogs: Vec::new(),
969 app_packs: workspace
970 .app_packs
971 .iter()
972 .map(|reference| DependencyLock {
973 reference: reference.clone(),
974 digest: None,
975 })
976 .collect(),
977 extension_providers: workspace
978 .extension_providers
979 .iter()
980 .map(|reference| DependencyLock {
981 reference: reference.clone(),
982 digest: None,
983 })
984 .collect(),
985 setup_state_files: Vec::new(),
986 }
987}
988
989fn yaml_list(values: &[String]) -> String {
990 if values.is_empty() {
991 " []".to_string()
992 } else {
993 values
994 .iter()
995 .map(|value| format!("\n - {value}"))
996 .collect::<String>()
997 }
998}
999
1000fn yaml_sorted_string_map(map: &BTreeMap<String, String>) -> String {
1007 if map.is_empty() {
1008 return " {}".to_string();
1009 }
1010 map.iter()
1011 .map(|(key, value)| format!("\n {key}: \"{value}\""))
1012 .collect()
1013}
1014
1015fn sort_unique(values: &mut Vec<String>) {
1016 values.retain(|value| !value.trim().is_empty());
1017 values.sort();
1018 values.dedup();
1019}
1020
1021fn canonicalize_mappings(values: &mut Vec<AppPackMapping>) {
1022 values.retain(|value| !value.reference.trim().is_empty());
1023 for value in values.iter_mut() {
1024 if value
1025 .tenant
1026 .as_deref()
1027 .is_some_and(|tenant| tenant.trim().is_empty())
1028 {
1029 value.tenant = None;
1030 }
1031 if value
1032 .team
1033 .as_deref()
1034 .is_some_and(|team| team.trim().is_empty())
1035 {
1036 value.team = None;
1037 }
1038 if matches!(value.scope, MappingScope::Global) {
1039 value.tenant = None;
1040 value.team = None;
1041 } else if matches!(value.scope, MappingScope::Tenant) {
1042 value.team = None;
1043 }
1044 }
1045 values.sort_by(|left, right| {
1046 left.reference
1047 .cmp(&right.reference)
1048 .then(left.scope.cmp(&right.scope))
1049 .then(left.tenant.cmp(&right.tenant))
1050 .then(left.team.cmp(&right.team))
1051 });
1052 values.dedup_by(|left, right| {
1053 left.reference == right.reference
1054 && left.scope == right.scope
1055 && left.tenant == right.tenant
1056 && left.team == right.team
1057 });
1058}
1059
1060fn default_schema_version() -> u32 {
1061 1
1062}
1063
1064fn default_locale() -> String {
1065 "en".to_string()
1066}
1067
1068fn default_mode() -> String {
1069 "create".to_string()
1070}
1071
1072fn write_resolved_outputs(
1073 root: &Path,
1074 tenant: &str,
1075 team: Option<&str>,
1076 manifest: &ResolvedManifest,
1077) -> Result<()> {
1078 let yaml = render_manifest_yaml(manifest);
1079 for output in resolved_output_paths(root, tenant, team) {
1080 if let Some(parent) = output.parent() {
1081 ensure_dir(parent)?;
1082 }
1083 std::fs::write(output, &yaml)?;
1084 }
1085 Ok(())
1086}
1087
1088fn render_manifest_yaml(manifest: &ResolvedManifest) -> String {
1089 let mut lines = vec![
1090 format!("version: {}", manifest.version),
1091 format!("tenant: {}", manifest.tenant),
1092 ];
1093 if let Some(team) = &manifest.team {
1094 lines.push(format!("team: {}", team));
1095 }
1096 lines.extend([
1097 format!("project_root: {}", manifest.project_root),
1098 "bundle:".to_string(),
1099 format!(" bundle_id: {}", manifest.bundle.bundle_id),
1100 format!(" bundle_name: {}", manifest.bundle.bundle_name),
1101 format!(" locale: {}", manifest.bundle.locale),
1102 format!(" mode: {}", manifest.bundle.mode),
1103 format!(" advanced_setup: {}", manifest.bundle.advanced_setup),
1104 format!(
1105 " setup_execution_intent: {}",
1106 manifest.bundle.setup_execution_intent
1107 ),
1108 format!(" export_intent: {}", manifest.bundle.export_intent),
1109 "policy:".to_string(),
1110 " source:".to_string(),
1111 format!(" tenant_gmap: {}", manifest.policy.source.tenant_gmap),
1112 ]);
1113 if let Some(team_gmap) = &manifest.policy.source.team_gmap {
1114 lines.push(format!(" team_gmap: {}", team_gmap));
1115 }
1116 lines.push(format!(" default: {}", manifest.policy.default));
1117 lines.push("catalogs:".to_string());
1118 lines.extend(render_yaml_list(" ", &manifest.catalogs));
1119 lines.push("app_packs:".to_string());
1120 if manifest.app_packs.is_empty() {
1121 lines.push(" []".to_string());
1122 } else {
1123 for entry in &manifest.app_packs {
1124 lines.push(format!(" - reference: {}", entry.reference));
1125 lines.push(format!(" policy: {}", entry.policy));
1126 }
1127 }
1128 lines.push("extension_providers:".to_string());
1129 lines.extend(render_yaml_list(" ", &manifest.extension_providers));
1130 lines.push("hooks:".to_string());
1131 lines.extend(render_yaml_list(" ", &manifest.hooks));
1132 lines.push("subscriptions:".to_string());
1133 lines.extend(render_yaml_list(" ", &manifest.subscriptions));
1134 lines.push("capabilities:".to_string());
1135 lines.extend(render_yaml_list(" ", &manifest.capabilities));
1136 format!("{}\n", lines.join("\n"))
1137}
1138
1139fn read_workspace_or_default(root: &Path) -> BundleWorkspaceDefinition {
1140 read_bundle_workspace(root).unwrap_or_else(|_| {
1141 let bundle_id = root
1142 .file_name()
1143 .and_then(|value| value.to_str())
1144 .map(ToOwned::to_owned)
1145 .filter(|value| !value.trim().is_empty())
1146 .unwrap_or_else(|| "bundle".to_string());
1147 BundleWorkspaceDefinition::new(
1148 bundle_id.clone(),
1149 bundle_id,
1150 default_locale(),
1151 default_mode(),
1152 )
1153 })
1154}
1155
1156fn evaluate_app_pack_policies(
1157 root: &Path,
1158 tenant: &str,
1159 team: Option<&str>,
1160 app_packs: &[String],
1161) -> Vec<ResolvedReferencePolicy> {
1162 let tenant_rules =
1163 crate::access::parse_file(&root.join("tenants").join(tenant).join("tenant.gmap"))
1164 .unwrap_or_default();
1165 let team_rules = team
1166 .and_then(|team_name| {
1167 crate::access::parse_file(
1168 &root
1169 .join("tenants")
1170 .join(tenant)
1171 .join("teams")
1172 .join(team_name)
1173 .join("team.gmap"),
1174 )
1175 .ok()
1176 })
1177 .unwrap_or_default();
1178
1179 let mut entries = app_packs
1180 .iter()
1181 .map(|reference| {
1182 let target = crate::access::GmapPath {
1183 pack: Some(inferred_access_pack_id(reference)),
1184 flow: None,
1185 node: None,
1186 };
1187 let policy = if team.is_some() {
1188 crate::access::eval_with_overlay(&tenant_rules, &team_rules, &target)
1189 } else {
1190 crate::access::eval_policy(&tenant_rules, &target)
1191 };
1192 ResolvedReferencePolicy {
1193 reference: reference.clone(),
1194 policy: policy
1195 .map(|decision| decision.policy.to_string())
1196 .unwrap_or_else(|| "unset".to_string()),
1197 }
1198 })
1199 .collect::<Vec<_>>();
1200 entries.sort_by(|left, right| left.reference.cmp(&right.reference));
1201 entries
1202}
1203
1204fn inferred_access_pack_id(reference: &str) -> String {
1205 let cleaned = reference
1206 .trim_end_matches('/')
1207 .rsplit('/')
1208 .next()
1209 .unwrap_or(reference)
1210 .split('@')
1211 .next()
1212 .unwrap_or(reference)
1213 .split(':')
1214 .next()
1215 .unwrap_or(reference)
1216 .trim_end_matches(".json")
1217 .trim_end_matches(".gtpack")
1218 .trim_end_matches(".yaml")
1219 .trim_end_matches(".yml");
1220 let mut normalized = String::with_capacity(cleaned.len());
1221 let mut last_dash = false;
1222 for ch in cleaned.chars() {
1223 let out = if ch.is_ascii_alphanumeric() {
1224 last_dash = false;
1225 ch.to_ascii_lowercase()
1226 } else if last_dash {
1227 continue;
1228 } else {
1229 last_dash = true;
1230 '-'
1231 };
1232 normalized.push(out);
1233 }
1234 normalized.trim_matches('-').to_string()
1235}
1236
1237fn inferred_provider_type(reference: &str) -> String {
1238 let raw = reference.trim();
1239 for marker in ["/providers/", "/packs/"] {
1240 if let Some((_, rest)) = raw.split_once(marker)
1241 && let Some(segment) = rest.split('/').next()
1242 && !segment.is_empty()
1243 {
1244 return segment.to_string();
1245 }
1246 }
1247
1248 let inferred = inferred_access_pack_id(reference);
1249 let mut parts = inferred.split('-');
1250 match (parts.next(), parts.next()) {
1251 (Some("greentic"), Some(domain)) if !domain.is_empty() => domain.to_string(),
1252 (Some(domain), Some(_)) if !domain.is_empty() => domain.to_string(),
1253 (Some(_domain), None) => "other".to_string(),
1254 _ => "other".to_string(),
1255 }
1256}
1257
1258fn inferred_provider_filename(reference: &str) -> String {
1259 let cleaned = reference
1260 .trim_end_matches('/')
1261 .rsplit('/')
1262 .next()
1263 .unwrap_or(reference)
1264 .split('@')
1265 .next()
1266 .unwrap_or(reference)
1267 .split(':')
1268 .next()
1269 .unwrap_or(reference)
1270 .trim_end_matches(".gtpack");
1271 if let Some(deployer_target) = cleaned.strip_prefix("greentic.deploy.")
1272 && !deployer_target.trim().is_empty()
1273 {
1274 return deployer_target.trim().to_string();
1275 }
1276 if cleaned.is_empty() {
1277 inferred_access_pack_id(reference)
1278 } else {
1279 cleaned.to_string()
1280 }
1281}
1282
1283fn render_yaml_list(indent: &str, values: &[String]) -> Vec<String> {
1284 if values.is_empty() {
1285 vec![format!("{indent}[]")]
1286 } else {
1287 values
1288 .iter()
1289 .map(|value| format!("{indent}- {value}"))
1290 .collect()
1291 }
1292}
1293
1294fn relative_path(root: &Path, path: &Path) -> String {
1295 path.strip_prefix(root)
1296 .unwrap_or(path)
1297 .display()
1298 .to_string()
1299}
1300
1301fn ensure_dir(path: &Path) -> Result<()> {
1302 std::fs::create_dir_all(path)?;
1303 Ok(())
1304}
1305
1306fn write_if_missing(path: &Path, contents: &str) -> Result<()> {
1307 if path.exists() {
1308 return Ok(());
1309 }
1310 if let Some(parent) = path.parent() {
1311 ensure_dir(parent)?;
1312 }
1313 std::fs::write(path, contents)?;
1314 Ok(())
1315}
1316
1317pub fn scaffold_assets_from_packs(root: &Path) -> Result<Vec<PathBuf>> {
1323 let mut written = Vec::new();
1324 let providers_dir = root.join("providers");
1325 if !providers_dir.is_dir() {
1326 return Ok(written);
1327 }
1328 for dir_entry in collect_gtpack_files(&providers_dir)? {
1329 match extract_pack_assets(root, &dir_entry) {
1330 Ok(paths) => written.extend(paths),
1331 Err(err) => {
1332 eprintln!(
1333 "Warning: could not scaffold assets from {}: {err}",
1334 dir_entry.display()
1335 );
1336 }
1337 }
1338 }
1339 Ok(written)
1340}
1341
1342fn collect_gtpack_files(dir: &Path) -> Result<Vec<PathBuf>> {
1343 let mut files = Vec::new();
1344 for entry in std::fs::read_dir(dir)? {
1345 let entry = entry?;
1346 let path = entry.path();
1347 if path.is_dir() {
1348 files.extend(collect_gtpack_files(&path)?);
1349 } else if path.extension().is_some_and(|ext| ext == "gtpack") {
1350 files.push(path);
1351 }
1352 }
1353 Ok(files)
1354}
1355
1356fn extract_pack_assets(root: &Path, pack_path: &Path) -> Result<Vec<PathBuf>> {
1357 let file =
1358 std::fs::File::open(pack_path).with_context(|| format!("open {}", pack_path.display()))?;
1359 let mut archive =
1360 zip::ZipArchive::new(file).with_context(|| format!("read zip {}", pack_path.display()))?;
1361 let mut written = Vec::new();
1362 for i in 0..archive.len() {
1363 let mut entry = archive.by_index(i)?;
1364 let name = entry.name().to_string();
1365 if !name.starts_with("assets/webchat-gui/") || entry.is_dir() {
1366 continue;
1367 }
1368 let target = root.join(&name);
1369 if target.exists() {
1370 continue;
1371 }
1372 if let Some(parent) = target.parent() {
1373 std::fs::create_dir_all(parent)?;
1374 }
1375 let mut out = std::fs::File::create(&target)?;
1376 std::io::copy(&mut entry, &mut out)?;
1377 written.push(target);
1378 }
1379 Ok(written)
1380}
1381
1382#[cfg(test)]
1383mod tests {
1384 use std::path::PathBuf;
1385
1386 use super::BundleWorkspaceDefinition;
1387 use super::{provider_destination_path, should_skip_extension_provider_materialization};
1388
1389 #[test]
1390 fn agent_packs_parses_into_map() {
1391 let raw = concat!(
1392 "schema_version: 1\n",
1393 "bundle_id: demo\n",
1394 "bundle_name: Demo Bundle\n",
1395 "agent_packs:\n",
1396 " tavily_researcher: \"store://greentic.agentic-research-tavily-agent@0.1.0\"\n",
1397 );
1398 let definition = serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(raw)
1399 .expect("config with agent_packs should parse");
1400 assert_eq!(
1401 definition
1402 .agent_packs
1403 .get("tavily_researcher")
1404 .map(String::as_str),
1405 Some("store://greentic.agentic-research-tavily-agent@0.1.0"),
1406 );
1407 }
1408
1409 #[test]
1410 fn agent_packs_defaults_to_empty_map() {
1411 let raw = concat!(
1412 "schema_version: 1\n",
1413 "bundle_id: demo\n",
1414 "bundle_name: Demo Bundle\n",
1415 );
1416 let definition = serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(raw)
1417 .expect("config without agent_packs should parse");
1418 assert!(definition.agent_packs.is_empty());
1419 }
1420
1421 #[test]
1422 fn bundled_catalog_mode_skips_https_provider_materialization() {
1423 unsafe {
1424 std::env::set_var("GREENTIC_BUNDLE_USE_BUNDLED_CATALOG", "1");
1425 }
1426 assert!(should_skip_extension_provider_materialization(
1427 "https://example.com/providers/events-webhook.gtpack"
1428 ));
1429 unsafe {
1430 std::env::remove_var("GREENTIC_BUNDLE_USE_BUNDLED_CATALOG");
1431 }
1432 }
1433
1434 #[test]
1435 fn deployer_provider_destination_uses_canonical_filename() {
1436 assert_eq!(
1437 provider_destination_path(
1438 "oci://ghcr.io/greenticai/packs/deployer/greentic.deploy.aws:stable"
1439 ),
1440 PathBuf::from("providers/deployer/aws.gtpack")
1441 );
1442 }
1443
1444 #[test]
1449 fn agent_packs_round_trips_through_render_bundle_workspace() {
1450 use super::render_bundle_workspace;
1451
1452 let raw = concat!(
1453 "schema_version: 1\n",
1454 "bundle_id: demo\n",
1455 "bundle_name: Demo Bundle\n",
1456 "agent_packs:\n",
1457 " crm_assistant: \"store://greentic.crm-assistant@1.2.0\"\n",
1458 " tavily_researcher: \"store://greentic.agentic-research-tavily-agent@0.1.0\"\n",
1459 );
1460 let original =
1461 serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(raw).expect("parse original");
1462
1463 let rendered = render_bundle_workspace(&original);
1465 let round_tripped = serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(&rendered)
1466 .expect("re-parse after render");
1467
1468 assert_eq!(
1470 round_tripped
1471 .agent_packs
1472 .get("tavily_researcher")
1473 .map(String::as_str),
1474 Some("store://greentic.agentic-research-tavily-agent@0.1.0"),
1475 "tavily_researcher must survive the render round-trip"
1476 );
1477 assert_eq!(
1478 round_tripped
1479 .agent_packs
1480 .get("crm_assistant")
1481 .map(String::as_str),
1482 Some("store://greentic.crm-assistant@1.2.0"),
1483 "crm_assistant must survive the render round-trip"
1484 );
1485 assert_eq!(
1486 round_tripped.agent_packs.len(),
1487 2,
1488 "no extra entries should appear after round-trip"
1489 );
1490 }
1491
1492 #[test]
1494 fn empty_agent_packs_round_trips_as_empty_map() {
1495 use super::render_bundle_workspace;
1496
1497 let raw = concat!(
1498 "schema_version: 1\n",
1499 "bundle_id: demo\n",
1500 "bundle_name: Demo Bundle\n",
1501 );
1502 let original =
1503 serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(raw).expect("parse original");
1504 assert!(original.agent_packs.is_empty());
1505
1506 let rendered = render_bundle_workspace(&original);
1507 let round_tripped = serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(&rendered)
1508 .expect("re-parse after render");
1509
1510 assert!(
1511 round_tripped.agent_packs.is_empty(),
1512 "empty agent_packs must survive the render round-trip"
1513 );
1514 }
1515
1516 fn sample_lock() -> super::BundleLock {
1517 let raw = concat!(
1518 "schema_version: 1\n",
1519 "bundle_id: demo\n",
1520 "bundle_name: Demo Bundle\n",
1521 );
1522 let workspace =
1523 serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(raw).expect("parse workspace");
1524 super::empty_bundle_lock(&workspace)
1525 }
1526
1527 fn write_lock_as(root: &std::path::Path, name: &str, lock: &super::BundleLock) {
1528 std::fs::write(
1529 root.join(name),
1530 serde_json::to_string_pretty(lock).expect("serialize lock"),
1531 )
1532 .expect("write lock");
1533 }
1534
1535 #[test]
1536 fn read_bundle_lock_reads_workspace_layout() {
1537 let dir = tempfile::tempdir().expect("tempdir");
1538 write_lock_as(dir.path(), super::LOCK_FILE, &sample_lock());
1539
1540 let lock = super::read_bundle_lock(dir.path()).expect("read workspace lock");
1541 assert_eq!(lock.bundle_id, "demo");
1542 }
1543
1544 #[test]
1547 fn read_bundle_lock_reads_artifact_layout() {
1548 let dir = tempfile::tempdir().expect("tempdir");
1549 write_lock_as(dir.path(), super::ARTIFACT_LOCK_FILE, &sample_lock());
1550
1551 let lock = super::read_bundle_lock(dir.path()).expect("read artifact lock");
1552 assert_eq!(lock.bundle_id, "demo");
1553 }
1554
1555 #[test]
1556 fn workspace_lock_wins_when_both_names_are_present() {
1557 let dir = tempfile::tempdir().expect("tempdir");
1558 let mut workspace_lock = sample_lock();
1559 workspace_lock.bundle_id = "workspace".to_string();
1560 let mut artifact_lock = sample_lock();
1561 artifact_lock.bundle_id = "artifact".to_string();
1562 write_lock_as(dir.path(), super::LOCK_FILE, &workspace_lock);
1563 write_lock_as(dir.path(), super::ARTIFACT_LOCK_FILE, &artifact_lock);
1564
1565 let lock = super::read_bundle_lock(dir.path()).expect("read lock");
1566 assert_eq!(lock.bundle_id, "workspace");
1567 }
1568
1569 #[test]
1570 fn read_bundle_lock_names_both_layouts_when_missing() {
1571 let dir = tempfile::tempdir().expect("tempdir");
1572
1573 let err = super::read_bundle_lock(dir.path()).expect_err("must fail with no lock");
1574 let message = err.to_string();
1575 assert!(message.contains(super::LOCK_FILE), "message: {message}");
1576 assert!(
1577 message.contains(super::ARTIFACT_LOCK_FILE),
1578 "message: {message}"
1579 );
1580 }
1581
1582 #[test]
1585 fn write_bundle_lock_updates_artifact_name_in_place() {
1586 let dir = tempfile::tempdir().expect("tempdir");
1587 write_lock_as(dir.path(), super::ARTIFACT_LOCK_FILE, &sample_lock());
1588
1589 let mut updated = sample_lock();
1590 updated.bundle_id = "updated".to_string();
1591 super::write_bundle_lock(dir.path(), &updated).expect("write lock");
1592
1593 assert!(
1594 !dir.path().join(super::LOCK_FILE).exists(),
1595 "must not create a second lock file"
1596 );
1597 let reread = super::read_bundle_lock(dir.path()).expect("reread lock");
1598 assert_eq!(reread.bundle_id, "updated");
1599 }
1600
1601 #[test]
1602 fn write_bundle_lock_defaults_to_workspace_name_on_empty_root() {
1603 let dir = tempfile::tempdir().expect("tempdir");
1604
1605 super::write_bundle_lock(dir.path(), &sample_lock()).expect("write lock");
1606
1607 assert!(dir.path().join(super::LOCK_FILE).exists());
1608 assert!(!dir.path().join(super::ARTIFACT_LOCK_FILE).exists());
1609 }
1610}