Skip to content

feat: route CLI write commands through a running serve process - #213

Merged
ArtemisMucaj merged 1 commit into
mainfrom
feat/cli-route-through-running-server
Aug 6, 2026
Merged

feat: route CLI write commands through a running serve process#213
ArtemisMucaj merged 1 commit into
mainfrom
feat/cli-route-through-running-server

Conversation

@ArtemisMucaj

@ArtemisMucaj ArtemisMucaj commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Problem

When codesearch serve is running (e.g. supervised by Hoplon), it holds the DuckDB write lock. A one-shot CLI write command then fails:

Error: Storage error: Failed to open DuckDB database: IO Error: Could not set
lock on file '.../codesearch.duckdb': Conflicting lock is held in
.../codesearch (PID …)

Fix

Write commands (create / index / delete) now auto-detect a running server for the same data-dir and route through its management API instead of opening the DB. Read commands are unchanged — they open the DB read-only, which DuckDB permits alongside the writer, so they never needed this.

Detection

  • serve writes <data-dir>/serve.json (mgmt/mcp ports, pid, version) on startup and removes it on shutdown. Shutdown now handles SIGTERM as well as SIGINT — SIGTERM is what a supervisor (Hoplon) sends, and without this the file leaked.
  • The CLI reads run-info, confirms the server is alive via GET /health, then routes. A stale file (server killed) fails the probe and the CLI falls back to direct DB access.

