Skip to main content

algocline_app/service/
run.rs

1use std::sync::Arc;
2
3use algocline_core::QueryId;
4use algocline_engine::{FeedResult, VariantPkg};
5
6use super::eval_store::{splice_response_string, splice_response_warnings};
7use super::resolve::{is_package_installed, make_require_code, resolve_code, QueryResponse};
8use super::transcript::write_transcript_log;
9use super::AppService;
10
11/// Splice `save_warning` into the JSON `result` when the optional
12/// warning is `Some(_)`. Returns the original string unchanged when
13/// there is no warning.
14fn splice_save_warning(result_json: &str, warning: Option<String>) -> String {
15    match warning {
16        Some(msg) => splice_response_string(result_json, "save_warning", &msg),
17        None => result_json.to_string(),
18    }
19}
20
21/// Splice `transcript_warning` into the JSON `result` when the optional
22/// warning is `Some(_)`. Returns the original string unchanged when
23/// there is no warning.
24fn splice_transcript_warning(result_json: &str, warning: Option<String>) -> String {
25    match warning {
26        Some(msg) => splice_response_string(result_json, "transcript_warning", &msg),
27        None => result_json.to_string(),
28    }
29}
30
31impl AppService {
32    /// Execute Lua code with optional JSON context.
33    ///
34    /// `project_root` — optional absolute path to the project root containing
35    /// `alc.lock`. Falls back to `ALC_PROJECT_ROOT` env or ancestor walk.
36    pub async fn run(
37        &self,
38        code: Option<String>,
39        code_file: Option<String>,
40        ctx: Option<serde_json::Value>,
41        project_root: Option<String>,
42    ) -> Result<String, String> {
43        let code = resolve_code(code, code_file)?;
44        let ctx = ctx.unwrap_or(serde_json::Value::Null);
45        let (extra, extra_warnings) = self.resolve_extra_lib_paths(project_root.as_deref());
46        let (variants, variant_warnings) = self.resolve_variant_pkgs(project_root.as_deref());
47        let mut warnings: Vec<String> = extra_warnings;
48        warnings.extend(variant_warnings);
49        let json = self
50            .start_and_tick(code, ctx, None, extra, variants)
51            .await?;
52        Ok(splice_response_warnings(
53            &json,
54            "lib_path_warnings",
55            &warnings,
56        ))
57    }
58
59    /// Apply a built-in strategy to a task.
60    ///
61    /// If the requested package is not installed, automatically installs the
62    /// bundled package collection from GitHub before executing.
63    ///
64    /// `project_root` — optional absolute path to the project root containing
65    /// `alc.lock`. Falls back to `ALC_PROJECT_ROOT` env or ancestor walk.
66    pub async fn advice(
67        &self,
68        strategy: &str,
69        task: Option<String>,
70        opts: Option<serde_json::Value>,
71        project_root: Option<String>,
72    ) -> Result<String, String> {
73        // Auto-install bundled packages if the requested strategy is missing
74        let app_dir = self.log_config.app_dir();
75        if !is_package_installed(&app_dir, strategy) {
76            self.auto_install_bundled_packages().await?;
77            if !is_package_installed(&app_dir, strategy) {
78                return Err(format!(
79                    "Package '{strategy}' not found after installing bundled collection. \
80                     Use alc_pkg_install to install it manually."
81                ));
82            }
83        }
84
85        let code = make_require_code(strategy);
86
87        let mut ctx_map = match opts {
88            Some(serde_json::Value::Object(m)) => m,
89            _ => serde_json::Map::new(),
90        };
91        if let Some(task) = task {
92            ctx_map.insert("task".into(), serde_json::Value::String(task));
93        }
94        let ctx = serde_json::Value::Object(ctx_map);
95
96        let (extra, extra_warnings) = self.resolve_extra_lib_paths(project_root.as_deref());
97        let (variants, variant_warnings) = self.resolve_variant_pkgs(project_root.as_deref());
98        let mut warnings: Vec<String> = extra_warnings;
99        warnings.extend(variant_warnings);
100        let json = self
101            .start_and_tick(code, ctx, Some(strategy), extra, variants)
102            .await?;
103        Ok(splice_response_warnings(
104            &json,
105            "lib_path_warnings",
106            &warnings,
107        ))
108    }
109
110    /// Continue a paused execution — batch feed.
111    pub async fn continue_batch(
112        &self,
113        session_id: &str,
114        responses: Vec<QueryResponse>,
115    ) -> Result<String, String> {
116        let mut last_result = None;
117        for qr in responses {
118            let qid = QueryId::parse(&qr.query_id);
119            let result = self
120                .registry
121                .feed_response(session_id, &qid, qr.response, qr.usage.as_ref())
122                .await
123                .map_err(|e| format!("Continue failed: {e}"))?;
124            last_result = Some(result);
125        }
126        let result = last_result.ok_or("Empty responses array")?;
127        let transcript_warning = self.maybe_log_transcript(&result, session_id);
128        let json = result.to_json(session_id).to_string();
129        let json = splice_transcript_warning(&json, transcript_warning);
130        let save_warning = self.maybe_save_eval(&result, session_id, &json);
131        Ok(splice_save_warning(&json, save_warning))
132    }
133
134    /// Continue a paused execution — single response (with optional query_id).
135    pub async fn continue_single(
136        &self,
137        session_id: &str,
138        response: String,
139        query_id: Option<&str>,
140        usage: Option<algocline_core::TokenUsage>,
141    ) -> Result<String, String> {
142        let query_id = match query_id {
143            Some(qid) => QueryId::parse(qid),
144            None => self
145                .registry
146                .resolve_sole_pending_id(session_id)
147                .await
148                .map_err(|e| format!("Continue failed: {e}"))?,
149        };
150
151        let result = self
152            .registry
153            .feed_response(session_id, &query_id, response, usage.as_ref())
154            .await
155            .map_err(|e| format!("Continue failed: {e}"))?;
156
157        let transcript_warning = self.maybe_log_transcript(&result, session_id);
158        let json = result.to_json(session_id).to_string();
159        let json = splice_transcript_warning(&json, transcript_warning);
160        let save_warning = self.maybe_save_eval(&result, session_id, &json);
161        Ok(splice_save_warning(&json, save_warning))
162    }
163
164    // ─── Internal ───────────────────────────────────────────────
165
166    pub(super) fn maybe_log_transcript(
167        &self,
168        result: &FeedResult,
169        session_id: &str,
170    ) -> Option<String> {
171        if let FeedResult::Finished(exec_result) = result {
172            // Mutex poison means a previous thread panicked while holding the lock.
173            // Strategy name is non-critical for correctness, but the failure must be
174            // surfaced to MCP callers so it is observable, not silently dropped.
175            // See CLAUDE.md §Service 層の Error 伝播規律.
176            let strategy = match self.session_strategies.lock() {
177                Ok(mut map) => map.remove(session_id),
178                Err(e) => {
179                    tracing::warn!(
180                        "session_strategies mutex poisoned for '{}': {}",
181                        session_id,
182                        e
183                    );
184                    // Return warning immediately; transcript cannot be written
185                    // without strategy context being reliably recoverable.
186                    return Some(format!(
187                        "session_strategies mutex poisoned for '{session_id}': {e}"
188                    ));
189                }
190            };
191            // write_transcript_log returns Ok(Some(warning)) when meta write
192            // failed but the main log succeeded, so both Err and meta warning
193            // are surfaced as transcript_warning on the wire response.
194            match write_transcript_log(
195                &self.log_config,
196                session_id,
197                &exec_result.metrics,
198                strategy.as_deref(),
199            ) {
200                Err(e) => Some(e.to_string()),
201                Ok(meta_warning) => meta_warning,
202            }
203        } else {
204            None
205        }
206    }
207
208    /// Persist eval result for a finished session, returning any storage
209    /// failure as `Some(msg)` so the caller can surface it on the wire
210    /// response. `None` covers both "not an eval session" and
211    /// "successfully saved" — they are indistinguishable to the caller
212    /// because both produce the same wire shape.
213    pub(super) fn maybe_save_eval(
214        &self,
215        result: &FeedResult,
216        session_id: &str,
217        result_json: &str,
218    ) -> Option<String> {
219        if !matches!(result, FeedResult::Finished(_)) {
220            return None;
221        }
222        let strategy = {
223            let mut map = self.eval_sessions.lock().unwrap_or_else(|e| e.into_inner());
224            map.remove(session_id)
225        };
226        strategy.and_then(|s| {
227            super::eval_store::save_eval_result(&self.log_config.app_dir(), &s, result_json).err()
228        })
229    }
230
231    pub(super) async fn start_and_tick(
232        &self,
233        code: String,
234        ctx: serde_json::Value,
235        strategy: Option<&str>,
236        extra_lib_paths: Vec<std::path::PathBuf>,
237        variant_pkgs: Vec<VariantPkg>,
238    ) -> Result<String, String> {
239        let scenarios_dir = self.log_config.app_dir().scenarios_dir();
240        let session = self
241            .executor
242            .start_session(
243                code,
244                ctx,
245                extra_lib_paths,
246                variant_pkgs,
247                Arc::clone(&self.state_store),
248                Arc::clone(&self.card_store),
249                scenarios_dir,
250            )
251            .await?;
252        let (session_id, result) = self
253            .registry
254            .start_execution(session)
255            .await
256            .map_err(|e| format!("Execution failed: {e}"))?;
257        if let Some(s) = strategy {
258            if let Ok(mut map) = self.session_strategies.lock() {
259                map.insert(session_id.clone(), s.to_string());
260            }
261        }
262        let transcript_warning = self.maybe_log_transcript(&result, &session_id);
263        let json = result.to_json(&session_id).to_string();
264        Ok(splice_transcript_warning(&json, transcript_warning))
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use std::path::PathBuf;
271    use std::sync::Arc;
272
273    use algocline_core::{
274        AppDir, ExecutionMetrics, ExecutionObserver, LlmQuery, QueryId, TerminalState,
275    };
276    use algocline_engine::{ExecutionResult, FeedResult};
277
278    use super::super::config::{AppConfig, LogDirSource};
279    use super::{splice_transcript_warning, AppService};
280
281    fn make_metrics_with_transcript() -> ExecutionMetrics {
282        let metrics = ExecutionMetrics::new();
283        let observer = metrics.create_observer();
284        observer.on_paused(&[LlmQuery {
285            id: QueryId::single(),
286            prompt: "test prompt".into(),
287            system: None,
288            max_tokens: 100,
289            grounded: false,
290            underspecified: false,
291        }]);
292        metrics
293    }
294
295    fn make_finished_result(metrics: ExecutionMetrics) -> FeedResult {
296        FeedResult::Finished(ExecutionResult {
297            state: TerminalState::Completed {
298                result: serde_json::json!({"ok": true}),
299            },
300            metrics,
301        })
302    }
303
304    /// Build a minimal AppService with log_enabled and a custom log_dir.
305    async fn make_app_service_with_log_dir(log_dir: PathBuf) -> AppService {
306        let executor = Arc::new(
307            algocline_engine::Executor::new(vec![])
308                .await
309                .expect("executor"),
310        );
311        let tmp_app = tempfile::tempdir().expect("test tempdir");
312        let log_config = AppConfig {
313            log_dir: Some(log_dir),
314            log_dir_source: LogDirSource::EnvVar,
315            log_enabled: true,
316            prompt_preview_chars: 200,
317            app_dir: Arc::new(AppDir::new(tmp_app.path().to_path_buf())),
318        };
319        std::mem::forget(tmp_app);
320        AppService::new(executor, log_config, vec![])
321    }
322
323    // ── (b) maybe_log_transcript returns Some when write fails ──────────
324
325    #[tokio::test]
326    async fn maybe_log_transcript_returns_some_on_write_failure() {
327        let tmp = tempfile::tempdir().expect("test tempdir");
328        let log_dir = tmp.path().to_path_buf();
329        // Block write by creating a directory at the session file path.
330        std::fs::create_dir_all(log_dir.join("fail-session.json"))
331            .expect("pre-create dir to block write");
332        let svc = make_app_service_with_log_dir(log_dir).await;
333        let metrics = make_metrics_with_transcript();
334        let result = make_finished_result(metrics);
335        let warning = svc.maybe_log_transcript(&result, "fail-session");
336        assert!(warning.is_some(), "expected Some warning on write failure");
337        let msg = warning.unwrap();
338        assert!(
339            msg.contains("transcript"),
340            "warning should mention 'transcript', got: {msg}"
341        );
342    }
343
344    #[tokio::test]
345    async fn maybe_log_transcript_returns_none_on_non_finished() {
346        let tmp = tempfile::tempdir().expect("test tempdir");
347        let svc = make_app_service_with_log_dir(tmp.path().to_path_buf()).await;
348        let result = FeedResult::Accepted { remaining: 1 };
349        let warning = svc.maybe_log_transcript(&result, "any-session");
350        assert!(warning.is_none(), "Accepted result should return None");
351    }
352
353    // ── (c) splice_transcript_warning inserts field into JSON ───────────
354
355    #[test]
356    fn splice_transcript_warning_injects_field_when_some() {
357        let json = r#"{"status":"finished","result":{}}"#;
358        let out = splice_transcript_warning(json, Some("write failed".to_string()));
359        let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
360        assert_eq!(
361            v["transcript_warning"].as_str(),
362            Some("write failed"),
363            "transcript_warning field should be present"
364        );
365        // Original fields are preserved.
366        assert_eq!(v["status"].as_str(), Some("finished"));
367    }
368
369    #[test]
370    fn splice_transcript_warning_passthrough_when_none() {
371        let json = r#"{"status":"finished"}"#;
372        let out = splice_transcript_warning(json, None);
373        let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
374        assert!(
375            v.get("transcript_warning").is_none(),
376            "transcript_warning must be absent when warning is None"
377        );
378    }
379}