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

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