server: project Skills from active virtual config
This commit is contained in:
@@ -57,8 +57,20 @@ pub enum SkillSourceKind {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct SkillProvenance {
|
pub struct SkillProvenance {
|
||||||
pub kind: SkillSourceKind,
|
pub kind: SkillSourceKind,
|
||||||
/// Stable path-free id: `builtin:<name>` or `workspace:<name>`.
|
/// Stable id: `builtin:<name>` or `workspace:<name>`.
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
/// Virtual config/resource path. Never an absolute host filesystem path.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub virtual_path: Option<String>,
|
||||||
|
/// Active Workspace config revision for Workspace Skills.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub revision: Option<u64>,
|
||||||
|
/// Digest of the immutable `SKILL.md` source.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub source_digest: Option<String>,
|
||||||
|
/// Digest of the active virtual config tree snapshot.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tree_digest: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -101,7 +113,8 @@ pub struct SkillDetailResponse {
|
|||||||
pub overrides: Vec<SkillProvenance>,
|
pub overrides: Vec<SkillProvenance>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub diagnostics: Vec<SkillDiagnostic>,
|
pub diagnostics: Vec<SkillDiagnostic>,
|
||||||
/// Full SKILL.md contents. This is intentionally omitted from catalog responses.
|
/// Imported Markdown content with YAML frontmatter delimiters removed.
|
||||||
|
/// This is intentionally omitted from catalog responses.
|
||||||
pub body: String,
|
pub body: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub allowed_tools: Vec<String>,
|
pub allowed_tools: Vec<String>,
|
||||||
@@ -117,7 +130,7 @@ pub struct SkillActivationResponse {
|
|||||||
pub provenance: SkillProvenance,
|
pub provenance: SkillProvenance,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub diagnostics: Vec<SkillDiagnostic>,
|
pub diagnostics: Vec<SkillDiagnostic>,
|
||||||
/// Full SKILL.md contents to append to Worker history on explicit activation.
|
/// Imported Markdown content to append to Worker history on explicit activation.
|
||||||
pub body: String,
|
pub body: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -176,8 +176,7 @@ impl SqliteWorkspaceStore {
|
|||||||
Ok((state, requires_toolchain_refresh))
|
Ok((state, requires_toolchain_refresh))
|
||||||
})?;
|
})?;
|
||||||
if requires_toolchain_refresh
|
if requires_toolchain_refresh
|
||||||
|| (state.contract.schema_bundle.contributions.is_empty()
|
|| state.contract.schema_bundle.fingerprint != desired_schema.fingerprint
|
||||||
&& !desired_schema.contributions.is_empty())
|
|
||||||
{
|
{
|
||||||
let candidate = evaluate_candidate(state, &[], desired_schema)?;
|
let candidate = evaluate_candidate(state, &[], desired_schema)?;
|
||||||
return self.commit_evaluated_workspace_config(workspace_id, &candidate);
|
return self.commit_evaluated_workspace_config(workspace_id, &candidate);
|
||||||
@@ -1093,6 +1092,64 @@ mod tests {
|
|||||||
assert_eq!(prior_fingerprint, "sha256:legacy");
|
assert_eq!(prior_fingerprint, "sha256:legacy");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn schema_provider_addition_re_evaluates_and_pins_a_new_revision() {
|
||||||
|
let store = open_store().await;
|
||||||
|
let initial = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
|
||||||
|
"builtin:profile-test",
|
||||||
|
"profile",
|
||||||
|
"1",
|
||||||
|
r#"{ profile = { enabled = Bool default true; }; }"#,
|
||||||
|
)
|
||||||
|
.unwrap()])
|
||||||
|
.unwrap();
|
||||||
|
let current = store
|
||||||
|
.ensure_workspace_config_materialized_with_schema(
|
||||||
|
"w-config",
|
||||||
|
"2026-08-13T00:00:00Z",
|
||||||
|
initial,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let extended = WorkspaceConfigSchemaBundle::compose([
|
||||||
|
ConfigSchemaContribution::new(
|
||||||
|
"builtin:profile-test",
|
||||||
|
"profile",
|
||||||
|
"1",
|
||||||
|
r#"{ profile = { enabled = Bool default true; }; }"#,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
ConfigSchemaContribution::new(
|
||||||
|
"builtin:skill-test",
|
||||||
|
"skills",
|
||||||
|
"1",
|
||||||
|
r#"{ skills = {...String} default {}; }"#,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let refreshed = store
|
||||||
|
.ensure_workspace_config_materialized_with_schema(
|
||||||
|
"w-config",
|
||||||
|
"2026-08-14T00:00:00Z",
|
||||||
|
extended.clone(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(refreshed.snapshot.revision, current.snapshot.revision + 1);
|
||||||
|
assert_eq!(refreshed.snapshot.digest, current.snapshot.digest);
|
||||||
|
assert_eq!(refreshed.contract.schema_bundle, extended);
|
||||||
|
assert_ne!(
|
||||||
|
refreshed.projection_digest, current.projection_digest,
|
||||||
|
"the newly defaulted namespace changes the evaluated projection"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.load_workspace_config_revision("w-config", current.snapshot.revision)
|
||||||
|
.unwrap()
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn invalid_toolchain_upgrade_returns_diagnostics_without_mutating_authority() {
|
async fn invalid_toolchain_upgrade_returns_diagnostics_without_mutating_authority() {
|
||||||
let store = open_store().await;
|
let store = open_store().await;
|
||||||
|
|||||||
@@ -42,11 +42,16 @@ struct WorkspacePathOptions {
|
|||||||
workspace: PathBuf,
|
workspace: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct SkillWorkspaceOptions {
|
||||||
|
workspace_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
enum SkillsCommand {
|
enum SkillsCommand {
|
||||||
List(WorkspacePathOptions),
|
List(SkillWorkspaceOptions),
|
||||||
Lint(WorkspacePathOptions),
|
Lint(SkillWorkspaceOptions),
|
||||||
Show { workspace: PathBuf, name: String },
|
Show { workspace_id: String, name: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -513,11 +518,13 @@ fn ensure_no_inline_value(flag: &str, inline_value: Option<&str>) -> Result<(),
|
|||||||
fn run_skills(command: SkillsCommand) -> Result<(), Box<dyn std::error::Error>> {
|
fn run_skills(command: SkillsCommand) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
match command {
|
match command {
|
||||||
SkillsCommand::List(options) => {
|
SkillsCommand::List(options) => {
|
||||||
let catalog = yoi_workspace_server::skills::catalog(&options.workspace);
|
let state = load_skill_workspace_config(&options.workspace_id)?;
|
||||||
|
let catalog = yoi_workspace_server::skills::catalog(&state)?;
|
||||||
println!("{}", serde_json::to_string_pretty(&catalog)?);
|
println!("{}", serde_json::to_string_pretty(&catalog)?);
|
||||||
}
|
}
|
||||||
SkillsCommand::Lint(options) => {
|
SkillsCommand::Lint(options) => {
|
||||||
let catalog = yoi_workspace_server::skills::lint(&options.workspace);
|
let state = load_skill_workspace_config(&options.workspace_id)?;
|
||||||
|
let catalog = yoi_workspace_server::skills::lint(&state)?;
|
||||||
println!("{}", serde_json::to_string_pretty(&catalog)?);
|
println!("{}", serde_json::to_string_pretty(&catalog)?);
|
||||||
if catalog
|
if catalog
|
||||||
.diagnostics
|
.diagnostics
|
||||||
@@ -535,14 +542,26 @@ fn run_skills(command: SkillsCommand) -> Result<(), Box<dyn std::error::Error>>
|
|||||||
return Err(Box::new(CliError("Skill lint found errors".to_string())));
|
return Err(Box::new(CliError("Skill lint found errors".to_string())));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SkillsCommand::Show { workspace, name } => {
|
SkillsCommand::Show { workspace_id, name } => {
|
||||||
let detail = yoi_workspace_server::skills::detail(&workspace, &name)?;
|
let state = load_skill_workspace_config(&workspace_id)?;
|
||||||
|
let detail = yoi_workspace_server::skills::detail(&state, &name)?;
|
||||||
println!("{}", serde_json::to_string_pretty(&detail)?);
|
println!("{}", serde_json::to_string_pretty(&detail)?);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn load_skill_workspace_config(
|
||||||
|
workspace_id: &str,
|
||||||
|
) -> Result<yoi_workspace_server::config_source::WorkspaceConfigState, Box<dyn std::error::Error>> {
|
||||||
|
let store = SqliteWorkspaceStore::open(ServerConfig::default_server_database_path())?;
|
||||||
|
store.load_workspace_config(workspace_id)?.ok_or_else(|| {
|
||||||
|
Box::new(CliError(format!(
|
||||||
|
"Workspace `{workspace_id}` has no active config revision"
|
||||||
|
))) as Box<dyn std::error::Error>
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Error>> {
|
async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let database_path = ServerConfig::default_server_database_path();
|
let database_path = ServerConfig::default_server_database_path();
|
||||||
if let Some(parent) = database_path.parent() {
|
if let Some(parent) = database_path.parent() {
|
||||||
@@ -706,17 +725,17 @@ fn parse_skills_command(args: &[String]) -> Result<Command, CliError> {
|
|||||||
};
|
};
|
||||||
match subcommand.as_str() {
|
match subcommand.as_str() {
|
||||||
"list" => Ok(Command::Skills(SkillsCommand::List(
|
"list" => Ok(Command::Skills(SkillsCommand::List(
|
||||||
parse_workspace_path_options(rest)?,
|
parse_skill_workspace_options(rest)?,
|
||||||
))),
|
))),
|
||||||
"lint" => Ok(Command::Skills(SkillsCommand::Lint(
|
"lint" => Ok(Command::Skills(SkillsCommand::Lint(
|
||||||
parse_workspace_path_options(rest)?,
|
parse_skill_workspace_options(rest)?,
|
||||||
))),
|
))),
|
||||||
"show" => {
|
"show" => {
|
||||||
let Some((name, rest)) = rest.split_first() else {
|
let Some((name, rest)) = rest.split_first() else {
|
||||||
return Err(CliError("skills show requires a Skill name".to_string()));
|
return Err(CliError("skills show requires a Skill name".to_string()));
|
||||||
};
|
};
|
||||||
Ok(Command::Skills(SkillsCommand::Show {
|
Ok(Command::Skills(SkillsCommand::Show {
|
||||||
workspace: parse_workspace_path_options(rest)?.workspace,
|
workspace_id: parse_skill_workspace_options(rest)?.workspace_id,
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -730,6 +749,32 @@ fn parse_skills_command(args: &[String]) -> Result<Command, CliError> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_skill_workspace_options(args: &[String]) -> Result<SkillWorkspaceOptions, CliError> {
|
||||||
|
let mut workspace_id = None;
|
||||||
|
let mut iter = args.iter();
|
||||||
|
while let Some(arg) = iter.next() {
|
||||||
|
match arg.as_str() {
|
||||||
|
"--workspace" => {
|
||||||
|
let value = iter
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| CliError("--workspace requires a Workspace id".to_string()))?;
|
||||||
|
workspace_id = Some(value.clone());
|
||||||
|
}
|
||||||
|
value if value.starts_with("--workspace=") => {
|
||||||
|
workspace_id = Some(value_after_equals(arg, "--workspace")?.to_string());
|
||||||
|
}
|
||||||
|
other => return Err(CliError(format!("unknown skills option `{other}`"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let workspace_id = workspace_id.ok_or_else(|| {
|
||||||
|
CliError("skills commands require --workspace <workspace-id>".to_string())
|
||||||
|
})?;
|
||||||
|
if workspace_id.trim().is_empty() {
|
||||||
|
return Err(CliError("--workspace must not be empty".to_string()));
|
||||||
|
}
|
||||||
|
Ok(SkillWorkspaceOptions { workspace_id })
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_workspace_path_options(args: &[String]) -> Result<WorkspacePathOptions, CliError> {
|
fn parse_workspace_path_options(args: &[String]) -> Result<WorkspacePathOptions, CliError> {
|
||||||
let mut workspace = std::env::current_dir()
|
let mut workspace = std::env::current_dir()
|
||||||
.map_err(|error| CliError(format!("failed to read current dir: {error}")))?;
|
.map_err(|error| CliError(format!("failed to read current dir: {error}")))?;
|
||||||
@@ -848,7 +893,7 @@ fn print_config_help() {
|
|||||||
|
|
||||||
fn print_skills_help() {
|
fn print_skills_help() {
|
||||||
println!(
|
println!(
|
||||||
"yoi-server skills\n\nUsage:\n yoi-server skills list [OPTIONS]\n yoi-server skills lint [OPTIONS]\n yoi-server skills show <NAME> [OPTIONS]\n\nDescription:\n Uses the Workspace backend Skill catalog/lint/detail authority. Catalog output is lightweight and omits full SKILL.md bodies; detail output includes the body. allowed-tools and scripts are diagnostics only.\n\nOptions:\n --workspace <PATH> Workspace root (defaults to cwd)\n -h, --help Print help"
|
"yoi-server skills\n\nUsage:\n yoi-server skills list --workspace <WORKSPACE_ID>\n yoi-server skills lint --workspace <WORKSPACE_ID>\n yoi-server skills show <NAME> --workspace <WORKSPACE_ID>\n\nDescription:\n Reads the active Server DB virtual-config revision. Catalog output is lightweight and omits imported Markdown content; detail output includes that content. allowed-tools and scripts are diagnostics only.\n\nOptions:\n --workspace <WORKSPACE_ID> Workspace id in the Server DB (required)\n -h, --help Print help"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -874,6 +919,26 @@ mod tests {
|
|||||||
assert_eq!(options.workspace, temp.path().canonicalize().unwrap());
|
assert_eq!(options.workspace, temp.path().canonicalize().unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_skills_requires_server_workspace_id() {
|
||||||
|
let error = parse_skills_command(&["list".to_string()]).unwrap_err();
|
||||||
|
assert_eq!(
|
||||||
|
error.to_string(),
|
||||||
|
"skills commands require --workspace <workspace-id>"
|
||||||
|
);
|
||||||
|
let command = parse_skills_command(&[
|
||||||
|
"show".to_string(),
|
||||||
|
"debug-rust".to_string(),
|
||||||
|
"--workspace=workspace-a".to_string(),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let Command::Skills(SkillsCommand::Show { workspace_id, name }) = command else {
|
||||||
|
panic!("expected skills show command");
|
||||||
|
};
|
||||||
|
assert_eq!(workspace_id, "workspace-a");
|
||||||
|
assert_eq!(name, "debug-rust");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_serve_accepts_listen_only() {
|
fn parse_serve_accepts_listen_only() {
|
||||||
let args = vec!["--listen".to_string(), "127.0.0.1:0".to_string()];
|
let args = vec!["--listen".to_string(), "127.0.0.1:0".to_string()];
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ impl WorkspaceConfigSchemaProvider for ProfileConfigSchemaProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
struct VirtualProfileConfig {
|
struct VirtualProfileConfig {
|
||||||
profile: VirtualProfileSection,
|
profile: VirtualProfileSection,
|
||||||
}
|
}
|
||||||
@@ -72,9 +71,14 @@ pub fn project_profiles_from_workspace_config(
|
|||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
state: &WorkspaceConfigState,
|
state: &WorkspaceConfigState,
|
||||||
) -> Result<ProfileConfigProjection> {
|
) -> Result<ProfileConfigProjection> {
|
||||||
let schema = ProfileConfigSchemaProvider.contribution()?;
|
let bundle = if state.contract.schema_bundle.contributions.is_empty() {
|
||||||
let bundle = config_source::WorkspaceConfigSchemaBundle::compose([schema])
|
config_source::WorkspaceConfigSchemaBundle::compose([
|
||||||
.map_err(|error| Error::Config(error.to_string()))?;
|
ProfileConfigSchemaProvider.contribution()?
|
||||||
|
])
|
||||||
|
.map_err(|error| Error::Config(error.to_string()))?
|
||||||
|
} else {
|
||||||
|
state.contract.schema_bundle.clone()
|
||||||
|
};
|
||||||
let evaluation = evaluate_workspace_config_state(state, bundle)?;
|
let evaluation = evaluate_workspace_config_state(state, bundle)?;
|
||||||
if evaluation.projection_digest != state.projection_digest
|
if evaluation.projection_digest != state.projection_digest
|
||||||
&& state
|
&& state
|
||||||
|
|||||||
@@ -754,7 +754,8 @@ impl WorkspaceApi {
|
|||||||
let config_schema_registry = crate::config_source::WorkspaceConfigSchemaRegistry::default()
|
let config_schema_registry = crate::config_source::WorkspaceConfigSchemaRegistry::default()
|
||||||
.with_provider(Arc::new(
|
.with_provider(Arc::new(
|
||||||
crate::profile_settings::ProfileConfigSchemaProvider,
|
crate::profile_settings::ProfileConfigSchemaProvider,
|
||||||
));
|
))
|
||||||
|
.with_provider(Arc::new(skills::SkillConfigSchemaProvider));
|
||||||
config_store.ensure_workspace_config_materialized_with_schema(
|
config_store.ensure_workspace_config_materialized_with_schema(
|
||||||
&config.workspace_id,
|
&config.workspace_id,
|
||||||
&Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
|
&Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||||
@@ -5168,7 +5169,16 @@ async fn scoped_list_skills(
|
|||||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
) -> ApiResult<Json<worker::skill::SkillCatalogResponse>> {
|
) -> ApiResult<Json<worker::skill::SkillCatalogResponse>> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
Ok(Json(skills::catalog(&api.config.workspace_root)))
|
let state = api
|
||||||
|
.config_store
|
||||||
|
.load_workspace_config(&path.workspace_id)?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
Error::RegistryInconsistency(format!(
|
||||||
|
"Workspace {} has no active config revision",
|
||||||
|
path.workspace_id
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
skills::catalog(&state).map(Json).map_err(skill_api_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn scoped_lint_skills(
|
async fn scoped_lint_skills(
|
||||||
@@ -5176,7 +5186,16 @@ async fn scoped_lint_skills(
|
|||||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
) -> ApiResult<Json<worker::skill::SkillCatalogResponse>> {
|
) -> ApiResult<Json<worker::skill::SkillCatalogResponse>> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
Ok(Json(skills::lint(&api.config.workspace_root)))
|
let state = api
|
||||||
|
.config_store
|
||||||
|
.load_workspace_config(&path.workspace_id)?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
Error::RegistryInconsistency(format!(
|
||||||
|
"Workspace {} has no active config revision",
|
||||||
|
path.workspace_id
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
skills::lint(&state).map(Json).map_err(skill_api_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn scoped_get_skill(
|
async fn scoped_get_skill(
|
||||||
@@ -5184,7 +5203,16 @@ async fn scoped_get_skill(
|
|||||||
AxumPath(path): AxumPath<ScopedSkillPath>,
|
AxumPath(path): AxumPath<ScopedSkillPath>,
|
||||||
) -> ApiResult<Json<worker::skill::SkillDetailResponse>> {
|
) -> ApiResult<Json<worker::skill::SkillDetailResponse>> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
skills::detail(&api.config.workspace_root, &path.name)
|
let state = api
|
||||||
|
.config_store
|
||||||
|
.load_workspace_config(&path.workspace_id)?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
Error::RegistryInconsistency(format!(
|
||||||
|
"Workspace {} has no active config revision",
|
||||||
|
path.workspace_id
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
skills::detail(&state, &path.name)
|
||||||
.map(Json)
|
.map(Json)
|
||||||
.map_err(skill_api_error)
|
.map_err(skill_api_error)
|
||||||
}
|
}
|
||||||
@@ -5194,7 +5222,16 @@ async fn scoped_activate_skill(
|
|||||||
AxumPath(path): AxumPath<ScopedSkillPath>,
|
AxumPath(path): AxumPath<ScopedSkillPath>,
|
||||||
) -> ApiResult<Json<worker::skill::SkillActivationResponse>> {
|
) -> ApiResult<Json<worker::skill::SkillActivationResponse>> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
skills::activation(&api.config.workspace_root, &path.name)
|
let state = api
|
||||||
|
.config_store
|
||||||
|
.load_workspace_config(&path.workspace_id)?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
Error::RegistryInconsistency(format!(
|
||||||
|
"Workspace {} has no active config revision",
|
||||||
|
path.workspace_id
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
skills::activation(&state, &path.name)
|
||||||
.map(Json)
|
.map(Json)
|
||||||
.map_err(skill_api_error)
|
.map_err(skill_api_error)
|
||||||
}
|
}
|
||||||
@@ -5213,7 +5250,14 @@ fn skill_api_error(error: skills::SkillError) -> ApiError {
|
|||||||
message: format!("unknown Skill `{name}`"),
|
message: format!("unknown Skill `{name}`"),
|
||||||
}],
|
}],
|
||||||
),
|
),
|
||||||
skills::SkillError::Io(error) => ApiError::from(Error::Io(error)),
|
skills::SkillError::InvalidSkill(name) => ApiError::from(Error::InvalidInput(format!(
|
||||||
|
"Skill `{name}` has blocking diagnostics"
|
||||||
|
))),
|
||||||
|
error => ApiError::from(Error::RuntimeOperationFailed {
|
||||||
|
runtime_id: "workspace".to_string(),
|
||||||
|
code: "skill_projection_failed".to_string(),
|
||||||
|
message: error.to_string(),
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14009,16 +14053,60 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn skills_endpoints_use_workspace_backend_catalog_and_progressive_detail() {
|
async fn skills_endpoints_use_active_virtual_config_and_ignore_repository_yoi() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let skill_dir = dir.path().join(".yoi/skills/triage-errors");
|
let skill_dir = dir.path().join(".yoi/skills/triage-errors");
|
||||||
fs::create_dir_all(&skill_dir).unwrap();
|
fs::create_dir_all(&skill_dir).unwrap();
|
||||||
fs::write(
|
fs::write(
|
||||||
skill_dir.join("SKILL.md"),
|
skill_dir.join("SKILL.md"),
|
||||||
"---\nname: triage-errors\ndescription: Use when triaging backend errors and choosing safe diagnostics.\n---\n\n# Triage Errors\n\nInspect logs before changing code.",
|
"---\nname: triage-errors\ndescription: stale filesystem authority\n---\nfilesystem body",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let api = test_api(dir.path()).await;
|
let api = test_api(dir.path()).await;
|
||||||
|
let current = api
|
||||||
|
.config_store
|
||||||
|
.load_workspace_config(TEST_WORKSPACE_ID)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let main_path = config_source::VirtualPath::parse("main.dcdl").unwrap();
|
||||||
|
let skill_path =
|
||||||
|
config_source::VirtualPath::parse("skills/triage-errors/SKILL.md").unwrap();
|
||||||
|
let main = format!(
|
||||||
|
r#"{{ skills = {{ triage_errors = import "./skills/triage-errors/SKILL.md" as {}; }}; }}"#,
|
||||||
|
skills::SKILL_DOCUMENT_SCHEMA_SOURCE
|
||||||
|
);
|
||||||
|
let request = crate::config_source::ConfigCommitRequest {
|
||||||
|
base_revision: current.snapshot.revision,
|
||||||
|
base_digest: current.snapshot.digest.clone(),
|
||||||
|
changes: vec![
|
||||||
|
config_source::ConfigTreeChange::Update {
|
||||||
|
path: main_path.clone(),
|
||||||
|
expected_digest: current.snapshot.entries[&main_path]
|
||||||
|
.content_digest
|
||||||
|
.clone(),
|
||||||
|
content: main,
|
||||||
|
},
|
||||||
|
config_source::ConfigTreeChange::Create {
|
||||||
|
path: skill_path,
|
||||||
|
content_type: config_source::ConfigContentType::Text,
|
||||||
|
content: "---\nname: triage-errors\ndescription: Use the active DB-backed virtual config when triaging errors.\n---\n# Triage Errors\n\nInspect logs before changing code."
|
||||||
|
.to_string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
entrypoints: current.contract.entrypoints.clone(),
|
||||||
|
toolchain_fingerprint: current.contract.fingerprint.clone(),
|
||||||
|
};
|
||||||
|
let candidate = api
|
||||||
|
.config_store
|
||||||
|
.evaluate_workspace_config_candidate_with_schema(
|
||||||
|
TEST_WORKSPACE_ID,
|
||||||
|
&request,
|
||||||
|
api.config_schema_registry.compose().unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
api.config_store
|
||||||
|
.commit_evaluated_workspace_config(TEST_WORKSPACE_ID, &candidate)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let Json(catalog) = scoped_list_skills(
|
let Json(catalog) = scoped_list_skills(
|
||||||
State(api.clone()),
|
State(api.clone()),
|
||||||
@@ -14034,6 +14122,13 @@ mod tests {
|
|||||||
.find(|entry| entry.name == "triage-errors")
|
.find(|entry| entry.name == "triage-errors")
|
||||||
.expect("workspace Skill catalog entry");
|
.expect("workspace Skill catalog entry");
|
||||||
assert_eq!(entry.provenance.id, "workspace:triage-errors");
|
assert_eq!(entry.provenance.id, "workspace:triage-errors");
|
||||||
|
assert_eq!(
|
||||||
|
entry.provenance.virtual_path.as_deref(),
|
||||||
|
Some("skills/triage-errors/SKILL.md")
|
||||||
|
);
|
||||||
|
assert!(entry.provenance.revision.is_some());
|
||||||
|
assert!(entry.provenance.source_digest.is_some());
|
||||||
|
assert_ne!(entry.description, "stale filesystem authority");
|
||||||
assert!(
|
assert!(
|
||||||
!serde_json::to_string(&catalog)
|
!serde_json::to_string(&catalog)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,10 @@ export type SkillDiagnostic = {
|
|||||||
export type SkillProvenance = {
|
export type SkillProvenance = {
|
||||||
kind: "builtin" | "workspace";
|
kind: "builtin" | "workspace";
|
||||||
id: string;
|
id: string;
|
||||||
|
virtual_path?: string;
|
||||||
|
revision?: number;
|
||||||
|
source_digest?: string;
|
||||||
|
tree_digest?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SkillCatalogEntry = {
|
export type SkillCatalogEntry = {
|
||||||
|
|||||||
Reference in New Issue
Block a user