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 session;
32pub(crate) mod source;
33mod status;
34mod transcript;
35mod update;
36
37#[cfg(test)]
38mod test_support;
39#[cfg(test)]
40mod tests;
41
42use std::path::Path;
43use std::sync::Arc;
44
45use crate::pool::{registry::with_registry_lock, PoolError, PoolRegistry};
46use algocline_engine::{Executor, FileCardStore, JsonFileStore, SessionRegistry, VariantPkg};
47
48pub use algocline_core::{EngineApi, TokenUsage};
49pub use config::{AppConfig, LogDirSource};
50pub use resolve::{QueryResponse, SearchPath};
51
52// ─── Application Service ────────────────────────────────────────
53
54/// Tracks in-flight eval sessions: session_id → strategy name.
55///
56/// Kept between `alc_eval` invocation and eventual completion (which may
57/// arrive via `alc_continue` after LLM round-trips). Used by
58/// `run.rs::maybe_save_eval` to persist the result to `~/.algocline/evals/`.
59/// Card emission is handled by `alc.eval()` Lua-side — no Rust tracking needed.
60///
61/// `std::sync::Mutex` is used (not tokio) because all operations are
62/// single HashMap insert/remove/get completing in microseconds, and no
63/// `.await` is held across the lock. Poison is silently skipped.
64type EvalSessions = std::sync::Mutex<std::collections::HashMap<String, String>>;
65
66/// Tracks session_id → strategy name for all strategy-based sessions (advice, eval).
67///
68/// Same locking rationale as `EvalSessions`. Used by `alc_status` and
69/// transcript logging. Poison is silently skipped — strategy name is
70/// non-critical metadata for observability.
71type SessionStrategies = std::sync::Mutex<std::collections::HashMap<String, String>>;
72
73#[derive(Clone)]
74pub struct AppService {
75 executor: Arc<Executor>,
76 registry: Arc<SessionRegistry>,
77 log_config: AppConfig,
78 /// Package search paths in priority order (first = highest).
79 search_paths: Vec<resolve::SearchPath>,
80 /// Persistent KV store backing `alc.state.*`.
81 ///
82 /// Rooted at `log_config.app_dir().state_dir()` and resolved once at
83 /// construction; `Arc`-wrapped so per-session clones are cheap.
84 state_store: Arc<JsonFileStore>,
85 /// Card store backing `alc.card.*`.
86 ///
87 /// Rooted at `log_config.app_dir().cards_dir()`, same `Arc` pattern.
88 card_store: Arc<FileCardStore>,
89 /// session_id → strategy name for eval sessions (cleared on completion).
90 eval_sessions: Arc<EvalSessions>,
91 /// session_id → strategy name for log/stats tracking (cleared on session completion).
92 session_strategies: Arc<SessionStrategies>,
93 /// Pool worker registry (persistent, backed by registry.json).
94 ///
95 /// `RwLock` because multiple concurrent callers may check for pool sessions
96 /// while a single writer spawns a new worker. The lock is never held across
97 /// an `.await` boundary (K-4: clone-then-release pattern).
98 pub(crate) pool_registry: Arc<tokio::sync::RwLock<PoolRegistry>>,
99 /// Filesystem paths for pool registry management.
100 ///
101 /// Stored here so `run.rs` / `engine_api_impl.rs` can reach them without
102 /// re-computing from `AppConfig` on every call.
103 pub(crate) pool_reg_path: std::path::PathBuf,
104 pub(crate) pool_lock_path: std::path::PathBuf,
105 pub(crate) pool_dir: std::path::PathBuf,
106 /// Activated session pin for `alc_session_new` (#1776627475). For
107 /// stdio MCP transport this is functionally a per-connection
108 /// pin (one process = one connection). `None` when no session
109 /// has been activated; callers fall back to the existing
110 /// `resolve_project_root` chain (P > E > W).
111 ///
112 /// `std::sync::Mutex` because all access is a single
113 /// load/store completing in microseconds, with no `.await` held
114 /// across the lock. Poison maps to "no session pin" so a
115 /// poisoned lock degrades to legacy behaviour rather than
116 /// breaking every subsequent tool call.
117 pub(crate) session: Arc<std::sync::Mutex<Option<session::AlcSession>>>,
118}
119
120impl AppService {
121 pub fn new(
122 executor: Arc<Executor>,
123 log_config: AppConfig,
124 search_paths: Vec<resolve::SearchPath>,
125 ) -> Self {
126 let registry = Arc::new(SessionRegistry::new());
127 // TTL = 3 hours. Complex strategies may run 30–60 min; 3h covers
128 // legitimate paused sessions while eventually reclaiming abandoned ones.
129 registry.spawn_gc_task(std::time::Duration::from_secs(10800));
130 let app_dir = log_config.app_dir();
131 let state_store = Arc::new(JsonFileStore::new(app_dir.state_dir()));
132 let card_store = Arc::new(FileCardStore::new(app_dir.cards_dir()));
133
134 // ─── Pool registry setup ───────────────────────────────────────────────
135 // Paths: ~/.algocline/state/pool/{registry.json, registry.lock}
136 // No pool_dir() helper in AppDir (not in scope); derive manually.
137 let pool_dir = app_dir.state_dir().join("pool");
138 let pool_reg_path = pool_dir.join("registry.json");
139 let pool_lock_path = pool_dir.join("registry.lock");
140
141 // Startup GC: remove dead worker entries accumulated from previous
142 // MCP sessions. Runs synchronously in new() (called before any
143 // tokio tasks spawn) so spawn_blocking is not needed (K-110).
144 //
145 // If GC fails (corrupt registry, lock I/O error), we start with an
146 // empty registry and emit a tracing::warn. This is justified for
147 // startup housekeeping only: the worker processes themselves remain
148 // alive — they accumulate as orphans until the next restart, which
149 // is acceptable for a POC. A corrupt registry.json on startup is
150 // surfaced via the tracing warn so operators can investigate.
151 //
152 // NOTE: this is the ONLY place in AppService that uses
153 // tracing::warn without propagating to MCP wire. It is defensible
154 // because new() has no return type capable of carrying the error, and
155 // GC failure has no correctness impact on the current session.
156 let pool_registry = match with_registry_lock(&pool_lock_path, || {
157 let mut reg = PoolRegistry::load_or_default(&pool_reg_path)?;
158 let _ = reg.scan_and_gc()?;
159 reg.save(&pool_reg_path)?;
160 Ok::<_, PoolError>(reg)
161 }) {
162 Ok(reg) => reg,
163 Err(e) => {
164 tracing::warn!("pool registry startup GC failed (workers may accumulate): {e}");
165 PoolRegistry::default()
166 }
167 };
168
169 Self {
170 executor,
171 registry,
172 log_config,
173 search_paths,
174 state_store,
175 card_store,
176 eval_sessions: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
177 session_strategies: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
178 pool_registry: Arc::new(tokio::sync::RwLock::new(pool_registry)),
179 pool_reg_path,
180 pool_lock_path,
181 pool_dir,
182 session: Arc::new(std::sync::Mutex::new(None)),
183 }
184 }
185
186 /// Activate (or replace) the session pin. Returns the new
187 /// `AlcSession`. See `session::AlcSession` for lifecycle
188 /// semantics. Wired through `EngineApi::session_new` to the
189 /// MCP `alc_session_new` tool.
190 pub(crate) fn activate_session(
191 &self,
192 project_root: Option<&str>,
193 mode: Option<&str>,
194 ) -> Result<session::AlcSession, String> {
195 let pinned = project::resolve_project_root(project_root);
196 let mode = session::SessionMode::parse(mode)?;
197 let new = session::AlcSession::new(pinned, mode);
198 let mut guard = self
199 .session
200 .lock()
201 .map_err(|_| "alc_session_new: session lock poisoned".to_string())?;
202 *guard = Some(new.clone());
203 Ok(new)
204 }
205
206 /// Snapshot the currently activated session, if any.
207 ///
208 /// Returns `None` when no session has been activated or when
209 /// the session lock is poisoned (degrades to legacy
210 /// `resolve_project_root` behaviour rather than failing every
211 /// subsequent tool call).
212 pub(crate) fn current_session(&self) -> Option<session::AlcSession> {
213 self.session.lock().ok().and_then(|g| g.clone())
214 }
215
216 /// Resolve the project root for a tool call, consulting the
217 /// activated session pin between the explicit per-call argument
218 /// and the `ALC_PROJECT_ROOT` env var (issue #1776627475 §6:
219 /// P > S > E > W).
220 ///
221 /// MCP tool entry points should use this instead of
222 /// `project::resolve_project_root` so the activated session
223 /// pin is honoured uniformly. Lower-level free functions
224 /// (hub_dist_preset, etc.) continue to use the legacy free
225 /// helper because their callers have already routed through
226 /// this method when invoked from the AppService layer.
227 pub(crate) fn resolve_root(&self, explicit: Option<&str>) -> Option<std::path::PathBuf> {
228 let session_pin = self.current_session().and_then(|s| s.project_root);
229 project::resolve_project_root_with_session(explicit, session_pin.as_deref())
230 }
231
232 /// Returns the log directory, or an error if file logging is unavailable.
233 fn require_log_dir(&self) -> Result<&Path, String> {
234 self.log_config
235 .log_dir
236 .as_deref()
237 .ok_or_else(|| "File logging is not available (no writable log directory)".to_string())
238 }
239
240 /// Resolve extra lib paths for a request.
241 ///
242 /// Merges two layers in priority order (first = highest = prepended
243 /// by the Executor to `package.path`):
244 ///
245 /// 1. `alc.local.toml` path entries — worktree-scoped override
246 /// (git-ignored, not persisted to alc.lock, loaded every call).
247 /// 2. `alc.lock` path entries — alc.toml-derived, git-managed.
248 ///
249 /// Returns `(paths, warnings)`. An empty `paths` is returned when no project
250 /// root is found. Corruption errors (parse failures) are returned as warning
251 /// strings in the second element rather than dropped silently — callers are
252 /// responsible for surfacing them on the MCP wire response. File-absent is
253 /// `Ok(None)` in the underlying loaders and produces no warning.
254 pub(crate) fn resolve_extra_lib_paths(
255 &self,
256 project_root: Option<&str>,
257 ) -> (Vec<std::path::PathBuf>, Vec<String>) {
258 let Some(root) = self.resolve_root(project_root) else {
259 return (vec![], vec![]);
260 };
261
262 let mut warnings: Vec<String> = Vec::new();
263
264 // Local override layer (highest priority) — merged every call,
265 // never persisted to alc.lock (decisions.md FsResolver priority).
266 let local_paths: Vec<std::path::PathBuf> = match alc_toml::load_alc_local_toml(&root) {
267 Ok(Some(local)) => alc_toml::resolve_local_path_entries(&root, &local),
268 Ok(None) => Vec::new(),
269 Err(e) => {
270 warnings.push(format!(
271 "failed to load alc.local.toml at {}: {e}",
272 root.display()
273 ));
274 Vec::new()
275 }
276 };
277
278 // Existing alc.lock layer.
279 let lock_paths: Vec<std::path::PathBuf> = match lockfile::load_lockfile(&root) {
280 Ok(Some(lock)) => {
281 self.warn_toml_lock_mismatch(&root, &lock);
282 let (paths, path_warnings) = lockfile::resolve_path_entries(&root, &lock);
283 warnings.extend(path_warnings);
284 paths
285 }
286 Ok(None) => Vec::new(),
287 Err(e) => {
288 warnings.push(format!(
289 "failed to load alc.lock at {}: {e}",
290 root.display()
291 ));
292 Vec::new()
293 }
294 };
295
296 let mut merged = local_paths;
297 merged.extend(lock_paths);
298 (merged, warnings)
299 }
300
301 /// Resolve variant pkg overrides for a request.
302 ///
303 /// Reads `alc.local.toml` (worktree-scoped, gitignored) and emits one
304 /// [`VariantPkg`] per `[packages.{name}] path = "..."` entry, preserving
305 /// the explicit `(name, pkg_dir)` mapping. Returns `(pkgs, warnings)` —
306 /// an empty `pkgs` when no project root is found or `alc.local.toml` is
307 /// absent. Corruption (parse failures) is returned as a warning string so
308 /// callers can surface it on the MCP wire response.
309 pub(crate) fn resolve_variant_pkgs(
310 &self,
311 project_root: Option<&str>,
312 ) -> (Vec<VariantPkg>, Vec<String>) {
313 let Some(root) = self.resolve_root(project_root) else {
314 return (vec![], vec![]);
315 };
316
317 match alc_toml::load_alc_local_toml(&root) {
318 Ok(Some(local)) => (alc_toml::resolve_local_variant_pkgs(&root, &local), vec![]),
319 Ok(None) => (Vec::new(), vec![]),
320 Err(e) => {
321 let msg = format!("failed to load alc.local.toml at {}: {e}", root.display());
322 (Vec::new(), vec![msg])
323 }
324 }
325 }
326
327 fn warn_toml_lock_mismatch(&self, root: &Path, lock: &lockfile::LockFile) {
328 let toml = match alc_toml::load_alc_toml(root) {
329 Ok(Some(t)) => t,
330 _ => return,
331 };
332
333 use std::collections::BTreeSet;
334 let toml_names: BTreeSet<&str> = toml.packages.keys().map(|s| s.as_str()).collect();
335 let lock_names: BTreeSet<&str> = lock.packages.iter().map(|p| p.name.as_str()).collect();
336
337 for name in toml_names.difference(&lock_names) {
338 eprintln!(
339 "warning: '{name}' is declared in alc.toml but missing from alc.lock. Run `alc_update` to sync."
340 );
341 }
342 for name in lock_names.difference(&toml_names) {
343 eprintln!("warning: '{name}' is in alc.lock but not declared in alc.toml.");
344 }
345 }
346}