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:
2026-06-07 13:06:41 +02:00
parent b9fd2fb925
commit d11eaaa8c0
13 changed files with 619 additions and 52 deletions

View File

@ -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" }"#);