1use super::errors::PackageError;
2use super::*;
3pub use harn_modules::personas::{
4 PersonaAutonomyTier, PersonaManifestEntry, PersonaStageDecl, PersonaStageExit,
5 PersonaValidationError, ResolvedPersonaManifest,
6};
7
8#[derive(Debug, Clone, Deserialize)]
9pub struct Manifest {
10 pub package: Option<PackageInfo>,
11 #[serde(default)]
12 pub dependencies: HashMap<String, Dependency>,
13 #[serde(default)]
14 pub mcp: Vec<McpServerConfig>,
15 #[serde(default)]
16 pub check: CheckConfig,
17 #[serde(default)]
18 pub workspace: WorkspaceConfig,
19 #[serde(default)]
23 pub registry: PackageRegistryConfig,
24 #[serde(default)]
27 pub skills: SkillsConfig,
28 #[serde(default)]
31 pub skill: SkillTables,
32 #[serde(default)]
40 pub capabilities: Option<harn_vm::llm::capabilities::CapabilitiesFile>,
41 #[serde(default)]
45 pub exports: HashMap<String, String>,
46 #[serde(default)]
51 pub llm: harn_vm::llm_config::ProvidersConfig,
52 #[serde(default)]
57 pub hooks: Vec<HookConfig>,
58 #[serde(default)]
63 pub triggers: Vec<TriggerManifestEntry>,
64 #[serde(default)]
68 pub handoff_routes: Vec<harn_vm::HandoffRouteConfig>,
69 #[serde(default)]
73 pub providers: Vec<ProviderManifestEntry>,
74 #[serde(default)]
78 pub personas: Vec<PersonaManifestEntry>,
79 #[serde(default, alias = "connector-contract")]
82 pub connector_contract: ConnectorContractConfig,
83 #[serde(default)]
86 pub orchestrator: OrchestratorConfig,
87 #[serde(default)]
91 pub rules: RulesConfig,
92 #[serde(default)]
102 pub contributes: Vec<ContributionEntry>,
103}
104
105#[derive(Debug, Clone, Deserialize, Serialize)]
120pub struct ContributionEntry {
121 pub kind: String,
126 pub id: String,
128 #[serde(default)]
129 pub title: Option<String>,
130 #[serde(default)]
133 pub when: Option<String>,
134 #[serde(default)]
137 pub scopes: Vec<String>,
138 #[serde(default)]
142 pub platforms: Vec<String>,
143 #[serde(flatten)]
145 pub config: BTreeMap<String, toml::Value>,
146}
147
148impl ContributionEntry {
149 pub fn has_namespaced_kind(&self) -> bool {
153 let mut segments = 0usize;
154 for segment in self.kind.split('.') {
155 if segment.is_empty()
156 || !segment
157 .chars()
158 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
159 || !segment.starts_with(|c: char| c.is_ascii_lowercase())
160 {
161 return false;
162 }
163 segments += 1;
164 }
165 segments >= 2
166 }
167}
168
169#[derive(Debug, Clone, Default, Deserialize)]
181pub struct RulesConfig {
182 #[serde(default, alias = "rule-dirs", alias = "ruleDirs")]
184 pub rule_dirs: Vec<String>,
185 #[serde(default, alias = "util-dirs", alias = "utilDirs")]
187 pub util_dirs: Vec<String>,
188 #[serde(default, alias = "test-configs", alias = "testConfigs")]
190 pub test_configs: Vec<String>,
191 #[serde(
193 default,
194 alias = "native-rule-dirs",
195 alias = "nativeRuleDirs",
196 alias = "native_rule_dirs"
197 )]
198 pub native_rule_dirs: Vec<String>,
199}
200
201#[derive(Debug, Clone, Default, Deserialize)]
202pub struct OrchestratorConfig {
203 #[serde(default, alias = "allowed-origins")]
204 pub allowed_origins: Vec<String>,
205 #[serde(default, alias = "max-body-bytes")]
206 pub max_body_bytes: Option<usize>,
207 #[serde(default)]
208 pub budget: OrchestratorBudgetSpec,
209 #[serde(default)]
210 pub drain: OrchestratorDrainConfig,
211 #[serde(default)]
212 pub pumps: OrchestratorPumpConfig,
213}
214
215#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
216pub struct OrchestratorBudgetSpec {
217 #[serde(default)]
218 pub daily_cost_usd: Option<f64>,
219 #[serde(default)]
220 pub hourly_cost_usd: Option<f64>,
221}
222
223#[derive(Debug, Clone, Deserialize)]
224pub struct OrchestratorDrainConfig {
225 #[serde(default = "default_orchestrator_drain_max_items", alias = "max-items")]
226 pub max_items: usize,
227 #[serde(
228 default = "default_orchestrator_drain_deadline_seconds",
229 alias = "deadline-seconds"
230 )]
231 pub deadline_seconds: u64,
232}
233
234impl Default for OrchestratorDrainConfig {
235 fn default() -> Self {
236 Self {
237 max_items: default_orchestrator_drain_max_items(),
238 deadline_seconds: default_orchestrator_drain_deadline_seconds(),
239 }
240 }
241}
242
243pub(crate) fn default_orchestrator_drain_max_items() -> usize {
244 1024
245}
246
247pub(crate) fn default_orchestrator_drain_deadline_seconds() -> u64 {
248 30
249}
250
251#[derive(Debug, Clone, Deserialize)]
252pub struct OrchestratorPumpConfig {
253 #[serde(
254 default = "default_orchestrator_pump_max_outstanding",
255 alias = "max-outstanding"
256 )]
257 pub max_outstanding: usize,
258}
259
260impl Default for OrchestratorPumpConfig {
261 fn default() -> Self {
262 Self {
263 max_outstanding: default_orchestrator_pump_max_outstanding(),
264 }
265 }
266}
267
268pub(crate) fn default_orchestrator_pump_max_outstanding() -> usize {
269 64
270}
271
272#[derive(Debug, Clone, Deserialize)]
273pub struct HookConfig {
274 pub event: harn_vm::orchestration::HookEvent,
275 #[serde(default = "default_hook_pattern")]
276 pub pattern: String,
277 pub handler: String,
278}
279
280pub(crate) fn default_hook_pattern() -> String {
281 "*".to_string()
282}
283
284#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
285pub struct TriggerManifestEntry {
286 pub id: String,
287 #[serde(default)]
288 pub kind: Option<TriggerKind>,
289 #[serde(default)]
290 pub provider: Option<harn_vm::ProviderId>,
291 #[serde(default, alias = "tier")]
292 pub autonomy_tier: harn_vm::AutonomyTier,
293 #[serde(default, rename = "match")]
294 pub match_: Option<TriggerMatchExpr>,
295 #[serde(default)]
296 pub sources: Vec<TriggerSourceManifestEntry>,
297 #[serde(default)]
298 pub when: Option<String>,
299 #[serde(default)]
300 pub when_budget: Option<TriggerWhenBudgetSpec>,
301 pub handler: String,
302 #[serde(default)]
303 pub dedupe_key: Option<String>,
304 #[serde(default)]
305 pub retry: TriggerRetrySpec,
306 #[serde(default)]
307 pub priority: Option<TriggerPriorityField>,
308 #[serde(default)]
309 pub budget: TriggerBudgetSpec,
310 #[serde(default)]
311 pub concurrency: Option<TriggerConcurrencyManifestSpec>,
312 #[serde(default)]
313 pub throttle: Option<TriggerThrottleManifestSpec>,
314 #[serde(default)]
315 pub rate_limit: Option<TriggerRateLimitManifestSpec>,
316 #[serde(default)]
317 pub debounce: Option<TriggerDebounceManifestSpec>,
318 #[serde(default)]
319 pub singleton: Option<TriggerSingletonManifestSpec>,
320 #[serde(default)]
321 pub batch: Option<TriggerBatchManifestSpec>,
322 #[serde(default)]
323 pub window: Option<TriggerStreamWindowManifestSpec>,
324 #[serde(default, alias = "dlq-alerts")]
325 pub dlq_alerts: Vec<TriggerDlqAlertManifestSpec>,
326 #[serde(default)]
327 pub secrets: BTreeMap<String, String>,
328 #[serde(default)]
329 pub filter: Option<String>,
330 #[serde(flatten, default)]
331 pub kind_specific: BTreeMap<String, toml::Value>,
332}
333
334#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
335pub struct TriggerSourceManifestEntry {
336 #[serde(default)]
337 pub id: Option<String>,
338 pub kind: TriggerKind,
339 pub provider: harn_vm::ProviderId,
340 #[serde(default, rename = "match")]
341 pub match_: Option<TriggerMatchExpr>,
342 #[serde(default)]
343 pub dedupe_key: Option<String>,
344 #[serde(default)]
345 pub retry: Option<TriggerRetrySpec>,
346 #[serde(default)]
347 pub priority: Option<TriggerPriorityField>,
348 #[serde(default)]
349 pub budget: Option<TriggerBudgetSpec>,
350 #[serde(default)]
351 pub concurrency: Option<TriggerConcurrencyManifestSpec>,
352 #[serde(default)]
353 pub throttle: Option<TriggerThrottleManifestSpec>,
354 #[serde(default)]
355 pub rate_limit: Option<TriggerRateLimitManifestSpec>,
356 #[serde(default)]
357 pub debounce: Option<TriggerDebounceManifestSpec>,
358 #[serde(default)]
359 pub singleton: Option<TriggerSingletonManifestSpec>,
360 #[serde(default)]
361 pub batch: Option<TriggerBatchManifestSpec>,
362 #[serde(default)]
363 pub window: Option<TriggerStreamWindowManifestSpec>,
364 #[serde(default)]
365 pub secrets: BTreeMap<String, String>,
366 #[serde(default)]
367 pub filter: Option<String>,
368 #[serde(flatten, default)]
369 pub kind_specific: BTreeMap<String, toml::Value>,
370}
371
372#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
373#[serde(rename_all = "kebab-case")]
374pub enum TriggerKind {
375 Webhook,
376 Cron,
377 Poll,
378 Stream,
379 Predicate,
380 A2aPush,
381}
382
383#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
384pub struct TriggerMatchExpr {
385 #[serde(default)]
386 pub events: Vec<String>,
387 #[serde(flatten, default)]
388 pub extra: BTreeMap<String, toml::Value>,
389}
390
391#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
392pub struct TriggerRetrySpec {
393 #[serde(default)]
394 pub max: u32,
395 #[serde(default)]
396 pub backoff: TriggerRetryBackoff,
397 #[serde(default = "default_trigger_retention_days")]
398 pub retention_days: u32,
399}
400
401#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
402#[serde(rename_all = "kebab-case")]
403pub enum TriggerRetryBackoff {
404 #[default]
405 Immediate,
406 Svix,
407}
408
409pub(crate) fn default_trigger_retention_days() -> u32 {
410 harn_vm::DEFAULT_INBOX_RETENTION_DAYS
411}
412
413impl Default for TriggerRetrySpec {
414 fn default() -> Self {
415 Self {
416 max: 0,
417 backoff: TriggerRetryBackoff::default(),
418 retention_days: default_trigger_retention_days(),
419 }
420 }
421}
422
423#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
424#[serde(rename_all = "lowercase")]
425pub enum TriggerDispatchPriority {
426 High,
427 #[default]
428 Normal,
429 Low,
430}
431
432#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
433#[serde(untagged)]
434pub enum TriggerPriorityField {
435 Dispatch(TriggerDispatchPriority),
436 Flow(TriggerPriorityManifestSpec),
437}
438
439#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
440pub struct TriggerBudgetSpec {
441 #[serde(default)]
442 pub max_cost_usd: Option<f64>,
443 #[serde(default, alias = "tokens_max")]
444 pub max_tokens: Option<u64>,
445 #[serde(default)]
446 pub daily_cost_usd: Option<f64>,
447 #[serde(default)]
448 pub hourly_cost_usd: Option<f64>,
449 #[serde(default)]
450 pub max_autonomous_decisions_per_hour: Option<u64>,
451 #[serde(default)]
452 pub max_autonomous_decisions_per_day: Option<u64>,
453 #[serde(default)]
454 pub max_concurrent: Option<u32>,
455 #[serde(default)]
456 pub on_budget_exhausted: harn_vm::TriggerBudgetExhaustionStrategy,
457}
458
459#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
460pub struct TriggerWhenBudgetSpec {
461 #[serde(default)]
462 pub max_cost_usd: Option<f64>,
463 #[serde(default)]
464 pub tokens_max: Option<u64>,
465 #[serde(default)]
466 pub timeout: Option<String>,
467}
468
469#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
470pub struct TriggerConcurrencyManifestSpec {
471 #[serde(default)]
472 pub key: Option<String>,
473 pub max: u32,
474}
475
476#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
477pub struct TriggerThrottleManifestSpec {
478 #[serde(default)]
479 pub key: Option<String>,
480 pub period: String,
481 pub max: u32,
482}
483
484#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
485pub struct TriggerRateLimitManifestSpec {
486 #[serde(default)]
487 pub key: Option<String>,
488 pub period: String,
489 pub max: u32,
490}
491
492#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
493pub struct TriggerDebounceManifestSpec {
494 pub key: String,
495 pub period: String,
496}
497
498#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
499pub struct TriggerSingletonManifestSpec {
500 #[serde(default)]
501 pub key: Option<String>,
502}
503
504#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
505pub struct TriggerBatchManifestSpec {
506 #[serde(default)]
507 pub key: Option<String>,
508 pub size: u32,
509 pub timeout: String,
510}
511
512#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
513pub struct TriggerPriorityManifestSpec {
514 pub key: String,
515 #[serde(default)]
516 pub order: Vec<String>,
517}
518
519#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
520#[serde(rename_all = "kebab-case")]
521pub enum TriggerStreamWindowMode {
522 Tumbling,
523 Sliding,
524 Session,
525}
526
527#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
528pub struct TriggerStreamWindowManifestSpec {
529 pub mode: TriggerStreamWindowMode,
530 #[serde(default)]
531 pub key: Option<String>,
532 #[serde(default)]
533 pub size: Option<String>,
534 #[serde(default)]
535 pub every: Option<String>,
536 #[serde(default)]
537 pub gap: Option<String>,
538 #[serde(default)]
539 pub max_items: Option<u32>,
540}
541
542#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
543pub struct TriggerDlqAlertManifestSpec {
544 #[serde(default)]
545 pub destinations: Vec<TriggerDlqAlertDestination>,
546 #[serde(default)]
547 pub threshold: TriggerDlqAlertThreshold,
548}
549
550#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
551pub struct TriggerDlqAlertThreshold {
552 #[serde(default, alias = "entries-in-1h")]
553 pub entries_in_1h: Option<u32>,
554 #[serde(default, alias = "percent-of-dispatches")]
555 pub percent_of_dispatches: Option<f64>,
556}
557
558#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
559#[serde(tag = "kind", rename_all = "snake_case")]
560pub enum TriggerDlqAlertDestination {
561 Slack {
562 channel: String,
563 #[serde(default)]
564 webhook_url_env: Option<String>,
565 },
566 Email {
567 address: String,
568 },
569 Webhook {
570 url: String,
571 #[serde(default)]
572 headers: BTreeMap<String, String>,
573 },
574}
575
576impl TriggerDlqAlertDestination {
577 pub fn label(&self) -> String {
578 match self {
579 Self::Slack { channel, .. } => format!("slack:{channel}"),
580 Self::Email { address } => format!("email:{address}"),
581 Self::Webhook { url, .. } => format!("webhook:{url}"),
582 }
583 }
584}
585
586#[derive(Debug, Clone, PartialEq, Eq)]
587pub enum TriggerHandlerUri {
588 Local(TriggerFunctionRef),
589 A2a {
590 target: String,
591 allow_cleartext: bool,
592 },
593 Worker {
594 queue: String,
595 },
596 Persona {
597 name: String,
598 },
599 EvalPack {
600 target: String,
601 },
602}
603
604#[derive(Debug, Clone, PartialEq, Eq)]
605pub struct TriggerFunctionRef {
606 pub raw: String,
607 pub module_name: Option<String>,
608 pub function_name: String,
609}
610
611#[derive(Debug, Default, Clone, Deserialize)]
613#[allow(dead_code)] pub struct SkillsConfig {
615 #[serde(default)]
619 pub paths: Vec<String>,
620 #[serde(default)]
625 pub lookup_order: Vec<String>,
626 #[serde(default)]
628 pub disable: Vec<String>,
629 #[serde(default)]
632 pub signer_registry_url: Option<String>,
633 #[serde(default)]
637 pub defaults: SkillDefaults,
638}
639
640#[derive(Debug, Default, Clone, Deserialize)]
641#[allow(dead_code)] pub struct SkillDefaults {
643 #[serde(default)]
644 pub tool_search: Option<String>,
645 #[serde(default)]
646 pub always_loaded: Vec<String>,
647}
648
649#[derive(Debug, Default, Clone, Deserialize)]
651pub struct SkillTables {
652 #[serde(default, rename = "source")]
653 pub sources: Vec<SkillSourceEntry>,
654}
655
656#[derive(Debug, Clone, Deserialize)]
660#[serde(tag = "type", rename_all = "lowercase")]
661#[allow(dead_code)] pub enum SkillSourceEntry {
663 Fs {
664 path: String,
665 #[serde(default)]
666 namespace: Option<String>,
667 },
668 Git {
669 url: String,
670 #[serde(default)]
671 tag: Option<String>,
672 #[serde(default)]
673 namespace: Option<String>,
674 },
675 Registry {
676 #[serde(default)]
677 url: Option<String>,
678 #[serde(default)]
679 name: Option<String>,
680 },
681}
682
683#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
689pub enum PreflightSeverity {
690 #[default]
691 Error,
692 Warning,
693 Off,
694}
695
696impl PreflightSeverity {
697 pub fn from_opt(raw: Option<&str>) -> Self {
698 match raw.map(|s| s.to_ascii_lowercase()) {
699 Some(v) if v == "warning" || v == "warn" => Self::Warning,
700 Some(v) if v == "off" || v == "allow" || v == "silent" => Self::Off,
701 _ => Self::Error,
702 }
703 }
704}
705
706#[derive(Debug, Default, Clone, Deserialize)]
707pub struct CheckConfig {
708 #[serde(default)]
709 pub strict: bool,
710 #[serde(default)]
711 pub strict_types: bool,
712 #[serde(default)]
713 pub disable_rules: Vec<String>,
714 #[serde(default)]
715 pub host_capabilities: HashMap<String, Vec<String>>,
716 #[serde(default, alias = "host_capabilities_file")]
717 pub host_capabilities_path: Option<String>,
718 #[serde(default)]
719 pub bundle_root: Option<String>,
720 #[serde(default, alias = "preflight-severity")]
723 pub preflight_severity: Option<String>,
724 #[serde(default, alias = "preflight-allow")]
728 pub preflight_allow: Vec<String>,
729}
730
731#[derive(Debug, Default, Clone, Deserialize)]
732pub struct WorkspaceConfig {
733 #[serde(default)]
736 pub pipelines: Vec<String>,
737}
738
739#[derive(Debug, Default, Clone, Deserialize)]
740pub struct PackageRegistryConfig {
741 #[serde(default)]
743 pub url: Option<String>,
744}
745
746#[derive(Debug, Clone, Deserialize)]
747pub struct McpServerConfig {
748 pub name: String,
749 #[serde(default)]
750 pub transport: Option<String>,
751 #[serde(default)]
752 pub command: String,
753 #[serde(default)]
754 pub args: Vec<String>,
755 #[serde(default)]
756 pub env: HashMap<String, String>,
757 #[serde(default)]
758 pub url: String,
759 #[serde(default)]
760 pub auth_token: Option<String>,
761 #[serde(default)]
762 pub token_exchange: Option<harn_vm::mcp_oauth::McpTokenExchangeConfig>,
763 #[serde(default)]
764 pub auth: Option<McpAuthConfig>,
765 #[serde(default)]
766 pub client_id: Option<String>,
767 #[serde(default)]
768 pub client_secret: Option<String>,
769 #[serde(default)]
770 pub scopes: Option<String>,
771 #[serde(default)]
772 pub protocol_version: Option<String>,
773 #[serde(default)]
774 pub protocol_mode: Option<String>,
775 #[serde(default)]
776 pub proxy_server_name: Option<String>,
777 #[serde(default)]
781 pub lazy: bool,
782 #[serde(default)]
786 pub card: Option<String>,
787 #[serde(default, alias = "keep-alive-ms", alias = "keep_alive")]
791 pub keep_alive_ms: Option<u64>,
792}
793
794#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
795pub struct McpAuthConfig {
796 #[serde(default)]
797 pub mode: Option<harn_vm::mcp_auth::OAuthClientAuthMode>,
798 #[serde(default, alias = "client-id")]
799 pub client_id: Option<String>,
800 #[serde(
801 default,
802 alias = "client_secret_id",
803 alias = "client-secret-id",
804 alias = "client_secret_ref",
805 alias = "client-secret-ref"
806 )]
807 pub client_secret_id: Option<String>,
808 #[serde(
809 default,
810 alias = "secret_id",
811 alias = "secret-id",
812 alias = "token-secret-id"
813 )]
814 pub secret_id: Option<String>,
815 #[serde(default, alias = "scope")]
816 pub scopes: Option<String>,
817 #[serde(default, alias = "token_auth_method", alias = "token-auth-method")]
818 pub token_endpoint_auth_method: Option<String>,
819}
820
821#[derive(Debug, Clone, Deserialize)]
822#[allow(dead_code)] pub struct PackageInfo {
824 pub name: Option<String>,
825 pub version: Option<String>,
826 #[serde(default)]
827 pub evals: Vec<String>,
828 #[serde(default)]
829 pub description: Option<String>,
830 #[serde(default)]
831 pub license: Option<String>,
832 #[serde(default)]
833 pub repository: Option<String>,
834 #[serde(default, alias = "harn_version", alias = "harn_version_range")]
835 pub harn: Option<String>,
836 #[serde(default)]
837 pub docs_url: Option<String>,
838 #[serde(default)]
839 pub provenance: Option<String>,
840 #[serde(default)]
842 pub publisher: Option<String>,
843 #[serde(default)]
845 pub contact: Option<String>,
846 #[serde(default)]
849 pub created: Option<String>,
850 #[serde(default)]
851 pub permissions: Vec<String>,
852 #[serde(default, alias = "host-requirements")]
853 pub host_requirements: Vec<String>,
854 #[serde(default)]
855 pub tools: Vec<PackageToolExport>,
856 #[serde(default)]
857 pub skills: Vec<PackageSkillExport>,
858}
859
860#[derive(Debug, Clone, Deserialize, PartialEq)]
861pub struct PackageToolExport {
862 pub name: String,
863 pub module: String,
864 #[serde(default = "default_package_tool_symbol")]
865 pub symbol: String,
866 #[serde(default)]
867 pub description: Option<String>,
868 #[serde(default)]
869 pub permissions: Vec<String>,
870 #[serde(default, alias = "host-requirements")]
871 pub host_requirements: Vec<String>,
872 #[serde(default, alias = "input-schema")]
873 pub input_schema: Option<toml::Value>,
874 #[serde(default, alias = "output-schema")]
875 pub output_schema: Option<toml::Value>,
876 #[serde(default)]
877 pub annotations: BTreeMap<String, toml::Value>,
878}
879
880pub(crate) fn default_package_tool_symbol() -> String {
881 "tools".to_string()
882}
883
884#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
885pub struct PackageSkillExport {
886 pub name: String,
887 pub path: String,
888 #[serde(default)]
889 pub description: Option<String>,
890 #[serde(default)]
891 pub permissions: Vec<String>,
892 #[serde(default, alias = "host-requirements")]
893 pub host_requirements: Vec<String>,
894}
895
896#[derive(Debug, Clone, Deserialize)]
897#[serde(untagged)]
898pub enum Dependency {
899 Table(Box<DepTable>),
900 Path(String),
901}
902
903#[derive(Debug, Clone, Default, Deserialize)]
904pub struct DepTable {
905 pub git: Option<String>,
906 #[serde(default, alias = "archive-url", alias = "archive_url")]
907 pub archive: Option<String>,
908 pub tag: Option<String>,
909 pub rev: Option<String>,
910 pub branch: Option<String>,
911 pub version: Option<String>,
912 pub path: Option<String>,
913 pub package: Option<String>,
914 #[serde(default)]
915 pub checksum: Option<String>,
916 #[serde(default)]
921 pub registry: Option<String>,
922 #[serde(default, alias = "registry-name")]
925 pub registry_name: Option<String>,
926 #[serde(default, alias = "registry-version")]
928 pub registry_version: Option<String>,
929}
930
931impl Dependency {
932 pub(crate) fn git_url(&self) -> Option<&str> {
933 match self {
934 Dependency::Table(t) => t.git.as_deref(),
935 Dependency::Path(_) => None,
936 }
937 }
938
939 pub(crate) fn archive_url(&self) -> Option<&str> {
940 match self {
941 Dependency::Table(t) => t.archive.as_deref(),
942 Dependency::Path(_) => None,
943 }
944 }
945
946 pub(crate) fn rev(&self) -> Option<&str> {
947 match self {
948 Dependency::Table(t) => t.rev.as_deref(),
949 Dependency::Path(_) => None,
950 }
951 }
952
953 pub(crate) fn tag(&self) -> Option<&str> {
954 match self {
955 Dependency::Table(t) => t.tag.as_deref(),
956 Dependency::Path(_) => None,
957 }
958 }
959
960 pub(crate) fn branch(&self) -> Option<&str> {
961 match self {
962 Dependency::Table(t) => t.branch.as_deref(),
963 Dependency::Path(_) => None,
964 }
965 }
966
967 pub(crate) fn version(&self) -> Option<&str> {
968 match self {
969 Dependency::Table(t) => t.version.as_deref(),
970 Dependency::Path(_) => None,
971 }
972 }
973
974 pub(crate) fn requires_git(&self) -> bool {
975 self.git_url().is_some()
976 }
977
978 pub(crate) fn local_path(&self) -> Option<&str> {
979 match self {
980 Dependency::Table(t) => t.path.as_deref(),
981 Dependency::Path(p) => Some(p.as_str()),
982 }
983 }
984
985 pub(crate) fn registry_provenance(&self) -> Option<crate::package::RegistryProvenance> {
986 let Dependency::Table(table) = self else {
987 return None;
988 };
989 let source = table.registry.clone()?;
990 let name = table.registry_name.clone()?;
991 let version = table.registry_version.clone()?;
992 Some(crate::package::RegistryProvenance {
993 source,
994 name,
995 version,
996 provenance_url: None,
997 })
998 }
999}
1000
1001pub(crate) fn validate_package_alias(alias: &str) -> Result<(), PackageError> {
1002 if harn_modules::package_snapshot::is_valid_package_name(alias) {
1003 Ok(())
1004 } else {
1005 Err(PackageError::Validation(format!(
1006 "invalid dependency alias {alias:?}; use ASCII letters, numbers, '.', '_' or '-'"
1007 )))
1008 }
1009}
1010
1011pub(crate) fn toml_string_literal(value: &str) -> Result<String, PackageError> {
1012 use std::fmt::Write as _;
1013
1014 let mut encoded = String::with_capacity(value.len() + 2);
1015 encoded.push('"');
1016 for ch in value.chars() {
1017 match ch {
1018 '\u{08}' => encoded.push_str("\\b"),
1019 '\t' => encoded.push_str("\\t"),
1020 '\n' => encoded.push_str("\\n"),
1021 '\u{0C}' => encoded.push_str("\\f"),
1022 '\r' => encoded.push_str("\\r"),
1023 '"' => encoded.push_str("\\\""),
1024 '\\' => encoded.push_str("\\\\"),
1025 ch if ch <= '\u{1F}' || ch == '\u{7F}' => {
1026 write!(&mut encoded, "\\u{:04X}", ch as u32).map_err(|error| {
1027 PackageError::Manifest(format!("failed to encode TOML string: {error}"))
1028 })?;
1029 }
1030 ch => encoded.push(ch),
1031 }
1032 }
1033 encoded.push('"');
1034 Ok(encoded)
1035}
1036#[derive(Debug, Default, Clone)]
1037pub struct RuntimeExtensions {
1038 pub root_manifest: Option<Manifest>,
1039 pub root_manifest_path: Option<PathBuf>,
1040 pub root_manifest_dir: Option<PathBuf>,
1041 pub(crate) runtime_personas: Vec<ResolvedRuntimePersona>,
1042 pub llm: Option<harn_vm::llm_config::ProvidersConfig>,
1043 pub capabilities: Option<harn_vm::llm::capabilities::CapabilitiesFile>,
1044 pub hooks: Vec<ResolvedHookConfig>,
1045 pub triggers: Vec<ResolvedTriggerConfig>,
1046 pub handoff_routes: Vec<harn_vm::HandoffRouteConfig>,
1047 pub provider_connectors: Vec<ResolvedProviderConnectorConfig>,
1048}
1049
1050#[derive(Debug, Clone, Deserialize)]
1051pub struct ProviderManifestEntry {
1052 pub id: harn_vm::ProviderId,
1053 pub connector: ProviderConnectorManifest,
1054 #[serde(default)]
1055 pub oauth: Option<ProviderOAuthManifest>,
1056 #[serde(default)]
1057 pub setup: Option<ProviderSetupManifest>,
1058 #[serde(default)]
1059 pub capabilities: ConnectorCapabilities,
1060}
1061
1062#[derive(Debug, Clone, Deserialize)]
1063pub struct ProviderConnectorManifest {
1064 #[serde(default)]
1065 pub harn: Option<String>,
1066 #[serde(default)]
1067 pub rust: Option<String>,
1068}
1069
1070#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
1071pub struct ProviderOAuthManifest {
1072 #[serde(default, alias = "auth_url", alias = "authorization-endpoint")]
1073 pub authorization_endpoint: Option<String>,
1074 #[serde(default, alias = "token_url", alias = "token-endpoint")]
1075 pub token_endpoint: Option<String>,
1076 #[serde(default, alias = "registration_url", alias = "registration-endpoint")]
1077 pub registration_endpoint: Option<String>,
1078 #[serde(default)]
1079 pub resource: Option<String>,
1080 #[serde(default, alias = "scope")]
1081 pub scopes: Option<String>,
1082 #[serde(default, alias = "client-id")]
1083 pub client_id: Option<String>,
1084 #[serde(default, alias = "client-secret")]
1085 pub client_secret: Option<String>,
1086 #[serde(default, alias = "token_auth_method", alias = "token-auth-method")]
1087 pub token_endpoint_auth_method: Option<String>,
1088}
1089
1090#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1091pub struct ProviderSetupManifest {
1092 #[serde(default, alias = "auth-type")]
1093 pub auth_type: Option<String>,
1094 #[serde(default)]
1095 pub flow: Option<String>,
1096 #[serde(default, alias = "required-scopes", alias = "scopes")]
1097 pub required_scopes: Vec<String>,
1098 #[serde(default, alias = "required-secrets")]
1099 pub required_secrets: Vec<String>,
1100 #[serde(default, alias = "setup-command")]
1101 pub setup_command: Vec<String>,
1102 #[serde(default, alias = "validation-command")]
1103 pub validation_command: Vec<String>,
1104 #[serde(default, alias = "health-checks")]
1105 pub health_checks: Vec<ConnectorHealthCheckManifest>,
1106 #[serde(default)]
1107 pub recovery: ConnectorRecoveryCopy,
1108 #[serde(flatten, default)]
1109 pub extra: BTreeMap<String, toml::Value>,
1110}
1111
1112#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1113pub struct ConnectorHealthCheckManifest {
1114 pub id: String,
1115 pub kind: String,
1116 #[serde(default)]
1117 pub command: Vec<String>,
1118 #[serde(default)]
1119 pub secret: Option<String>,
1120 #[serde(default)]
1121 pub url: Option<String>,
1122}
1123
1124#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1125pub struct ConnectorRecoveryCopy {
1126 #[serde(default, alias = "missing-install")]
1127 pub missing_install: Option<String>,
1128 #[serde(default, alias = "missing-auth")]
1129 pub missing_auth: Option<String>,
1130 #[serde(default, alias = "expired-credentials")]
1131 pub expired_credentials: Option<String>,
1132 #[serde(default, alias = "revoked-credentials")]
1133 pub revoked_credentials: Option<String>,
1134 #[serde(default, alias = "missing-scopes")]
1135 pub missing_scopes: Option<String>,
1136 #[serde(default, alias = "inaccessible-resource")]
1137 pub inaccessible_resource: Option<String>,
1138 #[serde(default, alias = "transient-provider-outage")]
1139 pub transient_provider_outage: Option<String>,
1140}
1141
1142#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
1143pub struct ConnectorCapabilities {
1144 pub webhook: bool,
1145 pub oauth: bool,
1146 pub rate_limit: bool,
1147 pub pagination: bool,
1148 pub graphql: bool,
1149 pub streaming: bool,
1150}
1151
1152impl ConnectorCapabilities {
1153 pub const FEATURES: [&'static str; 6] = [
1154 "webhook",
1155 "oauth",
1156 "rate_limit",
1157 "pagination",
1158 "graphql",
1159 "streaming",
1160 ];
1161
1162 fn enable(&mut self, feature: &str) -> Result<(), String> {
1163 match normalize_connector_capability(feature).as_str() {
1164 "webhook" => self.webhook = true,
1165 "oauth" => self.oauth = true,
1166 "rate_limit" => self.rate_limit = true,
1167 "pagination" => self.pagination = true,
1168 "graphql" => self.graphql = true,
1169 "streaming" => self.streaming = true,
1170 other => {
1171 return Err(format!(
1172 "unknown connector capability '{feature}' (normalized as '{other}')"
1173 ));
1174 }
1175 }
1176 Ok(())
1177 }
1178}
1179
1180#[derive(Debug, Default, Deserialize)]
1181struct ConnectorCapabilitiesTable {
1182 #[serde(default)]
1183 webhook: bool,
1184 #[serde(default)]
1185 oauth: bool,
1186 #[serde(default, alias = "rate-limit")]
1187 rate_limit: bool,
1188 #[serde(default)]
1189 pagination: bool,
1190 #[serde(default)]
1191 graphql: bool,
1192 #[serde(default)]
1193 streaming: bool,
1194}
1195
1196impl From<ConnectorCapabilitiesTable> for ConnectorCapabilities {
1197 fn from(value: ConnectorCapabilitiesTable) -> Self {
1198 Self {
1199 webhook: value.webhook,
1200 oauth: value.oauth,
1201 rate_limit: value.rate_limit,
1202 pagination: value.pagination,
1203 graphql: value.graphql,
1204 streaming: value.streaming,
1205 }
1206 }
1207}
1208
1209impl<'de> Deserialize<'de> for ConnectorCapabilities {
1210 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1211 where
1212 D: serde::Deserializer<'de>,
1213 {
1214 #[derive(Deserialize)]
1215 #[serde(untagged)]
1216 enum RawConnectorCapabilities {
1217 List(Vec<String>),
1218 Table(ConnectorCapabilitiesTable),
1219 }
1220
1221 match RawConnectorCapabilities::deserialize(deserializer)? {
1222 RawConnectorCapabilities::List(features) => {
1223 let mut capabilities = ConnectorCapabilities::default();
1224 for feature in features {
1225 capabilities
1226 .enable(&feature)
1227 .map_err(serde::de::Error::custom)?;
1228 }
1229 Ok(capabilities)
1230 }
1231 RawConnectorCapabilities::Table(table) => Ok(table.into()),
1232 }
1233 }
1234}
1235
1236pub fn normalize_connector_capability(feature: &str) -> String {
1237 feature.trim().to_lowercase().replace('-', "_")
1238}
1239
1240#[derive(Debug, Clone, Default, Deserialize)]
1241pub struct ConnectorContractConfig {
1242 #[serde(default)]
1243 pub version: Option<u32>,
1244 #[serde(default)]
1245 pub fixtures: Vec<ConnectorContractFixture>,
1246}
1247
1248#[derive(Debug, Clone, Deserialize)]
1249pub struct ConnectorContractFixture {
1250 pub provider: harn_vm::ProviderId,
1251 #[serde(default)]
1252 pub name: Option<String>,
1253 #[serde(default)]
1254 pub kind: Option<String>,
1255 #[serde(default)]
1256 pub headers: BTreeMap<String, String>,
1257 #[serde(default)]
1258 pub query: BTreeMap<String, String>,
1259 #[serde(default)]
1260 pub metadata: Option<toml::Value>,
1261 #[serde(default)]
1262 pub body: Option<String>,
1263 #[serde(default)]
1264 pub body_json: Option<toml::Value>,
1265 #[serde(default)]
1266 pub expect_type: Option<String>,
1267 #[serde(default)]
1268 pub expect_kind: Option<String>,
1269 #[serde(default)]
1270 pub expect_dedupe_key: Option<String>,
1271 #[serde(default)]
1272 pub expect_signature_state: Option<String>,
1273 #[serde(default)]
1274 pub expect_payload_contains: Option<toml::Value>,
1275 #[serde(default)]
1276 pub expect_response_status: Option<u16>,
1277 #[serde(default)]
1278 pub expect_response_body: Option<toml::Value>,
1279 #[serde(default)]
1280 pub expect_event_count: Option<usize>,
1281 #[serde(default)]
1282 pub expect_error_contains: Option<String>,
1283}
1284
1285#[derive(Debug, Clone, PartialEq, Eq)]
1286pub enum ResolvedProviderConnectorKind {
1287 Harn { module: String },
1288 RustBuiltin,
1289 Invalid(String),
1290}
1291
1292#[derive(Debug, Clone)]
1293pub struct ResolvedProviderConnectorConfig {
1294 pub id: harn_vm::ProviderId,
1295 pub manifest_dir: PathBuf,
1296 pub connector: ResolvedProviderConnectorKind,
1297 pub oauth: Option<ProviderOAuthManifest>,
1298 pub setup: Option<ProviderSetupManifest>,
1299}
1300
1301#[derive(Debug, Clone)]
1302pub struct ResolvedHookConfig {
1303 pub event: harn_vm::orchestration::HookEvent,
1304 pub pattern: String,
1305 pub handler: String,
1306 pub manifest_dir: PathBuf,
1307 pub package_name: Option<String>,
1308 pub exports: HashMap<String, String>,
1309}
1310
1311#[derive(Debug, Clone)]
1312#[allow(dead_code)] pub struct ResolvedTriggerConfig {
1314 pub id: String,
1315 pub kind: TriggerKind,
1316 pub provider: harn_vm::ProviderId,
1317 pub autonomy_tier: harn_vm::AutonomyTier,
1318 pub match_: TriggerMatchExpr,
1319 pub when: Option<String>,
1320 pub when_budget: Option<TriggerWhenBudgetSpec>,
1321 pub handler: String,
1322 pub dedupe_key: Option<String>,
1323 pub retry: TriggerRetrySpec,
1324 pub dispatch_priority: TriggerDispatchPriority,
1325 pub budget: TriggerBudgetSpec,
1326 pub concurrency: Option<TriggerConcurrencyManifestSpec>,
1327 pub throttle: Option<TriggerThrottleManifestSpec>,
1328 pub rate_limit: Option<TriggerRateLimitManifestSpec>,
1329 pub debounce: Option<TriggerDebounceManifestSpec>,
1330 pub singleton: Option<TriggerSingletonManifestSpec>,
1331 pub batch: Option<TriggerBatchManifestSpec>,
1332 pub window: Option<TriggerStreamWindowManifestSpec>,
1333 pub priority_flow: Option<TriggerPriorityManifestSpec>,
1334 pub secrets: BTreeMap<String, String>,
1335 pub filter: Option<String>,
1336 pub kind_specific: BTreeMap<String, toml::Value>,
1337 pub manifest_dir: PathBuf,
1338 pub manifest_path: PathBuf,
1339 pub package_name: Option<String>,
1340 pub exports: HashMap<String, String>,
1341 pub table_index: usize,
1342 pub shape_error: Option<String>,
1343}
1344
1345#[derive(Debug, Clone)]
1346#[allow(dead_code)] pub struct CollectedManifestTrigger {
1348 pub config: ResolvedTriggerConfig,
1349 pub handler: CollectedTriggerHandler,
1350 pub when: Option<CollectedTriggerPredicate>,
1351 pub flow_control: harn_vm::TriggerFlowControlConfig,
1352}
1353
1354#[derive(Debug, Clone)]
1355#[allow(dead_code)] pub enum CollectedTriggerHandler {
1357 Local {
1358 reference: TriggerFunctionRef,
1359 callable: harn_vm::VmCallable,
1360 },
1361 A2a {
1362 target: String,
1363 allow_cleartext: bool,
1364 },
1365 Worker {
1366 queue: String,
1367 },
1368 Persona {
1369 binding: harn_vm::PersonaRuntimeBinding,
1370 callable: harn_vm::VmCallable,
1371 },
1372 EvalPack {
1373 target: String,
1374 manifest: Box<harn_vm::orchestration::EvalPackManifest>,
1375 ledger_options: Option<serde_json::Value>,
1376 },
1377}
1378#[derive(Debug, Clone)]
1379#[allow(dead_code)] pub struct CollectedTriggerPredicate {
1381 pub reference: TriggerFunctionRef,
1382 pub callable: harn_vm::VmCallable,
1383}
1384
1385pub(crate) type ManifestModuleCacheKey = (PathBuf, Option<String>, Option<String>);
1386pub(crate) type ManifestModuleExports = BTreeMap<String, Arc<harn_vm::VmClosure>>;
1387
1388static MANIFEST_PROVIDER_SCHEMA_LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
1389
1390pub(crate) async fn lock_manifest_provider_schemas() -> tokio::sync::MutexGuard<'static, ()> {
1391 MANIFEST_PROVIDER_SCHEMA_LOCK
1392 .get_or_init(|| tokio::sync::Mutex::new(()))
1393 .lock()
1394 .await
1395}
1396
1397fn llm_manifest_diagnostics(content: &str) -> Vec<harn_vm::llm_config::ProviderConfigDiagnostic> {
1398 let Ok(value) = toml::from_str::<toml::Value>(content) else {
1399 return Vec::new();
1400 };
1401 let Some(llm) = value.get("llm") else {
1402 return Vec::new();
1403 };
1404 let Ok(llm_src) = toml::to_string(llm) else {
1405 return Vec::new();
1406 };
1407 let Ok(parsed) = harn_vm::llm_config::parse_config_toml_with_diagnostics(&llm_src) else {
1408 return Vec::new();
1409 };
1410 parsed
1411 .diagnostics
1412 .into_iter()
1413 .map(|mut diagnostic| {
1414 if !diagnostic.path.is_empty() {
1415 diagnostic.path = format!("llm.{}", diagnostic.path);
1416 }
1417 diagnostic
1418 })
1419 .collect()
1420}
1421
1422pub(crate) fn read_manifest_from_path(path: &Path) -> Result<Manifest, PackageError> {
1423 let content = fs::read_to_string(path).map_err(|error| {
1424 if error.kind() == std::io::ErrorKind::NotFound {
1425 PackageError::Manifest(format!(
1426 "No {} found in {}.",
1427 MANIFEST,
1428 path.parent().unwrap_or_else(|| Path::new(".")).display()
1429 ))
1430 } else {
1431 PackageError::Manifest(format!("failed to read {}: {error}", path.display()))
1432 }
1433 })?;
1434 let manifest = toml::from_str::<Manifest>(&content).map_err(|error| {
1435 PackageError::Manifest(format!("failed to parse {}: {error}", path.display()))
1436 })?;
1437 for diagnostic in llm_manifest_diagnostics(&content) {
1438 eprintln!("[llm_config] warning in {}: {diagnostic}", path.display());
1439 }
1440 Ok(manifest)
1441}
1442
1443pub(crate) fn write_manifest_content(path: &Path, content: &str) -> Result<(), PackageError> {
1444 harn_vm::atomic_io::atomic_write(path, content.as_bytes()).map_err(|error| {
1445 PackageError::Manifest(format!("failed to write {}: {error}", path.display()))
1446 })
1447}
1448
1449pub(crate) fn absolutize_check_config_paths(
1450 mut config: CheckConfig,
1451 manifest_dir: &Path,
1452) -> CheckConfig {
1453 if let Some(path) = config.host_capabilities_path.clone() {
1454 let candidate = PathBuf::from(&path);
1455 if !candidate.is_absolute() {
1456 config.host_capabilities_path =
1457 Some(manifest_dir.join(candidate).display().to_string());
1458 }
1459 }
1460 if let Some(path) = config.bundle_root.clone() {
1461 let candidate = PathBuf::from(&path);
1462 if !candidate.is_absolute() {
1463 config.bundle_root = Some(manifest_dir.join(candidate).display().to_string());
1464 }
1465 }
1466 config
1467}
1468
1469pub(crate) fn find_nearest_manifest(start: &Path) -> Option<(Manifest, PathBuf)> {
1475 const MAX_PARENT_DIRS: usize = 16;
1476 let base = if start.is_absolute() {
1477 start.to_path_buf()
1478 } else {
1479 std::env::current_dir()
1480 .unwrap_or_else(|_| PathBuf::from("."))
1481 .join(start)
1482 };
1483 let mut cursor: Option<PathBuf> = if base.is_dir() {
1484 Some(base)
1485 } else {
1486 base.parent().map(Path::to_path_buf)
1487 };
1488 let mut steps = 0usize;
1489 while let Some(dir) = cursor {
1490 if steps >= MAX_PARENT_DIRS {
1491 break;
1492 }
1493 steps += 1;
1494 let candidate = dir.join(MANIFEST);
1495 if candidate.is_file() {
1496 match read_manifest_from_path(&candidate) {
1497 Ok(manifest) => return Some((manifest, dir)),
1498 Err(error) => {
1499 eprintln!("warning: {error}");
1500 return None;
1501 }
1502 }
1503 }
1504 if dir.join(".git").exists() {
1505 break;
1506 }
1507 cursor = dir.parent().map(Path::to_path_buf);
1508 }
1509 None
1510}
1511
1512pub fn load_check_config(harn_file: Option<&std::path::Path>) -> CheckConfig {
1516 let anchor = harn_file
1517 .map(Path::to_path_buf)
1518 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
1519 if let Some((manifest, dir)) = find_nearest_manifest(&anchor) {
1520 return absolutize_check_config_paths(manifest.check, &dir);
1521 }
1522 CheckConfig::default()
1523}
1524
1525pub fn load_workspace_config(anchor: Option<&Path>) -> Option<(WorkspaceConfig, PathBuf)> {
1529 let anchor = anchor
1530 .map(Path::to_path_buf)
1531 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
1532 let (manifest, dir) = find_nearest_manifest(&anchor)?;
1533 Some((manifest.workspace, dir))
1534}
1535
1536pub fn load_package_eval_pack_paths(anchor: Option<&Path>) -> Result<Vec<PathBuf>, PackageError> {
1537 let anchor = anchor
1538 .map(Path::to_path_buf)
1539 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
1540 let Some((manifest, dir)) = find_nearest_manifest(&anchor) else {
1541 return Err(PackageError::Manifest(
1542 "no harn.toml found for package eval discovery".to_string(),
1543 ));
1544 };
1545
1546 let ctx = ManifestContext { manifest, dir };
1547 let mut paths = eval_pack_paths_from_manifest(&ctx.manifest, &ctx.dir)?;
1548 paths.extend(installed_package_eval_pack_paths(&ctx)?);
1549 paths.sort();
1550 paths.dedup();
1551 if paths.is_empty() {
1552 return Err(PackageError::Manifest(
1553 "package declares no eval packs; add [package].evals, harn.eval.toml, or install a dependency that ships eval packs".to_string(),
1554 ));
1555 }
1556 for path in &paths {
1557 if !path.is_file() {
1558 return Err(PackageError::Manifest(format!(
1559 "eval pack does not exist: {}",
1560 path.display()
1561 )));
1562 }
1563 }
1564 Ok(paths)
1565}
1566
1567fn eval_pack_paths_from_manifest(
1568 manifest: &Manifest,
1569 manifest_dir: &Path,
1570) -> Result<Vec<PathBuf>, PackageError> {
1571 let declared = manifest
1572 .package
1573 .as_ref()
1574 .map(|package| package.evals.clone())
1575 .unwrap_or_default();
1576 let paths = if declared.is_empty() {
1577 let default_pack = manifest_dir.join("harn.eval.toml");
1578 if default_pack.is_file() {
1579 vec![default_pack]
1580 } else {
1581 Vec::new()
1582 }
1583 } else {
1584 declared
1585 .iter()
1586 .map(|entry| {
1587 let path = PathBuf::from(entry);
1588 if path.is_absolute() {
1589 path
1590 } else {
1591 manifest_dir.join(path)
1592 }
1593 })
1594 .collect()
1595 };
1596 for path in &paths {
1597 if !path.is_file() {
1598 return Err(PackageError::Manifest(format!(
1599 "eval pack does not exist: {}",
1600 path.display()
1601 )));
1602 }
1603 }
1604 Ok(paths)
1605}
1606
1607fn installed_package_eval_pack_paths(ctx: &ManifestContext) -> Result<Vec<PathBuf>, PackageError> {
1608 let Some(snapshot) = dependency_package_snapshot(&ctx.manifest, &ctx.dir)? else {
1609 return Ok(Vec::new());
1610 };
1611 let lock = LockFile::load(snapshot.lock_path())?.ok_or_else(|| {
1612 PackageError::Lockfile(format!(
1613 "published package generation is missing {}",
1614 snapshot.lock_path().display()
1615 ))
1616 })?;
1617 let mut paths = Vec::new();
1618 let packages_dir = snapshot.packages_root();
1619 for entry in &lock.packages {
1620 validate_package_alias(&entry.name)?;
1621 let package_dir = packages_dir.join(&entry.name);
1622 if package_dir.is_dir() {
1623 if let Some(manifest) = read_package_manifest_from_dir(&package_dir)? {
1624 paths.extend(eval_pack_paths_from_manifest(&manifest, &package_dir)?);
1625 }
1626 continue;
1627 }
1628
1629 let package_file = packages_dir.join(format!("{}.harn", entry.name));
1630 if package_file.is_file() {
1631 continue;
1632 }
1633
1634 return Err(PackageError::Manifest(format!(
1635 "installed package {} is missing under {}; run `harn install`",
1636 entry.name,
1637 packages_dir.display()
1638 )));
1639 }
1640 Ok(paths)
1641}
1642
1643#[derive(Debug, Clone)]
1644pub(crate) struct ManifestContext {
1645 pub(crate) manifest: Manifest,
1646 pub(crate) dir: PathBuf,
1647}
1648
1649impl ManifestContext {
1650 pub(crate) fn manifest_path(&self) -> PathBuf {
1651 self.dir.join(MANIFEST)
1652 }
1653
1654 pub(crate) fn lock_path(&self) -> PathBuf {
1655 self.dir.join(LOCK_FILE)
1656 }
1657}
1658
1659#[derive(Debug, Clone)]
1660pub(crate) struct PackageWorkspace {
1661 manifest_dir: PathBuf,
1662 cache_dir: Option<PathBuf>,
1663 registry_source: Option<String>,
1664 read_process_env: bool,
1665}
1666
1667impl PackageWorkspace {
1668 pub(crate) fn from_current_dir() -> Result<Self, PackageError> {
1669 let manifest_dir = std::env::current_dir()
1670 .map_err(|error| PackageError::Manifest(format!("failed to read cwd: {error}")))?;
1671 Ok(Self {
1672 manifest_dir,
1673 cache_dir: None,
1674 registry_source: None,
1675 read_process_env: true,
1676 })
1677 }
1678
1679 #[cfg(test)]
1680 pub(crate) fn for_test(
1681 manifest_dir: impl Into<PathBuf>,
1682 cache_dir: impl Into<PathBuf>,
1683 ) -> Self {
1684 Self {
1685 manifest_dir: manifest_dir.into(),
1686 cache_dir: Some(cache_dir.into()),
1687 registry_source: None,
1688 read_process_env: false,
1689 }
1690 }
1691
1692 #[cfg(test)]
1693 pub(crate) fn with_registry_source(mut self, source: impl Into<String>) -> Self {
1694 self.registry_source = Some(source.into());
1695 self
1696 }
1697
1698 pub(crate) fn manifest_dir(&self) -> &Path {
1699 &self.manifest_dir
1700 }
1701
1702 pub(crate) fn load_manifest_context(&self) -> Result<ManifestContext, PackageError> {
1703 let manifest_path = self.manifest_dir.join(MANIFEST);
1704 let manifest = read_manifest_from_path(&manifest_path)?;
1705 Ok(ManifestContext {
1706 manifest,
1707 dir: self.manifest_dir.clone(),
1708 })
1709 }
1710
1711 pub(crate) fn cache_root(&self) -> Result<PathBuf, PackageError> {
1712 if let Some(cache_dir) = &self.cache_dir {
1713 return Ok(cache_dir.clone());
1714 }
1715 if self.read_process_env {
1716 if let Ok(value) = std::env::var(HARN_CACHE_DIR_ENV) {
1717 if !value.trim().is_empty() {
1718 return Ok(PathBuf::from(value));
1719 }
1720 }
1721 }
1722
1723 let home = harn_vm::user_dirs::home_dir().ok_or_else(|| {
1724 "neither HOME nor USERPROFILE is set and HARN_CACHE_DIR was not provided".to_string()
1725 })?;
1726 if cfg!(target_os = "macos") {
1727 return Ok(home.join("Library/Caches/harn"));
1728 }
1729 if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME") {
1730 return Ok(PathBuf::from(xdg).join("harn"));
1731 }
1732 Ok(home.join(".cache/harn"))
1733 }
1734
1735 pub(crate) fn resolve_registry_source(
1736 &self,
1737 explicit: Option<&str>,
1738 ) -> Result<String, PackageError> {
1739 if let Some(explicit) = explicit.map(str::trim).filter(|value| !value.is_empty()) {
1740 return Ok(explicit.to_string());
1741 }
1742 if let Some(source) = self
1743 .registry_source
1744 .as_deref()
1745 .map(str::trim)
1746 .filter(|value| !value.is_empty())
1747 {
1748 if Url::parse(source).is_ok() || PathBuf::from(source).is_absolute() {
1749 return Ok(source.to_string());
1750 }
1751 return Ok(self.manifest_dir.join(source).display().to_string());
1752 }
1753 if self.read_process_env {
1754 if let Ok(value) = std::env::var(HARN_PACKAGE_REGISTRY_ENV) {
1755 let value = value.trim();
1756 if !value.is_empty() {
1757 return Ok(value.to_string());
1758 }
1759 }
1760 }
1761
1762 if let Some((manifest, manifest_dir)) = find_nearest_manifest(&self.manifest_dir) {
1763 if let Some(raw) = manifest
1764 .registry
1765 .url
1766 .as_deref()
1767 .map(str::trim)
1768 .filter(|value| !value.is_empty())
1769 {
1770 if Url::parse(raw).is_ok() || PathBuf::from(raw).is_absolute() {
1771 return Ok(raw.to_string());
1772 }
1773 return Ok(manifest_dir.join(raw).display().to_string());
1774 }
1775 }
1776
1777 Ok(DEFAULT_PACKAGE_REGISTRY_URL.to_string())
1778 }
1779}
1780
1781#[cfg(test)]
1782mod tests {
1783 use super::*;
1784 use crate::package::test_support::{current_packages_dir, TestWorkspace};
1785
1786 #[test]
1787 fn rules_table_parses_camel_and_kebab_dir_keys() {
1788 let camel: Manifest =
1791 toml::from_str("[rules]\nruleDirs = [\"rules\", \"vendor/rules\"]\n").unwrap();
1792 assert_eq!(camel.rules.rule_dirs, vec!["rules", "vendor/rules"]);
1793
1794 let kebab: Manifest = toml::from_str("[rules]\nrule-dirs = [\"r\"]\n").unwrap();
1795 assert_eq!(kebab.rules.rule_dirs, vec!["r"]);
1796
1797 let native: Manifest =
1798 toml::from_str("[rules]\nnativeRuleDirs = [\"native-rules\"]\n").unwrap();
1799 assert_eq!(native.rules.native_rule_dirs, vec!["native-rules"]);
1800
1801 let native_kebab: Manifest =
1802 toml::from_str("[rules]\nnative-rule-dirs = [\"nr\"]\n").unwrap();
1803 assert_eq!(native_kebab.rules.native_rule_dirs, vec!["nr"]);
1804
1805 let none: Manifest = toml::from_str("[package]\nname = \"x\"\n").unwrap();
1807 assert!(none.rules.rule_dirs.is_empty());
1808 assert!(none.rules.native_rule_dirs.is_empty());
1809 }
1810
1811 #[test]
1812 fn llm_manifest_diagnostics_report_unknown_model_fields() {
1813 let diagnostics = llm_manifest_diagnostics(
1814 r#"
1815[llm.models."demo/model"]
1816name = "Demo"
1817provider = "demo"
1818context_window = 4096
1819fast_mode = true
1820"#,
1821 );
1822 let texts: Vec<String> = diagnostics
1823 .into_iter()
1824 .map(|diagnostic| diagnostic.to_string())
1825 .collect();
1826 assert!(
1827 texts.iter().any(
1828 |diagnostic| diagnostic.contains("llm.models.demo/model.fast_mode")
1829 && diagnostic.contains("serving_tiers")
1830 ),
1831 "expected manifest [llm] unknown-field diagnostic, got {texts:?}"
1832 );
1833 }
1834
1835 #[test]
1836 fn package_eval_pack_paths_use_package_manifest_entries() {
1837 let tmp = tempfile::tempdir().unwrap();
1838 let root = tmp.path();
1839 fs::create_dir_all(root.join(".git")).unwrap();
1840 fs::create_dir_all(root.join("evals")).unwrap();
1841 fs::write(
1842 root.join(MANIFEST),
1843 r#"
1844 [package]
1845 name = "demo"
1846 version = "0.1.0"
1847 evals = ["evals/webhook.toml"]
1848 "#,
1849 )
1850 .unwrap();
1851 fs::write(
1852 root.join("evals/webhook.toml"),
1853 "version = 1\n[[cases]]\nrun = \"run.json\"\n",
1854 )
1855 .unwrap();
1856
1857 let paths = load_package_eval_pack_paths(Some(&root.join("src/main.harn"))).unwrap();
1858
1859 assert_eq!(paths, vec![root.join("evals/webhook.toml")]);
1860 assert!(
1861 !root.join(".harn").exists(),
1862 "loading project eval packs without dependencies must remain read-only"
1863 );
1864 }
1865
1866 #[test]
1867 fn package_eval_pack_paths_include_installed_package_evals() {
1868 let dependency_tmp = tempfile::tempdir().unwrap();
1869 let dependency = dependency_tmp.path().join("coding-pack");
1870 fs::create_dir_all(dependency.join("evals")).unwrap();
1871 fs::write(
1872 dependency.join(MANIFEST),
1873 r#"
1874[package]
1875name = "coding-pack"
1876version = "0.1.0"
1877evals = ["evals/coding.toml"]
1878"#,
1879 )
1880 .unwrap();
1881 fs::write(
1882 dependency.join("evals/run.json"),
1883 serde_json::to_string_pretty(&serde_json::json!({
1884 "_type": "workflow_run",
1885 "id": "run_1",
1886 "workflow_id": "workflow_1",
1887 "status": "completed",
1888 "usage": {
1889 "total_duration_ms": 12,
1890 "total_cost": 0.01,
1891 "input_tokens": 3,
1892 "output_tokens": 4,
1893 "call_count": 1,
1894 "models": ["mock"]
1895 },
1896 "replay_fixture": {
1897 "_type": "replay_fixture",
1898 "expected_status": "completed"
1899 }
1900 }))
1901 .unwrap(),
1902 )
1903 .unwrap();
1904 fs::write(
1905 dependency.join("evals/coding.toml"),
1906 r#"
1907version = 1
1908id = "coding-pack"
1909trials = 2
1910
1911[package]
1912name = "coding-pack"
1913version = "0.1.0"
1914source = "path:test"
1915templates = ["templates/rubric.harn.prompt"]
1916
1917[metadata]
1918model = "mock-model"
1919commit = "commit-a"
1920
1921[[cases]]
1922id = "case-a"
1923run = "run.json"
1924rubrics = ["status"]
1925
1926[[rubrics]]
1927id = "status"
1928kind = "deterministic"
1929
1930[[rubrics.assertions]]
1931kind = "run-status"
1932expected = "completed"
1933"#,
1934 )
1935 .unwrap();
1936
1937 let helper = dependency_tmp.path().join("helper-lib");
1938 fs::create_dir_all(&helper).unwrap();
1939 fs::write(
1940 helper.join(MANIFEST),
1941 r#"
1942[package]
1943name = "helper-lib"
1944version = "0.1.0"
1945"#,
1946 )
1947 .unwrap();
1948
1949 let project_tmp = tempfile::tempdir().unwrap();
1950 let root = project_tmp.path();
1951 let workspace = TestWorkspace::new(root);
1952 fs::create_dir_all(root.join(".git")).unwrap();
1953 fs::write(
1954 root.join(MANIFEST),
1955 format!(
1956 r#"
1957[package]
1958name = "workspace"
1959version = "0.1.0"
1960
1961[dependencies]
1962coding-pack = {{ path = {} }}
1963helper-lib = {{ path = {} }}
1964"#,
1965 crate::format::toml_basic_string_literal(&dependency.display().to_string()),
1966 crate::format::toml_basic_string_literal(&helper.display().to_string())
1967 ),
1968 )
1969 .unwrap();
1970
1971 install_packages_in(workspace.env(), false, None, false).unwrap();
1972
1973 let paths = load_package_eval_pack_paths(Some(&root.join("src/main.harn"))).unwrap();
1974 assert_eq!(
1975 paths,
1976 vec![current_packages_dir(root)
1977 .join("coding-pack")
1978 .join("evals/coding.toml")]
1979 );
1980
1981 harn_vm::event_log::reset_active_event_log();
1982 let manifest = harn_vm::orchestration::load_eval_pack_manifest(&paths[0]).unwrap();
1983 let package = manifest.package.as_ref().expect("package descriptor");
1984 assert_eq!(package.name.as_deref(), Some("coding-pack"));
1985 assert_eq!(package.templates, vec!["templates/rubric.harn.prompt"]);
1986
1987 let report = harn_vm::orchestration::evaluate_eval_pack_manifest_resumable(
1988 &manifest,
1989 Some(serde_json::json!({
1990 "namespace": "installed-pack-evals",
1991 "suite": "coding-pack",
1992 "model": "mock-model",
1993 "commit": "commit-a",
1994 "branch": "main"
1995 })),
1996 )
1997 .unwrap();
1998 assert!(report.pass);
1999 assert_eq!(report.trial_count, 2);
2000 assert_eq!(report.run_state.ledger_rows_inserted, 2);
2001 assert_eq!(report.stats_rows.len(), 1);
2002 assert_eq!(report.stats_rows[0].trials, 2);
2003 assert!(!report.stats_rows[0].case_fingerprint.is_empty());
2004 assert_eq!(
2005 report.harness_config_fingerprint,
2006 report.stats_rows[0].harness_config_fingerprint
2007 );
2008
2009 let ledger = harn_vm::orchestration::eval_ledger_read_report(Some(serde_json::json!({
2010 "namespace": "installed-pack-evals",
2011 "suite": "coding-pack",
2012 "model": "mock-model",
2013 "commit": "commit-a"
2014 })))
2015 .unwrap();
2016 assert_eq!(ledger.rows.len(), 2);
2017 harn_vm::event_log::reset_active_event_log();
2018 }
2019 #[test]
2020 fn preflight_severity_parsing_accepts_synonyms() {
2021 assert_eq!(
2022 PreflightSeverity::from_opt(Some("warning")),
2023 PreflightSeverity::Warning
2024 );
2025 assert_eq!(
2026 PreflightSeverity::from_opt(Some("WARN")),
2027 PreflightSeverity::Warning
2028 );
2029 assert_eq!(
2030 PreflightSeverity::from_opt(Some("off")),
2031 PreflightSeverity::Off
2032 );
2033 assert_eq!(
2034 PreflightSeverity::from_opt(Some("allow")),
2035 PreflightSeverity::Off
2036 );
2037 assert_eq!(
2038 PreflightSeverity::from_opt(Some("error")),
2039 PreflightSeverity::Error
2040 );
2041 assert_eq!(PreflightSeverity::from_opt(None), PreflightSeverity::Error);
2042 assert_eq!(
2044 PreflightSeverity::from_opt(Some("bogus")),
2045 PreflightSeverity::Error
2046 );
2047 }
2048
2049 #[test]
2050 fn load_check_config_walks_up_from_nested_file() {
2051 let tmp = tempfile::tempdir().unwrap();
2052 let root = tmp.path();
2053 std::fs::create_dir_all(root.join(".git")).unwrap();
2055 fs::write(
2056 root.join(MANIFEST),
2057 r#"
2058 [check]
2059 preflight_severity = "warning"
2060 preflight_allow = ["custom.scan", "runtime.*"]
2061 host_capabilities_path = "./schemas/host-caps.json"
2062
2063 [workspace]
2064 pipelines = ["pipelines", "scripts"]
2065 "#,
2066 )
2067 .unwrap();
2068 let nested = root.join("src").join("deep");
2069 std::fs::create_dir_all(&nested).unwrap();
2070 let harn_file = nested.join("pipeline.harn");
2071 fs::write(&harn_file, "pipeline main() {}\n").unwrap();
2072
2073 let cfg = load_check_config(Some(&harn_file));
2074 assert_eq!(cfg.preflight_severity.as_deref(), Some("warning"));
2075 assert_eq!(cfg.preflight_allow, vec!["custom.scan", "runtime.*"]);
2076 let caps_path = cfg.host_capabilities_path.expect("host caps path");
2077 assert!(
2078 caps_path.ends_with("schemas/host-caps.json")
2079 || caps_path.ends_with("schemas\\host-caps.json"),
2080 "unexpected absolutized path: {caps_path}"
2081 );
2082
2083 let (workspace, manifest_dir) =
2084 load_workspace_config(Some(&harn_file)).expect("workspace manifest");
2085 assert_eq!(workspace.pipelines, vec!["pipelines", "scripts"]);
2086 assert_eq!(manifest_dir, root);
2088 }
2089
2090 #[test]
2091 fn toml_string_literal_escapes_all_basic_control_characters() {
2092 let literal = toml_string_literal("a\u{08}\t\n\u{0C}\r\"\\\u{07}z").unwrap();
2093 let parsed: toml::Value = toml::from_str(&format!("value = {literal}\n")).unwrap();
2094 assert_eq!(
2095 parsed.get("value").and_then(toml::Value::as_str),
2096 Some("a\u{08}\t\n\u{0C}\r\"\\\u{07}z")
2097 );
2098 }
2099
2100 #[test]
2101 fn orchestrator_drain_config_parses_defaults_and_overrides() {
2102 let default_manifest: Manifest = toml::from_str(
2103 r#"
2104 [package]
2105 name = "fixture"
2106 "#,
2107 )
2108 .unwrap();
2109 assert_eq!(default_manifest.orchestrator.drain.max_items, 1024);
2110 assert_eq!(default_manifest.orchestrator.drain.deadline_seconds, 30);
2111 assert_eq!(default_manifest.orchestrator.pumps.max_outstanding, 64);
2112
2113 let configured: Manifest = toml::from_str(
2114 r#"
2115 [package]
2116 name = "fixture"
2117
2118 [orchestrator]
2119 drain.max_items = 77
2120 drain.deadline_seconds = 12
2121 pumps.max_outstanding = 3
2122 "#,
2123 )
2124 .unwrap();
2125 assert_eq!(configured.orchestrator.drain.max_items, 77);
2126 assert_eq!(configured.orchestrator.drain.deadline_seconds, 12);
2127 assert_eq!(configured.orchestrator.pumps.max_outstanding, 3);
2128 }
2129
2130 #[test]
2131 fn load_check_config_stops_at_git_boundary() {
2132 let tmp = tempfile::tempdir().unwrap();
2133 fs::write(
2135 tmp.path().join(MANIFEST),
2136 "[check]\npreflight_severity = \"off\"\n",
2137 )
2138 .unwrap();
2139 let project = tmp.path().join("project");
2140 std::fs::create_dir_all(project.join(".git")).unwrap();
2141 let inner = project.join("src");
2142 std::fs::create_dir_all(&inner).unwrap();
2143 let harn_file = inner.join("main.harn");
2144 fs::write(&harn_file, "pipeline main() {}\n").unwrap();
2145 let cfg = load_check_config(Some(&harn_file));
2146 assert!(
2147 cfg.preflight_severity.is_none(),
2148 "must not inherit harn.toml from outside the .git boundary"
2149 );
2150 }
2151}