Skip to main content

algocline_app/service/
logging.rs

1use super::hub_dist_preset::PRESET_CATALOG_VERSION;
2use super::path::ContainedPath;
3use super::transcript::append_note;
4use super::AppService;
5
6impl AppService {
7    /// Append a note to a session's log file.
8    pub async fn add_note(
9        &self,
10        session_id: &str,
11        content: &str,
12        title: Option<&str>,
13    ) -> Result<String, String> {
14        let count = append_note(self.require_log_dir()?, session_id, content, title)?;
15        Ok(serde_json::json!({
16            "session_id": session_id,
17            "notes_count": count,
18        })
19        .to_string())
20    }
21
22    /// Default max response size for detail mode (100 KB).
23    const DEFAULT_MAX_CHARS: usize = 100_000;
24
25    /// View session logs.
26    pub async fn log_view(
27        &self,
28        session_id: Option<&str>,
29        limit: Option<usize>,
30        max_chars: Option<usize>,
31    ) -> Result<String, String> {
32        match session_id {
33            Some(sid) => self.log_read(sid, max_chars.unwrap_or(Self::DEFAULT_MAX_CHARS)),
34            None => self.log_list(limit.unwrap_or(50)),
35        }
36    }
37
38    fn log_read(&self, session_id: &str, max_chars: usize) -> Result<String, String> {
39        let log_dir = self.require_log_dir()?;
40        let path = ContainedPath::child(log_dir, &format!("{session_id}.json"))?;
41        if !path.as_ref().exists() {
42            return Err(format!("Log file not found for session '{session_id}'"));
43        }
44        let raw = std::fs::read_to_string(&path).map_err(|e| format!("Failed to read log: {e}"))?;
45
46        // 0 means unlimited
47        if max_chars == 0 || raw.len() <= max_chars {
48            return Ok(raw);
49        }
50
51        // Parse and truncate transcript (oldest rounds first) to fit within max_chars
52        let mut doc: serde_json::Value =
53            serde_json::from_str(&raw).map_err(|e| format!("Failed to parse log: {e}"))?;
54
55        let original_rounds = doc
56            .get("transcript")
57            .and_then(|t| t.as_array())
58            .map(|a| a.len())
59            .unwrap_or(0);
60
61        if original_rounds == 0 {
62            // No transcript to truncate; return as-is
63            return Ok(raw);
64        }
65
66        // Binary-search: keep the maximum number of newest rounds that fit
67        let transcript = doc
68            .get("transcript")
69            .and_then(|t| t.as_array())
70            .cloned()
71            .unwrap_or_default();
72
73        let mut kept = original_rounds;
74        loop {
75            if kept == 0 {
76                // Even with empty transcript it might still be too large (unlikely)
77                doc["transcript"] = serde_json::json!([]);
78                break;
79            }
80            // Keep the newest `kept` rounds
81            let slice = &transcript[original_rounds - kept..];
82            doc["transcript"] = serde_json::Value::Array(slice.to_vec());
83            let serialized =
84                serde_json::to_string(&doc).map_err(|e| format!("Failed to serialize: {e}"))?;
85            if serialized.len() <= max_chars {
86                break;
87            }
88            // Halve for speed, then linear scan
89            if kept > 8 {
90                kept /= 2;
91            } else {
92                kept -= 1;
93            }
94        }
95
96        let returned_rounds = doc
97            .get("transcript")
98            .and_then(|t| t.as_array())
99            .map(|a| a.len())
100            .unwrap_or(0);
101
102        doc["truncated"] = serde_json::json!(true);
103        doc["original_rounds"] = serde_json::json!(original_rounds);
104        doc["returned_rounds"] = serde_json::json!(returned_rounds);
105
106        serde_json::to_string_pretty(&doc).map_err(|e| format!("Failed to serialize: {e}"))
107    }
108
109    pub(super) fn log_list(&self, limit: usize) -> Result<String, String> {
110        let dir = match self.log_config.log_dir.as_deref() {
111            Some(d) if d.is_dir() => d,
112            _ => return Ok(serde_json::json!({ "sessions": [] }).to_string()),
113        };
114
115        let entries = std::fs::read_dir(dir).map_err(|e| format!("Failed to read log dir: {e}"))?;
116
117        // Collect .meta.json files first; fall back to .json for legacy logs
118        let mut files: Vec<(std::path::PathBuf, std::time::SystemTime)> = entries
119            .flatten()
120            .filter_map(|entry| {
121                let path = entry.path();
122                let name = path.file_name()?.to_str()?;
123                // Skip non-json and meta files in this pass
124                if !name.ends_with(".json") || name.ends_with(".meta.json") {
125                    return None;
126                }
127                let mtime = entry.metadata().ok()?.modified().ok()?;
128                Some((path, mtime))
129            })
130            .collect();
131
132        // Sort by modification time descending (newest first), take limit
133        files.sort_by_key(|b| std::cmp::Reverse(b.1));
134        files.truncate(limit);
135
136        let mut sessions = Vec::new();
137        for (path, _) in &files {
138            // Try .meta.json first (lightweight), fall back to full log
139            let meta_path = path.with_extension("meta.json");
140            let doc: serde_json::Value = if meta_path.exists() {
141                // Meta file: already flat summary (~200 bytes)
142                match std::fs::read_to_string(&meta_path)
143                    .ok()
144                    .and_then(|r| serde_json::from_str(&r).ok())
145                {
146                    Some(d) => d,
147                    None => continue,
148                }
149            } else {
150                // Legacy fallback: read full log and extract fields
151                let raw = match std::fs::read_to_string(path) {
152                    Ok(r) => r,
153                    Err(_) => continue,
154                };
155                match serde_json::from_str::<serde_json::Value>(&raw) {
156                    Ok(d) => {
157                        let stats = d.get("stats");
158                        serde_json::json!({
159                            "session_id": d.get("session_id").and_then(|v| v.as_str()).unwrap_or("unknown"),
160                            "task_hint": d.get("task_hint").and_then(|v| v.as_str()),
161                            "elapsed_ms": stats.and_then(|s| s.get("elapsed_ms")),
162                            "rounds": stats.and_then(|s| s.get("rounds")),
163                            "llm_calls": stats.and_then(|s| s.get("llm_calls")),
164                            "notes_count": d.get("notes").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0),
165                        })
166                    }
167                    Err(_) => continue,
168                }
169            };
170
171            sessions.push(doc);
172        }
173
174        Ok(serde_json::json!({ "sessions": sessions }).to_string())
175    }
176
177    // ─── Stats ──────────────────────────────────────────────────
178
179    /// Return diagnostic info about the current configuration (mise doctor style).
180    pub fn info(&self) -> String {
181        let mut info = serde_json::json!({
182            "version": env!("CARGO_PKG_VERSION"),
183            "preset_catalog_version": PRESET_CATALOG_VERSION,
184            "log_dir": {
185                "resolved": self.log_config.log_dir.as_ref().map(|p| p.display().to_string()),
186                "source": self.log_config.log_dir_source.to_string(),
187            },
188            "log_enabled": self.log_config.log_enabled,
189            "tracing": if self.log_config.log_dir.is_some() { "file + stderr" } else { "stderr only" },
190        });
191
192        // search paths (package resolution chain, priority order)
193        let search_paths_json: Vec<serde_json::Value> = self
194            .search_paths
195            .iter()
196            .map(|sp| {
197                serde_json::json!({
198                    "path": sp.path.display().to_string(),
199                    "source": sp.source.to_string(),
200                })
201            })
202            .collect();
203        info["search_paths"] = serde_json::json!(search_paths_json);
204
205        // packages dir (kept for backward compatibility)
206        let packages = self.log_config.app_dir().packages_dir();
207        if packages.is_dir() {
208            info["packages_dir"] = serde_json::json!(packages.display().to_string());
209        }
210
211        // GitHub push-credential diagnostics — always present, subprocess
212        // failures are absorbed into per-field error strings so info() never
213        // short-circuits on a missing gh/git binary.
214        // Uses std::process::Command (sync) because info() is a sync fn.
215        let home = crate::service::config::AppConfig::resolve_home();
216        let gh_report = crate::service::gh_credentials::diagnose(
217            self.log_config.app_dir().root(),
218            home.as_deref(),
219        );
220        info["gh_credentials"] = serde_json::to_value(&gh_report)
221            .unwrap_or_else(|e| serde_json::json!({ "error": e.to_string() }));
222
223        // Settings — resolve all [setting.*] layers in one call.
224        // resolve_setting is synchronous; info() is also synchronous, so no
225        // spawn_blocking is needed.
226        // On error, surface a warning string under settings_error rather than
227        // failing info() entirely — info() is used as a doctor/diagnostics tool
228        // and must return a useful response even when config files are corrupt
229        // (CLAUDE.md §Service 層 Error 伝播規律, warning-field pattern).
230        let app_dir = self.log_config.app_dir();
231        match crate::service::setting::resolve_setting(&app_dir, None, None) {
232            Ok(resolved) => match serde_json::to_value(&resolved) {
233                Ok(v) => {
234                    info["settings"] = v;
235                }
236                Err(e) => {
237                    info["settings_error"] = serde_json::json!(e.to_string());
238                }
239            },
240            Err(e) => {
241                info["settings_error"] = serde_json::json!(e.to_string());
242            }
243        }
244
245        serde_json::to_string_pretty(&info).unwrap_or_else(|_| "{}".to_string())
246    }
247
248    /// Aggregate stats across all logged sessions.
249    ///
250    /// Scans `.meta.json` files (with `.json` fallback for legacy logs).
251    /// Optional filters: `strategy` (exact match), `days` (last N days).
252    ///
253    /// # Legacy log compatibility
254    ///
255    /// Token fields (`prompt_tokens`, `response_tokens`) were introduced in v0.12.
256    /// Logs written by earlier versions lack these fields entirely. When absent,
257    /// the aggregation treats them as **0** (via `unwrap_or(0)`) — the same
258    /// pattern used for other numeric fields (`elapsed_ms`, `total_prompt_chars`,
259    /// etc.). This means per-strategy `total_tokens` may under-report if the
260    /// dataset includes pre-v0.12 sessions.
261    pub fn stats(
262        &self,
263        strategy_filter: Option<&str>,
264        days: Option<u64>,
265    ) -> Result<String, String> {
266        let dir = match self.log_config.log_dir.as_deref() {
267            Some(d) if d.is_dir() => d,
268            _ => {
269                let card_sinks = algocline_engine::card::subscriber_stats_snapshot();
270                return Ok(serde_json::json!({
271                    "total_sessions": 0,
272                    "strategies": {},
273                    "card_sinks": card_sinks,
274                })
275                .to_string());
276            }
277        };
278
279        let cutoff = days.map(|d| {
280            std::time::SystemTime::now()
281                .duration_since(std::time::UNIX_EPOCH)
282                .unwrap_or_default()
283                .as_millis() as u64
284                - d * 86_400_000
285        });
286
287        let entries = std::fs::read_dir(dir).map_err(|e| format!("Failed to read log dir: {e}"))?;
288
289        #[derive(Default)]
290        struct StrategyAcc {
291            count: u64,
292            sum_elapsed_ms: u64,
293            sum_llm_calls: u64,
294            sum_rounds: u64,
295            sum_prompt_chars: u64,
296            sum_response_chars: u64,
297            sum_prompt_tokens: u64,
298            sum_response_tokens: u64,
299        }
300
301        let mut acc: std::collections::HashMap<String, StrategyAcc> =
302            std::collections::HashMap::new();
303        let mut total: u64 = 0;
304
305        for entry in entries.flatten() {
306            let path = entry.path();
307            let name = match path.file_name().and_then(|n| n.to_str()) {
308                Some(n) => n.to_string(),
309                None => continue,
310            };
311
312            // Read meta from .meta.json or fall back to .json
313            let doc: serde_json::Value = if name.ends_with(".meta.json") {
314                match std::fs::read_to_string(&path)
315                    .ok()
316                    .and_then(|r| serde_json::from_str(&r).ok())
317                {
318                    Some(d) => d,
319                    None => continue,
320                }
321            } else if name.ends_with(".json") && !name.ends_with(".meta.json") {
322                // Skip full logs if meta exists
323                let meta_name =
324                    format!("{}.meta.json", name.strip_suffix(".json").unwrap_or(&name));
325                let meta_path = dir.join(meta_name);
326                if meta_path.exists() {
327                    continue;
328                }
329                // Legacy fallback
330                match std::fs::read_to_string(&path)
331                    .ok()
332                    .and_then(|r| serde_json::from_str::<serde_json::Value>(&r).ok())
333                {
334                    Some(d) => {
335                        let stats = d.get("stats");
336                        serde_json::json!({
337                            "strategy": d.get("strategy").and_then(|v| v.as_str()),
338                            "elapsed_ms": stats.and_then(|s| s.get("elapsed_ms")),
339                            "llm_calls": stats.and_then(|s| s.get("llm_calls")),
340                            "rounds": stats.and_then(|s| s.get("rounds")),
341                            "total_prompt_chars": stats.and_then(|s| s.get("total_prompt_chars")),
342                            "total_response_chars": stats.and_then(|s| s.get("total_response_chars")),
343                        })
344                    }
345                    None => continue,
346                }
347            } else {
348                continue;
349            };
350
351            // Apply time filter via elapsed_ms proxy (file mtime would be better but
352            // meta files don't store timestamps; use mtime as approximation)
353            if let Some(cutoff_ms) = cutoff {
354                let mtime = entry
355                    .metadata()
356                    .ok()
357                    .and_then(|m| m.modified().ok())
358                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
359                    .map(|d| d.as_millis() as u64)
360                    .unwrap_or(0);
361                if mtime < cutoff_ms {
362                    continue;
363                }
364            }
365
366            let strat = doc
367                .get("strategy")
368                .and_then(|v| v.as_str())
369                .unwrap_or("unknown")
370                .to_string();
371
372            // Apply strategy filter
373            if let Some(filter) = strategy_filter {
374                if strat != filter {
375                    continue;
376                }
377            }
378
379            let elapsed = doc.get("elapsed_ms").and_then(|v| v.as_u64()).unwrap_or(0);
380            let llm = doc.get("llm_calls").and_then(|v| v.as_u64()).unwrap_or(0);
381            let rounds = doc.get("rounds").and_then(|v| v.as_u64()).unwrap_or(0);
382            let prompt_chars = doc
383                .get("total_prompt_chars")
384                .and_then(|v| v.as_u64())
385                .unwrap_or(0);
386            let response_chars = doc
387                .get("total_response_chars")
388                .and_then(|v| v.as_u64())
389                .unwrap_or(0);
390
391            // Token counts: nested {"tokens": N, "source": "..."} or legacy absent
392            let prompt_tokens = doc
393                .get("prompt_tokens")
394                .and_then(|v| v.get("tokens"))
395                .and_then(|v| v.as_u64())
396                .unwrap_or(0);
397            let response_tokens = doc
398                .get("response_tokens")
399                .and_then(|v| v.get("tokens"))
400                .and_then(|v| v.as_u64())
401                .unwrap_or(0);
402
403            let a = acc.entry(strat).or_default();
404            a.count += 1;
405            a.sum_elapsed_ms += elapsed;
406            a.sum_llm_calls += llm;
407            a.sum_rounds += rounds;
408            a.sum_prompt_chars += prompt_chars;
409            a.sum_response_chars += response_chars;
410            a.sum_prompt_tokens += prompt_tokens;
411            a.sum_response_tokens += response_tokens;
412            total += 1;
413        }
414
415        // Build response
416        let mut strategies = serde_json::Map::new();
417        for (strat, a) in &acc {
418            let c = a.count.max(1); // avoid division by zero
419            strategies.insert(
420                strat.clone(),
421                serde_json::json!({
422                    "count": a.count,
423                    "avg_elapsed_ms": (a.sum_elapsed_ms + c / 2) / c,
424                    "avg_llm_calls": (a.sum_llm_calls + c / 2) / c,
425                    "avg_rounds": (a.sum_rounds + c / 2) / c,
426                    "total_prompt_chars": a.sum_prompt_chars,
427                    "total_response_chars": a.sum_response_chars,
428                    "total_prompt_tokens": a.sum_prompt_tokens,
429                    "total_response_tokens": a.sum_response_tokens,
430                    "total_tokens": a.sum_prompt_tokens + a.sum_response_tokens,
431                }),
432            );
433        }
434
435        let card_sinks = algocline_engine::card::subscriber_stats_snapshot();
436        Ok(serde_json::json!({
437            "total_sessions": total,
438            "strategies": strategies,
439            "card_sinks": card_sinks,
440        })
441        .to_string())
442    }
443}