Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 81 additions & 1 deletion docs/management-api.openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,86 @@
}
}
},
"/api/namespaces": {
"post": {
"summary": "Create a namespace with a fixed embedding configuration",
"description": "Mirrors the `codesearch create` CLI command so it can be routed through a running serve process instead of contending for the DuckDB write lock.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name"
],
"properties": {
"name": {
"type": "string",
"description": "Namespace to create"
},
"embedding_target": {
"type": "string",
"enum": [
"onnx",
"api"
],
"description": "Embedding backend (default onnx)"
},
"embedding_model": {
"type": "string",
"description": "Model id; required for api"
},
"embedding_dimensions": {
"type": "integer",
"description": "Vector dimensionality (default 384)"
},
"no_embeddings": {
"type": "boolean",
"description": "Keyword + call-graph only"
}
}
}
}
}
},
"responses": {
"200": {
"description": "Namespace created",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"created": {
"type": "boolean"
},
"namespace": {
"type": "string"
},
"embedding_target": {
"type": "string"
},
"embedding_model": {
"type": "string"
},
"embedding_dimensions": {
"type": "integer"
}
}
}
}
}
},
"400": {
"description": "Invalid request"
},
"500": {
"description": "Internal error"
}
}
}
},
"/api/search": {
"post": {
"tags": [
Expand Down Expand Up @@ -1503,4 +1583,4 @@
}
}
}
}
}
2 changes: 2 additions & 0 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use clap::{Subcommand, ValueEnum};

pub mod server_client;

/// Default port for the MCP HTTP server started by `codesearch serve`.
pub const DEFAULT_MCP_PORT: u16 = 8677;

Expand Down
292 changes: 292 additions & 0 deletions src/cli/server_client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,292 @@
//! Route write commands through a running `serve` process.
//!
//! DuckDB is single-writer per file. When `codesearch serve` holds the database
//! open, a one-shot CLI write command (`create`, `index`, `delete`) can't take
//! the write lock and would fail. This module detects such a server via the
//! run-info file it writes (see `connector::adapter::management::runinfo`),
//! confirms it's alive with a `/health` probe, and forwards the command to the
//! management API. Read commands don't come here — they open the DB read-only,
//! which DuckDB allows concurrently with the writer.
//!
//! Policy (chosen deliberately): if a server *owns* this data-dir but the API
//! call fails, we return an error telling the user to retry or pass `--local`.
//! We never silently fall back to opening the DB, because the server still holds
//! the lock and that fallback would just reproduce the confusing lock error.
Comment on lines +11 to +14

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The module documentation contradicts resolve_route.

The documentation states that the CLI returns an error and never falls back to direct database access when a server owns the data directory. resolve_route at Lines 75-79 returns RouteTarget::Local when the run-info file exists but the /health probe fails. The inline comment at Line 76 explains the intent for a stale file, which is reasonable, but the two statements conflict.

The fallback is also not safe in every case. A live server that is slow to answer, for example during ONNX model compilation on startup, exceeds the 500 ms HEALTH_TIMEOUT. The CLI then chooses RouteTarget::Local and hits the DuckDB write lock the server holds, which is the exact failure this module removes.

