From f8fe6f83aa1a573f9bc816bb5c78e5b54d75a9db Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 27 Apr 2026 21:45:30 +0900 Subject: [PATCH] =?UTF-8?q?home-dir=E3=81=AE=E6=95=B4=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO.md | 1 + crates/manifest/src/cascade.rs | 56 +--- crates/manifest/src/lib.rs | 6 +- crates/manifest/src/paths.rs | 330 ++++++++++++++++++++++++ crates/pod/src/factory.rs | 20 +- crates/pod/src/main.rs | 39 +-- crates/pod/src/runtime/dir.rs | 31 +-- crates/pod/src/runtime/scope_lock.rs | 120 +++++---- crates/pod/tests/pod_comm_tools_test.rs | 49 +++- crates/pod/tests/pod_events_test.rs | 65 +++-- crates/pod/tests/spawn_pod_test.rs | 24 +- crates/provider/src/catalog.rs | 84 +++--- crates/tui/src/main.rs | 19 +- tickets/worker-generation-settings.md | 51 ++++ 14 files changed, 635 insertions(+), 260 deletions(-) create mode 100644 crates/manifest/src/paths.rs create mode 100644 tickets/worker-generation-settings.md diff --git a/TODO.md b/TODO.md index a0f7db0d..6d9ce196 100644 --- a/TODO.md +++ b/TODO.md @@ -19,4 +19,5 @@ - [ ] Phase 2 consolidation → [tickets/memory-phase2-consolidation.md](tickets/memory-phase2-consolidation.md) - [ ] 使用頻度メトリクス + Knowledge 化候補レポート → [tickets/memory-usage-metrics.md](tickets/memory-usage-metrics.md) - [ ] GC(定期再評価) → [tickets/memory-gc.md](tickets/memory-gc.md) +- [ ] LLM 生成設定の manifest 露出整理 → [tickets/worker-generation-settings.md](tickets/worker-generation-settings.md) - [ ] モデル reasoning/thinking 制御の内部抽象整理 → [tickets/model-reasoning-control.md](tickets/model-reasoning-control.md) diff --git a/crates/manifest/src/cascade.rs b/crates/manifest/src/cascade.rs index 4c24c8bf..5f5f1463 100644 --- a/crates/manifest/src/cascade.rs +++ b/crates/manifest/src/cascade.rs @@ -3,16 +3,13 @@ //! Pod manifests are assembled from up to three on-disk layers (see //! `pod::PodFactory` for the full cascade story): //! -//! 1. **User manifest** at `$XDG_CONFIG_HOME/insomnia/manifest.toml`, -//! falling back to `$HOME/.config/insomnia/manifest.toml` +//! 1. **User manifest** — see [`crate::paths::user_manifest_path`] //! 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 conventions for (1) and (2): where each file -//! lives and how to parse it. Callers (pod's factory, the TUI's spawn -//! UI, future GUI flows) all share these helpers so the conventions -//! live in one place. +//! 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` @@ -40,28 +37,6 @@ pub enum LayerLoadError { }, } -/// Conventional path of the user manifest: -/// `$XDG_CONFIG_HOME/insomnia/manifest.toml`, falling back to -/// `$HOME/.config/insomnia/manifest.toml`. Returns `None` when neither -/// env var is set to a non-empty value. -/// -/// Existence of the file is **not** checked here — callers decide -/// whether a missing file is an error or a silent skip. -pub fn user_manifest_path() -> Option { - if let Ok(dir) = std::env::var("XDG_CONFIG_HOME") { - if !dir.is_empty() { - return Some(PathBuf::from(dir).join("insomnia").join("manifest.toml")); - } - } - let home = std::env::var("HOME").ok().filter(|s| !s.is_empty())?; - Some( - PathBuf::from(home) - .join(".config") - .join("insomnia") - .join("manifest.toml"), - ) -} - /// Walk up from `start` looking for `.insomnia/manifest.toml`. Returns /// the closest match, or `None` if none is found before reaching the /// filesystem root. @@ -147,29 +122,4 @@ name = "from-disk" } } - #[test] - fn user_manifest_path_uses_xdg_when_set() { - let saved_xdg = std::env::var("XDG_CONFIG_HOME").ok(); - let saved_home = std::env::var("HOME").ok(); - // SAFETY: tests in this module are not run concurrently with - // env-mutating threads (cargo's default test harness already - // serialises tests inside one binary, and these helpers don't - // spawn threads of their own). - unsafe { - std::env::set_var("XDG_CONFIG_HOME", "/tmp/xdg-conf"); - std::env::remove_var("HOME"); - } - let p = user_manifest_path().unwrap(); - assert_eq!(p, PathBuf::from("/tmp/xdg-conf/insomnia/manifest.toml")); - unsafe { - match saved_xdg { - Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), - None => std::env::remove_var("XDG_CONFIG_HOME"), - } - match saved_home { - Some(v) => std::env::set_var("HOME", v), - None => std::env::remove_var("HOME"), - } - } - } } diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 7909c286..4f4a5e6d 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -2,11 +2,11 @@ mod cascade; mod config; pub mod defaults; mod model; +pub mod paths; mod scope; -pub use cascade::{ - LayerLoadError, find_project_manifest_from, load_layer, user_manifest_path, -}; +pub use cascade::{LayerLoadError, find_project_manifest_from, load_layer}; +pub use paths::user_manifest_path; pub use config::{ CompactionConfigPartial, PodManifestConfig, PodMetaConfig, ResolveError, ToolOutputLimitsPartial, WorkerManifestConfig, diff --git a/crates/manifest/src/paths.rs b/crates/manifest/src/paths.rs new file mode 100644 index 00000000..37fa5241 --- /dev/null +++ b/crates/manifest/src/paths.rs @@ -0,0 +1,330 @@ +//! Insomnia のホームディレクトリ配下のパス解決を一元化するモジュール。 +//! +//! 用途別に三つの base directory を持つ: +//! +//! - **`config_dir`** — 人が手で書く / 編集する設定。`manifest.toml`, +//! `providers.toml`, `models.toml`, `prompts/`, `prompts.toml` 等 +//! - **`data_dir`** — プログラムが書く永続データ。`sessions/` 等 +//! - **`runtime_dir`** — 再起動で消えてよいランタイム状態。socket, +//! `scope.lock`, `pid` ファイル等 +//! +//! ## 解決順 (優先順位高 → 低) +//! +//! | base | 1. `INSOMNIA__DIR` | 2. `INSOMNIA_HOME` | 3. `XDG_*` | 4. 既定 | +//! |---|---|---|---|---| +//! | config | `INSOMNIA_CONFIG_DIR` | `$INSOMNIA_HOME/config` | `$XDG_CONFIG_HOME/insomnia` | `$HOME/.config/insomnia` | +//! | data | `INSOMNIA_DATA_DIR` | `$INSOMNIA_HOME` | — | `$HOME/.insomnia` | +//! | runtime | `INSOMNIA_RUNTIME_DIR` | `$INSOMNIA_HOME/run` | `$XDG_RUNTIME_DIR/insomnia` | `$HOME/.insomnia/run` | +//! +//! `INSOMNIA_HOME=$X` のとき config は `$X/config`、data は `$X` 直下、 +//! runtime は `$X/run` に集約される。テストや sandbox 利用ではこれ一本 +//! で全部 tempdir に向けられる。 +//! +//! 解決された各 base が存在するか / ディレクトリかは保証しない — +//! 呼び出し側がファイル操作の前に作成 / 検査する。 + +use std::path::PathBuf; + +/// 設定ディレクトリ。`manifest.toml`, `providers.toml`, `models.toml`, +/// `prompts/` などが置かれる。 +pub fn config_dir() -> Option { + if let Some(p) = env_path("INSOMNIA_CONFIG_DIR") { + return Some(p); + } + if let Some(p) = env_path("INSOMNIA_HOME") { + return Some(p.join("config")); + } + if let Some(p) = env_path("XDG_CONFIG_HOME") { + return Some(p.join("insomnia")); + } + Some(env_path("HOME")?.join(".config").join("insomnia")) +} + +/// データディレクトリ。`sessions/` などプログラムが書く永続データの +/// 置き場。 +pub fn data_dir() -> Option { + if let Some(p) = env_path("INSOMNIA_DATA_DIR") { + return Some(p); + } + if let Some(p) = env_path("INSOMNIA_HOME") { + return Some(p); + } + Some(env_path("HOME")?.join(".insomnia")) +} + +/// ランタイムディレクトリ。socket, `scope.lock`, Pod ごとの `pid` / +/// `status.json` 等が置かれる。再起動で消えて構わない。 +pub fn runtime_dir() -> Option { + if let Some(p) = env_path("INSOMNIA_RUNTIME_DIR") { + return Some(p); + } + if let Some(p) = env_path("INSOMNIA_HOME") { + return Some(p.join("run")); + } + if let Some(p) = env_path("XDG_RUNTIME_DIR") { + return Some(p.join("insomnia")); + } + Some(env_path("HOME")?.join(".insomnia").join("run")) +} + +// ---- well-known file getters ------------------------------------------------ + +/// `/manifest.toml` — user manifest。 +pub fn user_manifest_path() -> Option { + Some(config_dir()?.join("manifest.toml")) +} + +/// `/prompts/` — user prompts ライブラリ。 +pub fn user_prompts_dir() -> Option { + Some(config_dir()?.join("prompts")) +} + +/// `/prompts.toml` — user prompt pack。 +pub fn user_pack_file() -> Option { + Some(config_dir()?.join("prompts.toml")) +} + +/// `/` — providers.toml / models.toml 等の +/// user override ファイル。 +pub fn user_catalog_override(file_name: &str) -> Option { + Some(config_dir()?.join(file_name)) +} + +/// `/sessions/` — session store のデフォルト位置。 +pub fn sessions_dir() -> Option { + Some(data_dir()?.join("sessions")) +} + +/// `/scope.lock` — machine-wide scope allocation registry。 +pub fn scope_lock_path() -> Option { + Some(runtime_dir()?.join("scope.lock")) +} + +/// `//` — Pod ごとのランタイムディレクトリ。 +pub fn pod_runtime_dir(pod_name: &str) -> Option { + Some(runtime_dir()?.join(pod_name)) +} + +/// `//sock` — Pod の Unix socket パス (TUI が +/// attach 時に使う)。Pod プロセスが実際に socket を作成するのは +/// `RuntimeDir::socket_path()` 経由だが、外部からの予測はこの関数で +/// 行う。 +pub fn pod_socket_path(pod_name: &str) -> Option { + Some(pod_runtime_dir(pod_name)?.join("sock")) +} + +// ---- internals -------------------------------------------------------------- + +/// 空文字列の env は未設定として扱う。`std::env::var` は `Ok("")` と +/// `Err(NotPresent)` を区別するが、パス解決においては両者を未設定と +/// 同等に扱うのが直感的。 +fn env_path(name: &str) -> Option { + std::env::var(name) + .ok() + .filter(|s| !s.is_empty()) + .map(PathBuf::from) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Mutex, MutexGuard, OnceLock}; + + /// プロセス全体で env を弄るテスト同士が並行に走らないように保護 + /// する。Cargo の test harness はファイル単位で別プロセスにせず + /// マルチスレッドで実行するため、env を読む全テストはこの lock を + /// 取ってから操作する。 + fn env_lock() -> MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|e| e.into_inner()) + } + + /// テスト中だけ env を上書きし、drop 時に元の値に戻す RAII guard。 + struct EnvGuard { + vars: Vec<(&'static str, Option)>, + _lock: MutexGuard<'static, ()>, + } + + impl EnvGuard { + fn new(overrides: &[(&'static str, Option<&str>)]) -> Self { + let lock = env_lock(); + let names = [ + "INSOMNIA_CONFIG_DIR", + "INSOMNIA_DATA_DIR", + "INSOMNIA_RUNTIME_DIR", + "INSOMNIA_HOME", + "XDG_CONFIG_HOME", + "XDG_RUNTIME_DIR", + "HOME", + ]; + let saved: Vec<_> = names + .iter() + .map(|n| (*n, std::env::var(n).ok())) + .collect(); + // SAFETY: env_lock() 取得済みなので env への並行アクセスは + // この test バイナリ内では発生しない。 + unsafe { + for (n, _) in &saved { + std::env::remove_var(n); + } + for (n, v) in overrides { + if let Some(v) = v { + std::env::set_var(n, v); + } + } + } + Self { + vars: saved, + _lock: lock, + } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + // SAFETY: lock を握ったまま元に戻す。 + unsafe { + for (n, v) in &self.vars { + match v { + Some(v) => std::env::set_var(n, v), + None => std::env::remove_var(n), + } + } + } + } + } + + #[test] + fn config_dir_falls_back_to_home_dot_config() { + let _g = EnvGuard::new(&[("HOME", Some("/h"))]); + assert_eq!(config_dir().unwrap(), PathBuf::from("/h/.config/insomnia")); + } + + #[test] + fn config_dir_uses_xdg_when_set() { + let _g = EnvGuard::new(&[ + ("HOME", Some("/h")), + ("XDG_CONFIG_HOME", Some("/x")), + ]); + assert_eq!(config_dir().unwrap(), PathBuf::from("/x/insomnia")); + } + + #[test] + fn config_dir_insomnia_home_outranks_xdg() { + let _g = EnvGuard::new(&[ + ("HOME", Some("/h")), + ("XDG_CONFIG_HOME", Some("/x")), + ("INSOMNIA_HOME", Some("/sand")), + ]); + assert_eq!(config_dir().unwrap(), PathBuf::from("/sand/config")); + } + + #[test] + fn config_dir_explicit_wins_over_insomnia_home() { + let _g = EnvGuard::new(&[ + ("HOME", Some("/h")), + ("INSOMNIA_HOME", Some("/sand")), + ("INSOMNIA_CONFIG_DIR", Some("/explicit-cfg")), + ]); + assert_eq!(config_dir().unwrap(), PathBuf::from("/explicit-cfg")); + } + + #[test] + fn data_dir_default_is_dot_insomnia() { + let _g = EnvGuard::new(&[("HOME", Some("/h"))]); + assert_eq!(data_dir().unwrap(), PathBuf::from("/h/.insomnia")); + } + + #[test] + fn data_dir_insomnia_home_is_data_dir_itself() { + let _g = EnvGuard::new(&[ + ("HOME", Some("/h")), + ("INSOMNIA_HOME", Some("/sand")), + ]); + assert_eq!(data_dir().unwrap(), PathBuf::from("/sand")); + } + + #[test] + fn runtime_dir_prefers_xdg_runtime_dir() { + let _g = EnvGuard::new(&[ + ("HOME", Some("/h")), + ("XDG_RUNTIME_DIR", Some("/run/user/1000")), + ]); + assert_eq!( + runtime_dir().unwrap(), + PathBuf::from("") + ); + } + + #[test] + fn runtime_dir_falls_back_to_dot_insomnia_run() { + let _g = EnvGuard::new(&[("HOME", Some("/h"))]); + assert_eq!(runtime_dir().unwrap(), PathBuf::from("/h/.insomnia/run")); + } + + #[test] + fn runtime_dir_insomnia_home_is_run_subdir() { + let _g = EnvGuard::new(&[ + ("HOME", Some("/h")), + ("XDG_RUNTIME_DIR", Some("/run/user/1000")), + ("INSOMNIA_HOME", Some("/sand")), + ]); + assert_eq!(runtime_dir().unwrap(), PathBuf::from("/sand/run")); + } + + #[test] + fn empty_env_treated_as_unset() { + let _g = EnvGuard::new(&[ + ("HOME", Some("/h")), + ("XDG_CONFIG_HOME", Some("")), + ]); + assert_eq!(config_dir().unwrap(), PathBuf::from("/h/.config/insomnia")); + } + + #[test] + fn returns_none_when_nothing_set() { + let _g = EnvGuard::new(&[]); + assert!(config_dir().is_none()); + assert!(data_dir().is_none()); + assert!(runtime_dir().is_none()); + } + + #[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_prompts_dir().unwrap(), + PathBuf::from("/sand/config/prompts") + ); + assert_eq!( + user_pack_file().unwrap(), + PathBuf::from("/sand/config/prompts.toml") + ); + assert_eq!( + user_catalog_override("providers.toml").unwrap(), + PathBuf::from("/sand/config/providers.toml") + ); + assert_eq!( + sessions_dir().unwrap(), + PathBuf::from("/sand/sessions") + ); + assert_eq!( + scope_lock_path().unwrap(), + PathBuf::from("/sand/run/scope.lock") + ); + assert_eq!( + pod_runtime_dir("foo").unwrap(), + PathBuf::from("/sand/run/foo") + ); + assert_eq!( + pod_socket_path("foo").unwrap(), + PathBuf::from("/sand/run/foo/sock") + ); + } +} diff --git a/crates/pod/src/factory.rs b/crates/pod/src/factory.rs index be194fe4..072d92e5 100644 --- a/crates/pod/src/factory.rs +++ b/crates/pod/src/factory.rs @@ -18,6 +18,8 @@ //! 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 @@ -29,7 +31,7 @@ use std::path::{Path, PathBuf}; use manifest::{ LayerLoadError, PodManifest, PodManifestConfig, ResolveError, find_project_manifest_from, - load_layer, user_manifest_path, + load_layer, paths, }; use crate::prompt::loader::PromptLoader; @@ -101,21 +103,19 @@ impl PodFactory { Self::default() } - /// Attempt to load the user manifest from the XDG config directory. - /// - /// Looks at `$XDG_CONFIG_HOME/insomnia/manifest.toml` first, then - /// falls back to `$HOME/.config/insomnia/manifest.toml`. If neither - /// env var is set, or the resolved file does not exist, the call - /// is a no-op — user manifests are optional. + /// 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) = user_manifest_path() else { + 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 = Some(base.join("prompts")); - self.user_pack_file = Some(base.join("prompts.toml")); + self.user_prompts_dir = paths::user_prompts_dir(); + self.user_pack_file = paths::user_pack_file(); } Ok(self) } diff --git a/crates/pod/src/main.rs b/crates/pod/src/main.rs index 359b5ce5..d04079dc 100644 --- a/crates/pod/src/main.rs +++ b/crates/pod/src/main.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; use std::process::ExitCode; use clap::Parser; +use manifest::paths; use pod::{Pod, PodController, PodFactory}; use session_store::FsStore; @@ -11,8 +12,8 @@ use session_store::FsStore; about = "Spawn a Pod process from cascaded manifest layers" )] struct Cli { - /// User manifest TOML. Defaults to - /// `$XDG_CONFIG_HOME/insomnia/manifest.toml`. + /// User manifest TOML. Defaults to `/manifest.toml` + /// (see `manifest::paths`). #[arg(long, value_name = "PATH")] user_manifest: Option, @@ -28,7 +29,7 @@ struct Cli { overlay: Option, /// Directory for session persistence. Defaults to - /// `~/.insomnia/sessions/`. + /// `/sessions/` (see `manifest::paths`). #[arg(short, long)] store: Option, @@ -44,25 +45,6 @@ struct Cli { callback: Option, } -fn default_store_dir() -> Result { - let home = std::env::var("HOME") - .map_err(|_| std::io::Error::new(std::io::ErrorKind::NotFound, "HOME is not set"))?; - Ok(PathBuf::from(home).join(".insomnia").join("sessions")) -} - -fn default_runtime_dir() -> Result { - if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { - Ok(PathBuf::from(runtime_dir).join("insomnia")) - } else if let Ok(home) = std::env::var("HOME") { - Ok(PathBuf::from(home).join(".insomnia").join("run")) - } else { - Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - "neither XDG_RUNTIME_DIR nor HOME is set", - )) - } -} - async fn build_factory(cli: &Cli) -> Result { let mut factory = PodFactory::new(); @@ -115,7 +97,7 @@ async fn main() -> ExitCode { // Initialize persistent store let store_dir = cli.store.clone().unwrap_or_else(|| { - default_store_dir().unwrap_or_else(|_| PathBuf::from(".insomnia/sessions")) + paths::sessions_dir().unwrap_or_else(|| PathBuf::from(".insomnia/sessions")) }); let store = match FsStore::new(&store_dir).await { Ok(s) => s, @@ -152,10 +134,13 @@ async fn main() -> ExitCode { let pod_name = pod.manifest().pod.name.clone(); // Spawn the controller (starts socket server) - let runtime_base = match default_runtime_dir() { - Ok(d) => d, - Err(e) => { - eprintln!("error: {e}"); + let runtime_base = match paths::runtime_dir() { + Some(d) => d, + None => { + eprintln!( + "error: could not resolve runtime directory \ + (set INSOMNIA_HOME, INSOMNIA_RUNTIME_DIR, XDG_RUNTIME_DIR, or HOME)" + ); return ExitCode::FAILURE; } }; diff --git a/crates/pod/src/runtime/dir.rs b/crates/pod/src/runtime/dir.rs index 2028a95c..1f442fe4 100644 --- a/crates/pod/src/runtime/dir.rs +++ b/crates/pod/src/runtime/dir.rs @@ -1,7 +1,7 @@ use std::io; use std::path::{Path, PathBuf}; -use manifest::ScopeRule; +use manifest::{ScopeRule, paths}; use serde::{Deserialize, Serialize}; use tokio::fs; @@ -28,7 +28,7 @@ pub struct SpawnedPodRecord { /// Manages the Pod's runtime directory on tmpfs. /// /// ```text -/// $XDG_RUNTIME_DIR/insomnia/{pod_name}/ +/// /{pod_name}/ /// ├── pid /// ├── status.json /// ├── manifest.toml @@ -36,6 +36,7 @@ pub struct SpawnedPodRecord { /// └── sock (created by socket listener, not by RuntimeDir) /// ``` /// +/// `` is resolved via [`manifest::paths::runtime_dir`]. /// Files are written atomically (write tmp → rename). /// The directory is removed on drop. pub struct RuntimeDir { @@ -54,10 +55,8 @@ impl RuntimeDir { Ok(Self { path }) } - /// Create in the default base directory. - /// - /// Uses `$XDG_RUNTIME_DIR/insomnia/` if available, - /// otherwise falls back to `~/.insomnia/run/`. + /// Create in the default base directory resolved via + /// [`manifest::paths::runtime_dir`]. pub async fn create_default(pod_name: &str) -> Result { let base = default_base()?; Self::create(&base, pod_name).await @@ -118,20 +117,16 @@ async fn atomic_write(target: &Path, content: &[u8]) -> Result<(), io::Error> { /// Resolve the default base directory for runtime data. /// -/// Public so the scope-lock registry (which lives outside the -/// `RuntimeDir` instance lifecycle) can predict a Pod's socket path -/// without constructing a `RuntimeDir` first. +/// Thin wrapper over [`manifest::paths::runtime_dir`] that converts a +/// missing-env situation into an `io::Error`. pub fn default_base() -> Result { - if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { - Ok(PathBuf::from(runtime_dir).join("insomnia")) - } else if let Ok(home) = std::env::var("HOME") { - Ok(PathBuf::from(home).join(".insomnia").join("run")) - } else { - Err(io::Error::new( + paths::runtime_dir().ok_or_else(|| { + io::Error::new( io::ErrorKind::NotFound, - "neither XDG_RUNTIME_DIR nor HOME is set", - )) - } + "could not resolve runtime directory (no INSOMNIA_HOME / \ + INSOMNIA_RUNTIME_DIR / XDG_RUNTIME_DIR / HOME)", + ) + }) } #[cfg(test)] diff --git a/crates/pod/src/runtime/scope_lock.rs b/crates/pod/src/runtime/scope_lock.rs index 2f1f3913..355e7a38 100644 --- a/crates/pod/src/runtime/scope_lock.rs +++ b/crates/pod/src/runtime/scope_lock.rs @@ -1,9 +1,9 @@ //! Machine-wide scope allocation registry. //! -//! A single JSON file at `$XDG_RUNTIME_DIR/insomnia/scope.lock` records -//! every live Pod's scope allocation. File-level `flock(2)` serialises -//! access across processes so spawn sequences from unrelated Pods can't -//! race. +//! A single JSON file at `/scope.lock` records every live +//! Pod's scope allocation (see [`manifest::paths::scope_lock_path`] for +//! how the path is resolved). File-level `flock(2)` serialises access +//! across processes so spawn sequences from unrelated Pods can't race. //! //! Each Pod, when starting, acquires the lock, reclaims stale entries //! (Pods whose PID has died), checks that its requested write scope @@ -19,7 +19,7 @@ use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt}; use std::path::{Path, PathBuf}; use fs4::fs_std::FileExt; -use manifest::{Permission, ScopeRule}; +use manifest::{Permission, ScopeRule, paths}; use serde::{Deserialize, Serialize}; /// On-disk representation of the allocation table. @@ -62,27 +62,18 @@ impl LockFile { } } -/// Default on-disk path: `$XDG_RUNTIME_DIR/insomnia/scope.lock`, -/// falling back to `~/.insomnia/run/scope.lock` when XDG is unset. -/// -/// Honours `INSOMNIA_SCOPE_LOCK` as an explicit override, primarily so -/// tests can point at a tempdir without polluting the user's runtime -/// directory. +/// Default on-disk path: `/scope.lock` resolved via +/// [`manifest::paths::scope_lock_path`]. Tests should point this +/// elsewhere by setting `INSOMNIA_HOME` or `INSOMNIA_RUNTIME_DIR` to a +/// tempdir. pub fn default_lock_path() -> io::Result { - if let Ok(custom) = std::env::var("INSOMNIA_SCOPE_LOCK") { - return Ok(PathBuf::from(custom)); - } - let base = if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") { - PathBuf::from(dir).join("insomnia") - } else if let Ok(home) = std::env::var("HOME") { - PathBuf::from(home).join(".insomnia").join("run") - } else { - return Err(io::Error::new( + paths::scope_lock_path().ok_or_else(|| { + io::Error::new( io::ErrorKind::NotFound, - "neither XDG_RUNTIME_DIR nor HOME is set", - )); - }; - Ok(base.join("scope.lock")) + "could not resolve scope.lock path (no INSOMNIA_HOME / \ + INSOMNIA_RUNTIME_DIR / XDG_RUNTIME_DIR / HOME)", + ) + }) } /// RAII guard over an exclusively-locked lock file. @@ -555,15 +546,67 @@ pub enum ScopeLockError { mod tests { use super::*; use manifest::Permission; - use std::sync::{LazyLock, Mutex}; + use std::sync::{LazyLock, Mutex, MutexGuard}; use tempfile::TempDir; - /// Serialises tests that mutate `INSOMNIA_SCOPE_LOCK`. The test + /// Serialises tests that mutate runtime-dir env vars. The test /// harness runs tests on multiple threads inside a single process, /// so env-var writes from one test would otherwise leak into a /// parallel test's `default_lock_path()` lookup. static ENV_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); + /// Sandbox `INSOMNIA_RUNTIME_DIR` to a tempdir for the duration of + /// a test; restore the previous value (and any `INSOMNIA_HOME` / + /// `XDG_RUNTIME_DIR` that would otherwise outrank it) on drop. + struct RuntimeDirSandbox { + prev_runtime: Option, + prev_home: Option, + prev_xdg: Option, + _guard: MutexGuard<'static, ()>, + } + + impl RuntimeDirSandbox { + fn new(dir: &Path) -> Self { + let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let prev_runtime = std::env::var("INSOMNIA_RUNTIME_DIR").ok(); + let prev_home = std::env::var("INSOMNIA_HOME").ok(); + let prev_xdg = std::env::var("XDG_RUNTIME_DIR").ok(); + // SAFETY: ENV_LOCK serialises env writes across this test + // module; other modules that touch env vars rely on their + // own lock or `serial_test`. + unsafe { + std::env::remove_var("INSOMNIA_HOME"); + std::env::remove_var("XDG_RUNTIME_DIR"); + std::env::set_var("INSOMNIA_RUNTIME_DIR", dir); + } + Self { + prev_runtime, + prev_home, + prev_xdg, + _guard: guard, + } + } + } + + impl Drop for RuntimeDirSandbox { + fn drop(&mut self) { + unsafe { + match &self.prev_runtime { + Some(v) => std::env::set_var("INSOMNIA_RUNTIME_DIR", v), + None => std::env::remove_var("INSOMNIA_RUNTIME_DIR"), + } + match &self.prev_home { + Some(v) => std::env::set_var("INSOMNIA_HOME", v), + None => std::env::remove_var("INSOMNIA_HOME"), + } + match &self.prev_xdg { + Some(v) => std::env::set_var("XDG_RUNTIME_DIR", v), + None => std::env::remove_var("XDG_RUNTIME_DIR"), + } + } + } + } + fn write_rule(path: &str, recursive: bool) -> ScopeRule { ScopeRule { target: PathBuf::from(path), @@ -977,12 +1020,9 @@ mod tests { #[test] fn scope_allocation_guard_releases_on_drop() { - let _env = ENV_LOCK.lock().unwrap(); let dir = TempDir::new().unwrap(); + let _sandbox = RuntimeDirSandbox::new(dir.path()); let lock_path = dir.path().join("scope.lock"); - unsafe { - std::env::set_var("INSOMNIA_SCOPE_LOCK", &lock_path); - } let guard = install_top_level( "a".into(), std::process::id(), @@ -999,19 +1039,13 @@ mod tests { let g = LockFileGuard::open(&lock_path).unwrap(); assert!(g.data().find("a").is_none()); } - unsafe { - std::env::remove_var("INSOMNIA_SCOPE_LOCK"); - } } #[test] fn adopt_allocation_rewrites_pid_and_releases_on_drop() { - let _env = ENV_LOCK.lock().unwrap(); let dir = TempDir::new().unwrap(); + let _sandbox = RuntimeDirSandbox::new(dir.path()); let lock_path = dir.path().join("scope.lock"); - unsafe { - std::env::set_var("INSOMNIA_SCOPE_LOCK", &lock_path); - } // Pre-register an allocation under spawner's pid, as delegate_scope would. { let mut g = LockFileGuard::open(&lock_path).unwrap(); @@ -1029,24 +1063,14 @@ mod tests { let g = LockFileGuard::open(&lock_path).unwrap(); assert!(g.data().find("child").is_none()); } - unsafe { - std::env::remove_var("INSOMNIA_SCOPE_LOCK"); - } } #[test] fn adopt_allocation_errors_on_unknown_pod() { - let _env = ENV_LOCK.lock().unwrap(); let dir = TempDir::new().unwrap(); - let lock_path = dir.path().join("scope.lock"); - unsafe { - std::env::set_var("INSOMNIA_SCOPE_LOCK", &lock_path); - } + let _sandbox = RuntimeDirSandbox::new(dir.path()); let err = adopt_allocation("ghost".into(), 42).unwrap_err(); assert!(matches!(err, ScopeLockError::UnknownPod(ref n) if n == "ghost")); - unsafe { - std::env::remove_var("INSOMNIA_SCOPE_LOCK"); - } } /// Mimic what the spawner does before the child comes up: push an diff --git a/crates/pod/tests/pod_comm_tools_test.rs b/crates/pod/tests/pod_comm_tools_test.rs index d3bb0b3c..d4aea266 100644 --- a/crates/pod/tests/pod_comm_tools_test.rs +++ b/crates/pod/tests/pod_comm_tools_test.rs @@ -28,17 +28,47 @@ use tokio::net::UnixListener; use tokio::task::JoinHandle; /// Serialises env-mutating tests. The test harness runs tasks across -/// threads, and `INSOMNIA_SCOPE_LOCK` is a process-wide resource. +/// threads, and `INSOMNIA_RUNTIME_DIR` is a process-wide resource. static ENV_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); +/// Take `ENV_LOCK` and clear any env vars that would outrank +/// `INSOMNIA_RUNTIME_DIR` in `paths::runtime_dir` resolution; restore +/// previous values on drop. struct EnvGuard { + prev_home: Option, + prev_xdg: Option, _lock: std::sync::MutexGuard<'static, ()>, } impl EnvGuard { fn acquire() -> Self { + let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let prev_home = std::env::var("INSOMNIA_HOME").ok(); + let prev_xdg = std::env::var("XDG_RUNTIME_DIR").ok(); + unsafe { + std::env::remove_var("INSOMNIA_HOME"); + std::env::remove_var("XDG_RUNTIME_DIR"); + } Self { - _lock: ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()), + prev_home, + prev_xdg, + _lock: lock, + } + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + unsafe { + match &self.prev_home { + Some(v) => std::env::set_var("INSOMNIA_HOME", v), + None => std::env::remove_var("INSOMNIA_HOME"), + } + match &self.prev_xdg { + Some(v) => std::env::set_var("XDG_RUNTIME_DIR", v), + None => std::env::remove_var("XDG_RUNTIME_DIR"), + } + std::env::remove_var("INSOMNIA_RUNTIME_DIR"); } } } @@ -293,10 +323,10 @@ async fn read_pod_output_reports_stopped_on_dead_socket() { async fn stop_pod_sends_shutdown_and_releases_scope() { let _env = EnvGuard::acquire(); let (tmp, registry, rd) = setup_registry().await; - let lock_path = tmp.path().join("scope.lock"); unsafe { - std::env::set_var("INSOMNIA_SCOPE_LOCK", &lock_path); + std::env::set_var("INSOMNIA_RUNTIME_DIR", tmp.path()); } + let lock_path = tmp.path().join("scope.lock"); // Seed scope.lock with a top-level `spawner` allocation plus a // delegated `child` allocation — mimics what SpawnPod would have @@ -356,19 +386,14 @@ async fn stop_pod_sends_shutdown_and_releases_scope() { let contents = std::fs::read_to_string(&spawned).unwrap(); let records: Vec = serde_json::from_str(&contents).unwrap(); assert!(records.is_empty()); - - unsafe { - std::env::remove_var("INSOMNIA_SCOPE_LOCK"); - } } #[tokio::test] async fn stop_pod_succeeds_even_when_child_unreachable() { let _env = EnvGuard::acquire(); let (tmp, registry, _rd) = setup_registry().await; - let lock_path = tmp.path().join("scope.lock"); unsafe { - std::env::set_var("INSOMNIA_SCOPE_LOCK", &lock_path); + std::env::set_var("INSOMNIA_RUNTIME_DIR", tmp.path()); } // No live listener — socket never bound. Registered record points @@ -384,10 +409,6 @@ async fn stop_pod_succeeds_even_when_child_unreachable() { // Registry no longer knows about the child. assert!(registry.get("child").await.is_none()); - - unsafe { - std::env::remove_var("INSOMNIA_SCOPE_LOCK"); - } } // --------------------------------------------------------------------------- diff --git a/crates/pod/tests/pod_events_test.rs b/crates/pod/tests/pod_events_test.rs index a461d7e4..43b240e3 100644 --- a/crates/pod/tests/pod_events_test.rs +++ b/crates/pod/tests/pod_events_test.rs @@ -18,30 +18,61 @@ use protocol::{Method, Permission, PodEvent, ScopeRule}; use tempfile::TempDir; use tokio::net::UnixListener; -/// Serialises tests that mutate `INSOMNIA_SCOPE_LOCK`. +/// Serialises tests that mutate `INSOMNIA_RUNTIME_DIR`. static ENV_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); +/// Take `ENV_LOCK` and clear any env vars that would outrank +/// `INSOMNIA_RUNTIME_DIR`; restore previous values on drop. struct EnvGuard { + prev_home: Option, + prev_xdg: Option, _lock: std::sync::MutexGuard<'static, ()>, } impl EnvGuard { fn acquire() -> Self { + let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let prev_home = std::env::var("INSOMNIA_HOME").ok(); + let prev_xdg = std::env::var("XDG_RUNTIME_DIR").ok(); + unsafe { + std::env::remove_var("INSOMNIA_HOME"); + std::env::remove_var("XDG_RUNTIME_DIR"); + } Self { - _lock: ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()), + prev_home, + prev_xdg, + _lock: lock, } } } -fn set_scope_lock_path(path: &std::path::Path) { - unsafe { - std::env::set_var("INSOMNIA_SCOPE_LOCK", path); +impl Drop for EnvGuard { + fn drop(&mut self) { + unsafe { + match &self.prev_home { + Some(v) => std::env::set_var("INSOMNIA_HOME", v), + None => std::env::remove_var("INSOMNIA_HOME"), + } + match &self.prev_xdg { + Some(v) => std::env::set_var("XDG_RUNTIME_DIR", v), + None => std::env::remove_var("XDG_RUNTIME_DIR"), + } + std::env::remove_var("INSOMNIA_RUNTIME_DIR"); + } } } -fn clear_scope_lock_path() { +/// Point `INSOMNIA_RUNTIME_DIR` at `dir`. The scope-lock then lives at +/// `/scope.lock` and Pod runtime sub-dirs at `/{pod_name}/`. +fn set_runtime_dir(dir: &std::path::Path) { unsafe { - std::env::remove_var("INSOMNIA_SCOPE_LOCK"); + std::env::set_var("INSOMNIA_RUNTIME_DIR", dir); + } +} + +fn clear_runtime_dir() { + unsafe { + std::env::remove_var("INSOMNIA_RUNTIME_DIR"); } } @@ -133,7 +164,7 @@ async fn fresh_registry(runtime_base: &std::path::Path, pod_name: &str) -> Arc panic!("expected re-emitted ScopeSubDelegated, got {other:?}"), } - clear_scope_lock_path(); + clear_runtime_dir(); } #[tokio::test] async fn apply_turn_ended_and_errored_are_system_noops() { let _env = EnvGuard::acquire(); let scope_dir = TempDir::new().unwrap(); - set_scope_lock_path(&scope_dir.path().join("scope.lock")); + set_runtime_dir(scope_dir.path()); let runtime_base = TempDir::new().unwrap(); let registry = fresh_registry(runtime_base.path(), "parent").await; @@ -312,7 +343,7 @@ async fn apply_turn_ended_and_errored_are_system_noops() { .await; assert!(registry.get("child").await.is_some()); - clear_scope_lock_path(); + clear_runtime_dir(); } #[tokio::test] @@ -320,7 +351,7 @@ async fn shutdown_releases_scope_allocation_when_present() { let _env = EnvGuard::acquire(); let scope_dir = TempDir::new().unwrap(); let lock_path = scope_dir.path().join("scope.lock"); - set_scope_lock_path(&lock_path); + set_runtime_dir(scope_dir.path()); // Install a top-level allocation for "kid" so ShutDown has // something to release. @@ -362,5 +393,5 @@ async fn shutdown_releases_scope_allocation_when_present() { "ShutDown should have released the scope allocation" ); - clear_scope_lock_path(); + clear_runtime_dir(); } diff --git a/crates/pod/tests/spawn_pod_test.rs b/crates/pod/tests/spawn_pod_test.rs index a08032d3..5b54bd19 100644 --- a/crates/pod/tests/spawn_pod_test.rs +++ b/crates/pod/tests/spawn_pod_test.rs @@ -23,7 +23,7 @@ use std::sync::Arc; use tempfile::TempDir; use tokio::net::UnixListener; -/// Serialises tests that mutate `INSOMNIA_SCOPE_LOCK` / +/// Serialises tests that mutate `INSOMNIA_RUNTIME_DIR` / /// `INSOMNIA_POD_COMMAND` across the thread-pooled test harness. static ENV_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); @@ -39,22 +39,26 @@ impl EnvGuard { } } -/// Set up a tempdir, point `INSOMNIA_SCOPE_LOCK` + runtime-dir base at -/// it, and install a live top-level "spawner" allocation so the tool -/// has something to delegate from. Returns the tempdir (keeps it alive -/// for the test's lifetime), runtime base, spawner socket, and the -/// spawner's runtime dir. +/// Set up a tempdir, point `INSOMNIA_RUNTIME_DIR` at it (so +/// `scope.lock` and per-Pod runtime subdirs both land in the +/// sandbox), and install a live top-level "spawner" allocation so the +/// tool has something to delegate from. Returns the tempdir (keeps it +/// alive for the test's lifetime), runtime base, spawner socket, and +/// the spawner's runtime dir. async fn setup_spawner( spawner_name: &str, allow_root: &Path, ) -> (TempDir, PathBuf, PathBuf, Arc) { let tmp = TempDir::new().unwrap(); - let lock_path = tmp.path().join("scope.lock"); + let runtime_base = tmp.path().to_path_buf(); unsafe { - std::env::set_var("INSOMNIA_SCOPE_LOCK", &lock_path); + // Outranking env vars must be cleared so `paths::runtime_dir` + // resolves to our sandbox instead of the developer's real one. + std::env::remove_var("INSOMNIA_HOME"); + std::env::remove_var("XDG_RUNTIME_DIR"); + std::env::set_var("INSOMNIA_RUNTIME_DIR", &runtime_base); } - let runtime_base = tmp.path().join("runtime"); let spawner_rd = RuntimeDir::create(&runtime_base, spawner_name) .await .unwrap(); @@ -148,7 +152,7 @@ fn dummy_model() -> ModelManifest { fn clear_env() { unsafe { - std::env::remove_var("INSOMNIA_SCOPE_LOCK"); + std::env::remove_var("INSOMNIA_RUNTIME_DIR"); std::env::remove_var("INSOMNIA_POD_COMMAND"); } } diff --git a/crates/provider/src/catalog.rs b/crates/provider/src/catalog.rs index 1c92a50c..67f11fbd 100644 --- a/crates/provider/src/catalog.rs +++ b/crates/provider/src/catalog.rs @@ -2,8 +2,9 @@ //! //! - builtin プロバイダ: `resources/providers/builtin.toml` //! - builtin モデル: `resources/models/builtin.toml` -//! - user override: `$XDG_CONFIG_HOME/insomnia/{providers,models}.toml` +//! - user override: `/{providers,models}.toml` //! +//! `` の解決は [`manifest::paths::config_dir`] を参照。 //! どちらの override も「あれば builtin を置換、無ければ builtin」と //! いう一方向の差し替え(マージしない)。providers / models は独立に //! 読み、片方だけ user override も可。 @@ -150,13 +151,13 @@ fn auth_hint_to_ref(hint: &AuthHint) -> AuthRef { /// builtin + user override を解決して provider カタログを返す。 /// -/// user override (`$XDG_CONFIG_HOME/insomnia/providers.toml`) が -/// 存在すれば builtin を置き換える。存在しなければ builtin のみ。 -/// user override が存在するが壊れている場合はエラーを返す(silent -/// fallback はしない — ユーザーが書いた設定が silent に無視されて -/// builtin に戻る挙動は気付きにくいため)。 +/// user override (`/providers.toml`) が存在すれば builtin +/// を置き換える。存在しなければ builtin のみ。user override が存在 +/// するが壊れている場合はエラーを返す(silent fallback はしない — +/// ユーザーが書いた設定が silent に無視されて builtin に戻る挙動は +/// 気付きにくいため)。 pub fn load_providers() -> Result, CatalogError> { - if let Some(path) = user_override_path("providers.toml") + if let Some(path) = manifest::paths::user_catalog_override("providers.toml") && path.is_file() { return load_providers_from(&path); @@ -189,7 +190,7 @@ pub fn load_providers_from(path: &Path) -> Result, CatalogErr /// builtin + user override を解決してモデルカタログを返す。 pub fn load_models() -> Result, CatalogError> { - if let Some(path) = user_override_path("models.toml") + if let Some(path) = manifest::paths::user_catalog_override("models.toml") && path.is_file() { return load_models_from(&path); @@ -324,25 +325,6 @@ pub fn resolve_with_catalogs( } } -fn user_override_path(file_name: &str) -> Option { - if let Ok(dir) = std::env::var("XDG_CONFIG_HOME") - && !dir.is_empty() - { - return Some(PathBuf::from(dir).join("insomnia").join(file_name)); - } - if let Ok(home) = std::env::var("HOME") - && !home.is_empty() - { - return Some( - PathBuf::from(home) - .join(".config") - .join("insomnia") - .join(file_name), - ); - } - None -} - #[cfg(test)] mod tests { use super::*; @@ -538,14 +520,41 @@ auth_hint = { kind = "none" } assert!(matches!(err, CatalogError::Parse { .. })); } + /// `INSOMNIA_CONFIG_DIR` を tempdir に向けるテストガード。 + /// `paths::config_dir` は他の env (INSOMNIA_HOME / XDG_CONFIG_HOME) + /// より高優先で `INSOMNIA_CONFIG_DIR` を尊重するため、これだけで + /// 開発機の env 設定に左右されないテストになる。 + struct ConfigDirGuard { + prev: Option, + } + + impl ConfigDirGuard { + fn new(path: &Path) -> Self { + let prev = std::env::var("INSOMNIA_CONFIG_DIR").ok(); + // SAFETY: serial_test の `#[serial]` 属性で env を弄るテスト + // 同士は直列化される。 + unsafe { std::env::set_var("INSOMNIA_CONFIG_DIR", path) }; + Self { prev } + } + } + + impl Drop for ConfigDirGuard { + fn drop(&mut self) { + unsafe { + match &self.prev { + Some(v) => std::env::set_var("INSOMNIA_CONFIG_DIR", v), + None => std::env::remove_var("INSOMNIA_CONFIG_DIR"), + } + } + } + } + #[test] #[serial] fn load_prefers_user_override() { let dir = tempfile::tempdir().unwrap(); - let insomnia_dir = dir.path().join("insomnia"); - std::fs::create_dir_all(&insomnia_dir).unwrap(); std::fs::write( - insomnia_dir.join("providers.toml"), + dir.path().join("providers.toml"), r#" [[provider]] id = "only-one" @@ -556,14 +565,8 @@ auth_hint = { kind = "none" } ) .unwrap(); - let prev_xdg = std::env::var("XDG_CONFIG_HOME").ok(); - unsafe { std::env::set_var("XDG_CONFIG_HOME", dir.path()) }; + let _g = ConfigDirGuard::new(dir.path()); let entries = load_providers().unwrap(); - match prev_xdg { - Some(v) => unsafe { std::env::set_var("XDG_CONFIG_HOME", v) }, - None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") }, - } - assert_eq!(entries.len(), 1); assert_eq!(entries[0].id, "only-one"); } @@ -573,13 +576,8 @@ auth_hint = { kind = "none" } fn load_falls_back_to_builtin_when_override_absent() { let dir = tempfile::tempdir().unwrap(); // override ファイルは作らない - let prev_xdg = std::env::var("XDG_CONFIG_HOME").ok(); - unsafe { std::env::set_var("XDG_CONFIG_HOME", dir.path()) }; + let _g = ConfigDirGuard::new(dir.path()); let entries = load_providers().unwrap(); - match prev_xdg { - Some(v) => unsafe { std::env::set_var("XDG_CONFIG_HOME", v) }, - None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") }, - } assert_eq!(entries.len(), 4); } } diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index c318c626..78b07897 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -33,23 +33,8 @@ fn resolve_socket(pod_name: &str, override_path: Option) -> PathBuf { if let Some(p) = override_path { return p; } - if let Ok(rd) = std::env::var("XDG_RUNTIME_DIR") { - PathBuf::from(rd) - .join("insomnia") - .join(pod_name) - .join("sock") - } else if let Ok(home) = std::env::var("HOME") { - PathBuf::from(home) - .join(".insomnia") - .join("run") - .join(pod_name) - .join("sock") - } else { - PathBuf::from("/tmp") - .join("insomnia") - .join(pod_name) - .join("sock") - } + manifest::paths::pod_socket_path(pod_name) + .unwrap_or_else(|| PathBuf::from("/tmp").join("insomnia").join(pod_name).join("sock")) } enum Mode { diff --git a/tickets/worker-generation-settings.md b/tickets/worker-generation-settings.md new file mode 100644 index 00000000..3b7939d8 --- /dev/null +++ b/tickets/worker-generation-settings.md @@ -0,0 +1,51 @@ +# LLM 生成設定の manifest 露出整理 + +## 背景 + +`llm-worker::RequestConfig` には LLM 生成に関する共通設定として `max_tokens` / `temperature` / `top_p` / `top_k` / `stop_sequences` / `reasoning` がある。一方、Pod manifest の `[worker]` まで露出しているのは現状 `max_tokens` と `temperature` のみで、`top_p` / `top_k` / `stop_sequences` は manifest から指定できない。 + +`reasoning` は `tickets/model-reasoning-control.md` で内部抽象と manifest 経路を整理するため、本チケットではそれ以外の既存 `RequestConfig` 項目を manifest から指定できるようにする。 + +## 要件 + +### `[worker]` への生成設定追加 + +Pod manifest の `[worker]` セクションで、既存 `RequestConfig` に存在する以下の項目を指定できるようにする。 + +```toml +[worker] +top_p = 0.9 +top_k = 40 +stop_sequences = ["\n\n", ""] +``` + +- `top_p`: `Option` として扱い、指定時のみ request に渡す +- `top_k`: `Option` として扱い、指定時のみ request に渡す +- `stop_sequences`: `Vec` として扱い、未指定時は空配列と同等にする + +Provider / scheme によって効かない値がある場合でも、既存の scheme 投影に任せる。値の厳密な provider 別検証は本チケットでは行わない。 + +### manifest cascade / restore 経路 + +`WorkerManifestConfig` / `WorkerManifest` に上記フィールドを追加し、manifest cascade の merge 後に `pod::apply_worker_manifest()` から `RequestConfig` へ渡す。 + +`stop_sequences` は cascade 時に上位 manifest が指定した場合の意味を明確にする。初期方針は、`max_tokens` や `temperature` と同様に「上位指定があれば置き換え」とし、配列の追記マージは行わない。 + +### 既存挙動の保持 + +未指定時は現在と同じ request body になること。特に、既存 manifest で `top_p` / `top_k` / `stop_sequences` を省略している場合、wire request に新しい値が出ない。 + +## 範囲外 + +- `reasoning` / thinking 制御の manifest 露出。これは `tickets/model-reasoning-control.md` で扱う +- `presence_penalty` / `frequency_penalty` / `seed` / `response_format` / `tool_choice` / `parallel_tool_calls` 等、まだ `RequestConfig` に存在しない新規生成パラメータの追加 +- provider ごとの値域検証や推奨値テーブル +- UI での設定編集画面 + +## 完了条件 + +- `[worker].top_p` / `[worker].top_k` / `[worker].stop_sequences` を manifest TOML で指定できる +- cascade merge 後の `WorkerManifest` がこれらの値を保持する +- `pod::apply_worker_manifest()` が `RequestConfig.top_p` / `top_k` / `stop_sequences` へ値を渡す +- 未指定時の既存挙動が変わらない +- manifest parse / merge / apply のテストが追加または更新されている