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 algocline_engine::{Executor, FileCardStore, JsonFileStore, SessionRegistry, VariantPkg};
45
46pub use algocline_core::{EngineApi, TokenUsage};
47pub use config::{AppConfig, LogDirSource};
48pub use resolve::{QueryResponse, SearchPath};
49
50// ─── Application Service ────────────────────────────────────────
51
52/// Tracks in-flight eval sessions: session_id → strategy name.
53///
54/// Kept between `alc_eval` invocation and eventual completion (which may
55/// arrive via `alc_continue` after LLM round-trips). Used by
56/// `run.rs::maybe_save_eval` to persist the result to `~/.algocline/evals/`.
57/// Card emission is handled by `alc.eval()` Lua-side — no Rust tracking needed.
58///
59/// `std::sync::Mutex` is used (not tokio) because all operations are
60/// single HashMap insert/remove/get completing in microseconds, and no
61/// `.await` is held across the lock. Poison is silently skipped.
62type EvalSessions = std::sync::Mutex<std::collections::HashMap<String, String>>;
63
64/// Tracks session_id → strategy name for all strategy-based sessions (advice, eval).
65///
66/// Same locking rationale as `EvalSessions`. Used by `alc_status` and
67/// transcript logging. Poison is silently skipped — strategy name is
68/// non-critical metadata for observability.
69type SessionStrategies = std::sync::Mutex<std::collections::HashMap<String, String>>;
70
71#[derive(Clone)]
72pub struct AppService {
73    executor: Arc<Executor>,
74    registry: Arc<SessionRegistry>,
75    log_config: AppConfig,
76    /// Package search paths in priority order (first = highest).
77    search_paths: Vec<resolve::SearchPath>,
78    /// Persistent KV store backing `alc.state.*`.
79    ///
80    /// Rooted at `log_config.app_dir().state_dir()` and resolved once at
81    /// construction; `Arc`-wrapped so per-session clones are cheap.
82    state_store: Arc<JsonFileStore>,
83    /// Card store backing `alc.card.*`.
84    ///
85    /// Rooted at `log_config.app_dir().cards_dir()`, same `Arc` pattern.
86    card_store: Arc<FileCardStore>,
87    /// session_id → strategy name for eval sessions (cleared on completion).
88    eval_sessions: Arc<EvalSessions>,
89    /// session_id → strategy name for log/stats tracking (cleared on session completion).
90    session_strategies: Arc<SessionStrategies>,
91}
92
93impl AppService {
94    pub fn new(
95        executor: Arc<Executor>,
96        log_config: AppConfig,
97        search_paths: Vec<resolve::SearchPath>,
98    ) -> Self {
99        let registry = Arc::new(SessionRegistry::new());
100        // TTL = 3 hours. Complex strategies may run 30–60 min; 3h covers
101        // legitimate paused sessions while eventually reclaiming abandoned ones.
102        registry.spawn_gc_task(std::time::Duration::from_secs(10800));
103        let app_dir = log_config.app_dir();
104        let state_store = Arc::new(JsonFileStore::new(app_dir.state_dir()));
105        let card_store = Arc::new(FileCardStore::new(app_dir.cards_dir()));
106        Self {
107            executor,
108            registry,
109            log_config,
110            search_paths,
111            state_store,
112            card_store,
113            eval_sessions: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
114            session_strategies: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
115        }
116    }
117
118    /// Returns the log directory, or an error if file logging is unavailable.
119    fn require_log_dir(&self) -> Result<&Path, String> {
120        self.log_config
121            .log_dir
122            .as_deref()
123            .ok_or_else(|| "File logging is not available (no writable log directory)".to_string())
124    }
125
126    /// Resolve extra lib paths for a request.
127    ///
128    /// Merges two layers in priority order (first = highest = prepended
129    /// by the Executor to `package.path`):
130    ///
131    /// 1. `alc.local.toml` path entries — worktree-scoped override
132    ///    (git-ignored, not persisted to alc.lock, loaded every call).
133    /// 2. `alc.lock` path entries — alc.toml-derived, git-managed.
134    ///
135    /// Returns `(paths, warnings)`. An empty `paths` is returned when no project
136    /// root is found. Corruption errors (parse failures) are returned as warning
137    /// strings in the second element rather than dropped silently — callers are
138    /// responsible for surfacing them on the MCP wire response. File-absent is
139    /// `Ok(None)` in the underlying loaders and produces no warning.
140    pub(crate) fn resolve_extra_lib_paths(
141        &self,
142        project_root: Option<&str>,
143    ) -> (Vec<std::path::PathBuf>, Vec<String>) {
144        let Some(root) = project::resolve_project_root(project_root) else {
145            return (vec![], vec![]);
146        };
147
148        let mut warnings: Vec<String> = Vec::new();
149
150        // Local override layer (highest priority) — merged every call,
151        // never persisted to alc.lock (decisions.md FsResolver priority).
152        let local_paths: Vec<std::path::PathBuf> = match alc_toml::load_alc_local_toml(&root) {
153            Ok(Some(local)) => alc_toml::resolve_local_path_entries(&root, &local),
154            Ok(None) => Vec::new(),
155            Err(e) => {
156                warnings.push(format!(
157                    "failed to load alc.local.toml at {}: {e}",
158                    root.display()
159                ));
160                Vec::new()
161            }
162        };
163
164        // Existing alc.lock layer.
165        let lock_paths: Vec<std::path::PathBuf> = match lockfile::load_lockfile(&root) {
166            Ok(Some(lock)) => {
167                self.warn_toml_lock_mismatch(&root, &lock);
168                let (paths, path_warnings) = lockfile::resolve_path_entries(&root, &lock);
169                warnings.extend(path_warnings);
170                paths
171            }
172            Ok(None) => Vec::new(),
173            Err(e) => {
174                warnings.push(format!(
175                    "failed to load alc.lock at {}: {e}",
176                    root.display()
177                ));
178                Vec::new()
179            }
180        };
181
182        let mut merged = local_paths;
183        merged.extend(lock_paths);
184        (merged, warnings)
185    }
186
187    /// Resolve variant pkg overrides for a request.
188    ///
189    /// Reads `alc.local.toml` (worktree-scoped, gitignored) and emits one
190    /// [`VariantPkg`] per `[packages.{name}] path = "..."` entry, preserving
191    /// the explicit `(name, pkg_dir)` mapping. Returns `(pkgs, warnings)` —
192    /// an empty `pkgs` when no project root is found or `alc.local.toml` is
193    /// absent. Corruption (parse failures) is returned as a warning string so
194    /// callers can surface it on the MCP wire response.
195    pub(crate) fn resolve_variant_pkgs(
196        &self,
197        project_root: Option<&str>,
198    ) -> (Vec<VariantPkg>, Vec<String>) {
199        let Some(root) = project::resolve_project_root(project_root) else {
200            return (vec![], vec![]);
201        };
202
203        match alc_toml::load_alc_local_toml(&root) {
204            Ok(Some(local)) => (alc_toml::resolve_local_variant_pkgs(&root, &local), vec![]),
205            Ok(None) => (Vec::new(), vec![]),
206            Err(e) => {
207                let msg = format!("failed to load alc.local.toml at {}: {e}", root.display());
208                (Vec::new(), vec![msg])
209            }
210        }
211    }
212
213    fn warn_toml_lock_mismatch(&self, root: &Path, lock: &lockfile::LockFile) {
214        let toml = match alc_toml::load_alc_toml(root) {
215            Ok(Some(t)) => t,
216            _ => return,
217        };
218
219        use std::collections::BTreeSet;
220        let toml_names: BTreeSet<&str> = toml.packages.keys().map(|s| s.as_str()).collect();
221        let lock_names: BTreeSet<&str> = lock.packages.iter().map(|p| p.name.as_str()).collect();
222
223        for name in toml_names.difference(&lock_names) {
224            eprintln!(
225                "warning: '{name}' is declared in alc.toml but missing from alc.lock. Run `alc_update` to sync."
226            );
227        }
228        for name in lock_names.difference(&toml_names) {
229            eprintln!("warning: '{name}' is in alc.lock but not declared in alc.toml.");
230        }
231    }
232}