mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-10 17:10:18 -10:00
feat(runtime-api): expose workspace file suggestions
Reuse composer discovery and ranking for authenticated, bounded workspace-relative file suggestions. Preserve workspace containment and respect directory symlink opt-out for special AI scan roots. Include endpoint and discovery regression coverage and API documentation. Refs #6095. Signed-off-by: Isabel Wu <[email protected]>
This commit is contained in:
7 files changed
+309
-4
No files matched your search
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Authenticated Runtime API workspace file suggestions reuse TUI `@file`
|
||||
matching and discovery, with bounded queries/results and workspace-contained
|
||||
relative paths only (`GET /v1/workspace/files/search`, #6095). Shared discovery
|
||||
now honors disabled symlink following for AI-tool directory scan roots too.
|
||||
|
||||
## [0.9.13] - 2026-09-12
|
||||
|
||||
Codewhale v0.9.13 addresses integrity issues in 0.9.12:
|
||||
|
||||
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Authenticated Runtime API workspace file suggestions reuse TUI `@file`
|
||||
matching and discovery, with bounded queries/results and workspace-contained
|
||||
relative paths only (`GET /v1/workspace/files/search`, #6095). Shared discovery
|
||||
now honors disabled symlink following for AI-tool directory scan roots too.
|
||||
|
||||
## [0.9.13] - 2026-09-12
|
||||
|
||||
Codewhale v0.9.13 addresses integrity issues in 0.9.12:
|
||||
|
||||
@@ -112,7 +112,7 @@ use self::sessions::{
|
||||
use self::sessions::{messages_from_thread_detail, session_to_detail};
|
||||
#[cfg(test)]
|
||||
use self::workspace::collect_workspace_status;
|
||||
use self::workspace::{collect_workspace_git_metadata, workspace_status};
|
||||
use self::workspace::{collect_workspace_git_metadata, workspace_file_search, workspace_status};
|
||||
|
||||
const RUNTIME_TOKEN_ENV: &str = "CODEWHALE_RUNTIME_TOKEN";
|
||||
const LEGACY_RUNTIME_TOKEN_ENV: &str = "DEEPSEEK_RUNTIME_TOKEN";
|
||||
@@ -1088,6 +1088,7 @@ pub fn build_router(state: RuntimeApiState) -> Router {
|
||||
post(resume_session_thread),
|
||||
)
|
||||
.route("/v1/workspace/status", get(workspace_status))
|
||||
.route("/v1/workspace/files/search", get(workspace_file_search))
|
||||
.route("/v1/agent-runs", get(list_agent_runs))
|
||||
.route("/v1/agent-runs/{run_id}", get(get_agent_run))
|
||||
.route("/v1/fleet/profiles", get(list_fleet_profiles))
|
||||
|
||||
@@ -2087,6 +2087,109 @@ async fn thread_summary_includes_workspace_branch_metadata() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_file_search_auth_matching_and_bounds() -> Result<()> {
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let workspace = tmp.path().join("workspace");
|
||||
fs::create_dir_all(workspace.join("src"))?;
|
||||
fs::create_dir_all(workspace.join("match-directory"))?;
|
||||
fs::write(
|
||||
workspace.join("match.rs"),
|
||||
"contents must never be returned",
|
||||
)?;
|
||||
fs::write(workspace.join("src/match.rs"), "private file contents")?;
|
||||
for index in 0..105 {
|
||||
fs::write(workspace.join(format!("bounded-{index:03}.rs")), "")?;
|
||||
}
|
||||
let (addr, _, handle) = spawn_test_server_with_root_token_mobile_workspace(
|
||||
tmp.path().join("runtime"),
|
||||
tmp.path().join("sessions"),
|
||||
Some("file-search-token".to_string()),
|
||||
false,
|
||||
workspace,
|
||||
)
|
||||
.await?
|
||||
.context("file search test requires a loopback listener")?;
|
||||
let client = crate::tls::reqwest_client();
|
||||
let url = format!("http://{addr}/v1/workspace/files/search");
|
||||
let search_url = |pairs: &[(&str, &str)]| {
|
||||
let mut url = reqwest::Url::parse(&url).unwrap();
|
||||
url.query_pairs_mut().extend_pairs(pairs.iter().copied());
|
||||
url
|
||||
};
|
||||
for token in [None, Some("wrong-token")] {
|
||||
let mut request = client.get(search_url(&[("query", "match")]));
|
||||
if let Some(token) = token {
|
||||
request = request.bearer_auth(token);
|
||||
}
|
||||
assert_eq!(request.send().await?.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
for (query, expected) in [
|
||||
("MATCH", json!(["match.rs", "src/match.rs"])),
|
||||
("src/ma", json!(["src/match.rs"])),
|
||||
("", json!([])),
|
||||
(" ", json!([])),
|
||||
("no-such-file", json!([])),
|
||||
("../", json!([])),
|
||||
("/etc/passwd", json!([])),
|
||||
] {
|
||||
let body: Value = client
|
||||
.get(search_url(&[("query", query)]))
|
||||
.bearer_auth("file-search-token")
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(body, json!({"paths": expected}), "query={query:?}");
|
||||
}
|
||||
for (limit, expected_count) in [(None, 20), (Some("1"), 1), (Some("100"), 100)] {
|
||||
let mut url = search_url(&[("query", "bounded")]);
|
||||
if let Some(limit) = limit {
|
||||
url.query_pairs_mut().append_pair("limit", limit);
|
||||
}
|
||||
let body: Value = client
|
||||
.get(url)
|
||||
.bearer_auth("file-search-token")
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(body["paths"].as_array().unwrap().len(), expected_count);
|
||||
}
|
||||
for (key, value) in [
|
||||
("limit", "0".to_string()),
|
||||
("limit", "101".to_string()),
|
||||
("limit", "-1".to_string()),
|
||||
("limit", "abc".to_string()),
|
||||
("query", "é".repeat(129)),
|
||||
("workspace", tmp.path().display().to_string()),
|
||||
] {
|
||||
assert_eq!(
|
||||
client
|
||||
.get(search_url(&[(key, &value)]))
|
||||
.bearer_auth("file-search-token")
|
||||
.send()
|
||||
.await?
|
||||
.status(),
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid {key}"
|
||||
);
|
||||
}
|
||||
let missing_query: Value = client
|
||||
.get(&url)
|
||||
.bearer_auth("file-search-token")
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(missing_query, json!({"paths": []}));
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_and_automation_endpoints_work() -> Result<()> {
|
||||
let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else {
|
||||
|
||||
@@ -1,13 +1,84 @@
|
||||
use std::path::{Path as FsPath, PathBuf};
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use serde::Serialize;
|
||||
use axum::extract::{Query, State};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::dependencies::{ExternalTool as _, Git};
|
||||
|
||||
use super::{ApiError, RuntimeApiState};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct WorkspaceFileSearchQuery {
|
||||
#[serde(default)]
|
||||
query: String,
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct WorkspaceFileSearchResponse {
|
||||
paths: Vec<String>,
|
||||
}
|
||||
|
||||
/// Read-only suggestions: never use the process cwd or a caller-selected root.
|
||||
pub(super) async fn workspace_file_search(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Query(query): Query<WorkspaceFileSearchQuery>,
|
||||
) -> Result<Json<WorkspaceFileSearchResponse>, ApiError> {
|
||||
if query.query.len() > 256 {
|
||||
return Err(ApiError::bad_request(
|
||||
"query must be at most 256 UTF-8 bytes",
|
||||
));
|
||||
}
|
||||
let limit = query.limit.unwrap_or(20);
|
||||
if !(1..=100).contains(&limit) {
|
||||
return Err(ApiError::bad_request("limit must be between 1 and 100"));
|
||||
}
|
||||
if query.query.trim().is_empty() {
|
||||
return Ok(Json(WorkspaceFileSearchResponse { paths: Vec::new() }));
|
||||
}
|
||||
let paths = tokio::task::spawn_blocking(move || {
|
||||
collect_workspace_file_suggestions(&state.workspace, &query.query, limit)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("workspace file search failed"))??;
|
||||
Ok(Json(WorkspaceFileSearchResponse { paths }))
|
||||
}
|
||||
|
||||
fn collect_workspace_file_suggestions(
|
||||
root: &FsPath,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<String>, ApiError> {
|
||||
use crate::working_set::{Workspace, rank_completion_candidates};
|
||||
|
||||
let root = root
|
||||
.canonicalize()
|
||||
.map_err(|_| ApiError::internal("workspace is unavailable"))?;
|
||||
let workspace = Workspace::with_cwd(root.clone(), None);
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
|
||||
// Reuse the composer's bounded discovery and ignore policy, without its
|
||||
// divergent-cwd pass or symlink-directory traversal. No persistent index.
|
||||
let candidates = workspace
|
||||
.completion_discovery_candidates(20_000, &|| std::time::Instant::now() >= deadline);
|
||||
Ok(
|
||||
rank_completion_candidates(&candidates, query, candidates.len())
|
||||
.into_iter()
|
||||
.filter(|candidate| {
|
||||
let path = FsPath::new(candidate);
|
||||
path.components()
|
||||
.all(|component| matches!(component, std::path::Component::Normal(_)))
|
||||
&& root
|
||||
.join(path)
|
||||
.canonicalize()
|
||||
.is_ok_and(|resolved| resolved.starts_with(&root) && resolved.is_file())
|
||||
})
|
||||
.take(limit)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct WorkspaceStatusResponse {
|
||||
pub(super) workspace: PathBuf,
|
||||
@@ -141,3 +212,58 @@ fn current_git_head(workspace: &FsPath) -> Option<String> {
|
||||
let head = head.trim();
|
||||
(!head.is_empty()).then(|| head.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn workspace_file_search_reuses_discovery_ignores() -> anyhow::Result<()> {
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir_all(root.join(".git"))?;
|
||||
std::fs::create_dir_all(root.join(".agents"))?;
|
||||
std::fs::create_dir_all(root.join("target"))?;
|
||||
std::fs::write(root.join(".gitignore"), ".agents/\ntarget/\nlocal.rs\n")?;
|
||||
std::fs::write(root.join(".ignore"), "blocked.rs\n")?;
|
||||
std::fs::write(root.join(".deepseekignore"), "private.rs\n")?;
|
||||
for name in [
|
||||
"local.rs",
|
||||
"blocked.rs",
|
||||
"private.rs",
|
||||
".agents/guide.rs",
|
||||
"target/build.rs",
|
||||
] {
|
||||
std::fs::write(root.join(name), "not returned")?;
|
||||
}
|
||||
let paths = collect_workspace_file_suggestions(root, ".rs", 100)
|
||||
.map_err(|error| anyhow::anyhow!(error.message))?;
|
||||
assert_eq!(paths, [".agents/guide.rs", "local.rs"]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn workspace_file_search_contains_symlinks_and_excludes_other_workspaces() -> anyhow::Result<()>
|
||||
{
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let root = tmp.path().join("workspace");
|
||||
let other = tmp.path().join("workspace-other");
|
||||
std::fs::create_dir_all(&root)?;
|
||||
std::fs::create_dir_all(&other)?;
|
||||
std::fs::write(root.join("inside.rs"), "inside")?;
|
||||
std::fs::write(other.join("outside.rs"), "outside")?;
|
||||
symlink(root.join("inside.rs"), root.join("alias.rs"))?;
|
||||
symlink(other.join("outside.rs"), root.join("escape.rs"))?;
|
||||
symlink(&other, root.join("external-directory"))?;
|
||||
symlink(&other, root.join(".agents"))?;
|
||||
symlink(other.join("missing.rs"), root.join("broken.rs"))?;
|
||||
symlink(&root, root.join("loop"))?;
|
||||
let paths = collect_workspace_file_suggestions(&root, ".rs", 100)
|
||||
.map_err(|error| anyhow::anyhow!(error.message))?;
|
||||
assert_eq!(paths, ["alias.rs", "inside.rs"]);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -613,7 +613,9 @@ fn walk_always_discoverable_dirs(
|
||||
break;
|
||||
}
|
||||
let dot_dir = walk_root.join(dir_name);
|
||||
if !dot_dir.is_dir() {
|
||||
// A walker follows a symlink passed as its root even when ordinary
|
||||
// symlink following is off. Keep that opt-out for the extra roots too.
|
||||
if !dot_dir.is_dir() || (!follow_links && dot_dir.is_symlink()) {
|
||||
continue;
|
||||
}
|
||||
let mut builder = WalkBuilder::new(&dot_dir);
|
||||
@@ -1593,6 +1595,28 @@ mod tests {
|
||||
use codewhale_models::Role;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn workspace_file_search_discovery_respects_symlink_opt_out_for_ai_directories() {
|
||||
let workspace = TempDir::new().unwrap();
|
||||
let external = TempDir::new().unwrap();
|
||||
fs::write(external.path().join("outside.rs"), "").unwrap();
|
||||
std::os::unix::fs::symlink(external.path(), workspace.path().join(".agents")).unwrap();
|
||||
for follow_links in [false, true] {
|
||||
let resolver = Workspace::with_cwd_depth_and_follow_links(
|
||||
workspace.path().to_path_buf(),
|
||||
None,
|
||||
DEFAULT_COMPLETIONS_WALK_DEPTH,
|
||||
follow_links,
|
||||
);
|
||||
let candidates = resolver.completion_discovery_candidates(100, &|| false);
|
||||
assert_eq!(
|
||||
candidates.iter().any(|path| path == ".agents/outside.rs"),
|
||||
follow_links
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn make_message(role: &str, text: &str) -> Message {
|
||||
Message {
|
||||
role: Role::from(role),
|
||||
|
||||
@@ -65,6 +65,42 @@ The legacy in-process `codewhale app-server` also requires an explicit
|
||||
`--auth-token` or `CODEWHALE_APP_SERVER_TOKEN` before binding a non-loopback
|
||||
host; its generated one-time `cwapp_*` token is loopback-only.
|
||||
|
||||
### Workspace file suggestions
|
||||
|
||||
`GET /v1/workspace/files/search?query=runtime&limit=20` returns
|
||||
`{"paths":["src/runtime.rs"]}` through the existing authenticated `/v1/*`
|
||||
router. It searches only the server's configured workspace, not a thread's
|
||||
workspace or the process's current directory. No workspace/path override is
|
||||
accepted. The response contains workspace-relative file paths with `/`
|
||||
separators, never file contents, absolute paths, or directories.
|
||||
|
||||
- `query` is a literal partial filename/path, without an `@` prefix, at most
|
||||
256 UTF-8 bytes. Missing, empty, or whitespace-only queries return an empty
|
||||
list without walking the filesystem. No match also returns an empty list.
|
||||
- `limit` defaults to 20; accepted values are 1–100. Invalid limits, oversized
|
||||
queries, and unknown query parameters return HTTP 400.
|
||||
- Matching reuses TUI fuzzy `@file` discovery/ranking: case-insensitive path
|
||||
prefix matches first, then substring matches, alphabetically within each
|
||||
group. This is not glob, subsequence, content, or semantic search, and does
|
||||
not apply the TUI's personal frecency boosts.
|
||||
- Discovery shares the composer's ignore policy, including `.ignore` and
|
||||
`.deepseekignore`, always-discoverable AI directories, and the bounded
|
||||
hidden/gitignored local-reference fallback. The special `.agents`, `.claude`,
|
||||
`.cursor`, and `.deepseek` walks intentionally bypass ignore rules, as in
|
||||
the TUI. Ignore files are not confidentiality boundaries.
|
||||
- Directory symlinks are not traversed. Files are canonicalized and filtered
|
||||
for containment in the workspace before applying the result limit; external
|
||||
and broken file symlinks are omitted. In-workspace file symlinks may appear
|
||||
by their relative names. Suggestions are a filesystem snapshot, not
|
||||
authorization to read a file later; consumers must revalidate when opening it.
|
||||
|
||||
Discovery runs off the async executor, with the shared default depth of 10,
|
||||
at most 20,000 candidates, and a cooperative two-second discovery budget.
|
||||
Results are best-effort, not an exhaustive listing; a slow filesystem operation
|
||||
can finish after that budget. Each request scans anew; there is no new index or
|
||||
cache. This read-only endpoint does not alter sessions or the pinned model
|
||||
prompt/tool prefix.
|
||||
|
||||
### Runtime and account identity
|
||||
|
||||
`GET /v1/runtime/info` reports `codewhale_version` plus the full 40-character
|
||||
@@ -934,6 +970,7 @@ human gate. Auto-merge is `scripts/check-auto-merge.py --repo … --pr …
|
||||
|
||||
**Introspection**
|
||||
- `GET /v1/workspace/status`
|
||||
- `GET /v1/workspace/files/search?query=<partial>&limit=<1-100>` (see workspace file suggestions above)
|
||||
- `GET /v1/skills`
|
||||
- `GET /v1/apps/mcp/servers`
|
||||
- `GET /v1/apps/mcp/tools?server=<optional>`
|
||||
|
||||
Reference in new issue
Block a user