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:
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -68,12 +68,15 @@ pub async fn create_project(
|
||||
request: CreateProjectRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ProjectDto, ErrorDto> {
|
||||
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<ProjectDto, ErrorDto> {
|
||||
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 {
|
||||
|
||||
@ -5,8 +5,9 @@
|
||||
//! `Arc<dyn Port>` into the use cases (ARCHITECTURE §1.1, §10). The use cases
|
||||
//! are then exposed through `tauri::State<AppState>` 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<AssignSkillToAgent>,
|
||||
/// Unassign a skill from an agent.
|
||||
pub unassign_skill: Arc<UnassignSkillFromAgent>,
|
||||
// --- Orchestrator (§14.3) ---
|
||||
/// Dispatches validated orchestrator requests to the agent/skill use cases.
|
||||
/// Shared by every per-project filesystem watcher.
|
||||
pub orchestrator_service: Arc<OrchestratorService>,
|
||||
/// 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<HashMap<ProjectId, OrchestratorWatchHandle>>,
|
||||
}
|
||||
|
||||
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<dyn Fn(DomainEvent) + Send + Sync> =
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
100
crates/app-tauri/tests/orchestrator_wiring.rs
Normal file
100
crates/app-tauri/tests/orchestrator_wiring.rs
Normal file
@ -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);
|
||||
}
|
||||
@ -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<ListAgents>,
|
||||
close_terminal: Arc<CloseTerminal>,
|
||||
update_context: Arc<UpdateAgentContext>,
|
||||
create_skill: Arc<CreateSkill>,
|
||||
profiles: Arc<dyn ProfileStore>,
|
||||
sessions: Arc<TerminalSessions>,
|
||||
}
|
||||
@ -61,6 +63,7 @@ impl OrchestratorService {
|
||||
list_agents: Arc<ListAgents>,
|
||||
close_terminal: Arc<CloseTerminal>,
|
||||
update_context: Arc<UpdateAgentContext>,
|
||||
create_skill: Arc<CreateSkill>,
|
||||
profiles: Arc<dyn ProfileStore>,
|
||||
sessions: Arc<TerminalSessions>,
|
||||
) -> 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 `<root>/.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<OrchestratorOutcome, AppError> {
|
||||
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,
|
||||
|
||||
@ -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<Mutex<Vec<Skill>>>);
|
||||
|
||||
#[async_trait]
|
||||
impl SkillStore for RecordingSkills {
|
||||
async fn list(&self, _scope: SkillScope, _root: &ProjectPath) -> Result<Vec<Skill>, StoreError> {
|
||||
Ok(self.0.lock().unwrap().clone())
|
||||
}
|
||||
async fn get(
|
||||
&self,
|
||||
_scope: SkillScope,
|
||||
_root: &ProjectPath,
|
||||
id: SkillId,
|
||||
) -> Result<Skill, StoreError> {
|
||||
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<bool, RuntimeError> {
|
||||
@ -349,6 +387,7 @@ struct Fixture {
|
||||
pty: FakePty,
|
||||
bus: SpyBus,
|
||||
sessions: Arc<TerminalSessions>,
|
||||
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<dyn SkillStore>,
|
||||
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<dyn ProfileStore>,
|
||||
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);
|
||||
}
|
||||
|
||||
@ -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" }"#);
|
||||
|
||||
@ -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<OrchestratorService> {
|
||||
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<dyn SkillStore>,
|
||||
Arc::new(SeqIds(Mutex::new(1))),
|
||||
));
|
||||
Arc::new(OrchestratorService::new(
|
||||
create,
|
||||
launch,
|
||||
list,
|
||||
close,
|
||||
update,
|
||||
create_skill,
|
||||
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
|
||||
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();
|
||||
|
||||
@ -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(
|
||||
<DIProvider gateways={gateways}>
|
||||
<AgentsPanel projectId={PROJECT_ID} projectRoot="/home/me/proj" />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<Agent[]>([]);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
>
|
||||
⬍
|
||||
</button>
|
||||
{canMerge && (
|
||||
{canClose && (
|
||||
<button
|
||||
type="button"
|
||||
title="Merge: keep this cell, drop its siblings"
|
||||
aria-label={`merge ${id}`}
|
||||
onClick={() =>
|
||||
void vm.merge(parentSplit.container, parentSplit.index)
|
||||
}
|
||||
title="Close this terminal"
|
||||
aria-label={`close ${id}`}
|
||||
onClick={() => void vm.merge(parentSplit.container, siblingIndex)}
|
||||
>
|
||||
⤬
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user