Skip to main content

harn_cli/package/
extensions.rs

1use super::errors::PackageError;
2use super::*;
3
4pub(crate) fn manifest_capabilities(
5    manifest: &Manifest,
6) -> Option<&harn_vm::llm::capabilities::CapabilitiesFile> {
7    manifest.capabilities.as_ref()
8}
9
10pub(crate) fn is_empty_capabilities(file: &harn_vm::llm::capabilities::CapabilitiesFile) -> bool {
11    file.provider.is_empty() && file.provider_family.is_empty()
12}
13
14pub fn validate_runtime_manifest_extensions(anchor: &Path) -> Result<(), PackageError> {
15    let Some((manifest, _manifest_dir)) = find_nearest_manifest(anchor) else {
16        return Ok(());
17    };
18    validate_handoff_routes(&manifest.handoff_routes, &manifest)
19}
20
21/// Load the nearest project manifest plus any installed package manifests and
22/// merge the root project's runtime extensions.
23pub fn try_load_runtime_extensions(anchor: &Path) -> Result<RuntimeExtensions, PackageError> {
24    ensure_dependencies_materialized(anchor)?;
25    let Some((root_manifest, manifest_dir)) = find_nearest_manifest(anchor) else {
26        return Ok(RuntimeExtensions::default());
27    };
28
29    let mut llm = harn_vm::llm_config::ProvidersConfig::default();
30    let mut capabilities = harn_vm::llm::capabilities::CapabilitiesFile::default();
31    let mut hooks = Vec::new();
32    let mut triggers = Vec::new();
33
34    llm.merge_from(&root_manifest.llm);
35    if let Some(file) = manifest_capabilities(&root_manifest) {
36        merge_capability_overrides(&mut capabilities, file);
37    }
38    hooks.extend(resolved_hooks_from_manifest(&root_manifest, &manifest_dir));
39    triggers.extend(resolved_triggers_from_manifest(
40        &root_manifest,
41        &manifest_dir,
42    ));
43    let handoff_routes = root_manifest.handoff_routes.clone();
44    validate_handoff_routes(&handoff_routes, &root_manifest)?;
45    let provider_connectors =
46        resolved_provider_connectors_from_manifest(&root_manifest, &manifest_dir);
47
48    Ok(RuntimeExtensions {
49        root_manifest_path: Some(manifest_dir.join(MANIFEST)),
50        root_manifest_dir: Some(manifest_dir),
51        root_manifest: Some(root_manifest),
52        llm: (!llm.is_empty()).then_some(llm),
53        capabilities: (!is_empty_capabilities(&capabilities)).then_some(capabilities),
54        hooks,
55        triggers,
56        handoff_routes,
57        provider_connectors,
58    })
59}
60
61pub fn load_runtime_extensions(anchor: &Path) -> RuntimeExtensions {
62    match try_load_runtime_extensions(anchor) {
63        Ok(extensions) => extensions,
64        Err(error) => {
65            eprintln!("error: {error}");
66            process::exit(1);
67        }
68    }
69}
70
71/// Install merged runtime extensions on the current thread.
72pub fn install_runtime_extensions(extensions: &RuntimeExtensions) {
73    harn_vm::llm_config::set_user_overrides(extensions.llm.clone());
74    harn_vm::llm::capabilities::set_user_overrides(extensions.capabilities.clone());
75    install_manifest_handoff_routes(extensions);
76    install_orchestrator_budget(extensions);
77}
78
79pub fn install_manifest_handoff_routes(extensions: &RuntimeExtensions) {
80    harn_vm::install_handoff_routes(extensions.handoff_routes.clone());
81}
82
83pub fn install_orchestrator_budget(extensions: &RuntimeExtensions) {
84    let budget = extensions
85        .root_manifest
86        .as_ref()
87        .map(|manifest| harn_vm::OrchestratorBudgetConfig {
88            daily_cost_usd: manifest.orchestrator.budget.daily_cost_usd,
89            hourly_cost_usd: manifest.orchestrator.budget.hourly_cost_usd,
90        })
91        .unwrap_or_default();
92    harn_vm::install_orchestrator_budget(budget);
93}
94
95pub async fn install_manifest_hooks(
96    vm: &mut harn_vm::Vm,
97    extensions: &RuntimeExtensions,
98) -> Result<(), PackageError> {
99    harn_vm::orchestration::clear_runtime_hooks();
100    let mut loaded_exports: HashMap<ManifestModuleCacheKey, ManifestModuleExports> = HashMap::new();
101    for hook in &extensions.hooks {
102        let Some((module_name, function_name)) = hook.handler.rsplit_once("::") else {
103            return Err(format!(
104                "invalid hook handler '{}': expected <module>::<function>",
105                hook.handler
106            )
107            .into());
108        };
109        let cache_key = (
110            hook.manifest_dir.clone(),
111            hook.package_name.clone(),
112            Some(module_name.to_string()),
113        );
114        if !loaded_exports.contains_key(&cache_key) {
115            let exports = resolve_manifest_exports(
116                vm,
117                &hook.manifest_dir,
118                hook.package_name.as_deref(),
119                &hook.exports,
120                Some(module_name),
121            )
122            .await?;
123            loaded_exports.insert(cache_key.clone(), exports);
124        }
125        let exports = loaded_exports
126            .get(&cache_key)
127            .expect("manifest hook exports cached");
128        let Some(closure) = exports.get(function_name) else {
129            return Err(format!(
130                "hook handler '{}' is not exported by module '{}'",
131                function_name, module_name
132            )
133            .into());
134        };
135        harn_vm::orchestration::register_vm_hook(
136            hook.event,
137            hook.pattern.clone(),
138            hook.handler.clone(),
139            closure.clone(),
140        );
141    }
142    Ok(())
143}
144
145pub async fn collect_manifest_triggers(
146    vm: &mut harn_vm::Vm,
147    extensions: &RuntimeExtensions,
148) -> Result<Vec<CollectedManifestTrigger>, PackageError> {
149    let _provider_schema_guard = lock_manifest_provider_schemas().await;
150    let provider_catalog = build_manifest_provider_catalog(extensions).await?;
151    validate_orchestrator_budget(extensions.root_manifest.as_ref())?;
152    validate_static_trigger_configs(&extensions.triggers, &provider_catalog)?;
153    let mut loaded_exports: HashMap<ManifestModuleCacheKey, ManifestModuleExports> = HashMap::new();
154    let mut module_signatures: HashMap<PathBuf, BTreeMap<String, TriggerFunctionSignature>> =
155        HashMap::new();
156    let mut collected = Vec::new();
157
158    for trigger in &extensions.triggers {
159        let handler = parse_trigger_handler_uri(trigger)?;
160        let collected_handler = match handler {
161            TriggerHandlerUri::Local(reference) => {
162                let cache_key = (
163                    trigger.manifest_dir.clone(),
164                    trigger.package_name.clone(),
165                    reference.module_name.clone(),
166                );
167                if !loaded_exports.contains_key(&cache_key) {
168                    let exports = resolve_manifest_exports(
169                        vm,
170                        &trigger.manifest_dir,
171                        trigger.package_name.as_deref(),
172                        &trigger.exports,
173                        reference.module_name.as_deref(),
174                    )
175                    .await
176                    .map_err(|error| trigger_error(trigger, error))?;
177                    loaded_exports.insert(cache_key.clone(), exports);
178                }
179                let exports = loaded_exports
180                    .get(&cache_key)
181                    .expect("manifest trigger exports cached");
182                let Some(closure) = exports.get(&reference.function_name) else {
183                    return Err(trigger_error(
184                        trigger,
185                        format!(
186                            "handler '{}' is not exported by the resolved module",
187                            reference.raw
188                        ),
189                    ));
190                };
191                CollectedTriggerHandler::Local {
192                    reference,
193                    closure: closure.clone(),
194                }
195            }
196            TriggerHandlerUri::A2a {
197                target,
198                allow_cleartext,
199            } => CollectedTriggerHandler::A2a {
200                target,
201                allow_cleartext,
202            },
203            TriggerHandlerUri::Worker { queue } => CollectedTriggerHandler::Worker { queue },
204            TriggerHandlerUri::Persona { name } => {
205                let binding = persona_runtime_binding_for_handler(extensions, trigger, &name)?;
206                CollectedTriggerHandler::Persona { binding }
207            }
208        };
209
210        let collected_when = if let Some(when_raw) = &trigger.when {
211            let reference = parse_local_trigger_ref(when_raw, "when", trigger)?;
212            let cache_key = (
213                trigger.manifest_dir.clone(),
214                trigger.package_name.clone(),
215                reference.module_name.clone(),
216            );
217            if !loaded_exports.contains_key(&cache_key) {
218                let exports = resolve_manifest_exports(
219                    vm,
220                    &trigger.manifest_dir,
221                    trigger.package_name.as_deref(),
222                    &trigger.exports,
223                    reference.module_name.as_deref(),
224                )
225                .await
226                .map_err(|error| trigger_error(trigger, error))?;
227                loaded_exports.insert(cache_key.clone(), exports);
228            }
229            let exports = loaded_exports
230                .get(&cache_key)
231                .expect("manifest trigger predicate exports cached");
232            let Some(closure) = exports.get(&reference.function_name) else {
233                return Err(trigger_error(
234                    trigger,
235                    format!(
236                        "when predicate '{}' is not exported by the resolved module",
237                        reference.raw
238                    ),
239                ));
240            };
241
242            let source_path = manifest_module_source_path(
243                &trigger.manifest_dir,
244                trigger.package_name.as_deref(),
245                &trigger.exports,
246                reference.module_name.as_deref(),
247            )
248            .map_err(|error| trigger_error(trigger, error))?;
249            if !module_signatures.contains_key(&source_path) {
250                let signatures = load_trigger_function_signatures(&source_path)
251                    .map_err(|error| trigger_error(trigger, error))?;
252                module_signatures.insert(source_path.clone(), signatures);
253            }
254            let signatures = module_signatures
255                .get(&source_path)
256                .expect("module signatures cached");
257            let Some(signature) = signatures.get(&reference.function_name) else {
258                return Err(trigger_error(
259                    trigger,
260                    format!(
261                        "when predicate '{}' must resolve to a function declaration",
262                        reference.raw
263                    ),
264                ));
265            };
266            if signature.params.len() != 1
267                || signature.params[0]
268                    .as_ref()
269                    .is_none_or(|param| !is_trigger_event_type(param))
270            {
271                return Err(trigger_error(
272                    trigger,
273                    format!(
274                        "when predicate '{}' must have signature fn(TriggerEvent) -> bool",
275                        reference.raw
276                    ),
277                ));
278            }
279            if signature
280                .return_type
281                .as_ref()
282                .is_none_or(|return_type| !is_predicate_return_type(return_type))
283            {
284                return Err(trigger_error(
285                    trigger,
286                    format!(
287                        "when predicate '{}' must have signature fn(TriggerEvent) -> bool or Result<bool, _>",
288                        reference.raw
289                    ),
290                ));
291            }
292
293            Some(CollectedTriggerPredicate {
294                reference,
295                closure: closure.clone(),
296            })
297        } else {
298            None
299        };
300
301        let flow_control = collect_trigger_flow_control(vm, trigger).await?;
302
303        collected.push(CollectedManifestTrigger {
304            config: trigger.clone(),
305            handler: collected_handler,
306            when: collected_when,
307            flow_control,
308        });
309    }
310
311    harn_vm::install_provider_catalog(provider_catalog);
312    Ok(collected)
313}
314
315pub(crate) async fn collect_trigger_flow_control(
316    vm: &mut harn_vm::Vm,
317    trigger: &ResolvedTriggerConfig,
318) -> Result<harn_vm::TriggerFlowControlConfig, PackageError> {
319    let mut flow = harn_vm::TriggerFlowControlConfig::default();
320
321    let concurrency = if let Some(spec) = &trigger.concurrency {
322        Some(spec.clone())
323    } else if let Some(max) = trigger.budget.max_concurrent {
324        eprintln!(
325            "warning: {} uses deprecated budget.max_concurrent; prefer concurrency = {{ max = {} }}",
326            manifest_trigger_location(trigger),
327            max
328        );
329        Some(TriggerConcurrencyManifestSpec { key: None, max })
330    } else {
331        None
332    };
333    if let Some(spec) = concurrency {
334        flow.concurrency = Some(harn_vm::TriggerConcurrencyConfig {
335            key: compile_optional_trigger_expression(
336                vm,
337                trigger,
338                "concurrency.key",
339                spec.key.as_deref(),
340            )
341            .await?,
342            max: spec.max,
343        });
344    }
345
346    if let Some(spec) = &trigger.throttle {
347        flow.throttle = Some(harn_vm::TriggerThrottleConfig {
348            key: compile_optional_trigger_expression(
349                vm,
350                trigger,
351                "throttle.key",
352                spec.key.as_deref(),
353            )
354            .await?,
355            period: harn_vm::parse_flow_control_duration(&spec.period)
356                .map_err(|error| trigger_error(trigger, format!("throttle.period {error}")))?,
357            max: spec.max,
358        });
359    }
360
361    if let Some(spec) = &trigger.rate_limit {
362        flow.rate_limit = Some(harn_vm::TriggerRateLimitConfig {
363            key: compile_optional_trigger_expression(
364                vm,
365                trigger,
366                "rate_limit.key",
367                spec.key.as_deref(),
368            )
369            .await?,
370            period: harn_vm::parse_flow_control_duration(&spec.period)
371                .map_err(|error| trigger_error(trigger, format!("rate_limit.period {error}")))?,
372            max: spec.max,
373        });
374    }
375
376    if let Some(spec) = &trigger.debounce {
377        flow.debounce = Some(harn_vm::TriggerDebounceConfig {
378            key: compile_trigger_expression(vm, trigger, "debounce.key", &spec.key).await?,
379            period: harn_vm::parse_flow_control_duration(&spec.period)
380                .map_err(|error| trigger_error(trigger, format!("debounce.period {error}")))?,
381        });
382    }
383
384    if let Some(spec) = &trigger.singleton {
385        flow.singleton = Some(harn_vm::TriggerSingletonConfig {
386            key: compile_optional_trigger_expression(
387                vm,
388                trigger,
389                "singleton.key",
390                spec.key.as_deref(),
391            )
392            .await?,
393        });
394    }
395
396    if let Some(spec) = &trigger.batch {
397        flow.batch = Some(harn_vm::TriggerBatchConfig {
398            key: compile_optional_trigger_expression(vm, trigger, "batch.key", spec.key.as_deref())
399                .await?,
400            size: spec.size,
401            timeout: harn_vm::parse_flow_control_duration(&spec.timeout)
402                .map_err(|error| trigger_error(trigger, format!("batch.timeout {error}")))?,
403        });
404    }
405
406    if let Some(spec) = &trigger.priority_flow {
407        flow.priority = Some(harn_vm::TriggerPriorityOrderConfig {
408            key: compile_trigger_expression(vm, trigger, "priority.key", &spec.key).await?,
409            order: spec.order.clone(),
410        });
411    }
412
413    Ok(flow)
414}
415
416fn persona_runtime_binding_for_handler(
417    extensions: &RuntimeExtensions,
418    trigger: &ResolvedTriggerConfig,
419    name: &str,
420) -> Result<harn_vm::PersonaRuntimeBinding, PackageError> {
421    let Some(manifest) = extensions.root_manifest.as_ref() else {
422        return Err(trigger_error(
423            trigger,
424            format!("handler persona://{name} requires a root manifest"),
425        ));
426    };
427    let Some(persona) = manifest
428        .personas
429        .iter()
430        .find(|persona| persona.name.as_deref() == Some(name))
431    else {
432        return Err(trigger_error(
433            trigger,
434            format!("handler persona://{name} does not match a declared persona"),
435        ));
436    };
437    Ok(persona_runtime_binding(name, persona))
438}
439
440/// Lower a manifest persona entry into the runtime binding. Centralised so
441/// every call site (`harn persona` CLI, trigger registration, etc.) carries
442/// the same shape — stages included.
443pub(crate) fn persona_runtime_binding(
444    name: &str,
445    persona: &PersonaManifestEntry,
446) -> harn_vm::PersonaRuntimeBinding {
447    harn_vm::PersonaRuntimeBinding {
448        name: name.to_string(),
449        template_ref: persona_template_ref(persona),
450        entry_workflow: persona.entry_workflow.clone().unwrap_or_default(),
451        schedules: persona.schedules.clone(),
452        triggers: persona.triggers.clone(),
453        budget: harn_vm::PersonaBudgetPolicy {
454            daily_usd: persona.budget.daily_usd,
455            hourly_usd: persona.budget.hourly_usd,
456            run_usd: persona.budget.run_usd,
457            max_tokens: persona.budget.max_tokens,
458        },
459        stages: persona
460            .stages
461            .iter()
462            .map(persona_stage_decl_to_runtime)
463            .collect(),
464    }
465}
466
467fn persona_stage_decl_to_runtime(stage: &PersonaStageDecl) -> harn_vm::StageDecl {
468    harn_vm::StageDecl {
469        name: stage.name.clone(),
470        allowed_tools: stage.allowed_tools.clone(),
471        side_effect_level: stage.side_effect_level.clone(),
472        max_iterations: stage.max_iterations,
473        on_exit: stage.on_exit.as_ref().map(|exit| harn_vm::StageExit {
474            on_complete: exit.on_complete.clone(),
475            on_failure: exit.on_failure.clone(),
476            policy_override: None,
477        }),
478    }
479}
480
481pub(crate) async fn compile_optional_trigger_expression(
482    vm: &mut harn_vm::Vm,
483    trigger: &ResolvedTriggerConfig,
484    field_name: &str,
485    expr: Option<&str>,
486) -> Result<Option<harn_vm::TriggerExpressionSpec>, PackageError> {
487    match expr {
488        Some(expr) => compile_trigger_expression(vm, trigger, field_name, expr)
489            .await
490            .map(Some),
491        None => Ok(None),
492    }
493}
494
495pub(crate) async fn compile_trigger_expression(
496    vm: &mut harn_vm::Vm,
497    trigger: &ResolvedTriggerConfig,
498    field_name: &str,
499    expr: &str,
500) -> Result<harn_vm::TriggerExpressionSpec, PackageError> {
501    let synthetic = PathBuf::from(format!(
502        "<trigger-expr>/{}/{:04}-{}.harn",
503        harn_vm::event_log::sanitize_topic_component(&trigger.id),
504        trigger.table_index,
505        harn_vm::event_log::sanitize_topic_component(field_name),
506    ));
507    let source = format!(
508        "import \"std/triggers\"\n\npub fn __trigger_expr(event: TriggerEvent) -> any {{\n  return {expr}\n}}\n"
509    );
510    let exports = vm
511        .load_module_exports_from_source(synthetic, &source)
512        .await
513        .map_err(|error| {
514            trigger_error(
515                trigger,
516                format!("{field_name} '{expr}' is invalid Harn expression: {error}"),
517            )
518        })?;
519    let closure = exports.get("__trigger_expr").ok_or_else(|| {
520        trigger_error(
521            trigger,
522            format!("{field_name} '{expr}' did not compile into an exported closure"),
523        )
524    })?;
525    Ok(harn_vm::TriggerExpressionSpec {
526        raw: expr.to_string(),
527        closure: closure.clone(),
528    })
529}
530
531pub(crate) fn trigger_kind_label(kind: TriggerKind) -> &'static str {
532    match kind {
533        TriggerKind::Webhook => "webhook",
534        TriggerKind::Cron => "cron",
535        TriggerKind::Poll => "poll",
536        TriggerKind::Stream => "stream",
537        TriggerKind::Predicate => "predicate",
538        TriggerKind::A2aPush => "a2a-push",
539    }
540}
541
542pub(crate) fn worker_queue_priority(
543    priority: TriggerDispatchPriority,
544) -> harn_vm::WorkerQueuePriority {
545    match priority {
546        TriggerDispatchPriority::High => harn_vm::WorkerQueuePriority::High,
547        TriggerDispatchPriority::Normal => harn_vm::WorkerQueuePriority::Normal,
548        TriggerDispatchPriority::Low => harn_vm::WorkerQueuePriority::Low,
549    }
550}
551
552pub fn manifest_trigger_binding_spec(
553    trigger: CollectedManifestTrigger,
554) -> harn_vm::TriggerBindingSpec {
555    let flow_control = trigger.flow_control.clone();
556    let config = trigger.config;
557    let (handler, handler_descriptor) = match trigger.handler {
558        CollectedTriggerHandler::Local { reference, closure } => (
559            harn_vm::TriggerHandlerSpec::Local {
560                raw: reference.raw.clone(),
561                closure,
562            },
563            serde_json::json!({
564                "kind": "local",
565                "raw": reference.raw,
566            }),
567        ),
568        CollectedTriggerHandler::A2a {
569            target,
570            allow_cleartext,
571        } => (
572            harn_vm::TriggerHandlerSpec::A2a {
573                target: target.clone(),
574                allow_cleartext,
575            },
576            serde_json::json!({
577                "kind": "a2a",
578                "target": target,
579                "allow_cleartext": allow_cleartext,
580            }),
581        ),
582        CollectedTriggerHandler::Worker { queue } => (
583            harn_vm::TriggerHandlerSpec::Worker {
584                queue: queue.clone(),
585            },
586            serde_json::json!({
587                "kind": "worker",
588                "queue": queue,
589            }),
590        ),
591        CollectedTriggerHandler::Persona { binding } => (
592            harn_vm::TriggerHandlerSpec::Persona {
593                binding: binding.clone(),
594            },
595            serde_json::json!({
596                "kind": "persona",
597                "name": binding.name,
598                "entry_workflow": binding.entry_workflow,
599            }),
600        ),
601    };
602
603    let when_raw = trigger
604        .when
605        .as_ref()
606        .map(|predicate| predicate.reference.raw.clone());
607    let when = trigger.when.map(|predicate| harn_vm::TriggerPredicateSpec {
608        raw: predicate.reference.raw,
609        closure: predicate.closure,
610    });
611    let mut when_budget = config
612        .when_budget
613        .as_ref()
614        .map(|budget| {
615            Ok::<harn_vm::TriggerPredicateBudget, String>(harn_vm::TriggerPredicateBudget {
616                max_cost_usd: budget.max_cost_usd,
617                tokens_max: budget.tokens_max,
618                timeout_ms: budget
619                    .timeout
620                    .as_deref()
621                    .map(parse_duration_millis)
622                    .transpose()?,
623            })
624        })
625        .transpose()
626        .unwrap_or_default();
627    if config.budget.max_cost_usd.is_some() || config.budget.max_tokens.is_some() {
628        let budget = when_budget.get_or_insert_with(harn_vm::TriggerPredicateBudget::default);
629        if budget.max_cost_usd.is_none() {
630            budget.max_cost_usd = config.budget.max_cost_usd;
631        }
632        if budget.tokens_max.is_none() {
633            budget.tokens_max = config.budget.max_tokens;
634        }
635    }
636    let id = config.id.clone();
637    let kind = trigger_kind_label(config.kind).to_string();
638    let provider = config.provider.clone();
639    let autonomy_tier = config.autonomy_tier;
640    let match_events = config.match_.events.clone();
641    let dedupe_key = config.dedupe_key.clone();
642    let retry = harn_vm::TriggerRetryConfig::new(
643        config.retry.max,
644        match config.retry.backoff {
645            TriggerRetryBackoff::Immediate => harn_vm::RetryPolicy::Linear { delay_ms: 0 },
646            TriggerRetryBackoff::Svix => harn_vm::RetryPolicy::Svix,
647        },
648    );
649    let filter = config.filter.clone();
650    let dedupe_retention_days = config.retry.retention_days;
651    let daily_cost_usd = config.budget.daily_cost_usd;
652    let hourly_cost_usd = config.budget.hourly_cost_usd;
653    let max_autonomous_decisions_per_hour = config.budget.max_autonomous_decisions_per_hour;
654    let max_autonomous_decisions_per_day = config.budget.max_autonomous_decisions_per_day;
655    let on_budget_exhausted = config.budget.on_budget_exhausted;
656    let max_concurrent = flow_control.concurrency.as_ref().map(|config| config.max);
657    let manifest_path = Some(config.manifest_path.clone());
658    let package_name = config.package_name.clone();
659
660    let fingerprint = serde_json::to_string(&serde_json::json!({
661        "id": &id,
662        "kind": &kind,
663        "provider": provider.as_str(),
664        "autonomy_tier": autonomy_tier,
665        "match": config.match_,
666        "when": when_raw,
667        "when_budget": config.when_budget,
668        "handler": handler_descriptor,
669        "dedupe_key": &dedupe_key,
670        "retry": config.retry,
671        "dispatch_priority": config.dispatch_priority,
672        "budget": config.budget,
673        "flow_control": {
674            "concurrency": config.concurrency,
675            "throttle": config.throttle,
676            "rate_limit": config.rate_limit,
677            "debounce": config.debounce,
678            "singleton": config.singleton,
679            "batch": config.batch,
680            "priority": config.priority_flow,
681        },
682        "window": config.window,
683        "secrets": config.secrets,
684        "filter": &filter,
685        "kind_specific": config.kind_specific,
686        "manifest_path": &manifest_path,
687        "package_name": &package_name,
688    }))
689    .unwrap_or_else(|_| format!("{}:{}:{}", id, kind, provider.as_str()));
690
691    harn_vm::TriggerBindingSpec {
692        id,
693        source: harn_vm::TriggerBindingSource::Manifest,
694        kind,
695        provider,
696        autonomy_tier,
697        handler,
698        dispatch_priority: worker_queue_priority(config.dispatch_priority),
699        when,
700        when_budget,
701        retry,
702        match_events,
703        dedupe_key,
704        filter,
705        dedupe_retention_days,
706        daily_cost_usd,
707        hourly_cost_usd,
708        max_autonomous_decisions_per_hour,
709        max_autonomous_decisions_per_day,
710        on_budget_exhausted,
711        max_concurrent,
712        flow_control,
713        aggregation: None,
714        manifest_path,
715        package_name,
716        definition_fingerprint: fingerprint,
717    }
718}
719
720pub async fn install_manifest_triggers(
721    vm: &mut harn_vm::Vm,
722    extensions: &RuntimeExtensions,
723) -> Result<(), PackageError> {
724    install_orchestrator_budget(extensions);
725    let collected = collect_manifest_triggers(vm, extensions).await?;
726    let mut bindings: Vec<_> = collected
727        .iter()
728        .cloned()
729        .map(manifest_trigger_binding_spec)
730        .collect();
731    bindings.extend(collect_persona_trigger_binding_specs(extensions)?);
732    harn_vm::install_manifest_triggers(bindings)
733        .await
734        .map_err(|error| PackageError::Extensions(error.to_string()))
735}
736
737pub async fn install_collected_manifest_triggers(
738    collected: &[CollectedManifestTrigger],
739) -> Result<(), PackageError> {
740    let bindings = collected
741        .iter()
742        .cloned()
743        .map(manifest_trigger_binding_spec)
744        .collect();
745    harn_vm::install_manifest_triggers(bindings)
746        .await
747        .map_err(|error| PackageError::Extensions(error.to_string()))
748}
749
750pub fn collect_persona_trigger_binding_specs(
751    extensions: &RuntimeExtensions,
752) -> Result<Vec<harn_vm::TriggerBindingSpec>, PackageError> {
753    let Some(manifest) = extensions.root_manifest.clone() else {
754        return Ok(Vec::new());
755    };
756    let manifest_path = extensions
757        .root_manifest_path
758        .clone()
759        .unwrap_or_else(|| PathBuf::from(MANIFEST));
760    let manifest_dir = extensions
761        .root_manifest_dir
762        .clone()
763        .or_else(|| manifest_path.parent().map(Path::to_path_buf))
764        .unwrap_or_else(|| PathBuf::from("."));
765    let resolved = validate_and_resolve_personas(manifest, manifest_path.clone(), manifest_dir)
766        .map_err(|errors| {
767            errors
768                .iter()
769                .map(ToString::to_string)
770                .collect::<Vec<_>>()
771                .join("\n")
772        })?;
773    let mut bindings = Vec::new();
774    for persona in resolved.personas {
775        let Some(name) = persona.name.clone() else {
776            continue;
777        };
778        for trigger in &persona.triggers {
779            let Some((provider, kind)) = trigger.split_once('.') else {
780                continue;
781            };
782            let provider = provider.trim();
783            let kind = kind.trim();
784            if provider.is_empty() || kind.is_empty() {
785                continue;
786            }
787            bindings.push(persona_trigger_binding_spec(
788                &resolved.manifest_path,
789                &name,
790                provider,
791                kind,
792                &persona,
793            ));
794        }
795    }
796    Ok(bindings)
797}
798
799fn persona_trigger_binding_spec(
800    manifest_path: &Path,
801    name: &str,
802    provider: &str,
803    kind: &str,
804    persona: &PersonaManifestEntry,
805) -> harn_vm::TriggerBindingSpec {
806    let runtime_binding = persona_runtime_binding(name, persona);
807    let id = format!("persona.{name}.{provider}.{kind}");
808    let handler = harn_vm::TriggerHandlerSpec::Persona {
809        binding: runtime_binding.clone(),
810    };
811    let fingerprint = serde_json::to_string(&serde_json::json!({
812        "id": &id,
813        "kind": kind,
814        "provider": provider,
815        "handler": {
816            "kind": "persona",
817            "name": name,
818            "entry_workflow": runtime_binding.entry_workflow,
819        },
820        "budget": runtime_binding.budget,
821        "manifest_path": manifest_path,
822    }))
823    .unwrap_or_else(|_| format!("{id}:{provider}:{kind}:{name}"));
824
825    harn_vm::TriggerBindingSpec {
826        id,
827        source: harn_vm::TriggerBindingSource::Manifest,
828        kind: kind.to_string(),
829        provider: harn_vm::ProviderId::from(provider.to_string()),
830        autonomy_tier: persona
831            .autonomy_tier
832            .map(persona_autonomy_to_vm)
833            .unwrap_or(harn_vm::AutonomyTier::Suggest),
834        handler,
835        dispatch_priority: harn_vm::WorkerQueuePriority::Normal,
836        when: None,
837        when_budget: None,
838        retry: harn_vm::TriggerRetryConfig::default(),
839        match_events: vec![kind.to_string()],
840        dedupe_key: None,
841        filter: None,
842        dedupe_retention_days: 7,
843        daily_cost_usd: persona.budget.daily_usd,
844        hourly_cost_usd: persona.budget.hourly_usd,
845        max_autonomous_decisions_per_hour: None,
846        max_autonomous_decisions_per_day: None,
847        on_budget_exhausted: harn_vm::TriggerBudgetExhaustionStrategy::RetryLater,
848        max_concurrent: None,
849        flow_control: harn_vm::TriggerFlowControlConfig::default(),
850        aggregation: None,
851        manifest_path: Some(manifest_path.to_path_buf()),
852        package_name: None,
853        definition_fingerprint: fingerprint,
854    }
855}
856
857fn persona_autonomy_to_vm(value: PersonaAutonomyTier) -> harn_vm::AutonomyTier {
858    match value {
859        PersonaAutonomyTier::Shadow => harn_vm::AutonomyTier::Shadow,
860        PersonaAutonomyTier::Suggest => harn_vm::AutonomyTier::Suggest,
861        PersonaAutonomyTier::ActWithApproval => harn_vm::AutonomyTier::ActWithApproval,
862        PersonaAutonomyTier::ActAuto => harn_vm::AutonomyTier::ActAuto,
863    }
864}
865
866fn persona_template_ref(persona: &PersonaManifestEntry) -> Option<String> {
867    persona
868        .package_source
869        .package
870        .as_ref()
871        .zip(persona.version.as_ref())
872        .map(|(package, version)| format!("{package}@{version}"))
873        .or_else(|| persona.package_source.package.clone())
874        .or_else(|| {
875            persona
876                .name
877                .as_ref()
878                .zip(persona.version.as_ref())
879                .map(|(name, version)| format!("{name}@{version}"))
880        })
881}
882
883pub fn load_personas_from_manifest_path(
884    manifest_path: &Path,
885) -> Result<ResolvedPersonaManifest, Vec<PersonaValidationError>> {
886    let manifest_path = if manifest_path.is_dir() {
887        manifest_path.join(MANIFEST)
888    } else {
889        manifest_path.to_path_buf()
890    };
891    let manifest_dir = manifest_path
892        .parent()
893        .map(Path::to_path_buf)
894        .unwrap_or_else(|| PathBuf::from("."));
895    if manifest_path.extension().and_then(|ext| ext.to_str()) == Some("harn") {
896        return match harn_modules::personas::parse_persona_source_file(&manifest_path) {
897            Ok(document) if !document.personas.is_empty() => {
898                validate_and_resolve_standalone_personas(
899                    document.personas,
900                    manifest_path,
901                    manifest_dir,
902                )
903            }
904            Ok(_) => Err(vec![PersonaValidationError {
905                manifest_path: manifest_path.clone(),
906                field_path: "persona".to_string(),
907                message: "no @persona declarations found".to_string(),
908            }]),
909            Err(message) => Err(vec![PersonaValidationError {
910                manifest_path: manifest_path.clone(),
911                field_path: "persona".to_string(),
912                message,
913            }]),
914        };
915    }
916    let manifest = match read_manifest_from_path(&manifest_path) {
917        Ok(manifest) => manifest,
918        Err(message) => {
919            if let Ok(document) =
920                harn_modules::personas::parse_persona_manifest_file(&manifest_path)
921            {
922                if !document.personas.is_empty() {
923                    return validate_and_resolve_standalone_personas(
924                        document.personas,
925                        manifest_path,
926                        manifest_dir,
927                    );
928                }
929            }
930            return Err(vec![PersonaValidationError {
931                manifest_path: manifest_path.clone(),
932                field_path: "harn.toml".to_string(),
933                message: message.to_string(),
934            }]);
935        }
936    };
937    if manifest.personas.is_empty() {
938        if let Ok(document) = harn_modules::personas::parse_persona_manifest_file(&manifest_path) {
939            if !document.personas.is_empty() {
940                return validate_and_resolve_standalone_personas(
941                    document.personas,
942                    manifest_path,
943                    manifest_dir,
944                );
945            }
946        }
947    }
948    validate_and_resolve_personas(manifest, manifest_path, manifest_dir)
949}
950
951fn validate_and_resolve_standalone_personas(
952    personas: Vec<PersonaManifestEntry>,
953    manifest_path: PathBuf,
954    manifest_dir: PathBuf,
955) -> Result<ResolvedPersonaManifest, Vec<PersonaValidationError>> {
956    let known_names = personas
957        .iter()
958        .filter_map(|persona| persona.name.as_ref())
959        .filter(|name| !name.trim().is_empty())
960        .cloned()
961        .collect();
962    let context = harn_modules::personas::PersonaValidationContext {
963        known_capabilities: harn_modules::personas::default_persona_capabilities(),
964        known_tools: BTreeSet::new(),
965        known_names,
966    };
967    harn_modules::personas::validate_persona_manifests(&manifest_path, &personas, &context)?;
968    Ok(ResolvedPersonaManifest {
969        manifest_path,
970        manifest_dir,
971        personas,
972    })
973}
974
975pub fn load_personas_config(
976    anchor: Option<&Path>,
977) -> Result<Option<ResolvedPersonaManifest>, Vec<PersonaValidationError>> {
978    let anchor = anchor
979        .map(Path::to_path_buf)
980        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
981    let Some((manifest, dir)) = find_nearest_manifest(&anchor) else {
982        return Ok(None);
983    };
984    let manifest_path = dir.join(MANIFEST);
985    validate_and_resolve_personas(manifest, manifest_path, dir).map(Some)
986}
987
988pub(crate) fn validate_and_resolve_personas(
989    manifest: Manifest,
990    manifest_path: PathBuf,
991    manifest_dir: PathBuf,
992) -> Result<ResolvedPersonaManifest, Vec<PersonaValidationError>> {
993    let known_capabilities = known_persona_capabilities(&manifest, &manifest_dir);
994    let known_tools = known_persona_tools(&manifest);
995    let known_names: BTreeSet<String> = manifest
996        .personas
997        .iter()
998        .filter_map(|persona| persona.name.as_ref())
999        .filter(|name| !name.trim().is_empty())
1000        .cloned()
1001        .collect();
1002    let context = harn_modules::personas::PersonaValidationContext {
1003        known_capabilities,
1004        known_tools,
1005        known_names,
1006    };
1007    if let Err(errors) = harn_modules::personas::validate_persona_manifests(
1008        &manifest_path,
1009        &manifest.personas,
1010        &context,
1011    ) {
1012        Err(errors)
1013    } else {
1014        let mut personas = manifest.personas;
1015        attach_entry_workflow_steps(&mut personas, &manifest_dir);
1016        Ok(ResolvedPersonaManifest {
1017            manifest_path,
1018            manifest_dir,
1019            personas,
1020        })
1021    }
1022}
1023
1024fn attach_entry_workflow_steps(personas: &mut [PersonaManifestEntry], manifest_dir: &Path) {
1025    for persona in personas {
1026        if !persona.steps.is_empty() {
1027            continue;
1028        }
1029        let Some(entry_workflow) = persona.entry_workflow.as_deref() else {
1030            continue;
1031        };
1032        let Some((path, entry_name)) = entry_workflow.split_once('#') else {
1033            continue;
1034        };
1035        if !path.ends_with(".harn") {
1036            continue;
1037        }
1038        let source_path = manifest_dir.join(path);
1039        let Ok(document) = harn_modules::personas::parse_persona_source_file(&source_path) else {
1040            continue;
1041        };
1042        let entry_name = entry_name.trim();
1043        if let Some(source_persona) = document.personas.iter().find(|candidate| {
1044            candidate.entry_workflow.as_deref() == Some(entry_name)
1045                || candidate.name.as_deref() == persona.name.as_deref()
1046        }) {
1047            persona.steps.clone_from(&source_persona.steps);
1048        }
1049    }
1050}
1051
1052pub(crate) fn known_persona_capabilities(
1053    manifest: &Manifest,
1054    manifest_dir: &Path,
1055) -> BTreeSet<String> {
1056    let mut capabilities = BTreeSet::new();
1057    for (capability, operations) in default_persona_capability_map() {
1058        for operation in operations {
1059            capabilities.insert(format!("{capability}.{operation}"));
1060        }
1061    }
1062    for (capability, operations) in &manifest.check.host_capabilities {
1063        for operation in operations {
1064            capabilities.insert(format!("{capability}.{operation}"));
1065        }
1066    }
1067    if let Some(path) = manifest.check.host_capabilities_path.as_deref() {
1068        let path = PathBuf::from(path);
1069        let path = if path.is_absolute() {
1070            path
1071        } else {
1072            manifest_dir.join(path)
1073        };
1074        if let Ok(content) = fs::read_to_string(path) {
1075            let parsed_json = serde_json::from_str::<serde_json::Value>(&content).ok();
1076            let parsed_toml = toml::from_str::<toml::Value>(&content)
1077                .ok()
1078                .and_then(|value| serde_json::to_value(value).ok());
1079            if let Some(value) = parsed_json.or(parsed_toml) {
1080                collect_persona_capabilities_from_json(&value, &mut capabilities);
1081            }
1082        }
1083    }
1084    capabilities
1085}
1086
1087pub(crate) fn collect_persona_capabilities_from_json(
1088    value: &serde_json::Value,
1089    out: &mut BTreeSet<String>,
1090) {
1091    let root = value.get("capabilities").unwrap_or(value);
1092    let Some(capabilities) = root.as_object() else {
1093        return;
1094    };
1095    for (capability, entry) in capabilities {
1096        if let Some(list) = entry.as_array() {
1097            for item in list {
1098                if let Some(operation) = item.as_str() {
1099                    out.insert(format!("{capability}.{operation}"));
1100                }
1101            }
1102        } else if let Some(obj) = entry.as_object() {
1103            if let Some(list) = obj
1104                .get("operations")
1105                .or_else(|| obj.get("ops"))
1106                .and_then(|v| v.as_array())
1107            {
1108                for item in list {
1109                    if let Some(operation) = item.as_str() {
1110                        out.insert(format!("{capability}.{operation}"));
1111                    }
1112                }
1113            } else {
1114                for (operation, enabled) in obj {
1115                    if enabled.as_bool().unwrap_or(true) {
1116                        out.insert(format!("{capability}.{operation}"));
1117                    }
1118                }
1119            }
1120        }
1121    }
1122}
1123
1124pub(crate) fn default_persona_capability_map() -> BTreeMap<&'static str, Vec<&'static str>> {
1125    harn_modules::personas::default_persona_capability_map()
1126}
1127
1128pub(crate) fn known_persona_tools(manifest: &Manifest) -> BTreeSet<String> {
1129    let mut tools = BTreeSet::from([
1130        "a2a".to_string(),
1131        "acp".to_string(),
1132        "ci".to_string(),
1133        "filesystem".to_string(),
1134        "github".to_string(),
1135        "linear".to_string(),
1136        "mcp".to_string(),
1137        "notion".to_string(),
1138        "pagerduty".to_string(),
1139        "shell".to_string(),
1140        "slack".to_string(),
1141    ]);
1142    for server in &manifest.mcp {
1143        tools.insert(server.name.clone());
1144    }
1145    for provider in &manifest.providers {
1146        tools.insert(provider.id.as_str().to_string());
1147    }
1148    for trigger in &manifest.triggers {
1149        if let Some(provider) = trigger.provider.as_ref() {
1150            tools.insert(provider.as_str().to_string());
1151        }
1152        for source in &trigger.sources {
1153            tools.insert(source.provider.as_str().to_string());
1154        }
1155    }
1156    tools
1157}
1158
1159#[cfg(test)]
1160#[path = "extensions_tests.rs"]
1161mod tests;