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