Skip to main content

algocline_app/service/
mod.rs

1pub(crate) mod alc_toml;
2mod error;
3pub(crate) use error::{
4    HubRegistriesError, PkgListError, ProjectFilesError, ServiceError, TranscriptError,
5};
6mod card;
7mod config;
8mod dist;
9mod engine_api_impl;
10mod eval;
11mod eval_store;
12pub(crate) mod gendoc;
13mod hub;
14pub mod hub_dist_preset;
15mod init;
16pub(crate) mod list_opts;
17pub(crate) mod lock;
18pub(crate) mod lockfile;
19mod logging;
20pub(crate) mod manifest;
21mod migrate;
22pub(crate) mod path;
23mod pkg;
24mod pkg_link;
25mod pkg_scaffold;
26mod pkg_unlink;
27pub(crate) mod project;
28pub mod resolve;
29mod run;
30mod scenario;
31pub(crate) mod source;
32mod status;
33mod transcript;
34mod update;
35
36#[cfg(test)]
37mod test_support;
38#[cfg(test)]
39mod tests;
40
41use std::path::Path;
42use std::sync::Arc;
43
44use crate::pool::{registry::with_registry_lock, PoolError, PoolRegistry};
45use algocline_engine::{Executor, FileCardStore, JsonFileStore, SessionRegistry, VariantPkg};
46
47pub use algocline_core::{EngineApi, TokenUsage};
48pub use config::{AppConfig, LogDirSource};
49pub use resolve::{QueryResponse, SearchPath};
50
51// ─── Application Service ────────────────────────────────────────
52
53/// Tracks in-flight eval sessions: session_id → strategy name.
54///
55/// Kept between `alc_eval` invocation and eventual completion (which may
56/// arrive via `alc_continue` after LLM round-trips). Used by
57/// `run.rs::maybe_save_eval` to persist the result to `~/.algocline/evals/`.
58/// Card emission is handled by `alc.eval()` Lua-side — no Rust tracking needed.
59///
60/// `std::sync::Mutex` is used (not tokio) because all operations are
61/// single HashMap insert/remove/get completing in microseconds, and no
62/// `.await` is held across the lock. Poison is silently skipped.
63type EvalSessions = std::sync::Mutex<std::collections::HashMap<String, String>>;
64
65/// Tracks session_id → strategy name for all strategy-based sessions (advice, eval).
66///
67/// Same locking rationale as `EvalSessions`. Used by `alc_status` and
68/// transcript logging. Poison is silently skipped — strategy name is
69/// non-critical metadata for observability.
70type SessionStrategies = std::sync::Mutex<std::collections::HashMap<String, String>>;
71
72#[derive(Clone)]
73pub struct AppService {
74    executor: Arc<Executor>,
75    registry: Arc<SessionRegistry>,
76    log_config: AppConfig,
77    /// Package search paths in priority order (first = highest).
78    search_paths: Vec<resolve::SearchPath>,
79    /// Persistent KV store backing `alc.state.*`.
80    ///
81    /// Rooted at `log_config.app_dir().state_dir()` and resolved once at
82    /// construction; `Arc`-wrapped so per-session clones are cheap.
83    state_store: Arc<JsonFileStore>,
84    /// Card store backing `alc.card.*`.
85    ///
86    /// Rooted at `log_config.app_dir().cards_dir()`, same `Arc` pattern.
87    card_store: Arc<FileCardStore>,
88    /// session_id → strategy name for eval sessions (cleared on completion).
89    eval_sessions: Arc<EvalSessions>,
90    /// session_id → strategy name for log/stats tracking (cleared on session completion).
91    session_strategies: Arc<SessionStrategies>,
92    /// Pool worker registry (persistent, backed by registry.json).
93    ///
94    /// `RwLock` because multiple concurrent callers may check for pool sessions
95    /// while a single writer spawns a new worker.  The lock is never held across
96    /// an `.await` boundary (K-4: clone-then-release pattern).
97    pub(crate) pool_registry: Arc<tokio::sync::RwLock<PoolRegistry>>,
98    /// Filesystem paths for pool registry management.
99    ///
100    /// Stored here so `run.rs` / `engine_api_impl.rs` can reach them without
101    /// re-computing from `AppConfig` on every call.
102    pub(crate) pool_reg_path: std::path::PathBuf,
103    pub(crate) pool_lock_path: std::path::PathBuf,
104    pub(crate) pool_dir: std::path::PathBuf,
105}
106
107impl AppService {
108    pub fn new(
109        executor: Arc<Executor>,
110        log_config: AppConfig,
111        search_paths: Vec<resolve::SearchPath>,
112    ) -> Self {
113        let registry = Arc::new(SessionRegistry::new());
114        // TTL = 3 hours. Complex strategies may run 30–60 min; 3h covers
115        // legitimate paused sessions while eventually reclaiming abandoned ones.
116        registry.spawn_gc_task(std::time::Duration::from_secs(10800));
117        let app_dir = log_config.app_dir();
118        let state_store = Arc::new(JsonFileStore::new(app_dir.state_dir()));
119        let card_store = Arc::new(FileCardStore::new(app_dir.cards_dir()));
120
121        // ─── Pool registry setup ───────────────────────────────────────────────
122        // Paths: ~/.algocline/state/pool/{registry.json, registry.lock}
123        // No pool_dir() helper in AppDir (not in scope); derive manually.
124        let pool_dir = app_dir.state_dir().join("pool");
125        let pool_reg_path = pool_dir.join("registry.json");
126        let pool_lock_path = pool_dir.join("registry.lock");
127
128        // Startup GC: remove dead worker entries accumulated from previous
129        // MCP sessions.  Runs synchronously in new() (called before any
130        // tokio tasks spawn) so spawn_blocking is not needed (K-110).
131        //
132        // If GC fails (corrupt registry, lock I/O error), we start with an
133        // empty registry and emit a tracing::warn.  This is justified for
134        // startup housekeeping only: the worker processes themselves remain
135        // alive — they accumulate as orphans until the next restart, which
136        // is acceptable for a POC.  A corrupt registry.json on startup is
137        // surfaced via the tracing warn so operators can investigate.
138        //
139        // NOTE: this is the ONLY place in AppService that uses
140        // tracing::warn without propagating to MCP wire.  It is defensible
141        // because new() has no return type capable of carrying the error, and
142        // GC failure has no correctness impact on the current session.
143        let pool_registry = match with_registry_lock(&pool_lock_path, || {
144            let mut reg = PoolRegistry::load_or_default(&pool_reg_path)?;
145            let _ = reg.scan_and_gc()?;
146            reg.save(&pool_reg_path)?;
147            Ok::<_, PoolError>(reg)
148        }) {
149            Ok(reg) => reg,
150            Err(e) => {
151                tracing::warn!("pool registry startup GC failed (workers may accumulate): {e}");
152                PoolRegistry::default()
153            }
154        };
155
156        Self {
157            executor,
158            registry,
159            log_config,
160            search_paths,
161            state_store,
162            card_store,
163            eval_sessions: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
164            session_strategies: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
165            pool_registry: Arc::new(tokio::sync::RwLock::new(pool_registry)),
166            pool_reg_path,
167            pool_lock_path,
168            pool_dir,
169        }
170    }
171
172    /// Returns the log directory, or an error if file logging is unavailable.
173    fn require_log_dir(&self) -> Result<&Path, String> {
174        self.log_config
175            .log_dir
176            .as_deref()
177            .ok_or_else(|| "File logging is not available (no writable log directory)".to_string())
178    }
179
180    /// Resolve extra lib paths for a request.
181    ///
182    /// Merges two layers in priority order (first = highest = prepended
183    /// by the Executor to `package.path`):
184    ///
185    /// 1. `alc.local.toml` path entries — worktree-scoped override
186    ///    (git-ignored, not persisted to alc.lock, loaded every call).
187    /// 2. `alc.lock` path entries — alc.toml-derived, git-managed.
188    ///
189    /// Returns `(paths, warnings)`. An empty `paths` is returned when no project
190    /// root is found. Corruption errors (parse failures) are returned as warning
191    /// strings in the second element rather than dropped silently — callers are
192    /// responsible for surfacing them on the MCP wire response. File-absent is
193    /// `Ok(None)` in the underlying loaders and produces no warning.
194    pub(crate) fn resolve_extra_lib_paths(
195        &self,
196        project_root: Option<&str>,
197    ) -> (Vec<std::path::PathBuf>, Vec<String>) {
198        let Some(root) = project::resolve_project_root(project_root) else {
199            return (vec![], vec![]);
200        };
201
202        let mut warnings: Vec<String> = Vec::new();
203
204        // Local override layer (highest priority) — merged every call,
205        // never persisted to alc.lock (decisions.md FsResolver priority).
206        let local_paths: Vec<std::path::PathBuf> = match alc_toml::load_alc_local_toml(&root) {
207            Ok(Some(local)) => alc_toml::resolve_local_path_entries(&root, &local),
208            Ok(None) => Vec::new(),
209            Err(e) => {
210                warnings.push(format!(
211                    "failed to load alc.local.toml at {}: {e}",
212                    root.display()
213                ));
214                Vec::new()
215            }
216        };
217
218        // Existing alc.lock layer.
219        let lock_paths: Vec<std::path::PathBuf> = match lockfile::load_lockfile(&root) {
220            Ok(Some(lock)) => {
221                self.warn_toml_lock_mismatch(&root, &lock);
222                let (paths, path_warnings) = lockfile::resolve_path_entries(&root, &lock);
223                warnings.extend(path_warnings);
224                paths
225            }
226            Ok(None) => Vec::new(),
227            Err(e) => {
228                warnings.push(format!(
229                    "failed to load alc.lock at {}: {e}",
230                    root.display()
231                ));
232                Vec::new()
233            }
234        };
235
236        let mut merged = local_paths;
237        merged.extend(lock_paths);
238        (merged, warnings)
239    }
240
241    /// Resolve variant pkg overrides for a request.
242    ///
243    /// Reads `alc.local.toml` (worktree-scoped, gitignored) and emits one
244    /// [`VariantPkg`] per `[packages.{name}] path = "..."` entry, preserving
245    /// the explicit `(name, pkg_dir)` mapping. Returns `(pkgs, warnings)` —
246    /// an empty `pkgs` when no project root is found or `alc.local.toml` is
247    /// absent. Corruption (parse failures) is returned as a warning string so
248    /// callers can surface it on the MCP wire response.
249    pub(crate) fn resolve_variant_pkgs(
250        &self,
251        project_root: Option<&str>,
252    ) -> (Vec<VariantPkg>, Vec<String>) {
253        let Some(root) = project::resolve_project_root(project_root) else {
254            return (vec![], vec![]);
255        };
256
257        match alc_toml::load_alc_local_toml(&root) {
258            Ok(Some(local)) => (alc_toml::resolve_local_variant_pkgs(&root, &local), vec![]),
259            Ok(None) => (Vec::new(), vec![]),
260            Err(e) => {
261                let msg = format!("failed to load alc.local.toml at {}: {e}", root.display());
262                (Vec::new(), vec![msg])
263            }
264        }
265    }
266
267    fn warn_toml_lock_mismatch(&self, root: &Path, lock: &lockfile::LockFile) {
268        let toml = match alc_toml::load_alc_toml(root) {
269            Ok(Some(t)) => t,
270            _ => return,
271        };
272
273        use std::collections::BTreeSet;
274        let toml_names: BTreeSet<&str> = toml.packages.keys().map(|s| s.as_str()).collect();
275        let lock_names: BTreeSet<&str> = lock.packages.iter().map(|p| p.name.as_str()).collect();
276
277        for name in toml_names.difference(&lock_names) {
278            eprintln!(
279                "warning: '{name}' is declared in alc.toml but missing from alc.lock. Run `alc_update` to sync."
280            );
281        }
282        for name in lock_names.difference(&toml_names) {
283            eprintln!("warning: '{name}' is in alc.lock but not declared in alc.toml.");
284        }
285    }
286}