New surface

  • POST /api/namespaces — the server-side equivalent of codesearch create (the only write endpoint that didn't already exist; index/delete did).
  • cli::server_client — detection + HTTP client for the three write commands.
  • Global flags: --local (force direct DB) and --server <URL> (force a specific target).

Policy

If a server owns the data-dir but the API call fails, the command errors clearly (suggesting --local) rather than silently opening the DB and reproducing the lock error.

Verification

  • cargo fmt / clippy clean; full cargo test green (incl. new runinfo + server_client unit tests).
  • End-to-end against a release build:
    • create / index route through a running server (no lock error).
    • --local reproduces the original lock error (control).
    • --server <URL> targets a specific server.
    • serve.json written on start, removed on SIGTERM.
    • After the server stops, create opens the DB directly.

Summary by CodeRabbit

  • New Features
    • Added namespace creation through the management API, with configurable embedding backend, model, dimensions, or disabled embeddings.
    • Added CLI support for creating namespaces, indexing repositories, and deleting repositories through a running server or locally.
    • Added --local and --server options to control command routing.
    • Added automatic server discovery, health checking, and version mismatch warnings.
    • Added server run-status tracking and cleanup during shutdown.
    • Documented the new namespace creation endpoint and its validation responses.

DuckDB is single-writer per file, so when 'codesearch serve' holds the
database open (e.g. under Hoplon), a one-shot CLI write command failed
with a lock error:

  Could not set lock on file '.../codesearch.duckdb': Conflicting lock
  is held in .../codesearch (PID …)

Now the write commands (create/index/delete) detect a running server for
the same data-dir and route through its management API instead of
contending for the lock. Read commands are untouched — they already open
the DB read-only, which DuckDB allows alongside the writer.

Detection: 'serve' writes a run-info file (serve.json: mgmt/mcp ports,
pid, version) into the data-dir on startup and removes it on shutdown
(SIGINT and, newly, SIGTERM — the signal a supervising app sends). The
CLI reads it and confirms liveness with a /health probe before routing;
a stale file is ignored when the probe fails.

New surface:
- POST /api/namespaces — server-side 'create' (the one write endpoint
  that didn't exist; index/delete already did).
- cli::server_client — detection + client for create/index/delete, with
  --local (force direct) and --server <URL> (force a target) globals.

Policy: if a server owns the data-dir but the API call fails, the command
errors clearly (suggesting --local) rather than silently opening the DB
and reproducing the lock error.

Verified end to end against a release build: create/index route through a
running server; --local reproduces the original lock error; serve.json is
written on start and removed on SIGTERM.
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds POST /api/namespaces, run-information persistence for serve, and a CLI management client. Create, index, and delete commands can use a server route or local execution. Server shutdown removes the run-information file.

Changes

Server-routed management

Layer / File(s) Summary
Namespace creation API
src/connector/adapter/management/handlers/namespaces.rs, src/connector/adapter/management/handlers/mod.rs, src/connector/adapter/management/server.rs, docs/management-api.openapi.json
The management API validates namespace and embedding settings, creates namespaces through DuckDB, and returns the resolved configuration.
Serve run-information lifecycle
src/connector/adapter/management/runinfo.rs, src/connector/adapter/management/mod.rs, src/lib.rs, src/main.rs
The server writes serve.json with port, PID, and version data. It reads and removes the file through exported helpers and shutdown cleanup.
Management client and route resolution
src/cli/mod.rs, src/cli/server_client.rs
The CLI resolves local or server execution, calls management endpoints, parses JSON errors, consumes indexing SSE events, and encodes repository path segments.
CLI command and serve integration
src/main.rs
Global routing options control create, index, and delete execution. Routed commands use the management client, with direct database fallback.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CLI
  participant RouteResolver
  participant ServerClient
  participant ManagementAPI
  participant DuckDBRepository
  CLI->>RouteResolver: resolve write command target
  RouteResolver->>ManagementAPI: probe /health
  RouteResolver-->>CLI: server or local target
  CLI->>ServerClient: submit management request
  ServerClient->>ManagementAPI: POST namespace or repository operation
  ManagementAPI->>DuckDBRepository: create or update repository state
  DuckDBRepository-->>ManagementAPI: operation result
  ManagementAPI-->>ServerClient: JSON or SSE response
  ServerClient-->>CLI: formatted result
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change: routing CLI write commands through a running serve process.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/cli-route-through-running-server

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 11

🧹 Nitpick comments (5)
src/cli/server_client.rs (2)

269-292: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add tests for the SSE parser.

The tests cover urlencode and the two short-circuit paths of resolve_route. consume_index_sse carries the most parsing logic in this file and has no test. Extract the frame-handling logic into a function that accepts &str chunks, then cover these cases: a done event, an error event, a payload split across two chunks at a multi-byte character boundary, and a stream that ends without a terminal event.

🤖 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 269 - 292, Add focused tests for the
SSE parsing behavior in consume_index_sse, first extracting frame handling into
a helper that accepts &str chunks. Cover done events, error events, payloads
split across chunks at a multi-byte character boundary, and streams ending
without a terminal event, while preserving the existing urlencode and
resolve_route tests.

100-105: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Set a request timeout on the ServerClient HTTP client.

reqwest::Client::new() applies no request timeout. create_namespace and delete_repository therefore wait without bound if the server accepts the connection and then stalls. The CLI hangs with no output.

Set a connect timeout and a per-request timeout on the short calls. Keep index_repository without a total timeout, because the SSE stream is long-lived; use reqwest::RequestBuilder::timeout on the two short calls instead of a client-wide timeout.

♻️ Proposed refactor
     pub fn new(base: String) -> Self {
         Self {
             base,
-            client: reqwest::Client::new(),
+            client: reqwest::Client::builder()
+                .connect_timeout(CONNECT_TIMEOUT)
+                .build()
+                .unwrap_or_default(),
         }
     }

Add the constant next to HEALTH_TIMEOUT:

/// Connection timeout for management API calls. The server runs on loopback.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
/// Timeout for the short, non-streaming management calls.
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);

Then apply .timeout(REQUEST_TIMEOUT) to the create_namespace and delete_repository request builders.

🤖 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 100 - 105, Update ServerClient::new to
configure the reqwest client with the CONNECT_TIMEOUT connection timeout, while
leaving the client without a global request timeout. Apply REQUEST_TIMEOUT via
RequestBuilder::timeout to the short create_namespace and delete_repository
calls only; preserve index_repository’s long-lived SSE stream behavior.
src/connector/adapter/management/runinfo.rs (1)

52-93: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Move the filesystem calls off the async runtime, or document them as sync-only helpers.

write, remove, and read call std::fs directly. All three are invoked from async contexts: write at src/main.rs Line 460, remove at src/main.rs Line 496, and read through resolve_route in src/cli/server_client.rs Line 60. The coding guidelines require blocking calls to be wrapped in tokio::task::spawn_blocking. The payload is small, so the practical stall is short, but a slow or unresponsive filesystem blocks the runtime worker.

Wrap each call site in tokio::task::spawn_blocking, or provide async wrappers in this module and keep the sync functions private.

As per coding guidelines: "Use async/await for I/O, wrap blocking calls in tokio::task::spawn_blocking".

🤖 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/connector/adapter/management/runinfo.rs` around lines 52 - 93, Move the
blocking filesystem operations used by runinfo::write, runinfo::remove, and
runinfo::read off async runtime workers. Prefer adding async wrappers that call
the existing synchronous helpers through tokio::task::spawn_blocking, keeping
the sync implementations private, then update main.rs call sites and
resolve_route to await those wrappers.

Source: Coding guidelines

src/connector/adapter/management/handlers/namespaces.rs (1)

98-98: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reuse a shared constant for the database filename.

"codesearch.duckdb" is also hard-coded in src/main.rs at Line 228. Extract one constant or helper that returns the database path from a data directory. This prevents the two paths from diverging.

🤖 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/connector/adapter/management/handlers/namespaces.rs` at line 98, Replace
the hard-coded "codesearch.duckdb" in the namespace handler’s db_path
construction with a shared constant or data-directory path helper, and update
the existing usage in main.rs to reuse it so both database paths remain
consistent.
docs/management-api.openapi.json (1)

273-350: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Align the new operation with the document's existing conventions.

Every other operation in this document declares tags and operationId, and every documented error response references #/components/schemas/Error. The new operation declares none of these. Client generators derive method names from operationId, so the omission produces an inconsistent generated client. Also add "minimum": 1 to embedding_dimensions, because the handler rejects 0 with HTTP 400.

📝 Proposed documentation fix
     "/api/namespaces": {
       "post": {
+        "tags": [
+          "namespaces"
+        ],
         "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.",
+        "operationId": "createNamespace",
         "requestBody": {
                   "embedding_dimensions": {
                     "type": "integer",
+                    "minimum": 1,
                     "description": "Vector dimensionality (default 384)"
                   },
           "400": {
-            "description": "Invalid request"
+            "description": "Invalid request",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "`#/components/schemas/Error`"
+                }
+              }
+            }
           },
           "500": {
-            "description": "Internal error"
+            "description": "Internal error",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "`#/components/schemas/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 `@docs/management-api.openapi.json` around lines 273 - 350, Update the POST
operation for /api/namespaces to follow the document’s conventions: add the
appropriate tags and a unique operationId, reference `#/components/schemas/Error`
for its 400 and 500 responses, and set embedding_dimensions.minimum to 1 to
match handler validation.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@src/cli/server_client.rs`:
- Around line 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.
- Around line 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.
- Around line 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.
- Around line 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.

In `@src/connector/adapter/management/handlers/namespaces.rs`:
- Around line 57-96: Update the namespace creation handler around the name
validation to call validate_namespace on the trimmed name before persisting it,
matching create_namespace behavior. Preserve the existing empty-name rejection
and ensure invalid names such as those containing quotes are returned as a bad
request.

In `@src/connector/adapter/management/server.rs`:
- Line 85: Protect the POST /api/namespaces route registered in the management
router: when running with --public, restrict it to loopback (127.0.0.1) or
require owner/admin authentication middleware before invoking
handlers::namespaces::create. Preserve unauthenticated access only for
non-public deployments and keep the existing create handler behavior unchanged.

In `@src/main.rs`:
- Around line 481-497: Update the SIGTERM handling around the `tokio::select!`
serving block so it signals graceful shutdown to both `mcp` and `mgmt` instead
of returning immediately and dropping their futures. Pass a shared shutdown
signal into both server tasks, trigger it from the `sigterm.recv()` branch, and
await both server futures so in-flight requests and the management SSE stream
can complete before `result?` and run-info cleanup.
- Around line 450-460: Move the run-info publication out of the pre-bind flow
around the ServeRunInfo construction and perform it only after
run_management_server successfully binds. Use the actual bound management port
returned by the server startup rather than the requested serve_mgmt_port, and
ensure no run-info file is written when binding fails.
- Around line 118-128: Update the clap argument declarations for the `local` and
`server` fields so they belong to the same mutually exclusive argument group,
causing clap to reject commands that provide both flags while preserving each
flag’s existing behavior individually.
- Around line 237-285: Update the routed Create/Index/Delete flow in main so it
resolves the same effective namespace used by local execution before
dispatching. Extend ServerClient::index_repository and
ServerClient::delete_repository, plus the corresponding /api/stream/index and
DELETE /api/repositories/{id} handlers, to accept and forward that namespace;
reject requests when it differs from container.namespace(). Preserve existing
behavior for matching namespaces and local routes.
- Around line 476-480: Replace the expect call in the SIGTERM setup within main
with ? so failure to install the handler propagates through main’s Result
return. Preserve the existing signal handling flow and ensure cleanup, including
remove_runinfo, remains reachable through normal error propagation.

---

Nitpick comments:
In `@docs/management-api.openapi.json`:
- Around line 273-350: Update the POST operation for /api/namespaces to follow
the document’s conventions: add the appropriate tags and a unique operationId,
reference `#/components/schemas/Error` for its 400 and 500 responses, and set
embedding_dimensions.minimum to 1 to match handler validation.

In `@src/cli/server_client.rs`:
- Around line 269-292: Add focused tests for the SSE parsing behavior in
consume_index_sse, first extracting frame handling into a helper that accepts
&str chunks. Cover done events, error events, payloads split across chunks at a
multi-byte character boundary, and streams ending without a terminal event,
while preserving the existing urlencode and resolve_route tests.
- Around line 100-105: Update ServerClient::new to configure the reqwest client
with the CONNECT_TIMEOUT connection timeout, while leaving the client without a
global request timeout. Apply REQUEST_TIMEOUT via RequestBuilder::timeout to the
short create_namespace and delete_repository calls only; preserve
index_repository’s long-lived SSE stream behavior.

In `@src/connector/adapter/management/handlers/namespaces.rs`:
- Line 98: Replace the hard-coded "codesearch.duckdb" in the namespace handler’s
db_path construction with a shared constant or data-directory path helper, and
update the existing usage in main.rs to reuse it so both database paths remain
consistent.

In `@src/connector/adapter/management/runinfo.rs`:
- Around line 52-93: Move the blocking filesystem operations used by
runinfo::write, runinfo::remove, and runinfo::read off async runtime workers.
Prefer adding async wrappers that call the existing synchronous helpers through
tokio::task::spawn_blocking, keeping the sync implementations private, then
update main.rs call sites and resolve_route to await those wrappers.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: fdf27e7a-fc8f-412a-a521-bf9033629e1e

📥 Commits

Reviewing files that changed from the base of the PR and between 9ee3b54 and 44d7938.

📒 Files selected for processing (10)
  • docs/management-api.openapi.json
  • src/cli/mod.rs
  • src/cli/server_client.rs
  • src/connector/adapter/management/handlers/mod.rs
  • src/connector/adapter/management/handlers/namespaces.rs
  • src/connector/adapter/management/mod.rs
  • src/connector/adapter/management/runinfo.rs
  • src/connector/adapter/management/server.rs
  • src/lib.rs
  • src/main.rs

Comment thread src/cli/server_client.rs
Comment on lines +11 to +14
//! 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.

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.

Comment thread src/cli/server_client.rs
Comment on lines +141 to +154
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."))
}

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.

Comment thread src/cli/server_client.rs
Comment on lines +172 to +195
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}"))
}

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