Restrict the documentation to the stale-file case, and consider verifying that the PID recorded in the run-info file is no longer running before you choose RouteTarget::Local.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cli/server_client.rs` around lines 11 - 14, Update the module
documentation near resolve_route to describe fallback only for stale run-info
files, not every failed health probe. In resolve_route, do not return
RouteTarget::Local solely because the /health request times out or fails; verify
the run-info PID is no longer running before allowing the local route, while
preserving the existing server route for potentially live processes.


use std::path::Path;
use std::time::Duration;

use anyhow::{anyhow, bail, Context, Result};

use crate::read_runinfo;

/// How the CLI resolves where (if anywhere) to route a write command.
pub enum RouteTarget {
/// A live server owns this data-dir; forward to its management base URL.
Server(String),
/// No server detected — open the DB directly, as before.
Local,
}

/// Short timeout for the liveness probe. A running server on loopback answers
/// `/health` in well under this; if it doesn't, treat it as not usable.
const HEALTH_TIMEOUT: Duration = Duration::from_millis(500);

/// Decide how a write command should run.
///
/// - `force_local` (from `--local`) short-circuits to [`RouteTarget::Local`].
/// - `explicit_server` (from `--server <URL>`) forces routing to that URL
/// without reading run-info (still liveness-probed).
/// - Otherwise: read run-info for `data_dir`; if present and its `/health`
/// answers, route to it; if absent, run locally; if present but dead (stale
/// file), run locally (the DB is free, so direct access will succeed).
pub async fn resolve_route(
data_dir: &Path,
force_local: bool,
explicit_server: Option<&str>,
) -> Result<RouteTarget> {
if force_local {
return Ok(RouteTarget::Local);
}

if let Some(url) = explicit_server {
let base = url.trim_end_matches('/').to_string();
if probe_health(&base).await {
return Ok(RouteTarget::Server(base));
}
bail!("no codesearch server reachable at {base} (from --server)");
}

let Some(info) = read_runinfo(data_dir) else {
return Ok(RouteTarget::Local);
};
let base = info.mgmt_base_url();
if probe_health(&base).await {
// Warn (don't block) on a version mismatch: the CLI may send a request
// shape an older server doesn't understand.
let cli_version = env!("CARGO_PKG_VERSION");
if info.version != cli_version {
tracing::warn!(
"codesearch server at {base} is version {}, CLI is {cli_version}",
info.version
);
}
Ok(RouteTarget::Server(base))
} else {
// Stale run-info (server gone). The DB lock is free, so run locally.
tracing::info!("run-info present but server not responding; running locally");
Ok(RouteTarget::Local)
}
}

/// GET `{base}/health`; true iff it answers 2xx within the timeout.
async fn probe_health(base: &str) -> bool {
let Ok(client) = reqwest::Client::builder().timeout(HEALTH_TIMEOUT).build() else {
return false;
};
match client.get(format!("{base}/health")).send().await {
Ok(resp) => resp.status().is_success(),
Err(_) => false,
}
}

/// A client bound to one server's management base URL.
pub struct ServerClient {
base: String,
client: reqwest::Client,
}

impl ServerClient {
pub fn new(base: String) -> Self {
Self {
base,
client: reqwest::Client::new(),
}
}

/// `POST /api/namespaces` — create a namespace. Mirrors `codesearch create`.
pub async fn create_namespace(
&self,
name: &str,
embedding_target: &str,
embedding_model: Option<&str>,
embedding_dimensions: usize,
no_embeddings: bool,
) -> Result<String> {
let body = serde_json::json!({
"name": name,
"embedding_target": embedding_target,
"embedding_model": embedding_model,
"embedding_dimensions": embedding_dimensions,
"no_embeddings": no_embeddings,
});
let resp = self
.client
.post(format!("{}/api/namespaces", self.base))
.json(&body)
.send()
.await
.context("failed to reach the running codesearch server")?;
let value = read_json_or_error(resp, "create namespace").await?;
Ok(format!(
"Created namespace '{}' via the running server.",
value
.get("namespace")
.and_then(|v| v.as_str())
.unwrap_or(name)
))
}

/// `DELETE /api/repositories/{id}` — delete a repository by id or path.
pub async fn delete_repository(&self, id_or_path: &str) -> Result<String> {
let resp = self
.client
.delete(format!(
"{}/api/repositories/{}",
self.base,
urlencode(id_or_path)
))
.send()
.await
.context("failed to reach the running codesearch server")?;
read_json_or_error(resp, "delete repository").await?;
Ok(format!("Deleted '{id_or_path}' via the running server."))
}
Comment on lines +141 to +154

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

axum 0.8 Path extractor percent-decoding encoded slash %2F in path segment

💡 Result:

In axum (including the 0.8 series), the Path extractor automatically percent-decodes path segments before passing them to your handler [1][2]. This applies to both the standard Path extractor and the RawPathParams extractor [2][3]. Regarding the encoded slash (%2F): Because percent-decoding occurs automatically, an encoded slash (%2F) in a path segment will be decoded into a literal slash (/) character [1][2]. This behavior has significant implications for routing: 1. Path Matching: Since the path is decoded before it reaches the extractor, your route handler receives the decoded value [1][2]. However, the axum router itself treats / as a path segment separator [4]. If you attempt to use a single path parameter to capture a segment containing an encoded slash (e.g., /user/%2F/profile), the router will see this as a literal slash and likely fail to match the single parameter route, as it treats the slash as a delimiter rather than part of the parameter value [4]. 2. Security and Integrity: Automatically decoding %2F into / can lead to path traversal vulnerabilities if the decoded value is used directly to construct file paths or perform lookups without proper validation. If you require access to the raw, encoded path parameters to avoid premature decoding or to handle segments containing slashes safely, you may need to implement custom extraction logic or bypass the standard Path/RawPathParams extractors, as they are explicitly designed to perform this decoding [1][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "repo files relevant:"
git ls-files | rg '(^|/)(Cargo\.toml|Cargo\.lock|src/cli/server_client\.rs)$|server_client\.rs$' || true

echo
echo "Cargo axum version declarations:"
rg -n '^\s*axum\s*=|name = "axum"|axum_' Cargo.toml Cargo.lock || true

echo
echo "Target handler/route definitions mentioning delete_repository or repositories:"
rg -n 'delete_repository|/api/repositories|Repository|id_or_path|Path' src || true

Repository: ArtemisMucaj/codesearch

Length of output: 48991


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Cargo.lock axum version:"
sed -n '300,325p' Cargo.lock

echo
echo "src/cli/server_client.rs relevant section:"
sed -n '1,180p' src/cli/server_client.rs | cat -n

echo
echo "connector management server/routes and get/delete handlers:"
sed -n '70,90p' src/connector/adapter/management/server.rs | cat -n
sed -n '1,95p' src/connector/adapter/management/handlers/repositories.rs | cat -n

Repository: ArtemisMucaj/codesearch

Length of output: 14423


Send repository identifiers to the delete endpoint as a query parameter or request body.

Putting a filesystem path in the {id} path segment relies on URL encoding/proxy behavior for %2F. The server accepts the segment unchanged, so a non-path ID such as name/of/repo is also treated as the path. Move the value to ?id_or_path=... or a JSON body if the API intends to accept both IDs and absolute paths.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cli/server_client.rs` around lines 141 - 154, Update delete_repository so
id_or_path is sent as a query parameter or request body rather than interpolated
into the URL path segment. Preserve the existing delete request, error context,
response handling, and success message while targeting the delete endpoint
without an identifier path component.


