Skip to main content

harn_vm/orchestration/workflow_bundle/
mod.rs

1//! Portable workflow bundle contract, `.harnpack` container helpers, and
2//! deterministic local receipts.
3
4use 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
30/// Maximum decompressed size of a `.harnpack` archive. Matches the
31/// VM-level decompression cap in `stdlib/compression.rs`. Keeps a
32/// malicious bundle from exhausting memory during ingest (workflow
33/// bundles ride this exact path when a cloud platform accepts a plan
34/// transfer).
35const MAX_HARNPACK_DECOMPRESSED_BYTES: u64 = 100 * 1024 * 1024;
36
37/// Decompress zstd-compressed harnpack bytes, refusing to produce more
38/// than [`MAX_HARNPACK_DECOMPRESSED_BYTES`] of output. Returns
39/// [`WorkflowBundleErrorKind::InvalidArchive`] on overflow so callers
40/// can surface a clean rejection instead of OOMing the host.
41fn 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    /// Closed execution payload for schema-v3 bundles. Schema-v2 archives omit
63    /// this field and use the explicit legacy source/bytecode adapter.
64    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    /// Pass-through of the signed bundle's `metadata` map. Hosts read the
359    /// `contributes` and `extension` keys (populated by `harn pack`) to
360    /// discover host-surface extension contributions from the verified
361    /// preview without unpacking the archive.
362    #[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
557/// Read a manifest from any prior or current schema version without
558/// rejecting on a `schema_version` mismatch. Used by `harn pack
559/// --upgrade` to read v1 bundles before re-emitting them under the v2
560/// shape. Fields the older schema didn't carry deserialize to their
561/// type defaults via `#[serde(default)]`.
562pub 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
575/// Variant of [`load_workflow_bundle`] that accepts any historical
576/// schema version. Reads the manifest from a `.harnpack` archive or a
577/// bare JSON manifest as appropriate.
578pub 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        // Schema-v2 compatibility: preserve the historical path-agnostic
677        // multiset hash so existing signatures remain verifiable.
678        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;