Comment thread src/cli/server_client.rs
Comment on lines +208 to +237
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(),
)
}
_ => {}
}
}
}
}

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

Comment on lines +57 to +96
let name = req.name.trim();
if name.is_empty() {
return Err(ApiError::bad_request("name must not be empty"));
}

let dimensions = req
.embedding_dimensions
.unwrap_or(DEFAULT_EMBEDDING_DIMENSIONS);
if dimensions == 0 {
return Err(ApiError::bad_request(
"embedding_dimensions must be greater than 0",
));
}

let (embedding_target, embedding_model) = if req.no_embeddings {
(
NO_EMBEDDINGS_MODEL.to_string(),
NO_EMBEDDINGS_MODEL.to_string(),
)
} else {
match req.embedding_target.as_deref().unwrap_or("onnx") {
"onnx" => (
"onnx".to_string(),
req.embedding_model
.clone()
.unwrap_or_else(|| DEFAULT_ONNX_EMBEDDING_MODEL.to_string()),
),
"api" => {
let model = req.embedding_model.clone().ok_or_else(|| {
ApiError::bad_request("embedding_model is required with embedding_target=api")
})?;
("api".to_string(), model)
}
other => {
return Err(ApiError::bad_request(format!(
"unknown embedding_target '{other}' (expected 'onnx' or 'api')"
)))
}
}
};

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