/// `POST /api/stream/index` — index a repository, consuming the SSE stream
/// and surfacing the final `done`/`error` event as the command result.
pub async fn index_repository(
&self,
path: &str,
name: Option<&str>,
force: bool,
) -> Result<String> {
let body = serde_json::json!({ "path": path, "name": name, "force": force });
let resp = self
.client
.post(format!("{}/api/stream/index", self.base))
.json(&body)
.send()
.await
.context("failed to reach the running codesearch server")?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
bail!("server returned {status} for index: {text}");
}
consume_index_sse(resp).await
}
}

/// Read a JSON response, mapping a non-2xx status to a useful error that
/// includes the server's `{"error": …}` message when present.
async fn read_json_or_error(resp: reqwest::Response, op: &str) -> Result<serde_json::Value> {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if status.is_success() {
return serde_json::from_str(&text)
.with_context(|| format!("{op}: could not parse server response"));
}
let msg = serde_json::from_str::<serde_json::Value>(&text)
.ok()
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(str::to_string))
.unwrap_or(text);
Err(anyhow!("{op} failed on the server ({status}): {msg}"))
}
Comment on lines +172 to +195

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Log the dropped response-body errors.

resp.text().await.unwrap_or_default() at Line 174 and Line 185 discards the error and substitutes an empty string. The resulting message reports only the status code and gives no reason for the empty body. Log the error before you drop it.

As per coding guidelines: "Do not silently swallow errors; log with tracing::warn! or tracing::error! before dropping an error."

