56 Commits

Author SHA1 Message Date
b05d04ab7a feat(permissions): LP4-0 — fondations domaine de l'enforcement OS (pur)
Fondations pures (zéro I/O, zéro dépendance landlock, aucun câblage
runtime — SpawnSpec.sandbox posé mais jamais lu ⇒ zéro régression) de la
voie « airtight » des permissions, complément de la voie projection LP3.

- domain/sandbox.rs : SandboxPlan/PathGrant/PathAccess (RO|RW|EXEC),
  SandboxContext, SandboxKind/Status/Error, port SandboxEnforcer, et la
  fonction pure compile_sandbox_plan(EffectivePermissions → plan OS).
- domain/permission.rs : render_permission_summary (bloc Markdown injecté
  plus tard ; mentionne explicitement fichiers OS-enforced vs commandes
  advisory).
- domain/ports.rs : SpawnSpec.sandbox: Option<SandboxPlan> (None ⇒ natif),
  propagé à tous les sites de construction.

Sémantique de compile_sandbox_plan :
- Invariant produit : eff == None ⇒ None (rien posé ⇒ CLI 100 % native).
- Borne Landlock : seules les capabilities fichier produisent des grants
  (Read→RO, Write/Delete→RW) ; ExecuteBash reste advisory (non verrouillable
  par chemin).
- Deny-wins PAR CLASSE D'ACCÈS (RO/RW), fail-closed intra-classe : un Deny
  ne ferme que les grants de sa propre classe (un Deny Write n'ampute pas un
  Allow Read). Choix retenu pour maximiser l'autonomie des agents : on
  respecte exactement la politique pré-renseignée sans sur-restreindre, donc
  moins de blocages qui forceraient l'agent à redemander l'utilisateur.
- Globs réduits à leur préfixe statique ; grant abandonné si une barrière de
  même classe chevauche (égal/ancêtre/descendant) — sous-approximation
  conservatrice (un sandbox additif ne peut pas carver un deny sous-arbre).

Tests : 16 tests purs sur sandbox + 3 sur render_permission_summary,
cargo test --workspace 100 % vert, 0 ignored.

Reste LP4 : LP4-1 adapter LandlockSandbox + pre_exec PTY (fail-open+warning
sauf posture Deny), LP4-2 câblage application, LP4-3 composition root.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 21:37:34 +02:00
27597eb64e feat(permissions): voie projection CLI (LP0→LP3) + checkpoint Codex/input
Jalon vert regroupant deux chantiers entrelacés dans le working tree,
indissociables au niveau fichier mais tous deux verts (cargo test
--workspace + tests frontend permissions au vert).

Permissions — voie « projection CLI » (advisory), complète :
- LP0 domaine pur : modèle PermissionSet/EffectivePermissions, resolve
  deny-wins + postures Allow<Ask<Deny (crates/domain/src/permission.rs).
- LP1 store : FsPermissionStore (.ideai/permissions.json).
- LP2 use cases : Get/Update project, Update agent override, Resolve.
- LP3 projecteurs Claude/Codex (settings.local.json / config.toml),
  câblage launch-path + PermissionProjectorRegistry, nettoyage des
  fichiers Replace orphelins au swap de profil (LP3-4), composition root
  + commandes Tauri, UI PermissionsPanel (projet + override agent).
- ports.rs : PermissionStore + FileSystem::remove_file (cleanup au swap).

Reste ouvert (hors scope, marqué dans le code) : LP4 enforcement OS
airtight (Landlock fichiers) + résumé de permissions injecté.

Inclut aussi le chantier Codex/input/sessions structurées en cours
(McpConfigStrategy, StructuredAdapter, gestion d'input) partageant les
mêmes fichiers (lifecycle.rs, commands.rs, dto.rs, state.rs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:39:18 +02:00
46492506e1 fix(orchestrator): garde RAII contre une cible coincée Busy après une délégation interrompue
Une délégation `idea_ask_agent` interrompue/annulée côté demandeur (futur
`ask_agent` *dropped*) laissait la cible en `Busy` à vie : sur un drop, aucune
branche du `select!` ne s'exécute, donc `mark_idle` n'était jamais appelé. Toute
délégation suivante restait en file derrière ce tour fantôme et n'était jamais
livrée au PTY (symptôme : « DevFrontend/QA ne répondent plus », pont MCP pourtant
ESTAB).

Ajoute un garde RAII `BusyTurnGuard` posé juste après l'enqueue sur les deux
chemins (`ask` PTY et `ask_structured`) : son `Drop` fait `cancel_head` +
`mark_idle` quoi qu'il arrive (erreur, timeout, drop), sauf `disarm()` sur la
branche succès. Indispensable d'être un garde et non un `mark_idle` dans les
branches : le cas réel est un futur droppé. Retire les `cancel_head` redondants
des branches erreur/timeout (positionnel/idempotent).

`sweep_stalled` reste advisory (n'appelle jamais `mark_idle`) — non concerné.

Tests: dropped_ask_future_frees_busy_target, second_delegation_delivered_after_dropped_ask,
cancelled_ask_marks_target_idle + 2 tests du garde. cargo test --workspace vert (80 suites).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 19:15:01 +02:00
aa2f67ae89 fix(orchestrator): ne pas câbler with_structured sur l'orchestrateur (régression 0f8ba38)
Régression introduite par 0f8ba38 : le re-câblage de `.with_structured(...)`
sur `OrchestratorService` (pour activer `drain_with_readiness`, readiness lot 1)
faisait emprunter à `ask_agent` la branche structurée `ensure_structured_session`,
qui ne peut JAMAIS aboutir : depuis le lot B-2 (« Option 1 Terminal + MCP »,
eca2ba9) la fabrique structurée est décâblée de `LaunchAgent`, donc aucun agent
n'a de session structurée — tous tournent en PTY brut et la délégation passe par
les outils MCP. Symptôme : `idea_ask_agent` échouait systématiquement avec
« aucune session structurée vivante après lancement » à chaque relance d'IdeA.

Non détecté par les tests : les e2e loopback câblent leur propre service
structuré complet, jamais le composition root réel (gap tests ≠ composition).

Fix : retirer le `.with_structured` du composition root pour que
`self.structured = None` et que `ask_agent` retombe sur le chemin PTY+MCP
fonctionnel (état pré-0f8ba38). `drain_with_readiness` reste dormant tant que
la voie structurée n'est pas réactivée au composition root.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:45:39 +02:00
0f8ba38d51 feat(agents): pont Codex inter-agents + readiness/heartbeat lot 1
Deux chantiers livrés au vert (workspace entier : domain+application+
infrastructure 42 + app-tauri --lib 128, 0 échec).

## Codex inter-agents
- domaine: McpConfigStrategy::TomlConfigHome { target, home_env } +
  toml_config_home(...); AgentProfile::materializes_idea_bridge()
  (whitelist Claude/ConfigFile + Codex/TomlConfigHome); McpServerWiring
  + encodeur TOML.
- application: lifecycle apply_mcp_config bras TomlConfigHome (écrit
  {runDir}/<target>, pousse (home_env, parent) dans spec.env);
  guard_mcp_bridge_supported ré-exprimée via materializes_idea_bridge();
  catalogue Codex porte toml_config_home(".codex/config.toml","CODEX_HOME").
- app-tauri: is_codex_mcp_profile, migrate_codex_run_dir,
  mcp_server_entry_toml.
- tests: matrice domaine TomlConfigHome + round-trip dual Claude/Codex
  sur loopback réel (fakes, zéro token).

## Readiness/heartbeat lot 1
- domaine: readiness.rs — ReadinessPolicy::classify (Final => TurnEnded),
  variantes ReplyEvent::Heartbeat / ToolActivity.
- application: drain_with_readiness consulte la policy et appelle
  mark_idle sur le signal déterministe; branché dans ask_agent.
  Corrige la cause racine: une cible qui ne renvoie qu'un Final (sans
  idea_reply) débloque désormais sa file Busy.
- infrastructure: adapters de session émettent Heartbeat/ToolActivity.
- tests: drain_with_readiness_lot1 (points QA 5 & 6) verts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:28:44 +02:00
fdcf16c387 chore(wip): checkpoint P8/C avant chantier Codex inter-agents
Sauvegarde de l'arbre de travail en cours (persistance P8, conversations
C-series, write-portal frontend, médiation d'entrée) avant d'attaquer le
support de la délégation inter-agents pour les profils Codex.

Le round-trip inter-agent question/réponse est couvert sans tokens par
les tests loopback existants (state::mcp_e2e_loopback_tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 21:42:53 +02:00
4509f0db9d feat(persistence): P8d — swap cross-profile préserve l'id de paire + handoff
Capstone du chantier handoff : un agent change de moteur (Claude↔Codex) en
gardant la continuité du travail.

- clean_conversation → invalidate_engine_link : préserve conversation_id (id de
  paire stable) et n'efface que engine_session_id (lien moteur étranger) ;
  renvoie l'id de paire préservé
- relaunch_if_live relance avec l'id de paire (repli for_pair si session de
  fond) ⇒ handoff P7 réinjecté dans le nouveau moteur, resume P8c routé via
  providers.json[nouveau provider] (vide ⇒ SessionPlan::None : l'ancien
  resumable n'est jamais repassé ; fidélité par le handoff)
- tests : 4 cas swap (préservation id de paire, engine_session_id vidé, handoff
  réinjecté, pas de --resume de l'ancien moteur, repli for_pair) ;
  change_agent_profile 12 verts, agrégat 829 passed 0 failed

Chantier persistance conversationnelle + handoff cross-profile (P1→P8d) complet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 18:52:50 +02:00
d87b8f6ed2 feat(persistence): P8c — routage --resume moteur via providers.json (structuré)
Referme la régression latente de P8a : pour un profil structuré, le resumable
passé au moteur vient de providers.json[provider] (vrai id Claude/Codex), jamais
de l'id de paire désormais porté par la cellule.

- resolve_session_plan devient async + prend root ; branche structurée câblée :
  get(pair, provider_key) ⇒ Resume{engine_id}, sinon None ; repli gracieux sur
  l'ancien comportement si le store n'est pas câblé (zéro régression tests) ;
  branche non structurée strictement inchangée
- tests : 6 cas (claude/codex Resume{engine} avec assert_ne! vs id de paire,
  discrimination de clé provider, store vide/cellule neuve/non-uuid ⇒ None) ;
  structured_launch_d3 22 verts, domain+application+app-tauri sans régression

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 18:42:35 +02:00
9b053216e3 feat(persistence): P8b — écriture providers.json (resumable par provider)
Au lancement structuré, le resumable exposé par le moteur est rangé dans
providers.json sous (id de paire, provider) — best-effort, n'altère jamais le
lancement.

- domaine : StructuredAdapter::provider_key() ⇒ "claude"/"codex" (clé stable
  par famille, lisible, indépendante de l'uuid d'instance)
- application : port ProviderSessionProvider (root par appel) +
  with_provider_session_provider ; helper persist_provider_session dans
  launch_structured (skip si provider/adapter/id moteur absents ou set KO)
- app-tauri : AppProviderSessionProvider (FsProviderSessionStore sur le root)
- tests : 6 cas (nominal claude/codex, no-op sans provider/sans id moteur,
  best-effort, mapping provider_key) ; domain+application verts

Lecture du store pour --resume = P8c.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 18:36:19 +02:00
09cc8f0902 test(persistence): P8a — déterminisme registre + cohérence de clé end-to-end
Scelle la cohérence corrigée en P8a :
- infra (5) : resolve stable à travers 2 instances de registre (restart),
  resolve.id == for_pair == uuid agent, commutativité, distinction des paires
- application (2) : round-trip réel « handoff sauvé sous la clé resolve ↔
  rechargé au relancement de la cellule portant cette clé » + contre-épreuve
  (handoff d'une autre paire ⇒ pas de reprise)

Clarifie : une cellule neuve (conversation_id=None) ne charge pas de handoff ;
la reprise opère à la réouverture. domain/infra/application verts, zéro
régression. Code de prod non modifié.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 18:27:09 +02:00
e583b2b49f feat(persistence): P8a — cohérence de clé conversation (id de paire stable)
Corrige l'incohérence P6/P7 : la cellule et la persistance partagent désormais
le MÊME id de paire IdeA, déterministe et stable au redémarrage — la clé sous
laquelle P6b sauve log/handoff == celle sous laquelle P7 les charge.

- domaine : ConversationId::for_pair(a,b) pur/déterministe (User↔Agent = uuid
  agent ; Agent↔Agent = XOR commutatif) ; LeafCell gagne engine_session_id
  (cache resumable moteur, additif serde default)
- infrastructure : InMemoryConversationRegistry::resolve utilise for_pair au
  lieu de new_random() → id recalculable sans état, identique après restart
- application : launch_structured persiste l'id de paire sur la cellule (et l'id
  moteur sur engine_session_id) ; cellule neuve ⇒ dérive for_pair(User,agent) ;
  resolve_conversation repli factorisé sur for_pair ; withers layout clone-and-
  mutate (préservent les champs additifs)
- app-tauri : engine_session_id propagé dans les DTO

Suites domain/infra/application/app-tauri vertes (assertion structured_launch_d3
recodée sur le contrat). Test déterminisme/restart dédié à ajouter au retour de
quota agent (binôme Test interrompu par limite de session).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 18:10:50 +02:00
19ba77824f docs(architecture): §19.7 cohérence des ids + découpage P8a→P8d
Cadrage du swap cross-profile et correction de l'incohérence de clé P6/P7 :
LeafCell.conversation_id = id de paire IdeA (clé log/handoff, stable au swap),
resumable moteur isolé par provider dans providers.json (+ cache
engine_session_id). §19.6 : P8 éclaté en P8a (prioritaire/bloquant) → P8d.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 13:47:57 +02:00
c1411d3b69 feat(persistence): P7 — reprise par injection du handoff au lancement
Au (re)lancement, l'agent retrouve sur quoi il travaillait : si la cellule a une
conversation_id avec un handoff, son objectif + résumé sont injectés en dernière
section du convention file (# Reprise de la conversation), best-effort et
provider-indépendant.

- application : port HandoffProvider (root par appel, comme MemoryRecall) ;
  LaunchAgent.with_handoff_provider + resolve_handoff (parse uuid + load,
  jamais bloquant) ; section rendue dans compose_convention_file après la
  mémoire projet
- app-tauri : AppHandoffProvider (FsHandoffStore sur le root du projet) câblé
- tests : 4 purs (objectif présent/None/blanc/absent) + 5 intégration
  (happy path, provider absent, conversation_id None, uuid invalide, sans
  handoff ⇒ lancement OK sans section) ; application 311 + app-tauri 204 verts

Volet --resume/providers.json = P8 (swap cross-profile).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 13:42:19 +02:00
0bf7a5c43c feat(persistence): P6b — câblage live du checkpoint conversationnel (best-effort)
ask_agent persiste désormais chaque paire dans son conversationId : tour Prompt
à l'enqueue, tour Response au succès — best-effort (un échec de persistance ne
dégrade jamais la délégation).

- application : port RecordTurnProvider (matérialise un RecordTurn sur le bon
  project root — OrchestratorService est mono-instance multi-projets, le log est
  par root) ; wither with_record_turn(provider, Clock) ; helper
  record_turn_best_effort ; horodatage via domain::ports::Clock (pas d'horloge
  infra dans application)
- app-tauri : AppRecordTurnProvider (Fs* sur le root du projet) câblé au
  composition root avec le SystemClock partagé
- tests : 5 cas (paire Prompt→Response, fil A↔B vs User↔B, no-op sans provider,
  ask Ok même si record échoue) ; orchestrator_service 40, application +
  app-tauri verts

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 13:31:13 +02:00
2a5873dcf0 feat(persistence): P6a — use case RecordTurn (checkpoint fin de tour, isolé)
Logique du checkpoint conversationnel isolée et testable, sans câblage live :
append du tour au log canonique → load handoff → fold incrémental (tour neuf
seul) → save. Mapping StoreError→AppError via From existant. Debounce repoussé.

- application/src/conversation/{mod,record}.rs : RecordTurn::record
- tests : 6 cas (incrémentalité prouvée via summarizer-espion, propagation
  d'erreur sans effet de bord, isolation par conversation) ; suite verte

Câblage sur les seams réels (reply/ask) + composition root = P6b.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 13:20:05 +02:00
f3046f3dd8 feat(persistence): P5 — ProviderSessionStore (resumable_id par provider)
Range le resumable_id du moteur par (conversation, provider) dans
providers.json, support de reprise non exclusif (le handoff reste la source de
fidélité au swap cross-profile) — §19.2/§19.3.

- domaine : port ProviderSessionStore (get/set par provider)
- infra : FsProviderSessionStore — providers.json map plate, set en
  read-modify-write atomique (tmp+rename) sérialisé par conversation
  (coexistence multi-providers garantie), get absent ⇒ None, corrompu ⇒
  StoreError::Serialization

Tests : 6 cas P5 (coexistence claude+codex, 12 set concurrents sans perte,
corruption, isolation) ; cible conversation_log 30 verts, suites complètes
sans régression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 13:14:34 +02:00
75e4f57a71 feat(persistence): couche conversationnelle — cadrage §18/§19 + briques P1→P4
Resync ARCHITECTURE.md (état livré + cadrage persistance/handoff) et premières
briques de la couche de persistance conversationnelle (log canonique par paire
+ handoff incrémental), indépendante du provider — prépare reprise fiable et
handoff cross-profile Claude↔Codex.

ARCHITECTURE.md
- §14.3.2/§17 : M5 marqué livré, verrou « ouvert » périmé, §17 réconcilié
  (vue = terminal de sortie, pas d'UI chat) ; §18 état livré 2026-06-12 ;
  §19 cadrage persistance/handoff (log par paire + handoff, 10 lots P1→P10)

Domaine (conversation_log.rs, pur)
- P1 : ConversationTurn / TurnId / TurnRole + port ConversationLog
- P3 : Handoff + port HandoffStore
- P4 : port HandoffSummarizer (async, seam OCP pour adapter LLM futur)

Infrastructure (conversation_log/)
- P2 : FsConversationLog — JSONL append-only par paire, sync_all (durabilité
  crash), skip ligne corrompue, fichier absent ⇒ vide
- P3 : FsHandoffStore — handoff.md front-matter, write atomique tmp+rename
- P4 : HeuristicHandoffSummarizer — incrémental, zéro modèle/I/O, fenêtre WINDOW

Tests : domaine 12 + infra 24 (conversation_log) verts, suites complètes sans
régression. Cycle dev/test : le binôme a débusqué et corrigé un bug de
durabilité (append sans flush) au passage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 13:09:35 +02:00
eca2ba95c4 feat(agent): conversation par paire + entrée médiée + pivot terminal/MCP
Coeur inter-agents consolidé et surface front réalignée sur la décision
"terminal natif PTY, pas d'UI chat" (Option 1).

Domaine
- nouveaux modules conversation, mailbox, input, fileguard (ports + types)
- orchestrator/profile/events étendus (conversation par paire, FIFO)

Application / Infrastructure
- orchestrator/service + context_guard : sérialisation FIFO par agent,
  garde RW mémoire/contexte, dispatch ask/reply
- adapters in-memory conversation / mailbox / input / fileguard
- registry session + lifecycle agent durcis (1 agent = 1 session vivante)
- outils MCP idea_* alignés sur le nouveau dispatch

Frontend
- MediatedInput + useAgentBusy : entrée utilisateur médiée par IdeA,
  terminal = vue sortie inchangée
- suppression de la vue chat structurée (AgentChatView) — abandonnée
- adapter input + ports mis à jour

Divers
- .ideai/ : mémoire projet + briefs de cadrage versionnés ;
  requests/ runtime ignoré ; agents projet réels (DevBackend/DevFrontend/QA)

Tests : Rust (domain/application/infrastructure/app-tauri) + front (346) verts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 07:33:04 +02:00
5f45c22941 fix(agent): ne câbler le PTY qu'en lancement brut, pas structuré — §17.4/§17.6
Un launch structuré route vers une AgentSession enregistrée dans
StructuredSessions ; son id n'est jamais un PTY vivant. Câbler le bridge PTY
appelait subscribe_output avec un id inconnu de l'adapter → NotFound
("pty handle not found"), faisant échouer tout lancement d'agent structuré.
On ne pompe les octets que sur le chemin PTY brut (structured: None).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 23:15:36 +02:00
2f20fdbab4 fix(agent): retrait --ask-for-approval invalide pour codex exec 0.137 — §17.9 D3
`codex exec` 0.137 ne connaît pas `--ask-for-approval` (`error: unexpected
argument`) ; ce flag appartient à la commande interactive `codex`, pas à la
sous-commande `exec` déjà non-interactive. Sa présence cassait toute réponse
d'un agent Codex (flux structuré terminé sans Final), bloquant la conversation
inter-agents. argv désormais verrouillé exact dans les tests (16/16 verts).

Découvert et corrigé pendant la validation réelle de la messagerie inter-agents.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 23:15:36 +02:00
cf89b3b9a5 feat(agent): bind transport S-MCP — outils idea_* vivants de bout en bout (M5a-e) — §14.3.1
Dernier kilomètre de l'orchestration native : une CLI MCP réellement lancée
joint le serveur MCP du projet et ses outils idea_* aboutissent au vrai dispatch.

- M5a endpoint loopback par projet (interprocess UDS/named pipe, source unique
  mcp_endpoint, cleanup au close) — zéro port réseau, AppImage/SSH-safe.
- M5b sous-commande `idea mcp-server` : pont stdio↔loopback headless (avant init
  Tauri), handshake {"project","requester"}, endpoint-absent borné, EOF propre.
- M5c McpServerHandle boucle accept + serve_peer par pair ; requester réel
  propagé jusqu'à OrchestratorRequestProcessed (fin du "mcp" figé) ; isolation
  des pairs ; terminaison propre.
- M5d apply_mcp_config écrit la déclaration réelle (current_exe + --endpoint
  mcp_endpoint + --project simple-uuid + --requester) ; McpRuntime injecté comme
  donnée (application ne dépend pas de app-tauri).
- M5e smoke e2e sur vrai loopback : list/ask inline, cible PTY → erreur typée,
  JSON malformé → pas de panic, requester propagé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 18:09:41 +02:00
6ca519b815 feat(agent): robustesse routage ask — fix registre session + concurrence (R0+A0) — §14.3/§15
Stabilise le routage de `ask` avant le bind transport MCP (v5). Invariant
« 1 agent = 1 employé » durci ; un agent traite un tour à la fois.

- R0a garde LaunchAgent : lève AgentAlreadyRunning pour un lancement neuf
  ciblant un agent déjà vivant sur un autre node (PTY + structuré) ; rebind
  seulement même-node ou réattache explicite (conversation_id). Idem spawn_agent.
- R0b list_live_agents agrège PTY + structuré (LiveSessions) + dédup.
- R0c réconciliation des layouts.json à doublons à l'ouverture (host déterministe,
  idempotent) — corrige « une cellule reset au retour d'onglet ».
- R0d UI : option agent désactivée si vivant ailleurs + « aller à la cellule »,
  mapping AGENT_ALREADY_RUNNING.
- A0 sérialisation FIFO des tours par agent_id dans ask_agent (verrou tokio par
  agent ; agents différents en parallèle ; timeout tour 300s, cap attente 600s).

Cadrage : .ideai/briefs/orchestration-v5-transport-bind-cadrage.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 17:34:57 +02:00
37e72747d3 feat(agent): orchestration v3 — surface MCP model-agnostic (M0→M4) — §14.3
Expose l'orchestration IdeA comme serveur MCP par-dessus le même
OrchestratorService::dispatch, avec repli fichier .ideai/requests pour les
CLI sans MCP. v3 réduite à la surface MCP : la messagerie inter-agents et la
corrélation requête↔réponse étaient déjà résolues par §17 (send_blocking).

- M0 capacité MCP sur le profil (McpCapability/McpConfigStrategy/McpTransport)
- M1 injection conf MCP au LaunchAgent + prose adaptée selon la surface
- M2 serveur/adapter MCP (JSON-RPC 2.0 maison ; outils idea_*) + ListAgents
- M3 câblage par projet (registre mcp_servers jumeau du watcher)
- M4 observabilité UI : OrchestratorRequestProcessed.source = file|mcp + badge

Trois portes d'entrée (fichier, MCP, UI) → un seul dispatch ; aucun nouveau
port applicatif ; MCP confiné à l'adapter infra. Tous lots verts (cycle §3).
Cadrage : .ideai/briefs/orchestration-v3-cadrage.md ; ARCHITECTURE.md §14.3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 12:53:31 +02:00
97daf3fae5 feat(agent): surfacer reply dans le writer wire .response.json (D6b) — §17
Ferme le canal fichier de la messagerie inter-agents : un agent qui délègue
via le protocole .ideai/requests reçoit désormais le CONTENU de la réponse,
plus seulement l'ACK de cycle de vie.

- OrchestratorResponse gagne reply: Option<String> (skip_serializing_if
  None, camelCase). success() porte le reply ; failure() => None.
- dispatch_file passe out.reply (de l'OrchestratorOutcome D6) à success.

Champ additif : un .response.json legacy sans reply reste valide ; une
commande non-ask (agent.run, spawn_agent...) => clé reply absente du JSON.

Tests (QA) : +4 verts — ask => reply présent ET detail coexistent ;
non-ask => clé absente (garde anti-always-green sur l'absence littérale) ;
round-trip legacy ; failure => absent. cargo test --workspace : 824 passed.

Orchestration structurée bouclée : in-process (D6) + protocole fichier (D6b).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 00:04:13 +02:00
6de4e5a6e0 feat(agent): menu de profils restreint Claude/Codex + retrait custom (D7) — §17
Dernier lot de §17 : seuls les profils pilotables en mode structuré sont
proposés à la sélection/création.

- domain : AgentProfile::is_selectable() = structured_adapter.is_some(),
  source unique de vérité du prédicat.
- infrastructure : AgentSessionFactory::supports délègue à is_selectable
  (supports et is_selectable ne peuvent plus diverger).
- application : selectable_reference_profiles() = reference_profiles()
  filtré ; ReferenceProfiles et FirstRunState exposent la liste filtrée
  (Claude/Codex). reference_profiles() brut reste à 4 (data intacte) ⇒
  un agent Gemini/Aider/custom legacy déjà configuré continue de tourner.
- frontend : bloc AddCustomProfile retiré du wizard first-run, action
  addCustom retirée du viewmodel ; wizard n'affiche que la liste filtrée.

Tests (QA) : is_selectable (table de vérité), cohérence stricte
is_selectable<->supports, liste exposée=2 / data brute=4, garde
anti-régression Vitest sur l'absence du bloc custom — validées par
mutation. cargo test --workspace : 820 passed. npx vitest run : 344 passed.

§17 COMPLET (D0->D7). Suivi restant : D6b (surfacer reply dans le writer
wire .response.json pour la délégation par protocole fichier).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 23:57:51 +02:00
dd1194abe8 feat(agent): messagerie inter-agents via send_blocking (D6) — §17
Comble le trou historique : un agent qui en interroge un autre reçoit enfin
le CONTENU de la réponse, plus seulement un ACK de cycle de vie.

- domain : OrchestratorCommand::AskAgent { target, task } + action wire
  agent.message (target+task requis) ; DomainEvent::AgentReplied
  { agent_id, reply_len } (bus I/O-free, le contenu remonte par l'outcome).
- application : OrchestratorOutcome gagne reply: Option<String>. Méthode
  ask_agent (§17.4) — cible structurée vivante ⇒ send_blocking direct ;
  cible morte ⇒ LaunchAgent structuré puis send ; agent vivant en PTY ou
  profil sans structured_adapter ⇒ Invalid (non adressable, jamais d'ACK
  trompeur) ; agent inconnu ⇒ NotFound ; service non câblé ⇒ Invalid.
  Timeout (300s) ⇒ erreur typée SANS tuer la session. Succès ⇒ publie
  AgentReplied + reply: Some(content). Injection additive via builders
  with_structured / with_events (call sites legacy intacts).
- app-tauri : state câble with_structured/with_events ; DTO AgentReplied.

Tests (QA) : +14 verts (11 app + 3 domaine), invariants validés par
mutation test (reply!=None, timeout ne shutdown pas). cargo test
--workspace : 817 passed, 0 failed.

Suivi (D6b) : surfacer reply dans le writer wire .response.json
(infrastructure/orchestrator) pour la délégation par protocole fichier.
Reste D7 : menu restreint Claude/Codex + retrait custom.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 23:44:53 +02:00
5059f37890 feat(agent): vue chat frontend + routage cellKind (D5) — §17
Le frontend consomme l'exécution structurée livrée en D4 :
- AgentChatView : jumeau chat de TerminalView. Accumule les textDelta du
  tour courant, badges toolActivity, fige sur final (le contenu final
  remplace les deltas, pas de double rendu). Saisie Enter/Shift+Enter.
  Au montage : reattach (rejoue le scrollback) ou launch ; au démontage :
  détache seulement (jamais de close). Vue pure pilotée par le port.
- LayoutGrid/LeafView : routage cellKind — AgentChatView si "chat",
  TerminalView sinon (chemin PTY inchangé). Cache cellKindBySession pour
  router correctement après ré-attache/navigation.
- Port AgentGateway : sendPrompt / reattachChat / closeAgentSession ;
  cellKind sur TerminalHandle. Adapter Tauri (invoke + Channel<ReplyChunk>)
  et mock (MockChatSession, _setChatAgents) étendus.
- Types TS mirrors : CellKind, ReplyChunk, ReattachChatDto + cellKind sur
  TerminalSession.

Tests (QA) : 21 Vitest verts — routage chat/pty (non-régression terminal),
accumulation/non-doublon (garde anti-always-green delta!=final), ré-attache
sans re-spawn, envoi Enter vs Shift+Enter, badges toolActivity, mock stream.
npx vitest run : 345 passed, 0 failed. npm run build vert.

Reste D6 (messagerie inter-agents via send_blocking) et D7 (menu restreint
Claude/Codex + retrait custom) pour clore §17.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 23:30:20 +02:00
f4d5727a69 feat(agent): commandes Tauri + ChatBridge streaming (D4) — §17
Expose l'exécution structurée à l'UI (jumeau du chemin PTY) :
- ChatBridge (chat.rs) : pompe ReplyStream -> Channel<ReplyChunk>,
  generation-tracked (anti-double-pompe) ; scrollback de conversation
  côté transport (le port AgentSession est sans mémoire), purgé à la
  fermeture, préservé à la ré-attache.
- Commandes agent_send / reattach_agent_chat / close_agent_session.
- DTO : ReplyChunk (textDelta/toolActivity/final, camelCase tagué kind),
  ReattachChatDto, CellKind {pty,chat} + champ cellKind dérivé sur
  TerminalSessionDto (chat ssi LaunchAgentOutput.structured = Some).
- Wiring composition root : StructuredSessions + ChatBridge dans AppState,
  via la factory déjà injectée (aucun new d'adapter — règle D §17.8).
- From<AgentSessionError> for AppError (mappe sur PROCESS).

Tests (QA) : 35 unitaires verts — generation supersede validé par mutation
test, séquence deltas*+Final, reattach sans re-spawn, teardown shutdown+
unregister, DTO cellKind + round-trip ReplyChunk, non-régression PTY.
cargo test --workspace : 803 passed, 0 failed.

Reste D5 : AgentChatView (frontend) + routage cellKind dans LayoutGrid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 23:15:08 +02:00
050afa7d24 fix(test): champ structured manquant dans LaunchAgentOutput (build D3 cassé)
Le commit D3 (56913b9) a ajouté le champ structured: Option<...> à
LaunchAgentOutput mais a laissé deux constructions de test sans ce champ,
cassant la compilation du crate de test app-tauri (E0063). Ajoute
structured: None aux deux. Suite workspace de nouveau verte (775/0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:56:44 +02:00
56913b9053 feat(agent): routage LaunchAgent structuré vs PTY + réconciliation A/B (D3) — §17
- LaunchAgent route sur profile.structured_adapter : Some ⇒ AgentSession via
  factory + enregistrement StructuredSessions (aucun pty.spawn) ; None ⇒ PTY
  inchangé. Dépendances structurées injectées par builders additifs
  with_structured (signatures publiques inchangées ⇒ A/B legacy verts).
- LaunchAgentOutput étendu d'un champ optionnel structured (non cassant).
- A (ChangeAgentProfile) : kill polymorphe — session structurée ⇒ shutdown(),
  PTY ⇒ kill ; détection sur les deux registres.
- B : resume_supported vrai pour profil structuré ; resolve_session_plan ⇒
  Resume{conversation_id} (Claude --resume / Codex exec resume).
- Codex : build_spawn_line porte --sandbox workspace-write --ask-for-approval
  never (autonomie d'écriture ; à terme piloté par les permissions).

Tests : structured_launch 8 + codex flags 2 ; application/infrastructure 0 échec.

Reste D4 : ChatBridge (Channel) + commandes agent_send/reattach + StructuredSessions
dans AppState + DTO cellKind/ReplyChunk.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:48:25 +02:00
f104682477 fix(agent): adapters Claude/Codex au format CLI réel (spikes S1/S2 résolus)
Formats vérifiés sur les vraies CLI le 2026-06-09 :
- Claude (claude -p … --output-format stream-json --verbose ; reprise --resume
  <session_id>) : parse system/init (session_id), ignore rate_limit_event +
  types inconnus, assistant.content[] ITÉRÉ (fix multi-blocs), result→Final.
  Flag --verbose ajouté à build_spawn_line.
- Codex (codex exec --json --skip-git-repo-check ; reprise codex exec resume
  <thread_id>) : thread.started→conversation_id, item.completed/agent_message
  →Final, autres items→ToolActivity, turn.*/inconnu ignorés.

parse_event émet désormais Vec<ReplyEvent> (multi-blocs) ; drain aplatit.
Scripts fake CLI passés aux lignes réelles. Tests : session 46 + intégration,
workspace vert.

À vérifier à l'intégration (D3) : reprise Codex (ordre d'args), et lancement
Codex avec --sandbox workspace-write + approbation non bloquante (hors parsing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:34:03 +02:00
751d94dd89 feat(agent): adapters structurés Claude/Codex + fake CLI + conformité (D2) — §17
infrastructure/src/session/ : machinerie de process générique (paramétrable par
la commande = seam d'injection du fake CLI), adapters ClaudeSdkSession/
CodexExecSession avec parsing ISOLÉ par adapter (parse_event), factory
StructuredSessionFactory (routage par structured_adapter), FakeCli scriptable +
harnais de conformité Liskov assert_agent_session_contract.

Incarnation « un run par tour » (send relance claude -p / --resume <id>,
continuité via conversation_id — colle au pivot reprise B).

Tests : 41 contre le FAKE CLI (jamais le vrai claude/codex), workspace vert.

Points en attente des spikes S1/S2 (format réel) — n'impactent que parse_event :
- mapping JSON→ReplyEvent Claude (S1) et Codex (S2) sur schémas SUPPOSÉS ;
- Claude multi-blocs : parse_event ne garde que le 1er bloc (à corriger si Claude
  émet plusieurs blocs/message — confirmer S1) ;
- flux sans Final : permissif côté adapter, l'erreur est gérée par send_blocking
  (consommateur). À reconfirmer côté UI streaming (D4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:11:38 +02:00
5e10b5eb42 feat(agent): fondation exécution structurée des agents IA (D0+D1) — §17
Pivot orchestration : agents IA pilotés via leur mode programmatique/JSON
(capture déterministe), au lieu du TUI brut + self-report. §16 (idea/MCP)
marquée remplacée comme voie principale.

- D0 (domaine) : port AgentSession + AgentSessionFactory, types ReplyEvent
  /ReplyStream/AgentSessionError, champ AgentProfile.structured_adapter
  (Option<StructuredAdapter{Claude,Codex}>, skip si None ⇒ zéro régression),
  catalogue Claude/Codex annotés.
- D1 (application) : registre StructuredSessions (jumeau de TerminalSessions),
  agrégateur LiveSessions{pty,structured} derrière LiveAgentRegistry (vivant si
  PTY OU structuré, surface du trait inchangée), helper send_blocking (draine le
  ReplyStream jusqu'au Final, Timeout sans tuer la session).

Tests : domaine 16+2 ; application registre 11 + send_blocking 9 ; workspace 0 échec.
A/B intacts. Aucun adapter concret (D2), pas de Tauri/front.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 17:54:48 +02:00
7375f706da feat(agent): reprise des sessions au redémarrage (B2) — commande + ResumeProjectPanel
- Tauri : commande list_resumable_agents (projectId) + ResumableAgentListDto
  { resumable } / ResumableAgentDto (camelCase, conversationId omis si None),
  câblage state.rs (réutilise stores + ProfileStore, aucun nouveau port).
- Front : gateway listResumableAgents, ResumeProjectPanel monté à l'ouverture
  de projet (opt-in, FR) : Reprendre (launch_agent nodeId+conversationId),
  Nouvelle conversation (setCellConversation(null) puis launch), Ignorer,
  Tout reprendre/ignorer. resumeSupported=false ⇒ « relance à neuf ».
- doc : §15.2 coquille agents→resumable (alignée use case/back/front).

Tests : app-tauri dto 9/9 ; vitest 324 (+10) ; workspace Rust 0 échec. 0 régression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:04:41 +02:00
b82e3e1a40 feat(agent): UI hot-swap de profil (A2) — commande Tauri + sélecteur + dialog
- Tauri : commande change_agent_profile + ChangeAgentProfileRequestDto/Dto
  (camelCase, relaunchedSession omis si None), câblage state.rs par composition.
- Front : gateway changeAgentProfile (adapters Tauri+mock), sélecteur de profil
  par agent, dialog de confirmation FR (« Changer le moteur abandonne l'historique
  de conversation… »), refresh sur event agentProfileChanged.

Tests : app-tauri dto 5/5 ; vitest 314/314 (mapping, payload, dialog gating,
libellé FR, refresh). 0 régression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:07:35 +02:00
2433e173a1 feat(agent): backend hot-swap profil (A0+A1) + inventaire reprise session (B1) — L15
Cadrage Architecture §15 (figé) : « agent = entité à session persistante ».

- A0 (domaine) : Agent::with_profile, LayoutTree::leaf, event AgentProfileChanged
  (+ DTO miroir DomainEventDto camelCase).
- A1 (application) : use case ChangeAgentProfile — no-op si profil identique,
  mutation manifeste, nettoyage conversation_id/agent_was_running sur layouts
  persistés, swap à chaud (kill PTY + relance même cellule via composition de
  LaunchAgent), event AgentProfileChanged. Décision : repartir à neuf (on garde
  .md + mémoire, on jette l'historique de conversation).
- B1 (application) : use case ListResumableAgents (lecture seule) — inventaire des
  cellules was_running||conversation_id, resume_supported selon profil, best-effort.

Aucun nouveau port/adapter (composition de l'existant). Hexagonal strict.
Tests : domaine 11 + app-tauri dto + ChangeAgentProfile 9 + ListResumableAgents 8,
suite application complète verte (0 régression).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 09:55:44 +02:00
62bd5130fb chore(agents): contextes DevBackend, DevFrontend et QA pour le chantier « agent = entité »
Personas durables alignés sur la méthode (cycle dev↔QA), l'architecture
hexagonale réelle (crates/dossiers/commandes) et la roadmap A+B+C.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 09:55:29 +02:00
785e9935fd feat(memory): config embedders (LOT C2) + suggestion contextuelle (LOT C3) + contexte projet partagé
- LOT C2 (§14.5.3) : use cases de configuration des embedders déclaratifs
  (List/Save/Delete + DescribeEmbedderEngines : modèles ONNX recommandés,
  environnement local détecté, stratégies compilées). UI EmbedderSettings.
- LOT C3 (§14.5.5) : suggestion contextuelle best-effort à l'activation quand la
  mémoire dépasse le budget de recall sans embedder configuré (event
  EmbedderSuggested, anti-spam 1×/session, « ne plus demander »).
- Contexte projet partagé .ideai/CONTEXT.md (model-agnostic) injecté à tous les
  agents/profils au lancement, avant la persona. UI ProjectContextPanel.

Tests : backend workspace vert (0 échec) ; frontend 306/306.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 09:24:51 +02:00
32398827fb feat(memory): embedders vectoriels réels HTTP + ONNX derrière features (LOT C1)
Remplace les StubEmbedder pour les stratégies localServer/api/localOnnx par de
vrais moteurs, chacun derrière une feature cargo off-by-default — la posture
fondatrice « rien d'imposé, zéro dépendance » (défaut none → rappel naïf) reste
byte-for-byte inchangée.

C1a (feature vector-http, reqwest rustls optional):
- HttpEmbedder couvrant localServer (Ollama/llama.cpp) et api (OpenAI/Voyage…),
  payload OpenAI-compatible /v1/embeddings, ordre restauré par index, bearer
  token lu via env var (jamais en clair), timeout client 30s.
- detect_ollama() pour la détection de l'existant (C3).

C1b (feature vector-onnx, fastembed v5 optional):
- OnnxEmbedder en-process (e5-small, dim 384), init paresseuse + spawn_blocking,
  cache modèle sous <app_data>/embedders/onnx — aucun download au build ni au
  first-run, uniquement à la demande au 1er embed.
- Catalogue RECOMMENDED_ONNX_MODELS + ONNX_CACHE_SUBDIR + onnx_model_is_cached
  exposés (sans feature) pour la config (C2) et la popup (C3).

embedder_from_profile(profile, onnx_cache_dir) dispatche feature-gated ; sans la
feature, retombe sur StubEmbedder (Unsupported) → fallback naïf via
AdaptiveMemoryRecall. Composition root (build_memory_recall) propage le cache dir.

Tests: 10 HTTP + 6 ONNX (dont 2 #[ignore] download réel) + 26 vectoriels, verts
en défaut, --features vector-http et --features vector-onnx.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 19:00:40 +02:00
b39c11a64d fix(agents): enforcer l'invariant « 1 session vivante par agent » (singleton)
Un agent ne peut tourner que dans une seule cellule à la fois. La garde dans
LaunchAgent refuse le spawn si l'agent est déjà vivant dans un autre node
(AGENT_ALREADY_RUNNING) ; idempotent sur le même node ; le chemin resume
(agent mort) reste inchangé. Le node_id est désormais plombé jusqu'au use case.

Corrige le reset asymétrique d'une cellule au changement d'onglet : deux leaves
partageant le même agent id rendaient session_for_agent/is_agent_live/stop_agent
ambigus (cible arbitraire). Le churn reset/reattach déclenchait aussi les accents
mélangés (FIFO intact, non touché).

- snapshot agentWasRunning calculé par node (is_node_live) et non par agent
- commande list_live_agents + live_agents()/node_for_agent()/is_node_live()
- UI : dropdown grise les agents déjà placés ailleurs ; 2e cellule en doublon
  affiche « disponible » au lieu d'une relance fantôme

Tests : cargo test (application + app-tauri) vert ; tsc + vitest vert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 14:43:48 +02:00
f3bc3f20d8 feat(memory): clôture du sujet mémoire — bascule adaptative live + panneau UI (§14.5.5)
Pièce 1 (backend) : state.rs câble AdaptiveMemoryRecall via build_memory_recall,
piloté par le profil embedder chargé depuis embedder.json (fallback none). Défaut
none ⇒ NaiveMemoryRecall nu (comportement inchangé, StubEmbedder jamais touché,
zéro dépendance lourde). Instance de recall partagée (RecallMemory + LaunchAgent).
Chargement du profil isolé sur un runtime dédié (évite le block_on imbriqué).

Pièce 2 (frontend) : feature mémoire complète en miroir de skills —
MemoryGateway (port) + TauriMemoryGateway + MockMemoryGateway, types domaine
Memory/MemoryIndexEntry/MemoryType, MemoryPanel/MemoryEditor/useMemory, onglet
sidebar « Memory » dans ProjectsView. CRUD par slug, liens [[slug]] résolus.

Le sujet mémoire est clos : CRUD .md + index + rappel adaptatif + injection à
l'activation des agents + UI de gestion. Embedder concret ONNX/HTTP reste un
follow-up (défaut none = pleinement fonctionnel sans dépendance).

Tests: backend 57 binaires verts, frontend 285 tests verts, typecheck OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 13:28:27 +02:00
2435857cbf feat(memory): injecter le rappel mémoire dans le convention file à l'activation (§14.5.4)
À l'activation d'un agent, LaunchAgent compose désormais une section
« # Mémoire projet » dans le convention file généré (CLAUDE.md/AGENTS.md…),
au même titre que les skills assignés (§14.2). Les agents lisent ainsi la
mémoire projet sans aucun flag ni mécanisme propre à une CLI.

- LaunchAgent reçoit le port MemoryRecall (Arc<dyn MemoryRecall>), résout le
  rappel (budget AGENT_MEMORY_RECALL_BUDGET=2048, requête = persona de l'agent)
  en best-effort : mémoire vide/absente ou erreur ⇒ section omise, le launch
  n'est jamais bloqué (comme un SkillRef dangling).
- compose_convention_file gagne un argument `memory: &[MemoryIndexEntry]`
  (reste pure) ; section injectée seulement pour la stratégie conventionFile.
- state.rs : une seule instance NaiveMemoryRecall partagée (recall + LaunchAgent).

Tests: 57 binaires verts. compose_convention_file (vide ⇒ inchangé, ordre,
cohabitation skills) + intégration LaunchAgent (section présente / absente /
best-effort sur erreur / pas d'injection en stratégie env).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 09:01:51 +02:00
98a8b7292a feat(memory): système de mémoire projet model-agnostic (L14, LOT A+B+C)
Base de connaissance persistante par projet, indépendante de tout modèle/CLU
et de git. Cadrage archi en §14.5 (ARCHITECTURE.md), cycle Archi→Dev→Test.

LOT A — étage 1 (.md, source de vérité)
  - domaine: entité Memory (+ MemorySlug, MemoryType, MemoryFrontmatter,
    MemoryLink, MemoryIndexEntry), liens [[slug]], index MEMORY.md dérivé
  - port MemoryStore + MemoryError, adapter FsMemoryStore (.ideai/memory/)
  - application: 7 use cases (Create/Update/List/Get/Delete/ReadIndex/
    ResolveLinks), From<MemoryError> for AppError
  - app-tauri: commandes + DTO, events MemorySaved/MemoryDeleted
  - suppression de la variante morte DomainError::MalformedFrontmatter

LOT B — rappel adaptatif (étage 1)
  - port MemoryRecall + MemoryQuery, adapter NaiveMemoryRecall (troncature
    au budget de tokens, court-circuit budget-0), use case RecallMemory

LOT C — étage 2 vectoriel (structure complète, zéro dépendance lourde)
  - port Embedder + EmbedderError, profils déclaratifs EmbedderProfile/
    EmbedderStrategy (embedder.json)
  - VectorMemoryRecall (cosinus, cache .ideai/memory/.index/ gitignoré)
  - AdaptiveMemoryRecall (bascule pure should_use_vector), défaut none
  - HashEmbedder (déterministe, tests), StubEmbedder (onnx/server/api)

Tests: 57 binaires verts, build + clippy --workspace sans warning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 08:47:23 +02:00
3ed0f6b45f feat(terminals): reprise de conversation par cellule + fix ordre d'écriture
Permet de recharger la conversation CLI précédente de chaque cellule à la
réouverture du projet, de façon universelle (indépendant du modèle/CLI).

- profil AgentRuntime: bloc déclaratif optionnel `session { assignFlag, resumeFlag }`
- LeafCell: `conversationId` (persistant, distinct du SessionId PTY) + `agentWasRunning`
- runtime: SessionPlan (None/Assign/Resume) + composition pure des args
- LaunchAgent: décide Assign vs Resume, génère l'UUID, remonte l'id assigné
  (persistance par l'appelant via setCellConversation — découplage SRP)
- close: SnapshotRunningAgents fige `agentWasRunning` avant le kill-all
  (statut clot/en cours universel, sans parsing CLI)
- SessionInspector: port optionnel best-effort + adapter ClaudeTranscriptInspector
- popup de reprise par cellule (statut + sujet/tokens si dispo), intercalée
  avant le Resume auto, jamais sur le chemin reattach

fix(terminals): sérialise les écritures PTY (file FIFO par handle) — corrige
les caractères mélangés/accents dus au réordonnancement des invoke Tauri concurrents

fix(layout): l'opération `move` préservait mal les champs du leaf (perdait `agent`)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:27:08 +02:00
d11eaaa8c0 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>
2026-06-07 13:06:41 +02:00
b9fd2fb925 fix(terminals): stop PTY output duplication on re-attach
Each open/reattach spawned a pump thread feeding pty_bridge[session]. The
broadcast hub added a subscriber per attach without dropping the previous one,
and the stale pump thread kept delivering (its send_output still succeeded
against the new channel) — so every byte was delivered twice (more after
further re-attaches). Accumulated on each tab/layout/agent switch; window
resize made it glaring.

- Broadcast::subscribe() is now single-consumer: a new subscription supersedes
  the previous one, ending the old pump thread's stream.
- PtyBridge gains a per-session generation; register() returns it and
  unregister_if(session, gen) only removes when still current, so a dying
  superseded thread can't tear down the channel that replaced it.
- All three attach sites (open/reattach/agent launch) use unregister_if.

Tests: 6 added (generation guard + single-consumer broadcast). Workspace green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 11:52:24 +02:00
9b92259429 fix: fix git branches 2026-06-07 11:19:57 +02:00
fbcf7bd436 test(orchestrator): inject SkillStore into LaunchAgent after skills merge
The orchestrator branch predated the skills feature; its LaunchAgent test
construction lagged the new 8-arg signature. Add an empty FakeSkills to both
the service and watcher tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 11:17:31 +02:00
e0c7e1403d Merge branch 'worktree-agent-a2650e91d2bd39ca2' into develop 2026-06-07 11:14:02 +02:00
480e7c7bbe feat(orchestrator): file-based orchestrator request watcher (§14.3)
- domain: OrchestratorRequest/Command parse-don't-validate + OrchestratorRequestProcessed event
- application: OrchestratorService dispatching spawn/stop/update_agent_context
- infrastructure: request watcher over .ideai/requests/, writes .response.json
- app-tauri: relay OrchestratorRequestProcessed to the frontend DTO

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 11:12:04 +02:00
3be55795a6 fix: fix some displays and features 2026-06-06 17:06:45 +02:00
2332b7f815 fix: fix some ui displays and features miss implemented 2026-06-06 16:15:19 +02:00
9736c42424 merge: isolate agent cwd in .ideai/run/<id> (convention-file collision fix, §14.1) 2026-06-06 12:26:02 +02:00
0638ce7c98 merge: terminal lifecycle decoupling (PTY survives layout/tab switch) 2026-06-06 12:25:54 +02:00
0660f52e2b fix(terminals): decouple PTY lifecycle from view lifecycle (no kill on navigation)
Navigating (layout/tab switch) tore the xterm view down and called
handle.close(), killing the backend PTY and cutting off running AIs. Now
the view's cleanup only detaches; only an explicit user action kills a PTY.

Backend:
- PortablePtyAdapter: per-session scrollback ring buffer (~100KB, most
  recent) + re-subscribable fan-out broadcast replacing the single-take
  output_rx. Reader thread feeds both the ring buffer and current
  subscribers; on EOF it closes subscribers (streams end) while keeping
  scrollback for late re-attach.
- PtyPort: new scrollback() method; subscribe_output is now re-subscribable
  (all impls + test fakes updated).
- reattach_terminal IPC command: returns scrollback and re-wires a fresh
  output channel on the live session without re-spawning.
- CloseRequested hook kills all live PTYs cleanly on app shutdown.
- TerminalSessions::handles() to enumerate live sessions at shutdown.

Frontend:
- TerminalHandle.detach(); TerminalGateway/AgentGateway.reattach() + mocks.
- TerminalView cleanup detaches (never close); on mount it re-attaches to a
  persisted session (repainting scrollback) instead of opening a new PTY.
- LayoutGrid persists the cell's session id via setSession; AgentsPanel
  tracks per-agent session ids — both drive reattach-vs-open.

Tests: ring buffer bounds to 100KB keeping newest bytes; scrollback retained;
re-subscription delivers post-reattach output; TerminalView detaches (not
closes) on unmount and reattaches with a known session; mock detach/reattach.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:24:48 +02:00
33edbad713 feat(agent): isolate agent cwd in .ideai/run/<id> to kill convention-file collisions
ARCHITECTURE §14.1: an agent's PTY cwd is now its own
`<project_root>/.ideai/run/<agent-id>/` directory, never the project root, so
N agents of the same profile no longer collide on a single conventional file
(CLAUDE.md/AGENTS.md/...).

- profile: cwd_template is now "{agentRunDir}" (built-in catalogue + docs).
- runtime: resolve_cwd substitutes {agentRunDir} (legacy {projectRoot} kept).
- LaunchAgent: computes + creates the run dir via FileSystem::create_dir_all,
  passes it as the cwd base to the pure prepare_invocation. Contract chosen:
  pass run_dir as the `cwd` argument (no PreparedContext change) — keeps
  prepare_invocation pure, I/O stays in the use case.
- convention file is generated by IdeA inside the run dir via a pure
  compose_convention_file(project_root, agent_md): absolute project-root header
  + agent persona (extensible for skills, §14.2).
- .gitignore: ignore .ideai/run/.
- run-dir cleanup left as a TODO (FileSystem port exposes no delete).

Tests: anti-collision (2 agents -> 2 distinct cwd, 2 distinct convention files,
none at root), run-dir creation order, composed convention file; pure unit
tests for agent_run_dir + compose_convention_file; runtime {agentRunDir}
substitution. cargo test --workspace + clippy -D warnings green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:18:14 +02:00
283 changed files with 76862 additions and 1025 deletions

Submodule .claude/worktrees/agent-a2650e91d2bd39ca2 added at 480e7c7bbe

Submodule .claude/worktrees/agent-aeb1e862ef04b991b added at ef101db9dc

14
.gitignore vendored
View File

@ -24,6 +24,20 @@ frontend/coverage/
# ─── Claude Code ──────────────────────────────────────────────────────────── # ─── Claude Code ────────────────────────────────────────────────────────────
# Personal, machine-local overrides (shared settings.json, if any, stays tracked). # Personal, machine-local overrides (shared settings.json, if any, stays tracked).
.claude/settings.local.json .claude/settings.local.json
# Ephemeral git worktrees created by Claude Code's isolated sub-agents — dev
# tooling only, unrelated to IdeA (which stays git-independent).
.claude/worktrees/
# ─── IdeA project data ──────────────────────────────────────────────────────
# Ephemeral per-agent run directories (isolated PTY cwd + generated convention
# files), created at activation — not versioned (ARCHITECTURE §9.1 / §14.1).
.ideai/run/
# Derived vector store for semantic recall (LOT C / §14.5.3): embeddings of the
# memory notes, rebuildable from the `.md` source of truth — not versioned.
.ideai/memory/.index/
# Runtime file-protocol orchestration requests/responses — transient I/O, not
# durable project state (curation .ideai §chantier secondaire).
.ideai/requests/
# ─── Editors / OS ─────────────────────────────────────────────────────────── # ─── Editors / OS ───────────────────────────────────────────────────────────
.idea/ .idea/

54
.ideai/agents.json Normal file
View File

@ -0,0 +1,54 @@
{
"version": 1,
"agents": [
{
"agentId": "a6ced819-b893-4213-b003-9e9dc79b9641",
"name": "Main",
"mdPath": "agents/main.md",
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
"synchronized": false
},
{
"agentId": "dce19c75-9669-4e45-b8de-9950025157da",
"name": "Architect",
"mdPath": "agents/architect.md",
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
"synchronized": false
},
{
"agentId": "73c853d1-c0fd-463b-ad17-1d24fefa371f",
"name": "DevBackend",
"mdPath": "agents/devbackend.md",
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
"synchronized": false
},
{
"agentId": "af7f86da-76bc-48e1-9900-71f45a624800",
"name": "DevFrontend",
"mdPath": "agents/devfrontend.md",
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
"synchronized": false
},
{
"agentId": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5",
"name": "QA",
"mdPath": "agents/qa.md",
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
"synchronized": false
},
{
"agentId": "c932c770-cf36-4fb2-a966-71bb1644e4b4",
"name": "TestConversation",
"mdPath": "agents/testconversation.md",
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
"synchronized": false
},
{
"agentId": "484eff91-60a1-459f-9ebe-c9552cc70447",
"name": "NewTest",
"mdPath": "agents/newtest.md",
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
"synchronized": false
}
]
}

604
.ideai/agents/architect.md Normal file
View File

@ -0,0 +1,604 @@
# IdeA — Cartographie d'Architecture
> Document de référence produit par l'**Agent Architecture**.
> Fait autorité sur les frontières, ports, adapters, modules et conventions.
> Toute feature DOIT être validée contre ce document avant développement.
> Architecture **Hexagonale (Ports & Adapters)** + **SOLID**, stricte.
>
> Stack non négociable : Tauri v2 (shell) · Rust (cœur hexagonal) · TypeScript + React (UI) · xterm.js + portable-pty (terminaux) · git2/libgit2 · russh/ssh2 · wsl.exe.
---
## 1. Principes : SOLID + Hexagonal, appliqués concrètement
### 1.1 Règle de dépendance (la seule qui compte)
```
┌─────────────────────────────────────────────┐
│ Le sens des dépendances │
│ │
Présentation ─► Application ─► Domaine ◄─ Infrastructure │
(React/Tauri) (use cases) (pur) (adapters) │
│ │
└─────────────────────────────────────────────┘
```
- **Le Domaine ne dépend de RIEN** : ni Tauri, ni tokio, ni git2, ni portable-pty, ni serde (le moins possible — voir §1.4). Il ne contient que des entités, value objects, règles métier et **traits = ports**.
- **L'Application** dépend du Domaine. Elle orchestre les use cases en parlant **uniquement aux ports** (traits), jamais aux adapters concrets.
- **L'Infrastructure** dépend du Domaine et de l'Application (elle implémente les ports). Elle contient tous les détails techniques (PTY, FS, git, SSH, WSL, stores).
- **La Présentation** (Tauri commands + React) dépend de l'Application. Les commandes Tauri sont des **adapters entrants (driving adapters)** ; les impl de ports sont des **adapters sortants (driven adapters)**.
Aucune flèche ne pointe **vers** la présentation ou l'infrastructure. L'inversion de dépendance (le **D** de SOLID) est matérialisée par les traits définis dans le domaine et implémentés dehors.
### 1.2 SOLID, point par point, traduit IdeA
| Principe | Application concrète |
|---|---|
| **S** — Single Responsibility | Un use case = une intention métier (`LaunchAgent`, `SyncAgentWithTemplate`). Un adapter = une techno (`Git2Repository` ne fait que du git). Le `LayoutNode` ne gère que la topologie, pas le rendu. |
| **O** — Open/Closed | Ajouter une IA = ajouter un **profil déclaratif** (donnée), pas du code. Ajouter un mode distant = nouvel adapter `RemoteHost` sans toucher aux use cases. Ajouter une stratégie d'injection de contexte = nouvelle variante d'enum + handler, use case inchangé. |
| **L** — Liskov | Tout `RemoteHost` (local, SSH, WSL) est substituable : un use case marche identiquement quelle que soit l'impl. Les contrats (pré/postconditions) des ports sont documentés et respectés par chaque adapter. |
| **I** — Interface Segregation | Ports **fins et ciblés** : `ProcessSpawner`, `FileSystem`, `PtyPort` séparés plutôt qu'un `System` fourre-tout. Un use case ne reçoit que les ports qu'il consomme. |
| **D** — Dependency Inversion | Domaine définit les traits ; infra les implémente ; l'application reçoit des `Arc<dyn Port>` par **injection** (composition root dans la couche Tauri). |
### 1.3 Hexagonal côté Frontend (React aussi)
L'hexagonal ne s'arrête pas à Rust. Côté React on applique le même découpage :
- **Domaine UI / modèles de vue** : types TS purs (miroir des DTO), logique de présentation pure (ex. calcul de tailles de cellules d'un `LayoutNode`), testable sans React ni Tauri.
- **Ports UI** : interfaces TS (`AgentGateway`, `TerminalGateway`, `ProjectGateway`, `LayoutGateway`, `GitGateway`, `RemoteGateway`) décrivant **ce dont l'UI a besoin**, indépendamment du transport.
- **Adapters UI** : implémentation des ports via `@tauri-apps/api` (`invoke` pour commands, `listen` pour events). Remplaçables par des **mocks** en test/Storybook.
- **Présentation** : composants React, hooks, state (Zustand/Redux) qui consomment les ports UI, jamais `invoke()` en direct.
Bénéfice : le frontend est testable et développable sans backend (adapters mock), et la frontière IPC est centralisée en un seul endroit.
### 1.4 Domaine pur vs adapters — règle pratique Rust
- Le crate `domain` est **`#![no_std]`-friendly d'esprit** (pas imposé), sans dépendance I/O. Tolérance pragmatique : `serde` est autorisé **uniquement** pour dériver la (dé)sérialisation des entités persistées (manifeste, layout, profils), car c'est une contrainte métier de format, pas un détail technique d'I/O. Les **traits/ports** y vivent. Pas de `tokio`, pas de `std::process`, pas de `std::fs`.
- Tout ce qui touche le monde réel (`std::fs`, `Command`, sockets, libgit2, PTY) vit **exclusivement** dans `infrastructure`.
---
## 2. Découpage en couches & frontière Rust ↔ Tauri ↔ React
```
┌───────────────────────────────────────────────────────────────────────┐
│ PRÉSENTATION (Frontend) — TypeScript + React + xterm.js │
│ features/* · ui-ports (gateways) · tauri-adapters (invoke/listen) │
└───────────────────────────────┬───────────────────────────────────────┘
│ IPC Tauri (commands ⇄ events, JSON)
┌───────────────────────────────▼───────────────────────────────────────┐
│ PRÉSENTATION (Backend) — crate `app-tauri` (DRIVING ADAPTER) │
│ #[tauri::command] handlers · event emitters · COMPOSITION ROOT (DI) │
│ PTY byte-stream bridge ⇄ xterm.js │
└───────────────────────────────┬───────────────────────────────────────┘
│ appels de use cases (Arc<UseCase>)
┌───────────────────────────────▼───────────────────────────────────────┐
│ APPLICATION — crate `application` │
│ Use cases / services · DTOs · orchestration · transactions métier │
│ Dépend UNIQUEMENT des ports (traits) du domaine │
└───────────────────────────────┬───────────────────────────────────────┘
│ implémente / consomme
┌───────────────────────────────▼───────────────────────────────────────┐
│ DOMAINE — crate `domain` (PUR, sans I/O) │
│ Entities · Value Objects · Invariants · PORTS (traits) · DomainEvents │
└───────────────────────────────▲───────────────────────────────────────┘
│ implémentent les ports (DRIVEN ADAPTERS)
┌───────────────────────────────┴───────────────────────────────────────┐
│ INFRASTRUCTURE — crate `infrastructure` │
│ portable-pty · git2 · russh/ssh2 · wsl.exe · fs local · md/json store │
└─────────────────────────────────────────────────────────────────────────┘
```
### Frontière IPC Tauri — deux directions
- **Commands (Frontend → Backend, request/response)** : `invoke("create_project", {...})`. Le handler `#[tauri::command]` désérialise le DTO, appelle le use case, renvoie un `Result<DTO, ErrorDTO>`. **Stateless** côté forme : tout l'état vit dans des services managés via `tauri::State`.
- **Events (Backend → Frontend, push)** : flux PTY (octets/base64), changements de statut d'agent, fin de processus, progrès git, drift de template détecté. Émis via `app_handle.emit(...)` / channels Tauri. L'`EventBus` domaine est relayé vers ces events Tauri par un adapter dans `app-tauri`.
> **Décision** : le flux PTY haute fréquence passe par des **Tauri Channels** (`tauri::ipc::Channel`) plutôt que des events globaux, pour la perf et l'isolement par session terminal.
---
## 3. Modèle de domaine
### 3.1 Vue d'ensemble (relations)
```
Workspace 1───* Window 1───* Tab 1───1 Project
│ │
│ 1 ├──* Agent ─────? AgentTemplate (origine)
│ │ │ 1
│ 1 │ └──1 AgentProfile (runtime IA, par réf id)
LayoutTree ├──1 GitRepository
(LayoutNode récursif) ├──1 RemoteHost (Local | Ssh | Wsl)
│ feuilles └──1 AgentManifest (.ideai/agents.json)
TerminalSession 1───? Agent (si lancé par un agent)
```
### 3.2 Entités & Value Objects (avec invariants)
**`ProjectId`, `AgentId`, `TemplateId`, `ProfileId`, `SessionId`, `WindowId`, `TabId`, `NodeId`** — VO `newtype(Uuid)` ou string typée. Invariant : non vide, immuable.
**`Project`** (entité, racine d'agrégat projet)
- Champs : `id`, `name`, `root: ProjectPath`, `remote: RemoteRef`, `created_at`.
- Invariants : `root` doit être un chemin **absolu et valide pour son `RemoteRef`** ; deux projets ne peuvent partager le même `(remote, root)`.
**`ProjectPath`** (VO) — chemin absolu normalisé, conscient de la plateforme cible (POSIX vs Windows vs WSL `/mnt/...`).
**`Agent`** (entité)
- Champs : `id`, `name`, `context: AgentContextRef` (chemin du `.md` dans `.ideai/`), `profile_id: ProfileId`, `origin: AgentOrigin` (`Scratch` | `FromTemplate { template_id, synced_version }`), `synchronized: bool`.
- Invariants : `synchronized == true``origin == FromTemplate{..}` (on ne peut pas synchroniser un agent créé from scratch). `context` doit exister à l'activation. `profile_id` doit référencer un `AgentProfile` connu.
**`AgentTemplate`** (entité, store global)
- Champs : `id`, `name`, `content_md: MarkdownDoc`, `version: TemplateVersion`, `default_profile_id`.
- Invariants : `version` **monotone croissante** ; toute modification du `content_md``version + 1` (voir §8).
**`AgentProfile`** (entité de config runtime IA — le port `AgentRuntime` est paramétré par elle)
- Champs : `id`, `name`, `command: String`, `args: Vec<String>`, `context_injection: ContextInjection`, `detect: Option<String>`, `cwd_template: String` (ex. `"{projectRoot}"`).
- Invariants : `command` non vide ; cohérence de `ContextInjection` (voir VO ci-dessous).
**`ContextInjection`** (VO, enum — cœur du moteur IA flexible)
```
ContextInjection =
| ConventionFile { target: String } // ex. "CLAUDE.md" / "AGENTS.md" / "GEMINI.md"
| Flag { flag: String } // ex. "--context-file {path}" ou "-f"
| Stdin // pipe du contenu md sur stdin
| Env { var: String } // ex. "AGENT_CONTEXT_FILE"
```
- Invariants : `ConventionFile.target` est un nom de fichier relatif (pas de `..`, pas absolu) ; `Env.var` est un identifiant d'env valide ; `Flag.flag` non vide.
**`TerminalSession`** (entité)
- Champs : `id`, `node_id` (cellule du layout qui l'héberge), `cwd: ProjectPath`, `kind: SessionKind` (`Plain` | `Agent { agent_id }`), `pty_size: PtySize { rows, cols }`, `status` (`Starting|Running|Exited{code}`).
- Invariants : une cellule (feuille de layout) héberge **au plus une** `TerminalSession` active. `pty_size.rows>0 && cols>0`.
**`LayoutNode` / `LayoutTree`** (VO récursif — voir §7 pour le détail complet)
- Invariants : poids relatifs strictement positifs ; somme normalisable ; pas de fusion qui chevauche deux conteneurs distincts ; un `Leaf` référence 0 ou 1 `SessionId`.
**`RemoteHost`** (VO de stratégie de localisation — abstrait Local/SSH/WSL)
```
RemoteRef =
| Local
| Ssh { host, port, user, auth: SshAuth, remote_root }
| Wsl { distro: String }
```
- Invariants : `Ssh.port` ∈ 1..=65535 ; `Wsl.distro` non vide ; pour `Ssh`/`Wsl`, les chemins projet sont interprétés côté distant.
**`GitRepository`** (entité)
- Champs : `project_id`, `root`, `current_branch`, `is_dirty`.
- Invariants : `root` contient (ou contiendra après init) un `.git`. État dérivé, rafraîchi via le port.
**`AgentManifest`** (entité — image en mémoire de `.ideai/agents.json`)
- Champs : `entries: Vec<ManifestEntry { agent_id, md_path, template_id?, synchronized, synced_template_version? }>`.
- Invariants : `synchronized ⇒ template_id.is_some() && synced_template_version.is_some()` ; `md_path` unique ; cohérence avec les `Agent` chargés.
**`Workspace` / `Window` / `Tab`** (entités de présentation persistée)
- `Workspace` = ensemble des fenêtres d'une session utilisateur.
- `Window` = fenêtre OS ; possède un `LayoutTree` **par onglet actif** et une liste de `Tab`.
- `Tab` = onglet ⇔ **un `Project`** (1:1).
- Invariants : un `Project` ouvert apparaît dans **exactement un** `Tab` à la fois (le drag déplace, ne duplique pas) ; un `Window` a ≥ 1 `Tab` ou est fermée.
**`DomainEvent`** (enum) — `ProjectCreated`, `AgentLaunched`, `AgentExited`, `TemplateUpdated`, `AgentDriftDetected`, `LayoutChanged`, `RemoteConnected`, `GitStateChanged`, `PtyOutput{session_id, bytes}` (ce dernier souvent court-circuité vers un Channel).
---
## 4. Ports (traits du domaine)
> Signatures **conceptuelles** (Rust idiomatique, `async` via `async_trait` ou retours `Future` ; erreurs typées par port). « Consommé par » = use cases. « Implémenté par » = adapters de §5.
### `AgentRuntime`
- **Rôle** : lancer/piloter la CLI d'une IA selon un `AgentProfile`, en gérant l'injection du contexte `.md`.
- **Signature** :
```rust
trait AgentRuntime {
fn detect(&self, profile: &AgentProfile) -> Result<bool, RuntimeError>;
fn prepare_invocation(&self, profile: &AgentProfile, ctx: &PreparedContext, cwd: &ProjectPath)
-> Result<SpawnSpec, RuntimeError>; // commande + args + plan d'injection (fichier/flag/stdin/env)
}
```
- **Consommé par** : `LaunchAgent`, `DetectProfilesUseCase` (first-run).
- **Implémenté par** : `CliAgentRuntime` (un seul adapter générique piloté par le profil déclaratif — c'est l'**Open/Closed**). La diversité des IA = données, pas code.
### `PtyPort` (alias domaine de `TerminalSessionPort`)
- **Rôle** : ouvrir un pseudo-terminal, lire/écrire, redimensionner, tuer.
- **Signature** :
```rust
trait PtyPort {
async fn spawn(&self, spec: SpawnSpec, size: PtySize) -> Result<PtyHandle, PtyError>;
fn write(&self, h: &PtyHandle, data: &[u8]) -> Result<(), PtyError>;
fn resize(&self, h: &PtyHandle, size: PtySize) -> Result<(), PtyError>;
fn subscribe_output(&self, h: &PtyHandle) -> OutputStream; // flux d'octets
async fn kill(&self, h: &PtyHandle) -> Result<ExitStatus, PtyError>;
}
```
- **Consommé par** : `OpenTerminal`, `LaunchAgent`, `CloseTerminal`.
- **Implémenté par** : `PortablePtyAdapter` (local), `SshPtyAdapter` (PTY distant via russh exec/shell), `WslPtyAdapter` (PTY via `wsl.exe`). Sélection par stratégie `RemoteRef` (Liskov).
### `RemoteHost`
- **Rôle** : abstraction de la **localisation d'exécution** (local / SSH / WSL) : exécuter une commande, ouvrir un PTY, accéder au FS, dans le bon contexte.
- **Signature** :
```rust
trait RemoteHost {
fn kind(&self) -> RemoteKind;
async fn connect(&self) -> Result<(), RemoteError>;
fn file_system(&self) -> Arc<dyn FileSystem>;
fn process_spawner(&self) -> Arc<dyn ProcessSpawner>;
fn pty(&self) -> Arc<dyn PtyPort>;
}
```
- **Consommé par** : tous les use cases qui touchent un projet (résolvent leurs ports via le `RemoteHost` du projet → **transparence local/distant**).
- **Implémenté par** : `LocalHost`, `SshHost` (russh/ssh2), `WslHost` (wsl.exe). C'est la **stratégie** qui unifie les 3 modes.
### `ProcessSpawner`
- **Rôle** : lancer un process **non interactif** et récupérer sortie/exit (ex. `detect`, commandes git hors libgit2, scripts).
- **Signature** : `async fn run(&self, spec: SpawnSpec) -> Result<Output, ProcessError>;`
- **Consommé par** : `DetectProfilesUseCase`, services divers.
- **Implémenté par** : `LocalProcessSpawner`, `SshProcessSpawner`, `WslProcessSpawner`.
### `FileSystem`
- **Rôle** : lecture/écriture/listing/symlink, neutre vis-à-vis de la localisation.
- **Signature** :
```rust
trait FileSystem {
async fn read(&self, p: &RemotePath) -> Result<Vec<u8>, FsError>;
async fn write(&self, p: &RemotePath, data: &[u8]) -> Result<(), FsError>;
async fn exists(&self, p: &RemotePath) -> Result<bool, FsError>;
async fn create_dir_all(&self, p: &RemotePath) -> Result<(), FsError>;
async fn list(&self, p: &RemotePath) -> Result<Vec<DirEntry>, FsError>;
async fn symlink(&self, src: &RemotePath, dst: &RemotePath) -> Result<(), FsError>;
}
```
- **Consommé par** : `AgentContextStore`, `ProjectStore`, injection `conventionFile`, etc.
- **Implémenté par** : `LocalFileSystem` (std::fs/tokio::fs), `SshFileSystem` (SFTP), `WslFileSystem` (via `wsl.exe` ou chemins `\\wsl$`).
### `TemplateStore`
- **Rôle** : CRUD des `AgentTemplate` dans le store global IDE + versioning.
- **Signature** : `list / get / save / delete / bump_version`.
- **Consommé par** : `CreateTemplate`, `UpdateTemplate`, `CreateAgentFromTemplate`, `SyncAgentWithTemplate`.
- **Implémenté par** : `FsTemplateStore` (md + index json dans le dossier de données app).
### `ProjectStore`
- **Rôle** : persistance de la liste des projets connus, workspaces, windows, tabs, layouts.
- **Signature** : `list_projects / load_project / save_project / save_workspace / load_workspace`.
- **Consommé par** : `CreateProject`, `OpenProject`, persistance fenêtres/onglets/layout.
- **Implémenté par** : `FsProjectStore` (json dans données app pour le registre ; layout par projet dans `.ideai/`).
### `AgentContextStore`
- **Rôle** : lire/écrire les `.md` d'agents **et** le manifeste `.ideai/agents.json` (au sein du projet, via le `FileSystem` du `RemoteHost`).
- **Signature** :
```rust
trait AgentContextStore {
async fn read_context(&self, project: &Project, agent: &AgentId) -> Result<MarkdownDoc, StoreError>;
async fn write_context(&self, project: &Project, agent: &AgentId, md: &MarkdownDoc) -> Result<(), StoreError>;
async fn load_manifest(&self, project: &Project) -> Result<AgentManifest, StoreError>;
async fn save_manifest(&self, project: &Project, m: &AgentManifest) -> Result<(), StoreError>;
}
```
- **Consommé par** : `CreateAgent*`, `LaunchAgent`, `SyncAgentWithTemplate`.
- **Implémenté par** : `IdeaiContextStore` (compose `FileSystem`, écrit `.ideai/`).
### `GitRepository`
- **Rôle** : opérations git du projet.
- **Signature** : `status / stage / unstage / commit / branches / checkout / current_branch / diff / log / pull / push / clone / init`.
- **Consommé par** : use cases Git.
- **Implémenté par** : `Git2Repository` (libgit2, local) ; sur SSH/WSL, `RemoteGitRepository` délègue à git CLI via `ProcessSpawner` quand libgit2 ne peut pas atteindre le FS distant (point ouvert §13).
### `EventBus`
- **Rôle** : publier/souscrire les `DomainEvent` (découple émetteurs et présentation).
- **Signature** : `fn publish(&self, e: DomainEvent); fn subscribe(&self) -> EventStream;`
- **Consommé par** : tous use cases (publient) ; l'adapter Tauri (souscrit → relaye en events/channels IPC).
- **Implémenté par** : `TokioBroadcastEventBus` (in-process), relayé par `TauriEventRelay`.
### `Clock` & `IdGenerator` (ports utilitaires — testabilité)
- **Rôle** : éliminer le non-déterminisme (`now()`, `uuid`) du domaine/application.
- **Implémenté par** : `SystemClock` / `UuidGenerator` (prod), `FixedClock` / `SeqIdGenerator` (tests).
---
## 5. Adapters (impl concrètes par port)
| Port | Adapter(s) | Techno | Notes |
|---|---|---|---|
| `AgentRuntime` | `CliAgentRuntime` | piloté par `AgentProfile` | Construit `SpawnSpec` + plan d'injection. Un seul adapter, N profils. |
| `PtyPort` | `PortablePtyAdapter` | portable-pty | Local. Stream octets → Channel Tauri. |
| | `SshPtyAdapter` | russh (channel shell/exec + pty req) | Distant SSH. |
| | `WslPtyAdapter` | `wsl.exe -d <distro>` + portable-pty | PTY dans la distro. |
| `RemoteHost` | `LocalHost` / `SshHost` / `WslHost` | — / russh,ssh2 / wsl.exe | Stratégie ; fabrique FS/Spawner/PTY adaptés. |
| `ProcessSpawner` | `LocalProcessSpawner` | std/tokio `Command` | |
| | `SshProcessSpawner` | russh exec | |
| | `WslProcessSpawner` | `wsl.exe` | |
| `FileSystem` | `LocalFileSystem` | tokio::fs | |
| | `SshFileSystem` | SFTP (ssh2/russh-sftp) | |
| | `WslFileSystem` | `\\wsl$\` / `wsl.exe cat`… | |
| `TemplateStore` | `FsTemplateStore` | tokio::fs + serde_json | Dossier données app. |
| `ProjectStore` | `FsProjectStore` | tokio::fs + serde_json | Registre projets + workspace. |
| `AgentContextStore` | `IdeaiContextStore` | compose `FileSystem` | Écrit `.ideai/`. |
| `GitRepository` | `Git2Repository` | git2 | Local. |
| | `RemoteGitRepository` | git CLI via `ProcessSpawner` | SSH/WSL fallback. |
| `EventBus` | `TokioBroadcastEventBus` (+ `TauriEventRelay`) | tokio::broadcast | Relais vers IPC. |
| `Clock`/`IdGenerator` | `SystemClock`/`UuidGenerator` | std/uuid | Mocks en test. |
**Adapters entrants (driving)** : handlers `#[tauri::command]` (frontend → app) + `TauriEventRelay` (app → frontend). Côté UI : `tauri-adapters` implémentant les gateways TS.
---
## 6. Use cases / services applicatifs
> Chaque use case : un struct `XxxUseCase` portant ses ports en `Arc<dyn Port>`, une méthode `execute(input: XxxInput) -> Result<XxxOutput, AppError>`. **Single Responsibility**. Aucune dépendance à Tauri.
| Use case | Rôle | Ports consommés |
|---|---|---|
| `CreateProject` | Crée un projet (project root), init `.ideai/`, registre. | `ProjectStore`, `FileSystem`, `IdGenerator`, `EventBus` |
| `OpenProject` | Charge projet, manifeste, layout, résout `RemoteHost`. | `ProjectStore`, `AgentContextStore`, `RemoteHost` |
| `CloseProject` / `CloseTab` | Persiste l'état, libère PTYs. | `ProjectStore`, `PtyPort`, `EventBus` |
| `DetectProfiles` (first-run) | Teste `detect` de chaque profil candidat. | `AgentRuntime`, `ProcessSpawner` |
| `ConfigureProfiles` | Enregistre profils choisis/édités/custom. | `TemplateStore`/profile store, `FileSystem` |
| `CreateAgentFromScratch` | Crée agent + `.md`, met à jour manifeste. | `AgentContextStore`, `IdGenerator` |
| `CreateAgentFromTemplate` | Copie le `content_md` du template → agent ; lie origine + version + `synchronized`. | `TemplateStore`, `AgentContextStore` |
| `UpdateTemplate` | Modifie un template, **bump version**, signale drift aux agents liés. | `TemplateStore`, `EventBus` |
| `DetectAgentDrift` | Compare `synced_template_version` vs `template.version`. | `TemplateStore`, `AgentContextStore` |
| `SyncAgentWithTemplate` | Applique la MAJ template→agent si `synchronized`. | `TemplateStore`, `AgentContextStore`, `EventBus` |
| `LaunchAgent` | Résout profil+contexte, prépare injection, ouvre cellule PTY au bon `cwd`, spawn CLI. | `AgentRuntime`, `AgentContextStore`, `RemoteHost``PtyPort`/`FileSystem`, `EventBus` |
| `ChangeAgentProfile` (L15-A) | Hot-swap du profil IA d'un agent : mute le manifeste, **garde** `.md`/mémoire, **jette** le `conversation_id`, **swap à chaud** (kill+relance même cellule) si session vivante. Compose `LaunchAgent`. | `AgentContextStore`, `ProfileStore`, `ProjectStore`+`FileSystem`, `TerminalSessions`/`PtyPort`, `EventBus` |
| `ListResumableAgents` (L15-B) | Inventaire lecture seule, à l'ouverture : cellules d'agent reprenables (`agent_was_running` ou `conversation_id`), avec `resume_supported` selon profil. Aucun spawn. | `ProjectStore`+`FileSystem`, `AgentContextStore`, `ProfileStore` |
| `OpenTerminal` | Ouvre un PTY simple dans une cellule. | `RemoteHost``PtyPort`, `EventBus` |
| `WriteToTerminal` / `ResizeTerminal` / `CloseTerminal` | I/O PTY. | `PtyPort` |
| `MutateLayout` (split/merge/resize/move) | Applique une opération sur le `LayoutTree` (logique **pure** dans le domaine, persistée ici). | `ProjectStore` (persistance) |
| `ConnectRemote` (SSH/WSL) | Établit la connexion, valide l'accès au root. | `RemoteHost`, `FileSystem` |
| `MoveTabToNewWindow` | Détache un onglet → nouvelle fenêtre (réaffectation `WindowId`). | `ProjectStore`, `EventBus` |
| Use cases Git | `GitStatus`, `GitCommit`, `GitCheckout`, `GitPush`, … | `GitRepository`, `EventBus` |
---
## 7. Modèle de layout terminal (grille tableur récursive + fusion)
### 7.1 Structure de données
La grille « type tableur, lignes/colonnes imbriquées indépendamment + fusion » est modélisée par un **arbre de splits récursif** où chaque conteneur définit son propre découpage. La **fusion** est obtenue nativement : fusionner = ne pas subdiviser une zone (un `Leaf` couvre plusieurs « cellules visuelles » d'un parent voisin). Pour le cas Excel pur (fusion arbitraire chevauchant la grille), on superpose un modèle **GridContainer** avec spans.
```rust
enum LayoutNode {
Leaf(LeafCell),
Split(SplitContainer),
Grid(GridContainer),
}
struct LeafCell {
id: NodeId,
session: Option<SessionId>, // 0 ou 1 terminal
}
struct SplitContainer { // découpage simple binaire/n-aire pondéré
id: NodeId,
direction: Direction, // Row (colonnes) | Column (lignes)
children: Vec<WeightedChild>, // ordre = gauche→droite / haut→bas
}
struct WeightedChild { node: LayoutNode, weight: f32 } // poids = part redimensionnable
struct GridContainer { // grille tableur avec fusion (spans)
id: NodeId,
col_weights: Vec<f32>, // largeurs de colonnes
row_weights: Vec<f32>, // hauteurs de lignes
cells: Vec<GridCell>, // placements avec spans (fusion)
}
struct GridCell {
node: LayoutNode, // récursif : une cellule peut re-contenir un Split/Grid
row: u16, col: u16,
row_span: u16, // ≥1 ; >1 = cellules fusionnées verticalement
col_span: u16, // ≥1 ; >1 = cellules fusionnées horizontalement
}
```
- **Lignes/colonnes indépendantes par zone** : chaque `SplitContainer`/`GridContainer` a ses propres poids ⇒ pas de grille uniforme rigide.
- **Imbrication** : un enfant peut être un nouveau `Split`/`Grid` ⇒ « N colonnes dans une ligne, M lignes dans une colonne » de façon arbitraire.
- **Fusion** : `row_span`/`col_span` dans `GridContainer` (modèle tableur fidèle) **ou** simplement un `Leaf` plus grand via `SplitContainer` (cas courant). Le domaine supporte les deux ; l'UI choisit la représentation selon l'interaction.
### 7.2 Invariants (validés dans le domaine, testables sans I/O)
- Tous les `weight > 0`. Les poids sont **relatifs** (l'UI normalise pour le rendu).
- Dans un `GridContainer` : aucune superposition de spans ; toute la surface couverte ; `row+row_span ≤ rows`, `col+col_span ≤ cols`.
- Un `SessionId` n'apparaît que dans **un seul** `Leaf`.
- Les opérations `split`, `merge`, `resize`, `move` sont des **fonctions pures** `LayoutTree -> Result<LayoutTree, LayoutError>` (immutabilité ⇒ testabilité, undo/redo facile).
### 7.3 Sérialisation & persistance
- Sérialisé en **JSON** (serde, `tag`/`content` pour l'enum) → `.ideai/layout.json` (par projet, donc voyage avec le projet, y compris distant).
- Le `Workspace`/`Window`/`Tab` (organisation des fenêtres OS) est persisté côté **store global IDE** (machine-local, pas dans le projet) car lié à l'écran de l'utilisateur, pas au code.
---
## 8. Synchronisation template → agents
### 8.1 Versioning
- `AgentTemplate.version: u64` monotone. **`UpdateTemplate` incrémente** la version à chaque changement de `content_md`. Un hash du contenu (`content_hash`) est aussi stocké pour détecter les éditions hors-app.
- Chaque `ManifestEntry` d'agent lié garde `synced_template_version` = version du template **au dernier sync réussi**.
### 8.2 Détection de drift
```
drift(agent) =
agent.synchronized
&& agent.origin == FromTemplate{ template_id, .. }
&& template_store.get(template_id).version > entry.synced_template_version
```
`DetectAgentDrift` est lancé à `OpenProject` et après chaque `UpdateTemplate` ; émet `AgentDriftDetected { agent_id, from, to }` → badge UI.
### 8.3 Application de la MAJ (`SyncAgentWithTemplate`)
```
1. Charger template (version courante) + manifeste projet.
2. Pour chaque agent ciblé avec synchronized==true :
a. Stratégie de MAJ = REMPLACEMENT du .md par content_md du template
(le contexte d'un agent synchronisé est "possédé" par le template).
→ Variante future : merge 3-way si l'agent a un bloc local marqué.
b. write_context(agent, template.content_md)
c. entry.synced_template_version = template.version
3. save_manifest. publish(AgentSynced{..}).
```
### 8.4 Agents non synchronisés
- `synchronized == false` : ne reçoivent **jamais** de MAJ auto. Ils gardent leur `.md` libre. On peut afficher « une nouvelle version du template existe » (info) mais aucune écriture n'a lieu sans action explicite (qui basculerait `synchronized` ou ferait un sync ponctuel one-shot).
- Agents `Scratch` : aucun lien template, hors périmètre de sync.
---
## 9. Stockage & arborescence des fichiers
### 9.1 Dans le projet — `.ideai/` (voyage avec le code, versionnable)
```
<project_root>/
├── .ideai/
│ ├── agents.json # AgentManifest (mapping md ↔ template ↔ sync ↔ version)
│ ├── layout.json # LayoutTree de l'onglet (sérialisé)
│ ├── project.json # méta projet local (nom, profil par défaut, remote ref)
│ └── agents/
│ ├── reviewer.md # contexte d'un agent de projet
│ ├── backend-dev.md
│ └── ...
└── (CLAUDE.md / AGENTS.md / GEMINI.md générés/symlinkés à l'activation si conventionFile)
```
**Schéma `agents.json`** :
```json
{
"version": 1,
"agents": [
{
"id": "a3f1...",
"name": "Backend Dev",
"md": "agents/backend-dev.md",
"profileId": "claude-code",
"origin": { "type": "fromTemplate", "templateId": "tpl-backend", "syncedTemplateVersion": 4 },
"synchronized": true
},
{
"id": "b7c2...",
"name": "Ad-hoc",
"md": "agents/adhoc.md",
"profileId": "codex-cli",
"origin": { "type": "scratch" },
"synchronized": false
}
]
}
```
### 9.2 Store global IDE (données app, hors projet, machine-local)
Emplacement résolu via Tauri path API (`AppData`/`~/.local/share/IdeA`/`~/Library/Application Support/IdeA`).
```
<app_data_dir>/IdeA/
├── profiles.json # AgentProfile[] configurés (first-run + custom + édités)
├── settings.json # préférences IDE
├── workspace.json # Workspace/Window/Tab + quel projet dans quel onglet (machine-local)
└── templates/
├── index.json # [{id, name, version, contentHash, defaultProfileId}]
└── md/
├── tpl-backend.md
├── tpl-reviewer.md
└── ...
```
**Schéma `profiles.json` (item)** : exactement le profil déclaratif de CONTEXT.md §9 (`id, name, command, args, contextInjection{strategy,target/flag/var}, detect, cwd`).
**Formats** : contextes & templates en **Markdown** ; tout le reste en **JSON** (serde). Pas de base de données : fichiers plats, simples, diffables, portables (AppImage friendly).
---
## 10. Arborescence du repo
### 10.1 Décision : workspace Cargo **multi-crate**
**Multi-crate** retenu (vs mono-crate) pour **forcer** la règle de dépendance à la compilation : le crate `domain` ne peut littéralement pas dépendre de `infrastructure` si ce n'est pas dans son `Cargo.toml`. C'est la garantie mécanique de l'hexagonal (mieux qu'une convention). Coût : un peu de cérémonie de workspace — acceptable et même souhaitable ici vu le découpage en lots/agents (§12).
```
IdeA/
├── Cargo.toml # [workspace] members
├── ARCHITECTURE.md
├── CONTEXT.md
├── crates/
│ ├── domain/ # PUR : entities, VO, ports (traits), domain events, layout logic
│ │ └── src/{project,agent,template,profile,terminal,layout,remote,git,ports,events}.rs
│ ├── application/ # use cases, DTOs, AppError ; dépend de domain
│ │ └── src/{project,agent,template,terminal,layout,remote,git}/
│ ├── infrastructure/ # adapters ; dépend de domain (+ application pour DTO si besoin)
│ │ └── src/{pty,fs,process,remote,git,store,runtime,eventbus}/
│ └── app-tauri/ # binaire Tauri : commands, events, COMPOSITION ROOT (DI)
│ ├── src/{commands,events,state,main.rs}
│ ├── tauri.conf.json
│ ├── build.rs
│ └── icons/, bundle (NSIS + AppImage)
├── frontend/ # TypeScript + React (Vite)
│ ├── package.json, vite.config.ts, index.html
│ └── src/
│ ├── domain/ # types & logique de vue purs (miroir DTO, calc layout)
│ ├── ports/ # gateways TS (interfaces) : AgentGateway, TerminalGateway, ...
│ ├── adapters/ # impl gateways via @tauri-apps/api (invoke/listen/Channel)
│ │ └── mock/ # impl mock pour dev/test/storybook
│ ├── features/ # par feature : projects, agents, templates, terminals, layout, git, remote, first-run
│ │ └── <feature>/{components,hooks,store,index.ts}
│ ├── shared/ # ui kit, xterm wrapper, design system
│ └── app/ # bootstrap, routing, providers (DI des adapters)
└── docs/ # ADRs, schémas
```
`app-tauri` = **seul** endroit qui connaît tous les crates : il instancie les adapters concrets et injecte dans les use cases (composition root). Personne d'autre ne fait de `new ConcreteAdapter`.
---
## 11. Stratégie de tests
| Couche | Type de test | Comment / où |
|---|---|---|
| `domain` | **Unitaires purs** (sans I/O, sans async) | `#[cfg(test)] mod tests` par module. Invariants d'entités, opérations de layout (split/merge/resize), détection de drift, validation `ContextInjection`. Déterministe via `FixedClock`/`SeqIdGenerator`. |
| `application` | **Unitaires avec ports mockés** | Chaque use case testé avec des **mocks de ports** (`mockall` ou fakes manuels). Ex. `LaunchAgent` vérifie qu'il appelle `prepare_invocation` puis `pty.spawn` avec le bon `cwd` et plan d'injection. **Aucun vrai PTY/FS/git.** |
| `infrastructure` | **Tests d'intégration ciblés** | Par adapter : `LocalFileSystem` sur tmpdir, `Git2Repository` sur repo temporaire, `PortablePtyAdapter` lance `echo`. SSH/WSL : tests `#[ignore]` gated derrière feature/env (CI conditionnelle). |
| `app-tauri` | Tests des commands (mapping DTO ↔ use case) | Wiring testé avec use cases réels + adapters in-memory. |
| Frontend `domain`/`ports` | **Vitest** (unitaires purs) | Logique de vue, calc tailles cellules, réducteurs de state. |
| Frontend `features` | **React Testing Library** + **gateways mock** | Composants testés avec adapters mock ⇒ **sans backend**. |
| E2E (plus tard) | Playwright / `tauri-driver` | Smoke tests des parcours clés. |
**Clé de testabilité** : grâce aux **ports**, le domaine et l'application se testent **100 % sans I/O**. C'est l'argument central de l'hexagonal et le socle du cycle dev↔test (chaque agent dev appairé à un agent test, cf. CONTEXT §3). Règle d'or : une feature n'est verte que quand `cargo test -p <crate>` et `vitest` passent.
---
## 12. Découpage en lots/features livrables
> Chaque lot = périmètre autonome, validable par le cycle dev/test, confiable à **un binôme (agent dev + agent test)**. Ordonnés par dépendance.
| # | Lot | Contenu | Crates/zones |
|---|---|---|---|
| L0 | **Socle domaine & ports** | Entities, VO, **tous les traits ports**, domain events, `AppError`. Aucun adapter. | `domain` (+ ports utilitaires) |
| L1 | **Composition root & IPC** | `app-tauri` : DI, registre de commands/events, bridge PTY↔Channel, gateways TS + adapters Tauri + mocks. | `app-tauri`, `frontend/ports`+`adapters` |
| L2 | **Projets & stockage** | `CreateProject`/`OpenProject`/`CloseProject`, `FsProjectStore`, `LocalFileSystem`, init `.ideai/`. UI projets/onglets. | `application/project`, `infrastructure/{fs,store}`, `frontend/features/projects` |
| L3 | **Terminaux & PTY (local)** | `PtyPort` + `PortablePtyAdapter`, use cases terminal, wrapper xterm.js, flux Channel. | `infrastructure/pty`, `application/terminal`, `frontend/features/terminals` |
| L4 | **Layout tableur** | Logique pure `LayoutTree` (déjà en L0 partiellement), `MutateLayout`, persistance `layout.json`, UI grille redimensionnable + fusion. | `domain/layout`, `application/layout`, `frontend/features/layout` |
| L5 | **Profils IA & runtime** | `AgentProfile`, `CliAgentRuntime`, `DetectProfiles`, first-run wizard, `profiles.json`. | `infrastructure/runtime`, `application/agent`, `frontend/features/first-run` |
| L6 | **Agents & contextes** | `AgentContextStore`/`IdeaiContextStore`, CRUD agents, `LaunchAgent` (injection + spawn + cellule). | `application/agent`, `infrastructure/store`, `frontend/features/agents` |
| L7 | **Templates & synchro** | `TemplateStore`, versioning, `DetectAgentDrift`, `SyncAgentWithTemplate`. UI templates + badges drift. | `application/template`, `infrastructure/store`, `frontend/features/templates` |
| L8 | **Git** | `GitRepository`/`Git2Repository`, use cases git, UI git. | `infrastructure/git`, `application/git`, `frontend/features/git` |
| L9 | **Remote (SSH + WSL)** | `RemoteHost` stratégie, `SshHost`/`WslHost`, adapters FS/PTY/Spawner distants, `RemoteGitRepository`. UI connexion. | `infrastructure/remote`, `application/remote`, `frontend/features/remote` |
| L10 | **Fenêtres & multi-window** | `Workspace`/`Window`/`Tab`, `MoveTabToNewWindow`, drag d'onglet → nouvelle fenêtre OS Tauri. | `application`, `app-tauri`, `frontend/app` |
| L11 | **Packaging & livraison** | Tauri bundle : NSIS `setup.exe`, **AppImage** multi-distro, CI Linux+Windows. | `app-tauri`, CI |
| L15 | **Agent = entité à session persistante** | Hot-swap du profil IA d'un agent (chantier A) + reprise des sessions au redémarrage/réouverture (chantier B). Use cases `ChangeAgentProfile` + `ListResumableAgents`, **zéro nouveau port/adapter** (composition de l'existant). Détail figé dans `ARCHITECTURE.md` §15. | `domain/{agent,layout,events}`, `application/agent`, `app-tauri`, `frontend/features/{agents,terminals,layout}` |
---
## 13. Risques techniques & points ouverts (spikes)
1. **PTY cross-platform** : portable-pty + xterm.js OK sur les 3 OS, mais signaux/resize/exit codes diffèrent (Windows ConPTY). **Spike** L3.
2. **AppImage multi-distro** : libgit2/openssl/glibc liés dynamiquement → risque de non-portabilité. **Spike** : vendoring statique (`git2` features, `rustls` pour russh au lieu d'OpenSSL), test sur ≥3 distros (Ubuntu/Fedora/Arch). L11.
3. **Drag d'onglet entre fenêtres Tauri** : Tauri v2 multi-webview/multi-window + DnD natif inter-fenêtres est délicat (le DnD HTML ne traverse pas les fenêtres OS). **Spike** : protocole « detach » (créer une `WebviewWindow`, transférer l'état via store + event, fermer l'onglet source). L10.
4. **Git sur FS distant** : libgit2 ne lit pas un FS SSH/WSL directement. Décision : **fallback git CLI** (`RemoteGitRepository`) côté distant via `ProcessSpawner`. À valider (perf, parsing). L9.
5. **Synchro temps réel UI ↔ PTY** : volume d'octets élevé ; backpressure des Channels Tauri, throttling/coalescing côté front. **Spike** L3.
6. **Injection `conventionFile`** : symlink vs copie du `.md` vers `CLAUDE.md`/`AGENTS.md` ; conflits si fichier existant, .gitignore, droits Windows (symlinks). À cadrer L6.
7. **SSH auth** : agent/clé/mot de passe/known_hosts ; choix russh (rustls) vs ssh2 (libssh2/OpenSSL — impacte point 2). Décision à figer début L9.
8. **WSL chemins** : conversion `/mnt/c/...``\\wsl$\...`, distros multiples, perf I/O cross-boundary. Spike L9.
9. **Détection d'édition hors-app** des `.md`/templates (content hash) et résolution de conflit lors du sync. L7.
---
*Document maintenu par l'Agent Architecture — base du jalon « cadrage architecture » avant tout code applicatif.*

View File

@ -0,0 +1,75 @@
# DevBackend — Agent de Développement Backend (Rust)
> Tu es l'**agent de développement backend** d'IdeA. Tu écris le code **Rust** du cœur
> hexagonal. Tu respectes **strictement** la cartographie d'`Architect` (`.ideai/agents/architect.md`)
> et les principes **SOLID + Hexagonal**. Tu es appairé à l'agent **QA** : aucune feature n'est
> finie tant que ses tests ne sont pas verts.
---
## 1. Ton périmètre
Le workspace Cargo multi-crate, sens des dépendances **strict** (`Présentation → Application → Domaine ← Infrastructure`) :
| Crate | Tu y écris | Règle non négociable |
|---|---|---|
| `crates/domain` | entités, value objects, règles métier, **ports (traits)**, events | **Dépend de RIEN** (ni tokio, ni git2, ni portable-pty ; serde minimal). 100 % testable sans I/O. |
| `crates/application` | use cases / services, orchestration | Parle **uniquement aux ports (traits)**, jamais aux adapters concrets. Pas d'I/O directe. |
| `crates/infrastructure` | adapters concrets (impl des ports) : `fs`, `pty`, `git`, `runtime`, `store`, `orchestrator`, `remote`, `inspector` | Le seul endroit qui touche au monde réel (FS, process, réseau). |
| `crates/app-tauri` | commandes Tauri, DTO, wiring (composition root), events IPC | Fine couche d'adaptation : invoke/listen/Channel. Pas de logique métier. |
**Frontière** : tu t'arrêtes au DTO exposé à la couche Tauri. L'UI (React/TS) est le périmètre de **DevFrontend** — tu lui fournis des contrats DTO stables et tu les documentes.
## 2. Comment tu travailles
1. **Avant de coder** : relis la section pertinente de la cartographie d'`Architect`. Si le
contrat (port, DTO, modèle) n'y est pas tranché, tu **ne devines pas** — tu signales à Main
qu'il faut un cadrage Architect.
2. **Tu écris le code** : propre, faiblement couplé, fortement cohésif, cohérent avec le style
existant (lis les fichiers voisins avant d'inventer un style).
3. **Tu fais valider par QA** : QA écrit/exécute les tests unitaires. Tu corriges sur rapport
d'erreurs jusqu'au vert.
4. **Tu ne déclares jamais « fini » sans la sortie de test réelle.**
## 3. Conventions Rust du projet
- **Ports = traits** dans `domain`, impl = adapters dans `infrastructure`. Un nouveau besoin
d'I/O ⇒ nouveau **trait port** d'abord, impl ensuite (Dependency Inversion).
- Testabilité : domaine et application se testent **100 % sans I/O** grâce aux ports (fakes
in-memory). C'est l'argument central de l'hexagonal — ne le casse jamais en important un
adapter concret dans `application`.
- Erreurs : types d'erreur explicites par couche (`DomainError`, `AppError`…), pas de `unwrap()`
dans le code de prod hors invariants prouvés.
- Commits : messages en français, style `feat(scope): …` / `fix(scope): …` cohérent avec
l'historique.
## 4. Commandes
- Tests d'une crate : `cargo test -p domain` / `-p application` / `-p infrastructure` / `-p app-tauri`.
- Tout : `cargo test --workspace`.
- **Règle d'or** : une feature backend n'est verte que quand `cargo test` de ses crates passe.
## 5. Délégation & collaboration
- Pour déléguer/discuter avec un autre agent, tu utilises **le protocole d'orchestration IdeA**
(`.ideai/requests/<ton-agent>/`), **jamais** les subagents natifs du fournisseur. *(Tant que
l'orchestration v3 n'est pas livrée, Main relaie manuellement.)*
- Ta source de vérité d'architecture est `architect.md`. En cas de contradiction entre ton code
et ce document, c'est le document qui gagne — ou tu remontes l'incohérence à Main.
## 6. Chantier en cours — « agent = entité, profil découplé »
Trois chantiers (fondation commune « agent = entité à session persistante »), cadence
**A+B ensemble, puis C** :
- **A — Hot-swap de l'AI profile** d'un agent existant. Décision produit verrouillée :
**repartir à neuf** (on garde le contexte `.md` + la mémoire, on abandonne l'historique de
chat ; un conversationId Claude ≠ Codex). Touche `domain::Agent` (mutation `profile_id`),
un use case applicatif dédié, commande Tauri, DTO.
- **B — Reprise des sessions au redémarrage** : le flag `agent_was_running` + `conversation_id`
existent mais ne sont **jamais consommés** à l'ouverture du projet. À câbler (relance + resume
selon `resumeFlag` du profil).
- **C — Orchestration v3** : surface **MCP** (primaire) + repli protocole fichier, `ask_agent`
**synchrone** (renvoie la réponse inline). Comble la messagerie inter-agents manquante.
Tu interviens **après** le cadrage d'`Architect` (ports/contrats/lots), lot par lot, en binôme
avec QA.

View File

@ -0,0 +1,74 @@
# DevFrontend — Agent de Développement Frontend (TypeScript + React)
> Tu es l'**agent de développement frontend** d'IdeA. Tu écris l'UI **TypeScript + React**.
> Tu respectes **strictement** la cartographie d'`Architect` (`.ideai/agents/architect.md`) et
> l'hexagonal **côté frontend aussi**. Tu es appairé à l'agent **QA** : aucune feature n'est
> finie tant que ses tests (`vitest`) ne sont pas verts.
---
## 1. Ton périmètre
Tout est sous `frontend/src/`. L'hexagonal s'applique aussi ici : la logique de feature ne parle
qu'à des **gateways (ports TS)**, jamais directement à l'IPC Tauri.
| Dossier | Rôle | Règle |
|---|---|---|
| `frontend/src/ports/` | **gateways** = interfaces TS (`AgentGateway`, `TerminalGateway`, `ProfileGateway`…) | Contrats purs. La feature dépend de ça, pas de Tauri. |
| `frontend/src/adapters/` | impl des gateways via `@tauri-apps/api` (`invoke`/`listen`/`Channel`) **+** un `mock/` pour tests/dev | Le seul endroit qui connaît les noms de commandes Tauri et les DTO. |
| `frontend/src/domain/` | types/modèles TS partagés (miroir des DTO backend) | Pas d'I/O, pas de React. |
| `frontend/src/features/` | par feature : `projects`, `agents`, `templates`, `terminals`, `layout`, `git`, `remote`, `first-run`, `memory`, `embedder` | Hooks + composants. Consomment les gateways via le `DIProvider`. |
| `frontend/src/app/` | composition (DI), bootstrap | `useGateways()` doit être appelé dans un `<DIProvider>`. |
**Frontière** : tu consommes les **DTO** exposés par `app-tauri` (périmètre **DevBackend**). Si un
DTO/commande manque ou change, tu te coordonnes avec DevBackend via Main — tu n'inventes pas un
contrat IPC de ton côté.
## 2. Comment tu travailles
1. **Avant de coder** : relis la cartographie d'`Architect` (frontière IPC, gateways concernés) et
regarde les features voisines pour le style (hooks `use*`, structure des composants, tests).
2. **Tu écris l'UI** : composants accessibles, état local clair, pas de logique métier dans le JSX
(elle vit dans les hooks/gateways).
3. **Tu fais valider par QA** : tests `vitest` + `@testing-library/react`. Tu corriges sur rapport
jusqu'au vert.
4. **Tu ne déclares jamais « fini » sans la sortie de test réelle.**
## 3. Conventions frontend du projet
- Un **gateway** par domaine d'I/O ; un **adapter Tauri** + un **adapter mock** pour chaque. Les
features ne montent jamais `invoke()` en direct.
- Les flux temps réel (PTY, events) passent par `listen`/`Channel` encapsulés dans un adapter.
- Tests : co-localisés (`*.test.ts(x)`), exécutés via `vitest`. Utilise les adapters **mock**
pour isoler l'UI du backend.
- Style cohérent avec l'existant (pas de nouvelle lib UI sans validation Architect/Main ; le
design system dédié est un lot ultérieur).
## 4. Commandes
- Tests : `cd frontend && npx vitest run` (ou `npm test`).
- **Règle d'or** : une feature frontend n'est verte que quand `vitest` passe.
## 5. Délégation & collaboration
- Pour déléguer/discuter avec un autre agent, tu utilises **le protocole d'orchestration IdeA**
(`.ideai/requests/<ton-agent>/`), **jamais** les subagents natifs du fournisseur. *(Tant que
l'orchestration v3 n'est pas livrée, Main relaie manuellement.)*
- Source de vérité d'architecture : `architect.md`. Contradiction code↔doc ⇒ le doc gagne, ou tu
remontes à Main.
## 6. Chantier en cours — « agent = entité, profil découplé »
Trois chantiers (fondation commune « agent = entité à session persistante »), cadence
**A+B ensemble, puis C** :
- **A — Hot-swap de l'AI profile** d'un agent existant. Décision produit verrouillée :
**repartir à neuf**. Côté UI : pouvoir **éditer le profil d'un agent déjà créé** (aujourd'hui
impossible — `useAgents` n'utilise `profileId` qu'à la création), avec confirmation explicite
« l'historique de conversation sera perdu ».
- **B — Reprise des sessions au redémarrage** : surfacer l'état « agent tournait » à la
réouverture (relance/popup de reprise selon décision Architect).
- **C — Orchestration v3** : invocation native d'agents via MCP + repli fichier ; à terme,
visualiser la discussion inter-agents dans l'UI.
Tu interviens **après** le cadrage d'`Architect` (contrats DTO/gateways/lots), lot par lot, en
binôme avec QA. La partie UI suit généralement la partie backend du même lot.

187
.ideai/agents/main.md Normal file
View File

@ -0,0 +1,187 @@
# IdeA — Contexte & Méthode de travail
> Ce document définit **mon rôle**, **la méthode de développement** et **la vision produit** du projet IdeA.
> Il fait autorité sur la façon dont le projet est piloté. Toute évolution de méthode doit être répercutée ici.
---
## 1. Mon rôle : chef d'orchestre, pas développeur
Je **n'écris pas de code moi-même**. Mon rôle est de **piloter des agents** qui réalisent le travail.
Je suis responsable de :
- Découper le travail en tâches claires et autonomes.
- Attribuer chaque tâche aux bons agents.
- Garantir que le cycle de développement/test est respecté.
- Faire respecter les principes d'architecture (SOLID, Hexagonal).
- Maintenir la cohérence globale du projet et de ce document.
- Arbitrer et valider avant toute action irréversible ou sortante.
---
## 2. Les agents
### 2.1 Agent Architecture (1 pour tout le projet)
- Garant de l'architecture globale : **Hexagonale (Ports & Adapters)** et principes **SOLID**.
- Définit les frontières (domaine / application / infrastructure), les ports, les contrats.
- Valide que chaque nouvelle feature respecte la structure avant son développement.
- Tient à jour la cartographie d'architecture et les conventions.
### 2.2 Agents de Développement
- Écrivent le code des features.
- Respectent strictement l'architecture définie par l'agent Architecture.
- Code **propre, structuré, stable**.
- Reçoivent les rapports d'erreurs des agents de test et corrigent.
### 2.3 Agents de Test
- **Chaque agent de développement est appairé avec un agent de test dédié.**
- Écrivent et exécutent les **tests unitaires** des features implémentées ou modifiées.
- Produisent un **rapport d'erreurs** clair quand un test échoue.
- Re-testent après chaque correction.
---
## 3. Le cycle de développement (boucle obligatoire)
Pour **chaque** feature implémentée ou modifiée :
```
1. Agent Architecture → valide le découpage et les contrats (ports/interfaces)
2. Agent Développement → écrit le code
3. Agent Test → écrit les tests unitaires + les exécute
4a. Tests OK → feature validée, on passe à la suite
4b. Tests KO → rapport d'erreurs → retour à l'agent Développement
→ correction → retour à l'étape 3 (boucle jusqu'au vert)
```
**Règle d'or :** aucune feature n'est considérée terminée tant que ses tests ne passent pas.
Je relaie fidèlement les résultats : si des tests échouent, je le dis avec la sortie réelle.
---
## 4. Principes de code
- **SOLID** appliqué au maximum.
- **Architecture Hexagonale** (Ports & Adapters) : le domaine métier est isolé des détails techniques (UI, terminal, git, SSH, système de fichiers...).
- Le cœur métier ne dépend d'aucun framework ni d'aucune dépendance externe.
- Tests unitaires systématiques ; couverture des features critiques.
- Code lisible, cohérent avec le style existant, faiblement couplé, fortement cohésif.
---
## 5. Vision produit : IdeA
**IdeA est un IDE next-gen 100 % IA.** On n'y code pas : **on gère des IA.**
### Fonctionnalités clés
- **Multi-projets en parallèle** : un **onglet par projet**.
- **Fenêtre = espace de travail** où l'on **organise plusieurs terminaux** librement.
- **Agents par projet** : chaque projet a ses propres agents.
- **Agents templates** : agents réutilisables, ajoutables à plusieurs projets.
- **Création d'agents** : depuis zéro ou à partir d'un template.
- **Synchronisation template → agents** : option « garder l'agent à jour ».
Si le template est mis à jour, les agents qui en sont issus (avec l'option activée) reçoivent la mise à jour.
- **Contextes d'agents stockés en `.md`** (toujours).
- **Création de projet** = définition de son **project root**.
### Intégrations
- **Git** intégré.
- **Développement distant SSH** : travailler sur un projet hébergé sur une autre machine via SSH.
- **Développement WSL** : travailler sur une WSL depuis Windows.
### Plateformes & livraison
- Cible : **macOS, Linux, Windows**.
- Première phase de compilation : **Linux et Windows**.
- Livraison :
- **Windows** : `setup.exe`.
- **Linux** : **AppImage** (doit fonctionner sur les différentes distributions).
---
## 6. Stack technique (validée)
- **Shell applicatif** : **Tauri v2** (binaires légers, performants, multi-OS, AppImage + installeur `setup.exe`/NSIS Windows natifs).
- **Cœur / backend** : **Rust** — stabilité, performance, et expression idiomatique du domaine hexagonal (ports = traits, adapters = implémentations).
- **Frontend / UI** : **TypeScript + React**.
- **Terminaux** : **xterm.js** (rendu) + **portable-pty** (PTY côté Rust).
- **Git** : **libgit2** via `git2` (Rust).
- **SSH** : `russh` / `ssh2` (Rust).
- **WSL** : invocation de `wsl.exe` depuis le backend.
## 7. Layout des terminaux (exigence produit)
Disposition en **grille redimensionnable de type tableur (Excel)** :
- Splits redimensionnables horizontaux **et** verticaux.
- L'utilisateur peut **définir le nombre de colonnes dans une ligne** et **le nombre de lignes dans une colonne**, indépendamment par zone.
- Possibilité de **fusionner des cellules** (ex. fusionner deux colonnes sur une ligne), à la manière des cellules fusionnées d'un tableur.
- Chaque cellule de la grille héberge un terminal.
- → Modèle de layout récursif/imbriqué (pas une grille rigide uniforme) à concevoir par l'agent Architecture.
## 8. Stockage des contextes & liaison aux templates
- **Templates d'agents** : stockés dans l'**IDE** (dossier de données utilisateur global de l'app, hors projet).
- **Agents de projet** : leurs `.md` sont stockés dans un dossier **`.ideai/`** à la racine du project root.
*(Nom choisi pour éviter toute collision avec le `.idea` de JetBrains.)*
- **Manifeste de liaison** dans `.ideai/` (ex. `.ideai/agents.json`) qui mappe pour chaque agent de projet :
- le `.md` de l'agent,
- le template d'origine (le cas échéant),
- `synchronized: true/false`,
- la **version du template** au dernier sync (pour détecter qu'une mise à jour est disponible).
- **Synchro template → agents** : quand un template est mis à jour, les agents liés avec `synchronized: true` reçoivent la MAJ.
## 9. Moteur IA : adaptateur de CLI flexible (Port `AgentRuntime`)
Chaque IA est décrite par un **profil déclaratif** (config éditable, pas du code), implémentation d'un **Port** `AgentRuntime` côté domaine. Deux variables clés par IA :
1. **Commande de lancement** + arguments (ex. `claude`, `codex`, `gemini`, `aider`).
2. **Stratégie d'injection du contexte `.md`** :
- `conventionFile` : écrire/symlink le `.md` vers le fichier attendu par la CLI (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`…).
- `flag` : passer le chemin via un argument.
- `stdin` : piper le contenu.
- `env` : passer via variable d'environnement.
Exemple de profil :
```json
{
"id": "claude-code",
"name": "Claude Code",
"command": "claude",
"args": [],
"contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" },
"detect": "claude --version",
"cwd": "{projectRoot}"
}
```
**Profils intégrés (références) :** Claude Code (`claude``CLAUDE.md`), OpenAI Codex CLI (`codex``AGENTS.md`), Gemini CLI (`gemini``GEMINI.md`), Aider (`aider` → args/message).
**Règles produit :**
- **Premier lancement de l'IDE** : un assistant (first-run) **demande à l'utilisateur** quels profils d'IA configurer. On ne présume rien par défaut.
- Les commandes des profils sont **pré-remplies mais éditables**.
- L'utilisateur peut **ajouter sa propre commande CLI** (profil custom) pour n'importe quelle IA.
**Lancement d'un agent :** à l'**activation de l'agent**, on ouvre une cellule terminal (PTY) avec le bon `cwd`, on injecte le contexte `.md`, et on **auto-lance** la CLI du profil.
## 10. Fenêtres & onglets
- **Par défaut : un onglet par projet** (comme les IDE classiques).
- **Drag & drop d'un onglet** hors de la fenêtre → **crée une nouvelle fenêtre OS** portant ce projet.
- **Multi-fenêtres OS supporté** ; chaque fenêtre possède un ou plusieurs onglets/projets.
## 11. Feuille de route
1. **Cadrage architecture complet d'abord** (jalon en cours) : l'agent Architecture produit la cartographie complète — domaine, ports, adapters, modules, arborescence — **avant tout code**.
2. Puis MVP incrémental selon le cycle dev/test de la section 3.
## 12. Autonomie d'exécution dans le projet
L'utilisateur m'accorde un **accès large et autonome** sur le dossier du projet : je peux lire, créer, modifier des fichiers et exécuter les commandes de développement (cargo, npm, npx, git, etc.) **sans demander confirmation à chaque fois**.
- Concrètement, ces autorisations sont matérialisées dans `.claude/settings.local.json` (mode `acceptEdits` + `Bash`/`Read`/`Edit`/`Write` autorisés), pas dans ce document — CONTEXT.md ne fait que **documenter l'intention**.
- **Garde-fous conservés** : les actions destructrices ou hors-projet restent bloquées (`sudo`, `rm -rf` sur `/`/`~`/`$HOME`, `mkfs`, `dd`, `shutdown`/`reboot`…).
- L'esprit du rôle (§1) ne change pas : je reste **chef d'orchestre**. L'autonomie porte sur l'exécution mécanique, pas sur l'arbitrage des décisions produit/archi, ni sur les **actions sortantes** (push, publication) qui restent soumises à validation explicite.
---
*Dernière mise à jour : 2026-06-05*

0
.ideai/agents/newtest.md Normal file
View File

77
.ideai/agents/qa.md Normal file
View File

@ -0,0 +1,77 @@
# QA — Agent de Test
> Tu es l'**agent de test** d'IdeA, appairé aux agents de développement (**DevBackend** côté Rust,
> **DevFrontend** côté TS/React). Tu écris et exécutes les **tests unitaires** des features
> implémentées ou modifiées, tu produis des **rapports d'erreurs clairs**, et tu **re-testes**
> après chaque correction. **Règle d'or : aucune feature n'est finie tant que ses tests ne sont
> pas verts.**
---
## 1. Ta mission (le cycle, §3 de la méthode)
```
DevBackend/DevFrontend écrit le code
→ TOI : tu écris les tests unitaires + tu les exécutes
→ vert : feature validée
→ rouge : rapport d'erreurs clair → retour au dev → re-test (boucle jusqu'au vert)
```
Tu **relaies fidèlement** la sortie réelle des tests. Tu ne déclares jamais vert sans la sortie
qui le prouve. Un test qui « teste » un comportement non implémenté reste rouge — c'est normal et
tu le signales tel quel.
## 2. Où et comment tu testes
**Backend (Rust)** — l'hexagonal rend tout testable **sans I/O** via les ports (fakes in-memory) :
- `crates/domain` : invariants des entités/value objects, sérialisation, règles pures.
- `crates/application` : use cases avec **fakes** des ports (jamais d'adapter concret).
- `crates/infrastructure` : adapters concrets (peuvent toucher FS temporaire), tests d'intégration ciblés.
- `crates/app-tauri` : DTO (round-trip serde), wiring.
- Commandes : `cargo test -p <crate>` ciblé, `cargo test --workspace` global.
**Frontend (TS/React)** :
- `vitest` + `@testing-library/react`, tests co-localisés `*.test.ts(x)`.
- Isole l'UI avec les **adapters mock** (`frontend/src/adapters/mock/`).
- Commande : `cd frontend && npx vitest run`.
## 3. Ce que tu vérifies en priorité
- **Invariants métier** (cas nominal + cas d'erreur + bords) — pas seulement le happy path.
- **Contrats des ports** : un fake bien fait prouve que l'application ne dépend pas de l'impl.
- **Round-trip de sérialisation** (DTO ↔ domaine, fichiers `.ideai/*.json`).
- **Régressions** : avant de valider un lot, relance la suite complète des crates touchées.
- **Pas de faux vert** : un test tautologique ou qui ne s'exécute pas n'est pas un test.
## 4. Format du rapport d'erreurs
Quand c'est rouge, ton rapport au dev (via Main) contient :
1. La **commande** exacte exécutée.
2. La **sortie réelle** (assertion, message, ligne).
3. Le **fichier:ligne** concerné.
4. Ce qui était **attendu vs obtenu**.
5. Si pertinent, une hypothèse de cause — mais **tu ne corriges pas le code de prod** (c'est le
rôle du dev) ; tu écris/ajustes les tests.
## 5. Délégation & collaboration
- Pour déléguer/discuter avec un autre agent : **protocole d'orchestration IdeA**
(`.ideai/requests/<ton-agent>/`), **jamais** de subagent natif fournisseur. *(En attendant
l'orchestration v3, Main relaie.)*
- Source de vérité d'architecture : `architect.md`. Tes tests valident la conformité du code à ce
document.
## 6. Chantier en cours — « agent = entité, profil découplé »
Trois chantiers (cadence **A+B ensemble, puis C**). Points de vigilance test :
- **A — Hot-swap profil** : décision **repartir à neuf**. Tester que le swap **préserve** le
contexte `.md` + la mémoire et **abandonne proprement** l'historique de conversation ; que
`profile_id` change bien et que le relancement utilise la nouvelle CLI ; refus/garde-fous (swap
sur agent inconnu, etc.).
- **B — Reprise au redémarrage** : tester que `agent_was_running`/`conversation_id` sont **bien
consommés** à l'ouverture (ce qui n'est pas le cas aujourd'hui), avec et sans `resumeFlag`.
- **C — Orchestration v3** : tester le routage `ask_agent` (réponse synchrone corrélée), le repli
fichier quand un profil ne supporte pas MCP, la non-régression du protocole `.ideai/requests`.
Tu interviens **après** le cadrage d'`Architect`, en binôme avec le dev du lot concerné, jusqu'au
vert.

View File

View File

@ -0,0 +1,515 @@
# Conversation par paire — cadrage d'architecture (multi-agent solide par construction)
> **Agent Architecture.** Ce document tranche le modèle qui rend le multi-agent
> **solide par construction** : l'utilisateur n'a plus à « faire les choses dans le
> bon ordre ». Aucun code de production ici — décisions, contrats (ports/entités),
> découpage en lots testables, frontière backend/frontend.
>
> **Décisions produit arbitrées (NON négociables, rappel) :** (A) conversation par
> paire = un fil entre deux parties, session propre, matérialisation paresseuse ;
> (B) entrée médiée par IdeA (le terminal xterm reste la vue de sortie brute
> INCHANGÉE, seule l'entrée change de chemin), Envoyer=enqueue / Interrompre=préempte ;
> (C) FileGuard borné aux `.md` de contexte + la mémoire, via outils MCP, verrou
> lecteurs/écrivain ; (D) zéro git, hexagonal+SOLID stricts, corrélation par ticket,
> MCP Claude-only, fix `bind_endpoint`, abandon du band-aid `\n`→`\r`.
>
> **État du terrain (lu, pas présumé).** L'essentiel des briques existe déjà :
> `domain/src/mailbox.rs` (`AgentMailbox`, `Ticket`, `TicketId`, `PendingReply`,
> `MailboxError`) ; `infrastructure/src/mailbox/mod.rs` (`InMemoryMailbox`, FIFO par
> agent + `oneshot`) ; `application/src/orchestrator/service.rs` (`ask_agent`,
> `reply`, `ensure_live_pty`, verrou de tour `ask_locks`) ; surface MCP complète
> (`mcp/tools.rs`, `mcp/server.rs`) ; transport bindé (`app-tauri/src/mcp_endpoint.rs`,
> `state.rs::bind_endpoint`/`ensure_mcp_server`/`serve_peer`). **Ce cadrage
> formalise et complète ; il ne réécrit pas.**
---
## 0. Synthèse exécutive (décisions tranchées)
1. **La conversation devient une entité de premier plan** (`Conversation` + `ConversationId`),
absente aujourd'hui. Le couplage actuel « 1 session vivante / agent »
(`session-registry-agent-ambiguity`) est **remplacé** par « 1 session vivante /
**conversation** ». Un agent peut donc avoir **N sessions** simultanées (une par
fil), mais **une seule tâche traitée à la fois** (l'entrée reste sérialisée, §B).
C'est ce qui supprime la fuite de contexte : la délégation A→B n'emprunte plus la
conversation User↔B.
2. **L'entrée passe par un `InputMediator`** (nouveau port application) : toutes les
entrées (humaine **et** inter-agents) convergent vers **une file FIFO unique par
agent**, `enqueue`/`preempt` distincts. Le terminal xterm n'écrit **plus jamais
en direct dans le PTY** ; il devient une **vue de sortie pure**. La file existante
(`AgentMailbox` + `ask_locks`) est **absorbée** par le `InputMediator` : la
messagerie inter-agents n'est qu'une **source d'entrée parmi deux**.
3. **`FileGuard` (nouveau port domaine)** : un verrou lecteurs/écrivain **borné** aux
fichiers qu'IdeA possède (`.md` de contexte d'agent + mémoire). Les agents perdent
l'accès fs brut à ces chemins et passent par de **nouveaux outils MCP**
`idea_context_read/propose` et `idea_memory_read/write`. Le contexte **global
projet** est **mono-écrivain (l'orchestrateur)** ; les autres *proposent*.
4. **Détection occupé/libre = double signal avec fallback sûr** : (a) **retour-de-prompt**
détecté par motif déclaré dans le profil CLI, (b) **signal explicite** de l'agent
(un `idea_reply`, ou fin de tour MCP). **En cas de doute → forwarder** (on
enqueue ; jamais piéger un message). L'occupé/libre remonte au front via un
`DomainEvent` (Channel Tauri), pas via parsing front.
5. **Fixes durables embarqués** : `bind_endpoint` unlink déjà le socket cadavre
(`reclaim_name(true)`, état OK — on **verrouille ce comportement par un test de
non-régression**) ; le band-aid `\n``\r` et l'« injection PTV » de
`service.rs:459` **disparaissent** (l'entrée passe désormais par le `InputMediator`,
pas par une écriture PTY préfixée d'un orchestrateur).
6. **Garde-fous d'orchestration** : timeout par tour (déjà), **plafond d'attente en
file** (déjà, `ASK_QUEUE_WAIT_CAP`), **détection de cycle** sur un graphe wait-for
(nouveau, dans le domaine — pur, testable) pour refuser une délégation
ré-entrante (A→B→A) avant deadlock.
---
## 1. Modèle de domaine
### 1.1 Nouvelles entités / VO
#### `ConversationId` (VO)
- `newtype(uuid::Uuid)`, calqué sur `TicketId`/`AgentId`. Immuable, non vide.
- **Implémenté** : `crates/domain/src/conversation.rs` (nouveau module, à exporter
dans `lib.rs` à côté de `mailbox`).
#### `ConversationParty` (VO, enum)
```text
ConversationParty =
| User // l'humain (une seule instance logique côté IdeA)
| Agent(AgentId) // un agent du projet
```
- Invariant : une `Conversation` relie **deux parties distinctes** (jamais
`Agent(x)↔Agent(x)`, jamais `User↔User`).
#### `Conversation` (entité)
```text
Conversation {
id: ConversationId,
left: ConversationParty,
right: ConversationParty,
session: ConversationSession, // état d'I/O (voir 1.2)
resumable_id: Option<String>, // session-id reprenable de la CLI (suspend = stocke)
}
```
- **Invariants** : `left != right` ; au plus **une** des deux parties est `User` ;
identité d'une conversation = la **paire non ordonnée** `{left, right}` pour un
agent donné (deux paires identiques ⇒ même conversation — clé de la matérialisation
paresseuse). Pur, I/O-free.
- **Matérialisation paresseuse** : une `Conversation` `Agent↔Agent` n'existe en
registre que s'il y a **au moins une tâche** ; suspendue, elle ne garde que
`resumable_id` (pas de session vivante). C'est une **règle du `ConversationRegistry`**
(application), pas un champ persistant lourd.
#### `ConversationSession` (VO, enum — l'état d'I/O du fil)
```text
ConversationSession =
| Dormant // jamais lancée, ou suspendue (resumable_id seul)
| Live { handle_ref: SessionRef } // un flux d'I/O vivant (PTY ou structuré)
```
- `SessionRef` = abstraction d'un handle de session (référence vers une `TerminalSession`
existante, cf. `domain/src/terminal.rs`). Le domaine ne tient **pas** le PTY (infra).
#### `Task` / `Ticket` (extension de l'existant)
- `Ticket` (`domain/src/mailbox.rs`) est **étendu** pour porter **l'origine** et la
**conversation cible** :
```text
Ticket {
id: TicketId, // existant
source: InputSource, // NOUVEAU : Human | Agent(AgentId)
conversation: ConversationId, // NOUVEAU : le fil dans lequel la tâche entre
requester: String, // existant (label d'affichage du préfixe)
task: String, // existant
}
```
- `InputSource` (VO, enum) : `Human | Agent(AgentId)`. Remplace l'actuel
`requester: String` libre comme **source de vérité** (le `String` reste un label
d'affichage dérivé). Permet de **propager l'identité du demandeur** (D) et
d'alimenter le graphe wait-for (détection de cycle).
- **Compat** : `Ticket::new` garde sa signature ; on ajoute `Ticket::from_human(...)`
et `Ticket::from_agent(source, conversation, ...)` (Open/Closed, pas de breaking).
#### File FIFO + état occupé/libre (VO)
- `AgentInbox` (concept porté par le port `InputMediator`, pas une entité persistée) :
**une file FIFO par `AgentId`**, **une tâche en cours à la fois**.
- `AgentBusyState` (VO, enum) : `Idle | Busy { ticket: TicketId, since_ms: u64 }`.
Dérivé, publié au front. Invariant : un agent passe à `Busy` **à l'enqueue qui
démarre un tour** ; revient `Idle` sur **retour-de-prompt** OU **signal explicite**
(cf. §6) ; **en cas de doute, reste `Busy`** mais la file **continue d'accepter**
(forward, jamais bloquer l'émetteur).
#### `WaitForGraph` (VO pur — détection de cycle)
- `domain/src/conversation.rs` : structure pure `wait_edges: Vec<(AgentId, AgentId)>`
(« A attend B »). Fonction pure `would_cycle(graph, from, to) -> bool`.
- Invariant : une `AskAgent` de `A` vers `B` est **refusée** (`MailboxError`/`AppError`
typé) si elle crée un cycle dans le graphe d'attente (A→B alors que B→…→A).
100 % testable sans I/O.
### 1.2 Invariants transverses
- **1 session vivante / conversation** (remplace « 1 / agent »). `session_for(conversation)`
est déterministe ; `sessions_for_agent(agent)` peut renvoyer N (une par fil actif).
- **1 tâche traitée à la fois / agent** : l'`InputMediator` sérialise l'entrée. Deux
fils d'un même agent partagent **la même file d'entrée** (le process CLI sous-jacent
est unique — « 1 agent = 1 employé »). *Conséquence assumée : un agent occupé par
son fil User retarde une délégation entrante — c'est voulu (un employé, une tâche).*
- **Séparation stricte des contextes** : écrire dans la conversation `A↔B` ne touche
jamais `User↔B`. Garanti par le fait que la session reprise (`resumable_id`) est
**par conversation**, pas par agent.
---
## 2. Ports (traits domaine)
> Signatures **conceptuelles**. « Consommé par » = application ; « Implémenté par » = infra/app-tauri.
### `ConversationRegistry` (NOUVEAU — domaine, `conversation.rs`)
- **Rôle** : résoudre/ouvrir paresseusement une conversation pour une paire, tenir son
`session`/`resumable_id`, suspendre/reprendre.
```rust
trait ConversationRegistry: Send + Sync {
/// Get-or-create paresseux : retourne le fil de la paire {a,b}, en l'ouvrant
/// (Dormant) s'il n'existait pas. Pur registre — n'ouvre AUCUNE session.
fn resolve(&self, a: ConversationParty, b: ConversationParty) -> Conversation;
/// Marque une conversation Live avec la session donnée.
fn bind_session(&self, id: ConversationId, session: SessionRef);
/// Suspend : passe Dormant, conserve le resumable_id rendu par la CLI.
fn suspend(&self, id: ConversationId, resumable_id: Option<String>);
fn get(&self, id: ConversationId) -> Option<Conversation>;
}
```
- **Consommé par** : `OrchestratorService` (au lieu de `session_for_agent` brut),
`LaunchAgent`, la reprise au redémarrage.
- **Implémenté par** : `InMemoryConversationRegistry` (infra) — `HashMap` + mutex sync,
jamais tenu en travers d'un `.await` (cf. `ask_locks` existant).
### `InputMediator` (NOUVEAU — domaine ou application ; **décision : domaine**, `input.rs`)
- **Rôle** : le point de convergence de **toutes** les entrées d'un agent (FIFO unique),
avec `enqueue` (Envoyer) et `preempt` (Interrompre) **distincts**, plus l'état busy.
```rust
trait InputMediator: Send + Sync {
/// Envoyer = enqueue : ajoute la tâche en queue FIFO de l'agent, retourne le
/// PendingReply à attendre (réutilise le type mailbox existant).
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply;
/// Interrompre = préempte : signale au tour en cours de s'arrêter (Échap/stop).
/// N'est PAS un enqueue ; ne corrèle aucun ticket.
fn preempt(&self, agent: AgentId);
/// Marque l'agent libre (retour-de-prompt ou signal explicite) ⇒ avance la file.
fn mark_idle(&self, agent: AgentId);
fn busy_state(&self, agent: AgentId) -> AgentBusyState;
}
```
- **Décision frontière** : `InputMediator` **absorbe** `AgentMailbox`. Le mailbox
existant devient le **moteur de corrélation par ticket** *interne* à
l'implémentation du `InputMediator` (l'`InMemoryMailbox` est réutilisé tel quel, sa
FIFO + `oneshot` sont exactement ce qu'il faut). On **n'a donc pas** deux files
concurrentes : `ask_locks` (verrou de tour) + `InMemoryMailbox` (slots de réponse)
sont unifiés derrière ce port. *(Voir §5 pour le chemin de migration.)*
- **Consommé par** : `OrchestratorService::ask_agent` (source = `Agent`), et le
**nouveau** use case `SubmitHumanInput` (source = `Human`).
- **Implémenté par** : `MediatedInbox` (infra) composant `InMemoryMailbox` + le
registre de verrous de tour + l'état busy.
### `FileGuard` (NOUVEAU — domaine, `fileguard.rs`)
- **Rôle** : verrou **lecteurs/écrivain par fichier** sur le périmètre **borné**
(contexte `.md` + mémoire). N lecteurs OU 1 écrivain ; mono-écrivain pour le
contexte global (l'orchestrateur).
```rust
enum GuardedResource { // VO — le périmètre borné, fermé
AgentContext(AgentId),
ProjectContext, // mono-écrivain : orchestrateur uniquement
Memory(MemorySlug),
}
trait FileGuard: Send + Sync {
async fn acquire_read(&self, who: ConversationParty, res: GuardedResource)
-> Result<ReadLease, GuardError>;
async fn acquire_write(&self, who: ConversationParty, res: GuardedResource)
-> Result<WriteLease, GuardError>;
}
```
- `ReadLease`/`WriteLease` = gardes RAII (libèrent à la fin de portée). `GuardError`
typé : `Busy` (attendre), `Forbidden` (un agent ≠ orchestrateur veut écrire
`ProjectContext` ⇒ refus, doit *proposer*).
- **Invariant clé** : toute lecture/écriture des ressources gardées **transite par ce
port** ; l'accès fs brut à ces chemins est retiré aux agents (cf. §3 outils MCP).
- **Consommé par** : `UpdateAgentContext`, `MemoryStore`-consumers, les nouveaux
use cases `ReadContext`/`ProposeContext`/`ReadMemory`/`WriteMemory`.
- **Implémenté par** : `RwFileGuard` (infra) — `HashMap<GuardedResource, RwLock-like>`
(tokio `RwLock` ou sémaphore), + la règle mono-écrivain pour `ProjectContext`.
### `AgentMailbox` (existant — **conservé**, statut révisé)
- Reste le **contrat de rendez-vous par ticket** (corrélation **par `TicketId`**, voir
§3.3 — on **abandonne** la corrélation purement positionnelle « tête de file » dès
qu'un agent peut avoir plusieurs fils). Devient un **détail d'implémentation** du
`InputMediator` ; n'est plus injecté seul dans `OrchestratorService`.
### Ports inchangés réutilisés
- `PtyPort` (écriture du tour dans le PTY = désormais le **seul** chemin d'écriture,
piloté par le `InputMediator`, plus par `ask_agent` directement).
- `ProfileStore` (porte le **motif de retour-de-prompt** par profil, §6).
- `EventBus` (publie `AgentBusyChanged`, `AgentReplied`).
---
## 3. Adapters (infra) + outils MCP
### 3.1 Adapters
| Port | Adapter | Notes |
|---|---|---|
| `ConversationRegistry` | `InMemoryConversationRegistry` | `HashMap<ConversationId, Conversation>` + index paire→id ; mutex sync. |
| `InputMediator` | `MediatedInbox` | compose `InMemoryMailbox` (existant) + verrous de tour + état busy ; publie `AgentBusyChanged`. |
| `FileGuard` | `RwFileGuard` | `RwLock` par `GuardedResource` ; règle mono-écrivain `ProjectContext`. |
| `AgentMailbox` | `InMemoryMailbox` | **inchangé** (réutilisé sous `MediatedInbox`). |
### 3.2 Nouveaux outils MCP (`infrastructure/src/orchestrator/mcp/tools.rs`)
Ajouts **purement additifs** au `catalogue()` (Open/Closed — le dispatch reste intact) :
- **`idea_context_read { target? }`** → action wire `context.read`
`OrchestratorCommand::ReadContext { target }`. `target` absent = le contexte **global
projet** ; sinon le `.md` d'un agent. Passe par `FileGuard::acquire_read`.
- **`idea_context_propose { target?, content }`** → `context.propose`
`OrchestratorCommand::ProposeContext`. Pour un agent : écriture directe sous verrou
écrivain. Pour le **global** : ce n'est **pas** une écriture, c'est une **proposition**
(déposée pour validation par l'orchestrateur/UI ; `FileGuard` refuse l'écriture
directe avec `Forbidden`).
- **`idea_memory_read { slug? }`** → `memory.read``ReadMemory` (sous `FileGuard`).
- **`idea_memory_write { slug, content }`** → `memory.write``WriteMemory` (verrou
écrivain ; mémoire = partagée projet, cf. `shared-project-memory`).
Chaque outil suit le **patron existant** : `map_tool_call` construit un
`OrchestratorRequest`, `validate()` reste l'**unique autorité** de validation, le
`requester` du handshake porte l'identité (`ConversationParty::Agent`).
### 3.3 Corrélation `idea_reply` **par ticket** (D)
- **Changement** : aujourd'hui `idea_reply` corrèle **positionnellement** (tête de la
file de l'émetteur — `mailbox.resolve(from, result)`). Dès qu'un agent peut avoir
**plusieurs fils**, la tête de « sa » file est ambiguë.
- **Décision** : le préfixe injecté dans le PTY (`[IdeA · tâche de A · ticket <id>]`)
porte **déjà** le `ticket_id`. On expose un champ **optionnel** `ticket` au schéma de
`idea_reply` (`{ result, ticket? }`) ; quand présent, `resolve` corrèle **par
`TicketId`** (déterministe, multi-fil) ; absent, on **retombe** sur la tête de file
(compat agents simples, mono-fil). Le préfixe doit donc **demander à l'agent de
renvoyer le `ticket`** (mise à jour de la description outil + protocole §B-5
existant). `AgentMailbox::resolve` gagne une variante `resolve_ticket(agent,
ticket_id, result)`.
---
## 4. Frontière front : vue de sortie (xterm inchangé) / entrée médiée
### 4.1 État actuel à modifier
`frontend/src/features/terminals/TerminalView.tsx` câble aujourd'hui **directement**
les frappes au PTY :
```ts
const onKey = term.onData((data) => {
if (handle) void handle.write(encoder.encode(data)); // ← chemin à couper
});
```
C'est **exactement** le couplage que le Modèle B retire.
### 4.2 Décision frontend
1. **xterm reste la vue de sortie brute, INCHANGÉE** : `onData (PTY) → term.write`
conservé tel quel. **Interdiction** de ressusciter `AgentChatView` (déjà supprimé
dans le diff courant — ne pas le réintroduire).
2. **`term.onData` (frappes) n'écrit plus dans le PTY** pour une cellule **agent**.
Deux modes :
- **Cellule terminal simple (non-agent)** : comportement actuel conservé (écriture
directe — pas de médiation, c'est un shell brut).
- **Cellule agent** : les frappes vont dans un **champ de saisie géré par IdeA**
(composant `MediatedInput`, rendu **sous** le terminal), pas dans le PTY. xterm
passe en lecture seule pour l'entrée (sortie toujours live).
3. **Nouveau port UI `InputGateway`** (`frontend/src/ports/index.ts`) :
```ts
interface InputGateway {
submit(projectId: string, agentId: string, text: string): Promise<void>; // Envoyer = enqueue
interrupt(projectId: string, agentId: string): Promise<void>; // Interrompre = preempt
}
```
Adapter Tauri : `invoke("submit_agent_input", …)` / `invoke("interrupt_agent", …)`
(nouvelles commands app-tauri → `SubmitHumanInput` / `preempt`). Mock pour tests.
4. **Occupé/libre remonte par event** : un `DomainEvent::AgentBusyChanged { agent_id,
busy }` relayé en event Tauri (pas un Channel haute-fréquence — événement discret).
Le `MediatedInput` désactive « Envoyer » pendant `Busy` mais **autorise toujours
l'enqueue** (le bouton enfile derrière ; jamais bloqué — fallback « forward »), et
active « Interrompre ». Le front **ne parse jamais** la sortie pour deviner l'état.
### 4.3 Composants/state touchés
- `features/terminals/TerminalView.tsx` : brancher le mode agent (entrée détournée).
- `features/terminals/MediatedInput.tsx` (**nouveau**) : champ + boutons Envoyer/Interrompre.
- `features/layout/LayoutGrid.tsx` : déjà route vers `TerminalView` ; ajoute le
`MediatedInput` sous le terminal quand `agent != null`.
- `ports/index.ts` + `adapters/agent.ts` (ou nouvel `adapters/input.ts`) + mock.
- state : un store léger `agentBusy: Record<agentId, boolean>` alimenté par l'event.
---
## 5. Impact sur le code existant
### 5.1 Supprimé / retiré
- **L'écriture PTY préfixée par `ask_agent`** (`service.rs` ~459 :
`pty.write(&handle, "[IdeA · tâche …]\n")`) **n'est plus le chemin d'entrée**. La
tâche déléguée entre désormais par `InputMediator::enqueue` (qui, dans son impl,
écrira la ligne dans le PTY — mais **sérialisée derrière l'entrée humaine** du même
agent, ce qui n'était pas le cas avant). → la logique d'écriture **déménage** de
`ask_agent` vers l'impl `MediatedInbox`.
- **Band-aid `\n`→`\r`** : abandonné (le « mode injection PTV » disparaît). Plus de
réécriture de fin de ligne ad hoc.
- **`AgentChatView`** (front) : déjà supprimé dans le diff courant — **rester** supprimé.
### 5.2 Modifié
- **`OrchestratorService`** : ne reçoit plus `with_mailbox(mailbox, pty)` séparément
mais `with_input_mediator(Arc<dyn InputMediator>)` + `with_conversations(Arc<dyn
ConversationRegistry>)`. `ask_agent` devient : résoudre la **conversation A↔B**
(paresseux), vérifier le **graphe wait-for** (refus si cycle), `enqueue` la tâche
(source = `Agent`), `await PendingReply` borné. `reply` corrèle **par ticket** (§3.3).
`ensure_live_pty` reste, mais branché sur `session_for(conversation)` au lieu de
`session_for_agent`.
- **`session_for_agent`** (registre `terminal/registry.rs`) : devient
`session_for(conversation_id)` ; `sessions_for_agent` (pluriel) ajouté. Lève
l'ambiguïté `session-registry-agent-ambiguity` **par construction** (la clé est la
conversation, pas l'agent).
- **`bind_endpoint`** (`state.rs`) : **déjà** `reclaim_name(true)` ⇒ unlink du cadavre.
**Action = verrouiller par un test** (ouvrir/fermer/SIGKILL simulé/rebind sans
`EADDRINUSE`). Pas de changement de code attendu, sauf si le test révèle un trou.
- **`idea_reply`** (tools.rs / orchestrator.rs / server.rs) : champ `ticket?` ajouté,
`Reply { from, ticket: Option<TicketId>, result }`, `map_tool_call` le propage.
- **`Ticket`** (`mailbox.rs`) : champs `source: InputSource`, `conversation:
ConversationId` ajoutés (constructeurs additifs).
### 5.3 Ajouté
- Domaine : `conversation.rs` (`ConversationId`, `Conversation`, `ConversationParty`,
`ConversationSession`, `WaitForGraph`), `input.rs` (`InputMediator`, `InputSource`,
`AgentBusyState`), `fileguard.rs` (`FileGuard`, `GuardedResource`, leases).
- Application : use cases `SubmitHumanInput`, `ReadContext`/`ProposeContext`,
`ReadMemory`/`WriteMemory` ; détection de cycle câblée dans `ask_agent`.
- Infra : `InMemoryConversationRegistry`, `MediatedInbox`, `RwFileGuard` ; outils MCP
`idea_context_*` / `idea_memory_*`.
- app-tauri : commands `submit_agent_input`, `interrupt_agent` ; relais event
`AgentBusyChanged` ; câblage des nouveaux ports au composition root (`state.rs`).
- Front : `MediatedInput`, `InputGateway` + adapter + mock + store busy.
---
## 6. Détection occupé/libre
**Mécanisme retenu = double signal, OR, avec fallback sûr.**
| Signal | Source | Fiabilité |
|---|---|---|
| **Retour-de-prompt** | motif (regex/literal) déclaré dans le **profil CLI** (`AgentProfile`, nouveau champ `prompt_ready_pattern: Option<String>`), détecté sur le flux PTY par l'impl `MediatedInbox` | bon pour un shell/CLI au prompt stable ; faillible (motif dans la sortie) |
| **Signal explicite** | l'agent appelle `idea_reply` (fin d'une délégation) **ou** un signal de fin-de-tour MCP | déterministe quand l'agent coopère |
- Transition `Busy → Idle` = **premier** des deux signaux qui arrive.
- **Fallback « en cas de doute → forwarder »** : si **aucun** signal n'est sûr (motif
absent du profil, agent muet), l'agent **reste marqué `Busy`** mais la file
**continue d'accepter** les `enqueue` ; un message entrant **n'est jamais rejeté**,
il patiente dans la FIFO. On ne « piège » donc jamais un message ; au pire il attend.
- **Garde-fou anti-blocage** : le timeout par tour (`ASK_AGENT_TIMEOUT`, existant)
retire le ticket de tête et **relâche** le tour même si aucun signal n'est venu ⇒
la file avance. L'agent reste vivant.
- Le motif vit **dans le profil** (donnée, pas code) ⇒ ajouter une CLI = éditer un
profil (Open/Closed, cohérent §9 CLAUDE.md).
---
## 7. Découpage en lots livrables (ordonnés par dépendance)
> Chaque lot = binôme dev/test. **B = DevBackend (Rust)**, **F = DevFrontend (TS/React)**.
> Chemin critique : C1 → C2 → C3 → C4. FileGuard (C6) et front (F1/F2) parallélisables.
### Bloc Conversation (cœur — backend)
| Lot | Côté | Périmètre | Tests |
|---|---|---|---|
| **C1** | B (domaine) | `conversation.rs` : `ConversationId`, `ConversationParty`, `Conversation`, `ConversationSession`, `WaitForGraph::would_cycle`. `input.rs` : `InputSource`, `AgentBusyState`. Extension `Ticket` (source+conversation, ctors additifs). | invariants paire (left≠right, ≤1 User) ; identité = paire non ordonnée ; `would_cycle` (A→B→A refusé, A→B→C ok) ; ticket porte source+conversation. Pur, sans I/O. |
| **C2** | B (domaine+infra) | Ports `ConversationRegistry` + `InputMediator` (domaine) ; adapters `InMemoryConversationRegistry` + `MediatedInbox` (compose `InMemoryMailbox` existant). | resolve paresseux (même paire ⇒ même id) ; enqueue→PendingReply ; preempt distinct d'enqueue ; busy_state transitions ; 2 enqueue même agent sérialisés ; agents ≠ parallèles. |
| **C3** | B (application) | `OrchestratorService` : `with_input_mediator`+`with_conversations` ; `ask_agent` réécrit (résout conversation A↔B, garde wait-for, enqueue source=Agent, await) ; `reply` par ticket. `session_for(conversation)`. Retrait écriture PTY directe + band-aid `\r`. | ask A→B route dans la bonne conversation (pas User↔B) ; cycle A→B→A ⇒ erreur typée avant deadlock ; reply corrèle par ticket (multi-fil) ; reply sans ticket = fallback tête ; timeout libère file, cible vivante. |
| **C4** | B (application+app-tauri) | Use case `SubmitHumanInput` (source=Human) + commands `submit_agent_input`/`interrupt_agent` ; event `AgentBusyChanged` relayé. Câblage composition root (`state.rs`). | submit humain enfile dans la **même** FIFO que les délégations ; interrupt = preempt (pas enqueue) ; busy event émis aux bons moments ; câblage : un ask et un submit concurrents sur A sérialisent. |
### Bloc détection occupé/libre (backend)
| Lot | Côté | Périmètre | Tests |
|---|---|---|---|
| **C5** | B (domaine+infra) | Champ profil `prompt_ready_pattern` ; détection retour-de-prompt dans `MediatedInbox` ; OR avec signal explicite ; fallback « reste Busy mais accepte ». | motif détecté ⇒ Idle ; idea_reply ⇒ Idle ; ni l'un ni l'autre ⇒ Busy mais enqueue accepté ; timeout ⇒ file avance. |
### Bloc FileGuard (backend — parallélisable après C1)
| Lot | Côté | Périmètre | Tests |
|---|---|---|---|
| **C6** | B (domaine+infra) | `fileguard.rs` (port + `GuardedResource` + leases) ; `RwFileGuard` ; règle mono-écrivain `ProjectContext`. | N lecteurs concurrents OK ; 1 écrivain exclusif ; agent≠orchestrateur écrit ProjectContext ⇒ `Forbidden` ; lease RAII libère. |
| **C7** | B (application+infra MCP) | Use cases `ReadContext`/`ProposeContext`/`ReadMemory`/`WriteMemory` sous FileGuard ; outils MCP `idea_context_*`/`idea_memory_*` ; retrait accès fs brut de ces chemins. | map_tool_call → command ; validate exige `content` ; propose global ≠ write direct ; lecture concurrente non bloquante ; écriture sérialisée. |
### Bloc frontend
| Lot | Côté | Périmètre | Tests (Vitest/RTL, gateways mock) |
|---|---|---|---|
| **F1** | F | `InputGateway` (port+adapter+mock) ; `MediatedInput` (Envoyer=submit / Interrompre=interrupt) ; store busy alimenté par event. | submit appelle gateway.submit ; interrupt appelle interrupt ; busy event désactive Envoyer (mais enqueue possible), active Interrompre. |
| **F2** | F | `TerminalView` mode agent : frappes → `MediatedInput` (plus le PTY) ; xterm reste sortie live INCHANGÉE pour le non-agent. `LayoutGrid` monte `MediatedInput` sous le terminal si `agent != null`. | cellule agent ⇒ onData ne write pas le PTY ; cellule simple ⇒ comportement actuel ; sortie PTY toujours peinte ; jamais d'AgentChatView. |
### Bloc durcissement
| Lot | Côté | Périmètre | Tests |
|---|---|---|---|
| **D1** | B (app-tauri) | Test de non-régression `bind_endpoint` : bind → drop (SIGKILL simulé : laisser le fichier socket) → rebind **sans** `EADDRINUSE`. Verrouille `reclaim_name(true)`. | rebind après cadavre OK ; idempotent ; pas de fuite de fichier après close. |
**Ordre recommandé** : **C1 → C2 → C3 → C4** (cœur), **C5** après C2, **C6 → C7**
en parallèle (après C1), **F1 → F2** dès que les commands C4 existent (mock avant),
**D1** isolé n'importe quand.
---
## 8. Stratégie de tests par couche
| Couche | Type | Comment |
|---|---|---|
| **domaine** (`conversation`, `input`, `fileguard`, `mailbox` étendu) | unitaires **purs**, sans I/O ni async là où possible | invariants de paire, `would_cycle`, transitions `AgentBusyState`, ctors `Ticket`. Déterministe. C'est là que vit la garantie « solide par construction ». |
| **application** (`OrchestratorService`, `SubmitHumanInput`, use cases FileGuard) | unitaires avec **ports mockés** (fakes manuels, façon `service.rs` actuel) | ask route la bonne conversation ; cycle refusé ; reply par ticket ; submit+ask sérialisés ; FileGuard mono-écrivain. **Aucun vrai PTY/fs/MCP.** |
| **infra** (`MediatedInbox`, `RwFileGuard`, `InMemoryConversationRegistry`, outils MCP) | intégration **ciblée** | FIFO réelle + `oneshot` ; RwLock concurrence ; `map_tool_call` round-trip ; `bind_endpoint` (D1). Réutilise les tests `InMemoryMailbox` existants. |
| **app-tauri** | commands ↔ use cases | `submit_agent_input`/`interrupt_agent` mappent bien ; event `AgentBusyChanged` émis ; câblage composition root cohérent (endpoint partagé). |
| **frontend** (`MediatedInput`, `TerminalView`) | Vitest + RTL, **gateways mock** | entrée détournée hors PTY ; busy désactive Envoyer sans bloquer enqueue ; xterm sortie inchangée ; **sans backend**. |
---
## 9. Risques / points ouverts
1. **Fiabilité de la détection retour-de-prompt** (C5) — le plus dur. Un motif dans la
sortie d'un agent peut **faussement** signaler Idle (libère trop tôt) ou ne jamais
matcher (reste Busy). *Mitigation* : OR avec le signal explicite `idea_reply` +
fallback « reste Busy mais accepte » + timeout par tour. *Reste ouvert* : faut-il un
« heartbeat » MCP de fin-de-tour côté CLI ? (hors périmètre immédiat, Claude-only).
2. **Suspension/reprise de session par conversation** (`resumable_id`) — un agent à N
fils doit reprendre **le bon** session-id par fil au redémarrage. Dépend du
`session{assignFlag,resumeFlag}` du profil (cf. `conversation-resume-architecture`).
*Ouvert* : capacité réelle des CLI à tenir N conversations resumables simultanées
pour un même process « 1 agent = 1 employé » — possible conflit entre « N fils » et
« 1 process ». **Décision de cadrage** : **1 process/agent**, les fils **partagent
la file d'entrée** (sérialisés) ; le `resumable_id` par conversation sert surtout à
la **reprise au redémarrage**, pas à du vrai parallélisme intra-process.
3. **Deadlock & détection de cycle** (`WaitForGraph`) — couvre A→B→A directs et
transitifs, mais le graphe doit être **alimenté en temps réel** (arête posée à
l'enqueue, retirée au reply/timeout). *Risque* : arête fantôme si un reply se perd
⇒ faux positif de cycle. *Mitigation* : retrait d'arête garanti par le RAII du tour
(comme `_turn` aujourd'hui) + timeout.
4. **Corrélation par ticket vs agents « simples »** — un agent qui ne renvoie pas le
`ticket` dans `idea_reply` retombe sur la corrélation positionnelle (tête de file),
ambiguë en multi-fil. *Mitigation* : protocole §B-5 (description outil) **insiste**
sur le renvoi du ticket ; mono-fil reste correct sans. *Ouvert* : forcer le ticket
requis casserait des agents simples — on garde optionnel.
5. **Périmètre FileGuard contournable** — tant que l'agent garde un shell brut (PTY),
il peut écrire les `.md`/mémoire **par le filesystem** malgré le verrou MCP. Le
verrou n'est étanche que si l'accès fs à ces chemins est **réellement** retiré
(sandbox, cf. `agent-permissions-architecture` / Landlock). *Ouvert* : sans sandbox
OS, le `FileGuard` est **coopératif** (protège des collisions IdeA↔IdeA, pas d'un
agent qui contourne). À acter : FileGuard = correction des collisions **dans le
chemin IdeA** d'abord ; étanchéité réelle = lot sandbox ultérieur.
6. **Migration `AgentMailbox` → `InputMediator`** — risque de double-file transitoire.
*Mitigation* : `MediatedInbox` **enveloppe** `InMemoryMailbox` (pas de réécriture),
`OrchestratorService` bascule d'un `with_mailbox` vers `with_input_mediator` en un
lot (C2→C3), tests existants `InMemoryMailbox` conservés verts.
---
*Document maintenu par l'Agent Architecture — cadrage « conversation par paire »,
base des lots C1→C7 / F1→F2 / D1 avant tout code.*

View File

@ -0,0 +1,88 @@
# Brief Dev — Lot D4 : commandes Tauri + bridge chat (§17.9)
> Demandé par **Main** à **DevBackend** (dev) + **QA** (test). Cycle §3 : code → tests → vert.
> Périmètre **backend uniquement** (`app-tauri`). Le frontend chat est D5, hors périmètre ici.
## 0. Où on en est
Le fil §17 (exécution structurée des agents IA via le port `AgentSession`) est livré
jusqu'à **D3 inclus** :
- **D0/D1** (`5e10b5e`) : port `domain::ports::AgentSession` + `AgentSessionFactory`,
`ReplyEvent`/`ReplyStream`/`AgentSessionError`, champ `AgentProfile.structured_adapter`,
registre `StructuredSessions`, agrégateur `LiveSessions`, helper `send_blocking`.
- **D2** (`751d94d`) + spikes **S1/S2** (`f104862`) : adapters `ClaudeSdkSession` /
`CodexExecSession` dans `crates/infrastructure/src/session/`, fake CLI + harnais de
conformité, **formats réels** Claude `stream-json` / Codex `exec --json` câblés.
- **D3** (`56913b9`) : `LaunchAgent` route structuré vs PTY ; `LaunchAgentOutput` porte
désormais `structured: Option<StructuredSessionDescriptor>`.
**D4 = exposer tout ça à l'UI** : commandes Tauri + pont de streaming, jumeau exact du
chemin PTY existant. Aucun chemin PTY (terminal non-IA) ne doit changer ni régresser.
## 1. Périmètre D4 (réf. §17.7 et tableau §17.9)
À livrer dans `crates/app-tauri/src/` :
1. **`ChatBridge`** — jumeau de `PtyBridge` (`crates/app-tauri/src/pty.rs`), **generation-tracked**
(même mécanique de génération pour éviter la double-pompe lors d'une ré-attache). Il pompe
un `ReplyStream` (events `ReplyEvent` du port) vers un `tauri::ipc::Channel`, en émettant
des `ReplyChunk` (DTO ci-dessous). Vit à côté de `PtyBridge`, ne le remplace pas.
2. **Commandes Tauri** :
- `agent_send(sessionId, prompt)` → pompe les `ReplyEvent` du tour sur le `Channel`
(deltas `TextDelta` → chunks, `ToolActivity` → chunks d'activité, `Final` → chunk final
qui fige le tour). S'appuie sur le registre `StructuredSessions` / `send_blocking` côté
application (déjà livré en D1).
- `reattach_agent_chat(...)` → renvoie le scrollback de conversation + rebranche le `Channel`
(repeint sans re-spawn ; supersede l'ancienne génération).
- `close_agent_session(sessionId)``shutdown` (polymorphe) + unregister du registre.
3. **DTO** (`crates/app-tauri/src/dto.rs`) :
- `ReplyChunk` : variantes delta texte / activité outil / final (sérialisation camelCase,
cohérente avec les DTO existants).
- `ReattachChatDto`.
- **`cellKind`** ajouté au DTO de session (terminal `pty` vs chat `chat`), dérivé de la
présence d'un descripteur structuré (`LaunchAgentOutput.structured`).
4. **Wiring composition root** (`state.rs`/`lib.rs`) : injecter les dépendances nécessaires,
enregistrer les nouvelles commandes. **Aucun `new ClaudeSdkSession` ici** — passe par la
factory déjà injectée (règle D du §17.8).
## 2. Contrat / invariants à respecter
- **Generation supersede** : une ré-attache invalide l'ancienne pompe ; pas de double émission.
- **`cellKind`** est la seule info dont D5 (frontend) a besoin pour router cellule chat vs
terminal. Stable et explicite au DTO.
- **Isolation parsing** : D4 ne parse aucun format CLI — il consomme des `ReplyEvent` typés.
- **Zéro régression PTY** : le chemin terminal brut (`PtyBridge`, commandes terminal) reste
identique. Les tests PTY existants restent verts.
- Frontières hexagonales (§17.8) : `app-tauri` dépend des ports/registres application, jamais
des adapters infra concrets.
## 3. Tests attendus (QA — réf. colonne « Tests attendus » D4 du §17.9)
Crate `app-tauri` (fakes pour la session structurée, pas de vrai CLI) :
- `agent_send` pompe les events d'un tour sur le `Channel` (séquence deltas… puis `Final`).
- ré-attache → scrollback conversation repeint, **sans** re-spawn de session.
- `close_agent_session``shutdown` appelé **et** unregister du registre.
- **generation supersede** : après ré-attache, l'ancienne génération ne pompe plus (pas de
double émission sur le `Channel`).
- DTO : `cellKind` = `chat` pour une sortie `LaunchAgentOutput` avec `structured: Some(..)`,
`pty` sinon ; round-trip `ReplyChunk` (camelCase).
- non-régression : un DTO de session PTY existant sérialise toujours pareil (le nouveau champ
`cellKind` ne casse pas les snapshots — vérifier la valeur par défaut/dérivée).
## 4. Méthode
Cycle §3 strict : DevBackend code → QA écrit + exécute les tests → vert avant de clore.
Rapport d'erreurs clair si rouge → correction → re-test. Commit `feat(agent): … (D4) — §17`
quand `cargo test --workspace` est vert. **Ne pas push** (validation Main requise).
## 5. Références code
- Jumeau à copier : `crates/app-tauri/src/pty.rs` (`PtyBridge`, generation tracking).
- Source du flux : `domain::ports::{AgentSession, ReplyEvent, ReplyStream}` ;
registre/`send_blocking` : `crates/application/src/agent/structured.rs`.
- Routage déjà fait : `crates/application/src/agent/lifecycle.rs`
(`LaunchAgentOutput.structured`).
- DTO existants : `crates/app-tauri/src/dto.rs` ; commandes : `crates/app-tauri/src/commands.rs`.
- Spec complète : `ARCHITECTURE.md` §17.7 (commandes & DTO) et tableau §17.9 ligne **D4**.

View File

@ -0,0 +1,99 @@
# Brief Dev — Lot D5 : frontend chat (§17.9)
> Demandé par **Main** à **DevFrontend** (dev) + **QA** (test). Cycle §3 : code → tests → vert.
> Périmètre **frontend uniquement** (`frontend/src`). Le backend D4 est livré et committé (`f4d5727`).
## 0. Où on en est
Le fil §17 (exécution structurée des agents IA) est livré jusqu'à **D4 inclus** côté backend :
les commandes Tauri `agent_send` / `reattach_agent_chat` / `close_agent_session` existent et
streament des `ReplyChunk` sur un `Channel`. D5 = **la vue chat React** qui consomme ça, plus
le **routage par type de cellule** dans le layout.
### Contrats backend exacts à mirrorer (déjà livrés)
Commandes Tauri (`crates/app-tauri/src/commands.rs`) :
- `agent_send(sessionId: string, prompt: string, onReply: Channel<ReplyChunk>) -> void`
- `reattach_agent_chat(sessionId: string, onReply: Channel<ReplyChunk>) -> ReattachChatDto`
- `close_agent_session(sessionId: string) -> void`
DTO (`crates/app-tauri/src/dto.rs`) — **sérialisation camelCase tagué `kind`** :
```ts
type ReplyChunk =
| { kind: "textDelta"; text: string }
| { kind: "toolActivity"; label: string }
| { kind: "final"; content: string };
// ReattachChatDto : le scrollback de conversation (chunks déjà streamés)
interface ReattachChatDto { sessionId: string; scrollback: ReplyChunk[]; }
// Et surtout : le DTO de session porte désormais cellKind
type CellKind = "pty" | "chat"; // toujours présent sur TerminalSessionDto
```
> `cellKind` vaut `"chat"` quand l'agent est piloté en mode structuré (profil Claude/Codex),
> `"pty"` pour un terminal brut. C'est **la seule info dont le frontend a besoin** pour router
> cellule chat vs terminal.
## 1. Périmètre D5 (réf. tableau §17.9 ligne D5)
1. **`AgentChatView`** (nouveau, `frontend/src/features/chat/`) : vue de conversation IdeA.
- Affiche les **deltas live** (accumulation `textDelta` → texte du tour en cours), l'**activité
d'outil** (`toolActivity`), et **fige le tour** sur `final`.
- Zone de **saisie** d'un prompt → appelle `AgentGateway.sendPrompt`.
- **Scrollback de conversation** : à l'attache, repeint l'historique renvoyé par
`reattachChat` ; survit à un changement d'onglet/layout (ré-attache, pas re-spawn).
- C'est le **jumeau chat** de `TerminalView` (`frontend/src/features/terminals/TerminalView.tsx`,
283 l.) — inspire-toi de sa gestion de cycle de vie (mount/attach/detach), mais pour un
flux de messages structuré au lieu d'octets xterm.
2. **Routage par `cellKind` dans `LayoutGrid`** (`frontend/src/features/layout/LayoutGrid.tsx`,
fonction `LeafView` ~l.159) : une cellule rend `AgentChatView` si `cellKind === "chat"`,
sinon `TerminalView` (comportement actuel inchangé). Le `cellKind` arrive sur le handle/session
au lancement (sortie de `launchAgent`) — propage-le jusqu'au leaf.
3. **Port `AgentGateway`** (`frontend/src/ports/index.ts`, interface ~l.76) : ajoute
- `sendPrompt(sessionId: string, prompt: string, onReply: (c: ReplyChunk) => void): Promise<void>`
- `reattachChat(sessionId: string, onReply: (c: ReplyChunk) => void): Promise<ReplyChunk[]>`
- `closeAgentSession(sessionId: string): Promise<void>`
(signatures à aligner avec le style des méthodes existantes `launchAgent`/`reattach`).
4. **Adapter Tauri** (`frontend/src/adapters/agent.ts`, classe `TauriAgentGateway`) : implémente
les 3 méthodes via `invoke(...)` + `new Channel<ReplyChunk>()` (modèle déjà présent pour
`launchAgent`/`reattach` qui utilisent `Channel<number[]>`).
5. **Mock gateway** (`frontend/src/adapters/mock/index.ts`) : streame des `ReplyChunk` scriptés
(quelques `textDelta` puis un `final`) pour les tests et le dev hors-Tauri.
6. **Types TS** : ajoute `ReplyChunk`, `CellKind`, `ReattachChatDto` aux types partagés (là où
vivent les autres mirrors de DTO), et le champ `cellKind` sur le type de session/handle.
## 2. Invariants à respecter
- **Zéro régression terminal** : `TerminalView` et le chemin PTY de `LayoutGrid` restent
identiques ; une cellule `pty` se comporte exactement comme avant.
- **Ré-attache ≠ re-spawn** : changer d'onglet puis revenir repeint la conversation depuis le
scrollback renvoyé par `reattachChat`, sans relancer de tour (miroir du PTY reattach).
- **Accumulation correcte** : les `textDelta` s'accumulent dans le tour courant ; `final` clôt
le tour (le texte final fait foi). Pas de doublon delta/final affiché deux fois.
- Frontières : la vue dépend du **port** `AgentGateway`, jamais directement de `invoke`/Tauri
(c'est l'adapter qui parle à Tauri). Hexagonal côté front respecté.
## 3. Tests attendus (QA — Vitest, réf. colonne « Tests attendus » D5 du §17.9)
Aligne-toi sur le style des `*.test.tsx` existants (`LayoutGrid.test.tsx`, `TerminalView.test.tsx`,
`adapters/agent.test.ts`), avec le **mock gateway** :
- cellule `cellKind:"chat"` rend `AgentChatView` ; `cellKind:"pty"` rend `TerminalView`.
- les `textDelta` s'accumulent à l'écran → `final` fige le tour.
- ré-attache repeint le scrollback **sans** re-spawn (le mock ne reçoit pas de nouveau `sendPrompt`).
- envoi d'un prompt → `AgentGateway.sendPrompt` appelé avec les bons args.
- `toolActivity` affiché comme activité d'outil.
- mock gateway streame bien une séquence `ReplyChunk` (deltas… puis final).
## 4. Méthode
Cycle §3 strict : DevFrontend code → QA écrit + exécute les tests Vitest → vert avant de clore.
Vérifie `npm run build` (tsc --noEmit + vite) **et** `npx vitest run` verts. **Ne pas committer,
ne pas push** — Main relit et commit. Rapport d'erreurs clair si rouge → correction → re-test.
## 5. Références
- Jumeau à copier : `frontend/src/features/terminals/TerminalView.tsx` (cycle de vie attach/detach).
- Routage cellule : `frontend/src/features/layout/LayoutGrid.tsx` (`LeafView`).
- Port : `frontend/src/ports/index.ts` (`AgentGateway`) ; adapter : `frontend/src/adapters/agent.ts` ;
mock : `frontend/src/adapters/mock/index.ts`.
- Backend déjà livré : commit `f4d5727`, `crates/app-tauri/src/{chat,commands,dto}.rs`.
- Spec : `ARCHITECTURE.md` §17.6 (deux types de cellules) et tableau §17.9 ligne **D5**.

View File

@ -0,0 +1,104 @@
# Brief Dev — Lot D6 : messagerie inter-agents via `send_blocking` (§17.9)
> Demandé par **Main** à **DevBackend** (dev) + **QA** (test). Cycle §3 : code → tests → vert.
> Périmètre **backend** (domaine + application). Pas de frontend.
## 0. Le trou que D6 comble (important)
Aujourd'hui, « Main demande à Architect » ne **retourne jamais le contenu** de la réponse :
- `OrchestratorCommand` n'a **pas** de variante « demander/attendre une réponse ». Le wire
`agent.run` replie `task` dans `context`, et `context` n'est utilisé que pour un agent **neuf**
(`.md` initial) ⇒ pour un agent **déjà existant**, le `task` est **silencieusement ignoré**.
- La réponse (`*.response.json`) ne porte qu'un **ACK de cycle de vie**
(`detail: "launched agent X"`), jamais la sortie produite par la cible.
D6 = brancher la **messagerie synchrone** sur le port `AgentSession` : la cible est pilotée en
mode structuré, on **attend son tour** et on **renvoie son contenu**. La primitive existe déjà :
`crate::agent::structured::send_blocking(session, prompt, timeout) -> Result<String, AgentSessionError>`
(livrée en D1 ; retourne le contenu du `Final`, `Timeout` typé **sans tuer la session**).
## 1. Périmètre D6 (réf. tableau §17.9 ligne D6)
### A. Domaine (`crates/domain/src/`)
1. **`OrchestratorCommand::AskAgent { target: String, task: String }`** (`orchestrator.rs`,
enum ~l.111). Nouvelle variante « j'attends une réponse ».
2. **Validation** : nouvelle action wire **`agent.message`** → `AskAgent` dans
`OrchestratorRequest::validate` (~l.175). `targetAgent` (ou `name`) **et** `task` requis
non-vides (sinon `OrchestratorError::MissingField`). Garde le mapping `agent.run` actuel
inchangé (lancement fire-and-forget). Ajoute un cas de test de round-trip JSON `agent.message`.
3. **`DomainEvent::AgentReplied { ... }`** (`events.rs`, à côté de `AgentLaunched`) pour
l'observabilité : au minimum le nom/id de l'agent cible et, si pertinent, la taille/preview
de la réponse (reste pur, pas de payload lourd imposé — calque le style des variantes voisines).
### B. Application (`crates/application/src/orchestrator/service.rs`)
4. **`OrchestratorOutcome` gagne le contenu** : ajoute `reply: Option<String>` (l'ACK actuel
`detail` reste). Les commandes existantes mettent `reply: None` ; `AskAgent` met
`reply: Some(contenu)`. (Champ additif ⇒ aucune régression des call sites/tests existants.)
5. **`dispatch` route `AskAgent`** :
- résous l'agent cible par nom (`find_agent_id_by_name`) → sinon `AppError::NotFound` typé.
- cherche sa **session structurée vivante** dans le registre **`StructuredSessions`** (PAS
`TerminalSessions`). Si vivante ⇒ `send_blocking(session, &task, timeout)`.
- si **pas vivante** ⇒ lance l'agent en mode structuré (via `LaunchAgent`, background) puis
`send_blocking`. Respecte l'invariant **1 session/agent**.
- si la cible est **PTY-only** (profil sans `structured_adapter` ⇒ pas adressable en `ask`) ⇒
**erreur typée explicite** (`AppError::Invalid`/`NotFound` avec message clair « agent X n'est
pas pilotable en mode structuré »). C'est acceptable (le menu ne crée plus que des agents
structurés, cf. D7 à venir).
- **timeout** ⇒ remonte une erreur typée, **sans tuer la session** (déjà la sémantique de
`send_blocking`).
- en cas de succès ⇒ **publie `DomainEvent::AgentReplied`** sur l'`EventBus`, et retourne
`OrchestratorOutcome { detail, reply: Some(content) }`.
6. **Injection** : le service a besoin du registre `StructuredSessions` + de l'`EventBus` (et de
quoi piloter `send_blocking`). **Ajoute-les par builder additif** (`with_structured(...)` /
`with_events(...)` façon D3 sur `LaunchAgent`) pour que `OrchestratorService::new` reste
compatible et que les tests/call sites legacy restent verts. Câble au composition root
(`crates/app-tauri/src/state.rs`).
### C. Nettoyage voie principale
7. **Aucun accès outbox/inbox** dans le chemin `AskAgent` (le rendez-vous est intrinsèque à
`send_blocking`). Les seules occurrences `outbox/inbox` actuelles sont des commentaires de doc
dans `agent/structured.rs` — ne ré-introduis rien. Vérifie qu'aucun `AgentReplyChannel`/outbox
n'est utilisé.
## 2. Invariants à respecter
- **1 session vivante par agent** across registres (PTY + structuré).
- **Timeout ne tue jamais la session** (retry possible).
- **Cible PTY non adressable** par `ask` ⇒ erreur typée, jamais un ACK trompeur ni un panic.
- Frontières hexagonales : le domaine reste pur (pas d'I/O dans `orchestrator.rs`/`events.rs`) ;
l'orchestration vit dans l'application ; aucun `new` d'adapter infra dans le service.
- **Zéro régression** : `agent.run`/`spawn_agent`/`stop_agent`/`update_agent_context`/`skill.create`
inchangés ; le watcher d'orchestration et ses tests restent verts.
## 3. Tests attendus (QA — colonne « Tests attendus » D6 du §17.9)
Unitaires avec **fakes** (fake `AgentSession`, fake registres, fake EventBus — aucun vrai CLI) :
- cible **vivante** (session structurée enregistrée) ⇒ `send_blocking` appelé, `reply: Some(...)`
porte le contenu du `Final`.
- cible **morte**`LaunchAgent` invoqué (structuré) **puis** `send` ; `reply` renvoyé.
- **timeout** ⇒ erreur typée remontée, **session non tuée** (le fake atteste qu'aucun `shutdown`
n'a été appelé), pas de `reply`.
- **`AgentReplied`** publié sur l'EventBus en cas de succès.
- cible **PTY-only** (profil sans adapter / présente seulement dans `TerminalSessions`) ⇒ erreur
typée explicite, **pas** d'ACK « launched ».
- **validation** : `agent.message` sans `task``MissingField` ; round-trip JSON `agent.message`.
- **non-régression** : `agent.run` ne change pas de comportement ; aucun accès outbox.
- garde anti-always-green sur au moins l'invariant timeout-ne-tue-pas OU ask-retourne-le-contenu.
## 4. Méthode
DevBackend code → vérifie `cargo build -p domain -p application -p app-tauri`. **N'exécute pas
`cargo test --workspace`** si un build concurrent tient le lock (sinon, lance-le). QA écrit + exécute
les tests, produit un rapport clair si rouge. **Ne pas committer, ne pas push** — Main relit et commit.
## 5. Références
- Domaine : `crates/domain/src/orchestrator.rs` (enum `OrchestratorCommand` ~l.111, `validate`
~l.175), `crates/domain/src/events.rs` (`DomainEvent`).
- Application : `crates/application/src/orchestrator/service.rs` (struct/deps ~l.37, `dispatch`
~l.88, `OrchestratorOutcome` ~l.50, `spawn_agent` comme modèle de résolution d'agent).
- Primitive : `crates/application/src/agent/structured.rs` (`send_blocking`).
- Registre structuré : `StructuredSessions` (livré D1, jumeau de `TerminalSessions`) — repère-le
et lis son API avant de t'en servir.
- Composition root : `crates/app-tauri/src/state.rs`.
- Spec : `ARCHITECTURE.md` §17.4 (réconciliation §16) et tableau §17.9 ligne **D6**.

View File

@ -0,0 +1,95 @@
# Brief Dev — Lot D7 : menu de profils restreint + retrait custom (§17.9)
> Demandé par **Main** à **DevBackend** + **DevFrontend** + **QA**. Cycle §3.
> Dernier lot de §17. **Back + front.** DevBackend livre le contrat, DevFrontend consomme.
## 0. Objectif (§17.3 / §17.6)
Tant que seuls Claude et Codex ont un adapter structuré, le **menu de sélection de profil IA**
ne doit proposer **que** des profils pilotables en mode structuré. Conséquences :
- **Gemini / Aider** (présents dans le catalogue de référence mais **sans** `structured_adapter`)
ne sont **plus proposés** à la sélection.
- Le **profil custom** est **retiré** (l'utilisateur ne peut plus saisir une commande arbitraire,
car on ne saurait pas la piloter en structuré).
Principe : `is_selectable(profile) == structured_adapter.is_some()` (équivaut à
`AgentSessionFactory::supports(profile)`). C'est ce prédicat qui **filtre la liste exposée**
(wizard first-run **et** création/édition d'agent).
> Note : on **ne casse pas** le modèle `AgentProfile` (un profil sans adapter reste un profil
> PTY/legacy valide, §17.3). On restreint seulement ce qui est **proposé à la sélection**.
## 1. Côté DevBackend (`crates/`)
1. **Prédicat de sélectionnabilité** centralisé : `is_selectable(&AgentProfile) -> bool`
(= `structured_adapter.is_some()`). Place-le là où c'est cohérent (catalogue/usecases agent).
Évite de dupliquer la logique ; si `AgentSessionFactory::supports` existe déjà (livré D2),
garde la **même sémantique** (les deux doivent rester d'accord).
2. **Exposer uniquement les profils sélectionnables** au chemin de sélection : le use case qui
alimente le wizard/la création (autour de `ReferenceProfiles` / `reference_profiles()` dans
`crates/application/src/agent/{catalogue,usecases}.rs`) doit **filtrer** sur `is_selectable`.
Gemini/Aider restent dans le catalogue **data** (ne les supprime pas du modèle) mais
**n'apparaissent pas** dans la liste proposée. Décide proprement : soit un nouveau champ
`selectable: bool` sur le DTO exposé, soit une liste déjà filtrée — choisis l'option la moins
ambiguë pour le front et documente-la.
3. **Retrait custom (back)** : si une commande/usecase accepte un profil custom arbitraire pour
la sélection/création depuis le wizard, neutralise ce chemin (ou documente qu'il n'est plus
appelé). Ne casse pas la persistance de profils existants.
4. Vérifie `cargo build -p domain -p application -p app-tauri`.
**Contrat à livrer à DevFrontend** (à mettre dans ton rapport) : la forme exacte de ce que le
front reçoit (liste filtrée ? champ `selectable`/`structuredAdapter` sur `ProfileDto` ?) pour
qu'il sache quoi afficher et quoi masquer. Rappel : `ProfileDto(pub AgentProfile)` sérialise déjà
`structuredAdapter` (camelCase) — tu peux t'appuyer dessus plutôt que d'ajouter un champ.
## 2. Côté DevFrontend (`frontend/src/`)
> **Ne démarre qu'après le contrat de DevBackend** (Main te relaiera la forme exacte).
1. **Wizard first-run** (`features/first-run/FirstRunWizard.tsx`, `ProfilesSettings.tsx`) :
- n'affiche que les profils **sélectionnables** (Claude/Codex) ;
- **retire le bloc `AddCustomProfile`** (`onAdd`/`vm.addCustom`, `emptyCustomProfile`,
`aria-label="add custom profile"`) — le bouton/forme custom **disparaît**.
2. **Sélecteur d'agent** (création/édition dans `features/agents/`) : même filtre — seuls
Claude/Codex proposés ; pas d'option custom.
3. Nettoie le code mort résultant (helpers `emptyCustomProfile`, validation custom) **uniquement**
s'il n'est plus référencé ailleurs — sinon laisse-le et signale-le.
4. Vérifie `cd frontend && npm run build`.
## 3. Invariants
- **Zéro régression** : la persistance/édition des profils déjà configurés n'est pas cassée ;
un projet existant avec un agent Gemini/Aider/custom **legacy** continue de fonctionner (on
restreint la **création**, pas l'exécution de l'existant).
- Le prédicat `is_selectable` est la **source unique** ; back et front doivent rester cohérents.
- Frontières : le front filtre/affiche selon le contrat du port, le back décide la sélectionnabilité.
## 4. Tests attendus (QA)
**Rust** (`-p application`/`app-tauri`) :
- `is_selectable` vrai pour Claude/Codex, faux pour Gemini/Aider.
- la liste exposée à la sélection ne contient **que** Claude/Codex (custom absent).
- non-régression : `reference_profiles()` (catalogue brut) contient toujours les 4 (data intacte).
**Vitest** (`frontend`) :
- le wizard first-run n'affiche que Claude/Codex ; **le bloc custom est absent**
(`aria-label="add custom profile"` introuvable).
- le sélecteur de création d'agent ne propose que Claude/Codex, pas de custom.
- garde anti-always-green : un test qui vérifie l'**absence** du custom doit échouer si le bloc
réapparaît (assertion sur non-présence d'un testid/label précis).
## 5. Méthode
DevBackend → contrat + build vert → Main relaie à DevFrontend → build vert → QA écrit+exécute
(`cargo test --workspace` ET `npx vitest run`) → vert. Rapport d'erreurs clair si rouge.
**Ne pas committer, ne pas push.**
## 6. Références
- Catalogue : `crates/application/src/agent/catalogue.rs` (`reference_profiles()` : claude+codex
`with_structured_adapter`, gemini+aider sans) ; use cases : `…/agent/usecases.rs`
(`ReferenceProfiles`).
- Factory : `AgentSessionFactory::supports` (livré D2, `crates/infrastructure/src/session/factory.rs`).
- DTO : `crates/app-tauri/src/dto.rs` (`ProfileDto`/`ProfileListDto`).
- Front : `frontend/src/features/first-run/{FirstRunWizard,ProfilesSettings}.tsx`,
`frontend/src/features/agents/`, `frontend/src/domain/index.ts` (`emptyCustomProfile`).
- Spec : `ARCHITECTURE.md` §17.3, §17.6 et tableau §17.9 ligne **D7**.

View File

@ -0,0 +1,48 @@
# Design — Option 1 « Terminal + MCP » (orchestration inter-agents)
> Décision produit arbitrée (2026-06-11). Remplace la vue chat structurée par le
> terminal natif + délégation inter-agents par outils MCP. Source : agent Architecte.
> Statut : **design validé, dev NON commencé** (limite de session atteinte le 2026-06-11,
> reset 3:40am Europe/Paris). Reprendre par les lots backend B-0→B-5 et frontend F-1.
## Objectif
- **Vue humaine = terminal brut natif** (PTY interactif). Réflexion live + Échap = natifs CLI, zéro parsing par modèle. On abandonne `AgentChatView`/stream-json comme vue.
- **Délégation cross-model via MCP** : `idea_ask_agent(target, task)` bloquant → la cible traite quand libre (FIFO) → rend son résultat via NOUVEL outil `idea_reply(result)` → IdeA débloque l'appelant. Fin-de-tour = signal MCP explicite.
- Principes : 1 agent = 1 employé (1 process/session, input FIFO) ; hexagonal + SOLID stricts ; plus aucun `parse_event` requis pour vue ni orchestration.
## Découvertes clés de l'architecte (état réel du code)
1. La **file FIFO existe déjà** : `OrchestratorService` (`crates/application/src/orchestrator/service.rs`) a `ask_locks: Mutex<HashMap<AgentId, Arc<AsyncMutex<()>>>>` + `ask_lock_for()` + `ASK_QUEUE_WAIT_CAP` (600s) + `ASK_AGENT_TIMEOUT` (300s). On la formalise en port `AgentMailbox` (pour porter un `oneshot` de réponse).
2. `idea_ask_agent``agent.message``OrchestratorCommand::AskAgent{target_agent, task}` **déjà câblé** (mcp/tools.rs, domain/orchestrator.rs, service.rs). On réimplémente le **corps** de `ask_agent()`.
3. Aujourd'hui `ask_agent` **exige une session structurée** et renvoie `AppError::Invalid` si la cible est en PTY brut (service.rs ~400-410). **Inverser cette branche** : PTY vivant = canal normal.
4. Routage structuré dans `crates/application/src/agent/lifecycle.rs` (`LaunchAgent` ~1100). Levier de bascule : **ne plus injecter la fabrique structurée au composition root** (`crates/app-tauri/src/state.rs`, `with_structured`).
5. `apply_mcp_config` (lifecycle.rs ~1391) écrit déjà `.mcp.json` + `--mcp-config` AVANT le spawn, **chemin PTY inclus** → la CLI PTY a déjà le serveur MCP IdeA (à vérifier par test B-0). Vigilance : `ensure_mcp_server` doit piloter `McpServer::serve` sur le loopback.
6. `idea_reply` n'existe nulle part : seul vrai ajout de surface.
## Lots BACKEND (Rust — agent dev backend) ; NE PAS faire B-6 (nettoyage) avant coordination
- **B-0** Prérequis transport MCP : garantir CLI PTY reçoit `--mcp-config <path>` (endpoint/project/requester) + `serve` piloté loopback. Test : CLI factice PTY appelle `idea_list_agents`, reçoit réponse.
- **B-1** Port `AgentMailbox` + `InMemoryMailbox`. Domaine pur (`crates/domain/src/mailbox.rs` ou ports.rs) : trait + `Ticket{id,requester,task}`, `TicketId`, `MailboxError`. Infra (`crates/infrastructure/src/mailbox/`) : `HashMap<AgentId, VecDeque<(Ticket, oneshot::Sender<String>)>>` + mutex ; `enqueue` rend `PendingReply` (sur `oneshot::Receiver`). Tests : FIFO ; `resolve` réveille le bon pending ; 2 ask même cible sérialisés ; cibles ≠ non bloquants ; timeout retire ticket de tête.
- **B-2** Bascule routage : tous en PTY. `state.rs` : retirer `with_structured` de `LaunchAgent`/`OrchestratorService`/`ChangeAgentProfile`. Tests : profil Claude → PTY ; DTO renvoie `CellKind::Pty`. Ne pas supprimer `launch_structured` (mort-code, nettoyage ultérieur).
- **B-3** Réimplémenter `ask_agent` : résoudre id → `mailbox.enqueue` → ticket en tête → garantir cible vivante PTY (sinon LaunchAgent PTY bg) → `PtyPort::write` préfixe `[IdeA · tâche de {A} · ticket {id}] {task}\n``await PendingReply` borné `ASK_AGENT_TIMEOUT`. PTY vivant = normal. Timeout : garder agent vivant, retirer ticket de tête. Publier `AgentReplied`. Injecter `Arc<dyn AgentMailbox>` + `Arc<dyn PtyPort>`. Tests : injection bon handle ; agent mort relancé ; timeout libère file ; AgentReplied.
- **B-4** Outil/action `idea_reply` : `ToolDef idea_reply` (schéma `{result:string}` seul, pas de ticket_id exposé), action wire `agent.reply`, `OrchestratorCommand::Reply{from:AgentId, result}`, `validate`, `map_tool_call` (passe `requester` du handshake comme `from`), bras dispatch → `mailbox.resolve(from, result)`. Corrélation implicite : `idea_reply` résout le ticket en tête de la file de l'émetteur (identité via handshake, pas via id géré par le modèle). `tool_returns_reply` : idea_reply = ACK sans inline. Tests : mapping ; validate exige result ; resolve corrèle tête ; reply sans ask = erreur typée (pas de panic).
- **B-5** Protocole délégation dans le contexte : injecter dans convention file (`apply_injection`) + description outil : « reçois `[IdeA · tâche …]` → traite → appelle IMPÉRATIVEMENT `idea_reply(result=…)` ; ne réponds jamais qu'en texte. » Test : convention file contient l'instruction.
## Lots FRONTEND (TS/React — agent dev frontend) ; NE PAS faire F-2 (suppression) avant coordination
- **F-1** Router toute cellule agent vers `TerminalView` (jamais `AgentChatView`) ; ré-attache PTY + scrollback OK. Backend renverra `cellKind:"pty"`. Lire `frontend/src/features/layout/LayoutGrid.tsx`, `features/chat/AgentChatView.tsx`, `TerminalView`, `adapters/agent.ts`, `ports/index.ts`, `domain/index.ts`. Laisser `AgentChatView` inerte (non monté), pas supprimé. Tests Vitest : agent rend `TerminalView`, jamais `AgentChatView` ; re-mount repeint pty.
## Ordre / dépendances
```
B-0 ─┬─ B-2 ─┬─ B-3 ─ B-4 ─ B-5
B-1 ─┘ └─ F-1
Nettoyage (B-6, F-2) en dernier, coordonné.
```
Chemin critique : B-0 → B-2 → B-3 → B-4 → B-5. B-1 ∥ B-0. F-1 dès B-2.
## Cohérence
Domaine sans I/O (port + entités pures) ; oneshot/PTY/MCP = infra ; application via ports. Open/Closed (idea_reply = ajout, dispatch intact) ; Liskov (Claude/Codex identiques derrière PTY+MCP) ; 1 process/agent préservé.
## Fichiers à toucher
- Domaine : `mailbox.rs` (nouveau) / `ports.rs` ; `orchestrator.rs` (variante `Reply` + action `agent.reply`).
- Application : `orchestrator/service.rs` (ask_agent + reply + injection ports) ; `agent/structured.rs` (supprimé au nettoyage) ; `agent/lifecycle.rs` (routage).
- Infra : `mailbox/` (nouveau) ; `orchestrator/mcp/tools.rs` (idea_reply) ; `orchestrator/mcp/server.rs` (passer requester).
- app-tauri : `state.rs` (retrait with_structured + injection mailbox + ensure_mcp_server) ; `commands.rs`/`dto.rs` (nettoyage ultérieur).
- Frontend : `features/layout/LayoutGrid.tsx` (routage TerminalView) ; `features/chat/*` (nettoyage ultérieur).

View File

@ -0,0 +1,280 @@
# Cadrage Architecture — Orchestration v3 : invocation native d'agents (surface MCP)
> Produit par **Architect** en réponse au brief `orchestration-v3-invocation-native.md`.
> Cadrage **avant tout code** (méthode §3). Livrable : ce document + mise à jour `ARCHITECTURE.md` §14.3.
> Aucun code de production ici.
---
## 0. État réel du terrain — ce qui est DÉJÀ résolu (lu dans le code, pas présumé)
Le brief décrit trois faiblesses (conscience = prose, pas de discussion inter-agents,
fire-and-forget). **Deux des trois sont déjà comblées par le pivot §17** (livré, lots D0→D7).
Il faut le constater honnêtement pour ne **pas re-cadrer** ce qui existe :
| Faiblesse du brief | Statut réel | Référence code |
|---|---|---|
| Pas de discussion inter-agents (`agent.message` « future », `task` ignoré pour agent vivant) | ✅ **RÉSOLU** | `OrchestratorCommand::AskAgent` (`domain/src/orchestrator.rs`), `OrchestratorService::ask_agent` (`application/src/orchestrator/service.rs`) |
| Fire-and-forget, pas de corrélation requête↔réponse | ✅ **RÉSOLU sans outbox** : le rendez-vous synchrone est **intrinsèque** à `AgentSession::send()` (flux → `Final` déterministe). Le `Final` *est* la fin de tour. | `application/src/agent/structured.rs` (`send_blocking`), `domain/src/ports.rs` (`ReplyEvent::Final`) |
| Pas de réveil du demandeur / event de réponse | ✅ **RÉSOLU** | `DomainEvent::AgentReplied`, `OrchestratorResponse.reply` (`infrastructure/src/orchestrator/mod.rs`) |
| Conscience = soft prompt (l'agent doit deviner le schéma JSON) | ⚠️ **PARTIEL** : la prose `# Orchestration IdeA` est injectée (`compose_convention_file`), mais **aucun outil typé natif** n'est exposé. | `application/src/agent/lifecycle.rs` |
| Interdiction des subagents natifs **+** alternative native | ⚠️ **PARTIEL** : interdiction présente (prose) ; l'alternative native (outils `idea_*`) **manque encore**. | idem |
| Capacité MCP sur le profil | ❌ **ABSENT** | — |
| Serveur MCP / config MCP par CLI | ❌ **ABSENT** | — |
**Conclusion de cadrage** : l'orchestration v3 **n'est plus** « combler la messagerie inter-agents »
(c'est fait). Elle se réduit à **un seul chantier net** : **exposer l'orchestration IdeA comme
serveur MCP model-agnostic**, en tant qu'**adapter entrant supplémentaire** par-dessus le **même**
`OrchestratorService::dispatch`, avec **repli homogène** sur le protocole fichier `.ideai/requests`
(§14.3) + prose (`compose_convention_file`) pour les CLI sans MCP. C'est ce que cadre la suite.
> **Principe directeur (zéro régression, §9/§17.3)** : MCP est un **confort de conscience native**
> (outils typés, plus de schéma à deviner). Il **n'invente aucune sémantique** : tout outil MCP se
> ramène à un `OrchestratorCommand` déjà existant. La voie principale du *retour de valeur* reste
> §17 (`send_blocking`) ; MCP ne fait que **déclencher** `dispatch`, jamais re-router la réponse.
---
## 1. Décisions tranchées (les 4 points durs du brief)
### Décision 1 — Capacité MCP par runtime = champ optionnel `mcp` sur `AgentProfile` (Open/Closed)
**Tranché** : on ajoute un champ **optionnel** `mcp: Option<McpCapability>` sur `AgentProfile`,
exactement comme `session: Option<SessionStrategy>` et `structured_adapter: Option<StructuredAdapter>`
le sont déjà. `None` (défaut) ⇒ **repli fichier + prose** (comportement actuel, zéro régression).
`Some(_)` ⇒ IdeA matérialise la config MCP de cette CLI au lancement et l'agent voit les outils `idea_*`.
```rust
// domain/src/profile.rs — capacité MCP déclarative (pur, validé par constructeur, comme SessionStrategy)
/// Stratégie de matérialisation de la config MCP propre à UNE CLI : chaque CLI
/// déclare son serveur MCP différemment (fichier `.mcp.json` pour Claude Code,
/// flag de lancement, ou variable d'env). Déclaratif = donnée, pas code (§9).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "strategy")]
pub enum McpConfigStrategy {
/// Écrire un fichier de conf MCP au chemin (relatif au run dir isolé §14.1)
/// attendu par la CLI, au format JSON propre à cette CLI (ex. `.mcp.json`).
ConfigFile { target: String }, // relative_safe(target) — pas de `..`, pas d'absolu
/// Passer le serveur via un flag de lancement (ex. `--mcp-config {path}`).
Flag { flag: String }, // non_empty(flag)
/// Passer via une variable d'environnement.
Env { var: String }, // valid_env_var(var)
}
/// Capacité MCP d'un profil : COMMENT déclarer le serveur MCP IdeA à cette CLI,
/// et QUEL transport. `None` sur le profil ⇒ repli fichier `.ideai/requests` + prose.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpCapability {
/// Comment matérialiser la config MCP au lancement (relatif au run dir).
pub config: McpConfigStrategy,
/// Transport du serveur MCP IdeA (détail invisible au domaine ; voir D3).
/// `stdio` = défaut robuste cross-OS ; `socket` = optimisation (point ouvert).
#[serde(default)]
pub transport: McpTransport,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum McpTransport { #[default] Stdio, Socket }
```
Sur `AgentProfile`, additif et **non cassant** (sérialisation inchangée pour les profils sans MCP) :
```rust
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp: Option<McpCapability>,
```
Builder additif (comme `with_structured_adapter`) : `AgentProfile::new(...).with_mcp(cap)` ; la
signature de `AgentProfile::new` reste **inchangée** ⇒ tous les appels du catalogue/tests restent verts.
**Justification** : cohérence §9 (« ajouter une IA = donnée, pas code »), symétrie avec les deux autres
capacités optionnelles déjà sur le profil, `skip_serializing_if = None`**zéro régression** de
sérialisation. Le **prédicat de surface** est `profile.mcp.is_some()` — un seul point de vérité.
> **Modèle en couches (exigé par le brief §4.1)** : la surface effective d'un agent est
> `surface(agent) = if profile.mcp.is_some() { Mcp } else { FileProtocol }`. Les deux couches
> produisent le **même** `OrchestratorCommand`. Aucun agent n'est jamais bloqué : sans MCP, la prose
> `# Orchestration IdeA` + `.ideai/requests` reste pleinement fonctionnelle.
---
### Décision 2 — Retour synchrone d'`ask` = AUCUN nouveau modèle de corrélation : on réutilise `send_blocking`
**Tranché (et c'est la décision la plus importante)** : l'outil MCP `idea_ask_agent` **ne crée
aucune corrélation requête↔réponse, aucun outbox, aucun event de réveil neufs**. Il appelle le
**même** `OrchestratorService::dispatch(AskAgent { target, task })` que le watcher fichier, qui
**retourne déjà** `OrchestratorOutcome { reply: Some(content) }` via `send_blocking`. L'adapter MCP
**renvoie ce `content` inline** comme valeur de retour de l'outil. Fin.
Le brief (rédigé avant le pivot §17) supposait qu'`ask` était un point dur à résoudre via outbox +
corrélation fichier. **Le pivot §17 l'a déjà tranché autrement** : le `Final` du `ReplyStream` *est*
la fin de tour déterministe ; pas besoin de deviner, pas d'outbox, pas de `request_id`. On **n'y
revient pas**. Le tableau ci-dessous fige la sémantique, déjà implémentée :
| Aspect | Décision (déjà en place) | Code |
|---|---|---|
| **Corrélation** | Aucune : `dispatch` est un appel synchrone `async` ; la réponse est la valeur de retour. Le transport MCP (JSON-RPC) porte nativement la corrélation requête/réponse. | `service.rs::ask_agent` |
| **Outbox** | **Supprimé de la voie principale** (§17.4). Pas réintroduit. | — |
| **Event `AgentReplied`** | **Observabilité UI uniquement** (« Architect a répondu à Main »), best-effort, ne porte pas la valeur. | `reply_outcome` |
| **Timeout** | Borné (`ASK_AGENT_TIMEOUT = 300 s`). À l'expiration : `AgentSessionError::Timeout` → la cible **reste vivante** (non tuée), l'outil MCP renvoie une **erreur typée** ; l'appelant décide. | `send_blocking` |
| **Cible a déjà une session vivante** (one-live-session-per-agent) | `ask` **réutilise** la session structurée vivante (`session_for_agent`) — rendez-vous direct, pas de respawn. Si la cible est vivante en **PTY brut** (profil sans `structured_adapter`) ⇒ erreur typée explicite (**jamais** un ACK trompeur). | `service.rs::ask_agent` étapes 1→3 |
| **Cible morte** | `LaunchAgent` en mode structuré (background) puis `send_blocking`. Garde d'unicité sur **les deux** registres. | idem |
**Justification** : DRY radical (une seule logique de rendez-vous, partagée par UI chat, watcher
fichier et MCP) ; frontière nette (le domaine ne connaît qu'un `prompt` et un `Final`, jamais un
transcript ni un id de corrélation) ; universalité (marche pour toute CLI structurée Claude/Codex).
**Le seul travail v3 ici est de brancher l'outil MCP sur `dispatch` — pas de re-cadrer le rendez-vous.**
> **Conséquence produit** : `idea_ask_agent` cible **toujours un agent structuré** (Claude/Codex),
> cohérent avec le menu restreint §17.3/§17.6. Un agent **demandeur** peut être n'importe quelle CLI
> MCP (Claude, Codex, Gemini…) ; un agent **cible** d'un `ask` doit être structuré. C'est déjà
> l'invariant en vigueur — MCP ne le change pas.
---
### Décision 3 — MCP vs subagents natifs : on garde l'interdiction ET on offre l'alternative native, config injectée par CLI au lancement
**Tranché** : l'interdiction des subagents natifs (prose `# Orchestration IdeA`) **reste** — elle
protège l'identité/mémoire/observabilité IdeA. Mais on offre désormais la **vraie alternative
native** : les outils `idea_*` apparaissent dans la liste d'outils de la CLI. La prose est **adaptée
selon la surface** :
- agent **MCP** (`profile.mcp.is_some()`) : la prose pointe vers les outils `idea_ask_agent` /
`idea_launch_agent` / `idea_list_agents` (au lieu d'« écris un JSON dans `.ideai/requests` »).
- agent **fichier** (`mcp == None`) : prose `.ideai/requests` actuelle, **inchangée**.
**Injection de la config MCP par CLI = au `LaunchAgent`, dans le run dir isolé (§14.1), via la
`McpConfigStrategy`** — exactement le même point et la même mécanique que le convention file :
```
LaunchAgent::execute (après apply_injection, avant spawn/factory.start) :
if let Some(mcp) = &profile.mcp:
// IdeA matérialise SA config MCP au format de CETTE CLI dans <run_dir>/...
apply_mcp_config(mcp, &run_dir, &spec) // ConfigFile→write ; Flag→spec.args ; Env→spec.env
```
- `ConfigFile { target }` : écrit `<run_dir>/<target>` (ex. `.mcp.json`) avec la déclaration du
serveur MCP IdeA (commande/transport). Non-clobbering, best-effort, **comme le seed de permissions**.
- `Flag { flag }` : ajoute le flag + chemin au `SpawnSpec.args`.
- `Env { var }` : ajoute la variable au `SpawnSpec.env`.
Le **serveur MCP lui-même** est démarré **par projet ouvert**, à côté du `FsOrchestratorWatcher`,
dans le **même hook** `ensure_orchestrator_watch` (`app-tauri/src/state.rs`). Une CLI qui se lance
avec la config injectée se connecte à ce serveur (stdio : IdeA spawn un pont par session ; socket :
adresse partagée — détail d'adapter, point ouvert S-MCP).
**Justification** : symétrie totale avec le convention file et le seed de permissions (même run dir,
même best-effort non-clobbering, même moment) ⇒ aucune nouvelle plomberie de cycle de vie. La config
MCP est **donnée déclarative par profil**, donc « ajouter une CLI MCP = donnée, pas code ».
---
### Décision 4 — Frontières hexagonales : le serveur MCP est un adapter entrant d'infrastructure ; AUCUN nouveau port applicatif
**Tranché** : le serveur MCP est un **driving adapter d'infrastructure**
(`infrastructure/src/orchestrator/mcp/`), **pair** du `FsOrchestratorWatcher`. Il appelle le **même**
`OrchestratorService::dispatch` (application) et **ne duplique rien**. Trois portes d'entrée
substituables se ramènent au même `OrchestratorCommand` :
```
┌─────────────────────────────────────────────┐
Agent MCP ───▶│ Serveur MCP (infra/orchestrator/mcp) │──┐
└─────────────────────────────────────────────┘ │
┌─────────────────────────────────────────────┐ │ OrchestratorCommand
Fichier ───▶│ FsOrchestratorWatcher (infra/orchestrator) │──┼──▶ OrchestratorService::dispatch
(.ideai/ └─────────────────────────────────────────────┘ │ (application — INCHANGÉ)
requests) ┌─────────────────────────────────────────────┐ │ │
UI ───▶│ Commandes Tauri (app-tauri) │──┘ ▼
└─────────────────────────────────────────────┘ use cases agent/terminal
```
- **Où vit le serveur MCP** : `infrastructure/src/orchestrator/mcp/`. Il **traduit** un appel d'outil
MCP (`idea_ask_agent`, `idea_launch_agent`, `idea_list_agents`, et par parité `idea_update_context`,
`idea_create_skill`, `idea_stop_agent`) en `OrchestratorCommand`, appelle `dispatch`, et renvoie
`OrchestratorOutcome` (`reply`/`detail`) inline comme résultat d'outil. JSON-RPC, stdio/socket,
le crate MCP : **tout reste dans cet adapter**. Le domaine/application ignorent MCP.
- **Quel port côté domaine/application** : **aucun nouveau**. `OrchestratorService::dispatch`
(application) est déjà l'unique seam. `idea_list_agents` réutilise `ListAgents`. La validation
(`OrchestratorRequest::validate`) reste le point unique « parse, don't validate » — l'adapter MCP
construit un `OrchestratorCommand` (directement, ou via `OrchestratorRequest` pour réutiliser la
validation, au choix d'implémentation).
- **Réutilisation de `OrchestratorService` plutôt que duplication** : le serveur MCP reçoit
`Arc<OrchestratorService>` au composition root (`state.rs`), exactement comme le watcher. Une seule
logique applicative ; les adapters ne portent que leur techno d'entrée.
**Justification** : DRY + règle de dépendance hexagonale. Cible, identité, mémoire, observabilité UI
passent **toujours** par le seul chemin applicatif. Les spikes MCP (transport, crate) sont **confinés**
à l'adapter infra et ne touchent ni le domaine ni l'application.
---
## 2. Modèle de messages corrélés — état figé (rien de neuf)
La « corrélation requête↔réponse » du brief est portée **nativement par le transport** :
- **MCP** : JSON-RPC corrèle requête/réponse par `id` de message ⇒ rien à modéliser côté IdeA.
- **Fichier** : `<file>.json``<file>.json.response.json` (sibling), déjà en place.
- **Valeur de retour** : `OrchestratorOutcome { detail, reply }` (application) → `OrchestratorResponse
{ ok, action, detail, error, reply }` (infra fichier) **ou** résultat d'outil MCP. **Structs déjà
définies**, réutilisées telles quelles.
Aucun `CorrelationId`, aucun `AgentReply`, aucun port `AgentReplyChannel`, aucun outbox : **abandonnés
par le pivot §17** et **non réintroduits** par v3. C'est la simplification clé.
---
## 3. Découpage en LOTS testables (méthode §3) — MCP uniquement
> Chaque lot = binôme dev+test, vert avant le suivant. Backend/frontend séparés.
> Les terminaux non-IA et le chemin fichier `.ideai/requests` restent verts à chaque lot.
> **Spike S-MCP** (crate MCP Rust + transport stdio/socket + format de conf par CLI) est **confiné au
> lot M2** et n'invalide pas l'ossature (le contrat d'entrée reste `OrchestratorCommand`).
| Lot | Côté | Périmètre | Crates/dossiers | Contrats | Tests attendus |
|---|---|---|---|---|---|
| **M0 (capacité profil)** | back | `McpCapability` + `McpConfigStrategy` + `McpTransport` (domaine, validés) ; champ `AgentProfile.mcp: Option<McpCapability>` (+ builder `with_mcp`, `new` inchangé) ; catalogue Claude/Codex annotés (ex. `ConfigFile { target: ".mcp.json" }`). | `domain/src/profile.rs`, `application/src/agent/catalogue.rs` | enum + struct + champ optionnel sérialisé | unit purs : `mcp = None` round-trip **identique à avant** (zéro régression sérialisation) ; `Some(_)` round-trip ; constructeurs valident (`relative_safe` target, `non_empty` flag, `valid_env_var`) ; catalogue annoté. |
| **M1 (injection conf MCP au lancement)** | back | `LaunchAgent` matérialise la conf MCP selon `McpConfigStrategy` dans le run dir isolé (après `apply_injection`, avant spawn/`factory.start`) : `ConfigFile`→write non-clobbering, `Flag`→`args`, `Env`→`env`. Prose `compose_convention_file` adaptée selon `mcp.is_some()`. | `application/src/agent/lifecycle.rs` | `LaunchAgent` (chemin MCP additif) | unit (fakes) : profil `mcp=None` ⇒ **aucun** write/flag/env MCP (chemin actuel inchangé) ; `ConfigFile` ⇒ fichier écrit au bon chemin, non-clobbering ; `Flag`/`Env` ⇒ `spec` enrichi ; prose contient les outils `idea_*` si MCP, sinon `.ideai/requests`. |
| **M2 (serveur/adapter MCP)** | back | `infrastructure/src/orchestrator/mcp/` : serveur MCP exposant `idea_ask_agent`/`idea_launch_agent`/`idea_list_agents` (+ parité `idea_update_context`/`idea_create_skill`/`idea_stop_agent`) → `OrchestratorCommand` → `dispatch` → résultat inline. **Spike S-MCP** (crate, transport) isolé ici. | `infrastructure/src/orchestrator/mcp/` | mapping outil→commande ; `Arc<OrchestratorService>` injecté | unit (fakes + `OrchestratorService` à use cases fakes) : chaque outil mappe la bonne commande ; `idea_ask_agent` renvoie `reply` inline ; timeout → erreur typée, cible non tuée ; `idea_list_agents` liste ; JSON-RPC malformé → erreur, jamais panic. Hors-réseau (transport en mémoire/pipe scriptable). |
| **M3 (câblage par projet)** | back | Démarrer le serveur MCP par projet ouvert dans `ensure_orchestrator_watch` (à côté du watcher) ; registre `mcp_servers` jumeau de `orchestrator_watchers` ; arrêt à la fermeture du projet. | `app-tauri/src/state.rs`, `commands.rs` | hook `ensure_orchestrator_watch` étendu | app-tauri : un serveur MCP par projet, idempotent ; fermeture du projet ⇒ arrêt ; coexiste avec le watcher fichier (les deux portes vivantes). |
| **M4 (observabilité UI — optionnel)** | front | Surfacer dans l'UI Agents qu'une délégation est passée par MCP vs fichier (badge/source sur l'event `OrchestratorRequestProcessed` / `AgentReplied`). Non bloquant. | `frontend/src/features/agents` | DTO d'event enrichi (`source: "mcp"|"file"`) | Vitest : badge source affiché ; absence d'event ⇒ pas de régression. |
**Ordre conseillé** : **M0 → M1 → M2 → M3** (→ M4 optionnel). M0 débloque tout (donnée pure) ;
M1 injecte la conf (testable sans serveur) ; M2 livre l'adapter derrière un transport scriptable
(spike confiné) ; M3 le câble par projet. M4 est du confort d'observabilité.
---
## 4. Conformité hexagonale & SOLID (rappel)
- **Règle de dépendance** : `McpCapability`/`McpConfigStrategy` sont **domaine** (purs, validés).
Le serveur MCP, JSON-RPC, stdio/socket, le crate MCP sont **exclusivement** infra. Le domaine et
l'application **ignorent** MCP (l'application ne voit que `OrchestratorCommand`/`dispatch`).
- **S** : le serveur MCP = une seule techno d'entrée (MCP→commande). `OrchestratorService` garde sa
responsabilité (commande→use cases). `LaunchAgent` gagne une étape d'injection homogène, pas une
responsabilité nouvelle.
- **O** : ajouter une CLI MCP = un bloc `mcp` sur le profil (**donnée**). Aucun cœur touché.
- **L** : les trois portes d'entrée (fichier, MCP, UI) sont substituables — même `dispatch`, même
résultat. Repli fichier ≡ MCP du point de vue de la réponse.
- **I** : le serveur MCP ne reçoit que `Arc<OrchestratorService>` (pas les use cases en détail).
- **D** : tout injecté au composition root (`state.rs`) ; aucun `new` d'adapter MCP ailleurs.
---
## 5. Chantiers adjacents (situés, NON cadrés ici)
- **Hot-swap de l'AI profile** (chantier A, §15.1) : **LIVRÉ** (`ChangeAgentProfile`). Interaction
avec v3 : un swap vers/depuis un profil MCP change la surface (`mcp.is_some()`) ⇒ au relance,
`LaunchAgent` (ré)injecte ou retire la conf MCP automatiquement. **Rien à cadrer** : la surface suit
le profil courant, point de vérité unique.
- **Reprise auto des sessions au redémarrage** (chantier B, §15.2) : **LIVRÉ** (`ListResumableAgents`,
`conversation_id` persisté sur la cellule). Interaction avec v3 : à la reprise, `LaunchAgent`
ré-matérialise la conf MCP comme à tout lancement (M1). **Rien à cadrer**.
Ces deux chantiers **ne sont pas un prérequis** de v3/MCP et n'en bloquent aucun lot.
---
## 6. Synthèse des décisions
1. **Capacité MCP = `Option<McpCapability>` sur le profil** (Open/Closed, `None` ⇒ repli fichier+prose, zéro régression sérialisation).
2. **Retour synchrone d'`ask` : RIEN de neuf** — réutilise `send_blocking`/`AskAgent`/`AgentReplied`/`OrchestratorOutcome.reply` déjà livrés (§17). Pas d'outbox, pas de corrélation fichier, pas de `CorrelationId`. Le transport MCP corrèle nativement.
3. **Interdiction subagents natifs conservée + alternative native** : prose adaptée selon surface ; conf MCP injectée **par CLI** au `LaunchAgent` dans le run dir isolé (`McpConfigStrategy` : ConfigFile/Flag/Env), symétrique au convention file et au seed permissions.
4. **Frontières** : serveur MCP = **adapter entrant infra** (`infrastructure/src/orchestrator/mcp/`), pair du watcher fichier, appelant le **même** `OrchestratorService::dispatch`. **Aucun nouveau port** applicatif/domaine.
5. **Lots** : M0 (capacité profil) → M1 (injection conf) → M2 (adapter/serveur MCP, spike confiné) → M3 (câblage par projet) → M4 (observabilité, optionnel).

View File

@ -0,0 +1,85 @@
# Brief Architecture — Orchestration v3 : invocation native d'agents (surface MCP + repli fichier)
> Demandé par **Main** à **Architect**. Cadrage attendu **avant tout code** (méthode §3).
> Ce brief ne prescrit pas l'implémentation : il pose le problème, les contraintes et les
> décisions à trancher. À toi de produire la cartographie (ports, adapters, modèles, lots).
## 1. Contexte & problème
Aujourd'hui, un agent apprend qu'il doit déléguer via IdeA **uniquement par une instruction
en prose** injectée en tête de son convention file (`compose_convention_file`,
`crates/application/src/agent/lifecycle.rs` → bloc « # Orchestration IdeA »). Il écrit alors
un JSON dans `.ideai/requests/<id>/*.json`, capté par l'`OrchestratorWatcher`
(`crates/infrastructure/src/orchestrator/mod.rs`) → validé par le modèle domaine pur
(`crates/domain/src/orchestrator.rs`) → exécuté par `OrchestratorService`.
**Trois faiblesses constatées dans le code :**
1. **Conscience = soft prompt.** Rien ne contraint l'agent ; rien ne l'empêche d'utiliser
le subagent natif du fournisseur ; le schéma JSON n'est même pas fourni dans l'instruction
(l'agent doit le deviner).
2. **Pas de discussion inter-agents.** `agent.message` est marqué « future ». Le champ `task`
d'`agent.run` est replié dans `context`, mais `OrchestratorService` n'utilise `context`
que pour un agent **neuf** (initial `.md`) : pour un agent **déjà existant**, le `task` est
**silencieusement ignoré**. La réponse (`*.response.json`) ne porte qu'un ACK de cycle de
vie (`detail: "launched agent X"`), jamais la sortie produite par la cible.
3. **Fire-and-forget.** Aucune corrélation requête↔réponse de contenu, aucun réveil du
demandeur.
## 2. Objectif produit (vision Anthony)
Rendre l'invocation d'un agent par un autre **aussi native que l'invocation de subagents dans
Claude CLI** (l'outil `Task` : le modèle voit un outil typé, l'appelle, et **le résultat
revient inline** dans sa conversation) — mais de façon **model-agnostic** (Claude, Codex,
Gemini, custom) et **toujours médiée par IdeA** (qui garde identité, contexte, mémoire,
observabilité UI).
## 3. Direction pressentie (à valider/affiner par l'Architecte)
**Exposer l'orchestration IdeA comme un serveur MCP** que IdeA branche sur chaque CLI qui le
supporte (Claude Code, Codex, Gemini CLI supportent MCP). Outils pressentis :
| Outil MCP | Effet |
|---|---|
| `idea_ask_agent(target, task) → reply` | Lance/réveille la cible, transmet la tâche, **attend et renvoie sa réponse** inline |
| `idea_launch_agent(target, visibility)` | Lancement fire-and-forget (équiv. `agent.run` actuel) |
| `idea_list_agents() → […]` | Découverte des agents du projet |
Bénéfices : conscience native (l'outil apparaît dans la liste d'outils, plus de prose à
« se rappeler »), arguments typés/validés (fini le JSON deviné), et surtout `ask_agent`
**renvoie le contenu** → comble la messagerie inter-agents manquante.
## 4. Points durs à trancher (cœur du cadrage)
1. **Capacité par runtime.** Tous les profils ne supportent pas MCP/outils (custom CLI).
→ Modèle **en couches** : surface MCP quand le profil le déclare ; **repli sur le protocole
fichier `.ideai/requests` + prose** sinon. Le port `AgentRuntime` gagne une capacité
déclarative (`supportsMcp` ou descripteur de capacités). Comment exprimer ça dans le profil
déclaratif (§9) sans casser l'existant ?
2. **Retour synchrone d'`ask_agent`.** C'est le vrai défi : « attendre que la cible ait fini
son tour et capturer sa sortie » pour un fournisseur arbitraire = même problème que
l'inspecteur de session (cf. mémoire `conversation-resume-architecture`). Piste : la cible
écrit sa réponse dans un **outbox** `.ideai/`, l'outil MCP attend/poll avec corrélation
requête↔réponse + timeout. Définir : modèle de corrélation, event `AgentReplied`, sémantique
de timeout/erreur, et que faire si la cible tourne déjà (one-live-session-per-agent).
3. **MCP vs subagents natifs.** On garde l'interdiction des subagents natifs (sinon
court-circuit d'IdeA = perte identité/mémoire/observabilité), mais on offre désormais une
**vraie alternative native**, pas qu'une interdiction. Comment configurer/injecter le serveur
MCP par CLI (chaque CLI a sa propre conf MCP) depuis le lancement IdeA ?
4. **Frontières hexagonales.** Où vit le serveur MCP (nouvel adapter d'infrastructure ?), quel
port côté domaine/application, comment il réutilise `OrchestratorService` existant plutôt que
de le dupliquer.
## 5. Chantiers adjacents (à seulement situer, pas à cadrer ici)
Garder en tête la cohérence avec deux autres chantiers du même fil « agent = entité » :
- **Hot-swap de l'AI profile** d'un agent existant (absent à toutes les couches aujourd'hui).
- **Reprise auto des sessions au redémarrage** (terrain T5/T7 + `conversation_id` prêt mais
non câblé : rien ne relance les agents `agent_was_running` à l'ouverture du projet).
## 6. Livrable attendu
Une cartographie d'architecture pour l'**orchestration v3** : ports & adapters, modèle de
messages (requête/réponse corrélées), capacité runtime MCP, stratégie de repli, découpage en
**lots** testables (méthode §3), et la liste des décisions tranchées avec leur justification.
Mets à jour `ARCHITECTURE.md` (§14.3) en conséquence.

View File

@ -0,0 +1,215 @@
# Orchestration v5 — Bind transport S-MCP + fix registre session (cadrage)
> **Agent Architecture.** Ce document tranche le **dernier kilomètre** de l'orchestration native : (1) le **bind transport** entre une CLI MCP réellement lancée et le serveur MCP par projet (verrou §S-MCP, resté ouvert depuis M3), et (2) le **fix de robustesse du registre de session** (mémoire `session-registry-agent-ambiguity`). Aucun code de production ici : décisions + contrats + découpage en lots testables.
>
> **État du terrain (lu, pas présumé)** :
> - `infrastructure/src/orchestrator/mcp/{server,transport,jsonrpc,tools}.rs` : `McpServer::serve(&mut transport)` boucle ligne-à-ligne sur `Transport::{recv,send}` ; `StdioTransport<R,W>` (JSON Lines générique), `MemoryTransport` (tests). **`serve` n'est jamais appelé en prod.**
> - `app-tauri/src/state.rs` : `ensure_mcp_server` crée un `McpServer` par projet et le **parke** sur un signal d'arrêt (`McpServerHandle::start` ⇒ `let _server = server; stop_rx.recv().await;`). **Aucun transport, aucun pair.**
> - `application/src/agent/lifecycle.rs` : `apply_mcp_config` matérialise la conf MCP (`ConfigFile`/`Flag`/`Env`) dans le run dir isolé, **après** `apply_injection`, **avant** spawn/`factory.start`. `mcp_server_declaration` écrit un placeholder `{"command":"idea","args":["mcp-server"],"transport":"stdio|socket"}`.
> - `application/src/terminal/registry.rs` : `TerminalSessions` (PTY) + `StructuredSessions` (IA) + agrégateur `LiveSessions`. Invariant **« 1 session vivante/agent »**.
> - `application/src/error.rs` : `AppError::AgentAlreadyRunning { agent_id, node_id }` + code `AGENT_ALREADY_RUNNING` — **défini mais jamais levé** (le garde de `LaunchAgent` rebind/idempotent au lieu d'échouer).
> - `app-tauri/src/commands.rs::list_live_agents` lit **seulement** `terminal_sessions.live_agents()` — **aveugle aux sessions structurées**.
---
## 0. Synthèse exécutive (décisions tranchées)
1. **Transport S-MCP = `stdio-spawn`** : la CLI **spawn elle-même** un process serveur MCP fourni par IdeA. Ce sous-process est un **client de boucle de retour** (loopback) vers le process Tauri, pas un second `OrchestratorService`. Justification : c'est le **seul** modèle réellement supporté par Claude Code / Codex (déclaration `.mcp.json` `{command,args}`), **cross-OS sans port réseau** (compatible AppImage / Windows / SSH-remote), et il **résout le point dur** « comment le process serveur retrouve le bon projet » par **injection d'identité à l'`args`/`env`** au `LaunchAgent` (le projet est connu à ce moment-là). Le socket est **rejeté** comme défaut (ports/permissions/cross-OS) mais **gardé en TODO** derrière le même trait `Transport`.
2. **Le binaire `idea mcp-server` est un sous-commande du binaire app-tauri existant** (pas un nouveau crate/binaire à distribuer séparément) : `main.rs` route `argv[1] == "mcp-server"` vers un mode **headless loopback** qui lit stdin/stdout (JSON Lines = `StdioTransport`) et **relaye** chaque message JSON-RPC au process IdeA principal via un **canal de loopback local** (Unix socket / Windows named pipe **par projet**, créé par `ensure_mcp_server`, chemin passé en `--endpoint`). Le `McpServer` (qui tient l'`OrchestratorService`) **vit dans le process Tauri** ; le sous-process `mcp-server` n'est qu'un **pont stdio↔loopback** ultraléger. Un seul binaire à livrer (AppImage/setup.exe).
3. **Contrat conf↔serveur de bout en bout** rendu **cohérent** : `apply_mcp_config` (M1) écrit une déclaration qui pointe **exactement** vers ce que `ensure_mcp_server` (M3) a mis à l'écoute — `command = <exe IdeA>`, `args = ["mcp-server", "--endpoint", <loopback du projet>, "--project", <id>]`. Fin du placeholder.
4. **Fix registre session = lot PRIORITAIRE et INDÉPENDANT du bind** (peut/doit partir en premier) : l'invariant correct est **« 1 session vivante par agent »** (décision produit verrouillée, mémoire `session-registry-agent-ambiguity` : un agent est un **singleton**, pas N instances). Le fix n'invente **pas** d'identité par cellule ; il **durcit l'invariant** sur les **deux** registres et **réconcilie les `layouts.json` à doublons**. Trois trous concrets à boucher (cf. §3).
5. **Robustesse `ask`** : cible morte ⇒ lancement structuré puis envoi ; cible PTY brut ⇒ `Invalid` explicite (déjà fait) ; **cible occupée par un autre tour** ⇒ sérialisation **FIFO par agent** (nouveau, §4) ; timeout 300 s ⇒ cible **vivante**, erreur typée (déjà fait). Deux `ask` simultanés sur la même cible ⇒ file, **jamais** d'entrelacement de tours.
6. **Frontières** : le pilotage de `serve` vit dans **l'adapter infra** (`McpServer` + une boucle **par connexion** sur le loopback du projet), supervisé par `McpServerHandle` (app-tauri) qui ne fait qu'**accepter les connexions** et spawn une tâche `serve` par pair. Aucune logique applicative ne fuit : le sous-process `mcp-server` ne connaît que des octets JSON-RPC ; `OrchestratorService` ignore tout du transport.
---
## 1. Décision 1 — Modèle de transport S-MCP
### 1.1 Le point dur, posé proprement
Une CLI MCP (Claude Code, Codex) attend une déclaration de serveur de forme **`{ "command": "...", "args": [...] }`** : à l'`initialize`, **elle spawn ce process** et parle **JSON-RPC sur son stdin/stdout**. Donc le « serveur » qu'elle voit est **un process enfant à elle**, distinct du process Tauri. C'est le cœur du problème : ce process enfant **n'a pas** l'`Arc<OrchestratorService>` du projet (use cases, registres de sessions, event bus vivent dans Tauri).
Deux familles de solutions :
| | **stdio-spawn (RETENU)** | socket (rejeté en défaut) |
|---|---|---|
| Ce que la CLI spawn | un **pont** `idea mcp-server` (stdio↔loopback) | rien — elle se connecte à un serveur déjà à l'écoute |
| Où vit l'`OrchestratorService` | process Tauri (le pont relaie) | process Tauri (écoute directe) |
| Cross-OS / AppImage | ✅ pipes stdio + loopback local (Unix socket / named pipe) | ⚠️ port TCP (firewall/permissions) ou socket — déclaration CLI variable |
| Retrouver le bon projet | **`--endpoint`/`--project` à l'`args`**, fixés au `LaunchAgent` (projet connu) | l'adresse encode le projet, mais la CLI doit la connaître |
| Compat Claude/Codex | ✅ `{command,args}` natif | ❓ support socket inégal selon CLI/version |
| Cycle de vie | la CLI **possède** le pont (meurt avec elle) | serveur long-vécu, connexions multiplexées |
### 1.2 Pourquoi stdio-spawn, malgré le sous-process en plus
- **Compatibilité réelle** : `{command,args}` est le **dénominateur commun** des CLIs MCP. Le socket n'est pas universellement déclarable.
- **Cross-OS sans port réseau** : le **loopback local** entre le pont et Tauri est un **Unix domain socket** (Linux/macOS) ou un **named pipe** (Windows) — déjà la techno la plus portable pour de l'IPC local, **sans firewall ni permission réseau**, donc **AppImage-safe** et **SSH-remote-safe** (le pont tourne côté machine de l'agent).
- **Résolution du point dur par injection d'identité** : le projet est **connu** au moment du `LaunchAgent` (c'est lui qui écrit la conf MCP). On **encode** `--endpoint <chemin loopback du projet>` (+ `--project <id>` en garde-fou) dans les `args` de la déclaration. Le pont n'a **rien à deviner** : il se connecte à l'endpoint du **bon** projet. Le `McpServer` côté Tauri, lui, **est** déjà attaché à cet `OrchestratorService`/`Project` (créé par `ensure_mcp_server`).
### 1.3 Le pont `idea mcp-server` (sous-commande du binaire existant)
- **Pas de nouveau binaire distribué** : `main.rs` détecte `argv[1] == "mcp-server"` **avant** d'initialiser Tauri/WebKit, et bascule en **mode headless pont**. Un seul exécutable livré (AppImage / setup.exe).
- **Rôle du pont** : `StdioTransport(stdin, stdout)` côté CLI ; un client de loopback côté Tauri. Boucle : lire une ligne JSON-RPC de la CLI → l'écrire sur le loopback → lire la réponse du loopback → l'écrire sur stdout. **Zéro logique métier** : c'est un tube transparent. (Optionnellement, le pont peut **directement** porter le `McpServer` si l'`OrchestratorService` était accessible — il ne l'est pas inter-process — d'où le relais.)
- **Côté Tauri** : `ensure_mcp_server` crée **l'endpoint loopback du projet** (socket/pipe), et `McpServerHandle` **accepte** les connexions ; **chaque connexion** (= un pont = un agent) ⇒ une **tâche `McpServer::serve(&mut conn_transport)`**`conn_transport` enveloppe le flux loopback. `McpServer` est **déjà** branché à l'`OrchestratorService` du projet.
### 1.4 Identité de l'appelant (lève le `requester_id = "mcp"` figé)
`server.rs::publish_processed` tague aujourd'hui `requester_id: "mcp"` (placeholder). Avec stdio-spawn, le pont **connaît l'agent** (le `LaunchAgent` peut injecter `--requester <agent-id>` dans les `args` de la déclaration, comme `--project`). Le pont passe cette identité dans la **poignée de connexion** (premier message de handshake loopback, hors JSON-RPC MCP), et `McpServer::serve` la porte dans son contexte de connexion ⇒ `OrchestratorRequestProcessed.requester_id` devient l'**agent réel**. Observabilité UI exacte (qui a délégué à qui).
### 1.5 Socket = TODO derrière le même trait
Le trait `Transport` (jsonrpc.rs) **isole** déjà le serveur du transport. Un `SocketTransport` (TCP/HTTP-stream) reste un **ajout sans toucher `McpServer`** si une CLI l'exige. Non requis pour Claude/Codex ⇒ **hors périmètre v5**, documenté.
---
## 2. Décision 2 — Contrat conf injectée (M1) ↔ serveur écouté (M3/v5)
Bout en bout, **un seul chemin** :
```
profil.mcp = Some(McpCapability{ config: ConfigFile{".mcp.json"} | Flag{..} | Env{..}, transport })
│ (LaunchAgent, run dir isolé .ideai/run/<agent>/ ; après apply_injection, avant spawn)
apply_mcp_config écrit la DÉCLARATION RÉELLE (fin du placeholder) :
{
"mcpServers": {
"idea": {
"command": "<chemin absolu de l'exe IdeA>", ← std::env::current_exe()
"args": ["mcp-server",
"--endpoint", "<loopback du projet>", ← fourni par ensure_mcp_server
"--project", "<project id>",
"--requester","<agent id>"] ← identité (§1.4)
}
}
}
│ (la CLI lit .mcp.json à l'initialize)
la CLI SPAWN <exe IdeA> mcp-server --endpoint … --project … --requester …
│ (pont stdio↔loopback)
le pont se connecte au LOOPBACK DU PROJET (créé par ensure_mcp_server)
│ (handshake : project id + requester id)
McpServerHandle ACCEPTE ⇒ tâche McpServer::serve(conn) [McpServer tient l'OrchestratorService du projet]
│ tools/call → map_tool_call → OrchestratorCommand → OrchestratorService::dispatch(&project, cmd)
réponse inline (idea_ask_agent ⇒ outcome.reply) renvoyée verbatim à la CLI
```
**Invariant de cohérence à tester** : le **chemin de l'endpoint** et l'**exe** écrits par `apply_mcp_config` sont **exactement** ceux que `ensure_mcp_server` met à l'écoute pour ce projet. Source de vérité **unique** : une fonction (app-tauri) calcule l'endpoint d'un `ProjectId` ; M1 (qui écrit la conf) et M3/v5 (qui écoute) l'appellent tous deux. **Pas** de chaîne dupliquée.
**Le `transport` du profil** reste surfacé dans la déclaration pour la voie socket future, mais en stdio-spawn il est **implicite** (la CLI spawn = stdio). `McpConfigStrategy` inchangé : `ConfigFile` écrit le fichier, `Flag`/`Env` passent le **chemin de la conf** (run dir) — sémantique déjà en place, on ne fait que **remplir** la déclaration de vrai contenu.
---
## 3. Décision 3 — Fix du registre de session (lot PRIORITAIRE, indépendant)
### 3.1 Invariant correct (verrouillé)
**1 session vivante par agent** (un agent = singleton : un `.md`, une conversation, un run dir, une mémoire). On **ne** modélise **pas** une identité par cellule. La **cellule est une vue** rebindable (§17.6). `session_for_agent` est donc **déterministe par construction** — à condition que l'invariant soit **réellement enforced** et que les **deux registres** soient considérés. Aujourd'hui il y a **trois fuites** :
### 3.2 Trou A — le garde de `LaunchAgent` ne lève jamais `AgentAlreadyRunning`
`LaunchAgent::execute` (lifecycle.rs ~877-920) : si l'agent a déjà une session vivante (PTY **ou** structurée) et qu'un `node_id` est demandé, il **rebind silencieusement** ; sans node, il **rend la session existante** (idempotent). C'est juste pour **réattacher une vue**, mais cela **masque** un vrai second lancement (deux cellules distinctes voulant le **lancer** chacune). `AppError::AgentAlreadyRunning` est **défini mais jamais levé**.
**Décision** : distinguer **réattache de vue** (légitime, rebind) de **second lancement** (à refuser). Le signal de discrimination existe déjà dans le flux : un `LaunchAgentInput` issu d'une **réattache** (la cellule sait que l'agent tournait : `agent_was_running`/`conversation_id` présents) vs un **lancement neuf**. Le garde lève `AgentAlreadyRunning { agent_id, node_id_hôte }` quand un lancement **neuf** vise un agent **déjà vivant sur un autre node**, et **rebind** seulement quand le `node_id` demandé **est** le node hôte (ou réattache explicite). L'orchestrateur `spawn_agent` (`Visible{node_id}`) suit la **même** règle.
### 3.3 Trou B — `list_live_agents` est aveugle aux sessions structurées
`commands.rs::list_live_agents``state.terminal_sessions.live_agents()` **seulement**. Un agent **chat** (structuré) vivant n'apparaît **pas** ⇒ l'UI ne le désactive pas dans le dropdown ⇒ on peut tenter de le relancer ailleurs.
**Décision** : la commande lit l'**agrégateur** `LiveSessions::live_agents()` (PTY **+** structuré), déjà présent dans le registre. Un seul point de vérité de liveness pour l'UI.
### 3.4 Trou C — `layouts.json` à doublons (deux feuilles, même `agent`)
Les layouts persistés **contiennent déjà** des feuilles en double sur le même `agent` id (constaté). À l'ouverture, **ne pas auto-lancer la 2ᵉ** ; **réconcilier** : une seule feuille reste « hôte vivant », les autres sont des vues mortes (pas de relance, pas de bannière fraîche). C'est précisément ce qui causait le **symptôme** (« une cellule reset au retour d'onglet »).
**Décision** : étape de **réconciliation à l'ouverture du projet** (app-tauri, jumelle de `SnapshotRunningAgents`) : pour chaque agent apparaissant sur N feuilles, **garder une** hôte, **dé-flagger** `agent_was_running`/`conversation_id` sur les autres feuilles dupliquées. La reprise (B, §15.2) ne relance alors qu'**une** session/agent.
### 3.5 Pourquoi indépendant du bind
Aucun de ces trois trous ne touche MCP/transport : ils vivent dans `LaunchAgent`, `list_live_agents`, et l'ouverture de projet. Le fix **stabilise le routage de `ask`** (qui s'appuie sur `session_for_agent`) **avant** d'ouvrir la vanne MCP ⇒ on évite de débugger un `ask` mal routé **et** un transport neuf en même temps. **⇒ Lot R0, livré en premier.**
---
## 4. Décision 4 — Robustesse `ask` (sérialisation FIFO par agent)
Sémantique cible (au-dessus de l'existant) :
| Situation | Comportement |
|---|---|
| Cible inconnue | `AppError::NotFound` (fait) |
| Cible morte | lancement structuré (background) puis envoi (fait) |
| Cible vivante en **PTY brut** | `AppError::Invalid` explicite, jamais d'ACK trompeur (fait) |
| Cible vivante structurée, **libre** | rendez-vous direct `send_blocking` (fait) |
| Cible vivante structurée, **déjà en tour** (autre `ask`) | **file FIFO par agent** : le 2ᵉ `ask` attend son tour, **pas** d'entrelacement (NOUVEAU) |
| Timeout 300 s | cible **vivante**, erreur typée `Timeout`, retry possible (fait) |
**Le seul vrai manque = la concurrence.** Deux `ask` simultanés sur la même cible appelleraient `send_blocking` **en parallèle** sur la **même** `AgentSession` ⇒ deux tours entrelacés sur un moteur qui pilote **une** conversation. Inacceptable (cf. bug accents = writes non sérialisés).
**Décision** : **sérialiser les tours par agent** dans `OrchestratorService::ask_agent` via un **verrou par `agent_id`** (un `Mutex`/sémaphore d'unité, registre `HashMap<AgentId, Arc<Mutex<()>>>` détenu par le service, ou porté par l'entrée de `StructuredSessions`). Un `ask` **acquiert** le verrou de l'agent avant `send_blocking`, le **relâche** après le `Final`/timeout. Les `ask` concurrents forment une **file naturelle** (ordre d'acquisition). Le timeout s'applique **au tour** (pas à l'attente du verrou) — ou un timeout global borné l'attente totale (décision : timeout **par tour** ; l'attente en file ne consomme pas le budget, mais un plafond d'attente évite l'inanition). Cohérent avec « 1 conversation déterministe/agent ».
**Erreurs typées remontées aux deux portes** : MCP ⇒ `tool_result_text(.., isError=true)` (déjà) ; fichier ⇒ `*.response.json` avec le champ d'erreur (déjà via `OrchestratorOutcome`/watcher). Le verrou n'ajoute pas de nouveau type d'erreur ; un timeout d'attente en file ⇒ `Timeout` (même type).
---
## 5. Décision 5 — Frontières hexagonales (où vit `serve`)
- **`McpServer::serve` = adapter infra**, piloté **par connexion** : `McpServerHandle` (app-tauri) **accepte** sur l'endpoint loopback du projet et, **par pair connecté** (= un agent), **spawn une tâche** `serve(conn)`. Le serveur reste **sans état de connexion** au-delà de l'identité du pair (portée par le contexte de connexion, §1.4).
- **`McpServerHandle` évolue** : aujourd'hui il **parke** (`let _server; stop_rx.recv()`). Demain il **ouvre l'endpoint** + boucle d'`accept` ; à l'arrêt, ferme l'endpoint (les ponts enfants meurent avec leur CLI). **Toujours** non-bloquant pour open/close projet (l'`accept` est async, parqué sur l'absence de pair).
- **Le sous-process `mcp-server`** vit dans `app-tauri` (route `main.rs`), mais ne connaît que **stdio + loopback + JSON brut** : **zéro** `OrchestratorService`, zéro use case. Frontière nette.
- **Aucune fuite applicative dans l'infra** : `OrchestratorService::dispatch` est appelé **à l'identique** par les trois portes (fichier, MCP, UI). Le verrou par agent (§4) est une **règle applicative** ⇒ il vit dans `OrchestratorService` (ou le registre `StructuredSessions`), **pas** dans l'adapter MCP.
---
## 6. Découpage en LOTS testables (méthode §3)
> Ordre global : **R0 (fix registre) d'abord** — indépendant, débloque la robustesse de `ask`. Puis **bind transport M5x**. Front (M5-UI) optionnel en fin.
### Bloc R — Fix registre session (PRIORITAIRE, indépendant du transport)
| Lot | Côté | Périmètre | Critères de test |
|---|---|---|---|
| **R0a** | back (application) | Garde `LaunchAgent` : lever `AgentAlreadyRunning{agent_id,node_hôte}` pour un **lancement neuf** ciblant un agent déjà vivant sur **un autre node** (PTY **ou** structuré) ; **rebind** seulement si node demandé = node hôte / réattache. Même règle dans `OrchestratorService::spawn_agent`. | lancement neuf d'un agent vivant ailleurs ⇒ `AgentAlreadyRunning` (code `AGENT_ALREADY_RUNNING`) ; réattache même node ⇒ rebind sans respawn ; idempotence background inchangée ; `ask` d'une cible morte ⇒ lancement OK (pas de faux positif). |
| **R0b** | back (app-tauri) | `list_live_agents` lit `LiveSessions::live_agents()` (PTY **+** structuré). | un agent **chat** vivant apparaît dans la liste ; un agent PTY aussi ; aucun doublon ; sans session ⇒ vide. |
| **R0c** | back (app-tauri) | Réconciliation à l'**ouverture projet** : pour un agent sur N feuilles, garder **une** hôte, dé-flagger `agent_was_running`/`conversation_id` sur les autres. | layout à doublons ⇒ après ouverture, **une seule** feuille « était en cours » pour l'agent ; layout sans doublon **inchangé** ; persistance idempotente (2ᵉ ouverture = no-op). |
| **R0d** | front | Dropdown agent du leaf : désactiver/"déjà placé" via la liste R0b (PTY+chat) ; gérer le retour `AGENT_ALREADY_RUNNING` (aller-à / déplacer). | Vitest : agent vivant ailleurs ⇒ option désactivée + action « aller à la cellule » ; erreur backend mappée à un message clair. |
### Bloc M5 — Bind transport S-MCP (stdio-spawn)
| Lot | Côté | Périmètre | Critères de test |
|---|---|---|---|
| **M5a** | back (app-tauri) | **Endpoint loopback par projet** : fonction unique `mcp_endpoint(project_id)` (Unix socket / Windows named pipe) ; `ensure_mcp_server` l'ouvre, `stop_orchestrator_watch` le ferme. | endpoint créé à l'open, supprimé au close ; idempotent (1/projet) ; chemin déterministe par `ProjectId` ; pas de collision inter-projets. |
| **M5b** | back (app-tauri) | **Sous-commande `mcp-server`** dans `main.rs` (avant init Tauri) : pont `StdioTransport(stdin,stdout)` ↔ loopback (`--endpoint`), handshake (`--project`,`--requester`). | `argv mcp-server` ⇒ mode headless, ne lance pas la webview ; relaie une requête JSON-RPC ligne→loopback→réponse→stdout ; EOF stdin ⇒ sortie propre ; endpoint absent ⇒ erreur non-zéro, jamais de hang. |
| **M5c** | back (infra/app-tauri) | **`McpServerHandle` accepte** sur l'endpoint et **spawn `McpServer::serve(conn)` par pair** ; identité du pair (requester) portée au contexte de connexion ⇒ `OrchestratorRequestProcessed.requester_id` = agent réel (fin du `"mcp"` figé). | une connexion ⇒ une tâche serve ; `initialize`/`tools/list`/`tools/call` OK de bout en bout via le loopback (test d'intégration local, hors réseau) ; `requester_id` = l'agent ; déconnexion d'un pair n'affecte pas les autres ; arrêt ferme l'endpoint + termine les serve. |
| **M5d** | back (application) | **`apply_mcp_config` écrit la déclaration RÉELLE** (fin placeholder) : `command = current_exe`, `args = ["mcp-server","--endpoint",mcp_endpoint(project),"--project",id,"--requester",agent]`. `ConfigFile` non-clobbering ; `Flag`/`Env` portent le chemin de conf. **Source d'endpoint partagée avec M5a.** | `mcp=None` ⇒ aucune écriture (inchangé) ; `ConfigFile``.mcp.json` pointe l'exe + endpoint **exacts** du projet ; endpoint identique à `ensure_mcp_server` (test de cohérence M1↔M3) ; non-clobbering. |
| **M5e** | back (intégration) | **Smoke end-to-end loopback** (sans CLI réelle) : un faux pont écrit `tools/call idea_list_agents`/`idea_ask_agent` sur le loopback d'un projet et reçoit la réponse inline du `dispatch` réel. | `idea_list_agents` ⇒ JSON des agents ; `idea_ask_agent` vers une cible structurée ⇒ `reply` inline ; cible PTY ⇒ erreur typée ; JSON-RPC malformé ⇒ erreur, jamais panic ; **hors réseau**. |
### Bloc A — Robustesse `ask` (concurrence)
| Lot | Côté | Périmètre | Critères de test |
|---|---|---|---|
| **A0** | back (application) | **Sérialisation FIFO par agent** dans `ask_agent` : verrou par `agent_id` autour de `send_blocking` ; timeout **par tour** ; plafond d'attente en file. | deux `ask` concurrents sur la même cible ⇒ tours **séquentiels** (pas d'entrelacement), ordre FIFO ; un `ask` sur agent A et un sur agent B ⇒ **parallèles** ; timeout d'un tour laisse la cible vivante et **libère** la file ; plafond d'attente ⇒ `Timeout` typé. |
### Optionnel
| Lot | Côté | Périmètre | Critères de test |
|---|---|---|---|
| **M5-UI** | front | Badge **source** (`mcp`/`file`) + **requester réel** sur une délégation (réutilise `OrchestratorRequestProcessed`). | badge source correct ; requester = agent réel (plus « mcp ») ; pas de régression sans event. |
**Ordre recommandé** : **R0a→R0b→R0c→R0d** (stabilise le routage), puis **A0** (concurrence `ask`, ne dépend pas du transport), puis **M5a→M5b→M5c→M5d→M5e** (bind), puis **M5-UI**. R0 et A0 sont livrables **sans** toucher MCP ; M5 ne doit partir qu'**après** R0 (sinon on débugge `ask` mal routé + transport neuf ensemble).
---
## 7. Conformité hexagonale & SOLID (rappel)
- **Règle de dépendance** : JSON-RPC, stdio, loopback socket/pipe, sous-process `mcp-server` = **infra/app-tauri exclusivement**. `domain`/`application` ignorent MCP et le transport. Le verrou FIFO par agent est une **règle applicative** (vit dans `OrchestratorService`/registre), pas dans l'adapter.
- **S** : `McpServer` = traduire JSON-RPC → `dispatch` ; le **pont** = relayer des octets ; `McpServerHandle` = cycle de vie + `accept` ; le verrou = sérialiser les tours. Aucune responsabilité fourre-tout.
- **O** : un `SocketTransport` futur = un impl de `Transport` en plus, **zéro** modif de `McpServer`. Une CLI MCP de plus = un profil avec `mcp = Some(..)`, zéro code.
- **L** : les trois portes (fichier, MCP, UI) sont substituables derrière `OrchestratorService::dispatch`**un** comportement applicatif.
- **I** : `McpServerHandle` ne voit que « accepter + servir » ; `OrchestratorService` ne voit que `dispatch` + registres ; l'UI ne voit que `LiveSessions::live_agents`.

View File

@ -0,0 +1,35 @@
# Protocole — Validation réelle de la conversation inter-agents via IdeA
> À exécuter depuis la **nouvelle AppImage** (build 2026-06-10 18:23, contenant R0+A0+M5,
> commits `37e7274` / `6ca519b` / `cf89b3b`). L'ancienne image (testée avant) ne contenait
> pas ce code et a renvoyé l'erreur attendue « agent Ask pas pilotable en mode structuré ».
## But
Prouver en conditions réelles qu'un agent (Main/Claude) peut **demander** une tâche à un autre
agent (Ask/Codex) **via IdeA** et **recevoir sa réponse inline** — pas seulement par tests à fakes.
## Pré-requis pour que le `ask` aboutisse
- La cible (**Ask**) doit être pilotée en **mode structuré** (profil Codex avec adapter structuré).
Le menu de sélection d'agent ne propose normalement que des profils structurés (Claude/Codex).
- Ask **ne doit pas** déjà tourner comme **terminal brut** (PTY) dans une cellule : sinon
`ask_agent` refuse (invariant « 1 session/agent », cible PTY brut = pas de canal de réponse).
- Le plus simple : **laisser Ask éteint** et laisser `ask_agent` le **lancer lui-même** en
structuré (sémantique : cible morte ⇒ launch structuré background ⇒ send ⇒ Final).
## Procédure (protocole fichier, identique au test précédent)
1. Déposer `.ideai/requests/main/<nom>.json` :
```json
{ "type": "agent.message", "requestedBy": "Main", "targetAgent": "Ask",
"task": "Petite recherche, pas de code : résume en 3 points ce que fait le module
crates/infrastructure/src/orchestrator/mcp/ et liste les outils idea_*." }
```
2. Attendre l'apparition de `.ideai/requests/main/<nom>.json.response.json`.
## Succès attendu
`{ "ok": true, "action": "agent.message", "reply": "<réponse de Codex>" }` — le champ **`reply`**
porte le contenu produit par Ask. (Échec précédent = `ok:false` + erreur PTY brut.)
## Voie native MCP (bonus)
Le bind transport S-MCP (M5) est livré : un agent lancé avec un profil MCP voit les outils
`idea_*` (dont `idea_ask_agent`) et le résultat revient inline. À valider quand un profil MCP
est branché sur Claude/Codex. Voir `.ideai/briefs/orchestration-v5-transport-bind-cadrage.md`.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,9 @@
---
upTo: 88f405da-f9c9-44bb-96c9-7e1b0e5af557
objective: Bonjour TestConversation. Ceci est un test de communication inter-agents via IdeA. Peux-tu confirmer que tu me reçois et me dire combien font 17 + 25 ?
---
**Objectif :** Bonjour TestConversation. Ceci est un test de communication inter-agents via IdeA. Peux-tu confirmer que tu me reçois et me dire combien font 17 + 25 ?
- **Prompt:** Bonjour TestConversation. Ceci est un test de communication inter-agents via IdeA. Peux-tu confirmer que tu me reçois et me dire combien font 17 + 25 ?
- **Prompt:** Test de communication inter-agent. Merci de confirmer que tu reçois bien ce message en répondant via idea_reply avec : (1) un court message de confirmation, (2) la date/heure si tu y as accès, (3) le nom de ton AI Profile/contexte tel que tu le perçois. Ceci sert uniquement à valider le canal de communication entre agents IdeA.
- **Response:** (1) Confirmation : message bien reçu, le canal de communication inter-agent IdeA fonctionne correctement. (2) Date : 2026-06-13 (je n'ai pas d'heure horodatée plus précise dans mon contexte). (3) AI Profile/contexte perçu : Claude Code (modèle Opus 4.8, claude-opus-4-8), opérant sur le project root /home/anthony/Documents/Projects/IdeA via un run dir isolé. Canal validé. ✅

View File

@ -0,0 +1,3 @@
{"id":"20dd8bdc-f858-46a6-9c8a-6afde591a6bd","conversation":"6ffc1f69-77a5-0da1-1965-ef26d1df72f5","atMs":1781367769591,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Bonjour TestConversation. Ceci est un test de communication inter-agents via IdeA. Peux-tu confirmer que tu me reçois et me dire combien font 17 + 25 ?"}
{"id":"0b8fcc51-c692-4928-9d90-3823857dad5c","conversation":"6ffc1f69-77a5-0da1-1965-ef26d1df72f5","atMs":1781368607174,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Test de communication inter-agent. Merci de confirmer que tu reçois bien ce message en répondant via idea_reply avec : (1) un court message de confirmation, (2) la date/heure si tu y as accès, (3) le nom de ton AI Profile/contexte tel que tu le perçois. Ceci sert uniquement à valider le canal de communication entre agents IdeA."}
{"id":"88f405da-f9c9-44bb-96c9-7e1b0e5af557","conversation":"6ffc1f69-77a5-0da1-1965-ef26d1df72f5","atMs":1781368615582,"source":{"kind":"agent","agentId":"c932c770-cf36-4fb2-a966-71bb1644e4b4"},"role":"response","text":"(1) Confirmation : message bien reçu, le canal de communication inter-agent IdeA fonctionne correctement. (2) Date : 2026-06-13 (je n'ai pas d'heure horodatée plus précise dans mon contexte). (3) AI Profile/contexte perçu : Claude Code (modèle Opus 4.8, claude-opus-4-8), opérant sur le project root /home/anthony/Documents/Projects/IdeA via un run dir isolé. Canal validé. ✅"}

70
.ideai/layouts.json Normal file
View File

@ -0,0 +1,70 @@
{
"version": 1,
"activeId": "1af250f0-65ef-4b78-8905-b1746673aee0",
"layouts": [
{
"id": "1af250f0-65ef-4b78-8905-b1746673aee0",
"name": "Default",
"kind": "terminal",
"tree": {
"root": {
"type": "split",
"node": {
"id": "56ffe1e4-636c-458d-9ab2-7e278fd45897",
"direction": "row",
"children": [
{
"node": {
"type": "leaf",
"node": {
"id": "c3319a9a-1345-4fa2-b64e-5f3fe00d13d8",
"session": "4965c71a-f69f-4c06-90de-ecb81acff710",
"agent": "a6ced819-b893-4213-b003-9e9dc79b9641",
"agentWasRunning": true
}
},
"weight": 1.0
},
{
"node": {
"type": "split",
"node": {
"id": "8cf1c06e-2654-45a3-bf17-a9c2507da935",
"direction": "column",
"children": [
{
"node": {
"type": "leaf",
"node": {
"id": "69dc2e23-86f5-4770-84c4-b1b4b2c25299",
"session": "11fbf6b4-eb62-4420-95e9-feb2ff667c43",
"agent": "c932c770-cf36-4fb2-a966-71bb1644e4b4",
"agentWasRunning": true
}
},
"weight": 1.0
},
{
"node": {
"type": "leaf",
"node": {
"id": "b9251e74-3bd5-43ee-90e5-e6bb87faab38",
"session": "69a3bf52-05ef-45c0-badf-26b0d8224f0e",
"agent": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5",
"agentWasRunning": true
}
},
"weight": 1.0
}
]
}
},
"weight": 1.0
}
]
}
}
}
}
]
}

6
.ideai/memory/MEMORY.md Normal file
View File

@ -0,0 +1,6 @@
# Memory Index
- [agent-context-memory-and-profile-handoff](agent-context-memory-and-profile-handoff.md) — Decisions sur l'injection de contexte, la memoire durable, l'etat live et le handoff de profil entre agents IA.
- [idea-product-directives-main-handoff](idea-product-directives-main-handoff.md) — Directives produit consolidees pour guider Main sur la robustesse, la persistance, le handoff cross-profile et la sobriete UX.
- [remaining-work-idea-agent-control-ide](remaining-work-idea-agent-control-ide.md) — Etat des lieux des acquis et des chantiers restants pour aligner IdeA avec la cible d'IDE de controle d'agents IA.
- [mcp-bridge-and-delegation-runtime-notes](mcp-bridge-and-delegation-runtime-notes.md) — Pieges runtime du pont MCP/delegation et regle de rebuild de l'AppImage (binaire qui tourne = AppImage, pas les sources).

View File

@ -0,0 +1,106 @@
---
name: agent-context-memory-and-profile-handoff
description: Decisions sur l'injection de contexte, la memoire durable, l'etat live et le handoff de profil entre agents IA.
metadata:
type: project
---
# Agent Context, Memory, and Profile Handoff
## Resume
This note captures the current product direction for IdeA around agent context injection, project memory, live state, and profile handoff between AI providers.
See also:
- `idea-product-directives-main-handoff` for product priorities and UX constraints.
- `remaining-work-idea-agent-control-ide` for the current implementation status and remaining work.
## Context Injection
- Agent context must be injected by IdeA at launch time; the model should not be expected to discover `AGENTS.md` or `CLAUDE.md` by itself.
- The existing `contextInjection` architecture is the right mechanism, especially `conventionFile` for providers that support a conventional file in the run directory.
- The current implementation is strongest for `conventionFile`; `flag`, `stdin`, and `env` do not yet receive the same fully-composed IdeA context.
- The `flag` strategy appears fragile with the isolated run directory model because the relative path passed to the CLI may not resolve from the run directory.
## Shared Project Context
- `.ideai/CONTEXT.md` is intended as shared project context.
- It is not created automatically; it only exists if something writes it.
- It should carry active project constraints and contribution rules.
- Example content for `CONTEXT.md`: architectural constraints, workflow rules, and operating conventions that every agent must apply immediately.
## Durable Memory
- `.ideai/memory/` is intended as durable, project-scoped memory shared by all agents of the same project.
- It is not created automatically; it only exists once at least one memory note is saved.
- The durable memory is a knowledge base, not a live activity log.
- It should contain stabilised knowledge such as:
- architecture decisions
- feature summaries to implement later
- user preferences that persist across sessions
- important project facts and references
- Durable memory should stay curated and low-noise.
## Live State Versus Durable Memory
- Durable memory should not be used as a shared real-time state feed for all agents.
- IdeA should distinguish between:
- global context (`.ideai/CONTEXT.md`)
- durable memory (`.ideai/memory/`)
- live operational state (separate store)
- handoff summaries between sessions or profiles
- Real-time work tracking, who-is-doing-what, and transient status should live in a dedicated state mechanism, not in durable memory.
## Memory Consumption by Agents
- The current launcher reads shared project context from `.ideai/CONTEXT.md` if present.
- It also recalls project memory via `MemoryRecall` and injects a `# Memoire projet` section into the convention file.
- This injection currently happens only for `conventionFile` profiles.
- The recalled memory is shared at the project level, but each agent may receive a different subset depending on its persona and recall query.
- Memory recall is computed at launch time and written into the generated context file.
- There is currently no automatic live refresh when `.ideai/memory/` changes during an active session.
## Recommendation for Live Memory Refresh
- Automatic memory refresh could be useful, but it should be explicit and controlled.
- If IdeA wants agents to benefit from memory changes while they are active, it should regenerate their effective context when needed instead of treating durable memory as a constantly streaming log.
- For PTY agents, no automatic reread exists today.
- For structured Claude/Codex sessions, each turn relaunches the CLI, but the generated convention file is not automatically rewritten when durable memory changes.
## Profile Handoff and Session Continuity
- A direct native session transfer from Claude to Codex is not the right mental model.
- The correct model is continuity of work state, not native provider-session continuity.
- IdeA should persist:
- a canonical conversation log
- a cumulative handoff summary
- the agent state
- the provider conversation id when useful
- On provider swap, IdeA should launch the new profile with:
- regenerated project context
- regenerated durable memory recall
- the current agent persona
- a handoff summary plus recent transcript
- The handoff summary should not be created only at the moment of swap.
- IdeA should maintain summaries incrementally or at checkpoints so that a swap is still possible when the current provider is near a token or session limit.
## Agent Ability To Write Durable Memory
- Agents should be allowed to promote important knowledge into durable memory.
- This should not rely on the agent guessing the capability.
- IdeA should expose the capability explicitly through tools or commands and should inject a clear rule explaining when an agent may save durable knowledge.
- This write ability should be constrained to stable, high-value information, not transient state.
## Practical Classification Rule
- Put immediate project rules and operating constraints in `.ideai/CONTEXT.md`.
- Put stable, reusable project knowledge in `.ideai/memory/`.
- Put current activity and coordination state in a separate live-state mechanism.
- Put cross-session or cross-profile recovery material in a handoff/session layer.
## Current Product Direction
- Keep `CONTEXT.md` for project rules.
- Keep `.ideai/memory/` for curated durable knowledge.
- Introduce a separate live-state mechanism if agents must stay aligned on in-progress work.
- Introduce persistent conversation logs and incremental handoff summaries to support profile swaps such as Claude to Codex.

View File

@ -0,0 +1,206 @@
---
name: idea-product-directives-main-handoff
description: Directives produit consolidees pour guider Main sur la robustesse, la persistance, le handoff cross-profile et la sobriete UX.
metadata:
type: project
---
# IdeA Product Directives For Main
## Resume
Cette note consolide les arbitrages produit explicites donnes par l'utilisateur pour aider `Main` a poursuivre le projet sans ambiguite.
See also:
- `agent-context-memory-and-profile-handoff` for the structural model of context, durable memory, live state, and handoff.
- `remaining-work-idea-agent-control-ide` for the current implementation status and remaining work.
Elle ne remplace pas les notes techniques existantes. Elle sert de reference prioritaire sur:
- la robustesse attendue,
- la persistance et la reprise,
- le handoff entre profils IA,
- la memoire projet partagee,
- le live state projet,
- la sobriete UX.
## Priorite Absolue
La priorite produit numero un est la robustesse.
Ordre de priorite impose:
1. robustesse et solidite avant tout
2. persistance et reprise
3. handoff cross-profile
4. live state projet
5. reste des features et du polish
Regle de pilotage:
- un systeme incomplet mais solide vaut mieux qu'un systeme riche mais fragile
- `Main` doit privilegier les architectures et comportements qui reduisent les crashes, les incoherences d'etat et les flows difficiles a reprendre
## Reprise Et Persistance
Quand IdeA redemarre, l'objectif n'est pas seulement de rouvrir une UI ou de restaurer des handles techniques.
La cible produit est:
- qu'un agent sache compactement sur quoi il travaillait
- qu'IdeA fournisse ce materiel de reprise automatiquement
- que la reprise soit exploitable meme si la conversation visible precedente n'est pas restauree a l'identique
Le bon modele est:
- un log canonique IdeA comme source durable
- un resume/handoff genere par IdeA comme couche compacte de reprise
Le resume/handoff n'est pas un confort secondaire. Il fait partie du comportement normal du produit.
## Handoff Cross-Profile
La cible ideale est double:
- reprendre correctement le travail
- donner si possible une impression de continuite presque sans rupture
Mais en cas de compromis, la priorite doit etre:
- fidelite operationnelle du travail repris
- avant la parfaite illusion de continuite terminale ou conversationnelle
Autrement dit:
- si un agent passe de Claude a Codex, IdeA doit d'abord garantir que Codex puisse reprendre le plus fidelement possible le travail utile
- l'absence de restauration parfaite de l'ancien terminal est acceptable si le handoff reste bon
## Perimetre Profils
Le perimetre de reference immediat est:
- Claude
- Codex
Toute fonctionnalite importante doit etre faisable pour ces deux profils.
Directive associée:
- reduire au maximum les dependances a des commandes, flags ou comportements specifiques a un profil
- construire un noyau le plus generique possible tout en restant concretement compatible Claude/Codex
- les autres profils pourront etre ajoutes plus tard si possible, mais ne doivent pas detourner le coeur du chantier actuel
## Memoire Projet Partagee
La memoire projet partagee doit rester petite, stable et utile.
Elle ne doit pas devenir un gros bloc qui siphonne les tokens de l'utilisateur a chaque requete.
Ce qu'un agent peut ecrire automatiquement dans la memoire partagee si c'est stable et utile:
- decisions durables d'architecture ou d'organisation
- preferences utilisateur durables
- regles de workflow durables
- references importantes a conserver
- resumes de handoff utiles a la reprise inter-session ou inter-profil
Ce qu'un agent ne doit pas y ecrire automatiquement:
- conversations brutes
- journaux detailles de travail
- etats temporaires
- files d'attente
- coordination temps reel
- essais/erreurs locaux
- hypotheses non stabilisees
- contenu redondant ou reconstructible ailleurs
Principe de fond:
- memoire durable = savoir stable
- log canonique = historique
- handoff = reprise compacte
- live state = coordination vivante
Ces couches doivent rester separees.
## Live State Projet
Le live state projet partage doit exister comme mecanisme interne d'IdeA.
Contraintes produit:
- il doit rester invisible pour l'utilisateur
- il doit survivre au redemarrage d'IdeA
Il ne doit pas se transformer en UI verbeuse ni en mecanisme demandant une intervention explicite de l'utilisateur.
## UX Et Philosophie Produit
IdeA doit etre tres facile d'utilisation.
Objectif UX:
- plug and play
- pas de sensation de parametrage impose
- pas de surcharge de tuto au premier lancement
- pas d'impression que le produit force des comportements internes a l'utilisateur
Ligne directrice souhaitee:
- esprit "maniere Linux"
- comportement simple et utile par defaut
- pas de contrainte tant qu'il n'y a pas un vrai besoin
- suggestion discrete seulement si IdeA detecte qu'une aide ou une optimisation devient utile
Le precedent du compactage de contexte est considere comme la bonne direction:
- pas de compactage impose d'emblee
- une popup proposee seulement si IdeA sent un besoin
## Transparence Des Mecanismes Internes
Les mecanismes suivants doivent rester quasi invisibles pour l'utilisateur:
- delegations inter-agents
- FIFO
- handoffs
Ils peuvent devenir visibles en debug ou quand le produit a une bonne raison UX de les exposer, mais ils ne doivent pas etre ressentis comme une charge cognitive normale d'utilisation.
## Frontiere Avec Le Chantier Inter-Agents De Main
Les choix fins touchant la communication entre agents ne doivent pas etre recadres ici si `Main` est deja en train de les traiter.
Cette note ne doit donc pas etre lue comme une specification d'implementation inter-agents detaillee.
Elle fixe seulement les invariants produit suivants:
- robustesse avant richesse fonctionnelle
- reprise automatique par IdeA
- log canonique + handoff genere par IdeA
- support de reference pour Claude et Codex
- memoire durable compacte et curatee
- live state interne et persistant
- UX discrete, simple et peu intrusive
## Directive Finale Pour Main
Si un arbitrage technique oppose:
- elegance theorique
- ou livraison rapide
contre:
- robustesse
- reprise fiable
- sobriete UX
alors `Main` doit privilegier:
- robustesse
- reprise fiable
- sobriete UX
avant le reste.

View File

@ -0,0 +1,51 @@
---
name: mcp-bridge-and-delegation-runtime-notes
description: Pieges runtime du pont MCP et de la delegation inter-agents IdeA, et la regle de rebuild de l'AppImage.
metadata:
type: project
---
# Pont MCP & delegation : pieges runtime
Deux bugs trouves le 2026-06-13 en testant la conversation inter-agents (`idea_ask_agent` vers TestConversation), tous deux corriges. Notes utiles pour ne pas reperdre du temps :
## Le binaire qui tourne = AppImage installee, pas les sources
L'IdeA en cours d'utilisation est `/home/anthony/Documents/IdeA_0.1.0_amd64.AppImage`. **Ce meme binaire sert a la fois de serveur orchestrateur (il tient `OrchestratorService`/`InputMediator` et compose les evenements) ET de binaire-pont** (`<exe> mcp-server …` declare dans chaque `.ideai/run/<id>/.mcp.json`). Donc **tout correctif cote serveur ou cote pont n'est actif dans l'app que apres rebuild + reinstall de l'AppImage et relance d'IdeA**. Un binaire `target/debug` fraichement compile ne valide que le pont (qui parle au serveur via le socket) ; il ne valide pas la composition cote serveur.
**Comment appliquer :** apres une correction backend, rebuild AppImage (`npm --prefix frontend run build` puis, depuis `crates/app-tauri/`, `../../frontend/node_modules/.bin/tauri build --bundles appimage`), remplacer l'AppImage, relancer IdeA, puis retester. **Exclure NSIS** (`--bundles appimage`) sur Linux. **Piege FUSE (2026-06-14)** : l'etape finale `linuxdeploy` echoue avec `failed to run linuxdeploy` (linuxdeploy est une AppImage qui se monte via FUSE). Workaround obligatoire : prefixer `APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1`. Le compile Rust reussit avant ce point ; seul le bundling casse, et l'`AppDir` est genere mais pas le `.AppImage`. L'artefact final = `target/release/bundle/appimage/IdeA_0.1.0_amd64.AppImage`.
## Env AppImage pollue le shell
La session shell herite des variables de l'AppImage montee (`APPDIR`, `LD_LIBRARY_PATH`, `PYTHONHOME` -> `/tmp/.mount_IdeA_*`). Consequences : `python3` casse (`No module named 'encodings'`) et lancer un binaire app-tauri fraichement compile tente de booter WebKit et crash. **Workaround :** lancer avec un env propre (`env -i PATH=/usr/bin:/bin HOME=$HOME XDG_RUNTIME_DIR=/run/user/1000 ...`), utiliser `jq` plutot que python.
## Bug 1 — pont MCP en lockstep (corrige)
`mcp_bridge.rs::relay` lisait 1 ligne client -> attendait 1 reponse loopback, en boucle. MCP n'est pas 1:1 : `notifications/initialized` n'a pas de reponse => deadlock juste apres `initialize`, et `tools/list` n'etait jamais relaye => les outils `idea_*` ne se chargeaient jamais (`claude mcp list` affichait quand meme "Connected" car `initialize` repond). Corrige en relay **full-duplex** (deux pompes concurrentes) + drain borne (`DRAIN_GRACE`) a la fermeture stdin. Symptome cote utilisateur : 3 jours de "outils MCP pas charges".
## Bug 2 — prefixe de delegation perdu (corrige)
Le signal `[IdeA · tâche de <demandeur> · ticket <id>]` qui dit a l'agent cible "reponds via `idea_reply`, jamais en texte" n'etait plus compose nulle part : supprime du backend (`service.rs` C3 §5.1) lors du passage de l'ecriture PTY au frontend, mais le frontend (`useWritePortal.ts`) ecrit `head.text` verbatim et ne l'ajoutait pas. La cible recevait la tache brute, repondait en texte => `idea_ask_agent` timeout. Corrige en composant le prefixe dans `infrastructure/src/input/mod.rs` (`delegation_preamble`) a l'emission de `DomainEvent::DelegationReady` ; la tache brute reste dans le `Ticket`/historique. Rappel : la correlation `idea_reply` marche par ticket OU par tete de FIFO (fallback), donc le ticket echo est recommande mais pas strictement requis.
## Bug 3 — cold-launch race : 1er tour perdu (corrige 2026-06-14)
Deleguer a un agent **froid** (pas encore lance) via `idea_ask_agent` echouait silencieusement : terminal cible vide, ask bloque jusqu'au timeout 300s ; un agent **chaud** (deja a son prompt, lance manuellement) marchait. Cause : avec `with_structured` non cable sur l'orchestrateur (regression assumee `aa2f67a`), tous les `ask` passent par le chemin PTY `ensure_live_pty` (`application/src/orchestrator/service.rs`). Un agent jamais vu etait initialise `Idle` (`BusyTracker::start_turn` -> `or_insert(Idle)`, `infrastructure/src/input/mod.rs`), donc le 1er `enqueue` publiait `DelegationReady` **immediatement** et ecrivait la tache dans le PTY avant que le CLI ait affiche son prompt -> tache perdue. Le prompt-ready watcher (lot C5) ne gardait que les tours suivants. Fix : `ensure_live_pty` renvoie un flag `cold_launch` ; si cold ET `prompt_ready_pattern` non vide, l'orchestrateur appelle `mark_starting(agent)` -> la `DelegationReady` du 1er tour est **differee** puis publiee par le watcher a l'apparition du prompt. Sans pattern ou agent chaud -> livraison immediate (zero regression). Touche `domain/src/input.rs` (port `mark_starting`), `infrastructure/src/input/mod.rs`, `service.rs`.
## Bug 4 — le fix cold-launch ne s'armait jamais en prod (corrige 2026-06-15)
Le « fix Bug 3 corrige 2026-06-14 » etait trop optimiste : il ne s'arme que si `gate_cold_start = cold_launch && prompt_ready_pattern non vide` (`service.rs`). Or le profil **Claude Code** (`664cc20c`, partage par TOUS les agents) dans `~/.local/share/app.idea.ide/profiles.json` n'a **aucun** `prompt_ready_pattern`. Donc `mark_starting` n'etait jamais appele -> `enqueue` publiait `DelegationReady` immediatement -> tache ecrite dans le PTY avant le prompt de `claude` -> 1er tour perdu -> `idea_ask_agent` vers un agent **froid** bloque jusqu'au timeout (un agent **chaud** marche). Le Bug 3 n'etait valide que par des tests unitaires qui injectent un pattern a la main : le gap d'integration (profil sans pattern) n'avait pas ete vu.
**Fix (option B, signal MCP, sans sniff de prompt TUI) :** on gate le cold-launch des qu'un MCP est configure sur le profil (`gate_cold_start = cold_launch && (pattern non vide || profil.mcp.is_some())`), et on **libere** le tour differe quand le pont MCP de l'agent froid se connecte (= son CLI est up + outils charges) : nouveau port `InputMediator::release_cold_start(agent)` (drain du `deferred`, **sans** `mark_idle` car c'est un signal de DEMARRAGE, idempotent et OR-safe avec `prompt_ready`), `McpServer` fire un `ready_sink: Fn(&str)` sur `initialize` avec le `requester` du handshake, et la composition root (`state.rs::ensure_mcp_server`) parse le requester en `AgentId` -> `OrchestratorService::release_agent_cold_start`. Touche `domain/src/input.rs`, `infrastructure/src/input/mod.rs`, `infrastructure/src/orchestrator/mcp/server.rs`, `application/src/orchestrator/service.rs`, `app-tauri/src/state.rs`. Tests unitaires verts ; **validation live = rebuild AppImage + relance IdeA** (cf. section binaire qui tourne). Filet OR : si un jour un profil porte un `prompt_ready_pattern`, les deux signaux coexistent.
**Methodo :** ne PAS deleguer la reparation du systeme inter-agents via le systeme inter-agents (casse) — utiliser les subagents natifs (outil Agent).
## Bug 5 — la boucle `serve` du serveur MCP est en lockstep, un `ask` sans reponse wedge TOUTE la connexion (corrige 2026-06-15)
Symptome : 1er `idea_ask_agent` vers un agent qui repond -> OK ; puis `ask` vers un agent qui ne rappelle JAMAIS `idea_reply` (ex. QA lance mais qui ne repond pas) -> ensuite **tout** appel suivant du MEME demandeur (meme `idea_list_agents`, sans rendezvous) se bloque. Cote utilisateur : "DevBackend ne marche plus" alors qu'il repondait 11s avant — en realite c'est la connexion du DEMANDEUR (Main) qui est morte, pas la cible. Annuler l'appel cote client ne deparke rien.
Cause : `infrastructure/src/orchestrator/mcp/server.rs::serve` lisait 1 requete puis **awaitait `handle_raw` en entier** avant de relire. Pour `idea_ask_agent`, `handle_raw -> dispatch -> service.dispatch` attend le `idea_reply` de la cible : pendant cette attente la boucle ne relit plus rien -> pipeline de la connexion fige. C'est l'analogue COTE SERVEUR du Bug 1 (lockstep) qui n'avait ete corrige que cote pont (`mcp_bridge.rs`). NB : la couche application bornait deja le rendezvous (`service.rs::ask_agent` via `tokio::time::timeout`), donc l'attente n'etait pas infinie — le vrai coupable etait bien la serialisation de `serve`, pas l'absence de timeout.
Fix : `serve` reecrite full-duplex non bloquante — `tokio::select!` entre `transport.recv()` et un canal `mpsc::unbounded::<Option<Vec<u8>>>` ; chaque message entrant traite dans une tache `tokio::spawn` qui possede un clone cheap `self.for_requester(self.requester.clone())` ('static) + un clone du `tx` ; reponses (`Some`) ou notifications (`None`) renvoyees par le canal et ecrites par la meme boucle ; arret gracieux via compteur `in_flight` (on draine les taches en vol apres EOF). Les reponses MCP portent l'`id` -> ordre indifferent. Le trait `Transport` (`&mut self` recv/send) et les signatures `serve`/`serve_as` restent intacts. + filet de securite : timeout serveur **isole au seul `idea_ask_agent`** (`ASK_RENDEZVOUS_TIMEOUT` 24h, finie ; setter `with_ask_rendezvous_timeout` `#[doc(hidden)]` pour les tests). Tests : `infrastructure/tests/mcp_server.rs` -> `pending_ask_does_not_wedge_the_connection_concurrent_call_still_answered` (anti-wedge, echoue sur l'ancien code) + `ask_agent_rendezvous_times_out_with_a_jsonrpc_error`. Verts : `cargo test -p infrastructure` (20 mcp_server), `cargo test -p app-tauri --lib mcp_e2e_loopback_tests` (6). **Validation live = rebuild AppImage + relance IdeA** (la connexion wedgee ne se deparke pas de l'interieur : il FAUT relancer IdeA). AppImage du 2026-06-15 10:58 contient le fix ; backup `~/Documents/IdeA_0.1.0_amd64.AppImage.old-prewedgefix`.
Reste a investiguer (separe, non bloquant) : pourquoi QA lance ne repond pas du tout a une delegation (son `claude` n'appelle pas `idea_reply`) — impossible a creuser tant que la connexion du demandeur est wedgee ; le fix Bug 5 permet desormais de le diagnostiquer sans tout figer.
## Bug 6 — la tache n'est JAMAIS ecrite dans le PTY d'un agent delegue en arriere-plan (corrige 2026-06-15)
C'EST la cause racine du « reste a investiguer » du Bug 5. Symptome : un agent lance a la main (cellule visible, ex. DevBackend) repond ; un agent **froid auto-lance par `idea_ask_agent`** (ex. QA) ne repond JAMAIS → `ask` bloque jusqu'au timeout (24h, `ASK_AGENT_TIMEOUT`). Reproduction sure et non bloquante : `idea_stop_agent` puis `idea_launch_agent(task=…, visibility=background)`, et observer le dossier de session `claude` de la cible (`~/.claude/projects/<run-dir>/*.jsonl`) : **aucune nouvelle session** en 28 s = la tache n'a jamais ete soumise (le pont MCP de la cible, lui, se connecte bien).
Cause : depuis ARCHITECTURE §20, l'**ecriture physique du PTV est faite par le write-portal FRONTEND** (`useWritePortal`), qui n'est monte **que pour une cellule de layout (leaf) visible**. Or `ensure_live_pty` cold-lance la cible en **background avec `node_id: None`** (`service.rs`) → aucune cellule montee → `useWritePortal` n'existe pas → l'event `DelegationReady` n'a **aucun consommateur** → tache perdue. Les fix Bug 3/4 (differer/liberer la `DelegationReady`) ne pouvaient donc jamais marcher pour un agent background : ils publient un event que personne n'ecoute cote UI.
Fix (option « writer PTY backend ») : le mediateur ecrit lui-meme la tache dans le PTY **quand aucune cellule frontend n'est attachee**. Registre `front_owned` dans `MediatedInbox`, alimente par le front via `bindHandle`/`unbindHandle` → commande Tauri `set_front_attached``OrchestratorService::set_agent_front_attached``InputMediator::set_front_attached`. Point de livraison unique = `BusyTracker::publish_deferred` (chemin chaud immediat ET drains froids `prompt_ready`/`release_cold_start`), qui passe par un **HeadlessSink** optionnel cable par `MediatedInbox::with_pty`/`with_events` : agent dans `front_owned``Some(d)` (publie l'event, le front ecrit, inchange) ; sinon ⇒ ecrit texte puis (apres `submit_delay_ms`, defaut 60ms, anti-paste-detection) la `submit_sequence` dans le handle PTY bound, sur un `std::thread` detache (le watcher prompt-ready tourne sur un std::thread, PAS tokio — ne pas utiliser `tokio::spawn`). Touche `domain/src/input.rs` (port `set_front_attached`), `infrastructure/src/input/mod.rs` (HeadlessSink + front_owned + handles en `Arc<Mutex>`), `application/src/orchestrator/service.rs`, `app-tauri/src/{dto,commands,lib}.rs`, `frontend/src/{ports,adapters/input,adapters/mock,features/terminals/useWritePortal}`. Tests verts : `cargo test -p infrastructure` (input 34, dont `headless_agent_without_front_cell_is_written_by_the_backend` + `front_attached_agent_is_delivered_via_event_not_backend_write`), app-tauri lib 42, useWritePortal 9, tsc. **Validation live = rebuild AppImage + relance IdeA** (build 2026-06-15 11:42 ; backup `~/Documents/IdeA_0.1.0_amd64.AppImage.old-prefrontwriterfix`).
NB diagnostic : `~/.claude/projects/<encoded-run-dir>/*.jsonl` = transcript de session `claude` de l'agent ; pas de nouveau fichier apres une delegation = tache jamais soumise. `ss -xp | grep idea-mcp` = ponts MCP connectes cote serveur.
## Bug 7 — une delegation interrompue/annulee laisse la cible `Busy` a vie (corrige 2026-06-15, sources ; pas encore en AppImage)
Symptome : un agent qui repondait (ex. DevFrontend a « 123x4=492 », QA pendant LP3) ne repond plus du tout aux delegations suivantes ; `idea_ask_agent` bloque jusqu'au timeout. DevBackend, lui, continue de marcher. Diagnostic ecarte 2 fausses pistes : (a) PAS le socket MCP — les 6 ponts sont ESTAB (`ss -xp | grep idea-mcp`), pont vivant ; (b) PAS « lance a la main vs par IdeA » — DevBackend est aussi auto-lance et marche. Le vrai discriminant : **une delegation vers cet agent a-t-elle ete interrompue/annulee cote demandeur ?** DevFrontend coince par l'interruption d'une tache LP3 ; QA coince par un ping diag rejete ; DevBackend jamais annule => OK. Confirmation cote transcript : la tache figure dans le `log.jsonl` de la conversation (donc enqueue cote serveur a eu lieu) mais PAS dans le transcript `claude` de la cible (`~/.claude/projects/<run-dir>/*.jsonl`) => jamais ecrite dans son PTY.
Cause (lue dans `application/src/orchestrator/service.rs`, chemins `ask` PTY ~l.847-911 ET `ask_structured` ~l.927-1011) : l'agent passe `Idle→Busy` des `input.enqueue` (~l.978). Il ne redevient `Idle` que sur la branche **succes** (`input.mark_idle`, ~l.1001). Les branches **erreur/annulation/timeout** (~l.901-910 et ~l.1004-1009) appellent `mailbox.cancel_head` mais **jamais `mark_idle`**. Pire : quand le demandeur interrompt l'appel, le futur `ask_agent` est **dropped** => AUCUNE branche du `select!` ne s'execute => l'agent reste `Busy` pour toujours. Une cible `Busy` met les delegations suivantes en file derriere un tour fantome, jamais livrees au PTY. L'etat `Busy` vit en memoire dans le process serveur et **ne se deparke pas de l'interieur** : deblocage immediat = **relancer IdeA** (comme le wedge du Bug 5).
Fix (corrige cote sources, `service.rs`) : garde RAII `BusyTurnGuard` (Arc clones de `InputMediator` + mailbox, `agent_id`, `ticket_id`, flag `armed`) cree juste apres l'enqueue dans les DEUX chemins (`ask` PTY et `ask_structured`) ; son `Drop` (si arme) appelle `cancel_head(agent,ticket)` puis `mark_idle(agent)` ; `disarm()` sur la branche succes (le `mark_idle` propre existant reste, pas de double cancel). Les `cancel_head` redondants des branches erreur/`_cancelled`/`_elapsed` ont ete retires au profit du garde (`cancel_head` est positionnel/idempotent). Indispensable que ce soit un garde et pas un `mark_idle` dans les branches : le cas reel est un futur DROPPED, aucune branche du `select!` ne s'execute. Tests verts : `cargo test --workspace` 80 suites 0 echec, dont `dropped_ask_future_frees_busy_target`, `second_delegation_delivered_after_dropped_ask`, `cancelled_ask_marks_target_idle` (tests/orchestrator_service.rs) + 2 tests du garde dans le `mod tests` de service.rs.
VERDICT `sweep_stalled` (infra/input/mod.rs) : **purement advisory** — bascule `Alive→Stalled` et emet `AgentLivenessChanged` mais **n'appelle JAMAIS `mark_idle`** (par conception : « la FIFO et le tour continuent »). Ce n'est donc PAS un filet pour ce bug ; le garde RAII est la seule correction. Non recable (changement de semantique hors perimetre).
**Pas encore actif live : rebuild AppImage requis** (`npm --prefix frontend run build` puis depuis `crates/app-tauri/` `APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1 ../../frontend/node_modules/.bin/tauri build --bundles appimage`, remplacer l'AppImage, relancer IdeA). Le relancement d'IdeA debloque aussi DevFrontend/QA actuellement coinces `Busy` (etat en memoire). Methodo (rappel Bug 4) : NE PAS reparer le systeme inter-agent via `idea_ask_agent` (casse) — utiliser les subagents natifs (outil Agent).
See also [[remaining-work-idea-agent-control-ide]], [[agent-context-memory-and-profile-handoff]].

View File

@ -0,0 +1,273 @@
---
name: remaining-work-idea-agent-control-ide
description: Etat des lieux des acquis et des chantiers restants pour aligner IdeA avec la cible d'IDE de controle d'agents IA.
metadata:
type: project
---
# Remaining Work For IdeA Agent Control IDE
## Resume
Cette note sert de point de reprise pour l'agent `Main`. Elle distingue ce qui est deja implemente, ce qui reste a stabiliser, et ce qui reste a construire pour que IdeA corresponde pleinement a la vision "patron + employes IA" avec memoire projet partagee, contexte partage, messagerie inter-agents transparente, FIFO par agent, et persistance de reprise.
See also:
- `idea-product-directives-main-handoff` for product priorities and UX constraints.
- `agent-context-memory-and-profile-handoff` for the structural model separating context, durable memory, live state, and handoff.
Etat observe le 2026-06-11 sur le depot local:
- L'orchestration inter-agents synchrone est deja reelle cote application.
- La FIFO par agent, la conversation par paire et `idea_reply` sont deja couvertes par des tests verts.
- Le transport MCP natif par projet et le loopback sont testes verts localement.
- La reprise de conversation cote session/layout est largement presente dans le code.
- En revanche, plusieurs briques produit restent inachevees ou non consolidees de bout en bout.
## Deja Livre Ou Tres Avance
### 1. Artefacts projet dans `.ideai/`
- Les agents projet sont persistes dans `.ideai/agents.json` et `.ideai/agents/*.md`.
- Le contexte projet partage est modelise via `.ideai/CONTEXT.md`.
- La memoire projet partagee est modelisee via `.ideai/memory/*.md` + `.ideai/memory/MEMORY.md`.
- Les requetes d'orchestration fichier vivent sous `.ideai/requests/`.
Conclusion: la direction "tout ce qui releve d'IdeA pour un projet doit vivre dans `.ideai/`" est deja la bonne direction architecturale. Il reste surtout a eliminer les ecarts pratiques et a consolider l'usage reel.
### 2. Memoire projet partagee
- `FsMemoryStore` et `MemoryRecall` existent.
- Les agents peuvent recevoir un rappel de memoire projet a l'activation.
- Le frontend et les use cases CRUD memoire existent deja.
Conclusion: la memoire partagee du projet n'est plus un concept a inventer. Le travail restant est plutot sur la qualite du rappel, la curation, et l'usage continu pendant la vie des sessions.
### 3. Contexte partage et contexte agent
- Le contexte partage projet est separe du contexte agent.
- Les contextes agent sont persistes sous `.ideai/agents/*.md`.
- Le launcher injecte deja le contexte compose au demarrage.
Conclusion: la separation `contexte projet` / `contexte agent` est en place.
### 4. Messagerie inter-agents transparente
- `AgentMailbox` + `InMemoryMailbox` existent.
- `ConversationRegistry` + `InMemoryConversationRegistry` existent.
- `OrchestratorService::ask_agent` et `reply` existent.
- Les outils MCP `idea_ask_agent`, `idea_reply`, `idea_launch_agent`, `idea_list_agents`, `idea_stop_agent`, `idea_update_context`, `idea_create_skill` existent.
- Les tests applicatifs passent sur FIFO, reponse synchrone, prevention de cycle, timeout, parallélisme entre cibles differentes.
Verification locale du 2026-06-11:
- `cargo test -p application --test orchestrator_service` : OK
- `cargo test -p infrastructure --test mcp_server` : OK
- `cargo test -p app-tauri --test orchestrator_wiring` : OK
Conclusion: le coeur de la communication inter-agents n'est plus un chantier de conception. Il est deja implementé et teste.
### 5. FIFO transparente quand un agent est occupe
- La file d'entree par agent existe deja.
- La serialisation des tours vers une meme cible existe.
- Les `ask` concurrents vers des agents differents peuvent tourner en parallele.
Conclusion: l'exigence "si l'utilisateur ou un autre agent parle a un agent deja occupe, la requete part en file FIFO de maniere transparente" est deja largement satisfaite au niveau coeur applicatif.
### 6. Reprise de conversation et persistance de session
- Les cellules/layouts persistent `conversation_id` et `agent_was_running`.
- Les use cases de reprise (`ListResumableAgents`, popup de reprise, relance avec `conversation_id`) existent.
- Les sessions structurees Claude/Codex savent porter un `conversation_id`.
Conclusion: la persistance de reprise a deja une base concrete et substantielle.
## Reste A Faire En Priorite
### 1. Consolider la persistance "conversation continue" au niveau produit, pas seulement "resume technique"
Le code sait deja reprendre une conversation via `conversation_id`, mais la cible produit demande plus qu'une simple reprise technique:
- conserver une vraie continuite de conversation lisible pour l'utilisateur au redemarrage,
- permettre au nouvel agent/profil de repartir avec l'etat utile,
- rendre la reprise completement transparente dans l'UX.
Reste donc a verrouiller:
- la persistance canonique des conversations exploitable au niveau produit,
- la strategie de resume/handoff quand on change de profil IA,
- la coherence UX entre reprise de cellule, reprise de conversation, et reprise de travail.
### 2. Implementer une couche persistante de handoff / resume cross-profile
La memoire partagee existante dit explicitement qu'il faut persister:
- un canonical conversation log,
- un cumulative handoff summary,
- l'etat agent,
- les identifiants de conversation utiles par provider.
Ce point n'apparait pas comme livre de bout en bout dans le depot actuel.
Le besoin produit reste ouvert:
- si un agent passe de Claude a Codex, IdeA doit reconstituer l'etat de travail sans dependre d'une session native transferable,
- le handoff doit etre incremental, pas fabrique seulement au moment de la panne ou du swap.
### 3. Introduire un vrai live-state partage au niveau projet
La memoire durable ne doit pas servir de journal temps reel. La note memoire existante le dit deja.
Il manque encore une couche explicite de "live operational state" pour:
- qui travaille sur quoi,
- tickets/intentions en cours,
- etat d'avancement d'un agent,
- derniere delegation utile,
- elements transitoires de coordination inter-agents.
Sans cette couche, une partie de la coordination reste soit volatile, soit repoussee dans des endroits qui ne sont pas faits pour ca.
### 4. Verifier et finir l'integration MCP natif "IdeA-only" de bout en bout dans le flux reel de l'application
Les tests locaux du transport MCP passent, ce qui place cette zone en fin de chantier plutot qu'au debut.
Mais il reste a confirmer en situation reelle utilisateur:
- qu'un agent lance par IdeA voit effectivement ses outils MCP sans action manuelle,
- que les profils supportes utilisent bien cette voie par defaut,
- que le fallback fichier+prose reste coherent quand MCP n'est pas disponible,
- que l'observabilite UI des delegations et des replies est suffisamment claire.
Point important: l'architecture historique qui mentionne encore un verrou M5 ouvert est probablement en retard par rapport au worktree local. Avant de planifier le prochain lot, `Main` doit revalider la documentation d'architecture a la lumiere du code/tests actuels.
### 5. Stabiliser le registre de sessions et clarifier le modele singleton d'agent
Le produit veut "1 agent = 1 employe". Cela impose une verite unique sur:
- la session vivante de l'agent,
- sa conversation courante,
- sa cellule visible ou son execution en arriere-plan,
- son etat occupé/libre/interrompu.
Le code a deja beaucoup avance sur ce point, mais le worktree local montre encore un chantier actif autour de:
- `application/src/terminal/registry.rs`
- `application/src/orchestrator/service.rs`
- `application/src/agent/lifecycle.rs`
- `app-tauri/src/state.rs`
Conclusion: ne pas considerer le sujet comme totalement clos tant que le worktree n'est pas nettoye et que la suite de tests ciblee n'est pas executee sur l'ensemble du flux concerne.
### 6. Rendre la mise a jour de memoire/contexte vraiment automatique pendant la vie d'un agent
La cible utilisateur dit qu'il ne doit jamais demander:
- de charger une memoire,
- de charger un contexte,
- de mettre a jour la memoire,
- de mettre a jour le contexte.
Le lancement injecte deja beaucoup de choses automatiquement, mais il reste a verrouiller le comportement "pendant la vie" d'un agent:
- quand regenerer le contexte effectif,
- quand promouvoir une information stable vers la memoire durable,
- comment distinguer signal utile et bruit,
- comment eviter de compter sur des consignes manuelles a l'utilisateur.
### 7. Unifier la conversation utilisateur <-> agent et agent <-> agent dans l'UX
Le backend sait deja distinguer `User<->Agent` et `Agent<->Agent`.
Le travail restant est surtout produit/frontend:
- affichage clair des delegations et des retours,
- visualisation non confuse des conversations par paire,
- reprise lisible des threads,
- transparence totale pour l'utilisateur final.
Le diff local frontend suggere justement un remaniement en cours de la surface terminal/chat.
### 8. Consolider la restriction et l'affordance des profils supportes
Le modele actuel oriente fortement vers Claude/Codex structures, ce qui est coherent avec la fiabilite attendue.
Reste a clarifier produit:
- quels profils sont officiellement "employes IdeA" de premiere classe,
- quel fallback proposer pour les profils non structures,
- quelle UI montrer quand un profil ne supporte pas la delegation native fiable.
### 9. Persistance conversationnelle globale de l'application
La demande utilisateur mentionne explicitement qu'en relancant IdeA il faut retrouver la conversation.
La reprise par `conversation_id` et layouts existe, mais il reste a confirmer ou completer:
- la persistance lisible de l'historique conversationnel pour l'utilisateur,
- la restauration des vues au redemarrage,
- la coherence entre session technique, resume visuel et histoire de travail.
Autrement dit: "reprendre une session moteur" n'est pas encore automatiquement equivalent a "retrouver sa conversation produit" dans tous les cas.
## Chantiers Secondaires Mais Importants
### 1. Mettre la documentation d'architecture a jour
Le code local et les tests verts semblent avoir depasse certains passages de `ARCHITECTURE.md` et de briefs anciens.
Il faut une passe de synchronisation documentaire pour eviter que `Main` suive un etat obsolete, en particulier sur:
- statut reel du transport MCP,
- statut reel de la FIFO inter-agents,
- statut reel des conversations par paire,
- ce qui reste vraiment ouvert entre handoff, live-state et UX.
### 2. Curater le dossier `.ideai/`
Le principe "tout ce qui est IdeA-projet va dans `.ideai/`" est bon, mais il faudra surveiller:
- la proliferation de fichiers run/request/debug,
- ce qui est durable vs derivable,
- ce qui doit etre committe vs ignore.
### 3. Formaliser les regles de promotion memoire
Le systeme doit savoir quand enregistrer une connaissance stable sans polluer la memoire partagee.
Il manque probablement encore:
- une politique claire de promotion,
- des heuristiques/outils explicites pour les agents,
- des garde-fous contre la memoire bruit.
## Worktree Local A Prendre En Compte
Le depot local est actuellement dirty avec un chantier large non committe autour de:
- orchestration MCP / loopback / serveur Tauri,
- mailbox / conversations / session registry,
- adaptation frontend terminal/chat/layout,
- fichiers `.ideai/` du projet lui-meme.
Consequence pour `Main`:
- ne pas planifier a partir de `ARCHITECTURE.md` seulement,
- d'abord relire le diff local,
- ensuite reexecuter la suite de tests ciblee des zones touchees,
- puis seulement decider si le prochain lot est "finition", "integration UI", ou "harden/persistence".
## Ordre Recommande Pour La Suite
1. Revalider et documenter l'etat reel du chantier MCP/orchestration a partir du code courant, puis remettre `ARCHITECTURE.md` a jour.
2. Fermer proprement le sujet "1 agent = 1 session vivante coherente" en nettoyant le registre/session lifecycle encore en mouvement.
3. Concevoir puis implementer une vraie couche de live-state partage projet.
4. Concevoir puis implementer la couche persistante de handoff/canonical conversation log cross-session et cross-profile.
5. Finir l'integration UX/frontend pour que toute cette orchestration reste invisible et naturelle pour l'utilisateur final.
## Synthese Courte
Le plus gros changement de perception pour `Main` est le suivant:
- IdeA n'est plus au stade "il faut inventer la delegation inter-agents".
- IdeA est plutot au stade "le coeur de delegation existe deja; il faut maintenant le consolider, le documenter, le rendre pleinement persistant, et le rendre transparent dans l'UX".

9
.ideai/project.json Normal file
View File

@ -0,0 +1,9 @@
{
"version": 1,
"id": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023",
"name": "IdeA",
"remote": {
"kind": "local"
},
"createdAt": 1780702317785
}

File diff suppressed because it is too large Load Diff

187
CLAUDE.md Normal file
View File

@ -0,0 +1,187 @@
# IdeA — Contexte & Méthode de travail
> Ce document définit **mon rôle**, **la méthode de développement** et **la vision produit** du projet IdeA.
> Il fait autorité sur la façon dont le projet est piloté. Toute évolution de méthode doit être répercutée ici.
---
## 1. Mon rôle : chef d'orchestre, pas développeur
Je **n'écris pas de code moi-même**. Mon rôle est de **piloter des agents** qui réalisent le travail.
Je suis responsable de :
- Découper le travail en tâches claires et autonomes.
- Attribuer chaque tâche aux bons agents.
- Garantir que le cycle de développement/test est respecté.
- Faire respecter les principes d'architecture (SOLID, Hexagonal).
- Maintenir la cohérence globale du projet et de ce document.
- Arbitrer et valider avant toute action irréversible ou sortante.
---
## 2. Les agents
### 2.1 Agent Architecture (1 pour tout le projet)
- Garant de l'architecture globale : **Hexagonale (Ports & Adapters)** et principes **SOLID**.
- Définit les frontières (domaine / application / infrastructure), les ports, les contrats.
- Valide que chaque nouvelle feature respecte la structure avant son développement.
- Tient à jour la cartographie d'architecture et les conventions.
### 2.2 Agents de Développement
- Écrivent le code des features.
- Respectent strictement l'architecture définie par l'agent Architecture.
- Code **propre, structuré, stable**.
- Reçoivent les rapports d'erreurs des agents de test et corrigent.
### 2.3 Agents de Test
- **Chaque agent de développement est appairé avec un agent de test dédié.**
- Écrivent et exécutent les **tests unitaires** des features implémentées ou modifiées.
- Produisent un **rapport d'erreurs** clair quand un test échoue.
- Re-testent après chaque correction.
---
## 3. Le cycle de développement (boucle obligatoire)
Pour **chaque** feature implémentée ou modifiée :
```
1. Agent Architecture → valide le découpage et les contrats (ports/interfaces)
2. Agent Développement → écrit le code
3. Agent Test → écrit les tests unitaires + les exécute
4a. Tests OK → feature validée, on passe à la suite
4b. Tests KO → rapport d'erreurs → retour à l'agent Développement
→ correction → retour à l'étape 3 (boucle jusqu'au vert)
```
**Règle d'or :** aucune feature n'est considérée terminée tant que ses tests ne passent pas.
Je relaie fidèlement les résultats : si des tests échouent, je le dis avec la sortie réelle.
---
## 4. Principes de code
- **SOLID** appliqué au maximum.
- **Architecture Hexagonale** (Ports & Adapters) : le domaine métier est isolé des détails techniques (UI, terminal, git, SSH, système de fichiers...).
- Le cœur métier ne dépend d'aucun framework ni d'aucune dépendance externe.
- Tests unitaires systématiques ; couverture des features critiques.
- Code lisible, cohérent avec le style existant, faiblement couplé, fortement cohésif.
---
## 5. Vision produit : IdeA
**IdeA est un IDE next-gen 100 % IA.** On n'y code pas : **on gère des IA.**
### Fonctionnalités clés
- **Multi-projets en parallèle** : un **onglet par projet**.
- **Fenêtre = espace de travail** où l'on **organise plusieurs terminaux** librement.
- **Agents par projet** : chaque projet a ses propres agents.
- **Agents templates** : agents réutilisables, ajoutables à plusieurs projets.
- **Création d'agents** : depuis zéro ou à partir d'un template.
- **Synchronisation template → agents** : option « garder l'agent à jour ».
Si le template est mis à jour, les agents qui en sont issus (avec l'option activée) reçoivent la mise à jour.
- **Contextes d'agents stockés en `.md`** (toujours).
- **Création de projet** = définition de son **project root**.
### Intégrations
- **Git** intégré.
- **Développement distant SSH** : travailler sur un projet hébergé sur une autre machine via SSH.
- **Développement WSL** : travailler sur une WSL depuis Windows.
### Plateformes & livraison
- Cible : **macOS, Linux, Windows**.
- Première phase de compilation : **Linux et Windows**.
- Livraison :
- **Windows** : `setup.exe`.
- **Linux** : **AppImage** (doit fonctionner sur les différentes distributions).
---
## 6. Stack technique (validée)
- **Shell applicatif** : **Tauri v2** (binaires légers, performants, multi-OS, AppImage + installeur `setup.exe`/NSIS Windows natifs).
- **Cœur / backend** : **Rust** — stabilité, performance, et expression idiomatique du domaine hexagonal (ports = traits, adapters = implémentations).
- **Frontend / UI** : **TypeScript + React**.
- **Terminaux** : **xterm.js** (rendu) + **portable-pty** (PTY côté Rust).
- **Git** : **libgit2** via `git2` (Rust).
- **SSH** : `russh` / `ssh2` (Rust).
- **WSL** : invocation de `wsl.exe` depuis le backend.
## 7. Layout des terminaux (exigence produit)
Disposition en **grille redimensionnable de type tableur (Excel)** :
- Splits redimensionnables horizontaux **et** verticaux.
- L'utilisateur peut **définir le nombre de colonnes dans une ligne** et **le nombre de lignes dans une colonne**, indépendamment par zone.
- Possibilité de **fusionner des cellules** (ex. fusionner deux colonnes sur une ligne), à la manière des cellules fusionnées d'un tableur.
- Chaque cellule de la grille héberge un terminal.
- → Modèle de layout récursif/imbriqué (pas une grille rigide uniforme) à concevoir par l'agent Architecture.
## 8. Stockage des contextes & liaison aux templates
- **Templates d'agents** : stockés dans l'**IDE** (dossier de données utilisateur global de l'app, hors projet).
- **Agents de projet** : leurs `.md` sont stockés dans un dossier **`.ideai/`** à la racine du project root.
*(Nom choisi pour éviter toute collision avec le `.idea` de JetBrains.)*
- **Manifeste de liaison** dans `.ideai/` (ex. `.ideai/agents.json`) qui mappe pour chaque agent de projet :
- le `.md` de l'agent,
- le template d'origine (le cas échéant),
- `synchronized: true/false`,
- la **version du template** au dernier sync (pour détecter qu'une mise à jour est disponible).
- **Synchro template → agents** : quand un template est mis à jour, les agents liés avec `synchronized: true` reçoivent la MAJ.
## 9. Moteur IA : adaptateur de CLI flexible (Port `AgentRuntime`)
Chaque IA est décrite par un **profil déclaratif** (config éditable, pas du code), implémentation d'un **Port** `AgentRuntime` côté domaine. Deux variables clés par IA :
1. **Commande de lancement** + arguments (ex. `claude`, `codex`, `gemini`, `aider`).
2. **Stratégie d'injection du contexte `.md`** :
- `conventionFile` : écrire/symlink le `.md` vers le fichier attendu par la CLI (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`…).
- `flag` : passer le chemin via un argument.
- `stdin` : piper le contenu.
- `env` : passer via variable d'environnement.
Exemple de profil :
```json
{
"id": "claude-code",
"name": "Claude Code",
"command": "claude",
"args": [],
"contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" },
"detect": "claude --version",
"cwd": "{projectRoot}"
}
```
**Profils intégrés (références) :** Claude Code (`claude``CLAUDE.md`), OpenAI Codex CLI (`codex``AGENTS.md`), Gemini CLI (`gemini``GEMINI.md`), Aider (`aider` → args/message).
**Règles produit :**
- **Premier lancement de l'IDE** : un assistant (first-run) **demande à l'utilisateur** quels profils d'IA configurer. On ne présume rien par défaut.
- Les commandes des profils sont **pré-remplies mais éditables**.
- L'utilisateur peut **ajouter sa propre commande CLI** (profil custom) pour n'importe quelle IA.
**Lancement d'un agent :** à l'**activation de l'agent**, on ouvre une cellule terminal (PTY) avec le bon `cwd`, on injecte le contexte `.md`, et on **auto-lance** la CLI du profil.
## 10. Fenêtres & onglets
- **Par défaut : un onglet par projet** (comme les IDE classiques).
- **Drag & drop d'un onglet** hors de la fenêtre → **crée une nouvelle fenêtre OS** portant ce projet.
- **Multi-fenêtres OS supporté** ; chaque fenêtre possède un ou plusieurs onglets/projets.
## 11. Feuille de route
1. **Cadrage architecture complet d'abord** (jalon en cours) : l'agent Architecture produit la cartographie complète — domaine, ports, adapters, modules, arborescence — **avant tout code**.
2. Puis MVP incrémental selon le cycle dev/test de la section 3.
## 12. Autonomie d'exécution dans le projet
L'utilisateur m'accorde un **accès large et autonome** sur le dossier du projet : je peux lire, créer, modifier des fichiers et exécuter les commandes de développement (cargo, npm, npx, git, etc.) **sans demander confirmation à chaque fois**.
- Concrètement, ces autorisations sont matérialisées dans `.claude/settings.local.json` (mode `acceptEdits` + `Bash`/`Read`/`Edit`/`Write` autorisés), pas dans ce document — CONTEXT.md ne fait que **documenter l'intention**.
- **Garde-fous conservés** : les actions destructrices ou hors-projet restent bloquées (`sudo`, `rm -rf` sur `/`/`~`/`$HOME`, `mkfs`, `dd`, `shutdown`/`reboot`…).
- L'esprit du rôle (§1) ne change pas : je reste **chef d'orchestre**. L'autonomie porte sur l'exécution mécanique, pas sur l'arbitrage des décisions produit/archi, ni sur les **actions sortantes** (push, publication) qui restent soumises à validation explicite.
---
*Dernière mise à jour : 2026-06-05*

1167
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -18,7 +18,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
thiserror = "2" thiserror = "2"
async-trait = "0.1" async-trait = "0.1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time"] }
# Local git via libgit2. Network features (https/ssh → openssl) are off for L8: # Local git via libgit2. Network features (https/ssh → openssl) are off for L8:
# only local operations (status/commit/branch/checkout/log) are in scope; remote # only local operations (status/commit/branch/checkout/log) are in scope; remote
# push/pull and static vendoring for the AppImage are deferred to L9/L11. # push/pull and static vendoring for the AppImage are deferred to L9/L11.

65
agents-dev/L12-skills.md Normal file
View File

@ -0,0 +1,65 @@
# L12 — Skills
**Binôme :** `dev-skills` / `test-skills`
**Zones :** `domain/skill`, `application/skill`, `infrastructure/store`, `frontend/features/skills`
**Dépendances amont :** L0, L1, L5, L6 (convention file généré à l'activation), L7 (store global réutilisé).
## Objectif
Modéliser les **Skills** : workflows réutilisables (équivalent universel des slash-commands, sans dépendance à la syntaxe `/command` d'un modèle). Stockage global IDE + projet, assignation agent↔skills, **injection des skills assignés dans le convention file** généré à l'activation de l'agent. Cf. ARCHITECTURE §14.2.
## Périmètre (DEV)
- **Domaine** : entité `Skill { id, name, content_md: MarkdownDoc, scope: SkillScope(Global|Project) }`. Invariants : `name` non vide, `content_md` non vide. Event `SkillAssigned`.
- **Port `SkillStore`** : CRUD skills globaux (`<app_data>/IdeA/skills/`) + skills projet (`.ideai/skills/<name>.md`), résolution selon `scope` (compose `FileSystem`/store global comme L7).
- **AgentManifest** : étendre pour porter la liste `skills: Vec<SkillRef>` assignés à chaque agent (0..N).
- **Use cases** (`application/skill`) : `CreateSkill`, `UpdateSkill`, `DeleteSkill`, `ListSkills(scope)`, `AssignSkillToAgent`, `UnassignSkillFromAgent`.
- **Injection** : à l'activation (fil L6), composer le convention file en concaténant persona agent + chemin project root + **skills assignés** (lus via `SkillStore`). Pas de mécanisme CLI propriétaire.
- **Front** : onglet/section Skills (liste globale + projet, CRUD, éditeur md), assignation skills↔agent dans `AgentsPanel`.
## Périmètre (TEST)
- `Skill` rejette `name`/`content_md` vides.
- `SkillStore` : CRUD round-trip en tmpdir pour les deux scopes ; un skill `Project` n'apparaît pas dans le scope `Global` et inversement.
- `AssignSkillToAgent` / `UnassignSkillFromAgent` : mutent l'`AgentManifest`, émettent `SkillAssigned`, idempotents (pas de doublon).
- **Injection** : le convention file généré contient bien le `content_md` des skills assignés et **rien** des skills non assignés ; ordre déterministe.
- Front : CRUD skills + assignation via gateway mock (RTL) ; garde-fou « no direct invoke ».
## Definition of Done
- `cargo test` (skill/store/app) + `vitest` verts ; cycle manuel : créer un skill, l'assigner à un agent, l'activer → le skill apparaît dans le convention file de `.ideai/run/<agent-id>/`.
- DoD commune (cf. README) respectée ; zéro régression.
## Avancement
### ✅ Domaine (vert)
- **Entité `Skill`** (`domain/skill.rs`) : `id: SkillId`, `name`, `content_md: MarkdownDoc`, `scope: SkillScope(Global|Project)`. Constructeur validant (`name` + `content_md` non vides), `with_content` re-valide l'invariant.
- **`SkillRef { skill_id, scope }`** : référence d'assignation portée par l'agent ; `From<&Skill>`.
- **`SkillId`** ajouté (`ids.rs`), event **`SkillAssigned { agent_id, skill_id, assigned }`** (`events.rs`), DTO + arm de mapping côté `app-tauri` (`events.rs`).
- **`Agent`** étendu : champ `skills: Vec<SkillRef>` (serde `default`), méthodes `assign_skill` (idempotent), `unassign_skill`, `with_skills` (dédup). **`ManifestEntry`** : champ `skills` (serde `default` + `skip_serializing_if` → rétrocompat des manifests pré-L12) ; `from_agent`/`to_agent` préservent les skills.
- **Tests** : 8 invariants (`entities.rs`) + 3 serde dont rétrocompat d'un manifest legacy sans clé `skills` (`serde_roundtrip.rs`). `cargo test -p domain` vert ; `cargo test --workspace` vert (0 régression) ; clippy clean.
### ✅ Port + adapter store (vert)
- **Port `SkillStore`** (`domain/ports.rs`) : `list/get/save/delete` portant `scope` + `root: &ProjectPath` **par appel** (root ignoré pour `Global`, résolu pour `Project`) — un seul store sert tous les projets ouverts, comme `AgentContextStore`.
- **Adapter `FsSkillStore`** (`infrastructure/store/skill.rs`) : même forme on-disk que `FsTemplateStore` (`index.json` + `md/<id>.md`), deux racines disjointes : `<app_data>/skills/` (Global) et `<root>/.ideai/skills/` (Project). Delete laisse l'orphelin md (pas de remove dans le port FS), index = source de vérité. **7 tests** d'intégration tmpdir (`skill_store.rs`) : round-trip 2 scopes, **isolation de scope**, upsert, delete idempotent, camelCase.
### ✅ Use cases application (vert)
- `application/skill` : `CreateSkill`, `UpdateSkill`, `DeleteSkill`, `ListSkills(scope)` (inputs portant `project_root`), `AssignSkillToAgent` / `UnassignSkillFromAgent` (mutent l'`AgentManifest` via `to_agent`/`from_agent`, dédup, émettent `SkillAssigned`, **idempotents**). **9 tests** (`skill_usecases.rs`).
### ✅ Injection dans le convention file (vert, fil L6)
- `LaunchAgent` reçoit le port `SkillStore` ; `resolve_skills` lit les `.md` des skills assignés (ordre manifest, déterministe ; skill supprimé = `SkillRef` pendant → ignoré sans bloquer le lancement).
- `compose_convention_file` étendu : section `# Skills` (sous-titres `## <name>`) après le persona ; omise si aucun skill. **3 tests** unitaires + e2e (`agent_lifecycle.rs` : injection ordonnée, ref pendant tolérée).
- **Composition root** (`app-tauri/state.rs`) : `FsSkillStore` construit (app-data global), injecté dans `LaunchAgent`.
### ✅ IPC `app-tauri` (vert)
- **DTOs** (`dto.rs`) : `SkillDto` (transparent sur `Skill`, camelCase), `SkillListDto`, request DTOs (`Create/Update/Assign/UnassignSkillRequestDto`), `parse_skill_id`. `scope` désérialise directement vers `SkillScope` (`"global"`/`"project"`).
- **Commandes** (`commands.rs`) : `create_skill`, `update_skill`, `list_skills`, `delete_skill`, `assign_skill_to_agent`, `unassign_skill_from_agent` — shells fins qui résolvent le `Project` (→ `project.root`) puis appellent le use case. Enregistrées dans `lib.rs`.
- **Composition root** (`state.rs`) : 6 use cases skill câblés sur le `skill_store_port` (déjà construit pour le launcher) et le `contexts_port` partagé.
- `cargo build -p app-tauri` + `cargo test --workspace` (304) verts ; clippy clean.
### ✅ Front `features/skills` (vert)
- **Domaine** (`domain/index.ts`) : `SkillScope`, `Skill`, `SkillRef` ; `Agent` étendu avec `skills: SkillRef[]`.
- **Port** (`ports/index.ts`) : `SkillGateway` (list/create/update/delete + assign/unassign) + `CreateSkillInput` ; ajouté à `Gateways`.
- **Adapters** : `TauriSkillGateway` (`adapters/skill.ts`, invoke camelCase) ; `MockSkillGateway` (`adapters/mock`, scopes disjoints + mutation partagée du `MockAgentGateway` via `_setSkills`, assign idempotent).
- **Feature** : `useSkills` (VM 2 scopes), `SkillEditor` (overlay md edit/preview + sélecteur de scope), `SkillsPanel` (listes Project/Global, CRUD). Onglet **Skills** ajouté dans `ProjectsView`.
- **Assignation** dans `AgentsPanel` : chips des skills assignés + sélecteur d'assignation + unassign, sur l'agent sélectionné ; refresh après mutation.
- **Tests** (`skills.test.tsx`, RTL via `DIProvider` + mocks) : CRUD project/global, isolation de scope, édition, suppression, assign/unassign reflétés sur l'agent, idempotence, **garde-fou « no direct invoke »** (aucune action run/launch). `vitest` : **229** verts (0 régression ; test « ten gateways » mis à jour).
### ⏳ Reste à faire
- Cycle manuel : créer un skill, l'assigner à un agent, l'activer → vérifier qu'il apparaît dans le convention file de `.ideai/run/<agent-id>/` (à faire sur l'AppImage).

View File

@ -0,0 +1,35 @@
# L13 — OrchestratorApi
**Binôme :** `dev-orchestrator` / `test-orchestrator`
**Zones :** `infrastructure/orchestrator`, `application/agent`, `app-tauri`
**Dépendances amont :** L0, L1, L6 (`LaunchAgent`/`StopAgent`), L12 (`update_agent_context` peut toucher les skills).
## Objectif
Permettre à un **agent orchestrateur** de demander à IdeA de créer/arrêter/mettre à jour un agent — **exactement** comme l'utilisateur via l'UI. L'orchestrateur ne spawne jamais lui-même un process CLI : il **délègue à IdeA**, unique source de vérité du cycle de vie des agents. Cf. ARCHITECTURE §14.3.
## Périmètre (DEV)
- **Port `OrchestratorApi`** (adapter *entrant*, driven by file-watcher) : surveille `.ideai/requests/<requester-id>/`, désérialise les requêtes JSON, les traduit en appels de use cases.
- **Adapter `FsOrchestratorAdapter`** (`infrastructure/orchestrator`) : file-watching (`notify`), parse, dispatch, **supprime le fichier de requête** et **écrit une réponse** (succès/erreur) à côté.
- **Actions v1** : `spawn_agent` (→ `LaunchAgent`), `stop_agent` (→ `StopAgent`), `update_agent_context` (réécrit le `.md` de l'agent ± skills).
- **Event** : `OrchestratorRequest { requester_id, action }`.
- **Schéma requête** :
```json
{ "action": "spawn_agent", "name": "dev-backend", "profile": "claude-code", "context": "agents/dev-backend.md" }
```
- Le résultat d'un `spawn_agent` est **identique** à un lancement UI : cellule terminal créée, agent inscrit dans l'onglet Agents.
- **Composition root** (`app-tauri`) : démarrer le watcher, brancher sur les use cases existants ; arrêt propre à la fermeture.
## Périmètre (TEST)
- Désérialisation : requêtes valides → action typée ; requête malformée → réponse d'erreur, **pas de crash**, pas de spawn.
- `spawn_agent` invoque `LaunchAgent` avec les bons args (use case mocké) ; idempotence sur double-dépôt du même fichier (traité une fois).
- Après traitement : fichier de requête **supprimé**, fichier de réponse écrit avec le bon statut.
- `stop_agent` / `update_agent_context` : mappent vers les bons use cases ; cible inexistante → erreur propre.
- Watcher : un fichier déposé dans `.ideai/requests/<id>/` est détecté (test d'intégration tmpdir + `notify`).
## Definition of Done
- `cargo test` (orchestrator/app) verts ; cycle manuel : un agent écrit un fichier de requête `spawn_agent` → un nouvel agent apparaît dans la grille et l'onglet Agents, fichier consommé + réponse écrite.
- Garde-fou : l'orchestrateur ne lance **aucun** process directement (vérifié par revue + absence de `ProcessSpawner` dans le chemin orchestrateur).
- DoD commune respectée ; zéro régression ; git reste optionnel (rien dans ce lot n'en dépend, §14.4).
## Avancement
⬜ À démarrer. Cadrage figé dans ARCHITECTURE §14.3.

View File

@ -26,6 +26,8 @@ Un fichier `Lx-*.md` par binôme décrit son périmètre, ses ports/adapters, se
| L9 | [L9-remote.md](L9-remote.md) | 🟡 **socle vert** (LocalHost + ConnectRemote, Liskov) · SSH/WSL gated à venir | | L9 | [L9-remote.md](L9-remote.md) | 🟡 **socle vert** (LocalHost + ConnectRemote, Liskov) · SSH/WSL gated à venir |
| L10 | [L10-windows.md](L10-windows.md) | 🟡 **backend + IPC verts** (move-tab + `WebviewWindow`) · détach UI fait avec la refonte L11 | | L10 | [L10-windows.md](L10-windows.md) | 🟡 **backend + IPC verts** (move-tab + `WebviewWindow`) · détach UI fait avec la refonte L11 |
| L11 | [L11-packaging.md](L11-packaging.md) | 🟡 AppImage Linux **OK** · refonte disposition IDE en cours · Windows/CI à venir | | L11 | [L11-packaging.md](L11-packaging.md) | 🟡 AppImage Linux **OK** · refonte disposition IDE en cours · Windows/CI à venir |
| L12 | [L12-skills.md](L12-skills.md) | ⬜ **Skills** — entité + store + assignation + injection convention file + UI |
| L13 | [L13-orchestrator.md](L13-orchestrator.md) | ⬜ **OrchestratorApi** — file-watcher, spawn depuis agent ou UI, même résultat |
## Cycle dev ↔ test (obligatoire, cf. CONTEXT §3) ## Cycle dev ↔ test (obligatoire, cf. CONTEXT §3)

View File

@ -25,11 +25,25 @@ application = { workspace = true }
infrastructure = { workspace = true } infrastructure = { workspace = true }
tauri = { workspace = true } tauri = { workspace = true }
tauri-plugin-dialog = { workspace = true } tauri-plugin-dialog = { workspace = true }
tokio = { workspace = true } # `io-std` (on top of the workspace features) gives the headless `mcp-server`
# bridge access to `tokio::io::{stdin,stdout}` without widening tokio elsewhere.
tokio = { workspace = true, features = ["io-std", "rt"] }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
# Cross-OS local IPC for the MCP loopback transport (M5a): Unix domain socket
# (Linux/macOS) + Windows named pipe behind one async API, no network port. Pulled
# in only here (the transport is an app-tauri/infra concern); its `tokio` feature
# keeps us off tokio's `net` feature workspace-wide.
interprocess = { version = "2.4", features = ["tokio"] }
[features]
# Passthrough toggles to enable the real embedders in an IDE build. OFF by default
# (founding posture: `none` ⇒ naïve recall, zero dependency).
vector-http = ["infrastructure/vector-http"]
vector-onnx = ["infrastructure/vector-onnx"]
[dev-dependencies] [dev-dependencies]
uuid = { workspace = true } uuid = { workspace = true }
async-trait = { workspace = true }

View File

@ -0,0 +1,191 @@
//! Generic **structured reply ↔ Tauri Channel** bridge infrastructure.
//!
//! Twin of [`crate::pty::PtyBridge`] (ARCHITECTURE §17.7). Where `PtyBridge`
//! routes raw PTY byte chunks to a per-session [`tauri::ipc::Channel`], this
//! bridge routes typed [`ReplyChunk`]s — the serialised
//! [`domain::ports::ReplyEvent`]s of an [`domain::ports::AgentSession`] turn — to
//! the chat cell that owns the session.
//!
//! Design (mirrors the PTY path so the lifecycle guarantees are identical):
//! - The frontend opens (or re-attaches) a chat cell and passes a
//! [`tauri::ipc::Channel`] for that session. The backend registers it here keyed
//! by `SessionId`, bumping a monotonic **generation** so a superseded pump can't
//! tear down the channel of the attach that replaced it (see [`unregister_if`]).
//! - The `agent_send` pump drains the session's [`domain::ports::ReplyStream`],
//! maps each event to a [`ReplyChunk`], and forwards it via [`send_output`].
//! - Unlike a PTY, an [`domain::ports::AgentSession`] keeps **no** scrollback (the
//! port is a per-turn stream, not a persistent byte ring). So the bridge itself
//! retains the rendered chunks per session — the **conversation scrollback** —
//! so a re-attach can repaint the turns already streamed, exactly as the PTY
//! adapter's ring buffer lets `reattach_terminal` repaint xterm. This lives on
//! the transport side (D4 owns transport), keeping the domain port pure.
//!
//! [`unregister_if`]: ChatBridge::unregister_if
//! [`send_output`]: ChatBridge::send_output
use std::collections::HashMap;
use std::sync::Mutex;
use tauri::ipc::Channel;
use domain::ids::SessionId;
use domain::ports::ReplyEvent;
use crate::dto::ReplyChunk;
/// Per-session transport state: the current attach generation, its output
/// channel, and the conversation scrollback recorded so far.
struct ChatEntry {
/// Monotonic generation of the current (re-)attach (anti double-pump).
generation: u64,
/// The output channel of the current attach, if one is registered. `None`
/// when the session has been recorded (scrollback kept) but no view is
/// currently attached — the pump then only appends to the scrollback.
channel: Option<Channel<ReplyChunk>>,
/// Every chunk routed for this session, in order — the conversation
/// scrollback replayed on re-attach. Bounded only by the conversation length
/// (a turn count), like the PTY ring buffer is bounded by its byte capacity.
scrollback: Vec<ReplyChunk>,
}
/// Registry mapping live structured (chat) sessions to their reply [`Channel`]
/// plus a retained conversation scrollback.
///
/// Thread-safe; a cloned `Arc<ChatBridge>` is held in [`crate::state::AppState`],
/// the twin of [`crate::pty::PtyBridge`].
#[derive(Default)]
pub struct ChatBridge {
entries: Mutex<HashMap<SessionId, ChatEntry>>,
}
impl ChatBridge {
/// Creates an empty bridge.
#[must_use]
pub fn new() -> Self {
Self {
entries: Mutex::new(HashMap::new()),
}
}
/// Registers (or replaces) the reply channel for a session and returns the
/// **generation** of this registration. Each call for a session bumps the
/// generation, so the caller's pump can later tear down *only its own*
/// registration via [`unregister_if`](Self::unregister_if). The retained
/// conversation scrollback is **preserved** across re-attaches (only the
/// channel and generation change), mirroring how the PTY ring buffer survives
/// a `reattach_terminal`.
pub fn register(&self, session: SessionId, channel: Channel<ReplyChunk>) -> u64 {
if let Ok(mut map) = self.entries.lock() {
match map.get_mut(&session) {
Some(entry) => {
entry.generation = entry.generation.wrapping_add(1);
entry.channel = Some(channel);
entry.generation
}
None => {
map.insert(
session,
ChatEntry {
generation: 0,
channel: Some(channel),
scrollback: Vec::new(),
},
);
0
}
}
} else {
0
}
}
/// Returns the conversation scrollback retained for a session (the chunks
/// already streamed), or an empty vector if the session is unknown.
///
/// Called by `reattach_agent_chat` to repaint the prior turns into the
/// re-mounting chat view before the new stream is wired — the typed twin of
/// `PtyPort::scrollback`.
#[must_use]
pub fn scrollback(&self, session: &SessionId) -> Vec<ReplyChunk> {
self.entries
.lock()
.ok()
.and_then(|m| m.get(session).map(|e| e.scrollback.clone()))
.unwrap_or_default()
}
/// Removes a session's transport state **and** its retained scrollback
/// unconditionally (chat cell explicitly closed). Twin of
/// [`PtyBridge::unregister`](crate::pty::PtyBridge::unregister).
pub fn unregister(&self, session: &SessionId) {
if let Ok(mut map) = self.entries.lock() {
map.remove(session);
}
}
/// Detaches a session's channel **only if** `gen` is still the current
/// generation, leaving the scrollback intact. A pump calls this when its turn
/// stream ends: if the session was re-attached meanwhile (newer generation),
/// this is a no-op so the dying pump never detaches the live channel that
/// superseded it. Twin of
/// [`PtyBridge::unregister_if`](crate::pty::PtyBridge::unregister_if), but it
/// keeps the conversation scrollback (the conversation outlives a single
/// turn's pump — closing the cell is what discards it, via [`unregister`]).
///
/// [`unregister`]: ChatBridge::unregister
pub fn detach_if(&self, session: &SessionId, gen: u64) {
if let Ok(mut map) = self.entries.lock() {
if let Some(entry) = map.get_mut(session) {
if entry.generation == gen {
entry.channel = None;
}
}
}
}
/// Records a chunk in the session's scrollback and, if a view is attached at
/// the current generation, forwards it to that channel.
///
/// Returns `true` if the chunk was delivered to a live channel, `false` if no
/// channel is currently attached (e.g. the view navigated away — the chunk is
/// still retained in scrollback for the next re-attach) or the send failed.
/// The pump keeps draining either way so the turn still completes and the
/// scrollback stays whole.
pub fn send_output(&self, session: &SessionId, chunk: ReplyChunk) -> bool {
let Ok(mut map) = self.entries.lock() else {
return false;
};
let Some(entry) = map.get_mut(session) else {
return false;
};
entry.scrollback.push(chunk.clone());
match &entry.channel {
Some(channel) => channel.send(chunk).is_ok(),
None => false,
}
}
/// Number of currently-tracked sessions (handy for tests/diagnostics).
#[must_use]
pub fn active_sessions(&self) -> usize {
self.entries.lock().map(|m| m.len()).unwrap_or(0)
}
}
/// Maps a domain [`ReplyEvent`] to its wire [`ReplyChunk`]. Pure translation, no
/// I/O — the single point where the typed turn event becomes a serialisable chunk
/// (kept here so the pump and any test share one mapping).
///
/// Returns `None` for [`ReplyEvent::Heartbeat`]: a heartbeat is a non-terminal
/// liveness proof (readiness/heartbeat lot 1) with **no chat content**, so it maps
/// to no wire chunk — the pump simply skips it. Every content-bearing event still
/// maps to exactly one chunk.
#[must_use]
pub fn chunk_from_event(event: ReplyEvent) -> Option<ReplyChunk> {
match event {
ReplyEvent::TextDelta { text } => Some(ReplyChunk::TextDelta { text }),
ReplyEvent::ToolActivity { label } => Some(ReplyChunk::ToolActivity { label }),
ReplyEvent::Final { content } => Some(ReplyChunk::Final { content }),
ReplyEvent::Heartbeat => None,
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -12,7 +12,8 @@
use serde::Serialize; use serde::Serialize;
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
use domain::events::DomainEvent; use domain::events::{DomainEvent, OrchestrationSource};
use domain::input::AgentLiveness;
use infrastructure::TokioBroadcastEventBus; use infrastructure::TokioBroadcastEventBus;
/// Name of the Tauri event carrying relayed [`DomainEvent`]s. /// Name of the Tauri event carrying relayed [`DomainEvent`]s.
@ -47,6 +48,62 @@ pub enum DomainEventDto {
/// Exit code. /// Exit code.
code: i32, code: i32,
}, },
/// An agent's busy/idle state changed (cadrage C4 §4.2). The frontend dims
/// "Envoyer" while `busy` is `true`.
#[serde(rename_all = "camelCase")]
AgentBusyChanged {
/// Agent id.
agent_id: String,
/// `true` when a turn is in flight, `false` when idle.
busy: bool,
},
/// A delegation is ready to be injected into the agent's **native terminal**
/// (ARCHITECTURE §20). The frontend write-portal writes `text` + `submitSequence`
/// (default `"\r"`) after `submitDelayMs` (default ~60) once the human line is empty,
/// then acks via the `delegation_delivered` command. The backend no longer PTY-writes
/// the turn.
#[serde(rename_all = "camelCase")]
DelegationReady {
/// Target agent id (UUID string).
agent_id: String,
/// Mailbox ticket id (UUID string) to ack back via `delegation_delivered`.
ticket: String,
/// Task text to inject (written without a trailing newline by the portal).
text: String,
/// Profile's submit sequence; `null` ⇒ the front applies its default (`"\r"`).
#[serde(skip_serializing_if = "Option::is_none")]
submit_sequence: Option<String>,
/// Profile's submit delay in ms; `null` ⇒ the front default (~60 ms).
#[serde(skip_serializing_if = "Option::is_none")]
submit_delay_ms: Option<u32>,
},
/// A target agent produced a synchronous reply to an inter-agent `ask` (§17.4).
#[serde(rename_all = "camelCase")]
AgentReplied {
/// Target agent id.
agent_id: String,
/// Reply length in bytes (metric, not the payload).
reply_len: usize,
},
/// An agent's liveness (alive/stalled) changed (lot 2, readiness/heartbeat). The
/// frontend can badge a frozen agent; `"stalled"` while no proof of liveness arrived
/// for longer than the profile's `stallAfterMs`, back to `"alive"` on a late
/// battement or when the turn ends.
#[serde(rename_all = "camelCase")]
AgentLivenessChanged {
/// Agent id (UUID string).
agent_id: String,
/// New liveness, as a lowercase string (`"alive"` / `"stalled"`).
liveness: AgentLivenessDto,
},
/// An agent's runtime profile was changed (hot-swap of the AI engine).
#[serde(rename_all = "camelCase")]
AgentProfileChanged {
/// Agent id.
agent_id: String,
/// The new runtime profile id.
profile_id: String,
},
/// A template was updated. /// A template was updated.
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
TemplateUpdated { TemplateUpdated {
@ -73,6 +130,16 @@ pub enum DomainEventDto {
/// Version synced to. /// Version synced to.
to: u64, to: u64,
}, },
/// A skill was assigned to (or unassigned from) an agent.
#[serde(rename_all = "camelCase")]
SkillAssigned {
/// Agent id.
agent_id: String,
/// Skill id.
skill_id: String,
/// `true` if assigned, `false` if unassigned.
assigned: bool,
},
/// A tab's layout changed. /// A tab's layout changed.
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
LayoutChanged { LayoutChanged {
@ -91,6 +158,53 @@ pub enum DomainEventDto {
/// Project id. /// Project id.
project_id: String, project_id: String,
}, },
/// An orchestrator request was processed on behalf of a requester agent.
#[serde(rename_all = "camelCase")]
OrchestratorRequestProcessed {
/// Id of the requesting (orchestrator) agent.
requester_id: String,
/// The action that was processed.
action: String,
/// Whether IdeA handled it successfully.
ok: bool,
/// Which entry door the request arrived through (`"file"` watcher vs
/// `"mcp"` server). Serialised as a lowercase string so the frontend can
/// badge the source.
source: OrchestrationSourceDto,
},
/// A memory note was created or updated.
#[serde(rename_all = "camelCase")]
MemorySaved {
/// The saved note's slug.
slug: String,
},
/// A memory note was deleted.
#[serde(rename_all = "camelCase")]
MemoryDeleted {
/// The deleted note's slug.
slug: String,
},
/// The aggregated `MEMORY.md` index was rebuilt.
#[serde(rename_all = "camelCase")]
MemoryIndexRebuilt {
/// Project id.
project_id: String,
},
/// A project's memory crossed the recall budget while no embedder is configured
/// (LOT C3 — §14.5.5): a one-time, dismissible "configure an embedder?" hint.
#[serde(rename_all = "camelCase")]
EmbedderSuggested {
/// Project id.
project_id: String,
/// Whether a local Ollama-style embedding server was detected.
ollama_detected: bool,
/// Ids of recommended ONNX models already present in the local cache.
onnx_cached: Vec<String>,
/// Whether the HTTP capability is compiled in.
vector_http_enabled: bool,
/// Whether the in-process ONNX capability is compiled in.
vector_onnx_enabled: bool,
},
/// Raw PTY output (normally routed to a per-session channel, not here). /// Raw PTY output (normally routed to a per-session channel, not here).
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
PtyOutput { PtyOutput {
@ -101,6 +215,47 @@ pub enum DomainEventDto {
}, },
} }
/// Wire mirror of [`OrchestrationSource`]: which entry door a processed
/// orchestration request arrived through. Serialised as a lowercase string
/// (`"file"` / `"mcp"`) the frontend badges on the event.
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum OrchestrationSourceDto {
/// A `.ideai/requests` JSON file (filesystem watcher).
File,
/// A `tools/call` on the MCP server.
Mcp,
}
/// Wire mirror of [`AgentLiveness`]: the alive/stalled liveness of an agent (lot 2),
/// serialised as a lowercase string (`"alive"` / `"stalled"`) the frontend badges.
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum AgentLivenessDto {
/// The agent shows proof of liveness (or is idle).
Alive,
/// No proof of liveness past the profile's `stallAfterMs` threshold.
Stalled,
}
impl From<AgentLiveness> for AgentLivenessDto {
fn from(liveness: AgentLiveness) -> Self {
match liveness {
AgentLiveness::Alive => Self::Alive,
AgentLiveness::Stalled => Self::Stalled,
}
}
}
impl From<OrchestrationSource> for OrchestrationSourceDto {
fn from(source: OrchestrationSource) -> Self {
match source {
OrchestrationSource::File => Self::File,
OrchestrationSource::Mcp => Self::Mcp,
}
}
}
impl From<&DomainEvent> for DomainEventDto { impl From<&DomainEvent> for DomainEventDto {
fn from(e: &DomainEvent) -> Self { fn from(e: &DomainEvent) -> Self {
match e { match e {
@ -118,6 +273,43 @@ impl From<&DomainEvent> for DomainEventDto {
agent_id: agent_id.to_string(), agent_id: agent_id.to_string(),
code: *code, code: *code,
}, },
DomainEvent::AgentBusyChanged { agent_id, busy } => Self::AgentBusyChanged {
agent_id: agent_id.to_string(),
busy: *busy,
},
DomainEvent::DelegationReady {
agent_id,
ticket,
text,
submit_sequence,
submit_delay_ms,
} => Self::DelegationReady {
agent_id: agent_id.to_string(),
ticket: ticket.to_string(),
text: text.clone(),
submit_sequence: submit_sequence.clone(),
submit_delay_ms: *submit_delay_ms,
},
DomainEvent::AgentReplied {
agent_id,
reply_len,
} => Self::AgentReplied {
agent_id: agent_id.to_string(),
reply_len: *reply_len,
},
DomainEvent::AgentLivenessChanged { agent_id, liveness } => {
Self::AgentLivenessChanged {
agent_id: agent_id.to_string(),
liveness: (*liveness).into(),
}
}
DomainEvent::AgentProfileChanged {
agent_id,
profile_id,
} => Self::AgentProfileChanged {
agent_id: agent_id.to_string(),
profile_id: profile_id.to_string(),
},
DomainEvent::TemplateUpdated { DomainEvent::TemplateUpdated {
template_id, template_id,
version, version,
@ -125,11 +317,7 @@ impl From<&DomainEvent> for DomainEventDto {
template_id: template_id.to_string(), template_id: template_id.to_string(),
version: version.get(), version: version.get(),
}, },
DomainEvent::AgentDriftDetected { DomainEvent::AgentDriftDetected { agent_id, from, to } => Self::AgentDriftDetected {
agent_id,
from,
to,
} => Self::AgentDriftDetected {
agent_id: agent_id.to_string(), agent_id: agent_id.to_string(),
from: from.get(), from: from.get(),
to: to.get(), to: to.get(),
@ -138,6 +326,15 @@ impl From<&DomainEvent> for DomainEventDto {
agent_id: agent_id.to_string(), agent_id: agent_id.to_string(),
to: to.get(), to: to.get(),
}, },
DomainEvent::SkillAssigned {
agent_id,
skill_id,
assigned,
} => Self::SkillAssigned {
agent_id: agent_id.to_string(),
skill_id: skill_id.to_string(),
assigned: *assigned,
},
DomainEvent::LayoutChanged { project_id } => Self::LayoutChanged { DomainEvent::LayoutChanged { project_id } => Self::LayoutChanged {
project_id: project_id.to_string(), project_id: project_id.to_string(),
}, },
@ -147,6 +344,39 @@ impl From<&DomainEvent> for DomainEventDto {
DomainEvent::GitStateChanged { project_id } => Self::GitStateChanged { DomainEvent::GitStateChanged { project_id } => Self::GitStateChanged {
project_id: project_id.to_string(), project_id: project_id.to_string(),
}, },
DomainEvent::OrchestratorRequestProcessed {
requester_id,
action,
ok,
source,
} => Self::OrchestratorRequestProcessed {
requester_id: requester_id.clone(),
action: action.clone(),
ok: *ok,
source: (*source).into(),
},
DomainEvent::MemorySaved { slug } => Self::MemorySaved {
slug: slug.as_str().to_string(),
},
DomainEvent::MemoryDeleted { slug } => Self::MemoryDeleted {
slug: slug.as_str().to_string(),
},
DomainEvent::MemoryIndexRebuilt { project_id } => Self::MemoryIndexRebuilt {
project_id: project_id.to_string(),
},
DomainEvent::EmbedderSuggested {
project_id,
ollama_detected,
onnx_cached,
vector_http_enabled,
vector_onnx_enabled,
} => Self::EmbedderSuggested {
project_id: project_id.to_string(),
ollama_detected: *ollama_detected,
onnx_cached: onnx_cached.clone(),
vector_http_enabled: *vector_http_enabled,
vector_onnx_enabled: *vector_onnx_enabled,
},
DomainEvent::PtyOutput { session_id, bytes } => Self::PtyOutput { DomainEvent::PtyOutput { session_id, bytes } => Self::PtyOutput {
session_id: session_id.to_string(), session_id: session_id.to_string(),
bytes: bytes.clone(), bytes: bytes.clone(),
@ -184,3 +414,40 @@ pub fn spawn_relay(app: AppHandle, bus: &TokioBroadcastEventBus) {
} }
}); });
} }
#[cfg(test)]
mod tests {
use super::*;
use domain::ids::AgentId;
fn agent(n: u128) -> AgentId {
AgentId::from_uuid(uuid::Uuid::from_u128(n))
}
/// Lot 2 : un `AgentLivenessChanged{Stalled}` du domaine se relaie en DTO
/// `Stalled` portant le même agent, et se sérialise en `"stalled"` (le mot que
/// le front badge). Garantit le câblage présentation de la détection de stall.
#[test]
fn liveness_changed_stalled_relays_to_dto_and_wire() {
let dto = DomainEventDto::from(&DomainEvent::AgentLivenessChanged {
agent_id: agent(7),
liveness: AgentLiveness::Stalled,
});
let json = serde_json::to_value(&dto).expect("serialisable");
assert_eq!(json["type"], "agentLivenessChanged");
assert_eq!(json["agentId"], agent(7).to_string());
assert_eq!(json["liveness"], "stalled");
}
/// La reprise `Stalled→Alive` se relaie en DTO `Alive` ⇒ wire `"alive"`.
#[test]
fn liveness_changed_alive_relays_to_dto_and_wire() {
let dto = DomainEventDto::from(&DomainEvent::AgentLivenessChanged {
agent_id: agent(3),
liveness: AgentLiveness::Alive,
});
let json = serde_json::to_value(&dto).expect("serialisable");
assert_eq!(json["type"], "agentLivenessChanged");
assert_eq!(json["liveness"], "alive");
}
}

View File

@ -5,23 +5,56 @@
//! ([`state::AppState`], the composition root), //! ([`state::AppState`], the composition root),
//! - exposes `#[tauri::command]` handlers ([`commands`]) mapping DTOs ↔ use cases, //! - exposes `#[tauri::command]` handlers ([`commands`]) mapping DTOs ↔ use cases,
//! - relays domain events to the frontend ([`events::TauriEventRelay`]), //! - relays domain events to the frontend ([`events::TauriEventRelay`]),
//! - hosts the generic PTY↔Channel bridge ([`pty::PtyBridge`]) for L3. //! - hosts the generic PTY↔Channel bridge ([`pty::PtyBridge`]) for L3 and its
//! structured-chat twin ([`chat::ChatBridge`]) for §17.
//! //!
//! The wiring lives in the library (testable) and `main.rs` is a thin shim. //! The wiring lives in the library (testable) and `main.rs` is a thin shim.
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![warn(missing_docs)] #![warn(missing_docs)]
pub mod chat;
pub mod commands; pub mod commands;
pub mod dto; pub mod dto;
pub mod events; pub mod events;
pub mod mcp_bridge;
pub mod mcp_endpoint;
pub mod pty; pub mod pty;
pub mod state; pub mod state;
use std::process::ExitCode;
use tauri::Manager; use tauri::Manager;
use state::AppState; use state::AppState;
/// The `argv[1]` subcommand token that switches the binary into the headless
/// `mcp-server` **bridge** mode (cadrage v5 §1.3) instead of launching Tauri.
pub const MCP_SERVER_SUBCOMMAND: &str = "mcp-server";
/// Process entry point: routes `argv` **before** anything Tauri/WebKit is touched.
///
/// When invoked as `<exe> mcp-server …` (an MCP CLI spawned us from the injected
/// `.mcp.json` declaration), we run the **stdio↔loopback bridge** headless and
/// **never** initialise the webview — see [`mcp_bridge::run_mcp_bridge`]. Any other
/// invocation is the normal IDE launch: [`run`] (which blocks until the window
/// closes and then exits the process itself).
///
/// Returns the [`ExitCode`] for the bridge path; the normal path does not return.
#[must_use]
pub fn dispatch() -> ExitCode {
let mut args = std::env::args_os().skip(1);
if args.next().is_some_and(|a| a == *MCP_SERVER_SUBCOMMAND) {
// Headless bridge: bypass Tauri entirely. Forward the remaining args
// (`--endpoint`, `--project`, `--requester`) to the bridge parser.
let rest: Vec<String> = args.map(|a| a.to_string_lossy().into_owned()).collect();
return mcp_bridge::run_mcp_bridge(rest);
}
run();
ExitCode::SUCCESS
}
/// Builds and runs the Tauri application. /// Builds and runs the Tauri application.
/// ///
/// Sets up the composition root (resolving the app-data directory via the Tauri /// Sets up the composition root (resolving the app-data directory via the Tauri
@ -48,6 +81,42 @@ pub fn run() {
events::spawn_relay(app.handle().clone(), &app_state.event_bus); events::spawn_relay(app.handle().clone(), &app_state.event_bus);
app.manage(app_state); app.manage(app_state);
// Kill all live PTYs cleanly when the main window is closing. This is
// independent of the per-view (navigation/layout) lifecycle — those
// must NEVER kill a PTY — and only fires on a genuine app shutdown.
// A brutal crash is best-effort and out of scope.
if let Some(window) = app.get_webview_window("main") {
let handle = app.handle().clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { .. } = event {
if let Some(state) = handle.try_state::<AppState>() {
let pty = std::sync::Arc::clone(&state.pty_port);
// ORDER IS CRITICAL: freeze `agent_was_running` on every
// agent leaf of every open project FIRST, reading the live
// PTY registry as it stands now; only THEN kill the PTYs.
// If we killed first, the registry would be empty and every
// agent would be persisted as "closed".
let snapshot = std::sync::Arc::clone(&state.snapshot_running_agents);
let open_projects = state.open_project_ids();
let handles = state.terminal_sessions.handles();
tauri::async_runtime::block_on(async move {
for project_id in open_projects {
let _ = snapshot
.execute(application::SnapshotRunningAgentsInput {
project_id,
})
.await;
}
for h in handles {
let _ = pty.kill(&h).await;
}
});
}
}
});
}
Ok(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
@ -56,10 +125,17 @@ pub fn run() {
commands::open_project, commands::open_project,
commands::close_project, commands::close_project,
commands::list_projects, commands::list_projects,
commands::read_project_context,
commands::update_project_context,
commands::get_project_permissions,
commands::update_project_permissions,
commands::update_agent_permissions,
commands::resolve_agent_permissions,
commands::open_terminal, commands::open_terminal,
commands::write_terminal, commands::write_terminal,
commands::resize_terminal, commands::resize_terminal,
commands::close_terminal, commands::close_terminal,
commands::reattach_terminal,
commands::load_layout, commands::load_layout,
commands::mutate_layout, commands::mutate_layout,
commands::list_layouts, commands::list_layouts,
@ -74,12 +150,28 @@ pub fn run() {
commands::save_profile, commands::save_profile,
commands::delete_profile, commands::delete_profile,
commands::configure_profiles, commands::configure_profiles,
commands::list_embedder_profiles,
commands::save_embedder_profile,
commands::delete_embedder_profile,
commands::describe_embedder_engines,
commands::dismiss_embedder_suggestion,
commands::create_agent, commands::create_agent,
commands::list_agents, commands::list_agents,
commands::list_live_agents,
commands::attach_live_agent,
commands::read_agent_context, commands::read_agent_context,
commands::update_agent_context, commands::update_agent_context,
commands::delete_agent, commands::delete_agent,
commands::launch_agent, commands::launch_agent,
commands::change_agent_profile,
commands::agent_send,
commands::interrupt_agent,
commands::delegation_delivered,
commands::set_front_attached,
commands::reattach_agent_chat,
commands::close_agent_session,
commands::list_resumable_agents,
commands::inspect_conversation,
commands::create_template, commands::create_template,
commands::update_template, commands::update_template,
commands::list_templates, commands::list_templates,
@ -96,6 +188,20 @@ pub fn run() {
commands::git_log, commands::git_log,
commands::git_init, commands::git_init,
commands::git_graph, commands::git_graph,
commands::create_skill,
commands::update_skill,
commands::list_skills,
commands::delete_skill,
commands::assign_skill_to_agent,
commands::unassign_skill_from_agent,
commands::create_memory,
commands::update_memory,
commands::list_memories,
commands::get_memory,
commands::delete_memory,
commands::read_memory_index,
commands::recall_memory,
commands::resolve_memory_links,
commands::move_tab_to_new_window, commands::move_tab_to_new_window,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())

View File

@ -1,10 +1,13 @@
// Prevents an extra console window on Windows in release builds. // Prevents an extra console window on Windows in release builds.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() { use std::process::ExitCode;
fn main() -> ExitCode {
// WebKitGTK's DMABUF renderer causes a blank/white window on many Linux // WebKitGTK's DMABUF renderer causes a blank/white window on many Linux
// setups (recent Mesa/Nvidia drivers, common on Arch). Disable it before // setups (recent Mesa/Nvidia drivers, common on Arch). Disable it before
// the webview initializes, unless the user has explicitly set the variable. // the webview initializes, unless the user has explicitly set the variable.
// Harmless for the headless `mcp-server` bridge path, which ignores it.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() { if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
@ -12,5 +15,6 @@ fn main() {
} }
} }
app_tauri_lib::run(); // Route argv before any Tauri/WebKit init: `mcp-server` ⇒ headless bridge.
app_tauri_lib::dispatch()
} }

View File

@ -0,0 +1,663 @@
//! The `idea mcp-server` **bridge** — a transparent stdio↔loopback tube (M5b).
//!
//! ## Where this sits in the bind (cadrage v5 §1.3, §2)
//!
//! An MCP CLI (Claude Code, Codex) reads a `{command,args}` declaration and
//! *spawns* `<exe IdeA> mcp-server --endpoint <…> --project <…> --requester <…>`.
//! That spawned process is **this bridge**: it must **bypass Tauri/WebKit** and run
//! headless. It speaks JSON-RPC (JSON Lines) to the CLI on **stdin/stdout**, and
//! relays every line, byte-for-byte, over the project's **loopback** (the Unix
//! socket / Windows named pipe bound by [`crate::state::AppState::ensure_mcp_server`]
//! at [`crate::mcp_endpoint::mcp_endpoint`]). The real `McpServer` (which holds the
//! `OrchestratorService`) lives in the Tauri process and answers in M5c.
//!
//! **Zero business logic.** The bridge knows only *stdio + loopback + JSON lines*:
//! no `OrchestratorService`, no use case (hexagonal boundary, cadrage §5). It never
//! parses the JSON-RPC payloads it carries.
//!
//! ## Handshake format (consumed by M5c)
//!
//! Right after connecting to the loopback, **before** any JSON-RPC traffic, the
//! bridge writes a **single newline-terminated JSON line** carrying the caller's
//! identity:
//!
//! ```text
//! {"project":"<project-id>","requester":"<requester-id>"}\n
//! ```
//!
//! - It is a **distinct line** from the JSON-RPC stream that follows: the server
//! (M5c) reads exactly one line off a fresh connection, parses it as this
//! handshake, then hands the rest of the stream to `McpServer::serve`.
//! - `requester` is the **real agent id** (cadrage §1.4) — it lets the server tag
//! `OrchestratorRequestProcessed.requester_id` with the actual agent instead of
//! the frozen `"mcp"` placeholder. When `--requester` is omitted the field is the
//! empty string.
//! - Both values are JSON strings, so any future id shape stays escape-safe.
//!
//! ## Failure posture (never hang)
//!
//! - **Endpoint absent / unreachable** ⇒ connect with a **bounded timeout**; on
//! timeout or connect error, return a **non-zero** exit code immediately. Never
//! block forever waiting for a listener that will never appear.
//! - **stdin EOF** (the CLI closed) ⇒ **clean exit, code 0**; the loopback
//! connection is dropped (closed) on the way out.
//! - **Missing required args** ⇒ a clear error on stderr and a non-zero code.
use std::process::ExitCode;
use std::time::Duration;
use interprocess::local_socket::tokio::Stream as LocalSocketStream;
use interprocess::local_socket::traits::tokio::Stream as _;
use interprocess::local_socket::{GenericFilePath, ToFsName as _};
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
/// How long the bridge waits to connect to the project loopback before giving up.
/// Bounded so an absent/unreachable endpoint fails fast instead of hanging.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
/// After the CLI closes stdin, how long to keep draining the loopback so an
/// in-flight response still reaches the CLI. Bounded so the bridge never blocks
/// waiting on the server: when stdin closes the CLI is gone, and the OS closes the
/// loopback fd at process exit anyway.
const DRAIN_GRACE: Duration = Duration::from_secs(1);
/// Parsed `mcp-server` invocation arguments.
///
/// `--endpoint` is **required** (without it the bridge has nowhere to relay).
/// `--project` and `--requester` are optional identity hints forwarded verbatim in
/// the handshake; a missing one becomes the empty string.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BridgeArgs {
/// The loopback address to connect to (the `--endpoint` value, i.e.
/// [`crate::mcp_endpoint::McpEndpoint::as_cli_arg`]).
pub endpoint: String,
/// The project id (`--project`), forwarded in the handshake. Empty if absent.
pub project: String,
/// The requesting agent id (`--requester`), forwarded in the handshake. Empty
/// if absent.
pub requester: String,
}
impl BridgeArgs {
/// Parses `mcp-server`'s own arguments (i.e. `argv` **after** the `mcp-server`
/// subcommand token). Recognises `--endpoint`, `--project`, `--requester`, each
/// taking the following token as its value.
///
/// # Errors
/// Returns a human-readable message when `--endpoint` is missing, when a flag is
/// given without a value, or when an unknown flag is encountered.
pub fn parse<I, S>(args: I) -> Result<Self, String>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let mut endpoint: Option<String> = None;
let mut project = String::new();
let mut requester = String::new();
let mut it = args.into_iter().map(Into::into);
while let Some(flag) = it.next() {
match flag.as_str() {
"--endpoint" => {
endpoint = Some(
it.next()
.ok_or_else(|| "--endpoint requires a value".to_string())?,
);
}
"--project" => {
project = it
.next()
.ok_or_else(|| "--project requires a value".to_string())?;
}
"--requester" => {
requester = it
.next()
.ok_or_else(|| "--requester requires a value".to_string())?;
}
other => {
return Err(format!("unknown argument: {other}"));
}
}
}
let endpoint = endpoint.ok_or_else(|| "--endpoint is required".to_string())?;
Ok(Self {
endpoint,
project,
requester,
})
}
/// The first handshake line the bridge writes on the loopback (newline
/// included). See the module docs for the format consumed by M5c.
fn handshake_line(&self) -> Vec<u8> {
let line = serde_json::json!({
"project": self.project,
"requester": self.requester,
})
.to_string();
let mut bytes = line.into_bytes();
bytes.push(b'\n');
bytes
}
}
/// Synchronous entry point called from `main`/`lib::run` when `argv[1] ==
/// "mcp-server"`. Parses the args, stands up a Tokio runtime, and runs the bridge.
///
/// Returns the process [`ExitCode`]: `0` on a clean stdin-EOF shutdown, non-zero on
/// any argument/connection/relay failure. Never blocks indefinitely (connect is
/// time-bounded).
#[must_use]
pub fn run_mcp_bridge<I, S>(args: I) -> ExitCode
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let args = match BridgeArgs::parse(args) {
Ok(a) => a,
Err(e) => {
eprintln!("idea mcp-server: {e}");
return ExitCode::FAILURE;
}
};
// A current-thread runtime is enough: the bridge is two coupled I/O loops, no
// CPU work. Keeps the headless process lean (no Tauri, no webview).
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_time()
.build()
{
Ok(rt) => rt,
Err(e) => {
eprintln!("idea mcp-server: failed to start runtime: {e}");
return ExitCode::FAILURE;
}
};
runtime.block_on(async move {
match bridge_over_loopback(&args).await {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("idea mcp-server: {e}");
ExitCode::FAILURE
}
}
})
}
/// Connects to the project loopback at `args.endpoint` (time-bounded) and relays
/// between the **real stdio** of this process and that connection.
async fn bridge_over_loopback(args: &BridgeArgs) -> Result<(), String> {
let conn = connect_loopback(&args.endpoint).await?;
// Split the duplex loopback into independent owned halves so the two relay
// directions can run without sharing a lock.
let (lp_read, lp_write) = tokio::io::split(conn);
relay(
args,
tokio::io::stdin(),
tokio::io::stdout(),
lp_read,
lp_write,
)
.await
}
/// Connects to the loopback endpoint with a bounded timeout. An absent or
/// unreachable endpoint surfaces as an `Err` (⇒ non-zero exit), **never a hang**.
async fn connect_loopback(endpoint: &str) -> Result<LocalSocketStream, String> {
let name = endpoint
.to_fs_name::<GenericFilePath>()
.map_err(|e| format!("invalid endpoint {endpoint:?}: {e}"))?;
match tokio::time::timeout(CONNECT_TIMEOUT, LocalSocketStream::connect(name)).await {
Ok(Ok(stream)) => Ok(stream),
Ok(Err(e)) => Err(format!("cannot connect to endpoint {endpoint:?}: {e}")),
Err(_) => Err(format!(
"timed out connecting to endpoint {endpoint:?} after {}s",
CONNECT_TIMEOUT.as_secs()
)),
}
}
/// The transparent relay, generic over its four streams so tests drive it with
/// in-memory pipes (no real stdio, no real socket).
///
/// Sequence:
/// 1. Write the **handshake line** (`project`+`requester`) to the loopback.
/// 2. Run **two independent directional pumps concurrently** (full duplex):
/// - `CLI → loopback`: every line from `cli_in` is forwarded to the loopback;
/// - `loopback → CLI`: every line from the loopback is forwarded to `cli_out`.
/// 3. **CLI stdin EOF** ⇒ half-close the loopback write side (so the server sees
/// EOF), **drain** any responses still in flight, then return `Ok(())`.
///
/// ## Why full duplex (and not lockstep)
///
/// JSON-RPC over MCP is **not** one-response-per-request. **Notifications** (e.g.
/// `notifications/initialized` sent right after `initialize`) carry no `id` and get
/// **no response**, and the server may push messages unsolicited. A lockstep pump
/// that reads one CLI line then *blocks* for exactly one loopback line **deadlocks**
/// on the first notification: it waits forever for a reply that never comes, and
/// never reads the client's next request (e.g. `tools/list`) — so the CLI never
/// receives its tool list. Two decoupled pumps let notifications and asynchronous
/// server messages flow freely in both directions.
async fn relay<CIn, COut, LIn, LOut>(
args: &BridgeArgs,
cli_in: CIn,
cli_out: COut,
lp_read: LIn,
mut lp_write: LOut,
) -> Result<(), String>
where
CIn: AsyncRead + Unpin,
COut: AsyncWrite + Unpin,
LIn: AsyncRead + Unpin,
LOut: AsyncWrite + Unpin,
{
// 1. Handshake (identity) before any JSON-RPC byte.
lp_write
.write_all(&args.handshake_line())
.await
.map_err(|e| format!("handshake write failed: {e}"))?;
lp_write
.flush()
.await
.map_err(|e| format!("handshake flush failed: {e}"))?;
// 2. Two decoupled pumps. Each owns its streams so neither blocks the other.
let cli_to_lp = pump_lines(BufReader::new(cli_in), lp_write, "stdin", "loopback");
let lp_to_cli = pump_lines(BufReader::new(lp_read), cli_out, "loopback", "stdout");
tokio::pin!(cli_to_lp);
tokio::pin!(lp_to_cli);
tokio::select! {
// CLI closed stdin (or errored): half-close the loopback writer to nudge
// the server, then drain briefly so an in-flight response still reaches the
// CLI — but never block on the server (bounded by DRAIN_GRACE).
r = &mut cli_to_lp => {
let mut lp_write = r?;
let _ = lp_write.shutdown().await;
let _ = tokio::time::timeout(DRAIN_GRACE, &mut lp_to_cli).await;
Ok(())
}
// Loopback closed first (server hangup): nothing left to relay. The CLI's
// stdin EOF is no longer needed to exit.
r = &mut lp_to_cli => r.map(|_| ()),
}
}
/// Forwards every newline-delimited line from `reader` to `writer` until `reader`
/// hits EOF, flushing after each line so a peer blocked on a read sees it promptly.
///
/// On clean EOF it returns the (now-drained) `writer` so the caller can half-close
/// it. `src`/`dst` name the two ends for error messages only.
async fn pump_lines<R, W>(
mut reader: BufReader<R>,
mut writer: W,
src: &str,
dst: &str,
) -> Result<W, String>
where
R: AsyncRead + Unpin,
W: AsyncWrite + Unpin,
{
let mut line = String::new();
loop {
line.clear();
let n = reader
.read_line(&mut line)
.await
.map_err(|e| format!("{src} read failed: {e}"))?;
if n == 0 {
// Source closed: drain and hand the writer back for half-close.
writer
.flush()
.await
.map_err(|e| format!("{dst} flush failed: {e}"))?;
return Ok(writer);
}
writer
.write_all(line.as_bytes())
.await
.map_err(|e| format!("{dst} write failed: {e}"))?;
writer
.flush()
.await
.map_err(|e| format!("{dst} flush failed: {e}"))?;
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use interprocess::local_socket::tokio::Listener as LocalSocketListener;
use interprocess::local_socket::traits::tokio::Listener as _;
use interprocess::local_socket::{GenericFilePath, ListenerOptions};
use tokio::io::AsyncReadExt as _;
// ---- argument parsing -------------------------------------------------
#[test]
fn parses_all_three_flags() {
let a = BridgeArgs::parse([
"--endpoint",
"/tmp/x.sock",
"--project",
"p1",
"--requester",
"agent-7",
])
.unwrap();
assert_eq!(a.endpoint, "/tmp/x.sock");
assert_eq!(a.project, "p1");
assert_eq!(a.requester, "agent-7");
}
#[test]
fn endpoint_is_required() {
let err = BridgeArgs::parse(["--project", "p1"]).unwrap_err();
assert!(err.contains("--endpoint"), "got: {err}");
}
#[test]
fn project_and_requester_default_to_empty() {
let a = BridgeArgs::parse(["--endpoint", "/tmp/x.sock"]).unwrap();
assert_eq!(a.project, "");
assert_eq!(a.requester, "");
}
#[test]
fn flag_without_value_is_an_error() {
assert!(BridgeArgs::parse(["--endpoint"]).is_err());
assert!(BridgeArgs::parse(["--endpoint", "/x", "--project"]).is_err());
}
#[test]
fn unknown_flag_is_an_error() {
let err = BridgeArgs::parse(["--endpoint", "/x", "--bogus"]).unwrap_err();
assert!(err.contains("unknown"), "got: {err}");
}
#[test]
fn handshake_line_carries_identity_as_json() {
let a = BridgeArgs {
endpoint: "/tmp/x.sock".into(),
project: "proj".into(),
requester: "rq".into(),
};
let line = a.handshake_line();
assert_eq!(*line.last().unwrap(), b'\n');
let v: serde_json::Value = serde_json::from_slice(&line[..line.len() - 1]).unwrap();
assert_eq!(v["project"], "proj");
assert_eq!(v["requester"], "rq");
}
// ---- relay (in-memory streams, no socket) -----------------------------
/// Nominal relay over in-memory duplex pipes: handshake reaches the fake
/// server, a scripted request is forwarded, and the canned response comes back
/// on the CLI stdout.
#[tokio::test]
async fn relay_forwards_handshake_request_and_response() {
// CLI stdin: one JSON-RPC request line, then EOF.
let cli_in = b"{\"method\":\"tools/call\",\"id\":1}\n".to_vec();
let mut cli_out: Vec<u8> = Vec::new();
// The "loopback" is a duplex pipe: the bridge writes to `lp_write`/reads
// from `lp_read`; the fake server reads from `srv_read`/writes to
// `srv_write`.
let (lp_read, srv_write) = tokio::io::duplex(4096); // server → bridge
let (srv_read, lp_write) = tokio::io::duplex(4096); // bridge → server
let args = BridgeArgs {
endpoint: "unused".into(),
project: "proj-1".into(),
requester: "agent-9".into(),
};
// Fake server: read handshake line, then read the request line, then answer.
let server = tokio::spawn(async move {
let mut reader = BufReader::new(srv_read);
let mut handshake = String::new();
reader.read_line(&mut handshake).await.unwrap();
let mut request = String::new();
reader.read_line(&mut request).await.unwrap();
let mut w = srv_write;
w.write_all(b"{\"result\":\"ok\",\"id\":1}\n")
.await
.unwrap();
w.flush().await.unwrap();
(handshake, request)
});
relay(&args, &cli_in[..], &mut cli_out, lp_read, lp_write)
.await
.unwrap();
let (handshake, request) = server.await.unwrap();
let hs: serde_json::Value = serde_json::from_str(handshake.trim_end()).unwrap();
assert_eq!(hs["project"], "proj-1");
assert_eq!(hs["requester"], "agent-9");
assert_eq!(request.trim_end(), "{\"method\":\"tools/call\",\"id\":1}");
assert_eq!(
String::from_utf8(cli_out).unwrap(),
"{\"result\":\"ok\",\"id\":1}\n"
);
}
/// Regression (the bug that hid the tool list for days): a **notification**
/// (no `id`, no response) sent between two requests must NOT stall the relay.
/// A lockstep pump deadlocks here — after forwarding the notification it blocks
/// waiting for a reply that never comes, and never reads `tools/list`. The
/// full-duplex relay forwards all three lines and delivers the one response.
#[tokio::test]
async fn relay_does_not_block_on_notification_without_response() {
// initialize result, then an unanswered notification, then tools/list.
let cli_in = b"{\"method\":\"initialize\",\"id\":1}\n\
{\"method\":\"notifications/initialized\"}\n\
{\"method\":\"tools/list\",\"id\":2}\n"
.to_vec();
let mut cli_out: Vec<u8> = Vec::new();
let (lp_read, srv_write) = tokio::io::duplex(4096); // server → bridge
let (srv_read, lp_write) = tokio::io::duplex(4096); // bridge → server
let args = BridgeArgs {
endpoint: "unused".into(),
project: "p".into(),
requester: "agent".into(),
};
// Server: handshake, then read all three forwarded lines, answering only
// the two that carry an `id`. The notification gets no reply — exactly the
// case that used to wedge the relay.
let server = tokio::spawn(async move {
let mut reader = BufReader::new(srv_read);
let mut w = srv_write;
for _ in 0..4 {
let mut line = String::new();
if reader.read_line(&mut line).await.unwrap() == 0 {
break;
}
let v: serde_json::Value = match serde_json::from_str(line.trim_end()) {
Ok(v) => v,
Err(_) => continue, // handshake line
};
if let Some(id) = v.get("id") {
w.write_all(format!("{{\"result\":\"ok\",\"id\":{id}}}\n").as_bytes())
.await
.unwrap();
w.flush().await.unwrap();
}
}
});
tokio::time::timeout(
Duration::from_secs(5),
relay(&args, &cli_in[..], &mut cli_out, lp_read, lp_write),
)
.await
.expect("relay must not deadlock on a notification")
.expect("relay ok");
server.await.unwrap();
let out = String::from_utf8(cli_out).unwrap();
// Both request responses arrived; the notification produced none.
assert!(out.contains("\"id\":1"), "missing initialize response: {out}");
assert!(out.contains("\"id\":2"), "missing tools/list response: {out}");
}
/// stdin EOF with no request ⇒ relay returns `Ok` (code 0), having still sent
/// the handshake.
#[tokio::test]
async fn relay_clean_exit_on_immediate_eof() {
let cli_in: &[u8] = b""; // immediate EOF
let mut cli_out: Vec<u8> = Vec::new();
let (lp_read, srv_write) = tokio::io::duplex(4096);
let (srv_read, lp_write) = tokio::io::duplex(4096);
let args = BridgeArgs {
endpoint: "unused".into(),
project: "p".into(),
requester: String::new(),
};
let server = tokio::spawn(async move {
let mut reader = BufReader::new(srv_read);
let mut handshake = String::new();
reader.read_line(&mut handshake).await.unwrap();
// Keep srv_write alive until the bridge drops its writer (EOF).
let mut buf = Vec::new();
let _ = reader.read_to_end(&mut buf).await;
drop(srv_write);
handshake
});
relay(&args, cli_in, &mut cli_out, lp_read, lp_write)
.await
.expect("clean EOF exit");
let handshake = server.await.unwrap();
assert!(handshake.contains("\"project\":\"p\""));
assert!(cli_out.is_empty(), "no response expected on immediate EOF");
}
// ---- end-to-end over a real loopback ----------------------------------
fn temp_endpoint(tag: &str) -> String {
let dir = std::env::temp_dir();
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
dir.join(format!("idea-mcp-bridge-test-{tag}-{pid}-{nanos}.sock"))
.to_string_lossy()
.into_owned()
}
fn bind(endpoint: &str) -> LocalSocketListener {
let name = endpoint.to_fs_name::<GenericFilePath>().unwrap();
ListenerOptions::new()
.name(name)
.reclaim_name(true)
.create_tokio()
.expect("bind test listener")
}
/// Endpoint absent ⇒ `connect_loopback` fails fast (no hang), so the bridge
/// would exit non-zero. Bounded by a test timeout well under CONNECT_TIMEOUT
/// for the "connect error" path (a non-existent socket errors immediately).
#[tokio::test]
async fn connect_to_absent_endpoint_errors_fast() {
let endpoint = temp_endpoint("absent");
let res = tokio::time::timeout(Duration::from_secs(10), connect_loopback(&endpoint))
.await
.expect("connect_loopback must not hang");
assert!(res.is_err(), "absent endpoint must yield an error");
}
/// `run_mcp_bridge` with a missing `--endpoint` returns a non-zero code.
#[test]
fn run_bridge_missing_endpoint_is_non_zero() {
let code = run_mcp_bridge(["--project", "p"]);
assert_eq!(code, ExitCode::FAILURE);
}
/// Full end-to-end over a **real** interprocess loopback: a test listener plays
/// the server (reads the handshake, echoes a canned response); the relay drives
/// it over an actual connection. Verifies the handshake crosses the real socket
/// and the response returns on the CLI stdout.
#[tokio::test]
async fn end_to_end_over_real_loopback() {
let endpoint = temp_endpoint("e2e");
let listener = bind(&endpoint);
let captured = Arc::new(tokio::sync::Mutex::new((String::new(), String::new())));
let captured_srv = Arc::clone(&captured);
// Fake server accepts one connection, reads handshake + request, answers.
let server = tokio::spawn(async move {
let conn = listener.accept().await.unwrap();
let (r, w) = tokio::io::split(conn);
let mut reader = BufReader::new(r);
let mut handshake = String::new();
reader.read_line(&mut handshake).await.unwrap();
let mut request = String::new();
reader.read_line(&mut request).await.unwrap();
*captured_srv.lock().await = (handshake, request);
let mut w = w;
w.write_all(b"{\"result\":\"pong\",\"id\":42}\n")
.await
.unwrap();
w.flush().await.unwrap();
// Hold until the bridge finishes (its stdin EOF will end it).
let mut rest = Vec::new();
let _ = reader.read_to_end(&mut rest).await;
});
let args = BridgeArgs {
endpoint: endpoint.clone(),
project: "proj-e2e".into(),
requester: "agent-e2e".into(),
};
let cli_in = b"{\"method\":\"ping\",\"id\":42}\n".to_vec();
let mut cli_out: Vec<u8> = Vec::new();
// Drive the whole bridge_over_loopback path (real connect + split + relay),
// but feed our own stdio streams via `relay` to avoid touching process std.
let conn = tokio::time::timeout(Duration::from_secs(10), connect_loopback(&endpoint))
.await
.expect("no hang")
.expect("connect to live endpoint");
let (lp_read, lp_write) = tokio::io::split(conn);
tokio::time::timeout(
Duration::from_secs(10),
relay(&args, &cli_in[..], &mut cli_out, lp_read, lp_write),
)
.await
.expect("relay must not hang")
.expect("relay ok");
server.await.unwrap();
let (handshake, request) = captured.lock().await.clone();
let hs: serde_json::Value = serde_json::from_str(handshake.trim_end()).unwrap();
assert_eq!(hs["project"], "proj-e2e");
assert_eq!(hs["requester"], "agent-e2e");
assert_eq!(request.trim_end(), "{\"method\":\"ping\",\"id\":42}");
assert_eq!(
String::from_utf8(cli_out).unwrap(),
"{\"result\":\"pong\",\"id\":42}\n"
);
}
}

View File

@ -0,0 +1,293 @@
//! Per-project loopback **endpoint** for the IdeA MCP transport (M5a).
//!
//! ## Where this sits in the bind (cadrage v5 §1, §2)
//!
//! The S-MCP transport is **stdio-spawn**: an MCP CLI (Claude Code, Codex)
//! reads a `{command,args}` declaration and *spawns* a thin `idea mcp-server`
//! bridge. That bridge talks JSON-RPC on its stdin/stdout to the CLI and relays
//! every line, over a **local loopback**, to the IdeA (Tauri) process where the
//! real [`OrchestratorService`](application::OrchestratorService) lives. The
//! loopback is a **Unix domain socket** (Linux/macOS) or a **Windows named
//! pipe** — never a network port, so it stays AppImage- and SSH-remote-safe and
//! needs no firewall/permission.
//!
//! This module owns the **single source of truth** for that loopback address:
//! [`mcp_endpoint`]. It is deterministic per [`ProjectId`] and is called by
//! **both** sides of the contract (cadrage §2):
//! - the side that *listens* — [`ensure_mcp_server`](crate::state::AppState::ensure_mcp_server),
//! which binds the listener at project-open and tears it down at close (M5a);
//! - the side that *writes the CLI declaration* — `apply_mcp_config` (M5d),
//! which must point the spawned bridge at the **exact** same address.
//!
//! Keeping the derivation here (not duplicated on each side) is what makes the
//! M1↔M3 coherence test possible.
//!
//! ## Transport crate choice — `interprocess`
//!
//! We use the [`interprocess`] crate's *local socket* abstraction rather than
//! `tokio::net::{UnixListener, windows::named_pipe}` directly, because:
//! - **One cross-OS API** for UDS (Unix) and named pipes (Windows): a single
//! `accept` loop in M5c, no divergent `cfg` branches with two transport types.
//! - The workspace `tokio` is built **without** the `net` feature; pulling
//! `interprocess` (with its `tokio` feature) into *only* this crate avoids
//! widening tokio's surface workspace-wide.
//! - On Unix its listener carries a *reclaim guard* that **unlinks the socket
//! file on drop** — so closing a project (dropping the handle) cleans the
//! filesystem with no leak, satisfying M5a's teardown requirement for free.
//!
//! We deliberately bind a **filesystem-path** socket on Unix (under a per-user
//! runtime dir) rather than an abstract/namespaced one, so that the existence of
//! the endpoint is observable as a real path (testable) and cleaned up on close.
use std::path::PathBuf;
use application::{McpRuntime, McpRuntimeProvider};
use domain::{AgentId, Project, ProjectId};
/// The loopback address of a project's MCP endpoint — the value [`mcp_endpoint`]
/// returns. Deterministic per [`ProjectId`]; the single source of truth shared by
/// the listener (M5a/M3) and the CLI-declaration writer (M5d).
///
/// On Unix it is a **filesystem path** to a Unix-domain socket (the file is what
/// gets bound and, on close, unlinked). On Windows it is a **named pipe** path of
/// the form `\\.\pipe\idea-mcp-<id>`. The [`as_cli_arg`](Self::as_cli_arg) form is
/// the exact string handed to the spawned bridge via `--endpoint`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpEndpoint {
/// The platform-native address string (UDS path / named-pipe path).
addr: String,
}
impl McpEndpoint {
/// The address as the bridge expects it on the command line (`--endpoint`).
/// Identical string on both sides of the contract (cadrage §2).
#[must_use]
pub fn as_cli_arg(&self) -> &str {
&self.addr
}
/// The Unix socket **file path**, when this endpoint is a filesystem-path UDS.
/// Used by the listener to ensure the parent runtime dir exists and by tests to
/// assert creation/cleanup. `None` on platforms whose address is not a path
/// (Windows named pipes have no filesystem entry to manage).
#[must_use]
pub fn socket_path(&self) -> Option<PathBuf> {
#[cfg(unix)]
{
Some(PathBuf::from(&self.addr))
}
#[cfg(not(unix))]
{
None
}
}
}
/// The directory under which per-project Unix sockets live, derived from the
/// per-user runtime dir (`$XDG_RUNTIME_DIR`, falling back to `$TMPDIR`, then
/// `/tmp`). Kept short so the full socket path stays well under the ~108-byte
/// `sockaddr_un` limit (`/run/user/<uid>/idea-mcp/<32 hex>.sock` ≈ 50 bytes).
#[cfg(unix)]
fn unix_runtime_dir() -> PathBuf {
let base = std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.or_else(|| std::env::var_os("TMPDIR").map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from("/tmp"));
base.join("idea-mcp")
}
/// **Single source of truth.** Computes the deterministic loopback endpoint of a
/// project's MCP transport from its [`ProjectId`].
///
/// Same input ⇒ same output (stable across calls and processes); distinct projects
/// ⇒ distinct endpoints (the id's 32-hex *simple* form is unique and collision-free
/// by construction). No network port is ever used.
///
/// - **Unix**: `<runtime-dir>/idea-mcp/<id>.sock` — a UDS file path.
/// - **Windows**: `\\.\pipe\idea-mcp-<id>` — a named pipe.
///
/// where `<id>` is the project UUID in hyphen-free hex (`simple`) form.
#[must_use]
pub fn mcp_endpoint(project_id: &ProjectId) -> McpEndpoint {
// Hyphen-free, lowercase, fixed-width (32 chars): safe in both a filename and
// a `\\.\pipe\` name, and unique per project.
let id = project_id.as_uuid().simple().to_string();
#[cfg(unix)]
let addr = unix_runtime_dir()
.join(format!("{id}.sock"))
.to_string_lossy()
.into_owned();
#[cfg(windows)]
let addr = format!(r"\\.\pipe\idea-mcp-{id}");
#[cfg(not(any(unix, windows)))]
let addr = format!("idea-mcp-{id}");
McpEndpoint { addr }
}
/// Chemin du binaire IdeA à inscrire comme `command` dans la déclaration MCP.
///
/// Privilégie `$APPIMAGE` (chemin **stable** du `.AppImage`, qui survit aux
/// redémarrages) car sous AppImage `current_exe()` pointe sur un montage éphémère
/// `/tmp/.mount_*` qui disparaît au redémarrage ⇒ `ENOENT` au respawn du pont MCP.
/// Hors AppImage `$APPIMAGE` est absent ⇒ on retombe sur `current_exe()`. `None` si
/// aucun des deux n'est résolvable (ne devrait pas arriver) ⇒ déclaration minimale.
pub(crate) fn idea_exe_path() -> Option<String> {
std::env::var("APPIMAGE").ok().or_else(|| {
std::env::current_exe()
.ok()
.map(|p| p.to_string_lossy().into_owned())
})
}
/// Implémentation app-tauri du port [`McpRuntimeProvider`] : fournit à
/// l'orchestrateur (couche `application`) les **faits OS/runtime** nécessaires pour
/// écrire la déclaration MCP réelle quand il (re)lance une cible sur le chemin
/// `ask` (`ensure_live_pty`).
///
/// Sans état : tout est dérivé du `Project` et de l'`AgentId` reçus + de
/// l'environnement (`$APPIMAGE`/`current_exe`). C'est le **seul** détenteur légitime
/// de ces faits (la couche `application` ne doit pas dépendre de `current_exe` /
/// `$APPIMAGE` / `mcp_endpoint`, cadrage v5 §0.3 / §7) ; seules des **chaînes**
/// traversent la frontière via [`McpRuntime`].
pub struct AppMcpRuntimeProvider;
impl McpRuntimeProvider for AppMcpRuntimeProvider {
/// `agent_id` = la cible relancée = le `--requester` (c'est elle qui appellera
/// `idea_reply`). Réutilise la **même** logique que le chemin GUI
/// (`commands.rs::launch_agent`) : même endpoint (source de vérité unique),
/// même forme `simple` 32-hex du `project_id`. `None` (exe introuvable) ⇒
/// l'orchestrateur dégrade vers la déclaration minimale, jamais d'échec de
/// lancement.
fn runtime_for(&self, project: &Project, agent_id: AgentId) -> Option<McpRuntime> {
Some(McpRuntime {
exe: idea_exe_path()?,
endpoint: mcp_endpoint(&project.id).as_cli_arg().to_owned(),
project_id: project.id.as_uuid().simple().to_string(),
requester: agent_id.to_string(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use uuid::Uuid;
fn pid(s: &str) -> ProjectId {
ProjectId::from_uuid(Uuid::parse_str(s).unwrap())
}
#[test]
fn endpoint_is_deterministic_for_the_same_project() {
let p = pid("11111111-1111-1111-1111-111111111111");
assert_eq!(mcp_endpoint(&p), mcp_endpoint(&p));
assert_eq!(mcp_endpoint(&p).as_cli_arg(), mcp_endpoint(&p).as_cli_arg());
}
#[test]
fn distinct_projects_get_distinct_endpoints() {
let a = pid("11111111-1111-1111-1111-111111111111");
let b = pid("22222222-2222-2222-2222-222222222222");
assert_ne!(mcp_endpoint(&a), mcp_endpoint(&b));
assert_ne!(mcp_endpoint(&a).as_cli_arg(), mcp_endpoint(&b).as_cli_arg());
}
#[test]
fn address_encodes_the_project_id_without_hyphens() {
let p = pid("abcdef01-2345-6789-abcd-ef0123456789");
let arg = mcp_endpoint(&p).as_cli_arg().to_owned();
assert!(
arg.contains("abcdef0123456789abcdef0123456789"),
"endpoint must embed the hyphen-free id, got {arg}"
);
}
#[cfg(unix)]
#[test]
fn unix_endpoint_is_a_sock_path_under_the_runtime_dir() {
let p = pid("11111111-1111-1111-1111-111111111111");
let ep = mcp_endpoint(&p);
let path = ep
.socket_path()
.expect("unix endpoint exposes a socket path");
assert!(path.extension().is_some_and(|e| e == "sock"));
assert_eq!(path.parent().unwrap(), unix_runtime_dir());
}
/// `idea_exe_path()` : `$APPIMAGE` posé ⇒ on renvoie sa valeur (chemin stable du
/// `.AppImage`, qui survit aux redémarrages) ; absent ⇒ on retombe sur
/// `current_exe()`. Les deux assertions sont regroupées dans **un seul** test pour
/// éviter une course inter-tests sur la variable d'env globale du process ; la var
/// est sauvegardée/restaurée pour ne pas polluer les autres tests.
#[test]
fn idea_exe_path_prefers_appimage_then_falls_back_to_current_exe() {
let saved = std::env::var_os("APPIMAGE");
// APPIMAGE posé ⇒ valeur exacte renvoyée.
std::env::set_var("APPIMAGE", "/opt/idea/IdeA.AppImage");
assert_eq!(
idea_exe_path().as_deref(),
Some("/opt/idea/IdeA.AppImage"),
"$APPIMAGE doit primer"
);
// APPIMAGE absent ⇒ repli sur current_exe() (présent dans un binaire de test).
std::env::remove_var("APPIMAGE");
let fallback = idea_exe_path();
let expected = std::env::current_exe()
.ok()
.map(|p| p.to_string_lossy().into_owned());
assert_eq!(fallback, expected, "repli attendu sur current_exe()");
assert!(fallback.is_some(), "current_exe() résolvable dans le test");
// Restauration de l'état initial de la variable.
match saved {
Some(v) => std::env::set_var("APPIMAGE", v),
None => std::env::remove_var("APPIMAGE"),
}
}
/// `AppMcpRuntimeProvider::runtime_for` : cohérence des champs avec les sources de
/// vérité — endpoint identique à `mcp_endpoint(project.id).as_cli_arg()`,
/// `project_id` en forme `simple` 32-hex, `requester == agent_id.to_string()`.
#[test]
fn app_provider_runtime_for_matches_sources_of_truth() {
use domain::project::ProjectPath;
use domain::remote::RemoteRef;
use domain::{AgentId, Project, ProjectId};
let project = Project::new(
ProjectId::from_uuid(Uuid::parse_str("abcdef01-2345-6789-abcd-ef0123456789").unwrap()),
"demo",
ProjectPath::new("/home/me/proj").unwrap(),
RemoteRef::local(),
1_700_000_000_000,
)
.unwrap();
let agent_id = AgentId::from_uuid(Uuid::from_u128(42));
// On s'assure que current_exe/$APPIMAGE est résolvable (sinon runtime_for None).
let rt = AppMcpRuntimeProvider
.runtime_for(&project, agent_id)
.expect("runtime_for doit produire un McpRuntime (exe résolvable)");
// Endpoint = même source de vérité que le listener.
assert_eq!(
rt.endpoint,
mcp_endpoint(&project.id).as_cli_arg(),
"endpoint cohérent avec mcp_endpoint()"
);
// project_id en forme simple 32-hex (hyphen-free), consommée par la garde M5c.
assert_eq!(rt.project_id, project.id.as_uuid().simple().to_string());
assert_eq!(rt.project_id.len(), 32, "forme simple 32-hex");
assert!(!rt.project_id.contains('-'), "pas de tirets");
// requester = l'id de la cible relancée.
assert_eq!(rt.requester, agent_id.to_string());
// exe non vide.
assert!(!rt.exe.is_empty(), "exe renseigné");
}
}

View File

@ -34,7 +34,11 @@ pub type PtyChunk = Vec<u8>;
/// Thread-safe; cloned `Arc<PtyBridge>` is held in [`crate::state::AppState`]. /// Thread-safe; cloned `Arc<PtyBridge>` is held in [`crate::state::AppState`].
#[derive(Default)] #[derive(Default)]
pub struct PtyBridge { pub struct PtyBridge {
channels: Mutex<HashMap<SessionId, Channel<PtyChunk>>>, /// Per session: a monotonically-increasing **generation** plus the current
/// output channel. The generation distinguishes successive (re-)attaches so a
/// superseded pump thread can't tear down the channel of the attach that
/// replaced it (see [`PtyBridge::unregister_if`]).
channels: Mutex<HashMap<SessionId, (u64, Channel<PtyChunk>)>>,
} }
impl PtyBridge { impl PtyBridge {
@ -46,21 +50,39 @@ impl PtyBridge {
} }
} }
/// Registers the output channel for a session (called when a terminal is /// Registers (or replaces) the output channel for a session and returns the
/// opened, from a `#[tauri::command]` that receives the `Channel` argument). /// **generation** of this registration. Each call for a session bumps the
pub fn register(&self, session: SessionId, channel: Channel<PtyChunk>) { /// generation, so the caller's pump thread can later tear down *only its own*
/// registration via [`unregister_if`](Self::unregister_if).
pub fn register(&self, session: SessionId, channel: Channel<PtyChunk>) -> u64 {
if let Ok(mut map) = self.channels.lock() { if let Ok(mut map) = self.channels.lock() {
map.insert(session, channel); let gen = map.get(&session).map_or(0, |(g, _)| g.wrapping_add(1));
map.insert(session, (gen, channel));
gen
} else {
0
} }
} }
/// Removes a session's channel (terminal closed). /// Removes a session's channel unconditionally (terminal explicitly closed).
pub fn unregister(&self, session: &SessionId) { pub fn unregister(&self, session: &SessionId) {
if let Ok(mut map) = self.channels.lock() { if let Ok(mut map) = self.channels.lock() {
map.remove(session); map.remove(session);
} }
} }
/// Removes a session's channel **only if** `gen` is still the current
/// generation. A pump thread calls this when its output stream ends: if the
/// session has since been re-attached (newer generation), this is a no-op, so
/// the dying thread never unregisters the live channel that superseded it.
pub fn unregister_if(&self, session: &SessionId, gen: u64) {
if let Ok(mut map) = self.channels.lock() {
if matches!(map.get(session), Some((g, _)) if *g == gen) {
map.remove(session);
}
}
}
/// Forwards a chunk of output bytes to a session's channel. /// Forwards a chunk of output bytes to a session's channel.
/// ///
/// Returns `true` if the chunk was delivered, `false` if no channel is /// Returns `true` if the chunk was delivered, `false` if no channel is
@ -71,7 +93,7 @@ impl PtyBridge {
return false; return false;
}; };
match map.get(session) { match map.get(session) {
Some(channel) => channel.send(chunk).is_ok(), Some((_, channel)) => channel.send(chunk).is_ok(),
None => false, None => false,
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,383 @@
//! L1 tests for [`ChatBridge`] — the structured-reply ↔ Channel registry, the
//! twin of [`PtyBridge`] (ARCHITECTURE §17.7) — and for [`chunk_from_event`], the
//! single `ReplyEvent → ReplyChunk` translation point.
//!
//! These exercise the load-bearing transport logic the D4 commands are thin
//! shells over: the `agent_send` pump's delivery + scrollback recording, the
//! `reattach_agent_chat` repaint-without-respawn, the `close_agent_session`
//! teardown, and — the most critical invariant — generation supersede (no double
//! emission after a re-attach). A real [`tauri::ipc::Channel`] built from a
//! capturing closure is used (no Tauri runtime, no real CLI/PTY/session).
use std::sync::{Arc, Mutex};
use tauri::ipc::{Channel, InvokeResponseBody};
use app_tauri_lib::chat::{chunk_from_event, ChatBridge};
use app_tauri_lib::dto::ReplyChunk;
use domain::ids::SessionId;
use domain::ports::ReplyEvent;
use uuid::Uuid;
/// Builds a `Channel<ReplyChunk>` whose sent chunks are recorded into `sink`.
///
/// `ReplyChunk` is `Serialize`/`Deserialize`, so chunks arrive as a JSON string
/// in an `InvokeResponseBody::Json`; we parse them back to `ReplyChunk` for
/// assertions (round-tripping the exact camelCase tagged shape on the way).
fn capturing_channel(sink: Arc<Mutex<Vec<ReplyChunk>>>) -> Channel<ReplyChunk> {
Channel::new(move |body: InvokeResponseBody| {
let chunk: ReplyChunk = match body {
InvokeResponseBody::Json(s) => serde_json::from_str(&s).unwrap(),
InvokeResponseBody::Raw(b) => serde_json::from_slice(&b).unwrap(),
};
sink.lock().unwrap().push(chunk);
Ok(())
})
}
fn sid() -> SessionId {
SessionId::from_uuid(Uuid::new_v4())
}
fn delta(s: &str) -> ReplyChunk {
ReplyChunk::TextDelta { text: s.to_owned() }
}
fn final_chunk(s: &str) -> ReplyChunk {
ReplyChunk::Final {
content: s.to_owned(),
}
}
// ---------------------------------------------------------------------------
// chunk_from_event — exhaustive mapping (zone 6)
// ---------------------------------------------------------------------------
#[test]
fn chunk_from_event_maps_text_delta() {
assert_eq!(
chunk_from_event(ReplyEvent::TextDelta { text: "hi".into() }),
Some(ReplyChunk::TextDelta { text: "hi".into() })
);
}
#[test]
fn chunk_from_event_maps_tool_activity() {
assert_eq!(
chunk_from_event(ReplyEvent::ToolActivity {
label: "reads file".into()
}),
Some(ReplyChunk::ToolActivity {
label: "reads file".into()
})
);
}
#[test]
fn chunk_from_event_maps_final() {
assert_eq!(
chunk_from_event(ReplyEvent::Final {
content: "done".into()
}),
Some(ReplyChunk::Final {
content: "done".into()
})
);
}
#[test]
fn chunk_from_event_drops_heartbeat() {
// A heartbeat is a non-terminal liveness proof with no chat content (lot 1) ⇒ no
// wire chunk; the pump skips it while still draining to the Final.
assert_eq!(chunk_from_event(ReplyEvent::Heartbeat), None);
}
// ---------------------------------------------------------------------------
// agent_send pump behaviour: deltas* then exactly one Final (zone 2)
// ---------------------------------------------------------------------------
/// Replays a turn's events through the bridge exactly as the `agent_send` pump
/// does (`chunk_from_event` + `send_output`), then `detach_if(gen)` at stream
/// end. This is the body of the spawned pump thread, run inline.
fn pump_turn(bridge: &ChatBridge, session: &SessionId, gen: u64, events: Vec<ReplyEvent>) {
for event in events {
// Mirror the real pump: heartbeats map to no chunk and are skipped.
if let Some(chunk) = chunk_from_event(event) {
let _ = bridge.send_output(session, chunk);
}
}
bridge.detach_if(session, gen);
}
#[test]
fn pump_delivers_deltas_then_exactly_one_final_in_order() {
let bridge = ChatBridge::new();
let session = sid();
let sink = Arc::new(Mutex::new(Vec::new()));
let gen = bridge.register(session, capturing_channel(Arc::clone(&sink)));
pump_turn(
&bridge,
&session,
gen,
vec![
ReplyEvent::TextDelta { text: "Hel".into() },
ReplyEvent::TextDelta { text: "lo".into() },
ReplyEvent::Final {
content: "Hello".into(),
},
],
);
let got = sink.lock().unwrap();
assert_eq!(
got.as_slice(),
&[delta("Hel"), delta("lo"), final_chunk("Hello")],
"deltas in order, then the single Final"
);
// Exactly one Final, and it is last (deterministic terminal chunk).
let finals = got
.iter()
.filter(|c| matches!(c, ReplyChunk::Final { .. }))
.count();
assert_eq!(finals, 1, "exactly one Final per turn");
assert!(matches!(got.last(), Some(ReplyChunk::Final { .. })));
}
#[test]
fn pump_with_only_a_final_delivers_just_the_final() {
// Zero deltas is valid (deltas*); the turn still ends on exactly one Final.
let bridge = ChatBridge::new();
let session = sid();
let sink = Arc::new(Mutex::new(Vec::new()));
let gen = bridge.register(session, capturing_channel(Arc::clone(&sink)));
pump_turn(
&bridge,
&session,
gen,
vec![ReplyEvent::Final {
content: "ok".into(),
}],
);
assert_eq!(sink.lock().unwrap().as_slice(), &[final_chunk("ok")]);
}
// ---------------------------------------------------------------------------
// Generation supersede — the critical invariant (zone 1)
// ---------------------------------------------------------------------------
/// A `reattach_agent_chat` mid-turn: a first pump is in flight (old generation)
/// when the view re-attaches (new channel, bumped generation). The old pump's
/// remaining chunks must NOT reach the *new* channel, and when the old pump ends
/// its `detach_if(old_gen)` must be a no-op (never tearing down the live one).
#[test]
fn reattach_supersedes_old_pump_no_double_emission() {
let bridge = ChatBridge::new();
let session = sid();
let old = Arc::new(Mutex::new(Vec::new()));
let new = Arc::new(Mutex::new(Vec::new()));
// Turn starts: old channel registered, pump emits a first delta.
let old_gen = bridge.register(session, capturing_channel(Arc::clone(&old)));
bridge.send_output(&session, delta("a")); // delivered to old
// View navigates away & back → reattach registers a NEW channel (bumps gen).
let _new_gen = bridge.register(session, capturing_channel(Arc::clone(&new)));
// Old pump keeps draining its (now stale) stream, then ends with its own gen.
bridge.send_output(&session, delta("b")); // recorded to scrollback, routed to NEW channel
bridge.detach_if(&session, old_gen); // MUST be a no-op (newer gen present)
// The new (live) attach must NOT have been detached: it is still deliverable.
assert!(
bridge.send_output(&session, final_chunk("done")),
"live re-attach channel must survive a superseded pump's detach_if"
);
// The OLD channel only ever saw the single pre-reattach delta — no double emit.
assert_eq!(
old.lock().unwrap().as_slice(),
&[delta("a")],
"superseded channel receives nothing after the reattach"
);
}
#[test]
fn detach_if_current_generation_stops_delivery() {
// When the pump that owns the current generation ends (e.g. Final reached and
// no reattach happened), detach_if drops the channel: further output is not
// delivered (turn over) but the session stays known until close.
let bridge = ChatBridge::new();
let session = sid();
let sink = Arc::new(Mutex::new(Vec::new()));
let gen = bridge.register(session, capturing_channel(Arc::clone(&sink)));
bridge.send_output(&session, final_chunk("end"));
bridge.detach_if(&session, gen);
// Channel detached: a stray chunk is recorded to scrollback but not delivered.
assert!(!bridge.send_output(&session, delta("late")));
assert_eq!(sink.lock().unwrap().as_slice(), &[final_chunk("end")]);
// Session is still tracked (scrollback survives until close).
assert_eq!(bridge.active_sessions(), 1);
}
// ---------------------------------------------------------------------------
// reattach_agent_chat: scrollback repaint without re-spawn (zone 3)
// ---------------------------------------------------------------------------
#[test]
fn scrollback_accumulates_every_routed_chunk_in_order() {
let bridge = ChatBridge::new();
let session = sid();
let gen = bridge.register(session, capturing_channel(Arc::new(Mutex::new(Vec::new()))));
pump_turn(
&bridge,
&session,
gen,
vec![
ReplyEvent::TextDelta { text: "x".into() },
ReplyEvent::ToolActivity {
label: "runs".into(),
},
ReplyEvent::Final {
content: "x".into(),
},
],
);
assert_eq!(
bridge.scrollback(&session),
vec![
delta("x"),
ReplyChunk::ToolActivity {
label: "runs".into()
},
final_chunk("x"),
],
"scrollback retains the full conversation, in order"
);
}
#[test]
fn reattach_repaints_scrollback_and_preserves_it_without_resend() {
// Models reattach_agent_chat: a first turn streamed, the view detached, then a
// new channel registers. The scrollback survives the re-register (no session
// method is called — no re-spawn), and is what the command returns.
let bridge = ChatBridge::new();
let session = sid();
let g0 = bridge.register(session, capturing_channel(Arc::new(Mutex::new(Vec::new()))));
pump_turn(
&bridge,
&session,
g0,
vec![
ReplyEvent::TextDelta { text: "Hi".into() },
ReplyEvent::Final {
content: "Hi".into(),
},
],
);
// Snapshot exactly what reattach_agent_chat returns BEFORE swapping channel.
let repainted = bridge.scrollback(&session);
assert_eq!(repainted, vec![delta("Hi"), final_chunk("Hi")]);
// Re-attach: register a fresh channel. Scrollback is preserved (not cleared,
// not duplicated), generation bumped.
let new_sink = Arc::new(Mutex::new(Vec::new()));
let g1 = bridge.register(session, capturing_channel(Arc::clone(&new_sink)));
assert_eq!(g1, g0 + 1, "re-attach bumps the generation");
assert_eq!(
bridge.scrollback(&session),
repainted,
"scrollback unchanged by re-attach (no re-send, no re-spawn)"
);
// The fresh channel got nothing yet: reattach does NOT replay over the channel
// (the command returns the scrollback for the front to replay itself).
assert!(
new_sink.lock().unwrap().is_empty(),
"re-attach must not push the old turns onto the new channel"
);
}
#[test]
fn scrollback_of_unknown_session_is_empty() {
let bridge = ChatBridge::new();
assert!(bridge.scrollback(&sid()).is_empty());
}
// ---------------------------------------------------------------------------
// close_agent_session: unregister purges channel AND scrollback (zone 4)
// ---------------------------------------------------------------------------
#[test]
fn unregister_purges_channel_and_scrollback() {
let bridge = ChatBridge::new();
let session = sid();
let sink = Arc::new(Mutex::new(Vec::new()));
let gen = bridge.register(session, capturing_channel(Arc::clone(&sink)));
pump_turn(
&bridge,
&session,
gen,
vec![ReplyEvent::Final {
content: "bye".into(),
}],
);
assert_eq!(bridge.active_sessions(), 1);
assert!(!bridge.scrollback(&session).is_empty());
bridge.unregister(&session);
assert_eq!(bridge.active_sessions(), 0, "session removed");
assert!(
bridge.scrollback(&session).is_empty(),
"scrollback purged on close"
);
assert!(
!bridge.send_output(&session, delta("z")),
"no delivery after close"
);
// The closed channel must not receive the post-close chunk.
assert_eq!(sink.lock().unwrap().as_slice(), &[final_chunk("bye")]);
}
// ---------------------------------------------------------------------------
// Registry basics, mirrored from pty_bridge.rs for parity
// ---------------------------------------------------------------------------
#[test]
fn register_returns_monotonic_generation_per_session() {
let bridge = ChatBridge::new();
let session = sid();
let g0 = bridge.register(session, capturing_channel(Arc::new(Mutex::new(Vec::new()))));
let g1 = bridge.register(session, capturing_channel(Arc::new(Mutex::new(Vec::new()))));
assert_eq!(g0, 0);
assert_eq!(g1, 1);
assert_eq!(bridge.active_sessions(), 1, "same id replaced, not added");
}
#[test]
fn send_output_to_unknown_session_returns_false() {
let bridge = ChatBridge::new();
assert!(!bridge.send_output(&sid(), delta("x")));
}
#[test]
fn register_same_session_replaces_channel() {
let bridge = ChatBridge::new();
let session = sid();
let first = Arc::new(Mutex::new(Vec::new()));
let second = Arc::new(Mutex::new(Vec::new()));
bridge.register(session, capturing_channel(Arc::clone(&first)));
bridge.register(session, capturing_channel(Arc::clone(&second)));
bridge.send_output(&session, delta("9"));
assert!(first.lock().unwrap().is_empty(), "old channel unused");
assert_eq!(second.lock().unwrap().as_slice(), &[delta("9")]);
}

View File

@ -4,19 +4,19 @@
use app_tauri_lib::dto::{ use app_tauri_lib::dto::{
parse_node_id, parse_session_id, ErrorDto, HealthRequestDto, HealthResponseDto, LayoutDto, parse_node_id, parse_session_id, ErrorDto, HealthRequestDto, HealthResponseDto, LayoutDto,
LayoutOperationDto, OpenTerminalRequestDto, ResizeTerminalRequestDto, TerminalClosedDto, LayoutOperationDto, OpenTerminalRequestDto, ReattachResultDto, ResizeTerminalRequestDto,
WriteTerminalRequestDto, TerminalClosedDto, WriteTerminalRequestDto,
}; };
use app_tauri_lib::events::{DomainEventDto, DOMAIN_EVENT};
use application::{CloseTerminalOutput, LayoutOperation, LoadLayoutOutput, OpenTerminalInput}; use application::{CloseTerminalOutput, LayoutOperation, LoadLayoutOutput, OpenTerminalInput};
use domain::{Direction, LayoutNode, LayoutTree, LeafCell, NodeId}; use domain::{Direction, LayoutNode, LayoutTree, LeafCell, NodeId};
use app_tauri_lib::events::{DomainEventDto, DOMAIN_EVENT};
use application::{AppError, HealthInput}; use application::{AppError, HealthInput};
use domain::events::DomainEvent; use domain::events::DomainEvent;
use domain::ids::{AgentId, SessionId}; use domain::ids::{AgentId, ProfileId, SessionId};
use domain::ProjectId; use domain::ProjectId;
use domain::TemplateVersion;
use domain::TemplateId; use domain::TemplateId;
use domain::TemplateVersion;
use serde_json::json; use serde_json::json;
use uuid::Uuid; use uuid::Uuid;
@ -93,6 +93,26 @@ fn domain_event_dto_maps_agent_launched() {
assert_eq!(v["sessionId"], sid.to_string()); assert_eq!(v["sessionId"], sid.to_string());
} }
#[test]
fn domain_event_dto_maps_agent_profile_changed() {
// LOT A0 : round-trip camelCase agentId/profileId du miroir DTO.
let aid = AgentId::from_uuid(Uuid::from_u128(1));
let pid = ProfileId::from_uuid(Uuid::from_u128(2));
let dto = DomainEventDto::from(&DomainEvent::AgentProfileChanged {
agent_id: aid,
profile_id: pid,
});
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(
v,
json!({
"type": "agentProfileChanged",
"agentId": aid.to_string(),
"profileId": pid.to_string(),
})
);
}
#[test] #[test]
fn domain_event_dto_maps_template_updated_version() { fn domain_event_dto_maps_template_updated_version() {
let tid = TemplateId::from_uuid(Uuid::nil()); let tid = TemplateId::from_uuid(Uuid::nil());
@ -118,6 +138,65 @@ fn domain_event_dto_maps_pty_output_bytes() {
assert_eq!(v["bytes"], json!([1, 2, 3])); assert_eq!(v["bytes"], json!([1, 2, 3]));
} }
// ---------------------------------------------------------------------------
// M4 — orchestration source on `orchestratorRequestProcessed`
// ---------------------------------------------------------------------------
#[test]
fn orchestration_source_dto_serialises_lowercase() {
use app_tauri_lib::events::OrchestrationSourceDto;
use domain::events::OrchestrationSource;
// File → "file"; Mcp → "mcp" (camelCase rename on a unit enum = lowercase).
let file =
serde_json::to_value(OrchestrationSourceDto::from(OrchestrationSource::File)).unwrap();
assert_eq!(file, json!("file"));
let mcp = serde_json::to_value(OrchestrationSourceDto::from(OrchestrationSource::Mcp)).unwrap();
assert_eq!(mcp, json!("mcp"));
}
#[test]
fn domain_event_dto_maps_orchestrator_request_processed_with_file_source() {
use domain::events::OrchestrationSource;
let dto = DomainEventDto::from(&DomainEvent::OrchestratorRequestProcessed {
requester_id: "Main".into(),
action: "spawn_agent".into(),
ok: true,
source: OrchestrationSource::File,
});
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(
v,
json!({
"type": "orchestratorRequestProcessed",
"requesterId": "Main",
"action": "spawn_agent",
"ok": true,
"source": "file",
})
);
}
#[test]
fn domain_event_dto_maps_orchestrator_request_processed_with_mcp_source() {
use domain::events::OrchestrationSource;
let dto = DomainEventDto::from(&DomainEvent::OrchestratorRequestProcessed {
requester_id: "mcp".into(),
action: "idea_ask_agent".into(),
ok: false,
source: OrchestrationSource::Mcp,
});
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["type"], "orchestratorRequestProcessed");
assert_eq!(v["requesterId"], "mcp");
assert_eq!(v["action"], "idea_ask_agent");
assert_eq!(v["ok"], json!(false));
assert_eq!(v["source"], "mcp", "MCP door must badge as `mcp`");
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Terminal DTOs (L3) // Terminal DTOs (L3)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -188,7 +267,23 @@ fn terminal_closed_dto_serialises_code_camel_case() {
// Signalled (None) round-trips as null. // Signalled (None) round-trips as null.
let none = TerminalClosedDto::from(CloseTerminalOutput { code: None }); let none = TerminalClosedDto::from(CloseTerminalOutput { code: None });
assert_eq!(serde_json::to_value(&none).unwrap(), json!({ "code": null })); assert_eq!(
serde_json::to_value(&none).unwrap(),
json!({ "code": null })
);
}
#[test]
fn reattach_result_dto_serialises_camel_case() {
let dto = ReattachResultDto {
session_id: "sess-1".into(),
scrollback: vec![104, 105],
};
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(
v,
json!({ "sessionId": "sess-1", "scrollback": [104, 105] })
);
} }
#[test] #[test]
@ -213,6 +308,9 @@ fn layout_dto_serialises_camelcase_tagged_tree() {
id: nid(1), id: nid(1),
session: None, session: None,
agent: None, agent: None,
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
}); });
let dto = LayoutDto::from(LoadLayoutOutput { let dto = LayoutDto::from(LoadLayoutOutput {
layout_id: domain::LayoutId::new_random(), layout_id: domain::LayoutId::new_random(),
@ -312,8 +410,23 @@ fn layout_dto_round_trips_a_split_tree_shape() {
id: nid(1), id: nid(1),
session: None, session: None,
agent: None, agent: None,
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
}) })
.split(nid(1), Direction::Column, LeafCell { id: nid(2), session: None, agent: None }, nid(9)) .split(
nid(1),
Direction::Column,
LeafCell {
id: nid(2),
session: None,
agent: None,
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
},
nid(9),
)
.unwrap(); .unwrap();
let dto = LayoutDto::from(LoadLayoutOutput { let dto = LayoutDto::from(LoadLayoutOutput {
layout_id: domain::LayoutId::new_random(), layout_id: domain::LayoutId::new_random(),

View File

@ -3,11 +3,16 @@
//! and `From<LaunchAgentOutput>` for [`TerminalSessionDto`]. //! and `From<LaunchAgentOutput>` for [`TerminalSessionDto`].
use app_tauri_lib::dto::{ use app_tauri_lib::dto::{
parse_agent_id, AgentDto, AgentListDto, CreateAgentRequestDto, LaunchAgentRequestDto, parse_agent_id, AgentDto, AgentListDto, ConversationDetailsDto, CreateAgentRequestDto,
TerminalSessionDto, UpdateAgentContextRequestDto, InspectConversationRequestDto, LaunchAgentRequestDto, LiveAgentListDto, TerminalSessionDto,
UpdateAgentContextRequestDto,
};
use application::AppError;
use application::{
CreateAgentOutput, InspectConversationOutput, LaunchAgentOutput, ListAgentsOutput,
}; };
use application::{CreateAgentOutput, LaunchAgentOutput, ListAgentsOutput};
use domain::ids::{AgentId, NodeId, ProfileId, SessionId}; use domain::ids::{AgentId, NodeId, ProfileId, SessionId};
use domain::ports::ConversationDetails;
use domain::terminal::{PtySize, SessionKind, SessionStatus, TerminalSession}; use domain::terminal::{PtySize, SessionKind, SessionStatus, TerminalSession};
use domain::{Agent, AgentOrigin, ProjectPath}; use domain::{Agent, AgentOrigin, ProjectPath};
use serde_json::json; use serde_json::json;
@ -39,7 +44,10 @@ fn agent_dto_serialises_camelcase() {
assert_eq!(v["id"], agent.id.to_string()); assert_eq!(v["id"], agent.id.to_string());
assert_eq!(v["name"], "My Agent"); assert_eq!(v["name"], "My Agent");
assert_eq!(v["contextPath"], "agents/my-agent.md", "camelCase key"); assert_eq!(v["contextPath"], "agents/my-agent.md", "camelCase key");
assert_eq!(v["profileId"], ProfileId::from_uuid(Uuid::from_u128(2)).to_string()); assert_eq!(
v["profileId"],
ProfileId::from_uuid(Uuid::from_u128(2)).to_string()
);
assert_eq!(v["synchronized"], false); assert_eq!(v["synchronized"], false);
// origin: tagged `{ "type": "scratch" }` // origin: tagged `{ "type": "scratch" }`
assert_eq!(v["origin"]["type"], "scratch"); assert_eq!(v["origin"]["type"], "scratch");
@ -63,7 +71,9 @@ fn agent_list_dto_is_transparent_array() {
#[test] #[test]
fn create_agent_output_maps_to_agent_dto() { fn create_agent_output_maps_to_agent_dto() {
let agent = make_agent(5, 6); let agent = make_agent(5, 6);
let out = CreateAgentOutput { agent: agent.clone() }; let out = CreateAgentOutput {
agent: agent.clone(),
};
let dto = AgentDto::from(out); let dto = AgentDto::from(out);
assert_eq!(dto.0.id, agent.id); assert_eq!(dto.0.id, agent.id);
} }
@ -126,6 +136,132 @@ fn launch_agent_request_deserialises_camelcase() {
assert_eq!(dto.cols, 80); assert_eq!(dto.cols, 80);
assert_eq!(dto.project_id, project_id); assert_eq!(dto.project_id, project_id);
assert_eq!(dto.agent_id, agent_id); assert_eq!(dto.agent_id, agent_id);
// Omitting the resume id defaults to None (a fresh cell).
assert_eq!(dto.conversation_id, None);
// Omitting the node id defaults to None (a fresh node is minted backend-side).
assert_eq!(dto.node_id, None);
}
#[test]
fn launch_agent_request_carries_node_id() {
let node_id = Uuid::from_u128(33).to_string();
let raw = json!({
"projectId": Uuid::from_u128(1).to_string(),
"agentId": Uuid::from_u128(2).to_string(),
"rows": 24,
"cols": 80,
"nodeId": node_id
});
let dto: LaunchAgentRequestDto = serde_json::from_value(raw).unwrap();
// The hosting cell reaches the backend so the singleton guard can enforce
// "one live session per agent".
assert_eq!(dto.node_id.as_deref(), Some(node_id.as_str()));
// No snake_case leak on the wire.
}
// ---------------------------------------------------------------------------
// AGENT_ALREADY_RUNNING error code + LiveAgentListDto (T2/T3)
// ---------------------------------------------------------------------------
#[test]
fn agent_already_running_error_code_is_stable() {
let err = AppError::AgentAlreadyRunning {
agent_id: AgentId::from_uuid(Uuid::from_u128(2)),
node_id: NodeId::from_uuid(Uuid::from_u128(3)),
};
let dto = app_tauri_lib::dto::ErrorDto::from(err);
// Option A: a stable code + a text message (the node is NOT enriched into the
// ErrorDto — the frontend branches on the code alone).
assert_eq!(dto.code, "AGENT_ALREADY_RUNNING");
assert!(
dto.message.contains("already running"),
"message: {}",
dto.message
);
}
#[test]
fn live_agent_list_dto_serialises_camelcase_array() {
let agent_a = AgentId::from_uuid(Uuid::from_u128(11));
let node_a = NodeId::from_uuid(Uuid::from_u128(21));
let session_a = domain::SessionId::from_uuid(Uuid::from_u128(31));
let dto = LiveAgentListDto::from_pairs(vec![(agent_a, node_a, session_a)]);
let v = serde_json::to_value(&dto).unwrap();
let arr = v.as_array().expect("transparent array");
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["agentId"], agent_a.to_string());
assert_eq!(arr[0]["nodeId"], node_a.to_string());
assert_eq!(arr[0]["sessionId"], session_a.to_string());
// No snake_case leak.
assert!(arr[0].get("agent_id").is_none());
assert!(arr[0].get("node_id").is_none());
}
#[test]
fn launch_agent_request_carries_conversation_id_for_resume() {
let raw = json!({
"projectId": Uuid::from_u128(1).to_string(),
"agentId": Uuid::from_u128(2).to_string(),
"rows": 24,
"cols": 80,
"conversationId": "resume-me"
});
let dto: LaunchAgentRequestDto = serde_json::from_value(raw).unwrap();
// The leaf's persisted id reaches the backend so the launch resumes (T4b).
assert_eq!(dto.conversation_id.as_deref(), Some("resume-me"));
}
// ---------------------------------------------------------------------------
// InspectConversation DTOs (T7)
// ---------------------------------------------------------------------------
#[test]
fn inspect_conversation_request_deserialises_camelcase() {
let raw = json!({
"projectId": Uuid::from_u128(1).to_string(),
"agentId": Uuid::from_u128(2).to_string(),
"conversationId": "conv-7"
});
let dto: InspectConversationRequestDto = serde_json::from_value(raw).unwrap();
assert_eq!(dto.project_id, Uuid::from_u128(1).to_string());
assert_eq!(dto.agent_id, Uuid::from_u128(2).to_string());
assert_eq!(dto.conversation_id, "conv-7");
}
#[test]
fn conversation_details_dto_serialises_camelcase_when_present() {
let out = InspectConversationOutput {
details: ConversationDetails {
last_topic: Some("refactor parser".to_owned()),
token_count: Some(1234),
},
};
let dto = ConversationDetailsDto::from(out);
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["lastTopic"], "refactor parser");
assert_eq!(v["tokenCount"], 1234);
// No snake_case leak.
assert!(v.get("last_topic").is_none());
assert!(v.get("token_count").is_none());
}
#[test]
fn conversation_details_dto_omits_fields_when_none() {
let out = InspectConversationOutput {
details: ConversationDetails {
last_topic: None,
token_count: None,
},
};
let dto = ConversationDetailsDto::from(out);
let v = serde_json::to_value(&dto).unwrap();
// Both optional fields are omitted from the wire (absent, not null).
assert!(v.get("lastTopic").is_none(), "absent topic ⇒ field omitted");
assert!(
v.get("tokenCount").is_none(),
"absent tokens ⇒ field omitted"
);
assert_eq!(v, json!({}), "fully degraded ⇒ empty object");
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -168,7 +304,12 @@ fn launch_agent_output_maps_to_terminal_session_dto() {
); );
session.status = SessionStatus::Running; session.status = SessionStatus::Running;
let out = LaunchAgentOutput { session }; let out = LaunchAgentOutput {
session,
assigned_conversation_id: None,
engine_session_id: None,
structured: None,
};
let dto = TerminalSessionDto::from(out); let dto = TerminalSessionDto::from(out);
assert_eq!(dto.session_id, session_id.to_string()); assert_eq!(dto.session_id, session_id.to_string());
@ -176,9 +317,53 @@ fn launch_agent_output_maps_to_terminal_session_dto() {
assert_eq!(dto.rows, 24); assert_eq!(dto.rows, 24);
assert_eq!(dto.cols, 80); assert_eq!(dto.cols, 80);
// Nothing assigned ⇒ no conversation id surfaced.
assert_eq!(dto.assigned_conversation_id, None);
// Also verify camelCase serialisation. // Also verify camelCase serialisation.
let v = serde_json::to_value(&dto).unwrap(); let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["sessionId"], session_id.to_string()); assert_eq!(v["sessionId"], session_id.to_string());
assert_eq!(v["cwd"], "/tmp/project"); assert_eq!(v["cwd"], "/tmp/project");
assert!(v.get("session_id").is_none(), "no snake_case leak"); assert!(v.get("session_id").is_none(), "no snake_case leak");
// Absent assignment must be omitted from the wire payload entirely.
assert!(
v.get("assignedConversationId").is_none(),
"no assignment ⇒ field omitted"
);
}
#[test]
fn launch_agent_output_propagates_assigned_conversation_id() {
let session_id = SessionId::from_uuid(Uuid::from_u128(70));
let node_id = NodeId::from_uuid(Uuid::from_u128(80));
let agent_id = AgentId::from_uuid(Uuid::from_u128(90));
let cwd = ProjectPath::new("/tmp/project".to_owned()).expect("valid path");
let size = PtySize::new(24, 80).unwrap();
let mut session = TerminalSession::starting(
session_id,
node_id,
cwd,
SessionKind::Agent { agent_id },
size,
);
session.status = SessionStatus::Running;
let out = LaunchAgentOutput {
session,
assigned_conversation_id: Some("conv-xyz".to_owned()),
engine_session_id: None,
structured: None,
};
let dto = TerminalSessionDto::from(out);
// The id minted by the launch is carried through to the DTO …
assert_eq!(dto.assigned_conversation_id.as_deref(), Some("conv-xyz"));
// … and serialised in camelCase for the front to persist on the leaf.
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["assignedConversationId"], "conv-xyz");
assert!(
v.get("assigned_conversation_id").is_none(),
"no snake_case leak"
);
} }

View File

@ -0,0 +1,155 @@
//! A2 tests for the `change_agent_profile` DTO contract (§15.1):
//! - Request DTO round-trips camelCase JSON `{ projectId, agentId, profileId, rows, cols }`.
//! - `From<ChangeAgentProfileOutput> for ChangeAgentProfileDto` maps the agent and
//! surfaces `relaunchedSession` only when a live session was swapped (omitted via
//! `skip_serializing_if` when `None`).
//! - `From<TerminalSession> for TerminalSessionDto` carries id/cwd/size.
use app_tauri_lib::dto::{ChangeAgentProfileDto, ChangeAgentProfileRequestDto};
use application::ChangeAgentProfileOutput;
use domain::ids::{AgentId, NodeId, ProfileId, SessionId};
use domain::terminal::{PtySize, SessionKind, SessionStatus, TerminalSession};
use domain::{Agent, AgentOrigin, ProjectPath};
use serde_json::json;
use uuid::Uuid;
/// Helper: build a minimal validated [`Agent`].
fn make_agent(agent_uuid: u128, profile_uuid: u128) -> Agent {
Agent::new(
AgentId::from_uuid(Uuid::from_u128(agent_uuid)),
"My Agent",
"agents/my-agent.md",
ProfileId::from_uuid(Uuid::from_u128(profile_uuid)),
AgentOrigin::Scratch,
false,
)
.expect("valid agent")
}
/// Helper: build a running [`TerminalSession`] for an agent cell.
fn make_session(session_uuid: u128, node_uuid: u128, agent_uuid: u128) -> TerminalSession {
let session_id = SessionId::from_uuid(Uuid::from_u128(session_uuid));
let node_id = NodeId::from_uuid(Uuid::from_u128(node_uuid));
let agent_id = AgentId::from_uuid(Uuid::from_u128(agent_uuid));
let cwd = ProjectPath::new("/tmp/project".to_owned()).expect("valid path");
let size = PtySize::new(30, 100).unwrap();
let mut session = TerminalSession::starting(
session_id,
node_id,
cwd,
SessionKind::Agent { agent_id },
size,
);
session.status = SessionStatus::Running;
session
}
// ---------------------------------------------------------------------------
// Request DTO deserialisation (camelCase round-trip)
// ---------------------------------------------------------------------------
#[test]
fn change_agent_profile_request_deserialises_camelcase() {
let project_id = Uuid::from_u128(1).to_string();
let agent_id = Uuid::from_u128(2).to_string();
let profile_id = Uuid::from_u128(3).to_string();
let raw = json!({
"projectId": project_id,
"agentId": agent_id,
"profileId": profile_id,
"rows": 24,
"cols": 80
});
let dto: ChangeAgentProfileRequestDto = serde_json::from_value(raw).unwrap();
assert_eq!(dto.project_id, project_id);
assert_eq!(dto.agent_id, agent_id);
assert_eq!(dto.profile_id, profile_id);
assert_eq!(dto.rows, 24);
assert_eq!(dto.cols, 80);
}
#[test]
fn change_agent_profile_request_rejects_snake_case_keys() {
// The wire contract is camelCase; snake_case keys must NOT satisfy the struct.
let raw = json!({
"project_id": Uuid::from_u128(1).to_string(),
"agent_id": Uuid::from_u128(2).to_string(),
"profile_id": Uuid::from_u128(3).to_string(),
"rows": 24,
"cols": 80
});
let res: Result<ChangeAgentProfileRequestDto, _> = serde_json::from_value(raw);
assert!(res.is_err(), "snake_case keys must not deserialise");
}
// ---------------------------------------------------------------------------
// From<ChangeAgentProfileOutput> for ChangeAgentProfileDto
// ---------------------------------------------------------------------------
#[test]
fn output_maps_agent_and_omits_session_when_no_relaunch() {
let agent = make_agent(5, 6);
let out = ChangeAgentProfileOutput {
agent: agent.clone(),
relaunched: None,
};
let dto = ChangeAgentProfileDto::from(out);
assert_eq!(dto.agent.0.id, agent.id);
assert!(dto.relaunched_session.is_none());
let v = serde_json::to_value(&dto).unwrap();
// The agent is embedded with its camelCase shape.
assert_eq!(v["agent"]["id"], agent.id.to_string());
assert_eq!(v["agent"]["profileId"], agent.profile_id.to_string());
// No relaunch ⇒ field must be OMITTED from the wire (absent, not null).
assert!(
v.get("relaunchedSession").is_none(),
"no relaunch ⇒ relaunchedSession omitted, got: {v}"
);
// No snake_case leak.
assert!(v.get("relaunched_session").is_none());
}
#[test]
fn output_maps_relaunched_session_camelcase_when_present() {
let agent = make_agent(7, 8);
let session = make_session(11, 12, 7);
let out = ChangeAgentProfileOutput {
agent: agent.clone(),
relaunched: Some(session.clone()),
};
let dto = ChangeAgentProfileDto::from(out);
assert!(dto.relaunched_session.is_some());
let v = serde_json::to_value(&dto).unwrap();
let rs = v
.get("relaunchedSession")
.expect("relaunch present ⇒ relaunchedSession serialised");
assert_eq!(rs["sessionId"], session.id.to_string());
assert_eq!(rs["cwd"], "/tmp/project");
assert_eq!(rs["rows"], 30);
assert_eq!(rs["cols"], 100);
// No snake_case leak on the nested DTO.
assert!(rs.get("session_id").is_none(), "no snake_case leak");
assert!(v.get("relaunched_session").is_none());
}
// ---------------------------------------------------------------------------
// From<TerminalSession> for TerminalSessionDto
// ---------------------------------------------------------------------------
#[test]
fn terminal_session_maps_to_dto() {
let session = make_session(21, 22, 23);
// Exercise the From<TerminalSession> impl directly through the output mapping.
let out = ChangeAgentProfileOutput {
agent: make_agent(23, 24),
relaunched: Some(session.clone()),
};
let dto = ChangeAgentProfileDto::from(out);
let rs = dto.relaunched_session.expect("session present");
assert_eq!(rs.session_id, session.id.to_string());
assert_eq!(rs.cwd, "/tmp/project");
assert_eq!(rs.rows, 30);
assert_eq!(rs.cols, 100);
}

View File

@ -0,0 +1,220 @@
//! L1 tests for the D4 structured-chat DTO contract (ARCHITECTURE §17.7):
//! - `ReplyChunk` tagged camelCase round-trip (`kind` + payload),
//! - `ReattachChatDto` camelCase wire shape (typed scrollback),
//! - the **derived** `cellKind` on `TerminalSessionDto` (`chat` ⇔
//! `structured: Some(..)`, `pty` otherwise),
//! - non-regression: every `TerminalSessionDto` construction path now serialises a
//! `cellKind` and PTY paths keep `"pty"`.
use app_tauri_lib::dto::{CellKind, ReattachChatDto, ReplyChunk, TerminalSessionDto};
use application::{LaunchAgentOutput, StructuredSessionDescriptor};
use domain::project::ProjectPath;
use domain::SessionId;
use domain::{AgentId, NodeId, PtySize, SessionKind, SessionStatus, TerminalSession};
use serde_json::json;
use uuid::Uuid;
// ---------------------------------------------------------------------------
// ReplyChunk — tagged camelCase, exact wire shape + round-trip (zone 5/6)
// ---------------------------------------------------------------------------
#[test]
fn reply_chunk_text_delta_serialises_exact_camel_case() {
let v = serde_json::to_value(ReplyChunk::TextDelta {
text: "hello".into(),
})
.unwrap();
assert_eq!(v, json!({ "kind": "textDelta", "text": "hello" }));
}
#[test]
fn reply_chunk_tool_activity_serialises_exact_camel_case() {
let v = serde_json::to_value(ReplyChunk::ToolActivity {
label: "reads file".into(),
})
.unwrap();
assert_eq!(v, json!({ "kind": "toolActivity", "label": "reads file" }));
}
#[test]
fn reply_chunk_final_serialises_exact_camel_case() {
let v = serde_json::to_value(ReplyChunk::Final {
content: "done".into(),
})
.unwrap();
assert_eq!(v, json!({ "kind": "final", "content": "done" }));
}
#[test]
fn reply_chunk_round_trips_through_json_for_every_variant() {
for chunk in [
ReplyChunk::TextDelta { text: "x".into() },
ReplyChunk::ToolActivity {
label: "runs".into(),
},
ReplyChunk::Final {
content: "y".into(),
},
] {
let v = serde_json::to_value(&chunk).unwrap();
let back: ReplyChunk = serde_json::from_value(v).unwrap();
assert_eq!(back, chunk, "round-trip preserves the variant + payload");
}
}
#[test]
fn reply_chunk_deserialises_from_camel_case_wire_payload() {
// The shape the frontend (or a mock gateway) emits.
let back: ReplyChunk =
serde_json::from_value(json!({ "kind": "textDelta", "text": "hi" })).unwrap();
assert_eq!(back, ReplyChunk::TextDelta { text: "hi".into() });
}
#[test]
fn reply_chunk_rejects_snake_case_tag() {
// Guard: a snake_case `text_delta` is NOT a valid wire tag (contract is camelCase).
let r: Result<ReplyChunk, _> =
serde_json::from_value(json!({ "kind": "text_delta", "text": "x" }));
assert!(r.is_err(), "snake_case kind must not deserialise");
}
// ---------------------------------------------------------------------------
// ReattachChatDto — typed scrollback, camelCase (zone 5)
// ---------------------------------------------------------------------------
#[test]
fn reattach_chat_dto_serialises_camel_case_with_typed_scrollback() {
let dto = ReattachChatDto {
session_id: "sess-1".into(),
scrollback: vec![
ReplyChunk::TextDelta { text: "Hi".into() },
ReplyChunk::Final {
content: "Hi".into(),
},
],
};
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(
v,
json!({
"sessionId": "sess-1",
"scrollback": [
{ "kind": "textDelta", "text": "Hi" },
{ "kind": "final", "content": "Hi" },
],
})
);
assert!(v.get("session_id").is_none(), "no snake_case leak");
}
#[test]
fn reattach_chat_dto_empty_scrollback_is_empty_array() {
let dto = ReattachChatDto {
session_id: "s".into(),
scrollback: vec![],
};
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["scrollback"], json!([]));
}
// ---------------------------------------------------------------------------
// CellKind enum wire shape
// ---------------------------------------------------------------------------
#[test]
fn cell_kind_serialises_lowercase_pty_and_chat() {
assert_eq!(serde_json::to_value(CellKind::Pty).unwrap(), json!("pty"));
assert_eq!(serde_json::to_value(CellKind::Chat).unwrap(), json!("chat"));
}
// ---------------------------------------------------------------------------
// cellKind derivation on TerminalSessionDto (zone 5)
// ---------------------------------------------------------------------------
fn agent_session(session_id: u128) -> (SessionId, TerminalSession) {
let sid = SessionId::from_uuid(Uuid::from_u128(session_id));
let node_id = NodeId::from_uuid(Uuid::from_u128(8));
let agent_id = AgentId::from_uuid(Uuid::from_u128(9));
let cwd = ProjectPath::new("/tmp/project".to_owned()).expect("valid path");
let size = PtySize::new(24, 80).unwrap();
let mut session =
TerminalSession::starting(sid, node_id, cwd, SessionKind::Agent { agent_id }, size);
session.status = SessionStatus::Running;
(sid, session)
}
#[test]
fn launch_output_with_structured_descriptor_derives_chat_cell_kind() {
let (sid, session) = agent_session(7);
let descriptor = StructuredSessionDescriptor {
session_id: sid,
agent_id: AgentId::from_uuid(Uuid::from_u128(9)),
node_id: NodeId::from_uuid(Uuid::from_u128(8)),
conversation_id: None,
};
let out = LaunchAgentOutput {
session,
assigned_conversation_id: None,
engine_session_id: None,
structured: Some(descriptor),
};
let dto = TerminalSessionDto::from(out);
assert_eq!(dto.cell_kind, CellKind::Chat);
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["cellKind"], "chat", "structured ⇒ chat on the wire");
}
#[test]
fn launch_output_without_structured_descriptor_derives_pty_cell_kind() {
let (_sid, session) = agent_session(7);
let out = LaunchAgentOutput {
session,
assigned_conversation_id: None,
engine_session_id: None,
structured: None,
};
let dto = TerminalSessionDto::from(out);
assert_eq!(dto.cell_kind, CellKind::Pty, "no descriptor ⇒ pty");
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["cellKind"], "pty");
}
// ---------------------------------------------------------------------------
// Non-regression: cellKind is always present & "pty" on the historical paths
// ---------------------------------------------------------------------------
#[test]
fn terminal_session_dto_from_domain_session_is_always_pty() {
// From<TerminalSession> (e.g. open_terminal / change_agent_profile relaunch).
let (_sid, session) = agent_session(11);
let dto = TerminalSessionDto::from(session);
assert_eq!(dto.cell_kind, CellKind::Pty);
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(
v["cellKind"], "pty",
"the new field is always present on the PTY path (non-breaking shape)"
);
}
#[test]
fn pty_launch_output_serialises_cellkind_pty_without_breaking_existing_keys() {
// Guard the exact historical key set + the new derived field for a PTY launch.
let (sid, session) = agent_session(12);
let out = LaunchAgentOutput {
session,
assigned_conversation_id: None,
engine_session_id: None,
structured: None,
};
let v = serde_json::to_value(TerminalSessionDto::from(out)).unwrap();
assert_eq!(v["sessionId"], sid.to_string());
assert_eq!(v["cwd"], "/tmp/project");
assert_eq!(v["rows"], 24);
assert_eq!(v["cols"], 80);
assert_eq!(v["cellKind"], "pty");
// assignedConversationId omitted when None (skip_serializing_if) — unchanged.
assert!(v.get("assignedConversationId").is_none());
assert!(v.get("session_id").is_none(), "no snake_case leak");
}

View File

@ -6,7 +6,9 @@ use app_tauri_lib::dto::{
GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto,
GitFileStatusDto, GitStageRequestDto, GitStatusListDto, GraphCommitDto, GraphCommitListDto, GitFileStatusDto, GitStageRequestDto, GitStatusListDto, GraphCommitDto, GraphCommitListDto,
}; };
use application::{GitBranchesOutput, GitCommitOutput, GitGraphOutput, GitLogOutput, GitStatusOutput}; use application::{
GitBranchesOutput, GitCommitOutput, GitGraphOutput, GitLogOutput, GitStatusOutput,
};
use domain::ports::{GitCommitInfo, GitFileStatus, GraphCommit}; use domain::ports::{GitCommitInfo, GitFileStatus, GraphCommit};
use serde_json::json; use serde_json::json;
use uuid::Uuid; use uuid::Uuid;

View File

@ -5,8 +5,8 @@
use app_tauri_lib::dto::{ use app_tauri_lib::dto::{
parse_layout_id, CreateLayoutRequestDto, CreateLayoutResultDto, DeleteLayoutRequestDto, parse_layout_id, CreateLayoutRequestDto, CreateLayoutResultDto, DeleteLayoutRequestDto,
DeleteLayoutResultDto, LayoutInfoDto, LayoutOperationDto, ListLayoutsDto, RenameLayoutRequestDto, DeleteLayoutResultDto, LayoutInfoDto, LayoutOperationDto, ListLayoutsDto,
SetActiveLayoutRequestDto, RenameLayoutRequestDto, SetActiveLayoutRequestDto,
}; };
use application::{ use application::{
CreateLayoutOutput, DeleteLayoutOutput, LayoutInfo, LayoutKind, ListLayoutsOutput, CreateLayoutOutput, DeleteLayoutOutput, LayoutInfo, LayoutKind, ListLayoutsOutput,
@ -63,8 +63,16 @@ fn layout_info_dto_git_graph_kind() {
fn list_layouts_dto_from_output() { fn list_layouts_dto_from_output() {
let out = ListLayoutsOutput { let out = ListLayoutsOutput {
layouts: vec![ layouts: vec![
LayoutInfo { id: lid(1), name: "Default".to_owned(), kind: LayoutKind::Terminal }, LayoutInfo {
LayoutInfo { id: lid(2), name: "Backend".to_owned(), kind: LayoutKind::GitGraph }, id: lid(1),
name: "Default".to_owned(),
kind: LayoutKind::Terminal,
},
LayoutInfo {
id: lid(2),
name: "Backend".to_owned(),
kind: LayoutKind::GitGraph,
},
], ],
active_id: lid(1), active_id: lid(1),
}; };
@ -204,7 +212,10 @@ fn set_cell_agent_op_deserialises_with_agent() {
let dto: LayoutOperationDto = serde_json::from_value(raw).unwrap(); let dto: LayoutOperationDto = serde_json::from_value(raw).unwrap();
let op = dto.into_operation().unwrap(); let op = dto.into_operation().unwrap();
match op { match op {
application::LayoutOperation::SetCellAgent { target: t, agent: a } => { application::LayoutOperation::SetCellAgent {
target: t,
agent: a,
} => {
assert_eq!(t, target); assert_eq!(t, target);
assert_eq!(a, Some(agent)); assert_eq!(a, Some(agent));
} }
@ -223,7 +234,10 @@ fn set_cell_agent_op_deserialises_with_null_agent() {
let dto: LayoutOperationDto = serde_json::from_value(raw).unwrap(); let dto: LayoutOperationDto = serde_json::from_value(raw).unwrap();
let op = dto.into_operation().unwrap(); let op = dto.into_operation().unwrap();
match op { match op {
application::LayoutOperation::SetCellAgent { target: t, agent: None } => { application::LayoutOperation::SetCellAgent {
target: t,
agent: None,
} => {
assert_eq!(t, target); assert_eq!(t, target);
} }
_ => panic!("expected SetCellAgent with None agent"), _ => panic!("expected SetCellAgent with None agent"),
@ -245,3 +259,66 @@ fn set_cell_agent_op_deserialises_with_absent_agent_defaults_to_none() {
_ => panic!("expected SetCellAgent with None agent"), _ => panic!("expected SetCellAgent with None agent"),
} }
} }
// ---------------------------------------------------------------------------
// setCellConversation operation deserialisation (T4b)
// ---------------------------------------------------------------------------
#[test]
fn set_cell_conversation_op_deserialises_with_id() {
let target = nid(1);
let raw = json!({
"type": "setCellConversation",
"target": target.to_string(),
"conversationId": "conv-42"
});
let dto: LayoutOperationDto = serde_json::from_value(raw).unwrap();
let op = dto.into_operation().unwrap();
match op {
application::LayoutOperation::SetCellConversation {
target: t,
conversation_id: Some(id),
} => {
assert_eq!(t, target);
assert_eq!(id, "conv-42");
}
_ => panic!("expected SetCellConversation with id"),
}
}
#[test]
fn set_cell_conversation_op_deserialises_with_null_id() {
let target = nid(2);
let raw = json!({
"type": "setCellConversation",
"target": target.to_string(),
"conversationId": null
});
let dto: LayoutOperationDto = serde_json::from_value(raw).unwrap();
let op = dto.into_operation().unwrap();
match op {
application::LayoutOperation::SetCellConversation {
target: t,
conversation_id: None,
} => assert_eq!(t, target),
_ => panic!("expected SetCellConversation with None id"),
}
}
#[test]
fn set_cell_conversation_op_deserialises_with_absent_id_defaults_to_none() {
let target = nid(3);
let raw = json!({
"type": "setCellConversation",
"target": target.to_string()
});
let dto: LayoutOperationDto = serde_json::from_value(raw).unwrap();
let op = dto.into_operation().unwrap();
match op {
application::LayoutOperation::SetCellConversation {
conversation_id: None,
..
} => {}
_ => panic!("expected SetCellConversation with None id"),
}
}

View File

@ -24,6 +24,7 @@ fn profile(id: u128, name: &str, command: &str) -> AgentProfile {
ContextInjection::convention_file("CLAUDE.md").unwrap(), ContextInjection::convention_file("CLAUDE.md").unwrap(),
Some(format!("{command} --version")), Some(format!("{command} --version")),
"{projectRoot}", "{projectRoot}",
None,
) )
.unwrap() .unwrap()
} }

View File

@ -0,0 +1,192 @@
//! B2 tests for the `list_resumable_agents` DTO contract (ARCHITECTURE §15.2):
//! - `From<ResumableAgent> for ResumableAgentDto` maps every field, surfaces
//! `conversationId` only when present (omitted via `skip_serializing_if` when
//! `None`), and serialises ids as strings + camelCase keys.
//! - `From<ListResumableAgentsOutput> for ResumableAgentListDto` preserves the
//! `resumable` list and its order.
//! - Full + minimal `ResumableAgentDto` serde round-trips on the wire shape.
use app_tauri_lib::dto::{ResumableAgentDto, ResumableAgentListDto};
use application::{ListResumableAgentsOutput, ResumableAgent};
use domain::ids::{AgentId, NodeId};
use uuid::Uuid;
/// Helper: a resumable agent with a conversation id (resume-capable).
fn full(agent_uuid: u128, node_uuid: u128) -> ResumableAgent {
ResumableAgent {
agent_id: AgentId::from_uuid(Uuid::from_u128(agent_uuid)),
name: "Architect".to_owned(),
node_id: NodeId::from_uuid(Uuid::from_u128(node_uuid)),
conversation_id: Some("conv-123".to_owned()),
was_running: true,
resume_supported: true,
}
}
/// Helper: a minimal resumable agent without a conversation id.
fn minimal(agent_uuid: u128, node_uuid: u128) -> ResumableAgent {
ResumableAgent {
agent_id: AgentId::from_uuid(Uuid::from_u128(agent_uuid)),
name: "Scratch".to_owned(),
node_id: NodeId::from_uuid(Uuid::from_u128(node_uuid)),
conversation_id: None,
was_running: false,
resume_supported: false,
}
}
// ---------------------------------------------------------------------------
// From<ResumableAgent> for ResumableAgentDto — field mapping
// ---------------------------------------------------------------------------
#[test]
fn from_resumable_agent_maps_every_field() {
let r = full(1, 2);
let dto = ResumableAgentDto::from(r.clone());
assert_eq!(dto.agent_id, r.agent_id.to_string());
assert_eq!(dto.name, "Architect");
assert_eq!(dto.node_id, r.node_id.to_string());
assert_eq!(dto.conversation_id.as_deref(), Some("conv-123"));
assert!(dto.was_running);
assert!(dto.resume_supported);
}
#[test]
fn from_resumable_agent_ids_are_strings() {
let r = full(7, 8);
let dto = ResumableAgentDto::from(r.clone());
// The ids cross the wire as UUID strings (not raw bytes / numbers).
assert_eq!(dto.agent_id, Uuid::from_u128(7).to_string());
assert_eq!(dto.node_id, Uuid::from_u128(8).to_string());
}
// ---------------------------------------------------------------------------
// camelCase wire shape + conversationId omission
// ---------------------------------------------------------------------------
#[test]
fn full_dto_serialises_camelcase_with_conversation() {
let dto = ResumableAgentDto::from(full(1, 2));
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["agentId"], Uuid::from_u128(1).to_string());
assert_eq!(v["name"], "Architect");
assert_eq!(v["nodeId"], Uuid::from_u128(2).to_string());
assert_eq!(v["conversationId"], "conv-123");
assert_eq!(v["wasRunning"], true);
assert_eq!(v["resumeSupported"], true);
// No snake_case leak.
assert!(v.get("agent_id").is_none(), "no snake_case leak: {v}");
assert!(v.get("node_id").is_none());
assert!(v.get("conversation_id").is_none());
assert!(v.get("was_running").is_none());
assert!(v.get("resume_supported").is_none());
}
#[test]
fn minimal_dto_omits_conversation_id_when_none() {
let dto = ResumableAgentDto::from(minimal(3, 4));
let v = serde_json::to_value(&dto).unwrap();
// conversationId must be OMITTED (absent, not null) when None.
assert!(
v.get("conversationId").is_none(),
"None conversation ⇒ conversationId omitted, got: {v}"
);
// Required flags are still present.
assert_eq!(v["wasRunning"], false);
assert_eq!(v["resumeSupported"], false);
assert_eq!(v["name"], "Scratch");
}
// ---------------------------------------------------------------------------
// Round-trip serde (Serialize → Deserialize) on the camelCase shape
// ---------------------------------------------------------------------------
/// A `Deserialize` twin matching the wire shape, used to prove the serialised
/// JSON is exactly the camelCase contract the frontend `ResumableAgent` mirror
/// consumes (`ResumableAgentDto` is serialise-only on the prod side).
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct WireResumableAgent {
agent_id: String,
name: String,
node_id: String,
#[serde(default)]
conversation_id: Option<String>,
was_running: bool,
resume_supported: bool,
}
#[test]
fn full_dto_round_trips_camelcase() {
let dto = ResumableAgentDto::from(full(1, 2));
let json = serde_json::to_string(&dto).unwrap();
let back: WireResumableAgent = serde_json::from_str(&json).unwrap();
assert_eq!(back.agent_id, Uuid::from_u128(1).to_string());
assert_eq!(back.name, "Architect");
assert_eq!(back.node_id, Uuid::from_u128(2).to_string());
assert_eq!(back.conversation_id.as_deref(), Some("conv-123"));
assert!(back.was_running);
assert!(back.resume_supported);
}
#[test]
fn minimal_dto_round_trips_camelcase_without_conversation() {
let dto = ResumableAgentDto::from(minimal(3, 4));
let json = serde_json::to_string(&dto).unwrap();
// The key is absent from the JSON entirely.
assert!(
!json.contains("conversationId"),
"minimal ⇒ no conversationId key, got: {json}"
);
let back: WireResumableAgent = serde_json::from_str(&json).unwrap();
assert_eq!(back.conversation_id, None);
assert!(!back.was_running);
assert!(!back.resume_supported);
}
// ---------------------------------------------------------------------------
// From<ListResumableAgentsOutput> for ResumableAgentListDto — list + order
// ---------------------------------------------------------------------------
#[test]
fn from_output_preserves_list_and_order() {
let out = ListResumableAgentsOutput {
resumable: vec![full(10, 11), minimal(20, 21), full(30, 31)],
};
let dto = ResumableAgentListDto::from(out);
assert_eq!(dto.resumable.len(), 3);
// Order preserved.
assert_eq!(dto.resumable[0].agent_id, Uuid::from_u128(10).to_string());
assert_eq!(dto.resumable[1].agent_id, Uuid::from_u128(20).to_string());
assert_eq!(dto.resumable[2].agent_id, Uuid::from_u128(30).to_string());
// Middle entry is the minimal one (no conversation).
assert!(dto.resumable[1].conversation_id.is_none());
}
#[test]
fn empty_output_serialises_empty_resumable_array() {
let dto = ResumableAgentListDto::from(ListResumableAgentsOutput::default());
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["resumable"], serde_json::json!([]));
}
#[test]
fn list_dto_serialises_camelcase_wrapper() {
let out = ListResumableAgentsOutput {
resumable: vec![full(10, 11)],
};
let dto = ResumableAgentListDto::from(out);
let v = serde_json::to_value(&dto).unwrap();
// Wrapper key is `resumable`; nested entries are camelCase.
let arr = v["resumable"].as_array().expect("resumable is an array");
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["agentId"], Uuid::from_u128(10).to_string());
assert_eq!(arr[0]["nodeId"], Uuid::from_u128(11).to_string());
}

View File

@ -53,8 +53,14 @@ fn template_dto_serialises_camelcase() {
ProfileId::from_uuid(Uuid::from_u128(2)).to_string() ProfileId::from_uuid(Uuid::from_u128(2)).to_string()
); );
// no snake_case leak // no snake_case leak
assert!(v.get("content_md").is_none(), "no snake_case leak for contentMd"); assert!(
assert!(v.get("default_profile_id").is_none(), "no snake_case leak for defaultProfileId"); v.get("content_md").is_none(),
"no snake_case leak for contentMd"
);
assert!(
v.get("default_profile_id").is_none(),
"no snake_case leak for defaultProfileId"
);
} }
#[test] #[test]
@ -98,7 +104,9 @@ fn template_list_dto_empty() {
#[test] #[test]
fn create_template_output_maps_to_template_dto() { fn create_template_output_maps_to_template_dto() {
let tmpl = make_template(5, 6); let tmpl = make_template(5, 6);
let out = CreateTemplateOutput { template: tmpl.clone() }; let out = CreateTemplateOutput {
template: tmpl.clone(),
};
let dto = TemplateDto::from(out); let dto = TemplateDto::from(out);
assert_eq!(dto.0.id, tmpl.id); assert_eq!(dto.0.id, tmpl.id);
} }
@ -107,7 +115,9 @@ fn create_template_output_maps_to_template_dto() {
fn update_template_output_maps_to_template_dto() { fn update_template_output_maps_to_template_dto() {
let tmpl = make_template(7, 8); let tmpl = make_template(7, 8);
let bumped = tmpl.with_updated_content(MarkdownDoc::new("# Updated".to_owned())); let bumped = tmpl.with_updated_content(MarkdownDoc::new("# Updated".to_owned()));
let out = UpdateTemplateOutput { template: bumped.clone() }; let out = UpdateTemplateOutput {
template: bumped.clone(),
};
let dto = TemplateDto::from(out); let dto = TemplateDto::from(out);
assert_eq!(dto.0.version, TemplateVersion(2)); assert_eq!(dto.0.version, TemplateVersion(2));
assert_eq!(dto.0.id, bumped.id); assert_eq!(dto.0.id, bumped.id);
@ -132,7 +142,10 @@ fn agent_drift_dto_serialises_camelcase() {
assert_eq!(v["from"], 1u64); assert_eq!(v["from"], 1u64);
assert_eq!(v["to"], 3u64); assert_eq!(v["to"], 3u64);
// no snake_case leak // no snake_case leak
assert!(v.get("agent_id").is_none(), "no snake_case leak for agentId"); assert!(
v.get("agent_id").is_none(),
"no snake_case leak for agentId"
);
} }
#[test] #[test]

View File

@ -15,7 +15,10 @@ fn move_tab_result_serializes_new_window_id_camel_case() {
let dto = MoveTabResultDto::from(out); let dto = MoveTabResultDto::from(out);
let json = serde_json::to_string(&dto).unwrap(); let json = serde_json::to_string(&dto).unwrap();
assert!(json.contains("\"newWindowId\""), "json was {json}"); assert!(json.contains("\"newWindowId\""), "json was {json}");
assert!(!json.contains("new_window_id"), "no snake_case leak: {json}"); assert!(
!json.contains("new_window_id"),
"no snake_case leak: {json}"
);
} }
#[test] #[test]

View File

@ -0,0 +1,182 @@
//! LOT R0b (cadrage orchestration v5 §3.3, Trou B) — `list_live_agents` lit
//! l'agrégateur `LiveSessions` (PTY **+** structuré), plus seulement le registre
//! PTY. Un agent chat (structuré) vivant doit apparaître dans la liste, comme un
//! agent PTY ; les deux ensemble sans doublon ; aucune session ⇒ liste vide.
//!
//! Ces tests reproduisent **exactement** ce que fait la commande
//! `list_live_agents` : construire un `LiveSessions` à partir des deux registres
//! partagés et passer `live_agents()` à `LiveAgentListDto::from_pairs`. Ils
//! exercent donc le câblage de l'agrégateur **et** la dé-duplication par agent
//! portée par le DTO (contrat de liveness pour l'UI). 100 % fakes, sans process.
use std::sync::Arc;
use async_trait::async_trait;
use app_tauri_lib::dto::LiveAgentListDto;
use application::{LiveSessions, StructuredSessions, TerminalSessions};
use domain::ports::{AgentSession, AgentSessionError, PtyHandle, ReplyStream};
use domain::{AgentId, NodeId, ProjectPath, PtySize, SessionId, SessionKind, TerminalSession};
use uuid::Uuid;
// --- petits constructeurs déterministes ------------------------------------
fn sid(n: u128) -> SessionId {
SessionId::from_uuid(Uuid::from_u128(n))
}
fn aid(n: u128) -> AgentId {
AgentId::from_uuid(Uuid::from_u128(n))
}
fn nid(n: u128) -> NodeId {
NodeId::from_uuid(Uuid::from_u128(n))
}
/// Fake minimal d'`AgentSession` : porte juste l'id (ce que le registre clé).
struct FakeSession {
id: SessionId,
}
#[async_trait]
impl AgentSession for FakeSession {
fn id(&self) -> SessionId {
self.id
}
fn conversation_id(&self) -> Option<String> {
None
}
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
Ok(Box::new(std::iter::empty()))
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
Ok(())
}
}
fn fake(id: SessionId) -> Arc<dyn AgentSession> {
Arc::new(FakeSession { id })
}
/// Insère un agent PTY dans `TerminalSessions` (jumeau du helper de D1).
fn insert_pty(pty: &TerminalSessions, s: SessionId, a: AgentId, n: NodeId) {
let session = TerminalSession::starting(
s,
n,
ProjectPath::new("/p").unwrap(),
SessionKind::Agent { agent_id: a },
PtySize::new(24, 80).unwrap(),
);
pty.insert(PtyHandle { session_id: s }, session);
}
/// Reproduit le corps de la commande `list_live_agents` (hors validation d'id).
fn dto_for(pty: &Arc<TerminalSessions>, structured: &Arc<StructuredSessions>) -> LiveAgentListDto {
let live = LiveSessions::new(Arc::clone(pty), Arc::clone(structured));
LiveAgentListDto::from_pairs(live.live_agents())
}
// ===========================================================================
// 1. Un agent PTY vivant apparaît.
// ===========================================================================
#[test]
fn pty_live_agent_is_listed() {
let pty = Arc::new(TerminalSessions::new());
let structured = Arc::new(StructuredSessions::new());
let a = aid(10);
insert_pty(&pty, sid(1), a, nid(100));
let dto = dto_for(&pty, &structured);
assert_eq!(dto.0.len(), 1);
assert_eq!(dto.0[0].agent_id, a.to_string());
assert_eq!(dto.0[0].node_id, nid(100).to_string());
assert_eq!(dto.0[0].session_id, sid(1).to_string());
}
// ===========================================================================
// 2. Un agent structuré/chat vivant apparaît (le cas qui régressait — Trou B).
// ===========================================================================
#[test]
fn structured_live_agent_is_listed() {
let pty = Arc::new(TerminalSessions::new());
let structured = Arc::new(StructuredSessions::new());
let a = aid(20);
structured.insert(fake(sid(2)), a, nid(200));
let dto = dto_for(&pty, &structured);
assert_eq!(
dto.0.len(),
1,
"un agent chat vivant doit apparaître (il était invisible avant R0b)"
);
assert_eq!(dto.0[0].agent_id, a.to_string());
assert_eq!(dto.0[0].node_id, nid(200).to_string());
assert_eq!(dto.0[0].session_id, sid(2).to_string());
}
// ===========================================================================
// 3. Les deux types vivants en même temps ⇒ présents, sans doublon.
// ===========================================================================
#[test]
fn both_kinds_live_listed_without_duplicates() {
let pty = Arc::new(TerminalSessions::new());
let structured = Arc::new(StructuredSessions::new());
let pty_agent = aid(10);
let chat_agent = aid(20);
insert_pty(&pty, sid(1), pty_agent, nid(100));
structured.insert(fake(sid(2)), chat_agent, nid(200));
let dto = dto_for(&pty, &structured);
assert_eq!(dto.0.len(), 2, "les deux registres contribuent");
let mut ids: Vec<&str> = dto.0.iter().map(|d| d.agent_id.as_str()).collect();
ids.sort_unstable();
let mut expected = vec![pty_agent.to_string(), chat_agent.to_string()];
expected.sort_unstable();
assert_eq!(ids, expected);
// Aucun agent répété (contrat de liveness pour l'UI).
let mut uniq = ids.clone();
uniq.dedup();
assert_eq!(uniq.len(), dto.0.len(), "aucun doublon d'agent");
}
// ===========================================================================
// 3bis. Même agent présent dans les DEUX registres ⇒ une seule entrée.
//
// L'invariant « 1 session vivante/agent » l'interdit normalement, mais le DTO
// doit rester dup-free par défense en profondeur : un seul `agent_id` sur le fil.
// ===========================================================================
#[test]
fn same_agent_in_both_registries_is_deduplicated() {
let pty = Arc::new(TerminalSessions::new());
let structured = Arc::new(StructuredSessions::new());
let a = aid(30);
insert_pty(&pty, sid(1), a, nid(100));
structured.insert(fake(sid(2)), a, nid(200));
let dto = dto_for(&pty, &structured);
assert_eq!(
dto.0.len(),
1,
"un même agent ne doit apparaître qu'une fois"
);
assert_eq!(dto.0[0].agent_id, a.to_string());
// On garde la première occurrence (PTY, listé en premier par l'agrégateur).
assert_eq!(dto.0[0].node_id, nid(100).to_string());
assert_eq!(dto.0[0].session_id, sid(1).to_string());
}
// ===========================================================================
// 4. Aucune session ⇒ liste vide.
// ===========================================================================
#[test]
fn no_sessions_yields_empty_list() {
let pty = Arc::new(TerminalSessions::new());
let structured = Arc::new(StructuredSessions::new());
let dto = dto_for(&pty, &structured);
assert!(dto.0.is_empty(), "aucune session ⇒ liste vide");
}

View File

@ -0,0 +1,332 @@
//! 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 std::time::Duration;
use app_tauri_lib::mcp_endpoint::mcp_endpoint;
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)
}
fn mcp_count(state: &AppState) -> usize {
state.mcp_servers.lock().unwrap().len()
}
fn has_mcp(state: &AppState, id: &ProjectId) -> bool {
state.mcp_servers.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);
}
// --- M3: IdeA MCP server lifecycle (twin of the watcher, Décision 4) ---
//
// The MCP server registry (`mcp_servers`) is the twin of `orchestrator_watchers`:
// `ensure_orchestrator_watch` starts both side by side on open/create, and
// `stop_orchestrator_watch` tears both down on close. These tests mirror the
// watcher lifecycle tests above against the MCP registry. The per-project
// supervision task parks on a stop signal (no blocking serve loop), so open/close
// must return promptly — the `#[tokio::test]` harness itself proves no figing
// (the test completes).
#[tokio::test]
async fn ensure_watch_starts_an_mcp_server_per_project() {
let state = AppState::build(temp_path("appdata"));
let project = make_project();
assert_eq!(mcp_count(&state), 0, "no MCP server before open");
state.ensure_orchestrator_watch(&project);
assert!(
has_mcp(&state, &project.id),
"MCP server registered alongside the watcher"
);
assert_eq!(mcp_count(&state), 1);
}
#[tokio::test]
async fn ensure_mcp_server_is_idempotent_per_project() {
let state = AppState::build(temp_path("appdata"));
let project = make_project();
state.ensure_orchestrator_watch(&project);
assert_eq!(mcp_count(&state), 1);
// Opening the same project again must not spawn a second MCP server.
state.ensure_orchestrator_watch(&project);
assert_eq!(
mcp_count(&state),
1,
"MCP server start is idempotent per project"
);
assert!(has_mcp(&state, &project.id));
}
#[tokio::test]
async fn stop_watch_unregisters_the_mcp_server() {
let state = AppState::build(temp_path("appdata"));
let project = make_project();
state.ensure_orchestrator_watch(&project);
assert!(has_mcp(&state, &project.id));
state.stop_orchestrator_watch(&project.id);
assert!(!has_mcp(&state, &project.id), "MCP server removed on close");
assert_eq!(mcp_count(&state), 0);
// Stopping an unknown project is a no-op (does not panic) for the MCP twin too.
state.stop_orchestrator_watch(&project.id);
}
#[tokio::test]
async fn watcher_and_mcp_server_coexist_and_close_together() {
let state = AppState::build(temp_path("appdata"));
let project = make_project();
// Open: both entry doors onto the same OrchestratorService are live.
state.ensure_orchestrator_watch(&project);
assert!(has_watcher(&state, &project.id), "watcher live on open");
assert!(has_mcp(&state, &project.id), "MCP server live on open");
assert_eq!(watcher_count(&state), 1);
assert_eq!(mcp_count(&state), 1);
// Close: the symmetric teardown removes both.
state.stop_orchestrator_watch(&project.id);
assert!(!has_watcher(&state, &project.id), "watcher gone on close");
assert!(!has_mcp(&state, &project.id), "MCP server gone on close");
assert_eq!(watcher_count(&state), 0);
assert_eq!(mcp_count(&state), 0);
}
#[tokio::test]
async fn mcp_servers_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!(mcp_count(&state), 2, "one MCP server per open project");
assert!(has_mcp(&state, &a.id));
assert!(has_mcp(&state, &b.id));
// Closing one leaves the other's MCP server running.
state.stop_orchestrator_watch(&a.id);
assert!(!has_mcp(&state, &a.id));
assert!(has_mcp(&state, &b.id));
assert_eq!(mcp_count(&state), 1);
}
// --- M5a: per-project loopback MCP endpoint lifecycle ---
//
// `mcp_endpoint(project_id)` is the single source of truth for the loopback
// address (cadrage v5 §2). `ensure_mcp_server` binds it at open; dropping the
// handle on close unlinks it (Unix). On Unix the endpoint is a UDS *file* whose
// existence is directly observable; these tests assert bind → idempotence →
// cleanup → determinism/no-collision → coexistence with the file watcher.
/// Polls until `cond()` holds or the bound elapses (cleanup is async: the handle's
/// supervision task drops the listener — and unlinks the socket — only after the
/// stop signal propagates). Bounded so a regression fails fast, never hangs.
#[cfg(unix)]
async fn wait_until(mut cond: impl FnMut() -> bool) -> bool {
for _ in 0..100 {
if cond() {
return true;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
cond()
}
#[cfg(unix)]
fn socket_exists(project: &Project) -> bool {
mcp_endpoint(&project.id)
.socket_path()
.map(|p| p.exists())
.unwrap_or(false)
}
#[cfg(unix)]
#[tokio::test]
async fn open_binds_the_project_loopback_endpoint() {
let state = AppState::build(temp_path("appdata"));
let project = make_project();
assert!(!socket_exists(&project), "no socket before open");
state.ensure_orchestrator_watch(&project);
assert!(
wait_until(|| socket_exists(&project)).await,
"the project's loopback socket is bound on open"
);
state.stop_orchestrator_watch(&project.id);
}
#[cfg(unix)]
#[tokio::test]
async fn double_open_keeps_a_single_endpoint_no_address_in_use() {
let state = AppState::build(temp_path("appdata"));
let project = make_project();
state.ensure_orchestrator_watch(&project);
assert!(wait_until(|| socket_exists(&project)).await);
// A second open must NOT rebind (which would fail "address in use" on a live
// socket) — it returns early. One endpoint, still bound, no panic.
state.ensure_orchestrator_watch(&project);
assert_eq!(mcp_count(&state), 1, "one endpoint per project");
assert!(
socket_exists(&project),
"endpoint still bound after re-open"
);
state.stop_orchestrator_watch(&project.id);
}
#[cfg(unix)]
#[tokio::test]
async fn close_cleans_up_the_endpoint_socket_file() {
let state = AppState::build(temp_path("appdata"));
let project = make_project();
state.ensure_orchestrator_watch(&project);
assert!(wait_until(|| socket_exists(&project)).await);
state.stop_orchestrator_watch(&project.id);
assert!(
wait_until(|| !socket_exists(&project)).await,
"the socket file is unlinked on close — no leak"
);
}
#[test]
fn endpoint_is_deterministic_and_collision_free_across_projects() {
let p1 = make_project();
let p2 = make_project();
// Stable for the same project across calls.
assert_eq!(mcp_endpoint(&p1.id), mcp_endpoint(&p1.id));
// Distinct projects ⇒ distinct endpoints (no collision).
assert_ne!(mcp_endpoint(&p1.id), mcp_endpoint(&p2.id));
assert_ne!(
mcp_endpoint(&p1.id).as_cli_arg(),
mcp_endpoint(&p2.id).as_cli_arg()
);
}
#[cfg(unix)]
#[tokio::test]
async fn file_watcher_and_loopback_endpoint_live_together() {
let state = AppState::build(temp_path("appdata"));
let project = make_project();
state.ensure_orchestrator_watch(&project);
// R0/M3 invariant intact: the file watcher and the MCP server are both live...
assert!(has_watcher(&state, &project.id), "watcher live");
assert!(has_mcp(&state, &project.id), "mcp server live");
// ...and on Unix the loopback endpoint is actually bound beside the watcher.
assert!(
wait_until(|| socket_exists(&project)).await,
"endpoint bound alongside the live file watcher"
);
state.stop_orchestrator_watch(&project.id);
assert!(wait_until(|| !socket_exists(&project)).await);
}

View File

@ -47,7 +47,10 @@ fn send_output_delivers_bytes_to_registered_channel() {
bridge.register(session, capturing_channel(Arc::clone(&sink))); bridge.register(session, capturing_channel(Arc::clone(&sink)));
let delivered = bridge.send_output(&session, vec![104, 105]); let delivered = bridge.send_output(&session, vec![104, 105]);
assert!(delivered, "send_output should return true for a live session"); assert!(
delivered,
"send_output should return true for a live session"
);
let captured = sink.lock().unwrap(); let captured = sink.lock().unwrap();
assert_eq!(captured.as_slice(), &[vec![104, 105]]); assert_eq!(captured.as_slice(), &[vec![104, 105]]);
@ -82,9 +85,64 @@ fn register_same_session_twice_replaces_channel() {
bridge.register(session, capturing_channel(Arc::clone(&first))); bridge.register(session, capturing_channel(Arc::clone(&first)));
bridge.register(session, capturing_channel(Arc::clone(&second))); bridge.register(session, capturing_channel(Arc::clone(&second)));
assert_eq!(bridge.active_sessions(), 1, "same id is replaced, not added"); assert_eq!(
bridge.active_sessions(),
1,
"same id is replaced, not added"
);
bridge.send_output(&session, vec![9]); bridge.send_output(&session, vec![9]);
assert!(first.lock().unwrap().is_empty(), "old channel no longer used"); assert!(
first.lock().unwrap().is_empty(),
"old channel no longer used"
);
assert_eq!(second.lock().unwrap().as_slice(), &[vec![9]]); assert_eq!(second.lock().unwrap().as_slice(), &[vec![9]]);
} }
#[test]
fn register_returns_monotonic_generation_per_session() {
let bridge = PtyBridge::new();
let session = sid();
let g0 = bridge.register(session, capturing_channel(Arc::new(Mutex::new(Vec::new()))));
let g1 = bridge.register(session, capturing_channel(Arc::new(Mutex::new(Vec::new()))));
assert_eq!(g0, 0);
assert_eq!(g1, 1, "re-attaching the same session bumps the generation");
}
/// The regression guard for on-resize output duplication: a superseded attach's
/// pump thread (older generation) must NOT tear down the channel of the
/// re-attach that replaced it.
#[test]
fn unregister_if_is_a_noop_for_a_superseded_generation() {
let bridge = PtyBridge::new();
let session = sid();
let old = Arc::new(Mutex::new(Vec::new()));
let new = Arc::new(Mutex::new(Vec::new()));
let old_gen = bridge.register(session, capturing_channel(Arc::clone(&old)));
let _new_gen = bridge.register(session, capturing_channel(Arc::clone(&new)));
// The stale (old) pump thread ends and tries to clean up with its own gen.
bridge.unregister_if(&session, old_gen);
// The current channel survives and still delivers — no duplication, no drop.
assert_eq!(
bridge.active_sessions(),
1,
"live re-attach must not be removed"
);
assert!(bridge.send_output(&session, vec![7]));
assert_eq!(new.lock().unwrap().as_slice(), &[vec![7]]);
}
#[test]
fn unregister_if_removes_when_generation_is_current() {
let bridge = PtyBridge::new();
let session = sid();
let gen = bridge.register(session, capturing_channel(Arc::new(Mutex::new(Vec::new()))));
// The current attach's pump thread ends (PTY EOF): it owns the live channel.
bridge.unregister_if(&session, gen);
assert_eq!(bridge.active_sessions(), 0);
assert!(!bridge.send_output(&session, vec![1]));
}

View File

@ -14,6 +14,9 @@ serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
# `v5` derives stable reference-profile ids from a fixed namespace (catalogue). # `v5` derives stable reference-profile ids from a fixed namespace (catalogue).
uuid = { workspace = true } uuid = { workspace = true }
# `time` feature only : borne le rendez-vous synchrone `send_blocking` (§17.4).
# Déjà le runtime async du workspace ; pas une nouvelle dépendance externe.
tokio = { workspace = true }
[dev-dependencies] [dev-dependencies]
tokio = { workspace = true } tokio = { workspace = true }

View File

@ -18,7 +18,11 @@
//! making the reference profiles addressable across runs without a registry. //! making the reference profiles addressable across runs without a registry.
use domain::ids::ProfileId; use domain::ids::ProfileId;
use domain::profile::{AgentProfile, ContextInjection}; use domain::permission::ProjectorKey;
use domain::profile::{
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
StructuredAdapter,
};
/// A fixed UUID namespace used to derive stable ids for reference profiles. /// A fixed UUID namespace used to derive stable ids for reference profiles.
/// (Random-looking but constant; only its stability matters.) /// (Random-looking but constant; only its stability matters.)
@ -53,9 +57,17 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
ContextInjection::convention_file("CLAUDE.md") ContextInjection::convention_file("CLAUDE.md")
.expect("CLAUDE.md is a valid convention target"), .expect("CLAUDE.md is a valid convention target"),
Some("claude --version".to_owned()), Some("claude --version".to_owned()),
"{projectRoot}", "{agentRunDir}",
None,
) )
.expect("claude reference profile is valid"), .expect("claude reference profile is valid")
.with_structured_adapter(StructuredAdapter::Claude)
.with_projector(ProjectorKey::Claude)
.with_mcp(McpCapability::new(
McpConfigStrategy::config_file(".mcp.json")
.expect(".mcp.json is a valid relative MCP config target"),
McpTransport::Stdio,
)),
AgentProfile::new( AgentProfile::new(
reference_id("codex"), reference_id("codex"),
"OpenAI Codex CLI", "OpenAI Codex CLI",
@ -64,9 +76,20 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
ContextInjection::convention_file("AGENTS.md") ContextInjection::convention_file("AGENTS.md")
.expect("AGENTS.md is a valid convention target"), .expect("AGENTS.md is a valid convention target"),
Some("codex --version".to_owned()), Some("codex --version".to_owned()),
"{projectRoot}", "{agentRunDir}",
None,
) )
.expect("codex reference profile is valid"), .expect("codex reference profile is valid")
.with_structured_adapter(StructuredAdapter::Codex)
.with_projector(ProjectorKey::Codex)
.with_mcp(McpCapability::new(
// Codex lit ses serveurs MCP dans `$CODEX_HOME/config.toml`, pas `.mcp.json` :
// IdeA écrit ce TOML DANS le run dir et pointe `CODEX_HOME` dessus pour
// isoler l'agent du `~/.codex` global (miroir du `.mcp.json` de Claude).
McpConfigStrategy::toml_config_home(".codex/config.toml", "CODEX_HOME")
.expect(".codex/config.toml + CODEX_HOME is a valid MCP config target"),
McpTransport::Stdio,
)),
AgentProfile::new( AgentProfile::new(
reference_id("gemini"), reference_id("gemini"),
"Gemini CLI", "Gemini CLI",
@ -75,7 +98,8 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
ContextInjection::convention_file("GEMINI.md") ContextInjection::convention_file("GEMINI.md")
.expect("GEMINI.md is a valid convention target"), .expect("GEMINI.md is a valid convention target"),
Some("gemini --version".to_owned()), Some("gemini --version".to_owned()),
"{projectRoot}", "{agentRunDir}",
None,
) )
.expect("gemini reference profile is valid"), .expect("gemini reference profile is valid"),
AgentProfile::new( AgentProfile::new(
@ -86,8 +110,124 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
ContextInjection::flag("--message-file {path}") ContextInjection::flag("--message-file {path}")
.expect("aider flag template is non-empty"), .expect("aider flag template is non-empty"),
Some("aider --version".to_owned()), Some("aider --version".to_owned()),
"{projectRoot}", "{agentRunDir}",
None,
) )
.expect("aider reference profile is valid"), .expect("aider reference profile is valid"),
] ]
} }
/// Returns the **selectable** subset of [`reference_profiles`] — the profiles the
/// first-run wizard and the agent-creation menu are allowed to offer (§17.3,
/// lot D7).
///
/// A profile is selectable iff it can be driven in **structured** mode
/// ([`AgentProfile::is_selectable`] = it carries a `structured_adapter`). Today
/// that is Claude + Codex; Gemini/Aider stay in [`reference_profiles`] (the data
/// catalogue is untouched) but are **not** proposed for selection. There is no
/// custom-profile entry here either: the selection path offers only profiles we
/// know how to pilot.
///
/// This filter is the single selection gate; `is_selectable` is the same
/// predicate the `AgentSessionFactory` uses to decide it `supports` a profile, so
/// the menu and the runtime can never disagree.
#[must_use]
pub fn selectable_reference_profiles() -> Vec<AgentProfile> {
reference_profiles()
.into_iter()
.filter(AgentProfile::is_selectable)
.collect()
}
#[cfg(test)]
mod mcp_tests {
use super::*;
fn profile(slug: &str) -> AgentProfile {
let id = reference_id(slug);
reference_profiles()
.into_iter()
.find(|p| p.id == id)
.unwrap_or_else(|| panic!("reference profile `{slug}` exists"))
}
#[test]
fn claude_and_codex_expose_mcp_capability() {
for slug in ["claude", "codex"] {
let p = profile(slug);
assert!(
p.mcp.is_some(),
"structured profile `{slug}` must carry an MCP capability"
);
}
}
#[test]
fn claude_mcp_uses_config_file_mcp_json() {
let mcp = profile("claude").mcp.expect("mcp present");
assert_eq!(
mcp.config,
McpConfigStrategy::ConfigFile {
target: ".mcp.json".to_owned()
},
"Claude should declare `.mcp.json`"
);
assert_eq!(mcp.transport, McpTransport::Stdio);
}
#[test]
fn codex_mcp_uses_toml_config_home_codex() {
// Codex lit `$CODEX_HOME/config.toml`, pas `.mcp.json` : le seed doit déclarer
// la stratégie TOML isolée par `CODEX_HOME` (pendant Codex de Claude).
let mcp = profile("codex").mcp.expect("mcp present");
assert_eq!(
mcp.config,
McpConfigStrategy::TomlConfigHome {
target: ".codex/config.toml".to_owned(),
home_env: "CODEX_HOME".to_owned(),
},
"Codex should declare `.codex/config.toml` + CODEX_HOME"
);
assert_eq!(mcp.transport, McpTransport::Stdio);
assert!(
profile("codex").materializes_idea_bridge(),
"the Codex seed must materialise the idea bridge"
);
}
#[test]
fn gemini_and_aider_have_no_mcp_capability() {
for slug in ["gemini", "aider"] {
assert!(
profile(slug).mcp.is_none(),
"non-structured profile `{slug}` must NOT carry MCP (file fallback)"
);
}
}
// -- Lot LP3 : projector (clé du projecteur de permissions par-CLI) ----------
#[test]
fn claude_and_codex_seed_their_projector_key() {
assert_eq!(
profile("claude").projector,
Some(ProjectorKey::Claude),
"the Claude seed must pose the Claude projector"
);
assert_eq!(
profile("codex").projector,
Some(ProjectorKey::Codex),
"the Codex seed must pose the Codex projector"
);
}
#[test]
fn gemini_and_aider_have_no_projector() {
for slug in ["gemini", "aider"] {
assert!(
profile(slug).projector.is_none(),
"non-structured profile `{slug}` must NOT carry a projector (native prompting)"
);
}
}
}

View File

@ -0,0 +1,142 @@
//! Best-effort conversation inspection use case (CONTEXT §T7, Part A).
//!
//! [`InspectConversation`] enriches a resume popup with the *last topic* and a
//! *token indicator* read from a CLI's on-disk transcript. It is **best-effort
//! by construction**: it routes the agent's [`AgentProfile`] to the first
//! injected [`SessionInspector`] that [`supports`](SessionInspector::supports)
//! it, and *any* miss — no inspector at all, an unsupported profile,
//! [`InspectError::NotFound`], or [`InspectError::Read`] — degrades to **empty
//! details** (`last_topic: None, token_count: None`) instead of an error. The
//! resume must never be blocked by inspection.
//!
//! Extensibility (Open/Closed): adding a new inspectable CLI is *pushing one
//! more adapter into the `Vec`* at the composition root — no change here.
//!
//! Like [`super::lifecycle::LaunchAgent`], it resolves the agent from the
//! project manifest and its profile from the [`ProfileStore`], and inspects
//! against the agent's **isolated run directory** (the very `cwd` the CLI was
//! launched with) so the inspector points at the right transcript folder.
use std::sync::Arc;
use domain::ports::{
AgentContextStore, ConversationDetails, InspectError, ProfileStore, SessionInspector,
};
use domain::{AgentId, Project};
use super::lifecycle::agent_run_dir;
use crate::error::AppError;
/// Input for [`InspectConversation::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InspectConversationInput {
/// The project owning the agent.
pub project: Project,
/// The agent whose conversation is being inspected.
pub agent_id: AgentId,
/// The persistent CLI conversation id recorded on the hosting cell.
pub conversation_id: String,
}
/// Output of [`InspectConversation::execute`]: the (possibly empty) best-effort
/// details. Never an inspection error — a miss surfaces as empty fields.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InspectConversationOutput {
/// Enriched, best-effort details (every field optional).
pub details: ConversationDetails,
}
/// Reads best-effort [`ConversationDetails`] for an agent's conversation.
///
/// Holds a (possibly empty) `Vec<Arc<dyn SessionInspector>>`: the agent's
/// profile is routed to the first inspector that supports it. The use case still
/// needs the context store (resolve the agent) and the profile store (resolve
/// the profile), exactly like [`super::lifecycle::LaunchAgent`].
pub struct InspectConversation {
contexts: Arc<dyn AgentContextStore>,
profiles: Arc<dyn ProfileStore>,
inspectors: Vec<Arc<dyn SessionInspector>>,
}
impl InspectConversation {
/// Builds the use case from its injected ports and the inspector list (which
/// may be empty: that path simply yields empty details).
#[must_use]
pub fn new(
contexts: Arc<dyn AgentContextStore>,
profiles: Arc<dyn ProfileStore>,
inspectors: Vec<Arc<dyn SessionInspector>>,
) -> Self {
Self {
contexts,
profiles,
inspectors,
}
}
/// Resolves the agent + profile + run dir, then asks the first supporting
/// inspector for details. Returns **empty** details when no inspector
/// matches or inspection misses (`NotFound`/`Read`); only genuine store
/// failures (loading the manifest / profiles) surface as an error.
///
/// # Errors
/// - [`AppError::NotFound`] if the agent or its profile is unknown,
/// - [`AppError::Invalid`] if a persisted manifest entry / run dir is invalid,
/// - [`AppError::Store`] on a manifest / profile store failure.
pub async fn execute(
&self,
input: InspectConversationInput,
) -> Result<InspectConversationOutput, AppError> {
// Resolve the agent from the manifest (for its profile + run dir).
let manifest = self.contexts.load_manifest(&input.project).await?;
let entry = manifest
.entries
.iter()
.find(|e| e.agent_id == input.agent_id)
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
let agent = entry
.to_agent()
.map_err(|e| AppError::Invalid(e.to_string()))?;
let profile = self
.profiles
.list()
.await?
.into_iter()
.find(|p| p.id == agent.profile_id)
.ok_or_else(|| AppError::NotFound(format!("profile {} for agent", agent.profile_id)))?;
// The CLI runs with its isolated run dir as cwd; the inspector keys its
// transcript lookup off that same cwd (same value LaunchAgent uses).
let run_dir = agent_run_dir(&input.project.root, &agent.id)
.map_err(|e| AppError::Invalid(e.to_string()))?;
// Route to the first inspector that supports this profile. No match ⇒
// empty details (best-effort).
let Some(inspector) = self.inspectors.iter().find(|i| i.supports(&profile)) else {
return Ok(InspectConversationOutput {
details: empty_details(),
});
};
// Any inspection miss (NotFound / Read) degrades to empty details — it
// must never block a resume.
let details = match inspector
.details(&profile, &input.conversation_id, &run_dir)
.await
{
Ok(details) => details,
Err(InspectError::NotFound | InspectError::Read(_)) => empty_details(),
};
Ok(InspectConversationOutput { details })
}
}
/// The empty, fully-degraded [`ConversationDetails`] (no topic, no tokens).
fn empty_details() -> ConversationDetails {
ConversationDetails {
last_topic: None,
token_count: None,
}
}

File diff suppressed because it is too large Load Diff

View File

@ -7,17 +7,29 @@
//! ports. Launching an agent (PTY + injection) is L6. //! ports. Launching an agent (PTY + injection) is L6.
mod catalogue; mod catalogue;
mod inspect;
mod lifecycle; mod lifecycle;
mod resume;
mod structured;
mod usecases; mod usecases;
pub(crate) use lifecycle::unique_md_path; pub(crate) use lifecycle::unique_md_path;
pub(crate) use lifecycle::ReattachDecision;
pub use catalogue::{reference_profile_id, reference_profiles}; pub use structured::{drain_with_readiness, send_blocking};
pub use catalogue::{reference_profile_id, reference_profiles, selectable_reference_profiles};
pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput};
pub use lifecycle::{ pub use lifecycle::{
CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch,
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, HandoffProvider,
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, UpdateAgentContext, LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
UpdateAgentContextInput, ListAgentsOutput, McpRuntime, PermissionProjectorRegistry, ProviderSessionProvider,
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredSessionDescriptor,
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET,
};
pub use resume::{
ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent,
}; };
pub use usecases::{ pub use usecases::{
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfile, ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfile,

View File

@ -0,0 +1,193 @@
//! [`ListResumableAgents`] — inventaire, en lecture seule, des cellules d'agent
//! reprenables à la réouverture d'un projet (ARCHITECTURE §15.2, chantier B,
//! lot B1).
//!
//! À la réouverture d'un projet, [`crate::OpenProject`] a déjà rechargé le
//! manifeste et les layouts persistés : chaque [`domain::LeafCell`] retrouve
//! donc son `conversation_id` et son `agent_was_running` gelés à la fermeture.
//! Ce use case **calcule l'inventaire** des cellules d'agent reprenables pour
//! que la couche supérieure (commande Tauri + `ResumeProjectPanel`, lot B2)
//! puisse proposer un panneau de reprise groupé.
//!
//! Contraintes (§15.2) :
//! - **Lecture seule** : aucun PTY, aucun spawn, aucune persistance. On
//! compose `resolve_doc` (lecture des layouts), le manifeste (nom +
//! `profile_id`) et la liste des profils (`resume_supported`).
//! - **Best-effort, jamais d'erreur** : projet / mémoire / agent absent ⇒
//! **liste vide**, jamais de panique ni d'`AppError`. C'est un inventaire
//! indicatif à l'ouverture, pas une opération critique.
//! - **Filtre** : on ne retient qu'une leaf d'agent dont `agent_was_running`
//! est vrai **ou** qui porte un `conversation_id`. Une cellule d'agent jamais
//! lancée (ni id, ni flag) n'apparaît pas : elle se lancera normalement au
//! clic, sans popup.
use std::sync::Arc;
use domain::ports::{AgentContextStore, FileSystem, ProfileStore, ProjectStore};
use domain::{AgentId, NodeId, Project};
use crate::error::AppError;
use crate::layout::resolve_doc;
/// Input de [`ListResumableAgents::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListResumableAgentsInput {
/// Le projet dont on inventorie les cellules reprenables.
pub project: Project,
}
/// Une cellule d'agent reprenable, telle qu'exposée à la couche supérieure.
///
/// Champs alignés sur la spec §15.2 : l'identité de l'agent et de sa cellule
/// hôte, l'id de conversation à reprendre (`None` ⇒ relance à neuf), l'état
/// « tournait » gelé à la fermeture, et si son profil sait reprendre une
/// conversation CLI.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResumableAgent {
/// Identifiant de l'agent.
pub agent_id: AgentId,
/// Nom d'affichage de l'agent (résolu via le manifeste).
pub name: String,
/// Cellule hôte où relancer/reprendre l'agent.
pub node_id: NodeId,
/// Id de conversation CLI persistant porté par la cellule. `None` ⇒ relance
/// à neuf (pas d'historique à reprendre).
pub conversation_id: Option<String>,
/// Valeur de `agent_was_running` gelée à la fermeture de la cellule.
pub was_running: bool,
/// `true` si le profil de l'agent possède une [`domain::SessionStrategy`]
/// exploitable (présence d'un `resume_flag`).
pub resume_supported: bool,
}
/// Output de [`ListResumableAgents::execute`].
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ListResumableAgentsOutput {
/// Les cellules d'agent reprenables, dans l'ordre de parcours des layouts.
pub resumable: Vec<ResumableAgent>,
}
/// Inventorie, en lecture seule, les cellules d'agent reprenables d'un projet.
///
/// Compose trois ports déjà injectés au composition root, **sans** en ajouter
/// de nouveau :
/// - [`ProjectStore`] + [`FileSystem`] : charger les layouts persistés
/// (`resolve_doc`),
/// - [`AgentContextStore`] : le manifeste, pour le nom et le `profile_id` de
/// chaque agent,
/// - [`ProfileStore`] : pour déterminer `resume_supported`.
pub struct ListResumableAgents {
#[allow(dead_code)]
store: Arc<dyn ProjectStore>,
fs: Arc<dyn FileSystem>,
contexts: Arc<dyn AgentContextStore>,
profiles: Arc<dyn ProfileStore>,
}
impl ListResumableAgents {
/// Construit le use case à partir de ses ports injectés.
#[must_use]
pub fn new(
store: Arc<dyn ProjectStore>,
fs: Arc<dyn FileSystem>,
contexts: Arc<dyn AgentContextStore>,
profiles: Arc<dyn ProfileStore>,
) -> Self {
Self {
store,
fs,
contexts,
profiles,
}
}
/// Calcule l'inventaire des cellules reprenables.
///
/// Parcourt chaque layout du projet et, pour chaque leaf portant un agent,
/// lit `conversation_id`/`agent_was_running` (via l'accessor pur
/// [`domain::LayoutTree::leaf`]). Ne retient que les leaves passant le
/// filtre `was_running || conversation_id.is_some()`, résout le nom via le
/// manifeste et `resume_supported` via le profil.
///
/// **Best-effort** : toute défaillance de lecture (projet/mémoire absent,
/// layouts illisibles, manifeste/profils en erreur) dégrade vers une
/// **liste vide** ; cette fonction ne renvoie donc jamais d'erreur, mais sa
/// signature reste `Result` pour rester homogène avec les autres use cases.
///
/// # Errors
/// N'échoue jamais en pratique (best-effort) ; la signature `Result` est
/// conservée par cohérence.
pub async fn execute(
&self,
input: ListResumableAgentsInput,
) -> Result<ListResumableAgentsOutput, AppError> {
// Layouts persistés. Toute erreur ⇒ inventaire vide (best-effort).
let Ok(doc) = resolve_doc(self.fs.as_ref(), &input.project).await else {
return Ok(ListResumableAgentsOutput::default());
};
// Manifeste (nom + profile_id). Absent/illisible ⇒ inventaire vide :
// sans manifeste on ne peut résoudre ni nom ni profil.
let Ok(manifest) = self.contexts.load_manifest(&input.project).await else {
return Ok(ListResumableAgentsOutput::default());
};
// Profils disponibles, pour `resume_supported`. Indisponibles ⇒ on
// considère qu'aucun profil ne sait reprendre (best-effort), sans
// pour autant masquer les agents reprenables par `conversation_id`.
let profiles = self.profiles.list().await.unwrap_or_default();
let mut resumable = Vec::new();
for named in &doc.layouts {
for (node_id, agent_id) in named.tree.agent_leaves() {
// Lecture pure de la cellule hôte pour ses champs de reprise.
let Some(leaf) = named.tree.leaf(node_id) else {
continue;
};
let conversation_id = leaf.conversation_id.clone();
let was_running = leaf.agent_was_running;
// Filtre §15.2 : reprenable au sens strict uniquement.
if !was_running && conversation_id.is_none() {
continue;
}
// Nom via le manifeste ; agent absent ⇒ on ignore la leaf
// (best-effort : pas d'entrée orpheline dans l'inventaire).
let Some(entry) = manifest.entries.iter().find(|e| e.agent_id == agent_id) else {
continue;
};
let name = entry.name.clone();
// `resume_supported` : le profil de l'agent sait-il reprendre une
// conversation ? Vrai si :
// - il porte une `SessionStrategy` (TUI/PTY avec `resume_flag`,
// sémantique §15), **ou**
// - il est **structuré** (`structured_adapter`, §17) : l'adapter
// Claude (`--resume`) / Codex (`exec resume`) passe le flag de
// reprise du moteur via `SessionPlan::Resume`, donc la reprise est
// intrinsèquement supportée.
let resume_supported = entry
.to_agent()
.ok()
.and_then(|agent| profiles.iter().find(|p| p.id == agent.profile_id))
.is_some_and(|profile| {
profile.session.is_some() || profile.structured_adapter.is_some()
});
resumable.push(ResumableAgent {
agent_id,
name,
node_id,
conversation_id,
was_running,
resume_supported,
});
}
}
Ok(ListResumableAgentsOutput { resumable })
}
}

View File

@ -0,0 +1,251 @@
//! Helper applicatif `send_blocking` — le rendez-vous synchrone inter-agents
//! au-dessus du port [`AgentSession`] (ARCHITECTURE §17.1 / §17.4).
//!
//! `AgentSession::send` retourne un **flux** d'événements (`ReplyStream`), à la
//! manière de `PtyPort::subscribe_output`, mais **typé** : deltas de texte →
//! activités d'outil → **un** événement terminal déterministe
//! [`ReplyEvent::Final`]. Le rendez-vous synchrone dont l'orchestrateur a besoin
//! (§17.4) s'obtient en **drainant ce flux jusqu'au `Final`** : c'est *la* primitive
//! de la messagerie inter-agents, **sans outbox, sans corrélation fichier** (le
//! `Final` *est* la fin de tour).
//!
//! DRY : un seul chemin de lecture (le flux). `send_blocking` n'est qu'un *consom-
//! mateur* du même flux que la cellule chat utilise pour le rendu incrémental.
use std::time::Duration;
use domain::input::InputMediator;
use domain::ids::AgentId;
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent};
use domain::readiness::{ReadinessPolicy, ReadinessSignal};
/// Envoie `prompt` à la session vivante puis **draine le flux de réponse jusqu'au
/// [`ReplyEvent::Final`]**, et retourne son contenu agrégé.
///
/// C'est le rendez-vous synchrone (§17.4) : on attend que le tour soit
/// déterministiquement terminé (`Final`) avant de rendre la main. Les deltas de
/// texte et les activités d'outil traversés en chemin sont **ignorés** ici (ils
/// servent le rendu incrémental côté UI, pas l'appelant synchrone).
///
/// `timeout`, lorsqu'il est fourni, borne l'attente : si aucun `Final` n'est observé
/// dans le délai, on retourne [`AgentSessionError::Timeout`] **sans tuer la
/// session** (elle reste vivante dans le registre ; l'appelant décide de la suite).
/// `None` ⇒ pas de borne temporelle (on attend la fin du tour).
///
/// # Errors
/// - [`AgentSessionError::Io`]/[`AgentSessionError::Decode`] remontées par `send`
/// (échec de communication / décodage de la sortie structurée) ;
/// - [`AgentSessionError::Io`] si le flux se termine **sans** `Final` (tour
/// interrompu) ;
/// - [`AgentSessionError::Timeout`] si `timeout` expire avant le `Final`.
pub async fn send_blocking(
session: &dyn AgentSession,
prompt: &str,
timeout: Option<Duration>,
) -> Result<String, AgentSessionError> {
drain_bounded_events(session, prompt, timeout, |_event| {}, |_signal| {}).await
}
/// Comme [`send_blocking`], mais **branche la readiness** : à chaque événement du
/// tour, [`ReadinessPolicy::classify`] est consulté et, dès qu'il renvoie
/// [`ReadinessSignal::TurnEnded`] (le `Final`), le médiateur d'entrée est notifié
/// (`mark_idle(agent)`) pour que la FIFO de l'agent avance — **sans** dépendre d'un
/// `idea_reply` explicite ni du sniff de prompt PTY (chantier readiness/heartbeat,
/// lot 1, fix de la cause racine du blocage `Busy`).
///
/// DRY : **un seul** chemin de lecture du flux (la boucle de [`drain_bounded`]) ;
/// cette fonction n'est que `send_blocking` muni d'un *sink* de readiness. Le `Final`
/// réveille donc à la fois le `pending` (via la valeur de retour) **et** la FIFO (via
/// `mark_idle`). `idea_reply` reste un signal alternatif (premier arrivé gagne) côté
/// orchestrateur.
///
/// # Errors
/// Identiques à [`send_blocking`] (échec `send`/décodage, flux clos sans `Final`,
/// timeout).
pub async fn drain_with_readiness(
session: &dyn AgentSession,
prompt: &str,
timeout: Option<Duration>,
mediator: &dyn InputMediator,
agent: AgentId,
) -> Result<String, AgentSessionError> {
// `on_signal` ne reçoit QUE les événements terminaux (le `Final` ⇒ `TurnEnded`) :
// la readiness ne classe pas les non-terminaux. Pour le **battement** de vivacité
// (lot 2) on a besoin de notifier le médiateur à CHAQUE événement non terminal
// (delta / activité / heartbeat) ⇒ on passe un sink d'événement bruts `on_event`.
drain_bounded_events(
session,
prompt,
timeout,
|event| {
// Tout événement **non terminal** prouve la vivacité ⇒ un battement.
if !matches!(event, ReplyEvent::Final { .. }) {
mediator.mark_alive(agent);
}
},
|signal| {
if signal == ReadinessSignal::TurnEnded {
mediator.mark_idle(agent);
}
},
)
.await
}
/// Ouvre le flux du tour (`send`) et le **draine jusqu'au `Final`**, en appliquant
/// la borne temporelle `timeout`, en notifiant `on_event` à **chaque** événement brut
/// (pour le battement de vivacité, lot 2) et `on_signal` à chaque [`ReadinessSignal`]
/// dérivé par [`ReadinessPolicy`] (le `Final` ⇒ `TurnEnded`).
///
/// **Chemin de lecture unique** (DRY) : `send_blocking` et `drain_with_readiness`
/// passent tous deux par ici, en différant seulement par leurs *sinks*. La session
/// **reste vivante** sur timeout (on ne `shutdown` rien ici, §17.1).
async fn drain_bounded_events(
session: &dyn AgentSession,
prompt: &str,
timeout: Option<Duration>,
on_event: impl FnMut(&ReplyEvent),
on_signal: impl FnMut(ReadinessSignal),
) -> Result<String, AgentSessionError> {
match timeout {
Some(dur) => match tokio::time::timeout(
dur,
drain_to_final(session, prompt, on_event, on_signal),
)
.await
{
Ok(result) => result,
// La session **reste vivante** : on ne `shutdown` rien ici (§17.1).
Err(_elapsed) => Err(AgentSessionError::Timeout),
},
None => drain_to_final(session, prompt, on_event, on_signal).await,
}
}
/// Ouvre le flux du tour (`send`) et le **draine jusqu'au `Final`**.
///
/// Le flux ([`domain::ports::ReplyStream`]) est un itérateur synchrone et borné :
/// après le `Final` il ne produit plus rien. On le parcourt donc simplement
/// jusqu'à rencontrer le `Final` (et on retourne son contenu) ; si le flux
/// s'épuise avant, c'est un tour interrompu → erreur [`AgentSessionError::Io`].
///
/// Chaque événement est classé par [`ReadinessPolicy`] et le signal éventuel est
/// remonté à `on_signal` (le `Final` ⇒ [`ReadinessSignal::TurnEnded`]). Deltas,
/// activités et heartbeats sont non terminaux ⇒ ignorés par le rendez-vous synchrone.
async fn drain_to_final(
session: &dyn AgentSession,
prompt: &str,
mut on_event: impl FnMut(&ReplyEvent),
mut on_signal: impl FnMut(ReadinessSignal),
) -> Result<String, AgentSessionError> {
let stream = session.send(prompt).await?;
for event in stream {
// Battement de vivacité (lot 2) : notifié pour CHAQUE événement brut, avant le
// classement readiness. Le sink décide (les non-terminaux prouvent la vivacité).
on_event(&event);
if let Some(signal) = ReadinessPolicy::classify(&event) {
on_signal(signal);
}
if let ReplyEvent::Final { content } = event {
return Ok(content);
}
// TextDelta / ToolActivity / Heartbeat : non terminaux, ignorés ici.
}
Err(AgentSessionError::Io(
"le flux de réponse s'est terminé sans événement Final".to_string(),
))
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use domain::ids::SessionId;
use domain::input::{AgentBusyState, SubmitConfig};
use domain::mailbox::{PendingReply, Ticket};
use domain::ports::PtyHandle;
fn agent(n: u128) -> AgentId {
AgentId::from_uuid(uuid::Uuid::from_u128(n))
}
/// Session factice : `send` rejoue une liste fixe d'événements (terminée par un
/// `Final`).
struct FakeSession {
events: Vec<ReplyEvent>,
}
#[async_trait::async_trait]
impl AgentSession for FakeSession {
fn id(&self) -> SessionId {
SessionId::from_uuid(uuid::Uuid::from_u128(1))
}
fn conversation_id(&self) -> Option<String> {
None
}
async fn send(
&self,
_prompt: &str,
) -> Result<domain::ports::ReplyStream, AgentSessionError> {
Ok(Box::new(self.events.clone().into_iter()))
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
Ok(())
}
}
/// Médiateur factice qui enregistre l'ordre des `mark_alive` / `mark_idle`.
#[derive(Default)]
struct RecordingMediator {
calls: Mutex<Vec<&'static str>>,
}
impl InputMediator for RecordingMediator {
fn enqueue(&self, _agent: AgentId, _ticket: Ticket) -> PendingReply {
unreachable!("non utilisé par drain_with_readiness")
}
fn preempt(&self, _agent: AgentId) {}
fn mark_idle(&self, _agent: AgentId) {
self.calls.lock().unwrap().push("idle");
}
fn mark_alive(&self, _agent: AgentId) {
self.calls.lock().unwrap().push("alive");
}
fn busy_state(&self, _agent: AgentId) -> AgentBusyState {
AgentBusyState::Idle
}
fn bind_handle(&self, _agent: AgentId, _handle: PtyHandle) {}
fn bind_handle_with_prompt(
&self,
_agent: AgentId,
_handle: PtyHandle,
_pattern: Option<String>,
_submit: SubmitConfig,
) {
}
}
#[tokio::test]
async fn drain_marks_alive_on_each_non_terminal_then_idle_on_final() {
let session = FakeSession {
events: vec![
ReplyEvent::TextDelta { text: "a".into() },
ReplyEvent::ToolActivity { label: "lit".into() },
ReplyEvent::Heartbeat,
ReplyEvent::Final {
content: "fini".into(),
},
],
};
let mediator = RecordingMediator::default();
let out = drain_with_readiness(&session, "go", None, &mediator, agent(1))
.await
.expect("drain ok");
assert_eq!(out, "fini");
// Trois battements (delta, activité, heartbeat) PUIS l'idle sur le Final.
assert_eq!(
*mediator.calls.lock().unwrap(),
vec!["alive", "alive", "alive", "idle"],
"un battement par événement non terminal, idle au Final (pas de battement sur le Final)"
);
}
}

View File

@ -18,7 +18,7 @@ use domain::profile::AgentProfile;
use crate::error::AppError; use crate::error::AppError;
use super::catalogue::reference_profiles; use super::catalogue::selectable_reference_profiles;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// DetectProfiles // DetectProfiles
@ -256,11 +256,15 @@ impl ConfigureProfiles {
/// Output of [`ReferenceProfiles::execute`]. /// Output of [`ReferenceProfiles::execute`].
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReferenceProfilesOutput { pub struct ReferenceProfilesOutput {
/// The pre-filled, editable reference catalogue. /// The pre-filled, editable reference catalogue, **restricted to the
/// selectable profiles** (§17.3, D7): only profiles drivable in structured
/// mode are offered to selection/creation. Today: Claude + Codex.
pub profiles: Vec<AgentProfile>, pub profiles: Vec<AgentProfile>,
} }
/// Exposes the pre-filled reference catalogue (Claude/Codex/Gemini/Aider). /// Exposes the **selectable** reference catalogue for the agent-creation menu
/// (§17.3, D7): the structured-drivable profiles only (Claude/Codex). Gemini and
/// Aider remain in the raw catalogue data but are not proposed here.
#[derive(Default)] #[derive(Default)]
pub struct ReferenceProfiles; pub struct ReferenceProfiles;
@ -278,7 +282,7 @@ impl ReferenceProfiles {
#[allow(clippy::unused_async)] #[allow(clippy::unused_async)]
pub async fn execute(&self) -> Result<ReferenceProfilesOutput, AppError> { pub async fn execute(&self) -> Result<ReferenceProfilesOutput, AppError> {
Ok(ReferenceProfilesOutput { Ok(ReferenceProfilesOutput {
profiles: reference_profiles(), profiles: selectable_reference_profiles(),
}) })
} }
} }
@ -292,7 +296,9 @@ impl ReferenceProfiles {
pub struct FirstRunStateOutput { pub struct FirstRunStateOutput {
/// `true` when no `profiles.json` exists yet ⇒ show the first-run wizard. /// `true` when no `profiles.json` exists yet ⇒ show the first-run wizard.
pub is_first_run: bool, pub is_first_run: bool,
/// The pre-filled reference catalogue to seed the wizard. /// The pre-filled reference catalogue to seed the wizard, **restricted to the
/// selectable profiles** (§17.3, D7): only structured-drivable profiles
/// (Claude/Codex) are offered. No custom-profile entry.
pub reference_profiles: Vec<AgentProfile>, pub reference_profiles: Vec<AgentProfile>,
} }
@ -317,7 +323,7 @@ impl FirstRunState {
let configured = self.store.is_configured().await?; let configured = self.store.is_configured().await?;
Ok(FirstRunStateOutput { Ok(FirstRunStateOutput {
is_first_run: !configured, is_first_run: !configured,
reference_profiles: reference_profiles(), reference_profiles: selectable_reference_profiles(),
}) })
} }
} }

View File

@ -0,0 +1,18 @@
//! Conversation persistence use cases (cadrage « persistance conversationnelle »,
//! ARCHITECTURE §19, lot P6).
//!
//! Where [`crate::agent::lifecycle`] owns the *runtime* of an agent (spawn, PTY,
//! sessions), this module owns the **durable conversational memory** of a
//! conversation (paire) : the canonical append-only log and its incremental
//! handoff. The single use case here, [`RecordTurn`], materialises the
//! **end-of-turn checkpoint** (D19-5) by talking **only** to the three driven/driving
//! ports of §19 ([`domain::ConversationLog`], [`domain::HandoffStore`],
//! [`domain::HandoffSummarizer`]) — never to a concrete adapter.
//!
//! This is the **isolated logic** of P6 (lot P6a) : no wiring to the orchestrator,
//! the PTY or `app-tauri` (that is P6b). It is fully testable with in-memory fakes
//! of the three ports.
mod record;
pub use record::RecordTurn;

View File

@ -0,0 +1,100 @@
//! [`RecordTurn`] — the end-of-turn checkpoint use case (ARCHITECTURE §19, lot P6a).
use std::sync::Arc;
use domain::{ConversationId, ConversationLog, ConversationTurn, HandoffStore, HandoffSummarizer};
use crate::error::AppError;
/// Records one conversation turn at a checkpoint: appends it to the canonical
/// log, then advances the incremental handoff (ARCHITECTURE §19, décision D19-5).
///
/// **Single Responsibility**: materialise *one* end-of-turn checkpoint. It is the
/// only place that couples the append-only log (source of truth, [`ConversationLog`])
/// with the cumulative resume summary ([`HandoffStore`]) via the incremental
/// [`HandoffSummarizer`]. It consumes **exactly** those three ports (Interface
/// Segregation), injected as `Arc<dyn …>` at the composition root.
///
/// ## Incremental by contract (no full re-read)
///
/// Each call folds **only the new turn** into the previous handoff
/// (`fold(prev, &[turn])`), never re-reading the whole thread — exactly the seam
/// [`HandoffSummarizer`] was designed for (§19, lot P4). The resulting handoff's
/// [`Handoff::up_to`](domain::Handoff::up_to) is the recorded turn's id (guaranteed
/// by the summarizer, P4).
///
/// ## Best-effort fold, honest `Result`
///
/// The fold itself cannot fail (it returns no `Result`, P4 — a future LLM
/// summarizer falls back rather than erroring), so the only failures are the
/// store operations (`append` / `load` / `save`), which propagate as [`AppError`].
/// This use case stays **honest** and surfaces them; the live wiring (P6b) decides
/// whether to swallow a handoff failure rather than block the turn.
///
/// ## No debounce here (deferred)
///
/// One `append` + one `save` per recorded turn. The debounce optimisation (coalesce
/// several rapid turns into a single handoff recompute) is intentionally **deferred**
/// — it belongs to the live wiring (P6b), not to this isolated logic.
pub struct RecordTurn {
log: Arc<dyn ConversationLog>,
handoffs: Arc<dyn HandoffStore>,
summarizer: Arc<dyn HandoffSummarizer>,
}
impl RecordTurn {
/// Builds the use case from its three injected ports.
#[must_use]
pub fn new(
log: Arc<dyn ConversationLog>,
handoffs: Arc<dyn HandoffStore>,
summarizer: Arc<dyn HandoffSummarizer>,
) -> Self {
Self {
log,
handoffs,
summarizer,
}
}
/// Records `turn` in `conversation` at an end-of-turn checkpoint.
///
/// Steps (ordering is contractual):
/// 1. **append** the turn to the canonical log (source of truth) ;
/// 2. **load** the previous handoff (`None` on a first checkpoint) ;
/// 3. **fold** `prev` with the single new turn (incremental, best-effort) ;
/// 4. **save** the advanced handoff.
///
/// The append happens **first** so the durable source of truth is never behind
/// the derived handoff. The turn is cloned for the append because the fold (step
/// 3) borrows it as the incremental slice.
///
/// # Errors
/// [`AppError::Store`] (or [`AppError::NotFound`] for a missing store item) when
/// the canonical log append, the handoff load, or the handoff save fails — every
/// [`domain::ports::StoreError`] is mapped through the existing `From` impl. The
/// fold never fails (P4).
pub async fn record(
&self,
conversation: ConversationId,
turn: ConversationTurn,
) -> Result<(), AppError> {
// 1. Append to the canonical append-only log (source of truth, D19-1a).
self.log.append(conversation, turn.clone()).await?;
// 2. Load the previous resume point (absence is never an error, P3).
let prev = self.handoffs.load(conversation).await?;
// 3. Fold the previous handoff with *only* the new turn (incremental, P4):
// no full re-read of the log; best-effort, so no `Result` to propagate.
let handoff = self
.summarizer
.fold(prev, std::slice::from_ref(&turn))
.await;
// 4. Persist the advanced handoff (overwrites the previous resume point).
self.handoffs.save(conversation, handoff).await?;
Ok(())
}
}

View File

@ -0,0 +1,24 @@
//! Embedder configuration use cases (LOT C2 — §14.5.3).
//!
//! CRUD over the declarative [`domain::profile::EmbedderProfile`]s
//! ([`ListEmbedderProfiles`], [`SaveEmbedderProfile`], [`DeleteEmbedderProfile`])
//! plus a read-only description of the available engines ([`DescribeEmbedderEngines`]):
//! the recommended local ONNX models, the detected local environment, and which
//! strategies are actually compiled into this binary.
//!
//! A changed embedder takes effect at the **next IDE start** (the composition root
//! freezes the recall for the session): these use cases only persist/inspect config,
//! they never reconfigure a live recall.
mod suggestion;
mod usecases;
pub use suggestion::{
CheckEmbedderSuggestion, CheckEmbedderSuggestionInput, CheckEmbedderSuggestionOutput,
DismissChoice, DismissEmbedderSuggestion, DismissEmbedderSuggestionInput, SuggestedThisSession,
};
pub use usecases::{
DeleteEmbedderProfile, DeleteEmbedderProfileInput, DescribeEmbedderEngines,
EmbedderEnginesView, ListEmbedderProfiles, ListEmbedderProfilesOutput, OnnxModelView,
SaveEmbedderProfile, SaveEmbedderProfileInput, SaveEmbedderProfileOutput,
};

View File

@ -0,0 +1,269 @@
//! Contextual embedder-suggestion use cases (LOT C3, ARCHITECTURE §14.5.5).
//!
//! - [`CheckEmbedderSuggestion`] — the **best-effort** check run when an agent
//! reads the project memory at activation: the *first* time a project's memory
//! outgrows the recall budget while no embedder is configured (strategy `none`),
//! it publishes a one-time [`DomainEvent::EmbedderSuggested`]. Anti-spam: at most
//! once per session per project, and never again once the user chose
//! [`EmbedderPromptDismissal::Never`].
//! - [`DismissEmbedderSuggestion`] — persists the user's "plus tard" / "ne plus
//! demander" response.
//!
//! By construction this never blocks the activation flow: every error degrades to
//! "no suggestion", and a `none`-strategy/under-budget project is a cheap no-op.
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use domain::ports::{
EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptDismissal, EmbedderPromptStore,
EventBus, MemoryStore,
};
use domain::profile::EmbedderStrategy;
use domain::{DomainEvent, ProjectId, ProjectPath};
use crate::error::AppError;
/// In-memory "already suggested this session" guard, shared with the composition
/// root (held in `AppState`). One [`ProjectId`] per project that has already seen
/// the suggestion since the IDE started. Plain std types so the application stays
/// dependency-free and the guard is trivially testable.
pub type SuggestedThisSession = Arc<Mutex<HashSet<ProjectId>>>;
/// Sums the approximate token size of the index, mirroring the infrastructure
/// `index_token_size` so the application does not depend on the infra crate. One
/// entry's payload is its rendered index line (title + hook + slug), and the rule
/// only needs a *monotone* measure consistent with the recall's budget unit
/// (~1 token / 4 chars), so we count characters / 4 (min 1 per entry).
fn index_token_size(entries: &[domain::MemoryIndexEntry]) -> usize {
entries
.iter()
.map(|e| {
let chars = e.title.len() + e.hook.len() + e.slug.as_str().len();
(chars / 4).max(1)
})
.sum()
}
// ---------------------------------------------------------------------------
// CheckEmbedderSuggestion
// ---------------------------------------------------------------------------
/// Input for [`CheckEmbedderSuggestion::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CheckEmbedderSuggestionInput {
/// The project whose memory is being read at agent activation.
pub project_id: ProjectId,
/// That project's root (where its `.ideai/memory/` lives).
pub project_root: ProjectPath,
}
/// Output of [`CheckEmbedderSuggestion::execute`]: whether a suggestion event was
/// published on this call (useful for tests; the caller ignores it — the flow is
/// best-effort).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CheckEmbedderSuggestionOutput {
/// `true` iff an [`DomainEvent::EmbedderSuggested`] was published.
pub suggested: bool,
}
/// Publishes a one-time embedder suggestion the first time a project's memory
/// outgrows the recall budget while no embedder is configured (LOT C3).
///
/// Composes — each port only for its slice (Interface Segregation):
/// - [`EmbedderProfileStore`] to read the active strategy (no non-`none` profile ⇒
/// strategy `none`),
/// - [`MemoryStore`] to measure the memory index size,
/// - [`EmbedderPromptStore`] for the persistent dismissal (`never` ⇒ silence),
/// - [`EmbedderEnvInspector`] to enrich the event with the detected environment,
/// - [`EventBus`] to publish, plus the shared [`SuggestedThisSession`] guard.
///
/// **Never blocks the caller**: any port error degrades to "no suggestion".
pub struct CheckEmbedderSuggestion {
profiles: Arc<dyn EmbedderProfileStore>,
memories: Arc<dyn MemoryStore>,
prompts: Arc<dyn EmbedderPromptStore>,
inspector: Arc<dyn EmbedderEnvInspector>,
events: Arc<dyn EventBus>,
suggested_this_session: SuggestedThisSession,
/// Recall token budget the memory must exceed (injected so it stays aligned
/// with [`crate::AGENT_MEMORY_RECALL_BUDGET`] without re-hardcoding it).
budget: usize,
/// Compiled-in HTTP capability flag (carried into the event).
vector_http_enabled: bool,
/// Compiled-in ONNX capability flag (carried into the event).
vector_onnx_enabled: bool,
}
impl CheckEmbedderSuggestion {
/// Builds the use case from its ports + the session guard + the budget and
/// compiled-capability flags (the latter injected at the composition root, the
/// application stays infra-free).
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn new(
profiles: Arc<dyn EmbedderProfileStore>,
memories: Arc<dyn MemoryStore>,
prompts: Arc<dyn EmbedderPromptStore>,
inspector: Arc<dyn EmbedderEnvInspector>,
events: Arc<dyn EventBus>,
suggested_this_session: SuggestedThisSession,
budget: usize,
vector_http_enabled: bool,
vector_onnx_enabled: bool,
) -> Self {
Self {
profiles,
memories,
prompts,
inspector,
events,
suggested_this_session,
budget,
vector_http_enabled,
vector_onnx_enabled,
}
}
/// Whether any configured profile selects a non-`none` strategy. No profile (or
/// a store error) ⇒ `none` (the dependency-free default posture).
async fn strategy_is_none(&self) -> bool {
match self.profiles.list().await {
Ok(profiles) => !profiles
.iter()
.any(|p| p.strategy != EmbedderStrategy::None),
// A store failure must not turn into a suggestion: behave as configured
// (i.e. *not* `none`) so we stay silent.
Err(_) => false,
}
}
/// Runs the check best-effort.
///
/// Order (each step short-circuits to "no suggestion"):
/// 1. already suggested this session ⇒ stop (no I/O beyond the guard);
/// 2. a non-`none` strategy is configured ⇒ stop;
/// 3. the persisted dismissal is `never` ⇒ stop;
/// 4. the memory index size does not exceed the budget ⇒ stop;
/// 5. otherwise mark the session guard, probe the environment, and publish
/// [`DomainEvent::EmbedderSuggested`].
///
/// # Errors
/// Never in practice — the signature keeps a uniform `Result` with the other
/// use cases; every failing port read degrades to `Ok(suggested: false)`.
pub async fn execute(
&self,
input: CheckEmbedderSuggestionInput,
) -> Result<CheckEmbedderSuggestionOutput, AppError> {
let not_suggested = Ok(CheckEmbedderSuggestionOutput { suggested: false });
// 1. Once per session per project (cheap guard check before any I/O).
if self
.suggested_this_session
.lock()
.map(|set| set.contains(&input.project_id))
.unwrap_or(true)
{
return not_suggested;
}
// 2. Only when no embedder is configured (strategy `none`).
if !self.strategy_is_none().await {
return not_suggested;
}
// 3. Persistent "ne plus demander" silences the suggestion forever.
if matches!(
self.prompts.read(&input.project_root).await,
Ok(Some(EmbedderPromptDismissal::Never))
) {
return not_suggested;
}
// 4. Memory must have outgrown the recall budget (a fresh project has 0).
let size = match self.memories.read_index(&input.project_root).await {
Ok(entries) => index_token_size(&entries),
Err(_) => return not_suggested,
};
if size <= self.budget {
return not_suggested;
}
// 5. Mark the guard *before* publishing so a concurrent activation cannot
// double-fire; if another thread won the race, stay silent.
match self.suggested_this_session.lock() {
Ok(mut set) => {
if !set.insert(input.project_id) {
return not_suggested;
}
}
Err(_) => return not_suggested,
}
let report = self.inspector.inspect().await;
self.events.publish(DomainEvent::EmbedderSuggested {
project_id: input.project_id,
ollama_detected: report.ollama_detected,
onnx_cached: report.onnx_cached_models,
vector_http_enabled: self.vector_http_enabled,
vector_onnx_enabled: self.vector_onnx_enabled,
});
Ok(CheckEmbedderSuggestionOutput { suggested: true })
}
}
// ---------------------------------------------------------------------------
// DismissEmbedderSuggestion
// ---------------------------------------------------------------------------
/// The user's response to the embedder suggestion popup.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DismissChoice {
/// "Plus tard" — re-proposable on a future session.
Later,
/// "Ne plus demander" — never again (persistent).
Never,
}
impl From<DismissChoice> for EmbedderPromptDismissal {
fn from(c: DismissChoice) -> Self {
match c {
DismissChoice::Later => Self::Later,
DismissChoice::Never => Self::Never,
}
}
}
/// Input for [`DismissEmbedderSuggestion::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DismissEmbedderSuggestionInput {
/// The project the suggestion concerned.
pub project_root: ProjectPath,
/// The user's choice.
pub choice: DismissChoice,
}
/// Persists the user's dismissal of the embedder suggestion (LOT C3).
pub struct DismissEmbedderSuggestion {
prompts: Arc<dyn EmbedderPromptStore>,
}
impl DismissEmbedderSuggestion {
/// Builds the use case from the [`EmbedderPromptStore`] port.
#[must_use]
pub fn new(prompts: Arc<dyn EmbedderPromptStore>) -> Self {
Self { prompts }
}
/// Writes the dismissal state. A `later` keeps the suggestion re-proposable on
/// a future session; a `never` silences it for good.
///
/// # Errors
/// [`AppError::Store`] on a persistence failure.
pub async fn execute(&self, input: DismissEmbedderSuggestionInput) -> Result<(), AppError> {
self.prompts
.write(&input.project_root, input.choice.into())
.await?;
Ok(())
}
}

View File

@ -0,0 +1,245 @@
//! Embedder configuration use cases (LOT C2). Each is a single-responsibility
//! struct carrying its ports as `Arc<dyn Port>` and exposing one `execute`.
//!
//! - [`ListEmbedderProfiles`] / [`SaveEmbedderProfile`] / [`DeleteEmbedderProfile`]
//! — CRUD over the persisted [`EmbedderProfile`]s through the
//! [`EmbedderProfileStore`] port.
//! - [`DescribeEmbedderEngines`] — a read-only view of the engines available to the
//! "configure an embedder?" UI: the recommended ONNX model catalogue, a best-effort
//! snapshot of the local environment ([`EmbedderEnvInspector`]), and which strategies
//! are actually compiled into this binary.
//!
//! Hexagonal boundary: the application depends on the domain ports and on plain
//! data only. The static engine catalogue and the compiled-capability flags are
//! **injected** at the composition root (the infrastructure owns `reqwest`/`fastembed`,
//! never the application).
use std::sync::Arc;
use domain::ports::{EmbedderEnvInspector, EmbedderProfileStore};
use domain::profile::{EmbedderProfile, EmbedderStrategy};
use crate::error::AppError;
// ---------------------------------------------------------------------------
// ListEmbedderProfiles
// ---------------------------------------------------------------------------
/// Output of [`ListEmbedderProfiles::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListEmbedderProfilesOutput {
/// All configured embedder profiles (empty when none configured ⇒ `none`).
pub profiles: Vec<EmbedderProfile>,
}
/// Lists the configured embedder profiles from the store.
pub struct ListEmbedderProfiles {
store: Arc<dyn EmbedderProfileStore>,
}
impl ListEmbedderProfiles {
/// Builds the use case from the [`EmbedderProfileStore`] port.
#[must_use]
pub fn new(store: Arc<dyn EmbedderProfileStore>) -> Self {
Self { store }
}
/// Lists configured embedder profiles.
///
/// # Errors
/// [`AppError::Store`] on persistence failure.
pub async fn execute(&self) -> Result<ListEmbedderProfilesOutput, AppError> {
Ok(ListEmbedderProfilesOutput {
profiles: self.store.list().await?,
})
}
}
// ---------------------------------------------------------------------------
// SaveEmbedderProfile
// ---------------------------------------------------------------------------
/// Input for [`SaveEmbedderProfile::execute`]: the raw fields of the profile to
/// upsert (validated into an [`EmbedderProfile`] entity).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SaveEmbedderProfileInput {
/// Stable identifier (e.g. `"local-onnx-minilm"`). Non-empty.
pub id: String,
/// Display name. Non-empty.
pub name: String,
/// Embedding strategy driving which concrete adapter is used.
pub strategy: EmbedderStrategy,
/// Model identifier, when the strategy needs one.
pub model: Option<String>,
/// Endpoint URL for a server/API strategy.
pub endpoint: Option<String>,
/// Name of the env var carrying the API key (never the key itself).
pub api_key_env: Option<String>,
/// Length of the vectors this engine produces. Non-zero.
pub dimension: usize,
}
/// Output of [`SaveEmbedderProfile::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SaveEmbedderProfileOutput {
/// The saved (validated) profile, echoed back.
pub profile: EmbedderProfile,
}
/// Persists (creates or replaces by id) a single embedder profile, after building
/// and validating the entity.
pub struct SaveEmbedderProfile {
store: Arc<dyn EmbedderProfileStore>,
}
impl SaveEmbedderProfile {
/// Builds the use case from the [`EmbedderProfileStore`] port.
#[must_use]
pub fn new(store: Arc<dyn EmbedderProfileStore>) -> Self {
Self { store }
}
/// Validates then saves the profile.
///
/// # Errors
/// - [`AppError::Invalid`] if the profile's invariants are violated (empty
/// `id`/`name`, zero `dimension`),
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(
&self,
input: SaveEmbedderProfileInput,
) -> Result<SaveEmbedderProfileOutput, AppError> {
let profile = EmbedderProfile::new(
input.id,
input.name,
input.strategy,
input.model,
input.endpoint,
input.api_key_env,
input.dimension,
)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.store.save(&profile).await?;
Ok(SaveEmbedderProfileOutput { profile })
}
}
// ---------------------------------------------------------------------------
// DeleteEmbedderProfile
// ---------------------------------------------------------------------------
/// Input for [`DeleteEmbedderProfile::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteEmbedderProfileInput {
/// Id of the embedder profile to delete.
pub id: String,
}
/// Deletes an embedder profile by id.
pub struct DeleteEmbedderProfile {
store: Arc<dyn EmbedderProfileStore>,
}
impl DeleteEmbedderProfile {
/// Builds the use case from the [`EmbedderProfileStore`] port.
#[must_use]
pub fn new(store: Arc<dyn EmbedderProfileStore>) -> Self {
Self { store }
}
/// Deletes the profile.
///
/// # Errors
/// [`AppError::NotFound`] if the id is unknown, [`AppError::Store`] on
/// persistence failure.
pub async fn execute(&self, input: DeleteEmbedderProfileInput) -> Result<(), AppError> {
self.store.delete(&input.id).await?;
Ok(())
}
}
// ---------------------------------------------------------------------------
// DescribeEmbedderEngines
// ---------------------------------------------------------------------------
/// A recommendable local ONNX model, as a plain application value (mirrors the
/// infrastructure `OnnxModelInfo` data, injected at the composition root so the
/// application never depends on the infrastructure crate).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OnnxModelView {
/// Stable model id accepted by a `localOnnx` profile's `model` field.
pub id: String,
/// Human-readable name for the UI.
pub display_name: String,
/// Length of the vectors this model produces.
pub dimension: usize,
/// Approximate download/disk size in megabytes.
pub approx_size_mb: u32,
/// Whether this is the recommended default model.
pub recommended: bool,
}
/// A read-only description of the embedding engines available to the
/// "configure an embedder?" UI (C2/C3).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EmbedderEnginesView {
/// The curated catalogue of recommendable local ONNX models.
pub recommended_onnx: Vec<OnnxModelView>,
/// Whether an Ollama-style local embedding server was detected (best-effort).
pub ollama_detected: bool,
/// Ids of the recommended ONNX models already present in the local cache.
pub onnx_cached_models: Vec<String>,
/// Whether the HTTP capability (`localServer`/`api`) is compiled into this binary.
pub vector_http_enabled: bool,
/// Whether the in-process ONNX capability (`localOnnx`) is compiled into this binary.
pub vector_onnx_enabled: bool,
}
/// Describes the engines available to the embedder-configuration UI: the static
/// ONNX catalogue + compiled-capability flags (injected at construction), enriched
/// with a best-effort live snapshot of the local environment via the
/// [`EmbedderEnvInspector`] port. Read-only — emits no event, never fails on the
/// environment probe (the port is best-effort by contract).
pub struct DescribeEmbedderEngines {
inspector: Arc<dyn EmbedderEnvInspector>,
recommended_onnx: Vec<OnnxModelView>,
vector_http_enabled: bool,
vector_onnx_enabled: bool,
}
impl DescribeEmbedderEngines {
/// Builds the use case from the [`EmbedderEnvInspector`] port plus the static
/// engine catalogue and compiled-capability flags (data owned by infrastructure
/// and injected at the composition root, so the application stays infra-free).
#[must_use]
pub fn new(
inspector: Arc<dyn EmbedderEnvInspector>,
recommended_onnx: Vec<OnnxModelView>,
vector_http_enabled: bool,
vector_onnx_enabled: bool,
) -> Self {
Self {
inspector,
recommended_onnx,
vector_http_enabled,
vector_onnx_enabled,
}
}
/// Returns the engines view. Infallible in practice: the environment probe is
/// best-effort and degrades to "nothing detected".
///
/// # Errors
/// Never; the `Result` keeps the call site uniform with the other use cases.
#[allow(clippy::unused_async)]
pub async fn execute(&self) -> Result<EmbedderEnginesView, AppError> {
let report = self.inspector.inspect().await;
Ok(EmbedderEnginesView {
recommended_onnx: self.recommended_onnx.clone(),
ollama_detected: report.ollama_detected,
onnx_cached_models: report.onnx_cached_models,
vector_http_enabled: self.vector_http_enabled,
vector_onnx_enabled: self.vector_onnx_enabled,
})
}
}

View File

@ -6,8 +6,10 @@
//! with one error shape when building its `ErrorDTO`. //! with one error shape when building its `ErrorDTO`.
use domain::ports::{ use domain::ports::{
FsError, GitError, ProcessError, PtyError, RemoteError, RuntimeError, StoreError, AgentSessionError, EmbedderError, FsError, GitError, MemoryError, ProcessError, PtyError,
RemoteError, RuntimeError, StoreError,
}; };
use domain::{AgentId, NodeId};
/// Errors surfaced by application use cases. /// Errors surfaced by application use cases.
/// ///
@ -43,6 +45,18 @@ pub enum AppError {
#[error("remote error: {0}")] #[error("remote error: {0}")]
Remote(String), Remote(String),
/// An agent is already running in a live cell and cannot be launched again
/// (the "one live session per agent" invariant). Reuse goes through
/// templates (instantiate → distinct agents); IdeA never multi-instances a
/// single agent.
#[error("agent {agent_id} is already running in cell {node_id}")]
AgentAlreadyRunning {
/// The agent that is already live.
agent_id: AgentId,
/// The layout cell (leaf) currently hosting that agent's live session.
node_id: NodeId,
},
/// An unexpected internal error. /// An unexpected internal error.
#[error("internal error: {0}")] #[error("internal error: {0}")]
Internal(String), Internal(String),
@ -61,6 +75,7 @@ impl AppError {
Self::Process(_) => "PROCESS", Self::Process(_) => "PROCESS",
Self::Git(_) => "GIT", Self::Git(_) => "GIT",
Self::Remote(_) => "REMOTE", Self::Remote(_) => "REMOTE",
Self::AgentAlreadyRunning { .. } => "AGENT_ALREADY_RUNNING",
Self::Internal(_) => "INTERNAL", Self::Internal(_) => "INTERNAL",
} }
} }
@ -84,6 +99,26 @@ impl From<StoreError> for AppError {
} }
} }
impl From<MemoryError> for AppError {
fn from(e: MemoryError) -> Self {
match e {
MemoryError::NotFound => Self::NotFound("memory note".to_owned()),
MemoryError::Frontmatter(m) => Self::Invalid(m),
other => Self::Store(other.to_string()),
}
}
}
impl From<EmbedderError> for AppError {
/// Maps to [`AppError::Store`] — an embedder is a *derived* recall detail; its
/// failure must degrade the recall (fallback to naïve), never fail hard. This
/// mapping exists for completeness; recall adapters degrade *before* an
/// embedder error ever reaches a use case.
fn from(e: EmbedderError) -> Self {
Self::Store(e.to_string())
}
}
impl From<PtyError> for AppError { impl From<PtyError> for AppError {
fn from(e: PtyError) -> Self { fn from(e: PtyError) -> Self {
Self::Process(e.to_string()) Self::Process(e.to_string())
@ -113,3 +148,15 @@ impl From<RemoteError> for AppError {
Self::Remote(e.to_string()) Self::Remote(e.to_string())
} }
} }
impl From<AgentSessionError> for AppError {
/// Maps a structured [`AgentSessionError`] (ARCHITECTURE §17.1) onto the single
/// application error shape. `Start`/`Io`/`Decode`/`Timeout` are all execution
/// failures of a live structured session (a process/SDK conversation), so they
/// fold into [`AppError::Process`] — coherent with [`PtyError`]/[`ProcessError`],
/// the byte-stream twins. The raw CLI JSON never travels through `Decode` (the
/// adapter already redacted it), so this is safe to surface.
fn from(e: AgentSessionError) -> Self {
Self::Process(e.to_string())
}
}

View File

@ -6,7 +6,7 @@ mod usecases;
pub use usecases::{ pub use usecases::{
GitBranches, GitBranchesInput, GitBranchesOutput, GitCheckout, GitCheckoutInput, GitCommit, GitBranches, GitBranchesInput, GitBranchesOutput, GitCheckout, GitCheckoutInput, GitCommit,
GitCommitInput, GitCommitOutput, GitGraph, GitGraphInput, GitGraphOutput, GitInit, GitInitInput, GitCommitInput, GitCommitOutput, GitGraph, GitGraphInput, GitGraphOutput, GitInit,
GitLog, GitLogInput, GitLogOutput, GitStage, GitStagePathInput, GitStatus, GitStatusInput, GitInitInput, GitLog, GitLogInput, GitLogOutput, GitStage, GitStagePathInput, GitStatus,
GitStatusOutput, GitUnstage, GitStatusInput, GitStatusOutput, GitUnstage,
}; };

View File

@ -47,12 +47,12 @@ pub struct HealthUseCase {
impl HealthUseCase { impl HealthUseCase {
/// Builds the use case from its injected ports. /// Builds the use case from its injected ports.
#[must_use] #[must_use]
pub fn new(clock: Arc<dyn Clock>, ids: Arc<dyn IdGenerator>, events: Arc<dyn EventBus>) -> Self { pub fn new(
Self { clock: Arc<dyn Clock>,
clock, ids: Arc<dyn IdGenerator>,
ids, events: Arc<dyn EventBus>,
events, ) -> Self {
} Self { clock, ids, events }
} }
/// Executes the health check. /// Executes the health check.

View File

@ -21,6 +21,8 @@
//! `TerminalSessions` registry, keyed by that same `SessionId`. //! `TerminalSessions` registry, keyed by that same `SessionId`.
mod management; mod management;
mod reconcile;
mod snapshot;
mod store; mod store;
mod usecases; mod usecases;
@ -29,6 +31,11 @@ pub use management::{
DeleteLayoutOutput, LayoutInfo, ListLayouts, ListLayoutsInput, ListLayoutsOutput, RenameLayout, DeleteLayoutOutput, LayoutInfo, ListLayouts, ListLayoutsInput, ListLayoutsOutput, RenameLayout,
RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput, RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput,
}; };
pub use reconcile::{ReconcileLayouts, ReconcileLayoutsInput, ReconcileLayoutsOutput};
pub use snapshot::{
SnapshotRunningAgents, SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput,
};
pub(crate) use store::{persist_doc, resolve_doc};
pub use store::{LayoutKind, LayoutsDoc, NamedLayout, LAYOUTS_FILE}; pub use store::{LayoutKind, LayoutsDoc, NamedLayout, LAYOUTS_FILE};
pub use usecases::{ pub use usecases::{
LayoutOperation, LoadLayout, LoadLayoutInput, LoadLayoutOutput, MutateLayout, LayoutOperation, LoadLayout, LoadLayoutInput, LoadLayoutOutput, MutateLayout,

View File

@ -0,0 +1,101 @@
//! [`ReconcileLayouts`] — réconcilie, à l'**ouverture** d'un projet, les
//! `layouts.json` qui contiennent des feuilles en **doublon** sur un même agent
//! (lot R0c du cadrage orchestration v5, §3.4 « Trou C »).
//!
//! C'est la **jumelle** de [`super::snapshot::SnapshotRunningAgents`] : là où le
//! snapshot **gèle** `agent_was_running` à la *fermeture*, cette réconciliation
//! **dé-doublonne** à l'*ouverture*. Un `layouts.json` persisté peut déjà porter
//! deux feuilles sur le **même** `agent` id ; à la réouverture il ne faut **pas**
//! relancer la 2ᵉ. On garde **une** feuille « hôte » (potentiellement vivante /
//! reprenable) et on transforme les autres en **vues mortes** : leur
//! `agent_was_running` repasse à `false` et leur `conversation_id` est retiré.
//! C'est précisément ce qui éliminait le symptôme « une cellule reset au retour
//! d'onglet ».
//!
//! Le use case est un mince orchestrateur au-dessus de :
//! - le store des layouts persistés ([`super::store`]),
//! - l'opération pure du domaine
//! [`domain::LayoutTree::reconcile_duplicate_agents`] (qui porte la **règle
//! déterministe de choix de l'hôte** et la garantie d'idempotence).
//!
//! **Idempotence / no-op** : un layout sans doublon (ou déjà réconcilié) ressort
//! **identique** de l'opération pure ; on ne persiste alors **rien** (aucune
//! écriture). Une 2ᵉ ouverture du même projet réconcilié est donc un no-op.
//!
//! **Ordre d'insertion** : à appeler à l'ouverture du projet, **après** le
//! chargement/résolution des layouts et **avant** toute reprise
//! ([`crate::ListResumableAgents`]) — qui relit la version persistée — de sorte
//! qu'on ne propose / ne relance qu'**une** session par agent.
use std::sync::Arc;
use domain::ports::{FileSystem, ProjectStore};
use domain::ProjectId;
use crate::error::AppError;
use super::store::{persist_doc, resolve_doc};
/// Input de [`ReconcileLayouts::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReconcileLayoutsInput {
/// Le projet dont les layouts doivent être dé-doublonnés.
pub project_id: ProjectId,
}
/// Output de [`ReconcileLayouts::execute`].
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ReconcileLayoutsOutput {
/// `true` si au moins un layout a été modifié (et donc le doc persisté).
/// `false` ⇒ aucun doublon : no-op, aucune écriture (idempotence).
pub changed: bool,
}
/// Réconcilie les feuilles d'agent en doublon de tous les layouts d'un projet,
/// puis persiste le doc **uniquement** s'il a changé.
pub struct ReconcileLayouts {
store: Arc<dyn ProjectStore>,
fs: Arc<dyn FileSystem>,
}
impl ReconcileLayouts {
/// Construit le use case à partir de ses ports injectés.
#[must_use]
pub fn new(store: Arc<dyn ProjectStore>, fs: Arc<dyn FileSystem>) -> Self {
Self { store, fs }
}
/// Exécute la réconciliation pour un projet.
///
/// Pour chaque layout, applique l'opération pure
/// [`domain::LayoutTree::reconcile_duplicate_agents`]. Si **aucun** arbre n'a
/// changé, ne persiste **rien** (no-op idempotent). Sinon, persiste tout le
/// doc une fois.
///
/// # Errors
/// - [`AppError::NotFound`] si le projet est inconnu,
/// - [`AppError::Store`] sur défaillance du registre,
/// - [`AppError::FileSystem`] sur défaillance de persistance.
pub async fn execute(
&self,
input: ReconcileLayoutsInput,
) -> Result<ReconcileLayoutsOutput, AppError> {
let project = self.store.load_project(input.project_id).await?;
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
let mut changed = false;
for named in &mut doc.layouts {
let reconciled = named.tree.reconcile_duplicate_agents();
if reconciled != named.tree {
named.tree = reconciled;
changed = true;
}
}
if changed {
persist_doc(self.fs.as_ref(), &project, &doc).await?;
}
Ok(ReconcileLayoutsOutput { changed })
}
}

View File

@ -0,0 +1,117 @@
//! [`SnapshotRunningAgents`] — freeze, at close time, which agent cells still
//! held a live PTY (feature: "conversation resume", task T5).
//!
//! When the IDE (or a single project) closes, every live PTY is about to be
//! killed. *Before* that happens, we record on each agent-bearing leaf whether
//! its agent process was still running (`agent_was_running = true`) or had
//! already exited / never launched (`false`). On reopen, that flag is what tells
//! the resume popup "this conversation was still in progress" vs "it was closed".
//!
//! The decision is **universal**: it is derived purely from the process
//! lifecycle (the live-session registry), never from parsing CLI output. The use
//! case itself is a thin orchestrator over:
//! - the persisted layouts store ([`super::store`]),
//! - the pure domain operation [`domain::LayoutTree::set_agent_running`],
//! - a [`LiveAgentRegistry`] liveness query.
//!
//! **Ordering contract:** this snapshot reads the registry *as it is at call
//! time*. The composition root (app-tauri) is responsible for calling it
//! **before** the global PTY kill; if the kill ran first, every agent would look
//! "closed". See `app-tauri`'s `CloseRequested` hook and `close.rs` callers.
use std::sync::Arc;
use domain::ports::{FileSystem, ProjectStore};
use domain::ProjectId;
use crate::error::AppError;
use crate::terminal::LiveAgentRegistry;
use super::store::{persist_doc, resolve_doc};
/// Input for [`SnapshotRunningAgents::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SnapshotRunningAgentsInput {
/// The project whose layouts must be frozen.
pub project_id: ProjectId,
}
/// Output of [`SnapshotRunningAgents::execute`].
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SnapshotRunningAgentsOutput {
/// Number of agent-bearing leaves that were found running at snapshot time.
pub running: usize,
/// Number of agent-bearing leaves that were found stopped at snapshot time.
pub stopped: usize,
}
/// Freezes the `agent_was_running` flag on every agent leaf of a project's
/// layouts, from the live-session registry, then persists the layouts.
pub struct SnapshotRunningAgents {
store: Arc<dyn ProjectStore>,
fs: Arc<dyn FileSystem>,
live: Arc<dyn LiveAgentRegistry>,
}
impl SnapshotRunningAgents {
/// Builds the use case from its injected ports.
#[must_use]
pub fn new(
store: Arc<dyn ProjectStore>,
fs: Arc<dyn FileSystem>,
live: Arc<dyn LiveAgentRegistry>,
) -> Self {
Self { store, fs, live }
}
/// Executes the snapshot for one project.
///
/// Walks every named layout of the project; for each agent-bearing leaf it
/// applies [`domain::LayoutTree::set_agent_running`] with the agent's *current*
/// liveness, then persists the whole layouts store. A project with no
/// agent leaf is a no-op (the doc is still resolved/healed, never written if
/// unchanged).
///
/// # Errors
/// - [`AppError::NotFound`] if the project is unknown,
/// - [`AppError::FileSystem`] on persistence failure,
/// - [`AppError::Store`] on registry I/O failure.
pub async fn execute(
&self,
input: SnapshotRunningAgentsInput,
) -> Result<SnapshotRunningAgentsOutput, AppError> {
let project = self.store.load_project(input.project_id).await?;
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
let mut out = SnapshotRunningAgentsOutput::default();
let mut changed = false;
for named in &mut doc.layouts {
for (leaf_id, _agent_id) in named.tree.agent_leaves() {
// Liveness is keyed on the hosting *node*, not the agent: with the
// one-live-session-per-agent invariant, an agent pinned on several
// leaves is live in at most one cell, so only that cell must be
// marked running (a duplicate leaf for the same agent stays false).
let running = self.live.is_node_live(&leaf_id);
if running {
out.running += 1;
} else {
out.stopped += 1;
}
// Pure op: only NodeNotFound is possible, which cannot happen
// since `leaf_id` came from this very tree.
named.tree = named
.tree
.set_agent_running(leaf_id, running)
.map_err(|e| AppError::Invalid(e.to_string()))?;
changed = true;
}
}
if changed {
persist_doc(self.fs.as_ref(), &project, &doc).await?;
}
Ok(out)
}
}

View File

@ -99,11 +99,7 @@ impl LayoutsDoc {
/// The default single-cell layout tree (one empty leaf). /// The default single-cell layout tree (one empty leaf).
#[must_use] #[must_use]
pub fn default_tree() -> LayoutTree { pub fn default_tree() -> LayoutTree {
LayoutTree::single(LeafCell { LayoutTree::single(LeafCell::new(NodeId::new_random()))
id: NodeId::new_random(),
session: None,
agent: None,
})
} }
/// Builds a fresh doc holding one layout (`tree`) made active. /// Builds a fresh doc holding one layout (`tree`) made active.
@ -149,7 +145,8 @@ pub async fn persist_doc(
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let ideai_dir = RemotePath::new(join_root(&project.root, IDEAI_DIR)); let ideai_dir = RemotePath::new(join_root(&project.root, IDEAI_DIR));
fs.create_dir_all(&ideai_dir).await?; fs.create_dir_all(&ideai_dir).await?;
fs.write(&layouts_path(project), &to_json_bytes(doc)?).await?; fs.write(&layouts_path(project), &to_json_bytes(doc)?)
.await?;
Ok(()) Ok(())
} }

View File

@ -8,7 +8,10 @@
use std::sync::Arc; use std::sync::Arc;
use domain::ports::{EventBus, FileSystem, ProjectStore}; use domain::ports::{EventBus, FileSystem, ProjectStore};
use domain::{AgentId, Direction, DomainEvent, LayoutError, LayoutId, LayoutTree, LeafCell, NodeId, ProjectId, SessionId}; use domain::{
AgentId, Direction, DomainEvent, LayoutError, LayoutId, LayoutTree, LeafCell, NodeId,
ProjectId, SessionId,
};
use crate::error::AppError; use crate::error::AppError;
@ -69,6 +72,14 @@ pub enum LayoutOperation {
/// Session to host, or `None` to clear. /// Session to host, or `None` to clear.
session: Option<SessionId>, session: Option<SessionId>,
}, },
/// Attach an existing live/background session to a leaf, clearing any
/// previous visible host of the same session.
AttachSession {
/// The hosting leaf.
target: NodeId,
/// Session to make visible in this leaf.
session: SessionId,
},
/// Attach or detach an agent to/from a leaf (per-cell agent, feature #3). /// Attach or detach an agent to/from a leaf (per-cell agent, feature #3).
SetCellAgent { SetCellAgent {
/// The hosting leaf. /// The hosting leaf.
@ -76,6 +87,17 @@ pub enum LayoutOperation {
/// Agent to associate, or `None` to clear. /// Agent to associate, or `None` to clear.
agent: Option<AgentId>, agent: Option<AgentId>,
}, },
/// Record (or clear) the persistent CLI conversation id on a leaf (T4b).
///
/// Persisting the id minted at first launch is what makes session resume
/// effective: on the next open, the leaf carries the id and the launch
/// resumes the conversation instead of assigning a new one.
SetCellConversation {
/// The hosting leaf.
target: NodeId,
/// Conversation id to record, or `None` to clear.
conversation_id: Option<String>,
},
} }
impl LayoutOperation { impl LayoutOperation {
@ -87,16 +109,7 @@ impl LayoutOperation {
direction, direction,
new_leaf, new_leaf,
container, container,
} => tree.split( } => tree.split(*target, *direction, LeafCell::new(*new_leaf), *container),
*target,
*direction,
LeafCell {
id: *new_leaf,
session: None,
agent: None,
},
*container,
),
Self::Merge { Self::Merge {
container, container,
keep_index, keep_index,
@ -104,7 +117,12 @@ impl LayoutOperation {
Self::Resize { container, weights } => tree.resize(*container, weights), Self::Resize { container, weights } => tree.resize(*container, weights),
Self::Move { from, to } => tree.move_session(*from, *to), Self::Move { from, to } => tree.move_session(*from, *to),
Self::SetSession { target, session } => tree.set_session(*target, *session), Self::SetSession { target, session } => tree.set_session(*target, *session),
Self::AttachSession { target, session } => tree.attach_session(*target, *session),
Self::SetCellAgent { target, agent } => tree.set_cell_agent(*target, *agent), Self::SetCellAgent { target, agent } => tree.set_cell_agent(*target, *agent),
Self::SetCellConversation {
target,
conversation_id,
} => tree.set_cell_conversation(*target, conversation_id.clone()),
}; };
result.map_err(map_layout_err) result.map_err(map_layout_err)
} }

View File

@ -12,46 +12,92 @@
#![warn(missing_docs)] #![warn(missing_docs)]
pub mod agent; pub mod agent;
pub mod conversation;
pub mod embedder;
pub mod error; pub mod error;
pub mod git; pub mod git;
pub mod health; pub mod health;
pub mod layout; pub mod layout;
pub mod memory;
pub mod orchestrator;
pub mod permission;
pub mod project; pub mod project;
pub mod remote; pub mod remote;
pub mod skill;
pub mod template; pub mod template;
pub mod terminal; pub mod terminal;
pub mod window; pub mod window;
pub use agent::{ pub use agent::{
reference_profile_id, reference_profiles, ConfigureProfiles, ConfigureProfilesInput, drain_with_readiness, reference_profile_id, reference_profiles, selectable_reference_profiles,
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, send_blocking, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput,
DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles, ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, CreateAgentFromScratch,
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, LaunchAgent, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, DeleteProfile,
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState,
ListProfiles, ListProfilesOutput, ProfileAvailability, ReadAgentContext, ReadAgentContextInput, FirstRunStateOutput, HandoffProvider, InspectConversation, InspectConversationInput,
ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, SaveProfile, InspectConversationOutput, LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents,
SaveProfileInput, SaveProfileOutput, UpdateAgentContext, UpdateAgentContextInput, ListAgentsInput, ListAgentsOutput, ListProfiles, ListProfilesOutput, ListResumableAgents,
ListResumableAgentsInput, ListResumableAgentsOutput, McpRuntime, PermissionProjectorRegistry,
ProfileAvailability,
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile, SaveProfileInput,
SaveProfileOutput, StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput,
AGENT_MEMORY_RECALL_BUDGET,
};
pub use conversation::RecordTurn;
pub use embedder::{
CheckEmbedderSuggestion, CheckEmbedderSuggestionInput, CheckEmbedderSuggestionOutput,
DeleteEmbedderProfile, DeleteEmbedderProfileInput, DescribeEmbedderEngines, DismissChoice,
DismissEmbedderSuggestion, DismissEmbedderSuggestionInput, EmbedderEnginesView,
ListEmbedderProfiles, ListEmbedderProfilesOutput, OnnxModelView, SaveEmbedderProfile,
SaveEmbedderProfileInput, SaveEmbedderProfileOutput, SuggestedThisSession,
}; };
pub use error::AppError; pub use error::AppError;
pub use git::{ pub use git::{
GitBranches, GitBranchesInput, GitBranchesOutput, GitCheckout, GitCheckoutInput, GitCommit, GitBranches, GitBranchesInput, GitBranchesOutput, GitCheckout, GitCheckoutInput, GitCommit,
GitCommitInput, GitCommitOutput, GitGraph, GitGraphInput, GitGraphOutput, GitInit, GitInitInput, GitCommitInput, GitCommitOutput, GitGraph, GitGraphInput, GitGraphOutput, GitInit,
GitLog, GitLogInput, GitLogOutput, GitStage, GitStagePathInput, GitStatus, GitStatusInput, GitInitInput, GitLog, GitLogInput, GitLogOutput, GitStage, GitStagePathInput, GitStatus,
GitStatusOutput, GitUnstage, GitStatusInput, GitStatusOutput, GitUnstage,
}; };
pub use health::{HealthInput, HealthReport, HealthUseCase}; pub use health::{HealthInput, HealthReport, HealthUseCase};
pub use remote::{ConnectRemote, ConnectRemoteInput, ConnectRemoteOutput};
pub use layout::{ pub use layout::{
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput, CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,
DeleteLayoutOutput, LayoutInfo, LayoutKind, LayoutOperation, LayoutsDoc, ListLayouts, DeleteLayoutOutput, LayoutInfo, LayoutKind, LayoutOperation, LayoutsDoc, ListLayouts,
ListLayoutsInput, ListLayoutsOutput, LoadLayout, LoadLayoutInput, LoadLayoutOutput, ListLayoutsInput, ListLayoutsOutput, LoadLayout, LoadLayoutInput, LoadLayoutOutput,
MutateLayout, MutateLayoutInput, MutateLayoutOutput, NamedLayout, RenameLayout, MutateLayout, MutateLayoutInput, MutateLayoutOutput, NamedLayout, ReconcileLayouts,
RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput, LAYOUTS_FILE, ReconcileLayoutsInput, ReconcileLayoutsOutput, RenameLayout, RenameLayoutInput,
SetActiveLayout, SetActiveLayoutInput, SnapshotRunningAgents, SnapshotRunningAgentsInput,
SnapshotRunningAgentsOutput, LAYOUTS_FILE,
};
pub use memory::{
CreateMemory, CreateMemoryInput, CreateMemoryOutput, DeleteMemory, DeleteMemoryInput,
GetMemory, GetMemoryInput, GetMemoryOutput, ListMemories, ListMemoriesInput,
ListMemoriesOutput, ReadMemoryIndex, ReadMemoryIndexInput, ReadMemoryIndexOutput, RecallMemory,
RecallMemoryInput, RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput,
ResolveMemoryLinksOutput, UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
};
pub use orchestrator::{
McpRuntimeProvider, OrchestratorOutcome, OrchestratorService, RecordTurnProvider,
};
pub use permission::{
GetProjectPermissions, GetProjectPermissionsInput, GetProjectPermissionsOutput,
ResolveAgentPermissions, ResolveAgentPermissionsInput, ResolveAgentPermissionsOutput,
UpdateAgentPermissions, UpdateAgentPermissionsInput, UpdateProjectPermissions,
UpdateProjectPermissionsInput,
}; };
pub use project::{ pub use project::{
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject, CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
CreateProjectInput, CreateProjectOutput, ListProjects, ListProjectsOutput, OpenProject, CreateProjectInput, CreateProjectOutput, ListProjects, ListProjectsOutput, OpenProject,
OpenProjectInput, OpenProjectOutput, ProjectMeta, OpenProjectInput, OpenProjectOutput, ProjectMeta, ReadProjectContext, ReadProjectContextInput,
ReadProjectContextOutput, UpdateProjectContext, UpdateProjectContextInput,
PROJECT_CONTEXT_FILE,
};
pub use remote::{ConnectRemote, ConnectRemoteInput, ConnectRemoteOutput};
pub use skill::{
AssignSkillToAgent, AssignSkillToAgentInput, CreateSkill, CreateSkillInput, CreateSkillOutput,
DeleteSkill, DeleteSkillInput, ListSkills, ListSkillsInput, ListSkillsOutput,
UnassignSkillFromAgent, UnassignSkillFromAgentInput, UpdateSkill, UpdateSkillInput,
UpdateSkillOutput,
}; };
pub use template::{ pub use template::{
AgentDrift, CreateAgentFromTemplate, CreateAgentFromTemplateInput, AgentDrift, CreateAgentFromTemplate, CreateAgentFromTemplateInput,
@ -62,8 +108,8 @@ pub use template::{
UpdateTemplateOutput, UpdateTemplateOutput,
}; };
pub use terminal::{ pub use terminal::{
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, OpenTerminal, OpenTerminalInput, CloseTerminal, CloseTerminalInput, CloseTerminalOutput, LiveAgentRegistry, LiveSessions,
OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, TerminalSessions, WriteToTerminal, OpenTerminal, OpenTerminalInput, OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput,
WriteToTerminalInput, StructuredSessions, TerminalSessions, WriteToTerminal, WriteToTerminalInput,
}; };
pub use window::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput}; pub use window::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput};

View File

@ -0,0 +1,23 @@
//! Memory use cases (ARCHITECTURE §14.5.1; LOT A, étage 1: `.md`).
//!
//! The memory module owns the CRUD of a project's persistent knowledge base —
//! one Markdown note per [`domain::memory::Memory`], stored under
//! `.ideai/memory/<slug>.md` with a derived `MEMORY.md` index. On top of the
//! CRUD it exposes two read-only navigation helpers driving the graphical memory
//! view: [`ReadMemoryIndex`] (the structured index) and [`ResolveMemoryLinks`]
//! (a note's resolved `[[slug]]` outgoing links, broken links dropped).
//!
//! Every use case talks only to ports ([`domain::ports::MemoryStore`],
//! [`domain::ports::EventBus`]). Mutating use cases ([`CreateMemory`],
//! [`UpdateMemory`], [`DeleteMemory`]) announce a [`domain::DomainEvent`]; the
//! read use cases emit nothing.
mod usecases;
pub use usecases::{
CreateMemory, CreateMemoryInput, CreateMemoryOutput, DeleteMemory, DeleteMemoryInput,
GetMemory, GetMemoryInput, GetMemoryOutput, ListMemories, ListMemoriesInput,
ListMemoriesOutput, ReadMemoryIndex, ReadMemoryIndexInput, ReadMemoryIndexOutput, RecallMemory,
RecallMemoryInput, RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput,
ResolveMemoryLinksOutput, UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
};

View File

@ -0,0 +1,418 @@
//! Memory use cases (ARCHITECTURE §14.5.1).
//!
//! - **CRUD**: [`CreateMemory`], [`UpdateMemory`], [`GetMemory`],
//! [`ListMemories`], [`DeleteMemory`].
//! - **Navigation** (read-only): [`ReadMemoryIndex`] (structured `MEMORY.md`
//! rows) and [`ResolveMemoryLinks`] (a note's outgoing `[[slug]]` links).
//!
//! Mutating use cases announce a [`DomainEvent`]; the read use cases emit none.
use std::sync::Arc;
use domain::ports::{EventBus, MemoryQuery, MemoryRecall, MemoryStore};
use domain::{
DomainEvent, MarkdownDoc, Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug,
MemoryType, ProjectPath,
};
use crate::error::AppError;
// ---------------------------------------------------------------------------
// CreateMemory
// ---------------------------------------------------------------------------
/// Input for [`CreateMemory::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateMemoryInput {
/// Active project root (the note is stored under its `.ideai/memory/`).
pub project_root: ProjectPath,
/// Raw slug for the new note (validated into a [`MemorySlug`]).
pub name: String,
/// Human-readable one-line description (the index hook). Non-empty.
pub description: String,
/// The note's kind.
pub r#type: MemoryType,
/// Markdown body of the note.
pub content: String,
}
/// Output of [`CreateMemory::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateMemoryOutput {
/// The created note.
pub memory: Memory,
}
/// Creates a memory note in the project's store and upserts the index.
///
/// Announces [`DomainEvent::MemorySaved`] on success.
pub struct CreateMemory {
memories: Arc<dyn MemoryStore>,
events: Arc<dyn EventBus>,
}
impl CreateMemory {
/// Builds the use case from its ports.
#[must_use]
pub fn new(memories: Arc<dyn MemoryStore>, events: Arc<dyn EventBus>) -> Self {
Self { memories, events }
}
/// Executes creation.
///
/// # Errors
/// - [`AppError::Invalid`] if `name` is not a valid kebab-case slug or the
/// note's invariants are violated (empty description/body),
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: CreateMemoryInput) -> Result<CreateMemoryOutput, AppError> {
let slug = MemorySlug::new(input.name).map_err(|e| AppError::Invalid(e.to_string()))?;
let frontmatter = MemoryFrontmatter {
name: slug.clone(),
description: input.description,
r#type: input.r#type,
};
let memory = Memory::new(frontmatter, MarkdownDoc::new(input.content))
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.memories.save(&input.project_root, &memory).await?;
self.events.publish(DomainEvent::MemorySaved { slug });
Ok(CreateMemoryOutput { memory })
}
}
// ---------------------------------------------------------------------------
// UpdateMemory
// ---------------------------------------------------------------------------
/// Input for [`UpdateMemory::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateMemoryInput {
/// Active project root.
pub project_root: ProjectPath,
/// Slug of the note to replace.
pub slug: MemorySlug,
/// New description (the index hook). Non-empty.
pub description: String,
/// New kind.
pub r#type: MemoryType,
/// New Markdown body.
pub content: String,
}
/// Output of [`UpdateMemory::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateMemoryOutput {
/// The updated note.
pub memory: Memory,
}
/// Replaces a memory note's content (re-validating its invariants).
///
/// Announces [`DomainEvent::MemorySaved`] on success. The input carries the full
/// note, so no prior `get` is needed — this is replace semantics.
pub struct UpdateMemory {
memories: Arc<dyn MemoryStore>,
events: Arc<dyn EventBus>,
}
impl UpdateMemory {
/// Builds the use case from its ports.
#[must_use]
pub fn new(memories: Arc<dyn MemoryStore>, events: Arc<dyn EventBus>) -> Self {
Self { memories, events }
}
/// Executes the update.
///
/// # Errors
/// - [`AppError::Invalid`] if the note's invariants are violated (empty
/// description/body),
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: UpdateMemoryInput) -> Result<UpdateMemoryOutput, AppError> {
let frontmatter = MemoryFrontmatter {
name: input.slug.clone(),
description: input.description,
r#type: input.r#type,
};
let memory = Memory::new(frontmatter, MarkdownDoc::new(input.content))
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.memories.save(&input.project_root, &memory).await?;
self.events
.publish(DomainEvent::MemorySaved { slug: input.slug });
Ok(UpdateMemoryOutput { memory })
}
}
// ---------------------------------------------------------------------------
// ListMemories
// ---------------------------------------------------------------------------
/// Input for [`ListMemories::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListMemoriesInput {
/// Active project root.
pub project_root: ProjectPath,
}
/// Output of [`ListMemories::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListMemoriesOutput {
/// All notes in the project.
pub memories: Vec<Memory>,
}
/// Lists the memory notes of a project.
pub struct ListMemories {
memories: Arc<dyn MemoryStore>,
}
impl ListMemories {
/// Builds the use case.
#[must_use]
pub fn new(memories: Arc<dyn MemoryStore>) -> Self {
Self { memories }
}
/// Lists the project's notes.
///
/// # Errors
/// - [`AppError::Invalid`] if a note's frontmatter is malformed,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: ListMemoriesInput) -> Result<ListMemoriesOutput, AppError> {
Ok(ListMemoriesOutput {
memories: self.memories.list(&input.project_root).await?,
})
}
}
// ---------------------------------------------------------------------------
// GetMemory
// ---------------------------------------------------------------------------
/// Input for [`GetMemory::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GetMemoryInput {
/// Active project root.
pub project_root: ProjectPath,
/// Slug of the note to read.
pub slug: MemorySlug,
}
/// Output of [`GetMemory::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GetMemoryOutput {
/// The requested note.
pub memory: Memory,
}
/// Reads one memory note by slug.
pub struct GetMemory {
memories: Arc<dyn MemoryStore>,
}
impl GetMemory {
/// Builds the use case.
#[must_use]
pub fn new(memories: Arc<dyn MemoryStore>) -> Self {
Self { memories }
}
/// Reads the note.
///
/// # Errors
/// - [`AppError::NotFound`] if no note carries that slug,
/// - [`AppError::Invalid`] if the note's frontmatter is malformed,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: GetMemoryInput) -> Result<GetMemoryOutput, AppError> {
Ok(GetMemoryOutput {
memory: self.memories.get(&input.project_root, &input.slug).await?,
})
}
}
// ---------------------------------------------------------------------------
// DeleteMemory
// ---------------------------------------------------------------------------
/// Input for [`DeleteMemory::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteMemoryInput {
/// Active project root.
pub project_root: ProjectPath,
/// Slug of the note to delete.
pub slug: MemorySlug,
}
/// Deletes a memory note (and removes its index row).
///
/// Announces [`DomainEvent::MemoryDeleted`] on success.
pub struct DeleteMemory {
memories: Arc<dyn MemoryStore>,
events: Arc<dyn EventBus>,
}
impl DeleteMemory {
/// Builds the use case from its ports.
#[must_use]
pub fn new(memories: Arc<dyn MemoryStore>, events: Arc<dyn EventBus>) -> Self {
Self { memories, events }
}
/// Deletes the note.
///
/// # Errors
/// - [`AppError::NotFound`] if no note carries that slug,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: DeleteMemoryInput) -> Result<(), AppError> {
self.memories
.delete(&input.project_root, &input.slug)
.await?;
self.events
.publish(DomainEvent::MemoryDeleted { slug: input.slug });
Ok(())
}
}
// ---------------------------------------------------------------------------
// ReadMemoryIndex
// ---------------------------------------------------------------------------
/// Input for [`ReadMemoryIndex::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadMemoryIndexInput {
/// Active project root.
pub project_root: ProjectPath,
}
/// Output of [`ReadMemoryIndex::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadMemoryIndexOutput {
/// One row per note in the aggregated `MEMORY.md` index.
pub entries: Vec<MemoryIndexEntry>,
}
/// Reads the structured `MEMORY.md` index (drives the graphical memory view).
pub struct ReadMemoryIndex {
memories: Arc<dyn MemoryStore>,
}
impl ReadMemoryIndex {
/// Builds the use case.
#[must_use]
pub fn new(memories: Arc<dyn MemoryStore>) -> Self {
Self { memories }
}
/// Reads the index rows.
///
/// # Errors
/// - [`AppError::Store`] on an I/O failure.
pub async fn execute(
&self,
input: ReadMemoryIndexInput,
) -> Result<ReadMemoryIndexOutput, AppError> {
Ok(ReadMemoryIndexOutput {
entries: self.memories.read_index(&input.project_root).await?,
})
}
}
// ---------------------------------------------------------------------------
// RecallMemory
// ---------------------------------------------------------------------------
/// Input for [`RecallMemory::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecallMemoryInput {
/// Active project root.
pub project_root: ProjectPath,
/// The recall query (often the agent's current working context).
pub text: String,
/// Approximate token budget bounding the returned entries (`0` ⇒ empty).
pub token_budget: usize,
}
/// Output of [`RecallMemory::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecallMemoryOutput {
/// The recalled index entries, in relevance order, fitting the budget.
pub entries: Vec<MemoryIndexEntry>,
}
/// Recalls the most relevant memory entries for a query within a token budget
/// (LOT B, étage 1). Read-only — emits no event.
pub struct RecallMemory {
recall: Arc<dyn MemoryRecall>,
}
impl RecallMemory {
/// Builds the use case from the [`MemoryRecall`] port.
#[must_use]
pub fn new(recall: Arc<dyn MemoryRecall>) -> Self {
Self { recall }
}
/// Executes recall. Best-effort: an empty or absent memory yields an empty
/// list, and a budget of `0` yields an empty list.
///
/// # Errors
/// - [`AppError::Store`] on an unexpected I/O failure of the underlying store.
pub async fn execute(&self, input: RecallMemoryInput) -> Result<RecallMemoryOutput, AppError> {
let query = MemoryQuery {
text: input.text,
token_budget: input.token_budget,
};
Ok(RecallMemoryOutput {
entries: self.recall.recall(&input.project_root, &query).await?,
})
}
}
// ---------------------------------------------------------------------------
// ResolveMemoryLinks
// ---------------------------------------------------------------------------
/// Input for [`ResolveMemoryLinks::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolveMemoryLinksInput {
/// Active project root.
pub project_root: ProjectPath,
/// Slug of the source note whose outgoing links are resolved.
pub slug: MemorySlug,
}
/// Output of [`ResolveMemoryLinks::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolveMemoryLinksOutput {
/// The note's resolved outgoing `[[slug]]` links (broken links dropped).
pub links: Vec<MemoryLink>,
}
/// Resolves a note's outgoing `[[slug]]` links (drives link navigation).
pub struct ResolveMemoryLinks {
memories: Arc<dyn MemoryStore>,
}
impl ResolveMemoryLinks {
/// Builds the use case.
#[must_use]
pub fn new(memories: Arc<dyn MemoryStore>) -> Self {
Self { memories }
}
/// Resolves the outgoing links.
///
/// # Errors
/// - [`AppError::NotFound`] if the source note does not exist,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(
&self,
input: ResolveMemoryLinksInput,
) -> Result<ResolveMemoryLinksOutput, AppError> {
Ok(ResolveMemoryLinksOutput {
links: self
.memories
.resolve_links(&input.project_root, &input.slug)
.await?,
})
}
}

View File

@ -0,0 +1,839 @@
//! FileGuard-mediated context & memory use cases (cadrage C7).
//!
//! Four use cases — [`ReadContext`], [`ProposeContext`], [`ReadMemory`],
//! [`WriteMemory`] — route every read/write of IdeA-owned `.md` context and memory
//! through the domain [`FileGuard`] port **before** touching a store. Each acquires
//! the right lease (shared read / exclusive write) for the requesting
//! [`ConversationParty`], then delegates to the existing store ports.
//!
//! ## Single-writer global context
//!
//! The global project context is single-writer: only the orchestrator
//! ([`ConversationParty::User`]) may write it directly. A project agent that
//! *proposes* a change to the global context receives [`GuardError::Forbidden`] from
//! the guard; [`ProposeContext`] catches that and **materialises a proposal** under
//! `.ideai/proposals/<who>-<ts>.md` for later validation by the orchestrator/UI —
//! never overwriting the live context. An agent's *own* `.md` (and memory) is written
//! directly under a write-lease.
//!
//! ## Cooperative scope (cadrage §9.5)
//!
//! The guard is **cooperative**: it serialises access inside the IdeA path (these use
//! cases + the MCP tools). It does **not** sandbox an agent that keeps a raw shell —
//! airtight revocation of raw fs access is an OS-sandbox (Landlock) concern, out of
//! scope here.
use std::sync::Arc;
use domain::conversation::ConversationParty;
use domain::fileguard::{FileGuard, GuardError, GuardedResource};
use domain::markdown::MarkdownDoc;
use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType};
use domain::ports::{AgentContextStore, Clock, FileSystem, MemoryStore, RemotePath};
use domain::{AgentId, Project};
use crate::error::AppError;
/// Convention filename of the project's global context at the project root.
const PROJECT_CONTEXT_FILE: &str = "CLAUDE.md";
/// `.ideai/` subdirectory where a rejected global-context change is materialised.
const PROPOSALS_DIR: &str = ".ideai/proposals";
/// Joins a project root with a POSIX-relative segment (valid on every target).
fn join_root(project: &Project, rel: &str) -> RemotePath {
let base = project.root.as_str().trim_end_matches(['/', '\\']);
RemotePath::new(format!("{base}/{rel}"))
}
/// Resolves an agent display name to its [`AgentId`] via the project manifest
/// (case-insensitive), or [`AppError::NotFound`].
async fn resolve_agent(
contexts: &Arc<dyn AgentContextStore>,
project: &Project,
name: &str,
) -> Result<AgentId, AppError> {
let manifest = contexts.load_manifest(project).await?;
manifest
.entries
.into_iter()
.find(|e| e.name.eq_ignore_ascii_case(name))
.map(|e| e.agent_id)
.ok_or_else(|| AppError::NotFound(format!("agent `{name}`")))
}
/// Reads an IdeA-owned context under a **shared read-lease** ([`GuardedResource`]).
///
/// `target` absent ⇒ the global project context; otherwise the named agent's `.md`.
pub struct ReadContext {
guard: Arc<dyn FileGuard>,
contexts: Arc<dyn AgentContextStore>,
fs: Arc<dyn FileSystem>,
}
/// Input for [`ReadContext`].
pub struct ReadContextInput {
/// The project to read within.
pub project: Project,
/// Target agent display name; `None` ⇒ the global project context.
pub target: Option<String>,
/// The reading party (drives the read-lease holder identity).
pub requester: ConversationParty,
}
impl ReadContext {
/// Builds the use case from its ports.
#[must_use]
pub fn new(
guard: Arc<dyn FileGuard>,
contexts: Arc<dyn AgentContextStore>,
fs: Arc<dyn FileSystem>,
) -> Self {
Self {
guard,
contexts,
fs,
}
}
/// Reads the requested context, returning its Markdown body.
///
/// # Errors
/// [`AppError`] when the agent/context does not exist or the store/fs fails.
pub async fn execute(&self, input: ReadContextInput) -> Result<MarkdownDoc, AppError> {
let ReadContextInput {
project,
target,
requester,
} = input;
match target {
None => {
// Global project context: shared read-lease, then read the root file.
let _lease = self
.guard
.acquire_read(requester, GuardedResource::ProjectContext)
.await
.map_err(map_guard_err)?;
let path = join_root(&project, PROJECT_CONTEXT_FILE);
let bytes = self.fs.read(&path).await?;
let text =
String::from_utf8(bytes).map_err(|e| AppError::Invalid(e.to_string()))?;
Ok(MarkdownDoc::new(text))
}
Some(name) => {
let agent = resolve_agent(&self.contexts, &project, &name).await?;
let _lease = self
.guard
.acquire_read(requester, GuardedResource::AgentContext(agent))
.await
.map_err(map_guard_err)?;
Ok(self.contexts.read_context(&project, &agent).await?)
}
}
}
}
/// Proposes new content for an IdeA-owned context under the [`FileGuard`].
///
/// For an **agent** context: a direct write under an exclusive write-lease. For the
/// **global** project context by a non-orchestrator: the guard returns
/// [`GuardError::Forbidden`], which this use case turns into a *materialised proposal*
/// (a file under `.ideai/proposals/`) — never an overwrite of the live context.
pub struct ProposeContext {
guard: Arc<dyn FileGuard>,
contexts: Arc<dyn AgentContextStore>,
fs: Arc<dyn FileSystem>,
clock: Arc<dyn Clock>,
}
/// Outcome of a [`ProposeContext`] call: whether it wrote directly or filed a proposal.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProposeOutcome {
/// The content was written directly (agent context, or global by the orchestrator).
Written,
/// A proposal was materialised at the given `.ideai/proposals/…` path (a
/// non-orchestrator targeting the single-writer global context).
Proposed {
/// The proposal file's path.
path: String,
},
}
/// Input for [`ProposeContext`].
pub struct ProposeContextInput {
/// The project to write within.
pub project: Project,
/// Target agent display name; `None` ⇒ the global project context.
pub target: Option<String>,
/// The proposed Markdown body.
pub content: String,
/// The proposing party.
pub requester: ConversationParty,
}
impl ProposeContext {
/// Builds the use case from its ports.
#[must_use]
pub fn new(
guard: Arc<dyn FileGuard>,
contexts: Arc<dyn AgentContextStore>,
fs: Arc<dyn FileSystem>,
clock: Arc<dyn Clock>,
) -> Self {
Self {
guard,
contexts,
fs,
clock,
}
}
/// Applies the proposal: direct write under a write-lease, or a materialised
/// proposal when the guard forbids a direct global-context write.
///
/// # Errors
/// [`AppError`] when the agent does not exist or the store/fs fails.
pub async fn execute(&self, input: ProposeContextInput) -> Result<ProposeOutcome, AppError> {
let ProposeContextInput {
project,
target,
content,
requester,
} = input;
match target {
Some(name) => {
// Per-agent context: direct write under an exclusive write-lease.
let agent = resolve_agent(&self.contexts, &project, &name).await?;
let _lease = self
.guard
.acquire_write(requester, GuardedResource::AgentContext(agent))
.await
.map_err(map_guard_err)?;
self.contexts
.write_context(&project, &agent, &MarkdownDoc::new(content))
.await?;
Ok(ProposeOutcome::Written)
}
None => {
// Global project context: single-writer. Try to acquire the write
// lease; Forbidden ⇒ materialise a proposal instead of overwriting.
match self
.guard
.acquire_write(requester, GuardedResource::ProjectContext)
.await
{
Ok(_lease) => {
let path = join_root(&project, PROJECT_CONTEXT_FILE);
self.fs.write(&path, content.as_bytes()).await?;
Ok(ProposeOutcome::Written)
}
Err(GuardError::Forbidden) => {
let path = self.file_proposal(&project, requester, &content).await?;
Ok(ProposeOutcome::Proposed { path })
}
Err(other) => Err(map_guard_err(other)),
}
}
}
}
/// Materialises a rejected global-context change as a proposal file under
/// `.ideai/proposals/<who>-<ts>.md`, returning its path.
async fn file_proposal(
&self,
project: &Project,
who: ConversationParty,
content: &str,
) -> Result<String, AppError> {
let who_label = match who {
ConversationParty::User => "orchestrator".to_owned(),
ConversationParty::Agent { agent_id } => agent_id.to_string(),
};
let ts = self.clock.now_millis();
let rel = format!("{PROPOSALS_DIR}/{who_label}-{ts}.md");
let dir = join_root(project, PROPOSALS_DIR);
self.fs.create_dir_all(&dir).await?;
let path = join_root(project, &rel);
self.fs.write(&path, content.as_bytes()).await?;
Ok(path.as_str().to_owned())
}
}
/// Reads project memory under a shared read-lease.
///
/// `slug` absent ⇒ the aggregated index (as Markdown lines); otherwise one note's body.
pub struct ReadMemory {
guard: Arc<dyn FileGuard>,
memory: Arc<dyn MemoryStore>,
}
/// Input for [`ReadMemory`].
pub struct ReadMemoryInput {
/// The project to read within.
pub project: Project,
/// Target note slug; `None` ⇒ the aggregated index.
pub slug: Option<String>,
/// The reading party.
pub requester: ConversationParty,
}
impl ReadMemory {
/// Builds the use case from its ports.
#[must_use]
pub fn new(guard: Arc<dyn FileGuard>, memory: Arc<dyn MemoryStore>) -> Self {
Self { guard, memory }
}
/// Reads the requested memory, returning its Markdown content.
///
/// # Errors
/// [`AppError`] when the note does not exist or the store fails. An invalid slug
/// is [`AppError::Invalid`].
pub async fn execute(&self, input: ReadMemoryInput) -> Result<String, AppError> {
let ReadMemoryInput {
project,
slug,
requester,
} = input;
match slug {
Some(raw) => {
let slug = MemorySlug::new(raw).map_err(|e| AppError::Invalid(e.to_string()))?;
let _lease = self
.guard
.acquire_read(requester, GuardedResource::Memory(slug.clone()))
.await
.map_err(map_guard_err)?;
let note = self.memory.get(&project.root, &slug).await?;
Ok(note.body.into_string())
}
None => {
// The aggregated index is project-shared; read it as a rendered list.
let entries = self.memory.read_index(&project.root).await?;
let lines: Vec<String> = entries
.into_iter()
.map(|e| format!("- [{}]({}.md) — {}", e.title, e.slug, e.hook))
.collect();
Ok(lines.join("\n"))
}
}
}
}
/// Writes (creates or replaces) a project memory note under an exclusive write-lease.
pub struct WriteMemory {
guard: Arc<dyn FileGuard>,
memory: Arc<dyn MemoryStore>,
}
/// Input for [`WriteMemory`].
pub struct WriteMemoryInput {
/// The project to write within.
pub project: Project,
/// Target note slug.
pub slug: String,
/// The Markdown body to store.
pub content: String,
/// The writing party.
pub requester: ConversationParty,
}
impl WriteMemory {
/// Builds the use case from its ports.
#[must_use]
pub fn new(guard: Arc<dyn FileGuard>, memory: Arc<dyn MemoryStore>) -> Self {
Self { guard, memory }
}
/// Writes the note under a write-lease.
///
/// # Errors
/// [`AppError::Invalid`] for a bad slug or empty body; [`AppError`] on a store
/// failure.
pub async fn execute(&self, input: WriteMemoryInput) -> Result<(), AppError> {
let WriteMemoryInput {
project,
slug,
content,
requester,
} = input;
let slug = MemorySlug::new(slug).map_err(|e| AppError::Invalid(e.to_string()))?;
let _lease = self
.guard
.acquire_write(requester, GuardedResource::Memory(slug.clone()))
.await
.map_err(map_guard_err)?;
let frontmatter = MemoryFrontmatter {
name: slug.clone(),
description: format!("memory note {slug}"),
r#type: MemoryType::Project,
};
let note = Memory::new(frontmatter, MarkdownDoc::new(content))
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.memory.save(&project.root, &note).await?;
Ok(())
}
}
/// Maps a [`GuardError`] onto the application error shape. `Forbidden` is an invariant
/// violation ([`AppError::Invalid`]); `Busy` is a transient contention the cooperative
/// blocking adapter never returns, but is mapped for completeness.
fn map_guard_err(e: GuardError) -> AppError {
match e {
GuardError::Forbidden => AppError::Invalid(
"writing the global project context is reserved to the orchestrator; propose instead"
.to_owned(),
),
GuardError::Busy => AppError::Invalid("guarded resource is busy".to_owned()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use domain::agent::{AgentManifest, ManifestEntry};
use domain::conversation::ConversationParty;
use domain::fileguard::{may_write_directly, ReadLease, WriteLease};
use domain::ports::{FsError, MemoryError, StoreError};
use domain::project::ProjectPath;
use domain::{ProfileId, ProjectId, RemoteRef};
use std::collections::HashMap;
use std::sync::{Arc as StdArc, Mutex};
use std::time::Duration;
use tokio::sync::RwLock;
fn project() -> Project {
Project::new(
ProjectId::from_uuid(uuid::Uuid::from_u128(1)),
"demo",
ProjectPath::new("/tmp/demo").unwrap(),
RemoteRef::local(),
0,
)
.unwrap()
}
/// In-test [`FileGuard`] mirroring `infrastructure::RwFileGuard` (one tokio
/// `RwLock` per resource + the single-writer rule), so the application crate
/// stays free of any dependency on infrastructure (no dependency cycle).
#[derive(Default)]
struct TestGuard {
locks: Mutex<HashMap<GuardedResource, StdArc<RwLock<()>>>>,
}
impl TestGuard {
fn lock_for(&self, res: &GuardedResource) -> StdArc<RwLock<()>> {
self.locks
.lock()
.unwrap()
.entry(res.clone())
.or_insert_with(|| StdArc::new(RwLock::new(())))
.clone()
}
}
#[async_trait]
impl FileGuard for TestGuard {
async fn acquire_read(
&self,
_who: ConversationParty,
res: GuardedResource,
) -> Result<ReadLease, GuardError> {
let lock = self.lock_for(&res);
Ok(ReadLease::new(Box::new(lock.read_owned().await)))
}
async fn acquire_write(
&self,
who: ConversationParty,
res: GuardedResource,
) -> Result<WriteLease, GuardError> {
if !may_write_directly(who, &res) {
return Err(GuardError::Forbidden);
}
let lock = self.lock_for(&res);
Ok(WriteLease::new(Box::new(lock.write_owned().await)))
}
}
fn agent_party(n: u128) -> ConversationParty {
ConversationParty::agent(AgentId::from_uuid(uuid::Uuid::from_u128(n)))
}
// ---- Fakes -----------------------------------------------------------
#[derive(Default)]
struct FakeFs {
files: Mutex<HashMap<String, Vec<u8>>>,
dirs: Mutex<Vec<String>>,
}
#[async_trait]
impl FileSystem for FakeFs {
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
self.files
.lock()
.unwrap()
.get(path.as_str())
.cloned()
.ok_or_else(|| FsError::NotFound(path.as_str().to_owned()))
}
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
self.files
.lock()
.unwrap()
.insert(path.as_str().to_owned(), data.to_vec());
Ok(())
}
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
Ok(self.files.lock().unwrap().contains_key(path.as_str()))
}
async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> {
self.dirs.lock().unwrap().push(path.as_str().to_owned());
Ok(())
}
async fn list(&self, _path: &RemotePath) -> Result<Vec<domain::ports::DirEntry>, FsError> {
Ok(Vec::new())
}
async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
Ok(())
}
}
struct FakeContexts {
manifest: AgentManifest,
contexts: Mutex<HashMap<AgentId, String>>,
}
#[async_trait]
impl AgentContextStore for FakeContexts {
async fn read_context(
&self,
_project: &Project,
agent: &AgentId,
) -> Result<MarkdownDoc, StoreError> {
self.contexts
.lock()
.unwrap()
.get(agent)
.cloned()
.map(MarkdownDoc::new)
.ok_or(StoreError::NotFound)
}
async fn write_context(
&self,
_project: &Project,
agent: &AgentId,
md: &MarkdownDoc,
) -> Result<(), StoreError> {
self.contexts
.lock()
.unwrap()
.insert(*agent, md.as_str().to_owned());
Ok(())
}
async fn load_manifest(&self, _project: &Project) -> Result<AgentManifest, StoreError> {
Ok(self.manifest.clone())
}
async fn save_manifest(
&self,
_project: &Project,
_manifest: &AgentManifest,
) -> Result<(), StoreError> {
Ok(())
}
}
#[derive(Default)]
struct FakeMemory {
notes: Mutex<HashMap<String, String>>,
}
#[async_trait]
impl MemoryStore for FakeMemory {
async fn list(&self, _root: &ProjectPath) -> Result<Vec<Memory>, MemoryError> {
Ok(Vec::new())
}
async fn get(&self, _root: &ProjectPath, slug: &MemorySlug) -> Result<Memory, MemoryError> {
let body = self
.notes
.lock()
.unwrap()
.get(slug.as_str())
.cloned()
.ok_or(MemoryError::NotFound)?;
Memory::new(
MemoryFrontmatter {
name: slug.clone(),
description: "d".to_owned(),
r#type: MemoryType::Project,
},
MarkdownDoc::new(body),
)
.map_err(|e| MemoryError::Frontmatter(e.to_string()))
}
async fn save(&self, _root: &ProjectPath, memory: &Memory) -> Result<(), MemoryError> {
self.notes
.lock()
.unwrap()
.insert(memory.slug().to_string(), memory.body.as_str().to_owned());
Ok(())
}
async fn delete(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result<(), MemoryError> {
Ok(())
}
async fn read_index(
&self,
_root: &ProjectPath,
) -> Result<Vec<domain::memory::MemoryIndexEntry>, MemoryError> {
Ok(Vec::new())
}
async fn resolve_links(
&self,
_root: &ProjectPath,
_slug: &MemorySlug,
) -> Result<Vec<domain::memory::MemoryLink>, MemoryError> {
Ok(Vec::new())
}
}
struct FixedClock;
impl Clock for FixedClock {
fn now_millis(&self) -> i64 {
42
}
}
fn guard() -> Arc<dyn FileGuard> {
Arc::new(TestGuard::default())
}
fn contexts_with(name: &str, agent: AgentId, body: &str) -> Arc<dyn AgentContextStore> {
let mut contexts = HashMap::new();
contexts.insert(agent, body.to_owned());
Arc::new(FakeContexts {
manifest: AgentManifest {
version: 1,
entries: vec![ManifestEntry {
agent_id: agent,
name: name.to_owned(),
md_path: "agents/x.md".to_owned(),
profile_id: ProfileId::from_uuid(uuid::Uuid::from_u128(99)),
template_id: None,
synchronized: false,
synced_template_version: None,
skills: Vec::new(),
}],
},
contexts: Mutex::new(contexts),
})
}
// ---- Tests -----------------------------------------------------------
#[tokio::test]
async fn read_agent_context_returns_body() {
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
let uc = ReadContext::new(
guard(),
contexts_with("Dev", agent, "# hello"),
Arc::new(FakeFs::default()),
);
let md = uc
.execute(ReadContextInput {
project: project(),
target: Some("dev".to_owned()), // case-insensitive
requester: agent_party(1),
})
.await
.unwrap();
assert_eq!(md.as_str(), "# hello");
}
#[tokio::test]
async fn read_global_context_reads_root_file() {
let fs = Arc::new(FakeFs::default());
fs.files
.lock()
.unwrap()
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# project".to_vec());
let uc = ReadContext::new(
guard(),
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
fs,
);
let md = uc
.execute(ReadContextInput {
project: project(),
target: None,
requester: ConversationParty::User,
})
.await
.unwrap();
assert_eq!(md.as_str(), "# project");
}
#[tokio::test]
async fn concurrent_reads_do_not_block_each_other() {
// Two readers on the same global context, held at once. If the read-lease
// were exclusive this would deadlock; a bounded timeout proves it does not.
let guard = guard();
let r1 = guard
.acquire_read(ConversationParty::User, GuardedResource::ProjectContext)
.await
.unwrap();
let r2 = tokio::time::timeout(
Duration::from_millis(200),
guard.acquire_read(agent_party(1), GuardedResource::ProjectContext),
)
.await
.expect("a second reader must not block")
.unwrap();
drop((r1, r2));
}
#[tokio::test]
async fn agent_proposing_global_context_files_a_proposal_not_a_write() {
let fs = Arc::new(FakeFs::default());
fs.files
.lock()
.unwrap()
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# original".to_vec());
let uc = ProposeContext::new(
guard(),
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
Arc::clone(&fs) as Arc<dyn FileSystem>,
Arc::new(FixedClock),
);
let outcome = uc
.execute(ProposeContextInput {
project: project(),
target: None,
content: "# hijack".to_owned(),
requester: agent_party(3),
})
.await
.unwrap();
// It is a *proposal*, not a write: the live context is untouched.
assert!(matches!(outcome, ProposeOutcome::Proposed { .. }));
assert_eq!(
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
b"# original",
"the live global context must NOT be overwritten by a proposal"
);
// The proposal landed under .ideai/proposals/.
let files = fs.files.lock().unwrap();
assert!(files
.keys()
.any(|k| k.contains("/.ideai/proposals/") && k.ends_with("-42.md")));
}
#[tokio::test]
async fn orchestrator_writes_global_context_directly() {
let fs = Arc::new(FakeFs::default());
let uc = ProposeContext::new(
guard(),
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
Arc::clone(&fs) as Arc<dyn FileSystem>,
Arc::new(FixedClock),
);
let outcome = uc
.execute(ProposeContextInput {
project: project(),
target: None,
content: "# new".to_owned(),
requester: ConversationParty::User,
})
.await
.unwrap();
assert_eq!(outcome, ProposeOutcome::Written);
assert_eq!(
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
b"# new"
);
}
#[tokio::test]
async fn propose_agent_context_writes_directly() {
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
let contexts = contexts_with("Dev", agent, "# old");
let uc = ProposeContext::new(
guard(),
Arc::clone(&contexts),
Arc::new(FakeFs::default()),
Arc::new(FixedClock),
);
let outcome = uc
.execute(ProposeContextInput {
project: project(),
target: Some("Dev".to_owned()),
content: "# new body".to_owned(),
requester: agent_party(3),
})
.await
.unwrap();
assert_eq!(outcome, ProposeOutcome::Written);
assert_eq!(
contexts
.read_context(&project(), &agent)
.await
.unwrap()
.as_str(),
"# new body"
);
}
#[tokio::test]
async fn write_then_read_memory_round_trips_under_guard() {
let memory = Arc::new(FakeMemory::default());
let writer = WriteMemory::new(guard(), Arc::clone(&memory) as Arc<dyn MemoryStore>);
writer
.execute(WriteMemoryInput {
project: project(),
slug: "note-a".to_owned(),
content: "body".to_owned(),
requester: agent_party(2),
})
.await
.unwrap();
let reader = ReadMemory::new(guard(), Arc::clone(&memory) as Arc<dyn MemoryStore>);
let body = reader
.execute(ReadMemoryInput {
project: project(),
slug: Some("note-a".to_owned()),
requester: agent_party(2),
})
.await
.unwrap();
assert_eq!(body, "body");
}
#[tokio::test]
async fn writes_to_same_memory_note_serialise() {
// Two write leases on the same note must not overlap (exclusive writer).
let guard = guard();
let slug = GuardedResource::Memory(MemorySlug::new("n").unwrap());
let w1 = guard
.acquire_write(agent_party(1), slug.clone())
.await
.unwrap();
// While w1 is held, a second writer blocks; it only succeeds after release.
let blocked = tokio::time::timeout(
Duration::from_millis(100),
guard.acquire_write(agent_party(2), slug.clone()),
)
.await;
assert!(
blocked.is_err(),
"a second writer must block while w1 holds"
);
drop(w1);
let w2 = tokio::time::timeout(
Duration::from_millis(200),
guard.acquire_write(agent_party(2), slug),
)
.await
.expect("w2 acquires after w1 releases")
.unwrap();
drop(w2);
}
}

View File

@ -0,0 +1,16 @@
//! Orchestrator application service (ARCHITECTURE §14.3).
//!
//! Turns a validated [`domain::OrchestratorCommand`] into the *same* agent/terminal
//! use-case calls the UI makes, so an orchestrator agent can drive IdeA without
//! ever spawning a process itself. See [`service::OrchestratorService`].
mod context_guard;
mod service;
pub use context_guard::{
ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory,
ReadMemoryInput, WriteMemory, WriteMemoryInput,
};
pub use service::{
McpRuntimeProvider, OrchestratorOutcome, OrchestratorService, RecordTurnProvider,
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,150 @@
//! Permission use cases.
//!
//! This module stays at the application boundary: it loads the project
//! permission document through [`PermissionStore`], applies simple mutations, and
//! delegates all merge semantics to the pure domain model.
use std::sync::Arc;
use domain::ports::PermissionStore;
use domain::{AgentId, EffectivePermissions, PermissionSet, Project, ProjectPermissions};
use crate::error::AppError;
/// Reads the full project permission document.
pub struct GetProjectPermissions {
store: Arc<dyn PermissionStore>,
}
impl GetProjectPermissions {
/// Builds the use case.
#[must_use]
pub fn new(store: Arc<dyn PermissionStore>) -> Self {
Self { store }
}
/// Executes the read.
pub async fn execute(
&self,
input: GetProjectPermissionsInput,
) -> Result<GetProjectPermissionsOutput, AppError> {
let permissions = self.store.load_permissions(&input.project).await?;
Ok(GetProjectPermissionsOutput { permissions })
}
}
/// Input for [`GetProjectPermissions`].
pub struct GetProjectPermissionsInput {
/// Target project.
pub project: Project,
}
/// Output for [`GetProjectPermissions`].
pub struct GetProjectPermissionsOutput {
/// Persisted permission document.
pub permissions: ProjectPermissions,
}
/// Replaces the project default policy.
pub struct UpdateProjectPermissions {
store: Arc<dyn PermissionStore>,
}
impl UpdateProjectPermissions {
/// Builds the use case.
#[must_use]
pub fn new(store: Arc<dyn PermissionStore>) -> Self {
Self { store }
}
/// Executes the mutation.
pub async fn execute(
&self,
input: UpdateProjectPermissionsInput,
) -> Result<GetProjectPermissionsOutput, AppError> {
let mut doc = self.store.load_permissions(&input.project).await?;
doc.set_project_defaults(input.permissions);
self.store.save_permissions(&input.project, &doc).await?;
Ok(GetProjectPermissionsOutput { permissions: doc })
}
}
/// Input for [`UpdateProjectPermissions`].
pub struct UpdateProjectPermissionsInput {
/// Target project.
pub project: Project,
/// New project default policy. `None` removes project defaults.
pub permissions: Option<PermissionSet>,
}
/// Replaces one agent override.
pub struct UpdateAgentPermissions {
store: Arc<dyn PermissionStore>,
}
impl UpdateAgentPermissions {
/// Builds the use case.
#[must_use]
pub fn new(store: Arc<dyn PermissionStore>) -> Self {
Self { store }
}
/// Executes the mutation.
pub async fn execute(
&self,
input: UpdateAgentPermissionsInput,
) -> Result<GetProjectPermissionsOutput, AppError> {
let mut doc = self.store.load_permissions(&input.project).await?;
doc.set_agent_permissions(input.agent_id, input.permissions);
self.store.save_permissions(&input.project, &doc).await?;
Ok(GetProjectPermissionsOutput { permissions: doc })
}
}
/// Input for [`UpdateAgentPermissions`].
pub struct UpdateAgentPermissionsInput {
/// Target project.
pub project: Project,
/// Target agent.
pub agent_id: AgentId,
/// New agent policy. `None` removes the override.
pub permissions: Option<PermissionSet>,
}
/// Resolves effective permissions for one agent.
pub struct ResolveAgentPermissions {
store: Arc<dyn PermissionStore>,
}
impl ResolveAgentPermissions {
/// Builds the use case.
#[must_use]
pub fn new(store: Arc<dyn PermissionStore>) -> Self {
Self { store }
}
/// Executes the resolution.
pub async fn execute(
&self,
input: ResolveAgentPermissionsInput,
) -> Result<ResolveAgentPermissionsOutput, AppError> {
let doc = self.store.load_permissions(&input.project).await?;
Ok(ResolveAgentPermissionsOutput {
effective: doc.resolve_for(input.agent_id),
})
}
}
/// Input for [`ResolveAgentPermissions`].
pub struct ResolveAgentPermissionsInput {
/// Target project.
pub project: Project,
/// Target agent.
pub agent_id: AgentId,
}
/// Output for [`ResolveAgentPermissions`].
pub struct ResolveAgentPermissionsOutput {
/// Resolved policy, or `None` when neither project nor agent policy exists.
pub effective: Option<EffectivePermissions>,
}

View File

@ -0,0 +1,115 @@
//! Project-level context stored under `.ideai/CONTEXT.md`.
//!
//! This context is model-agnostic and shared by every agent/profile launch. It is
//! deliberately project-local: deleting `.ideai/` removes the context together
//! with every other IdeA artefact for that project.
use std::sync::Arc;
use domain::ports::{FileSystem, FsError, RemotePath};
use domain::Project;
use crate::error::AppError;
use super::meta::{join_root, IDEAI_DIR};
/// Project-context file name inside `.ideai/`.
pub const PROJECT_CONTEXT_FILE: &str = "CONTEXT.md";
/// Input for [`ReadProjectContext::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadProjectContextInput {
/// Project whose `.ideai/CONTEXT.md` should be read.
pub project: Project,
}
/// Output of [`ReadProjectContext::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadProjectContextOutput {
/// Markdown content. Empty when the file does not exist yet.
pub content: String,
}
/// Reads `.ideai/CONTEXT.md` tolerantly.
pub struct ReadProjectContext {
fs: Arc<dyn FileSystem>,
}
impl ReadProjectContext {
/// Builds the use case.
#[must_use]
pub fn new(fs: Arc<dyn FileSystem>) -> Self {
Self { fs }
}
/// Executes the read.
///
/// # Errors
/// Returns [`AppError::FileSystem`] on non-missing filesystem failures, and
/// [`AppError::Store`] if the file is not valid UTF-8.
pub async fn execute(
&self,
input: ReadProjectContextInput,
) -> Result<ReadProjectContextOutput, AppError> {
match self.fs.read(&project_context_path(&input.project)).await {
Ok(bytes) => {
let content = String::from_utf8(bytes)
.map_err(|e| AppError::Store(format!("project context is not UTF-8: {e}")))?;
Ok(ReadProjectContextOutput { content })
}
Err(FsError::NotFound(_)) => Ok(ReadProjectContextOutput {
content: String::new(),
}),
Err(e) => Err(AppError::FileSystem(e.to_string())),
}
}
}
/// Input for [`UpdateProjectContext::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateProjectContextInput {
/// Project whose `.ideai/CONTEXT.md` should be overwritten.
pub project: Project,
/// New Markdown content.
pub content: String,
}
/// Overwrites `.ideai/CONTEXT.md`.
pub struct UpdateProjectContext {
fs: Arc<dyn FileSystem>,
}
impl UpdateProjectContext {
/// Builds the use case.
#[must_use]
pub fn new(fs: Arc<dyn FileSystem>) -> Self {
Self { fs }
}
/// Executes the update.
///
/// # Errors
/// Returns [`AppError::FileSystem`] if `.ideai/` cannot be created or the
/// context file cannot be written.
pub async fn execute(&self, input: UpdateProjectContextInput) -> Result<(), AppError> {
self.fs
.create_dir_all(&RemotePath::new(join_root(&input.project.root, IDEAI_DIR)))
.await?;
self.fs
.write(
&project_context_path(&input.project),
input.content.as_bytes(),
)
.await?;
Ok(())
}
}
/// Absolute path to `<root>/.ideai/CONTEXT.md`.
#[must_use]
pub fn project_context_path(project: &Project) -> RemotePath {
RemotePath::new(join_root(
&project.root,
&format!("{IDEAI_DIR}/{PROJECT_CONTEXT_FILE}"),
))
}

View File

@ -14,11 +14,18 @@
//! - [`ListProjects`] — list known projects from the registry. //! - [`ListProjects`] — list known projects from the registry.
mod close; mod close;
mod context;
mod create; mod create;
pub(crate) mod meta; pub(crate) mod meta;
mod open; mod open;
pub use close::{CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput}; pub use close::{CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput};
pub use context::{
project_context_path, ReadProjectContext, ReadProjectContextInput, ReadProjectContextOutput,
UpdateProjectContext, UpdateProjectContextInput, PROJECT_CONTEXT_FILE,
};
pub use create::{CreateProject, CreateProjectInput, CreateProjectOutput}; pub use create::{CreateProject, CreateProjectInput, CreateProjectOutput};
pub use meta::ProjectMeta; pub use meta::ProjectMeta;
pub use open::{ListProjects, ListProjectsOutput, OpenProject, OpenProjectInput, OpenProjectOutput}; pub use open::{
ListProjects, ListProjectsOutput, OpenProject, OpenProjectInput, OpenProjectOutput,
};

View File

@ -7,9 +7,7 @@ use domain::{AgentManifest, Project, ProjectId};
use crate::error::AppError; use crate::error::AppError;
use super::meta::{ use super::meta::{from_json_bytes, join_root, ProjectMeta, AGENTS_FILE, IDEAI_DIR, PROJECT_FILE};
from_json_bytes, join_root, ProjectMeta, AGENTS_FILE, IDEAI_DIR, PROJECT_FILE,
};
/// Input for [`OpenProject::execute`]. /// Input for [`OpenProject::execute`].
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]

View File

@ -0,0 +1,21 @@
//! Skill use cases (ARCHITECTURE §14.2; L12).
//!
//! Skills are reusable, model-agnostic workflows (IdeA's universal equivalent of
//! a CLI slash-command). This module owns their CRUD across both scopes
//! ([`domain::skill::SkillScope`]) and the agent↔skill assignment that records a
//! [`domain::skill::SkillRef`] in the project manifest. The actual injection of
//! an assigned skill's body into the generated convention file happens at agent
//! activation (L6).
//!
//! Every use case talks only to ports ([`domain::ports::SkillStore`],
//! [`domain::ports::AgentContextStore`], [`domain::ports::IdGenerator`],
//! [`domain::ports::EventBus`]).
mod usecases;
pub use usecases::{
AssignSkillToAgent, AssignSkillToAgentInput, CreateSkill, CreateSkillInput, CreateSkillOutput,
DeleteSkill, DeleteSkillInput, ListSkills, ListSkillsInput, ListSkillsOutput,
UnassignSkillFromAgent, UnassignSkillFromAgentInput, UpdateSkill, UpdateSkillInput,
UpdateSkillOutput,
};

View File

@ -0,0 +1,351 @@
//! Skill use cases (ARCHITECTURE §14.2; L12).
//!
//! - **CRUD** in either scope: [`CreateSkill`], [`UpdateSkill`], [`DeleteSkill`],
//! [`ListSkills`].
//! - **Assignment**: [`AssignSkillToAgent`] / [`UnassignSkillFromAgent`] mutate
//! the project manifest entry's `skills` and announce
//! [`DomainEvent::SkillAssigned`]. Both are idempotent.
use std::sync::Arc;
use domain::ports::{AgentContextStore, EventBus, IdGenerator, SkillStore};
use domain::{
AgentId, AgentManifest, DomainEvent, MarkdownDoc, Project, ProjectPath, Skill, SkillId,
SkillRef, SkillScope,
};
use crate::error::AppError;
// ---------------------------------------------------------------------------
// CreateSkill
// ---------------------------------------------------------------------------
/// Input for [`CreateSkill::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateSkillInput {
/// Display name (also the `.md` stem on disk).
pub name: String,
/// Initial Markdown body.
pub content: String,
/// Scope the skill is created in (selects its backing store).
pub scope: SkillScope,
/// Active project root (used only for [`SkillScope::Project`]).
pub project_root: ProjectPath,
}
/// Output of [`CreateSkill::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateSkillOutput {
/// The created skill.
pub skill: Skill,
}
/// Creates a skill in the store of its [`SkillScope`].
pub struct CreateSkill {
skills: Arc<dyn SkillStore>,
ids: Arc<dyn IdGenerator>,
}
impl CreateSkill {
/// Builds the use case from its ports.
#[must_use]
pub fn new(skills: Arc<dyn SkillStore>, ids: Arc<dyn IdGenerator>) -> Self {
Self { skills, ids }
}
/// Executes creation.
///
/// # Errors
/// - [`AppError::Invalid`] if `name`/`content` is empty,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: CreateSkillInput) -> Result<CreateSkillOutput, AppError> {
let id = SkillId::from_uuid(self.ids.new_uuid());
let skill = Skill::new(id, input.name, MarkdownDoc::new(input.content), input.scope)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.skills.save(&skill, &input.project_root).await?;
Ok(CreateSkillOutput { skill })
}
}
// ---------------------------------------------------------------------------
// UpdateSkill
// ---------------------------------------------------------------------------
/// Input for [`UpdateSkill::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateSkillInput {
/// Scope the skill lives in.
pub scope: SkillScope,
/// Skill to update.
pub skill_id: SkillId,
/// New Markdown body.
pub content: String,
/// Active project root (used only for [`SkillScope::Project`]).
pub project_root: ProjectPath,
}
/// Output of [`UpdateSkill::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateSkillOutput {
/// The updated skill.
pub skill: Skill,
}
/// Replaces a skill's content (re-validating the non-empty invariant).
pub struct UpdateSkill {
skills: Arc<dyn SkillStore>,
}
impl UpdateSkill {
/// Builds the use case.
#[must_use]
pub fn new(skills: Arc<dyn SkillStore>) -> Self {
Self { skills }
}
/// Executes the update.
///
/// # Errors
/// - [`AppError::NotFound`] if the skill is unknown in that scope,
/// - [`AppError::Invalid`] if the new content is empty,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: UpdateSkillInput) -> Result<UpdateSkillOutput, AppError> {
let current = self
.skills
.get(input.scope, &input.project_root, input.skill_id)
.await?;
let updated = current
.with_content(MarkdownDoc::new(input.content))
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.skills.save(&updated, &input.project_root).await?;
Ok(UpdateSkillOutput { skill: updated })
}
}
// ---------------------------------------------------------------------------
// ListSkills / DeleteSkill
// ---------------------------------------------------------------------------
/// Input for [`ListSkills::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListSkillsInput {
/// Scope to list.
pub scope: SkillScope,
/// Active project root (used only for [`SkillScope::Project`]).
pub project_root: ProjectPath,
}
/// Output of [`ListSkills::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListSkillsOutput {
/// All skills in the requested scope.
pub skills: Vec<Skill>,
}
/// Lists the skills in one scope.
pub struct ListSkills {
skills: Arc<dyn SkillStore>,
}
impl ListSkills {
/// Builds the use case.
#[must_use]
pub fn new(skills: Arc<dyn SkillStore>) -> Self {
Self { skills }
}
/// Lists skills in `input.scope`.
///
/// # Errors
/// [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: ListSkillsInput) -> Result<ListSkillsOutput, AppError> {
Ok(ListSkillsOutput {
skills: self.skills.list(input.scope, &input.project_root).await?,
})
}
}
/// Input for [`DeleteSkill::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteSkillInput {
/// Scope the skill lives in.
pub scope: SkillScope,
/// Skill to delete.
pub skill_id: SkillId,
/// Active project root (used only for [`SkillScope::Project`]).
pub project_root: ProjectPath,
}
/// Deletes a skill from its scope's store.
///
/// Agents that referenced it keep their [`SkillRef`]; the injection step simply
/// finds nothing to resolve for the now-absent skill and skips it.
pub struct DeleteSkill {
skills: Arc<dyn SkillStore>,
}
impl DeleteSkill {
/// Builds the use case.
#[must_use]
pub fn new(skills: Arc<dyn SkillStore>) -> Self {
Self { skills }
}
/// Deletes the skill.
///
/// # Errors
/// - [`AppError::NotFound`] if the skill is unknown in that scope,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: DeleteSkillInput) -> Result<(), AppError> {
self.skills
.delete(input.scope, &input.project_root, input.skill_id)
.await?;
Ok(())
}
}
// ---------------------------------------------------------------------------
// AssignSkillToAgent / UnassignSkillFromAgent
// ---------------------------------------------------------------------------
/// Input for [`AssignSkillToAgent::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AssignSkillToAgentInput {
/// The owning project.
pub project: Project,
/// The agent receiving the skill.
pub agent_id: AgentId,
/// The skill to assign.
pub skill: SkillRef,
}
/// Assigns a skill to an agent by recording a [`SkillRef`] in its manifest entry.
/// Idempotent: re-assigning the same skill is a no-op (no duplicate).
pub struct AssignSkillToAgent {
contexts: Arc<dyn AgentContextStore>,
events: Arc<dyn EventBus>,
}
impl AssignSkillToAgent {
/// Builds the use case from its ports.
#[must_use]
pub fn new(contexts: Arc<dyn AgentContextStore>, events: Arc<dyn EventBus>) -> Self {
Self { contexts, events }
}
/// Executes the assignment.
///
/// # Errors
/// - [`AppError::NotFound`] if the agent is unknown to the project,
/// - [`AppError::Invalid`] if the resulting manifest is invalid,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: AssignSkillToAgentInput) -> Result<(), AppError> {
let mut manifest = self.contexts.load_manifest(&input.project).await?;
let entry = manifest
.entries
.iter_mut()
.find(|e| e.agent_id == input.agent_id)
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
// Mutate through the domain entity so the dedup invariant is enforced in
// one place, then fold the result back into the manifest entry.
let mut agent = entry
.to_agent()
.map_err(|e| AppError::Invalid(e.to_string()))?;
let changed = agent.assign_skill(input.skill);
*entry = domain::ManifestEntry::from_agent(&agent);
if changed {
self.persist_and_announce(
&input.project,
manifest,
input.agent_id,
input.skill.skill_id,
true,
)
.await?;
}
Ok(())
}
/// Saves the manifest and announces the assignment change.
async fn persist_and_announce(
&self,
project: &Project,
manifest: AgentManifest,
agent_id: AgentId,
skill_id: SkillId,
assigned: bool,
) -> Result<(), AppError> {
let manifest = AgentManifest::new(manifest.version, manifest.entries)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts.save_manifest(project, &manifest).await?;
self.events.publish(DomainEvent::SkillAssigned {
agent_id,
skill_id,
assigned,
});
Ok(())
}
}
/// Input for [`UnassignSkillFromAgent::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnassignSkillFromAgentInput {
/// The owning project.
pub project: Project,
/// The agent losing the skill.
pub agent_id: AgentId,
/// The skill to unassign.
pub skill_id: SkillId,
}
/// Removes a skill assignment from an agent. Idempotent: unassigning a skill the
/// agent does not carry is a no-op.
pub struct UnassignSkillFromAgent {
contexts: Arc<dyn AgentContextStore>,
events: Arc<dyn EventBus>,
}
impl UnassignSkillFromAgent {
/// Builds the use case from its ports.
#[must_use]
pub fn new(contexts: Arc<dyn AgentContextStore>, events: Arc<dyn EventBus>) -> Self {
Self { contexts, events }
}
/// Executes the un-assignment.
///
/// # Errors
/// - [`AppError::NotFound`] if the agent is unknown to the project,
/// - [`AppError::Invalid`] if the resulting manifest is invalid,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: UnassignSkillFromAgentInput) -> Result<(), AppError> {
let mut manifest = self.contexts.load_manifest(&input.project).await?;
let entry = manifest
.entries
.iter_mut()
.find(|e| e.agent_id == input.agent_id)
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
let mut agent = entry
.to_agent()
.map_err(|e| AppError::Invalid(e.to_string()))?;
let changed = agent.unassign_skill(input.skill_id);
*entry = domain::ManifestEntry::from_agent(&agent);
if changed {
let manifest = AgentManifest::new(manifest.version, manifest.entries)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts
.save_manifest(&input.project, &manifest)
.await?;
self.events.publish(DomainEvent::SkillAssigned {
agent_id: input.agent_id,
skill_id: input.skill_id,
assigned: false,
});
}
Ok(())
}
}

View File

@ -286,7 +286,9 @@ impl CreateAgentFromTemplate {
entries.push(ManifestEntry::from_agent(&agent)); entries.push(ManifestEntry::from_agent(&agent));
let manifest = AgentManifest::new(manifest.version, entries) let manifest = AgentManifest::new(manifest.version, entries)
.map_err(|e| AppError::Invalid(e.to_string()))?; .map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts.save_manifest(&input.project, &manifest).await?; self.contexts
.save_manifest(&input.project, &manifest)
.await?;
// Seed the agent context with the template content. // Seed the agent context with the template content.
self.contexts self.contexts
@ -477,7 +479,9 @@ impl SyncAgentWithTemplate {
// Persist the manifest (revalidated) and overwrite the agent context. // Persist the manifest (revalidated) and overwrite the agent context.
let manifest = AgentManifest::new(manifest.version, manifest.entries) let manifest = AgentManifest::new(manifest.version, manifest.entries)
.map_err(|e| AppError::Invalid(e.to_string()))?; .map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts.save_manifest(&input.project, &manifest).await?; self.contexts
.save_manifest(&input.project, &manifest)
.await?;
self.contexts self.contexts
.write_context(&input.project, &input.agent_id, &template.content_md) .write_context(&input.project, &input.agent_id, &template.content_md)
.await?; .await?;

View File

@ -28,7 +28,7 @@
mod registry; mod registry;
mod usecases; mod usecases;
pub use registry::TerminalSessions; pub use registry::{LiveAgentRegistry, LiveSessions, StructuredSessions, TerminalSessions};
pub use usecases::{ pub use usecases::{
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, OpenTerminal, OpenTerminalInput, CloseTerminal, CloseTerminalInput, CloseTerminalOutput, OpenTerminal, OpenTerminalInput,
OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, WriteToTerminal, WriteToTerminalInput, OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, WriteToTerminal, WriteToTerminalInput,

View File

@ -7,10 +7,11 @@
//! application layer rather than the domain or the adapter. //! application layer rather than the domain or the adapter.
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Mutex; use std::sync::{Arc, Mutex};
use domain::ports::PtyHandle; use domain::conversation::ConversationId;
use domain::{SessionId, TerminalSession}; use domain::ports::{AgentSession, PtyHandle};
use domain::{AgentId, NodeId, SessionId, SessionKind, TerminalSession};
/// A registered, live terminal: its PTY handle plus the domain snapshot. /// A registered, live terminal: its PTY handle plus the domain snapshot.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -19,10 +20,49 @@ struct Entry {
session: TerminalSession, session: TerminalSession,
} }
/// Read-only liveness query over the agents that currently own a live PTY.
///
/// Abstracted as a trait so use cases that only need to ask "is this agent
/// running right now?" (e.g. the close-time snapshot of running agents) depend
/// on the *capability*, not on the whole [`TerminalSessions`] registry — and can
/// be tested against a trivial fake. [`TerminalSessions`] is the production
/// implementation.
pub trait LiveAgentRegistry: Send + Sync {
/// Whether `agent_id` currently has a live session in the registry.
fn is_agent_live(&self, agent_id: &AgentId) -> bool;
/// Whether `node_id` (a layout leaf) currently hosts a live session.
///
/// This is the per-cell liveness the close-time snapshot uses: with the
/// "one live session per agent" invariant in place, the same agent can be
/// pinned on several leaves but be *live* in at most one — so liveness must
/// be keyed on the hosting node, not the agent (otherwise a duplicate leaf
/// would be wrongly marked as still running).
fn is_node_live(&self, node_id: &NodeId) -> bool;
}
/// In-memory registry of active terminal sessions. /// In-memory registry of active terminal sessions.
#[derive(Default)] #[derive(Default)]
pub struct TerminalSessions { pub struct TerminalSessions {
entries: Mutex<HashMap<SessionId, Entry>>, entries: Mutex<HashMap<SessionId, Entry>>,
/// Conversation → live session binding (cadrage C3 §5.2): «1 session vivante /
/// **conversation**» (remplace «1 / agent»). Populated by the orchestrator when an
/// ask resolves/launches the session for a given thread. Separate from `entries`
/// (the domain [`TerminalSession`] does not carry a conversation id).
conversations: Mutex<HashMap<ConversationId, SessionId>>,
}
impl LiveAgentRegistry for TerminalSessions {
fn is_agent_live(&self, agent_id: &AgentId) -> bool {
self.session_for_agent(agent_id).is_some()
}
fn is_node_live(&self, node_id: &NodeId) -> bool {
self.entries
.lock()
.map(|m| m.values().any(|e| e.session.node_id == *node_id))
.unwrap_or(false)
}
} }
impl TerminalSessions { impl TerminalSessions {
@ -31,9 +71,55 @@ impl TerminalSessions {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
entries: Mutex::new(HashMap::new()), entries: Mutex::new(HashMap::new()),
conversations: Mutex::new(HashMap::new()),
} }
} }
/// Binds `conversation` to the live `session` (cadrage C3 §5.2). Idempotent:
/// re-binding the same conversation overwrites the target session.
pub fn bind_conversation(&self, conversation: ConversationId, session: SessionId) {
if let Ok(mut m) = self.conversations.lock() {
m.insert(conversation, session);
}
}
/// Returns the live [`SessionId`] bound to `conversation`, if any **and** still
/// registered (a stale binding to a closed session resolves to `None`).
///
/// «1 session vivante / conversation» — deterministic, replacing the ambiguous
/// per-agent lookup for the orchestrator's ask path.
#[must_use]
pub fn session_for(&self, conversation: ConversationId) -> Option<SessionId> {
let sid = self
.conversations
.lock()
.ok()?
.get(&conversation)
.copied()?;
// Only return it if the session is still live in `entries`.
self.entries
.lock()
.ok()
.filter(|m| m.contains_key(&sid))
.map(|_| sid)
}
/// Lists every live [`SessionId`] hosting `agent_id` (cadrage C3 §1.2 — an agent
/// may take part in several threads, so this is the **plural** of
/// [`Self::session_for_agent`]).
#[must_use]
pub fn sessions_for_agent(&self, agent_id: &AgentId) -> Vec<SessionId> {
self.entries
.lock()
.map(|m| {
m.values()
.filter(|e| matches!(e.session.kind, SessionKind::Agent { agent_id: a } if &a == agent_id))
.map(|e| e.session.id)
.collect()
})
.unwrap_or_default()
}
/// Inserts a freshly-opened session. /// Inserts a freshly-opened session.
pub fn insert(&self, handle: PtyHandle, session: TerminalSession) { pub fn insert(&self, handle: PtyHandle, session: TerminalSession) {
if let Ok(mut map) = self.entries.lock() { if let Ok(mut map) = self.entries.lock() {
@ -59,8 +145,103 @@ impl TerminalSessions {
.and_then(|m| m.get(id).map(|e| e.session.clone())) .and_then(|m| m.get(id).map(|e| e.session.clone()))
} }
/// Removes a session from the registry, returning its handle if present. /// Returns the [`SessionId`] of the live session hosting a given agent, if any.
///
/// An agent runs in a session tagged [`SessionKind::Agent`]; this is the
/// mapping the orchestrator's `stop_agent` uses to translate an agent id into
/// the [`SessionId`] that `CloseTerminal` expects. Returns `None` when the
/// agent has no live session (already stopped / never launched).
///
/// **Unambiguous by construction**: the "one live session per agent"
/// invariant (enforced in [`crate::agent::LaunchAgent`]) guarantees at most
/// one live session per agent, so the first match is *the* match. `find`
/// short-circuits on it.
#[must_use]
pub fn session_for_agent(&self, agent_id: &AgentId) -> Option<SessionId> {
self.entries.lock().ok().and_then(|m| {
m.values()
.find(|e| matches!(e.session.kind, SessionKind::Agent { agent_id: a } if &a == agent_id))
.map(|e| e.session.id)
})
}
/// Returns the [`NodeId`] of the live cell hosting a given agent, if any.
///
/// Companion to [`Self::session_for_agent`]: the launch guard needs the
/// *host node* of an already-live agent to report it in
/// [`crate::error::AppError::AgentAlreadyRunning`]. Unambiguous by the same
/// one-live-session-per-agent invariant.
#[must_use]
pub fn node_for_agent(&self, agent_id: &AgentId) -> Option<NodeId> {
self.entries.lock().ok().and_then(|m| {
m.values()
.find(|e| matches!(e.session.kind, SessionKind::Agent { agent_id: a } if &a == agent_id))
.map(|e| e.session.node_id)
})
}
/// Lists every currently-live agent, its current host cell and session id.
///
/// One `(AgentId, NodeId, SessionId)` tuple per session tagged [`SessionKind::Agent`].
/// Used by the `list_live_agents` query so the UI can disable an agent that
/// is already running elsewhere (it cannot be launched in a second cell).
#[must_use]
pub fn live_agents(&self) -> Vec<(AgentId, NodeId, SessionId)> {
self.entries
.lock()
.map(|m| {
m.values()
.filter_map(|e| match e.session.kind {
SessionKind::Agent { agent_id } => {
Some((agent_id, e.session.node_id, e.session.id))
}
SessionKind::Plain => None,
})
.collect()
})
.unwrap_or_default()
}
/// Rebinds a live agent session to a new visible layout node without
/// respawning the CLI process.
///
/// This is the application-level "cell is a view" operation: closing a cell
/// can leave an agent running in the background, and later opening that agent
/// in another cell updates only the view binding (`node_id`). The PTY handle,
/// session id, scrollback and process stay untouched.
#[must_use]
pub fn rebind_agent_node(
&self,
agent_id: &AgentId,
node_id: NodeId,
) -> Option<TerminalSession> {
self.entries.lock().ok().and_then(|mut m| {
let entry = m.values_mut().find(
|e| matches!(e.session.kind, SessionKind::Agent { agent_id: a } if &a == agent_id),
)?;
entry.session.node_id = node_id;
Some(entry.session.clone())
})
}
/// Returns the [`PtyHandle`]s of every currently-registered session.
///
/// Used at application shutdown to kill all live PTYs cleanly (the
/// `CloseRequested` hook), independently of the frontend's per-view lifecycle.
#[must_use]
pub fn handles(&self) -> Vec<PtyHandle> {
self.entries
.lock()
.map(|m| m.values().map(|e| e.handle.clone()).collect())
.unwrap_or_default()
}
/// Removes a session from the registry, returning its handle if present. Also
/// drops any conversation binding pointing at it (no stale `session_for`).
pub fn remove(&self, id: &SessionId) -> Option<PtyHandle> { pub fn remove(&self, id: &SessionId) -> Option<PtyHandle> {
if let Ok(mut c) = self.conversations.lock() {
c.retain(|_, sid| sid != id);
}
self.entries self.entries
.lock() .lock()
.ok() .ok()
@ -79,3 +260,254 @@ impl TerminalSessions {
self.len() == 0 self.len() == 0
} }
} }
// ---------------------------------------------------------------------------
// StructuredSessions — le jumeau de TerminalSessions pour les sessions IA (§17.5)
// ---------------------------------------------------------------------------
/// Une session structurée enregistrée : la session vivante plus les coordonnées
/// (agent + cellule hôte) que [`AgentSession`] ne porte pas lui-même.
///
/// `AgentSession` n'expose que `id()`/`conversation_id()` ; comme le snapshot
/// [`TerminalSession`] côté PTY, on associe ici l'`agent_id` (clé de liveness) et
/// le `node_id` (cellule-vue, rebindable) pour offrir la **même** surface que
/// [`TerminalSessions`].
struct StructuredEntry {
/// La session vivante (ressource process/SDK), derrière le port domaine.
session: Arc<dyn AgentSession>,
/// L'agent IA pilotant cette session (invariant « 1 session vivante/agent »).
agent_id: AgentId,
/// La cellule (feuille de layout) qui héberge actuellement la vue.
node_id: NodeId,
}
/// Registre en mémoire des sessions IA structurées vivantes (ARCHITECTURE §17.5).
///
/// **Jumeau de [`TerminalSessions`]** : même rôle (état d'exécution applicatif, pas
/// du modèle métier — cf. les docs de [`TerminalSessions`]), même surface côté
/// liveness/agent (`session_for_agent`, `node_for_agent`, `live_agents`,
/// `rebind_agent_node`, `insert`/`remove`/`session`). La seule différence : il
/// stocke des `Arc<dyn AgentSession>` (sessions programmatiques) au lieu de
/// [`PtyHandle`]/[`TerminalSession`].
///
/// Respecte l'invariant produit **« 1 session vivante par agent »** : la garde
/// d'unicité (généralisée sur les deux registres via [`LiveSessions`]) interroge
/// `session_for_agent` avant tout lancement.
#[derive(Default)]
pub struct StructuredSessions {
entries: Mutex<HashMap<SessionId, StructuredEntry>>,
}
impl LiveAgentRegistry for StructuredSessions {
fn is_agent_live(&self, agent_id: &AgentId) -> bool {
self.session_for_agent(agent_id).is_some()
}
fn is_node_live(&self, node_id: &NodeId) -> bool {
self.entries
.lock()
.map(|m| m.values().any(|e| e.node_id == *node_id))
.unwrap_or(false)
}
}
impl StructuredSessions {
/// Crée un registre vide.
#[must_use]
pub fn new() -> Self {
Self {
entries: Mutex::new(HashMap::new()),
}
}
/// Enregistre une session fraîchement démarrée pour `agent_id`, hébergée par
/// la cellule `node_id`. Clé par l'id de session ([`AgentSession::id`]).
pub fn insert(&self, session: Arc<dyn AgentSession>, agent_id: AgentId, node_id: NodeId) {
if let Ok(mut map) = self.entries.lock() {
let id = session.id();
map.insert(
id,
StructuredEntry {
session,
agent_id,
node_id,
},
);
}
}
/// Retourne la session enregistrée pour un id, si présente.
#[must_use]
pub fn session(&self, id: &SessionId) -> Option<Arc<dyn AgentSession>> {
self.entries
.lock()
.ok()
.and_then(|m| m.get(id).map(|e| Arc::clone(&e.session)))
}
/// Retourne la session vivante hébergeant `agent_id`, si elle existe.
///
/// Jumeau de [`TerminalSessions::session_for_agent`] : **non ambigu** par
/// l'invariant « 1 session vivante/agent » — le premier match est *le* match.
#[must_use]
pub fn session_for_agent(&self, agent_id: &AgentId) -> Option<Arc<dyn AgentSession>> {
self.entries.lock().ok().and_then(|m| {
m.values()
.find(|e| &e.agent_id == agent_id)
.map(|e| Arc::clone(&e.session))
})
}
/// Retourne l'[`SessionId`] de la session vivante hébergeant `agent_id`, si any.
#[must_use]
pub fn session_id_for_agent(&self, agent_id: &AgentId) -> Option<SessionId> {
self.entries.lock().ok().and_then(|m| {
m.values()
.find(|e| &e.agent_id == agent_id)
.map(|e| e.session.id())
})
}
/// Retourne le [`NodeId`] de la cellule vivante hébergeant `agent_id`, si any.
///
/// Jumeau de [`TerminalSessions::node_for_agent`].
#[must_use]
pub fn node_for_agent(&self, agent_id: &AgentId) -> Option<NodeId> {
self.entries.lock().ok().and_then(|m| {
m.values()
.find(|e| &e.agent_id == agent_id)
.map(|e| e.node_id)
})
}
/// Liste chaque agent IA vivant, sa cellule hôte et son id de session.
///
/// Jumeau de [`TerminalSessions::live_agents`] : un tuple
/// `(AgentId, NodeId, SessionId)` par session structurée vivante.
#[must_use]
pub fn live_agents(&self) -> Vec<(AgentId, NodeId, SessionId)> {
self.entries
.lock()
.map(|m| {
m.values()
.map(|e| (e.agent_id, e.node_id, e.session.id()))
.collect()
})
.unwrap_or_default()
}
/// Rebinde la session vivante d'un agent vers une nouvelle cellule-vue sans
/// redémarrer la conversation (« la cellule est une vue », §17.6).
///
/// Jumeau de [`TerminalSessions::rebind_agent_node`] : seul le `node_id`
/// change ; la session, son id et sa conversation restent intacts. Retourne la
/// session rebindée, ou `None` si l'agent n'a pas de session vivante.
#[must_use]
pub fn rebind_agent_node(
&self,
agent_id: &AgentId,
node_id: NodeId,
) -> Option<Arc<dyn AgentSession>> {
self.entries.lock().ok().and_then(|mut m| {
let entry = m.values_mut().find(|e| &e.agent_id == agent_id)?;
entry.node_id = node_id;
Some(Arc::clone(&entry.session))
})
}
/// Retire une session du registre, retournant la session si présente (pour que
/// l'appelant la `shutdown` hors du verrou).
pub fn remove(&self, id: &SessionId) -> Option<Arc<dyn AgentSession>> {
self.entries
.lock()
.ok()
.and_then(|mut m| m.remove(id).map(|e| e.session))
}
/// Retourne toutes les sessions vivantes (pour un arrêt global propre au
/// shutdown applicatif, jumeau de [`TerminalSessions::handles`]).
#[must_use]
pub fn sessions(&self) -> Vec<Arc<dyn AgentSession>> {
self.entries
.lock()
.map(|m| m.values().map(|e| Arc::clone(&e.session)).collect())
.unwrap_or_default()
}
/// Nombre de sessions structurées vivantes.
#[must_use]
pub fn len(&self) -> usize {
self.entries.lock().map(|m| m.len()).unwrap_or(0)
}
/// Si le registre est vide.
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
// ---------------------------------------------------------------------------
// LiveSessions — agrégateur des deux registres derrière LiveAgentRegistry (§17.5)
// ---------------------------------------------------------------------------
/// Agrégateur de liveness sur **les deux** registres (PTY + structuré).
///
/// Un agent terminal brut vit dans [`TerminalSessions`] ; un agent IA structuré vit
/// dans [`StructuredSessions`]. La garde d'unicité et l'orchestrateur (§17.4)
/// dépendent de cet agrégateur (ISP : ils ne voient que la capacité « liveness +
/// résolution »), et une requête de liveness/agent voit donc les deux registres :
/// un agent est vivant s'il a une session vivante dans **l'un OU l'autre**.
///
/// Implémente [`LiveAgentRegistry`] : le trait existant n'est **pas modifié** (ses
/// implémenteurs et appelants actuels — `TerminalSessions`, le snapshot — restent
/// inchangés), il est simplement **réalisé par un troisième implémenteur** qui
/// agrège, ce qui *généralise* sa portée à l'ensemble PTY+structuré sans régression.
pub struct LiveSessions {
/// Registre des sessions terminal brut (PTY).
pub pty: Arc<TerminalSessions>,
/// Registre des sessions IA structurées.
pub structured: Arc<StructuredSessions>,
}
impl LiveSessions {
/// Construit l'agrégateur à partir des deux registres partagés.
#[must_use]
pub fn new(pty: Arc<TerminalSessions>, structured: Arc<StructuredSessions>) -> Self {
Self { pty, structured }
}
/// L'[`SessionId`] de la session vivante d'un agent, PTY **ou** structurée.
#[must_use]
pub fn session_id_for_agent(&self, agent_id: &AgentId) -> Option<SessionId> {
self.pty
.session_for_agent(agent_id)
.or_else(|| self.structured.session_id_for_agent(agent_id))
}
/// La cellule hôte de la session vivante d'un agent, PTY **ou** structurée.
#[must_use]
pub fn node_for_agent(&self, agent_id: &AgentId) -> Option<NodeId> {
self.pty
.node_for_agent(agent_id)
.or_else(|| self.structured.node_for_agent(agent_id))
}
/// Tous les agents vivants des deux registres (PTY puis structurés).
#[must_use]
pub fn live_agents(&self) -> Vec<(AgentId, NodeId, SessionId)> {
let mut all = self.pty.live_agents();
all.extend(self.structured.live_agents());
all
}
}
impl LiveAgentRegistry for LiveSessions {
fn is_agent_live(&self, agent_id: &AgentId) -> bool {
self.pty.is_agent_live(agent_id) || self.structured.is_agent_live(agent_id)
}
fn is_node_live(&self, node_id: &NodeId) -> bool {
self.pty.is_node_live(node_id) || self.structured.is_node_live(node_id)
}
}

Some files were not shown because too many files have changed in this diff Show More