🏁 Script executed:

#!/bin/bash
# Compare the local create path with the new handler.
set -euo pipefail

rg -n -C 20 'fn create_namespace' --type=rust
rg -n -C 8 'fn validate_namespace' --type=rust

Repository: ArtemisMucaj/codesearch

Length of output: 161


🏁 Script executed:

#!/bin/bash
set -u

echo "Rust files mentioning create_namespace / validate_namespace / embedding_target:"
rg -n "create_namespace|validate_namespace|embedding_target|embedding_model|embedding_dimensions|no_embeddings" --type=rust . || true

echo
echo "Candidate namespace handler outline:"
ast-grep outline src/connector/adapter/management/handlers/namespaces.rs --view expanded 2>/dev/null || true

echo
echo "Handler file relevant section:"
nl -ba src/connector/adapter/management/handlers/namespaces.rs | sed -n '1,160p'

Repository: ArtemisMucaj/codesearch

Length of output: 17420


🏁 Script executed:

#!/bin/bash
set -u

echo "src/cli/mod.rs validate_namespace and create namespace args:"
sed -n '1,80p' src/cli/mod.rs
sed -n '240,370p' src/cli/mod.rs

echo
echo "src/main.rs create_namespace route and CLI route:"
sed -n '1,90p' src/main.rs
sed -n '245,310p' src/main.rs

