mirror of
https://github.com/openai/codex.git
synced 2026-09-05 15:18:41 +00:00
Drop skill discovery batching
This commit is contained in:
@@ -13,34 +13,18 @@ use codex_config::default_project_root_markers;
|
||||
use codex_config::merge_toml_values;
|
||||
use codex_config::project_root_markers_from_config;
|
||||
use codex_exec_server::ExecutorFileSystem;
|
||||
use codex_exec_server::ExecutorRpcBatchCall;
|
||||
use codex_exec_server::ExecutorRpcBatchResult;
|
||||
use codex_exec_server::FS_CANONICALIZE_METHOD;
|
||||
use codex_exec_server::FS_GET_METADATA_METHOD;
|
||||
use codex_exec_server::FS_READ_DIRECTORY_METHOD;
|
||||
use codex_exec_server::FS_READ_FILE_METHOD;
|
||||
use codex_exec_server::FileMetadata;
|
||||
use codex_exec_server::FsCanonicalizeParams;
|
||||
use codex_exec_server::FsCanonicalizeResponse;
|
||||
use codex_exec_server::FsGetMetadataParams;
|
||||
use codex_exec_server::FsGetMetadataResponse;
|
||||
use codex_exec_server::FsReadDirectoryParams;
|
||||
use codex_exec_server::FsReadDirectoryResponse;
|
||||
use codex_exec_server::FsReadFileParams;
|
||||
use codex_exec_server::FsReadFileResponse;
|
||||
use codex_exec_server::LOCAL_FS;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use codex_utils_plugins::PluginNamespaceResolver;
|
||||
use codex_utils_plugins::PluginSkillRoot;
|
||||
use codex_utils_plugins::plugin_namespace_for_skill_path;
|
||||
use dirs::home_dir;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::VecDeque;
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
use std::io;
|
||||
@@ -84,12 +68,6 @@ struct LoadedSkillMetadata {
|
||||
policy: Option<SkillPolicy>,
|
||||
}
|
||||
|
||||
struct PreloadedSkillFile {
|
||||
contents: io::Result<String>,
|
||||
metadata_contents: io::Result<String>,
|
||||
resolved_path: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct Interface {
|
||||
display_name: Option<String>,
|
||||
@@ -516,7 +494,7 @@ async fn discover_skills_under_root(
|
||||
}
|
||||
|
||||
fn enqueue_dir(
|
||||
queue: &mut Vec<(AbsolutePathBuf, usize)>,
|
||||
queue: &mut VecDeque<(AbsolutePathBuf, usize)>,
|
||||
visited_dirs: &mut HashSet<AbsolutePathBuf>,
|
||||
truncated_by_dir_limit: &mut bool,
|
||||
path: AbsolutePathBuf,
|
||||
@@ -530,7 +508,7 @@ async fn discover_skills_under_root(
|
||||
return;
|
||||
}
|
||||
if visited_dirs.insert(path.clone()) {
|
||||
queue.push((path, depth));
|
||||
queue.push_back((path, depth));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,196 +521,101 @@ async fn discover_skills_under_root(
|
||||
let mut visited_dirs: HashSet<AbsolutePathBuf> = HashSet::new();
|
||||
visited_dirs.insert(root.clone());
|
||||
|
||||
let mut queue = vec![(root.clone(), 0)];
|
||||
let mut queue: VecDeque<(AbsolutePathBuf, usize)> = VecDeque::from([(root.clone(), 0)]);
|
||||
let mut truncated_by_dir_limit = false;
|
||||
let mut skill_paths = Vec::new();
|
||||
|
||||
while !queue.is_empty() {
|
||||
let dirs = std::mem::take(&mut queue);
|
||||
let directory_calls = match dirs
|
||||
.iter()
|
||||
.map(|(dir, _)| {
|
||||
rpc_batch_call(
|
||||
FS_READ_DIRECTORY_METHOD,
|
||||
FsReadDirectoryParams {
|
||||
path: PathUri::from_abs_path(dir),
|
||||
sandbox: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect::<io::Result<Vec<_>>>()
|
||||
{
|
||||
Ok(calls) => calls,
|
||||
Err(err) => {
|
||||
error!("failed to build batched skills directory reads: {err:#}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
let directory_results = match fs.execute_rpc_batch(directory_calls).await {
|
||||
Ok(results) => results,
|
||||
Err(err) => {
|
||||
error!("failed to batch-read skills directories: {err:#}");
|
||||
break;
|
||||
while let Some((dir, depth)) = queue.pop_front() {
|
||||
let dir_uri = PathUri::from_abs_path(&dir);
|
||||
let entries = match fs.read_directory(&dir_uri, /*sandbox*/ None).await {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => {
|
||||
error!("failed to read skills dir {}: {e:#}", dir.display());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let mut candidates = Vec::new();
|
||||
for ((dir, depth), result) in dirs.into_iter().zip(directory_results) {
|
||||
let response: FsReadDirectoryResponse =
|
||||
match decode_rpc_batch_result(Some(result), "read skills dir") {
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
error!("failed to read skills dir {}: {err:#}", dir.display());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
candidates.extend(response.entries.into_iter().filter_map(|entry| {
|
||||
let file_name = entry.file_name;
|
||||
if file_name.starts_with('.') {
|
||||
return None;
|
||||
for entry in entries {
|
||||
let file_name = entry.file_name;
|
||||
if file_name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = dir.join(&file_name);
|
||||
let path_uri = PathUri::from_abs_path(&path);
|
||||
let metadata = match fs.get_metadata(&path_uri, /*sandbox*/ None).await {
|
||||
Ok(metadata) => metadata,
|
||||
Err(e) => {
|
||||
error!("failed to stat skills path {}: {e:#}", path.display());
|
||||
continue;
|
||||
}
|
||||
Some((dir.join(&file_name), depth, file_name))
|
||||
}));
|
||||
}
|
||||
|
||||
let metadata_calls = match candidates
|
||||
.iter()
|
||||
.map(|(path, _, _)| {
|
||||
rpc_batch_call(
|
||||
FS_GET_METADATA_METHOD,
|
||||
FsGetMetadataParams {
|
||||
path: PathUri::from_abs_path(path),
|
||||
sandbox: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect::<io::Result<Vec<_>>>()
|
||||
{
|
||||
Ok(calls) => calls,
|
||||
Err(err) => {
|
||||
error!("failed to build batched skills path stats: {err:#}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
let metadata_results = match fs.execute_rpc_batch(metadata_calls).await {
|
||||
Ok(results) => results,
|
||||
Err(err) => {
|
||||
error!("failed to batch-stat skills paths: {err:#}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let mut directories = Vec::new();
|
||||
for ((path, depth, file_name), result) in candidates.into_iter().zip(metadata_results) {
|
||||
let response: FsGetMetadataResponse =
|
||||
match decode_rpc_batch_result(Some(result), "stat skills path") {
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
error!("failed to stat skills path {}: {err:#}", path.display());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let metadata = FileMetadata {
|
||||
is_directory: response.is_directory,
|
||||
is_file: response.is_file,
|
||||
is_symlink: response.is_symlink,
|
||||
size: response.size,
|
||||
created_at_ms: response.created_at_ms,
|
||||
modified_at_ms: response.modified_at_ms,
|
||||
};
|
||||
|
||||
if metadata.is_symlink {
|
||||
if follow_symlinks && metadata.is_directory {
|
||||
directories.push((path, depth + 1));
|
||||
if !follow_symlinks {
|
||||
continue;
|
||||
}
|
||||
} else if metadata.is_directory {
|
||||
directories.push((path, depth + 1));
|
||||
} else if metadata.is_file && file_name == SKILLS_FILENAME {
|
||||
skill_paths.push(path);
|
||||
match fs.read_directory(&path_uri, /*sandbox*/ None).await {
|
||||
Ok(_) => {
|
||||
let resolved_dir = canonicalize_for_skill_identity(fs, &path).await;
|
||||
enqueue_dir(
|
||||
&mut queue,
|
||||
&mut visited_dirs,
|
||||
&mut truncated_by_dir_limit,
|
||||
resolved_dir,
|
||||
depth + 1,
|
||||
);
|
||||
}
|
||||
Err(err)
|
||||
if matches!(
|
||||
err.kind(),
|
||||
io::ErrorKind::NotADirectory | io::ErrorKind::NotFound
|
||||
) => {}
|
||||
Err(err) => {
|
||||
error!(
|
||||
"failed to read skills symlink dir {}: {err:#}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let canonical_calls = match directories
|
||||
.iter()
|
||||
.map(|(path, _)| {
|
||||
rpc_batch_call(
|
||||
FS_CANONICALIZE_METHOD,
|
||||
FsCanonicalizeParams {
|
||||
path: PathUri::from_abs_path(path),
|
||||
sandbox: None,
|
||||
},
|
||||
if metadata.is_directory {
|
||||
let resolved_dir = canonicalize_for_skill_identity(fs, &path).await;
|
||||
enqueue_dir(
|
||||
&mut queue,
|
||||
&mut visited_dirs,
|
||||
&mut truncated_by_dir_limit,
|
||||
resolved_dir,
|
||||
depth + 1,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if metadata.is_file && file_name == SKILLS_FILENAME {
|
||||
match parse_skill_file(
|
||||
fs,
|
||||
&path,
|
||||
scope,
|
||||
plugin_id,
|
||||
plugin_namespace,
|
||||
plugin_root.as_ref(),
|
||||
)
|
||||
})
|
||||
.collect::<io::Result<Vec<_>>>()
|
||||
{
|
||||
Ok(calls) => calls,
|
||||
Err(err) => {
|
||||
error!("failed to build batched skills directory canonicalization: {err:#}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
let canonical_results = match fs.execute_rpc_batch(canonical_calls).await {
|
||||
Ok(results) => results,
|
||||
Err(err) => {
|
||||
error!("failed to batch-canonicalize skills directories: {err:#}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
for ((path, depth), result) in directories.into_iter().zip(canonical_results) {
|
||||
let resolved_dir = match decode_rpc_batch_result::<FsCanonicalizeResponse>(
|
||||
Some(result),
|
||||
"canonicalize skills dir",
|
||||
) {
|
||||
Ok(response) => response.path.to_abs_path().unwrap_or_else(|_| path.clone()),
|
||||
Err(err) => {
|
||||
error!(
|
||||
"failed to canonicalize skills dir {}: {err:#}",
|
||||
path.display()
|
||||
);
|
||||
path
|
||||
.await
|
||||
{
|
||||
Ok(skill) => {
|
||||
outcome.skills.push(skill);
|
||||
}
|
||||
Err(err) => {
|
||||
if scope != SkillScope::System {
|
||||
outcome.errors.push(SkillError {
|
||||
path: path.clone(),
|
||||
message: err.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
enqueue_dir(
|
||||
&mut queue,
|
||||
&mut visited_dirs,
|
||||
&mut truncated_by_dir_limit,
|
||||
resolved_dir,
|
||||
depth,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let plugin_namespace_resolver = if plugin_namespace.is_none() {
|
||||
Some(PluginNamespaceResolver::load(fs, &skill_paths).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let preloaded_skills = match preload_skill_files(fs, &skill_paths).await {
|
||||
Ok(preloaded_skills) => preloaded_skills,
|
||||
Err(err) => {
|
||||
error!("failed to batch-read skill files: {err:#}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
for (path, preloaded) in skill_paths.into_iter().zip(preloaded_skills) {
|
||||
let plugin_namespace = plugin_namespace.or_else(|| {
|
||||
plugin_namespace_resolver
|
||||
.as_ref()
|
||||
.and_then(|resolver| resolver.resolve(&path))
|
||||
});
|
||||
match parse_skill_file(
|
||||
&path,
|
||||
scope,
|
||||
plugin_id,
|
||||
plugin_namespace,
|
||||
plugin_root.as_ref(),
|
||||
preloaded,
|
||||
) {
|
||||
Ok(skill) => outcome.skills.push(skill),
|
||||
Err(err) if scope != SkillScope::System => outcome.errors.push(SkillError {
|
||||
path,
|
||||
message: err.to_string(),
|
||||
}),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -745,116 +628,19 @@ async fn discover_skills_under_root(
|
||||
}
|
||||
}
|
||||
|
||||
async fn preload_skill_files(
|
||||
async fn parse_skill_file(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
skill_paths: &[AbsolutePathBuf],
|
||||
) -> io::Result<Vec<PreloadedSkillFile>> {
|
||||
let calls = skill_paths
|
||||
.iter()
|
||||
.flat_map(|path| {
|
||||
let metadata_path = path
|
||||
.parent()
|
||||
.map(|parent| {
|
||||
parent
|
||||
.join(SKILLS_METADATA_DIR)
|
||||
.join(SKILLS_METADATA_FILENAME)
|
||||
})
|
||||
.unwrap_or_else(|| path.clone());
|
||||
[
|
||||
rpc_batch_call(
|
||||
FS_READ_FILE_METHOD,
|
||||
FsReadFileParams {
|
||||
path: PathUri::from_abs_path(path),
|
||||
sandbox: None,
|
||||
},
|
||||
),
|
||||
rpc_batch_call(
|
||||
FS_READ_FILE_METHOD,
|
||||
FsReadFileParams {
|
||||
path: PathUri::from_abs_path(&metadata_path),
|
||||
sandbox: None,
|
||||
},
|
||||
),
|
||||
rpc_batch_call(
|
||||
FS_CANONICALIZE_METHOD,
|
||||
FsCanonicalizeParams {
|
||||
path: PathUri::from_abs_path(path),
|
||||
sandbox: None,
|
||||
},
|
||||
),
|
||||
]
|
||||
})
|
||||
.collect::<io::Result<Vec<_>>>()?;
|
||||
let mut results = fs.execute_rpc_batch(calls).await?.into_iter();
|
||||
|
||||
Ok(skill_paths
|
||||
.iter()
|
||||
.map(|path| {
|
||||
let contents = decode_text_rpc_result(results.next(), "read skill file");
|
||||
let metadata_contents = decode_text_rpc_result(results.next(), "read skill metadata");
|
||||
let resolved_path = match decode_rpc_batch_result::<FsCanonicalizeResponse>(
|
||||
results.next(),
|
||||
"canonicalize skill path",
|
||||
) {
|
||||
Ok(response) => response.path.to_abs_path().unwrap_or_else(|_| path.clone()),
|
||||
Err(_) => path.clone(),
|
||||
};
|
||||
PreloadedSkillFile {
|
||||
contents,
|
||||
metadata_contents,
|
||||
resolved_path,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn rpc_batch_call<P: Serialize>(method: &str, params: P) -> io::Result<ExecutorRpcBatchCall> {
|
||||
Ok(ExecutorRpcBatchCall {
|
||||
method: method.to_string(),
|
||||
params: serde_json::to_value(params).map_err(io::Error::other)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn decode_rpc_batch_result<T: DeserializeOwned>(
|
||||
result: Option<ExecutorRpcBatchResult>,
|
||||
operation: &str,
|
||||
) -> io::Result<T> {
|
||||
match result {
|
||||
Some(Ok(value)) => serde_json::from_value(value)
|
||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)),
|
||||
Some(Err(error)) => Err(error),
|
||||
None => Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("filesystem returned no result for {operation}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_text_rpc_result(
|
||||
result: Option<ExecutorRpcBatchResult>,
|
||||
operation: &str,
|
||||
) -> io::Result<String> {
|
||||
let response: FsReadFileResponse = decode_rpc_batch_result(result, operation)?;
|
||||
let contents = response
|
||||
.into_bytes()
|
||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
|
||||
String::from_utf8(contents).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
|
||||
}
|
||||
|
||||
fn parse_skill_file(
|
||||
path: &AbsolutePathBuf,
|
||||
scope: SkillScope,
|
||||
plugin_id: Option<&str>,
|
||||
plugin_namespace: Option<&str>,
|
||||
plugin_root: Option<&AbsolutePathBuf>,
|
||||
preloaded: PreloadedSkillFile,
|
||||
) -> Result<SkillMetadata, SkillParseError> {
|
||||
let PreloadedSkillFile {
|
||||
contents,
|
||||
metadata_contents,
|
||||
resolved_path,
|
||||
} = preloaded;
|
||||
let contents = contents.map_err(SkillParseError::Read)?;
|
||||
let path_uri = PathUri::from_abs_path(path);
|
||||
let contents = fs
|
||||
.read_file_text(&path_uri, /*sandbox*/ None)
|
||||
.await
|
||||
.map_err(SkillParseError::Read)?;
|
||||
|
||||
let frontmatter = extract_frontmatter(&contents).ok_or(SkillParseError::MissingFrontmatter)?;
|
||||
|
||||
@@ -878,9 +664,7 @@ fn parse_skill_file(
|
||||
.map(sanitize_single_line)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| default_skill_name(path));
|
||||
let name = plugin_namespace
|
||||
.map(|namespace| format!("{namespace}:{base_name}"))
|
||||
.unwrap_or_else(|| base_name.clone());
|
||||
let name = namespaced_skill_name(fs, path, &base_name, plugin_namespace).await;
|
||||
let description = parsed
|
||||
.description
|
||||
.as_deref()
|
||||
@@ -896,7 +680,7 @@ fn parse_skill_file(
|
||||
interface,
|
||||
dependencies,
|
||||
policy,
|
||||
} = load_skill_metadata(path, plugin_root, metadata_contents);
|
||||
} = load_skill_metadata(fs, path, plugin_root).await;
|
||||
|
||||
validate_len(&base_name, MAX_NAME_LEN, "name")?;
|
||||
validate_len(&name, MAX_QUALIFIED_NAME_LEN, "qualified name")?;
|
||||
@@ -909,6 +693,8 @@ fn parse_skill_file(
|
||||
)?;
|
||||
}
|
||||
|
||||
let resolved_path = canonicalize_for_skill_identity(fs, path).await;
|
||||
|
||||
Ok(SkillMetadata {
|
||||
name,
|
||||
description,
|
||||
@@ -934,10 +720,25 @@ fn default_skill_name(path: &AbsolutePathBuf) -> String {
|
||||
.unwrap_or_else(|| "skill".to_string())
|
||||
}
|
||||
|
||||
fn load_skill_metadata(
|
||||
async fn namespaced_skill_name(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
path: &AbsolutePathBuf,
|
||||
base_name: &str,
|
||||
plugin_namespace: Option<&str>,
|
||||
) -> String {
|
||||
if let Some(plugin_namespace) = plugin_namespace {
|
||||
return format!("{plugin_namespace}:{base_name}");
|
||||
}
|
||||
plugin_namespace_for_skill_path(fs, path)
|
||||
.await
|
||||
.map(|namespace| format!("{namespace}:{base_name}"))
|
||||
.unwrap_or_else(|| base_name.to_string())
|
||||
}
|
||||
|
||||
async fn load_skill_metadata(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
skill_path: &AbsolutePathBuf,
|
||||
plugin_root: Option<&AbsolutePathBuf>,
|
||||
contents: io::Result<String>,
|
||||
) -> LoadedSkillMetadata {
|
||||
// Fail open: optional metadata should not block loading SKILL.md.
|
||||
let Some(skill_dir) = skill_path.parent() else {
|
||||
@@ -946,11 +747,28 @@ fn load_skill_metadata(
|
||||
let metadata_path = skill_dir
|
||||
.join(SKILLS_METADATA_DIR)
|
||||
.join(SKILLS_METADATA_FILENAME);
|
||||
let contents = match contents {
|
||||
Ok(contents) => contents,
|
||||
let metadata_path_uri = PathUri::from_abs_path(&metadata_path);
|
||||
match fs.get_metadata(&metadata_path_uri, /*sandbox*/ None).await {
|
||||
Ok(metadata) if metadata.is_file => {}
|
||||
Ok(_) => return LoadedSkillMetadata::default(),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
||||
return LoadedSkillMetadata::default();
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"ignoring {path}: failed to stat {label}: {error}",
|
||||
path = metadata_path.display(),
|
||||
label = SKILLS_METADATA_FILENAME
|
||||
);
|
||||
return LoadedSkillMetadata::default();
|
||||
}
|
||||
}
|
||||
|
||||
let contents = match fs
|
||||
.read_file_text(&metadata_path_uri, /*sandbox*/ None)
|
||||
.await
|
||||
{
|
||||
Ok(contents) => contents,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"ignoring {path}: failed to read {label}: {error}",
|
||||
|
||||
@@ -8,7 +8,6 @@ pub mod mention_syntax;
|
||||
pub mod plugin_namespace;
|
||||
|
||||
pub use plugin_namespace::DISCOVERABLE_PLUGIN_MANIFEST_PATHS;
|
||||
pub use plugin_namespace::PluginNamespaceResolver;
|
||||
pub use plugin_namespace::find_plugin_manifest_path;
|
||||
pub use plugin_namespace::plugin_namespace_for_skill_path;
|
||||
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
//! Resolve plugin namespace from skill file paths by walking ancestors for `plugin.json`.
|
||||
|
||||
use codex_exec_server::ExecutorFileSystem;
|
||||
use codex_exec_server::ExecutorRpcBatchCall;
|
||||
use codex_exec_server::ExecutorRpcBatchResult;
|
||||
use codex_exec_server::FS_GET_METADATA_METHOD;
|
||||
use codex_exec_server::FS_READ_FILE_METHOD;
|
||||
use codex_exec_server::FsGetMetadataParams;
|
||||
use codex_exec_server::FsGetMetadataResponse;
|
||||
use codex_exec_server::FsReadFileParams;
|
||||
use codex_exec_server::FsReadFileResponse;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -35,8 +24,29 @@ struct RawPluginManifestName {
|
||||
name: String,
|
||||
}
|
||||
|
||||
fn plugin_manifest_name(plugin_root: &AbsolutePathBuf, contents: &[u8]) -> Option<String> {
|
||||
let RawPluginManifestName { name: raw_name } = serde_json::from_slice(contents).ok()?;
|
||||
async fn plugin_manifest_name(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
plugin_root: &AbsolutePathBuf,
|
||||
) -> Option<String> {
|
||||
let mut manifest_path = None;
|
||||
for relative_path in DISCOVERABLE_PLUGIN_MANIFEST_PATHS {
|
||||
let candidate = plugin_root.join(relative_path);
|
||||
let candidate_uri = PathUri::from_abs_path(&candidate);
|
||||
match fs.get_metadata(&candidate_uri, /*sandbox*/ None).await {
|
||||
Ok(metadata) if metadata.is_file => {
|
||||
manifest_path = Some(candidate);
|
||||
break;
|
||||
}
|
||||
Ok(_) | Err(_) => {}
|
||||
}
|
||||
}
|
||||
let manifest_path = manifest_path?;
|
||||
let manifest_path_uri = PathUri::from_abs_path(&manifest_path);
|
||||
let contents = fs
|
||||
.read_file_text(&manifest_path_uri, /*sandbox*/ None)
|
||||
.await
|
||||
.ok()?;
|
||||
let RawPluginManifestName { name: raw_name } = serde_json::from_str(&contents).ok()?;
|
||||
Some(
|
||||
plugin_root
|
||||
.file_name()
|
||||
@@ -47,124 +57,18 @@ fn plugin_manifest_name(plugin_root: &AbsolutePathBuf, contents: &[u8]) -> Optio
|
||||
)
|
||||
}
|
||||
|
||||
/// Resolves plugin namespaces while caching and batching ancestor manifest probes.
|
||||
pub struct PluginNamespaceResolver {
|
||||
manifests_by_root: HashMap<AbsolutePathBuf, String>,
|
||||
}
|
||||
|
||||
impl PluginNamespaceResolver {
|
||||
/// Loads plugin manifest names for every ancestor of `paths`.
|
||||
pub async fn load(fs: &dyn ExecutorFileSystem, paths: &[AbsolutePathBuf]) -> Self {
|
||||
let mut seen = HashSet::new();
|
||||
let roots = paths
|
||||
.iter()
|
||||
.flat_map(AbsolutePathBuf::ancestors)
|
||||
.filter(|ancestor| seen.insert(ancestor.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
let calls = roots
|
||||
.iter()
|
||||
.flat_map(|root| {
|
||||
DISCOVERABLE_PLUGIN_MANIFEST_PATHS
|
||||
.iter()
|
||||
.flat_map(|relative_path| {
|
||||
let path = PathUri::from_abs_path(&root.join(relative_path));
|
||||
[
|
||||
rpc_batch_call(
|
||||
FS_GET_METADATA_METHOD,
|
||||
FsGetMetadataParams {
|
||||
path: path.clone(),
|
||||
sandbox: None,
|
||||
},
|
||||
),
|
||||
rpc_batch_call(
|
||||
FS_READ_FILE_METHOD,
|
||||
FsReadFileParams {
|
||||
path,
|
||||
sandbox: None,
|
||||
},
|
||||
),
|
||||
]
|
||||
})
|
||||
})
|
||||
.collect::<io::Result<Vec<_>>>();
|
||||
let mut results = fs
|
||||
.execute_rpc_batch(calls.unwrap_or_default())
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter();
|
||||
let manifests_by_root = roots
|
||||
.into_iter()
|
||||
.filter_map(|root| {
|
||||
let mut name = None;
|
||||
let mut selected = false;
|
||||
for _ in DISCOVERABLE_PLUGIN_MANIFEST_PATHS {
|
||||
let metadata_result = results.next();
|
||||
let contents_result = results.next();
|
||||
let is_file = decode_rpc_batch_result::<FsGetMetadataResponse>(
|
||||
metadata_result,
|
||||
"stat plugin manifest",
|
||||
)
|
||||
.is_ok_and(|metadata| metadata.is_file);
|
||||
if !selected && is_file {
|
||||
selected = true;
|
||||
if let Ok(response) = decode_rpc_batch_result::<FsReadFileResponse>(
|
||||
contents_result,
|
||||
"read plugin manifest",
|
||||
) && let Ok(contents) = response.into_bytes()
|
||||
{
|
||||
name = plugin_manifest_name(&root, &contents);
|
||||
}
|
||||
}
|
||||
}
|
||||
name.map(|name| (root, name))
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self { manifests_by_root }
|
||||
}
|
||||
|
||||
/// Returns the nearest preloaded plugin namespace for `path`.
|
||||
pub fn resolve(&self, path: &AbsolutePathBuf) -> Option<&str> {
|
||||
path.ancestors()
|
||||
.find_map(|ancestor| self.manifests_by_root.get(&ancestor).map(String::as_str))
|
||||
}
|
||||
}
|
||||
|
||||
fn rpc_batch_call<P: serde::Serialize>(
|
||||
method: &str,
|
||||
params: P,
|
||||
) -> io::Result<ExecutorRpcBatchCall> {
|
||||
Ok(ExecutorRpcBatchCall {
|
||||
method: method.to_string(),
|
||||
params: serde_json::to_value(params).map_err(io::Error::other)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn decode_rpc_batch_result<T: serde::de::DeserializeOwned>(
|
||||
result: Option<ExecutorRpcBatchResult>,
|
||||
operation: &str,
|
||||
) -> io::Result<T> {
|
||||
match result {
|
||||
Some(Ok(value)) => serde_json::from_value(value)
|
||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)),
|
||||
Some(Err(error)) => Err(error),
|
||||
None => Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("filesystem returned no result for {operation}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the plugin manifest `name` for the nearest ancestor of `path` that contains a valid
|
||||
/// plugin manifest (same `name` rules as full manifest loading in codex-core).
|
||||
pub async fn plugin_namespace_for_skill_path(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
path: &AbsolutePathBuf,
|
||||
) -> Option<String> {
|
||||
PluginNamespaceResolver::load(fs, std::slice::from_ref(path))
|
||||
.await
|
||||
.resolve(path)
|
||||
.map(str::to_string)
|
||||
for ancestor in path.ancestors() {
|
||||
if let Some(name) = plugin_manifest_name(fs, &ancestor).await {
|
||||
return Some(name);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -176,7 +80,6 @@ mod tests {
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
const PRIMARY_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".codex-plugin/plugin.json";
|
||||
const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
|
||||
|
||||
#[tokio::test]
|
||||
@@ -188,7 +91,7 @@ mod tests {
|
||||
fs::create_dir_all(skill_path.parent().expect("parent")).expect("mkdir");
|
||||
fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("mkdir manifest");
|
||||
fs::write(
|
||||
plugin_root.join(PRIMARY_PLUGIN_MANIFEST_RELATIVE_PATH),
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"sample"}"#,
|
||||
)
|
||||
.expect("write manifest");
|
||||
@@ -219,32 +122,4 @@ mod tests {
|
||||
);
|
||||
assert_eq!(find_plugin_manifest_path(&plugin_root), Some(manifest_path));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_primary_manifest_does_not_fall_back_to_alternate() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let plugin_root = tmp.path().join("plugins/sample");
|
||||
let skill_path = plugin_root.join("skills/search/SKILL.md");
|
||||
let primary_manifest_path = plugin_root.join(PRIMARY_PLUGIN_MANIFEST_RELATIVE_PATH);
|
||||
let alternate_manifest_path = plugin_root.join(ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH);
|
||||
|
||||
fs::create_dir_all(skill_path.parent().expect("parent")).expect("mkdir");
|
||||
fs::create_dir_all(primary_manifest_path.parent().expect("manifest parent"))
|
||||
.expect("mkdir primary manifest");
|
||||
fs::create_dir_all(alternate_manifest_path.parent().expect("manifest parent"))
|
||||
.expect("mkdir alternate manifest");
|
||||
fs::write(&primary_manifest_path, "invalid json").expect("write primary manifest");
|
||||
fs::write(&alternate_manifest_path, r#"{"name":"sample"}"#)
|
||||
.expect("write alternate manifest");
|
||||
fs::write(&skill_path, "---\ndescription: search\n---\n").expect("write skill");
|
||||
|
||||
assert_eq!(
|
||||
plugin_namespace_for_skill_path(LOCAL_FS.as_ref(), &skill_path.abs()).await,
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
find_plugin_manifest_path(&plugin_root),
|
||||
Some(primary_manifest_path)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user