feat(orchestrator): create_skill action + wire per-project watchers (§14.3)
- domain: OrchestratorCommand::CreateSkill with optional scope field (defaults to Project, case-insensitive, UnknownScope on bad value) - application: dispatch CreateSkill through the CreateSkill use case - app-tauri: build OrchestratorService and start/stop per-project request watchers on open/create/close_project (ensure/stop_orchestrator_watch) - tests: domain validation, service dispatch, infra watcher e2e, app-tauri wiring lifecycle (idempotent, isolated, stop) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -13,6 +13,8 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::skill::SkillScope;
|
||||
|
||||
/// Errors raised while validating a raw [`OrchestratorRequest`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum OrchestratorError {
|
||||
@ -27,6 +29,14 @@ pub enum OrchestratorError {
|
||||
/// The required field that was absent or empty.
|
||||
field: String,
|
||||
},
|
||||
/// The `scope` field is present but not a recognised skill scope.
|
||||
#[error("unknown skill scope `{scope}` for action `{action}`")]
|
||||
UnknownScope {
|
||||
/// The action being validated.
|
||||
action: String,
|
||||
/// The offending scope value.
|
||||
scope: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// The raw, wire-level orchestrator request as deserialised from a request file.
|
||||
@ -47,10 +57,15 @@ pub struct OrchestratorRequest {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub profile: Option<String>,
|
||||
/// Context reference: for `spawn_agent` the relative `.md` path is informative
|
||||
/// (the manifest owns the real path); for `update_agent_context` this carries
|
||||
/// the **new Markdown body** to write.
|
||||
/// (the manifest owns the real path); for `update_agent_context` **and**
|
||||
/// `create_skill` this carries the **new Markdown body** to write.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<String>,
|
||||
/// Skill scope for `create_skill` (`"global"` or `"project"`, case-insensitive).
|
||||
/// Optional — absent/empty defaults to [`SkillScope::Project`]. Ignored by the
|
||||
/// other actions.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub scope: Option<String>,
|
||||
}
|
||||
|
||||
/// A validated orchestrator command — the only thing the application layer acts on.
|
||||
@ -81,6 +96,15 @@ pub enum OrchestratorCommand {
|
||||
/// New Markdown body.
|
||||
context: String,
|
||||
},
|
||||
/// Create a reusable skill in the given scope — exactly as the UI would.
|
||||
CreateSkill {
|
||||
/// Display name (also the `.md` stem on disk).
|
||||
name: String,
|
||||
/// Markdown body of the skill.
|
||||
content: String,
|
||||
/// Scope the skill is created in (defaults to [`SkillScope::Project`]).
|
||||
scope: SkillScope,
|
||||
},
|
||||
}
|
||||
|
||||
impl OrchestratorRequest {
|
||||
@ -114,10 +138,33 @@ impl OrchestratorRequest {
|
||||
name: self.require_name(action)?,
|
||||
context: self.require("context", action, self.context.as_deref())?,
|
||||
}),
|
||||
"create_skill" => Ok(OrchestratorCommand::CreateSkill {
|
||||
name: self.require_name(action)?,
|
||||
content: self.require("context", action, self.context.as_deref())?,
|
||||
scope: self.parse_scope(action)?,
|
||||
}),
|
||||
other => Err(OrchestratorError::UnknownAction(other.to_owned())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses the optional `scope` field into a [`SkillScope`], defaulting to
|
||||
/// [`SkillScope::Project`] when absent or empty.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`OrchestratorError::UnknownScope`] when the value is neither `global` nor
|
||||
/// `project` (case-insensitive).
|
||||
fn parse_scope(&self, action: &str) -> Result<SkillScope, OrchestratorError> {
|
||||
match self.scope.as_deref().map(str::trim) {
|
||||
None | Some("") => Ok(SkillScope::Project),
|
||||
Some(s) if s.eq_ignore_ascii_case("project") => Ok(SkillScope::Project),
|
||||
Some(s) if s.eq_ignore_ascii_case("global") => Ok(SkillScope::Global),
|
||||
Some(other) => Err(OrchestratorError::UnknownScope {
|
||||
action: action.to_owned(),
|
||||
scope: other.to_owned(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Requires a non-empty `name`, shared by all actions.
|
||||
fn require_name(&self, action: &str) -> Result<String, OrchestratorError> {
|
||||
self.require("name", action, self.name.as_deref())
|
||||
@ -235,6 +282,62 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_skill_defaults_to_project_scope() {
|
||||
let r = req(
|
||||
r##"{ "action": "create_skill", "name": "deploy", "context": "# Deploy steps" }"##,
|
||||
);
|
||||
assert_eq!(
|
||||
r.validate().unwrap(),
|
||||
OrchestratorCommand::CreateSkill {
|
||||
name: "deploy".to_owned(),
|
||||
content: "# Deploy steps".to_owned(),
|
||||
scope: SkillScope::Project,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_skill_honours_explicit_scope_case_insensitively() {
|
||||
let r = req(
|
||||
r##"{ "action": "create_skill", "name": "deploy", "context": "body", "scope": "Global" }"##,
|
||||
);
|
||||
assert_eq!(
|
||||
r.validate().unwrap(),
|
||||
OrchestratorCommand::CreateSkill {
|
||||
name: "deploy".to_owned(),
|
||||
content: "body".to_owned(),
|
||||
scope: SkillScope::Global,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_skill_missing_body_is_rejected() {
|
||||
let r = req(r#"{ "action": "create_skill", "name": "deploy" }"#);
|
||||
assert_eq!(
|
||||
r.validate(),
|
||||
Err(OrchestratorError::MissingField {
|
||||
action: "create_skill".to_owned(),
|
||||
field: "context".to_owned(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_skill_unknown_scope_is_rejected() {
|
||||
let r = req(
|
||||
r##"{ "action": "create_skill", "name": "deploy", "context": "body", "scope": "team" }"##,
|
||||
);
|
||||
assert_eq!(
|
||||
r.validate(),
|
||||
Err(OrchestratorError::UnknownScope {
|
||||
action: "create_skill".to_owned(),
|
||||
scope: "team".to_owned(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_action_is_rejected() {
|
||||
let r = req(r#"{ "action": "delete_everything", "name": "a" }"#);
|
||||
|
||||
Reference in New Issue
Block a user