diff --git a/src/lib.zig b/src/lib.zig index 2224ad2..5ed3693 100644 --- a/src/lib.zig +++ b/src/lib.zig @@ -60,3 +60,4 @@ pub const watcher = @import("watcher.zig"); pub const mcp = @import("mcp.zig"); pub const snapshot = @import("snapshot.zig"); pub const snapshot_json = @import("snapshot_json.zig"); +pub const remote_cache = @import("remote_cache.zig"); diff --git a/src/mcp.zig b/src/mcp.zig index 09f4f8f..afc3d79 100644 --- a/src/mcp.zig +++ b/src/mcp.zig @@ -24,6 +24,7 @@ const snapshot_mod = @import("snapshot.zig"); const telemetry_mod = @import("telemetry.zig"); const git_mod = @import("git.zig"); const root_policy = @import("root_policy.zig"); +const remote_cache = @import("remote_cache.zig"); const release_info = @import("release_info.zig"); pub const DeferredScan = struct { io: std.Io, @@ -633,7 +634,7 @@ pub const tools_list = \\{"name":"codedb_status","description":"Current indexed-file count, sequence number, and scan phase.","inputSchema":{"type":"object","properties":{"project":{"type":"string","description":"Optional absolute path to a different project (must have codedb.snapshot)"}},"required":[]}}, \\{"name":"codedb_snapshot","description":"Pre-rendered JSON snapshot of the entire index — tree, outlines, symbols, deps. For caching or shipping to edge workers.","inputSchema":{"type":"object","properties":{"project":{"type":"string","description":"Optional absolute path to a different project (must have codedb.snapshot)"}},"required":[]}}, \\{"name":"codedb_bundle","description":"Run up to 20 codedb_* calls in one round-trip. Each op is either MCP-style {\"tool\":\"codedb_search\",\"arguments\":{\"query\":\"Agent\"}} or inline {\"tool\":\"codedb_search\",\"query\":\"Agent\"} — both are accepted. Example: {\"ops\":[{\"tool\":\"codedb_search\",\"arguments\":{\"query\":\"Agent\"}},{\"tool\":\"codedb_outline\",\"arguments\":{\"path\":\"src/main.zig\"}}]}. Best for parallel outline/symbol/search; avoid bundling large codedb_read calls — responses are not size-capped. If a sub-op reports `received keys: []`, the wrapper field is misnamed: use `arguments` (MCP spec), not `args`.","inputSchema":{"type":"object","properties":{"ops":{"type":"array","description":"Sub-tool calls to dispatch (max 20). Each item must have `tool` AND `arguments` (pass `{}` if the sub-tool takes none). Inline args alongside `tool` are still accepted as a fallback.","items":{"type":"object","properties":{"tool":{"type":"string","description":"codedb_* tool name to invoke (e.g. codedb_outline, codedb_symbol, codedb_search, codedb_word, codedb_callers, codedb_read, codedb_deps, codedb_tree, codedb_hot, codedb_status, codedb_changes). Required."},"arguments":{"type":"object","description":"Per-call args matching that tool's inputSchema. Field MUST be named `arguments` (MCP `tools/call` convention) — `args` is silently ignored. Pass `{}` only if the sub-tool takes no arguments. Required."}},"required":["tool","arguments"]}},"project":{"type":"string","description":"Optional absolute path to a different project (must have codedb.snapshot)"}},"required":["ops"]}}, - \\{"name":"codedb_remote","description":"Query indexed public repos via api.wiki.codes. Pass action= one of: tree, outline, search, read, symbol, deps, score, cves, commits, branches, dep-history, policy, actions. Use action=actions first if unsure what a repo supports.","inputSchema":{"type":"object","properties":{"repo":{"type":"string","description":"GitHub repo in owner/repo format (e.g. vercel/next.js) or a raw wiki slug such as chromium."},"action":{"type":"string","enum":["tree","outline","search","read","actions","symbol","policy","deps","score","cves","commits","branches","dep-history"],"description":"What to query from api.wiki.codes: actions, tree, search, outline, read, symbol, policy, deps, score, cves, commits, branches, dep-history."},"query":{"type":"string","description":"Action-specific argument. search: text query. symbol: identifier name. outline: file path."},"path":{"type":"string","description":"For action=read: the file path to fetch."},"lines":{"type":"string","description":"For action=read: line range like '10-60' (1-indexed, inclusive). Omit for full file."},"limit":{"type":"integer","description":"For search/tree/deps/commits/branches/dep-history: cap the number of items returned (server may enforce its own ceiling)."},"offset":{"type":"integer","description":"For tree/deps/commits/branches/dep-history: skip the first N items (pagination)."},"prefix":{"type":"string","description":"For tree: only return paths starting with this prefix (e.g. 'src/')."},"expand":{"type":"boolean","description":"For tree: when true, return the full file list. When false returns a compact directory summary when supported."},"since":{"type":"string","description":"For commits/dep-history: ISO timestamp or commit SHA to start from."},"scope":{"type":"string","enum":["runtime","all"],"description":"For score/cves only. Defaults to runtime; use all to include dev/tooling dependencies."},"backend":{"type":"string","enum":["wiki"],"description":"Deprecated compatibility field. Only 'wiki' is accepted; requests always use api.wiki.codes."}},"required":["repo","action"]}}, + \\{"name":"codedb_remote","description":"Query indexed public repos via api.wiki.codes. When the upstream is unreachable, automatically falls back to a local shallow clone + index for tree, outline, search, read, symbol, and deps actions. Pass action= one of: tree, outline, search, read, symbol, deps, score, cves, commits, branches, dep-history, policy, actions, clean_cache. Use action=actions first if unsure what a repo supports.","inputSchema":{"type":"object","properties":{"repo":{"type":"string","description":"GitHub repo in owner/repo format (e.g. vercel/next.js) or a raw wiki slug such as chromium."},"action":{"type":"string","enum":["tree","outline","search","read","actions","symbol","policy","deps","score","cves","commits","branches","dep-history","clean_cache"],"description":"What to query from api.wiki.codes: actions, tree, search, outline, read, symbol, policy, deps, score, cves, commits, branches, dep-history. Or clean_cache to purge the local fallback cache."},"query":{"type":"string","description":"Action-specific argument. search: text query. symbol: identifier name. outline: file path."},"path":{"type":"string","description":"For action=read: the file path to fetch."},"lines":{"type":"string","description":"For action=read: line range like '10-60' (1-indexed, inclusive). Omit for full file."},"limit":{"type":"integer","description":"For search/tree/deps/commits/branches/dep-history: cap the number of items returned (server may enforce its own ceiling)."},"offset":{"type":"integer","description":"For tree/deps/commits/branches/dep-history: skip the first N items (pagination)."},"prefix":{"type":"string","description":"For tree: only return paths starting with this prefix (e.g. 'src/')."},"expand":{"type":"boolean","description":"For tree: when true, return the full file list. When false returns a compact directory summary when supported."},"since":{"type":"string","description":"For commits/dep-history: ISO timestamp or commit SHA to start from."},"scope":{"type":"string","enum":["runtime","all"],"description":"For score/cves only. Defaults to runtime; use all to include dev/tooling dependencies."},"backend":{"type":"string","enum":["wiki"],"description":"Deprecated compatibility field. Only 'wiki' is accepted; requests always use api.wiki.codes."}},"required":["repo","action"]}}, \\{"name":"codedb_projects","description":"List every locally indexed project on this machine: path, data-dir hash, snapshot presence.","inputSchema":{"type":"object","properties":{},"required":[]}}, \\{"name":"codedb_index","description":"Index a local FOLDER (not a file). Builds outlines, trigrams, word index, and writes codedb.snapshot. After indexing, query it via the project= param on any other tool.","inputSchema":{"type":"object","properties":{"path":{"type":"string","description":"Absolute path to the FOLDER (not a file) to index, e.g. /Users/you/myproject"}},"required":["path"]}}, \\{"name":"codedb_find","description":"Fuzzy FILE-NAME search ONLY — typo-tolerant subsequence match against indexed file paths. NOT a content/symbol search: 'rerank' will NOT find files containing rerankSignalScore unless the filename itself contains 'rerank'. For symbol lookups use codedb_word/codedb_symbol; for content use codedb_search.","inputSchema":{"type":"object","properties":{"query":{"type":"string","description":"Fuzzy filename query (e.g. 'authmidlware' for auth_middleware.go, 'test_auth', 'main.zig'). Matched against path basenames, not file contents."},"max_results":{"type":"integer","description":"Maximum results to return (default: 10)"},"project":{"type":"string","description":"Optional absolute path to a different project (must have codedb.snapshot)"}},"required":["query"]}}, @@ -1366,7 +1367,7 @@ fn dispatch( .codedb_status => handleStatus(alloc, out, ctx.store, ctx.explorer), .codedb_snapshot => handleSnapshot(alloc, out, ctx.explorer, ctx.store, ctx.snapshot_cache), .codedb_bundle => handleBundle(io, alloc, args, out, ctx.store, ctx.explorer, agents, cache, deferred_scan, edit_agent_id), - .codedb_remote => handleRemote(alloc, args, out), + .codedb_remote => handleRemote(io, alloc, args, out, cache, default_explorer, default_store), .codedb_projects => handleProjects(io, alloc, out), .codedb_index => handleIndex(io, alloc, args, out, cache, default_store, default_explorer, deferred_scan), .codedb_find => handleFind(io, alloc, args, out, ctx.explorer), @@ -3527,13 +3528,13 @@ fn fetchRemote( return .{ .captured = captured, .status = status, .body_len = body_len }; } -fn handleRemote(alloc: std.mem.Allocator, args: *const std.json.ObjectMap, out: *std.ArrayList(u8)) void { +fn handleRemote(io: std.Io, alloc: std.mem.Allocator, args: *const std.json.ObjectMap, out: *std.ArrayList(u8), cache: *ProjectCache, default_explorer: *Explorer, default_store: *Store) void { const repo = getStr(args, "repo") orelse { out.appendSlice(alloc, "error: missing 'repo' (e.g. justrach/merjs)") catch {}; return; }; const action = getStr(args, "action") orelse { - out.appendSlice(alloc, "error: missing 'action' (actions, tree, outline, search, read, symbol, policy, deps, score, cves, commits, branches, dep-history)") catch {}; + out.appendSlice(alloc, "error: missing 'action' (actions, tree, outline, search, read, symbol, policy, deps, score, cves, commits, branches, dep-history, clean_cache)") catch {}; return; }; @@ -3560,6 +3561,7 @@ fn handleRemote(alloc: std.mem.Allocator, args: *const std.json.ObjectMap, out: "branches", "dep-history", "actions", + "clean_cache", }; var action_valid = false; for (&wiki_actions) |va| { @@ -3571,7 +3573,20 @@ fn handleRemote(alloc: std.mem.Allocator, args: *const std.json.ObjectMap, out: if (!action_valid) { out.appendSlice(alloc, "error: action '") catch {}; out.appendSlice(alloc, action) catch {}; - out.appendSlice(alloc, "' not supported by api.wiki.codes (supports: tree, outline, search, read, symbol, policy, deps, score, cves, commits, branches, dep-history, actions)") catch {}; + out.appendSlice(alloc, "' not supported by api.wiki.codes (supports: tree, outline, search, read, symbol, policy, deps, score, cves, commits, branches, dep-history, actions, clean_cache)") catch {}; + return; + } + + if (std.mem.eql(u8, action, "clean_cache")) { + const count = remote_cache.cleanRemoteCache(io, alloc) catch { + out.appendSlice(alloc, "error: failed to clean remote cache") catch {}; + return; + }; + var num_buf: [16]u8 = undefined; + const num_str = std.fmt.bufPrint(&num_buf, "{d}", .{count}) catch "0"; + out.appendSlice(alloc, "cleaned remote cache: removed ") catch {}; + out.appendSlice(alloc, num_str) catch {}; + out.appendSlice(alloc, " repos") catch {}; return; } @@ -3697,8 +3712,10 @@ fn handleRemote(alloc: std.mem.Allocator, args: *const std.json.ObjectMap, out: } } + // Try remote first const remote = fetchRemote(alloc, url, params.items) catch { - out.appendSlice(alloc, "error: failed to fetch from api.wiki.codes") catch {}; + if (handleRemoteFallback(io, alloc, args, out, cache, repo, wiki_slug, action, default_explorer, default_store)) return; + out.appendSlice(alloc, "error: failed to fetch from api.wiki.codes (local fallback unavailable)") catch {}; return; }; defer alloc.free(remote.captured.stdout); @@ -3713,6 +3730,9 @@ fn handleRemote(alloc: std.mem.Allocator, args: *const std.json.ObjectMap, out: return; } + // Remote returned an error — try local fallback + if (handleRemoteFallback(io, alloc, args, out, cache, repo, wiki_slug, action, default_explorer, default_store)) return; + out.appendSlice(alloc, "error: ") catch {}; out.appendSlice(alloc, "api.wiki.codes") catch {}; if (remote.status == 0) { @@ -3743,6 +3763,94 @@ fn handleRemote(alloc: std.mem.Allocator, args: *const std.json.ObjectMap, out: appendRemoteErrorHint(alloc, out, remote.status, body); } +fn isLocallyServiceable(action: []const u8) bool { + const local_actions = [_][]const u8{ "tree", "outline", "search", "read", "symbol", "deps" }; + for (&local_actions) |a| { + if (std.mem.eql(u8, action, a)) return true; + } + return false; +} + +fn handleRemoteFallback( + io: std.Io, + alloc: std.mem.Allocator, + args: *const std.json.ObjectMap, + out: *std.ArrayList(u8), + cache: *ProjectCache, + repo: []const u8, + wiki_slug: []const u8, + action: []const u8, + default_explorer: *Explorer, + default_store: *Store, +) bool { + if (!isLocallyServiceable(action)) return false; + + const ensure_result = remote_cache.ensureCached(io, alloc, repo, wiki_slug); + if (ensure_result != .ready) return false; + + const cache_dir = remote_cache.getRemoteCacheDir(alloc, wiki_slug) orelse return false; + defer alloc.free(cache_dir); + + const ctx = cache.get(io, cache_dir, default_explorer, default_store) catch return false; + + if (std.mem.eql(u8, action, "search") or std.mem.eql(u8, action, "symbol")) { + loadProjectWordIndexFromDiskIfPresent(io, ctx.explorer, cache_dir, alloc); + } + + var translated_args: std.json.ObjectMap = .empty; + defer translated_args.deinit(alloc); + translateRemoteArgs(alloc, args, action, &translated_args); + + if (std.mem.eql(u8, action, "tree")) { + handleTree(alloc, out, ctx.explorer); + } else if (std.mem.eql(u8, action, "outline")) { + handleOutline(alloc, &translated_args, out, ctx.explorer); + } else if (std.mem.eql(u8, action, "search")) { + handleSearch(alloc, &translated_args, out, ctx.explorer); + } else if (std.mem.eql(u8, action, "read")) { + handleRead(io, alloc, &translated_args, out, ctx.explorer); + } else if (std.mem.eql(u8, action, "symbol")) { + handleSymbol(alloc, &translated_args, out, ctx.explorer); + } else if (std.mem.eql(u8, action, "deps")) { + handleDeps(alloc, &translated_args, out, ctx.explorer); + } else { + return false; + } + return true; +} + +pub fn translateRemoteArgs(alloc: std.mem.Allocator, args: *const std.json.ObjectMap, action: []const u8, out: *std.json.ObjectMap) void { + if (std.mem.eql(u8, action, "outline")) { + if (getStr(args, "query")) |q| { + out.put(alloc, "path", .{ .string = q }) catch {}; + } + } else if (std.mem.eql(u8, action, "symbol")) { + if (getStr(args, "query")) |q| { + out.put(alloc, "name", .{ .string = q }) catch {}; + } + } else if (std.mem.eql(u8, action, "read")) { + if (getStr(args, "path")) |p| { + out.put(alloc, "path", .{ .string = p }) catch {}; + } + if (getStr(args, "lines")) |lines| { + if (std.mem.indexOfScalar(u8, lines, '-')) |dash_pos| { + const start_str = lines[0..dash_pos]; + const end_str = lines[dash_pos + 1 ..]; + if (std.fmt.parseInt(i64, start_str, 10)) |start| { + out.put(alloc, "line_start", .{ .integer = start }) catch {}; + } else |_| {} + if (std.fmt.parseInt(i64, end_str, 10)) |end| { + out.put(alloc, "line_end", .{ .integer = end }) catch {}; + } else |_| {} + } + } + } else if (std.mem.eql(u8, action, "search")) { + if (getStr(args, "query")) |q| { + out.put(alloc, "query", .{ .string = q }) catch {}; + } + } +} + pub fn appendRemoteErrorHint(alloc: std.mem.Allocator, out: *std.ArrayList(u8), status: u16, body: []const u8) void { const has_cf_origin_down = std.mem.indexOf(u8, body, "error code: 1033") != null or diff --git a/src/remote_cache.zig b/src/remote_cache.zig new file mode 100644 index 0000000..ef98f1f --- /dev/null +++ b/src/remote_cache.zig @@ -0,0 +1,224 @@ +const std = @import("std"); +const cio = @import("cio.zig"); + +pub const REMOTE_CACHE_TTL_SECONDS: i64 = 7 * 24 * 60 * 60; +pub const MAX_CACHED_REPOS: usize = 50; + +const CLONE_LOW_SPEED_LIMIT = "1000"; +const CLONE_LOW_SPEED_TIME = "30"; + +pub fn getRemoteCacheDir(alloc: std.mem.Allocator, wiki_slug: []const u8) ?[]u8 { + const home = cio.posixGetenv("HOME") orelse return null; + return std.fmt.allocPrint(alloc, "{s}/.codedb/remote-cache/{s}", .{ home, wiki_slug }) catch null; +} + +fn getCacheRoot(alloc: std.mem.Allocator) ?[]u8 { + const home = cio.posixGetenv("HOME") orelse return null; + return std.fmt.allocPrint(alloc, "{s}/.codedb/remote-cache", .{home}) catch null; +} + +pub fn isCacheFresh(io: std.Io, cache_dir: []const u8, ttl_seconds: i64) bool { + var snap_buf: [std.fs.max_path_bytes]u8 = undefined; + const snap_path = std.fmt.bufPrint(&snap_buf, "{s}/codedb.snapshot", .{cache_dir}) catch return false; + const stat = std.Io.Dir.cwd().statFile(io, snap_path, .{}) catch return false; + const mtime_ns: i128 = @intCast(stat.mtime.nanoseconds); + const now_ns = cio.nanoTimestamp(); + const age_s = @divTrunc(now_ns - mtime_ns, std.time.ns_per_s); + return age_s < ttl_seconds; +} + +pub fn isCacheValid(io: std.Io, cache_dir: []const u8) bool { + var snap_buf: [std.fs.max_path_bytes]u8 = undefined; + const snap_path = std.fmt.bufPrint(&snap_buf, "{s}/codedb.snapshot", .{cache_dir}) catch return false; + std.Io.Dir.cwd().access(io, snap_path, .{}) catch return false; + return true; +} + +pub const EnsureResult = enum { + ready, + clone_failed, + index_failed, + no_github_url, +}; + +pub fn ensureCached( + io: std.Io, + alloc: std.mem.Allocator, + repo: []const u8, + wiki_slug: []const u8, +) EnsureResult { + const cache_dir = getRemoteCacheDir(alloc, wiki_slug) orelse return .clone_failed; + defer alloc.free(cache_dir); + + if (isCacheFresh(io, cache_dir, REMOTE_CACHE_TTL_SECONDS)) return .ready; + + if (isCacheValid(io, cache_dir)) { + std.Io.Dir.cwd().deleteTree(io, cache_dir) catch {}; + } + + if (std.mem.indexOfScalar(u8, repo, '/') == null) return .no_github_url; + + evictIfNeeded(io, alloc, wiki_slug); + + const cache_root = getCacheRoot(alloc) orelse return .clone_failed; + defer alloc.free(cache_root); + std.Io.Dir.cwd().createDirPath(io, cache_root) catch {}; + + const tmp_dir = std.fmt.allocPrint(alloc, "{s}.tmp.{d}", .{ cache_dir, @as(u64, @bitCast(cio.milliTimestamp())) }) catch return .clone_failed; + defer alloc.free(tmp_dir); + + const clone_url = std.fmt.allocPrint(alloc, "https://github.com/{s}.git", .{repo}) catch return .clone_failed; + defer alloc.free(clone_url); + + const clone_result = cio.runCapture(.{ + .allocator = alloc, + .argv = &.{ + "git", + "-c", "http.lowSpeedLimit=" ++ CLONE_LOW_SPEED_LIMIT, + "-c", "http.lowSpeedTime=" ++ CLONE_LOW_SPEED_TIME, + "clone", "--depth=1", "--single-branch", "--no-tags", "--quiet", + clone_url, tmp_dir, + }, + .max_output_bytes = 4 * 1024, + }) catch { + std.Io.Dir.cwd().deleteTree(io, tmp_dir) catch {}; + return .clone_failed; + }; + defer alloc.free(clone_result.stdout); + defer alloc.free(clone_result.stderr); + + if (clone_result.term.Exited != 0) { + std.Io.Dir.cwd().deleteTree(io, tmp_dir) catch {}; + return .clone_failed; + } + + const exe_path = std.process.executablePathAlloc(io, alloc) catch { + std.Io.Dir.cwd().deleteTree(io, tmp_dir) catch {}; + return .index_failed; + }; + defer alloc.free(exe_path); + + var snap_buf: [std.fs.max_path_bytes]u8 = undefined; + const snap_path = std.fmt.bufPrint(&snap_buf, "{s}/codedb.snapshot", .{tmp_dir}) catch { + std.Io.Dir.cwd().deleteTree(io, tmp_dir) catch {}; + return .index_failed; + }; + + const index_result = cio.runCapture(.{ + .allocator = alloc, + .argv = &.{ exe_path, tmp_dir, "snapshot", snap_path }, + .max_output_bytes = 64 * 1024, + }) catch { + std.Io.Dir.cwd().deleteTree(io, tmp_dir) catch {}; + return .index_failed; + }; + defer alloc.free(index_result.stdout); + defer alloc.free(index_result.stderr); + + if (index_result.term.Exited != 0) { + std.Io.Dir.cwd().deleteTree(io, tmp_dir) catch {}; + return .index_failed; + } + + std.Io.Dir.cwd().deleteTree(io, cache_dir) catch {}; + std.Io.Dir.cwd().rename(tmp_dir, std.Io.Dir.cwd(), cache_dir, io) catch { + std.Io.Dir.cwd().deleteTree(io, tmp_dir) catch {}; + return .clone_failed; + }; + + return .ready; +} + +fn evictIfNeeded(io: std.Io, alloc: std.mem.Allocator, skip_slug: []const u8) void { + _ = cleanStaleEntries(io, alloc, REMOTE_CACHE_TTL_SECONDS) catch 0; + + const cache_root = getCacheRoot(alloc) orelse return; + defer alloc.free(cache_root); + + var dir = std.Io.Dir.cwd().openDir(io, cache_root, .{ .iterate = true }) catch return; + defer dir.close(io); + + var count: usize = 0; + var iter_count = dir.iterate(); + while (iter_count.next(io) catch null) |entry| { + if (entry.kind == .directory) count += 1; + } + + if (count < MAX_CACHED_REPOS) return; + + var oldest_name: ?[]u8 = null; + var oldest_mtime: i128 = std.math.maxInt(i128); + var iter_find = dir.iterate(); + while (iter_find.next(io) catch null) |entry| { + if (entry.kind != .directory) continue; + if (std.mem.eql(u8, entry.name, skip_slug)) continue; + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const full_path = std.fmt.bufPrint(&path_buf, "{s}/{s}", .{ cache_root, entry.name }) catch continue; + var snap_buf: [std.fs.max_path_bytes]u8 = undefined; + const snap_path = std.fmt.bufPrint(&snap_buf, "{s}/codedb.snapshot", .{full_path}) catch continue; + const stat = std.Io.Dir.cwd().statFile(io, snap_path, .{}) catch continue; + const mtime_ns: i128 = @intCast(stat.mtime.nanoseconds); + if (mtime_ns < oldest_mtime) { + oldest_mtime = mtime_ns; + if (oldest_name) |n| alloc.free(n); + oldest_name = alloc.dupe(u8, entry.name) catch null; + } + } + + if (oldest_name) |name| { + defer alloc.free(name); + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const full_path = std.fmt.bufPrint(&path_buf, "{s}/{s}", .{ cache_root, name }) catch return; + std.Io.Dir.cwd().deleteTree(io, full_path) catch {}; + } +} + +pub fn cleanRemoteCache(io: std.Io, alloc: std.mem.Allocator) !u32 { + const cache_root = getCacheRoot(alloc) orelse return error.NoHome; + defer alloc.free(cache_root); + + var dir = std.Io.Dir.cwd().openDir(io, cache_root, .{ .iterate = true }) catch return 0; + defer dir.close(io); + + var count: u32 = 0; + var iter = dir.iterate(); + while (iter.next(io) catch null) |entry| { + if (entry.kind != .directory) continue; + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const full_path = std.fmt.bufPrint(&path_buf, "{s}/{s}", .{ cache_root, entry.name }) catch continue; + std.Io.Dir.cwd().deleteTree(io, full_path) catch continue; + count += 1; + } + return count; +} + +pub fn cleanStaleEntries(io: std.Io, alloc: std.mem.Allocator, ttl_seconds: i64) !u32 { + const cache_root = getCacheRoot(alloc) orelse return error.NoHome; + defer alloc.free(cache_root); + + var dir = std.Io.Dir.cwd().openDir(io, cache_root, .{ .iterate = true }) catch return 0; + defer dir.close(io); + + var count: u32 = 0; + var iter = dir.iterate(); + while (iter.next(io) catch null) |entry| { + if (entry.kind != .directory) continue; + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const full_path = std.fmt.bufPrint(&path_buf, "{s}/{s}", .{ cache_root, entry.name }) catch continue; + if (isEntryStale(io, full_path, ttl_seconds)) { + std.Io.Dir.cwd().deleteTree(io, full_path) catch continue; + count += 1; + } + } + return count; +} + +fn isEntryStale(io: std.Io, cache_dir: []const u8, ttl_seconds: i64) bool { + var snap_buf: [std.fs.max_path_bytes]u8 = undefined; + const snap_path = std.fmt.bufPrint(&snap_buf, "{s}/codedb.snapshot", .{cache_dir}) catch return true; + const stat = std.Io.Dir.cwd().statFile(io, snap_path, .{}) catch return true; + const mtime_ns: i128 = @intCast(stat.mtime.nanoseconds); + const now_ns = cio.nanoTimestamp(); + const age_s = @divTrunc(now_ns - mtime_ns, std.time.ns_per_s); + return age_s > ttl_seconds; +} diff --git a/src/test_mcp.zig b/src/test_mcp.zig index 2f99d71..463071f 100644 --- a/src/test_mcp.zig +++ b/src/test_mcp.zig @@ -1919,6 +1919,87 @@ test "issue-538: temp roots are indexable only when CODEDB_ALLOW_TEMP opts in" { try testing.expect(!root_policy.isIndexableRoot("/")); } +test "issue-534: remote cache TTL enforcement" { + const remote_cache = @import("remote_cache.zig"); + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const dir_path_len = try tmp.dir.realPathFile(io, ".", &path_buf); + const cache_dir = path_buf[0..dir_path_len]; + + var snap_path_buf: [std.fs.max_path_bytes]u8 = undefined; + const snap_path = try std.fmt.bufPrint(&snap_path_buf, "{s}/codedb.snapshot", .{cache_dir}); + { + const file = try std.Io.Dir.cwd().createFile(io, snap_path, .{}); + defer file.close(io); + try file.writeStreamingAll(io, "test"); + } + + try testing.expect(remote_cache.isCacheFresh(io, cache_dir, 3600)); + try testing.expect(!remote_cache.isCacheFresh(io, cache_dir, 0)); +} + +test "issue-534: remote cache clone-to-temp-then-rename prevents race" { + const remote_cache = @import("remote_cache.zig"); + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const dir_path_len = try tmp.dir.realPathFile(io, ".", &path_buf); + const root_dir = path_buf[0..dir_path_len]; + + const tmp_clone = try std.fmt.allocPrint(testing.allocator, "{s}/test-repo.tmp.12345", .{root_dir}); + defer testing.allocator.free(tmp_clone); + const final_dir = try std.fmt.allocPrint(testing.allocator, "{s}/test-repo", .{root_dir}); + defer testing.allocator.free(final_dir); + + try std.Io.Dir.cwd().createDirPath(io, tmp_clone); + var snap_buf: [std.fs.max_path_bytes]u8 = undefined; + const snap_path = try std.fmt.bufPrint(&snap_buf, "{s}/codedb.snapshot", .{tmp_clone}); + { + const file = try std.Io.Dir.cwd().createFile(io, snap_path, .{}); + defer file.close(io); + try file.writeStreamingAll(io, "test"); + } + + try std.Io.Dir.cwd().rename(tmp_clone, std.Io.Dir.cwd(), final_dir, io); + + try testing.expect(remote_cache.isCacheValid(io, final_dir)); + std.Io.Dir.cwd().access(io, tmp_clone, .{}) catch |err| { + try testing.expect(err == error.FileNotFound); + }; +} + +test "issue-534: translateRemoteArgs maps remote keys to local handler keys" { + const alloc = testing.allocator; + + var remote_args: std.json.ObjectMap = .empty; + defer remote_args.deinit(alloc); + + var translated: std.json.ObjectMap = .empty; + defer translated.deinit(alloc); + + try remote_args.put(alloc, "query", .{ .string = "src/foo.zig" }); + mcp_mod.translateRemoteArgs(alloc, &remote_args, "outline", &translated); + try testing.expectEqualStrings("src/foo.zig", translated.get("path").?.string); + + translated.deinit(alloc); + translated = .empty; + try remote_args.put(alloc, "query", .{ .string = "MySymbol" }); + mcp_mod.translateRemoteArgs(alloc, &remote_args, "symbol", &translated); + try testing.expectEqualStrings("MySymbol", translated.get("name").?.string); + + translated.deinit(alloc); + translated = .empty; + try remote_args.put(alloc, "path", .{ .string = "src/bar.zig" }); + try remote_args.put(alloc, "lines", .{ .string = "10-60" }); + mcp_mod.translateRemoteArgs(alloc, &remote_args, "read", &translated); + try testing.expectEqualStrings("src/bar.zig", translated.get("path").?.string); + try testing.expectEqual(@as(i64, 10), translated.get("line_start").?.integer); + try testing.expectEqual(@as(i64, 60), translated.get("line_end").?.integer); +} + test "issue-570: codedb_context falls back to plain words for all-lowercase tasks" { // 'fix search ranking' has no identifier-shaped token (no snake_case, no // camelCase, no quotes), so extractContextCandidates finds nothing and the