diff --git a/.ideai/layouts.json b/.ideai/layouts.json index d7e2794..3f373f6 100644 --- a/.ideai/layouts.json +++ b/.ideai/layouts.json @@ -1,56 +1,43 @@ { "version": 1, - "activeId": "9188db80-8535-4786-a20b-3c9a36b222e3", + "activeId": "eed26045-b208-47ff-98ee-ca0d6e5933b3", "layouts": [ { - "id": "9188db80-8535-4786-a20b-3c9a36b222e3", + "id": "eed26045-b208-47ff-98ee-ca0d6e5933b3", "name": "Default", "kind": "terminal", "tree": { "root": { "type": "split", "node": { - "id": "8aca2f93-1a9b-4693-9bba-9a01e130a48c", + "id": "8cbe4c7c-357f-49ad-ac20-c96802a84684", "direction": "row", "children": [ { "node": { "type": "leaf", "node": { - "id": "d8a86eb1-cd4d-4937-b900-4989da7c868d", - "session": "8a976ad4-72d5-4dfa-9176-b0838e8e7e1d", + "id": "e8933dbd-1c53-4342-96ca-020b9a9b7970", + "session": "51c96008-f875-4515-9b5b-50c4217f64d6", "agent": "a6ced819-b893-4213-b003-9e9dc79b9641" } }, - "weight": 0.9225053 + "weight": 1.0 }, { "node": { "type": "leaf", "node": { - "id": "6c5be5e7-a54b-468c-a2e2-8ec853629d5e", - "session": "b6ef5e09-411b-4da4-93a7-d32ecd4bb750" + "id": "50da405b-18f3-4273-9fe0-57bb242cb2f7", + "session": "5a796d0e-433e-4ded-9955-ed02b389d9c1" } }, - "weight": 1.0774947 + "weight": 1.0 } ] } } } - }, - { - "id": "40ea4fa9-25b3-410b-9d3e-6350834b421b", - "name": "Git Graph", - "kind": "gitGraph", - "tree": { - "root": { - "type": "leaf", - "node": { - "id": "c840bfdd-3330-46a3-b727-0799f6853e72" - } - } - } } ] } diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e6ea822..950a727 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -658,7 +658,12 @@ IdeA détecte le fichier, exécute la même logique que `LaunchAgent` déclench **Port `OrchestratorApi`** (adapter entrant, driven by file-watcher) : surveille `.ideai/requests/`, désérialise les commandes, les traduit en appels de use cases (`LaunchAgent`, `StopAgent`…). Implémenté dans `infrastructure/orchestrator`. -**Actions supportées (v1)** : `spawn_agent`, `stop_agent`, `update_agent_context`. +**Actions supportées (v1)** : `spawn_agent`, `stop_agent`, `update_agent_context`, `create_skill`. + +`create_skill` permet à un orchestrateur de créer un skill réutilisable exactement comme l'UI (use case `CreateSkill`). Champs : `name`, `context` (corps Markdown du skill), `scope` optionnel (`"global"` | `"project"`, défaut `project`). Exemple : +```json +{ "action": "create_skill", "name": "deploy", "context": "# Étapes de déploiement…", "scope": "project" } +``` --- diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index eef8642..4f0bbda 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -68,12 +68,15 @@ pub async fn create_project( request: CreateProjectRequestDto, state: State<'_, AppState>, ) -> Result { - state + let output = state .create_project .execute(request.into()) .await - .map(ProjectDto::from) - .map_err(ErrorDto::from) + .map_err(ErrorDto::from)?; + // Start tailing this project's `.ideai/requests/` tree so an orchestrator + // agent can delegate agent/skill creation to IdeA (§14.3). + state.ensure_orchestrator_watch(&output.project); + Ok(ProjectDto::from(output)) } /// `open_project` — load a project and its `.ideai/` meta/manifest. @@ -87,12 +90,14 @@ pub async fn open_project( state: State<'_, AppState>, ) -> Result { let id = parse_project_id(&project_id)?; - state + let output = state .open_project .execute(OpenProjectInput { project_id: id }) .await - .map(ProjectDto::from) - .map_err(ErrorDto::from) + .map_err(ErrorDto::from)?; + // (Re)start the orchestrator watcher for this project (idempotent, §14.3). + state.ensure_orchestrator_watch(&output.project); + Ok(ProjectDto::from(output)) } /// `close_project` — persist state and release resources for a project. @@ -105,6 +110,8 @@ pub async fn close_project( state: State<'_, AppState>, ) -> Result<(), ErrorDto> { let id = parse_project_id(&project_id)?; + // Stop tailing this project's `.ideai/requests/` tree (§14.3). + state.stop_orchestrator_watch(&id); state .close_project .execute(CloseProjectInput { diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index ee0a1da..1c430a5 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -5,8 +5,9 @@ //! `Arc` into the use cases (ARCHITECTURE §1.1, §10). The use cases //! are then exposed through `tauri::State` to the command handlers. +use std::collections::HashMap; use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use application::{ CloseProject, CloseTab, CloseTerminal, ConfigureProfiles, CreateAgentFromScratch, @@ -15,7 +16,8 @@ use application::{ GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage, HealthUseCase, LaunchAgent, ListAgents, ListLayouts, ListProfiles, ListProjects, ListSkills, ListTemplates, LoadLayout, MoveTabToNewWindow, MutateLayout, OpenProject, OpenTerminal, - ReadAgentContext, ReferenceProfiles, RenameLayout, ResizeTerminal, SaveProfile, SetActiveLayout, + OrchestratorService, ReadAgentContext, ReferenceProfiles, RenameLayout, ResizeTerminal, + SaveProfile, SetActiveLayout, AssignSkillToAgent, CreateSkill, DeleteSkill, UnassignSkillFromAgent, UpdateSkill, SyncAgentWithTemplate, TerminalSessions, UpdateAgentContext, UpdateTemplate, WriteToTerminal, }; @@ -23,11 +25,12 @@ use domain::ports::{ AgentContextStore, AgentRuntime, Clock, EventBus, FileSystem, GitPort, IdGenerator, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, SkillStore, TemplateStore, }; +use domain::{DomainEvent, Project, ProjectId}; use infrastructure::{ - CliAgentRuntime, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, Git2Repository, - IdeaiContextStore, LocalFileSystem, LocalProcessSpawner, PortablePtyAdapter, SystemClock, - TokioBroadcastEventBus, UuidGenerator, + CliAgentRuntime, FsOrchestratorWatcher, FsProfileStore, FsProjectStore, FsSkillStore, + FsTemplateStore, Git2Repository, IdeaiContextStore, LocalFileSystem, LocalProcessSpawner, + OrchestratorWatchHandle, PortablePtyAdapter, SystemClock, TokioBroadcastEventBus, UuidGenerator, }; use crate::pty::PtyBridge; @@ -161,6 +164,15 @@ pub struct AppState { pub assign_skill: Arc, /// Unassign a skill from an agent. pub unassign_skill: Arc, + // --- Orchestrator (§14.3) --- + /// Dispatches validated orchestrator requests to the agent/skill use cases. + /// Shared by every per-project filesystem watcher. + pub orchestrator_service: Arc, + /// Live orchestrator request watchers, keyed by project. One watcher per open + /// project tails its `.ideai/requests/` tree; dropping the handle stops it. + /// Guarded by a `Mutex` so the open/close commands can register/unregister + /// watchers concurrently. + pub orchestrator_watchers: Mutex>, } impl AppState { @@ -409,6 +421,22 @@ impl AppState { Arc::clone(&events_port), )); + // --- Orchestrator service (§14.3) --- + // Dispatches file-based orchestrator requests through the *same* use cases + // the UI drives (IdeA stays the single source of truth for the agent/skill + // lifecycle). The per-project watcher that feeds it is started lazily when + // a project is opened (see `ensure_orchestrator_watch`). + let orchestrator_service = Arc::new(OrchestratorService::new( + Arc::clone(&create_agent), + Arc::clone(&launch_agent), + Arc::clone(&list_agents), + Arc::clone(&close_terminal), + Arc::clone(&update_agent_context), + Arc::clone(&create_skill), + Arc::clone(&profile_store_port), + Arc::clone(&terminal_sessions), + )); + // --- Windows (L10) --- let move_tab = Arc::new(MoveTabToNewWindow::new( Arc::clone(&store_port), @@ -473,7 +501,50 @@ impl AppState { delete_skill, assign_skill, unassign_skill, + orchestrator_service, + orchestrator_watchers: Mutex::new(HashMap::new()), move_tab, } } + + /// Starts a [`FsOrchestratorWatcher`] for `project` if one is not already + /// running for it (idempotent). The watcher tails the project's + /// `.ideai/requests/` tree and dispatches each request through the shared + /// [`OrchestratorService`]; processed-request events are republished on the + /// domain event bus so the relay surfaces them to the frontend. + /// + /// Called from the `open_project` / `create_project` commands. Must run inside + /// the Tokio runtime (the watcher spawns a background task) — Tauri async + /// commands satisfy this. + pub fn ensure_orchestrator_watch(&self, project: &Project) { + let mut watchers = self + .orchestrator_watchers + .lock() + .expect("orchestrator watcher registry poisoned"); + if watchers.contains_key(&project.id) { + return; + } + let bus = Arc::clone(&self.event_bus); + let events: Arc = + Arc::new(move |event| bus.publish(event)); + let handle = FsOrchestratorWatcher::start( + project.clone(), + Arc::clone(&self.orchestrator_service), + events, + ); + watchers.insert(project.id, handle); + } + + /// Stops and removes the orchestrator watcher for `project_id`, if any. + /// Called from `close_project` so a closed project stops consuming requests. + pub fn stop_orchestrator_watch(&self, project_id: &ProjectId) { + if let Some(handle) = self + .orchestrator_watchers + .lock() + .expect("orchestrator watcher registry poisoned") + .remove(project_id) + { + handle.stop(); + } + } } diff --git a/crates/app-tauri/tests/orchestrator_wiring.rs b/crates/app-tauri/tests/orchestrator_wiring.rs new file mode 100644 index 0000000..7635d17 --- /dev/null +++ b/crates/app-tauri/tests/orchestrator_wiring.rs @@ -0,0 +1,100 @@ +//! Integration test for the orchestrator wiring in the composition root +//! (ARCHITECTURE §14.3). +//! +//! These tests prove that [`AppState`] actually *starts and stops* per-project +//! orchestrator watchers — the gap that previously left the whole §14.3 feature +//! dormant (the `OrchestratorService`/watcher existed but were never constructed +//! at runtime). The per-file request→dispatch→response behaviour is covered by +//! the infrastructure watcher tests; here we assert the lifecycle the open/close +//! commands rely on: registration is idempotent, projects are isolated, and +//! stopping unregisters. + +use std::path::PathBuf; + +use app_tauri_lib::state::AppState; +use domain::ports::IdGenerator; +use domain::project::{Project, ProjectPath}; +use domain::remote::RemoteRef; +use domain::ProjectId; +use infrastructure::UuidGenerator; + +/// A unique, absolute temp path (never written to at build time — the stores are +/// lazy — so it need not exist). +fn temp_path(tag: &str) -> PathBuf { + let ids = UuidGenerator::new(); + std::env::temp_dir().join(format!("idea-orch-test-{tag}-{}", ids.new_uuid())) +} + +/// Builds a domain [`Project`] rooted at a fresh temp path. +fn make_project() -> Project { + let ids = UuidGenerator::new(); + let root = temp_path("root"); + Project::new( + ProjectId::from_uuid(ids.new_uuid()), + "demo", + ProjectPath::new(root.to_string_lossy().into_owned()).unwrap(), + RemoteRef::local(), + 1_700_000_000_000, + ) + .unwrap() +} + +fn watcher_count(state: &AppState) -> usize { + state.orchestrator_watchers.lock().unwrap().len() +} + +fn has_watcher(state: &AppState, id: &ProjectId) -> bool { + state.orchestrator_watchers.lock().unwrap().contains_key(id) +} + +#[tokio::test] +async fn ensure_watch_registers_a_watcher_and_is_idempotent() { + let state = AppState::build(temp_path("appdata")); + let project = make_project(); + + assert_eq!(watcher_count(&state), 0, "no watcher before open"); + + state.ensure_orchestrator_watch(&project); + assert!(has_watcher(&state, &project.id)); + assert_eq!(watcher_count(&state), 1); + + // Opening the same project again must not spawn a second watcher. + state.ensure_orchestrator_watch(&project); + assert_eq!(watcher_count(&state), 1, "ensure is idempotent per project"); +} + +#[tokio::test] +async fn stop_watch_unregisters_the_watcher() { + let state = AppState::build(temp_path("appdata")); + let project = make_project(); + + state.ensure_orchestrator_watch(&project); + assert!(has_watcher(&state, &project.id)); + + state.stop_orchestrator_watch(&project.id); + assert!(!has_watcher(&state, &project.id), "watcher removed on close"); + assert_eq!(watcher_count(&state), 0); + + // Stopping an unknown project is a no-op (does not panic). + state.stop_orchestrator_watch(&project.id); +} + +#[tokio::test] +async fn watchers_are_isolated_per_project() { + let state = AppState::build(temp_path("appdata")); + let a = make_project(); + let b = make_project(); + + state.ensure_orchestrator_watch(&a); + state.ensure_orchestrator_watch(&b); + + assert_eq!(watcher_count(&state), 2); + assert!(has_watcher(&state, &a.id)); + assert!(has_watcher(&state, &b.id)); + + // Closing one leaves the other running. + state.stop_orchestrator_watch(&a.id); + assert!(!has_watcher(&state, &a.id)); + assert!(has_watcher(&state, &b.id)); + assert_eq!(watcher_count(&state), 1); +} diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index c73a129..6326e30 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -23,6 +23,7 @@ use crate::agent::{ ListAgentsInput, UpdateAgentContext, UpdateAgentContextInput, }; use crate::error::AppError; +use crate::skill::{CreateSkill, CreateSkillInput}; use crate::terminal::{CloseTerminal, CloseTerminalInput, TerminalSessions}; /// Default terminal geometry for an orchestrator-launched agent cell. The UI @@ -39,6 +40,7 @@ pub struct OrchestratorService { list_agents: Arc, close_terminal: Arc, update_context: Arc, + create_skill: Arc, profiles: Arc, sessions: Arc, } @@ -61,6 +63,7 @@ impl OrchestratorService { list_agents: Arc, close_terminal: Arc, update_context: Arc, + create_skill: Arc, profiles: Arc, sessions: Arc, ) -> Self { @@ -70,6 +73,7 @@ impl OrchestratorService { list_agents, close_terminal, update_context, + create_skill, profiles, sessions, } @@ -96,6 +100,11 @@ impl OrchestratorService { OrchestratorCommand::UpdateAgentContext { name, context } => { self.update_agent_context(project, name, context).await } + OrchestratorCommand::CreateSkill { + name, + content, + scope, + } => self.create_skill(project, name, content, scope).await, } } @@ -193,6 +202,32 @@ impl OrchestratorService { }) } + /// `create_skill`: create a reusable skill in the requested scope — the same + /// path the UI's "New skill" action takes. For [`SkillScope::Project`] the + /// skill lands under `/.ideai/skills/`; for [`SkillScope::Global`] the + /// project root is ignored by the store. + async fn create_skill( + &self, + project: &Project, + name: String, + content: String, + scope: domain::SkillScope, + ) -> Result { + let created = self + .create_skill + .execute(CreateSkillInput { + name: name.clone(), + content, + scope, + project_root: project.root.clone(), + }) + .await?; + + Ok(OrchestratorOutcome { + detail: format!("created skill {} ({:?})", created.skill.name, scope), + }) + } + /// Finds an agent id by display name (case-insensitive) in the project manifest. async fn find_agent_id_by_name( &self, diff --git a/crates/application/tests/orchestrator_service.rs b/crates/application/tests/orchestrator_service.rs index d0bc0d4..8794cbc 100644 --- a/crates/application/tests/orchestrator_service.rs +++ b/crates/application/tests/orchestrator_service.rs @@ -33,8 +33,8 @@ use domain::{OrchestratorCommand, OrchestratorRequest, PtySize, SessionId}; use uuid::Uuid; use application::{ - CloseTerminal, CreateAgentFromScratch, LaunchAgent, ListAgents, OrchestratorService, - TerminalSessions, UpdateAgentContext, + CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents, + OrchestratorService, TerminalSessions, UpdateAgentContext, }; // --------------------------------------------------------------------------- @@ -183,6 +183,44 @@ impl SkillStore for FakeSkills { } } +/// A [`SkillStore`] that records every `save` so a `create_skill` dispatch can be +/// asserted against the persisted skill (name + scope). +#[derive(Clone, Default)] +struct RecordingSkills(Arc>>); + +#[async_trait] +impl SkillStore for RecordingSkills { + async fn list(&self, _scope: SkillScope, _root: &ProjectPath) -> Result, StoreError> { + Ok(self.0.lock().unwrap().clone()) + } + async fn get( + &self, + _scope: SkillScope, + _root: &ProjectPath, + id: SkillId, + ) -> Result { + self.0 + .lock() + .unwrap() + .iter() + .find(|s| s.id == id) + .cloned() + .ok_or(StoreError::NotFound) + } + async fn save(&self, skill: &Skill, _root: &ProjectPath) -> Result<(), StoreError> { + self.0.lock().unwrap().push(skill.clone()); + Ok(()) + } + async fn delete( + &self, + _scope: SkillScope, + _root: &ProjectPath, + _id: SkillId, + ) -> Result<(), StoreError> { + Ok(()) + } +} + struct FakeRuntime; impl AgentRuntime for FakeRuntime { fn detect(&self, _profile: &AgentProfile) -> Result { @@ -349,6 +387,7 @@ struct Fixture { pty: FakePty, bus: SpyBus, sessions: Arc, + skills: RecordingSkills, } fn fixture(contexts: FakeContexts) -> Fixture { @@ -378,6 +417,11 @@ fn fixture(contexts: FakeContexts) -> Fixture { Arc::clone(&sessions), )); let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts.clone()))); + let skills = RecordingSkills::default(); + let create_skill = Arc::new(CreateSkill::new( + Arc::new(skills.clone()) as Arc, + Arc::new(SeqIds::new()), + )); let service = OrchestratorService::new( create, @@ -385,6 +429,7 @@ fn fixture(contexts: FakeContexts) -> Fixture { list, close, update, + create_skill, Arc::clone(&profiles) as Arc, Arc::clone(&sessions), ); @@ -395,6 +440,7 @@ fn fixture(contexts: FakeContexts) -> Fixture { pty, bus, sessions, + skills, } } @@ -541,3 +587,43 @@ async fn spawn_with_unknown_profile_is_not_found_and_does_not_create() { assert!(fx.contexts.manifest().entries.is_empty()); assert!(fx.sessions.session(&sid(777)).is_none()); } + +#[tokio::test] +async fn create_skill_persists_a_project_scoped_skill_by_default() { + let fx = fixture(FakeContexts::new()); + + let out = fx + .service + .dispatch( + &project(), + cmd(r##"{ "action":"create_skill", "name":"deploy", "context":"# Deploy" }"##), + ) + .await + .expect("create_skill ok"); + + assert!(out.detail.contains("deploy")); + + let saved = fx.skills.0.lock().unwrap(); + assert_eq!(saved.len(), 1); + assert_eq!(saved[0].name, "deploy"); + assert_eq!(saved[0].scope, SkillScope::Project); +} + +#[tokio::test] +async fn create_skill_honours_global_scope() { + let fx = fixture(FakeContexts::new()); + + fx.service + .dispatch( + &project(), + cmd( + r##"{ "action":"create_skill", "name":"shared", "context":"# Shared", "scope":"global" }"##, + ), + ) + .await + .expect("create_skill ok"); + + let saved = fx.skills.0.lock().unwrap(); + assert_eq!(saved.len(), 1); + assert_eq!(saved[0].scope, SkillScope::Global); +} diff --git a/crates/domain/src/orchestrator.rs b/crates/domain/src/orchestrator.rs index 6df293e..bc9303c 100644 --- a/crates/domain/src/orchestrator.rs +++ b/crates/domain/src/orchestrator.rs @@ -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, /// 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, + /// 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, } /// 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 { + 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 { 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" }"#); diff --git a/crates/infrastructure/tests/orchestrator_watcher.rs b/crates/infrastructure/tests/orchestrator_watcher.rs index 5d0e3a9..7eff560 100644 --- a/crates/infrastructure/tests/orchestrator_watcher.rs +++ b/crates/infrastructure/tests/orchestrator_watcher.rs @@ -32,8 +32,8 @@ use domain::{PtySize, SessionId}; use uuid::Uuid; use application::{ - CloseTerminal, CreateAgentFromScratch, LaunchAgent, ListAgents, OrchestratorService, - TerminalSessions, UpdateAgentContext, + CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents, + OrchestratorService, TerminalSessions, UpdateAgentContext, }; use infrastructure::{process_request_file, OrchestratorResponse}; @@ -305,12 +305,17 @@ fn build_service(contexts: FakeContexts) -> Arc { let list = Arc::new(ListAgents::new(Arc::new(contexts.clone()))); let close = Arc::new(CloseTerminal::new(Arc::new(FakePty), Arc::clone(&sessions))); let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts))); + let create_skill = Arc::new(CreateSkill::new( + Arc::new(FakeSkills) as Arc, + Arc::new(SeqIds(Mutex::new(1))), + )); Arc::new(OrchestratorService::new( create, launch, list, close, update, + create_skill, Arc::clone(&profiles) as Arc, sessions, )) @@ -350,6 +355,27 @@ async fn valid_spawn_request_succeeds_and_is_consumed() { assert_eq!(contexts.entries()[0].name, "dev-backend"); } +#[tokio::test] +async fn valid_create_skill_request_succeeds_and_is_consumed() { + let tmp = TempDir::new(); + let service = build_service(FakeContexts::new()); + + let req = tmp.0.join("skill-req.json"); + std::fs::write( + &req, + br##"{ "action": "create_skill", "name": "deploy", "context": "# Deploy steps" }"##, + ) + .unwrap(); + + let response = process_request_file(&req, &project(), &service).await; + + assert!(response.ok, "expected ok, got {response:?}"); + assert_eq!(response.action.as_deref(), Some("create_skill")); + // Request consumed; a response sibling written and marked ok. + assert!(!req.exists(), "request file must be removed"); + assert!(read_response(&req).ok); +} + #[tokio::test] async fn invalid_json_request_yields_error_response() { let tmp = TempDir::new(); diff --git a/frontend/src/features/agents/agents.test.tsx b/frontend/src/features/agents/agents.test.tsx index 7e8e212..dfc1ee3 100644 --- a/frontend/src/features/agents/agents.test.tsx +++ b/frontend/src/features/agents/agents.test.tsx @@ -19,7 +19,12 @@ import { fireEvent, } from "@testing-library/react"; -import { MockAgentGateway, MockProfileGateway, MockTemplateGateway } from "@/adapters/mock"; +import { + MockAgentGateway, + MockProfileGateway, + MockSystemGateway, + MockTemplateGateway, +} from "@/adapters/mock"; import type { Gateways } from "@/ports"; import { DIProvider } from "@/app/di"; import { AgentsPanel } from "./AgentsPanel"; @@ -384,3 +389,45 @@ describe("MockAgentGateway (unit)", () => { expect(listB).toHaveLength(0); }); }); + +describe("AgentsPanel live refresh on domain events", () => { + it("refreshes the list when an `agentLaunched` event fires (out-of-band creation)", async () => { + const agent = new MockAgentGateway(); + const profile = new MockProfileGateway(); + const system = new MockSystemGateway(); + const template = new MockTemplateGateway(agent); + const gateways = { agent, profile, system, template } as unknown as Gateways; + + render( + + + , + ); + + // Initially empty. + await waitFor(() => expect(screen.getByText("No agents yet.")).toBeTruthy()); + + // Simulate the orchestrator creating an agent directly in the store + // (bypassing the UI's createAgent path), then emitting `agentLaunched`. + const created = await agent.createAgent(PROJECT_ID, { + name: "Orchestrated", + profileId: "p1", + }); + system.emit({ + type: "agentLaunched", + agentId: created.id, + sessionId: "sess-1", + }); + + // The panel must reflect the new agent without any manual reload. + expect(await screen.findByText("Orchestrated")).toBeTruthy(); + }); + + it("does not crash when the system gateway is absent", async () => { + // The existing renderPanel helper injects no `system` gateway; the + // subscription effect must no-op rather than throw. + renderPanel(); + await waitForIdle(); + expect(screen.getByText("No agents yet.")).toBeTruthy(); + }); +}); diff --git a/frontend/src/features/agents/useAgents.ts b/frontend/src/features/agents/useAgents.ts index 22ed2c1..9cbab5f 100644 --- a/frontend/src/features/agents/useAgents.ts +++ b/frontend/src/features/agents/useAgents.ts @@ -64,7 +64,7 @@ function describe(e: unknown): string { } export function useAgents(projectId: string): AgentsViewModel { - const { agent, profile } = useGateways(); + const { agent, profile, system } = useGateways(); const [agents, setAgents] = useState([]); const [selectedAgentId, setSelectedAgentId] = useState(null); @@ -95,6 +95,32 @@ export function useAgents(projectId: string): AgentsViewModel { void refresh(); }, [refresh]); + // Live refresh: the agents list is otherwise only reloaded on mount or after a + // UI-driven CRUD. An agent created/launched/stopped *out of band* — most + // notably by the orchestrator (`spawn_agent`, ARCHITECTURE §14.3) when an AI + // delegates agent creation to IdeA — emits `agentLaunched` / `agentExited`. + // Subscribing here makes the tab reflect those changes without a manual reload. + // `system` may be absent in unit tests that inject a partial gateway set; guard. + useEffect(() => { + if (!system) return; + let unsubscribe: (() => void) | undefined; + let cancelled = false; + void system + .onDomainEvent((event) => { + if (event.type === "agentLaunched" || event.type === "agentExited") { + void refresh(); + } + }) + .then((un) => { + if (cancelled) un(); + else unsubscribe = un; + }); + return () => { + cancelled = true; + unsubscribe?.(); + }; + }, [system, refresh]); + const createAgent = useCallback( async (name: string, profileId: string, initialContent?: string) => { setBusy(true); diff --git a/frontend/src/features/layout/LayoutGrid.test.tsx b/frontend/src/features/layout/LayoutGrid.test.tsx index d742bf9..368673b 100644 --- a/frontend/src/features/layout/LayoutGrid.test.tsx +++ b/frontend/src/features/layout/LayoutGrid.test.tsx @@ -9,7 +9,7 @@ * bailed. */ import { describe, it, expect } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; import type { Gateways } from "@/ports"; import { MockLayoutGateway, MockTerminalGateway } from "@/adapters/mock"; @@ -90,6 +90,72 @@ describe("LayoutGrid (with MockLayoutGateway)", () => { }); }); + it("closing a cell removes THAT cell and keeps its sibling (closes the right terminal)", async () => { + const layout = new MockLayoutGateway(); + const initial = await layout.loadLayout("p1"); + const a = leaves(initial)[0].id; + // Split A → container c with children [A (index 0), b (index 1)]. + await layout.mutateLayout("p1", { + type: "split", + target: a, + direction: "row", + newLeaf: "b", + container: "c", + }); + + renderGrid(layout); + await waitFor(() => + expect(screen.getAllByTestId("layout-leaf")).toHaveLength(2), + ); + + // Click the close button on cell `b` — `b` must disappear, `a` must remain. + fireEvent.click(screen.getByLabelText("close b")); + + await waitFor(() => + expect(screen.getAllByTestId("layout-leaf")).toHaveLength(1), + ); + expect( + screen.getByTestId("layout-leaf").getAttribute("data-node-id"), + ).toBe(a); + }); + + it("closing the other cell keeps the opposite sibling", async () => { + const layout = new MockLayoutGateway(); + const initial = await layout.loadLayout("p1"); + const a = leaves(initial)[0].id; + await layout.mutateLayout("p1", { + type: "split", + target: a, + direction: "row", + newLeaf: "b", + container: "c", + }); + + renderGrid(layout); + await waitFor(() => + expect(screen.getAllByTestId("layout-leaf")).toHaveLength(2), + ); + + // Closing `a` (index 0) must keep `b` (index 1). + fireEvent.click(screen.getByLabelText(`close ${a}`)); + + await waitFor(() => + expect(screen.getAllByTestId("layout-leaf")).toHaveLength(1), + ); + expect( + screen.getByTestId("layout-leaf").getAttribute("data-node-id"), + ).toBe("b"); + }); + + it("the sole root cell has no close button (cannot be closed)", async () => { + const layout = new MockLayoutGateway(); + renderGrid(layout); + await waitFor(() => + expect(screen.getAllByTestId("layout-leaf")).toHaveLength(1), + ); + expect(screen.queryByLabelText(/^close /)).toBeNull(); + }); + it("renders the loading/empty container when the layout gateway is absent", () => { // A DI subset without the layout gateway → useLayout is defensive (null). const gateways = { terminal: new MockTerminalGateway() } as unknown as Gateways; diff --git a/frontend/src/features/layout/LayoutGrid.tsx b/frontend/src/features/layout/LayoutGrid.tsx index a3126d3..4d2270c 100644 --- a/frontend/src/features/layout/LayoutGrid.tsx +++ b/frontend/src/features/layout/LayoutGrid.tsx @@ -113,7 +113,14 @@ interface LeafViewProps { } function LeafView({ id, session, agent, cwd, vm, parentSplit, projectId }: LeafViewProps) { - const canMerge = parentSplit !== null && parentSplit.siblings > 1; + // A cell can be closed only when it lives inside a (binary) split: closing it + // collapses the parent split, keeping the *sibling*. Splits are always binary + // in this model (a split wraps a leaf into a 2-child container), so the kept + // child is simply the other index. Guarding on `siblings === 2` keeps the + // operation correct (merge keeps exactly one child) and avoids ever dropping + // the wrong terminal. The root cell (no parent split) cannot be closed. + const canClose = parentSplit !== null && parentSplit.siblings === 2; + const siblingIndex = parentSplit ? (parentSplit.index === 0 ? 1 : 0) : 0; const { agent: agentGateway } = useGateways(); // Load the project's agents for the dropdown. @@ -159,7 +166,10 @@ function LeafView({ id, session, agent, cwd, vm, parentSplit, projectId }: LeafV style={{ position: "absolute", top: 2, - right: 2, + // Clear the xterm viewport scrollbar (rendered flush against the right + // edge, ~15px wide). Without this offset the right-most control — the + // close button — sits *behind* the scrollbar and is hard to click. + right: 16, zIndex: 2, display: "flex", gap: 2, @@ -208,16 +218,14 @@ function LeafView({ id, session, agent, cwd, vm, parentSplit, projectId }: LeafV > ⬍ - {canMerge && ( + {canClose && ( )}