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