Skip to main content

harn_cli/commands/
pack.rs

1//! `harn pack <entrypoint>` — build a signed-ready `.harnpack` from a
2//! Harn entrypoint.
3//!
4//! Walks the entrypoint's transitive imports, precompiles every module
5//! into a `.harnbc` artifact, snapshots the provider catalog and
6//! stdlib pin, generates a minimal SBOM, assembles a v2 `WorkflowBundle`
7//! manifest, and emits a deterministic tar.zst archive. Signing lands
8//! in E6.3; SBOM body and CLI `--json` polish in E6.4.
9
10use 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
35/// Stable schema version for the `harn pack --json` envelope. Bump when
36/// [`PackJsonData`] changes shape in a way that agents need to detect.
37pub const PACK_SCHEMA_VERSION: u32 = 2;
38pub const PACK_SBOM_ARCHIVE_PATH: &str = "sbom.spdx.json";
39
40/// JSON payload emitted under `JsonEnvelope.data` for `harn pack`.
41#[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
110/// Programmatic entrypoint used by tests and other CLI command code
111/// that needs the JSON envelope without going through stdout.
112pub 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
178/// Outcome of [`build`]. Used by tests; the dispatcher consumes it
179/// directly via [`run`].
180pub 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    // The entrypoint is always present in the graph; ensure deterministic order.
288    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    // The static module walk does not yet discover tool definitions, but
482    // the SBOM path below is wired so future tool manifest entries are
483    // reflected in both the manifest and JSON summary without another
484    // archive format change.
485    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    // Fallback: keep the filename so deeply-nested entrypoints still
835    // pack as relative paths.
836    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}