1use super::errors::PackageError;
2use super::*;
3pub use harn_modules::personas::{
4 PersonaAutonomyTier, PersonaManifestEntry, PersonaValidationError, ResolvedPersonaManifest,
5};
6
7#[derive(Debug, Clone, Deserialize)]
8pub struct Manifest {
9 pub package: Option<PackageInfo>,
10 #[serde(default)]
11 pub dependencies: HashMap<String, Dependency>,
12 #[serde(default)]
13 pub mcp: Vec<McpServerConfig>,
14 #[serde(default)]
15 pub check: CheckConfig,
16 #[serde(default)]
17 pub workspace: WorkspaceConfig,
18 #[serde(default)]
22 pub registry: PackageRegistryConfig,
23 #[serde(default)]
26 pub skills: SkillsConfig,
27 #[serde(default)]
30 pub skill: SkillTables,
31 #[serde(default)]
39 pub capabilities: Option<harn_vm::llm::capabilities::CapabilitiesFile>,
40 #[serde(default)]
44 pub exports: HashMap<String, String>,
45 #[serde(default)]
50 pub llm: harn_vm::llm_config::ProvidersConfig,
51 #[serde(default)]
56 pub hooks: Vec<HookConfig>,
57 #[serde(default)]
62 pub triggers: Vec<TriggerManifestEntry>,
63 #[serde(default)]
67 pub handoff_routes: Vec<harn_vm::HandoffRouteConfig>,
68 #[serde(default)]
72 pub providers: Vec<ProviderManifestEntry>,
73 #[serde(default)]
77 pub personas: Vec<PersonaManifestEntry>,
78 #[serde(default, alias = "connector-contract")]
81 pub connector_contract: ConnectorContractConfig,
82 #[serde(default)]
85 pub orchestrator: OrchestratorConfig,
86}
87
88#[derive(Debug, Clone, Default, Deserialize)]
89pub struct OrchestratorConfig {
90 #[serde(default, alias = "allowed-origins")]
91 pub allowed_origins: Vec<String>,
92 #[serde(default, alias = "max-body-bytes")]
93 pub max_body_bytes: Option<usize>,
94 #[serde(default)]
95 pub budget: OrchestratorBudgetSpec,
96 #[serde(default)]
97 pub drain: OrchestratorDrainConfig,
98 #[serde(default)]
99 pub pumps: OrchestratorPumpConfig,
100}
101
102#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
103pub struct OrchestratorBudgetSpec {
104 #[serde(default)]
105 pub daily_cost_usd: Option<f64>,
106 #[serde(default)]
107 pub hourly_cost_usd: Option<f64>,
108}
109
110#[derive(Debug, Clone, Deserialize)]
111pub struct OrchestratorDrainConfig {
112 #[serde(default = "default_orchestrator_drain_max_items", alias = "max-items")]
113 pub max_items: usize,
114 #[serde(
115 default = "default_orchestrator_drain_deadline_seconds",
116 alias = "deadline-seconds"
117 )]
118 pub deadline_seconds: u64,
119}
120
121impl Default for OrchestratorDrainConfig {
122 fn default() -> Self {
123 Self {
124 max_items: default_orchestrator_drain_max_items(),
125 deadline_seconds: default_orchestrator_drain_deadline_seconds(),
126 }
127 }
128}
129
130pub(crate) fn default_orchestrator_drain_max_items() -> usize {
131 1024
132}
133
134pub(crate) fn default_orchestrator_drain_deadline_seconds() -> u64 {
135 30
136}
137
138#[derive(Debug, Clone, Deserialize)]
139pub struct OrchestratorPumpConfig {
140 #[serde(
141 default = "default_orchestrator_pump_max_outstanding",
142 alias = "max-outstanding"
143 )]
144 pub max_outstanding: usize,
145}
146
147impl Default for OrchestratorPumpConfig {
148 fn default() -> Self {
149 Self {
150 max_outstanding: default_orchestrator_pump_max_outstanding(),
151 }
152 }
153}
154
155pub(crate) fn default_orchestrator_pump_max_outstanding() -> usize {
156 64
157}
158
159#[derive(Debug, Clone, Deserialize)]
160pub struct HookConfig {
161 pub event: harn_vm::orchestration::HookEvent,
162 #[serde(default = "default_hook_pattern")]
163 pub pattern: String,
164 pub handler: String,
165}
166
167pub(crate) fn default_hook_pattern() -> String {
168 "*".to_string()
169}
170
171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
172pub struct TriggerManifestEntry {
173 pub id: String,
174 #[serde(default)]
175 pub kind: Option<TriggerKind>,
176 #[serde(default)]
177 pub provider: Option<harn_vm::ProviderId>,
178 #[serde(default, alias = "tier")]
179 pub autonomy_tier: harn_vm::AutonomyTier,
180 #[serde(default, rename = "match")]
181 pub match_: Option<TriggerMatchExpr>,
182 #[serde(default)]
183 pub sources: Vec<TriggerSourceManifestEntry>,
184 #[serde(default)]
185 pub when: Option<String>,
186 #[serde(default)]
187 pub when_budget: Option<TriggerWhenBudgetSpec>,
188 pub handler: String,
189 #[serde(default)]
190 pub dedupe_key: Option<String>,
191 #[serde(default)]
192 pub retry: TriggerRetrySpec,
193 #[serde(default)]
194 pub priority: Option<TriggerPriorityField>,
195 #[serde(default)]
196 pub budget: TriggerBudgetSpec,
197 #[serde(default)]
198 pub concurrency: Option<TriggerConcurrencyManifestSpec>,
199 #[serde(default)]
200 pub throttle: Option<TriggerThrottleManifestSpec>,
201 #[serde(default)]
202 pub rate_limit: Option<TriggerRateLimitManifestSpec>,
203 #[serde(default)]
204 pub debounce: Option<TriggerDebounceManifestSpec>,
205 #[serde(default)]
206 pub singleton: Option<TriggerSingletonManifestSpec>,
207 #[serde(default)]
208 pub batch: Option<TriggerBatchManifestSpec>,
209 #[serde(default)]
210 pub window: Option<TriggerStreamWindowManifestSpec>,
211 #[serde(default, alias = "dlq-alerts")]
212 pub dlq_alerts: Vec<TriggerDlqAlertManifestSpec>,
213 #[serde(default)]
214 pub secrets: BTreeMap<String, String>,
215 #[serde(default)]
216 pub filter: Option<String>,
217 #[serde(flatten, default)]
218 pub kind_specific: BTreeMap<String, toml::Value>,
219}
220
221#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
222pub struct TriggerSourceManifestEntry {
223 #[serde(default)]
224 pub id: Option<String>,
225 pub kind: TriggerKind,
226 pub provider: harn_vm::ProviderId,
227 #[serde(default, rename = "match")]
228 pub match_: Option<TriggerMatchExpr>,
229 #[serde(default)]
230 pub dedupe_key: Option<String>,
231 #[serde(default)]
232 pub retry: Option<TriggerRetrySpec>,
233 #[serde(default)]
234 pub priority: Option<TriggerPriorityField>,
235 #[serde(default)]
236 pub budget: Option<TriggerBudgetSpec>,
237 #[serde(default)]
238 pub concurrency: Option<TriggerConcurrencyManifestSpec>,
239 #[serde(default)]
240 pub throttle: Option<TriggerThrottleManifestSpec>,
241 #[serde(default)]
242 pub rate_limit: Option<TriggerRateLimitManifestSpec>,
243 #[serde(default)]
244 pub debounce: Option<TriggerDebounceManifestSpec>,
245 #[serde(default)]
246 pub singleton: Option<TriggerSingletonManifestSpec>,
247 #[serde(default)]
248 pub batch: Option<TriggerBatchManifestSpec>,
249 #[serde(default)]
250 pub window: Option<TriggerStreamWindowManifestSpec>,
251 #[serde(default)]
252 pub secrets: BTreeMap<String, String>,
253 #[serde(default)]
254 pub filter: Option<String>,
255 #[serde(flatten, default)]
256 pub kind_specific: BTreeMap<String, toml::Value>,
257}
258
259#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
260#[serde(rename_all = "kebab-case")]
261pub enum TriggerKind {
262 Webhook,
263 Cron,
264 Poll,
265 Stream,
266 Predicate,
267 A2aPush,
268}
269
270#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
271pub struct TriggerMatchExpr {
272 #[serde(default)]
273 pub events: Vec<String>,
274 #[serde(flatten, default)]
275 pub extra: BTreeMap<String, toml::Value>,
276}
277
278#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
279pub struct TriggerRetrySpec {
280 #[serde(default)]
281 pub max: u32,
282 #[serde(default)]
283 pub backoff: TriggerRetryBackoff,
284 #[serde(default = "default_trigger_retention_days")]
285 pub retention_days: u32,
286}
287
288#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
289#[serde(rename_all = "kebab-case")]
290pub enum TriggerRetryBackoff {
291 #[default]
292 Immediate,
293 Svix,
294}
295
296pub(crate) fn default_trigger_retention_days() -> u32 {
297 harn_vm::DEFAULT_INBOX_RETENTION_DAYS
298}
299
300impl Default for TriggerRetrySpec {
301 fn default() -> Self {
302 Self {
303 max: 0,
304 backoff: TriggerRetryBackoff::default(),
305 retention_days: default_trigger_retention_days(),
306 }
307 }
308}
309
310#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
311#[serde(rename_all = "lowercase")]
312pub enum TriggerDispatchPriority {
313 High,
314 #[default]
315 Normal,
316 Low,
317}
318
319#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
320#[serde(untagged)]
321pub enum TriggerPriorityField {
322 Dispatch(TriggerDispatchPriority),
323 Flow(TriggerPriorityManifestSpec),
324}
325
326#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
327pub struct TriggerBudgetSpec {
328 #[serde(default)]
329 pub max_cost_usd: Option<f64>,
330 #[serde(default, alias = "tokens_max")]
331 pub max_tokens: Option<u64>,
332 #[serde(default)]
333 pub daily_cost_usd: Option<f64>,
334 #[serde(default)]
335 pub hourly_cost_usd: Option<f64>,
336 #[serde(default)]
337 pub max_autonomous_decisions_per_hour: Option<u64>,
338 #[serde(default)]
339 pub max_autonomous_decisions_per_day: Option<u64>,
340 #[serde(default)]
341 pub max_concurrent: Option<u32>,
342 #[serde(default)]
343 pub on_budget_exhausted: harn_vm::TriggerBudgetExhaustionStrategy,
344}
345
346#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
347pub struct TriggerWhenBudgetSpec {
348 #[serde(default)]
349 pub max_cost_usd: Option<f64>,
350 #[serde(default)]
351 pub tokens_max: Option<u64>,
352 #[serde(default)]
353 pub timeout: Option<String>,
354}
355
356#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
357pub struct TriggerConcurrencyManifestSpec {
358 #[serde(default)]
359 pub key: Option<String>,
360 pub max: u32,
361}
362
363#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
364pub struct TriggerThrottleManifestSpec {
365 #[serde(default)]
366 pub key: Option<String>,
367 pub period: String,
368 pub max: u32,
369}
370
371#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
372pub struct TriggerRateLimitManifestSpec {
373 #[serde(default)]
374 pub key: Option<String>,
375 pub period: String,
376 pub max: u32,
377}
378
379#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
380pub struct TriggerDebounceManifestSpec {
381 pub key: String,
382 pub period: String,
383}
384
385#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
386pub struct TriggerSingletonManifestSpec {
387 #[serde(default)]
388 pub key: Option<String>,
389}
390
391#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
392pub struct TriggerBatchManifestSpec {
393 #[serde(default)]
394 pub key: Option<String>,
395 pub size: u32,
396 pub timeout: String,
397}
398
399#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
400pub struct TriggerPriorityManifestSpec {
401 pub key: String,
402 #[serde(default)]
403 pub order: Vec<String>,
404}
405
406#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
407#[serde(rename_all = "kebab-case")]
408pub enum TriggerStreamWindowMode {
409 Tumbling,
410 Sliding,
411 Session,
412}
413
414#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
415pub struct TriggerStreamWindowManifestSpec {
416 pub mode: TriggerStreamWindowMode,
417 #[serde(default)]
418 pub key: Option<String>,
419 #[serde(default)]
420 pub size: Option<String>,
421 #[serde(default)]
422 pub every: Option<String>,
423 #[serde(default)]
424 pub gap: Option<String>,
425 #[serde(default)]
426 pub max_items: Option<u32>,
427}
428
429#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
430pub struct TriggerDlqAlertManifestSpec {
431 #[serde(default)]
432 pub destinations: Vec<TriggerDlqAlertDestination>,
433 #[serde(default)]
434 pub threshold: TriggerDlqAlertThreshold,
435}
436
437#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
438pub struct TriggerDlqAlertThreshold {
439 #[serde(default, alias = "entries-in-1h")]
440 pub entries_in_1h: Option<u32>,
441 #[serde(default, alias = "percent-of-dispatches")]
442 pub percent_of_dispatches: Option<f64>,
443}
444
445#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
446#[serde(tag = "kind", rename_all = "snake_case")]
447pub enum TriggerDlqAlertDestination {
448 Slack {
449 channel: String,
450 #[serde(default)]
451 webhook_url_env: Option<String>,
452 },
453 Email {
454 address: String,
455 },
456 Webhook {
457 url: String,
458 #[serde(default)]
459 headers: BTreeMap<String, String>,
460 },
461}
462
463impl TriggerDlqAlertDestination {
464 pub fn label(&self) -> String {
465 match self {
466 Self::Slack { channel, .. } => format!("slack:{channel}"),
467 Self::Email { address } => format!("email:{address}"),
468 Self::Webhook { url, .. } => format!("webhook:{url}"),
469 }
470 }
471}
472
473#[derive(Debug, Clone, PartialEq, Eq)]
474pub enum TriggerHandlerUri {
475 Local(TriggerFunctionRef),
476 A2a {
477 target: String,
478 allow_cleartext: bool,
479 },
480 Worker {
481 queue: String,
482 },
483 Persona {
484 name: String,
485 },
486}
487
488#[derive(Debug, Clone, PartialEq, Eq)]
489pub struct TriggerFunctionRef {
490 pub raw: String,
491 pub module_name: Option<String>,
492 pub function_name: String,
493}
494
495#[derive(Debug, Default, Clone, Deserialize)]
497#[allow(dead_code)] pub struct SkillsConfig {
499 #[serde(default)]
503 pub paths: Vec<String>,
504 #[serde(default)]
509 pub lookup_order: Vec<String>,
510 #[serde(default)]
512 pub disable: Vec<String>,
513 #[serde(default)]
516 pub signer_registry_url: Option<String>,
517 #[serde(default)]
521 pub defaults: SkillDefaults,
522}
523
524#[derive(Debug, Default, Clone, Deserialize)]
525#[allow(dead_code)] pub struct SkillDefaults {
527 #[serde(default)]
528 pub tool_search: Option<String>,
529 #[serde(default)]
530 pub always_loaded: Vec<String>,
531}
532
533#[derive(Debug, Default, Clone, Deserialize)]
535pub struct SkillTables {
536 #[serde(default, rename = "source")]
537 pub sources: Vec<SkillSourceEntry>,
538}
539
540#[derive(Debug, Clone, Deserialize)]
544#[serde(tag = "type", rename_all = "lowercase")]
545#[allow(dead_code)] pub enum SkillSourceEntry {
547 Fs {
548 path: String,
549 #[serde(default)]
550 namespace: Option<String>,
551 },
552 Git {
553 url: String,
554 #[serde(default)]
555 tag: Option<String>,
556 #[serde(default)]
557 namespace: Option<String>,
558 },
559 Registry {
560 #[serde(default)]
561 url: Option<String>,
562 #[serde(default)]
563 name: Option<String>,
564 },
565}
566
567#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
573pub enum PreflightSeverity {
574 #[default]
575 Error,
576 Warning,
577 Off,
578}
579
580impl PreflightSeverity {
581 pub fn from_opt(raw: Option<&str>) -> Self {
582 match raw.map(|s| s.to_ascii_lowercase()) {
583 Some(v) if v == "warning" || v == "warn" => Self::Warning,
584 Some(v) if v == "off" || v == "allow" || v == "silent" => Self::Off,
585 _ => Self::Error,
586 }
587 }
588}
589
590#[derive(Debug, Default, Clone, Deserialize)]
591pub struct CheckConfig {
592 #[serde(default)]
593 pub strict: bool,
594 #[serde(default)]
595 pub strict_types: bool,
596 #[serde(default)]
597 pub disable_rules: Vec<String>,
598 #[serde(default)]
599 pub host_capabilities: HashMap<String, Vec<String>>,
600 #[serde(default, alias = "host_capabilities_file")]
601 pub host_capabilities_path: Option<String>,
602 #[serde(default)]
603 pub bundle_root: Option<String>,
604 #[serde(default, alias = "preflight-severity")]
607 pub preflight_severity: Option<String>,
608 #[serde(default, alias = "preflight-allow")]
612 pub preflight_allow: Vec<String>,
613}
614
615#[derive(Debug, Default, Clone, Deserialize)]
616pub struct WorkspaceConfig {
617 #[serde(default)]
620 pub pipelines: Vec<String>,
621}
622
623#[derive(Debug, Default, Clone, Deserialize)]
624pub struct PackageRegistryConfig {
625 #[serde(default)]
627 pub url: Option<String>,
628}
629
630#[derive(Debug, Clone, Deserialize)]
631pub struct McpServerConfig {
632 pub name: String,
633 #[serde(default)]
634 pub transport: Option<String>,
635 #[serde(default)]
636 pub command: String,
637 #[serde(default)]
638 pub args: Vec<String>,
639 #[serde(default)]
640 pub env: HashMap<String, String>,
641 #[serde(default)]
642 pub url: String,
643 #[serde(default)]
644 pub auth_token: Option<String>,
645 #[serde(default)]
646 pub client_id: Option<String>,
647 #[serde(default)]
648 pub client_secret: Option<String>,
649 #[serde(default)]
650 pub scopes: Option<String>,
651 #[serde(default)]
652 pub protocol_version: Option<String>,
653 #[serde(default)]
654 pub proxy_server_name: Option<String>,
655 #[serde(default)]
659 pub lazy: bool,
660 #[serde(default)]
664 pub card: Option<String>,
665 #[serde(default, alias = "keep-alive-ms", alias = "keep_alive")]
669 pub keep_alive_ms: Option<u64>,
670}
671
672#[derive(Debug, Clone, Deserialize)]
673#[allow(dead_code)] pub struct PackageInfo {
675 pub name: Option<String>,
676 pub version: Option<String>,
677 #[serde(default)]
678 pub evals: Vec<String>,
679 #[serde(default)]
680 pub description: Option<String>,
681 #[serde(default)]
682 pub license: Option<String>,
683 #[serde(default)]
684 pub repository: Option<String>,
685 #[serde(default, alias = "harn_version", alias = "harn_version_range")]
686 pub harn: Option<String>,
687 #[serde(default)]
688 pub docs_url: Option<String>,
689 #[serde(default)]
690 pub provenance: Option<String>,
691 #[serde(default)]
692 pub permissions: Vec<String>,
693 #[serde(default, alias = "host-requirements")]
694 pub host_requirements: Vec<String>,
695 #[serde(default)]
696 pub tools: Vec<PackageToolExport>,
697 #[serde(default)]
698 pub skills: Vec<PackageSkillExport>,
699}
700
701#[derive(Debug, Clone, Deserialize, PartialEq)]
702pub struct PackageToolExport {
703 pub name: String,
704 pub module: String,
705 #[serde(default = "default_package_tool_symbol")]
706 pub symbol: String,
707 #[serde(default)]
708 pub description: Option<String>,
709 #[serde(default)]
710 pub permissions: Vec<String>,
711 #[serde(default, alias = "host-requirements")]
712 pub host_requirements: Vec<String>,
713 #[serde(default, alias = "input-schema")]
714 pub input_schema: Option<toml::Value>,
715 #[serde(default, alias = "output-schema")]
716 pub output_schema: Option<toml::Value>,
717 #[serde(default)]
718 pub annotations: BTreeMap<String, toml::Value>,
719}
720
721pub(crate) fn default_package_tool_symbol() -> String {
722 "tools".to_string()
723}
724
725#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
726pub struct PackageSkillExport {
727 pub name: String,
728 pub path: String,
729 #[serde(default)]
730 pub description: Option<String>,
731 #[serde(default)]
732 pub permissions: Vec<String>,
733 #[serde(default, alias = "host-requirements")]
734 pub host_requirements: Vec<String>,
735}
736
737#[derive(Debug, Clone, Deserialize)]
738#[serde(untagged)]
739pub enum Dependency {
740 Table(DepTable),
741 Path(String),
742}
743
744#[derive(Debug, Clone, Default, Deserialize)]
745pub struct DepTable {
746 pub git: Option<String>,
747 pub tag: Option<String>,
748 pub rev: Option<String>,
749 pub branch: Option<String>,
750 pub path: Option<String>,
751 pub package: Option<String>,
752 #[serde(default)]
757 pub registry: Option<String>,
758 #[serde(default, alias = "registry-name")]
761 pub registry_name: Option<String>,
762 #[serde(default, alias = "registry-version")]
764 pub registry_version: Option<String>,
765}
766
767impl Dependency {
768 pub(crate) fn git_url(&self) -> Option<&str> {
769 match self {
770 Dependency::Table(t) => t.git.as_deref(),
771 Dependency::Path(_) => None,
772 }
773 }
774
775 pub(crate) fn rev(&self) -> Option<&str> {
776 match self {
777 Dependency::Table(t) => t.rev.as_deref().or(t.tag.as_deref()),
778 Dependency::Path(_) => None,
779 }
780 }
781
782 pub(crate) fn branch(&self) -> Option<&str> {
783 match self {
784 Dependency::Table(t) => t.branch.as_deref(),
785 Dependency::Path(_) => None,
786 }
787 }
788
789 pub(crate) fn local_path(&self) -> Option<&str> {
790 match self {
791 Dependency::Table(t) => t.path.as_deref(),
792 Dependency::Path(p) => Some(p.as_str()),
793 }
794 }
795
796 pub(crate) fn registry_provenance(&self) -> Option<crate::package::RegistryProvenance> {
797 let Dependency::Table(table) = self else {
798 return None;
799 };
800 let source = table.registry.clone()?;
801 let name = table.registry_name.clone()?;
802 let version = table.registry_version.clone()?;
803 Some(crate::package::RegistryProvenance {
804 source,
805 name,
806 version,
807 provenance_url: None,
808 })
809 }
810}
811
812pub(crate) fn validate_package_alias(alias: &str) -> Result<(), PackageError> {
813 let valid = !alias.is_empty()
814 && alias != "."
815 && alias != ".."
816 && alias
817 .bytes()
818 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'));
819 if valid {
820 Ok(())
821 } else {
822 Err(PackageError::Validation(format!(
823 "invalid dependency alias {alias:?}; use ASCII letters, numbers, '.', '_' or '-'"
824 )))
825 }
826}
827
828pub(crate) fn toml_string_literal(value: &str) -> Result<String, PackageError> {
829 use std::fmt::Write as _;
830
831 let mut encoded = String::with_capacity(value.len() + 2);
832 encoded.push('"');
833 for ch in value.chars() {
834 match ch {
835 '\u{08}' => encoded.push_str("\\b"),
836 '\t' => encoded.push_str("\\t"),
837 '\n' => encoded.push_str("\\n"),
838 '\u{0C}' => encoded.push_str("\\f"),
839 '\r' => encoded.push_str("\\r"),
840 '"' => encoded.push_str("\\\""),
841 '\\' => encoded.push_str("\\\\"),
842 ch if ch <= '\u{1F}' || ch == '\u{7F}' => {
843 write!(&mut encoded, "\\u{:04X}", ch as u32).map_err(|error| {
844 PackageError::Manifest(format!("failed to encode TOML string: {error}"))
845 })?;
846 }
847 ch => encoded.push(ch),
848 }
849 }
850 encoded.push('"');
851 Ok(encoded)
852}
853
854#[derive(Debug, Default, Clone)]
855pub struct RuntimeExtensions {
856 pub root_manifest: Option<Manifest>,
857 pub root_manifest_path: Option<PathBuf>,
858 pub root_manifest_dir: Option<PathBuf>,
859 pub llm: Option<harn_vm::llm_config::ProvidersConfig>,
860 pub capabilities: Option<harn_vm::llm::capabilities::CapabilitiesFile>,
861 pub hooks: Vec<ResolvedHookConfig>,
862 pub triggers: Vec<ResolvedTriggerConfig>,
863 pub handoff_routes: Vec<harn_vm::HandoffRouteConfig>,
864 pub provider_connectors: Vec<ResolvedProviderConnectorConfig>,
865}
866
867#[derive(Debug, Clone, Deserialize)]
868pub struct ProviderManifestEntry {
869 pub id: harn_vm::ProviderId,
870 pub connector: ProviderConnectorManifest,
871 #[serde(default)]
872 pub oauth: Option<ProviderOAuthManifest>,
873 #[serde(default)]
874 pub setup: Option<ProviderSetupManifest>,
875 #[serde(default)]
876 pub capabilities: ConnectorCapabilities,
877}
878
879#[derive(Debug, Clone, Deserialize)]
880pub struct ProviderConnectorManifest {
881 #[serde(default)]
882 pub harn: Option<String>,
883 #[serde(default)]
884 pub rust: Option<String>,
885}
886
887#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
888pub struct ProviderOAuthManifest {
889 #[serde(default, alias = "auth_url", alias = "authorization-endpoint")]
890 pub authorization_endpoint: Option<String>,
891 #[serde(default, alias = "token_url", alias = "token-endpoint")]
892 pub token_endpoint: Option<String>,
893 #[serde(default, alias = "registration_url", alias = "registration-endpoint")]
894 pub registration_endpoint: Option<String>,
895 #[serde(default)]
896 pub resource: Option<String>,
897 #[serde(default, alias = "scope")]
898 pub scopes: Option<String>,
899 #[serde(default, alias = "client-id")]
900 pub client_id: Option<String>,
901 #[serde(default, alias = "client-secret")]
902 pub client_secret: Option<String>,
903 #[serde(default, alias = "token_auth_method", alias = "token-auth-method")]
904 pub token_endpoint_auth_method: Option<String>,
905}
906
907#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
908pub struct ProviderSetupManifest {
909 #[serde(default, alias = "auth-type")]
910 pub auth_type: Option<String>,
911 #[serde(default)]
912 pub flow: Option<String>,
913 #[serde(default, alias = "required-scopes", alias = "scopes")]
914 pub required_scopes: Vec<String>,
915 #[serde(default, alias = "required-secrets")]
916 pub required_secrets: Vec<String>,
917 #[serde(default, alias = "setup-command")]
918 pub setup_command: Vec<String>,
919 #[serde(default, alias = "validation-command")]
920 pub validation_command: Vec<String>,
921 #[serde(default, alias = "health-checks")]
922 pub health_checks: Vec<ConnectorHealthCheckManifest>,
923 #[serde(default)]
924 pub recovery: ConnectorRecoveryCopy,
925 #[serde(flatten, default)]
926 pub extra: BTreeMap<String, toml::Value>,
927}
928
929#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
930pub struct ConnectorHealthCheckManifest {
931 pub id: String,
932 pub kind: String,
933 #[serde(default)]
934 pub command: Vec<String>,
935 #[serde(default)]
936 pub secret: Option<String>,
937 #[serde(default)]
938 pub url: Option<String>,
939}
940
941#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
942pub struct ConnectorRecoveryCopy {
943 #[serde(default, alias = "missing-install")]
944 pub missing_install: Option<String>,
945 #[serde(default, alias = "missing-auth")]
946 pub missing_auth: Option<String>,
947 #[serde(default, alias = "expired-credentials")]
948 pub expired_credentials: Option<String>,
949 #[serde(default, alias = "revoked-credentials")]
950 pub revoked_credentials: Option<String>,
951 #[serde(default, alias = "missing-scopes")]
952 pub missing_scopes: Option<String>,
953 #[serde(default, alias = "inaccessible-resource")]
954 pub inaccessible_resource: Option<String>,
955 #[serde(default, alias = "transient-provider-outage")]
956 pub transient_provider_outage: Option<String>,
957}
958
959#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
960pub struct ConnectorCapabilities {
961 pub webhook: bool,
962 pub oauth: bool,
963 pub rate_limit: bool,
964 pub pagination: bool,
965 pub graphql: bool,
966 pub streaming: bool,
967}
968
969impl ConnectorCapabilities {
970 pub const FEATURES: [&'static str; 6] = [
971 "webhook",
972 "oauth",
973 "rate_limit",
974 "pagination",
975 "graphql",
976 "streaming",
977 ];
978
979 fn enable(&mut self, feature: &str) -> Result<(), String> {
980 match normalize_connector_capability(feature).as_str() {
981 "webhook" => self.webhook = true,
982 "oauth" => self.oauth = true,
983 "rate_limit" => self.rate_limit = true,
984 "pagination" => self.pagination = true,
985 "graphql" => self.graphql = true,
986 "streaming" => self.streaming = true,
987 other => {
988 return Err(format!(
989 "unknown connector capability '{feature}' (normalized as '{other}')"
990 ));
991 }
992 }
993 Ok(())
994 }
995}
996
997#[derive(Debug, Default, Deserialize)]
998struct ConnectorCapabilitiesTable {
999 #[serde(default)]
1000 webhook: bool,
1001 #[serde(default)]
1002 oauth: bool,
1003 #[serde(default, alias = "rate-limit")]
1004 rate_limit: bool,
1005 #[serde(default)]
1006 pagination: bool,
1007 #[serde(default)]
1008 graphql: bool,
1009 #[serde(default)]
1010 streaming: bool,
1011}
1012
1013impl From<ConnectorCapabilitiesTable> for ConnectorCapabilities {
1014 fn from(value: ConnectorCapabilitiesTable) -> Self {
1015 Self {
1016 webhook: value.webhook,
1017 oauth: value.oauth,
1018 rate_limit: value.rate_limit,
1019 pagination: value.pagination,
1020 graphql: value.graphql,
1021 streaming: value.streaming,
1022 }
1023 }
1024}
1025
1026impl<'de> Deserialize<'de> for ConnectorCapabilities {
1027 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1028 where
1029 D: serde::Deserializer<'de>,
1030 {
1031 #[derive(Deserialize)]
1032 #[serde(untagged)]
1033 enum RawConnectorCapabilities {
1034 List(Vec<String>),
1035 Table(ConnectorCapabilitiesTable),
1036 }
1037
1038 match RawConnectorCapabilities::deserialize(deserializer)? {
1039 RawConnectorCapabilities::List(features) => {
1040 let mut capabilities = ConnectorCapabilities::default();
1041 for feature in features {
1042 capabilities
1043 .enable(&feature)
1044 .map_err(serde::de::Error::custom)?;
1045 }
1046 Ok(capabilities)
1047 }
1048 RawConnectorCapabilities::Table(table) => Ok(table.into()),
1049 }
1050 }
1051}
1052
1053pub fn normalize_connector_capability(feature: &str) -> String {
1054 feature.trim().to_lowercase().replace('-', "_")
1055}
1056
1057#[derive(Debug, Clone, Default, Deserialize)]
1058pub struct ConnectorContractConfig {
1059 #[serde(default)]
1060 pub version: Option<u32>,
1061 #[serde(default)]
1062 pub fixtures: Vec<ConnectorContractFixture>,
1063}
1064
1065#[derive(Debug, Clone, Deserialize)]
1066pub struct ConnectorContractFixture {
1067 pub provider: harn_vm::ProviderId,
1068 #[serde(default)]
1069 pub name: Option<String>,
1070 #[serde(default)]
1071 pub kind: Option<String>,
1072 #[serde(default)]
1073 pub headers: BTreeMap<String, String>,
1074 #[serde(default)]
1075 pub query: BTreeMap<String, String>,
1076 #[serde(default)]
1077 pub metadata: Option<toml::Value>,
1078 #[serde(default)]
1079 pub body: Option<String>,
1080 #[serde(default)]
1081 pub body_json: Option<toml::Value>,
1082 #[serde(default)]
1083 pub expect_type: Option<String>,
1084 #[serde(default)]
1085 pub expect_kind: Option<String>,
1086 #[serde(default)]
1087 pub expect_dedupe_key: Option<String>,
1088 #[serde(default)]
1089 pub expect_signature_state: Option<String>,
1090 #[serde(default)]
1091 pub expect_payload_contains: Option<toml::Value>,
1092 #[serde(default)]
1093 pub expect_response_status: Option<u16>,
1094 #[serde(default)]
1095 pub expect_response_body: Option<toml::Value>,
1096 #[serde(default)]
1097 pub expect_event_count: Option<usize>,
1098 #[serde(default)]
1099 pub expect_error_contains: Option<String>,
1100}
1101
1102#[derive(Debug, Clone, PartialEq, Eq)]
1103pub enum ResolvedProviderConnectorKind {
1104 Harn { module: String },
1105 RustBuiltin,
1106 Invalid(String),
1107}
1108
1109#[derive(Debug, Clone)]
1110pub struct ResolvedProviderConnectorConfig {
1111 pub id: harn_vm::ProviderId,
1112 pub manifest_dir: PathBuf,
1113 pub connector: ResolvedProviderConnectorKind,
1114 pub oauth: Option<ProviderOAuthManifest>,
1115 pub setup: Option<ProviderSetupManifest>,
1116}
1117
1118#[derive(Debug, Clone)]
1119pub struct ResolvedHookConfig {
1120 pub event: harn_vm::orchestration::HookEvent,
1121 pub pattern: String,
1122 pub handler: String,
1123 pub manifest_dir: PathBuf,
1124 pub package_name: Option<String>,
1125 pub exports: HashMap<String, String>,
1126}
1127
1128#[derive(Debug, Clone)]
1129#[allow(dead_code)] pub struct ResolvedTriggerConfig {
1131 pub id: String,
1132 pub kind: TriggerKind,
1133 pub provider: harn_vm::ProviderId,
1134 pub autonomy_tier: harn_vm::AutonomyTier,
1135 pub match_: TriggerMatchExpr,
1136 pub when: Option<String>,
1137 pub when_budget: Option<TriggerWhenBudgetSpec>,
1138 pub handler: String,
1139 pub dedupe_key: Option<String>,
1140 pub retry: TriggerRetrySpec,
1141 pub dispatch_priority: TriggerDispatchPriority,
1142 pub budget: TriggerBudgetSpec,
1143 pub concurrency: Option<TriggerConcurrencyManifestSpec>,
1144 pub throttle: Option<TriggerThrottleManifestSpec>,
1145 pub rate_limit: Option<TriggerRateLimitManifestSpec>,
1146 pub debounce: Option<TriggerDebounceManifestSpec>,
1147 pub singleton: Option<TriggerSingletonManifestSpec>,
1148 pub batch: Option<TriggerBatchManifestSpec>,
1149 pub window: Option<TriggerStreamWindowManifestSpec>,
1150 pub priority_flow: Option<TriggerPriorityManifestSpec>,
1151 pub secrets: BTreeMap<String, String>,
1152 pub filter: Option<String>,
1153 pub kind_specific: BTreeMap<String, toml::Value>,
1154 pub manifest_dir: PathBuf,
1155 pub manifest_path: PathBuf,
1156 pub package_name: Option<String>,
1157 pub exports: HashMap<String, String>,
1158 pub table_index: usize,
1159 pub shape_error: Option<String>,
1160}
1161
1162#[derive(Debug, Clone)]
1163#[allow(dead_code)] pub struct CollectedManifestTrigger {
1165 pub config: ResolvedTriggerConfig,
1166 pub handler: CollectedTriggerHandler,
1167 pub when: Option<CollectedTriggerPredicate>,
1168 pub flow_control: harn_vm::TriggerFlowControlConfig,
1169}
1170
1171#[derive(Debug, Clone)]
1172#[allow(dead_code)] pub enum CollectedTriggerHandler {
1174 Local {
1175 reference: TriggerFunctionRef,
1176 closure: Rc<harn_vm::VmClosure>,
1177 },
1178 A2a {
1179 target: String,
1180 allow_cleartext: bool,
1181 },
1182 Worker {
1183 queue: String,
1184 },
1185 Persona {
1186 binding: harn_vm::PersonaRuntimeBinding,
1187 },
1188}
1189
1190#[derive(Debug, Clone)]
1191#[allow(dead_code)] pub struct CollectedTriggerPredicate {
1193 pub reference: TriggerFunctionRef,
1194 pub closure: Rc<harn_vm::VmClosure>,
1195}
1196
1197pub(crate) type ManifestModuleCacheKey = (PathBuf, Option<String>, Option<String>);
1198pub(crate) type ManifestModuleExports = BTreeMap<String, Rc<harn_vm::VmClosure>>;
1199
1200static MANIFEST_PROVIDER_SCHEMA_LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
1201
1202pub(crate) async fn lock_manifest_provider_schemas() -> tokio::sync::MutexGuard<'static, ()> {
1203 MANIFEST_PROVIDER_SCHEMA_LOCK
1204 .get_or_init(|| tokio::sync::Mutex::new(()))
1205 .lock()
1206 .await
1207}
1208
1209pub(crate) fn read_manifest_from_path(path: &Path) -> Result<Manifest, PackageError> {
1210 let content = fs::read_to_string(path).map_err(|error| {
1211 if error.kind() == std::io::ErrorKind::NotFound {
1212 PackageError::Manifest(format!(
1213 "No {} found in {}.",
1214 MANIFEST,
1215 path.parent().unwrap_or_else(|| Path::new(".")).display()
1216 ))
1217 } else {
1218 PackageError::Manifest(format!("failed to read {}: {error}", path.display()))
1219 }
1220 })?;
1221 toml::from_str::<Manifest>(&content).map_err(|error| {
1222 PackageError::Manifest(format!("failed to parse {}: {error}", path.display()))
1223 })
1224}
1225
1226pub(crate) fn write_manifest_content(path: &Path, content: &str) -> Result<(), PackageError> {
1227 harn_vm::atomic_io::atomic_write(path, content.as_bytes()).map_err(|error| {
1228 PackageError::Manifest(format!("failed to write {}: {error}", path.display()))
1229 })
1230}
1231
1232pub(crate) fn absolutize_check_config_paths(
1233 mut config: CheckConfig,
1234 manifest_dir: &Path,
1235) -> CheckConfig {
1236 if let Some(path) = config.host_capabilities_path.clone() {
1237 let candidate = PathBuf::from(&path);
1238 if !candidate.is_absolute() {
1239 config.host_capabilities_path =
1240 Some(manifest_dir.join(candidate).display().to_string());
1241 }
1242 }
1243 if let Some(path) = config.bundle_root.clone() {
1244 let candidate = PathBuf::from(&path);
1245 if !candidate.is_absolute() {
1246 config.bundle_root = Some(manifest_dir.join(candidate).display().to_string());
1247 }
1248 }
1249 config
1250}
1251
1252pub(crate) fn find_nearest_manifest(start: &Path) -> Option<(Manifest, PathBuf)> {
1258 const MAX_PARENT_DIRS: usize = 16;
1259 let base = if start.is_absolute() {
1260 start.to_path_buf()
1261 } else {
1262 std::env::current_dir()
1263 .unwrap_or_else(|_| PathBuf::from("."))
1264 .join(start)
1265 };
1266 let mut cursor: Option<PathBuf> = if base.is_dir() {
1267 Some(base)
1268 } else {
1269 base.parent().map(Path::to_path_buf)
1270 };
1271 let mut steps = 0usize;
1272 while let Some(dir) = cursor {
1273 if steps >= MAX_PARENT_DIRS {
1274 break;
1275 }
1276 steps += 1;
1277 let candidate = dir.join(MANIFEST);
1278 if candidate.is_file() {
1279 match read_manifest_from_path(&candidate) {
1280 Ok(manifest) => return Some((manifest, dir)),
1281 Err(error) => {
1282 eprintln!("warning: {error}");
1283 return None;
1284 }
1285 }
1286 }
1287 if dir.join(".git").exists() {
1288 break;
1289 }
1290 cursor = dir.parent().map(Path::to_path_buf);
1291 }
1292 None
1293}
1294
1295pub fn load_check_config(harn_file: Option<&std::path::Path>) -> CheckConfig {
1299 let anchor = harn_file
1300 .map(Path::to_path_buf)
1301 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
1302 if let Some((manifest, dir)) = find_nearest_manifest(&anchor) {
1303 return absolutize_check_config_paths(manifest.check, &dir);
1304 }
1305 CheckConfig::default()
1306}
1307
1308pub fn load_workspace_config(anchor: Option<&Path>) -> Option<(WorkspaceConfig, PathBuf)> {
1312 let anchor = anchor
1313 .map(Path::to_path_buf)
1314 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
1315 let (manifest, dir) = find_nearest_manifest(&anchor)?;
1316 Some((manifest.workspace, dir))
1317}
1318
1319pub fn load_package_eval_pack_paths(anchor: Option<&Path>) -> Result<Vec<PathBuf>, PackageError> {
1320 let anchor = anchor
1321 .map(Path::to_path_buf)
1322 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
1323 let Some((manifest, dir)) = find_nearest_manifest(&anchor) else {
1324 return Err(PackageError::Manifest(
1325 "no harn.toml found for package eval discovery".to_string(),
1326 ));
1327 };
1328
1329 let declared = manifest
1330 .package
1331 .as_ref()
1332 .map(|package| package.evals.clone())
1333 .unwrap_or_default();
1334 let mut paths = if declared.is_empty() {
1335 let default_pack = dir.join("harn.eval.toml");
1336 if default_pack.is_file() {
1337 vec![default_pack]
1338 } else {
1339 Vec::new()
1340 }
1341 } else {
1342 declared
1343 .iter()
1344 .map(|entry| {
1345 let path = PathBuf::from(entry);
1346 if path.is_absolute() {
1347 path
1348 } else {
1349 dir.join(path)
1350 }
1351 })
1352 .collect()
1353 };
1354 paths.sort();
1355 if paths.is_empty() {
1356 return Err(PackageError::Manifest(
1357 "package declares no eval packs; add [package].evals or harn.eval.toml".to_string(),
1358 ));
1359 }
1360 for path in &paths {
1361 if !path.is_file() {
1362 return Err(PackageError::Manifest(format!(
1363 "eval pack does not exist: {}",
1364 path.display()
1365 )));
1366 }
1367 }
1368 Ok(paths)
1369}
1370
1371#[derive(Debug, Clone)]
1372pub(crate) struct ManifestContext {
1373 pub(crate) manifest: Manifest,
1374 pub(crate) dir: PathBuf,
1375}
1376
1377impl ManifestContext {
1378 pub(crate) fn manifest_path(&self) -> PathBuf {
1379 self.dir.join(MANIFEST)
1380 }
1381
1382 pub(crate) fn lock_path(&self) -> PathBuf {
1383 self.dir.join(LOCK_FILE)
1384 }
1385
1386 pub(crate) fn packages_dir(&self) -> PathBuf {
1387 self.dir.join(PKG_DIR)
1388 }
1389}
1390
1391#[derive(Debug, Clone)]
1392pub(crate) struct PackageWorkspace {
1393 manifest_dir: PathBuf,
1394 cache_dir: Option<PathBuf>,
1395 registry_source: Option<String>,
1396 read_process_env: bool,
1397}
1398
1399impl PackageWorkspace {
1400 pub(crate) fn from_current_dir() -> Result<Self, PackageError> {
1401 let manifest_dir = std::env::current_dir()
1402 .map_err(|error| PackageError::Manifest(format!("failed to read cwd: {error}")))?;
1403 Ok(Self {
1404 manifest_dir,
1405 cache_dir: None,
1406 registry_source: None,
1407 read_process_env: true,
1408 })
1409 }
1410
1411 #[cfg(test)]
1412 pub(crate) fn for_test(
1413 manifest_dir: impl Into<PathBuf>,
1414 cache_dir: impl Into<PathBuf>,
1415 ) -> Self {
1416 Self {
1417 manifest_dir: manifest_dir.into(),
1418 cache_dir: Some(cache_dir.into()),
1419 registry_source: None,
1420 read_process_env: false,
1421 }
1422 }
1423
1424 #[cfg(test)]
1425 pub(crate) fn with_registry_source(mut self, source: impl Into<String>) -> Self {
1426 self.registry_source = Some(source.into());
1427 self
1428 }
1429
1430 pub(crate) fn manifest_dir(&self) -> &Path {
1431 &self.manifest_dir
1432 }
1433
1434 pub(crate) fn load_manifest_context(&self) -> Result<ManifestContext, PackageError> {
1435 let manifest_path = self.manifest_dir.join(MANIFEST);
1436 let manifest = read_manifest_from_path(&manifest_path)?;
1437 Ok(ManifestContext {
1438 manifest,
1439 dir: self.manifest_dir.clone(),
1440 })
1441 }
1442
1443 pub(crate) fn cache_root(&self) -> Result<PathBuf, PackageError> {
1444 if let Some(cache_dir) = &self.cache_dir {
1445 return Ok(cache_dir.clone());
1446 }
1447 if self.read_process_env {
1448 if let Ok(value) = std::env::var(HARN_CACHE_DIR_ENV) {
1449 if !value.trim().is_empty() {
1450 return Ok(PathBuf::from(value));
1451 }
1452 }
1453 }
1454
1455 let home = std::env::var_os("HOME")
1456 .map(PathBuf::from)
1457 .ok_or_else(|| "HOME is not set and HARN_CACHE_DIR was not provided".to_string())?;
1458 if cfg!(target_os = "macos") {
1459 return Ok(home.join("Library/Caches/harn"));
1460 }
1461 if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME") {
1462 return Ok(PathBuf::from(xdg).join("harn"));
1463 }
1464 Ok(home.join(".cache/harn"))
1465 }
1466
1467 pub(crate) fn resolve_registry_source(
1468 &self,
1469 explicit: Option<&str>,
1470 ) -> Result<String, PackageError> {
1471 if let Some(explicit) = explicit.map(str::trim).filter(|value| !value.is_empty()) {
1472 return Ok(explicit.to_string());
1473 }
1474 if let Some(source) = self
1475 .registry_source
1476 .as_deref()
1477 .map(str::trim)
1478 .filter(|value| !value.is_empty())
1479 {
1480 if Url::parse(source).is_ok() || PathBuf::from(source).is_absolute() {
1481 return Ok(source.to_string());
1482 }
1483 return Ok(self.manifest_dir.join(source).display().to_string());
1484 }
1485 if self.read_process_env {
1486 if let Ok(value) = std::env::var(HARN_PACKAGE_REGISTRY_ENV) {
1487 let value = value.trim();
1488 if !value.is_empty() {
1489 return Ok(value.to_string());
1490 }
1491 }
1492 }
1493
1494 if let Some((manifest, manifest_dir)) = find_nearest_manifest(&self.manifest_dir) {
1495 if let Some(raw) = manifest
1496 .registry
1497 .url
1498 .as_deref()
1499 .map(str::trim)
1500 .filter(|value| !value.is_empty())
1501 {
1502 if Url::parse(raw).is_ok() || PathBuf::from(raw).is_absolute() {
1503 return Ok(raw.to_string());
1504 }
1505 return Ok(manifest_dir.join(raw).display().to_string());
1506 }
1507 }
1508
1509 Ok(DEFAULT_PACKAGE_REGISTRY_URL.to_string())
1510 }
1511}
1512
1513#[cfg(test)]
1514mod tests {
1515 use super::*;
1516
1517 #[test]
1518 fn package_eval_pack_paths_use_package_manifest_entries() {
1519 let tmp = tempfile::tempdir().unwrap();
1520 let root = tmp.path();
1521 fs::create_dir_all(root.join(".git")).unwrap();
1522 fs::create_dir_all(root.join("evals")).unwrap();
1523 fs::write(
1524 root.join(MANIFEST),
1525 r#"
1526 [package]
1527 name = "demo"
1528 version = "0.1.0"
1529 evals = ["evals/webhook.toml"]
1530 "#,
1531 )
1532 .unwrap();
1533 fs::write(
1534 root.join("evals/webhook.toml"),
1535 "version = 1\n[[cases]]\nrun = \"run.json\"\n",
1536 )
1537 .unwrap();
1538
1539 let paths = load_package_eval_pack_paths(Some(&root.join("src/main.harn"))).unwrap();
1540
1541 assert_eq!(paths, vec![root.join("evals/webhook.toml")]);
1542 }
1543 #[test]
1544 fn preflight_severity_parsing_accepts_synonyms() {
1545 assert_eq!(
1546 PreflightSeverity::from_opt(Some("warning")),
1547 PreflightSeverity::Warning
1548 );
1549 assert_eq!(
1550 PreflightSeverity::from_opt(Some("WARN")),
1551 PreflightSeverity::Warning
1552 );
1553 assert_eq!(
1554 PreflightSeverity::from_opt(Some("off")),
1555 PreflightSeverity::Off
1556 );
1557 assert_eq!(
1558 PreflightSeverity::from_opt(Some("allow")),
1559 PreflightSeverity::Off
1560 );
1561 assert_eq!(
1562 PreflightSeverity::from_opt(Some("error")),
1563 PreflightSeverity::Error
1564 );
1565 assert_eq!(PreflightSeverity::from_opt(None), PreflightSeverity::Error);
1566 assert_eq!(
1568 PreflightSeverity::from_opt(Some("bogus")),
1569 PreflightSeverity::Error
1570 );
1571 }
1572
1573 #[test]
1574 fn load_check_config_walks_up_from_nested_file() {
1575 let tmp = tempfile::tempdir().unwrap();
1576 let root = tmp.path();
1577 std::fs::create_dir_all(root.join(".git")).unwrap();
1579 fs::write(
1580 root.join(MANIFEST),
1581 r#"
1582 [check]
1583 preflight_severity = "warning"
1584 preflight_allow = ["custom.scan", "runtime.*"]
1585 host_capabilities_path = "./schemas/host-caps.json"
1586
1587 [workspace]
1588 pipelines = ["pipelines", "scripts"]
1589 "#,
1590 )
1591 .unwrap();
1592 let nested = root.join("src").join("deep");
1593 std::fs::create_dir_all(&nested).unwrap();
1594 let harn_file = nested.join("pipeline.harn");
1595 fs::write(&harn_file, "pipeline main() {}\n").unwrap();
1596
1597 let cfg = load_check_config(Some(&harn_file));
1598 assert_eq!(cfg.preflight_severity.as_deref(), Some("warning"));
1599 assert_eq!(cfg.preflight_allow, vec!["custom.scan", "runtime.*"]);
1600 let caps_path = cfg.host_capabilities_path.expect("host caps path");
1601 assert!(
1602 caps_path.ends_with("schemas/host-caps.json")
1603 || caps_path.ends_with("schemas\\host-caps.json"),
1604 "unexpected absolutized path: {caps_path}"
1605 );
1606
1607 let (workspace, manifest_dir) =
1608 load_workspace_config(Some(&harn_file)).expect("workspace manifest");
1609 assert_eq!(workspace.pipelines, vec!["pipelines", "scripts"]);
1610 assert_eq!(manifest_dir, root);
1612 }
1613
1614 #[test]
1615 fn orchestrator_drain_config_parses_defaults_and_overrides() {
1616 let default_manifest: Manifest = toml::from_str(
1617 r#"
1618 [package]
1619 name = "fixture"
1620 "#,
1621 )
1622 .unwrap();
1623 assert_eq!(default_manifest.orchestrator.drain.max_items, 1024);
1624 assert_eq!(default_manifest.orchestrator.drain.deadline_seconds, 30);
1625 assert_eq!(default_manifest.orchestrator.pumps.max_outstanding, 64);
1626
1627 let configured: Manifest = toml::from_str(
1628 r#"
1629 [package]
1630 name = "fixture"
1631
1632 [orchestrator]
1633 drain.max_items = 77
1634 drain.deadline_seconds = 12
1635 pumps.max_outstanding = 3
1636 "#,
1637 )
1638 .unwrap();
1639 assert_eq!(configured.orchestrator.drain.max_items, 77);
1640 assert_eq!(configured.orchestrator.drain.deadline_seconds, 12);
1641 assert_eq!(configured.orchestrator.pumps.max_outstanding, 3);
1642 }
1643
1644 #[test]
1645 fn load_check_config_stops_at_git_boundary() {
1646 let tmp = tempfile::tempdir().unwrap();
1647 fs::write(
1649 tmp.path().join(MANIFEST),
1650 "[check]\npreflight_severity = \"off\"\n",
1651 )
1652 .unwrap();
1653 let project = tmp.path().join("project");
1654 std::fs::create_dir_all(project.join(".git")).unwrap();
1655 let inner = project.join("src");
1656 std::fs::create_dir_all(&inner).unwrap();
1657 let harn_file = inner.join("main.harn");
1658 fs::write(&harn_file, "pipeline main() {}\n").unwrap();
1659 let cfg = load_check_config(Some(&harn_file));
1660 assert!(
1661 cfg.preflight_severity.is_none(),
1662 "must not inherit harn.toml from outside the .git boundary"
1663 );
1664 }
1665}