feat(domain,app,infra): injection « État du projet » + outils MCP workstate (LS4)

Clôt le cold-start de coordination du programme live-state :
- domain : WorkStatus::parse/label, commands ReadWorkState/SetWorkState
  (request status/intent/progress/lastDelegation), erreur UnknownWorkStatus + validate.
- application : section bornée « # État du projet » injectée au lancement
  (port LiveStateLeanProvider, InjectedLiveRow, LIVE_STATE_INJECT_MAX, resolve_live_state) ;
  LiveStateReadProvider + read_workstate/set_workstate câblés au dispatch.
- infrastructure : 2 ToolDef idea_workstate_read / idea_workstate_set (mapping,
  tool_returns_reply), compteur d'outils MCP 12→14.
- app-tauri : providers câblés, garde du nombre d'outils 12→14.
- tests (QA, verts) : domain/orchestrator, application lifecycle + orchestrator_service,
  infrastructure mcp_server, app-tauri state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 08:09:57 +02:00
parent 9d46e6cd21
commit 533a9c57f3
12 changed files with 1503 additions and 68 deletions

View File

@ -18,21 +18,22 @@ use application::{
CreateMemory, CreateProject, CreateSkill, CreateTemplate, DeleteAgent, DeleteEmbedderProfile,
DeleteLayout, DeleteMemory, DeleteProfile, DeleteSkill, DeleteTemplate,
DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion,
FirstRunState, GetMemory, GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout,
GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn,
HealthUseCase, InspectConversation, LaunchAgent, LaunchAgentInput, ListAgents, ListAgentsInput,
ListEmbedderProfiles, ListLayouts, ListMemories, ListProfiles, ListProjects,
ListResumableAgents, ListSkills, ListTemplates, LiveAgentRegistry, LiveSessions,
LiveStateProvider, LoadLayout, McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView,
OpenProject, OpenTerminal, OrchestratorService, PermissionProjectorRegistry, ProposeContext,
ReadAgentContext, ReadContext, ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadSkill,
RecallMemory, ReconcileLayouts, RecordTurn, RecordTurnProvider, ReferenceProfiles,
RenameLayout, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks, SaveEmbedderProfile,
SaveProfile, SessionLimitService, SetActiveLayout, SnapshotRunningAgents, StopLiveAgent,
StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions,
UnassignSkillFromAgent, UpdateAgentContext, UpdateAgentPermissions, UpdateLiveState,
UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate,
WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
FirstRunState, GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectWorkState,
GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus,
GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation, LaunchAgent,
LaunchAgentInput, ListAgents, ListAgentsInput, ListEmbedderProfiles, ListLayouts, ListMemories,
ListProfiles, ListProjects, ListResumableAgents, ListSkills, ListTemplates, LiveAgentRegistry,
LiveSessions, LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout,
McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
OrchestratorService, PermissionProjectorRegistry, ProposeContext, ReadAgentContext,
ReadContext, ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory,
ReconcileLayouts, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameLayout,
ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks, SaveEmbedderProfile, SaveProfile,
SessionLimitService, SetActiveLayout, SnapshotRunningAgents, StopLiveAgent, StructuredSessions,
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent,
UpdateAgentContext, UpdateAgentPermissions, UpdateLiveState, UpdateMemory,
UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate, WriteMemory,
WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
};
use domain::ports::{
AgentContextStore, AgentRuntime, AgentSessionFactory, Clock, Embedder, EmbedderEnvInspector,
@ -153,6 +154,44 @@ impl LiveStateProvider for AppLiveStateProvider {
}
}
/// Implémente [`LiveStateLeanProvider`] (injection au lancement, lot LS4) **et**
/// [`LiveStateReadProvider`] (outil `idea_workstate_read`) en matérialisant un
/// [`GetLiveStateLean`] dont le [`FsLiveStateStore`] cible le **project root** courant.
///
/// Même raison d'être que [`AppLiveStateProvider`] (côté écriture) : [`LaunchAgent`] et
/// l'[`OrchestratorService`] sont uniques pour tous les projets, alors que le live-state
/// est **par project root** (`<root>/.ideai/live-state.json`) et le store fixe sa racine
/// à la construction. On construit donc un `GetLiveStateLean` frais par appel, ciblant le
/// bon dossier (prune-on-read + snapshot lean). Porte l'horloge (port [`Clock`]).
struct AppLiveStateLeanProvider {
clock: Arc<dyn Clock>,
}
impl AppLiveStateLeanProvider {
fn getter(&self, root: &domain::project::ProjectPath) -> Arc<GetLiveStateLean> {
let store = Arc::new(FsLiveStateStore::new(root));
Arc::new(GetLiveStateLean::new(store, Arc::clone(&self.clock)))
}
}
impl LiveStateLeanProvider for AppLiveStateLeanProvider {
fn live_state_lean_for(
&self,
root: &domain::project::ProjectPath,
) -> Option<Arc<GetLiveStateLean>> {
Some(self.getter(root))
}
}
impl LiveStateReadProvider for AppLiveStateLeanProvider {
fn live_state_lean_for(
&self,
root: &domain::project::ProjectPath,
) -> Option<Arc<GetLiveStateLean>> {
Some(self.getter(root))
}
}
/// Implémente [`ProviderSessionProvider`](application::ProviderSessionProvider) (lot
/// P8b) en matérialisant un [`FsProviderSessionStore`] ciblant le **project root** du
/// lancement en cours.
@ -856,7 +895,13 @@ impl AppState {
// Projection des permissions au (re)lancement (lot LP3-5) : avec le
// permission store câblé ci-dessus, `resolve` a une source ⇒ le projecteur
// du profil matérialise la config de permission de la CLI dans le run dir.
.with_permission_projectors(Arc::clone(&permission_projectors)),
.with_permission_projectors(Arc::clone(&permission_projectors))
// Aperçu live-state des autres agents (lot LS4) : section `# État du projet`
// injectée au lancement. Best-effort strict : provider absent / erreur /
// parse ⇒ section omise, jamais d'échec de lancement.
.with_live_state_lean(Arc::new(AppLiveStateLeanProvider {
clock: Arc::clone(&clock) as Arc<dyn Clock>,
}) as Arc<dyn LiveStateLeanProvider>),
);
// Hot-swap an agent's runtime profile (§15.1). Reuses the shared context/
@ -1227,7 +1272,13 @@ impl AppState {
// délégation.
.with_live_state(Arc::new(AppLiveStateProvider {
clock: Arc::clone(&clock) as Arc<dyn Clock>,
}) as Arc<dyn LiveStateProvider>),
}) as Arc<dyn LiveStateProvider>)
// Lecture du live-state (lot LS4) pour l'outil `idea_workstate_read` : snapshot
// lean (prune-on-read) enrichi du nom d'agent. Provider par root (le store fixe
// sa racine à la construction). Sans ça, l'outil renverrait « not configured ».
.with_live_state_read(Arc::new(AppLiveStateLeanProvider {
clock: Arc::clone(&clock) as Arc<dyn Clock>,
}) as Arc<dyn LiveStateReadProvider>),
// NB (régression corrigée) : on ne câble PAS `.with_structured(...)` ici.
// Décision produit lot B-2 (« Option 1 Terminal + MCP », cf. construction
// de `LaunchAgent` plus haut) : la fabrique structurée est décâblée, donc
@ -3083,6 +3134,9 @@ mod mcp_serve_peer_tests {
"idea_memory_write",
// Skill-awareness : lecture à la demande du corps d'un skill.
"idea_skill_read",
// Live-state (programme live-state, lot LS4).
"idea_workstate_read",
"idea_workstate_set",
] {
assert!(
names.contains(&expected),
@ -3091,8 +3145,8 @@ mod mcp_serve_peer_tests {
}
assert_eq!(
tools.len(),
12,
"exactly the twelve idea_* tools (7 base + 4 FileGuard C7 + idea_skill_read); got {names:?}"
14,
"exactly the fourteen idea_* tools (7 base + 4 FileGuard C7 + idea_skill_read + 2 live-state LS4); got {names:?}"
);
drop(client); // EOF ⇒ serve loop ends
@ -3529,6 +3583,133 @@ mod mcp_serve_peer_tests {
ConversationParty::agent(AgentId::from_uuid(Uuid::from_u128(n)))
}
// -----------------------------------------------------------------------
// LS4 — live-state round-trip through the app-tauri-wired service.
// -----------------------------------------------------------------------
/// In-memory [`LiveStateStore`] (keyed LWW + prune), no I/O — so the LS4 round-trip
/// exercises the real `UpdateLiveState`/`GetLiveStateLean` use cases behind the
/// service's write/read providers without touching the filesystem.
#[derive(Default)]
struct InMemLiveStore {
state: Mutex<domain::live_state::LiveState>,
}
#[async_trait]
impl domain::ports::LiveStateStore for InMemLiveStore {
async fn load(&self) -> Result<domain::live_state::LiveState, StoreError> {
Ok(self.state.lock().unwrap().clone())
}
async fn upsert(&self, entry: domain::live_state::LiveEntry) -> Result<(), StoreError> {
self.state.lock().unwrap().upsert(entry);
Ok(())
}
async fn prune(&self, now_ms: u64, ttl_ms: u64, max_n: usize) -> Result<(), StoreError> {
self.state.lock().unwrap().prune(now_ms, ttl_ms, max_n);
Ok(())
}
}
struct WsWriteProvider {
update: Arc<application::UpdateLiveState>,
}
impl application::LiveStateProvider for WsWriteProvider {
fn live_state_for(&self, _root: &ProjectPath) -> Option<Arc<application::UpdateLiveState>> {
Some(Arc::clone(&self.update))
}
}
struct WsReadProvider {
getter: Arc<application::GetLiveStateLean>,
}
impl application::LiveStateReadProvider for WsReadProvider {
fn live_state_lean_for(
&self,
_root: &ProjectPath,
) -> Option<Arc<application::GetLiveStateLean>> {
Some(Arc::clone(&self.getter))
}
}
/// Like [`build_service_with_guard`] but additively wires the LS4 write+read
/// providers over a SHARED in-memory store, exactly the prod builder's
/// `.with_live_state(...).with_live_state_read(...)`.
fn build_service_with_live_state(contexts: FakeContexts) -> Arc<OrchestratorService> {
let store = Arc::new(InMemLiveStore::default()) as Arc<dyn domain::ports::LiveStateStore>;
let clock = Arc::new(FixedClock) as Arc<dyn Clock>;
let write = Arc::new(WsWriteProvider {
update: Arc::new(application::UpdateLiveState::new(
Arc::clone(&store),
Arc::clone(&clock),
)),
});
let read = Arc::new(WsReadProvider {
getter: Arc::new(application::GetLiveStateLean::new(
Arc::clone(&store),
Arc::clone(&clock),
)),
});
let service = build_service(contexts);
let service = Arc::try_unwrap(service)
.map_err(|_| ())
.expect("freshly built service is uniquely owned")
.with_live_state(write as Arc<dyn application::LiveStateProvider>)
.with_live_state_read(read as Arc<dyn application::LiveStateReadProvider>);
Arc::new(service)
}
/// END-TO-END (lot LS4) — through the **app-tauri-wired** `OrchestratorService`,
/// `idea_workstate_set` then `idea_workstate_read` round-trips: the current agent's
/// row comes back with the declared status/intent and the **resolved display name**
/// (via `ListAgents`), and `progress` never surfaces.
#[tokio::test]
async fn wired_serves_workstate_set_then_read_round_trip() {
let proj = project();
let contexts = FakeContexts::new();
let agent = contexts.seed_agent("architect");
let service = build_service_with_live_state(contexts);
// set — keyed on the agent identity; a progress note is supplied (must not leak).
let ack = service
.dispatch(
&proj,
domain::OrchestratorCommand::SetWorkState {
agent,
status: domain::live_state::WorkStatus::Working,
intent: Some("ship LS4".to_owned()),
progress: Some("hidden note".to_owned()),
ticket: None,
last_delegation: None,
},
)
.await
.expect("set must succeed when live-state is wired");
assert!(ack.reply.is_none(), "set is ACK only: {ack:?}");
// read — over the SAME store, the architect's row comes back resolved.
let out = service
.dispatch(
&proj,
domain::OrchestratorCommand::ReadWorkState {
requester: ConversationParty::User,
},
)
.await
.expect("read must succeed when live-state is wired");
let json: Value = serde_json::from_str(out.reply.as_deref().expect("read reply")).unwrap();
let rows = json.as_array().expect("array");
assert_eq!(rows.len(), 1, "exactly the current agent's row: {json}");
assert_eq!(
rows[0]["agent"],
json!("architect"),
"name resolved: {json}"
);
assert_eq!(rows[0]["status"], json!("working"));
assert_eq!(rows[0]["intent"], json!("ship LS4"));
assert!(
rows[0].get("progress").is_none(),
"progress must never surface on read: {json}"
);
}
/// WIRING — with `.with_context_guard(...)`, `memory.write` then
/// `memory.read` round-trips the content instead of erroring "not
/// configured". Proves the four use cases reached the dispatch.