Skip to main content

harn_cli/package/
manifest.rs

1use super::errors::PackageError;
2use super::*;
3mod check_config;
4mod connector_module;
5mod provider_setup;
6pub(crate) use check_config::absolutize_check_config_paths;
7pub use check_config::{load_check_config, CheckConfig, PreflightSeverity};
8pub use connector_module::is_declared_connector_module;
9pub use harn_modules::personas::{
10    PersonaAutonomyTier, PersonaManifestEntry, PersonaStageDecl, PersonaStageExit,
11    PersonaValidationError, ResolvedPersonaManifest,
12};
13pub use provider_setup::{
14    ConnectorCredentialEnvironmentManifest, ConnectorHealthCheckManifest, ConnectorRecoveryCopy,
15    ProviderSetupManifest,
16};
17
18#[derive(Debug, Clone, Deserialize)]
19pub struct Manifest {
20    pub package: Option<PackageInfo>,
21    #[serde(default)]
22    pub dependencies: HashMap<String, Dependency>,
23    #[serde(default)]
24    pub mcp: Vec<McpServerConfig>,
25    #[serde(default)]
26    pub check: CheckConfig,
27    #[serde(default)]
28    pub workspace: WorkspaceConfig,
29    /// `[registry]` table — lightweight package discovery index
30    /// configuration. The CLI also honors `HARN_PACKAGE_REGISTRY` and
31    /// `--registry` flags for one-off overrides.
32    #[serde(default)]
33    pub registry: PackageRegistryConfig,
34    /// `[skills]` table — per-project skill discovery configuration
35    /// (paths, lookup_order, disable).
36    #[serde(default)]
37    pub skills: SkillsConfig,
38    /// `[[skill.source]]` array-of-tables — declared skill sources
39    /// (filesystem, git, reserved registry).
40    #[serde(default)]
41    pub skill: SkillTables,
42    /// `[capabilities]` section — per-provider-per-model override of
43    /// the shipped capability matrix (`defer_loading`, `tool_search`,
44    /// `prompt_caching`, etc.). Entries under `[[capabilities.provider.<name>]]`
45    /// are prepended to the built-in rules for the same provider so
46    /// early adopters can flag proxied endpoints as supporting tool
47    /// search without waiting for a Harn release. See
48    /// `harn_vm::llm::capabilities` for the rule schema.
49    #[serde(default)]
50    pub capabilities: Option<harn_vm::llm::capabilities::CapabilitiesFile>,
51    /// Stable exported package modules. Keys are the logical import
52    /// suffixes (e.g. `providers/openai`) and values are package-root-
53    /// relative file paths. Consumers import them via `<package>/<key>`.
54    #[serde(default)]
55    pub exports: HashMap<String, String>,
56    /// `[llm]` section — packaged provider definitions, aliases,
57    /// inference rules, tier rules, and model defaults. Uses the same
58    /// schema as `providers.toml`, but merges into the current run
59    /// instead of replacing the global config file.
60    #[serde(default)]
61    pub llm: harn_vm::llm_config::ProvidersConfig,
62    /// `[[hooks]]` array-of-tables — declarative runtime hooks installed
63    /// once per process/thread before execution starts. Matches the
64    /// manifest-extension ABI shape added by `[exports]` / `[llm]`, but
65    /// the handlers themselves live in Harn modules.
66    #[serde(default)]
67    pub hooks: Vec<HookConfig>,
68    /// `[[triggers]]` array-of-tables — declarative event-driven trigger
69    /// registrations that resolve local handlers and predicates from Harn
70    /// modules at load time and preserve remote URI schemes for later
71    /// dispatcher work.
72    #[serde(default)]
73    pub triggers: Vec<TriggerManifestEntry>,
74    /// `[[handoff_routes]]` array-of-tables — declarative handoff route data.
75    /// Route selection stays in Harn stdlib/persona code; the Rust manifest
76    /// loader makes these tenant routes available to that code.
77    #[serde(default)]
78    pub handoff_routes: Vec<harn_vm::HandoffRouteConfig>,
79    /// `[[providers]]` array-of-tables — provider-specific connector
80    /// overrides used by the orchestrator to load either builtin Rust
81    /// connectors or `.harn` modules as connector implementations.
82    #[serde(default)]
83    pub providers: Vec<ProviderManifestEntry>,
84    /// `[[personas]]` array-of-tables — durable, non-executing agent role
85    /// manifests. Personas bind an entry workflow to tools, capabilities,
86    /// autonomy, budgets, receipts, handoffs, evals, and rollout metadata.
87    #[serde(default)]
88    pub personas: Vec<PersonaManifestEntry>,
89    /// `[connector_contract]` table — deterministic package-local fixtures
90    /// consumed by `harn connector check` for pure-Harn connector packages.
91    #[serde(default, alias = "connector-contract")]
92    pub connector_contract: ConnectorContractConfig,
93    /// `[orchestrator]` table — listener-level controls shared by
94    /// manifest-driven ingress surfaces.
95    #[serde(default)]
96    pub orchestrator: OrchestratorConfig,
97    /// `[rules]` table — `sgconfig`-style structural-rule discovery. Lists the
98    /// directories `harn scan` / `harn codemod` load rules from when no
99    /// explicit `--rule`/`--rule-pack` is given.
100    #[serde(default)]
101    pub rules: RulesConfig,
102    /// `[[contributes]]` array-of-tables — host-surface extension
103    /// contributions (editor languages, preview panes, build profiles,
104    /// commands, themes, …). Harn treats `kind` as a host-owned, namespaced
105    /// string and validates only the envelope plus that each contribution's
106    /// declared `scopes` are covered by `[package].permissions`; the host
107    /// (e.g. a host) interprets the kind-specific payload. New contribution
108    /// kinds therefore need no Harn release. This is the editor-layer twin of
109    /// the agent-layer blocks (`[[providers]]`, `[[personas]]`, `[[hooks]]`):
110    /// one signed package may populate any mix of both.
111    #[serde(default)]
112    pub contributes: Vec<ContributionEntry>,
113}
114
115/// A single `[[contributes]]` host-surface contribution.
116///
117/// ```toml
118/// [[contributes]]
119/// kind = "editor.language"          # host-owned namespaced vocabulary
120/// id = "latex"                      # unique within the package
121/// title = "LaTeX"
122/// when = "*.tex"                    # optional activation predicate (host-interpreted)
123/// scopes = ["workspace:read_text"]  # MUST be a subset of [package].permissions
124/// platforms = ["macos", "linux"]    # optional support/parity matrix; empty = all
125/// # kind-specific keys are captured into `config` and interpreted by the host:
126/// languageId = "latex"
127/// extensions = [".tex", ".sty"]
128/// ```
129#[derive(Debug, Clone, Deserialize, Serialize)]
130pub struct ContributionEntry {
131    /// Host-owned, namespaced contribution kind (e.g. `editor.language`,
132    /// `editor.preview`, `build.profile`, `editor.command`, `editor.theme`).
133    /// Harn does not enumerate kinds — new ones need no release; it only
134    /// requires the value be namespaced (`segment(.segment)+`).
135    pub kind: String,
136    /// Stable identifier, unique across the package's contributions.
137    pub id: String,
138    #[serde(default)]
139    pub title: Option<String>,
140    /// Optional activation predicate the host interprets (a glob, a
141    /// `languageId`, or a host-defined expression). Absent = always available.
142    #[serde(default)]
143    pub when: Option<String>,
144    /// Capability scopes this contribution exercises. Every entry MUST be
145    /// declared in `[package].permissions`; validation fails closed otherwise.
146    #[serde(default)]
147    pub scopes: Vec<String>,
148    /// Optional support/parity matrix — which surfaces/platforms this
149    /// contribution targets (e.g. `macos`, `linux`, `windows`, `ide`, `tui`).
150    /// Empty means "all".
151    #[serde(default)]
152    pub platforms: Vec<String>,
153    /// Kind-specific payload, captured verbatim and interpreted by the host.
154    #[serde(flatten)]
155    pub config: BTreeMap<String, toml::Value>,
156}
157
158impl ContributionEntry {
159    /// `true` when `kind` is a non-empty, dot-namespaced identifier such as
160    /// `editor.language`. Single-segment kinds are rejected so third parties
161    /// cannot squat unprefixed names.
162    pub fn has_namespaced_kind(&self) -> bool {
163        let mut segments = 0usize;
164        for segment in self.kind.split('.') {
165            if segment.is_empty()
166                || !segment
167                    .chars()
168                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
169                || !segment.starts_with(|c: char| c.is_ascii_lowercase())
170            {
171                return false;
172            }
173            segments += 1;
174        }
175        segments >= 2
176    }
177}
178
179/// `[rules]` table — project-local structural-rule discovery (#2843).
180///
181/// ```toml
182/// [rules]
183/// ruleDirs = ["rules", "vendor/rules"]
184/// utilDirs = ["rules/util"]
185/// testConfigs = ["rules/tests"]
186/// nativeRuleDirs = ["target/harn-native-rules"]
187/// ```
188///
189/// Paths are resolved relative to the manifest's directory.
190#[derive(Debug, Clone, Default, Deserialize)]
191pub struct RulesConfig {
192    /// Directories of top-level rule `*.toml` files to load.
193    #[serde(default, alias = "rule-dirs", alias = "ruleDirs")]
194    pub rule_dirs: Vec<String>,
195    /// Directories of utility-rule `*.toml` files (referenced via `matches`).
196    #[serde(default, alias = "util-dirs", alias = "utilDirs")]
197    pub util_dirs: Vec<String>,
198    /// Directories holding rule-test fixtures (for `harn rule test`).
199    #[serde(default, alias = "test-configs", alias = "testConfigs")]
200    pub test_configs: Vec<String>,
201    /// Trusted directories of native lint-rule dynamic libraries.
202    #[serde(
203        default,
204        alias = "native-rule-dirs",
205        alias = "nativeRuleDirs",
206        alias = "native_rule_dirs"
207    )]
208    pub native_rule_dirs: Vec<String>,
209}
210
211#[derive(Debug, Clone, Default, Deserialize)]
212pub struct OrchestratorConfig {
213    #[serde(default, alias = "allowed-origins")]
214    pub allowed_origins: Vec<String>,
215    #[serde(default, alias = "max-body-bytes")]
216    pub max_body_bytes: Option<usize>,
217    #[serde(default)]
218    pub budget: OrchestratorBudgetSpec,
219    #[serde(default)]
220    pub drain: OrchestratorDrainConfig,
221    #[serde(default)]
222    pub pumps: OrchestratorPumpConfig,
223}
224
225#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
226pub struct OrchestratorBudgetSpec {
227    #[serde(default)]
228    pub daily_cost_usd: Option<f64>,
229    #[serde(default)]
230    pub hourly_cost_usd: Option<f64>,
231}
232
233#[derive(Debug, Clone, Deserialize)]
234pub struct OrchestratorDrainConfig {
235    #[serde(default = "default_orchestrator_drain_max_items", alias = "max-items")]
236    pub max_items: usize,
237    #[serde(
238        default = "default_orchestrator_drain_deadline_seconds",
239        alias = "deadline-seconds"
240    )]
241    pub deadline_seconds: u64,
242}
243
244impl Default for OrchestratorDrainConfig {
245    fn default() -> Self {
246        Self {
247            max_items: default_orchestrator_drain_max_items(),
248            deadline_seconds: default_orchestrator_drain_deadline_seconds(),
249        }
250    }
251}
252
253pub(crate) fn default_orchestrator_drain_max_items() -> usize {
254    1024
255}
256
257pub(crate) fn default_orchestrator_drain_deadline_seconds() -> u64 {
258    30
259}
260
261#[derive(Debug, Clone, Deserialize)]
262pub struct OrchestratorPumpConfig {
263    #[serde(
264        default = "default_orchestrator_pump_max_outstanding",
265        alias = "max-outstanding"
266    )]
267    pub max_outstanding: usize,
268}
269
270impl Default for OrchestratorPumpConfig {
271    fn default() -> Self {
272        Self {
273            max_outstanding: default_orchestrator_pump_max_outstanding(),
274        }
275    }
276}
277
278pub(crate) fn default_orchestrator_pump_max_outstanding() -> usize {
279    64
280}
281
282#[derive(Debug, Clone, Deserialize)]
283pub struct HookConfig {
284    pub event: harn_vm::orchestration::HookEvent,
285    #[serde(default = "default_hook_pattern")]
286    pub pattern: String,
287    pub handler: String,
288}
289
290pub(crate) fn default_hook_pattern() -> String {
291    "*".to_string()
292}
293
294#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
295pub struct TriggerManifestEntry {
296    pub id: String,
297    #[serde(default)]
298    pub kind: Option<TriggerKind>,
299    #[serde(default)]
300    pub provider: Option<harn_vm::ProviderId>,
301    #[serde(default, alias = "tier")]
302    pub autonomy_tier: harn_vm::AutonomyTier,
303    #[serde(default, rename = "match")]
304    pub match_: Option<TriggerMatchExpr>,
305    #[serde(default)]
306    pub sources: Vec<TriggerSourceManifestEntry>,
307    #[serde(default)]
308    pub when: Option<String>,
309    #[serde(default)]
310    pub when_budget: Option<TriggerWhenBudgetSpec>,
311    pub handler: String,
312    #[serde(default)]
313    pub dedupe_key: Option<String>,
314    #[serde(default)]
315    pub retry: TriggerRetrySpec,
316    #[serde(default)]
317    pub priority: Option<TriggerPriorityField>,
318    #[serde(default)]
319    pub budget: TriggerBudgetSpec,
320    #[serde(default)]
321    pub concurrency: Option<TriggerConcurrencyManifestSpec>,
322    #[serde(default)]
323    pub throttle: Option<TriggerThrottleManifestSpec>,
324    #[serde(default)]
325    pub rate_limit: Option<TriggerRateLimitManifestSpec>,
326    #[serde(default)]
327    pub debounce: Option<TriggerDebounceManifestSpec>,
328    #[serde(default)]
329    pub singleton: Option<TriggerSingletonManifestSpec>,
330    #[serde(default)]
331    pub batch: Option<TriggerBatchManifestSpec>,
332    #[serde(default)]
333    pub window: Option<TriggerStreamWindowManifestSpec>,
334    #[serde(default, alias = "dlq-alerts")]
335    pub dlq_alerts: Vec<TriggerDlqAlertManifestSpec>,
336    #[serde(default)]
337    pub secrets: BTreeMap<String, String>,
338    #[serde(default)]
339    pub filter: Option<String>,
340    #[serde(flatten, default)]
341    pub kind_specific: BTreeMap<String, toml::Value>,
342}
343
344#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
345pub struct TriggerSourceManifestEntry {
346    #[serde(default)]
347    pub id: Option<String>,
348    pub kind: TriggerKind,
349    pub provider: harn_vm::ProviderId,
350    #[serde(default, rename = "match")]
351    pub match_: Option<TriggerMatchExpr>,
352    #[serde(default)]
353    pub dedupe_key: Option<String>,
354    #[serde(default)]
355    pub retry: Option<TriggerRetrySpec>,
356    #[serde(default)]
357    pub priority: Option<TriggerPriorityField>,
358    #[serde(default)]
359    pub budget: Option<TriggerBudgetSpec>,
360    #[serde(default)]
361    pub concurrency: Option<TriggerConcurrencyManifestSpec>,
362    #[serde(default)]
363    pub throttle: Option<TriggerThrottleManifestSpec>,
364    #[serde(default)]
365    pub rate_limit: Option<TriggerRateLimitManifestSpec>,
366    #[serde(default)]
367    pub debounce: Option<TriggerDebounceManifestSpec>,
368    #[serde(default)]
369    pub singleton: Option<TriggerSingletonManifestSpec>,
370    #[serde(default)]
371    pub batch: Option<TriggerBatchManifestSpec>,
372    #[serde(default)]
373    pub window: Option<TriggerStreamWindowManifestSpec>,
374    #[serde(default)]
375    pub secrets: BTreeMap<String, String>,
376    #[serde(default)]
377    pub filter: Option<String>,
378    #[serde(flatten, default)]
379    pub kind_specific: BTreeMap<String, toml::Value>,
380}
381
382#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
383#[serde(rename_all = "kebab-case")]
384pub enum TriggerKind {
385    Webhook,
386    Cron,
387    Poll,
388    Stream,
389    Predicate,
390    A2aPush,
391}
392
393#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
394pub struct TriggerMatchExpr {
395    #[serde(default)]
396    pub events: Vec<String>,
397    #[serde(flatten, default)]
398    pub extra: BTreeMap<String, toml::Value>,
399}
400
401#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
402pub struct TriggerRetrySpec {
403    #[serde(default)]
404    pub max: u32,
405    #[serde(default)]
406    pub backoff: TriggerRetryBackoff,
407    #[serde(default = "default_trigger_retention_days")]
408    pub retention_days: u32,
409}
410
411#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
412#[serde(rename_all = "kebab-case")]
413pub enum TriggerRetryBackoff {
414    #[default]
415    Immediate,
416    Svix,
417}
418
419pub(crate) fn default_trigger_retention_days() -> u32 {
420    harn_vm::DEFAULT_INBOX_RETENTION_DAYS
421}
422
423impl Default for TriggerRetrySpec {
424    fn default() -> Self {
425        Self {
426            max: 0,
427            backoff: TriggerRetryBackoff::default(),
428            retention_days: default_trigger_retention_days(),
429        }
430    }
431}
432
433#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
434#[serde(rename_all = "lowercase")]
435pub enum TriggerDispatchPriority {
436    High,
437    #[default]
438    Normal,
439    Low,
440}
441
442#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
443#[serde(untagged)]
444pub enum TriggerPriorityField {
445    Dispatch(TriggerDispatchPriority),
446    Flow(TriggerPriorityManifestSpec),
447}
448
449#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
450pub struct TriggerBudgetSpec {
451    #[serde(default)]
452    pub max_cost_usd: Option<f64>,
453    #[serde(default, alias = "tokens_max")]
454    pub max_tokens: Option<u64>,
455    #[serde(default)]
456    pub daily_cost_usd: Option<f64>,
457    #[serde(default)]
458    pub hourly_cost_usd: Option<f64>,
459    #[serde(default)]
460    pub max_autonomous_decisions_per_hour: Option<u64>,
461    #[serde(default)]
462    pub max_autonomous_decisions_per_day: Option<u64>,
463    #[serde(default)]
464    pub max_concurrent: Option<u32>,
465    #[serde(default)]
466    pub on_budget_exhausted: harn_vm::TriggerBudgetExhaustionStrategy,
467}
468
469#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
470pub struct TriggerWhenBudgetSpec {
471    #[serde(default)]
472    pub max_cost_usd: Option<f64>,
473    #[serde(default)]
474    pub tokens_max: Option<u64>,
475    #[serde(default)]
476    pub timeout: Option<String>,
477}
478
479#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
480pub struct TriggerConcurrencyManifestSpec {
481    #[serde(default)]
482    pub key: Option<String>,
483    pub max: u32,
484}
485
486#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
487pub struct TriggerThrottleManifestSpec {
488    #[serde(default)]
489    pub key: Option<String>,
490    pub period: String,
491    pub max: u32,
492}
493
494#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
495pub struct TriggerRateLimitManifestSpec {
496    #[serde(default)]
497    pub key: Option<String>,
498    pub period: String,
499    pub max: u32,
500}
501
502#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
503pub struct TriggerDebounceManifestSpec {
504    pub key: String,
505    pub period: String,
506}
507
508#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
509pub struct TriggerSingletonManifestSpec {
510    #[serde(default)]
511    pub key: Option<String>,
512}
513
514#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
515pub struct TriggerBatchManifestSpec {
516    #[serde(default)]
517    pub key: Option<String>,
518    pub size: u32,
519    pub timeout: String,
520}
521
522#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
523pub struct TriggerPriorityManifestSpec {
524    pub key: String,
525    #[serde(default)]
526    pub order: Vec<String>,
527}
528
529#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
530#[serde(rename_all = "kebab-case")]
531pub enum TriggerStreamWindowMode {
532    Tumbling,
533    Sliding,
534    Session,
535}
536
537#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
538pub struct TriggerStreamWindowManifestSpec {
539    pub mode: TriggerStreamWindowMode,
540    #[serde(default)]
541    pub key: Option<String>,
542    #[serde(default)]
543    pub size: Option<String>,
544    #[serde(default)]
545    pub every: Option<String>,
546    #[serde(default)]
547    pub gap: Option<String>,
548    #[serde(default)]
549    pub max_items: Option<u32>,
550}
551
552#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
553pub struct TriggerDlqAlertManifestSpec {
554    #[serde(default)]
555    pub destinations: Vec<TriggerDlqAlertDestination>,
556    #[serde(default)]
557    pub threshold: TriggerDlqAlertThreshold,
558}
559
560#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
561pub struct TriggerDlqAlertThreshold {
562    #[serde(default, alias = "entries-in-1h")]
563    pub entries_in_1h: Option<u32>,
564    #[serde(default, alias = "percent-of-dispatches")]
565    pub percent_of_dispatches: Option<f64>,
566}
567
568#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
569#[serde(tag = "kind", rename_all = "snake_case")]
570pub enum TriggerDlqAlertDestination {
571    Slack {
572        channel: String,
573        #[serde(default)]
574        webhook_url_env: Option<String>,
575    },
576    Email {
577        address: String,
578    },
579    Webhook {
580        url: String,
581        #[serde(default)]
582        headers: BTreeMap<String, String>,
583    },
584}
585
586impl TriggerDlqAlertDestination {
587    pub fn label(&self) -> String {
588        match self {
589            Self::Slack { channel, .. } => format!("slack:{channel}"),
590            Self::Email { address } => format!("email:{address}"),
591            Self::Webhook { url, .. } => format!("webhook:{url}"),
592        }
593    }
594}
595
596#[derive(Debug, Clone, PartialEq, Eq)]
597pub enum TriggerHandlerUri {
598    Local(TriggerFunctionRef),
599    A2a {
600        target: String,
601        allow_cleartext: bool,
602    },
603    Worker {
604        queue: String,
605    },
606    Persona {
607        name: String,
608    },
609    EvalPack {
610        target: String,
611    },
612}
613
614#[derive(Debug, Clone, PartialEq, Eq)]
615pub struct TriggerFunctionRef {
616    pub raw: String,
617    pub module_name: Option<String>,
618    pub function_name: String,
619}
620
621/// `[skills]` table body.
622#[derive(Debug, Default, Clone, Deserialize)]
623#[allow(dead_code)] // `defaults` is parsed per harn#73; default application remains staged.
624pub struct SkillsConfig {
625    /// Additional filesystem roots to scan. Each entry may be a
626    /// literal directory or a glob (`packages/*/skills`). Resolved
627    /// relative to the directory holding harn.toml.
628    #[serde(default)]
629    pub paths: Vec<String>,
630    /// Override priority order. Values are layer labels —
631    /// `cli`, `env`, `project`, `manifest`, `user`, `package`,
632    /// `system`, `host`. Unlisted layers fall through to default
633    /// priority after listed ones.
634    #[serde(default)]
635    pub lookup_order: Vec<String>,
636    /// Disable entire layers. Same label set as `lookup_order`.
637    #[serde(default)]
638    pub disable: Vec<String>,
639    /// Optional remote registry base URL used to resolve
640    /// `<fingerprint>.pub` when a signer is not installed locally.
641    #[serde(default)]
642    pub signer_registry_url: Option<String>,
643    /// `[skills.defaults]` inline sub-table — applied to every
644    /// discovered skill when the field is unset in its SKILL.md
645    /// frontmatter.
646    #[serde(default)]
647    pub defaults: SkillDefaults,
648}
649
650#[derive(Debug, Default, Clone, Deserialize)]
651#[allow(dead_code)] // Parsed per harn#73; loader default application is still staged.
652pub struct SkillDefaults {
653    #[serde(default)]
654    pub tool_search: Option<String>,
655    #[serde(default)]
656    pub always_loaded: Vec<String>,
657}
658
659/// Container for `[[skill.source]]` array-of-tables.
660#[derive(Debug, Default, Clone, Deserialize)]
661pub struct SkillTables {
662    #[serde(default, rename = "source")]
663    pub sources: Vec<SkillSourceEntry>,
664}
665
666/// One `[[skill.source]]` entry. The `registry` variant is accepted
667/// for forward-compat but inert — see issue #73 and `docs/src/skills.md`
668/// for the marketplace timeline.
669#[derive(Debug, Clone, Deserialize)]
670#[serde(tag = "type", rename_all = "lowercase")]
671#[allow(dead_code)] // Git/registry skill sources are manifest-reserved by harn#73.
672pub enum SkillSourceEntry {
673    Fs {
674        path: String,
675        #[serde(default)]
676        namespace: Option<String>,
677    },
678    Git {
679        url: String,
680        #[serde(default)]
681        tag: Option<String>,
682        #[serde(default)]
683        namespace: Option<String>,
684    },
685    Registry {
686        #[serde(default)]
687        url: Option<String>,
688        #[serde(default)]
689        name: Option<String>,
690    },
691}
692
693#[derive(Debug, Default, Clone, Deserialize)]
694pub struct WorkspaceConfig {
695    /// Directory or file globs (repo-relative) that `harn check --workspace`
696    /// walks to collect the full pipeline tree in one invocation.
697    #[serde(default)]
698    pub pipelines: Vec<String>,
699}
700
701#[derive(Debug, Default, Clone, Deserialize)]
702pub struct PackageRegistryConfig {
703    /// URL or filesystem path to a TOML package index.
704    #[serde(default)]
705    pub url: Option<String>,
706}
707
708#[derive(Debug, Clone, Deserialize)]
709pub struct McpServerConfig {
710    pub name: String,
711    #[serde(default)]
712    pub transport: Option<String>,
713    #[serde(default)]
714    pub command: String,
715    #[serde(default)]
716    pub args: Vec<String>,
717    #[serde(default)]
718    pub env: HashMap<String, String>,
719    #[serde(default)]
720    pub url: String,
721    #[serde(default)]
722    pub auth_token: Option<String>,
723    #[serde(default)]
724    pub token_exchange: Option<harn_vm::mcp_oauth::McpTokenExchangeConfig>,
725    #[serde(default)]
726    pub auth: Option<McpAuthConfig>,
727    #[serde(default)]
728    pub client_id: Option<String>,
729    #[serde(default)]
730    pub client_secret: Option<String>,
731    #[serde(default)]
732    pub scopes: Option<String>,
733    #[serde(default)]
734    pub protocol_version: Option<String>,
735    #[serde(default)]
736    pub proxy_server_name: Option<String>,
737    /// When `true`, the server is NOT booted up-front. It boots on the
738    /// first `mcp_call` or on skill activation that declares it in
739    /// `requires_mcp`. See harn#75.
740    #[serde(default)]
741    pub lazy: bool,
742    /// Optional pointer to a Server Card — either an HTTP(S) URL or a
743    /// local filesystem path. When set, `mcp_server_card("name")` reads
744    /// the card from this source (cached per-process with a TTL).
745    #[serde(default)]
746    pub card: Option<String>,
747    /// How long (milliseconds) to keep a lazy server's process alive
748    /// after its last binder releases. 0 / unset → disconnect
749    /// immediately. Ignored for non-lazy servers.
750    #[serde(default, alias = "keep-alive-ms", alias = "keep_alive")]
751    pub keep_alive_ms: Option<u64>,
752}
753
754#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
755pub struct McpAuthConfig {
756    #[serde(default)]
757    pub mode: Option<harn_vm::mcp_auth::OAuthClientAuthMode>,
758    #[serde(default, alias = "client-id")]
759    pub client_id: Option<String>,
760    #[serde(
761        default,
762        alias = "client_secret_id",
763        alias = "client-secret-id",
764        alias = "client_secret_ref",
765        alias = "client-secret-ref"
766    )]
767    pub client_secret_id: Option<String>,
768    #[serde(
769        default,
770        alias = "secret_id",
771        alias = "secret-id",
772        alias = "token-secret-id"
773    )]
774    pub secret_id: Option<String>,
775    #[serde(default, alias = "scope")]
776    pub scopes: Option<String>,
777    #[serde(default, alias = "token_auth_method", alias = "token-auth-method")]
778    pub token_endpoint_auth_method: Option<String>,
779}
780
781#[derive(Debug, Clone, Deserialize)]
782#[allow(dead_code)] // Package metadata feeds authoring/publish validation tracked in harn#471.
783pub struct PackageInfo {
784    pub name: Option<String>,
785    pub version: Option<String>,
786    #[serde(default)]
787    pub evals: Vec<String>,
788    #[serde(default)]
789    pub description: Option<String>,
790    #[serde(default)]
791    pub license: Option<String>,
792    #[serde(default)]
793    pub repository: Option<String>,
794    #[serde(default, alias = "harn_version", alias = "harn_version_range")]
795    pub harn: Option<String>,
796    #[serde(default)]
797    pub docs_url: Option<String>,
798    #[serde(default)]
799    pub provenance: Option<String>,
800    /// Human-facing publisher / developer name shown in marketplace surfaces.
801    #[serde(default)]
802    pub publisher: Option<String>,
803    /// Publisher contact (email or URL) shown alongside the publisher name.
804    #[serde(default)]
805    pub contact: Option<String>,
806    /// Optional ISO-8601 authoring date. Mutation dates are better derived
807    /// from the registry/VCS, but a declared creation date is allowed.
808    #[serde(default)]
809    pub created: Option<String>,
810    #[serde(default)]
811    pub permissions: Vec<String>,
812    #[serde(default, alias = "host-requirements")]
813    pub host_requirements: Vec<String>,
814    #[serde(default)]
815    pub tools: Vec<PackageToolExport>,
816    #[serde(default)]
817    pub skills: Vec<PackageSkillExport>,
818}
819
820#[derive(Debug, Clone, Deserialize, PartialEq)]
821pub struct PackageToolExport {
822    pub name: String,
823    pub module: String,
824    #[serde(default = "default_package_tool_symbol")]
825    pub symbol: String,
826    #[serde(default)]
827    pub description: Option<String>,
828    #[serde(default)]
829    pub permissions: Vec<String>,
830    #[serde(default, alias = "host-requirements")]
831    pub host_requirements: Vec<String>,
832    #[serde(default, alias = "input-schema")]
833    pub input_schema: Option<toml::Value>,
834    #[serde(default, alias = "output-schema")]
835    pub output_schema: Option<toml::Value>,
836    #[serde(default)]
837    pub annotations: BTreeMap<String, toml::Value>,
838}
839
840pub(crate) fn default_package_tool_symbol() -> String {
841    "tools".to_string()
842}
843
844#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
845pub struct PackageSkillExport {
846    pub name: String,
847    pub path: String,
848    #[serde(default)]
849    pub description: Option<String>,
850    #[serde(default)]
851    pub permissions: Vec<String>,
852    #[serde(default, alias = "host-requirements")]
853    pub host_requirements: Vec<String>,
854}
855
856#[derive(Debug, Clone, Deserialize)]
857#[serde(untagged)]
858pub enum Dependency {
859    Table(Box<DepTable>),
860    Path(String),
861}
862
863#[derive(Debug, Clone, Default, Deserialize)]
864pub struct DepTable {
865    pub git: Option<String>,
866    #[serde(default, alias = "archive-url", alias = "archive_url")]
867    pub archive: Option<String>,
868    pub tag: Option<String>,
869    pub rev: Option<String>,
870    pub branch: Option<String>,
871    pub version: Option<String>,
872    pub path: Option<String>,
873    pub package: Option<String>,
874    #[serde(default)]
875    pub checksum: Option<String>,
876    /// Registry index URL/path the dependency was originally added from.
877    /// Persisted in the manifest so registry provenance survives
878    /// round-trips and the lockfile can compare against the registry's
879    /// latest version.
880    #[serde(default)]
881    pub registry: Option<String>,
882    /// Registry-side package name (e.g. `@burin/notion-sdk`). May differ
883    /// from the alias and from the git URL's repo name.
884    #[serde(default, alias = "registry-name")]
885    pub registry_name: Option<String>,
886    /// Registry version specifier the dependency was added against.
887    #[serde(default, alias = "registry-version")]
888    pub registry_version: Option<String>,
889    /// Immutable commit recorded by registry v2 for a Git-backed version.
890    #[serde(default, alias = "registry-commit")]
891    pub registry_commit: Option<String>,
892    /// Registry-v2 evidence URL for the selected published version.
893    #[serde(default, alias = "registry-provenance")]
894    pub registry_provenance: Option<String>,
895}
896
897impl Dependency {
898    pub(crate) fn git_url(&self) -> Option<&str> {
899        match self {
900            Dependency::Table(t) => t.git.as_deref(),
901            Dependency::Path(_) => None,
902        }
903    }
904
905    pub(crate) fn archive_url(&self) -> Option<&str> {
906        match self {
907            Dependency::Table(t) => t.archive.as_deref(),
908            Dependency::Path(_) => None,
909        }
910    }
911
912    pub(crate) fn rev(&self) -> Option<&str> {
913        match self {
914            Dependency::Table(t) => t.rev.as_deref(),
915            Dependency::Path(_) => None,
916        }
917    }
918
919    pub(crate) fn tag(&self) -> Option<&str> {
920        match self {
921            Dependency::Table(t) => t.tag.as_deref(),
922            Dependency::Path(_) => None,
923        }
924    }
925
926    pub(crate) fn branch(&self) -> Option<&str> {
927        match self {
928            Dependency::Table(t) => t.branch.as_deref(),
929            Dependency::Path(_) => None,
930        }
931    }
932
933    pub(crate) fn version(&self) -> Option<&str> {
934        match self {
935            Dependency::Table(t) => t.version.as_deref(),
936            Dependency::Path(_) => None,
937        }
938    }
939
940    pub(crate) fn requires_git(&self) -> bool {
941        self.git_url().is_some()
942    }
943
944    pub(crate) fn local_path(&self) -> Option<&str> {
945        match self {
946            Dependency::Table(t) => t.path.as_deref(),
947            Dependency::Path(p) => Some(p.as_str()),
948        }
949    }
950}
951
952pub(crate) fn validate_package_alias(alias: &str) -> Result<(), PackageError> {
953    if harn_modules::package_snapshot::is_valid_package_name(alias) {
954        Ok(())
955    } else {
956        Err(PackageError::Validation(format!(
957            "invalid dependency alias {alias:?}; use ASCII letters, numbers, '.', '_' or '-'"
958        )))
959    }
960}
961
962pub(crate) fn toml_string_literal(value: &str) -> Result<String, PackageError> {
963    use std::fmt::Write as _;
964
965    let mut encoded = String::with_capacity(value.len() + 2);
966    encoded.push('"');
967    for ch in value.chars() {
968        match ch {
969            '\u{08}' => encoded.push_str("\\b"),
970            '\t' => encoded.push_str("\\t"),
971            '\n' => encoded.push_str("\\n"),
972            '\u{0C}' => encoded.push_str("\\f"),
973            '\r' => encoded.push_str("\\r"),
974            '"' => encoded.push_str("\\\""),
975            '\\' => encoded.push_str("\\\\"),
976            ch if ch <= '\u{1F}' || ch == '\u{7F}' => {
977                write!(&mut encoded, "\\u{:04X}", ch as u32).map_err(|error| {
978                    PackageError::Manifest(format!("failed to encode TOML string: {error}"))
979                })?;
980            }
981            ch => encoded.push(ch),
982        }
983    }
984    encoded.push('"');
985    Ok(encoded)
986}
987#[derive(Debug, Default, Clone)]
988pub struct RuntimeExtensions {
989    pub root_manifest: Option<Manifest>,
990    pub root_manifest_path: Option<PathBuf>,
991    pub root_manifest_dir: Option<PathBuf>,
992    pub(crate) runtime_personas: Vec<ResolvedRuntimePersona>,
993    pub llm: Option<harn_vm::llm_config::ProvidersConfig>,
994    pub capabilities: Option<harn_vm::llm::capabilities::CapabilitiesFile>,
995    pub hooks: Vec<ResolvedHookConfig>,
996    pub triggers: Vec<ResolvedTriggerConfig>,
997    pub handoff_routes: Vec<harn_vm::HandoffRouteConfig>,
998    pub provider_connectors: Vec<ResolvedProviderConnectorConfig>,
999}
1000
1001#[derive(Debug, Clone, Deserialize)]
1002pub struct ProviderManifestEntry {
1003    pub id: harn_vm::ProviderId,
1004    pub connector: ProviderConnectorManifest,
1005    #[serde(default)]
1006    pub oauth: Option<ProviderOAuthManifest>,
1007    #[serde(default)]
1008    pub setup: Option<ProviderSetupManifest>,
1009    #[serde(default)]
1010    pub capabilities: ConnectorCapabilities,
1011}
1012
1013#[derive(Debug, Clone, Deserialize)]
1014pub struct ProviderConnectorManifest {
1015    #[serde(default)]
1016    pub harn: Option<String>,
1017    #[serde(default)]
1018    pub rust: Option<String>,
1019}
1020
1021#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
1022pub struct ProviderOAuthManifest {
1023    #[serde(default, alias = "auth_url", alias = "authorization-endpoint")]
1024    pub authorization_endpoint: Option<String>,
1025    #[serde(default, alias = "token_url", alias = "token-endpoint")]
1026    pub token_endpoint: Option<String>,
1027    #[serde(default, alias = "registration_url", alias = "registration-endpoint")]
1028    pub registration_endpoint: Option<String>,
1029    #[serde(default)]
1030    pub resource: Option<String>,
1031    #[serde(default, alias = "scope")]
1032    pub scopes: Option<String>,
1033    #[serde(default, alias = "client-id")]
1034    pub client_id: Option<String>,
1035    #[serde(default, alias = "client-secret")]
1036    pub client_secret: Option<String>,
1037    #[serde(default, alias = "token_auth_method", alias = "token-auth-method")]
1038    pub token_endpoint_auth_method: Option<String>,
1039}
1040
1041#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
1042pub struct ConnectorCapabilities {
1043    pub webhook: bool,
1044    pub oauth: bool,
1045    pub rate_limit: bool,
1046    pub pagination: bool,
1047    pub graphql: bool,
1048    pub streaming: bool,
1049}
1050
1051impl ConnectorCapabilities {
1052    pub const FEATURES: [&'static str; 6] = [
1053        "webhook",
1054        "oauth",
1055        "rate_limit",
1056        "pagination",
1057        "graphql",
1058        "streaming",
1059    ];
1060
1061    fn enable(&mut self, feature: &str) -> Result<(), String> {
1062        match normalize_connector_capability(feature).as_str() {
1063            "webhook" => self.webhook = true,
1064            "oauth" => self.oauth = true,
1065            "rate_limit" => self.rate_limit = true,
1066            "pagination" => self.pagination = true,
1067            "graphql" => self.graphql = true,
1068            "streaming" => self.streaming = true,
1069            other => {
1070                return Err(format!(
1071                    "unknown connector capability '{feature}' (normalized as '{other}')"
1072                ));
1073            }
1074        }
1075        Ok(())
1076    }
1077}
1078
1079#[derive(Debug, Default, Deserialize)]
1080struct ConnectorCapabilitiesTable {
1081    #[serde(default)]
1082    webhook: bool,
1083    #[serde(default)]
1084    oauth: bool,
1085    #[serde(default, alias = "rate-limit")]
1086    rate_limit: bool,
1087    #[serde(default)]
1088    pagination: bool,
1089    #[serde(default)]
1090    graphql: bool,
1091    #[serde(default)]
1092    streaming: bool,
1093}
1094
1095impl From<ConnectorCapabilitiesTable> for ConnectorCapabilities {
1096    fn from(value: ConnectorCapabilitiesTable) -> Self {
1097        Self {
1098            webhook: value.webhook,
1099            oauth: value.oauth,
1100            rate_limit: value.rate_limit,
1101            pagination: value.pagination,
1102            graphql: value.graphql,
1103            streaming: value.streaming,
1104        }
1105    }
1106}
1107
1108impl<'de> Deserialize<'de> for ConnectorCapabilities {
1109    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1110    where
1111        D: serde::Deserializer<'de>,
1112    {
1113        #[derive(Deserialize)]
1114        #[serde(untagged)]
1115        enum RawConnectorCapabilities {
1116            List(Vec<String>),
1117            Table(ConnectorCapabilitiesTable),
1118        }
1119
1120        match RawConnectorCapabilities::deserialize(deserializer)? {
1121            RawConnectorCapabilities::List(features) => {
1122                let mut capabilities = ConnectorCapabilities::default();
1123                for feature in features {
1124                    capabilities
1125                        .enable(&feature)
1126                        .map_err(serde::de::Error::custom)?;
1127                }
1128                Ok(capabilities)
1129            }
1130            RawConnectorCapabilities::Table(table) => Ok(table.into()),
1131        }
1132    }
1133}
1134
1135pub fn normalize_connector_capability(feature: &str) -> String {
1136    feature.trim().to_lowercase().replace('-', "_")
1137}
1138
1139#[derive(Debug, Clone, Default, Deserialize)]
1140pub struct ConnectorContractConfig {
1141    #[serde(default)]
1142    pub version: Option<u32>,
1143    #[serde(default)]
1144    pub fixtures: Vec<ConnectorContractFixture>,
1145}
1146
1147#[derive(Debug, Clone, Deserialize)]
1148pub struct ConnectorContractFixture {
1149    pub provider: harn_vm::ProviderId,
1150    #[serde(default)]
1151    pub name: Option<String>,
1152    #[serde(default)]
1153    pub kind: Option<String>,
1154    #[serde(default)]
1155    pub headers: BTreeMap<String, String>,
1156    #[serde(default)]
1157    pub query: BTreeMap<String, String>,
1158    #[serde(default)]
1159    pub metadata: Option<toml::Value>,
1160    #[serde(default)]
1161    pub body: Option<String>,
1162    #[serde(default)]
1163    pub body_json: Option<toml::Value>,
1164    #[serde(default)]
1165    pub expect_type: Option<String>,
1166    #[serde(default)]
1167    pub expect_kind: Option<String>,
1168    #[serde(default)]
1169    pub expect_dedupe_key: Option<String>,
1170    #[serde(default)]
1171    pub expect_signature_state: Option<String>,
1172    #[serde(default)]
1173    pub expect_payload_contains: Option<toml::Value>,
1174    #[serde(default)]
1175    pub expect_response_status: Option<u16>,
1176    #[serde(default)]
1177    pub expect_response_body: Option<toml::Value>,
1178    #[serde(default)]
1179    pub expect_event_count: Option<usize>,
1180    #[serde(default)]
1181    pub expect_error_contains: Option<String>,
1182}
1183
1184#[derive(Debug, Clone, PartialEq, Eq)]
1185pub enum ResolvedProviderConnectorKind {
1186    Harn { module: String },
1187    RustBuiltin,
1188    Invalid(String),
1189}
1190
1191#[derive(Debug, Clone)]
1192pub struct ResolvedProviderConnectorConfig {
1193    pub id: harn_vm::ProviderId,
1194    pub manifest_dir: PathBuf,
1195    pub connector: ResolvedProviderConnectorKind,
1196    pub oauth: Option<ProviderOAuthManifest>,
1197    pub setup: Option<ProviderSetupManifest>,
1198}
1199
1200#[derive(Debug, Clone)]
1201pub struct ResolvedHookConfig {
1202    pub event: harn_vm::orchestration::HookEvent,
1203    pub pattern: String,
1204    pub handler: String,
1205    pub manifest_dir: PathBuf,
1206    pub package_name: Option<String>,
1207    pub exports: HashMap<String, String>,
1208}
1209
1210#[derive(Debug, Clone)]
1211pub struct ResolvedTriggerConfig {
1212    pub id: String,
1213    pub kind: TriggerKind,
1214    pub provider: harn_vm::ProviderId,
1215    pub autonomy_tier: harn_vm::AutonomyTier,
1216    pub match_: TriggerMatchExpr,
1217    pub when: Option<String>,
1218    pub when_budget: Option<TriggerWhenBudgetSpec>,
1219    pub handler: String,
1220    pub dedupe_key: Option<String>,
1221    pub retry: TriggerRetrySpec,
1222    pub dispatch_priority: TriggerDispatchPriority,
1223    pub budget: TriggerBudgetSpec,
1224    pub concurrency: Option<TriggerConcurrencyManifestSpec>,
1225    pub throttle: Option<TriggerThrottleManifestSpec>,
1226    pub rate_limit: Option<TriggerRateLimitManifestSpec>,
1227    pub debounce: Option<TriggerDebounceManifestSpec>,
1228    pub singleton: Option<TriggerSingletonManifestSpec>,
1229    pub batch: Option<TriggerBatchManifestSpec>,
1230    pub window: Option<TriggerStreamWindowManifestSpec>,
1231    pub priority_flow: Option<TriggerPriorityManifestSpec>,
1232    pub secrets: BTreeMap<String, String>,
1233    pub filter: Option<String>,
1234    pub kind_specific: BTreeMap<String, toml::Value>,
1235    pub manifest_dir: PathBuf,
1236    pub manifest_path: PathBuf,
1237    pub package_name: Option<String>,
1238    pub exports: HashMap<String, String>,
1239    pub execution_guard: Option<Arc<harn_modules::package_execution::PackageExecutionGuard>>,
1240    pub table_index: usize,
1241    pub shape_error: Option<String>,
1242}
1243
1244#[derive(Debug, Clone)]
1245#[allow(dead_code)] // Collected bindings are validated now and consumed by harn#159 dispatcher work.
1246pub struct CollectedManifestTrigger {
1247    pub config: ResolvedTriggerConfig,
1248    pub handler: CollectedTriggerHandler,
1249    pub when: Option<CollectedTriggerPredicate>,
1250    pub flow_control: harn_vm::TriggerFlowControlConfig,
1251}
1252
1253#[derive(Debug, Clone)]
1254#[allow(dead_code)] // Remote targets and closures are retained for harn#159 trigger execution.
1255pub enum CollectedTriggerHandler {
1256    Local {
1257        reference: TriggerFunctionRef,
1258        callable: harn_vm::VmCallable,
1259    },
1260    A2a {
1261        target: String,
1262        allow_cleartext: bool,
1263    },
1264    Worker {
1265        queue: String,
1266    },
1267    Persona {
1268        binding: harn_vm::PersonaRuntimeBinding,
1269        callable: harn_vm::VmCallable,
1270    },
1271    EvalPack {
1272        target: String,
1273        manifest: Box<harn_vm::orchestration::EvalPackManifest>,
1274        ledger_options: Option<serde_json::Value>,
1275    },
1276}
1277#[derive(Debug, Clone)]
1278#[allow(dead_code)] // Predicate callables are validated now and reused by harn#161 dispatch gating.
1279pub struct CollectedTriggerPredicate {
1280    pub reference: TriggerFunctionRef,
1281    pub callable: harn_vm::VmCallable,
1282}
1283
1284pub(crate) type ManifestModuleCacheKey = (PathBuf, Option<String>, Option<String>);
1285pub(crate) type ManifestModuleExports = BTreeMap<String, Arc<harn_vm::VmClosure>>;
1286
1287static MANIFEST_PROVIDER_SCHEMA_LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
1288
1289pub(crate) async fn lock_manifest_provider_schemas() -> tokio::sync::MutexGuard<'static, ()> {
1290    MANIFEST_PROVIDER_SCHEMA_LOCK
1291        .get_or_init(|| tokio::sync::Mutex::new(()))
1292        .lock()
1293        .await
1294}
1295
1296fn llm_manifest_diagnostics(content: &str) -> Vec<harn_vm::llm_config::ProviderConfigDiagnostic> {
1297    let Ok(value) = toml::from_str::<toml::Value>(content) else {
1298        return Vec::new();
1299    };
1300    let Some(llm) = value.get("llm") else {
1301        return Vec::new();
1302    };
1303    let Ok(llm_src) = toml::to_string(llm) else {
1304        return Vec::new();
1305    };
1306    let Ok(parsed) = harn_vm::llm_config::parse_config_toml_with_diagnostics(&llm_src) else {
1307        return Vec::new();
1308    };
1309    parsed
1310        .diagnostics
1311        .into_iter()
1312        .map(|mut diagnostic| {
1313            if !diagnostic.path.is_empty() {
1314                diagnostic.path = format!("llm.{}", diagnostic.path);
1315            }
1316            diagnostic
1317        })
1318        .collect()
1319}
1320
1321pub(crate) fn read_manifest_from_path(path: &Path) -> Result<Manifest, PackageError> {
1322    let content = fs::read_to_string(path).map_err(|error| {
1323        if error.kind() == std::io::ErrorKind::NotFound {
1324            PackageError::Manifest(format!(
1325                "No {} found in {}.",
1326                MANIFEST,
1327                path.parent().unwrap_or_else(|| Path::new(".")).display()
1328            ))
1329        } else {
1330            PackageError::Manifest(format!("failed to read {}: {error}", path.display()))
1331        }
1332    })?;
1333    let manifest = toml::from_str::<Manifest>(&content).map_err(|error| {
1334        PackageError::Manifest(format!("failed to parse {}: {error}", path.display()))
1335    })?;
1336    for diagnostic in llm_manifest_diagnostics(&content) {
1337        eprintln!("[llm_config] warning in {}: {diagnostic}", path.display());
1338    }
1339    Ok(manifest)
1340}
1341
1342/// Load the `[workspace]` config and the directory of the `harn.toml`
1343/// it came from. Paths in the returned config are left as-is (callers
1344/// resolve them against the returned `manifest_dir`).
1345pub fn load_workspace_config(anchor: Option<&Path>) -> Option<(WorkspaceConfig, PathBuf)> {
1346    let anchor = anchor
1347        .map(Path::to_path_buf)
1348        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
1349    let (manifest, dir) = nearest_manifest_or_warn(&anchor)?;
1350    Some((manifest.workspace, dir))
1351}
1352
1353pub fn load_package_eval_pack_paths(anchor: Option<&Path>) -> Result<Vec<PathBuf>, PackageError> {
1354    let anchor = anchor
1355        .map(Path::to_path_buf)
1356        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
1357    let Some((manifest, dir)) = load_nearest_manifest(&anchor).into_result()? else {
1358        return Err(PackageError::Manifest(
1359            "no harn.toml found for package eval discovery".to_string(),
1360        ));
1361    };
1362
1363    let ctx = ManifestContext { manifest, dir };
1364    let mut paths = eval_pack_paths_from_manifest(&ctx.manifest, &ctx.dir)?;
1365    paths.extend(installed_package_eval_pack_paths(&ctx)?);
1366    paths.sort();
1367    paths.dedup();
1368    if paths.is_empty() {
1369        return Err(PackageError::Manifest(
1370            "package declares no eval packs; add [package].evals, harn.eval.toml, or install a dependency that ships eval packs".to_string(),
1371        ));
1372    }
1373    for path in &paths {
1374        if !path.is_file() {
1375            return Err(PackageError::Manifest(format!(
1376                "eval pack does not exist: {}",
1377                path.display()
1378            )));
1379        }
1380    }
1381    Ok(paths)
1382}
1383
1384fn eval_pack_paths_from_manifest(
1385    manifest: &Manifest,
1386    manifest_dir: &Path,
1387) -> Result<Vec<PathBuf>, PackageError> {
1388    let declared = manifest
1389        .package
1390        .as_ref()
1391        .map(|package| package.evals.clone())
1392        .unwrap_or_default();
1393    let paths = if declared.is_empty() {
1394        let default_pack = manifest_dir.join("harn.eval.toml");
1395        if default_pack.is_file() {
1396            vec![default_pack]
1397        } else {
1398            Vec::new()
1399        }
1400    } else {
1401        declared
1402            .iter()
1403            .map(|entry| {
1404                let path = PathBuf::from(entry);
1405                if path.is_absolute() {
1406                    path
1407                } else {
1408                    manifest_dir.join(path)
1409                }
1410            })
1411            .collect()
1412    };
1413    for path in &paths {
1414        if !path.is_file() {
1415            return Err(PackageError::Manifest(format!(
1416                "eval pack does not exist: {}",
1417                path.display()
1418            )));
1419        }
1420    }
1421    Ok(paths)
1422}
1423
1424fn installed_package_eval_pack_paths(ctx: &ManifestContext) -> Result<Vec<PathBuf>, PackageError> {
1425    let Some(snapshot) = dependency_package_snapshot(&ctx.manifest, &ctx.dir)? else {
1426        return Ok(Vec::new());
1427    };
1428    let lock = LockFile::load(snapshot.lock_path())?.ok_or_else(|| {
1429        PackageError::Lockfile(format!(
1430            "published package generation is missing {}",
1431            snapshot.lock_path().display()
1432        ))
1433    })?;
1434    let mut paths = Vec::new();
1435    let packages_dir = snapshot.packages_root();
1436    for entry in &lock.packages {
1437        validate_package_alias(&entry.name)?;
1438        let package_dir = packages_dir.join(&entry.name);
1439        if package_dir.is_dir() {
1440            if let Some(manifest) = read_package_manifest_from_dir(&package_dir)? {
1441                paths.extend(eval_pack_paths_from_manifest(&manifest, &package_dir)?);
1442            }
1443            continue;
1444        }
1445
1446        let package_file = packages_dir.join(format!("{}.harn", entry.name));
1447        if package_file.is_file() {
1448            continue;
1449        }
1450
1451        return Err(PackageError::Manifest(format!(
1452            "installed package {} is missing under {}; run `harn install`",
1453            entry.name,
1454            packages_dir.display()
1455        )));
1456    }
1457    Ok(paths)
1458}
1459
1460#[derive(Debug, Clone)]
1461pub(crate) struct ManifestContext {
1462    pub(crate) manifest: Manifest,
1463    pub(crate) dir: PathBuf,
1464}
1465
1466impl ManifestContext {
1467    pub(crate) fn manifest_path(&self) -> PathBuf {
1468        self.dir.join(MANIFEST)
1469    }
1470
1471    pub(crate) fn lock_path(&self) -> PathBuf {
1472        self.dir.join(LOCK_FILE)
1473    }
1474}
1475
1476#[cfg(test)]
1477mod tests {
1478    use super::*;
1479    use crate::package::test_support::{current_packages_dir, TestWorkspace};
1480
1481    #[test]
1482    fn rules_table_parses_camel_and_kebab_dir_keys() {
1483        // The documented `ruleDirs` camelCase form and the kebab alias both map
1484        // to `rule_dirs` (#2843).
1485        let camel: Manifest =
1486            toml::from_str("[rules]\nruleDirs = [\"rules\", \"vendor/rules\"]\n").unwrap();
1487        assert_eq!(camel.rules.rule_dirs, vec!["rules", "vendor/rules"]);
1488
1489        let kebab: Manifest = toml::from_str("[rules]\nrule-dirs = [\"r\"]\n").unwrap();
1490        assert_eq!(kebab.rules.rule_dirs, vec!["r"]);
1491
1492        let native: Manifest =
1493            toml::from_str("[rules]\nnativeRuleDirs = [\"native-rules\"]\n").unwrap();
1494        assert_eq!(native.rules.native_rule_dirs, vec!["native-rules"]);
1495
1496        let native_kebab: Manifest =
1497            toml::from_str("[rules]\nnative-rule-dirs = [\"nr\"]\n").unwrap();
1498        assert_eq!(native_kebab.rules.native_rule_dirs, vec!["nr"]);
1499
1500        // No `[rules]` table → empty discovery, never an error.
1501        let none: Manifest = toml::from_str("[package]\nname = \"x\"\n").unwrap();
1502        assert!(none.rules.rule_dirs.is_empty());
1503        assert!(none.rules.native_rule_dirs.is_empty());
1504    }
1505
1506    #[test]
1507    fn llm_manifest_diagnostics_report_unknown_model_fields() {
1508        let diagnostics = llm_manifest_diagnostics(
1509            r#"
1510[llm.models."demo/model"]
1511name = "Demo"
1512provider = "demo"
1513context_window = 4096
1514fast_mode = true
1515"#,
1516        );
1517        let texts: Vec<String> = diagnostics
1518            .into_iter()
1519            .map(|diagnostic| diagnostic.to_string())
1520            .collect();
1521        assert!(
1522            texts.iter().any(
1523                |diagnostic| diagnostic.contains("llm.models.demo/model.fast_mode")
1524                    && diagnostic.contains("serving_tiers")
1525            ),
1526            "expected manifest [llm] unknown-field diagnostic, got {texts:?}"
1527        );
1528    }
1529
1530    #[test]
1531    fn package_eval_pack_paths_use_package_manifest_entries() {
1532        let tmp = tempfile::tempdir().unwrap();
1533        let root = tmp.path();
1534        fs::create_dir_all(root.join(".git")).unwrap();
1535        fs::create_dir_all(root.join("evals")).unwrap();
1536        fs::write(
1537            root.join(MANIFEST),
1538            r#"
1539    [package]
1540    name = "demo"
1541    version = "0.1.0"
1542    evals = ["evals/webhook.toml"]
1543    "#,
1544        )
1545        .unwrap();
1546        fs::write(
1547            root.join("evals/webhook.toml"),
1548            "version = 1\n[[cases]]\nrun = \"run.json\"\n",
1549        )
1550        .unwrap();
1551
1552        let paths = load_package_eval_pack_paths(Some(&root.join("src/main.harn"))).unwrap();
1553
1554        assert_eq!(paths, vec![root.join("evals/webhook.toml")]);
1555        assert!(
1556            !root.join(".harn").exists(),
1557            "loading project eval packs without dependencies must remain read-only"
1558        );
1559    }
1560
1561    #[test]
1562    fn package_eval_pack_paths_include_installed_package_evals() {
1563        let dependency_tmp = tempfile::tempdir().unwrap();
1564        let dependency = dependency_tmp.path().join("coding-pack");
1565        fs::create_dir_all(dependency.join("evals")).unwrap();
1566        fs::write(
1567            dependency.join(MANIFEST),
1568            r#"
1569[package]
1570name = "coding-pack"
1571version = "0.1.0"
1572evals = ["evals/coding.toml"]
1573"#,
1574        )
1575        .unwrap();
1576        fs::write(
1577            dependency.join("evals/run.json"),
1578            serde_json::to_string_pretty(&serde_json::json!({
1579                "_type": "workflow_run",
1580                "id": "run_1",
1581                "workflow_id": "workflow_1",
1582                "status": "completed",
1583                "usage": {
1584                    "total_duration_ms": 12,
1585                    "total_cost": 0.01,
1586                    "input_tokens": 3,
1587                    "output_tokens": 4,
1588                    "call_count": 1,
1589                    "models": ["mock"]
1590                },
1591                "replay_fixture": {
1592                    "_type": "replay_fixture",
1593                    "expected_status": "completed"
1594                }
1595            }))
1596            .unwrap(),
1597        )
1598        .unwrap();
1599        fs::write(
1600            dependency.join("evals/coding.toml"),
1601            r#"
1602version = 1
1603id = "coding-pack"
1604trials = 2
1605
1606[package]
1607name = "coding-pack"
1608version = "0.1.0"
1609source = "path:test"
1610templates = ["templates/rubric.harn.prompt"]
1611
1612[metadata]
1613model = "mock-model"
1614commit = "commit-a"
1615
1616[[cases]]
1617id = "case-a"
1618run = "run.json"
1619rubrics = ["status"]
1620
1621[[rubrics]]
1622id = "status"
1623kind = "deterministic"
1624
1625[[rubrics.assertions]]
1626kind = "run-status"
1627expected = "completed"
1628"#,
1629        )
1630        .unwrap();
1631
1632        let helper = dependency_tmp.path().join("helper-lib");
1633        fs::create_dir_all(&helper).unwrap();
1634        fs::write(
1635            helper.join(MANIFEST),
1636            r#"
1637[package]
1638name = "helper-lib"
1639version = "0.1.0"
1640"#,
1641        )
1642        .unwrap();
1643
1644        let project_tmp = tempfile::tempdir().unwrap();
1645        let root = project_tmp.path();
1646        let workspace = TestWorkspace::new(root);
1647        fs::create_dir_all(root.join(".git")).unwrap();
1648        fs::write(
1649            root.join(MANIFEST),
1650            format!(
1651                r#"
1652[package]
1653name = "workspace"
1654version = "0.1.0"
1655
1656[dependencies]
1657coding-pack = {{ path = {} }}
1658helper-lib = {{ path = {} }}
1659"#,
1660                crate::format::toml_basic_string_literal(&dependency.display().to_string()),
1661                crate::format::toml_basic_string_literal(&helper.display().to_string())
1662            ),
1663        )
1664        .unwrap();
1665
1666        install_packages_in(workspace.env(), false, None, false).unwrap();
1667
1668        let paths = load_package_eval_pack_paths(Some(&root.join("src/main.harn"))).unwrap();
1669        assert_eq!(
1670            paths,
1671            vec![current_packages_dir(root)
1672                .join("coding-pack")
1673                .join("evals/coding.toml")]
1674        );
1675
1676        harn_vm::event_log::reset_active_event_log();
1677        let manifest = harn_vm::orchestration::load_eval_pack_manifest(&paths[0]).unwrap();
1678        let package = manifest.package.as_ref().expect("package descriptor");
1679        assert_eq!(package.name.as_deref(), Some("coding-pack"));
1680        assert_eq!(package.templates, vec!["templates/rubric.harn.prompt"]);
1681
1682        let report = harn_vm::orchestration::evaluate_eval_pack_manifest_resumable(
1683            &manifest,
1684            Some(serde_json::json!({
1685                "namespace": "installed-pack-evals",
1686                "suite": "coding-pack",
1687                "model": "mock-model",
1688                "commit": "commit-a",
1689                "branch": "main"
1690            })),
1691        )
1692        .unwrap();
1693        assert!(report.pass);
1694        assert_eq!(report.trial_count, 2);
1695        assert_eq!(report.run_state.ledger_rows_inserted, 2);
1696        assert_eq!(report.stats_rows.len(), 1);
1697        assert_eq!(report.stats_rows[0].trials, 2);
1698        assert!(!report.stats_rows[0].case_fingerprint.is_empty());
1699        assert_eq!(
1700            report.harness_config_fingerprint,
1701            report.stats_rows[0].harness_config_fingerprint
1702        );
1703
1704        let ledger = harn_vm::orchestration::eval_ledger_read_report(Some(serde_json::json!({
1705            "namespace": "installed-pack-evals",
1706            "suite": "coding-pack",
1707            "model": "mock-model",
1708            "commit": "commit-a"
1709        })))
1710        .unwrap();
1711        assert_eq!(ledger.rows.len(), 2);
1712        harn_vm::event_log::reset_active_event_log();
1713    }
1714    #[test]
1715    fn preflight_severity_parsing_accepts_synonyms() {
1716        assert_eq!(
1717            PreflightSeverity::from_opt(Some("warning")),
1718            PreflightSeverity::Warning
1719        );
1720        assert_eq!(
1721            PreflightSeverity::from_opt(Some("WARN")),
1722            PreflightSeverity::Warning
1723        );
1724        assert_eq!(
1725            PreflightSeverity::from_opt(Some("off")),
1726            PreflightSeverity::Off
1727        );
1728        assert_eq!(
1729            PreflightSeverity::from_opt(Some("allow")),
1730            PreflightSeverity::Off
1731        );
1732        assert_eq!(
1733            PreflightSeverity::from_opt(Some("error")),
1734            PreflightSeverity::Error
1735        );
1736        assert_eq!(PreflightSeverity::from_opt(None), PreflightSeverity::Error);
1737        // Unknown values fall back to the safe default (error).
1738        assert_eq!(
1739            PreflightSeverity::from_opt(Some("bogus")),
1740            PreflightSeverity::Error
1741        );
1742    }
1743
1744    #[test]
1745    fn load_check_config_walks_up_from_nested_file() {
1746        let tmp = tempfile::tempdir().unwrap();
1747        let root = tmp.path();
1748        // Mark root as project boundary so walk-up terminates here.
1749        std::fs::create_dir_all(root.join(".git")).unwrap();
1750        fs::write(
1751            root.join(MANIFEST),
1752            r#"
1753    [check]
1754    preflight_severity = "warning"
1755    preflight_allow = ["custom.scan", "runtime.*"]
1756    host_capabilities_path = "./schemas/host-caps.json"
1757
1758    [workspace]
1759    pipelines = ["pipelines", "scripts"]
1760    "#,
1761        )
1762        .unwrap();
1763        let nested = root.join("src").join("deep");
1764        std::fs::create_dir_all(&nested).unwrap();
1765        let harn_file = nested.join("pipeline.harn");
1766        fs::write(&harn_file, "pipeline main() {}\n").unwrap();
1767
1768        let cfg = load_check_config(Some(&harn_file));
1769        assert_eq!(cfg.preflight_severity.as_deref(), Some("warning"));
1770        assert_eq!(cfg.preflight_allow, vec!["custom.scan", "runtime.*"]);
1771        let caps_path = cfg.host_capabilities_path.expect("host caps path");
1772        assert!(
1773            caps_path.ends_with("schemas/host-caps.json")
1774                || caps_path.ends_with("schemas\\host-caps.json"),
1775            "unexpected absolutized path: {caps_path}"
1776        );
1777
1778        let (workspace, manifest_dir) =
1779            load_workspace_config(Some(&harn_file)).expect("workspace manifest");
1780        assert_eq!(workspace.pipelines, vec!["pipelines", "scripts"]);
1781        // Walk-up lands on the directory containing the harn.toml.
1782        assert_eq!(manifest_dir, root);
1783    }
1784
1785    #[test]
1786    fn toml_string_literal_escapes_all_basic_control_characters() {
1787        let literal = toml_string_literal("a\u{08}\t\n\u{0C}\r\"\\\u{07}z").unwrap();
1788        let parsed: toml::Value = toml::from_str(&format!("value = {literal}\n")).unwrap();
1789        assert_eq!(
1790            parsed.get("value").and_then(toml::Value::as_str),
1791            Some("a\u{08}\t\n\u{0C}\r\"\\\u{07}z")
1792        );
1793    }
1794
1795    #[test]
1796    fn orchestrator_drain_config_parses_defaults_and_overrides() {
1797        let default_manifest: Manifest = toml::from_str(
1798            r#"
1799    [package]
1800    name = "fixture"
1801    "#,
1802        )
1803        .unwrap();
1804        assert_eq!(default_manifest.orchestrator.drain.max_items, 1024);
1805        assert_eq!(default_manifest.orchestrator.drain.deadline_seconds, 30);
1806        assert_eq!(default_manifest.orchestrator.pumps.max_outstanding, 64);
1807
1808        let configured: Manifest = toml::from_str(
1809            r#"
1810    [package]
1811    name = "fixture"
1812
1813    [orchestrator]
1814    drain.max_items = 77
1815    drain.deadline_seconds = 12
1816    pumps.max_outstanding = 3
1817    "#,
1818        )
1819        .unwrap();
1820        assert_eq!(configured.orchestrator.drain.max_items, 77);
1821        assert_eq!(configured.orchestrator.drain.deadline_seconds, 12);
1822        assert_eq!(configured.orchestrator.pumps.max_outstanding, 3);
1823    }
1824
1825    #[test]
1826    fn load_check_config_stops_at_git_boundary() {
1827        let tmp = tempfile::tempdir().unwrap();
1828        // An ancestor harn.toml above .git must NOT be picked up.
1829        fs::write(
1830            tmp.path().join(MANIFEST),
1831            "[check]\npreflight_severity = \"off\"\n",
1832        )
1833        .unwrap();
1834        let project = tmp.path().join("project");
1835        std::fs::create_dir_all(project.join(".git")).unwrap();
1836        let inner = project.join("src");
1837        std::fs::create_dir_all(&inner).unwrap();
1838        let harn_file = inner.join("main.harn");
1839        fs::write(&harn_file, "pipeline main() {}\n").unwrap();
1840        let cfg = load_check_config(Some(&harn_file));
1841        assert!(
1842            cfg.preflight_severity.is_none(),
1843            "must not inherit harn.toml from outside the .git boundary"
1844        );
1845    }
1846}