1use std::collections::BTreeMap;
11use std::path::{Component, Path, PathBuf};
12use std::process;
13
14use ed25519_dalek::Signer;
15use harn_parser::DiagnosticSeverity;
16use harn_vm::bytecode_cache;
17use harn_vm::module_artifact;
18use harn_vm::orchestration::{
19 build_harnpack, load_workflow_bundle_any_version, workflow_bundle_hash, CatchupPolicySpec,
20 ConnectorRequirement, Ed25519Signature, EnvironmentRequirements, HarnpackEntry, ModuleEntry,
21 RetryPolicySpec, SBOMDoc, SBOMPackage, SBOMRelationship, ToolEntry, WorkflowBundle,
22 WorkflowBundlePolicy, WorkflowBundleReplayMetadata, WorkflowBundleTrigger,
23 WORKFLOW_BUNDLE_SCHEMA_VERSION,
24};
25use harn_vm::Compiler;
26use harn_vm::{AutonomyTier, TrustRecord};
27use serde::Serialize;
28
29use crate::cli::PackArgs;
30use crate::command_error;
31use crate::json_envelope::{to_string_pretty, JsonEnvelope, JsonOutput};
32use crate::parse_source_file;
33use crate::skill_provenance;
34
35pub const PACK_SCHEMA_VERSION: u32 = 2;
38pub const PACK_SBOM_ARCHIVE_PATH: &str = "sbom.spdx.json";
39
40#[derive(Debug, Clone, Serialize)]
42pub struct PackJsonData {
43 pub bundle_hash: String,
44 pub output_path: PathBuf,
45 pub size_bytes: u64,
46 pub signature: PackSignatureSummary,
47 pub sbom_summary: PackSbomSummary,
48 pub debug_symbol_metadata: PackDebugSymbolMetadata,
49 pub manifest: WorkflowBundle,
50}
51
52#[derive(Debug, Clone, Serialize)]
53pub struct PackSignatureSummary {
54 pub algorithm: String,
55 pub key_id: Option<String>,
56 pub present: bool,
57}
58
59#[derive(Debug, Clone, Serialize)]
60pub struct PackSbomSummary {
61 pub components: usize,
62 pub stdlib_modules: usize,
63 pub providers: usize,
64 pub tools: usize,
65}
66
67#[derive(Debug, Clone, Serialize)]
68pub struct PackDebugSymbolMetadata {
69 pub harnbc_count: usize,
70 pub total_bytes: u64,
71}
72
73struct PackJsonOutput(PackJsonData);
74
75impl JsonOutput for PackJsonOutput {
76 const SCHEMA_VERSION: u32 = PACK_SCHEMA_VERSION;
77 type Data = PackJsonData;
78 fn into_envelope(self) -> JsonEnvelope<Self::Data> {
79 JsonEnvelope::ok(Self::SCHEMA_VERSION, self.0)
80 }
81}
82
83pub fn run(args: PackArgs) {
84 match build(&args) {
85 Ok(outcome) => {
86 if args.json {
87 let envelope = PackJsonOutput(outcome.json).into_envelope();
88 println!("{}", to_string_pretty(&envelope));
89 } else {
90 println!(
91 "wrote {} ({} bytes, bundle_hash {})",
92 outcome.output_path.display(),
93 outcome.size_bytes,
94 outcome.bundle_hash
95 );
96 }
97 }
98 Err(err) => {
99 if args.json {
100 let envelope: JsonEnvelope<PackJsonData> =
101 JsonEnvelope::err(PACK_SCHEMA_VERSION, err.code, err.message);
102 println!("{}", to_string_pretty(&envelope));
103 process::exit(1);
104 }
105 command_error(&err.message);
106 }
107 }
108}
109
110pub fn run_to_envelope(args: &PackArgs) -> JsonEnvelope<PackJsonData> {
113 match build(args) {
114 Ok(outcome) => PackJsonOutput(outcome.json).into_envelope(),
115 Err(err) => JsonEnvelope::err(PACK_SCHEMA_VERSION, err.code, err.message),
116 }
117}
118
119pub fn json_schema() -> serde_json::Value {
120 serde_json::json!({
121 "$schema": "https://json-schema.org/draft/2020-12/schema",
122 "title": "harn pack --json",
123 "type": "object",
124 "required": ["schemaVersion", "ok", "data", "warnings"],
125 "properties": {
126 "schemaVersion": { "const": PACK_SCHEMA_VERSION },
127 "ok": { "const": true },
128 "warnings": { "type": "array" },
129 "data": {
130 "type": "object",
131 "required": [
132 "bundle_hash",
133 "output_path",
134 "size_bytes",
135 "signature",
136 "sbom_summary",
137 "debug_symbol_metadata",
138 "manifest"
139 ],
140 "properties": {
141 "bundle_hash": { "type": "string", "pattern": "^blake3:" },
142 "output_path": { "type": "string", "minLength": 1 },
143 "size_bytes": { "type": "integer", "minimum": 1 },
144 "signature": {
145 "type": "object",
146 "required": ["algorithm", "key_id", "present"],
147 "properties": {
148 "algorithm": { "const": "ed25519" },
149 "key_id": { "type": ["string", "null"] },
150 "present": { "type": "boolean" }
151 }
152 },
153 "sbom_summary": {
154 "type": "object",
155 "required": ["components", "stdlib_modules", "providers", "tools"],
156 "properties": {
157 "components": { "type": "integer", "minimum": 1 },
158 "stdlib_modules": { "type": "integer", "minimum": 0 },
159 "providers": { "type": "integer", "minimum": 0 },
160 "tools": { "type": "integer", "minimum": 0 }
161 }
162 },
163 "debug_symbol_metadata": {
164 "type": "object",
165 "required": ["harnbc_count", "total_bytes"],
166 "properties": {
167 "harnbc_count": { "type": "integer", "minimum": 1 },
168 "total_bytes": { "type": "integer", "minimum": 1 }
169 }
170 },
171 "manifest": { "type": "object" }
172 }
173 }
174 }
175 })
176}
177
178pub struct PackOutcome {
181 pub bundle_hash: String,
182 pub output_path: PathBuf,
183 pub size_bytes: u64,
184 pub json: PackJsonData,
185}
186
187#[derive(Debug)]
188pub struct PackError {
189 pub code: &'static str,
190 pub message: String,
191}
192
193impl std::fmt::Display for PackError {
194 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
195 write!(f, "{}: {}", self.code, self.message)
196 }
197}
198
199impl std::error::Error for PackError {}
200
201impl PackError {
202 fn new(code: &'static str, message: impl Into<String>) -> Self {
203 Self {
204 code,
205 message: message.into(),
206 }
207 }
208}
209
210pub fn build(args: &PackArgs) -> Result<PackOutcome, PackError> {
211 if args.sign && args.unsigned {
212 return Err(PackError::new(
213 "pack.sign_conflict",
214 "--sign and --unsigned cannot be used together",
215 ));
216 }
217 if args.sign && args.key.is_none() {
218 return Err(PackError::new(
219 "pack.sign_missing_key",
220 "--sign requires --key <path>",
221 ));
222 }
223 if !args.sign && args.key.is_some() {
224 return Err(PackError::new(
225 "pack.key_without_sign",
226 "--key requires --sign",
227 ));
228 }
229 if let Some(upgrade) = &args.upgrade {
230 if !upgrade.exists() {
231 return Err(PackError::new(
232 "upgrade.not_found",
233 format!(
234 "--upgrade source bundle does not exist: {}",
235 upgrade.display()
236 ),
237 ));
238 }
239 }
240 let entrypoint = args
241 .entrypoint
242 .canonicalize()
243 .unwrap_or_else(|_| args.entrypoint.clone());
244 if !entrypoint.exists() {
245 return Err(PackError::new(
246 "entrypoint.not_found",
247 format!("entrypoint does not exist: {}", args.entrypoint.display()),
248 ));
249 }
250 if !entrypoint.is_file() || entrypoint.extension().and_then(|ext| ext.to_str()) != Some("harn")
251 {
252 return Err(PackError::new(
253 "entrypoint.invalid",
254 format!(
255 "entrypoint must be a .harn file: {}",
256 args.entrypoint.display()
257 ),
258 ));
259 }
260 let project_root = entrypoint
261 .parent()
262 .map(Path::to_path_buf)
263 .unwrap_or_else(|| PathBuf::from("."));
264 let entrypoint_rel = relativize(&project_root, &entrypoint).ok_or_else(|| {
265 PackError::new(
266 "entrypoint.outside_root",
267 format!(
268 "entrypoint {} could not be relativized against {}",
269 entrypoint.display(),
270 project_root.display()
271 ),
272 )
273 })?;
274
275 let prior = match &args.upgrade {
276 Some(path) => Some(load_workflow_bundle_any_version(path).map_err(|err| {
277 PackError::new(
278 "upgrade.read_failed",
279 format!("failed to read --upgrade source {}: {err}", path.display()),
280 )
281 })?),
282 None => None,
283 };
284
285 let graph = harn_modules::build(std::slice::from_ref(&entrypoint));
286 let mut module_paths = graph.module_paths();
287 module_paths.sort();
289
290 let mut transitive_modules = Vec::new();
291 let mut contents = Vec::new();
292 let mut sbom_packages = Vec::new();
293 let mut sbom_relationships = Vec::new();
294 let mut debug_symbol_metadata = PackDebugSymbolMetadata {
295 harnbc_count: 0,
296 total_bytes: 0,
297 };
298
299 let stdlib_version = bytecode_cache::HARN_VERSION.to_string();
300 let harn_version = bytecode_cache::HARN_VERSION.to_string();
301
302 sbom_packages.push(SBOMPackage {
303 name: "harn-stdlib".to_string(),
304 version: Some(stdlib_version.clone()),
305 package_hash_blake3: None,
306 license: None,
307 });
308
309 for module_path in &module_paths {
310 let module_str = module_path.to_string_lossy().to_string();
311 if module_str.starts_with("<std>/") {
312 let stdlib_name = module_str.trim_start_matches("<std>/").to_string();
313 sbom_packages.push(SBOMPackage {
314 name: format!("std/{stdlib_name}"),
315 version: Some(stdlib_version.clone()),
316 package_hash_blake3: None,
317 license: None,
318 });
319 sbom_relationships.push(SBOMRelationship {
320 from: format!("entrypoint:{}", entrypoint_rel.display()),
321 to: format!("std/{stdlib_name}"),
322 relationship_type: "depends_on".to_string(),
323 });
324 continue;
325 }
326
327 let source = std::fs::read_to_string(module_path).map_err(|err| {
328 PackError::new(
329 "module.read_failed",
330 format!("failed to read {}: {err}", module_path.display()),
331 )
332 })?;
333
334 let (parsed_source, program) = parse_source_file(&module_str);
335 debug_assert_eq!(parsed_source, source);
336 type_check_or_fail(&source, &module_str, &program)?;
337
338 let entry_chunk = Compiler::new().compile(&program).map_err(|err| {
339 PackError::new(
340 "module.compile_failed",
341 format!("compile error in {}: {err}", module_path.display()),
342 )
343 })?;
344
345 let module_artifact_opt =
346 module_artifact::compile_module_artifact(&program, Some(module_str.clone())).ok();
347
348 let cache_key = bytecode_cache::CacheKey::from_source(module_path, &source);
349 let chunk_bytes = bytecode_cache::serialize_chunk_artifact(&cache_key, &entry_chunk)
350 .map_err(|err| {
351 PackError::new(
352 "module.serialize_failed",
353 format!(
354 "failed to serialize chunk for {}: {err}",
355 module_path.display()
356 ),
357 )
358 })?;
359
360 let module_artifact_bytes = match module_artifact_opt.as_ref() {
361 Some(artifact) => Some(
362 bytecode_cache::serialize_module_artifact(&cache_key, artifact).map_err(|err| {
363 PackError::new(
364 "module.serialize_failed",
365 format!(
366 "failed to serialize module artifact for {}: {err}",
367 module_path.display()
368 ),
369 )
370 })?,
371 ),
372 None => None,
373 };
374
375 let rel = relativize(&project_root, module_path).unwrap_or_else(|| {
376 PathBuf::from(
377 module_path
378 .file_name()
379 .map(|name| name.to_string_lossy().into_owned())
380 .unwrap_or_else(|| module_str.clone()),
381 )
382 });
383 let source_archive_path = PathBuf::from("sources").join(&rel);
384 let chunk_archive_path = adjacent_with_extension(&rel, bytecode_cache::CACHE_EXTENSION)
385 .ok_or_else(|| {
386 PackError::new(
387 "module.invalid_path",
388 format!("module path has no stem: {}", module_path.display()),
389 )
390 })?;
391 let chunk_archive_path = PathBuf::from("bytecode").join(chunk_archive_path);
392
393 let source_hash = blake3_hash(source.as_bytes());
394 let harnbc_hash = blake3_hash(&chunk_bytes);
395 debug_symbol_metadata.harnbc_count += 1;
396 debug_symbol_metadata.total_bytes += chunk_bytes.len() as u64;
397
398 transitive_modules.push(ModuleEntry {
399 path: rel.clone(),
400 source_hash_blake3: source_hash.clone(),
401 harnbc_hash_blake3: harnbc_hash.clone(),
402 });
403
404 contents.push(HarnpackEntry::new(
405 source_archive_path,
406 source.as_bytes().to_vec(),
407 ));
408 contents.push(HarnpackEntry::new(chunk_archive_path, chunk_bytes));
409 if let Some(artifact_bytes) = module_artifact_bytes {
410 debug_symbol_metadata.total_bytes += artifact_bytes.len() as u64;
411 let module_rel = adjacent_with_extension(&rel, bytecode_cache::MODULE_CACHE_EXTENSION)
412 .ok_or_else(|| {
413 PackError::new(
414 "module.invalid_path",
415 format!("module path has no stem: {}", module_path.display()),
416 )
417 })?;
418 let module_archive_path = PathBuf::from("bytecode").join(module_rel);
419 contents.push(HarnpackEntry::new(module_archive_path, artifact_bytes));
420 }
421
422 if module_path != &entrypoint {
423 sbom_relationships.push(SBOMRelationship {
424 from: format!("entrypoint:{}", entrypoint_rel.display()),
425 to: format!("module:{}", rel.display()),
426 relationship_type: "depends_on".to_string(),
427 });
428 }
429 sbom_packages.push(SBOMPackage {
430 name: format!("module:{}", rel.display()),
431 version: Some(harn_version.clone()),
432 package_hash_blake3: Some(source_hash),
433 license: None,
434 });
435 }
436
437 if transitive_modules.is_empty() {
438 return Err(PackError::new(
439 "pack.no_modules",
440 format!(
441 "no Harn modules resolved from entrypoint {}",
442 entrypoint.display()
443 ),
444 ));
445 }
446
447 let provider_catalog = harn_vm::provider_catalog::artifact();
448 let provider_catalog_bytes = serde_json::to_vec(&provider_catalog).map_err(|err| {
449 PackError::new(
450 "provider_catalog.failed",
451 format!("failed to serialize provider catalog snapshot: {err}"),
452 )
453 })?;
454 let provider_catalog_hash = blake3_hash(&provider_catalog_bytes);
455 sbom_packages.push(SBOMPackage {
456 name: "harn-provider-catalog".to_string(),
457 version: Some(harn_version.clone()),
458 package_hash_blake3: Some(provider_catalog_hash.clone()),
459 license: None,
460 });
461 sbom_relationships.push(SBOMRelationship {
462 from: format!("entrypoint:{}", entrypoint_rel.display()),
463 to: "harn-provider-catalog".to_string(),
464 relationship_type: "depends_on".to_string(),
465 });
466 for provider in &provider_catalog.providers {
467 let provider_name = format!("provider:{}", provider.id);
468 sbom_packages.push(SBOMPackage {
469 name: provider_name.clone(),
470 version: None,
471 package_hash_blake3: None,
472 license: None,
473 });
474 sbom_relationships.push(SBOMRelationship {
475 from: "harn-provider-catalog".to_string(),
476 to: provider_name,
477 relationship_type: "contains".to_string(),
478 });
479 }
480
481 let tool_manifest: Vec<ToolEntry> = Vec::new();
486 for tool in &tool_manifest {
487 sbom_packages.push(SBOMPackage {
488 name: format!("tool:{}", tool.name),
489 version: None,
490 package_hash_blake3: tool.schema_hash_blake3.clone(),
491 license: None,
492 });
493 sbom_relationships.push(SBOMRelationship {
494 from: format!("entrypoint:{}", entrypoint_rel.display()),
495 to: format!("tool:{}", tool.name),
496 relationship_type: "depends_on".to_string(),
497 });
498 }
499 let mut bundle = assemble_bundle(
500 &entrypoint_rel,
501 transitive_modules,
502 stdlib_version,
503 harn_version,
504 provider_catalog_hash,
505 tool_manifest,
506 SBOMDoc {
507 format: "spdx-lite".to_string(),
508 version: "2.3".to_string(),
509 packages: sbom_packages,
510 relationships: sbom_relationships,
511 },
512 prior.as_ref(),
513 );
514 sort_sbom_doc(&mut bundle.sbom);
515 let sbom_bytes = serde_json::to_vec_pretty(&bundle.sbom).map_err(|err| {
516 PackError::new(
517 "pack.sbom_failed",
518 format!("failed to render SBOM document: {err}"),
519 )
520 })?;
521 contents.push(HarnpackEntry::new(PACK_SBOM_ARCHIVE_PATH, sbom_bytes));
522
523 if args.sign {
524 let key_path = args.key.as_ref().expect("checked above");
525 sign_bundle(&mut bundle, &contents, key_path)?;
526 }
527
528 let bundle_hash = workflow_bundle_hash(&bundle, &contents).map_err(|err| {
529 PackError::new(
530 "pack.hash_failed",
531 format!("failed to compute bundle hash: {err}"),
532 )
533 })?;
534 let archive_bytes = build_harnpack(&bundle, &contents).map_err(|err| {
535 PackError::new(
536 "pack.archive_failed",
537 format!("failed to assemble .harnpack archive: {err}"),
538 )
539 })?;
540
541 let output_path = resolve_output_path(&args.out, &entrypoint);
542 if let Some(parent) = output_path.parent() {
543 if !parent.as_os_str().is_empty() {
544 std::fs::create_dir_all(parent).map_err(|err| {
545 PackError::new(
546 "pack.output_dir_failed",
547 format!("failed to create output dir {}: {err}", parent.display()),
548 )
549 })?;
550 }
551 }
552 std::fs::write(&output_path, &archive_bytes).map_err(|err| {
553 PackError::new(
554 "pack.write_failed",
555 format!("failed to write {}: {err}", output_path.display()),
556 )
557 })?;
558 let size_bytes = archive_bytes.len() as u64;
559 emit_release_trust_record(&project_root, &bundle_hash, &bundle.harn_version, args.sign)?;
560
561 Ok(PackOutcome {
562 bundle_hash: bundle_hash.clone(),
563 output_path: output_path.clone(),
564 size_bytes,
565 json: PackJsonData {
566 bundle_hash,
567 output_path,
568 size_bytes,
569 signature: signature_summary(&bundle),
570 sbom_summary: sbom_summary(&bundle),
571 debug_symbol_metadata,
572 manifest: bundle,
573 },
574 })
575}
576
577fn sign_bundle(
578 bundle: &mut WorkflowBundle,
579 contents: &[HarnpackEntry],
580 key_path: &Path,
581) -> Result<(), PackError> {
582 let signing_key = skill_provenance::load_ed25519_signing_key(key_path).map_err(|err| {
583 PackError::new(
584 "pack.sign_key_failed",
585 format!("failed to load signing key {}: {err}", key_path.display()),
586 )
587 })?;
588 let bundle_hash = workflow_bundle_hash(bundle, contents).map_err(|err| {
589 PackError::new(
590 "pack.hash_failed",
591 format!("failed to compute bundle hash before signing: {err}"),
592 )
593 })?;
594 let verifying_key = signing_key.verifying_key();
595 let signature = signing_key.sign(bundle_hash.as_bytes());
596 bundle.signature = Some(Ed25519Signature {
597 key_id: Some(skill_provenance::fingerprint_for_key(&verifying_key)),
598 public_key: hex_encode(&verifying_key.to_bytes()),
599 signature: hex_encode(&signature.to_bytes()),
600 manifest_hash_blake3: bundle_hash,
601 algorithm: "ed25519".to_string(),
602 });
603 Ok(())
604}
605
606fn hex_encode(bytes: &[u8]) -> String {
607 let mut out = String::with_capacity(bytes.len() * 2);
608 for byte in bytes {
609 out.push_str(&format!("{byte:02x}"));
610 }
611 out
612}
613
614fn emit_release_trust_record(
615 project_root: &Path,
616 bundle_hash: &str,
617 harn_version: &str,
618 signed: bool,
619) -> Result<TrustRecord, PackError> {
620 let log = harn_vm::event_log::install_default_for_base_dir(project_root).map_err(|err| {
621 PackError::new(
622 "pack.trust_log_failed",
623 format!(
624 "failed to open OpenTrustGraph event log under {}: {err}",
625 project_root.display()
626 ),
627 )
628 })?;
629 let parent_trust_record_id = futures::executor::block_on(harn_vm::query_trust_records(
630 &log,
631 &harn_vm::TrustQueryFilters::default(),
632 ))
633 .map_err(|err| {
634 PackError::new(
635 "pack.trust_query_failed",
636 format!("failed to query prior OpenTrustGraph records: {err}"),
637 )
638 })?
639 .last()
640 .map(|record| record.record_id.clone());
641 let mut record = TrustRecord::release(
642 std::env::var("USER")
643 .ok()
644 .filter(|value| !value.trim().is_empty())
645 .unwrap_or_else(|| "harn-pack".to_string()),
646 bundle_hash.to_string(),
647 harn_version.to_string(),
648 parent_trust_record_id,
649 format!("harnpack-release-{}", uuid::Uuid::now_v7()),
650 if signed {
651 AutonomyTier::ActAuto
652 } else {
653 AutonomyTier::Suggest
654 },
655 );
656 record
657 .metadata
658 .insert("signed".to_string(), serde_json::json!(signed));
659 futures::executor::block_on(harn_vm::append_trust_record(&log, &record)).map_err(|err| {
660 PackError::new(
661 "pack.trust_record_failed",
662 format!("failed to append OpenTrustGraph release record: {err}"),
663 )
664 })
665}
666
667fn signature_summary(bundle: &WorkflowBundle) -> PackSignatureSummary {
668 match &bundle.signature {
669 Some(signature) => PackSignatureSummary {
670 algorithm: signature.algorithm.clone(),
671 key_id: signature.key_id.clone(),
672 present: true,
673 },
674 None => PackSignatureSummary {
675 algorithm: "ed25519".to_string(),
676 key_id: None,
677 present: false,
678 },
679 }
680}
681
682fn sbom_summary(bundle: &WorkflowBundle) -> PackSbomSummary {
683 let stdlib_modules = bundle
684 .sbom
685 .packages
686 .iter()
687 .filter(|package| package.name.starts_with("std/"))
688 .count();
689 let providers = bundle
690 .sbom
691 .packages
692 .iter()
693 .filter(|package| package.name.starts_with("provider:"))
694 .count();
695 PackSbomSummary {
696 components: bundle.sbom.packages.len(),
697 stdlib_modules,
698 providers,
699 tools: bundle.tool_manifest.len(),
700 }
701}
702
703fn sort_sbom_doc(sbom: &mut SBOMDoc) {
704 sbom.packages.sort_by(|left, right| {
705 (&left.name, &left.version, &left.package_hash_blake3).cmp(&(
706 &right.name,
707 &right.version,
708 &right.package_hash_blake3,
709 ))
710 });
711 sbom.relationships.sort_by(|left, right| {
712 (&left.from, &left.to, &left.relationship_type).cmp(&(
713 &right.from,
714 &right.to,
715 &right.relationship_type,
716 ))
717 });
718}
719
720fn assemble_bundle(
721 entrypoint_rel: &Path,
722 transitive_modules: Vec<ModuleEntry>,
723 stdlib_version: String,
724 harn_version: String,
725 provider_catalog_hash: String,
726 tool_manifest: Vec<ToolEntry>,
727 sbom: SBOMDoc,
728 prior: Option<&WorkflowBundle>,
729) -> WorkflowBundle {
730 let stem = entrypoint_rel
731 .file_stem()
732 .map(|s| s.to_string_lossy().into_owned())
733 .unwrap_or_else(|| "harnpack".to_string());
734
735 let mut bundle = prior.cloned().unwrap_or_else(|| WorkflowBundle {
736 id: stem.clone(),
737 name: Some(stem.clone()),
738 version: "0.0.0".to_string(),
739 workflow: degenerate_workflow(&stem),
740 triggers: vec![WorkflowBundleTrigger {
741 id: "manual".to_string(),
742 kind: "manual".to_string(),
743 node_id: Some("entry".to_string()),
744 ..WorkflowBundleTrigger::default()
745 }],
746 policy: WorkflowBundlePolicy {
747 autonomy_tier: "act_with_approval".to_string(),
748 tool_policy: BTreeMap::new(),
749 approval_required: Vec::new(),
750 retry: RetryPolicySpec {
751 max_attempts: 1,
752 backoff: "none".to_string(),
753 },
754 catchup: CatchupPolicySpec {
755 mode: "none".to_string(),
756 max_events: None,
757 },
758 },
759 connectors: Vec::<ConnectorRequirement>::new(),
760 environment: EnvironmentRequirements::default(),
761 receipts: WorkflowBundleReplayMetadata::default(),
762 ..WorkflowBundle::default()
763 });
764
765 bundle.schema_version = WORKFLOW_BUNDLE_SCHEMA_VERSION;
766 bundle.entrypoint = entrypoint_rel.to_path_buf();
767 bundle.transitive_modules = transitive_modules;
768 bundle.stdlib_version = stdlib_version;
769 bundle.harn_version = harn_version;
770 bundle.provider_catalog_hash = provider_catalog_hash;
771 bundle.tool_manifest = tool_manifest;
772 bundle.sbom = sbom;
773 bundle.signature = None;
774 bundle
775}
776
777fn degenerate_workflow(stem: &str) -> harn_vm::orchestration::WorkflowGraph {
778 use harn_vm::orchestration::{WorkflowGraph, WorkflowNode};
779 let mut nodes = BTreeMap::new();
780 nodes.insert(
781 "entry".to_string(),
782 WorkflowNode {
783 id: Some("entry".to_string()),
784 kind: "action".to_string(),
785 task_label: Some(stem.to_string()),
786 ..WorkflowNode::default()
787 },
788 );
789 WorkflowGraph {
790 type_name: "workflow_graph".to_string(),
791 id: format!("{stem}_pack"),
792 name: Some(stem.to_string()),
793 version: 1,
794 entry: "entry".to_string(),
795 nodes,
796 ..WorkflowGraph::default()
797 }
798}
799
800fn type_check_or_fail(
801 source: &str,
802 path: &str,
803 program: &[harn_parser::SNode],
804) -> Result<(), PackError> {
805 let mut had_error = false;
806 let mut messages = String::new();
807 for diag in harn_parser::TypeChecker::new().check_with_source(program, source) {
808 let rendered = harn_parser::diagnostic::render_type_diagnostic(source, path, &diag);
809 if matches!(diag.severity, DiagnosticSeverity::Error) {
810 had_error = true;
811 }
812 messages.push_str(&rendered);
813 }
814 if had_error {
815 return Err(PackError::new(
816 "module.type_error",
817 format!("type errors in {path}:\n{messages}"),
818 ));
819 }
820 if !messages.is_empty() {
821 eprint!("{messages}");
822 }
823 Ok(())
824}
825
826fn relativize(root: &Path, target: &Path) -> Option<PathBuf> {
827 let root_canon = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
828 let target_canon = target
829 .canonicalize()
830 .unwrap_or_else(|_| target.to_path_buf());
831 if let Ok(rel) = target_canon.strip_prefix(&root_canon) {
832 return Some(rel.to_path_buf());
833 }
834 target.file_name().map(PathBuf::from)
837}
838
839fn adjacent_with_extension(rel: &Path, extension: &str) -> Option<PathBuf> {
840 let stem = rel.file_stem()?.to_string_lossy().into_owned();
841 if stem.is_empty() {
842 return None;
843 }
844 let parent_components: Vec<Component<'_>> = rel
845 .parent()
846 .map(|p| p.components().collect())
847 .unwrap_or_default();
848 let mut adjacent = PathBuf::new();
849 for component in parent_components {
850 adjacent.push(component.as_os_str());
851 }
852 let mut filename = stem;
853 filename.push('.');
854 filename.push_str(extension);
855 adjacent.push(filename);
856 Some(adjacent)
857}
858
859fn blake3_hash(bytes: &[u8]) -> String {
860 format!("blake3:{}", blake3::hash(bytes))
861}
862
863fn resolve_output_path(out: &Option<PathBuf>, entrypoint: &Path) -> PathBuf {
864 if let Some(path) = out {
865 return path.clone();
866 }
867 let stem = entrypoint
868 .file_stem()
869 .map(|s| s.to_string_lossy().into_owned())
870 .unwrap_or_else(|| "bundle".to_string());
871 let parent = entrypoint.parent().unwrap_or_else(|| Path::new("."));
872 parent.join(format!("{stem}.harnpack"))
873}