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
588fn provider_destination_path(reference: &str) -> PathBuf {
589 let provider_type = inferred_provider_type(reference);
590 let provider_name = inferred_provider_filename(reference);
591 PathBuf::from("providers")
592 .join(provider_type)
593 .join(format!("{provider_name}.gtpack"))
594}
595
596fn materialize_reference_into(
597 root: &Path,
598 reference_roots: &[PathBuf],
599 reference: &str,
600 relative_destination: &Path,
601) -> Result<()> {
602 let destination = root.join(relative_destination);
603 if destination.exists() {
604 if !crate::runtime::refresh() {
605 return Ok(());
606 }
607 std::fs::remove_file(&destination)
608 .with_context(|| format!("remove existing {} before refresh", destination.display()))?;
609 }
610 if let Some(parent) = destination.parent() {
611 ensure_dir(parent)?;
612 }
613
614 if let Some(local_path) = parse_local_pack_reference(root, reference_roots, reference) {
615 if local_path.is_dir() {
616 return Ok(());
617 }
618 std::fs::copy(&local_path, &destination).with_context(|| {
619 format!("copy {} to {}", local_path.display(), destination.display())
620 })?;
621 return Ok(());
622 }
623
624 if !(reference.starts_with("oci://")
625 || reference.starts_with("repo://")
626 || reference.starts_with("store://")
627 || reference.starts_with("https://"))
628 {
629 return Ok(());
630 }
631
632 let path = resolve_remote_pack_path(root, reference)?;
633 std::fs::copy(&path, &destination)
634 .with_context(|| format!("copy {} to {}", path.display(), destination.display()))?;
635
636 Ok(())
637}
638
639fn parse_local_pack_reference(
640 root: &Path,
641 reference_roots: &[PathBuf],
642 reference: &str,
643) -> Option<PathBuf> {
644 if let Some(path) = reference.strip_prefix("file://") {
645 let path = PathBuf::from(path.trim());
646 if path.is_absolute() {
647 return path.exists().then_some(path);
648 }
649 for base in reference_roots
650 .iter()
651 .map(PathBuf::as_path)
652 .chain(std::iter::once(root))
653 {
654 let candidate = base.join(&path);
655 if candidate.exists() {
656 return Some(candidate);
657 }
658 }
659 return None;
660 }
661 if reference.contains("://") {
662 return None;
663 }
664 let candidate = PathBuf::from(reference);
665 if candidate.is_absolute() {
666 return candidate.exists().then_some(candidate);
667 }
668 for base in reference_roots
669 .iter()
670 .map(PathBuf::as_path)
671 .chain(std::iter::once(root))
672 {
673 let joined = base.join(&candidate);
674 if joined.exists() {
675 return Some(joined);
676 }
677 }
678 None
679}
680
681fn resolve_remote_pack_path(root: &Path, reference: &str) -> Result<PathBuf> {
682 if let Some(oci_reference) = reference.strip_prefix("oci://") {
683 let mut options = PackFetchOptions {
684 allow_tags: true,
685 offline: crate::runtime::offline(),
686 cache_dir: root.join(crate::catalog::CACHE_ROOT_DIR).join("artifacts"),
687 ..PackFetchOptions::default()
688 };
689 options.accepted_layer_media_types.extend([
690 GREENTIC_GTPACK_TAR_MEDIA_TYPE.to_string(),
691 GREENTIC_GTPACK_TAR_GZIP_MEDIA_TYPE.to_string(),
692 ]);
693 options.preferred_layer_media_types.splice(
694 0..0,
695 [
696 GREENTIC_GTPACK_TAR_MEDIA_TYPE.to_string(),
697 GREENTIC_GTPACK_TAR_GZIP_MEDIA_TYPE.to_string(),
698 ],
699 );
700 let fetcher: OciPackFetcher<DefaultRegistryClient> = OciPackFetcher::new(options);
701 let runtime = Runtime::new().context("create OCI pack resolver runtime")?;
702 let resolved = runtime
703 .block_on(fetcher.fetch_pack_to_cache(oci_reference))
704 .with_context(|| format!("resolve OCI pack ref {reference}"))?;
705 return Ok(resolved.path);
706 }
707
708 let options = DistOptions {
709 allow_tags: true,
710 offline: crate::runtime::offline(),
711 cache_dir: root.join(crate::catalog::CACHE_ROOT_DIR).join("artifacts"),
712 ..DistOptions::default()
713 };
714 let client = DistClient::new(options);
715 let runtime = Runtime::new().context("create artifact resolver runtime")?;
716 let source = client
717 .parse_source(reference)
718 .with_context(|| format!("parse artifact ref {reference}"))?;
719 let descriptor = runtime
720 .block_on(client.resolve(source, ResolvePolicy))
721 .with_context(|| format!("resolve artifact ref {reference}"))?;
722 let resolved = runtime
723 .block_on(client.fetch(&descriptor, CachePolicy))
724 .with_context(|| format!("fetch artifact ref {reference}"))?;
725 if let Some(path) = resolved.wasm_path {
726 return Ok(path);
727 }
728 if let Some(bytes) = resolved.wasm_bytes {
729 let digest = resolved.resolved_digest.trim_start_matches("sha256:");
730 let temp_path = root
731 .join(crate::catalog::CACHE_ROOT_DIR)
732 .join("artifacts")
733 .join("inline")
734 .join(format!("{digest}.gtpack"));
735 if let Some(parent) = temp_path.parent() {
736 ensure_dir(parent)?;
737 }
738 std::fs::write(&temp_path, bytes)
739 .with_context(|| format!("write cached inline artifact {}", temp_path.display()))?;
740 return Ok(temp_path);
741 }
742 anyhow::bail!("artifact ref {reference} resolved without file payload");
743}
744
745pub fn list_tenants(root: &Path) -> Result<Vec<String>> {
746 let tenants_dir = root.join("tenants");
747 let mut tenants = Vec::new();
748 if !tenants_dir.exists() {
749 return Ok(tenants);
750 }
751 for entry in std::fs::read_dir(tenants_dir)? {
752 let entry = entry?;
753 if entry.file_type()?.is_dir() {
754 tenants.push(entry.file_name().to_string_lossy().to_string());
755 }
756 }
757 tenants.sort();
758 Ok(tenants)
759}
760
761pub fn list_teams(root: &Path, tenant: &str) -> Result<Vec<String>> {
762 let teams_dir = root.join("tenants").join(tenant).join("teams");
763 let mut teams = Vec::new();
764 if !teams_dir.exists() {
765 return Ok(teams);
766 }
767 for entry in std::fs::read_dir(teams_dir)? {
768 let entry = entry?;
769 if entry.file_type()?.is_dir() {
770 teams.push(entry.file_name().to_string_lossy().to_string());
771 }
772 }
773 teams.sort();
774 Ok(teams)
775}
776
777pub fn resolve_lock_path(root: &Path) -> Option<PathBuf> {
786 let workspace = root.join(LOCK_FILE);
787 if workspace.is_file() {
788 return Some(workspace);
789 }
790 let artifact = root.join(ARTIFACT_LOCK_FILE);
791 if artifact.is_file() {
792 return Some(artifact);
793 }
794 None
795}
796
797pub fn write_bundle_lock(root: &Path, lock: &BundleLock) -> Result<()> {
798 let path = resolve_lock_path(root).unwrap_or_else(|| root.join(LOCK_FILE));
801 if let Some(parent) = path.parent() {
802 ensure_dir(parent)?;
803 }
804 std::fs::write(&path, format!("{}\n", serde_json::to_string_pretty(lock)?))?;
805 Ok(())
806}
807
808pub fn read_bundle_lock(root: &Path) -> Result<BundleLock> {
809 let path = resolve_lock_path(root).ok_or_else(|| {
810 anyhow::anyhow!(
811 "no bundle lock in {}: expected `{LOCK_FILE}` (workspace layout) or \
812 `{ARTIFACT_LOCK_FILE}` (artifact layout)",
813 root.display()
814 )
815 })?;
816 let raw = std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
817 serde_json::from_str(&raw).with_context(|| format!("parse {}", path.display()))
818}
819
820fn build_manifest(root: &Path, tenant: &str, team: Option<&str>) -> ResolvedManifest {
821 let workspace = read_workspace_or_default(root);
822 let tenant_gmap = relative_path(root, &root.join("tenants").join(tenant).join("tenant.gmap"));
823 let team_gmap = team.map(|team| {
824 relative_path(
825 root,
826 &root
827 .join("tenants")
828 .join(tenant)
829 .join("teams")
830 .join(team)
831 .join("team.gmap"),
832 )
833 });
834
835 let app_packs = evaluate_app_pack_policies(root, tenant, team, &workspace.app_packs);
836
837 ResolvedManifest {
838 version: "1".to_string(),
839 tenant: tenant.to_string(),
840 team: team.map(ToOwned::to_owned),
841 project_root: root.display().to_string(),
842 bundle: BundleSummary {
843 bundle_id: workspace.bundle_id,
844 bundle_name: workspace.bundle_name,
845 locale: workspace.locale,
846 mode: workspace.mode,
847 advanced_setup: workspace.advanced_setup,
848 setup_execution_intent: workspace.setup_execution_intent,
849 export_intent: workspace.export_intent,
850 },
851 policy: PolicySection {
852 source: PolicySource {
853 tenant_gmap,
854 team_gmap,
855 },
856 default: "forbidden".to_string(),
857 },
858 catalogs: workspace.remote_catalogs,
859 app_packs,
860 extension_providers: workspace.extension_providers,
861 hooks: workspace.hooks,
862 subscriptions: workspace.subscriptions,
863 capabilities: workspace.capabilities,
864 }
865}
866
867fn render_bundle_workspace(workspace: &BundleWorkspaceDefinition) -> String {
868 format!(
871 concat!(
872 "schema_version: {}\n",
873 "bundle_id: {}\n",
874 "bundle_name: {}\n",
875 "locale: {}\n",
876 "mode: {}\n",
877 "advanced_setup: {}\n",
878 "agent_packs:{}\n",
879 "app_packs:{}\n",
880 "app_pack_mappings:{}\n",
881 "extension_providers:{}\n",
882 "remote_catalogs:{}\n",
883 "hooks:{}\n",
884 "subscriptions:{}\n",
885 "capabilities:{}\n",
886 "setup_execution_intent: {}\n",
887 "export_intent: {}\n"
888 ),
889 workspace.schema_version,
890 workspace.bundle_id,
891 workspace.bundle_name,
892 workspace.locale,
893 workspace.mode,
894 workspace.advanced_setup,
895 yaml_sorted_string_map(&workspace.agent_packs),
896 yaml_list(&workspace.app_packs),
897 yaml_mapping_list(&workspace.app_pack_mappings),
898 yaml_list(&workspace.extension_providers),
899 yaml_list(&workspace.remote_catalogs),
900 yaml_list(&workspace.hooks),
901 yaml_list(&workspace.subscriptions),
902 yaml_list(&workspace.capabilities),
903 workspace.setup_execution_intent,
904 workspace.export_intent
905 )
906}
907
908fn yaml_mapping_list(values: &[AppPackMapping]) -> String {
909 if values.is_empty() {
910 " []".to_string()
911 } else {
912 values
913 .iter()
914 .map(|value| {
915 let mut out = format!(
916 "\n - reference: {}\n scope: {}",
917 value.reference,
918 match value.scope {
919 MappingScope::Global => "global",
920 MappingScope::Tenant => "tenant",
921 MappingScope::Team => "team",
922 }
923 );
924 if let Some(tenant) = &value.tenant {
925 out.push_str(&format!("\n tenant: {tenant}"));
926 }
927 if let Some(team) = &value.team {
928 out.push_str(&format!("\n team: {team}"));
929 }
930 out
931 })
932 .collect::<String>()
933 }
934}
935
936fn empty_bundle_lock(workspace: &BundleWorkspaceDefinition) -> BundleLock {
937 BundleLock {
938 schema_version: LOCK_SCHEMA_VERSION,
939 bundle_id: workspace.bundle_id.clone(),
940 env_id: None,
941 requested_mode: workspace.mode.clone(),
942 execution: "execute".to_string(),
943 cache_policy: "workspace-local".to_string(),
944 tool_version: env!("CARGO_PKG_VERSION").to_string(),
945 build_format_version: "bundle-lock-v1".to_string(),
946 workspace_root: WORKSPACE_ROOT_FILE.to_string(),
947 lock_file: LOCK_FILE.to_string(),
948 catalogs: Vec::new(),
949 app_packs: workspace
950 .app_packs
951 .iter()
952 .map(|reference| DependencyLock {
953 reference: reference.clone(),
954 digest: None,
955 })
956 .collect(),
957 extension_providers: workspace
958 .extension_providers
959 .iter()
960 .map(|reference| DependencyLock {
961 reference: reference.clone(),
962 digest: None,
963 })
964 .collect(),
965 setup_state_files: Vec::new(),
966 }
967}
968
969fn yaml_list(values: &[String]) -> String {
970 if values.is_empty() {
971 " []".to_string()
972 } else {
973 values
974 .iter()
975 .map(|value| format!("\n - {value}"))
976 .collect::<String>()
977 }
978}
979
980fn yaml_sorted_string_map(map: &BTreeMap<String, String>) -> String {
987 if map.is_empty() {
988 return " {}".to_string();
989 }
990 map.iter()
991 .map(|(key, value)| format!("\n {key}: \"{value}\""))
992 .collect()
993}
994
995fn sort_unique(values: &mut Vec<String>) {
996 values.retain(|value| !value.trim().is_empty());
997 values.sort();
998 values.dedup();
999}
1000
1001fn canonicalize_mappings(values: &mut Vec<AppPackMapping>) {
1002 values.retain(|value| !value.reference.trim().is_empty());
1003 for value in values.iter_mut() {
1004 if value
1005 .tenant
1006 .as_deref()
1007 .is_some_and(|tenant| tenant.trim().is_empty())
1008 {
1009 value.tenant = None;
1010 }
1011 if value
1012 .team
1013 .as_deref()
1014 .is_some_and(|team| team.trim().is_empty())
1015 {
1016 value.team = None;
1017 }
1018 if matches!(value.scope, MappingScope::Global) {
1019 value.tenant = None;
1020 value.team = None;
1021 } else if matches!(value.scope, MappingScope::Tenant) {
1022 value.team = None;
1023 }
1024 }
1025 values.sort_by(|left, right| {
1026 left.reference
1027 .cmp(&right.reference)
1028 .then(left.scope.cmp(&right.scope))
1029 .then(left.tenant.cmp(&right.tenant))
1030 .then(left.team.cmp(&right.team))
1031 });
1032 values.dedup_by(|left, right| {
1033 left.reference == right.reference
1034 && left.scope == right.scope
1035 && left.tenant == right.tenant
1036 && left.team == right.team
1037 });
1038}
1039
1040fn default_schema_version() -> u32 {
1041 1
1042}
1043
1044fn default_locale() -> String {
1045 "en".to_string()
1046}
1047
1048fn default_mode() -> String {
1049 "create".to_string()
1050}
1051
1052fn write_resolved_outputs(
1053 root: &Path,
1054 tenant: &str,
1055 team: Option<&str>,
1056 manifest: &ResolvedManifest,
1057) -> Result<()> {
1058 let yaml = render_manifest_yaml(manifest);
1059 for output in resolved_output_paths(root, tenant, team) {
1060 if let Some(parent) = output.parent() {
1061 ensure_dir(parent)?;
1062 }
1063 std::fs::write(output, &yaml)?;
1064 }
1065 Ok(())
1066}
1067
1068fn render_manifest_yaml(manifest: &ResolvedManifest) -> String {
1069 let mut lines = vec![
1070 format!("version: {}", manifest.version),
1071 format!("tenant: {}", manifest.tenant),
1072 ];
1073 if let Some(team) = &manifest.team {
1074 lines.push(format!("team: {}", team));
1075 }
1076 lines.extend([
1077 format!("project_root: {}", manifest.project_root),
1078 "bundle:".to_string(),
1079 format!(" bundle_id: {}", manifest.bundle.bundle_id),
1080 format!(" bundle_name: {}", manifest.bundle.bundle_name),
1081 format!(" locale: {}", manifest.bundle.locale),
1082 format!(" mode: {}", manifest.bundle.mode),
1083 format!(" advanced_setup: {}", manifest.bundle.advanced_setup),
1084 format!(
1085 " setup_execution_intent: {}",
1086 manifest.bundle.setup_execution_intent
1087 ),
1088 format!(" export_intent: {}", manifest.bundle.export_intent),
1089 "policy:".to_string(),
1090 " source:".to_string(),
1091 format!(" tenant_gmap: {}", manifest.policy.source.tenant_gmap),
1092 ]);
1093 if let Some(team_gmap) = &manifest.policy.source.team_gmap {
1094 lines.push(format!(" team_gmap: {}", team_gmap));
1095 }
1096 lines.push(format!(" default: {}", manifest.policy.default));
1097 lines.push("catalogs:".to_string());
1098 lines.extend(render_yaml_list(" ", &manifest.catalogs));
1099 lines.push("app_packs:".to_string());
1100 if manifest.app_packs.is_empty() {
1101 lines.push(" []".to_string());
1102 } else {
1103 for entry in &manifest.app_packs {
1104 lines.push(format!(" - reference: {}", entry.reference));
1105 lines.push(format!(" policy: {}", entry.policy));
1106 }
1107 }
1108 lines.push("extension_providers:".to_string());
1109 lines.extend(render_yaml_list(" ", &manifest.extension_providers));
1110 lines.push("hooks:".to_string());
1111 lines.extend(render_yaml_list(" ", &manifest.hooks));
1112 lines.push("subscriptions:".to_string());
1113 lines.extend(render_yaml_list(" ", &manifest.subscriptions));
1114 lines.push("capabilities:".to_string());
1115 lines.extend(render_yaml_list(" ", &manifest.capabilities));
1116 format!("{}\n", lines.join("\n"))
1117}
1118
1119fn read_workspace_or_default(root: &Path) -> BundleWorkspaceDefinition {
1120 read_bundle_workspace(root).unwrap_or_else(|_| {
1121 let bundle_id = root
1122 .file_name()
1123 .and_then(|value| value.to_str())
1124 .map(ToOwned::to_owned)
1125 .filter(|value| !value.trim().is_empty())
1126 .unwrap_or_else(|| "bundle".to_string());
1127 BundleWorkspaceDefinition::new(
1128 bundle_id.clone(),
1129 bundle_id,
1130 default_locale(),
1131 default_mode(),
1132 )
1133 })
1134}
1135
1136fn evaluate_app_pack_policies(
1137 root: &Path,
1138 tenant: &str,
1139 team: Option<&str>,
1140 app_packs: &[String],
1141) -> Vec<ResolvedReferencePolicy> {
1142 let tenant_rules =
1143 crate::access::parse_file(&root.join("tenants").join(tenant).join("tenant.gmap"))
1144 .unwrap_or_default();
1145 let team_rules = team
1146 .and_then(|team_name| {
1147 crate::access::parse_file(
1148 &root
1149 .join("tenants")
1150 .join(tenant)
1151 .join("teams")
1152 .join(team_name)
1153 .join("team.gmap"),
1154 )
1155 .ok()
1156 })
1157 .unwrap_or_default();
1158
1159 let mut entries = app_packs
1160 .iter()
1161 .map(|reference| {
1162 let target = crate::access::GmapPath {
1163 pack: Some(inferred_access_pack_id(reference)),
1164 flow: None,
1165 node: None,
1166 };
1167 let policy = if team.is_some() {
1168 crate::access::eval_with_overlay(&tenant_rules, &team_rules, &target)
1169 } else {
1170 crate::access::eval_policy(&tenant_rules, &target)
1171 };
1172 ResolvedReferencePolicy {
1173 reference: reference.clone(),
1174 policy: policy
1175 .map(|decision| decision.policy.to_string())
1176 .unwrap_or_else(|| "unset".to_string()),
1177 }
1178 })
1179 .collect::<Vec<_>>();
1180 entries.sort_by(|left, right| left.reference.cmp(&right.reference));
1181 entries
1182}
1183
1184fn inferred_access_pack_id(reference: &str) -> String {
1185 let cleaned = reference
1186 .trim_end_matches('/')
1187 .rsplit('/')
1188 .next()
1189 .unwrap_or(reference)
1190 .split('@')
1191 .next()
1192 .unwrap_or(reference)
1193 .split(':')
1194 .next()
1195 .unwrap_or(reference)
1196 .trim_end_matches(".json")
1197 .trim_end_matches(".gtpack")
1198 .trim_end_matches(".yaml")
1199 .trim_end_matches(".yml");
1200 let mut normalized = String::with_capacity(cleaned.len());
1201 let mut last_dash = false;
1202 for ch in cleaned.chars() {
1203 let out = if ch.is_ascii_alphanumeric() {
1204 last_dash = false;
1205 ch.to_ascii_lowercase()
1206 } else if last_dash {
1207 continue;
1208 } else {
1209 last_dash = true;
1210 '-'
1211 };
1212 normalized.push(out);
1213 }
1214 normalized.trim_matches('-').to_string()
1215}
1216
1217fn inferred_provider_type(reference: &str) -> String {
1218 let raw = reference.trim();
1219 for marker in ["/providers/", "/packs/"] {
1220 if let Some((_, rest)) = raw.split_once(marker)
1221 && let Some(segment) = rest.split('/').next()
1222 && !segment.is_empty()
1223 {
1224 return segment.to_string();
1225 }
1226 }
1227
1228 let inferred = inferred_access_pack_id(reference);
1229 let mut parts = inferred.split('-');
1230 match (parts.next(), parts.next()) {
1231 (Some("greentic"), Some(domain)) if !domain.is_empty() => domain.to_string(),
1232 (Some(domain), Some(_)) if !domain.is_empty() => domain.to_string(),
1233 (Some(_domain), None) => "other".to_string(),
1234 _ => "other".to_string(),
1235 }
1236}
1237
1238fn inferred_provider_filename(reference: &str) -> String {
1239 let cleaned = reference
1240 .trim_end_matches('/')
1241 .rsplit('/')
1242 .next()
1243 .unwrap_or(reference)
1244 .split('@')
1245 .next()
1246 .unwrap_or(reference)
1247 .split(':')
1248 .next()
1249 .unwrap_or(reference)
1250 .trim_end_matches(".gtpack");
1251 if let Some(deployer_target) = cleaned.strip_prefix("greentic.deploy.")
1252 && !deployer_target.trim().is_empty()
1253 {
1254 return deployer_target.trim().to_string();
1255 }
1256 if cleaned.is_empty() {
1257 inferred_access_pack_id(reference)
1258 } else {
1259 cleaned.to_string()
1260 }
1261}
1262
1263fn render_yaml_list(indent: &str, values: &[String]) -> Vec<String> {
1264 if values.is_empty() {
1265 vec![format!("{indent}[]")]
1266 } else {
1267 values
1268 .iter()
1269 .map(|value| format!("{indent}- {value}"))
1270 .collect()
1271 }
1272}
1273
1274fn relative_path(root: &Path, path: &Path) -> String {
1275 path.strip_prefix(root)
1276 .unwrap_or(path)
1277 .display()
1278 .to_string()
1279}
1280
1281fn ensure_dir(path: &Path) -> Result<()> {
1282 std::fs::create_dir_all(path)?;
1283 Ok(())
1284}
1285
1286fn write_if_missing(path: &Path, contents: &str) -> Result<()> {
1287 if path.exists() {
1288 return Ok(());
1289 }
1290 if let Some(parent) = path.parent() {
1291 ensure_dir(parent)?;
1292 }
1293 std::fs::write(path, contents)?;
1294 Ok(())
1295}
1296
1297pub fn scaffold_assets_from_packs(root: &Path) -> Result<Vec<PathBuf>> {
1303 let mut written = Vec::new();
1304 let providers_dir = root.join("providers");
1305 if !providers_dir.is_dir() {
1306 return Ok(written);
1307 }
1308 for dir_entry in collect_gtpack_files(&providers_dir)? {
1309 match extract_pack_assets(root, &dir_entry) {
1310 Ok(paths) => written.extend(paths),
1311 Err(err) => {
1312 eprintln!(
1313 "Warning: could not scaffold assets from {}: {err}",
1314 dir_entry.display()
1315 );
1316 }
1317 }
1318 }
1319 Ok(written)
1320}
1321
1322fn collect_gtpack_files(dir: &Path) -> Result<Vec<PathBuf>> {
1323 let mut files = Vec::new();
1324 for entry in std::fs::read_dir(dir)? {
1325 let entry = entry?;
1326 let path = entry.path();
1327 if path.is_dir() {
1328 files.extend(collect_gtpack_files(&path)?);
1329 } else if path.extension().is_some_and(|ext| ext == "gtpack") {
1330 files.push(path);
1331 }
1332 }
1333 Ok(files)
1334}
1335
1336fn extract_pack_assets(root: &Path, pack_path: &Path) -> Result<Vec<PathBuf>> {
1337 let file =
1338 std::fs::File::open(pack_path).with_context(|| format!("open {}", pack_path.display()))?;
1339 let mut archive =
1340 zip::ZipArchive::new(file).with_context(|| format!("read zip {}", pack_path.display()))?;
1341 let mut written = Vec::new();
1342 for i in 0..archive.len() {
1343 let mut entry = archive.by_index(i)?;
1344 let name = entry.name().to_string();
1345 if !name.starts_with("assets/webchat-gui/") || entry.is_dir() {
1346 continue;
1347 }
1348 let target = root.join(&name);
1349 if target.exists() {
1350 continue;
1351 }
1352 if let Some(parent) = target.parent() {
1353 std::fs::create_dir_all(parent)?;
1354 }
1355 let mut out = std::fs::File::create(&target)?;
1356 std::io::copy(&mut entry, &mut out)?;
1357 written.push(target);
1358 }
1359 Ok(written)
1360}
1361
1362#[cfg(test)]
1363mod tests {
1364 use std::path::PathBuf;
1365
1366 use super::BundleWorkspaceDefinition;
1367 use super::{provider_destination_path, should_skip_extension_provider_materialization};
1368
1369 #[test]
1370 fn agent_packs_parses_into_map() {
1371 let raw = concat!(
1372 "schema_version: 1\n",
1373 "bundle_id: demo\n",
1374 "bundle_name: Demo Bundle\n",
1375 "agent_packs:\n",
1376 " tavily_researcher: \"store://greentic.agentic-research-tavily-agent@0.1.0\"\n",
1377 );
1378 let definition = serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(raw)
1379 .expect("config with agent_packs should parse");
1380 assert_eq!(
1381 definition
1382 .agent_packs
1383 .get("tavily_researcher")
1384 .map(String::as_str),
1385 Some("store://greentic.agentic-research-tavily-agent@0.1.0"),
1386 );
1387 }
1388
1389 #[test]
1390 fn agent_packs_defaults_to_empty_map() {
1391 let raw = concat!(
1392 "schema_version: 1\n",
1393 "bundle_id: demo\n",
1394 "bundle_name: Demo Bundle\n",
1395 );
1396 let definition = serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(raw)
1397 .expect("config without agent_packs should parse");
1398 assert!(definition.agent_packs.is_empty());
1399 }
1400
1401 #[test]
1402 fn bundled_catalog_mode_skips_https_provider_materialization() {
1403 unsafe {
1404 std::env::set_var("GREENTIC_BUNDLE_USE_BUNDLED_CATALOG", "1");
1405 }
1406 assert!(should_skip_extension_provider_materialization(
1407 "https://example.com/providers/events-webhook.gtpack"
1408 ));
1409 unsafe {
1410 std::env::remove_var("GREENTIC_BUNDLE_USE_BUNDLED_CATALOG");
1411 }
1412 }
1413
1414 #[test]
1415 fn deployer_provider_destination_uses_canonical_filename() {
1416 assert_eq!(
1417 provider_destination_path(
1418 "oci://ghcr.io/greenticai/packs/deployer/greentic.deploy.aws:stable"
1419 ),
1420 PathBuf::from("providers/deployer/aws.gtpack")
1421 );
1422 }
1423
1424 #[test]
1429 fn agent_packs_round_trips_through_render_bundle_workspace() {
1430 use super::render_bundle_workspace;
1431
1432 let raw = concat!(
1433 "schema_version: 1\n",
1434 "bundle_id: demo\n",
1435 "bundle_name: Demo Bundle\n",
1436 "agent_packs:\n",
1437 " crm_assistant: \"store://greentic.crm-assistant@1.2.0\"\n",
1438 " tavily_researcher: \"store://greentic.agentic-research-tavily-agent@0.1.0\"\n",
1439 );
1440 let original =
1441 serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(raw).expect("parse original");
1442
1443 let rendered = render_bundle_workspace(&original);
1445 let round_tripped = serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(&rendered)
1446 .expect("re-parse after render");
1447
1448 assert_eq!(
1450 round_tripped
1451 .agent_packs
1452 .get("tavily_researcher")
1453 .map(String::as_str),
1454 Some("store://greentic.agentic-research-tavily-agent@0.1.0"),
1455 "tavily_researcher must survive the render round-trip"
1456 );
1457 assert_eq!(
1458 round_tripped
1459 .agent_packs
1460 .get("crm_assistant")
1461 .map(String::as_str),
1462 Some("store://greentic.crm-assistant@1.2.0"),
1463 "crm_assistant must survive the render round-trip"
1464 );
1465 assert_eq!(
1466 round_tripped.agent_packs.len(),
1467 2,
1468 "no extra entries should appear after round-trip"
1469 );
1470 }
1471
1472 #[test]
1474 fn empty_agent_packs_round_trips_as_empty_map() {
1475 use super::render_bundle_workspace;
1476
1477 let raw = concat!(
1478 "schema_version: 1\n",
1479 "bundle_id: demo\n",
1480 "bundle_name: Demo Bundle\n",
1481 );
1482 let original =
1483 serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(raw).expect("parse original");
1484 assert!(original.agent_packs.is_empty());
1485
1486 let rendered = render_bundle_workspace(&original);
1487 let round_tripped = serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(&rendered)
1488 .expect("re-parse after render");
1489
1490 assert!(
1491 round_tripped.agent_packs.is_empty(),
1492 "empty agent_packs must survive the render round-trip"
1493 );
1494 }
1495
1496 fn sample_lock() -> super::BundleLock {
1497 let raw = concat!(
1498 "schema_version: 1\n",
1499 "bundle_id: demo\n",
1500 "bundle_name: Demo Bundle\n",
1501 );
1502 let workspace =
1503 serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(raw).expect("parse workspace");
1504 super::empty_bundle_lock(&workspace)
1505 }
1506
1507 fn write_lock_as(root: &std::path::Path, name: &str, lock: &super::BundleLock) {
1508 std::fs::write(
1509 root.join(name),
1510 serde_json::to_string_pretty(lock).expect("serialize lock"),
1511 )
1512 .expect("write lock");
1513 }
1514
1515 #[test]
1516 fn read_bundle_lock_reads_workspace_layout() {
1517 let dir = tempfile::tempdir().expect("tempdir");
1518 write_lock_as(dir.path(), super::LOCK_FILE, &sample_lock());
1519
1520 let lock = super::read_bundle_lock(dir.path()).expect("read workspace lock");
1521 assert_eq!(lock.bundle_id, "demo");
1522 }
1523
1524 #[test]
1527 fn read_bundle_lock_reads_artifact_layout() {
1528 let dir = tempfile::tempdir().expect("tempdir");
1529 write_lock_as(dir.path(), super::ARTIFACT_LOCK_FILE, &sample_lock());
1530
1531 let lock = super::read_bundle_lock(dir.path()).expect("read artifact lock");
1532 assert_eq!(lock.bundle_id, "demo");
1533 }
1534
1535 #[test]
1536 fn workspace_lock_wins_when_both_names_are_present() {
1537 let dir = tempfile::tempdir().expect("tempdir");
1538 let mut workspace_lock = sample_lock();
1539 workspace_lock.bundle_id = "workspace".to_string();
1540 let mut artifact_lock = sample_lock();
1541 artifact_lock.bundle_id = "artifact".to_string();
1542 write_lock_as(dir.path(), super::LOCK_FILE, &workspace_lock);
1543 write_lock_as(dir.path(), super::ARTIFACT_LOCK_FILE, &artifact_lock);
1544
1545 let lock = super::read_bundle_lock(dir.path()).expect("read lock");
1546 assert_eq!(lock.bundle_id, "workspace");
1547 }
1548
1549 #[test]
1550 fn read_bundle_lock_names_both_layouts_when_missing() {
1551 let dir = tempfile::tempdir().expect("tempdir");
1552
1553 let err = super::read_bundle_lock(dir.path()).expect_err("must fail with no lock");
1554 let message = err.to_string();
1555 assert!(message.contains(super::LOCK_FILE), "message: {message}");
1556 assert!(
1557 message.contains(super::ARTIFACT_LOCK_FILE),
1558 "message: {message}"
1559 );
1560 }
1561
1562 #[test]
1565 fn write_bundle_lock_updates_artifact_name_in_place() {
1566 let dir = tempfile::tempdir().expect("tempdir");
1567 write_lock_as(dir.path(), super::ARTIFACT_LOCK_FILE, &sample_lock());
1568
1569 let mut updated = sample_lock();
1570 updated.bundle_id = "updated".to_string();
1571 super::write_bundle_lock(dir.path(), &updated).expect("write lock");
1572
1573 assert!(
1574 !dir.path().join(super::LOCK_FILE).exists(),
1575 "must not create a second lock file"
1576 );
1577 let reread = super::read_bundle_lock(dir.path()).expect("reread lock");
1578 assert_eq!(reread.bundle_id, "updated");
1579 }
1580
1581 #[test]
1582 fn write_bundle_lock_defaults_to_workspace_name_on_empty_root() {
1583 let dir = tempfile::tempdir().expect("tempdir");
1584
1585 super::write_bundle_lock(dir.path(), &sample_lock()).expect("write lock");
1586
1587 assert!(dir.path().join(super::LOCK_FILE).exists());
1588 assert!(!dir.path().join(super::ARTIFACT_LOCK_FILE).exists());
1589 }
1590}