feat: add main features
Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
8
.cargo/config.toml
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# Build configuration for the IdeA workspace.
|
||||||
|
#
|
||||||
|
# Link the system libgit2 (via pkg-config) instead of vendoring/compiling it from
|
||||||
|
# source. The dev machines and CI provide libgit2 ≥ 1.9 (matching git2 0.20), and
|
||||||
|
# this avoids requiring cmake for the vendored build. Static vendoring for the
|
||||||
|
# portable AppImage is handled separately at packaging time (L11).
|
||||||
|
[env]
|
||||||
|
LIBGIT2_SYS_USE_PKG_CONFIG = "1"
|
||||||
601
ARCHITECTURE.md
Normal file
@ -0,0 +1,601 @@
|
|||||||
|
# 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` |
|
||||||
|
| `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 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.*
|
||||||
187
CONTEXT.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*
|
||||||
4975
Cargo.lock
generated
Normal file
35
Cargo.toml
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = [
|
||||||
|
"crates/domain",
|
||||||
|
"crates/application",
|
||||||
|
"crates/infrastructure",
|
||||||
|
"crates/app-tauri",
|
||||||
|
]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
edition = "2021"
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
rust-version = "1.80"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
uuid = { version = "1", features = ["serde", "v4", "v5", "macro-diagnostics"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
thiserror = "2"
|
||||||
|
async-trait = "0.1"
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util"] }
|
||||||
|
# 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
|
||||||
|
# push/pull and static vendoring for the AppImage are deferred to L9/L11.
|
||||||
|
git2 = { version = "0.20", default-features = false }
|
||||||
|
|
||||||
|
# Internal crates
|
||||||
|
domain = { path = "crates/domain" }
|
||||||
|
application = { path = "crates/application" }
|
||||||
|
infrastructure = { path = "crates/infrastructure" }
|
||||||
|
|
||||||
|
# Tauri v2
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
54
agents-dev/L0-core-domain.md
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
# L0 — Socle domaine & ports
|
||||||
|
|
||||||
|
**Binôme :** `dev-core-domain` / `test-core-domain`
|
||||||
|
**Crate :** `crates/domain` (pur, zéro I/O)
|
||||||
|
**Dépendances amont :** aucune (fondation du projet).
|
||||||
|
**Statut :** en cours.
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Poser le **cœur hexagonal** : toutes les entités, value objects, invariants, **ports (traits)**, domain events et la **logique de layout pure**. Aucun adapter, aucune I/O.
|
||||||
|
|
||||||
|
## Périmètre (DEV)
|
||||||
|
|
||||||
|
### Entities & Value Objects (cf. ARCHITECTURE §3)
|
||||||
|
- IDs typés (`ProjectId`, `AgentId`, `TemplateId`, `ProfileId`, `SessionId`, `WindowId`, `TabId`, `NodeId`).
|
||||||
|
- `Project`, `ProjectPath`, `Agent`, `AgentOrigin`, `AgentTemplate`, `TemplateVersion`, `AgentProfile`, `ContextInjection`.
|
||||||
|
- `TerminalSession`, `SessionKind`, `PtySize`, `RemoteRef`/`SshAuth`, `GitRepository`, `AgentManifest`/`ManifestEntry`, `Workspace`/`Window`/`Tab`.
|
||||||
|
- `MarkdownDoc` (VO contenu .md).
|
||||||
|
- Tous les **invariants** documentés en §3 doivent être appliqués (constructeurs validants / `try_new`).
|
||||||
|
|
||||||
|
### Logique de layout pure (cf. ARCHITECTURE §7)
|
||||||
|
- `LayoutNode` (`Leaf`/`Split`/`Grid`), `SplitContainer`, `WeightedChild`, `GridContainer`, `GridCell`.
|
||||||
|
- Opérations **pures** : `split`, `merge`, `resize`, `move` → `LayoutTree -> Result<LayoutTree, LayoutError>`.
|
||||||
|
- Validation des invariants (poids > 0, pas de chevauchement de spans, surface couverte, 1 session par leaf max).
|
||||||
|
|
||||||
|
### Ports (traits) — définitions seulement, pas d'impl
|
||||||
|
`AgentRuntime`, `PtyPort`, `RemoteHost`, `ProcessSpawner`, `FileSystem`, `TemplateStore`, `ProjectStore`, `AgentContextStore`, `GitRepository`, `EventBus`, `Clock`, `IdGenerator` (signatures conceptuelles en ARCHITECTURE §4).
|
||||||
|
|
||||||
|
### Domain events & erreurs
|
||||||
|
- `DomainEvent` (enum complet de §3.2).
|
||||||
|
- Types d'erreur par domaine (`LayoutError`, et erreurs de port définies ici si partagées).
|
||||||
|
|
||||||
|
### serde
|
||||||
|
- Autorisé **uniquement** pour les types persistés (manifeste, layout, profils) — dérive `Serialize`/`Deserialize`. Aucune autre dépendance I/O.
|
||||||
|
|
||||||
|
## Périmètre (TEST)
|
||||||
|
|
||||||
|
- Invariants d'entités : rejets attendus (chemin relatif, `synchronized` sans template, port SSH hors plage, etc.).
|
||||||
|
- **Layout** (cœur du lot) : `split`/`merge`/`resize`/`move` — cas nominaux + cas d'erreur (chevauchement, poids ≤ 0, span hors grille, session dupliquée).
|
||||||
|
- Déterminisme via `FixedClock`/`SeqIdGenerator`.
|
||||||
|
- `ContextInjection` : validation des 4 variantes (target relatif, var env valide, flag non vide).
|
||||||
|
- Sérialisation round-trip JSON des types persistés.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
- `cargo test -p domain` vert.
|
||||||
|
- `crates/domain/Cargo.toml` ne dépend d'aucun crate I/O (vérifié).
|
||||||
|
- Tous les ports compilent et sont documentés.
|
||||||
|
- Logique de layout couverte (cas limites inclus).
|
||||||
|
|
||||||
|
## Notes / points d'attention
|
||||||
|
|
||||||
|
- Choisir `async_trait` vs `-> impl Future` pour les ports I/O (à figer ici, impacte tous les lots).
|
||||||
|
- Garder les traits **fins** (Interface Segregation) : ne pas fusionner FS/PTY/Process.
|
||||||
44
agents-dev/L1-ipc-bridge.md
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# L1 — Composition root & IPC
|
||||||
|
|
||||||
|
**Binôme :** `dev-ipc-bridge` / `test-ipc-bridge`
|
||||||
|
**Zones :** `crates/app-tauri`, `frontend/ports`, `frontend/adapters`
|
||||||
|
**Dépendances amont :** L0 (ports figés).
|
||||||
|
**Statut :** suivant (enchaîné après L0).
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Mettre en place le **squelette Tauri qui tourne** : composition root (DI), pont IPC bidirectionnel, et la couche ports/adapters du frontend (avec mocks) — pour que le front soit développable **sans backend** dès les lots suivants.
|
||||||
|
|
||||||
|
## Périmètre (DEV)
|
||||||
|
|
||||||
|
### Backend `app-tauri` (driving adapter + composition root)
|
||||||
|
- **Composition root** : instancier les adapters concrets (au départ : `LocalFileSystem`, `TokioBroadcastEventBus`, `SystemClock`, `UuidGenerator`) et les injecter dans les use cases via `tauri::State` (`Arc<dyn Port>`).
|
||||||
|
- Registre des `#[tauri::command]` (squelette, mapping DTO ↔ use case) et `ErrorDTO`.
|
||||||
|
- **`TauriEventRelay`** : souscrit l'`EventBus` domaine → relaie en events/Channels Tauri.
|
||||||
|
- **Bridge PTY ↔ Tauri Channel** (`tauri::ipc::Channel`) : infrastructure générique de flux d'octets par session (sera consommée par L3).
|
||||||
|
- App Tauri v2 minimale qui démarre (fenêtre vide).
|
||||||
|
|
||||||
|
### Frontend (hexagonal côté UI)
|
||||||
|
- `frontend/ports` : gateways TS (`AgentGateway`, `TerminalGateway`, `ProjectGateway`, `LayoutGateway`, `GitGateway`, `RemoteGateway`).
|
||||||
|
- `frontend/adapters` : impl via `@tauri-apps/api` (`invoke`/`listen`/`Channel`).
|
||||||
|
- `frontend/adapters/mock` : impl mock de chaque gateway.
|
||||||
|
- `frontend/app` : bootstrap React + Vite, provider de DI des adapters (réel vs mock).
|
||||||
|
|
||||||
|
## Périmètre (TEST)
|
||||||
|
|
||||||
|
- Backend : mapping commands ↔ use cases (un use case in-memory simple validant le wiring).
|
||||||
|
- Relais `EventBus` → events Tauri (un `DomainEvent` publié arrive bien côté relais).
|
||||||
|
- Front : chaque gateway mock satisfait l'interface du port (typecheck + tests Vitest).
|
||||||
|
- Provider de DI : bascule réel/mock fonctionnelle.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
- L'app Tauri démarre (fenêtre vide) sur Linux.
|
||||||
|
- `cargo test -p app-tauri` et `vitest` verts.
|
||||||
|
- Front compile et tourne en mode **mock** sans backend.
|
||||||
|
- Aucun `invoke()` direct dans les composants (uniquement via gateways).
|
||||||
|
|
||||||
|
## Points d'attention / spikes
|
||||||
|
|
||||||
|
- Forme du bridge PTY↔Channel (backpressure) — préparé ici, stressé en L3.
|
||||||
|
- Convention de (dé)sérialisation DTO Rust ↔ TS (serde camelCase ?). À figer ici.
|
||||||
38
agents-dev/L10-windows.md
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# L10 — Fenêtres & multi-window
|
||||||
|
|
||||||
|
**Binôme :** `dev-windows` / `test-windows`
|
||||||
|
**Zones :** `application`, `app-tauri`, `frontend/app`
|
||||||
|
**Dépendances amont :** L0, L1, L2, L4.
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
Gestion des fenêtres et onglets : un onglet par projet ; **drag d'un onglet hors de la fenêtre → nouvelle fenêtre OS** portant ce projet.
|
||||||
|
|
||||||
|
## Périmètre (DEV)
|
||||||
|
- Entités `Workspace`/`Window`/`Tab` + persistance (`workspace.json`, machine-local).
|
||||||
|
- Use case `MoveTabToNewWindow` (réaffectation `WindowId`, l'onglet est déplacé, pas dupliqué).
|
||||||
|
- `app-tauri` : création de `WebviewWindow`, transfert d'état, fermeture de l'onglet source.
|
||||||
|
- Front : barre d'onglets, drag & drop, restauration de session.
|
||||||
|
|
||||||
|
## Périmètre (TEST)
|
||||||
|
- `MoveTabToNewWindow` : invariants (un projet dans un seul onglet à la fois ; fenêtre ≥ 1 onglet ou fermée).
|
||||||
|
- Persistance workspace round-trip.
|
||||||
|
- Front : interactions onglets (mock).
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
- `cargo test` + `vitest` verts ; détacher un onglet en nouvelle fenêtre fonctionne (dev manuel).
|
||||||
|
|
||||||
|
## Avancement
|
||||||
|
|
||||||
|
### ✅ Backend (vert)
|
||||||
|
- **Domaine** : opération **pure** `Workspace::move_tab_to_new_window(tab, new_window)` (`layout.rs`) — l'onglet est *déplacé* (jamais dupliqué) ; fenêtre source vidée → supprimée ; onglet actif déplacé → repli sur un onglet restant. Variante d'erreur `LayoutError::TabNotFound`. 4 tests domaine.
|
||||||
|
- **Application** (`application/window/`) : `MoveTabToNewWindow` (charge le workspace, mint `WindowId`, applique l'op pure, persiste via `ProjectStore`). 2 tests (store mock). La persistance round-trip du workspace est déjà couverte par `FsProjectStore` (L2).
|
||||||
|
- `cargo test --workspace` : **323 verts, 0 régression** ; clippy clean.
|
||||||
|
|
||||||
|
### ✅ IPC `app-tauri` (vert)
|
||||||
|
- Composition root : use case `MoveTabToNewWindow` injecté. Commande `move_tab_to_new_window(tabId)` : applique la topologie (persistée) **et ouvre une vraie `WebviewWindow`** (primitive de détach résolue). DTO `MoveTabResultDto` + `parse_tab_id`. Test `tests/dto_window.rs` (2). Workspace **325 verts, 0 régression**, clippy clean.
|
||||||
|
|
||||||
|
### ⏳ Reste (fait pendant la refonte disposition L11)
|
||||||
|
- **Front multi-fenêtres** : adopter le modèle `Workspace`/`Window`/`Tab` persistant (aujourd'hui les onglets vivent en state React transitoire), barre d'onglets, **DnD detach** + handoff d'état vers la nouvelle fenêtre. Couplé à la refonte de disposition IDE (L11), donc traité là-bas pour éviter de construire une barre d'onglets jetable.
|
||||||
|
|
||||||
|
## Spike (cf. ARCHITECTURE §13)
|
||||||
|
- DnD inter-fenêtres Tauri (le DnD HTML ne traverse pas les fenêtres OS) → protocole « detach » via store + event.
|
||||||
45
agents-dev/L11-packaging.md
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# L11 — Packaging & livraison
|
||||||
|
|
||||||
|
**Binôme :** `dev-packaging` / `test-packaging`
|
||||||
|
**Zones :** `app-tauri` (bundle), CI
|
||||||
|
**Dépendances amont :** transverse (mûrit avec les autres lots) ; finalisé en fin de cycle.
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
Livrer IdeA : **`setup.exe` (NSIS) Windows** et **AppImage Linux multi-distro**. macOS plus tard.
|
||||||
|
|
||||||
|
## Périmètre (DEV)
|
||||||
|
- Config bundle Tauri v2 : NSIS (Windows), AppImage (Linux).
|
||||||
|
- Vendoring statique des deps natives (git2/openssl → préférer `rustls` pour russh ; features git2) pour la portabilité AppImage.
|
||||||
|
- Pipeline CI : build Linux + Windows, artefacts publiés.
|
||||||
|
|
||||||
|
## Périmètre (TEST)
|
||||||
|
- Le bundle se construit sur Linux et Windows (CI verte).
|
||||||
|
- L'AppImage **démarre sur ≥3 distros** (Ubuntu, Fedora, Arch) — smoke test.
|
||||||
|
- L'installeur Windows installe/lance/désinstalle proprement.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
- CI produit un `setup.exe` et un AppImage fonctionnels ; smoke tests multi-distro verts.
|
||||||
|
|
||||||
|
## Spike (cf. ARCHITECTURE §13)
|
||||||
|
- AppImage multi-distro : glibc/openssl/libgit2 liés dynamiquement = risque ; valider le vendoring statique tôt (coordonné avec L8/L9).
|
||||||
|
|
||||||
|
## Notes de build vérifiées (2026-06-04, premier build sur Arch)
|
||||||
|
- **CLI** : `@tauri-apps/cli` v2 installé en devDependency frontend ; binaire à `frontend/node_modules/.bin/tauri`.
|
||||||
|
- **Hooks `tauri.conf.json`** : Tauri exécute `beforeBuildCommand`/`beforeDevCommand` avec cwd = `IdeA/crates/`. Les chemins npm doivent donc être `--prefix ../frontend` (et NON `../../frontend` ni `frontend`).
|
||||||
|
- **Arch Linux — `linuxdeploy` strip échoue** : le `strip` embarqué dans `linuxdeploy-x86_64.AppImage` ne comprend pas la section ELF `.relr.dyn` des libs Arch modernes (`libzstd.so.1`, `libyuv.so`…) → erreur `unknown type [0x13] section .relr.dyn`. **Parade** : exporter **`NO_STRIP=true`** avant `tauri build`. À intégrer dans la CI/scripts de build Linux.
|
||||||
|
- Build de référence OK : `cd crates/app-tauri && NO_STRIP=true tauri build --bundles appimage` → `target/release/bundle/appimage/IdeA_0.1.0_amd64.AppImage` (~101 Mo).
|
||||||
|
- Le 1er échec `failed to run linuxdeploy` masquait le vrai message (strip) ; `--verbose` est nécessaire pour le voir.
|
||||||
|
- **Écran blanc au lancement (Linux/WebKitGTK)** : le renderer DMABUF de WebKitGTK rend une fenêtre blanche sur beaucoup de configs Linux récentes (Mesa/Nvidia, fréquent sur Arch). **Fix baké dans `crates/app-tauri/src/main.rs`** : on positionne `WEBKIT_DISABLE_DMABUF_RENDERER=1` au début de `main()` (cfg `target_os = "linux"`, seulement si non déjà défini) avant l'init du webview. Plus besoin de variable d'env côté utilisateur. Vérifié visuellement OK sur Arch (UI projets + health-check rendus).
|
||||||
|
|
||||||
|
## Avancement (2026-06-05)
|
||||||
|
|
||||||
|
### ✅ Fait
|
||||||
|
- **Icônes** générées (`tauri icon` → `crates/app-tauri/icons/`, monogramme « IA » sombre/accent) — manquaient, requises par le bundle.
|
||||||
|
- **AppImage Linux reconstruite** : `target/release/bundle/appimage/IdeA_0.1.0_amd64.AppImage` (~103 Mo), validée (ELF AppImage-runtime, libfuse2 présent, se lance directement). `git2` lié à la **libgit2 système** via `.cargo/config.toml` (`LIBGIT2_SYS_USE_PKG_CONFIG=1`).
|
||||||
|
- **`beforeBuildCommand`/`beforeDevCommand`** confirmés à `--prefix ../frontend` (cwd des hooks = `IdeA/crates/`).
|
||||||
|
- **Refonte disposition IDE** (passe UI/altitude) ✅ : `ProjectsView` réécrit en disposition d'IDE — **barre d'onglets projets** en haut, **sidebar** (onglets Projects/Agents/Templates/Git, un panneau à la fois) + **zone principale** = `LayoutGrid` (grille de terminaux) qui remplit la hauteur. App shell en `h-full`. Composants `ProjectLauncher`/`ProjectTabs`/`Workspace` extraits. Hooks de test préservés ; front **158 tests verts**, `tsc` clean. (Note : `beforeBuildCommand` retiré de `tauri.conf.json` — chemins de hook ambigus selon le cwd d'invocation ; on **build le front manuellement** (`npm --prefix frontend run build`) avant `tauri build` lancé **depuis la racine du repo**. C'est le recette déterministe vérifiée.)
|
||||||
|
|
||||||
|
### ⏳ Reste
|
||||||
|
- **Vendoring statique** pour AppImage portable multi-distro : passer git2 en `vendored-libgit2` (nécessite **cmake**, absent de la machine actuelle) + `rustls` pour russh (L9). Aujourd'hui l'AppImage lie la libgit2 **système** → portable seulement vers des distros fournissant libgit2 ≥ 1.9.
|
||||||
|
- **Windows `setup.exe` (NSIS)** : non constructible ici (Linux) → CI Windows.
|
||||||
|
- **CI** Linux+Windows (avec `NO_STRIP=true` côté Linux) + smoke tests ≥3 distros.
|
||||||
22
agents-dev/L2-projects.md
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# L2 — Projets & stockage
|
||||||
|
|
||||||
|
**Binôme :** `dev-projects` / `test-projects`
|
||||||
|
**Zones :** `application/project`, `infrastructure/{fs,store}`, `frontend/features/projects`
|
||||||
|
**Dépendances amont :** L0, L1.
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
Gérer le cycle de vie des projets (création par project root, ouverture, fermeture) et le stockage de base.
|
||||||
|
|
||||||
|
## Périmètre (DEV)
|
||||||
|
- Use cases : `CreateProject` (init `.ideai/` + `project.json` + registre), `OpenProject`, `CloseProject`/`CloseTab`.
|
||||||
|
- Adapters : `LocalFileSystem` (tokio::fs), `FsProjectStore` (registre projets + workspace en JSON dans données app).
|
||||||
|
- UI : sélection du project root, liste des projets, ouverture en onglet.
|
||||||
|
|
||||||
|
## Périmètre (TEST)
|
||||||
|
- Use cases avec `FileSystem`/`ProjectStore` mockés : création initialise bien `.ideai/`, invariants projet respectés (root absolu, unicité `(remote, root)`).
|
||||||
|
- Intégration ciblée : `LocalFileSystem` sur tmpdir, `FsProjectStore` round-trip.
|
||||||
|
- Front : feature projects avec gateway mock (RTL).
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
- `cargo test -p application -p infrastructure` (filtré projet) + `vitest` verts.
|
||||||
|
- Créer/ouvrir/fermer un projet de bout en bout (avec adapters réels en dev manuel).
|
||||||
25
agents-dev/L3-terminals.md
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# L3 — Terminaux & PTY (local)
|
||||||
|
|
||||||
|
**Binôme :** `dev-terminals` / `test-terminals`
|
||||||
|
**Zones :** `infrastructure/pty`, `application/terminal`, `frontend/features/terminals`
|
||||||
|
**Dépendances amont :** L0, L1.
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
Terminaux fonctionnels en local : ouverture PTY, I/O, resize, fermeture, rendu xterm.js, flux via Tauri Channel.
|
||||||
|
|
||||||
|
## Périmètre (DEV)
|
||||||
|
- Adapter `PortablePtyAdapter` (portable-pty) implémentant `PtyPort`.
|
||||||
|
- Use cases : `OpenTerminal`, `WriteToTerminal`, `ResizeTerminal`, `CloseTerminal`.
|
||||||
|
- Front : wrapper xterm.js, abonnement au flux d'octets (Channel), envoi des frappes/resize.
|
||||||
|
|
||||||
|
## Périmètre (TEST)
|
||||||
|
- Use cases avec `PtyPort` mocké (spawn/write/resize/kill appelés correctement).
|
||||||
|
- Intégration : `PortablePtyAdapter` lance `echo`/`printf` et reçoit la sortie attendue.
|
||||||
|
- Front : wrapper xterm avec gateway mock (frappe → write, octets reçus → rendu).
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
- `cargo test` (pty/terminal) + `vitest` verts ; un terminal réel utilisable en dev manuel sur Linux.
|
||||||
|
|
||||||
|
## Spikes (cf. ARCHITECTURE §13)
|
||||||
|
- ConPTY Windows (resize/signaux/exit codes).
|
||||||
|
- Backpressure/coalescing du flux haute fréquence via Channel.
|
||||||
21
agents-dev/L4-layout.md
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# L4 — Layout tableur
|
||||||
|
|
||||||
|
**Binôme :** `dev-layout` / `test-layout`
|
||||||
|
**Zones :** `domain/layout` (déjà amorcé en L0), `application/layout`, `frontend/features/layout`
|
||||||
|
**Dépendances amont :** L0, L1, L3 (cellules ↔ terminaux).
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
Grille redimensionnable type tableur : N colonnes par ligne / M lignes par colonne indépendantes, **fusion de cellules**, persistance.
|
||||||
|
|
||||||
|
## Périmètre (DEV)
|
||||||
|
- Compléter la logique de layout pure du domaine (si reliquats post-L0).
|
||||||
|
- Use case `MutateLayout` (split/merge/resize/move) + persistance `.ideai/layout.json`.
|
||||||
|
- UI : grille redimensionnable (drag des séparateurs), création/suppression de cellules, fusion, mapping cellule → terminal.
|
||||||
|
|
||||||
|
## Périmètre (TEST)
|
||||||
|
- Domaine : opérations pures exhaustives (déjà couvertes L0, étendre cas combinés).
|
||||||
|
- Application : `MutateLayout` persiste et publie `LayoutChanged`.
|
||||||
|
- Front : logique de calcul des tailles de cellules (Vitest, pure) ; interactions de split/merge (RTL + mock).
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
- `cargo test` (layout) + `vitest` verts ; manipulation visuelle de la grille fonctionnelle.
|
||||||
22
agents-dev/L5-ai-runtime.md
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# L5 — Profils IA & runtime
|
||||||
|
|
||||||
|
**Binôme :** `dev-ai-runtime` / `test-ai-runtime`
|
||||||
|
**Zones :** `infrastructure/runtime`, `application/agent`, `frontend/features/first-run`
|
||||||
|
**Dépendances amont :** L0, L1, L2.
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
Moteur IA flexible : profils déclaratifs, détection, first-run wizard. Cœur du « 100% IA, piloté par données ».
|
||||||
|
|
||||||
|
## Périmètre (DEV)
|
||||||
|
- Adapter `CliAgentRuntime` (un seul, piloté par `AgentProfile`) : `detect`, `prepare_invocation` (construit `SpawnSpec` + plan d'injection selon `ContextInjection`).
|
||||||
|
- Use cases : `DetectProfiles`, `ConfigureProfiles`.
|
||||||
|
- Stockage `profiles.json` (store global IDE).
|
||||||
|
- Front : **first-run wizard** demandant quels profils configurer ; commandes pré-remplies (Claude/Codex/Gemini/Aider) **éditables** ; ajout de **profil custom**.
|
||||||
|
|
||||||
|
## Périmètre (TEST)
|
||||||
|
- `CliAgentRuntime` : pour chaque stratégie d'injection (conventionFile/flag/stdin/env), le `SpawnSpec` produit est correct.
|
||||||
|
- `DetectProfiles` avec `ProcessSpawner` mocké (présent/absent).
|
||||||
|
- Front : wizard avec gateway mock (sélection, édition, ajout custom).
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
- `cargo test` (runtime/agent) + `vitest` verts ; wizard fonctionnel en dev manuel.
|
||||||
52
agents-dev/L6-agents.md
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
# L6 — Agents & contextes
|
||||||
|
|
||||||
|
**Binôme :** `dev-agents` / `test-agents`
|
||||||
|
**Zones :** `application/agent`, `infrastructure/store`, `frontend/features/agents`
|
||||||
|
**Dépendances amont :** L0, L1, L2, L3, L5.
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
Agents de projet : contextes `.md` dans `.ideai/`, manifeste, et **lancement d'un agent** (injection contexte + spawn CLI dans une cellule terminal).
|
||||||
|
|
||||||
|
## Périmètre (DEV)
|
||||||
|
- Adapter `IdeaiContextStore` (compose `FileSystem`) : lecture/écriture `.md` + `agents.json`.
|
||||||
|
- Use cases : `CreateAgentFromScratch`, `LaunchAgent` (résout profil+contexte, injecte, ouvre cellule PTY au bon `cwd`, spawn CLI), CRUD agents.
|
||||||
|
- Front : panneau agents (créer, éditer le `.md`, activer → terminal).
|
||||||
|
|
||||||
|
## Périmètre (TEST)
|
||||||
|
- `IdeaiContextStore` : round-trip `.md` + manifeste (intégration tmpdir + mock FS pour les use cases).
|
||||||
|
- `LaunchAgent` : ordre des appels (prepare_invocation → injection → pty.spawn) avec `cwd` correct.
|
||||||
|
- Front : feature agents avec gateway mock.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
- `cargo test` (agent/store) + `vitest` verts ; activer un agent ouvre un terminal avec la CLI lancée (dev manuel).
|
||||||
|
|
||||||
|
## Spike
|
||||||
|
- Injection `conventionFile` : symlink vs copie ; conflits si `CLAUDE.md` existe ; symlinks Windows.
|
||||||
|
|
||||||
|
## Avancement
|
||||||
|
|
||||||
|
### ✅ Backend (vert)
|
||||||
|
- **Domaine** : `ManifestEntry` réconcilié avec le schéma documenté `agents.json` (ARCHITECTURE §9.1) — porte désormais `name` + `profile_id` ; helpers `from_agent`/`to_agent` (le manifeste est la forme persistée d'un `Agent`). Tests domaine maj, verts.
|
||||||
|
- **Infra** : `IdeaiContextStore` (`infrastructure/store/context.rs`) implémente `AgentContextStore` en composant `FileSystem` ; écrit `.ideai/agents.json` + `.ideai/agents/*.md`, location-neutre (réutilisable local/SSH/WSL). Test d'intégration tmpdir (5 tests).
|
||||||
|
- **Application** (`application/agent/lifecycle.rs`) : `CreateAgentFromScratch`, `ListAgents`, `ReadAgentContext`, `UpdateAgentContext`, `DeleteAgent`, et `LaunchAgent` (résout profil+contexte → `prepare_invocation` → injection → `pty.spawn` au bon `cwd` → event `AgentLaunched`). 9 tests use cases (ordre d'appel vérifié via trace partagée).
|
||||||
|
- **Spike `conventionFile`** tranché pour L6 : **copie** du `.md` vers le fichier conventionnel (ex. `CLAUDE.md`), écrasement si présent — choix portable (symlinks Windows = privilèges, sémantique SFTP/WSL divergente). Stratégie `Env` → chemin absolu du `.md` ; `Stdin` → contenu piped après spawn.
|
||||||
|
|
||||||
|
### ✅ Front (vert)
|
||||||
|
- **Port** `AgentGateway` étendu (list/create/read/update/delete/launch) + type `Agent` dans `domain` ; **mock** stateful `MockAgentGateway` ; **adapter** Tauri `TauriAgentGateway` (`src/adapters/agent.ts`, commandes `*_agent` — câblage backend à venir).
|
||||||
|
- **Feature** `frontend/features/agents` : hook `useAgents(projectId)` + `AgentsPanel` (liste, création nom+profil, éditeur de contexte `.md`, Launch/Delete), bâti sur le **design system** (LD) et intégré dans l'onglet projet actif.
|
||||||
|
- **Tests** : 13 nouveaux (RTL + mock) ; suite front **116 verts**, `tsc` clean. Garde-fou « no direct invoke » respecté.
|
||||||
|
|
||||||
|
### ✅ IPC `app-tauri` (vert)
|
||||||
|
- **Composition root** (`state.rs`) : `IdeaiContextStore` construit ; 6 use cases agents instanciés en réutilisant les ports existants ; `LaunchAgent` partage le **même** `pty_port` + `terminal_sessions` que les terminaux (indispensable au `PtyBridge`) ; handle `project_store` ajouté pour résoudre le `Project` depuis un `projectId`.
|
||||||
|
- **Commands** (`commands.rs`) : `create_agent`, `list_agents`, `read_agent_context`, `update_agent_context`, `delete_agent`, `launch_agent`. `launch_agent` imite `open_terminal` (Channel + `PtyBridge` + thread de pompe). Enregistrées dans `lib.rs`.
|
||||||
|
- **DTOs** (`dto.rs`) : `AgentDto`/`AgentListDto` (transparent, camelCase), request DTOs, `parse_agent_id`, `From<LaunchAgentOutput> for TerminalSessionDto`.
|
||||||
|
- **Tests** : `tests/dto_agents.rs` (10) ; `cargo test -p app-tauri` 44 verts ; `cargo test --workspace` **256 verts, 0 régression** ; clippy clean.
|
||||||
|
|
||||||
|
### ✅ Terminal d'agent (front, vert) — L6 clos
|
||||||
|
- `AgentGateway.launchAgent(projectId, agentId, options, onData)` renvoie désormais un `TerminalHandle` (signature calquée sur `openTerminal`) ; adapter Tauri = `Channel<number[]>` + `invoke("launch_agent", …)` + write/resize/close via les commandes terminal (clé `sessionId`) ; mock = greeting + echo.
|
||||||
|
- `TerminalView` généralisé avec une prop `open?` optionnelle (par défaut = gateway terminal) → réutilisé tel quel pour le terminal d'agent (xterm/fit/resize/cleanup partagés).
|
||||||
|
- `AgentsPanel` : bouton **Launch** monte un `TerminalView` (conteneur sombre `h-96`) branché sur la session d'agent ; bouton **Stop** le démonte (cleanup `close()`).
|
||||||
|
- **Tests** front : 120 verts (`tsc` clean), garde-fou « no direct invoke » respecté. Backend/IPC : 256 verts.
|
||||||
|
|
||||||
|
### ⏳ Hors périmètre L6 (à reprendre plus tard)
|
||||||
|
- Affiner la stratégie `Env` (support adapter de premier ordre).
|
||||||
42
agents-dev/L7-templates.md
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
# L7 — Templates & synchronisation
|
||||||
|
|
||||||
|
**Binôme :** `dev-templates` / `test-templates`
|
||||||
|
**Zones :** `application/template`, `infrastructure/store`, `frontend/features/templates`
|
||||||
|
**Dépendances amont :** L0, L1, L5, L6.
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
Templates d'agents (store global IDE), versioning, création d'agent depuis template, **détection de drift** et **synchronisation template → agents**.
|
||||||
|
|
||||||
|
## Périmètre (DEV)
|
||||||
|
- Adapter `FsTemplateStore` (md + `index.json`) avec versioning monotone + `content_hash`.
|
||||||
|
- Use cases : `CreateTemplate`, `UpdateTemplate` (bump version), `CreateAgentFromTemplate`, `DetectAgentDrift`, `SyncAgentWithTemplate` (remplacement du `.md` si `synchronized`).
|
||||||
|
- Front : gestion des templates, badge « MAJ disponible », action de sync.
|
||||||
|
|
||||||
|
## Périmètre (TEST)
|
||||||
|
- `UpdateTemplate` incrémente la version ; `DetectAgentDrift` détecte `version > synced_template_version`.
|
||||||
|
- `SyncAgentWithTemplate` : applique aux `synchronized==true`, ignore `false` et `scratch` ; met à jour `synced_template_version`.
|
||||||
|
- Front : badge drift + flux de sync avec gateway mock.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
- `cargo test` (template/store) + `vitest` verts ; cycle MAJ template → propagation aux agents synchronisés (dev manuel).
|
||||||
|
|
||||||
|
## Avancement
|
||||||
|
|
||||||
|
### ✅ Backend (vert)
|
||||||
|
- **Infra** : `FsTemplateStore` (`infrastructure/store/template.rs`) — store global `<app>/templates/{index.json, md/<id>.md}`, versioning persisté, `contentHash` (digest stable, sans dépendance) pour détection d'édition hors-app. 6 tests d'intégration (tmpdir).
|
||||||
|
- **Application** (`application/template/usecases.rs`) : `CreateTemplate`, `UpdateTemplate` (bump version + event `TemplateUpdated`), `ListTemplates`, `DeleteTemplate`, `CreateAgentFromTemplate` (origine `FromTemplate` + seed du `.md`, réutilise le helper de nommage L6), `DetectAgentDrift` (ne flague que les `synchronized` en retard ; ignore non-sync/scratch/à-jour/template supprimé ; émet `AgentDriftDetected`), `SyncAgentWithTemplate` (remplace le `.md`, avance `synced_template_version`, émet `AgentSynced` ; laisse intacts non-sync/scratch). 8 tests use cases.
|
||||||
|
- `cargo test --workspace` : **270 verts, 0 régression** ; clippy clean.
|
||||||
|
|
||||||
|
### ✅ IPC `app-tauri` (vert)
|
||||||
|
- Composition root : `FsTemplateStore` construit, 7 use cases injectés (réutilise `contexts_port`/`ids`/`events_port`).
|
||||||
|
- 7 commands : `create_template`, `update_template`, `list_templates`, `delete_template`, `create_agent_from_template`, `detect_agent_drift`, `sync_agent_with_template` (shells fins, `resolve_project` réutilisé). DTOs camelCase (`TemplateDto` transparent, `AgentDriftDto`, `SyncResultDto`, `parse_template_id`).
|
||||||
|
- Tests `tests/dto_templates.rs` (18) ; `cargo test -p app-tauri` 62 verts ; workspace **288 verts, 0 régression** ; clippy clean.
|
||||||
|
|
||||||
|
### ✅ Front (vert)
|
||||||
|
- Port `TemplateGateway` (7 méthodes) + types `Template`/`AgentDrift` ; adapter Tauri `TauriTemplateGateway` ; `MockTemplateGateway` stateful **partageant le registre d'agents** du `MockAgentGateway` (helpers internes `_insertAgent`/`_updateAgent`/`_rawAgents`) pour faire vivre le drift offline.
|
||||||
|
- Feature `features/templates` : `useTemplates` (CRUD), `useDrift(projectId)` (détection + sync), `TemplatesPanel` (liste nom+version, création, édition, suppression, « Create agent from template »), monté dans l'onglet projet.
|
||||||
|
- `AgentsPanel` : **badge « update available »** + bouton **Sync** par agent en drift.
|
||||||
|
- Tests : 20 ajoutés ; suite front **140 verts** ; `tsc` clean ; garde-fou « no direct invoke » respecté.
|
||||||
|
|
||||||
|
### ⏳ Hors périmètre L7
|
||||||
|
- Intégration front du terminal d'agent depuis un agent créé via template (réutilise le fil L6).
|
||||||
42
agents-dev/L8-git.md
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
# L8 — Git
|
||||||
|
|
||||||
|
**Binôme :** `dev-git` / `test-git`
|
||||||
|
**Zones :** `infrastructure/git`, `application/git`, `frontend/features/git`
|
||||||
|
**Dépendances amont :** L0, L1, L2.
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
Support Git intégré (local) via libgit2.
|
||||||
|
|
||||||
|
## Périmètre (DEV)
|
||||||
|
- Adapter `Git2Repository` (git2) : `status`, `stage`/`unstage`, `commit`, `branches`, `checkout`, `current_branch`, `diff`, `log`, `pull`, `push`, `clone`, `init`.
|
||||||
|
- Use cases Git (`GitStatus`, `GitCommit`, `GitCheckout`, `GitPush`, …).
|
||||||
|
- Front : panneau Git (changements, staging, commit, branches).
|
||||||
|
|
||||||
|
## Périmètre (TEST)
|
||||||
|
- Use cases avec `GitRepository` mocké.
|
||||||
|
- Intégration : `Git2Repository` sur repo temporaire (init → commit → branch → status).
|
||||||
|
- Front : feature git avec gateway mock.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
- `cargo test` (git) + `vitest` verts ; opérations git de base utilisables (dev manuel).
|
||||||
|
|
||||||
|
## Spike
|
||||||
|
- Vendoring statique git2/openssl pour l'AppImage (coordonné avec L11).
|
||||||
|
|
||||||
|
## Avancement
|
||||||
|
|
||||||
|
### ✅ Backend (vert)
|
||||||
|
- **Dépendance** : `git2` 0.20 (`default-features = false`, pas d'openssl) liée à la **libgit2 système** via `.cargo/config.toml` (`LIBGIT2_SYS_USE_PKG_CONFIG=1`) — évite cmake/vendoring ; le vendoring statique AppImage reste pour L11.
|
||||||
|
- **Infra** : `Git2Repository` (`infrastructure/git/mod.rs`) implémente `GitPort` — `init`, `status`, `stage`, `unstage`, `commit` (signature de repli `IdeA <idea@localhost>` si pas de config user), `branches`, `current_branch`, `checkout`, `log`. `pull`/`push` renvoient une erreur explicite (réseau/credentials → L9). Appels libgit2 synchrones sans `await` ⇒ futures `Send`. 3 tests d'intégration (vrai repo temporaire).
|
||||||
|
- **Application** (`application/git/`) : `GitStatus`, `GitStage`, `GitUnstage`, `GitCommit` (valide message non vide + event `GitStateChanged`), `GitBranches` (liste + courante), `GitCheckout` (+ event), `GitLog`, `GitInit` (+ event). 7 tests use cases (mock `GitPort`).
|
||||||
|
- `cargo test --workspace` : **298 verts, 0 régression** ; clippy clean.
|
||||||
|
|
||||||
|
### ✅ IPC `app-tauri` (vert)
|
||||||
|
- Composition root : `Git2Repository` construit, 8 use cases injectés. 8 commands (`git_status`, `git_stage`, `git_unstage`, `git_commit`, `git_branches`, `git_checkout`, `git_log`, `git_init`), résolution `projectId → root` via `resolve_project`. DTOs `GitFileStatusDto`/`GitCommitDto`/`GitBranchesDto` + request DTOs camelCase. Tests `tests/dto_git.rs` (12). `cargo test -p app-tauri` 74 verts ; workspace **310 verts, 0 régression** ; clippy clean.
|
||||||
|
|
||||||
|
### ✅ Front (vert)
|
||||||
|
- Port `GitGateway` complet (8 méthodes) + types `GitFileStatus`/`GitCommit`/`GitBranches` ; adapter `TauriGitGateway` ; `MockGitGateway` stateful (état par projet, seedé) pour le mode offline.
|
||||||
|
- Feature `features/git` : `useGit(projectId)` + `GitPanel` (sections Staged/Unstaged + stage/unstage, message + commit, branches + checkout, log récent), monté dans l'onglet projet. 17 tests ; suite front **157 verts** ; `tsc` clean ; garde-fou « no invoke » OK.
|
||||||
|
|
||||||
|
### ⏳ Hors périmètre L8
|
||||||
|
- `pull`/`push`/`clone` (réseau + credentials) → L9 (`RemoteGitRepository` + callbacks) ; vendoring statique git2 pour l'AppImage → L11.
|
||||||
40
agents-dev/L9-remote.md
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# L9 — Remote (SSH + WSL)
|
||||||
|
|
||||||
|
**Binôme :** `dev-remote` / `test-remote`
|
||||||
|
**Zones :** `infrastructure/remote`, `application/remote`, `frontend/features/remote`
|
||||||
|
**Dépendances amont :** L0, L1, L2, L3, L8.
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
Développement distant : projet sur autre machine via **SSH**, ou sur une **WSL** depuis Windows. Transparence local/distant via la stratégie `RemoteHost`.
|
||||||
|
|
||||||
|
## Périmètre (DEV)
|
||||||
|
- `RemoteHost` : `LocalHost`, `SshHost` (russh/ssh2), `WslHost` (`wsl.exe`) — fabriquent FS/PTY/Spawner adaptés.
|
||||||
|
- Adapters distants : `SshFileSystem` (SFTP), `SshPtyAdapter`, `SshProcessSpawner` ; `WslFileSystem`, `WslPtyAdapter`, `WslProcessSpawner`.
|
||||||
|
- `RemoteGitRepository` (git CLI via `ProcessSpawner`) en fallback distant.
|
||||||
|
- Use case `ConnectRemote` (valide l'accès au root). Front : UI de connexion SSH/WSL.
|
||||||
|
|
||||||
|
## Périmètre (TEST)
|
||||||
|
- Substituabilité (Liskov) : un use case marche identiquement quel que soit le `RemoteHost` (tests avec hosts mockés).
|
||||||
|
- Intégration SSH/WSL : tests `#[ignore]` gated derrière feature/env (CI conditionnelle).
|
||||||
|
- Front : feature remote avec gateway mock.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
- `cargo test` (remote, hors `#[ignore]`) + `vitest` verts ; connexion SSH et WSL démontrée en dev manuel.
|
||||||
|
|
||||||
|
## Avancement
|
||||||
|
|
||||||
|
### ✅ Socle (vert)
|
||||||
|
- **Domaine** : `RemoteRef` (Local/Ssh/Wsl) + port `RemoteHost` (fabrique FS/PTY/Spawner) déjà en place (L0).
|
||||||
|
- **Infra** (`infrastructure/remote/mod.rs`) : `LocalHost` (fabrique les adapters locaux ; `connect` = no-op) + sélecteur `remote_host(&RemoteRef)` (Local → `LocalHost` ; SSH/WSL → `RemoteError::Connection` explicite « not yet supported »). 4 tests d'intégration.
|
||||||
|
- **Application** (`application/remote/`) : `ConnectRemote` (établit l'hôte, valide que le root existe via le FS de l'hôte, émet `RemoteConnected`). 3 tests **Liskov** (comportement identique Local/Ssh/Wsl avec hôte mocké ; échec connexion → REMOTE ; root absent → NOT_FOUND).
|
||||||
|
- `cargo test --workspace` : **317 verts, 0 régression** ; clippy clean.
|
||||||
|
|
||||||
|
### ⏳ Reste à faire (gated / non vérifiable dans cet environnement)
|
||||||
|
- Adapters distants **SSH** (`SshHost`/`SshFileSystem` SFTP/`SshPtyAdapter`/`SshProcessSpawner` via russh ou ssh2 — décision auth à figer, cf. spikes) et **WSL** (`WslHost` + adapters préfixant `wsl.exe`), avec tests d'intégration `#[ignore]` derrière feature/env.
|
||||||
|
- `RemoteGitRepository` (git CLI distant via `ProcessSpawner`) + `pull`/`push` (reportés depuis L8).
|
||||||
|
- IPC `app-tauri` `connect_remote` + front feature remote (UI connexion SSH/WSL).
|
||||||
|
|
||||||
|
## Spikes (cf. ARCHITECTURE §13)
|
||||||
|
- Auth SSH (clé/agent/mot de passe/known_hosts) ; choix russh(rustls) vs ssh2(OpenSSL) — impacte l'AppImage.
|
||||||
|
- Conversion de chemins WSL `/mnt/...` ↔ `\\wsl$\...` ; perf I/O cross-boundary.
|
||||||
|
- Git sur FS distant (perf, parsing CLI).
|
||||||
39
agents-dev/LD-design-system.md
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# LD — Design System (UI kit maison)
|
||||||
|
|
||||||
|
**Binôme :** `dev-ui` / `test-ui`
|
||||||
|
**Zones :** `frontend/src/shared` (UI kit), `frontend` (config Tailwind), `frontend/src/app` (app shell)
|
||||||
|
**Dépendances amont :** L1 (frontend ports/adapters/DI en place).
|
||||||
|
**Position :** inséré **après L6** (décision produit : voir CONTEXT, lot transverse), consommé ensuite au fil de l'eau par chaque feature.
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
Doter IdeA d'un **design system maison** cohérent et d'un **thème sombre** par défaut (c'est un IDE), pour que chaque feature cesse de bricoler des styles inline et consomme des composants réutilisables.
|
||||||
|
|
||||||
|
## Stack (validée)
|
||||||
|
- **Tailwind v4** + `@tailwindcss/vite` (CSS-first, `@theme`), composants **maison** dans `shared/ui` (pas de lib de composants tierce).
|
||||||
|
- Tokens de design (couleurs/typo/espacements/rayons) en variables de thème ; helper `cn()` pour composer les classes.
|
||||||
|
|
||||||
|
## Périmètre (DEV)
|
||||||
|
- Config Tailwind (plugin Vite, feuille de thème globale, tokens, dark par défaut).
|
||||||
|
- `shared/ui` : composants de base — `Button`, `IconButton`, `Input`, `Panel`/`Card`, `Tabs`, `Toolbar`, `Spinner`, `Field`. Helper `cn`.
|
||||||
|
- **App shell** sombre dans `app/` (remplace les styles inline du smoke-test).
|
||||||
|
|
||||||
|
## Périmètre (TEST)
|
||||||
|
- `cn()` (unitaire).
|
||||||
|
- Rendu + variantes/états des composants (`Button` variants/disabled, `Input` label/aria, `Tabs` sélection) via RTL/vitest, **sans backend**.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
- `vitest` vert ; `tsc --noEmit` vert ; le domaine/les ports UI restent inchangés (pur skin).
|
||||||
|
- Aucune feature ne régresse ; l'app monte avec le thème sombre.
|
||||||
|
|
||||||
|
## Hors périmètre
|
||||||
|
- Re-styling exhaustif de toutes les features (fait au fil de l'eau quand on touche chacune).
|
||||||
|
|
||||||
|
## Avancement — ✅ vert
|
||||||
|
|
||||||
|
- **Config** : `tailwindcss@4` + `@tailwindcss/vite` ajoutés ; plugin câblé dans `vite.config.ts` ; feuille de thème globale `src/shared/styles/theme.css` (tokens sémantiques sous `@theme`, thème **sombre** par défaut, focus ring), importée une fois dans `app/main.tsx`.
|
||||||
|
- **UI kit** `src/shared/ui` : `Button` (variants primary/secondary/ghost/danger, sizes, loading), `IconButton`, `Input`, `Field` (label/hint/error + aria), `Panel`, `Tabs` (sélection + close), `Toolbar`, `Spinner`. Helper `cn()`. Baril `@/shared`.
|
||||||
|
- **App shell** sombre (`app/App.tsx`) : header avec statut backend, plus de styles inline.
|
||||||
|
- **Consommation** : `features/projects/ProjectsView` migré sur le kit (premier consommateur), hooks d'accessibilité préservés (tests L2 toujours verts).
|
||||||
|
- **Tests** : `cn` + composants (`Button`/`Input`/`Field`/`Tabs`) via RTL — assertions sur rôles/aria, pas sur les classes Tailwind (le design peut évoluer sans casser la suite).
|
||||||
|
|
||||||
|
**Vérifs** : `tsc --noEmit` vert · `vitest` **103 tests verts** (14 fichiers) · `vite build` OK (Tailwind compile, CSS 22.8 kB).
|
||||||
69
agents-dev/README.md
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
# Agents de développement IdeA — Protocole commun
|
||||||
|
|
||||||
|
> Ces agents servent à **développer l'IDE IdeA lui-même**. Ils ne sont pas la feature produit « agents IA » de l'application.
|
||||||
|
> Référence produit/archi : [`../CONTEXT.md`](../CONTEXT.md) · [`../ARCHITECTURE.md`](../ARCHITECTURE.md).
|
||||||
|
|
||||||
|
## Composition de l'équipe
|
||||||
|
|
||||||
|
Chaque **lot livrable** (L0…L11) est confié à un **binôme** :
|
||||||
|
- un **agent de développement** (écrit le code),
|
||||||
|
- un **agent de test** appairé (écrit et exécute les tests unitaires).
|
||||||
|
|
||||||
|
Un fichier `Lx-*.md` par binôme décrit son périmètre, ses ports/adapters, ses dépendances et sa *definition of done*.
|
||||||
|
|
||||||
|
| Lot | Fichier | Statut |
|
||||||
|
|---|---|---|
|
||||||
|
| L0 | [L0-core-domain.md](L0-core-domain.md) | ✅ **vert** (84 tests) |
|
||||||
|
| L1 | [L1-ipc-bridge.md](L1-ipc-bridge.md) | ✅ **vert** (46 tests) |
|
||||||
|
| L2 | [L2-projects.md](L2-projects.md) | ✅ **vert** (26 tests) |
|
||||||
|
| L3 | [L3-terminals.md](L3-terminals.md) | ✅ **vert** (61 tests) |
|
||||||
|
| L4 | [L4-layout.md](L4-layout.md) | ✅ **vert** (52 tests) |
|
||||||
|
| L5 | [L5-ai-runtime.md](L5-ai-runtime.md) | ✅ **vert** (69 tests) |
|
||||||
|
| L6 | [L6-agents.md](L6-agents.md) | ✅ **vert** (domaine+app+infra+IPC+front · activer un agent ouvre son terminal) |
|
||||||
|
| LD | [LD-design-system.md](LD-design-system.md) | ✅ **vert** (Tailwind v4 + UI kit maison, thème sombre) |
|
||||||
|
| L7 | [L7-templates.md](L7-templates.md) | ✅ **vert** (backend + IPC + front · drift & sync) |
|
||||||
|
| L8 | [L8-git.md](L8-git.md) | ✅ **vert** (backend + IPC + front · git local libgit2) |
|
||||||
|
| 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 |
|
||||||
|
| L11 | [L11-packaging.md](L11-packaging.md) | 🟡 AppImage Linux **OK** · refonte disposition IDE en cours · Windows/CI à venir |
|
||||||
|
|
||||||
|
## Cycle dev ↔ test (obligatoire, cf. CONTEXT §3)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Archi valide le découpage et les contrats (ports/interfaces) du lot.
|
||||||
|
2. Agent DEV écrit le code de la feature.
|
||||||
|
3. Agent TEST écrit les tests unitaires + les exécute.
|
||||||
|
4a. Vert → feature validée, lot suivant.
|
||||||
|
4b. Rouge → rapport d'erreurs structuré → retour DEV → correction → retour étape 3.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Règle d'or** : aucune feature n'est « terminée » tant que ses tests ne passent pas. Les résultats sont relayés fidèlement (sortie réelle des tests).
|
||||||
|
|
||||||
|
## Definition of Done (commune à tous les lots)
|
||||||
|
|
||||||
|
- [ ] Code conforme à l'architecture hexagonale et SOLID (cf. ARCHITECTURE §1).
|
||||||
|
- [ ] Le `domain` reste pur (aucune dépendance I/O qui y entre).
|
||||||
|
- [ ] Les use cases ne parlent qu'aux **ports**, jamais aux adapters concrets.
|
||||||
|
- [ ] Tests unitaires écrits et **verts** : `cargo test -p <crate>` (Rust) et/ou `vitest` (front).
|
||||||
|
- [ ] Domaine/application testés **sans I/O** (ports mockés : `mockall` ou fakes).
|
||||||
|
- [ ] Pas de `new ConcreteAdapter` ailleurs que dans la composition root (`app-tauri`).
|
||||||
|
- [ ] Pas de régression sur les lots déjà verts.
|
||||||
|
- [ ] Code lisible, cohérent avec le style existant.
|
||||||
|
|
||||||
|
## Format du rapport d'erreurs (TEST → DEV)
|
||||||
|
|
||||||
|
```
|
||||||
|
LOT: Lx
|
||||||
|
TEST EN ÉCHEC: <nom du test>
|
||||||
|
ATTENDU: <…>
|
||||||
|
OBTENU: <…>
|
||||||
|
SORTIE: <extrait pertinent de cargo test / vitest>
|
||||||
|
HYPOTHÈSE: <cause probable, si identifiable>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conventions techniques
|
||||||
|
|
||||||
|
- Rust : workspace multi-crate (`crates/domain`, `application`, `infrastructure`, `app-tauri`).
|
||||||
|
- Erreurs typées par port/use case ; `async` via `async_trait` côté ports I/O.
|
||||||
|
- Déterminisme des tests via ports `Clock`/`IdGenerator` (impl `Fixed`/`Seq` en test).
|
||||||
|
- Front : `domain`/`ports` purs (Vitest), `features` testés avec gateways **mock** (RTL).
|
||||||
35
crates/app-tauri/Cargo.toml
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
[package]
|
||||||
|
name = "app-tauri"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
description = "IdeA — Tauri v2 shell: composition root (DI), IPC commands/events, PTY↔Channel bridge."
|
||||||
|
|
||||||
|
# The library carries all the wiring so it is unit-testable; the binary is a
|
||||||
|
# thin entry point.
|
||||||
|
[lib]
|
||||||
|
name = "app_tauri_lib"
|
||||||
|
crate-type = ["lib", "cdylib", "staticlib"]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "app-tauri"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { workspace = true }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain = { workspace = true }
|
||||||
|
application = { workspace = true }
|
||||||
|
infrastructure = { workspace = true }
|
||||||
|
tauri = { workspace = true }
|
||||||
|
tauri-plugin-dialog = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
uuid = { workspace = true }
|
||||||
3
crates/app-tauri/build.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build();
|
||||||
|
}
|
||||||
7
crates/app-tauri/capabilities/default.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Default capability set for the IdeA main window.",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": ["core:default", "dialog:allow-open"]
|
||||||
|
}
|
||||||
1
crates/app-tauri/gen/schemas/acl-manifests.json
Normal file
1
crates/app-tauri/gen/schemas/capabilities.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"default":{"identifier":"default","description":"Default capability set for the IdeA main window.","local":true,"windows":["main"],"permissions":["core:default","dialog:allow-open"]}}
|
||||||
2358
crates/app-tauri/gen/schemas/desktop-schema.json
Normal file
2358
crates/app-tauri/gen/schemas/linux-schema.json
Normal file
BIN
crates/app-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
crates/app-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
crates/app-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
crates/app-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
crates/app-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
crates/app-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
crates/app-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
crates/app-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
crates/app-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
crates/app-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
crates/app-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
crates/app-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
crates/app-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
crates/app-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
</adaptive-icon>
|
||||||
BIN
crates/app-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
BIN
crates/app-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
crates/app-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
BIN
crates/app-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
crates/app-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
BIN
crates/app-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
BIN
crates/app-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#fff</color>
|
||||||
|
</resources>
|
||||||
BIN
crates/app-tauri/icons/icon.icns
Normal file
BIN
crates/app-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
crates/app-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
crates/app-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 729 B |
BIN
crates/app-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
crates/app-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
crates/app-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
crates/app-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
crates/app-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
crates/app-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
crates/app-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
crates/app-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
crates/app-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
crates/app-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
crates/app-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
crates/app-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
crates/app-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
crates/app-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
crates/app-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
crates/app-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
crates/app-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
1121
crates/app-tauri/src/commands.rs
Normal file
1332
crates/app-tauri/src/dto.rs
Normal file
186
crates/app-tauri/src/events.rs
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
//! `TauriEventRelay` — bridges the domain [`EventBus`] to Tauri events
|
||||||
|
//! (backend → frontend push channel, ARCHITECTURE §2 "Events").
|
||||||
|
//!
|
||||||
|
//! The relay subscribes to the bus and re-emits each [`DomainEvent`] as a Tauri
|
||||||
|
//! event named [`DOMAIN_EVENT`], carrying a serialisable [`DomainEventDto`]
|
||||||
|
//! payload (the domain event itself is deliberately not `Serialize`; the wire
|
||||||
|
//! format is owned here, the infrastructure/presentation layer).
|
||||||
|
//!
|
||||||
|
//! High-frequency `PtyOutput` is intentionally *not* relayed through this global
|
||||||
|
//! event; it goes through per-session [`crate::pty::PtyBridge`] channels instead.
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use tauri::{AppHandle, Emitter};
|
||||||
|
|
||||||
|
use domain::events::DomainEvent;
|
||||||
|
use infrastructure::TokioBroadcastEventBus;
|
||||||
|
|
||||||
|
/// Name of the Tauri event carrying relayed [`DomainEvent`]s.
|
||||||
|
pub const DOMAIN_EVENT: &str = "domain://event";
|
||||||
|
|
||||||
|
/// Serialisable mirror of [`DomainEvent`] for the IPC wire (camelCase, tagged).
|
||||||
|
///
|
||||||
|
/// `type` is the discriminant; payload fields are flattened per variant. This is
|
||||||
|
/// the single owner of the event wire format on the backend side.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "camelCase")]
|
||||||
|
pub enum DomainEventDto {
|
||||||
|
/// A project was created.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
ProjectCreated {
|
||||||
|
/// Project id (UUID string).
|
||||||
|
project_id: String,
|
||||||
|
},
|
||||||
|
/// An agent was launched.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
AgentLaunched {
|
||||||
|
/// Agent id.
|
||||||
|
agent_id: String,
|
||||||
|
/// Session id.
|
||||||
|
session_id: String,
|
||||||
|
},
|
||||||
|
/// An agent exited.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
AgentExited {
|
||||||
|
/// Agent id.
|
||||||
|
agent_id: String,
|
||||||
|
/// Exit code.
|
||||||
|
code: i32,
|
||||||
|
},
|
||||||
|
/// A template was updated.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
TemplateUpdated {
|
||||||
|
/// Template id.
|
||||||
|
template_id: String,
|
||||||
|
/// New version.
|
||||||
|
version: u64,
|
||||||
|
},
|
||||||
|
/// A synchronized agent drifted from its template.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
AgentDriftDetected {
|
||||||
|
/// Agent id.
|
||||||
|
agent_id: String,
|
||||||
|
/// Current version.
|
||||||
|
from: u64,
|
||||||
|
/// Available version.
|
||||||
|
to: u64,
|
||||||
|
},
|
||||||
|
/// A synchronized agent was brought up to date.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
AgentSynced {
|
||||||
|
/// Agent id.
|
||||||
|
agent_id: String,
|
||||||
|
/// Version synced to.
|
||||||
|
to: u64,
|
||||||
|
},
|
||||||
|
/// A tab's layout changed.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
LayoutChanged {
|
||||||
|
/// Project id.
|
||||||
|
project_id: String,
|
||||||
|
},
|
||||||
|
/// A remote connection was established.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
RemoteConnected {
|
||||||
|
/// Project id.
|
||||||
|
project_id: String,
|
||||||
|
},
|
||||||
|
/// Git state changed.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
GitStateChanged {
|
||||||
|
/// Project id.
|
||||||
|
project_id: String,
|
||||||
|
},
|
||||||
|
/// Raw PTY output (normally routed to a per-session channel, not here).
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
PtyOutput {
|
||||||
|
/// Session id.
|
||||||
|
session_id: String,
|
||||||
|
/// Output bytes.
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&DomainEvent> for DomainEventDto {
|
||||||
|
fn from(e: &DomainEvent) -> Self {
|
||||||
|
match e {
|
||||||
|
DomainEvent::ProjectCreated { project_id } => Self::ProjectCreated {
|
||||||
|
project_id: project_id.to_string(),
|
||||||
|
},
|
||||||
|
DomainEvent::AgentLaunched {
|
||||||
|
agent_id,
|
||||||
|
session_id,
|
||||||
|
} => Self::AgentLaunched {
|
||||||
|
agent_id: agent_id.to_string(),
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
},
|
||||||
|
DomainEvent::AgentExited { agent_id, code } => Self::AgentExited {
|
||||||
|
agent_id: agent_id.to_string(),
|
||||||
|
code: *code,
|
||||||
|
},
|
||||||
|
DomainEvent::TemplateUpdated {
|
||||||
|
template_id,
|
||||||
|
version,
|
||||||
|
} => Self::TemplateUpdated {
|
||||||
|
template_id: template_id.to_string(),
|
||||||
|
version: version.get(),
|
||||||
|
},
|
||||||
|
DomainEvent::AgentDriftDetected {
|
||||||
|
agent_id,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
} => Self::AgentDriftDetected {
|
||||||
|
agent_id: agent_id.to_string(),
|
||||||
|
from: from.get(),
|
||||||
|
to: to.get(),
|
||||||
|
},
|
||||||
|
DomainEvent::AgentSynced { agent_id, to } => Self::AgentSynced {
|
||||||
|
agent_id: agent_id.to_string(),
|
||||||
|
to: to.get(),
|
||||||
|
},
|
||||||
|
DomainEvent::LayoutChanged { project_id } => Self::LayoutChanged {
|
||||||
|
project_id: project_id.to_string(),
|
||||||
|
},
|
||||||
|
DomainEvent::RemoteConnected { project_id } => Self::RemoteConnected {
|
||||||
|
project_id: project_id.to_string(),
|
||||||
|
},
|
||||||
|
DomainEvent::GitStateChanged { project_id } => Self::GitStateChanged {
|
||||||
|
project_id: project_id.to_string(),
|
||||||
|
},
|
||||||
|
DomainEvent::PtyOutput { session_id, bytes } => Self::PtyOutput {
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
bytes: bytes.clone(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscribes the relay to the bus and spawns a background task that forwards
|
||||||
|
/// every [`DomainEvent`] to the frontend as a [`DOMAIN_EVENT`] Tauri event.
|
||||||
|
///
|
||||||
|
/// Uses the bus's raw async broadcast receiver so the relay runs cooperatively
|
||||||
|
/// on the Tokio runtime (no blocking thread). Returns immediately; the spawned
|
||||||
|
/// task lives for the duration of the app.
|
||||||
|
pub fn spawn_relay(app: AppHandle, bus: &TokioBroadcastEventBus) {
|
||||||
|
use tokio::sync::broadcast::error::RecvError;
|
||||||
|
|
||||||
|
let mut rx = bus.raw_receiver();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
loop {
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(event) => {
|
||||||
|
// Skip high-frequency PTY output on the global channel.
|
||||||
|
if matches!(event, DomainEvent::PtyOutput { .. }) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let dto = DomainEventDto::from(&event);
|
||||||
|
let _ = app.emit(DOMAIN_EVENT, dto);
|
||||||
|
}
|
||||||
|
// The bus dropped some events for this slow receiver; keep going.
|
||||||
|
Err(RecvError::Lagged(_)) => continue,
|
||||||
|
// The bus was dropped (app shutting down); stop the relay.
|
||||||
|
Err(RecvError::Closed) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
103
crates/app-tauri/src/lib.rs
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
//! # IdeA — `app-tauri` (presentation / driving adapter + composition root)
|
||||||
|
//!
|
||||||
|
//! This crate is the **only** place that knows every other crate. It:
|
||||||
|
//! - builds the concrete adapters and injects them into use cases
|
||||||
|
//! ([`state::AppState`], the composition root),
|
||||||
|
//! - exposes `#[tauri::command]` handlers ([`commands`]) mapping DTOs ↔ use cases,
|
||||||
|
//! - relays domain events to the frontend ([`events::TauriEventRelay`]),
|
||||||
|
//! - hosts the generic PTY↔Channel bridge ([`pty::PtyBridge`]) for L3.
|
||||||
|
//!
|
||||||
|
//! The wiring lives in the library (testable) and `main.rs` is a thin shim.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
pub mod commands;
|
||||||
|
pub mod dto;
|
||||||
|
pub mod events;
|
||||||
|
pub mod pty;
|
||||||
|
pub mod state;
|
||||||
|
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
use state::AppState;
|
||||||
|
|
||||||
|
/// Builds and runs the Tauri application.
|
||||||
|
///
|
||||||
|
/// Sets up the composition root (resolving the app-data directory via the Tauri
|
||||||
|
/// path API), registers commands, spawns the event relay, and starts the main
|
||||||
|
/// window.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
/// Panics if the Tauri application fails to build or run (no window/webview), or
|
||||||
|
/// if the app-data directory cannot be resolved.
|
||||||
|
pub fn run() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.setup(|app| {
|
||||||
|
// Resolve the machine-local IDE data directory (ARCHITECTURE §9.2)
|
||||||
|
// and build the composition root once the app handle exists, so the
|
||||||
|
// stores receive a concrete path without ever touching Tauri.
|
||||||
|
let app_data_dir = app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.expect("failed to resolve the app data directory");
|
||||||
|
let app_state = AppState::build(app_data_dir);
|
||||||
|
|
||||||
|
// Wire the domain event bus → Tauri events relay.
|
||||||
|
events::spawn_relay(app.handle().clone(), &app_state.event_bus);
|
||||||
|
|
||||||
|
app.manage(app_state);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
commands::health,
|
||||||
|
commands::create_project,
|
||||||
|
commands::open_project,
|
||||||
|
commands::close_project,
|
||||||
|
commands::list_projects,
|
||||||
|
commands::open_terminal,
|
||||||
|
commands::write_terminal,
|
||||||
|
commands::resize_terminal,
|
||||||
|
commands::close_terminal,
|
||||||
|
commands::load_layout,
|
||||||
|
commands::mutate_layout,
|
||||||
|
commands::list_layouts,
|
||||||
|
commands::create_layout,
|
||||||
|
commands::rename_layout,
|
||||||
|
commands::delete_layout,
|
||||||
|
commands::set_active_layout,
|
||||||
|
commands::first_run_state,
|
||||||
|
commands::reference_profiles,
|
||||||
|
commands::detect_profiles,
|
||||||
|
commands::list_profiles,
|
||||||
|
commands::save_profile,
|
||||||
|
commands::delete_profile,
|
||||||
|
commands::configure_profiles,
|
||||||
|
commands::create_agent,
|
||||||
|
commands::list_agents,
|
||||||
|
commands::read_agent_context,
|
||||||
|
commands::update_agent_context,
|
||||||
|
commands::delete_agent,
|
||||||
|
commands::launch_agent,
|
||||||
|
commands::create_template,
|
||||||
|
commands::update_template,
|
||||||
|
commands::list_templates,
|
||||||
|
commands::delete_template,
|
||||||
|
commands::create_agent_from_template,
|
||||||
|
commands::detect_agent_drift,
|
||||||
|
commands::sync_agent_with_template,
|
||||||
|
commands::git_status,
|
||||||
|
commands::git_stage,
|
||||||
|
commands::git_unstage,
|
||||||
|
commands::git_commit,
|
||||||
|
commands::git_branches,
|
||||||
|
commands::git_checkout,
|
||||||
|
commands::git_log,
|
||||||
|
commands::git_init,
|
||||||
|
commands::git_graph,
|
||||||
|
commands::move_tab_to_new_window,
|
||||||
|
])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running IdeA Tauri application");
|
||||||
|
}
|
||||||
16
crates/app-tauri/src/main.rs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
// Prevents an extra console window on Windows in release builds.
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
// WebKitGTK's DMABUF renderer causes a blank/white window on many Linux
|
||||||
|
// setups (recent Mesa/Nvidia drivers, common on Arch). Disable it before
|
||||||
|
// the webview initializes, unless the user has explicitly set the variable.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
|
||||||
|
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app_tauri_lib::run();
|
||||||
|
}
|
||||||
84
crates/app-tauri/src/pty.rs
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
//! Generic **PTY ↔ Tauri Channel** bridge infrastructure.
|
||||||
|
//!
|
||||||
|
//! ARCHITECTURE §2 decides that high-frequency PTY byte streams travel over
|
||||||
|
//! per-session [`tauri::ipc::Channel`]s rather than global events, for
|
||||||
|
//! throughput and isolation. This module provides the transport-side plumbing
|
||||||
|
//! that L3 will plug a real `PtyPort` into; here there is **no real PTY** yet —
|
||||||
|
//! only the registry + the abstraction that routes byte chunks to the right
|
||||||
|
//! frontend channel.
|
||||||
|
//!
|
||||||
|
//! Design:
|
||||||
|
//! - The frontend opens a terminal and passes a [`tauri::ipc::Channel`] for that
|
||||||
|
//! session. The backend registers it in [`PtyBridge`] keyed by `SessionId`.
|
||||||
|
//! - Whatever produces output (the PTY adapter in L3) calls
|
||||||
|
//! [`PtyBridge::send_output`], which forwards the bytes on the matching
|
||||||
|
//! channel. Bytes are sent as-is; the frontend xterm wrapper consumes them.
|
||||||
|
//! - [`PtyBridge::unregister`] tears the channel down on terminal close.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use tauri::ipc::Channel;
|
||||||
|
|
||||||
|
use domain::ids::SessionId;
|
||||||
|
|
||||||
|
/// A chunk of PTY output bytes destined for a specific session's channel.
|
||||||
|
///
|
||||||
|
/// Sent as a raw byte vector; serde encodes it for the IPC channel. Kept as a
|
||||||
|
/// distinct type so the wire shape can evolve (e.g. add a sequence number for
|
||||||
|
/// backpressure handling) without touching call sites.
|
||||||
|
pub type PtyChunk = Vec<u8>;
|
||||||
|
|
||||||
|
/// Registry mapping live terminal sessions to their output [`Channel`].
|
||||||
|
///
|
||||||
|
/// Thread-safe; cloned `Arc<PtyBridge>` is held in [`crate::state::AppState`].
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct PtyBridge {
|
||||||
|
channels: Mutex<HashMap<SessionId, Channel<PtyChunk>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PtyBridge {
|
||||||
|
/// Creates an empty bridge.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
channels: Mutex::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers the output channel for a session (called when a terminal is
|
||||||
|
/// opened, from a `#[tauri::command]` that receives the `Channel` argument).
|
||||||
|
pub fn register(&self, session: SessionId, channel: Channel<PtyChunk>) {
|
||||||
|
if let Ok(mut map) = self.channels.lock() {
|
||||||
|
map.insert(session, channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a session's channel (terminal closed).
|
||||||
|
pub fn unregister(&self, session: &SessionId) {
|
||||||
|
if let Ok(mut map) = self.channels.lock() {
|
||||||
|
map.remove(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forwards a chunk of output bytes to a session's channel.
|
||||||
|
///
|
||||||
|
/// Returns `true` if the chunk was delivered, `false` if no channel is
|
||||||
|
/// registered for the session (e.g. already closed). In L3 the PTY adapter's
|
||||||
|
/// output stream drives this.
|
||||||
|
pub fn send_output(&self, session: &SessionId, chunk: PtyChunk) -> bool {
|
||||||
|
let Ok(map) = self.channels.lock() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
match map.get(session) {
|
||||||
|
Some(channel) => channel.send(chunk).is_ok(),
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of currently-registered sessions (handy for tests/diagnostics).
|
||||||
|
#[must_use]
|
||||||
|
pub fn active_sessions(&self) -> usize {
|
||||||
|
self.channels.lock().map(|m| m.len()).unwrap_or(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
428
crates/app-tauri/src/state.rs
Normal file
@ -0,0 +1,428 @@
|
|||||||
|
//! Managed application state — the product of the **composition root**.
|
||||||
|
//!
|
||||||
|
//! The composition root ([`build_app_state`]) is the *single* place that
|
||||||
|
//! constructs concrete adapters (`new ConcreteAdapter`) and injects them as
|
||||||
|
//! `Arc<dyn Port>` into the use cases (ARCHITECTURE §1.1, §10). The use cases
|
||||||
|
//! are then exposed through `tauri::State<AppState>` to the command handlers.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use application::{
|
||||||
|
CloseProject, CloseTab, CloseTerminal, ConfigureProfiles, CreateAgentFromScratch,
|
||||||
|
CreateAgentFromTemplate, CreateLayout, CreateProject, CreateTemplate, DeleteAgent,
|
||||||
|
DeleteLayout, DeleteProfile, DeleteTemplate, DetectAgentDrift, DetectProfiles, FirstRunState,
|
||||||
|
GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage,
|
||||||
|
HealthUseCase, LaunchAgent, ListAgents, ListLayouts, ListProfiles, ListProjects, ListTemplates,
|
||||||
|
LoadLayout, MoveTabToNewWindow, MutateLayout, OpenProject, OpenTerminal, ReadAgentContext,
|
||||||
|
ReferenceProfiles, RenameLayout, ResizeTerminal, SaveProfile, SetActiveLayout,
|
||||||
|
SyncAgentWithTemplate, TerminalSessions, UpdateAgentContext, UpdateTemplate, WriteToTerminal,
|
||||||
|
};
|
||||||
|
use domain::ports::{
|
||||||
|
AgentContextStore, AgentRuntime, Clock, EventBus, FileSystem, GitPort, IdGenerator,
|
||||||
|
ProcessSpawner, ProfileStore, ProjectStore, PtyPort, TemplateStore,
|
||||||
|
};
|
||||||
|
|
||||||
|
use infrastructure::{
|
||||||
|
CliAgentRuntime, FsProfileStore, FsProjectStore, FsTemplateStore, Git2Repository,
|
||||||
|
IdeaiContextStore, LocalFileSystem, LocalProcessSpawner, PortablePtyAdapter, SystemClock,
|
||||||
|
TokioBroadcastEventBus, UuidGenerator,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::pty::PtyBridge;
|
||||||
|
|
||||||
|
/// Everything the IPC layer needs at runtime, managed by Tauri.
|
||||||
|
///
|
||||||
|
/// Use cases are stored behind `Arc` so handlers clone cheaply. The concrete
|
||||||
|
/// adapters are owned here and never leak past the composition root as concrete
|
||||||
|
/// types — downstream code only sees the `Arc<dyn Port>` held inside the use
|
||||||
|
/// cases.
|
||||||
|
pub struct AppState {
|
||||||
|
/// Trivial health use case validating the end-to-end wiring.
|
||||||
|
pub health: Arc<HealthUseCase>,
|
||||||
|
/// Create a project (init `.ideai/`, register it).
|
||||||
|
pub create_project: Arc<CreateProject>,
|
||||||
|
/// Open a project (load meta + manifest).
|
||||||
|
pub open_project: Arc<OpenProject>,
|
||||||
|
/// Close a project (persist state).
|
||||||
|
pub close_project: Arc<CloseProject>,
|
||||||
|
/// Close a tab.
|
||||||
|
pub close_tab: Arc<CloseTab>,
|
||||||
|
/// List known projects.
|
||||||
|
pub list_projects: Arc<ListProjects>,
|
||||||
|
/// Open a terminal (spawn PTY, register session).
|
||||||
|
pub open_terminal: Arc<OpenTerminal>,
|
||||||
|
/// Write keystrokes to a terminal.
|
||||||
|
pub write_terminal: Arc<WriteToTerminal>,
|
||||||
|
/// Resize a terminal.
|
||||||
|
pub resize_terminal: Arc<ResizeTerminal>,
|
||||||
|
/// Close a terminal (kill PTY).
|
||||||
|
pub close_terminal: Arc<CloseTerminal>,
|
||||||
|
/// Load a project's persisted layout tree.
|
||||||
|
pub load_layout: Arc<LoadLayout>,
|
||||||
|
/// Mutate + persist a project's layout tree.
|
||||||
|
pub mutate_layout: Arc<MutateLayout>,
|
||||||
|
/// List all named layouts for a project (#4).
|
||||||
|
pub list_layouts: Arc<ListLayouts>,
|
||||||
|
/// Create a new named layout (#4).
|
||||||
|
pub create_layout: Arc<CreateLayout>,
|
||||||
|
/// Rename a named layout (#4).
|
||||||
|
pub rename_layout: Arc<RenameLayout>,
|
||||||
|
/// Delete a named layout (#4).
|
||||||
|
pub delete_layout: Arc<DeleteLayout>,
|
||||||
|
/// Set the active named layout (#4).
|
||||||
|
pub set_active_layout: Arc<SetActiveLayout>,
|
||||||
|
/// Detect which candidate profiles' CLIs are installed (first-run).
|
||||||
|
pub detect_profiles: Arc<DetectProfiles>,
|
||||||
|
/// List configured profiles.
|
||||||
|
pub list_profiles: Arc<ListProfiles>,
|
||||||
|
/// Save (upsert) a profile.
|
||||||
|
pub save_profile: Arc<SaveProfile>,
|
||||||
|
/// Delete a profile.
|
||||||
|
pub delete_profile: Arc<DeleteProfile>,
|
||||||
|
/// Persist the batch of chosen profiles (closes the first run).
|
||||||
|
pub configure_profiles: Arc<ConfigureProfiles>,
|
||||||
|
/// Expose the pre-filled reference catalogue.
|
||||||
|
pub reference_profiles: Arc<ReferenceProfiles>,
|
||||||
|
/// Whether the first-run wizard should show + the reference catalogue.
|
||||||
|
pub first_run_state: Arc<FirstRunState>,
|
||||||
|
/// The local PTY adapter, kept port-typed so the presentation layer can
|
||||||
|
/// `subscribe_output` to pump bytes into the [`PtyBridge`] (it owns transport).
|
||||||
|
pub pty_port: Arc<dyn PtyPort>,
|
||||||
|
/// Active-terminal registry shared by the terminal use cases.
|
||||||
|
pub terminal_sessions: Arc<TerminalSessions>,
|
||||||
|
/// The domain event bus (also handed to the event relay).
|
||||||
|
pub event_bus: Arc<TokioBroadcastEventBus>,
|
||||||
|
/// Generic PTY↔Channel bridge registry (consumed by L3).
|
||||||
|
pub pty_bridge: Arc<PtyBridge>,
|
||||||
|
// --- Agents (L6) ---
|
||||||
|
/// Create a project agent from scratch.
|
||||||
|
pub create_agent: Arc<CreateAgentFromScratch>,
|
||||||
|
/// List a project's agents.
|
||||||
|
pub list_agents: Arc<ListAgents>,
|
||||||
|
/// Read an agent's Markdown context.
|
||||||
|
pub read_agent_context: Arc<ReadAgentContext>,
|
||||||
|
/// Overwrite an agent's Markdown context.
|
||||||
|
pub update_agent_context: Arc<UpdateAgentContext>,
|
||||||
|
/// Delete an agent from the manifest.
|
||||||
|
pub delete_agent: Arc<DeleteAgent>,
|
||||||
|
/// Launch an agent (spawn PTY, apply injection strategy).
|
||||||
|
pub launch_agent: Arc<LaunchAgent>,
|
||||||
|
/// Project registry — used by agent commands to resolve a `Project` from an id.
|
||||||
|
pub project_store: Arc<dyn ProjectStore>,
|
||||||
|
// --- Windows (L10) ---
|
||||||
|
/// Detach a tab into a new OS window (persists the workspace topology).
|
||||||
|
pub move_tab: Arc<MoveTabToNewWindow>,
|
||||||
|
// --- Templates & sync (L7) ---
|
||||||
|
/// Create a template in the global store.
|
||||||
|
pub create_template: Arc<CreateTemplate>,
|
||||||
|
/// Update a template's content (bumps version).
|
||||||
|
pub update_template: Arc<UpdateTemplate>,
|
||||||
|
/// List all templates in the global store.
|
||||||
|
pub list_templates: Arc<ListTemplates>,
|
||||||
|
/// Delete a template from the global store.
|
||||||
|
pub delete_template: Arc<DeleteTemplate>,
|
||||||
|
/// Create an agent from a template.
|
||||||
|
pub create_agent_from_template: Arc<CreateAgentFromTemplate>,
|
||||||
|
/// Detect which synchronized agents are behind their template.
|
||||||
|
pub detect_agent_drift: Arc<DetectAgentDrift>,
|
||||||
|
/// Apply a template update to a synchronized agent.
|
||||||
|
pub sync_agent_with_template: Arc<SyncAgentWithTemplate>,
|
||||||
|
// --- Git (L8) ---
|
||||||
|
/// Report the working-tree status of a repository.
|
||||||
|
pub git_status: Arc<GitStatus>,
|
||||||
|
/// Stage a path.
|
||||||
|
pub git_stage: Arc<GitStage>,
|
||||||
|
/// Unstage a path.
|
||||||
|
pub git_unstage: Arc<GitUnstage>,
|
||||||
|
/// Create a commit.
|
||||||
|
pub git_commit: Arc<GitCommit>,
|
||||||
|
/// List branches.
|
||||||
|
pub git_branches: Arc<GitBranches>,
|
||||||
|
/// Check out a branch.
|
||||||
|
pub git_checkout: Arc<GitCheckout>,
|
||||||
|
/// Return the recent commit log.
|
||||||
|
pub git_log: Arc<GitLog>,
|
||||||
|
/// Initialise a repository.
|
||||||
|
pub git_init: Arc<GitInit>,
|
||||||
|
/// Return the commit graph for all local branches.
|
||||||
|
pub git_graph: Arc<GitGraph>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
/// **Composition root.** Builds all adapters and use cases.
|
||||||
|
///
|
||||||
|
/// `app_data_dir` is the machine-local IDE data directory (ARCHITECTURE
|
||||||
|
/// §9.2), resolved by the caller via the Tauri path API and injected here so
|
||||||
|
/// the stores never touch Tauri themselves (Dependency Inversion).
|
||||||
|
///
|
||||||
|
/// This is the only function that constructs concrete adapters; every other
|
||||||
|
/// layer depends on ports. Adapters added in later lots (PTY, git, remote)
|
||||||
|
/// are wired in here.
|
||||||
|
#[must_use]
|
||||||
|
pub fn build(app_data_dir: PathBuf) -> Self {
|
||||||
|
// --- Concrete adapters (driven adapters) ---
|
||||||
|
let event_bus = Arc::new(TokioBroadcastEventBus::new());
|
||||||
|
let clock = Arc::new(SystemClock::new());
|
||||||
|
let ids = Arc::new(UuidGenerator::new());
|
||||||
|
let fs = Arc::new(LocalFileSystem::new());
|
||||||
|
let store = Arc::new(FsProjectStore::new(
|
||||||
|
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||||
|
app_data_dir.to_string_lossy().into_owned(),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Port-typed handles for injection.
|
||||||
|
let fs_port = Arc::clone(&fs) as Arc<dyn FileSystem>;
|
||||||
|
let store_port = Arc::clone(&store) as Arc<dyn ProjectStore>;
|
||||||
|
let events_port = Arc::clone(&event_bus) as Arc<dyn EventBus>;
|
||||||
|
|
||||||
|
// --- Use cases (ports injected as Arc<dyn Port>) ---
|
||||||
|
let health = Arc::new(HealthUseCase::new(
|
||||||
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||||
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||||
|
Arc::clone(&events_port),
|
||||||
|
));
|
||||||
|
|
||||||
|
let create_project = Arc::new(CreateProject::new(
|
||||||
|
Arc::clone(&store_port),
|
||||||
|
Arc::clone(&fs_port),
|
||||||
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||||
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||||
|
Arc::clone(&events_port),
|
||||||
|
));
|
||||||
|
let open_project = Arc::new(OpenProject::new(
|
||||||
|
Arc::clone(&store_port),
|
||||||
|
Arc::clone(&fs_port),
|
||||||
|
));
|
||||||
|
let close_project = Arc::new(CloseProject::new(Arc::clone(&store_port)));
|
||||||
|
let close_tab = Arc::new(CloseTab::new(Arc::clone(&store_port)));
|
||||||
|
let list_projects = Arc::new(ListProjects::new(Arc::clone(&store_port)));
|
||||||
|
|
||||||
|
// --- PTY adapter + terminal use cases (L3) ---
|
||||||
|
let pty = Arc::new(PortablePtyAdapter::new());
|
||||||
|
let pty_port = Arc::clone(&pty) as Arc<dyn PtyPort>;
|
||||||
|
let terminal_sessions = Arc::new(TerminalSessions::new());
|
||||||
|
|
||||||
|
let open_terminal = Arc::new(OpenTerminal::new(
|
||||||
|
Arc::clone(&pty_port),
|
||||||
|
Arc::clone(&terminal_sessions),
|
||||||
|
Arc::clone(&events_port),
|
||||||
|
));
|
||||||
|
let write_terminal = Arc::new(WriteToTerminal::new(
|
||||||
|
Arc::clone(&pty_port),
|
||||||
|
Arc::clone(&terminal_sessions),
|
||||||
|
));
|
||||||
|
let resize_terminal = Arc::new(ResizeTerminal::new(
|
||||||
|
Arc::clone(&pty_port),
|
||||||
|
Arc::clone(&terminal_sessions),
|
||||||
|
));
|
||||||
|
let close_terminal = Arc::new(CloseTerminal::new(
|
||||||
|
Arc::clone(&pty_port),
|
||||||
|
Arc::clone(&terminal_sessions),
|
||||||
|
));
|
||||||
|
|
||||||
|
// --- Layout use cases (L4 + #4) ---
|
||||||
|
let load_layout = Arc::new(LoadLayout::new(
|
||||||
|
Arc::clone(&store_port),
|
||||||
|
Arc::clone(&fs_port),
|
||||||
|
));
|
||||||
|
let mutate_layout = Arc::new(MutateLayout::new(
|
||||||
|
Arc::clone(&store_port),
|
||||||
|
Arc::clone(&fs_port),
|
||||||
|
Arc::clone(&events_port),
|
||||||
|
));
|
||||||
|
let list_layouts = Arc::new(ListLayouts::new(
|
||||||
|
Arc::clone(&store_port),
|
||||||
|
Arc::clone(&fs_port),
|
||||||
|
));
|
||||||
|
let create_layout = Arc::new(CreateLayout::new(
|
||||||
|
Arc::clone(&store_port),
|
||||||
|
Arc::clone(&fs_port),
|
||||||
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||||
|
Arc::clone(&events_port),
|
||||||
|
));
|
||||||
|
let rename_layout = Arc::new(RenameLayout::new(
|
||||||
|
Arc::clone(&store_port),
|
||||||
|
Arc::clone(&fs_port),
|
||||||
|
Arc::clone(&events_port),
|
||||||
|
));
|
||||||
|
let delete_layout = Arc::new(DeleteLayout::new(
|
||||||
|
Arc::clone(&store_port),
|
||||||
|
Arc::clone(&fs_port),
|
||||||
|
Arc::clone(&events_port),
|
||||||
|
));
|
||||||
|
let set_active_layout = Arc::new(SetActiveLayout::new(
|
||||||
|
Arc::clone(&store_port),
|
||||||
|
Arc::clone(&fs_port),
|
||||||
|
Arc::clone(&events_port),
|
||||||
|
));
|
||||||
|
|
||||||
|
// --- Profiles & AI runtime (L5) ---
|
||||||
|
// One generic, profile-driven runtime adapter (Open/Closed): it holds the
|
||||||
|
// process spawner used for detection. The profile store persists
|
||||||
|
// `profiles.json` in the same machine-local app-data dir as the project
|
||||||
|
// registry.
|
||||||
|
let spawner = Arc::new(LocalProcessSpawner::new());
|
||||||
|
let spawner_port = Arc::clone(&spawner) as Arc<dyn ProcessSpawner>;
|
||||||
|
let runtime = Arc::new(CliAgentRuntime::new(Arc::clone(&spawner_port)));
|
||||||
|
let runtime_port = Arc::clone(&runtime) as Arc<dyn AgentRuntime>;
|
||||||
|
|
||||||
|
let profile_store = Arc::new(FsProfileStore::new(
|
||||||
|
Arc::clone(&fs_port),
|
||||||
|
app_data_dir.to_string_lossy().into_owned(),
|
||||||
|
));
|
||||||
|
let profile_store_port = Arc::clone(&profile_store) as Arc<dyn ProfileStore>;
|
||||||
|
|
||||||
|
let detect_profiles = Arc::new(DetectProfiles::new(Arc::clone(&runtime_port)));
|
||||||
|
let list_profiles = Arc::new(ListProfiles::new(Arc::clone(&profile_store_port)));
|
||||||
|
let save_profile = Arc::new(SaveProfile::new(Arc::clone(&profile_store_port)));
|
||||||
|
let delete_profile = Arc::new(DeleteProfile::new(Arc::clone(&profile_store_port)));
|
||||||
|
let configure_profiles = Arc::new(ConfigureProfiles::new(Arc::clone(&profile_store_port)));
|
||||||
|
let reference_profiles = Arc::new(ReferenceProfiles::new());
|
||||||
|
let first_run_state = Arc::new(FirstRunState::new(Arc::clone(&profile_store_port)));
|
||||||
|
|
||||||
|
let pty_bridge = Arc::new(PtyBridge::new());
|
||||||
|
|
||||||
|
// --- Agent context store + use cases (L6) ---
|
||||||
|
let contexts = Arc::new(IdeaiContextStore::new(Arc::clone(&fs_port)));
|
||||||
|
let contexts_port = Arc::clone(&contexts) as Arc<dyn AgentContextStore>;
|
||||||
|
|
||||||
|
let create_agent = Arc::new(CreateAgentFromScratch::new(
|
||||||
|
Arc::clone(&contexts_port),
|
||||||
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||||
|
Arc::clone(&events_port),
|
||||||
|
));
|
||||||
|
let list_agents = Arc::new(ListAgents::new(Arc::clone(&contexts_port)));
|
||||||
|
let read_agent_context = Arc::new(ReadAgentContext::new(Arc::clone(&contexts_port)));
|
||||||
|
let update_agent_context = Arc::new(UpdateAgentContext::new(Arc::clone(&contexts_port)));
|
||||||
|
let delete_agent = Arc::new(DeleteAgent::new(
|
||||||
|
Arc::clone(&contexts_port),
|
||||||
|
Arc::clone(&events_port),
|
||||||
|
));
|
||||||
|
// LaunchAgent shares the SAME pty_port and terminal_sessions as the terminal
|
||||||
|
// use cases — indispensable for the PtyBridge to work correctly.
|
||||||
|
let launch_agent = Arc::new(LaunchAgent::new(
|
||||||
|
Arc::clone(&contexts_port),
|
||||||
|
Arc::clone(&profile_store_port),
|
||||||
|
Arc::clone(&runtime_port),
|
||||||
|
Arc::clone(&fs_port),
|
||||||
|
Arc::clone(&pty_port),
|
||||||
|
Arc::clone(&terminal_sessions),
|
||||||
|
Arc::clone(&events_port),
|
||||||
|
));
|
||||||
|
|
||||||
|
let project_store = Arc::clone(&store_port);
|
||||||
|
|
||||||
|
// --- Template store + use cases (L7) ---
|
||||||
|
let template_store = Arc::new(FsTemplateStore::new(
|
||||||
|
Arc::clone(&fs_port),
|
||||||
|
app_data_dir.to_string_lossy().into_owned(),
|
||||||
|
));
|
||||||
|
let template_store_port = Arc::clone(&template_store) as Arc<dyn TemplateStore>;
|
||||||
|
|
||||||
|
let create_template = Arc::new(CreateTemplate::new(
|
||||||
|
Arc::clone(&template_store_port),
|
||||||
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||||
|
));
|
||||||
|
let update_template = Arc::new(UpdateTemplate::new(
|
||||||
|
Arc::clone(&template_store_port),
|
||||||
|
Arc::clone(&events_port),
|
||||||
|
));
|
||||||
|
let list_templates = Arc::new(ListTemplates::new(Arc::clone(&template_store_port)));
|
||||||
|
let delete_template = Arc::new(DeleteTemplate::new(Arc::clone(&template_store_port)));
|
||||||
|
let create_agent_from_template = Arc::new(CreateAgentFromTemplate::new(
|
||||||
|
Arc::clone(&template_store_port),
|
||||||
|
Arc::clone(&contexts_port),
|
||||||
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||||
|
Arc::clone(&events_port),
|
||||||
|
));
|
||||||
|
let detect_agent_drift = Arc::new(DetectAgentDrift::new(
|
||||||
|
Arc::clone(&template_store_port),
|
||||||
|
Arc::clone(&contexts_port),
|
||||||
|
Arc::clone(&events_port),
|
||||||
|
));
|
||||||
|
let sync_agent_with_template = Arc::new(SyncAgentWithTemplate::new(
|
||||||
|
Arc::clone(&template_store_port),
|
||||||
|
Arc::clone(&contexts_port),
|
||||||
|
Arc::clone(&events_port),
|
||||||
|
));
|
||||||
|
|
||||||
|
// --- Git adapter + use cases (L8) ---
|
||||||
|
let git = Arc::new(Git2Repository::new());
|
||||||
|
let git_port = Arc::clone(&git) as Arc<dyn GitPort>;
|
||||||
|
|
||||||
|
let git_status = Arc::new(GitStatus::new(Arc::clone(&git_port)));
|
||||||
|
let git_stage = Arc::new(GitStage::new(Arc::clone(&git_port)));
|
||||||
|
let git_unstage = Arc::new(GitUnstage::new(Arc::clone(&git_port)));
|
||||||
|
let git_commit = Arc::new(GitCommit::new(Arc::clone(&git_port), Arc::clone(&events_port)));
|
||||||
|
let git_branches = Arc::new(GitBranches::new(Arc::clone(&git_port)));
|
||||||
|
let git_checkout = Arc::new(GitCheckout::new(
|
||||||
|
Arc::clone(&git_port),
|
||||||
|
Arc::clone(&events_port),
|
||||||
|
));
|
||||||
|
let git_log = Arc::new(GitLog::new(Arc::clone(&git_port)));
|
||||||
|
let git_init = Arc::new(GitInit::new(Arc::clone(&git_port), Arc::clone(&events_port)));
|
||||||
|
let git_graph = Arc::new(GitGraph::new(Arc::clone(&git_port)));
|
||||||
|
|
||||||
|
// --- Windows (L10) ---
|
||||||
|
let move_tab = Arc::new(MoveTabToNewWindow::new(
|
||||||
|
Arc::clone(&store_port),
|
||||||
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||||
|
));
|
||||||
|
|
||||||
|
Self {
|
||||||
|
health,
|
||||||
|
create_project,
|
||||||
|
open_project,
|
||||||
|
close_project,
|
||||||
|
close_tab,
|
||||||
|
list_projects,
|
||||||
|
open_terminal,
|
||||||
|
write_terminal,
|
||||||
|
resize_terminal,
|
||||||
|
close_terminal,
|
||||||
|
load_layout,
|
||||||
|
mutate_layout,
|
||||||
|
list_layouts,
|
||||||
|
create_layout,
|
||||||
|
rename_layout,
|
||||||
|
delete_layout,
|
||||||
|
set_active_layout,
|
||||||
|
detect_profiles,
|
||||||
|
list_profiles,
|
||||||
|
save_profile,
|
||||||
|
delete_profile,
|
||||||
|
configure_profiles,
|
||||||
|
reference_profiles,
|
||||||
|
first_run_state,
|
||||||
|
pty_port,
|
||||||
|
terminal_sessions,
|
||||||
|
event_bus,
|
||||||
|
pty_bridge,
|
||||||
|
create_agent,
|
||||||
|
list_agents,
|
||||||
|
read_agent_context,
|
||||||
|
update_agent_context,
|
||||||
|
delete_agent,
|
||||||
|
launch_agent,
|
||||||
|
project_store,
|
||||||
|
create_template,
|
||||||
|
update_template,
|
||||||
|
list_templates,
|
||||||
|
delete_template,
|
||||||
|
create_agent_from_template,
|
||||||
|
detect_agent_drift,
|
||||||
|
sync_agent_with_template,
|
||||||
|
git_status,
|
||||||
|
git_stage,
|
||||||
|
git_unstage,
|
||||||
|
git_commit,
|
||||||
|
git_branches,
|
||||||
|
git_checkout,
|
||||||
|
git_log,
|
||||||
|
git_init,
|
||||||
|
git_graph,
|
||||||
|
move_tab,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
crates/app-tauri/tauri.conf.json
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "IdeA",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "app.idea.ide",
|
||||||
|
"build": {
|
||||||
|
"frontendDist": "../../frontend/dist",
|
||||||
|
"devUrl": "http://localhost:5173",
|
||||||
|
"beforeDevCommand": "npm --prefix ../frontend run dev"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "IdeA",
|
||||||
|
"width": 1280,
|
||||||
|
"height": 800,
|
||||||
|
"resizable": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": ["appimage", "nsis"],
|
||||||
|
"icon": ["icons/icon.png"]
|
||||||
|
}
|
||||||
|
}
|
||||||
326
crates/app-tauri/tests/dto.rs
Normal file
@ -0,0 +1,326 @@
|
|||||||
|
//! L1 tests for the IPC DTO (de)serialisation contract: camelCase on the wire,
|
||||||
|
//! stable `ErrorDto.code`, and the `DomainEvent -> DomainEventDto` mapping with
|
||||||
|
//! its tagged, camelCase JSON shape.
|
||||||
|
|
||||||
|
use app_tauri_lib::dto::{
|
||||||
|
parse_node_id, parse_session_id, ErrorDto, HealthRequestDto, HealthResponseDto, LayoutDto,
|
||||||
|
LayoutOperationDto, OpenTerminalRequestDto, ResizeTerminalRequestDto, TerminalClosedDto,
|
||||||
|
WriteTerminalRequestDto,
|
||||||
|
};
|
||||||
|
use application::{CloseTerminalOutput, LayoutOperation, LoadLayoutOutput, OpenTerminalInput};
|
||||||
|
use domain::{Direction, LayoutNode, LayoutTree, LeafCell, NodeId};
|
||||||
|
use app_tauri_lib::events::{DomainEventDto, DOMAIN_EVENT};
|
||||||
|
|
||||||
|
use application::{AppError, HealthInput};
|
||||||
|
use domain::events::DomainEvent;
|
||||||
|
use domain::ids::{AgentId, SessionId};
|
||||||
|
use domain::ProjectId;
|
||||||
|
use domain::TemplateVersion;
|
||||||
|
use domain::TemplateId;
|
||||||
|
use serde_json::json;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn health_response_serialises_camel_case() {
|
||||||
|
let dto = HealthResponseDto {
|
||||||
|
version: "1.2.3".into(),
|
||||||
|
alive: true,
|
||||||
|
time_millis: 42,
|
||||||
|
correlation_id: "abc".into(),
|
||||||
|
note: Some("hi".into()),
|
||||||
|
};
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
v,
|
||||||
|
json!({
|
||||||
|
"version": "1.2.3",
|
||||||
|
"alive": true,
|
||||||
|
"timeMillis": 42,
|
||||||
|
"correlationId": "abc",
|
||||||
|
"note": "hi",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn health_request_deserialises_camel_case_and_defaults() {
|
||||||
|
let dto: HealthRequestDto = serde_json::from_value(json!({ "note": "x" })).unwrap();
|
||||||
|
assert_eq!(HealthInput::from(dto).note.as_deref(), Some("x"));
|
||||||
|
|
||||||
|
// Missing note defaults to None.
|
||||||
|
let empty: HealthRequestDto = serde_json::from_value(json!({})).unwrap();
|
||||||
|
assert_eq!(HealthInput::from(empty).note, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn error_dto_carries_stable_code_and_message() {
|
||||||
|
let dto = ErrorDto::from(AppError::NotFound("project".into()));
|
||||||
|
assert_eq!(dto.code, "NOT_FOUND");
|
||||||
|
assert!(dto.message.contains("project"));
|
||||||
|
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
assert_eq!(v["code"], "NOT_FOUND");
|
||||||
|
assert!(v.get("message").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn domain_event_relay_name_is_frozen() {
|
||||||
|
assert_eq!(DOMAIN_EVENT, "domain://event");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn domain_event_dto_is_tagged_and_camel_case() {
|
||||||
|
let pid = ProjectId::from_uuid(Uuid::nil());
|
||||||
|
let dto = DomainEventDto::from(&DomainEvent::ProjectCreated { project_id: pid });
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
v,
|
||||||
|
json!({ "type": "projectCreated", "projectId": pid.to_string() })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn domain_event_dto_maps_agent_launched() {
|
||||||
|
let aid = AgentId::from_uuid(Uuid::nil());
|
||||||
|
let sid = SessionId::from_uuid(Uuid::nil());
|
||||||
|
let dto = DomainEventDto::from(&DomainEvent::AgentLaunched {
|
||||||
|
agent_id: aid,
|
||||||
|
session_id: sid,
|
||||||
|
});
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
assert_eq!(v["type"], "agentLaunched");
|
||||||
|
assert_eq!(v["agentId"], aid.to_string());
|
||||||
|
assert_eq!(v["sessionId"], sid.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn domain_event_dto_maps_template_updated_version() {
|
||||||
|
let tid = TemplateId::from_uuid(Uuid::nil());
|
||||||
|
let dto = DomainEventDto::from(&DomainEvent::TemplateUpdated {
|
||||||
|
template_id: tid,
|
||||||
|
version: TemplateVersion::INITIAL,
|
||||||
|
});
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
assert_eq!(v["type"], "templateUpdated");
|
||||||
|
assert_eq!(v["templateId"], tid.to_string());
|
||||||
|
assert!(v["version"].is_u64());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn domain_event_dto_maps_pty_output_bytes() {
|
||||||
|
let sid = SessionId::from_uuid(Uuid::nil());
|
||||||
|
let dto = DomainEventDto::from(&DomainEvent::PtyOutput {
|
||||||
|
session_id: sid,
|
||||||
|
bytes: vec![1, 2, 3],
|
||||||
|
});
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
assert_eq!(v["type"], "ptyOutput");
|
||||||
|
assert_eq!(v["bytes"], json!([1, 2, 3]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Terminal DTOs (L3)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_terminal_request_deserialises_camel_case_with_defaults() {
|
||||||
|
// Minimal payload: command/args omitted → None / empty.
|
||||||
|
let dto: OpenTerminalRequestDto =
|
||||||
|
serde_json::from_value(json!({ "cwd": "/p", "rows": 24, "cols": 80 })).unwrap();
|
||||||
|
let input = OpenTerminalInput::from(dto);
|
||||||
|
assert_eq!(input.cwd, "/p");
|
||||||
|
assert_eq!(input.rows, 24);
|
||||||
|
assert_eq!(input.cols, 80);
|
||||||
|
assert_eq!(input.command, None);
|
||||||
|
assert!(input.args.is_empty());
|
||||||
|
assert_eq!(input.node_id, None);
|
||||||
|
|
||||||
|
// Full payload with explicit command + args.
|
||||||
|
let full: OpenTerminalRequestDto = serde_json::from_value(json!({
|
||||||
|
"cwd": "/q", "rows": 10, "cols": 20,
|
||||||
|
"command": "/bin/zsh", "args": ["-l"],
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
let input = OpenTerminalInput::from(full);
|
||||||
|
assert_eq!(input.command.as_deref(), Some("/bin/zsh"));
|
||||||
|
assert_eq!(input.args, vec!["-l".to_owned()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_terminal_request_deserialises_session_id_and_data() {
|
||||||
|
let sid = SessionId::from_uuid(Uuid::nil());
|
||||||
|
let dto: WriteTerminalRequestDto = serde_json::from_value(json!({
|
||||||
|
"sessionId": sid.to_string(),
|
||||||
|
"data": [104, 105],
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
let input = dto.into_input().expect("valid session id");
|
||||||
|
assert_eq!(input.session_id, sid);
|
||||||
|
assert_eq!(input.data, vec![104u8, 105]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_terminal_request_rejects_bad_session_id() {
|
||||||
|
let dto: WriteTerminalRequestDto =
|
||||||
|
serde_json::from_value(json!({ "sessionId": "not-a-uuid", "data": [] })).unwrap();
|
||||||
|
let err = dto.into_input().expect_err("malformed id rejected");
|
||||||
|
assert_eq!(err.code, "INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resize_terminal_request_deserialises_camel_case() {
|
||||||
|
let sid = SessionId::from_uuid(Uuid::nil());
|
||||||
|
let dto: ResizeTerminalRequestDto = serde_json::from_value(json!({
|
||||||
|
"sessionId": sid.to_string(), "rows": 40, "cols": 120,
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
let input = dto.into_input().expect("valid");
|
||||||
|
assert_eq!(input.session_id, sid);
|
||||||
|
assert_eq!(input.rows, 40);
|
||||||
|
assert_eq!(input.cols, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn terminal_closed_dto_serialises_code_camel_case() {
|
||||||
|
let dto = TerminalClosedDto::from(CloseTerminalOutput { code: Some(0) });
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
assert_eq!(v, json!({ "code": 0 }));
|
||||||
|
|
||||||
|
// Signalled (None) round-trips as null.
|
||||||
|
let none = TerminalClosedDto::from(CloseTerminalOutput { code: None });
|
||||||
|
assert_eq!(serde_json::to_value(&none).unwrap(), json!({ "code": null }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_session_id_accepts_uuid_and_rejects_garbage() {
|
||||||
|
let sid = SessionId::from_uuid(Uuid::nil());
|
||||||
|
assert_eq!(parse_session_id(&sid.to_string()).unwrap(), sid);
|
||||||
|
assert_eq!(parse_session_id("nope").unwrap_err().code, "INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Layout (L4)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn nid(n: u128) -> NodeId {
|
||||||
|
NodeId::from_uuid(Uuid::from_u128(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn layout_dto_serialises_camelcase_tagged_tree() {
|
||||||
|
// A single-leaf tree → transparent over LayoutTree, tagged `{type,node}`.
|
||||||
|
let tree = LayoutTree::single(LeafCell {
|
||||||
|
id: nid(1),
|
||||||
|
session: None,
|
||||||
|
agent: None,
|
||||||
|
});
|
||||||
|
let dto = LayoutDto::from(LoadLayoutOutput {
|
||||||
|
layout_id: domain::LayoutId::new_random(),
|
||||||
|
layout: tree,
|
||||||
|
});
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
assert_eq!(v["root"]["type"], "leaf", "enum tagged on `type`");
|
||||||
|
assert_eq!(v["root"]["node"]["id"], nid(1).to_string());
|
||||||
|
// Empty session is skipped (skip_serializing_if).
|
||||||
|
assert!(v["root"]["node"].get("session").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn layout_operation_dto_split_deserialises_camelcase() {
|
||||||
|
let json = json!({
|
||||||
|
"type": "split",
|
||||||
|
"target": nid(1).to_string(),
|
||||||
|
"direction": "row",
|
||||||
|
"newLeaf": nid(2).to_string(),
|
||||||
|
"container": nid(9).to_string(),
|
||||||
|
});
|
||||||
|
let dto: LayoutOperationDto = serde_json::from_value(json).unwrap();
|
||||||
|
match dto.into_operation().unwrap() {
|
||||||
|
LayoutOperation::Split {
|
||||||
|
target,
|
||||||
|
direction,
|
||||||
|
new_leaf,
|
||||||
|
container,
|
||||||
|
} => {
|
||||||
|
assert_eq!(target, nid(1));
|
||||||
|
assert_eq!(direction, Direction::Row);
|
||||||
|
assert_eq!(new_leaf, nid(2));
|
||||||
|
assert_eq!(container, nid(9));
|
||||||
|
}
|
||||||
|
other => panic!("expected Split, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn layout_operation_dto_set_session_accepts_null_session() {
|
||||||
|
// `session: null` → detach (None).
|
||||||
|
let json = json!({
|
||||||
|
"type": "setSession",
|
||||||
|
"target": nid(1).to_string(),
|
||||||
|
"session": null,
|
||||||
|
});
|
||||||
|
let dto: LayoutOperationDto = serde_json::from_value(json).unwrap();
|
||||||
|
match dto.into_operation().unwrap() {
|
||||||
|
LayoutOperation::SetSession { target, session } => {
|
||||||
|
assert_eq!(target, nid(1));
|
||||||
|
assert!(session.is_none());
|
||||||
|
}
|
||||||
|
other => panic!("expected SetSession, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn layout_operation_dto_resize_carries_weights() {
|
||||||
|
let json = json!({
|
||||||
|
"type": "resize",
|
||||||
|
"container": nid(9).to_string(),
|
||||||
|
"weights": [3.0, 1.0],
|
||||||
|
});
|
||||||
|
let dto: LayoutOperationDto = serde_json::from_value(json).unwrap();
|
||||||
|
match dto.into_operation().unwrap() {
|
||||||
|
LayoutOperation::Resize { container, weights } => {
|
||||||
|
assert_eq!(container, nid(9));
|
||||||
|
assert_eq!(weights, vec![3.0, 1.0]);
|
||||||
|
}
|
||||||
|
other => panic!("expected Resize, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn layout_operation_dto_rejects_malformed_uuid_as_invalid() {
|
||||||
|
let json = json!({
|
||||||
|
"type": "merge",
|
||||||
|
"container": "not-a-uuid",
|
||||||
|
"keepIndex": 0,
|
||||||
|
});
|
||||||
|
let dto: LayoutOperationDto = serde_json::from_value(json).unwrap();
|
||||||
|
let err = dto.into_operation().expect_err("malformed uuid rejected");
|
||||||
|
assert_eq!(err.code, "INVALID", "got {err:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_node_id_accepts_uuid_and_rejects_garbage() {
|
||||||
|
assert_eq!(parse_node_id(&nid(7).to_string()).unwrap(), nid(7));
|
||||||
|
assert_eq!(parse_node_id("garbage").unwrap_err().code, "INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn layout_dto_round_trips_a_split_tree_shape() {
|
||||||
|
// Build a split tree, serialise via the DTO, and confirm the tagged shape
|
||||||
|
// re-parses into an equivalent LayoutTree.
|
||||||
|
let tree = LayoutTree::single(LeafCell {
|
||||||
|
id: nid(1),
|
||||||
|
session: None,
|
||||||
|
agent: None,
|
||||||
|
})
|
||||||
|
.split(nid(1), Direction::Column, LeafCell { id: nid(2), session: None, agent: None }, nid(9))
|
||||||
|
.unwrap();
|
||||||
|
let dto = LayoutDto::from(LoadLayoutOutput {
|
||||||
|
layout_id: domain::LayoutId::new_random(),
|
||||||
|
layout: tree.clone(),
|
||||||
|
});
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
let back: LayoutTree = serde_json::from_value(v).unwrap();
|
||||||
|
assert_eq!(back, tree, "DTO serialisation round-trips the tree");
|
||||||
|
assert!(matches!(back.root, LayoutNode::Split(_)));
|
||||||
|
}
|
||||||
184
crates/app-tauri/tests/dto_agents.rs
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
//! L6 tests for the agent DTO (de)serialisation contract: camelCase on the
|
||||||
|
//! wire, embedded [`Agent`] shape preserved, `parse_agent_id` error behaviour,
|
||||||
|
//! and `From<LaunchAgentOutput>` for [`TerminalSessionDto`].
|
||||||
|
|
||||||
|
use app_tauri_lib::dto::{
|
||||||
|
parse_agent_id, AgentDto, AgentListDto, CreateAgentRequestDto, LaunchAgentRequestDto,
|
||||||
|
TerminalSessionDto, UpdateAgentContextRequestDto,
|
||||||
|
};
|
||||||
|
use application::{CreateAgentOutput, LaunchAgentOutput, ListAgentsOutput};
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AgentDto / AgentListDto serialisation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_dto_serialises_camelcase() {
|
||||||
|
let agent = make_agent(1, 2);
|
||||||
|
let dto = AgentDto(agent.clone());
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(v["id"], agent.id.to_string());
|
||||||
|
assert_eq!(v["name"], "My Agent");
|
||||||
|
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["synchronized"], false);
|
||||||
|
// origin: tagged `{ "type": "scratch" }`
|
||||||
|
assert_eq!(v["origin"]["type"], "scratch");
|
||||||
|
// no snake_case leak
|
||||||
|
assert!(v.get("context_path").is_none());
|
||||||
|
assert!(v.get("profile_id").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_list_dto_is_transparent_array() {
|
||||||
|
let out = ListAgentsOutput {
|
||||||
|
agents: vec![make_agent(1, 2), make_agent(3, 4)],
|
||||||
|
};
|
||||||
|
let dto = AgentListDto::from(out);
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
let arr = v.as_array().expect("transparent array");
|
||||||
|
assert_eq!(arr.len(), 2);
|
||||||
|
assert_eq!(arr[0]["name"], "My Agent");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_agent_output_maps_to_agent_dto() {
|
||||||
|
let agent = make_agent(5, 6);
|
||||||
|
let out = CreateAgentOutput { agent: agent.clone() };
|
||||||
|
let dto = AgentDto::from(out);
|
||||||
|
assert_eq!(dto.0.id, agent.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Request DTO deserialisation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_agent_request_deserialises_camelcase() {
|
||||||
|
let project_id = Uuid::from_u128(10).to_string();
|
||||||
|
let profile_id = Uuid::from_u128(20).to_string();
|
||||||
|
let raw = json!({
|
||||||
|
"projectId": project_id,
|
||||||
|
"name": "Backend Dev",
|
||||||
|
"profileId": profile_id,
|
||||||
|
"initialContent": "# Hello"
|
||||||
|
});
|
||||||
|
let dto: CreateAgentRequestDto = serde_json::from_value(raw).unwrap();
|
||||||
|
assert_eq!(dto.project_id, project_id);
|
||||||
|
assert_eq!(dto.name, "Backend Dev");
|
||||||
|
assert_eq!(dto.profile_id, profile_id);
|
||||||
|
assert_eq!(dto.initial_content.as_deref(), Some("# Hello"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_agent_request_initial_content_defaults_to_none() {
|
||||||
|
let raw = json!({
|
||||||
|
"projectId": Uuid::nil().to_string(),
|
||||||
|
"name": "Agent",
|
||||||
|
"profileId": Uuid::nil().to_string()
|
||||||
|
});
|
||||||
|
let dto: CreateAgentRequestDto = serde_json::from_value(raw).unwrap();
|
||||||
|
assert!(dto.initial_content.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_agent_context_request_deserialises_camelcase() {
|
||||||
|
let raw = json!({
|
||||||
|
"projectId": Uuid::nil().to_string(),
|
||||||
|
"agentId": Uuid::nil().to_string(),
|
||||||
|
"content": "# Updated"
|
||||||
|
});
|
||||||
|
let dto: UpdateAgentContextRequestDto = serde_json::from_value(raw).unwrap();
|
||||||
|
assert_eq!(dto.content, "# Updated");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn launch_agent_request_deserialises_camelcase() {
|
||||||
|
let project_id = Uuid::from_u128(1).to_string();
|
||||||
|
let agent_id = Uuid::from_u128(2).to_string();
|
||||||
|
let raw = json!({
|
||||||
|
"projectId": project_id,
|
||||||
|
"agentId": agent_id,
|
||||||
|
"rows": 24,
|
||||||
|
"cols": 80
|
||||||
|
});
|
||||||
|
let dto: LaunchAgentRequestDto = serde_json::from_value(raw).unwrap();
|
||||||
|
assert_eq!(dto.rows, 24);
|
||||||
|
assert_eq!(dto.cols, 80);
|
||||||
|
assert_eq!(dto.project_id, project_id);
|
||||||
|
assert_eq!(dto.agent_id, agent_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// parse_agent_id
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_agent_id_accepts_uuid() {
|
||||||
|
let id = Uuid::from_u128(99);
|
||||||
|
assert_eq!(
|
||||||
|
parse_agent_id(&id.to_string()).unwrap(),
|
||||||
|
AgentId::from_uuid(id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_agent_id_rejects_garbage() {
|
||||||
|
let err = parse_agent_id("not-a-uuid").expect_err("garbage rejected");
|
||||||
|
assert_eq!(err.code, "INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// From<LaunchAgentOutput> for TerminalSessionDto
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn launch_agent_output_maps_to_terminal_session_dto() {
|
||||||
|
let session_id = SessionId::from_uuid(Uuid::from_u128(7));
|
||||||
|
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(
|
||||||
|
session_id,
|
||||||
|
node_id,
|
||||||
|
cwd,
|
||||||
|
SessionKind::Agent { agent_id },
|
||||||
|
size,
|
||||||
|
);
|
||||||
|
session.status = SessionStatus::Running;
|
||||||
|
|
||||||
|
let out = LaunchAgentOutput { session };
|
||||||
|
let dto = TerminalSessionDto::from(out);
|
||||||
|
|
||||||
|
assert_eq!(dto.session_id, session_id.to_string());
|
||||||
|
assert_eq!(dto.cwd, "/tmp/project");
|
||||||
|
assert_eq!(dto.rows, 24);
|
||||||
|
assert_eq!(dto.cols, 80);
|
||||||
|
|
||||||
|
// Also verify camelCase serialisation.
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
assert_eq!(v["sessionId"], session_id.to_string());
|
||||||
|
assert_eq!(v["cwd"], "/tmp/project");
|
||||||
|
assert!(v.get("session_id").is_none(), "no snake_case leak");
|
||||||
|
}
|
||||||
262
crates/app-tauri/tests/dto_git.rs
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
//! L8 tests for the Git DTO (de)serialisation contract: camelCase on the
|
||||||
|
//! wire, transparent list shapes, `From<…Output>` conversions, and request
|
||||||
|
//! DTO deserialisation.
|
||||||
|
|
||||||
|
use app_tauri_lib::dto::{
|
||||||
|
GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto,
|
||||||
|
GitFileStatusDto, GitStageRequestDto, GitStatusListDto, GraphCommitDto, GraphCommitListDto,
|
||||||
|
};
|
||||||
|
use application::{GitBranchesOutput, GitCommitOutput, GitGraphOutput, GitLogOutput, GitStatusOutput};
|
||||||
|
use domain::ports::{GitCommitInfo, GitFileStatus, GraphCommit};
|
||||||
|
use serde_json::json;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GitFileStatusDto serialisation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_file_status_dto_serialises_camelcase() {
|
||||||
|
let dto = GitFileStatusDto {
|
||||||
|
path: "src/main.rs".to_owned(),
|
||||||
|
staged: true,
|
||||||
|
};
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
assert_eq!(v["path"], "src/main.rs");
|
||||||
|
assert_eq!(v["staged"], true);
|
||||||
|
// no snake_case leak — both fields are single words so no renaming needed,
|
||||||
|
// but verify no extra/unexpected keys.
|
||||||
|
assert!(v.get("path").is_some());
|
||||||
|
assert!(v.get("staged").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_file_status_from_domain_type() {
|
||||||
|
let domain = GitFileStatus {
|
||||||
|
path: "README.md".to_owned(),
|
||||||
|
staged: false,
|
||||||
|
};
|
||||||
|
let dto = GitFileStatusDto::from(domain);
|
||||||
|
assert_eq!(dto.path, "README.md");
|
||||||
|
assert!(!dto.staged);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GitStatusListDto — transparent array
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_status_list_dto_is_transparent_array() {
|
||||||
|
let out = GitStatusOutput {
|
||||||
|
entries: vec![
|
||||||
|
GitFileStatus {
|
||||||
|
path: "a.rs".to_owned(),
|
||||||
|
staged: true,
|
||||||
|
},
|
||||||
|
GitFileStatus {
|
||||||
|
path: "b.rs".to_owned(),
|
||||||
|
staged: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let dto = GitStatusListDto::from(out);
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
let arr = v.as_array().expect("transparent array");
|
||||||
|
assert_eq!(arr.len(), 2);
|
||||||
|
assert_eq!(arr[0]["path"], "a.rs");
|
||||||
|
assert_eq!(arr[0]["staged"], true);
|
||||||
|
assert_eq!(arr[1]["path"], "b.rs");
|
||||||
|
assert_eq!(arr[1]["staged"], false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GitCommitDto serialisation & From impls
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_commit_dto_serialises_camelcase() {
|
||||||
|
let dto = GitCommitDto {
|
||||||
|
hash: "abc123".to_owned(),
|
||||||
|
summary: "feat: add git support".to_owned(),
|
||||||
|
};
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
assert_eq!(v["hash"], "abc123");
|
||||||
|
assert_eq!(v["summary"], "feat: add git support");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_commit_dto_from_commit_info() {
|
||||||
|
let info = GitCommitInfo {
|
||||||
|
hash: "deadbeef".to_owned(),
|
||||||
|
summary: "fix: something".to_owned(),
|
||||||
|
};
|
||||||
|
let dto = GitCommitDto::from(info);
|
||||||
|
assert_eq!(dto.hash, "deadbeef");
|
||||||
|
assert_eq!(dto.summary, "fix: something");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_commit_dto_from_commit_output() {
|
||||||
|
let out = GitCommitOutput {
|
||||||
|
commit: GitCommitInfo {
|
||||||
|
hash: "cafebabe".to_owned(),
|
||||||
|
summary: "chore: cleanup".to_owned(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let dto = GitCommitDto::from(out);
|
||||||
|
assert_eq!(dto.hash, "cafebabe");
|
||||||
|
assert_eq!(dto.summary, "chore: cleanup");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GitCommitListDto — transparent array
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_commit_list_dto_is_transparent_array() {
|
||||||
|
let out = GitLogOutput {
|
||||||
|
commits: vec![
|
||||||
|
GitCommitInfo {
|
||||||
|
hash: "aaa".to_owned(),
|
||||||
|
summary: "first".to_owned(),
|
||||||
|
},
|
||||||
|
GitCommitInfo {
|
||||||
|
hash: "bbb".to_owned(),
|
||||||
|
summary: "second".to_owned(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let dto = GitCommitListDto::from(out);
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
let arr = v.as_array().expect("transparent array");
|
||||||
|
assert_eq!(arr.len(), 2);
|
||||||
|
assert_eq!(arr[0]["hash"], "aaa");
|
||||||
|
assert_eq!(arr[0]["summary"], "first");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GitBranchesDto serialisation (incl. current: null)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_branches_dto_serialises_with_current() {
|
||||||
|
let out = GitBranchesOutput {
|
||||||
|
branches: vec!["main".to_owned(), "feature/x".to_owned()],
|
||||||
|
current: Some("main".to_owned()),
|
||||||
|
};
|
||||||
|
let dto = GitBranchesDto::from(out);
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
assert_eq!(v["branches"][0], "main");
|
||||||
|
assert_eq!(v["branches"][1], "feature/x");
|
||||||
|
assert_eq!(v["current"], "main");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_branches_dto_serialises_null_current() {
|
||||||
|
let out = GitBranchesOutput {
|
||||||
|
branches: vec![],
|
||||||
|
current: None,
|
||||||
|
};
|
||||||
|
let dto = GitBranchesDto::from(out);
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
assert!(v["current"].is_null(), "current must be null when None");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GraphCommitDto serialisation & From impls
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn graph_commit_dto_serialises_camelcase() {
|
||||||
|
let commit = GraphCommit {
|
||||||
|
hash: "abc123".to_owned(),
|
||||||
|
summary: "feat: graph".to_owned(),
|
||||||
|
parents: vec!["deadbeef".to_owned()],
|
||||||
|
refs: vec!["main".to_owned(), "tag: v1.0".to_owned()],
|
||||||
|
author: "Alice".to_owned(),
|
||||||
|
timestamp: 1_700_000_000,
|
||||||
|
};
|
||||||
|
let dto = GraphCommitDto::from(commit);
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
assert_eq!(v["hash"], "abc123");
|
||||||
|
assert_eq!(v["summary"], "feat: graph");
|
||||||
|
assert_eq!(v["parents"][0], "deadbeef");
|
||||||
|
assert_eq!(v["refs"][0], "main");
|
||||||
|
assert_eq!(v["refs"][1], "tag: v1.0");
|
||||||
|
assert_eq!(v["author"], "Alice");
|
||||||
|
assert_eq!(v["timestamp"], 1_700_000_000_i64);
|
||||||
|
// No snake_case leaks for multi-word fields — none here but verify shape.
|
||||||
|
assert!(v.get("hash").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn graph_commit_list_dto_is_transparent_array() {
|
||||||
|
let out = GitGraphOutput {
|
||||||
|
commits: vec![
|
||||||
|
GraphCommit {
|
||||||
|
hash: "aaa".to_owned(),
|
||||||
|
summary: "first".to_owned(),
|
||||||
|
parents: vec![],
|
||||||
|
refs: vec!["main".to_owned()],
|
||||||
|
author: "Bob".to_owned(),
|
||||||
|
timestamp: 1000,
|
||||||
|
},
|
||||||
|
GraphCommit {
|
||||||
|
hash: "bbb".to_owned(),
|
||||||
|
summary: "second".to_owned(),
|
||||||
|
parents: vec!["aaa".to_owned()],
|
||||||
|
refs: vec![],
|
||||||
|
author: "Bob".to_owned(),
|
||||||
|
timestamp: 999,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let dto = GraphCommitListDto::from(out);
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
let arr = v.as_array().expect("transparent array");
|
||||||
|
assert_eq!(arr.len(), 2);
|
||||||
|
assert_eq!(arr[0]["hash"], "aaa");
|
||||||
|
assert_eq!(arr[0]["refs"][0], "main");
|
||||||
|
assert_eq!(arr[1]["hash"], "bbb");
|
||||||
|
assert!(arr[1]["parents"][0] == "aaa");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Request DTO deserialisation (camelCase)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_stage_request_deserialises_camelcase() {
|
||||||
|
let project_id = Uuid::from_u128(1).to_string();
|
||||||
|
let raw = json!({
|
||||||
|
"projectId": project_id,
|
||||||
|
"path": "src/lib.rs"
|
||||||
|
});
|
||||||
|
let dto: GitStageRequestDto = serde_json::from_value(raw).unwrap();
|
||||||
|
assert_eq!(dto.project_id, project_id);
|
||||||
|
assert_eq!(dto.path, "src/lib.rs");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_commit_request_deserialises_camelcase() {
|
||||||
|
let project_id = Uuid::from_u128(2).to_string();
|
||||||
|
let raw = json!({
|
||||||
|
"projectId": project_id,
|
||||||
|
"message": "feat: initial commit"
|
||||||
|
});
|
||||||
|
let dto: GitCommitRequestDto = serde_json::from_value(raw).unwrap();
|
||||||
|
assert_eq!(dto.project_id, project_id);
|
||||||
|
assert_eq!(dto.message, "feat: initial commit");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_checkout_request_deserialises_camelcase() {
|
||||||
|
let project_id = Uuid::from_u128(3).to_string();
|
||||||
|
let raw = json!({
|
||||||
|
"projectId": project_id,
|
||||||
|
"branch": "feature/awesome"
|
||||||
|
});
|
||||||
|
let dto: GitCheckoutRequestDto = serde_json::from_value(raw).unwrap();
|
||||||
|
assert_eq!(dto.project_id, project_id);
|
||||||
|
assert_eq!(dto.branch, "feature/awesome");
|
||||||
|
}
|
||||||
247
crates/app-tauri/tests/dto_layouts.rs
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
//! Tests for the layout-management (#4) and per-cell-agent (#3) DTOs:
|
||||||
|
//! camelCase wire shape, `parse_layout_id` error behaviour, `ListLayoutsDto` /
|
||||||
|
//! `CreateLayoutResultDto` / `DeleteLayoutResultDto` mappings, and
|
||||||
|
//! `setCellAgent` operation deserialisation.
|
||||||
|
|
||||||
|
use app_tauri_lib::dto::{
|
||||||
|
parse_layout_id, CreateLayoutRequestDto, CreateLayoutResultDto, DeleteLayoutRequestDto,
|
||||||
|
DeleteLayoutResultDto, LayoutInfoDto, LayoutOperationDto, ListLayoutsDto, RenameLayoutRequestDto,
|
||||||
|
SetActiveLayoutRequestDto,
|
||||||
|
};
|
||||||
|
use application::{
|
||||||
|
CreateLayoutOutput, DeleteLayoutOutput, LayoutInfo, LayoutKind, ListLayoutsOutput,
|
||||||
|
};
|
||||||
|
use domain::ids::{AgentId, LayoutId, NodeId};
|
||||||
|
use serde_json::json;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
fn lid(n: u128) -> LayoutId {
|
||||||
|
LayoutId::from_uuid(Uuid::from_u128(n))
|
||||||
|
}
|
||||||
|
fn nid(n: u128) -> NodeId {
|
||||||
|
NodeId::from_uuid(Uuid::from_u128(n))
|
||||||
|
}
|
||||||
|
fn aid(n: u128) -> AgentId {
|
||||||
|
AgentId::from_uuid(Uuid::from_u128(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// LayoutInfoDto
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn layout_info_dto_serialises_camelcase() {
|
||||||
|
let info = LayoutInfo {
|
||||||
|
id: lid(1),
|
||||||
|
name: "Default".to_owned(),
|
||||||
|
kind: LayoutKind::Terminal,
|
||||||
|
};
|
||||||
|
let dto = LayoutInfoDto::from(info);
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
assert_eq!(v["id"], lid(1).to_string());
|
||||||
|
assert_eq!(v["name"], "Default");
|
||||||
|
assert_eq!(v["kind"], "terminal");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn layout_info_dto_git_graph_kind() {
|
||||||
|
let info = LayoutInfo {
|
||||||
|
id: lid(2),
|
||||||
|
name: "Graph".to_owned(),
|
||||||
|
kind: LayoutKind::GitGraph,
|
||||||
|
};
|
||||||
|
let dto = LayoutInfoDto::from(info);
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
assert_eq!(v["kind"], "gitGraph");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ListLayoutsDto
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_layouts_dto_from_output() {
|
||||||
|
let out = ListLayoutsOutput {
|
||||||
|
layouts: vec![
|
||||||
|
LayoutInfo { 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),
|
||||||
|
};
|
||||||
|
let dto = ListLayoutsDto::from(out);
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
|
||||||
|
let layouts = v["layouts"].as_array().unwrap();
|
||||||
|
assert_eq!(layouts.len(), 2);
|
||||||
|
assert_eq!(layouts[0]["name"], "Default");
|
||||||
|
assert_eq!(layouts[0]["kind"], "terminal");
|
||||||
|
assert_eq!(layouts[1]["name"], "Backend");
|
||||||
|
assert_eq!(layouts[1]["kind"], "gitGraph");
|
||||||
|
assert_eq!(v["activeId"], lid(1).to_string(), "camelCase activeId");
|
||||||
|
assert!(v.get("active_id").is_none(), "no snake_case leak");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CreateLayoutResultDto
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_layout_result_dto_from_output() {
|
||||||
|
let out = CreateLayoutOutput { layout_id: lid(42) };
|
||||||
|
let dto = CreateLayoutResultDto::from(out);
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
assert_eq!(v["layoutId"], lid(42).to_string(), "camelCase layoutId");
|
||||||
|
assert!(v.get("layout_id").is_none(), "no snake_case leak");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DeleteLayoutResultDto
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_layout_result_dto_from_output() {
|
||||||
|
let out = DeleteLayoutOutput { active_id: lid(1) };
|
||||||
|
let dto = DeleteLayoutResultDto::from(out);
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
assert_eq!(v["activeId"], lid(1).to_string(), "camelCase activeId");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Request DTO deserialisation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_layout_request_deserialises_camelcase() {
|
||||||
|
let project_id = Uuid::from_u128(10).to_string();
|
||||||
|
let raw = json!({ "projectId": project_id, "name": "Backend" });
|
||||||
|
let dto: CreateLayoutRequestDto = serde_json::from_value(raw).unwrap();
|
||||||
|
assert_eq!(dto.project_id, project_id);
|
||||||
|
assert_eq!(dto.name, "Backend");
|
||||||
|
assert!(dto.kind.is_none(), "kind defaults to None when absent");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_layout_request_with_git_graph_kind() {
|
||||||
|
let project_id = Uuid::from_u128(11).to_string();
|
||||||
|
let raw = json!({ "projectId": project_id, "name": "Graph", "kind": "gitGraph" });
|
||||||
|
let dto: CreateLayoutRequestDto = serde_json::from_value(raw).unwrap();
|
||||||
|
assert_eq!(dto.kind.as_deref(), Some("gitGraph"));
|
||||||
|
let kind = dto.parse_kind().unwrap();
|
||||||
|
assert_eq!(kind, application::LayoutKind::GitGraph);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_layout_request_unknown_kind_is_invalid() {
|
||||||
|
let project_id = Uuid::from_u128(12).to_string();
|
||||||
|
let raw = json!({ "projectId": project_id, "name": "X", "kind": "unknown" });
|
||||||
|
let dto: CreateLayoutRequestDto = serde_json::from_value(raw).unwrap();
|
||||||
|
let err = dto.parse_kind().unwrap_err();
|
||||||
|
assert_eq!(err.code, "INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rename_layout_request_deserialises_camelcase() {
|
||||||
|
let raw = json!({
|
||||||
|
"projectId": Uuid::nil().to_string(),
|
||||||
|
"layoutId": Uuid::nil().to_string(),
|
||||||
|
"name": "Renamed"
|
||||||
|
});
|
||||||
|
let dto: RenameLayoutRequestDto = serde_json::from_value(raw).unwrap();
|
||||||
|
assert_eq!(dto.name, "Renamed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_layout_request_deserialises_camelcase() {
|
||||||
|
let project_id = Uuid::from_u128(1).to_string();
|
||||||
|
let layout_id = Uuid::from_u128(2).to_string();
|
||||||
|
let raw = json!({ "projectId": project_id, "layoutId": layout_id });
|
||||||
|
let dto: DeleteLayoutRequestDto = serde_json::from_value(raw).unwrap();
|
||||||
|
assert_eq!(dto.project_id, project_id);
|
||||||
|
assert_eq!(dto.layout_id, layout_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_active_layout_request_deserialises_camelcase() {
|
||||||
|
let raw = json!({
|
||||||
|
"projectId": Uuid::nil().to_string(),
|
||||||
|
"layoutId": Uuid::nil().to_string()
|
||||||
|
});
|
||||||
|
let _dto: SetActiveLayoutRequestDto = serde_json::from_value(raw).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// parse_layout_id
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_layout_id_accepts_uuid() {
|
||||||
|
let id = Uuid::from_u128(5);
|
||||||
|
assert_eq!(
|
||||||
|
parse_layout_id(&id.to_string()).unwrap(),
|
||||||
|
LayoutId::from_uuid(id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_layout_id_rejects_garbage() {
|
||||||
|
let err = parse_layout_id("not-a-uuid").expect_err("garbage rejected");
|
||||||
|
assert_eq!(err.code, "INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// setCellAgent operation deserialisation (#3)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_cell_agent_op_deserialises_with_agent() {
|
||||||
|
let target = nid(1);
|
||||||
|
let agent = aid(99);
|
||||||
|
let raw = json!({
|
||||||
|
"type": "setCellAgent",
|
||||||
|
"target": target.to_string(),
|
||||||
|
"agent": agent.to_string()
|
||||||
|
});
|
||||||
|
let dto: LayoutOperationDto = serde_json::from_value(raw).unwrap();
|
||||||
|
let op = dto.into_operation().unwrap();
|
||||||
|
match op {
|
||||||
|
application::LayoutOperation::SetCellAgent { target: t, agent: a } => {
|
||||||
|
assert_eq!(t, target);
|
||||||
|
assert_eq!(a, Some(agent));
|
||||||
|
}
|
||||||
|
_ => panic!("expected SetCellAgent"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_cell_agent_op_deserialises_with_null_agent() {
|
||||||
|
let target = nid(1);
|
||||||
|
let raw = json!({
|
||||||
|
"type": "setCellAgent",
|
||||||
|
"target": target.to_string(),
|
||||||
|
"agent": null
|
||||||
|
});
|
||||||
|
let dto: LayoutOperationDto = serde_json::from_value(raw).unwrap();
|
||||||
|
let op = dto.into_operation().unwrap();
|
||||||
|
match op {
|
||||||
|
application::LayoutOperation::SetCellAgent { target: t, agent: None } => {
|
||||||
|
assert_eq!(t, target);
|
||||||
|
}
|
||||||
|
_ => panic!("expected SetCellAgent with None agent"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_cell_agent_op_deserialises_with_absent_agent_defaults_to_none() {
|
||||||
|
let target = nid(2);
|
||||||
|
// omitting `agent` should default to None
|
||||||
|
let raw = json!({
|
||||||
|
"type": "setCellAgent",
|
||||||
|
"target": target.to_string()
|
||||||
|
});
|
||||||
|
let dto: LayoutOperationDto = serde_json::from_value(raw).unwrap();
|
||||||
|
let op = dto.into_operation().unwrap();
|
||||||
|
match op {
|
||||||
|
application::LayoutOperation::SetCellAgent { agent: None, .. } => {}
|
||||||
|
_ => panic!("expected SetCellAgent with None agent"),
|
||||||
|
}
|
||||||
|
}
|
||||||
133
crates/app-tauri/tests/dto_profiles.rs
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
//! L5 tests for the profile/first-run DTO (de)serialisation contract: camelCase
|
||||||
|
//! on the wire, embedded [`AgentProfile`] shape preserved, and `parse_profile_id`
|
||||||
|
//! error behaviour.
|
||||||
|
|
||||||
|
use app_tauri_lib::dto::{
|
||||||
|
parse_delete_profile, parse_profile_id, ConfigureProfilesRequestDto, DetectProfilesRequestDto,
|
||||||
|
DetectProfilesResponseDto, FirstRunStateDto, ProfileListDto, SaveProfileRequestDto,
|
||||||
|
};
|
||||||
|
use application::{
|
||||||
|
ConfigureProfilesInput, DetectProfilesInput, DetectProfilesOutput, FirstRunStateOutput,
|
||||||
|
ProfileAvailability, SaveProfileInput,
|
||||||
|
};
|
||||||
|
use domain::ids::ProfileId;
|
||||||
|
use domain::profile::{AgentProfile, ContextInjection};
|
||||||
|
use serde_json::json;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
fn profile(id: u128, name: &str, command: &str) -> AgentProfile {
|
||||||
|
AgentProfile::new(
|
||||||
|
ProfileId::from_uuid(Uuid::from_u128(id)),
|
||||||
|
name,
|
||||||
|
command,
|
||||||
|
Vec::new(),
|
||||||
|
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||||
|
Some(format!("{command} --version")),
|
||||||
|
"{projectRoot}",
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_list_dto_serialises_camelcase_profiles() {
|
||||||
|
let dto: ProfileListDto = vec![profile(1, "Claude", "claude")].into();
|
||||||
|
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]["command"], "claude");
|
||||||
|
assert_eq!(arr[0]["cwdTemplate"], "{projectRoot}");
|
||||||
|
assert_eq!(arr[0]["contextInjection"]["strategy"], "conventionFile");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_request_deserialises_candidates() {
|
||||||
|
let raw = json!({
|
||||||
|
"candidates": [{
|
||||||
|
"id": Uuid::from_u128(1).to_string(),
|
||||||
|
"name": "Claude",
|
||||||
|
"command": "claude",
|
||||||
|
"args": [],
|
||||||
|
"contextInjection": { "strategy": "stdin" },
|
||||||
|
"detect": "claude --version",
|
||||||
|
"cwdTemplate": "{projectRoot}"
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
let dto: DetectProfilesRequestDto = serde_json::from_value(raw).unwrap();
|
||||||
|
let input: DetectProfilesInput = dto.into();
|
||||||
|
assert_eq!(input.candidates.len(), 1);
|
||||||
|
assert_eq!(input.candidates[0].command, "claude");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_response_serialises_available_flag_camelcase() {
|
||||||
|
let out = DetectProfilesOutput {
|
||||||
|
results: vec![ProfileAvailability {
|
||||||
|
profile: profile(1, "Claude", "claude"),
|
||||||
|
available: true,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let dto: DetectProfilesResponseDto = out.into();
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
let arr = v.as_array().unwrap();
|
||||||
|
assert_eq!(arr[0]["available"], true);
|
||||||
|
assert_eq!(arr[0]["profile"]["command"], "claude");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_request_deserialises_profile() {
|
||||||
|
let raw = json!({
|
||||||
|
"profile": {
|
||||||
|
"id": Uuid::from_u128(2).to_string(),
|
||||||
|
"name": "Codex",
|
||||||
|
"command": "codex",
|
||||||
|
"args": ["--foo"],
|
||||||
|
"contextInjection": { "strategy": "conventionFile", "target": "AGENTS.md" },
|
||||||
|
"detect": null,
|
||||||
|
"cwdTemplate": ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let dto: SaveProfileRequestDto = serde_json::from_value(raw).unwrap();
|
||||||
|
let input: SaveProfileInput = dto.into();
|
||||||
|
assert_eq!(input.profile.command, "codex");
|
||||||
|
assert_eq!(input.profile.args, vec!["--foo"]);
|
||||||
|
assert!(input.profile.detect.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn configure_request_deserialises_profiles() {
|
||||||
|
let raw = json!({ "profiles": [] });
|
||||||
|
let dto: ConfigureProfilesRequestDto = serde_json::from_value(raw).unwrap();
|
||||||
|
let input: ConfigureProfilesInput = dto.into();
|
||||||
|
assert!(input.profiles.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_run_state_dto_serialises_camelcase() {
|
||||||
|
let out = FirstRunStateOutput {
|
||||||
|
is_first_run: true,
|
||||||
|
reference_profiles: vec![profile(1, "Claude", "claude")],
|
||||||
|
};
|
||||||
|
let dto: FirstRunStateDto = out.into();
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
assert_eq!(v["isFirstRun"], true);
|
||||||
|
assert!(v.get("is_first_run").is_none(), "no snake_case leak");
|
||||||
|
assert_eq!(v["referenceProfiles"][0]["command"], "claude");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_profile_id_accepts_uuid_and_rejects_garbage() {
|
||||||
|
let id = Uuid::from_u128(7);
|
||||||
|
assert_eq!(
|
||||||
|
parse_profile_id(&id.to_string()).unwrap(),
|
||||||
|
ProfileId::from_uuid(id)
|
||||||
|
);
|
||||||
|
let err = parse_profile_id("not-a-uuid").expect_err("garbage rejected");
|
||||||
|
assert_eq!(err.code, "INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_delete_profile_builds_input() {
|
||||||
|
let id = Uuid::from_u128(9);
|
||||||
|
let input = parse_delete_profile(&id.to_string()).unwrap();
|
||||||
|
assert_eq!(input.id, ProfileId::from_uuid(id));
|
||||||
|
}
|
||||||
275
crates/app-tauri/tests/dto_templates.rs
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
//! L7 tests for template & sync DTO (de)serialisation contract: camelCase on
|
||||||
|
//! the wire, `TemplateDto`/`TemplateListDto` shapes, `AgentDriftDto`/
|
||||||
|
//! `AgentDriftListDto`, `SyncResultDto`, request DTO deserialisation, and
|
||||||
|
//! `parse_template_id` error behaviour. No Tauri runtime required.
|
||||||
|
|
||||||
|
use app_tauri_lib::dto::{
|
||||||
|
parse_template_id, AgentDriftDto, AgentDriftListDto, CreateAgentFromTemplateRequestDto,
|
||||||
|
CreateTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
|
||||||
|
UpdateTemplateRequestDto,
|
||||||
|
};
|
||||||
|
use application::{
|
||||||
|
AgentDrift, CreateTemplateOutput, DetectAgentDriftOutput, ListTemplatesOutput,
|
||||||
|
SyncAgentWithTemplateOutput, UpdateTemplateOutput,
|
||||||
|
};
|
||||||
|
use domain::ids::{AgentId, ProfileId, TemplateId};
|
||||||
|
use domain::markdown::MarkdownDoc;
|
||||||
|
use domain::template::{AgentTemplate, TemplateVersion};
|
||||||
|
use serde_json::json;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn make_template(template_uuid: u128, profile_uuid: u128) -> AgentTemplate {
|
||||||
|
AgentTemplate::new(
|
||||||
|
TemplateId::from_uuid(Uuid::from_u128(template_uuid)),
|
||||||
|
"My Template",
|
||||||
|
MarkdownDoc::new("# Hello".to_owned()),
|
||||||
|
ProfileId::from_uuid(Uuid::from_u128(profile_uuid)),
|
||||||
|
)
|
||||||
|
.expect("valid template")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TemplateDto serialisation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn template_dto_serialises_camelcase() {
|
||||||
|
let tmpl = make_template(1, 2);
|
||||||
|
let dto = TemplateDto(tmpl.clone());
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(v["id"], tmpl.id.to_string());
|
||||||
|
assert_eq!(v["name"], "My Template");
|
||||||
|
// contentMd is the camelCase field on AgentTemplate; MarkdownDoc is transparent → plain string
|
||||||
|
assert_eq!(v["contentMd"], "# Hello");
|
||||||
|
// version is a transparent number
|
||||||
|
assert_eq!(v["version"], 1u64);
|
||||||
|
assert_eq!(
|
||||||
|
v["defaultProfileId"],
|
||||||
|
ProfileId::from_uuid(Uuid::from_u128(2)).to_string()
|
||||||
|
);
|
||||||
|
// no snake_case leak
|
||||||
|
assert!(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]
|
||||||
|
fn template_dto_version_is_number() {
|
||||||
|
let tmpl = make_template(3, 4);
|
||||||
|
let dto = TemplateDto(tmpl);
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
assert!(v["version"].is_number(), "version should be a JSON number");
|
||||||
|
assert_eq!(v["version"].as_u64().unwrap(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TemplateListDto
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn template_list_dto_is_transparent_array() {
|
||||||
|
let out = ListTemplatesOutput {
|
||||||
|
templates: vec![make_template(1, 2), make_template(3, 4)],
|
||||||
|
};
|
||||||
|
let dto = TemplateListDto::from(out);
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
let arr = v.as_array().expect("transparent array");
|
||||||
|
assert_eq!(arr.len(), 2);
|
||||||
|
assert_eq!(arr[0]["name"], "My Template");
|
||||||
|
assert_eq!(arr[1]["name"], "My Template");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn template_list_dto_empty() {
|
||||||
|
let out = ListTemplatesOutput { templates: vec![] };
|
||||||
|
let dto = TemplateListDto::from(out);
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
assert!(v.as_array().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// From<CreateTemplateOutput> / From<UpdateTemplateOutput>
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_template_output_maps_to_template_dto() {
|
||||||
|
let tmpl = make_template(5, 6);
|
||||||
|
let out = CreateTemplateOutput { template: tmpl.clone() };
|
||||||
|
let dto = TemplateDto::from(out);
|
||||||
|
assert_eq!(dto.0.id, tmpl.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_template_output_maps_to_template_dto() {
|
||||||
|
let tmpl = make_template(7, 8);
|
||||||
|
let bumped = tmpl.with_updated_content(MarkdownDoc::new("# Updated".to_owned()));
|
||||||
|
let out = UpdateTemplateOutput { template: bumped.clone() };
|
||||||
|
let dto = TemplateDto::from(out);
|
||||||
|
assert_eq!(dto.0.version, TemplateVersion(2));
|
||||||
|
assert_eq!(dto.0.id, bumped.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AgentDriftDto / AgentDriftListDto serialisation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_drift_dto_serialises_camelcase() {
|
||||||
|
let agent_id = AgentId::from_uuid(Uuid::from_u128(10));
|
||||||
|
let drift = AgentDrift {
|
||||||
|
agent_id,
|
||||||
|
from: TemplateVersion(1),
|
||||||
|
to: TemplateVersion(3),
|
||||||
|
};
|
||||||
|
let dto = AgentDriftDto::from(drift);
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(v["agentId"], agent_id.to_string());
|
||||||
|
assert_eq!(v["from"], 1u64);
|
||||||
|
assert_eq!(v["to"], 3u64);
|
||||||
|
// no snake_case leak
|
||||||
|
assert!(v.get("agent_id").is_none(), "no snake_case leak for agentId");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_drift_list_dto_is_transparent_array() {
|
||||||
|
let agent_id = AgentId::from_uuid(Uuid::from_u128(11));
|
||||||
|
let out = DetectAgentDriftOutput {
|
||||||
|
drifts: vec![AgentDrift {
|
||||||
|
agent_id,
|
||||||
|
from: TemplateVersion(2),
|
||||||
|
to: TemplateVersion(5),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let dto = AgentDriftListDto::from(out);
|
||||||
|
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]["from"], 2u64);
|
||||||
|
assert_eq!(arr[0]["to"], 5u64);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_drift_list_dto_empty() {
|
||||||
|
let out = DetectAgentDriftOutput { drifts: vec![] };
|
||||||
|
let dto = AgentDriftListDto::from(out);
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
assert!(v.as_array().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SyncResultDto
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_result_dto_synced_with_version() {
|
||||||
|
let out = SyncAgentWithTemplateOutput {
|
||||||
|
synced: true,
|
||||||
|
version: Some(TemplateVersion(4)),
|
||||||
|
};
|
||||||
|
let dto = SyncResultDto::from(out);
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(v["synced"], true);
|
||||||
|
assert_eq!(v["version"], 4u64);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_result_dto_not_synced_version_null() {
|
||||||
|
let out = SyncAgentWithTemplateOutput {
|
||||||
|
synced: false,
|
||||||
|
version: None,
|
||||||
|
};
|
||||||
|
let dto = SyncResultDto::from(out);
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(v["synced"], false);
|
||||||
|
assert!(v["version"].is_null(), "version should be null when None");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Request DTO deserialisation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_template_request_deserialises_camelcase() {
|
||||||
|
let profile_id = Uuid::from_u128(20).to_string();
|
||||||
|
let raw = json!({
|
||||||
|
"name": "Backend Template",
|
||||||
|
"content": "# My context",
|
||||||
|
"defaultProfileId": profile_id
|
||||||
|
});
|
||||||
|
let dto: CreateTemplateRequestDto = serde_json::from_value(raw).unwrap();
|
||||||
|
assert_eq!(dto.name, "Backend Template");
|
||||||
|
assert_eq!(dto.content, "# My context");
|
||||||
|
assert_eq!(dto.default_profile_id, profile_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_template_request_deserialises_camelcase() {
|
||||||
|
let template_id = Uuid::from_u128(30).to_string();
|
||||||
|
let raw = json!({
|
||||||
|
"templateId": template_id,
|
||||||
|
"content": "# Updated content"
|
||||||
|
});
|
||||||
|
let dto: UpdateTemplateRequestDto = serde_json::from_value(raw).unwrap();
|
||||||
|
assert_eq!(dto.template_id, template_id);
|
||||||
|
assert_eq!(dto.content, "# Updated content");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_agent_from_template_request_deserialises_camelcase() {
|
||||||
|
let project_id = Uuid::from_u128(40).to_string();
|
||||||
|
let template_id = Uuid::from_u128(41).to_string();
|
||||||
|
let raw = json!({
|
||||||
|
"projectId": project_id,
|
||||||
|
"templateId": template_id,
|
||||||
|
"name": "My Agent",
|
||||||
|
"synchronized": true
|
||||||
|
});
|
||||||
|
let dto: CreateAgentFromTemplateRequestDto = serde_json::from_value(raw).unwrap();
|
||||||
|
assert_eq!(dto.project_id, project_id);
|
||||||
|
assert_eq!(dto.template_id, template_id);
|
||||||
|
assert_eq!(dto.name.as_deref(), Some("My Agent"));
|
||||||
|
assert!(dto.synchronized);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_agent_from_template_request_name_defaults_to_none() {
|
||||||
|
let raw = json!({
|
||||||
|
"projectId": Uuid::nil().to_string(),
|
||||||
|
"templateId": Uuid::nil().to_string(),
|
||||||
|
"synchronized": false
|
||||||
|
});
|
||||||
|
let dto: CreateAgentFromTemplateRequestDto = serde_json::from_value(raw).unwrap();
|
||||||
|
assert!(dto.name.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// parse_template_id
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_template_id_accepts_uuid() {
|
||||||
|
let id = Uuid::from_u128(99);
|
||||||
|
assert_eq!(
|
||||||
|
parse_template_id(&id.to_string()).unwrap(),
|
||||||
|
TemplateId::from_uuid(id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_template_id_rejects_garbage() {
|
||||||
|
let err = parse_template_id("INVALID").expect_err("garbage rejected");
|
||||||
|
assert_eq!(err.code, "INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_template_id_rejects_empty() {
|
||||||
|
let err = parse_template_id("").expect_err("empty string rejected");
|
||||||
|
assert_eq!(err.code, "INVALID");
|
||||||
|
}
|
||||||
28
crates/app-tauri/tests/dto_window.rs
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
//! L10 DTO tests: `move_tab_to_new_window` result mapping + tab-id parsing.
|
||||||
|
|
||||||
|
use app_tauri_lib::dto::{parse_tab_id, MoveTabResultDto};
|
||||||
|
use application::MoveTabToNewWindowOutput;
|
||||||
|
use domain::ids::WindowId;
|
||||||
|
use domain::layout::Workspace;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_tab_result_serializes_new_window_id_camel_case() {
|
||||||
|
let out = MoveTabToNewWindowOutput {
|
||||||
|
new_window_id: WindowId::from_uuid(Uuid::from_u128(42)),
|
||||||
|
workspace: Workspace::default(),
|
||||||
|
};
|
||||||
|
let dto = MoveTabResultDto::from(out);
|
||||||
|
let json = serde_json::to_string(&dto).unwrap();
|
||||||
|
assert!(json.contains("\"newWindowId\""), "json was {json}");
|
||||||
|
assert!(!json.contains("new_window_id"), "no snake_case leak: {json}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_tab_id_accepts_uuid_and_rejects_garbage() {
|
||||||
|
let uuid = Uuid::from_u128(7).to_string();
|
||||||
|
assert!(parse_tab_id(&uuid).is_ok());
|
||||||
|
|
||||||
|
let err = parse_tab_id("not-a-uuid").unwrap_err();
|
||||||
|
assert_eq!(err.code, "INVALID");
|
||||||
|
}
|
||||||
90
crates/app-tauri/tests/pty_bridge.rs
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
//! L1 tests for [`PtyBridge`] — the PTY↔Channel registry — exercised with a
|
||||||
|
//! real [`tauri::ipc::Channel`] built from a capturing closure (no Tauri runtime
|
||||||
|
//! and no real PTY needed).
|
||||||
|
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use tauri::ipc::{Channel, InvokeResponseBody};
|
||||||
|
|
||||||
|
use app_tauri_lib::pty::PtyBridge;
|
||||||
|
use domain::ids::SessionId;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Builds a `Channel<Vec<u8>>` whose sent chunks are recorded into `sink`.
|
||||||
|
///
|
||||||
|
/// `Vec<u8>` is `Serialize`, so chunks arrive as a JSON array string in an
|
||||||
|
/// `InvokeResponseBody::Json`; we parse them back to bytes for assertions.
|
||||||
|
fn capturing_channel(sink: Arc<Mutex<Vec<Vec<u8>>>>) -> Channel<Vec<u8>> {
|
||||||
|
Channel::new(move |body: InvokeResponseBody| {
|
||||||
|
let bytes: Vec<u8> = match body {
|
||||||
|
InvokeResponseBody::Json(s) => serde_json::from_str(&s).unwrap(),
|
||||||
|
InvokeResponseBody::Raw(b) => b,
|
||||||
|
};
|
||||||
|
sink.lock().unwrap().push(bytes);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sid() -> SessionId {
|
||||||
|
SessionId::from_uuid(Uuid::new_v4())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn register_increases_active_sessions() {
|
||||||
|
let bridge = PtyBridge::new();
|
||||||
|
assert_eq!(bridge.active_sessions(), 0);
|
||||||
|
|
||||||
|
let sink = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
bridge.register(sid(), capturing_channel(sink));
|
||||||
|
assert_eq!(bridge.active_sessions(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn send_output_delivers_bytes_to_registered_channel() {
|
||||||
|
let bridge = PtyBridge::new();
|
||||||
|
let session = sid();
|
||||||
|
let sink = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
bridge.register(session, capturing_channel(Arc::clone(&sink)));
|
||||||
|
|
||||||
|
let delivered = bridge.send_output(&session, vec![104, 105]);
|
||||||
|
assert!(delivered, "send_output should return true for a live session");
|
||||||
|
|
||||||
|
let captured = sink.lock().unwrap();
|
||||||
|
assert_eq!(captured.as_slice(), &[vec![104, 105]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn send_output_to_unknown_session_returns_false() {
|
||||||
|
let bridge = PtyBridge::new();
|
||||||
|
assert!(!bridge.send_output(&sid(), vec![0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unregister_removes_session_and_stops_delivery() {
|
||||||
|
let bridge = PtyBridge::new();
|
||||||
|
let session = sid();
|
||||||
|
let sink = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
bridge.register(session, capturing_channel(Arc::clone(&sink)));
|
||||||
|
assert_eq!(bridge.active_sessions(), 1);
|
||||||
|
|
||||||
|
bridge.unregister(&session);
|
||||||
|
assert_eq!(bridge.active_sessions(), 0);
|
||||||
|
assert!(!bridge.send_output(&session, vec![1]));
|
||||||
|
assert!(sink.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn register_same_session_twice_replaces_channel() {
|
||||||
|
let bridge = PtyBridge::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)));
|
||||||
|
assert_eq!(bridge.active_sessions(), 1, "same id is replaced, not added");
|
||||||
|
|
||||||
|
bridge.send_output(&session, vec![9]);
|
||||||
|
assert!(first.lock().unwrap().is_empty(), "old channel no longer used");
|
||||||
|
assert_eq!(second.lock().unwrap().as_slice(), &[vec![9]]);
|
||||||
|
}
|
||||||
19
crates/application/Cargo.toml
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "application"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
description = "IdeA — application layer: use cases, DTOs, AppError. Depends only on domain ports."
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
# `v5` derives stable reference-profile ids from a fixed namespace (catalogue).
|
||||||
|
uuid = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { workspace = true }
|
||||||
93
crates/application/src/agent/catalogue.rs
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
//! Reference profile **catalogue** — the pre-filled, *editable* profiles offered
|
||||||
|
//! by the first-run wizard (CONTEXT §9, ARCHITECTURE §6 `ConfigureProfiles`).
|
||||||
|
//!
|
||||||
|
//! These are **data, not domain code**: the catalogue lives in the application
|
||||||
|
//! layer (a product decision about *which* AIs to suggest), built from the
|
||||||
|
//! domain's validating constructors. Nothing is imposed — the user picks, edits
|
||||||
|
//! the pre-filled commands, and may add custom profiles. The single
|
||||||
|
//! [`domain::ports::AgentRuntime`] adapter consumes whatever profiles result.
|
||||||
|
//!
|
||||||
|
//! Reference set (CONTEXT §9):
|
||||||
|
//! - **Claude Code** — `claude`, context via `CLAUDE.md` (convention file),
|
||||||
|
//! - **OpenAI Codex CLI** — `codex`, context via `AGENTS.md`,
|
||||||
|
//! - **Gemini CLI** — `gemini`, context via `GEMINI.md`,
|
||||||
|
//! - **Aider** — `aider`, context passed as an argument (`--message-file {path}`).
|
||||||
|
//!
|
||||||
|
//! The ids are **stable, deterministic UUIDs** (derived from a fixed namespace)
|
||||||
|
//! so re-deriving the catalogue yields the same id for "claude" every time,
|
||||||
|
//! making the reference profiles addressable across runs without a registry.
|
||||||
|
|
||||||
|
use domain::ids::ProfileId;
|
||||||
|
use domain::profile::{AgentProfile, ContextInjection};
|
||||||
|
|
||||||
|
/// A fixed UUID namespace used to derive stable ids for reference profiles.
|
||||||
|
/// (Random-looking but constant; only its stability matters.)
|
||||||
|
const REFERENCE_NAMESPACE: uuid::Uuid = uuid::uuid!("6f9b1d2a-7c34-4e58-9a1b-2c3d4e5f6a7b");
|
||||||
|
|
||||||
|
/// Derives a stable [`ProfileId`] for a reference profile from its slug.
|
||||||
|
#[must_use]
|
||||||
|
fn reference_id(slug: &str) -> ProfileId {
|
||||||
|
ProfileId::from_uuid(uuid::Uuid::new_v5(&REFERENCE_NAMESPACE, slug.as_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the stable id a reference profile slug maps to (exposed for tests and
|
||||||
|
/// callers that need to address a reference profile).
|
||||||
|
#[must_use]
|
||||||
|
pub fn reference_profile_id(slug: &str) -> ProfileId {
|
||||||
|
reference_id(slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the pre-filled, editable reference profiles (CONTEXT §9).
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
/// Never in practice: every literal here satisfies the domain invariants, so the
|
||||||
|
/// constructors cannot fail; the `expect`s document that.
|
||||||
|
#[must_use]
|
||||||
|
pub fn reference_profiles() -> Vec<AgentProfile> {
|
||||||
|
vec![
|
||||||
|
AgentProfile::new(
|
||||||
|
reference_id("claude"),
|
||||||
|
"Claude Code",
|
||||||
|
"claude",
|
||||||
|
Vec::new(),
|
||||||
|
ContextInjection::convention_file("CLAUDE.md")
|
||||||
|
.expect("CLAUDE.md is a valid convention target"),
|
||||||
|
Some("claude --version".to_owned()),
|
||||||
|
"{projectRoot}",
|
||||||
|
)
|
||||||
|
.expect("claude reference profile is valid"),
|
||||||
|
AgentProfile::new(
|
||||||
|
reference_id("codex"),
|
||||||
|
"OpenAI Codex CLI",
|
||||||
|
"codex",
|
||||||
|
Vec::new(),
|
||||||
|
ContextInjection::convention_file("AGENTS.md")
|
||||||
|
.expect("AGENTS.md is a valid convention target"),
|
||||||
|
Some("codex --version".to_owned()),
|
||||||
|
"{projectRoot}",
|
||||||
|
)
|
||||||
|
.expect("codex reference profile is valid"),
|
||||||
|
AgentProfile::new(
|
||||||
|
reference_id("gemini"),
|
||||||
|
"Gemini CLI",
|
||||||
|
"gemini",
|
||||||
|
Vec::new(),
|
||||||
|
ContextInjection::convention_file("GEMINI.md")
|
||||||
|
.expect("GEMINI.md is a valid convention target"),
|
||||||
|
Some("gemini --version".to_owned()),
|
||||||
|
"{projectRoot}",
|
||||||
|
)
|
||||||
|
.expect("gemini reference profile is valid"),
|
||||||
|
AgentProfile::new(
|
||||||
|
reference_id("aider"),
|
||||||
|
"Aider",
|
||||||
|
"aider",
|
||||||
|
Vec::new(),
|
||||||
|
ContextInjection::flag("--message-file {path}")
|
||||||
|
.expect("aider flag template is non-empty"),
|
||||||
|
Some("aider --version".to_owned()),
|
||||||
|
"{projectRoot}",
|
||||||
|
)
|
||||||
|
.expect("aider reference profile is valid"),
|
||||||
|
]
|
||||||
|
}
|
||||||
538
crates/application/src/agent/lifecycle.rs
Normal file
@ -0,0 +1,538 @@
|
|||||||
|
//! Agent lifecycle use cases (ARCHITECTURE §6, L6).
|
||||||
|
//!
|
||||||
|
//! These own the *project-agent* side (distinct from the profile side in
|
||||||
|
//! [`super::usecases`]): creating agents and their `.md` contexts under
|
||||||
|
//! `.ideai/`, listing/reading/updating them, and — the centrepiece —
|
||||||
|
//! [`LaunchAgent`], which resolves the agent's profile + context, applies the
|
||||||
|
//! profile's context-injection strategy, opens a PTY cell at the right `cwd` and
|
||||||
|
//! spawns the CLI.
|
||||||
|
//!
|
||||||
|
//! Every use case talks **only to ports** ([`AgentContextStore`], [`ProfileStore`],
|
||||||
|
//! [`AgentRuntime`], [`PtyPort`], [`FileSystem`], [`EventBus`]); none knows about
|
||||||
|
//! a concrete adapter or Tauri.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::ports::{
|
||||||
|
AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, PreparedContext,
|
||||||
|
ProfileStore, PtyPort, RemotePath, SpawnSpec,
|
||||||
|
};
|
||||||
|
use domain::{
|
||||||
|
Agent, AgentId, AgentManifest, AgentOrigin, DomainEvent, ManifestEntry, MarkdownDoc, NodeId,
|
||||||
|
Project, ProfileId, ProjectPath, PtySize, SessionKind, SessionStatus, TerminalSession,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::terminal::TerminalSessions;
|
||||||
|
|
||||||
|
/// Directory (relative to `.ideai/`) under which agent contexts are written.
|
||||||
|
const AGENTS_SUBDIR: &str = "agents";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CreateAgentFromScratch
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Input for [`CreateAgentFromScratch::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CreateAgentInput {
|
||||||
|
/// The project that owns the agent.
|
||||||
|
pub project: Project,
|
||||||
|
/// Display name of the agent.
|
||||||
|
pub name: String,
|
||||||
|
/// Runtime profile the agent launches with.
|
||||||
|
pub profile_id: ProfileId,
|
||||||
|
/// Initial `.md` content (empty when `None`).
|
||||||
|
pub initial_content: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Output of [`CreateAgentFromScratch::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CreateAgentOutput {
|
||||||
|
/// The freshly-created agent.
|
||||||
|
pub agent: Agent,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a project agent from scratch: mints an id, derives a unique `.md`
|
||||||
|
/// path, records the manifest entry, then writes the (possibly empty) context.
|
||||||
|
pub struct CreateAgentFromScratch {
|
||||||
|
contexts: Arc<dyn AgentContextStore>,
|
||||||
|
ids: Arc<dyn domain::ports::IdGenerator>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CreateAgentFromScratch {
|
||||||
|
/// Builds the use case from its injected ports.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
contexts: Arc<dyn AgentContextStore>,
|
||||||
|
ids: Arc<dyn domain::ports::IdGenerator>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
contexts,
|
||||||
|
ids,
|
||||||
|
events,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes creation.
|
||||||
|
///
|
||||||
|
/// Ordering matters: the manifest entry is persisted **before** the context
|
||||||
|
/// is written, because [`AgentContextStore::write_context`] resolves the
|
||||||
|
/// on-disk path from the manifest.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// - [`AppError::Invalid`] if the name is empty or the manifest would become
|
||||||
|
/// inconsistent,
|
||||||
|
/// - [`AppError::Store`] on persistence failure.
|
||||||
|
pub async fn execute(&self, input: CreateAgentInput) -> Result<CreateAgentOutput, AppError> {
|
||||||
|
let manifest = self.contexts.load_manifest(&input.project).await?;
|
||||||
|
|
||||||
|
let id = AgentId::from_uuid(self.ids.new_uuid());
|
||||||
|
let md_path = unique_md_path(&input.name, &manifest);
|
||||||
|
|
||||||
|
let agent = Agent::new(
|
||||||
|
id,
|
||||||
|
input.name,
|
||||||
|
md_path,
|
||||||
|
input.profile_id,
|
||||||
|
AgentOrigin::Scratch,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||||
|
|
||||||
|
// Append the entry and re-validate the whole manifest (unique md_paths).
|
||||||
|
let mut entries = manifest.entries;
|
||||||
|
entries.push(ManifestEntry::from_agent(&agent));
|
||||||
|
let manifest = AgentManifest::new(manifest.version, entries)
|
||||||
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||||
|
self.contexts.save_manifest(&input.project, &manifest).await?;
|
||||||
|
|
||||||
|
// Now the path resolves: write the initial context.
|
||||||
|
let md = MarkdownDoc::new(input.initial_content.unwrap_or_default());
|
||||||
|
self.contexts
|
||||||
|
.write_context(&input.project, &agent.id, &md)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
self.events.publish(DomainEvent::LayoutChanged {
|
||||||
|
project_id: input.project.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(CreateAgentOutput { agent })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ListAgents
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Input for [`ListAgents::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ListAgentsInput {
|
||||||
|
/// The project whose agents to list.
|
||||||
|
pub project: Project,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Output of [`ListAgents::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ListAgentsOutput {
|
||||||
|
/// The project's agents (reconstructed from the manifest).
|
||||||
|
pub agents: Vec<Agent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists a project's agents by reconstructing them from the manifest entries.
|
||||||
|
pub struct ListAgents {
|
||||||
|
contexts: Arc<dyn AgentContextStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListAgents {
|
||||||
|
/// Builds the use case from the [`AgentContextStore`] port.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(contexts: Arc<dyn AgentContextStore>) -> Self {
|
||||||
|
Self { contexts }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads the manifest and folds each entry back into an [`Agent`].
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// - [`AppError::Store`] on persistence failure,
|
||||||
|
/// - [`AppError::Invalid`] if a persisted entry violates an agent invariant.
|
||||||
|
pub async fn execute(&self, input: ListAgentsInput) -> Result<ListAgentsOutput, AppError> {
|
||||||
|
let manifest = self.contexts.load_manifest(&input.project).await?;
|
||||||
|
let agents = manifest
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.map(|e| e.to_agent().map_err(|err| AppError::Invalid(err.to_string())))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
Ok(ListAgentsOutput { agents })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ReadAgentContext / UpdateAgentContext
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Input for [`ReadAgentContext::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ReadAgentContextInput {
|
||||||
|
/// The owning project.
|
||||||
|
pub project: Project,
|
||||||
|
/// The agent whose `.md` to read.
|
||||||
|
pub agent_id: AgentId,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Output of [`ReadAgentContext::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ReadAgentContextOutput {
|
||||||
|
/// The agent's Markdown context.
|
||||||
|
pub content: MarkdownDoc,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads an agent's `.md` context.
|
||||||
|
pub struct ReadAgentContext {
|
||||||
|
contexts: Arc<dyn AgentContextStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReadAgentContext {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(contexts: Arc<dyn AgentContextStore>) -> Self {
|
||||||
|
Self { contexts }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the context.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// - [`AppError::NotFound`] if the agent (or its `.md`) is unknown,
|
||||||
|
/// - [`AppError::Store`] on persistence failure.
|
||||||
|
pub async fn execute(
|
||||||
|
&self,
|
||||||
|
input: ReadAgentContextInput,
|
||||||
|
) -> Result<ReadAgentContextOutput, AppError> {
|
||||||
|
let content = self
|
||||||
|
.contexts
|
||||||
|
.read_context(&input.project, &input.agent_id)
|
||||||
|
.await?;
|
||||||
|
Ok(ReadAgentContextOutput { content })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Input for [`UpdateAgentContext::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct UpdateAgentContextInput {
|
||||||
|
/// The owning project.
|
||||||
|
pub project: Project,
|
||||||
|
/// The agent whose `.md` to overwrite.
|
||||||
|
pub agent_id: AgentId,
|
||||||
|
/// New Markdown content.
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Overwrites an agent's `.md` context.
|
||||||
|
pub struct UpdateAgentContext {
|
||||||
|
contexts: Arc<dyn AgentContextStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpdateAgentContext {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(contexts: Arc<dyn AgentContextStore>) -> Self {
|
||||||
|
Self { contexts }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes the new context.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// - [`AppError::NotFound`] if the agent is unknown,
|
||||||
|
/// - [`AppError::Store`] on persistence failure.
|
||||||
|
pub async fn execute(&self, input: UpdateAgentContextInput) -> Result<(), AppError> {
|
||||||
|
let md = MarkdownDoc::new(input.content);
|
||||||
|
self.contexts
|
||||||
|
.write_context(&input.project, &input.agent_id, &md)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DeleteAgent
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Input for [`DeleteAgent::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct DeleteAgentInput {
|
||||||
|
/// The owning project.
|
||||||
|
pub project: Project,
|
||||||
|
/// The agent to remove.
|
||||||
|
pub agent_id: AgentId,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes an agent from the project manifest.
|
||||||
|
///
|
||||||
|
/// The orphaned `.md` file is left on disk: the [`FileSystem`] port exposes no
|
||||||
|
/// delete, and keeping the file is the safe default (the user may want to recover
|
||||||
|
/// the context). Re-creating an agent with the same name reuses a fresh path.
|
||||||
|
pub struct DeleteAgent {
|
||||||
|
contexts: Arc<dyn AgentContextStore>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeleteAgent {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(contexts: Arc<dyn AgentContextStore>, events: Arc<dyn EventBus>) -> Self {
|
||||||
|
Self { contexts, events }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops the manifest entry for the agent.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// - [`AppError::NotFound`] if the agent is not in the manifest,
|
||||||
|
/// - [`AppError::Store`] on persistence failure.
|
||||||
|
pub async fn execute(&self, input: DeleteAgentInput) -> Result<(), AppError> {
|
||||||
|
let manifest = self.contexts.load_manifest(&input.project).await?;
|
||||||
|
let before = manifest.entries.len();
|
||||||
|
let entries: Vec<ManifestEntry> = manifest
|
||||||
|
.entries
|
||||||
|
.into_iter()
|
||||||
|
.filter(|e| e.agent_id != input.agent_id)
|
||||||
|
.collect();
|
||||||
|
if entries.len() == before {
|
||||||
|
return Err(AppError::NotFound(format!("agent {}", input.agent_id)));
|
||||||
|
}
|
||||||
|
let manifest = AgentManifest::new(manifest.version, entries)
|
||||||
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||||
|
self.contexts.save_manifest(&input.project, &manifest).await?;
|
||||||
|
self.events.publish(DomainEvent::LayoutChanged {
|
||||||
|
project_id: input.project.id,
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// LaunchAgent
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Input for [`LaunchAgent::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct LaunchAgentInput {
|
||||||
|
/// The owning project.
|
||||||
|
pub project: Project,
|
||||||
|
/// The agent to launch.
|
||||||
|
pub agent_id: AgentId,
|
||||||
|
/// Initial terminal height in rows.
|
||||||
|
pub rows: u16,
|
||||||
|
/// Initial terminal width in columns.
|
||||||
|
pub cols: u16,
|
||||||
|
/// The layout leaf hosting the session (a fresh node when `None`).
|
||||||
|
pub node_id: Option<NodeId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Output of [`LaunchAgent::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct LaunchAgentOutput {
|
||||||
|
/// The created agent terminal session.
|
||||||
|
pub session: TerminalSession,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Launches an agent: resolve profile + context, prepare the invocation, apply
|
||||||
|
/// the context-injection plan, open a PTY at the resolved `cwd`, spawn the CLI.
|
||||||
|
///
|
||||||
|
/// This is the orchestrating use case of L6 and therefore consumes several ports
|
||||||
|
/// — each only for the slice it needs (Interface Segregation): the context store
|
||||||
|
/// (agent `.md` + manifest), the profile store (resolve the runtime), the runtime
|
||||||
|
/// (build the [`SpawnSpec`]), the filesystem (materialise a `conventionFile`
|
||||||
|
/// context), and the PTY (spawn + optional stdin injection).
|
||||||
|
pub struct LaunchAgent {
|
||||||
|
contexts: Arc<dyn AgentContextStore>,
|
||||||
|
profiles: Arc<dyn ProfileStore>,
|
||||||
|
runtime: Arc<dyn AgentRuntime>,
|
||||||
|
fs: Arc<dyn FileSystem>,
|
||||||
|
pty: Arc<dyn PtyPort>,
|
||||||
|
sessions: Arc<TerminalSessions>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LaunchAgent {
|
||||||
|
/// Builds the use case from its injected ports.
|
||||||
|
#[must_use]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn new(
|
||||||
|
contexts: Arc<dyn AgentContextStore>,
|
||||||
|
profiles: Arc<dyn ProfileStore>,
|
||||||
|
runtime: Arc<dyn AgentRuntime>,
|
||||||
|
fs: Arc<dyn FileSystem>,
|
||||||
|
pty: Arc<dyn PtyPort>,
|
||||||
|
sessions: Arc<TerminalSessions>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
contexts,
|
||||||
|
profiles,
|
||||||
|
runtime,
|
||||||
|
fs,
|
||||||
|
pty,
|
||||||
|
sessions,
|
||||||
|
events,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes the launch.
|
||||||
|
///
|
||||||
|
/// Step order is contractually significant (and unit-tested): resolve the
|
||||||
|
/// agent + context, **`prepare_invocation`**, **apply the injection plan**
|
||||||
|
/// (write a `conventionFile` / set an env var), then **`pty.spawn`** at the
|
||||||
|
/// resolved `cwd`, and finally pipe the context on stdin for the `Stdin`
|
||||||
|
/// strategy.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// - [`AppError::NotFound`] if the agent or its profile is unknown,
|
||||||
|
/// - [`AppError::Invalid`] for a zero-sized terminal,
|
||||||
|
/// - [`AppError::Store`] / [`AppError::FileSystem`] / [`AppError::Process`] on
|
||||||
|
/// the respective port failures.
|
||||||
|
pub async fn execute(&self, input: LaunchAgentInput) -> Result<LaunchAgentOutput, AppError> {
|
||||||
|
let size =
|
||||||
|
PtySize::new(input.rows, input.cols).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||||
|
|
||||||
|
// 1. Resolve the agent from the manifest (name + profile + md_path).
|
||||||
|
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()))?;
|
||||||
|
|
||||||
|
// 2. Read its context and resolve its profile.
|
||||||
|
let content = self
|
||||||
|
.contexts
|
||||||
|
.read_context(&input.project, &agent.id)
|
||||||
|
.await?;
|
||||||
|
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))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 3. Prepare the invocation (pure): command + args + injection plan + cwd.
|
||||||
|
let prepared = PreparedContext {
|
||||||
|
content: content.clone(),
|
||||||
|
relative_path: agent.context_path.clone(),
|
||||||
|
};
|
||||||
|
let mut spec = self
|
||||||
|
.runtime
|
||||||
|
.prepare_invocation(&profile, &prepared, &input.project.root)?;
|
||||||
|
|
||||||
|
// 4. Apply the injection plan side effects *before* spawning.
|
||||||
|
self.apply_injection(&input.project, &agent.context_path, &content, &mut spec)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 5. Spawn the PTY at the resolved cwd; adopt its session id everywhere.
|
||||||
|
let handle = self.pty.spawn(spec.clone(), size).await?;
|
||||||
|
let session_id = handle.session_id;
|
||||||
|
|
||||||
|
// 6. For the Stdin strategy, pipe the context once the PTY is live.
|
||||||
|
if matches!(spec.context_plan, Some(ContextInjectionPlan::Stdin)) {
|
||||||
|
self.pty.write(&handle, content.as_str().as_bytes())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let node_id = input.node_id.unwrap_or_else(NodeId::new_random);
|
||||||
|
let mut session = TerminalSession::starting(
|
||||||
|
session_id,
|
||||||
|
node_id,
|
||||||
|
spec.cwd.clone(),
|
||||||
|
SessionKind::Agent {
|
||||||
|
agent_id: agent.id,
|
||||||
|
},
|
||||||
|
size,
|
||||||
|
);
|
||||||
|
session.status = SessionStatus::Running;
|
||||||
|
self.sessions.insert(handle, session.clone());
|
||||||
|
|
||||||
|
self.events.publish(DomainEvent::AgentLaunched {
|
||||||
|
agent_id: agent.id,
|
||||||
|
session_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(LaunchAgentOutput { session })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies the context-injection plan that must happen *before* spawn:
|
||||||
|
/// materialising a `conventionFile` context (write the `.md` to `<cwd>/target`)
|
||||||
|
/// or attaching the on-disk context path to an environment variable. `Args` is
|
||||||
|
/// already folded into the spec by the runtime; `Stdin` is handled post-spawn.
|
||||||
|
async fn apply_injection(
|
||||||
|
&self,
|
||||||
|
project: &Project,
|
||||||
|
context_rel_path: &str,
|
||||||
|
content: &MarkdownDoc,
|
||||||
|
spec: &mut SpawnSpec,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
match spec.context_plan.clone() {
|
||||||
|
Some(ContextInjectionPlan::File { target }) => {
|
||||||
|
// conventionFile spike (ARCHITECTURE §13.6): copy the context to the
|
||||||
|
// conventional file (e.g. CLAUDE.md), overwriting any existing one.
|
||||||
|
// A copy (not a symlink) is the portable choice — Windows symlinks
|
||||||
|
// need privileges and SFTP/WSL symlink semantics differ.
|
||||||
|
let path = RemotePath::new(join(&spec.cwd, &target));
|
||||||
|
self.fs.write(&path, content.as_str().as_bytes()).await?;
|
||||||
|
}
|
||||||
|
Some(ContextInjectionPlan::Env { var }) => {
|
||||||
|
// Hand the CLI the absolute path of the agent's `.md` (which lives at
|
||||||
|
// `<root>/.ideai/<context_rel_path>`) via the environment variable.
|
||||||
|
let abspath = join(&project.root, &format!(".ideai/{context_rel_path}"));
|
||||||
|
spec.env.push((var, abspath));
|
||||||
|
}
|
||||||
|
// Args were folded into spec.args by prepare_invocation; Stdin is
|
||||||
|
// applied after the PTY is live.
|
||||||
|
Some(ContextInjectionPlan::Args { .. }) | Some(ContextInjectionPlan::Stdin) | None => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds an absolute path string by joining a [`ProjectPath`] with a relative
|
||||||
|
/// segment using a POSIX separator.
|
||||||
|
fn join(base: &ProjectPath, rel: &str) -> String {
|
||||||
|
let b = base.as_str().trim_end_matches(['/', '\\']);
|
||||||
|
format!("{b}/{rel}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derives a unique, filesystem-safe `md_path` (`agents/<slug>.md`) for a new
|
||||||
|
/// agent, disambiguating against the manifest's existing paths with a numeric
|
||||||
|
/// suffix when needed. Shared with the template-driven agent creation (L7).
|
||||||
|
pub(crate) fn unique_md_path(name: &str, manifest: &AgentManifest) -> String {
|
||||||
|
let slug = slugify(name);
|
||||||
|
let base = if slug.is_empty() { "agent".to_owned() } else { slug };
|
||||||
|
let mut candidate = format!("{AGENTS_SUBDIR}/{base}.md");
|
||||||
|
let mut n = 2;
|
||||||
|
while manifest.entries.iter().any(|e| e.md_path == candidate) {
|
||||||
|
candidate = format!("{AGENTS_SUBDIR}/{base}-{n}.md");
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lowercases and slugifies a display name into a safe file stem
|
||||||
|
/// (`[a-z0-9-]`), collapsing runs of separators.
|
||||||
|
fn slugify(name: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(name.len());
|
||||||
|
let mut prev_dash = false;
|
||||||
|
for ch in name.trim().chars() {
|
||||||
|
if ch.is_ascii_alphanumeric() {
|
||||||
|
out.push(ch.to_ascii_lowercase());
|
||||||
|
prev_dash = false;
|
||||||
|
} else if !prev_dash {
|
||||||
|
out.push('-');
|
||||||
|
prev_dash = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.trim_matches('-').to_owned()
|
||||||
|
}
|
||||||
27
crates/application/src/agent/mod.rs
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
//! Agent-profile use cases & reference catalogue (ARCHITECTURE §6, L5).
|
||||||
|
//!
|
||||||
|
//! This module owns the *profile* side of the AI runtime: detecting which CLIs
|
||||||
|
//! are installed, persisting the chosen/edited/custom profiles, and exposing the
|
||||||
|
//! pre-filled reference catalogue that seeds the first-run wizard. It talks only
|
||||||
|
//! to the [`domain::ports::AgentRuntime`] and [`domain::ports::ProfileStore`]
|
||||||
|
//! ports. Launching an agent (PTY + injection) is L6.
|
||||||
|
|
||||||
|
mod catalogue;
|
||||||
|
mod lifecycle;
|
||||||
|
mod usecases;
|
||||||
|
|
||||||
|
pub(crate) use lifecycle::unique_md_path;
|
||||||
|
|
||||||
|
pub use catalogue::{reference_profile_id, reference_profiles};
|
||||||
|
pub use lifecycle::{
|
||||||
|
CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput,
|
||||||
|
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput,
|
||||||
|
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, UpdateAgentContext,
|
||||||
|
UpdateAgentContextInput,
|
||||||
|
};
|
||||||
|
pub use usecases::{
|
||||||
|
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfile,
|
||||||
|
DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState,
|
||||||
|
FirstRunStateOutput, ListProfiles, ListProfilesOutput, ProfileAvailability, ReferenceProfiles,
|
||||||
|
ReferenceProfilesOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
|
||||||
|
};
|
||||||
323
crates/application/src/agent/usecases.rs
Normal file
@ -0,0 +1,323 @@
|
|||||||
|
//! Profile use cases (ARCHITECTURE §6, L5). Each is a single-responsibility
|
||||||
|
//! struct carrying its ports as `Arc<dyn Port>` and exposing one `execute`.
|
||||||
|
//!
|
||||||
|
//! - [`DetectProfiles`] — probe a set of candidate profiles via [`AgentRuntime`]
|
||||||
|
//! and report which CLIs are installed (first-run availability ✓/✗).
|
||||||
|
//! - [`ListProfiles`] / [`SaveProfile`] / [`DeleteProfile`] — CRUD over the
|
||||||
|
//! persisted profiles through the [`ProfileStore`].
|
||||||
|
//! - [`ConfigureProfiles`] — persist a batch of chosen/edited/custom profiles
|
||||||
|
//! (closes the first-run wizard).
|
||||||
|
//! - [`ReferenceProfiles`] — expose the pre-filled, editable catalogue.
|
||||||
|
//! - [`FirstRunState`] — tell the UI whether the first-run wizard should show
|
||||||
|
//! (no `profiles.json` yet) and hand it the reference catalogue.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::ports::{AgentRuntime, ProfileStore};
|
||||||
|
use domain::profile::AgentProfile;
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
use super::catalogue::reference_profiles;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DetectProfiles
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Input for [`DetectProfiles::execute`]: the candidate profiles to probe.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct DetectProfilesInput {
|
||||||
|
/// Profiles whose `detect` command should be run.
|
||||||
|
pub candidates: Vec<AgentProfile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Availability of a single candidate after detection.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ProfileAvailability {
|
||||||
|
/// The probed profile.
|
||||||
|
pub profile: AgentProfile,
|
||||||
|
/// Whether its CLI was detected as installed (exit code 0).
|
||||||
|
pub available: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Output of [`DetectProfiles::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct DetectProfilesOutput {
|
||||||
|
/// One entry per candidate (same order), with its availability.
|
||||||
|
pub results: Vec<ProfileAvailability>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probes candidate profiles' detection commands and reports availability.
|
||||||
|
pub struct DetectProfiles {
|
||||||
|
runtime: Arc<dyn AgentRuntime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DetectProfiles {
|
||||||
|
/// Builds the use case from the [`AgentRuntime`] port. The runtime itself
|
||||||
|
/// holds the [`domain::ports::ProcessSpawner`] used for detection.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(runtime: Arc<dyn AgentRuntime>) -> Self {
|
||||||
|
Self { runtime }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs detection for each candidate. A detection *error* (e.g. the command
|
||||||
|
/// could not even be launched) is reported as `available: false`, not a
|
||||||
|
/// hard failure — the wizard just shows ✗ and the user can still keep the
|
||||||
|
/// profile.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Currently never returns `Err` (failures degrade to `available: false`);
|
||||||
|
/// the `Result` keeps the signature uniform with the other use cases.
|
||||||
|
pub async fn execute(
|
||||||
|
&self,
|
||||||
|
input: DetectProfilesInput,
|
||||||
|
) -> Result<DetectProfilesOutput, AppError> {
|
||||||
|
let results = input
|
||||||
|
.candidates
|
||||||
|
.into_iter()
|
||||||
|
.map(|profile| {
|
||||||
|
let available = self.runtime.detect(&profile).unwrap_or(false);
|
||||||
|
ProfileAvailability { profile, available }
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(DetectProfilesOutput { results })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ListProfiles
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Output of [`ListProfiles::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ListProfilesOutput {
|
||||||
|
/// All configured profiles.
|
||||||
|
pub profiles: Vec<AgentProfile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists the configured profiles from the store.
|
||||||
|
pub struct ListProfiles {
|
||||||
|
store: Arc<dyn ProfileStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListProfiles {
|
||||||
|
/// Builds the use case from the [`ProfileStore`] port.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(store: Arc<dyn ProfileStore>) -> Self {
|
||||||
|
Self { store }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists configured profiles.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`AppError::Store`] on persistence failure.
|
||||||
|
pub async fn execute(&self) -> Result<ListProfilesOutput, AppError> {
|
||||||
|
Ok(ListProfilesOutput {
|
||||||
|
profiles: self.store.list().await?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SaveProfile
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Input for [`SaveProfile::execute`]: the profile to upsert.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SaveProfileInput {
|
||||||
|
/// The profile to create or replace (by id).
|
||||||
|
pub profile: AgentProfile,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Output of [`SaveProfile::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SaveProfileOutput {
|
||||||
|
/// The saved profile (echoed back).
|
||||||
|
pub profile: AgentProfile,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persists (creates or replaces) a single profile.
|
||||||
|
pub struct SaveProfile {
|
||||||
|
store: Arc<dyn ProfileStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SaveProfile {
|
||||||
|
/// Builds the use case from the [`ProfileStore`] port.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(store: Arc<dyn ProfileStore>) -> Self {
|
||||||
|
Self { store }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves the profile.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`AppError::Store`] on persistence failure.
|
||||||
|
pub async fn execute(&self, input: SaveProfileInput) -> Result<SaveProfileOutput, AppError> {
|
||||||
|
self.store.save(&input.profile).await?;
|
||||||
|
Ok(SaveProfileOutput {
|
||||||
|
profile: input.profile,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DeleteProfile
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Input for [`DeleteProfile::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct DeleteProfileInput {
|
||||||
|
/// Id of the profile to delete.
|
||||||
|
pub id: domain::ids::ProfileId,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a profile by id.
|
||||||
|
pub struct DeleteProfile {
|
||||||
|
store: Arc<dyn ProfileStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeleteProfile {
|
||||||
|
/// Builds the use case from the [`ProfileStore`] port.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(store: Arc<dyn ProfileStore>) -> 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: DeleteProfileInput) -> Result<(), AppError> {
|
||||||
|
self.store.delete(input.id).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ConfigureProfiles
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Input for [`ConfigureProfiles::execute`]: the chosen/edited/custom profiles.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ConfigureProfilesInput {
|
||||||
|
/// All profiles the user decided to keep (closes the first run).
|
||||||
|
pub profiles: Vec<AgentProfile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Output of [`ConfigureProfiles::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ConfigureProfilesOutput {
|
||||||
|
/// The persisted profiles.
|
||||||
|
pub profiles: Vec<AgentProfile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persists the batch of profiles chosen at the end of the first-run wizard.
|
||||||
|
///
|
||||||
|
/// Saving even an empty list creates `profiles.json`, which marks the first run
|
||||||
|
/// as done (so the wizard does not reappear).
|
||||||
|
pub struct ConfigureProfiles {
|
||||||
|
store: Arc<dyn ProfileStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigureProfiles {
|
||||||
|
/// Builds the use case from the [`ProfileStore`] port.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(store: Arc<dyn ProfileStore>) -> Self {
|
||||||
|
Self { store }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persists each chosen profile.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`AppError::Store`] on persistence failure.
|
||||||
|
pub async fn execute(
|
||||||
|
&self,
|
||||||
|
input: ConfigureProfilesInput,
|
||||||
|
) -> Result<ConfigureProfilesOutput, AppError> {
|
||||||
|
for profile in &input.profiles {
|
||||||
|
self.store.save(profile).await?;
|
||||||
|
}
|
||||||
|
// Ensure `profiles.json` exists even when the user kept nothing, so the
|
||||||
|
// first run is recorded as complete.
|
||||||
|
if input.profiles.is_empty() {
|
||||||
|
self.store.mark_configured().await?;
|
||||||
|
}
|
||||||
|
Ok(ConfigureProfilesOutput {
|
||||||
|
profiles: input.profiles,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ReferenceProfiles (catalogue accessor)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Output of [`ReferenceProfiles::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ReferenceProfilesOutput {
|
||||||
|
/// The pre-filled, editable reference catalogue.
|
||||||
|
pub profiles: Vec<AgentProfile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exposes the pre-filled reference catalogue (Claude/Codex/Gemini/Aider).
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct ReferenceProfiles;
|
||||||
|
|
||||||
|
impl ReferenceProfiles {
|
||||||
|
/// Builds the (stateless) use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the reference catalogue. Infallible.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Never; the `Result` keeps the call site uniform.
|
||||||
|
#[allow(clippy::unused_async)]
|
||||||
|
pub async fn execute(&self) -> Result<ReferenceProfilesOutput, AppError> {
|
||||||
|
Ok(ReferenceProfilesOutput {
|
||||||
|
profiles: reference_profiles(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// FirstRunState
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Output of [`FirstRunState::execute`]: whether to show the wizard + catalogue.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct FirstRunStateOutput {
|
||||||
|
/// `true` when no `profiles.json` exists yet ⇒ show the first-run wizard.
|
||||||
|
pub is_first_run: bool,
|
||||||
|
/// The pre-filled reference catalogue to seed the wizard.
|
||||||
|
pub reference_profiles: Vec<AgentProfile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reports whether the IDE is on its first run (no profiles configured yet) and
|
||||||
|
/// provides the reference catalogue to seed the wizard.
|
||||||
|
pub struct FirstRunState {
|
||||||
|
store: Arc<dyn ProfileStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FirstRunState {
|
||||||
|
/// Builds the use case from the [`ProfileStore`] port.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(store: Arc<dyn ProfileStore>) -> Self {
|
||||||
|
Self { store }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes the first-run state.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`AppError::Store`] on persistence failure.
|
||||||
|
pub async fn execute(&self) -> Result<FirstRunStateOutput, AppError> {
|
||||||
|
let configured = self.store.is_configured().await?;
|
||||||
|
Ok(FirstRunStateOutput {
|
||||||
|
is_first_run: !configured,
|
||||||
|
reference_profiles: reference_profiles(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
115
crates/application/src/error.rs
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
//! [`AppError`] — the single error type returned by every use case.
|
||||||
|
//!
|
||||||
|
//! Per-port errors from the domain ([`domain::ports::FsError`],
|
||||||
|
//! [`domain::ports::StoreError`], …) are mapped into this application-level
|
||||||
|
//! error so that the presentation layer (Tauri commands) only ever has to deal
|
||||||
|
//! with one error shape when building its `ErrorDTO`.
|
||||||
|
|
||||||
|
use domain::ports::{
|
||||||
|
FsError, GitError, ProcessError, PtyError, RemoteError, RuntimeError, StoreError,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Errors surfaced by application use cases.
|
||||||
|
///
|
||||||
|
/// Each variant carries a stable, machine-readable `code` (see [`AppError::code`])
|
||||||
|
/// so the presentation layer can map it to an `ErrorDTO` without string matching.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||||
|
pub enum AppError {
|
||||||
|
/// A requested resource was not found.
|
||||||
|
#[error("not found: {0}")]
|
||||||
|
NotFound(String),
|
||||||
|
|
||||||
|
/// The input failed a domain/application invariant.
|
||||||
|
#[error("invalid input: {0}")]
|
||||||
|
Invalid(String),
|
||||||
|
|
||||||
|
/// A filesystem operation failed.
|
||||||
|
#[error("filesystem error: {0}")]
|
||||||
|
FileSystem(String),
|
||||||
|
|
||||||
|
/// A persistence (store) operation failed.
|
||||||
|
#[error("store error: {0}")]
|
||||||
|
Store(String),
|
||||||
|
|
||||||
|
/// A process/PTY/runtime operation failed.
|
||||||
|
#[error("process error: {0}")]
|
||||||
|
Process(String),
|
||||||
|
|
||||||
|
/// A git operation failed.
|
||||||
|
#[error("git error: {0}")]
|
||||||
|
Git(String),
|
||||||
|
|
||||||
|
/// A remote (SSH/WSL) operation failed.
|
||||||
|
#[error("remote error: {0}")]
|
||||||
|
Remote(String),
|
||||||
|
|
||||||
|
/// An unexpected internal error.
|
||||||
|
#[error("internal error: {0}")]
|
||||||
|
Internal(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppError {
|
||||||
|
/// A stable, machine-readable code for this error, intended for the
|
||||||
|
/// `ErrorDTO` so the frontend can branch without parsing messages.
|
||||||
|
#[must_use]
|
||||||
|
pub fn code(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::NotFound(_) => "NOT_FOUND",
|
||||||
|
Self::Invalid(_) => "INVALID",
|
||||||
|
Self::FileSystem(_) => "FILESYSTEM",
|
||||||
|
Self::Store(_) => "STORE",
|
||||||
|
Self::Process(_) => "PROCESS",
|
||||||
|
Self::Git(_) => "GIT",
|
||||||
|
Self::Remote(_) => "REMOTE",
|
||||||
|
Self::Internal(_) => "INTERNAL",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<FsError> for AppError {
|
||||||
|
fn from(e: FsError) -> Self {
|
||||||
|
match e {
|
||||||
|
FsError::NotFound(p) => Self::NotFound(p),
|
||||||
|
other => Self::FileSystem(other.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<StoreError> for AppError {
|
||||||
|
fn from(e: StoreError) -> Self {
|
||||||
|
match e {
|
||||||
|
StoreError::NotFound => Self::NotFound("store item".to_owned()),
|
||||||
|
other => Self::Store(other.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PtyError> for AppError {
|
||||||
|
fn from(e: PtyError) -> Self {
|
||||||
|
Self::Process(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ProcessError> for AppError {
|
||||||
|
fn from(e: ProcessError) -> Self {
|
||||||
|
Self::Process(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<RuntimeError> for AppError {
|
||||||
|
fn from(e: RuntimeError) -> Self {
|
||||||
|
Self::Process(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<GitError> for AppError {
|
||||||
|
fn from(e: GitError) -> Self {
|
||||||
|
Self::Git(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<RemoteError> for AppError {
|
||||||
|
fn from(e: RemoteError) -> Self {
|
||||||
|
Self::Remote(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||