🐛 Proposed fix
 async fn read_json_or_error(resp: reqwest::Response, op: &str) -> Result<serde_json::Value> {
     let status = resp.status();
-    let text = resp.text().await.unwrap_or_default();
+    let text = resp.text().await.unwrap_or_else(|e| {
+        tracing::warn!("{op}: could not read the server response body: {e}");
+        String::new()
+    });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cli/server_client.rs` around lines 172 - 195, Update the response-body
reads in the surrounding server request flow and read_json_or_error to handle
text() failures explicitly: log each dropped error with tracing::warn! or
tracing::error! before falling back to an empty string. Preserve the existing
status and parsing behavior after logging.

Source: Coding guidelines


/// Consume the index SSE stream, returning the final human-readable line built
/// from the terminal `done` event, or an error from an `error` event.
async fn consume_index_sse(resp: reqwest::Response) -> Result<String> {
use futures_util::StreamExt;

let mut stream = resp.bytes_stream();
let mut buf = String::new();
let mut current_event: Option<String> = None;
let mut last_error: Option<String> = None;
let mut done: Option<serde_json::Value> = None;

while let Some(chunk) = stream.next().await {
let bytes = chunk.context("index stream interrupted")?;
buf.push_str(&String::from_utf8_lossy(&bytes));

// SSE frames are separated by a blank line; process complete lines.
while let Some(nl) = buf.find('\n') {
let line = buf[..nl].trim_end_matches('\r').to_string();
buf.drain(..=nl);

if let Some(name) = line.strip_prefix("event:") {
current_event = Some(name.trim().to_string());
} else if let Some(data) = line.strip_prefix("data:") {
let data = data.trim();
let payload: serde_json::Value = serde_json::from_str(data).unwrap_or_default();
match current_event.as_deref() {
Some("done") => done = Some(payload),
Some("error") => {
last_error = Some(
payload
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("indexing failed")
.to_string(),
)
}
_ => {}
}
}
}
}
Comment on lines +208 to +237

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Decoding per chunk corrupts multi-byte characters, and a failed payload parse is reported as a successful index.

Two defects combine in this loop.

String::from_utf8_lossy(&bytes) decodes each chunk independently. A multi-byte UTF-8 character split across a chunk boundary is replaced with U+FFFD. Repository names, file paths, and language names reach this stream inside the JSON payloads, so non-ASCII input is corrupted.

serde_json::from_str(data).unwrap_or_default() then converts an unparseable payload into Value::Null without any signal. If the corrupted line is the done event, done becomes Some(Null), the lookups at Lines 243-248 fall back to "", 0, and 0, and the CLI prints Indexed '' via the running server: 0 files, 0 chunks. The index actually succeeded on the server. The user sees a false zero-count result.

Buffer the bytes and decode only complete lines, and treat a parse failure as an error rather than a default value.

🐛 Proposed fix
     let mut stream = resp.bytes_stream();
-    let mut buf = String::new();
+    let mut buf: Vec<u8> = Vec::new();
     let mut current_event: Option<String> = None;
     let mut last_error: Option<String> = None;
     let mut done: Option<serde_json::Value> = None;
 
     while let Some(chunk) = stream.next().await {
         let bytes = chunk.context("index stream interrupted")?;
-        buf.push_str(&String::from_utf8_lossy(&bytes));
+        buf.extend_from_slice(&bytes);
 
         // SSE frames are separated by a blank line; process complete lines.
-        while let Some(nl) = buf.find('\n') {
-            let line = buf[..nl].trim_end_matches('\r').to_string();
-            buf.drain(..=nl);
+        while let Some(nl) = buf.iter().position(|b| *b == b'\n') {
+            // A complete line is a complete UTF-8 sequence, so decoding here is
+            // safe across chunk boundaries.
+            let line = String::from_utf8_lossy(&buf[..nl])
+                .trim_end_matches('\r')
+                .to_string();
+            buf.drain(..=nl);
 
             if let Some(name) = line.strip_prefix("event:") {
                 current_event = Some(name.trim().to_string());
             } else if let Some(data) = line.strip_prefix("data:") {
                 let data = data.trim();
-                let payload: serde_json::Value = serde_json::from_str(data).unwrap_or_default();
+                let payload: serde_json::Value = match serde_json::from_str(data) {
+                    Ok(v) => v,
+                    Err(e) => {
+                        tracing::warn!("ignoring unparseable index SSE payload: {e}");
+                        continue;
+                    }
+                };
                 match current_event.as_deref() {

As per coding guidelines: "Do not silently swallow errors; log with tracing::warn! or tracing::error! before dropping an error."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cli/server_client.rs` around lines 208 - 237, Update the SSE parsing loop
around the stream buffer and `serde_json::from_str` so UTF-8 decoding occurs
only after complete line bytes are assembled, preserving characters split across
chunks. Replace `unwrap_or_default()` with explicit parse-error handling: report
the malformed payload using the established tracing error or warning mechanism,
stop or propagate the indexing failure, and ensure an invalid `done` payload
cannot set `done` to `Some(Null)` or produce a false zero-count success.

Source: Coding guidelines


if let Some(err) = last_error {
bail!("{err}");
}
let done = done.ok_or_else(|| anyhow!("index stream ended without a result"))?;
let name = done.get("name").and_then(|v| v.as_str()).unwrap_or("");
let files = done.get("file_count").and_then(|v| v.as_u64()).unwrap_or(0);
let chunks = done
.get("chunk_count")
.and_then(|v| v.as_u64())
.unwrap_or(0);
Ok(format!(
"Indexed '{name}' via the running server: {files} files, {chunks} chunks."
))
}

/// Minimal path-segment encoding for the repository id/path in a URL. Percent-
/// encodes the characters that would otherwise break the path segment.
fn urlencode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn urlencode_escapes_slashes_and_spaces() {
assert_eq!(urlencode("a/b c"), "a%2Fb%20c");
assert_eq!(urlencode("plain-id_1.2~"), "plain-id_1.2~");
}

#[tokio::test]
async fn resolve_route_force_local_skips_detection() {
let dir = tempfile::tempdir().unwrap();
let route = resolve_route(dir.path(), true, None).await.unwrap();
assert!(matches!(route, RouteTarget::Local));
}

#[tokio::test]
async fn resolve_route_no_runinfo_is_local() {
let dir = tempfile::tempdir().unwrap();
let route = resolve_route(dir.path(), false, None).await.unwrap();
assert!(matches!(route, RouteTarget::Local));
}
}
Loading
Loading