Skip to main content

algocline_app/service/
mod.rs

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