echo
echo "src/connector/adapter/management/handlers/namespaces.rs entire file:"
cat -n src/connector/adapter/management/handlers/namespaces.rs | sed -n '1,145p'

echo
echo "DuckdbVectorRepository config struct/create_namespace/read_and_validate relevant blocks:"
sed -n '36,85p' src/connector/adapter/duckdb_vector_repository.rs
sed -n '395,455p' src/connector/adapter/duckdb_vector_repository.rs
sed -n '446,560p' src/connector/adapter/duckdb_vector_repository.rs
sed -n '620,720p' src/connector/adapter/duckdb_vector_repository.rs

echo
echo "Use namespace_config read methods / identifier interpolations:"
rg -n "schema_token|CREATE TABLE|namespace_config|CREATE .*namespace|namespace" src/connector/adapter/duckdb_vector_repository.rs src/connector/api/repo_resolver.rs -C 8 --type=rust

Repository: ArtemisMucaj/codesearch

Length of output: 50380


Reuse the same namespace-name validation as create_namespace.

This handler trims the name but does not run validate_namespace, so "foo\"bar" can be created here while the CLI rejects it. Apply validate_namespace before persisting the namespace, or make the CLI parser use the same validation as the API path.

🤖 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/connector/adapter/management/handlers/namespaces.rs` around lines 57 -
96, Update the namespace creation handler around the name validation to call
validate_namespace on the trimmed name before persisting it, matching
create_namespace behavior. Preserve the existing empty-name rejection and ensure
invalid names such as those containing quotes are returned as a bad request.

Comment thread src/main.rs
Comment on lines +118 to +128
/// Force direct database access for write commands even if a `serve`
/// process is running. Fails with a DuckDB lock error if the server still
/// holds the write lock — use only when you know the server is stopped.
#[arg(long, global = true)]
local: bool,

/// Route write commands (create/index/delete) through the management API of
/// a specific running server (e.g. http://127.0.0.1:8676) instead of
/// auto-detecting one. Implies not opening the database directly.
#[arg(long, global = true)]
server: Option<String>,

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 | 🟡 Minor | ⚡ Quick win

Declare --local and --server as mutually exclusive.

Both flags are accepted together. resolve_route checks force_local first, so --server is silently ignored in that combination. Let clap reject the contradiction instead.

🐛 Proposed fix
     /// Route write commands (create/index/delete) through the management API of
     /// a specific running server (e.g. http://127.0.0.1:8676) instead of
     /// auto-detecting one. Implies not opening the database directly.
-    #[arg(long, global = true)]
+    #[arg(long, global = true, conflicts_with = "local")]
     server: Option<String>,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// Force direct database access for write commands even if a `serve`
/// process is running. Fails with a DuckDB lock error if the server still
/// holds the write lock — use only when you know the server is stopped.
#[arg(long, global = true)]
local: bool,
/// Route write commands (create/index/delete) through the management API of
/// a specific running server (e.g. http://127.0.0.1:8676) instead of
/// auto-detecting one. Implies not opening the database directly.
#[arg(long, global = true)]
server: Option<String>,
/// Force direct database access for write commands even if a `serve`
/// process is running. Fails with a DuckDB lock error if the server still
/// holds the write lock — use only when you know the server is stopped.
#[arg(long, global = true)]
local: bool,
/// Route write commands (create/index/delete) through the management API of
/// a specific running server (e.g. http://127.0.0.1:8676) instead of
/// auto-detecting one. Implies not opening the database directly.
#[arg(long, global = true, conflicts_with = "local")]
server: Option<String>,
🤖 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/main.rs` around lines 118 - 128, Update the clap argument declarations
for the `local` and `server` fields so they belong to the same mutually
exclusive argument group, causing clap to reject commands that provide both
flags while preserving each flag’s existing behavior individually.

