Skip to main content

algocline_app/service/
mod.rs

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