Skip to content

Commit e597d24

Browse files
committed
MCP: log tail, recent listing errors, and state projection
Three additions that close the biggest gaps an MCP-driven smoke test hit while verifying the previous SMB volume-id fix end-to-end: - New `cmdr://logs?since=<iso>&filter=<substring>&limit=<n>` resource: tail the live `cmdr.log` from MCP without grepping it off disk. `limit` defaults to 100, clamped to 1000. `filter` is case-sensitive substring match (no regex dep). `since` drops lines whose ISO-8601 prefix is <= the given moment; lines without a recognizable timestamp prefix (Rust panics) are kept. Reads the last ~5 MB of the file from the end so a 50 MB rotated log doesn't blow up memory. - `recentErrors:` section in `cmdr://state`: bounded ring buffer (capacity 20) of the most recent directory-listing failures, populated from both `emit_error` sites in `file_system::listing::streaming` right before the `listing-error` Tauri event fires, so MCP sees what the FE sees. Lets tests assert "no error since timestamp X" without grepping logs. Lives in the new `mcp::listing_errors` module with unit tests for capacity, ordering, and the `snapshot_since` filter. - Query params on `cmdr://state`: `?include=panes,volumes,dialogs,listings,recentErrors` to project only listed sections (defaults to all), and `?compact=true` to drop the per-pane `files:` list (the biggest source of YAML noise when the caller only cares about path / volumeId / cursor). Example: `cmdr://state?include=listings,recentErrors` is the minimal payload for "did the last listing succeed?". The `cursor`, `totalFiles`, and `loadedRange` summary still render under `compact`, so direction is clear. Wiring details: - `read_resource` now `split_uri`s into base + query before dispatch and parses each resource's options. - `parse_query` is a small flat-map decoder that percent-decodes via the existing `urlencoding` crate; no new deps. - The unit-test-only `snapshot_since` helper is gated behind `#[cfg(test)]` to keep `#![deny(unused)]` happy in non-test builds. - `mcp::CLAUDE.md` updated with the new resource entries, the projection examples, and a freshness contract for `recentErrors`. - The `tests/resource_tests.rs::test_resource_count` and `test_resources_exist` updated for 5 resources. End-to-end verified via MCP against a running `pnpm dev` build: `cmdr://logs?filter=SmbVolume&limit=3`, `cmdr://logs?since=<iso>&filter=mcp::server`, `cmdr://state?include=listings,recentErrors`, `cmdr://state?compact=true&include=panes,listings` all return the expected projections. `recentErrors: []` correctly stays empty in a healthy session; the unit tests pin the populated case. `./scripts/check.sh` green: 1914 unit tests (up from 1902 with the 12 new ones), 32 SMB integration tests, svelte-check, eslint, everything else.
1 parent f241455 commit e597d24

6 files changed

Lines changed: 588 additions & 147 deletions

File tree

