1use std::collections::{BTreeMap, BTreeSet, VecDeque};
5use std::fs;
6use std::io::{Cursor, Read};
7use std::path::{Component, Path, PathBuf};
8
9use ed25519_dalek::{Signature, Verifier, VerifyingKey};
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12
13use super::{validate_workflow, WorkflowEdge, WorkflowGraph};
14use crate::tool_annotations::ToolAnnotations;
15
16mod projection;
17
18use projection::{
19 catchup_editable_fields, connector_editable_fields, render_workflow_bundle_mermaid,
20 retry_editable_fields, trigger_editable_fields, workflow_node_editable_fields,
21};
22
23pub const WORKFLOW_BUNDLE_SCHEMA_VERSION: u32 = 3;
24pub const LEGACY_WORKFLOW_BUNDLE_SCHEMA_VERSION: u32 = 2;
25pub const WORKFLOW_BUNDLE_RECEIPT_TYPE: &str = "harn.workflow_bundle.run";
26pub const HARNPACK_MANIFEST_PATH: &str = "harnpack.json";
27
28const DEFAULT_HARNPACK_FILE_MODE: u32 = 0o644;
29
30const MAX_HARNPACK_DECOMPRESSED_BYTES: u64 = 100 * 1024 * 1024;
36
37fn decompress_harnpack_zstd(bytes: &[u8]) -> Result<Vec<u8>, WorkflowBundleError> {
42 let mut decoder = zstd::stream::Decoder::new(Cursor::new(bytes))?;
43 let mut output = Vec::new();
44 let mut limited = (&mut decoder).take(MAX_HARNPACK_DECOMPRESSED_BYTES.saturating_add(1));
45 limited.read_to_end(&mut output)?;
46 if output.len() as u64 > MAX_HARNPACK_DECOMPRESSED_BYTES {
47 return Err(WorkflowBundleError::new(
48 WorkflowBundleErrorKind::InvalidArchive,
49 format!(
50 "harnpack zstd payload decompressed past max size ({MAX_HARNPACK_DECOMPRESSED_BYTES} bytes); refusing to extract"
51 ),
52 ));
53 }
54 Ok(output)
55}
56
57#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
58#[serde(default)]
59pub struct WorkflowBundle {
60 pub schema_version: u32,
61 pub entrypoint: PathBuf,
62 pub execution_artifact: Option<ExecutionArtifact>,
65 pub transitive_modules: Vec<ModuleEntry>,
66 pub stdlib_version: String,
67 pub harn_version: String,
68 pub provider_catalog_hash: String,
69 pub tool_manifest: Vec<ToolEntry>,
70 pub sbom: SBOMDoc,
71 pub signature: Option<Ed25519Signature>,
72 pub parent_trust_record_id: Option<String>,
73 pub id: String,
74 pub name: Option<String>,
75 pub version: String,
76 pub triggers: Vec<WorkflowBundleTrigger>,
77 pub workflow: WorkflowGraph,
78 pub prompt_capsules: BTreeMap<String, PromptCapsule>,
79 pub policy: WorkflowBundlePolicy,
80 pub connectors: Vec<ConnectorRequirement>,
81 pub environment: EnvironmentRequirements,
82 pub receipts: WorkflowBundleReplayMetadata,
83 pub metadata: BTreeMap<String, serde_json::Value>,
84}
85
86impl Default for WorkflowBundle {
87 fn default() -> Self {
88 Self {
89 schema_version: WORKFLOW_BUNDLE_SCHEMA_VERSION,
90 entrypoint: PathBuf::new(),
91 execution_artifact: None,
92 transitive_modules: Vec::new(),
93 stdlib_version: env!("CARGO_PKG_VERSION").to_string(),
94 harn_version: env!("CARGO_PKG_VERSION").to_string(),
95 provider_catalog_hash: String::new(),
96 tool_manifest: Vec::new(),
97 sbom: SBOMDoc::default(),
98 signature: None,
99 parent_trust_record_id: None,
100 id: String::new(),
101 name: None,
102 version: String::new(),
103 triggers: Vec::new(),
104 workflow: WorkflowGraph::default(),
105 prompt_capsules: BTreeMap::new(),
106 policy: WorkflowBundlePolicy::default(),
107 connectors: Vec::new(),
108 environment: EnvironmentRequirements::default(),
109 receipts: WorkflowBundleReplayMetadata::default(),
110 metadata: BTreeMap::new(),
111 }
112 }
113}
114
115#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
116#[serde(default)]
117pub struct ModuleEntry {
118 pub path: PathBuf,
119 pub source_hash_blake3: String,
120 pub harnbc_hash_blake3: String,
121}
122
123#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
124#[serde(default)]
125pub struct ExecutionArtifact {
126 pub format: String,
127 pub path: PathBuf,
128 pub hash_blake3: String,
129 pub graph_digest_blake3: String,
130 pub fallback: ExecutionArtifactFallback,
131 pub link_report: crate::linked_program::LinkReport,
132}
133
134impl Default for ExecutionArtifact {
135 fn default() -> Self {
136 Self {
137 format: "harn.linked_program.v1".to_string(),
138 path: PathBuf::from(crate::linked_program::LINKED_PROGRAM_ARCHIVE_PATH),
139 hash_blake3: String::new(),
140 graph_digest_blake3: String::new(),
141 fallback: ExecutionArtifactFallback::Deny,
142 link_report: crate::linked_program::LinkReport::default(),
143 }
144 }
145}
146
147#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
148#[serde(rename_all = "snake_case")]
149pub enum ExecutionArtifactFallback {
150 #[default]
151 Deny,
152 ExactSources,
153}
154
155#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
156#[serde(default)]
157pub struct ToolEntry {
158 pub name: String,
159 pub provider: Option<String>,
160 pub annotations: Option<ToolAnnotations>,
161 pub schema_hash_blake3: Option<String>,
162 pub metadata: BTreeMap<String, String>,
163}
164
165#[allow(clippy::upper_case_acronyms)]
166#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
167#[serde(default)]
168pub struct SBOMDoc {
169 pub format: String,
170 pub version: String,
171 pub packages: Vec<SBOMPackage>,
172 pub relationships: Vec<SBOMRelationship>,
173}
174
175#[allow(clippy::upper_case_acronyms)]
176#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
177#[serde(default)]
178pub struct SBOMPackage {
179 pub name: String,
180 pub version: Option<String>,
181 pub package_hash_blake3: Option<String>,
182 pub license: Option<String>,
183}
184
185#[allow(clippy::upper_case_acronyms)]
186#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
187#[serde(default)]
188pub struct SBOMRelationship {
189 pub from: String,
190 pub to: String,
191 pub relationship_type: String,
192}
193
194#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
195#[serde(default)]
196pub struct Ed25519Signature {
197 pub key_id: Option<String>,
198 pub public_key: String,
199 pub signature: String,
200 pub manifest_hash_blake3: String,
201 pub algorithm: String,
202}
203
204#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
205#[serde(default)]
206pub struct WorkflowBundleTrigger {
207 pub id: String,
208 pub kind: String,
209 pub provider: Option<String>,
210 pub events: Vec<String>,
211 pub schedule: Option<String>,
212 pub delay: Option<String>,
213 pub webhook_path: Option<String>,
214 pub mcp_tool: Option<String>,
215 pub resume_key: Option<String>,
216 pub node_id: Option<String>,
217 pub metadata: BTreeMap<String, String>,
218}
219
220#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
221#[serde(default)]
222pub struct PromptCapsule {
223 pub id: String,
224 pub node_id: String,
225 pub trigger_id: Option<String>,
226 pub prompt: String,
227 pub system: Option<String>,
228 pub context: BTreeMap<String, serde_json::Value>,
229}
230
231#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
232#[serde(default)]
233pub struct WorkflowBundlePolicy {
234 pub autonomy_tier: String,
235 pub tool_policy: BTreeMap<String, serde_json::Value>,
236 pub approval_required: Vec<String>,
237 pub retry: RetryPolicySpec,
238 pub catchup: CatchupPolicySpec,
239}
240
241impl Default for WorkflowBundlePolicy {
242 fn default() -> Self {
243 Self {
244 autonomy_tier: "act_with_approval".to_string(),
245 tool_policy: BTreeMap::new(),
246 approval_required: Vec::new(),
247 retry: RetryPolicySpec::default(),
248 catchup: CatchupPolicySpec::default(),
249 }
250 }
251}
252
253#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
254#[serde(default)]
255pub struct RetryPolicySpec {
256 pub max_attempts: u32,
257 pub backoff: String,
258}
259
260impl Default for RetryPolicySpec {
261 fn default() -> Self {
262 Self {
263 max_attempts: 1,
264 backoff: "none".to_string(),
265 }
266 }
267}
268
269#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
270#[serde(default)]
271pub struct CatchupPolicySpec {
272 pub mode: String,
273 pub max_events: Option<u32>,
274}
275
276impl Default for CatchupPolicySpec {
277 fn default() -> Self {
278 Self {
279 mode: "latest".to_string(),
280 max_events: Some(1),
281 }
282 }
283}
284
285#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
286#[serde(default)]
287pub struct ConnectorRequirement {
288 pub id: String,
289 pub provider_id: String,
290 pub scopes: Vec<String>,
291 pub setup_required: bool,
292 pub status_required: bool,
293}
294
295#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
296#[serde(default)]
297pub struct EnvironmentRequirements {
298 pub repo_setup_profile: Option<String>,
299 pub worktree_policy: String,
300 pub command_gates: Vec<String>,
301}
302
303impl Default for EnvironmentRequirements {
304 fn default() -> Self {
305 Self {
306 repo_setup_profile: None,
307 worktree_policy: "host_managed".to_string(),
308 command_gates: Vec::new(),
309 }
310 }
311}
312
313#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
314#[serde(default)]
315pub struct WorkflowBundleReplayMetadata {
316 pub run_id: Option<String>,
317 pub event_ids: Vec<String>,
318 pub workflow_version: Option<usize>,
319 pub graph_digest: Option<String>,
320}
321
322#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
323#[serde(default)]
324pub struct WorkflowBundleDiagnostic {
325 pub severity: String,
326 pub path: String,
327 pub message: String,
328 pub node_id: Option<String>,
329}
330
331#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
332#[serde(default)]
333pub struct WorkflowBundleValidationReport {
334 pub valid: bool,
335 pub bundle_id: String,
336 pub workflow_id: String,
337 pub graph_digest: String,
338 pub errors: Vec<WorkflowBundleDiagnostic>,
339 pub warnings: Vec<WorkflowBundleDiagnostic>,
340}
341
342#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
343pub struct WorkflowBundlePreview {
344 pub schema_version: u32,
345 pub bundle_id: String,
346 pub bundle_version: String,
347 pub workflow_id: String,
348 pub workflow_version: usize,
349 pub graph_digest: String,
350 pub validation: WorkflowBundleValidationReport,
351 pub graph: WorkflowBundleGraphExport,
352 pub mermaid: String,
353 pub triggers: Vec<WorkflowBundleTrigger>,
354 pub connectors: Vec<ConnectorRequirement>,
355 pub environment: EnvironmentRequirements,
356 pub nodes: Vec<WorkflowBundlePreviewNode>,
357 pub edges: Vec<WorkflowEdge>,
358 #[serde(default)]
363 pub metadata: BTreeMap<String, serde_json::Value>,
364}
365
366#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
367pub struct WorkflowBundlePreviewNode {
368 pub id: String,
369 pub kind: String,
370 pub label: Option<String>,
371 pub prompt_capsule: Option<String>,
372 pub trigger_ids: Vec<String>,
373 pub outgoing: Vec<String>,
374}
375
376#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
377pub struct WorkflowBundleGraphExport {
378 pub schema_version: u32,
379 pub graph_id: String,
380 pub graph_digest: String,
381 pub nodes: Vec<WorkflowBundleGraphNode>,
382 pub edges: Vec<WorkflowBundleGraphEdge>,
383 pub diagnostics: Vec<WorkflowBundleGraphDiagnostic>,
384 pub editable_fields: Vec<WorkflowBundleEditableField>,
385 pub mermaid: String,
386}
387
388#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
389pub struct WorkflowBundleGraphNode {
390 pub id: String,
391 pub node_type: String,
392 pub label: String,
393 pub workflow_node_id: Option<String>,
394 pub trigger_id: Option<String>,
395 pub connector_id: Option<String>,
396 pub editable_fields: Vec<WorkflowBundleEditableField>,
397 pub metadata: BTreeMap<String, serde_json::Value>,
398}
399
400#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
401pub struct WorkflowBundleGraphEdge {
402 pub from: String,
403 pub to: String,
404 pub label: Option<String>,
405 pub branch: Option<String>,
406}
407
408#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
409pub struct WorkflowBundleGraphDiagnostic {
410 pub severity: String,
411 pub path: String,
412 pub message: String,
413 pub node_id: Option<String>,
414 pub graph_node_id: Option<String>,
415}
416
417#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
418pub struct WorkflowBundleEditableField {
419 pub id: String,
420 pub label: String,
421 pub json_pointer: String,
422 pub value_type: String,
423 pub required: bool,
424 pub enum_values: Vec<String>,
425}
426
427#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
428#[serde(default)]
429pub struct WorkflowBundleRunRequest {
430 pub trigger_id: Option<String>,
431 pub event_id: Option<String>,
432}
433
434#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
435pub struct WorkflowBundleRunReceipt {
436 pub schema_version: u32,
437 pub receipt_type: String,
438 pub bundle_id: String,
439 pub bundle_version: String,
440 pub workflow_id: String,
441 pub workflow_version: usize,
442 pub graph_digest: String,
443 pub run_id: String,
444 pub trigger_id: Option<String>,
445 pub event_ids: Vec<String>,
446 pub status: String,
447 pub executed_nodes: Vec<WorkflowBundleRunNodeReceipt>,
448 pub policy: WorkflowBundlePolicy,
449 pub connectors: Vec<ConnectorRequirement>,
450 pub environment: EnvironmentRequirements,
451}
452
453#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
454pub struct WorkflowBundleRunNodeReceipt {
455 pub node_id: String,
456 pub kind: String,
457 pub prompt_capsule: Option<String>,
458 pub status: String,
459}
460
461#[derive(Clone, Debug, PartialEq, Eq)]
462pub struct HarnpackEntry {
463 pub path: PathBuf,
464 pub bytes: Vec<u8>,
465 pub mode: u32,
466}
467
468impl HarnpackEntry {
469 pub fn new(path: impl Into<PathBuf>, bytes: impl Into<Vec<u8>>) -> Self {
470 Self {
471 path: path.into(),
472 bytes: bytes.into(),
473 mode: DEFAULT_HARNPACK_FILE_MODE,
474 }
475 }
476
477 pub fn with_mode(mut self, mode: u32) -> Self {
478 self.mode = mode;
479 self
480 }
481}
482
483#[derive(Clone, Debug, PartialEq)]
484pub struct HarnpackArchive {
485 pub manifest: WorkflowBundle,
486 pub contents: Vec<HarnpackEntry>,
487}
488
489#[derive(Clone, Debug, PartialEq, Eq)]
490pub enum WorkflowBundleErrorKind {
491 Io,
492 Json,
493 MissingSchemaVersion,
494 UnsupportedSchemaVersion { actual: u32, expected: u32 },
495 InvalidArchive,
496 DuplicateArchiveEntry,
497 UnsafeArchivePath,
498 InvalidSignature,
499}
500
501#[derive(Clone, Debug, PartialEq, Eq)]
502pub struct WorkflowBundleError {
503 pub kind: WorkflowBundleErrorKind,
504 pub message: String,
505}
506
507impl WorkflowBundleError {
508 fn new(kind: WorkflowBundleErrorKind, message: impl Into<String>) -> Self {
509 Self {
510 kind,
511 message: message.into(),
512 }
513 }
514
515 fn unsupported_schema_version(actual: u32) -> Self {
516 Self::new(
517 WorkflowBundleErrorKind::UnsupportedSchemaVersion {
518 actual,
519 expected: WORKFLOW_BUNDLE_SCHEMA_VERSION,
520 },
521 format!(
522 "unsupported workflow bundle schema_version {actual}; expected {WORKFLOW_BUNDLE_SCHEMA_VERSION}"
523 ),
524 )
525 }
526}
527
528impl std::fmt::Display for WorkflowBundleError {
529 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
530 self.message.fmt(f)
531 }
532}
533
534impl std::error::Error for WorkflowBundleError {}
535
536impl From<std::io::Error> for WorkflowBundleError {
537 fn from(error: std::io::Error) -> Self {
538 Self::new(WorkflowBundleErrorKind::Io, error.to_string())
539 }
540}
541
542impl From<serde_json::Error> for WorkflowBundleError {
543 fn from(error: serde_json::Error) -> Self {
544 Self::new(WorkflowBundleErrorKind::Json, error.to_string())
545 }
546}
547
548pub fn load_workflow_bundle(path: &Path) -> Result<WorkflowBundle, WorkflowBundleError> {
549 let bytes = fs::read(path)?;
550 if path.extension().and_then(|extension| extension.to_str()) == Some("harnpack") {
551 read_harnpack(&bytes).map(|archive| archive.manifest)
552 } else {
553 parse_workflow_bundle_manifest(&bytes)
554 }
555}
556
557pub fn read_workflow_bundle_manifest_any_version(
563 bytes: &[u8],
564) -> Result<WorkflowBundle, WorkflowBundleError> {
565 let value: serde_json::Value = serde_json::from_slice(bytes)?;
566 if value.get("schema_version").is_none() {
567 return Err(WorkflowBundleError::new(
568 WorkflowBundleErrorKind::MissingSchemaVersion,
569 "workflow bundle manifest is missing schema_version",
570 ));
571 }
572 serde_json::from_value(value).map_err(Into::into)
573}
574
575pub fn load_workflow_bundle_any_version(
579 path: &Path,
580) -> Result<WorkflowBundle, WorkflowBundleError> {
581 let bytes = fs::read(path)?;
582 if path.extension().and_then(|extension| extension.to_str()) == Some("harnpack") {
583 let tar_bytes = decompress_harnpack_zstd(&bytes)?;
584 let mut archive = tar::Archive::new(Cursor::new(tar_bytes));
585 for entry in archive.entries()? {
586 let mut entry = entry?;
587 if entry.header().entry_type().is_dir() {
588 continue;
589 }
590 let path = normalize_archive_path(entry.path()?.as_ref())?;
591 if path == HARNPACK_MANIFEST_PATH {
592 let mut entry_bytes = Vec::new();
593 entry.read_to_end(&mut entry_bytes)?;
594 return read_workflow_bundle_manifest_any_version(&entry_bytes);
595 }
596 }
597 Err(WorkflowBundleError::new(
598 WorkflowBundleErrorKind::InvalidArchive,
599 format!("harnpack archive is missing {HARNPACK_MANIFEST_PATH}"),
600 ))
601 } else {
602 read_workflow_bundle_manifest_any_version(&bytes)
603 }
604}
605
606pub fn parse_workflow_bundle_manifest(bytes: &[u8]) -> Result<WorkflowBundle, WorkflowBundleError> {
607 let value: serde_json::Value = serde_json::from_slice(bytes)?;
608 let schema_version = value
609 .get("schema_version")
610 .and_then(serde_json::Value::as_u64)
611 .ok_or_else(|| {
612 WorkflowBundleError::new(
613 WorkflowBundleErrorKind::MissingSchemaVersion,
614 "workflow bundle manifest is missing numeric schema_version",
615 )
616 })?;
617 let actual = u32::try_from(schema_version).map_err(|_| {
618 WorkflowBundleError::new(
619 WorkflowBundleErrorKind::UnsupportedSchemaVersion {
620 actual: u32::MAX,
621 expected: WORKFLOW_BUNDLE_SCHEMA_VERSION,
622 },
623 format!(
624 "unsupported workflow bundle schema_version {schema_version}; expected {WORKFLOW_BUNDLE_SCHEMA_VERSION}"
625 ),
626 )
627 })?;
628 if actual != WORKFLOW_BUNDLE_SCHEMA_VERSION && actual != LEGACY_WORKFLOW_BUNDLE_SCHEMA_VERSION {
629 return Err(WorkflowBundleError::unsupported_schema_version(actual));
630 }
631 serde_json::from_value(value).map_err(Into::into)
632}
633
634pub fn canonical_workflow_bundle_manifest_bytes(
635 bundle: &WorkflowBundle,
636) -> Result<Vec<u8>, WorkflowBundleError> {
637 serde_json::to_vec(&canonical_workflow_bundle_manifest(bundle)).map_err(Into::into)
638}
639
640pub fn workflow_bundle_hash(
641 bundle: &WorkflowBundle,
642 contents: &[HarnpackEntry],
643) -> Result<String, WorkflowBundleError> {
644 let mut hasher = blake3::Hasher::new();
645 let mut canonical = canonical_workflow_bundle_manifest(bundle);
646 canonical.signature = None;
647 let manifest_bytes = serde_json::to_vec(&canonical)?;
648 if bundle.schema_version >= WORKFLOW_BUNDLE_SCHEMA_VERSION {
649 hasher.update(b"harn.workflow-bundle.v3\0");
650 }
651 hasher.update(&manifest_bytes);
652
653 if bundle.schema_version >= WORKFLOW_BUNDLE_SCHEMA_VERSION {
654 let mut entries = contents
655 .iter()
656 .map(|entry| normalize_archive_path(&entry.path).map(|path| (path, entry)))
657 .collect::<Result<Vec<_>, _>>()?;
658 entries.sort_by(|left, right| left.0.cmp(&right.0));
659 for pair in entries.windows(2) {
660 if pair[0].0 == pair[1].0 {
661 return Err(WorkflowBundleError::new(
662 WorkflowBundleErrorKind::DuplicateArchiveEntry,
663 format!("duplicate archive entry {}", pair[0].0),
664 ));
665 }
666 }
667 for (path, entry) in entries {
668 let path_bytes = path;
669 hasher.update(b"\nentry\0");
670 hasher.update(&(path_bytes.len() as u64).to_le_bytes());
671 hasher.update(path_bytes.as_bytes());
672 hasher.update(&entry.mode.to_le_bytes());
673 hasher.update(blake3_hash_bytes(&entry.bytes).as_bytes());
674 }
675 } else {
676 let mut content_hashes = contents
679 .iter()
680 .map(|entry| blake3_hash_bytes(&entry.bytes))
681 .collect::<Vec<_>>();
682 content_hashes.sort();
683 for content_hash in content_hashes {
684 hasher.update(b"\n");
685 hasher.update(content_hash.as_bytes());
686 }
687 }
688
689 Ok(blake3_digest_string(hasher.finalize()))
690}
691
692pub fn verify_workflow_bundle_signature(
693 bundle: &WorkflowBundle,
694 contents: &[HarnpackEntry],
695) -> Result<(), WorkflowBundleError> {
696 let signature = bundle.signature.as_ref().ok_or_else(|| {
697 WorkflowBundleError::new(
698 WorkflowBundleErrorKind::InvalidSignature,
699 "workflow bundle is unsigned",
700 )
701 })?;
702 if signature.algorithm != "ed25519" {
703 return Err(WorkflowBundleError::new(
704 WorkflowBundleErrorKind::InvalidSignature,
705 format!(
706 "unsupported workflow bundle signature algorithm {}",
707 signature.algorithm
708 ),
709 ));
710 }
711 let expected_hash = workflow_bundle_hash(bundle, contents)?;
712 if signature.manifest_hash_blake3 != expected_hash {
713 return Err(WorkflowBundleError::new(
714 WorkflowBundleErrorKind::InvalidSignature,
715 format!(
716 "workflow bundle signature hash mismatch; expected {expected_hash}, found {}",
717 signature.manifest_hash_blake3
718 ),
719 ));
720 }
721 let public_key_bytes = decode_hex_exact::<32>(
722 "workflow bundle signature public_key",
723 &signature.public_key,
724 )?;
725 let signature_bytes =
726 decode_hex_exact::<64>("workflow bundle signature", &signature.signature)?;
727 let verifying_key = VerifyingKey::from_bytes(&public_key_bytes).map_err(|error| {
728 WorkflowBundleError::new(
729 WorkflowBundleErrorKind::InvalidSignature,
730 format!("workflow bundle signature public_key is invalid Ed25519: {error}"),
731 )
732 })?;
733 let ed25519_signature = Signature::from_bytes(&signature_bytes);
734 verifying_key
735 .verify(expected_hash.as_bytes(), &ed25519_signature)
736 .map_err(|error| {
737 WorkflowBundleError::new(
738 WorkflowBundleErrorKind::InvalidSignature,
739 format!("workflow bundle signature failed Ed25519 verification: {error}"),
740 )
741 })
742}
743
744pub fn build_harnpack(
745 bundle: &WorkflowBundle,
746 contents: &[HarnpackEntry],
747) -> Result<Vec<u8>, WorkflowBundleError> {
748 let manifest_bytes = canonical_workflow_bundle_manifest_bytes(bundle)?;
749 let mut entries = contents
750 .iter()
751 .map(|entry| {
752 normalize_archive_path(&entry.path).map(|path| (path, entry.bytes.clone(), entry.mode))
753 })
754 .collect::<Result<Vec<_>, _>>()?;
755 entries.sort_by(|left, right| left.0.cmp(&right.0));
756
757 let mut seen = BTreeSet::new();
758 for (path, _, _) in &entries {
759 if path == HARNPACK_MANIFEST_PATH {
760 return Err(WorkflowBundleError::new(
761 WorkflowBundleErrorKind::DuplicateArchiveEntry,
762 format!("archive content cannot replace {HARNPACK_MANIFEST_PATH}"),
763 ));
764 }
765 if !seen.insert(path.clone()) {
766 return Err(WorkflowBundleError::new(
767 WorkflowBundleErrorKind::DuplicateArchiveEntry,
768 format!("duplicate archive entry: {path}"),
769 ));
770 }
771 }
772
773 let mut tar_bytes = Vec::new();
774 {
775 let mut builder = tar::Builder::new(&mut tar_bytes);
776 append_harnpack_entry(
777 &mut builder,
778 HARNPACK_MANIFEST_PATH,
779 &manifest_bytes,
780 DEFAULT_HARNPACK_FILE_MODE,
781 )?;
782 for (path, bytes, mode) in entries {
783 append_harnpack_entry(&mut builder, &path, &bytes, mode)?;
784 }
785 builder.finish()?;
786 }
787
788 zstd::stream::encode_all(Cursor::new(tar_bytes), 0).map_err(Into::into)
789}
790
791pub fn read_harnpack(bytes: &[u8]) -> Result<HarnpackArchive, WorkflowBundleError> {
792 let tar_bytes = decompress_harnpack_zstd(bytes)?;
793 let mut archive = tar::Archive::new(Cursor::new(tar_bytes));
794 let mut manifest = None;
795 let mut contents = Vec::new();
796 let mut seen = BTreeSet::new();
797
798 for entry in archive.entries()? {
799 let mut entry = entry?;
800 if entry.header().entry_type().is_dir() {
801 continue;
802 }
803 let path = normalize_archive_path(entry.path()?.as_ref())?;
804 if !seen.insert(path.clone()) {
805 return Err(WorkflowBundleError::new(
806 WorkflowBundleErrorKind::DuplicateArchiveEntry,
807 format!("duplicate archive entry: {path}"),
808 ));
809 }
810 let mode = entry.header().mode().unwrap_or(DEFAULT_HARNPACK_FILE_MODE);
811 let mut entry_bytes = Vec::new();
812 entry.read_to_end(&mut entry_bytes)?;
813
814 if path == HARNPACK_MANIFEST_PATH {
815 manifest = Some(parse_workflow_bundle_manifest(&entry_bytes)?);
816 } else {
817 contents.push(HarnpackEntry {
818 path: PathBuf::from(path),
819 bytes: entry_bytes,
820 mode,
821 });
822 }
823 }
824
825 contents.sort_by(|left, right| left.path.cmp(&right.path));
826 Ok(HarnpackArchive {
827 manifest: manifest.ok_or_else(|| {
828 WorkflowBundleError::new(
829 WorkflowBundleErrorKind::InvalidArchive,
830 format!("harnpack archive is missing {HARNPACK_MANIFEST_PATH}"),
831 )
832 })?,
833 contents,
834 })
835}
836
837pub fn current_provider_catalog_hash_blake3() -> Result<String, WorkflowBundleError> {
838 let bytes = serde_json::to_vec(&crate::provider_catalog::artifact())?;
839 Ok(blake3_hash_bytes(&bytes))
840}
841
842pub fn workflow_graph_digest(graph: &WorkflowGraph) -> String {
843 let mut canonical = canonical_workflow_graph(graph);
844 canonical.audit_log.clear();
845 let bytes = serde_json::to_vec(&canonical).expect("workflow graph serializes");
846 let digest = Sha256::digest(bytes);
847 let hex = digest
848 .iter()
849 .map(|byte| format!("{byte:02x}"))
850 .collect::<String>();
851 format!("sha256:{hex}")
852}
853
854fn canonical_workflow_bundle_manifest(bundle: &WorkflowBundle) -> WorkflowBundle {
855 let mut canonical = bundle.clone();
856 canonical.workflow = canonical_workflow_graph(&bundle.workflow);
857 canonical.transitive_modules.sort_by(|left, right| {
858 (
859 path_sort_key(&left.path),
860 &left.source_hash_blake3,
861 &left.harnbc_hash_blake3,
862 )
863 .cmp(&(
864 path_sort_key(&right.path),
865 &right.source_hash_blake3,
866 &right.harnbc_hash_blake3,
867 ))
868 });
869 canonical.tool_manifest.sort_by(|left, right| {
870 (&left.name, &left.provider, &left.schema_hash_blake3).cmp(&(
871 &right.name,
872 &right.provider,
873 &right.schema_hash_blake3,
874 ))
875 });
876 canonical.sbom.packages.sort_by(|left, right| {
877 (&left.name, &left.version, &left.package_hash_blake3).cmp(&(
878 &right.name,
879 &right.version,
880 &right.package_hash_blake3,
881 ))
882 });
883 canonical.sbom.relationships.sort_by(|left, right| {
884 (&left.from, &left.to, &left.relationship_type).cmp(&(
885 &right.from,
886 &right.to,
887 &right.relationship_type,
888 ))
889 });
890 canonical
891}
892
893fn decode_hex_exact<const N: usize>(
894 label: &str,
895 value: &str,
896) -> Result<[u8; N], WorkflowBundleError> {
897 let bytes = hex::decode(value).map_err(|error| {
898 WorkflowBundleError::new(
899 WorkflowBundleErrorKind::InvalidSignature,
900 format!("{label} is not hex: {error}"),
901 )
902 })?;
903 bytes.try_into().map_err(|bytes: Vec<u8>| {
904 WorkflowBundleError::new(
905 WorkflowBundleErrorKind::InvalidSignature,
906 format!("{label} must decode to {N} bytes, got {}", bytes.len()),
907 )
908 })
909}
910
911fn append_harnpack_entry<W: std::io::Write>(
912 builder: &mut tar::Builder<W>,
913 path: &str,
914 bytes: &[u8],
915 mode: u32,
916) -> Result<(), WorkflowBundleError> {
917 let mut header = tar::Header::new_gnu();
918 header.set_path(path).map_err(|error| {
919 WorkflowBundleError::new(
920 WorkflowBundleErrorKind::UnsafeArchivePath,
921 format!("invalid archive path {path}: {error}"),
922 )
923 })?;
924 header.set_entry_type(tar::EntryType::Regular);
925 header.set_size(bytes.len() as u64);
926 header.set_mode(mode);
927 header.set_mtime(0);
928 header.set_uid(0);
929 header.set_gid(0);
930 header.set_cksum();
931 builder.append(&header, bytes)?;
932 Ok(())
933}
934
935fn normalize_archive_path(path: &Path) -> Result<String, WorkflowBundleError> {
936 let mut parts = Vec::new();
937 for component in path.components() {
938 match component {
939 Component::Normal(part) => {
940 let Some(part) = part.to_str() else {
941 return Err(WorkflowBundleError::new(
942 WorkflowBundleErrorKind::UnsafeArchivePath,
943 format!("archive path is not valid UTF-8: {}", path.display()),
944 ));
945 };
946 if part.is_empty() {
947 return Err(WorkflowBundleError::new(
948 WorkflowBundleErrorKind::UnsafeArchivePath,
949 "archive path contains an empty component",
950 ));
951 }
952 parts.push(part.to_string());
953 }
954 Component::CurDir => {}
955 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
956 return Err(WorkflowBundleError::new(
957 WorkflowBundleErrorKind::UnsafeArchivePath,
958 format!(
959 "archive path must be relative and contained: {}",
960 path.display()
961 ),
962 ));
963 }
964 }
965 }
966 if parts.is_empty() {
967 return Err(WorkflowBundleError::new(
968 WorkflowBundleErrorKind::UnsafeArchivePath,
969 "archive path is empty",
970 ));
971 }
972 Ok(parts.join("/"))
973}
974
975fn path_sort_key(path: &Path) -> String {
976 path.components()
977 .filter_map(|component| match component {
978 Component::Normal(part) => part.to_str().map(ToOwned::to_owned),
979 _ => None,
980 })
981 .collect::<Vec<_>>()
982 .join("/")
983}
984
985fn blake3_hash_bytes(bytes: &[u8]) -> String {
986 blake3_digest_string(blake3::hash(bytes))
987}
988
989fn blake3_digest_string(hash: blake3::Hash) -> String {
990 format!("blake3:{hash}")
991}
992
993pub fn validate_workflow_bundle(bundle: &WorkflowBundle) -> WorkflowBundleValidationReport {
994 let canonical = canonical_workflow_graph(&bundle.workflow);
995 let mut report = WorkflowBundleValidationReport {
996 valid: true,
997 bundle_id: bundle.id.clone(),
998 workflow_id: canonical.id.clone(),
999 graph_digest: workflow_graph_digest(&canonical),
1000 errors: Vec::new(),
1001 warnings: Vec::new(),
1002 };
1003
1004 validate_manifest_contract(bundle, &mut report);
1005 validate_bundle_identity(bundle, &canonical, &mut report);
1006 validate_triggers(bundle, &canonical, &mut report);
1007 validate_prompt_capsules(bundle, &canonical, &mut report);
1008 validate_policy(bundle, &mut report);
1009 validate_connectors(bundle, &mut report);
1010 validate_environment(bundle, &mut report);
1011
1012 let graph_report = validate_workflow(&canonical, None);
1013 for error in graph_report.errors {
1014 let node_id = workflow_diagnostic_node_id(&error, &canonical);
1015 push_error(&mut report, "workflow", error, node_id);
1016 }
1017 for warning in graph_report.warnings {
1018 let node_id = workflow_diagnostic_node_id(&warning, &canonical);
1019 push_warning(&mut report, "workflow", warning, node_id);
1020 }
1021
1022 if let Some(expected) = bundle.receipts.graph_digest.as_deref() {
1023 if expected != report.graph_digest {
1024 let actual = report.graph_digest.clone();
1025 push_error(
1026 &mut report,
1027 "receipts.graph_digest",
1028 format!("graph digest mismatch: expected {expected}, computed {actual}"),
1029 None,
1030 );
1031 }
1032 }
1033 if let Some(expected_version) = bundle.receipts.workflow_version {
1034 if expected_version != canonical.version {
1035 push_error(
1036 &mut report,
1037 "receipts.workflow_version",
1038 format!(
1039 "workflow version mismatch: expected {expected_version}, computed {}",
1040 canonical.version
1041 ),
1042 None,
1043 );
1044 }
1045 }
1046
1047 report.valid = report.errors.is_empty();
1048 report
1049}
1050
1051pub fn preview_workflow_bundle(bundle: &WorkflowBundle) -> WorkflowBundlePreview {
1052 let canonical = canonical_workflow_graph(&bundle.workflow);
1053 let validation = validate_workflow_bundle(bundle);
1054 let graph = export_workflow_bundle_graph(bundle, &validation);
1055 let mermaid = graph.mermaid.clone();
1056 let triggers_by_node = triggers_by_node(bundle);
1057 let capsules_by_node = capsules_by_node(bundle);
1058 let mut nodes = Vec::new();
1059
1060 for (node_id, node) in &canonical.nodes {
1061 let mut outgoing = canonical
1062 .edges
1063 .iter()
1064 .filter(|edge| edge.from == *node_id)
1065 .map(|edge| edge.to.clone())
1066 .collect::<Vec<_>>();
1067 outgoing.sort();
1068 outgoing.dedup();
1069 nodes.push(WorkflowBundlePreviewNode {
1070 id: node_id.clone(),
1071 kind: node.kind.clone(),
1072 label: node.task_label.clone(),
1073 prompt_capsule: capsules_by_node.get(node_id).cloned(),
1074 trigger_ids: triggers_by_node.get(node_id).cloned().unwrap_or_default(),
1075 outgoing,
1076 });
1077 }
1078
1079 WorkflowBundlePreview {
1080 schema_version: WORKFLOW_BUNDLE_SCHEMA_VERSION,
1081 bundle_id: bundle.id.clone(),
1082 bundle_version: bundle.version.clone(),
1083 workflow_id: canonical.id.clone(),
1084 workflow_version: canonical.version,
1085 graph_digest: validation.graph_digest.clone(),
1086 validation,
1087 graph,
1088 mermaid,
1089 triggers: bundle.triggers.clone(),
1090 connectors: bundle.connectors.clone(),
1091 environment: bundle.environment.clone(),
1092 nodes,
1093 edges: sorted_edges(&canonical),
1094 metadata: bundle.metadata.clone(),
1095 }
1096}
1097
1098pub fn export_workflow_bundle_graph(
1099 bundle: &WorkflowBundle,
1100 validation: &WorkflowBundleValidationReport,
1101) -> WorkflowBundleGraphExport {
1102 let canonical = canonical_workflow_graph(&bundle.workflow);
1103 let mut nodes = Vec::new();
1104 let mut edges = Vec::new();
1105 let mut editable_fields = Vec::new();
1106 let capsules_by_node = capsules_by_node(bundle);
1107 let catchup_enabled = bundle.policy.catchup.mode != "none";
1108 let retry_can_dlq = bundle.policy.retry.max_attempts > 1;
1109
1110 for (index, connector) in bundle.connectors.iter().enumerate() {
1111 let node_fields = connector_editable_fields(index, connector);
1112 editable_fields.extend(node_fields.clone());
1113 nodes.push(WorkflowBundleGraphNode {
1114 id: connector_graph_id(&connector.id),
1115 node_type: "connector_call".to_string(),
1116 label: connector_label(connector),
1117 workflow_node_id: None,
1118 trigger_id: None,
1119 connector_id: Some(connector.id.clone()),
1120 editable_fields: node_fields,
1121 metadata: BTreeMap::from([
1122 (
1123 "provider_id".to_string(),
1124 serde_json::json!(connector.provider_id),
1125 ),
1126 ("scopes".to_string(), serde_json::json!(connector.scopes)),
1127 ]),
1128 });
1129 }
1130
1131 let catchup_fields = catchup_editable_fields();
1132 let retry_fields = retry_editable_fields();
1133 if catchup_enabled {
1134 let node_fields = catchup_fields;
1135 editable_fields.extend(node_fields.clone());
1136 nodes.push(WorkflowBundleGraphNode {
1137 id: catchup_graph_id(),
1138 node_type: "catchup".to_string(),
1139 label: "Catch up".to_string(),
1140 workflow_node_id: None,
1141 trigger_id: None,
1142 connector_id: None,
1143 editable_fields: node_fields,
1144 metadata: BTreeMap::from([(
1145 "mode".to_string(),
1146 serde_json::json!(bundle.policy.catchup.mode),
1147 )]),
1148 });
1149 } else {
1150 editable_fields.extend(catchup_fields);
1151 }
1152 if retry_can_dlq {
1153 let node_fields = retry_fields;
1154 editable_fields.extend(node_fields.clone());
1155 nodes.push(WorkflowBundleGraphNode {
1156 id: dlq_graph_id(),
1157 node_type: "dlq".to_string(),
1158 label: "Dead letter queue".to_string(),
1159 workflow_node_id: None,
1160 trigger_id: None,
1161 connector_id: None,
1162 editable_fields: node_fields,
1163 metadata: BTreeMap::from([(
1164 "max_attempts".to_string(),
1165 serde_json::json!(bundle.policy.retry.max_attempts),
1166 )]),
1167 });
1168 } else {
1169 editable_fields.extend(retry_fields);
1170 }
1171
1172 for (index, trigger) in bundle.triggers.iter().enumerate() {
1173 let node_fields = trigger_editable_fields(index, trigger);
1174 editable_fields.extend(node_fields.clone());
1175 nodes.push(WorkflowBundleGraphNode {
1176 id: trigger_graph_id(&trigger.id),
1177 node_type: "trigger".to_string(),
1178 label: trigger_label(trigger),
1179 workflow_node_id: trigger.node_id.clone(),
1180 trigger_id: Some(trigger.id.clone()),
1181 connector_id: None,
1182 editable_fields: node_fields,
1183 metadata: BTreeMap::from([
1184 ("kind".to_string(), serde_json::json!(trigger.kind)),
1185 ("provider".to_string(), serde_json::json!(trigger.provider)),
1186 ("events".to_string(), serde_json::json!(trigger.events)),
1187 ]),
1188 });
1189 if let Some(provider) = trigger.provider.as_deref() {
1190 if let Some(connector) = bundle
1191 .connectors
1192 .iter()
1193 .find(|connector| connector.provider_id == provider || connector.id == provider)
1194 {
1195 edges.push(WorkflowBundleGraphEdge {
1196 from: connector_graph_id(&connector.id),
1197 to: trigger_graph_id(&trigger.id),
1198 label: Some("binds".to_string()),
1199 branch: None,
1200 });
1201 }
1202 }
1203 let target = trigger
1204 .node_id
1205 .clone()
1206 .unwrap_or_else(|| canonical.entry.clone());
1207 if catchup_enabled {
1208 edges.push(WorkflowBundleGraphEdge {
1209 from: trigger_graph_id(&trigger.id),
1210 to: catchup_graph_id(),
1211 label: Some(bundle.policy.catchup.mode.clone()),
1212 branch: Some("catchup".to_string()),
1213 });
1214 edges.push(WorkflowBundleGraphEdge {
1215 from: catchup_graph_id(),
1216 to: workflow_graph_id(&target),
1217 label: Some("dispatch".to_string()),
1218 branch: None,
1219 });
1220 } else {
1221 edges.push(WorkflowBundleGraphEdge {
1222 from: trigger_graph_id(&trigger.id),
1223 to: workflow_graph_id(&target),
1224 label: Some("dispatch".to_string()),
1225 branch: None,
1226 });
1227 }
1228 }
1229
1230 for (node_id, node) in &canonical.nodes {
1231 let capsule_id = capsules_by_node.get(node_id);
1232 let node_fields = workflow_node_editable_fields(node_id, capsule_id);
1233 editable_fields.extend(node_fields.clone());
1234 nodes.push(WorkflowBundleGraphNode {
1235 id: workflow_graph_id(node_id),
1236 node_type: workflow_node_type(&node.kind),
1237 label: workflow_node_label(node_id, node),
1238 workflow_node_id: Some(node_id.clone()),
1239 trigger_id: None,
1240 connector_id: None,
1241 editable_fields: node_fields,
1242 metadata: BTreeMap::from([
1243 ("kind".to_string(), serde_json::json!(node.kind)),
1244 ("task_label".to_string(), serde_json::json!(node.task_label)),
1245 (
1246 "prompt_capsule".to_string(),
1247 serde_json::json!(capsule_id.cloned()),
1248 ),
1249 ]),
1250 });
1251 }
1252
1253 for edge in sorted_edges(&canonical) {
1254 edges.push(WorkflowBundleGraphEdge {
1255 from: workflow_graph_id(&edge.from),
1256 to: workflow_graph_id(&edge.to),
1257 label: edge.label.clone(),
1258 branch: edge.branch.clone(),
1259 });
1260 }
1261
1262 let outgoing: BTreeSet<&str> = canonical
1263 .edges
1264 .iter()
1265 .map(|edge| edge.from.as_str())
1266 .collect();
1267 for node_id in canonical.nodes.keys() {
1268 if !outgoing.contains(node_id.as_str()) {
1269 edges.push(WorkflowBundleGraphEdge {
1270 from: workflow_graph_id(node_id),
1271 to: terminal_completed_graph_id(),
1272 label: Some("completed".to_string()),
1273 branch: Some("completed".to_string()),
1274 });
1275 }
1276 if retry_can_dlq {
1277 edges.push(WorkflowBundleGraphEdge {
1278 from: workflow_graph_id(node_id),
1279 to: dlq_graph_id(),
1280 label: Some("retry exhausted".to_string()),
1281 branch: Some("failed".to_string()),
1282 });
1283 }
1284 }
1285
1286 nodes.push(WorkflowBundleGraphNode {
1287 id: terminal_completed_graph_id(),
1288 node_type: "terminal".to_string(),
1289 label: "Completed".to_string(),
1290 workflow_node_id: None,
1291 trigger_id: None,
1292 connector_id: None,
1293 editable_fields: Vec::new(),
1294 metadata: BTreeMap::from([("status".to_string(), serde_json::json!("completed"))]),
1295 });
1296 nodes.push(WorkflowBundleGraphNode {
1297 id: terminal_failed_graph_id(),
1298 node_type: "terminal".to_string(),
1299 label: "Failed".to_string(),
1300 workflow_node_id: None,
1301 trigger_id: None,
1302 connector_id: None,
1303 editable_fields: Vec::new(),
1304 metadata: BTreeMap::from([("status".to_string(), serde_json::json!("failed"))]),
1305 });
1306 if retry_can_dlq {
1307 edges.push(WorkflowBundleGraphEdge {
1308 from: dlq_graph_id(),
1309 to: terminal_failed_graph_id(),
1310 label: Some("failed".to_string()),
1311 branch: Some("failed".to_string()),
1312 });
1313 }
1314
1315 nodes.sort_by(|left, right| left.id.cmp(&right.id));
1316 edges.sort_by(|left, right| {
1317 (&left.from, &left.to, &left.branch, &left.label).cmp(&(
1318 &right.from,
1319 &right.to,
1320 &right.branch,
1321 &right.label,
1322 ))
1323 });
1324 editable_fields.sort_by(|left, right| left.id.cmp(&right.id));
1325
1326 let diagnostics = validation
1327 .errors
1328 .iter()
1329 .chain(validation.warnings.iter())
1330 .map(|diagnostic| WorkflowBundleGraphDiagnostic {
1331 severity: diagnostic.severity.clone(),
1332 path: diagnostic.path.clone(),
1333 message: diagnostic.message.clone(),
1334 node_id: diagnostic.node_id.clone(),
1335 graph_node_id: diagnostic.node_id.as_deref().map(workflow_graph_id),
1336 })
1337 .collect::<Vec<_>>();
1338 let mermaid = render_workflow_bundle_mermaid(&nodes, &edges);
1339
1340 WorkflowBundleGraphExport {
1341 schema_version: WORKFLOW_BUNDLE_SCHEMA_VERSION,
1342 graph_id: canonical.id,
1343 graph_digest: validation.graph_digest.clone(),
1344 nodes,
1345 edges,
1346 diagnostics,
1347 editable_fields,
1348 mermaid,
1349 }
1350}
1351
1352pub fn run_workflow_bundle(
1353 bundle: &WorkflowBundle,
1354 request: WorkflowBundleRunRequest,
1355) -> Result<WorkflowBundleRunReceipt, WorkflowBundleValidationReport> {
1356 let validation = validate_workflow_bundle(bundle);
1357 if !validation.valid {
1358 return Err(validation);
1359 }
1360
1361 let canonical = canonical_workflow_graph(&bundle.workflow);
1362 let trigger_id = match request.trigger_id {
1363 Some(trigger_id)
1364 if !bundle
1365 .triggers
1366 .iter()
1367 .any(|trigger| trigger.id == trigger_id) =>
1368 {
1369 let mut report = validation;
1370 push_error(
1371 &mut report,
1372 "trigger_id",
1373 format!("unknown trigger id: {trigger_id}"),
1374 None,
1375 );
1376 report.valid = false;
1377 return Err(report);
1378 }
1379 Some(trigger_id) => Some(trigger_id),
1380 None => bundle.triggers.first().map(|trigger| trigger.id.clone()),
1381 };
1382 let mut event_ids = bundle.receipts.event_ids.clone();
1383 if let Some(event_id) = request.event_id {
1384 if !event_ids.contains(&event_id) {
1385 event_ids.push(event_id);
1386 }
1387 }
1388 let run_id = bundle
1389 .receipts
1390 .run_id
1391 .clone()
1392 .unwrap_or_else(|| default_run_id(bundle, &validation.graph_digest));
1393 let capsules_by_node = capsules_by_node(bundle);
1394 let executed_nodes = execution_order(&canonical)
1395 .into_iter()
1396 .map(|node_id| {
1397 let node = canonical
1398 .nodes
1399 .get(&node_id)
1400 .expect("execution order only contains known nodes");
1401 WorkflowBundleRunNodeReceipt {
1402 node_id: node_id.clone(),
1403 kind: node.kind.clone(),
1404 prompt_capsule: capsules_by_node.get(&node_id).cloned(),
1405 status: "completed".to_string(),
1406 }
1407 })
1408 .collect();
1409
1410 Ok(WorkflowBundleRunReceipt {
1411 schema_version: WORKFLOW_BUNDLE_SCHEMA_VERSION,
1412 receipt_type: WORKFLOW_BUNDLE_RECEIPT_TYPE.to_string(),
1413 bundle_id: bundle.id.clone(),
1414 bundle_version: bundle.version.clone(),
1415 workflow_id: canonical.id,
1416 workflow_version: canonical.version,
1417 graph_digest: validation.graph_digest,
1418 run_id,
1419 trigger_id,
1420 event_ids,
1421 status: "completed".to_string(),
1422 executed_nodes,
1423 policy: bundle.policy.clone(),
1424 connectors: bundle.connectors.clone(),
1425 environment: bundle.environment.clone(),
1426 })
1427}
1428
1429fn canonical_workflow_graph(graph: &WorkflowGraph) -> WorkflowGraph {
1430 let mut canonical = graph.clone();
1431 if canonical.type_name.is_empty() {
1432 canonical.type_name = "workflow_graph".to_string();
1433 }
1434 if canonical.version == 0 {
1435 canonical.version = 1;
1436 }
1437 if canonical.entry.is_empty() {
1438 canonical.entry = canonical.nodes.keys().next().cloned().unwrap_or_default();
1439 }
1440 for (node_id, node) in &mut canonical.nodes {
1441 if node.id.is_none() {
1442 node.id = Some(node_id.clone());
1443 }
1444 if node.kind.is_empty() {
1445 node.kind = "stage".to_string();
1446 }
1447 if node.retry_policy.max_attempts == 0 {
1448 node.retry_policy.max_attempts = 1;
1449 }
1450 }
1451 canonical.edges = sorted_edges(&canonical);
1452 canonical
1453}
1454
1455fn sorted_edges(graph: &WorkflowGraph) -> Vec<WorkflowEdge> {
1456 let mut edges = graph.edges.clone();
1457 edges.sort_by(|left, right| {
1458 (
1459 &left.from,
1460 &left.to,
1461 left.branch.as_deref(),
1462 left.label.as_deref(),
1463 )
1464 .cmp(&(
1465 &right.from,
1466 &right.to,
1467 right.branch.as_deref(),
1468 right.label.as_deref(),
1469 ))
1470 });
1471 edges
1472}
1473
1474fn validate_bundle_identity(
1475 bundle: &WorkflowBundle,
1476 graph: &WorkflowGraph,
1477 report: &mut WorkflowBundleValidationReport,
1478) {
1479 if bundle.schema_version != WORKFLOW_BUNDLE_SCHEMA_VERSION
1480 && bundle.schema_version != LEGACY_WORKFLOW_BUNDLE_SCHEMA_VERSION
1481 {
1482 push_error(
1483 report,
1484 "schema_version",
1485 format!(
1486 "unsupported schema_version {}; expected {}",
1487 bundle.schema_version, WORKFLOW_BUNDLE_SCHEMA_VERSION
1488 ),
1489 None,
1490 );
1491 }
1492 if bundle.id.trim().is_empty() {
1493 push_error(report, "id", "bundle id is required", None);
1494 }
1495 if bundle.version.trim().is_empty() {
1496 push_error(report, "version", "bundle version is required", None);
1497 }
1498 if graph.id.trim().is_empty() {
1499 push_error(
1500 report,
1501 "workflow.id",
1502 "workflow id is required for portable bundles",
1503 None,
1504 );
1505 }
1506 if graph.nodes.is_empty() {
1507 push_error(
1508 report,
1509 "workflow.nodes",
1510 "workflow must contain nodes",
1511 None,
1512 );
1513 }
1514 for (node_id, node) in &graph.nodes {
1515 if node_id.trim().is_empty() {
1516 push_error(report, "workflow.nodes", "node id is required", None);
1517 }
1518 if node.id.as_deref().is_some_and(|id| id != node_id) {
1519 push_error(
1520 report,
1521 format!("workflow.nodes.{node_id}.id"),
1522 "node id field must match its map key",
1523 Some(node_id.clone()),
1524 );
1525 }
1526 }
1527}
1528
1529fn validate_manifest_contract(
1530 bundle: &WorkflowBundle,
1531 report: &mut WorkflowBundleValidationReport,
1532) {
1533 validate_relative_path(
1534 report,
1535 "entrypoint",
1536 &bundle.entrypoint,
1537 "entrypoint is required",
1538 );
1539 if bundle.schema_version >= WORKFLOW_BUNDLE_SCHEMA_VERSION {
1540 match &bundle.execution_artifact {
1541 Some(artifact) => {
1542 if artifact.format != "harn.linked_program.v1" {
1543 push_error(
1544 report,
1545 "execution_artifact.format",
1546 "execution artifact format must be harn.linked_program.v1",
1547 None,
1548 );
1549 }
1550 validate_relative_path(
1551 report,
1552 "execution_artifact.path",
1553 &artifact.path,
1554 "execution artifact path is required",
1555 );
1556 validate_blake3_hash(
1557 report,
1558 "execution_artifact.hash_blake3",
1559 &artifact.hash_blake3,
1560 true,
1561 );
1562 validate_blake3_hash(
1563 report,
1564 "execution_artifact.graph_digest_blake3",
1565 &artifact.graph_digest_blake3,
1566 true,
1567 );
1568 }
1569 None => push_error(
1570 report,
1571 "execution_artifact",
1572 "schema-v3 bundles require a linked execution artifact",
1573 None,
1574 ),
1575 }
1576 }
1577 if bundle.transitive_modules.is_empty() {
1578 push_error(
1579 report,
1580 "transitive_modules",
1581 "at least one transitive module entry is required",
1582 None,
1583 );
1584 }
1585 let mut module_paths = BTreeSet::new();
1586 for (index, module) in bundle.transitive_modules.iter().enumerate() {
1587 let path = format!("transitive_modules[{index}]");
1588 validate_relative_path(
1589 report,
1590 format!("{path}.path"),
1591 &module.path,
1592 "module path is required",
1593 );
1594 if !module.path.as_os_str().is_empty() && !module_paths.insert(path_sort_key(&module.path))
1595 {
1596 push_error(
1597 report,
1598 format!("{path}.path"),
1599 format!(
1600 "duplicate transitive module path: {}",
1601 module.path.display()
1602 ),
1603 None,
1604 );
1605 }
1606 validate_blake3_hash(
1607 report,
1608 format!("{path}.source_hash_blake3"),
1609 &module.source_hash_blake3,
1610 true,
1611 );
1612 if bundle.schema_version == LEGACY_WORKFLOW_BUNDLE_SCHEMA_VERSION {
1613 validate_blake3_hash(
1614 report,
1615 format!("{path}.harnbc_hash_blake3"),
1616 &module.harnbc_hash_blake3,
1617 true,
1618 );
1619 }
1620 }
1621 if bundle.stdlib_version.trim().is_empty() {
1622 push_error(report, "stdlib_version", "stdlib_version is required", None);
1623 }
1624 if bundle.harn_version.trim().is_empty() {
1625 push_error(report, "harn_version", "harn_version is required", None);
1626 }
1627 validate_blake3_hash(
1628 report,
1629 "provider_catalog_hash",
1630 &bundle.provider_catalog_hash,
1631 true,
1632 );
1633
1634 let mut tool_names = BTreeSet::new();
1635 for (index, tool) in bundle.tool_manifest.iter().enumerate() {
1636 let path = format!("tool_manifest[{index}]");
1637 if tool.name.trim().is_empty() {
1638 push_error(
1639 report,
1640 format!("{path}.name"),
1641 "tool manifest entry name is required",
1642 None,
1643 );
1644 } else if !tool_names.insert(tool.name.clone()) {
1645 push_error(
1646 report,
1647 format!("{path}.name"),
1648 format!("duplicate tool manifest entry: {}", tool.name),
1649 None,
1650 );
1651 }
1652 if let Some(hash) = tool.schema_hash_blake3.as_deref() {
1653 validate_blake3_hash(report, format!("{path}.schema_hash_blake3"), hash, true);
1654 }
1655 }
1656
1657 if bundle.sbom.format.trim().is_empty() {
1658 push_error(report, "sbom.format", "SBOM format is required", None);
1659 }
1660 if bundle.sbom.version.trim().is_empty() {
1661 push_error(report, "sbom.version", "SBOM version is required", None);
1662 }
1663 let mut sbom_packages = BTreeSet::new();
1664 for (index, package) in bundle.sbom.packages.iter().enumerate() {
1665 let path = format!("sbom.packages[{index}]");
1666 if package.name.trim().is_empty() {
1667 push_error(
1668 report,
1669 format!("{path}.name"),
1670 "SBOM package name is required",
1671 None,
1672 );
1673 } else if !sbom_packages.insert(package.name.clone()) {
1674 push_error(
1675 report,
1676 format!("{path}.name"),
1677 format!("duplicate SBOM package: {}", package.name),
1678 None,
1679 );
1680 }
1681 if let Some(hash) = package.package_hash_blake3.as_deref() {
1682 validate_blake3_hash(report, format!("{path}.package_hash_blake3"), hash, true);
1683 }
1684 }
1685 for (index, relationship) in bundle.sbom.relationships.iter().enumerate() {
1686 let path = format!("sbom.relationships[{index}]");
1687 if relationship.from.trim().is_empty() {
1688 push_error(
1689 report,
1690 format!("{path}.from"),
1691 "SBOM relationship source is required",
1692 None,
1693 );
1694 }
1695 if relationship.to.trim().is_empty() {
1696 push_error(
1697 report,
1698 format!("{path}.to"),
1699 "SBOM relationship target is required",
1700 None,
1701 );
1702 }
1703 if relationship.relationship_type.trim().is_empty() {
1704 push_error(
1705 report,
1706 format!("{path}.relationship_type"),
1707 "SBOM relationship type is required",
1708 None,
1709 );
1710 }
1711 }
1712
1713 if let Some(parent_id) = bundle.parent_trust_record_id.as_deref() {
1714 if parent_id.trim().is_empty() {
1715 push_error(
1716 report,
1717 "parent_trust_record_id",
1718 "parent_trust_record_id cannot be empty when present",
1719 None,
1720 );
1721 }
1722 }
1723 if let Some(signature) = &bundle.signature {
1724 if signature.algorithm.trim().is_empty() {
1725 push_error(
1726 report,
1727 "signature.algorithm",
1728 "signature algorithm is required",
1729 None,
1730 );
1731 } else if signature.algorithm != "ed25519" {
1732 push_error(
1733 report,
1734 "signature.algorithm",
1735 "signature algorithm must be ed25519",
1736 None,
1737 );
1738 }
1739 if signature.public_key.trim().is_empty() {
1740 push_error(
1741 report,
1742 "signature.public_key",
1743 "signature public_key is required",
1744 None,
1745 );
1746 }
1747 if signature.signature.trim().is_empty() {
1748 push_error(
1749 report,
1750 "signature.signature",
1751 "signature value is required",
1752 None,
1753 );
1754 }
1755 validate_blake3_hash(
1756 report,
1757 "signature.manifest_hash_blake3",
1758 &signature.manifest_hash_blake3,
1759 true,
1760 );
1761 }
1762}
1763
1764fn validate_relative_path(
1765 report: &mut WorkflowBundleValidationReport,
1766 path: impl Into<String>,
1767 value: &Path,
1768 empty_message: &str,
1769) {
1770 let path = path.into();
1771 if value.as_os_str().is_empty() {
1772 push_error(report, path, empty_message, None);
1773 return;
1774 }
1775 if normalize_archive_path(value).is_err() {
1776 push_error(
1777 report,
1778 path,
1779 format!("path must be relative and contained: {}", value.display()),
1780 None,
1781 );
1782 }
1783}
1784
1785fn validate_blake3_hash(
1786 report: &mut WorkflowBundleValidationReport,
1787 path: impl Into<String>,
1788 value: &str,
1789 required: bool,
1790) {
1791 let path = path.into();
1792 if value.trim().is_empty() {
1793 if required {
1794 push_error(report, path, "BLAKE3 hash is required", None);
1795 }
1796 return;
1797 }
1798 let Some(hex) = value.strip_prefix("blake3:") else {
1799 push_error(report, path, "BLAKE3 hash must use blake3:<hex>", None);
1800 return;
1801 };
1802 if hex.len() != 64
1803 || !hex
1804 .bytes()
1805 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
1806 {
1807 push_error(
1808 report,
1809 path,
1810 "BLAKE3 hash must contain 64 lowercase hex digits",
1811 None,
1812 );
1813 }
1814}
1815
1816fn validate_triggers(
1817 bundle: &WorkflowBundle,
1818 graph: &WorkflowGraph,
1819 report: &mut WorkflowBundleValidationReport,
1820) {
1821 if bundle.triggers.is_empty() {
1822 push_warning(report, "triggers", "bundle declares no triggers", None);
1823 }
1824 let mut ids = BTreeSet::new();
1825 for (index, trigger) in bundle.triggers.iter().enumerate() {
1826 let path = format!("triggers[{index}]");
1827 if trigger.id.trim().is_empty() {
1828 push_error(report, format!("{path}.id"), "trigger id is required", None);
1829 } else if !ids.insert(trigger.id.clone()) {
1830 push_error(
1831 report,
1832 format!("{path}.id"),
1833 format!("duplicate trigger id: {}", trigger.id),
1834 None,
1835 );
1836 }
1837 match trigger.kind.as_str() {
1838 "github" => {
1839 if trigger.provider.as_deref() != Some("github") {
1840 push_error(
1841 report,
1842 format!("{path}.provider"),
1843 "github triggers require provider=\"github\"",
1844 None,
1845 );
1846 }
1847 if trigger.events.is_empty() {
1848 push_error(
1849 report,
1850 format!("{path}.events"),
1851 "github triggers require at least one event",
1852 None,
1853 );
1854 }
1855 }
1856 "cron" if trigger.schedule.is_none() => push_error(
1857 report,
1858 format!("{path}.schedule"),
1859 "cron triggers require schedule",
1860 None,
1861 ),
1862 "delay" if trigger.delay.is_none() => push_error(
1863 report,
1864 format!("{path}.delay"),
1865 "delay triggers require delay",
1866 None,
1867 ),
1868 "webhook" if trigger.webhook_path.is_none() => push_error(
1869 report,
1870 format!("{path}.webhook_path"),
1871 "webhook triggers require webhook_path",
1872 None,
1873 ),
1874 "mcp" if trigger.mcp_tool.is_none() => push_error(
1875 report,
1876 format!("{path}.mcp_tool"),
1877 "mcp triggers require mcp_tool",
1878 None,
1879 ),
1880 "manual" => {}
1881 "" => push_error(
1882 report,
1883 format!("{path}.kind"),
1884 "trigger kind is required",
1885 None,
1886 ),
1887 other
1888 if !matches!(
1889 other,
1890 "github" | "cron" | "delay" | "webhook" | "mcp" | "manual"
1891 ) =>
1892 {
1893 push_error(
1894 report,
1895 format!("{path}.kind"),
1896 format!("unsupported trigger kind: {other}"),
1897 None,
1898 );
1899 }
1900 _ => {}
1901 }
1902 if let Some(node_id) = trigger.node_id.as_deref() {
1903 if !graph.nodes.contains_key(node_id) {
1904 push_error(
1905 report,
1906 format!("{path}.node_id"),
1907 format!("trigger references unknown node: {node_id}"),
1908 Some(node_id.to_string()),
1909 );
1910 }
1911 }
1912 }
1913}
1914
1915fn validate_prompt_capsules(
1916 bundle: &WorkflowBundle,
1917 graph: &WorkflowGraph,
1918 report: &mut WorkflowBundleValidationReport,
1919) {
1920 let trigger_ids: BTreeSet<&str> = bundle
1921 .triggers
1922 .iter()
1923 .map(|trigger| trigger.id.as_str())
1924 .collect();
1925 let mut node_refs = BTreeSet::new();
1926 for (key, capsule) in &bundle.prompt_capsules {
1927 let path = format!("prompt_capsules.{key}");
1928 if capsule.id.trim().is_empty() {
1929 push_error(
1930 report,
1931 format!("{path}.id"),
1932 "prompt capsule id is required",
1933 None,
1934 );
1935 } else if capsule.id != *key {
1936 push_error(
1937 report,
1938 format!("{path}.id"),
1939 "prompt capsule id must match its map key",
1940 None,
1941 );
1942 }
1943 if capsule.prompt.trim().is_empty() {
1944 push_error(
1945 report,
1946 format!("{path}.prompt"),
1947 "prompt capsule prompt is required",
1948 Some(capsule.node_id.clone()),
1949 );
1950 }
1951 if !graph.nodes.contains_key(&capsule.node_id) {
1952 push_error(
1953 report,
1954 format!("{path}.node_id"),
1955 format!(
1956 "prompt capsule references unknown node: {}",
1957 capsule.node_id
1958 ),
1959 Some(capsule.node_id.clone()),
1960 );
1961 }
1962 if !capsule.node_id.is_empty() && !node_refs.insert(capsule.node_id.clone()) {
1963 push_error(
1964 report,
1965 format!("{path}.node_id"),
1966 format!("multiple prompt capsules target node {}", capsule.node_id),
1967 Some(capsule.node_id.clone()),
1968 );
1969 }
1970 if let Some(trigger_id) = capsule.trigger_id.as_deref() {
1971 if !trigger_ids.contains(trigger_id) {
1972 push_error(
1973 report,
1974 format!("{path}.trigger_id"),
1975 format!("prompt capsule references unknown trigger: {trigger_id}"),
1976 Some(capsule.node_id.clone()),
1977 );
1978 }
1979 }
1980 }
1981}
1982
1983fn validate_policy(bundle: &WorkflowBundle, report: &mut WorkflowBundleValidationReport) {
1984 if !matches!(
1985 bundle.policy.autonomy_tier.as_str(),
1986 "shadow" | "suggest" | "act_with_approval" | "act_auto"
1987 ) {
1988 push_error(
1989 report,
1990 "policy.autonomy_tier",
1991 "autonomy_tier must be shadow, suggest, act_with_approval, or act_auto",
1992 None,
1993 );
1994 }
1995 if bundle.policy.retry.max_attempts == 0 {
1996 push_error(
1997 report,
1998 "policy.retry.max_attempts",
1999 "retry.max_attempts must be at least 1",
2000 None,
2001 );
2002 }
2003 if !matches!(
2004 bundle.policy.catchup.mode.as_str(),
2005 "none" | "latest" | "all"
2006 ) {
2007 push_error(
2008 report,
2009 "policy.catchup.mode",
2010 "catchup.mode must be none, latest, or all",
2011 None,
2012 );
2013 }
2014}
2015
2016fn validate_connectors(bundle: &WorkflowBundle, report: &mut WorkflowBundleValidationReport) {
2017 let mut ids = BTreeSet::new();
2018 let provider_ids: BTreeSet<&str> = bundle
2019 .connectors
2020 .iter()
2021 .map(|connector| connector.provider_id.as_str())
2022 .collect();
2023 for (index, connector) in bundle.connectors.iter().enumerate() {
2024 let path = format!("connectors[{index}]");
2025 if connector.id.trim().is_empty() {
2026 push_error(
2027 report,
2028 format!("{path}.id"),
2029 "connector id is required",
2030 None,
2031 );
2032 } else if !ids.insert(connector.id.clone()) {
2033 push_error(
2034 report,
2035 format!("{path}.id"),
2036 format!("duplicate connector id: {}", connector.id),
2037 None,
2038 );
2039 }
2040 if connector.provider_id.trim().is_empty() {
2041 push_error(
2042 report,
2043 format!("{path}.provider_id"),
2044 "connector provider_id is required",
2045 None,
2046 );
2047 }
2048 }
2049 for trigger in &bundle.triggers {
2050 if let Some(provider) = trigger.provider.as_deref() {
2051 if !provider_ids.contains(provider) {
2052 push_warning(
2053 report,
2054 "connectors",
2055 format!(
2056 "trigger {} references provider {provider} with no connector requirement",
2057 trigger.id
2058 ),
2059 trigger.node_id.clone(),
2060 );
2061 }
2062 }
2063 }
2064}
2065
2066fn validate_environment(bundle: &WorkflowBundle, report: &mut WorkflowBundleValidationReport) {
2067 if !matches!(
2068 bundle.environment.worktree_policy.as_str(),
2069 "reuse_current" | "new_worktree" | "host_managed"
2070 ) {
2071 push_error(
2072 report,
2073 "environment.worktree_policy",
2074 "worktree_policy must be reuse_current, new_worktree, or host_managed",
2075 None,
2076 );
2077 }
2078}
2079
2080fn workflow_diagnostic_node_id(message: &str, graph: &WorkflowGraph) -> Option<String> {
2081 for prefix in [
2082 "node is unreachable: ",
2083 "edge.from references unknown node: ",
2084 "edge.to references unknown node: ",
2085 "entry node does not exist: ",
2086 ] {
2087 if let Some(node_id) = message.strip_prefix(prefix) {
2088 return Some(node_id.to_string());
2089 }
2090 }
2091 if let Some(rest) = message.strip_prefix("node ") {
2092 if let Some((node_id, _)) = rest.split_once(':') {
2093 return Some(node_id.to_string());
2094 }
2095 }
2096 graph
2097 .nodes
2098 .keys()
2099 .find(|node_id| message.contains(&format!("node {node_id}:")))
2100 .cloned()
2101}
2102
2103fn workflow_graph_id(node_id: &str) -> String {
2104 format!("node/{node_id}")
2105}
2106
2107fn trigger_graph_id(trigger_id: &str) -> String {
2108 format!("trigger/{trigger_id}")
2109}
2110
2111fn connector_graph_id(connector_id: &str) -> String {
2112 format!("connector/{connector_id}")
2113}
2114
2115fn catchup_graph_id() -> String {
2116 "policy/catchup".to_string()
2117}
2118
2119fn dlq_graph_id() -> String {
2120 "policy/dlq".to_string()
2121}
2122
2123fn terminal_completed_graph_id() -> String {
2124 "terminal/completed".to_string()
2125}
2126
2127fn terminal_failed_graph_id() -> String {
2128 "terminal/failed".to_string()
2129}
2130
2131fn workflow_node_type(kind: &str) -> String {
2132 match kind {
2133 "action" => "action",
2134 "stage" | "agent" => "agent",
2135 "subagent" | "worker" => "subagent",
2136 "wait" | "waitpoint" | "delay" => "wait",
2137 "approval" | "hitl" => "approval",
2138 "connector" | "connector_call" => "connector_call",
2139 "notification" | "notify" => "notification",
2140 "terminal" | "success" | "failure" => "terminal",
2141 other if other.trim().is_empty() => "agent",
2142 other => other,
2143 }
2144 .to_string()
2145}
2146
2147fn workflow_node_label(node_id: &str, node: &super::WorkflowNode) -> String {
2148 node.task_label
2149 .clone()
2150 .or_else(|| node.prompt.clone())
2151 .map(|label| label.trim().to_string())
2152 .filter(|label| !label.is_empty())
2153 .unwrap_or_else(|| node_id.to_string())
2154}
2155
2156fn trigger_label(trigger: &WorkflowBundleTrigger) -> String {
2157 if !trigger.events.is_empty() {
2158 format!("{}: {}", trigger.kind, trigger.events.join(", "))
2159 } else if let Some(schedule) = trigger.schedule.as_deref() {
2160 format!("cron: {schedule}")
2161 } else if let Some(delay) = trigger.delay.as_deref() {
2162 format!("delay: {delay}")
2163 } else {
2164 trigger.id.clone()
2165 }
2166}
2167
2168fn connector_label(connector: &ConnectorRequirement) -> String {
2169 if connector.provider_id.is_empty() || connector.provider_id == connector.id {
2170 connector.id.clone()
2171 } else {
2172 format!("{} ({})", connector.id, connector.provider_id)
2173 }
2174}
2175
2176fn triggers_by_node(bundle: &WorkflowBundle) -> BTreeMap<String, Vec<String>> {
2177 let mut by_node: BTreeMap<String, Vec<String>> = BTreeMap::new();
2178 for trigger in &bundle.triggers {
2179 if let Some(node_id) = trigger.node_id.as_ref() {
2180 by_node
2181 .entry(node_id.clone())
2182 .or_default()
2183 .push(trigger.id.clone());
2184 }
2185 }
2186 by_node
2187}
2188
2189fn capsules_by_node(bundle: &WorkflowBundle) -> BTreeMap<String, String> {
2190 bundle
2191 .prompt_capsules
2192 .iter()
2193 .map(|(id, capsule)| (capsule.node_id.clone(), id.clone()))
2194 .collect()
2195}
2196
2197fn execution_order(graph: &WorkflowGraph) -> Vec<String> {
2198 let outgoing =
2199 graph
2200 .edges
2201 .iter()
2202 .fold(BTreeMap::<String, Vec<String>>::new(), |mut acc, edge| {
2203 acc.entry(edge.from.clone())
2204 .or_default()
2205 .push(edge.to.clone());
2206 acc
2207 });
2208 let mut seen = BTreeSet::new();
2209 let mut queue = VecDeque::from([graph.entry.clone()]);
2210 let mut order = Vec::new();
2211 while let Some(node_id) = queue.pop_front() {
2212 if !graph.nodes.contains_key(&node_id) || !seen.insert(node_id.clone()) {
2213 continue;
2214 }
2215 order.push(node_id.clone());
2216 if let Some(next) = outgoing.get(&node_id) {
2217 let mut next = next.clone();
2218 next.sort();
2219 for child in next {
2220 queue.push_back(child);
2221 }
2222 }
2223 }
2224 order
2225}
2226
2227fn default_run_id(bundle: &WorkflowBundle, graph_digest: &str) -> String {
2228 let suffix = graph_digest
2229 .strip_prefix("sha256:")
2230 .unwrap_or(graph_digest)
2231 .chars()
2232 .take(12)
2233 .collect::<String>();
2234 format!("bundle_run_{}_{}", sanitize_id(&bundle.id), suffix)
2235}
2236
2237fn sanitize_id(value: &str) -> String {
2238 value
2239 .chars()
2240 .map(|ch| {
2241 if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
2242 ch
2243 } else {
2244 '_'
2245 }
2246 })
2247 .collect()
2248}
2249
2250fn push_error(
2251 report: &mut WorkflowBundleValidationReport,
2252 path: impl Into<String>,
2253 message: impl Into<String>,
2254 node_id: Option<String>,
2255) {
2256 report.errors.push(WorkflowBundleDiagnostic {
2257 severity: "error".to_string(),
2258 path: path.into(),
2259 message: message.into(),
2260 node_id,
2261 });
2262}
2263
2264fn push_warning(
2265 report: &mut WorkflowBundleValidationReport,
2266 path: impl Into<String>,
2267 message: impl Into<String>,
2268 node_id: Option<String>,
2269) {
2270 report.warnings.push(WorkflowBundleDiagnostic {
2271 severity: "warning".to_string(),
2272 path: path.into(),
2273 message: message.into(),
2274 node_id,
2275 });
2276}
2277
2278#[cfg(test)]
2279mod tests;