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