apps/desktop/src-tauri/src/file_system/listing/streaming.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,16 @@ pub async fn list_directory_start_streaming(
336336
if matches!(&e, VolumeError::PermissionDenied(_)) {
337337
crate::restricted_paths::record_denial(&path_for_error);
338338
}
339-
events_for_error.emit_error(&listing_id_for_cleanup, e.to_string(), Some(friendly));
339+
let message = e.to_string();
340+
// Record into the MCP recent-errors ring buffer so `cmdr://state`
341+
// surfaces what just failed, without callers grepping the log file.
342+
crate::mcp::listing_errors::record(
343+
&listing_id_for_cleanup,
344+
&volume_id_owned,
345+
&path_for_error.to_string_lossy(),
346+
&message,
347+
);
348+
events_for_error.emit_error(&listing_id_for_cleanup, message, Some(friendly));
340349
}
341350
Ok(()) => {
342351
// Success: listing-complete already emitted. If this path was
@@ -542,7 +551,9 @@ pub(crate) async fn read_directory_with_progress(
542551
&& volume_root_path.as_deref() == Some(path)
543552
&& let Some(friendly) = friendly_error_for_restricted_empty_root(volume_id, path)
544553
{
545-
events.emit_error(listing_id, friendly.raw_detail.clone(), Some(friendly));
554+
let message = friendly.raw_detail.clone();
555+
crate::mcp::listing_errors::record(listing_id, volume_id, &path.to_string_lossy(), &message);
556+
events.emit_error(listing_id, message, Some(friendly));
546557
return Ok(());
547558
}
548559

apps/desktop/src-tauri/src/mcp/CLAUDE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,11 @@ Expose Cmdr functionality to AI agents via the Model Context Protocol (MCP). Age
3636

3737
### Resources (`resources.rs`)
3838

39-
- `cmdr://state`: Complete app state in YAML (both panes, volumes, dialogs, active `listings` cache). Includes MTP volumes with `name` and `id`, and per-pane `volumeId`. The `listings` section reflects every entry in `LISTING_CACHE` (id, volumeId, path, entry count, ageMs), useful for triaging orphan listings in error reports.
39+
- `cmdr://state`: Complete app state in YAML (both panes, volumes, dialogs, active `listings` cache, `recentErrors`). Includes MTP volumes with `name` and `id`, and per-pane `volumeId`. The `listings` section reflects every entry in `LISTING_CACHE` (id, volumeId, path, entry count, ageMs); `recentErrors` is the last 20 directory-listing failures with `atUnixMs`, `listingId`, `volumeId`, `path`, `message` (see `listing_errors.rs` and the freshness contract below). Supports `?include=panes,volumes,dialogs,listings,recentErrors` projection (defaults to all) and `?compact=true` (drops the `files:` list inside each pane while keeping every summary field). Example: `cmdr://state?include=listings,recentErrors` is the minimal payload for "did the last listing succeed?".
4040
- `cmdr://dialogs/available`: Static metadata about available dialogs
4141
- `cmdr://indexing`: Drive indexing status in plain text (current phase, timeline history, DB stats). Calls `indexing::get_debug_status()` and formats as human-readable text.
4242
- `cmdr://settings`: All settings with current values, defaults, types, and constraints. Fetched via round-trip to the frontend (`mcp-get-all-settings` event).
43+
- `cmdr://logs`: Tail of the live `cmdr.log` file. Query: `?since=<iso>&filter=<substring>&limit=<n>`. `limit` defaults to 100, capped at 1000; `filter` is a case-sensitive substring match (no regex dep); `since` drops lines whose ISO-8601 timestamp prefix is ≤ the given value (lines without a timestamp prefix, like a Rust panic, are kept). Reads the last ~5 MB of the file from the end so a 50 MB rotated log doesn't blow up MCP memory. Returns oldest-first.
4344

4445
### Executor (`executor/`)
4546

@@ -99,6 +100,7 @@ Constants and configuration for the MCP server (port, bind address, transport se
99100

100101
- `PaneStateStore`: Current state of left/right panes (path, files, cursor, selection, tabs, type-to-jump). Includes a monotonic `generation` counter (AtomicU64) bumped on every `set_left`/`set_right`. Exposed in `cmdr://state` as `generation:` and used by the `await` tool's `after_generation` param to avoid matching stale state. The optional `typeToJump` field (buffer, indicatorVisible, indicatorStale, lastMatchedName) mirrors the per-pane type-to-jump state when a buffer or indicator is live, so MCP-driven tests can assert the feature without DOM access.
101102
- `SoftDialogTracker`: Which dialogs MCP thinks are open (in `dialog_state.rs`)
103+
- `listing_errors`: Bounded ring buffer (capacity 20) of the most recent `listing-error` events. Populated from `file_system::listing::streaming` at both `emit_error` sites — see the call to `crate::mcp::listing_errors::record(...)` right before the FE event fires, so MCP-visible state matches what the FE saw. Surfaced as `recentErrors:` in `cmdr://state`. **Freshness contract**: the buffer holds the absolute-newest 20 errors process-wide; on a busy session older errors silently drop off, so test scenarios that need older context should snapshot earlier and compare. Cancellations are not recorded — only failures.
102104

103105
Frontend syncs state to these stores via Tauri commands (`update_left_pane_state`, `update_pane_tabs`, etc.). Settings are fetched on-demand via round-trip to the frontend rather than stored in a state store.
104106

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
//! Recent-listing-errors ring buffer for the `cmdr://state` resource.
2+
//!
3+
//! Surfaces the last few directory-listing failures (volume not found, permission
4+
//! denied, I/O error, SMB protocol error) to MCP-driven tests so they can assert
5+
//! "no error since timestamp X" without grepping the on-disk log file. Populated
6+
//! from `file_system::listing::streaming` whenever it emits `listing-error`.
7+
//!
8+
//! Ring-buffered to 20 entries: enough to triage a problem, small enough that
9+
//! every `cmdr://state` read pays a bounded YAML cost.
10+
11+
use std::collections::VecDeque;
12+
use std::sync::{LazyLock, Mutex};
13+
use std::time::{SystemTime, UNIX_EPOCH};
14+
15+
/// One recorded listing error.
16+
#[derive(Debug, Clone)]
17+
pub struct RecentListingError {
18+
/// Wall-clock millis since UNIX epoch; matches the format used by JS `Date.now()`.
19+
pub at_unix_ms: u64,
20+
pub listing_id: String,
21+
pub volume_id: String,
22+
pub path: String,
23+
/// Raw error text. Matches what the FE sees on the `listing-error` event.
24+
pub message: String,
25+
}
26+
27+
const CAPACITY: usize = 20;
28+
29+
static BUFFER: LazyLock<Mutex<VecDeque<RecentListingError>>> =
30+
LazyLock::new(|| Mutex::new(VecDeque::with_capacity(CAPACITY)));
31+
32+
/// Record a listing error. Called from the streaming event sink right after it
33+
/// emits the `listing-error` Tauri event, so MCP sees what the FE saw.
34+
pub fn record(listing_id: &str, volume_id: &str, path: &str, message: &str) {
35+
let at_unix_ms = SystemTime::now()
36+
.duration_since(UNIX_EPOCH)
37+
.map(|d| d.as_millis() as u64)
38+
.unwrap_or(0);
39+
let entry = RecentListingError {
40+
at_unix_ms,
41+
listing_id: listing_id.to_string(),
42+
volume_id: volume_id.to_string(),
43+
path: path.to_string(),
44+
message: message.to_string(),
45+
};
46+
let mut buf = match BUFFER.lock() {
47+
Ok(b) => b,
48+
Err(poisoned) => poisoned.into_inner(),
49+
};
50+
if buf.len() == CAPACITY {
51+
buf.pop_front();
52+
}
53+
buf.push_back(entry);
54+
}
55+
56+
/// Returns a snapshot of recorded errors, oldest first. Cheap; clones each entry.
57+
pub fn snapshot() -> Vec<RecentListingError> {
58+
let buf = match BUFFER.lock() {
59+
Ok(b) => b,
60+
Err(poisoned) => poisoned.into_inner(),
61+
};
62+
buf.iter().cloned().collect()
63+
}
64+
65+
/// Returns only entries with `at_unix_ms > since_ms`. Convenience for tests that
66+
/// want to assert "no errors since I started this scenario." Kept under
67+
/// `#[cfg(test)]` until a production caller needs it; the unit tests below
68+
/// pin the semantics.
69+
#[cfg(test)]
70+
pub fn snapshot_since(since_ms: u64) -> Vec<RecentListingError> {
71+
snapshot().into_iter().filter(|e| e.at_unix_ms > since_ms).collect()
72+
}
73+
74+
#[cfg(test)]
75+
pub fn clear_for_test() {
76+
if let Ok(mut buf) = BUFFER.lock() {
77+
buf.clear();
78+
}
79+
}
80+
81+
#[cfg(test)]
82+
mod tests {
83+
use super::*;
84+
85+
#[test]
86+
fn record_pushes_to_buffer_and_snapshot_returns_in_order() {
87+
clear_for_test();
88+
record("l1", "v1", "/a", "boom");
89+
record("l2", "v1", "/b", "kaboom");
90+
let snap = snapshot();
91+
assert_eq!(snap.len(), 2);
92+
assert_eq!(snap[0].listing_id, "l1");
93+
assert_eq!(snap[1].listing_id, "l2");
94+
}
95+
96+
#[test]
97+
fn buffer_drops_oldest_past_capacity() {
98+
clear_for_test();
99+
for i in 0..(CAPACITY + 5) {
100+
record(&format!("l{i}"), "v", "/p", "err");
101+
}
102+
let snap = snapshot();
103+
assert_eq!(snap.len(), CAPACITY);
104+
// First retained entry should be `l5` (5 oldest dropped).
105+
assert_eq!(snap.first().unwrap().listing_id, "l5");
106+
// Last retained entry should be the newest push.
107+
assert_eq!(snap.last().unwrap().listing_id, format!("l{}", CAPACITY + 4));
108+
}
109+
110+
#[test]
111+
fn snapshot_since_filters_by_timestamp() {
112+
clear_for_test();
113+
record("l1", "v", "/p", "err");
114+
let mid = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() as u64;
115+
// Sleep just past the millisecond boundary so the next `record` definitely
116+
// gets a strictly later timestamp than `mid` (otherwise the test is flaky
117+
// on machines where SystemTime resolution is coarse enough that two
118+
// back-to-back calls land in the same millisecond).
119+
std::thread::sleep(std::time::Duration::from_millis(2));
120+
record("l2", "v", "/p", "err");
121+
let recent = snapshot_since(mid);
122+
assert_eq!(recent.len(), 1);
123+
assert_eq!(recent[0].listing_id, "l2");
124+
}
125+
}

apps/desktop/src-tauri/src/mcp/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
pub mod config;
77
pub mod dialog_state;
88
mod executor;
9+
pub mod listing_errors;
910
pub mod pane_state;
1011
mod protocol;
1112
pub mod resources;

0 commit comments

Comments
 (0)