Compare commits
56 Commits
307ae71857
...
wip/p8c-ch
| Author | SHA1 | Date | |
|---|---|---|---|
| b05d04ab7a | |||
| 27597eb64e | |||
| 46492506e1 | |||
| aa2f67ae89 | |||
| 0f8ba38d51 | |||
| fdcf16c387 | |||
| 4509f0db9d | |||
| d87b8f6ed2 | |||
| 9b053216e3 | |||
| 09cc8f0902 | |||
| e583b2b49f | |||
| 19ba77824f | |||
| c1411d3b69 | |||
| 0bf7a5c43c | |||
| 2a5873dcf0 | |||
| f3046f3dd8 | |||
| 75e4f57a71 | |||
| eca2ba95c4 | |||
| 5f45c22941 | |||
| 2f20fdbab4 | |||
| cf89b3b9a5 | |||
| 6ca519b815 | |||
| 37e72747d3 | |||
| 97daf3fae5 | |||
| 6de4e5a6e0 | |||
| dd1194abe8 | |||
| 5059f37890 | |||
| f4d5727a69 | |||
| 050afa7d24 | |||
| 56913b9053 | |||
| f104682477 | |||
| 751d94dd89 | |||
| 5e10b5eb42 | |||
| 7375f706da | |||
| b82e3e1a40 | |||
| 2433e173a1 | |||
| 62bd5130fb | |||
| 785e9935fd | |||
| 32398827fb | |||
| b39c11a64d | |||
| f3bc3f20d8 | |||
| 2435857cbf | |||
| 98a8b7292a | |||
| 3ed0f6b45f | |||
| d11eaaa8c0 | |||
| b9fd2fb925 | |||
| 9b92259429 | |||
| fbcf7bd436 | |||
| e0c7e1403d | |||
| 480e7c7bbe | |||
| 3be55795a6 | |||
| 2332b7f815 | |||
| 9736c42424 | |||
| 0638ce7c98 | |||
| 0660f52e2b | |||
| 33edbad713 |
1
.claude/worktrees/agent-a2650e91d2bd39ca2
Submodule
1
.claude/worktrees/agent-a2650e91d2bd39ca2
Submodule
Submodule .claude/worktrees/agent-a2650e91d2bd39ca2 added at 480e7c7bbe
1
.claude/worktrees/agent-aeb1e862ef04b991b
Submodule
1
.claude/worktrees/agent-aeb1e862ef04b991b
Submodule
Submodule .claude/worktrees/agent-aeb1e862ef04b991b added at ef101db9dc
14
.gitignore
vendored
14
.gitignore
vendored
@ -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
54
.ideai/agents.json
Normal 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
604
.ideai/agents/architect.md
Normal 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.*
|
||||||
75
.ideai/agents/devbackend.md
Normal file
75
.ideai/agents/devbackend.md
Normal 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.
|
||||||
74
.ideai/agents/devfrontend.md
Normal file
74
.ideai/agents/devfrontend.md
Normal 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
187
.ideai/agents/main.md
Normal 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
0
.ideai/agents/newtest.md
Normal file
77
.ideai/agents/qa.md
Normal file
77
.ideai/agents/qa.md
Normal 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.
|
||||||
0
.ideai/agents/testconversation.md
Normal file
0
.ideai/agents/testconversation.md
Normal file
515
.ideai/briefs/conversation-pair-cadrage.md
Normal file
515
.ideai/briefs/conversation-pair-cadrage.md
Normal 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.*
|
||||||
88
.ideai/briefs/d4-commandes-bridge-chat.md
Normal file
88
.ideai/briefs/d4-commandes-bridge-chat.md
Normal 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**.
|
||||||
99
.ideai/briefs/d5-frontend-chat.md
Normal file
99
.ideai/briefs/d5-frontend-chat.md
Normal 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**.
|
||||||
104
.ideai/briefs/d6-messagerie-inter-agents.md
Normal file
104
.ideai/briefs/d6-messagerie-inter-agents.md
Normal 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**.
|
||||||
95
.ideai/briefs/d7-menu-restreint.md
Normal file
95
.ideai/briefs/d7-menu-restreint.md
Normal 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**.
|
||||||
48
.ideai/briefs/option1-terminal-mcp-design.md
Normal file
48
.ideai/briefs/option1-terminal-mcp-design.md
Normal 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).
|
||||||
280
.ideai/briefs/orchestration-v3-cadrage.md
Normal file
280
.ideai/briefs/orchestration-v3-cadrage.md
Normal 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).
|
||||||
85
.ideai/briefs/orchestration-v3-invocation-native.md
Normal file
85
.ideai/briefs/orchestration-v3-invocation-native.md
Normal 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.
|
||||||
215
.ideai/briefs/orchestration-v5-transport-bind-cadrage.md
Normal file
215
.ideai/briefs/orchestration-v5-transport-bind-cadrage.md
Normal 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)`** où `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`.
|
||||||
35
.ideai/briefs/validation-reelle-inter-agents.md
Normal file
35
.ideai/briefs/validation-reelle-inter-agents.md
Normal 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
@ -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é. ✅
|
||||||
@ -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
70
.ideai/layouts.json
Normal 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
6
.ideai/memory/MEMORY.md
Normal 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).
|
||||||
106
.ideai/memory/agent-context-memory-and-profile-handoff.md
Normal file
106
.ideai/memory/agent-context-memory-and-profile-handoff.md
Normal 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.
|
||||||
206
.ideai/memory/idea-product-directives-main-handoff.md
Normal file
206
.ideai/memory/idea-product-directives-main-handoff.md
Normal 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.
|
||||||
51
.ideai/memory/mcp-bridge-and-delegation-runtime-notes.md
Normal file
51
.ideai/memory/mcp-bridge-and-delegation-runtime-notes.md
Normal 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]].
|
||||||
273
.ideai/memory/remaining-work-idea-agent-control-ide.md
Normal file
273
.ideai/memory/remaining-work-idea-agent-control-ide.md
Normal 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
9
.ideai/project.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"id": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023",
|
||||||
|
"name": "IdeA",
|
||||||
|
"remote": {
|
||||||
|
"kind": "local"
|
||||||
|
},
|
||||||
|
"createdAt": 1780702317785
|
||||||
|
}
|
||||||
1584
ARCHITECTURE.md
1584
ARCHITECTURE.md
File diff suppressed because it is too large
Load Diff
187
CLAUDE.md
Normal file
187
CLAUDE.md
Normal 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
1167
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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
65
agents-dev/L12-skills.md
Normal 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).
|
||||||
35
agents-dev/L13-orchestrator.md
Normal file
35
agents-dev/L13-orchestrator.md
Normal 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.
|
||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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 }
|
||||||
|
|||||||
191
crates/app-tauri/src/chat.rs
Normal file
191
crates/app-tauri/src/chat.rs
Normal 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
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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!())
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
663
crates/app-tauri/src/mcp_bridge.rs
Normal file
663
crates/app-tauri/src/mcp_bridge.rs
Normal 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
293
crates/app-tauri/src/mcp_endpoint.rs
Normal file
293
crates/app-tauri/src/mcp_endpoint.rs
Normal 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é");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
383
crates/app-tauri/tests/chat_bridge.rs
Normal file
383
crates/app-tauri/tests/chat_bridge.rs
Normal 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")]);
|
||||||
|
}
|
||||||
@ -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(),
|
||||||
|
|||||||
@ -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"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
155
crates/app-tauri/tests/dto_change_agent_profile.rs
Normal file
155
crates/app-tauri/tests/dto_change_agent_profile.rs
Normal 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);
|
||||||
|
}
|
||||||
220
crates/app-tauri/tests/dto_chat.rs
Normal file
220
crates/app-tauri/tests/dto_chat.rs
Normal 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");
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
192
crates/app-tauri/tests/dto_resumable_agents.rs
Normal file
192
crates/app-tauri/tests/dto_resumable_agents.rs
Normal 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());
|
||||||
|
}
|
||||||
@ -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]
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
182
crates/app-tauri/tests/list_live_agents_r0b.rs
Normal file
182
crates/app-tauri/tests/list_live_agents_r0b.rs
Normal 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");
|
||||||
|
}
|
||||||
332
crates/app-tauri/tests/orchestrator_wiring.rs
Normal file
332
crates/app-tauri/tests/orchestrator_wiring.rs
Normal 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);
|
||||||
|
}
|
||||||
@ -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]));
|
||||||
|
}
|
||||||
|
|||||||
@ -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 }
|
||||||
|
|||||||
@ -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)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
142
crates/application/src/agent/inspect.rs
Normal file
142
crates/application/src/agent/inspect.rs
Normal 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
@ -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,
|
||||||
|
|||||||
193
crates/application/src/agent/resume.rs
Normal file
193
crates/application/src/agent/resume.rs
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
251
crates/application/src/agent/structured.rs
Normal file
251
crates/application/src/agent/structured.rs
Normal 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)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
crates/application/src/conversation/mod.rs
Normal file
18
crates/application/src/conversation/mod.rs
Normal 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;
|
||||||
100
crates/application/src/conversation/record.rs
Normal file
100
crates/application/src/conversation/record.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
24
crates/application/src/embedder/mod.rs
Normal file
24
crates/application/src/embedder/mod.rs
Normal 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,
|
||||||
|
};
|
||||||
269
crates/application/src/embedder/suggestion.rs
Normal file
269
crates/application/src/embedder/suggestion.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
245
crates/application/src/embedder/usecases.rs
Normal file
245
crates/application/src/embedder/usecases.rs
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
101
crates/application/src/layout/reconcile.rs
Normal file
101
crates/application/src/layout/reconcile.rs
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
117
crates/application/src/layout/snapshot.rs
Normal file
117
crates/application/src/layout/snapshot.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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};
|
||||||
|
|||||||
23
crates/application/src/memory/mod.rs
Normal file
23
crates/application/src/memory/mod.rs
Normal 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,
|
||||||
|
};
|
||||||
418
crates/application/src/memory/usecases.rs
Normal file
418
crates/application/src/memory/usecases.rs
Normal 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?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
839
crates/application/src/orchestrator/context_guard.rs
Normal file
839
crates/application/src/orchestrator/context_guard.rs
Normal 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, ¬e).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);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
crates/application/src/orchestrator/mod.rs
Normal file
16
crates/application/src/orchestrator/mod.rs
Normal 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,
|
||||||
|
};
|
||||||
2056
crates/application/src/orchestrator/service.rs
Normal file
2056
crates/application/src/orchestrator/service.rs
Normal file
File diff suppressed because it is too large
Load Diff
150
crates/application/src/permission.rs
Normal file
150
crates/application/src/permission.rs
Normal 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>,
|
||||||
|
}
|
||||||
115
crates/application/src/project/context.rs
Normal file
115
crates/application/src/project/context.rs
Normal 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}"),
|
||||||
|
))
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
|||||||
@ -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)]
|
||||||
|
|||||||
21
crates/application/src/skill/mod.rs
Normal file
21
crates/application/src/skill/mod.rs
Normal 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,
|
||||||
|
};
|
||||||
351
crates/application/src/skill/usecases.rs
Normal file
351
crates/application/src/skill/usecases.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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?;
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
Reference in New Issue
Block a user