Comment thread src/main.rs
Comment on lines +237 to +285
if matches!(
&cli.command,
Commands::Create { .. } | Commands::Index { .. } | Commands::Delete { .. }
) {
use codesearch::cli::server_client::{resolve_route, RouteTarget, ServerClient};
let route = resolve_route(
std::path::Path::new(&data_dir),
cli.local,
cli.server.as_deref(),
)
.await?;
if let RouteTarget::Server(base) = route {
let client = ServerClient::new(base);
let output = match &cli.command {
Commands::Create {
name,
embedding_target,
embedding_model,
embedding_dimensions,
no_embeddings,
} => {
let namespace = name.as_deref().unwrap_or(&cli.namespace);
let target = match embedding_target {
EmbeddingTarget::Onnx => "onnx",
EmbeddingTarget::Api => "api",
};
client
.create_namespace(
namespace,
target,
embedding_model.as_deref(),
*embedding_dimensions,
*no_embeddings,
)
.await?
}
Commands::Index { path, name, force } => {
client
.index_repository(path, name.as_deref(), *force)
.await?
}
Commands::Delete { id_or_path } => client.delete_repository(id_or_path).await?,
_ => unreachable!("guarded by the matches! above"),
};
println!("{output}");
return Ok(());
}
// RouteTarget::Local — fall through to the direct-DB paths below.
}

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 | 🟠 Major | 🏗️ Heavy lift

The routed path drops the CLI namespace, so index and delete can act on a different namespace than the local path.

This block runs before the namespace auto-resolution at Lines 333-379. ServerClient::index_repository sends only path, name, and force; ServerClient::delete_repository sends only the id or path. The server then operates on the namespace that its own Container booted with, taken from container.namespace() in run_index_stream.

Two consequences follow:

  1. --namespace <ns> is ignored for routed index and delete.
  2. A repository indexed under a namespace that the CLI would auto-resolve from the working directory is written into the server's boot namespace instead.

The result is silent. The same command produces a different stored location depending on whether a server owns the data directory.

Forward the effective namespace to the server, and reject the request on the server when the namespace does not match. Confirm that /api/stream/index and DELETE /api/repositories/{id} can accept a namespace parameter.

#!/bin/bash
# Check how the server derives the namespace for index and delete.
set -euo pipefail

