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:
@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user