Skip to main content

algocline_app/service/
mod.rs

1pub(crate) mod alc_toml;
2mod card;
3mod config;
4mod engine_api_impl;
5mod eval;
6mod eval_store;
7mod hub;
8mod init;
9pub(crate) mod list_opts;
10pub(crate) mod lock;
11pub(crate) mod lockfile;
12mod logging;
13pub(crate) mod manifest;
14mod migrate;
15pub(crate) mod path;
16mod pkg;
17mod pkg_link;
18mod pkg_unlink;
19pub(crate) mod project;
20pub mod resolve;
21mod run;
22mod scenario;
23pub(crate) mod source;
24mod status;
25mod transcript;
26mod update;
27
28#[cfg(test)]
29mod test_support;
30#[cfg(test)]
31mod tests;
32
33use std::path::Path;
34use std::sync::Arc;
35
36use algocline_engine::{Executor, SessionRegistry, VariantPkg};
37
38pub use algocline_core::{EngineApi, TokenUsage};
39pub use config::{AppConfig, LogDirSource};
40pub use resolve::{QueryResponse, SearchPath};
41
42// ─── Application Service ────────────────────────────────────────
43
44/// Tracks in-flight eval sessions: session_id → strategy name.
45///
46/// Kept between `alc_eval` invocation and eventual completion (which may
47/// arrive via `alc_continue` after LLM round-trips). Used by
48/// `run.rs::maybe_save_eval` to persist the result to `~/.algocline/evals/`.
49/// Card emission is handled by `alc.eval()` Lua-side — no Rust tracking needed.
50///
51/// `std::sync::Mutex` is used (not tokio) because all operations are
52/// single HashMap insert/remove/get completing in microseconds, and no
53/// `.await` is held across the lock. Poison is silently skipped.
54type EvalSessions = std::sync::Mutex<std::collections::HashMap<String, String>>;
55
56/// Tracks session_id → strategy name for all strategy-based sessions (advice, eval).
57///
58/// Same locking rationale as `EvalSessions`. Used by `alc_status` and
59/// transcript logging. Poison is silently skipped — strategy name is
60/// non-critical metadata for observability.
61type SessionStrategies = std::sync::Mutex<std::collections::HashMap<String, String>>;
62
63#[derive(Clone)]
64pub struct AppService {
65    executor: Arc<Executor>,
66    registry: Arc<SessionRegistry>,
67    log_config: AppConfig,
68    /// Package search paths in priority order (first = highest).
69    search_paths: Vec<resolve::SearchPath>,
70    /// session_id → strategy name for eval sessions (cleared on completion).
71    eval_sessions: Arc<EvalSessions>,
72    /// session_id → strategy name for log/stats tracking (cleared on session completion).
73    session_strategies: Arc<SessionStrategies>,
74}
75
76impl AppService {
77    pub fn new(
78        executor: Arc<Executor>,
79        log_config: AppConfig,
80        search_paths: Vec<resolve::SearchPath>,
81    ) -> Self {
82        let registry = Arc::new(SessionRegistry::new());
83        // TTL = 3 hours. Complex strategies may run 30–60 min; 3h covers
84        // legitimate paused sessions while eventually reclaiming abandoned ones.
85        registry.spawn_gc_task(std::time::Duration::from_secs(10800));
86        Self {
87            executor,
88            registry,
89            log_config,
90            search_paths,
91            eval_sessions: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
92            session_strategies: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
93        }
94    }
95
96    /// Returns the log directory, or an error if file logging is unavailable.
97    fn require_log_dir(&self) -> Result<&Path, String> {
98        self.log_config
99            .log_dir
100            .as_deref()
101            .ok_or_else(|| "File logging is not available (no writable log directory)".to_string())
102    }
103
104    /// Resolve extra lib paths for a request.
105    ///
106    /// Merges two layers in priority order (first = highest = prepended
107    /// by the Executor to `package.path`):
108    ///
109    /// 1. `alc.local.toml` path entries — worktree-scoped override
110    ///    (git-ignored, not persisted to alc.lock, loaded every call).
111    /// 2. `alc.lock` path entries — alc.toml-derived, git-managed.
112    ///
113    /// Returns an empty `Vec` if no project root is found. Partial
114    /// failures (e.g. malformed `alc.local.toml`) are logged at `warn`
115    /// and degraded to the empty layer without failing the whole call.
116    pub(crate) fn resolve_extra_lib_paths(
117        &self,
118        project_root: Option<&str>,
119    ) -> Vec<std::path::PathBuf> {
120        let Some(root) = project::resolve_project_root(project_root) else {
121            return vec![];
122        };
123
124        // Local override layer (highest priority) — merged every call,
125        // never persisted to alc.lock (decisions.md FsResolver priority).
126        let local_paths: Vec<std::path::PathBuf> = match alc_toml::load_alc_local_toml(&root) {
127            Ok(Some(local)) => alc_toml::resolve_local_path_entries(&root, &local),
128            Ok(None) => Vec::new(),
129            Err(e) => {
130                tracing::warn!("failed to load alc.local.toml at {}: {e}", root.display());
131                Vec::new()
132            }
133        };
134
135        // Existing alc.lock layer.
136        let lock_paths: Vec<std::path::PathBuf> = match lockfile::load_lockfile(&root) {
137            Ok(Some(lock)) => {
138                self.warn_toml_lock_mismatch(&root, &lock);
139                lockfile::resolve_path_entries(&root, &lock)
140            }
141            Ok(None) => Vec::new(),
142            Err(e) => {
143                tracing::warn!("failed to load alc.lock at {}: {e}", root.display());
144                Vec::new()
145            }
146        };
147
148        let mut merged = local_paths;
149        merged.extend(lock_paths);
150        merged
151    }
152
153    /// Resolve variant pkg overrides for a request.
154    ///
155    /// Reads `alc.local.toml` (worktree-scoped, gitignored) and emits one
156    /// [`VariantPkg`] per `[packages.{name}] path = "..."` entry, preserving
157    /// the explicit `(name, pkg_dir)` mapping. Returns an empty `Vec` if no
158    /// project root is found or `alc.local.toml` is missing/malformed
159    /// (failures are logged at `warn`).
160    pub(crate) fn resolve_variant_pkgs(&self, project_root: Option<&str>) -> Vec<VariantPkg> {
161        let Some(root) = project::resolve_project_root(project_root) else {
162            return vec![];
163        };
164
165        match alc_toml::load_alc_local_toml(&root) {
166            Ok(Some(local)) => alc_toml::resolve_local_variant_pkgs(&root, &local),
167            Ok(None) => Vec::new(),
168            Err(e) => {
169                tracing::warn!("failed to load alc.local.toml at {}: {e}", root.display());
170                Vec::new()
171            }
172        }
173    }
174
175    fn warn_toml_lock_mismatch(&self, root: &Path, lock: &lockfile::LockFile) {
176        let toml = match alc_toml::load_alc_toml(root) {
177            Ok(Some(t)) => t,
178            _ => return,
179        };
180
181        use std::collections::BTreeSet;
182        let toml_names: BTreeSet<&str> = toml.packages.keys().map(|s| s.as_str()).collect();
183        let lock_names: BTreeSet<&str> = lock.packages.iter().map(|p| p.name.as_str()).collect();
184
185        for name in toml_names.difference(&lock_names) {
186            eprintln!(
187                "warning: '{name}' is declared in alc.toml but missing from alc.lock. Run `alc_update` to sync."
188            );
189        }
190        for name in lock_names.difference(&toml_names) {
191            eprintln!("warning: '{name}' is in alc.lock but not declared in alc.toml.");
192        }
193    }
194}