rg -n -C 6 'container.namespace\(\)' --type=rust
rg -n -C 15 'pub async fn delete' src/connector/adapter/management/handlers/repositories.rs
🤖 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/main.rs` around lines 237 - 285, Update the routed Create/Index/Delete
flow in main so it resolves the same effective namespace used by local execution
before dispatching. Extend ServerClient::index_repository and
ServerClient::delete_repository, plus the corresponding /api/stream/index and
DELETE /api/repositories/{id} handlers, to accept and forward that namespace;
reject requests when it differs from container.namespace(). Preserve existing
behavior for matching namespaces and local routes.

Comment thread src/main.rs
Comment on lines +450 to +460
// Advertise this server so one-shot CLI invocations against the same
// data-dir route their write commands through it instead of hitting the
// DuckDB write lock we hold. Removed after the servers exit; a stale
// file (hard kill) is caught by the CLI's /health probe.
let runinfo = codesearch::ServeRunInfo {
mgmt_port: serve_mgmt_port,
mcp_port: serve_mcp_port,
pid: std::process::id(),
version: env!("CARGO_PKG_VERSION").to_string(),
};
codesearch::write_runinfo(std::path::Path::new(&data_dir_for_runinfo), &runinfo);

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.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Write the run-info file after the management API binds its port.

write_runinfo runs before run_management_server binds the listener. Two problems follow.

First, Container::new already holds the DuckDB write lock at this point. During the window before the listener binds, a concurrent CLI write command reads the run-info file, fails the /health probe, resolves to RouteTarget::Local, and then hits the write lock. The user sees a raw DuckDB lock error instead of the routed behavior this PR adds.

Second, the advertised mgmt_port is the requested port, not the bound port. If the bind fails, the file advertises a port that never served.

The doc comment on runinfo::write states the function is "Called once, when serve has bound its ports", so the intent is already documented. Move the write after a successful bind, or have run_management_server report its bound address and write the file then.

🤖 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/main.rs` around lines 450 - 460, Move the run-info publication out of the
pre-bind flow around the ServeRunInfo construction and perform it only after
run_management_server successfully binds. Use the actual bound management port
returned by the server startup rather than the requested serve_mgmt_port, and
ensure no run-info file is written when binding fails.

Comment thread src/main.rs
Comment on lines +476 to +480
#[cfg(unix)]
let result: Result<()> = {
let mut sigterm =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler");

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Propagate the signal-handler error instead of panicking.

main returns Result<()>, so ? reports the failure without a panic. A panic here also skips the remove_runinfo call at Line 496 and leaves a stale run-info file.

🐛 Proposed fix
-        #[cfg(unix)]
-        let result: Result<()> = {
-            let mut sigterm =
-                tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
-                    .expect("failed to install SIGTERM handler");
+        #[cfg(unix)]
+        let result: Result<()> = {
+            let mut sigterm =
+                tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
+                    .context("failed to install SIGTERM handler")?;

As per coding guidelines: "Prefer ? for error propagation; do not use .unwrap() or .expect() in library code".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#[cfg(unix)]
let result: Result<()> = {
let mut sigterm =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler");
#[cfg(unix)]
let result: Result<()> = {
let mut sigterm =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.context("failed to install SIGTERM handler")?;
🤖 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/main.rs` around lines 476 - 480, Replace the expect call in the SIGTERM
setup within main with ? so failure to install the handler propagates through
main’s Result return. Preserve the existing signal handling flow and ensure
cleanup, including remove_runinfo, remains reachable through normal error
propagation.

Source: Coding guidelines

Comment thread src/main.rs
Comment on lines +481 to +497
tokio::select! {
res = mcp => res,
res = mgmt => res,
_ = sigterm.recv() => {
tracing::info!("received SIGTERM; shutting down serve");
Ok(())
}
}
};
#[cfg(not(unix))]
let result: Result<()> = tokio::select! {
res = mcp => res,
res = mgmt => res,
};

codesearch::remove_runinfo(std::path::Path::new(&data_dir_for_runinfo));
result?;

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.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

SIGTERM drops both server futures without a graceful shutdown.

The sigterm.recv() arm returns Ok(()). tokio::select! then drops the mcp and mgmt futures. Neither axum::serve graceful-shutdown path runs, so in-flight requests are cut. A routed index command holds a long-lived SSE stream on the management API, and a supervised stop terminates it mid-index without a terminal error event. The client then reports "index stream ended without a result".

Signal the servers to shut down gracefully instead. Pass a shared shutdown signal (for example a CancellationToken or a broadcast channel) into both servers, trigger it on SIGTERM, and await both futures.

🤖 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/main.rs` around lines 481 - 497, Update the SIGTERM handling around the
`tokio::select!` serving block so it signals graceful shutdown to both `mcp` and
`mgmt` instead of returning immediately and dropping their futures. Pass a
shared shutdown signal into both server tasks, trigger it from the
`sigterm.recv()` branch, and await both server futures so in-flight requests and
the management SSE stream can complete before `result?` and run-info cleanup.

@ArtemisMucaj
ArtemisMucaj merged commit 296f70b into main Aug 6, 2026
2 checks passed
@ArtemisMucaj
ArtemisMucaj deleted the feat/cli-route-through-running-server branch August 6, 2026 15:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant