Skip to main content

harn_cli/package/
manifest.rs

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