1use std::collections::BTreeMap;
11use std::path::{Component, Path, PathBuf};
12use std::process;
13
14use harn_parser::DiagnosticSeverity;
15use harn_vm::bytecode_cache;
16use harn_vm::module_artifact;
17use harn_vm::orchestration::{
18 build_harnpack, current_provider_catalog_hash_blake3, load_workflow_bundle_any_version,
19 workflow_bundle_hash, CatchupPolicySpec, ConnectorRequirement, EnvironmentRequirements,
20 HarnpackEntry, ModuleEntry, RetryPolicySpec, SBOMDoc, SBOMPackage, SBOMRelationship, ToolEntry,
21 WorkflowBundle, WorkflowBundlePolicy, WorkflowBundleReplayMetadata, WorkflowBundleTrigger,
22 WORKFLOW_BUNDLE_SCHEMA_VERSION,
23};
24use harn_vm::Compiler;
25use serde::Serialize;
26
27use crate::cli::PackArgs;
28use crate::command_error;
29use crate::json_envelope::{to_string_pretty, JsonEnvelope, JsonOutput};
30use crate::parse_source_file;
31
32pub const PACK_SCHEMA_VERSION: u32 = 1;
35
36#[derive(Debug, Clone, Serialize)]
38pub struct PackJsonData {
39 pub bundle_hash: String,
40 pub output_path: PathBuf,
41 pub size_bytes: u64,
42 pub manifest: WorkflowBundle,
43}
44
45struct PackJsonOutput(PackJsonData);
46
47impl JsonOutput for PackJsonOutput {
48 const SCHEMA_VERSION: u32 = PACK_SCHEMA_VERSION;
49 type Data = PackJsonData;
50 fn into_envelope(self) -> JsonEnvelope<Self::Data> {
51 JsonEnvelope::ok(Self::SCHEMA_VERSION, self.0)
52 }
53}
54
55pub fn run(args: PackArgs) {
56 match build(&args) {
57 Ok(outcome) => {
58 if args.json {
59 let envelope = PackJsonOutput(outcome.json).into_envelope();
60 println!("{}", to_string_pretty(&envelope));
61 } else {
62 println!(
63 "wrote {} ({} bytes, bundle_hash {})",
64 outcome.output_path.display(),
65 outcome.size_bytes,
66 outcome.bundle_hash
67 );
68 }
69 }
70 Err(err) => {
71 if args.json {
72 let envelope: JsonEnvelope<PackJsonData> =
73 JsonEnvelope::err(PACK_SCHEMA_VERSION, err.code, err.message);
74 println!("{}", to_string_pretty(&envelope));
75 process::exit(1);
76 }
77 command_error(&err.message);
78 }
79 }
80}
81
82pub fn run_to_envelope(args: &PackArgs) -> JsonEnvelope<PackJsonData> {
85 match build(args) {
86 Ok(outcome) => PackJsonOutput(outcome.json).into_envelope(),
87 Err(err) => JsonEnvelope::err(PACK_SCHEMA_VERSION, err.code, err.message),
88 }
89}
90
91pub struct PackOutcome {
94 pub bundle_hash: String,
95 pub output_path: PathBuf,
96 pub size_bytes: u64,
97 pub json: PackJsonData,
98}
99
100#[derive(Debug)]
101pub struct PackError {
102 pub code: &'static str,
103 pub message: String,
104}
105
106impl std::fmt::Display for PackError {
107 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 write!(f, "{}: {}", self.code, self.message)
109 }
110}
111
112impl std::error::Error for PackError {}
113
114impl PackError {
115 fn new(code: &'static str, message: impl Into<String>) -> Self {
116 Self {
117 code,
118 message: message.into(),
119 }
120 }
121}
122
123pub fn build(args: &PackArgs) -> Result<PackOutcome, PackError> {
124 if let Some(upgrade) = &args.upgrade {
125 if !upgrade.exists() {
126 return Err(PackError::new(
127 "upgrade.not_found",
128 format!(
129 "--upgrade source bundle does not exist: {}",
130 upgrade.display()
131 ),
132 ));
133 }
134 }
135 let entrypoint = args
136 .entrypoint
137 .canonicalize()
138 .unwrap_or_else(|_| args.entrypoint.clone());
139 if !entrypoint.exists() {
140 return Err(PackError::new(
141 "entrypoint.not_found",
142 format!("entrypoint does not exist: {}", args.entrypoint.display()),
143 ));
144 }
145 if !entrypoint.is_file() || entrypoint.extension().and_then(|ext| ext.to_str()) != Some("harn")
146 {
147 return Err(PackError::new(
148 "entrypoint.invalid",
149 format!(
150 "entrypoint must be a .harn file: {}",
151 args.entrypoint.display()
152 ),
153 ));
154 }
155 let project_root = entrypoint
156 .parent()
157 .map(Path::to_path_buf)
158 .unwrap_or_else(|| PathBuf::from("."));
159 let entrypoint_rel = relativize(&project_root, &entrypoint).ok_or_else(|| {
160 PackError::new(
161 "entrypoint.outside_root",
162 format!(
163 "entrypoint {} could not be relativized against {}",
164 entrypoint.display(),
165 project_root.display()
166 ),
167 )
168 })?;
169
170 let prior = match &args.upgrade {
171 Some(path) => Some(load_workflow_bundle_any_version(path).map_err(|err| {
172 PackError::new(
173 "upgrade.read_failed",
174 format!("failed to read --upgrade source {}: {err}", path.display()),
175 )
176 })?),
177 None => None,
178 };
179
180 let graph = harn_modules::build(std::slice::from_ref(&entrypoint));
181 let mut module_paths = graph.module_paths();
182 module_paths.sort();
184
185 let mut transitive_modules = Vec::new();
186 let mut contents = Vec::new();
187 let mut sbom_packages = Vec::new();
188 let mut sbom_relationships = Vec::new();
189
190 let stdlib_version = bytecode_cache::HARN_VERSION.to_string();
191 let harn_version = bytecode_cache::HARN_VERSION.to_string();
192
193 sbom_packages.push(SBOMPackage {
194 name: "harn-stdlib".to_string(),
195 version: Some(stdlib_version.clone()),
196 package_hash_blake3: None,
197 license: None,
198 });
199
200 for module_path in &module_paths {
201 let module_str = module_path.to_string_lossy().to_string();
202 if module_str.starts_with("<std>/") {
203 let stdlib_name = module_str.trim_start_matches("<std>/").to_string();
204 sbom_packages.push(SBOMPackage {
205 name: format!("std/{stdlib_name}"),
206 version: Some(stdlib_version.clone()),
207 package_hash_blake3: None,
208 license: None,
209 });
210 sbom_relationships.push(SBOMRelationship {
211 from: format!("entrypoint:{}", entrypoint_rel.display()),
212 to: format!("std/{stdlib_name}"),
213 relationship_type: "depends_on".to_string(),
214 });
215 continue;
216 }
217
218 let source = std::fs::read_to_string(module_path).map_err(|err| {
219 PackError::new(
220 "module.read_failed",
221 format!("failed to read {}: {err}", module_path.display()),
222 )
223 })?;
224
225 let (parsed_source, program) = parse_source_file(&module_str);
226 debug_assert_eq!(parsed_source, source);
227 type_check_or_fail(&source, &module_str, &program)?;
228
229 let entry_chunk = Compiler::new().compile(&program).map_err(|err| {
230 PackError::new(
231 "module.compile_failed",
232 format!("compile error in {}: {err}", module_path.display()),
233 )
234 })?;
235
236 let module_artifact_opt =
237 module_artifact::compile_module_artifact(&program, Some(module_str.clone())).ok();
238
239 let cache_key = bytecode_cache::CacheKey::from_source(module_path, &source);
240 let chunk_bytes = bytecode_cache::serialize_chunk_artifact(&cache_key, &entry_chunk)
241 .map_err(|err| {
242 PackError::new(
243 "module.serialize_failed",
244 format!(
245 "failed to serialize chunk for {}: {err}",
246 module_path.display()
247 ),
248 )
249 })?;
250
251 let module_artifact_bytes = match module_artifact_opt.as_ref() {
252 Some(artifact) => Some(
253 bytecode_cache::serialize_module_artifact(&cache_key, artifact).map_err(|err| {
254 PackError::new(
255 "module.serialize_failed",
256 format!(
257 "failed to serialize module artifact for {}: {err}",
258 module_path.display()
259 ),
260 )
261 })?,
262 ),
263 None => None,
264 };
265
266 let rel = relativize(&project_root, module_path).unwrap_or_else(|| {
267 PathBuf::from(
268 module_path
269 .file_name()
270 .map(|name| name.to_string_lossy().into_owned())
271 .unwrap_or_else(|| module_str.clone()),
272 )
273 });
274 let source_archive_path = PathBuf::from("sources").join(&rel);
275 let chunk_archive_path = adjacent_with_extension(&rel, bytecode_cache::CACHE_EXTENSION)
276 .ok_or_else(|| {
277 PackError::new(
278 "module.invalid_path",
279 format!("module path has no stem: {}", module_path.display()),
280 )
281 })?;
282 let chunk_archive_path = PathBuf::from("bytecode").join(chunk_archive_path);
283
284 let source_hash = blake3_hash(source.as_bytes());
285 let harnbc_hash = blake3_hash(&chunk_bytes);
286
287 transitive_modules.push(ModuleEntry {
288 path: rel.clone(),
289 source_hash_blake3: source_hash.clone(),
290 harnbc_hash_blake3: harnbc_hash.clone(),
291 });
292
293 contents.push(HarnpackEntry::new(
294 source_archive_path,
295 source.as_bytes().to_vec(),
296 ));
297 contents.push(HarnpackEntry::new(chunk_archive_path, chunk_bytes));
298 if let Some(artifact_bytes) = module_artifact_bytes {
299 let module_rel = adjacent_with_extension(&rel, bytecode_cache::MODULE_CACHE_EXTENSION)
300 .ok_or_else(|| {
301 PackError::new(
302 "module.invalid_path",
303 format!("module path has no stem: {}", module_path.display()),
304 )
305 })?;
306 let module_archive_path = PathBuf::from("bytecode").join(module_rel);
307 contents.push(HarnpackEntry::new(module_archive_path, artifact_bytes));
308 }
309
310 if module_path != &entrypoint {
311 sbom_relationships.push(SBOMRelationship {
312 from: format!("entrypoint:{}", entrypoint_rel.display()),
313 to: format!("module:{}", rel.display()),
314 relationship_type: "depends_on".to_string(),
315 });
316 }
317 sbom_packages.push(SBOMPackage {
318 name: format!("module:{}", rel.display()),
319 version: Some(harn_version.clone()),
320 package_hash_blake3: Some(source_hash),
321 license: None,
322 });
323 }
324
325 if transitive_modules.is_empty() {
326 return Err(PackError::new(
327 "pack.no_modules",
328 format!(
329 "no Harn modules resolved from entrypoint {}",
330 entrypoint.display()
331 ),
332 ));
333 }
334
335 let provider_catalog_hash = current_provider_catalog_hash_blake3().map_err(|err| {
336 PackError::new(
337 "provider_catalog.failed",
338 format!("failed to snapshot provider catalog: {err}"),
339 )
340 })?;
341
342 let tool_manifest: Vec<ToolEntry> = Vec::new();
345 let bundle = assemble_bundle(
346 &entrypoint_rel,
347 transitive_modules,
348 stdlib_version,
349 harn_version,
350 provider_catalog_hash,
351 tool_manifest,
352 SBOMDoc {
353 format: "spdx-lite".to_string(),
354 version: "2.3".to_string(),
355 packages: sbom_packages,
356 relationships: sbom_relationships,
357 },
358 prior.as_ref(),
359 );
360
361 let archive_bytes = build_harnpack(&bundle, &contents).map_err(|err| {
362 PackError::new(
363 "pack.archive_failed",
364 format!("failed to assemble .harnpack archive: {err}"),
365 )
366 })?;
367 let bundle_hash = workflow_bundle_hash(&bundle, &contents).map_err(|err| {
368 PackError::new(
369 "pack.hash_failed",
370 format!("failed to compute bundle hash: {err}"),
371 )
372 })?;
373
374 let output_path = resolve_output_path(&args.out, &entrypoint);
375 if let Some(parent) = output_path.parent() {
376 if !parent.as_os_str().is_empty() {
377 std::fs::create_dir_all(parent).map_err(|err| {
378 PackError::new(
379 "pack.output_dir_failed",
380 format!("failed to create output dir {}: {err}", parent.display()),
381 )
382 })?;
383 }
384 }
385 std::fs::write(&output_path, &archive_bytes).map_err(|err| {
386 PackError::new(
387 "pack.write_failed",
388 format!("failed to write {}: {err}", output_path.display()),
389 )
390 })?;
391 let size_bytes = archive_bytes.len() as u64;
392
393 Ok(PackOutcome {
394 bundle_hash: bundle_hash.clone(),
395 output_path: output_path.clone(),
396 size_bytes,
397 json: PackJsonData {
398 bundle_hash,
399 output_path,
400 size_bytes,
401 manifest: bundle,
402 },
403 })
404}
405
406fn assemble_bundle(
407 entrypoint_rel: &Path,
408 transitive_modules: Vec<ModuleEntry>,
409 stdlib_version: String,
410 harn_version: String,
411 provider_catalog_hash: String,
412 tool_manifest: Vec<ToolEntry>,
413 sbom: SBOMDoc,
414 prior: Option<&WorkflowBundle>,
415) -> WorkflowBundle {
416 let stem = entrypoint_rel
417 .file_stem()
418 .map(|s| s.to_string_lossy().into_owned())
419 .unwrap_or_else(|| "harnpack".to_string());
420
421 let mut bundle = prior.cloned().unwrap_or_else(|| WorkflowBundle {
422 id: stem.clone(),
423 name: Some(stem.clone()),
424 version: "0.0.0".to_string(),
425 workflow: degenerate_workflow(&stem),
426 triggers: vec![WorkflowBundleTrigger {
427 id: "manual".to_string(),
428 kind: "manual".to_string(),
429 node_id: Some("entry".to_string()),
430 ..WorkflowBundleTrigger::default()
431 }],
432 policy: WorkflowBundlePolicy {
433 autonomy_tier: "act_with_approval".to_string(),
434 tool_policy: BTreeMap::new(),
435 approval_required: Vec::new(),
436 retry: RetryPolicySpec {
437 max_attempts: 1,
438 backoff: "none".to_string(),
439 },
440 catchup: CatchupPolicySpec {
441 mode: "none".to_string(),
442 max_events: None,
443 },
444 },
445 connectors: Vec::<ConnectorRequirement>::new(),
446 environment: EnvironmentRequirements::default(),
447 receipts: WorkflowBundleReplayMetadata::default(),
448 ..WorkflowBundle::default()
449 });
450
451 bundle.schema_version = WORKFLOW_BUNDLE_SCHEMA_VERSION;
452 bundle.entrypoint = entrypoint_rel.to_path_buf();
453 bundle.transitive_modules = transitive_modules;
454 bundle.stdlib_version = stdlib_version;
455 bundle.harn_version = harn_version;
456 bundle.provider_catalog_hash = provider_catalog_hash;
457 bundle.tool_manifest = tool_manifest;
458 bundle.sbom = sbom;
459 bundle.signature = None;
460 bundle
461}
462
463fn degenerate_workflow(stem: &str) -> harn_vm::orchestration::WorkflowGraph {
464 use harn_vm::orchestration::{WorkflowGraph, WorkflowNode};
465 let mut nodes = BTreeMap::new();
466 nodes.insert(
467 "entry".to_string(),
468 WorkflowNode {
469 id: Some("entry".to_string()),
470 kind: "action".to_string(),
471 task_label: Some(stem.to_string()),
472 ..WorkflowNode::default()
473 },
474 );
475 WorkflowGraph {
476 type_name: "workflow_graph".to_string(),
477 id: format!("{stem}_pack"),
478 name: Some(stem.to_string()),
479 version: 1,
480 entry: "entry".to_string(),
481 nodes,
482 ..WorkflowGraph::default()
483 }
484}
485
486fn type_check_or_fail(
487 source: &str,
488 path: &str,
489 program: &[harn_parser::SNode],
490) -> Result<(), PackError> {
491 let mut had_error = false;
492 let mut messages = String::new();
493 for diag in harn_parser::TypeChecker::new().check_with_source(program, source) {
494 let rendered = harn_parser::diagnostic::render_type_diagnostic(source, path, &diag);
495 if matches!(diag.severity, DiagnosticSeverity::Error) {
496 had_error = true;
497 }
498 messages.push_str(&rendered);
499 }
500 if had_error {
501 return Err(PackError::new(
502 "module.type_error",
503 format!("type errors in {path}:\n{messages}"),
504 ));
505 }
506 if !messages.is_empty() {
507 eprint!("{messages}");
508 }
509 Ok(())
510}
511
512fn relativize(root: &Path, target: &Path) -> Option<PathBuf> {
513 let root_canon = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
514 let target_canon = target
515 .canonicalize()
516 .unwrap_or_else(|_| target.to_path_buf());
517 if let Ok(rel) = target_canon.strip_prefix(&root_canon) {
518 return Some(rel.to_path_buf());
519 }
520 target.file_name().map(PathBuf::from)
523}
524
525fn adjacent_with_extension(rel: &Path, extension: &str) -> Option<PathBuf> {
526 let stem = rel.file_stem()?.to_string_lossy().into_owned();
527 if stem.is_empty() {
528 return None;
529 }
530 let parent_components: Vec<Component<'_>> = rel
531 .parent()
532 .map(|p| p.components().collect())
533 .unwrap_or_default();
534 let mut adjacent = PathBuf::new();
535 for component in parent_components {
536 adjacent.push(component.as_os_str());
537 }
538 let mut filename = stem;
539 filename.push('.');
540 filename.push_str(extension);
541 adjacent.push(filename);
542 Some(adjacent)
543}
544
545fn blake3_hash(bytes: &[u8]) -> String {
546 format!("blake3:{}", blake3::hash(bytes))
547}
548
549fn resolve_output_path(out: &Option<PathBuf>, entrypoint: &Path) -> PathBuf {
550 if let Some(path) = out {
551 return path.clone();
552 }
553 let stem = entrypoint
554 .file_stem()
555 .map(|s| s.to_string_lossy().into_owned())
556 .unwrap_or_else(|| "bundle".to_string());
557 let parent = entrypoint.parent().unwrap_or_else(|| Path::new("."));
558 parent.join(format!("{stem}.harnpack"))
559}