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 lockfile;
10mod logging;
11pub(crate) mod manifest;
12mod migrate;
13pub(crate) mod path;
14mod pkg;
15mod pkg_link;
16mod pkg_unlink;
17pub(crate) mod project;
18pub mod resolve;
19mod run;
20mod scenario;
21pub(crate) mod source;
22mod status;
23mod transcript;
24mod update;
25
26#[cfg(test)]
27mod test_support;
28#[cfg(test)]
29mod tests;
30
31use std::path::Path;
32use std::sync::Arc;
33
34use algocline_engine::{Executor, SessionRegistry};
35
36pub use algocline_core::{EngineApi, TokenUsage};
37pub use config::{AppConfig, LogDirSource};
38pub use resolve::{QueryResponse, SearchPath};
39
40// ─── Application Service ────────────────────────────────────────
41
42/// Tracks in-flight eval sessions: session_id → strategy name.
43///
44/// Kept between `alc_eval` invocation and eventual completion (which may
45/// arrive via `alc_continue` after LLM round-trips). Used by
46/// `run.rs::maybe_save_eval` to persist the result to `~/.algocline/evals/`.
47/// Card emission is handled by `alc.eval()` Lua-side — no Rust tracking needed.
48///
49/// `std::sync::Mutex` is used (not tokio) because all operations are
50/// single HashMap insert/remove/get completing in microseconds, and no
51/// `.await` is held across the lock. Poison is silently skipped.
52type EvalSessions = std::sync::Mutex<std::collections::HashMap<String, String>>;
53
54/// Tracks session_id → strategy name for all strategy-based sessions (advice, eval).
55///
56/// Same locking rationale as `EvalSessions`. Used by `alc_status` and
57/// transcript logging. Poison is silently skipped — strategy name is
58/// non-critical metadata for observability.
59type SessionStrategies = std::sync::Mutex<std::collections::HashMap<String, String>>;
60
61#[derive(Clone)]
62pub struct AppService {
63    executor: Arc<Executor>,
64    registry: Arc<SessionRegistry>,
65    log_config: AppConfig,
66    /// Package search paths in priority order (first = highest).
67    search_paths: Vec<resolve::SearchPath>,
68    /// session_id → strategy name for eval sessions (cleared on completion).
69    eval_sessions: Arc<EvalSessions>,
70    /// session_id → strategy name for log/stats tracking (cleared on session completion).
71    session_strategies: Arc<SessionStrategies>,
72}
73
74impl AppService {
75    pub fn new(
76        executor: Arc<Executor>,
77        log_config: AppConfig,
78        search_paths: Vec<resolve::SearchPath>,
79    ) -> Self {
80        let registry = Arc::new(SessionRegistry::new());
81        // TTL = 3 hours. Complex strategies may run 30–60 min; 3h covers
82        // legitimate paused sessions while eventually reclaiming abandoned ones.
83        registry.spawn_gc_task(std::time::Duration::from_secs(10800));
84        Self {
85            executor,
86            registry,
87            log_config,
88            search_paths,
89            eval_sessions: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
90            session_strategies: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
91        }
92    }
93
94    /// Returns the log directory, or an error if file logging is unavailable.
95    fn require_log_dir(&self) -> Result<&Path, String> {
96        self.log_config
97            .log_dir
98            .as_deref()
99            .ok_or_else(|| "File logging is not available (no writable log directory)".to_string())
100    }
101
102    /// Resolve extra lib paths for a request.
103    ///
104    /// 1. Determines the project root from `project_root` (explicit) or
105    ///    `ALC_PROJECT_ROOT` env or ancestor walk from cwd.
106    /// 2. Reads `alc.lock` from that root.
107    /// 3. Returns the resolved absolute paths of all `local_dir` entries.
108    ///
109    /// Returns an empty `Vec` if no project root is found, if `alc.lock`
110    /// does not exist, or if no `local_dir` entries are present.
111    pub(crate) fn resolve_extra_lib_paths(
112        &self,
113        project_root: Option<&str>,
114    ) -> Vec<std::path::PathBuf> {
115        let Some(root) = project::resolve_project_root(project_root) else {
116            return vec![];
117        };
118
119        match lockfile::load_lockfile(&root) {
120            Ok(Some(lock)) => {
121                self.warn_toml_lock_mismatch(&root, &lock);
122                lockfile::resolve_path_entries(&root, &lock)
123            }
124            Ok(None) => vec![],
125            Err(e) => {
126                tracing::warn!("failed to load alc.lock at {}: {e}", root.display());
127                vec![]
128            }
129        }
130    }
131
132    fn warn_toml_lock_mismatch(&self, root: &Path, lock: &lockfile::LockFile) {
133        let toml = match alc_toml::load_alc_toml(root) {
134            Ok(Some(t)) => t,
135            _ => return,
136        };
137
138        use std::collections::BTreeSet;
139        let toml_names: BTreeSet<&str> = toml.packages.keys().map(|s| s.as_str()).collect();
140        let lock_names: BTreeSet<&str> = lock.packages.iter().map(|p| p.name.as_str()).collect();
141
142        for name in toml_names.difference(&lock_names) {
143            eprintln!(
144                "warning: '{name}' is declared in alc.toml but missing from alc.lock. Run `alc_update` to sync."
145            );
146        }
147        for name in lock_names.difference(&toml_names) {
148            eprintln!("warning: '{name}' is in alc.lock but not declared in alc.toml.");
149        }
150    }
151}