diff --git a/crates/manifest/src/cascade.rs b/crates/manifest/src/cascade.rs deleted file mode 100644 index 718884b2..00000000 --- a/crates/manifest/src/cascade.rs +++ /dev/null @@ -1,125 +0,0 @@ -//! Cascade-layer collection helpers. -//! -//! Pod manifests are assembled from up to three on-disk layers (see -//! `pod::PodFactory` for the full cascade story): -//! -//! 1. **User manifest** — Pod CLI uses -//! [`crate::paths::user_manifest_path_with_env_override`] -//! 2. **Project manifest** at the closest `.insomnia/manifest.toml` -//! found by walking up from a starting directory (typically `cwd`) -//! 3. **Programmatic overlay** supplied at the call site -//! -//! This module owns the project-layer discovery and the parser glue. -//! User-layer path resolution lives in [`crate::paths`]. -//! -//! Cascade *merging* and final validation stay outside this module — -//! that's the data layer's responsibility (`PodManifestConfig::merge` -//! and `PodManifest::try_from`). This module only handles the I/O and -//! path-discovery glue around them. - -use std::path::{Path, PathBuf}; - -use crate::PodManifestConfig; - -/// Errors returned when reading a single manifest layer from disk. -#[derive(Debug, thiserror::Error)] -pub enum LayerLoadError { - #[error("failed to read manifest {}: {source}", .path.display())] - Io { - path: PathBuf, - #[source] - source: std::io::Error, - }, - #[error("failed to parse manifest {}: {source}", .path.display())] - Parse { - path: PathBuf, - #[source] - source: toml::de::Error, - }, -} - -/// Walk up from `start` looking for `.insomnia/manifest.toml`. Returns -/// the closest match, or `None` if none is found before reaching the -/// filesystem root. -pub fn find_project_manifest_from(start: &Path) -> Option { - let start = start - .canonicalize() - .ok() - .unwrap_or_else(|| start.to_path_buf()); - let mut cur: Option<&Path> = Some(start.as_path()); - while let Some(dir) = cur { - let candidate = dir.join(".insomnia").join("manifest.toml"); - if candidate.is_file() { - return Some(candidate); - } - cur = dir.parent(); - } - None -} - -/// Read a manifest file from `path` and parse it as a partial -/// [`PodManifestConfig`]. Path resolution against a base directory and -/// merging with other layers are the caller's responsibility. -pub fn load_layer(path: &Path) -> Result { - let toml = std::fs::read_to_string(path).map_err(|source| LayerLoadError::Io { - path: path.to_path_buf(), - source, - })?; - PodManifestConfig::from_toml(&toml).map_err(|source| LayerLoadError::Parse { - path: path.to_path_buf(), - source, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn find_project_manifest_walks_up() { - let tmp = TempDir::new().unwrap(); - let root = tmp.path().canonicalize().unwrap(); - let manifest = root.join(".insomnia").join("manifest.toml"); - std::fs::create_dir_all(manifest.parent().unwrap()).unwrap(); - std::fs::write(&manifest, "").unwrap(); - - let nested = root.join("a").join("b"); - std::fs::create_dir_all(&nested).unwrap(); - - let found = find_project_manifest_from(&nested).unwrap(); - assert_eq!(found, manifest); - } - - #[test] - fn find_project_manifest_returns_none_when_absent() { - let tmp = TempDir::new().unwrap(); - assert!(find_project_manifest_from(tmp.path()).is_none()); - } - - #[test] - fn load_layer_round_trips_partial_config() { - let tmp = TempDir::new().unwrap(); - let path = tmp.path().join("manifest.toml"); - std::fs::write( - &path, - r#" -[pod] -name = "from-disk" -"#, - ) - .unwrap(); - let cfg = load_layer(&path).unwrap(); - assert_eq!(cfg.pod.name.as_deref(), Some("from-disk")); - } - - #[test] - fn load_layer_io_error_carries_path() { - let bogus = PathBuf::from("/definitely/does/not/exist/manifest.toml"); - let err = load_layer(&bogus).unwrap_err(); - match err { - LayerLoadError::Io { path, .. } => assert_eq!(path, bogus), - _ => panic!("expected Io variant"), - } - } -} diff --git a/crates/manifest/src/config.rs b/crates/manifest/src/config.rs index 4756489b..c29f37e5 100644 --- a/crates/manifest/src/config.rs +++ b/crates/manifest/src/config.rs @@ -210,9 +210,9 @@ impl PodManifestConfig { }) } - /// Cascade layer populated with the in-code defaults listed in - /// [`crate::defaults`]. Used by [`PodFactory::resolve`] as the - /// bottom layer, so every per-field default lives at exactly one + /// Base config populated with the in-code defaults listed in + /// [`crate::defaults`]. Profile and one-file Manifest resolvers start + /// from this layer so every per-field default lives at exactly one /// call site (the `defaults` module). /// /// `TryFrom` also reads the same constants as a diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index bd99ba2f..bd47f46c 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -1,4 +1,3 @@ -mod cascade; mod config; pub mod defaults; mod model; @@ -6,7 +5,6 @@ pub mod paths; mod profile; mod scope; -pub use cascade::{LayerLoadError, find_project_manifest_from, load_layer}; pub use config::{ CompactionConfigPartial, FileUploadLimitsPartial, PermissionConfigPartial, PodManifestConfig, PodMetaConfig, ResolveError, SessionConfigPartial, ToolOutputLimitsPartial, @@ -15,10 +13,7 @@ pub use config::{ pub use model::{ AuthRef, ModelCapability, ModelManifest, ReasoningControl, ReasoningEffort, SchemeKind, }; -pub use paths::{ - user_manifest_path, user_manifest_path_from_env, user_manifest_path_with_env_override, - user_profiles_path, -}; +pub use paths::user_profiles_path; pub use profile::{ ProfileDiscovery, ProfileError, ProfileManifestSnapshot, ProfileMetadata, ProfileRegistry, ProfileRegistryEntry, ProfileRegistrySource, ProfileResolveOptions, ProfileResolver, @@ -76,17 +71,15 @@ pub struct PodManifest { pub skills: Option, /// Optional profile provenance for manifests produced by profile resolution. /// Stored only after profile resolution so Pod restore can prefer the - /// validated snapshot over ambient manifest cascade state. + /// validated snapshot over current profile files or one-file Manifest input. #[serde(default, skip_serializing_if = "Option::is_none")] pub profile: Option, } /// External Agent Skills (`SKILL.md`) ingest configuration. Skills are /// loaded *only* from the directories listed here — there is no -/// implicit `$config_dir/skills/` or builtin probe. Cascade-merged -/// across manifest layers, so a user-level manifest can declare a -/// shared skill root once while a project manifest adds its own -/// `.claude/skills/` / `.cursor/skills/` paths on top. +/// implicit `$config_dir/skills/` or builtin probe. Profile and Manifest +/// resolution may compose these entries before validation. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct SkillsConfig { /// Skills *roots*. Children of each root must be individual diff --git a/crates/manifest/src/paths.rs b/crates/manifest/src/paths.rs index 5a2d2d37..178e1dc6 100644 --- a/crates/manifest/src/paths.rs +++ b/crates/manifest/src/paths.rs @@ -2,7 +2,7 @@ //! //! 用途別に三つの base directory を持つ: //! -//! - **`config_dir`** — 人が手で書く / 編集する設定。`manifest.toml`, +//! - **`config_dir`** — 人が手で書く / 編集する設定。`profiles.toml`, //! `providers.toml`, `models.toml`, `prompts/`, `prompts.toml` 等 //! - **`data_dir`** — プログラムが書く永続データ。`sessions/` 等 //! - **`runtime_dir`** — 再起動で消えてよいランタイム状態。socket, @@ -23,20 +23,12 @@ //! 解決された各 base が存在するか / ディレクトリかは保証しない — //! 呼び出し側がファイル操作の前に作成 / 検査する。 -use std::ffi::OsString; use std::path::PathBuf; -/// Environment variable that points at an explicit user manifest. -/// -/// Pod CLI treats a non-empty value as an explicit manifest path. Empty values -/// are treated the same as an unset variable, so callers fall back to the -/// auto-discovered user manifest path. -pub const USER_MANIFEST_ENV: &str = "INSOMNIA_USER_MANIFEST"; - /// Environment variable that points at installed project resources. pub const RESOURCE_DIR_ENV: &str = "INSOMNIA_RESOURCE_DIR"; -/// 設定ディレクトリ。`manifest.toml`, `providers.toml`, `models.toml`, +/// 設定ディレクトリ。`profiles.toml`, `providers.toml`, `models.toml`, /// `prompts/` などが置かれる。 pub fn config_dir() -> Option { if let Some(p) = env_path("INSOMNIA_CONFIG_DIR") { @@ -80,42 +72,10 @@ pub fn runtime_dir() -> Option { // ---- well-known file getters ------------------------------------------------ -/// `/manifest.toml` — user manifest の既定位置。 -/// -/// This deliberately ignores [`USER_MANIFEST_ENV`]. Use -/// [`user_manifest_path_with_env_override`] when mirroring the Pod CLI cascade -/// resolution rules. -pub fn user_manifest_path() -> Option { - Some(config_dir()?.join("manifest.toml")) -} - -/// Resolve an explicit user manifest override from an env value. -/// -/// Non-empty values are paths. `None` and empty strings are both treated as no -/// override, matching the Pod CLI's `INSOMNIA_USER_MANIFEST` handling. -pub fn user_manifest_path_from_env(value: Option) -> Option { - value.and_then(|value| { - if value.as_os_str().is_empty() { - None - } else { - Some(PathBuf::from(value)) - } - }) -} - -/// User manifest path using the same env override rule as the Pod CLI cascade. -/// -/// A non-empty [`USER_MANIFEST_ENV`] value wins. If the variable is unset or -/// empty, this falls back to [`user_manifest_path`]. The returned path is not -/// guaranteed to exist. -pub fn user_manifest_path_with_env_override() -> Option { - user_manifest_path_from_env(std::env::var_os(USER_MANIFEST_ENV)).or_else(user_manifest_path) -} - /// `/profiles.toml` — user profile registry/default configuration. /// /// This is application/profile selection configuration, not a Pod manifest -/// layer. It deliberately ignores [`USER_MANIFEST_ENV`]. +/// layer. pub fn user_profiles_path() -> Option { Some(config_dir()?.join("profiles.toml")) } @@ -228,7 +188,6 @@ mod tests { "INSOMNIA_CONFIG_DIR", "INSOMNIA_DATA_DIR", "INSOMNIA_RUNTIME_DIR", - "INSOMNIA_USER_MANIFEST", "INSOMNIA_RESOURCE_DIR", "INSOMNIA_HOME", "XDG_CONFIG_HOME", @@ -355,44 +314,9 @@ mod tests { assert!(runtime_dir().is_none()); } - #[test] - fn user_manifest_env_override_wins_when_non_empty() { - let _g = EnvGuard::new(&[ - ("HOME", Some("/h")), - ("INSOMNIA_USER_MANIFEST", Some("/tmp/user.toml")), - ]); - assert_eq!( - user_manifest_path_with_env_override().unwrap(), - PathBuf::from("/tmp/user.toml") - ); - } - - #[test] - fn empty_user_manifest_env_falls_back_to_default_path() { - let _g = EnvGuard::new(&[("HOME", Some("/h")), ("INSOMNIA_USER_MANIFEST", Some(""))]); - assert_eq!( - user_manifest_path_with_env_override().unwrap(), - PathBuf::from("/h/.config/insomnia/manifest.toml") - ); - } - - #[test] - fn user_manifest_path_from_env_treats_empty_as_unset() { - assert_eq!(user_manifest_path_from_env(None), None); - assert_eq!(user_manifest_path_from_env(Some(OsString::from(""))), None); - assert_eq!( - user_manifest_path_from_env(Some(OsString::from("/tmp/u.toml"))).unwrap(), - PathBuf::from("/tmp/u.toml") - ); - } - #[test] fn well_known_files_compose_off_base_dirs() { let _g = EnvGuard::new(&[("INSOMNIA_HOME", Some("/sand"))]); - assert_eq!( - user_manifest_path().unwrap(), - PathBuf::from("/sand/config/manifest.toml") - ); assert_eq!( user_profiles_path().unwrap(), PathBuf::from("/sand/config/profiles.toml") diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index 9611bccc..ec82bd8c 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -1186,7 +1186,6 @@ mod tests { let lock = env_lock(); let names = [ "INSOMNIA_CONFIG_DIR", - "INSOMNIA_USER_MANIFEST", "INSOMNIA_RESOURCE_DIR", "INSOMNIA_HOME", "XDG_CONFIG_HOME", @@ -1456,61 +1455,6 @@ return profile { assert!(err.to_string().contains("Lua profiles must end in .lua")); } #[test] - fn for_cwd_reads_profiles_toml_and_ignores_manifest_profiles() { - let tmp = TempDir::new().unwrap(); - let config_dir = tmp.path().join("config"); - std::fs::create_dir_all(&config_dir).unwrap(); - let _env = EnvGuard::new(&[("INSOMNIA_CONFIG_DIR", Some(config_dir.to_str().unwrap()))]); - let project = tmp.path().join("project").join("nested"); - let insomnia = tmp.path().join("project").join(".insomnia"); - std::fs::create_dir_all(&project).unwrap(); - std::fs::create_dir_all(&insomnia).unwrap(); - std::fs::write( - insomnia.join("manifest.toml"), - "[profiles]\ndefault = \"wrong\"\n[profiles.profile]\nwrong = \"wrong.lua\"\n", - ) - .unwrap(); - std::fs::write( - insomnia.join("profiles.toml"), - "default = \"coder\"\n[profile]\ncoder = \"profiles/coder.lua\"\n", - ) - .unwrap(); - let registry = ProfileDiscovery::for_cwd(&project).discover().unwrap(); - assert!(registry.select_named(None, "wrong").is_err()); - let selected = registry.default_entry().unwrap(); - assert_eq!(selected.source, ProfileRegistrySource::Project); - assert_eq!(selected.name, "coder"); - } - #[test] - fn user_manifest_env_does_not_affect_profile_registry_discovery() { - let tmp = TempDir::new().unwrap(); - let config_dir = tmp.path().join("config"); - std::fs::create_dir_all(&config_dir).unwrap(); - let env_manifest = tmp.path().join("env-manifest.toml"); - std::fs::write( - &env_manifest, - "[profiles]\ndefault = \"wrong\"\n[profiles.profile]\nwrong = \"wrong.lua\"\n", - ) - .unwrap(); - std::fs::write( - config_dir.join("profiles.toml"), - "default = \"coder\"\n[profile]\ncoder = \"profiles/coder.lua\"\n", - ) - .unwrap(); - let _env = EnvGuard::new(&[ - ("INSOMNIA_CONFIG_DIR", Some(config_dir.to_str().unwrap())), - ( - "INSOMNIA_USER_MANIFEST", - Some(env_manifest.to_str().unwrap()), - ), - ]); - let registry = ProfileDiscovery::for_cwd(tmp.path()).discover().unwrap(); - assert!(registry.select_named(None, "wrong").is_err()); - let selected = registry.default_entry().unwrap(); - assert_eq!(selected.source, ProfileRegistrySource::User); - assert_eq!(selected.name, "coder"); - } - #[test] fn discovery_reads_user_and_project_registry_and_project_default_wins() { let tmp = TempDir::new().unwrap(); let user_config = tmp.path().join("profiles.toml"); diff --git a/crates/memory/src/workspace.rs b/crates/memory/src/workspace.rs index 86bc7d20..29c1e89b 100644 --- a/crates/memory/src/workspace.rs +++ b/crates/memory/src/workspace.rs @@ -3,7 +3,8 @@ //! `WorkspaceLayout` carries the workspace root (typically the Pod's //! pwd). All insomnia-managed content lives under the conventional //! `/.insomnia/` subdirectory — the same place that holds -//! `manifest.toml` and `prompts/`. The trees inside it: +//! `profiles.toml`, `prompts/`, workflow, knowledge, and generated +//! memory. The trees inside it: //! //! - `/.insomnia/workflow/.md` //! - `/.insomnia/knowledge/.md` diff --git a/crates/pod/src/factory.rs b/crates/pod/src/factory.rs deleted file mode 100644 index 4b69b908..00000000 --- a/crates/pod/src/factory.rs +++ /dev/null @@ -1,686 +0,0 @@ -//! Builder that assembles a [`PodManifest`] from cascade layers. -//! -//! Layers are merged in order of increasing priority: -//! 1. **Builtin defaults** — in-code defaults, currently empty. Upper -//! layers provide everything; `TryFrom` fills in -//! per-field defaults (`ToolOutputLimits`, `CompactionConfig`, ...). -//! 2. **User manifest** — `$XDG_CONFIG_HOME/insomnia/manifest.toml` -//! (falling back to `~/.config/insomnia/manifest.toml`). -//! 3. **Project manifest** — closest `.insomnia/manifest.toml` found by -//! walking up from `cwd`. -//! 4. **Programmatic overlay** — inline TOML string or typed -//! [`PodManifestConfig`] supplied by the caller (CLI flags, GUI, -//! spawning Pod, etc.). Highest priority. -//! -//! Path resolution happens **before** merge. Each layer is resolved -//! against its own base directory so that a relative `target = "."` -//! in the project manifest means the project root regardless of how -//! the user or overlay layers lay out their own paths: -//! -//! - user manifest: base = the directory holding the manifest file -//! (which is `manifest::paths::config_dir()` when loaded via the -//! `_auto` variant) -//! - project manifest: base = the **project root** (the parent of -//! `.insomnia/`, not `.insomnia/` itself) so that natural project -//! manifests with `target = "."` cover the whole workspace -//! - overlay: base = the process's `current_dir()` at the time the -//! overlay is installed, since an inline TOML string has no file -//! location of its own - -use std::path::{Path, PathBuf}; - -use manifest::{ - LayerLoadError, PodManifest, PodManifestConfig, ResolveError, find_project_manifest_from, - load_layer, paths, -}; - -use crate::prompt::loader::PromptLoader; - -/// Errors raised while building a [`PodManifest`] from cascade layers. -#[derive(Debug, thiserror::Error)] -pub enum FactoryError { - #[error("failed to read manifest {}: {source}", .path.display())] - Io { - path: PathBuf, - #[source] - source: std::io::Error, - }, - #[error("failed to parse manifest {}: {source}", .path.display())] - Parse { - path: PathBuf, - #[source] - source: toml::de::Error, - }, - #[error("failed to parse overlay TOML: {0}")] - OverlayParse(#[source] toml::de::Error), - #[error("failed to resolve manifest config: {0}")] - Resolve(#[source] ResolveError), -} - -impl From for FactoryError { - fn from(e: LayerLoadError) -> Self { - match e { - LayerLoadError::Io { path, source } => Self::Io { path, source }, - LayerLoadError::Parse { path, source } => Self::Parse { path, source }, - } - } -} - -/// Builder that accumulates cascade layers and resolves them to a -/// validated [`PodManifest`]. -/// -/// Call order does not matter — layers are always merged in the fixed -/// priority order listed at the module level. Calling the same -/// `with_*` method twice overwrites the previous value for that slot. -#[derive(Debug, Default)] -pub struct PodFactory { - /// User layer paired with the directory the manifest lives in - /// (base for resolving its relative paths). - user: Option<(PodManifestConfig, PathBuf)>, - /// Project layer paired with the directory the manifest lives in. - project: Option<(PodManifestConfig, PathBuf)>, - /// Programmatic overlays are resolved against the process's - /// `current_dir()` at the time each call arrives, then merged into - /// this slot. Storing a pre-resolved (absolute-paths) config means - /// later overlay calls from a different cwd still work correctly. - overlay: Option, - /// Directory holding the user prompts library — co-located with - /// the user manifest when loaded. `/prompts/`. - user_prompts_dir: Option, - /// `/.insomnia/prompts/` — co-located with the - /// project manifest when loaded. - project_prompts_dir: Option, - /// `/prompts.toml`, sibling of the user - /// prompts library. Consumed by the prompt catalog's user layer. - user_pack_file: Option, - /// `/.insomnia/prompts.toml`, sibling of the project - /// prompts library. Consumed by the prompt catalog's workspace layer. - project_pack_file: Option, -} - -impl PodFactory { - pub fn new() -> Self { - Self::default() - } - - /// Attempt to load the user manifest from the user's config - /// directory (see [`manifest::paths::config_dir`] for how the path - /// is resolved). If the resolved file does not exist, the call is a - /// no-op — user manifests are optional. - pub fn with_user_manifest_auto(mut self) -> Result { - let Some(path) = paths::user_manifest_path() else { - return Ok(self); - }; - if path.exists() { - let base = manifest_base(&path)?; - self.user = Some((load_layer(&path)?, base.clone())); - self.user_prompts_dir = paths::user_prompts_dir(); - self.user_pack_file = paths::user_pack_file(); - } - Ok(self) - } - - /// Load the user manifest from an explicit path. The file must - /// exist; missing files are an error (unlike the `_auto` variant). - pub fn with_user_manifest(mut self, path: impl AsRef) -> Result { - let path = path.as_ref(); - let base = manifest_base(path)?; - self.user = Some((load_layer(path)?, base.clone())); - self.user_prompts_dir = Some(base.join("prompts")); - self.user_pack_file = Some(base.join("prompts.toml")); - Ok(self) - } - - /// Walk up from `cwd` looking for a `.insomnia/manifest.toml` and - /// load it as the project layer. If no project root is found the - /// call is a no-op. - pub fn with_project_manifest_auto(mut self) -> Result { - let cwd = std::env::current_dir().map_err(|source| FactoryError::Io { - path: PathBuf::from("."), - source, - })?; - if let Some(path) = find_project_manifest_from(&cwd) { - self.install_project_manifest(&path)?; - } - Ok(self) - } - - /// Walk up from `start` looking for a `.insomnia/manifest.toml`. - /// Explicit variant of [`with_project_manifest_auto`] for tests. - pub fn with_project_manifest_from( - mut self, - start: impl AsRef, - ) -> Result { - if let Some(path) = find_project_manifest_from(start.as_ref()) { - self.install_project_manifest(&path)?; - } - Ok(self) - } - - /// Shared setup for `with_project_manifest_auto` / `_from`: record - /// the manifest's project root as the base for relative-path - /// resolution (the parent of `.insomnia/`, not `.insomnia/` itself) - /// so `target = "."` in a project manifest means the project root. - /// `prompts/` still lives inside `.insomnia/`. - fn install_project_manifest(&mut self, path: &Path) -> Result<(), FactoryError> { - let insomnia_dir = manifest_base(path)?; - let project_root = insomnia_dir - .parent() - .map(Path::to_path_buf) - .unwrap_or_else(|| insomnia_dir.clone()); - self.project = Some((load_layer(path)?, project_root)); - self.project_prompts_dir = Some(insomnia_dir.join("prompts")); - self.project_pack_file = Some(insomnia_dir.join("prompts.toml")); - Ok(()) - } - - /// Install a programmatic overlay parsed from a TOML string. Any - /// relative paths in the overlay are resolved against the process's - /// current working directory at the time of this call — an inline - /// TOML string has no file location of its own. - pub fn with_overlay_toml(mut self, toml: &str) -> Result { - let config = PodManifestConfig::from_toml(toml).map_err(FactoryError::OverlayParse)?; - self.overlay = Some(resolve_and_merge_overlay(self.overlay, config)?); - Ok(self) - } - - /// Install a programmatic overlay from an already-parsed config. - /// Behaves like [`Self::with_overlay_toml`] regarding relative paths. - pub fn with_overlay_config(mut self, config: PodManifestConfig) -> Result { - self.overlay = Some(resolve_and_merge_overlay(self.overlay, config)?); - Ok(self) - } - - /// Build a [`PromptLoader`] that reflects the user / project - /// prompt directories registered with this factory (a sibling of - /// each manifest file: `prompts/`). Missing directories are - /// silently skipped. - fn build_prompt_loader(&self) -> PromptLoader { - let user = self - .user_prompts_dir - .as_ref() - .filter(|p| p.is_dir()) - .cloned(); - let project = self - .project_prompts_dir - .as_ref() - .filter(|p| p.is_dir()) - .cloned(); - // Pack file filters: `.is_file()` keeps the loader's view - // consistent with the catalog loader, which skips missing packs - // silently. An existing but non-file path (e.g. a directory - // named `prompts.toml`) is also elided here and will surface - // only when a manifest pack explicitly references it. - let user_pack = self - .user_pack_file - .as_ref() - .filter(|p| p.is_file()) - .cloned(); - let project_pack = self - .project_pack_file - .as_ref() - .filter(|p| p.is_file()) - .cloned(); - PromptLoader::new(user, project).with_pack_files(user_pack, project_pack) - } - - /// Merge all installed layers, convert the result to a validated - /// [`PodManifest`], and return it together with a [`PromptLoader`] - /// that reflects the user / project prompt directories. The loader - /// feeds `{% include "name" %}` references in the Pod's system - /// prompt template. - /// - /// Each layer is resolved to absolute paths against its own base - /// (see module docs) **before** merge, so scope rules and - /// `api_key_file` paths from different layers do not accidentally - /// inherit another layer's base. - /// - /// The base layer is [`PodManifestConfig::builtin_defaults`] so - /// every per-field default flows through a single source of truth - /// (see [`manifest::defaults`]). - pub fn resolve(self) -> Result<(PodManifest, PromptLoader), FactoryError> { - let loader = self.build_prompt_loader(); - let merged = PodManifestConfig::builtin_defaults(); - let merged = match self.user { - Some((user, base)) => merged.merge(user.resolve_paths(&base)), - None => merged, - }; - let merged = match self.project { - Some((project, base)) => merged.merge(project.resolve_paths(&base)), - None => merged, - }; - let merged = match self.overlay { - Some(overlay) => merged.merge(overlay), - None => merged, - }; - let manifest = PodManifest::try_from(merged).map_err(FactoryError::Resolve)?; - Ok((manifest, loader)) - } -} - -fn manifest_base(path: &Path) -> Result { - let parent = path.parent().ok_or_else(|| FactoryError::Io { - path: path.to_path_buf(), - source: std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "manifest path has no parent directory", - ), - })?; - // Absolutise against cwd so later path joins produce absolute - // results regardless of whether the caller passed a relative - // manifest path. - if parent.is_absolute() { - Ok(parent.to_path_buf()) - } else { - let cwd = std::env::current_dir().map_err(|source| FactoryError::Io { - path: PathBuf::from("."), - source, - })?; - Ok(cwd.join(parent)) - } -} - -fn resolve_and_merge_overlay( - existing: Option, - incoming: PodManifestConfig, -) -> Result { - let cwd = std::env::current_dir().map_err(|source| FactoryError::Io { - path: PathBuf::from("."), - source, - })?; - let resolved = incoming.resolve_paths(&cwd); - Ok(match existing { - Some(prev) => prev.merge(resolved), - None => resolved, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - fn write(path: &Path, contents: &str) { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).unwrap(); - } - std::fs::write(path, contents).unwrap(); - } - - #[test] - fn resolve_overlay_only() { - let tmp = TempDir::new().unwrap(); - let pwd = tmp.path().canonicalize().unwrap(); - let overlay = format!( - r#" -[pod] -name = "solo" - -[model] -scheme = "anthropic" -model_id = "claude-sonnet-4-20250514" - -[[scope.allow]] -target = "{pwd}" -permission = "write" -"#, - pwd = pwd.display() - ); - let manifest = PodFactory::new() - .with_overlay_toml(&overlay) - .unwrap() - .resolve() - .unwrap(); - let manifest = manifest.0; - assert_eq!(manifest.pod.name, "solo"); - } - - #[test] - fn overlay_stacking_merges_in_place() { - let tmp = TempDir::new().unwrap(); - let pwd = tmp.path().canonicalize().unwrap(); - let user_cfg = PodManifestConfig::from_toml(&format!( - r#" -[model] -scheme = "anthropic" -model_id = "user-model" - -[[scope.allow]] -target = "{pwd}" -permission = "read" -"#, - pwd = pwd.display() - )) - .unwrap(); - let project_cfg = PodManifestConfig::from_toml(&format!( - r#" -[model] -model_id = "project-model" - -[[scope.allow]] -target = "{pwd}" -permission = "write" -"#, - pwd = pwd.display() - )) - .unwrap(); - let overlay_cfg = PodManifestConfig::from_toml( - r#" -[pod] -name = "overlay-name" -"#, - ) - .unwrap(); - - let (manifest, _loader) = PodFactory::new() - .with_overlay_config(user_cfg) - .unwrap() - .with_overlay_config(project_cfg) - .unwrap() - .with_overlay_config(overlay_cfg) - .unwrap() - .resolve() - .unwrap(); - - // Note: stacking via with_overlay_config merges into one - // overlay layer so later calls win. This also exercises the - // scope union across layers (two allow rules). - assert_eq!(manifest.pod.name, "overlay-name"); - assert_eq!(manifest.model.model_id.as_deref(), Some("project-model")); - assert_eq!(manifest.scope.allow.len(), 2); - } - - #[test] - fn cascade_priority_layer_ordering() { - let tmp = TempDir::new().unwrap(); - let pwd = tmp.path().canonicalize().unwrap(); - - // Simulate distinct user / project / overlay layers by using - // the dedicated slots on the factory. - let user = tmp.path().join("user.toml"); - write( - &user, - &format!( - r#" -[pod] -name = "from-user" - -[model] -scheme = "anthropic" -model_id = "user-model" - -[[scope.allow]] -target = "{pwd}" -permission = "write" -"#, - pwd = pwd.display() - ), - ); - - let project_root = tmp.path().join("proj"); - let project_manifest = project_root.join(".insomnia").join("manifest.toml"); - write( - &project_manifest, - r#" -[model] -model_id = "project-model" -"#, - ); - - let (manifest, _loader) = PodFactory::new() - .with_user_manifest(&user) - .unwrap() - .with_project_manifest_from(&project_root) - .unwrap() - .resolve() - .unwrap(); - - // project layer overrides user layer on model.model_id - assert_eq!(manifest.model.model_id.as_deref(), Some("project-model")); - // user layer provides the rest - assert_eq!(manifest.pod.name, "from-user"); - } - - #[test] - fn project_manifest_walks_up_from_nested_dir() { - let tmp = TempDir::new().unwrap(); - let root = tmp.path().canonicalize().unwrap(); - let project_manifest = root.join(".insomnia").join("manifest.toml"); - write( - &project_manifest, - &format!( - r#" -[pod] -name = "walked-up" - -[model] -scheme = "anthropic" -model_id = "claude-sonnet-4-20250514" - -[[scope.allow]] -target = "{root}" -permission = "write" -"#, - root = root.display() - ), - ); - - let nested = root.join("a").join("b").join("c"); - std::fs::create_dir_all(&nested).unwrap(); - - let manifest = PodFactory::new() - .with_project_manifest_from(&nested) - .unwrap() - .resolve() - .unwrap(); - let manifest = manifest.0; - assert_eq!(manifest.pod.name, "walked-up"); - } - - #[test] - fn missing_project_root_is_ok() { - let tmp = TempDir::new().unwrap(); - let pwd = tmp.path().canonicalize().unwrap(); - let overlay = format!( - r#" -[pod] -name = "standalone" - -[model] -scheme = "anthropic" -model_id = "m" - -[[scope.allow]] -target = "{pwd}" -permission = "write" -"#, - pwd = pwd.display() - ); - - // The temp dir has no .insomnia/ — walking up should skip the - // project layer silently. - let manifest = PodFactory::new() - .with_project_manifest_from(&pwd) - .unwrap() - .with_overlay_toml(&overlay) - .unwrap() - .resolve() - .unwrap(); - let manifest = manifest.0; - assert_eq!(manifest.pod.name, "standalone"); - } - - #[test] - fn user_manifest_relative_paths_resolve_against_its_directory() { - // user manifest at /cfg/manifest.toml with a relative - // scope target `./workspace` must resolve to /cfg/workspace. - let tmp = TempDir::new().unwrap(); - let root = tmp.path().canonicalize().unwrap(); - let cfg_dir = root.join("cfg"); - std::fs::create_dir_all(&cfg_dir).unwrap(); - let workspace = cfg_dir.join("workspace"); - std::fs::create_dir_all(&workspace).unwrap(); - - let user = cfg_dir.join("manifest.toml"); - write( - &user, - r#" -[pod] -name = "rel-user" - -[model] -scheme = "anthropic" -model_id = "m" - -[[scope.allow]] -target = "./workspace" -permission = "write" -"#, - ); - - let (manifest, _loader) = PodFactory::new() - .with_user_manifest(&user) - .unwrap() - .resolve() - .unwrap(); - assert_eq!(manifest.scope.allow[0].target, workspace); - } - - #[test] - fn project_manifest_relative_paths_resolve_against_project_root() { - // `.insomnia/manifest.toml` is the marker for the project, but - // the intuitive base for its relative paths is the project - // root (the parent of `.insomnia/`) — `target = "."` in a - // project manifest should cover the whole workspace, not the - // `.insomnia/` subdir. - let tmp = TempDir::new().unwrap(); - let root = tmp.path().canonicalize().unwrap(); - let insomnia_dir = root.join(".insomnia"); - std::fs::create_dir_all(&insomnia_dir).unwrap(); - let project_manifest = insomnia_dir.join("manifest.toml"); - write( - &project_manifest, - r#" -[pod] -name = "rel-project" - -[model] -scheme = "anthropic" -model_id = "m" - -[[scope.allow]] -target = "." -permission = "read" - -[[scope.allow]] -target = "src" -permission = "write" -"#, - ); - - let (manifest, _loader) = PodFactory::new() - .with_project_manifest_from(&root) - .unwrap() - .resolve() - .unwrap(); - assert_eq!(manifest.scope.allow[0].target, root); - assert_eq!(manifest.scope.allow[1].target, root.join("src")); - } - - #[test] - fn resolve_produces_loader_with_workspace_prompts_dir() { - use crate::prompt::system::{SystemPromptContext, SystemPromptTemplate}; - use manifest::{Permission, Scope, ScopeConfig, ScopeRule}; - - let tmp = TempDir::new().unwrap(); - let root = tmp.path().canonicalize().unwrap(); - // .insomnia/manifest.toml and .insomnia/prompts/local.md - let manifest_path = root.join(".insomnia").join("manifest.toml"); - write( - &manifest_path, - &format!( - r#" -[pod] -name = "factory-pod" - -[model] -scheme = "anthropic" -model_id = "m" - -[[scope.allow]] -target = "{root}" -permission = "write" -"#, - root = root.display() - ), - ); - let workspace_prompts_dir = root.join(".insomnia").join("prompts"); - std::fs::create_dir_all(&workspace_prompts_dir).unwrap(); - std::fs::write( - workspace_prompts_dir.join("local.md"), - "WORKSPACE-BODY from {{ cwd }}", - ) - .unwrap(); - - let (_manifest, loader) = PodFactory::new() - .with_project_manifest_from(&root) - .unwrap() - .resolve() - .unwrap(); - - // The workspace prompt must be reachable via $workspace/local. - let tmpl = SystemPromptTemplate::parse("$workspace/local", loader).unwrap(); - let scope_cfg = ScopeConfig { - allow: vec![ScopeRule { - target: root.clone(), - permission: Permission::Write, - recursive: true, - }], - deny: Vec::new(), - }; - let scope = Scope::from_config(&scope_cfg).unwrap(); - let catalog = crate::prompt::catalog::PromptCatalog::builtins_only().unwrap(); - let ctx = SystemPromptContext { - now: chrono::Utc::now(), - cwd: &root, - language: manifest::defaults::WORKER_LANGUAGE, - scope: &scope, - tool_names: Vec::new(), - agents_md: None, - resident_summary: None, - resident_knowledge: None, - resident_workflows: None, - prompts: &catalog, - }; - let rendered = tmpl.render(&ctx).unwrap(); - assert!( - rendered.starts_with("WORKSPACE-BODY"), - "expected workspace body, got: {rendered}" - ); - } - - #[test] - fn resolve_fails_on_missing_required_field() { - let tmp = TempDir::new().unwrap(); - let pwd = tmp.path().canonicalize().unwrap(); - // pod.name missing — resolver must reject. - let overlay = format!( - r#" -[model] -scheme = "anthropic" -model_id = "m" - -[[scope.allow]] -target = "{pwd}" -permission = "write" -"#, - pwd = pwd.display() - ); - let err = PodFactory::new() - .with_overlay_toml(&overlay) - .unwrap() - .resolve() - .unwrap_err(); - assert!(matches!(err, FactoryError::Resolve(_))); - } -} diff --git a/crates/pod/src/lib.rs b/crates/pod/src/lib.rs index ba819360..ad0cfefc 100644 --- a/crates/pod/src/lib.rs +++ b/crates/pod/src/lib.rs @@ -11,14 +11,12 @@ pub mod shared_state; pub mod spawn; pub mod workflow; -mod factory; mod interrupt_prep; mod permission; mod pod; pub use compact::token_counter::{EstimateSource, SplitPoint, TokenEstimate}; pub use controller::{PodController, PodHandle, ShutdownReceiver}; -pub use factory::{FactoryError, PodFactory}; pub use hook::{Hook, HookEventKind, HookRegistryBuilder}; pub use ipc::alerter::Alerter; pub use ipc::server::SocketServer; diff --git a/crates/pod/src/pod.rs b/crates/pod/src/pod.rs index e4f039ac..b671d6d9 100644 --- a/crates/pod/src/pod.rs +++ b/crates/pod/src/pod.rs @@ -3911,8 +3911,7 @@ where /// Restore a Pod from an existing session log. /// - /// Resolves the manifest cascade exactly like [`Self::from_manifest`] - /// (pwd / scope / pod-registry / client / prompt catalog), seeds a + /// Uses the resolved manifest supplied by the caller, seeds a /// fresh Worker from the source session's `RestoredState`, and /// reuses the same `segment_id` so subsequent turns append to the /// source jsonl as a continuation of the same conversation. @@ -4634,7 +4633,7 @@ pub enum PodError { /// Bundle of resources that every high-level Pod constructor needs: /// pwd, scope, an LLM client, the prompt catalog, and (optionally) a /// parsed system-prompt template. Built once by [`prepare_pod_common`] -/// from the manifest cascade and then split into Pod fields. +/// from the resolved manifest and then split into Pod fields. struct PodCommon { pwd: PathBuf, scope: Scope, @@ -4711,7 +4710,7 @@ fn delegated_write_rule_to_deny(rule: PodSpawnedScopeRule) -> Option } /// Resolve pwd / scope / LLM client / prompt catalog from a validated -/// manifest cascade. Used by `from_manifest`, `from_manifest_spawned`, +/// manifest. Used by `from_manifest`, `from_manifest_spawned`, /// and `restore_from_manifest` so they share one definition of "what /// pieces fall out of a manifest". /// diff --git a/crates/pod/src/prompt/catalog.rs b/crates/pod/src/prompt/catalog.rs index 702fa392..e3c25410 100644 --- a/crates/pod/src/prompt/catalog.rs +++ b/crates/pod/src/prompt/catalog.rs @@ -16,10 +16,10 @@ //! //! 1. **builtin** — `resources/prompts/internal.toml`, baked into the //! binary. Must cover every [`PodPrompt`] variant (build-time check). -//! 2. **user** — `/prompts.toml`, auto-discovered by -//! [`PodFactory`]. Optional. -//! 3. **workspace** — `/.insomnia/prompts.toml`, auto-discovered. +//! 2. **user** — `/prompts.toml`, when a caller supplies it. //! Optional. +//! 3. **workspace** — `/.insomnia/prompts.toml`, when a caller +//! supplies it. Optional. //! 4. **manifest pack** — `manifest.pod.prompt_pack`, an explicit path //! per-Pod. Optional. //! @@ -270,7 +270,7 @@ impl PromptCatalog { /// - Layer 2 (user): `loader.user_pack_file()` if present. /// - Layer 3 (workspace): `loader.workspace_pack_file()` if present. /// - Layer 4 (manifest): `manifest_pack` as an absolute filesystem - /// path (pre-resolved by the manifest cascade). + /// path (pre-resolved by profile/manifest resolution). pub fn load( loader: &PromptLoader, manifest_pack: Option<&Path>, diff --git a/crates/pod/src/prompt/loader.rs b/crates/pod/src/prompt/loader.rs index 76b8473d..fd9a1723 100644 --- a/crates/pod/src/prompt/loader.rs +++ b/crates/pod/src/prompt/loader.rs @@ -138,9 +138,8 @@ impl PromptLoader { } } - /// Override the auto-discovered pack file paths. Used by - /// [`crate::PodFactory`] to surface `/prompts.toml` - /// and `/.insomnia/prompts.toml`. + /// Override pack file paths supplied by the caller's profile/manifest + /// resolution context. pub fn with_pack_files( mut self, user_pack_file: Option,