Compare commits
6 Commits
307ae71857
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 3be55795a6 | |||
| 2332b7f815 | |||
| 9736c42424 | |||
| 0638ce7c98 | |||
| 0660f52e2b | |||
| 33edbad713 |
1
.claude/worktrees/agent-a2650e91d2bd39ca2
Submodule
1
.claude/worktrees/agent-a2650e91d2bd39ca2
Submodule
Submodule .claude/worktrees/agent-a2650e91d2bd39ca2 added at 9736c42424
1
.claude/worktrees/agent-a4227a8f495123597
Submodule
1
.claude/worktrees/agent-a4227a8f495123597
Submodule
Submodule .claude/worktrees/agent-a4227a8f495123597 added at 33edbad713
1
.claude/worktrees/agent-a78df266a0bbaf5c3
Submodule
1
.claude/worktrees/agent-a78df266a0bbaf5c3
Submodule
Submodule .claude/worktrees/agent-a78df266a0bbaf5c3 added at 0660f52e2b
1
.claude/worktrees/agent-aeb1e862ef04b991b
Submodule
1
.claude/worktrees/agent-aeb1e862ef04b991b
Submodule
Submodule .claude/worktrees/agent-aeb1e862ef04b991b added at 9736c42424
8
.gitignore
vendored
8
.gitignore
vendored
@ -24,6 +24,14 @@ frontend/coverage/
|
|||||||
# ─── Claude Code ────────────────────────────────────────────────────────────
|
# ─── Claude Code ────────────────────────────────────────────────────────────
|
||||||
# Personal, machine-local overrides (shared settings.json, if any, stays tracked).
|
# Personal, machine-local overrides (shared settings.json, if any, stays tracked).
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
# Ephemeral git worktrees created by Claude Code's isolated sub-agents — dev
|
||||||
|
# tooling only, unrelated to IdeA (which stays git-independent).
|
||||||
|
.claude/worktrees/
|
||||||
|
|
||||||
|
# ─── IdeA project data ──────────────────────────────────────────────────────
|
||||||
|
# Ephemeral per-agent run directories (isolated PTY cwd + generated convention
|
||||||
|
# files), created at activation — not versioned (ARCHITECTURE §9.1 / §14.1).
|
||||||
|
.ideai/run/
|
||||||
|
|
||||||
# ─── Editors / OS ───────────────────────────────────────────────────────────
|
# ─── Editors / OS ───────────────────────────────────────────────────────────
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
19
.ideai/agents.json
Normal file
19
.ideai/agents.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"agents": [
|
||||||
|
{
|
||||||
|
"agentId": "a6ced819-b893-4213-b003-9e9dc79b9641",
|
||||||
|
"name": "Main",
|
||||||
|
"mdPath": "agents/main.md",
|
||||||
|
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
|
||||||
|
"synchronized": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"agentId": "dce19c75-9669-4e45-b8de-9950025157da",
|
||||||
|
"name": "Architect",
|
||||||
|
"mdPath": "agents/architect.md",
|
||||||
|
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
|
||||||
|
"synchronized": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
601
.ideai/agents/architect.md
Normal file
601
.ideai/agents/architect.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
.ideai/agents/main.md
Normal file
187
.ideai/agents/main.md
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
# IdeA — Contexte & Méthode de travail
|
||||||
|
|
||||||
|
> Ce document définit **mon rôle**, **la méthode de développement** et **la vision produit** du projet IdeA.
|
||||||
|
> Il fait autorité sur la façon dont le projet est piloté. Toute évolution de méthode doit être répercutée ici.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Mon rôle : chef d'orchestre, pas développeur
|
||||||
|
|
||||||
|
Je **n'écris pas de code moi-même**. Mon rôle est de **piloter des agents** qui réalisent le travail.
|
||||||
|
Je suis responsable de :
|
||||||
|
|
||||||
|
- Découper le travail en tâches claires et autonomes.
|
||||||
|
- Attribuer chaque tâche aux bons agents.
|
||||||
|
- Garantir que le cycle de développement/test est respecté.
|
||||||
|
- Faire respecter les principes d'architecture (SOLID, Hexagonal).
|
||||||
|
- Maintenir la cohérence globale du projet et de ce document.
|
||||||
|
- Arbitrer et valider avant toute action irréversible ou sortante.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Les agents
|
||||||
|
|
||||||
|
### 2.1 Agent Architecture (1 pour tout le projet)
|
||||||
|
- Garant de l'architecture globale : **Hexagonale (Ports & Adapters)** et principes **SOLID**.
|
||||||
|
- Définit les frontières (domaine / application / infrastructure), les ports, les contrats.
|
||||||
|
- Valide que chaque nouvelle feature respecte la structure avant son développement.
|
||||||
|
- Tient à jour la cartographie d'architecture et les conventions.
|
||||||
|
|
||||||
|
### 2.2 Agents de Développement
|
||||||
|
- Écrivent le code des features.
|
||||||
|
- Respectent strictement l'architecture définie par l'agent Architecture.
|
||||||
|
- Code **propre, structuré, stable**.
|
||||||
|
- Reçoivent les rapports d'erreurs des agents de test et corrigent.
|
||||||
|
|
||||||
|
### 2.3 Agents de Test
|
||||||
|
- **Chaque agent de développement est appairé avec un agent de test dédié.**
|
||||||
|
- Écrivent et exécutent les **tests unitaires** des features implémentées ou modifiées.
|
||||||
|
- Produisent un **rapport d'erreurs** clair quand un test échoue.
|
||||||
|
- Re-testent après chaque correction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Le cycle de développement (boucle obligatoire)
|
||||||
|
|
||||||
|
Pour **chaque** feature implémentée ou modifiée :
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Agent Architecture → valide le découpage et les contrats (ports/interfaces)
|
||||||
|
2. Agent Développement → écrit le code
|
||||||
|
3. Agent Test → écrit les tests unitaires + les exécute
|
||||||
|
4a. Tests OK → feature validée, on passe à la suite
|
||||||
|
4b. Tests KO → rapport d'erreurs → retour à l'agent Développement
|
||||||
|
→ correction → retour à l'étape 3 (boucle jusqu'au vert)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Règle d'or :** aucune feature n'est considérée terminée tant que ses tests ne passent pas.
|
||||||
|
Je relaie fidèlement les résultats : si des tests échouent, je le dis avec la sortie réelle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Principes de code
|
||||||
|
|
||||||
|
- **SOLID** appliqué au maximum.
|
||||||
|
- **Architecture Hexagonale** (Ports & Adapters) : le domaine métier est isolé des détails techniques (UI, terminal, git, SSH, système de fichiers...).
|
||||||
|
- Le cœur métier ne dépend d'aucun framework ni d'aucune dépendance externe.
|
||||||
|
- Tests unitaires systématiques ; couverture des features critiques.
|
||||||
|
- Code lisible, cohérent avec le style existant, faiblement couplé, fortement cohésif.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Vision produit : IdeA
|
||||||
|
|
||||||
|
**IdeA est un IDE next-gen 100 % IA.** On n'y code pas : **on gère des IA.**
|
||||||
|
|
||||||
|
### Fonctionnalités clés
|
||||||
|
- **Multi-projets en parallèle** : un **onglet par projet**.
|
||||||
|
- **Fenêtre = espace de travail** où l'on **organise plusieurs terminaux** librement.
|
||||||
|
- **Agents par projet** : chaque projet a ses propres agents.
|
||||||
|
- **Agents templates** : agents réutilisables, ajoutables à plusieurs projets.
|
||||||
|
- **Création d'agents** : depuis zéro ou à partir d'un template.
|
||||||
|
- **Synchronisation template → agents** : option « garder l'agent à jour ».
|
||||||
|
Si le template est mis à jour, les agents qui en sont issus (avec l'option activée) reçoivent la mise à jour.
|
||||||
|
- **Contextes d'agents stockés en `.md`** (toujours).
|
||||||
|
- **Création de projet** = définition de son **project root**.
|
||||||
|
|
||||||
|
### Intégrations
|
||||||
|
- **Git** intégré.
|
||||||
|
- **Développement distant SSH** : travailler sur un projet hébergé sur une autre machine via SSH.
|
||||||
|
- **Développement WSL** : travailler sur une WSL depuis Windows.
|
||||||
|
|
||||||
|
### Plateformes & livraison
|
||||||
|
- Cible : **macOS, Linux, Windows**.
|
||||||
|
- Première phase de compilation : **Linux et Windows**.
|
||||||
|
- Livraison :
|
||||||
|
- **Windows** : `setup.exe`.
|
||||||
|
- **Linux** : **AppImage** (doit fonctionner sur les différentes distributions).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Stack technique (validée)
|
||||||
|
|
||||||
|
- **Shell applicatif** : **Tauri v2** (binaires légers, performants, multi-OS, AppImage + installeur `setup.exe`/NSIS Windows natifs).
|
||||||
|
- **Cœur / backend** : **Rust** — stabilité, performance, et expression idiomatique du domaine hexagonal (ports = traits, adapters = implémentations).
|
||||||
|
- **Frontend / UI** : **TypeScript + React**.
|
||||||
|
- **Terminaux** : **xterm.js** (rendu) + **portable-pty** (PTY côté Rust).
|
||||||
|
- **Git** : **libgit2** via `git2` (Rust).
|
||||||
|
- **SSH** : `russh` / `ssh2` (Rust).
|
||||||
|
- **WSL** : invocation de `wsl.exe` depuis le backend.
|
||||||
|
|
||||||
|
## 7. Layout des terminaux (exigence produit)
|
||||||
|
|
||||||
|
Disposition en **grille redimensionnable de type tableur (Excel)** :
|
||||||
|
|
||||||
|
- Splits redimensionnables horizontaux **et** verticaux.
|
||||||
|
- L'utilisateur peut **définir le nombre de colonnes dans une ligne** et **le nombre de lignes dans une colonne**, indépendamment par zone.
|
||||||
|
- Possibilité de **fusionner des cellules** (ex. fusionner deux colonnes sur une ligne), à la manière des cellules fusionnées d'un tableur.
|
||||||
|
- Chaque cellule de la grille héberge un terminal.
|
||||||
|
- → Modèle de layout récursif/imbriqué (pas une grille rigide uniforme) à concevoir par l'agent Architecture.
|
||||||
|
|
||||||
|
## 8. Stockage des contextes & liaison aux templates
|
||||||
|
|
||||||
|
- **Templates d'agents** : stockés dans l'**IDE** (dossier de données utilisateur global de l'app, hors projet).
|
||||||
|
- **Agents de projet** : leurs `.md` sont stockés dans un dossier **`.ideai/`** à la racine du project root.
|
||||||
|
*(Nom choisi pour éviter toute collision avec le `.idea` de JetBrains.)*
|
||||||
|
- **Manifeste de liaison** dans `.ideai/` (ex. `.ideai/agents.json`) qui mappe pour chaque agent de projet :
|
||||||
|
- le `.md` de l'agent,
|
||||||
|
- le template d'origine (le cas échéant),
|
||||||
|
- `synchronized: true/false`,
|
||||||
|
- la **version du template** au dernier sync (pour détecter qu'une mise à jour est disponible).
|
||||||
|
- **Synchro template → agents** : quand un template est mis à jour, les agents liés avec `synchronized: true` reçoivent la MAJ.
|
||||||
|
|
||||||
|
## 9. Moteur IA : adaptateur de CLI flexible (Port `AgentRuntime`)
|
||||||
|
|
||||||
|
Chaque IA est décrite par un **profil déclaratif** (config éditable, pas du code), implémentation d'un **Port** `AgentRuntime` côté domaine. Deux variables clés par IA :
|
||||||
|
|
||||||
|
1. **Commande de lancement** + arguments (ex. `claude`, `codex`, `gemini`, `aider`).
|
||||||
|
2. **Stratégie d'injection du contexte `.md`** :
|
||||||
|
- `conventionFile` : écrire/symlink le `.md` vers le fichier attendu par la CLI (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`…).
|
||||||
|
- `flag` : passer le chemin via un argument.
|
||||||
|
- `stdin` : piper le contenu.
|
||||||
|
- `env` : passer via variable d'environnement.
|
||||||
|
|
||||||
|
Exemple de profil :
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "claude-code",
|
||||||
|
"name": "Claude Code",
|
||||||
|
"command": "claude",
|
||||||
|
"args": [],
|
||||||
|
"contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" },
|
||||||
|
"detect": "claude --version",
|
||||||
|
"cwd": "{projectRoot}"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Profils intégrés (références) :** Claude Code (`claude` → `CLAUDE.md`), OpenAI Codex CLI (`codex` → `AGENTS.md`), Gemini CLI (`gemini` → `GEMINI.md`), Aider (`aider` → args/message).
|
||||||
|
|
||||||
|
**Règles produit :**
|
||||||
|
- **Premier lancement de l'IDE** : un assistant (first-run) **demande à l'utilisateur** quels profils d'IA configurer. On ne présume rien par défaut.
|
||||||
|
- Les commandes des profils sont **pré-remplies mais éditables**.
|
||||||
|
- L'utilisateur peut **ajouter sa propre commande CLI** (profil custom) pour n'importe quelle IA.
|
||||||
|
|
||||||
|
**Lancement d'un agent :** à l'**activation de l'agent**, on ouvre une cellule terminal (PTY) avec le bon `cwd`, on injecte le contexte `.md`, et on **auto-lance** la CLI du profil.
|
||||||
|
|
||||||
|
## 10. Fenêtres & onglets
|
||||||
|
|
||||||
|
- **Par défaut : un onglet par projet** (comme les IDE classiques).
|
||||||
|
- **Drag & drop d'un onglet** hors de la fenêtre → **crée une nouvelle fenêtre OS** portant ce projet.
|
||||||
|
- **Multi-fenêtres OS supporté** ; chaque fenêtre possède un ou plusieurs onglets/projets.
|
||||||
|
|
||||||
|
## 11. Feuille de route
|
||||||
|
|
||||||
|
1. **Cadrage architecture complet d'abord** (jalon en cours) : l'agent Architecture produit la cartographie complète — domaine, ports, adapters, modules, arborescence — **avant tout code**.
|
||||||
|
2. Puis MVP incrémental selon le cycle dev/test de la section 3.
|
||||||
|
|
||||||
|
## 12. Autonomie d'exécution dans le projet
|
||||||
|
|
||||||
|
L'utilisateur m'accorde un **accès large et autonome** sur le dossier du projet : je peux lire, créer, modifier des fichiers et exécuter les commandes de développement (cargo, npm, npx, git, etc.) **sans demander confirmation à chaque fois**.
|
||||||
|
|
||||||
|
- Concrètement, ces autorisations sont matérialisées dans `.claude/settings.local.json` (mode `acceptEdits` + `Bash`/`Read`/`Edit`/`Write` autorisés), pas dans ce document — CONTEXT.md ne fait que **documenter l'intention**.
|
||||||
|
- **Garde-fous conservés** : les actions destructrices ou hors-projet restent bloquées (`sudo`, `rm -rf` sur `/`/`~`/`$HOME`, `mkfs`, `dd`, `shutdown`/`reboot`…).
|
||||||
|
- L'esprit du rôle (§1) ne change pas : je reste **chef d'orchestre**. L'autonomie porte sur l'exécution mécanique, pas sur l'arbitrage des décisions produit/archi, ni sur les **actions sortantes** (push, publication) qui restent soumises à validation explicite.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Dernière mise à jour : 2026-06-05*
|
||||||
56
.ideai/layouts.json
Normal file
56
.ideai/layouts.json
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"activeId": "9188db80-8535-4786-a20b-3c9a36b222e3",
|
||||||
|
"layouts": [
|
||||||
|
{
|
||||||
|
"id": "9188db80-8535-4786-a20b-3c9a36b222e3",
|
||||||
|
"name": "Default",
|
||||||
|
"kind": "terminal",
|
||||||
|
"tree": {
|
||||||
|
"root": {
|
||||||
|
"type": "split",
|
||||||
|
"node": {
|
||||||
|
"id": "8aca2f93-1a9b-4693-9bba-9a01e130a48c",
|
||||||
|
"direction": "row",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"node": {
|
||||||
|
"type": "leaf",
|
||||||
|
"node": {
|
||||||
|
"id": "d8a86eb1-cd4d-4937-b900-4989da7c868d",
|
||||||
|
"session": "2e6293fe-250a-4654-a8b2-4fe6996f8288",
|
||||||
|
"agent": "a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"weight": 1.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": {
|
||||||
|
"type": "leaf",
|
||||||
|
"node": {
|
||||||
|
"id": "6c5be5e7-a54b-468c-a2e2-8ec853629d5e",
|
||||||
|
"session": "9aa5859c-5868-46b3-a045-68cbea6eb629"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"weight": 1.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "40ea4fa9-25b3-410b-9d3e-6350834b421b",
|
||||||
|
"name": "Git Graph",
|
||||||
|
"kind": "gitGraph",
|
||||||
|
"tree": {
|
||||||
|
"root": {
|
||||||
|
"type": "leaf",
|
||||||
|
"node": {
|
||||||
|
"id": "c840bfdd-3330-46a3-b727-0799f6853e72"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
9
.ideai/project.json
Normal file
9
.ideai/project.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"id": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023",
|
||||||
|
"name": "IdeA",
|
||||||
|
"remote": {
|
||||||
|
"kind": "local"
|
||||||
|
},
|
||||||
|
"createdAt": 1780702317785
|
||||||
|
}
|
||||||
@ -6,6 +6,8 @@
|
|||||||
> Architecture **Hexagonale (Ports & Adapters)** + **SOLID**, stricte.
|
> 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.
|
> 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.
|
||||||
|
>
|
||||||
|
> **Principe fondateur** : IdeA est un IDE 100 % IA dont le rôle est de **refléter fidèlement la façon dont on travaille avec des IAs** — sans jamais dépendre des commandes, flags ou conventions d'un modèle en particulier. Les deux abstractions de premier rang sont les **Agents** (instances IA à rôle/contexte définis) et les **Skills** (workflows réutilisables). Ces deux concepts sont gérés par IdeA de façon universelle : un utilisateur qui passe de Claude Code à Gemini CLI ou Codex retrouve exactement les mêmes Agents et Skills — seul le moteur d'exécution change.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -134,7 +136,7 @@ Workspace 1───* Window 1───* Tab 1───1 Project
|
|||||||
- Invariants : `version` **monotone croissante** ; toute modification du `content_md` ⇒ `version + 1` (voir §8).
|
- 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)
|
**`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}"`).
|
- Champs : `id`, `name`, `command: String`, `args: Vec<String>`, `context_injection: ContextInjection`, `detect: Option<String>`, `cwd_template: String` (vaut toujours `"{agentRunDir}"` — voir §9.1 et §14.1).
|
||||||
- Invariants : `command` non vide ; cohérence de `ContextInjection` (voir VO ci-dessous).
|
- Invariants : `command` non vide ; cohérence de `ContextInjection` (voir VO ci-dessous).
|
||||||
|
|
||||||
**`ContextInjection`** (VO, enum — cœur du moteur IA flexible)
|
**`ContextInjection`** (VO, enum — cœur du moteur IA flexible)
|
||||||
@ -177,7 +179,12 @@ RemoteRef =
|
|||||||
- `Tab` = onglet ⇔ **un `Project`** (1:1).
|
- `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.
|
- 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).
|
**`Skill`** (entité)
|
||||||
|
- Champs : `id`, `name`, `content_md: MarkdownDoc`, `scope: SkillScope` (`Global` | `Project`).
|
||||||
|
- Invariants : `name` non vide ; `content_md` non vide.
|
||||||
|
- Un agent référence 0..N skills (dans l'`AgentManifest`). Les skills assignés sont injectés dans son convention file à l'activation.
|
||||||
|
|
||||||
|
**`DomainEvent`** (enum) — `ProjectCreated`, `AgentLaunched`, `AgentExited`, `TemplateUpdated`, `AgentDriftDetected`, `SkillAssigned`, `LayoutChanged`, `RemoteConnected`, `GitStateChanged`, `PtyOutput{session_id, bytes}`, `OrchestratorRequest{requester_id, action}` (ce dernier souvent court-circuité vers un Channel).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -451,11 +458,19 @@ drift(agent) =
|
|||||||
│ ├── agents.json # AgentManifest (mapping md ↔ template ↔ sync ↔ version)
|
│ ├── agents.json # AgentManifest (mapping md ↔ template ↔ sync ↔ version)
|
||||||
│ ├── layout.json # LayoutTree de l'onglet (sérialisé)
|
│ ├── layout.json # LayoutTree de l'onglet (sérialisé)
|
||||||
│ ├── project.json # méta projet local (nom, profil par défaut, remote ref)
|
│ ├── project.json # méta projet local (nom, profil par défaut, remote ref)
|
||||||
│ └── agents/
|
│ ├── agents/
|
||||||
│ ├── reviewer.md # contexte d'un agent de projet
|
│ │ ├── reviewer.md # contexte d'un agent de projet
|
||||||
│ ├── backend-dev.md
|
│ │ ├── backend-dev.md
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── skills/
|
||||||
|
│ │ ├── code-review.md # contexte d'un skill (voir §14.2)
|
||||||
|
│ │ ├── simplify.md
|
||||||
|
│ │ └── ...
|
||||||
|
│ └── run/
|
||||||
|
│ ├── <agent-id>/ # cwd isolé par agent actif (créé à l'activation)
|
||||||
|
│ │ └── CLAUDE.md # fichier de convention généré par IdeA (profil-dépendant)
|
||||||
│ └── ...
|
│ └── ...
|
||||||
└── (CLAUDE.md / AGENTS.md / GEMINI.md générés/symlinkés à l'activation si conventionFile)
|
└── (aucun CLAUDE.md/AGENTS.md/GEMINI.md à la racine — jamais — voir §14.1)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Schéma `agents.json`** :
|
**Schéma `agents.json`** :
|
||||||
@ -504,6 +519,8 @@ Emplacement résolu via Tauri path API (`AppData`/`~/.local/share/IdeA`/`~/Libra
|
|||||||
|
|
||||||
**Formats** : contextes & templates en **Markdown** ; tout le reste en **JSON** (serde). Pas de base de données : fichiers plats, simples, diffables, portables (AppImage friendly).
|
**Formats** : contextes & templates en **Markdown** ; tout le reste en **JSON** (serde). Pas de base de données : fichiers plats, simples, diffables, portables (AppImage friendly).
|
||||||
|
|
||||||
|
> **Note** : `.ideai/run/` contient des répertoires d'exécution éphémères (créés à l'activation, nettoyés à la fermeture). Leur contenu (convention files générés) ne doit **pas** être versionné dans git — ajouter `.ideai/run/` au `.gitignore` du projet.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Arborescence du repo
|
## 10. Arborescence du repo
|
||||||
@ -581,6 +598,73 @@ IdeA/
|
|||||||
| L9 | **Remote (SSH + WSL)** | `RemoteHost` stratégie, `SshHost`/`WslHost`, adapters FS/PTY/Spawner distants, `RemoteGitRepository`. UI connexion. | `infrastructure/remote`, `application/remote`, `frontend/features/remote` |
|
| 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` |
|
| 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 |
|
| L11 | **Packaging & livraison** | Tauri bundle : NSIS `setup.exe`, **AppImage** multi-distro, CI Linux+Windows. | `app-tauri`, CI |
|
||||||
|
| L12 | **Skills** | Entité `Skill`, `SkillStore`, CRUD skills global+projet, assignation agent↔skills, injection dans convention file à l'activation. UI onglet Skills. | `domain/skill`, `application/skill`, `infrastructure/store`, `frontend/features/skills` |
|
||||||
|
| L13 | **OrchestratorApi** | File-watcher `.ideai/requests/`, port `OrchestratorApi`, adapter `FsOrchestratorAdapter`, actions `spawn_agent`/`stop_agent`/`update_agent_context`. | `infrastructure/orchestrator`, `application/agent`, `app-tauri` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Décisions d'architecture figées (2026-06-06)
|
||||||
|
|
||||||
|
### 14.1 Isolation du cwd par agent — résolution de la collision de contexte
|
||||||
|
|
||||||
|
**Problème** : plusieurs agents du même profil (ex. deux instances Claude Code) sur le même project root produisaient une collision — le fichier de convention (`CLAUDE.md`, `AGENTS.md`…) est un emplacement fixe unique à la racine.
|
||||||
|
|
||||||
|
**Décision** : le cwd du PTY d'un agent n'est **jamais** le project root. C'est `.ideai/run/<agent-id>/`, un dossier créé par IdeA à l'activation et nettoyé à la fermeture.
|
||||||
|
|
||||||
|
**Convention file généré par IdeA** : IdeA écrit dans ce dossier le fichier conventionnel attendu par le profil (`CLAUDE.md`, `AGENTS.md`, etc.). Ce fichier contient :
|
||||||
|
1. La **persona/rôle** de l'agent (son `.md` dans `.ideai/agents/`).
|
||||||
|
2. Le **chemin absolu du project root** (pour que l'agent sache où opérer).
|
||||||
|
3. Les **skills actifs** assignés à cet agent (voir §14.2).
|
||||||
|
4. Une référence au **contexte projet partagé** si présent.
|
||||||
|
|
||||||
|
**Avantages** :
|
||||||
|
- Zéro collision entre agents, même N instances du même profil.
|
||||||
|
- Universel : fonctionne pour toute CLI qui lit un fichier de convention depuis son cwd — aucun flag ou commande propre à un modèle.
|
||||||
|
- Zéro dépendance à git (git est optionnel — supprimer un repo ne casse rien).
|
||||||
|
|
||||||
|
**Impact sur `AgentProfile.cwd_template`** : la valeur est toujours `"{agentRunDir}"`, jamais `"{projectRoot}"`. La connaissance du project root passe par le *contenu* du convention file, pas par le cwd.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 14.2 Skills — abstraction universelle de workflows réutilisables
|
||||||
|
|
||||||
|
**Définition** : un **Skill** est un workflow/comportement réutilisable qu'on peut assigner à un agent. Exemples : `code-review`, `simplify`, `run-tests`, `explain`. C'est l'équivalent universel des slash-commands de Claude Code — mais sans dépendance à la syntaxe `/command` d'un modèle particulier.
|
||||||
|
|
||||||
|
**Stockage** :
|
||||||
|
- Skills globaux (templates) : `<app_data>/IdeA/skills/` (store global IDE, réutilisables entre projets).
|
||||||
|
- Skills de projet : `.ideai/skills/<skill-name>.md` (spécifiques au projet).
|
||||||
|
|
||||||
|
**Injection** : les skills assignés à un agent sont **inclus dans son convention file** généré par IdeA au moment de l'activation. L'agent reçoit donc ses skills comme du contexte textuel — aucun mécanisme CLI propriétaire.
|
||||||
|
|
||||||
|
**Entité `Skill`** (à ajouter au domaine) :
|
||||||
|
- Champs : `id`, `name`, `content_md: MarkdownDoc`, `scope: SkillScope` (`Global` | `Project`).
|
||||||
|
- Un agent peut avoir 0..N skills assignés (stocké dans l'`AgentManifest`).
|
||||||
|
|
||||||
|
**Port `SkillStore`** : CRUD skills globaux + skills projet (compose `FileSystem`/store global selon le scope).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 14.3 OrchestratorApi — spawn d'agents depuis un agent ou depuis l'UI
|
||||||
|
|
||||||
|
**Objectif** : qu'un agent orchestrateur puisse demander à IdeA de créer un nouvel agent (visible dans la grille et dans l'onglet Agents), exactement comme le ferait l'utilisateur via l'UI.
|
||||||
|
|
||||||
|
**Mécanisme** : file-watching sur `.ideai/requests/<requester-id>/`. L'orchestrateur écrit un fichier JSON de requête :
|
||||||
|
```json
|
||||||
|
{ "action": "spawn_agent", "name": "dev-backend", "profile": "claude-code", "context": "agents/dev-backend.md" }
|
||||||
|
```
|
||||||
|
IdeA détecte le fichier, exécute la même logique que `LaunchAgent` déclenché depuis l'UI, crée la cellule terminal, inscrit l'agent dans l'onglet Agents, puis supprime le fichier et écrit une réponse.
|
||||||
|
|
||||||
|
**Règle** : l'orchestrateur ne spawne jamais lui-même un process CLI — il **délègue à IdeA**. IdeA reste l'unique source de vérité du cycle de vie des agents.
|
||||||
|
|
||||||
|
**Port `OrchestratorApi`** (adapter entrant, driven by file-watcher) : surveille `.ideai/requests/`, désérialise les commandes, les traduit en appels de use cases (`LaunchAgent`, `StopAgent`…). Implémenté dans `infrastructure/orchestrator`.
|
||||||
|
|
||||||
|
**Actions supportées (v1)** : `spawn_agent`, `stop_agent`, `update_agent_context`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 14.4 Git = intégration optionnelle, zéro dépendance fonctionnelle
|
||||||
|
|
||||||
|
Git est un **outil posé par-dessus l'IDE**, pas un socle. Supprimer le repo git d'un projet ne doit casser aucune feature d'IdeA (agents, terminaux, layout, skills, orchestration). Les use cases git (L8) sont un module indépendant ; rien d'autre n'en dépend. Cette contrainte s'applique à toute future décision de conception.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -591,7 +675,7 @@ IdeA/
|
|||||||
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.
|
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.
|
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.
|
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.
|
6. ~~**Injection `conventionFile`** : symlink vs copie du `.md` vers `CLAUDE.md`/`AGENTS.md` ; conflits si fichier existant, .gitignore, droits Windows (symlinks).~~ **Résolu (§14.1)** : cwd isolé par agent dans `.ideai/run/<id>/` — plus de conflit à la racine, convention file généré par copie simple.
|
||||||
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.
|
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.
|
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.
|
9. **Détection d'édition hors-app** des `.md`/templates (content hash) et résolution de conflit lors du sync. L7.
|
||||||
|
|||||||
187
CLAUDE.md
Normal file
187
CLAUDE.md
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
# IdeA — Contexte & Méthode de travail
|
||||||
|
|
||||||
|
> Ce document définit **mon rôle**, **la méthode de développement** et **la vision produit** du projet IdeA.
|
||||||
|
> Il fait autorité sur la façon dont le projet est piloté. Toute évolution de méthode doit être répercutée ici.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Mon rôle : chef d'orchestre, pas développeur
|
||||||
|
|
||||||
|
Je **n'écris pas de code moi-même**. Mon rôle est de **piloter des agents** qui réalisent le travail.
|
||||||
|
Je suis responsable de :
|
||||||
|
|
||||||
|
- Découper le travail en tâches claires et autonomes.
|
||||||
|
- Attribuer chaque tâche aux bons agents.
|
||||||
|
- Garantir que le cycle de développement/test est respecté.
|
||||||
|
- Faire respecter les principes d'architecture (SOLID, Hexagonal).
|
||||||
|
- Maintenir la cohérence globale du projet et de ce document.
|
||||||
|
- Arbitrer et valider avant toute action irréversible ou sortante.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Les agents
|
||||||
|
|
||||||
|
### 2.1 Agent Architecture (1 pour tout le projet)
|
||||||
|
- Garant de l'architecture globale : **Hexagonale (Ports & Adapters)** et principes **SOLID**.
|
||||||
|
- Définit les frontières (domaine / application / infrastructure), les ports, les contrats.
|
||||||
|
- Valide que chaque nouvelle feature respecte la structure avant son développement.
|
||||||
|
- Tient à jour la cartographie d'architecture et les conventions.
|
||||||
|
|
||||||
|
### 2.2 Agents de Développement
|
||||||
|
- Écrivent le code des features.
|
||||||
|
- Respectent strictement l'architecture définie par l'agent Architecture.
|
||||||
|
- Code **propre, structuré, stable**.
|
||||||
|
- Reçoivent les rapports d'erreurs des agents de test et corrigent.
|
||||||
|
|
||||||
|
### 2.3 Agents de Test
|
||||||
|
- **Chaque agent de développement est appairé avec un agent de test dédié.**
|
||||||
|
- Écrivent et exécutent les **tests unitaires** des features implémentées ou modifiées.
|
||||||
|
- Produisent un **rapport d'erreurs** clair quand un test échoue.
|
||||||
|
- Re-testent après chaque correction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Le cycle de développement (boucle obligatoire)
|
||||||
|
|
||||||
|
Pour **chaque** feature implémentée ou modifiée :
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Agent Architecture → valide le découpage et les contrats (ports/interfaces)
|
||||||
|
2. Agent Développement → écrit le code
|
||||||
|
3. Agent Test → écrit les tests unitaires + les exécute
|
||||||
|
4a. Tests OK → feature validée, on passe à la suite
|
||||||
|
4b. Tests KO → rapport d'erreurs → retour à l'agent Développement
|
||||||
|
→ correction → retour à l'étape 3 (boucle jusqu'au vert)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Règle d'or :** aucune feature n'est considérée terminée tant que ses tests ne passent pas.
|
||||||
|
Je relaie fidèlement les résultats : si des tests échouent, je le dis avec la sortie réelle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Principes de code
|
||||||
|
|
||||||
|
- **SOLID** appliqué au maximum.
|
||||||
|
- **Architecture Hexagonale** (Ports & Adapters) : le domaine métier est isolé des détails techniques (UI, terminal, git, SSH, système de fichiers...).
|
||||||
|
- Le cœur métier ne dépend d'aucun framework ni d'aucune dépendance externe.
|
||||||
|
- Tests unitaires systématiques ; couverture des features critiques.
|
||||||
|
- Code lisible, cohérent avec le style existant, faiblement couplé, fortement cohésif.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Vision produit : IdeA
|
||||||
|
|
||||||
|
**IdeA est un IDE next-gen 100 % IA.** On n'y code pas : **on gère des IA.**
|
||||||
|
|
||||||
|
### Fonctionnalités clés
|
||||||
|
- **Multi-projets en parallèle** : un **onglet par projet**.
|
||||||
|
- **Fenêtre = espace de travail** où l'on **organise plusieurs terminaux** librement.
|
||||||
|
- **Agents par projet** : chaque projet a ses propres agents.
|
||||||
|
- **Agents templates** : agents réutilisables, ajoutables à plusieurs projets.
|
||||||
|
- **Création d'agents** : depuis zéro ou à partir d'un template.
|
||||||
|
- **Synchronisation template → agents** : option « garder l'agent à jour ».
|
||||||
|
Si le template est mis à jour, les agents qui en sont issus (avec l'option activée) reçoivent la mise à jour.
|
||||||
|
- **Contextes d'agents stockés en `.md`** (toujours).
|
||||||
|
- **Création de projet** = définition de son **project root**.
|
||||||
|
|
||||||
|
### Intégrations
|
||||||
|
- **Git** intégré.
|
||||||
|
- **Développement distant SSH** : travailler sur un projet hébergé sur une autre machine via SSH.
|
||||||
|
- **Développement WSL** : travailler sur une WSL depuis Windows.
|
||||||
|
|
||||||
|
### Plateformes & livraison
|
||||||
|
- Cible : **macOS, Linux, Windows**.
|
||||||
|
- Première phase de compilation : **Linux et Windows**.
|
||||||
|
- Livraison :
|
||||||
|
- **Windows** : `setup.exe`.
|
||||||
|
- **Linux** : **AppImage** (doit fonctionner sur les différentes distributions).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Stack technique (validée)
|
||||||
|
|
||||||
|
- **Shell applicatif** : **Tauri v2** (binaires légers, performants, multi-OS, AppImage + installeur `setup.exe`/NSIS Windows natifs).
|
||||||
|
- **Cœur / backend** : **Rust** — stabilité, performance, et expression idiomatique du domaine hexagonal (ports = traits, adapters = implémentations).
|
||||||
|
- **Frontend / UI** : **TypeScript + React**.
|
||||||
|
- **Terminaux** : **xterm.js** (rendu) + **portable-pty** (PTY côté Rust).
|
||||||
|
- **Git** : **libgit2** via `git2` (Rust).
|
||||||
|
- **SSH** : `russh` / `ssh2` (Rust).
|
||||||
|
- **WSL** : invocation de `wsl.exe` depuis le backend.
|
||||||
|
|
||||||
|
## 7. Layout des terminaux (exigence produit)
|
||||||
|
|
||||||
|
Disposition en **grille redimensionnable de type tableur (Excel)** :
|
||||||
|
|
||||||
|
- Splits redimensionnables horizontaux **et** verticaux.
|
||||||
|
- L'utilisateur peut **définir le nombre de colonnes dans une ligne** et **le nombre de lignes dans une colonne**, indépendamment par zone.
|
||||||
|
- Possibilité de **fusionner des cellules** (ex. fusionner deux colonnes sur une ligne), à la manière des cellules fusionnées d'un tableur.
|
||||||
|
- Chaque cellule de la grille héberge un terminal.
|
||||||
|
- → Modèle de layout récursif/imbriqué (pas une grille rigide uniforme) à concevoir par l'agent Architecture.
|
||||||
|
|
||||||
|
## 8. Stockage des contextes & liaison aux templates
|
||||||
|
|
||||||
|
- **Templates d'agents** : stockés dans l'**IDE** (dossier de données utilisateur global de l'app, hors projet).
|
||||||
|
- **Agents de projet** : leurs `.md` sont stockés dans un dossier **`.ideai/`** à la racine du project root.
|
||||||
|
*(Nom choisi pour éviter toute collision avec le `.idea` de JetBrains.)*
|
||||||
|
- **Manifeste de liaison** dans `.ideai/` (ex. `.ideai/agents.json`) qui mappe pour chaque agent de projet :
|
||||||
|
- le `.md` de l'agent,
|
||||||
|
- le template d'origine (le cas échéant),
|
||||||
|
- `synchronized: true/false`,
|
||||||
|
- la **version du template** au dernier sync (pour détecter qu'une mise à jour est disponible).
|
||||||
|
- **Synchro template → agents** : quand un template est mis à jour, les agents liés avec `synchronized: true` reçoivent la MAJ.
|
||||||
|
|
||||||
|
## 9. Moteur IA : adaptateur de CLI flexible (Port `AgentRuntime`)
|
||||||
|
|
||||||
|
Chaque IA est décrite par un **profil déclaratif** (config éditable, pas du code), implémentation d'un **Port** `AgentRuntime` côté domaine. Deux variables clés par IA :
|
||||||
|
|
||||||
|
1. **Commande de lancement** + arguments (ex. `claude`, `codex`, `gemini`, `aider`).
|
||||||
|
2. **Stratégie d'injection du contexte `.md`** :
|
||||||
|
- `conventionFile` : écrire/symlink le `.md` vers le fichier attendu par la CLI (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`…).
|
||||||
|
- `flag` : passer le chemin via un argument.
|
||||||
|
- `stdin` : piper le contenu.
|
||||||
|
- `env` : passer via variable d'environnement.
|
||||||
|
|
||||||
|
Exemple de profil :
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "claude-code",
|
||||||
|
"name": "Claude Code",
|
||||||
|
"command": "claude",
|
||||||
|
"args": [],
|
||||||
|
"contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" },
|
||||||
|
"detect": "claude --version",
|
||||||
|
"cwd": "{projectRoot}"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Profils intégrés (références) :** Claude Code (`claude` → `CLAUDE.md`), OpenAI Codex CLI (`codex` → `AGENTS.md`), Gemini CLI (`gemini` → `GEMINI.md`), Aider (`aider` → args/message).
|
||||||
|
|
||||||
|
**Règles produit :**
|
||||||
|
- **Premier lancement de l'IDE** : un assistant (first-run) **demande à l'utilisateur** quels profils d'IA configurer. On ne présume rien par défaut.
|
||||||
|
- Les commandes des profils sont **pré-remplies mais éditables**.
|
||||||
|
- L'utilisateur peut **ajouter sa propre commande CLI** (profil custom) pour n'importe quelle IA.
|
||||||
|
|
||||||
|
**Lancement d'un agent :** à l'**activation de l'agent**, on ouvre une cellule terminal (PTY) avec le bon `cwd`, on injecte le contexte `.md`, et on **auto-lance** la CLI du profil.
|
||||||
|
|
||||||
|
## 10. Fenêtres & onglets
|
||||||
|
|
||||||
|
- **Par défaut : un onglet par projet** (comme les IDE classiques).
|
||||||
|
- **Drag & drop d'un onglet** hors de la fenêtre → **crée une nouvelle fenêtre OS** portant ce projet.
|
||||||
|
- **Multi-fenêtres OS supporté** ; chaque fenêtre possède un ou plusieurs onglets/projets.
|
||||||
|
|
||||||
|
## 11. Feuille de route
|
||||||
|
|
||||||
|
1. **Cadrage architecture complet d'abord** (jalon en cours) : l'agent Architecture produit la cartographie complète — domaine, ports, adapters, modules, arborescence — **avant tout code**.
|
||||||
|
2. Puis MVP incrémental selon le cycle dev/test de la section 3.
|
||||||
|
|
||||||
|
## 12. Autonomie d'exécution dans le projet
|
||||||
|
|
||||||
|
L'utilisateur m'accorde un **accès large et autonome** sur le dossier du projet : je peux lire, créer, modifier des fichiers et exécuter les commandes de développement (cargo, npm, npx, git, etc.) **sans demander confirmation à chaque fois**.
|
||||||
|
|
||||||
|
- Concrètement, ces autorisations sont matérialisées dans `.claude/settings.local.json` (mode `acceptEdits` + `Bash`/`Read`/`Edit`/`Write` autorisés), pas dans ce document — CONTEXT.md ne fait que **documenter l'intention**.
|
||||||
|
- **Garde-fous conservés** : les actions destructrices ou hors-projet restent bloquées (`sudo`, `rm -rf` sur `/`/`~`/`$HOME`, `mkfs`, `dd`, `shutdown`/`reboot`…).
|
||||||
|
- L'esprit du rôle (§1) ne change pas : je reste **chef d'orchestre**. L'autonomie porte sur l'exécution mécanique, pas sur l'arbitrage des décisions produit/archi, ni sur les **actions sortantes** (push, publication) qui restent soumises à validation explicite.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Dernière mise à jour : 2026-06-05*
|
||||||
65
agents-dev/L12-skills.md
Normal file
65
agents-dev/L12-skills.md
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
# L12 — Skills
|
||||||
|
|
||||||
|
**Binôme :** `dev-skills` / `test-skills`
|
||||||
|
**Zones :** `domain/skill`, `application/skill`, `infrastructure/store`, `frontend/features/skills`
|
||||||
|
**Dépendances amont :** L0, L1, L5, L6 (convention file généré à l'activation), L7 (store global réutilisé).
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
Modéliser les **Skills** : workflows réutilisables (équivalent universel des slash-commands, sans dépendance à la syntaxe `/command` d'un modèle). Stockage global IDE + projet, assignation agent↔skills, **injection des skills assignés dans le convention file** généré à l'activation de l'agent. Cf. ARCHITECTURE §14.2.
|
||||||
|
|
||||||
|
## Périmètre (DEV)
|
||||||
|
- **Domaine** : entité `Skill { id, name, content_md: MarkdownDoc, scope: SkillScope(Global|Project) }`. Invariants : `name` non vide, `content_md` non vide. Event `SkillAssigned`.
|
||||||
|
- **Port `SkillStore`** : CRUD skills globaux (`<app_data>/IdeA/skills/`) + skills projet (`.ideai/skills/<name>.md`), résolution selon `scope` (compose `FileSystem`/store global comme L7).
|
||||||
|
- **AgentManifest** : étendre pour porter la liste `skills: Vec<SkillRef>` assignés à chaque agent (0..N).
|
||||||
|
- **Use cases** (`application/skill`) : `CreateSkill`, `UpdateSkill`, `DeleteSkill`, `ListSkills(scope)`, `AssignSkillToAgent`, `UnassignSkillFromAgent`.
|
||||||
|
- **Injection** : à l'activation (fil L6), composer le convention file en concaténant persona agent + chemin project root + **skills assignés** (lus via `SkillStore`). Pas de mécanisme CLI propriétaire.
|
||||||
|
- **Front** : onglet/section Skills (liste globale + projet, CRUD, éditeur md), assignation skills↔agent dans `AgentsPanel`.
|
||||||
|
|
||||||
|
## Périmètre (TEST)
|
||||||
|
- `Skill` rejette `name`/`content_md` vides.
|
||||||
|
- `SkillStore` : CRUD round-trip en tmpdir pour les deux scopes ; un skill `Project` n'apparaît pas dans le scope `Global` et inversement.
|
||||||
|
- `AssignSkillToAgent` / `UnassignSkillFromAgent` : mutent l'`AgentManifest`, émettent `SkillAssigned`, idempotents (pas de doublon).
|
||||||
|
- **Injection** : le convention file généré contient bien le `content_md` des skills assignés et **rien** des skills non assignés ; ordre déterministe.
|
||||||
|
- Front : CRUD skills + assignation via gateway mock (RTL) ; garde-fou « no direct invoke ».
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
- `cargo test` (skill/store/app) + `vitest` verts ; cycle manuel : créer un skill, l'assigner à un agent, l'activer → le skill apparaît dans le convention file de `.ideai/run/<agent-id>/`.
|
||||||
|
- DoD commune (cf. README) respectée ; zéro régression.
|
||||||
|
|
||||||
|
## Avancement
|
||||||
|
|
||||||
|
### ✅ Domaine (vert)
|
||||||
|
- **Entité `Skill`** (`domain/skill.rs`) : `id: SkillId`, `name`, `content_md: MarkdownDoc`, `scope: SkillScope(Global|Project)`. Constructeur validant (`name` + `content_md` non vides), `with_content` re-valide l'invariant.
|
||||||
|
- **`SkillRef { skill_id, scope }`** : référence d'assignation portée par l'agent ; `From<&Skill>`.
|
||||||
|
- **`SkillId`** ajouté (`ids.rs`), event **`SkillAssigned { agent_id, skill_id, assigned }`** (`events.rs`), DTO + arm de mapping côté `app-tauri` (`events.rs`).
|
||||||
|
- **`Agent`** étendu : champ `skills: Vec<SkillRef>` (serde `default`), méthodes `assign_skill` (idempotent), `unassign_skill`, `with_skills` (dédup). **`ManifestEntry`** : champ `skills` (serde `default` + `skip_serializing_if` → rétrocompat des manifests pré-L12) ; `from_agent`/`to_agent` préservent les skills.
|
||||||
|
- **Tests** : 8 invariants (`entities.rs`) + 3 serde dont rétrocompat d'un manifest legacy sans clé `skills` (`serde_roundtrip.rs`). `cargo test -p domain` vert ; `cargo test --workspace` vert (0 régression) ; clippy clean.
|
||||||
|
|
||||||
|
### ✅ Port + adapter store (vert)
|
||||||
|
- **Port `SkillStore`** (`domain/ports.rs`) : `list/get/save/delete` portant `scope` + `root: &ProjectPath` **par appel** (root ignoré pour `Global`, résolu pour `Project`) — un seul store sert tous les projets ouverts, comme `AgentContextStore`.
|
||||||
|
- **Adapter `FsSkillStore`** (`infrastructure/store/skill.rs`) : même forme on-disk que `FsTemplateStore` (`index.json` + `md/<id>.md`), deux racines disjointes : `<app_data>/skills/` (Global) et `<root>/.ideai/skills/` (Project). Delete laisse l'orphelin md (pas de remove dans le port FS), index = source de vérité. **7 tests** d'intégration tmpdir (`skill_store.rs`) : round-trip 2 scopes, **isolation de scope**, upsert, delete idempotent, camelCase.
|
||||||
|
|
||||||
|
### ✅ Use cases application (vert)
|
||||||
|
- `application/skill` : `CreateSkill`, `UpdateSkill`, `DeleteSkill`, `ListSkills(scope)` (inputs portant `project_root`), `AssignSkillToAgent` / `UnassignSkillFromAgent` (mutent l'`AgentManifest` via `to_agent`/`from_agent`, dédup, émettent `SkillAssigned`, **idempotents**). **9 tests** (`skill_usecases.rs`).
|
||||||
|
|
||||||
|
### ✅ Injection dans le convention file (vert, fil L6)
|
||||||
|
- `LaunchAgent` reçoit le port `SkillStore` ; `resolve_skills` lit les `.md` des skills assignés (ordre manifest, déterministe ; skill supprimé = `SkillRef` pendant → ignoré sans bloquer le lancement).
|
||||||
|
- `compose_convention_file` étendu : section `# Skills` (sous-titres `## <name>`) après le persona ; omise si aucun skill. **3 tests** unitaires + e2e (`agent_lifecycle.rs` : injection ordonnée, ref pendant tolérée).
|
||||||
|
- **Composition root** (`app-tauri/state.rs`) : `FsSkillStore` construit (app-data global), injecté dans `LaunchAgent`.
|
||||||
|
|
||||||
|
### ✅ IPC `app-tauri` (vert)
|
||||||
|
- **DTOs** (`dto.rs`) : `SkillDto` (transparent sur `Skill`, camelCase), `SkillListDto`, request DTOs (`Create/Update/Assign/UnassignSkillRequestDto`), `parse_skill_id`. `scope` désérialise directement vers `SkillScope` (`"global"`/`"project"`).
|
||||||
|
- **Commandes** (`commands.rs`) : `create_skill`, `update_skill`, `list_skills`, `delete_skill`, `assign_skill_to_agent`, `unassign_skill_from_agent` — shells fins qui résolvent le `Project` (→ `project.root`) puis appellent le use case. Enregistrées dans `lib.rs`.
|
||||||
|
- **Composition root** (`state.rs`) : 6 use cases skill câblés sur le `skill_store_port` (déjà construit pour le launcher) et le `contexts_port` partagé.
|
||||||
|
- `cargo build -p app-tauri` + `cargo test --workspace` (304) verts ; clippy clean.
|
||||||
|
|
||||||
|
### ✅ Front `features/skills` (vert)
|
||||||
|
- **Domaine** (`domain/index.ts`) : `SkillScope`, `Skill`, `SkillRef` ; `Agent` étendu avec `skills: SkillRef[]`.
|
||||||
|
- **Port** (`ports/index.ts`) : `SkillGateway` (list/create/update/delete + assign/unassign) + `CreateSkillInput` ; ajouté à `Gateways`.
|
||||||
|
- **Adapters** : `TauriSkillGateway` (`adapters/skill.ts`, invoke camelCase) ; `MockSkillGateway` (`adapters/mock`, scopes disjoints + mutation partagée du `MockAgentGateway` via `_setSkills`, assign idempotent).
|
||||||
|
- **Feature** : `useSkills` (VM 2 scopes), `SkillEditor` (overlay md edit/preview + sélecteur de scope), `SkillsPanel` (listes Project/Global, CRUD). Onglet **Skills** ajouté dans `ProjectsView`.
|
||||||
|
- **Assignation** dans `AgentsPanel` : chips des skills assignés + sélecteur d'assignation + unassign, sur l'agent sélectionné ; refresh après mutation.
|
||||||
|
- **Tests** (`skills.test.tsx`, RTL via `DIProvider` + mocks) : CRUD project/global, isolation de scope, édition, suppression, assign/unassign reflétés sur l'agent, idempotence, **garde-fou « no direct invoke »** (aucune action run/launch). `vitest` : **229** verts (0 régression ; test « ten gateways » mis à jour).
|
||||||
|
|
||||||
|
### ⏳ Reste à faire
|
||||||
|
- Cycle manuel : créer un skill, l'assigner à un agent, l'activer → vérifier qu'il apparaît dans le convention file de `.ideai/run/<agent-id>/` (à faire sur l'AppImage).
|
||||||
35
agents-dev/L13-orchestrator.md
Normal file
35
agents-dev/L13-orchestrator.md
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# L13 — OrchestratorApi
|
||||||
|
|
||||||
|
**Binôme :** `dev-orchestrator` / `test-orchestrator`
|
||||||
|
**Zones :** `infrastructure/orchestrator`, `application/agent`, `app-tauri`
|
||||||
|
**Dépendances amont :** L0, L1, L6 (`LaunchAgent`/`StopAgent`), L12 (`update_agent_context` peut toucher les skills).
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
Permettre à un **agent orchestrateur** de demander à IdeA de créer/arrêter/mettre à jour un agent — **exactement** comme l'utilisateur via l'UI. L'orchestrateur ne spawne jamais lui-même un process CLI : il **délègue à IdeA**, unique source de vérité du cycle de vie des agents. Cf. ARCHITECTURE §14.3.
|
||||||
|
|
||||||
|
## Périmètre (DEV)
|
||||||
|
- **Port `OrchestratorApi`** (adapter *entrant*, driven by file-watcher) : surveille `.ideai/requests/<requester-id>/`, désérialise les requêtes JSON, les traduit en appels de use cases.
|
||||||
|
- **Adapter `FsOrchestratorAdapter`** (`infrastructure/orchestrator`) : file-watching (`notify`), parse, dispatch, **supprime le fichier de requête** et **écrit une réponse** (succès/erreur) à côté.
|
||||||
|
- **Actions v1** : `spawn_agent` (→ `LaunchAgent`), `stop_agent` (→ `StopAgent`), `update_agent_context` (réécrit le `.md` de l'agent ± skills).
|
||||||
|
- **Event** : `OrchestratorRequest { requester_id, action }`.
|
||||||
|
- **Schéma requête** :
|
||||||
|
```json
|
||||||
|
{ "action": "spawn_agent", "name": "dev-backend", "profile": "claude-code", "context": "agents/dev-backend.md" }
|
||||||
|
```
|
||||||
|
- Le résultat d'un `spawn_agent` est **identique** à un lancement UI : cellule terminal créée, agent inscrit dans l'onglet Agents.
|
||||||
|
- **Composition root** (`app-tauri`) : démarrer le watcher, brancher sur les use cases existants ; arrêt propre à la fermeture.
|
||||||
|
|
||||||
|
## Périmètre (TEST)
|
||||||
|
- Désérialisation : requêtes valides → action typée ; requête malformée → réponse d'erreur, **pas de crash**, pas de spawn.
|
||||||
|
- `spawn_agent` invoque `LaunchAgent` avec les bons args (use case mocké) ; idempotence sur double-dépôt du même fichier (traité une fois).
|
||||||
|
- Après traitement : fichier de requête **supprimé**, fichier de réponse écrit avec le bon statut.
|
||||||
|
- `stop_agent` / `update_agent_context` : mappent vers les bons use cases ; cible inexistante → erreur propre.
|
||||||
|
- Watcher : un fichier déposé dans `.ideai/requests/<id>/` est détecté (test d'intégration tmpdir + `notify`).
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
- `cargo test` (orchestrator/app) verts ; cycle manuel : un agent écrit un fichier de requête `spawn_agent` → un nouvel agent apparaît dans la grille et l'onglet Agents, fichier consommé + réponse écrite.
|
||||||
|
- Garde-fou : l'orchestrateur ne lance **aucun** process directement (vérifié par revue + absence de `ProcessSpawner` dans le chemin orchestrateur).
|
||||||
|
- DoD commune respectée ; zéro régression ; git reste optionnel (rien dans ce lot n'en dépend, §14.4).
|
||||||
|
|
||||||
|
## Avancement
|
||||||
|
⬜ À démarrer. Cadrage figé dans ARCHITECTURE §14.3.
|
||||||
@ -26,6 +26,8 @@ Un fichier `Lx-*.md` par binôme décrit son périmètre, ses ports/adapters, se
|
|||||||
| L9 | [L9-remote.md](L9-remote.md) | 🟡 **socle vert** (LocalHost + ConnectRemote, Liskov) · SSH/WSL gated à venir |
|
| L9 | [L9-remote.md](L9-remote.md) | 🟡 **socle vert** (LocalHost + ConnectRemote, Liskov) · SSH/WSL gated à venir |
|
||||||
| L10 | [L10-windows.md](L10-windows.md) | 🟡 **backend + IPC verts** (move-tab + `WebviewWindow`) · détach UI fait avec la refonte L11 |
|
| L10 | [L10-windows.md](L10-windows.md) | 🟡 **backend + IPC verts** (move-tab + `WebviewWindow`) · détach UI fait avec la refonte L11 |
|
||||||
| L11 | [L11-packaging.md](L11-packaging.md) | 🟡 AppImage Linux **OK** · refonte disposition IDE en cours · Windows/CI à venir |
|
| L11 | [L11-packaging.md](L11-packaging.md) | 🟡 AppImage Linux **OK** · refonte disposition IDE en cours · Windows/CI à venir |
|
||||||
|
| L12 | [L12-skills.md](L12-skills.md) | ⬜ **Skills** — entité + store + assignation + injection convention file + UI |
|
||||||
|
| L13 | [L13-orchestrator.md](L13-orchestrator.md) | ⬜ **OrchestratorApi** — file-watcher, spawn depuis agent ou UI, même résultat |
|
||||||
|
|
||||||
## Cycle dev ↔ test (obligatoire, cf. CONTEXT §3)
|
## Cycle dev ↔ test (obligatoire, cf. CONTEXT §3)
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,8 @@ use application::{
|
|||||||
GitStatusInput, LaunchAgentInput, ListAgentsInput, ListLayoutsInput, LoadLayoutInput,
|
GitStatusInput, LaunchAgentInput, ListAgentsInput, ListLayoutsInput, LoadLayoutInput,
|
||||||
MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, RenameLayoutInput,
|
MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, RenameLayoutInput,
|
||||||
SetActiveLayoutInput, SyncAgentWithTemplateInput, UpdateAgentContextInput,
|
SetActiveLayoutInput, SyncAgentWithTemplateInput, UpdateAgentContextInput,
|
||||||
|
AssignSkillToAgentInput, CreateSkillInput, DeleteSkillInput, ListSkillsInput,
|
||||||
|
UnassignSkillFromAgentInput, UpdateSkillInput,
|
||||||
};
|
};
|
||||||
use domain::ports::PtyHandle;
|
use domain::ports::PtyHandle;
|
||||||
|
|
||||||
@ -28,11 +30,13 @@ use crate::dto::{
|
|||||||
GitStageRequestDto, GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto,
|
GitStageRequestDto, GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto,
|
||||||
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, OpenTerminalRequestDto,
|
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, OpenTerminalRequestDto,
|
||||||
ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, ReadAgentContextResponseDto,
|
ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, ReadAgentContextResponseDto,
|
||||||
RenameLayoutRequestDto, ResizeTerminalRequestDto, SaveProfileRequestDto,
|
ReattachResultDto, RenameLayoutRequestDto, ResizeTerminalRequestDto, SaveProfileRequestDto,
|
||||||
SetActiveLayoutRequestDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
|
SetActiveLayoutRequestDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
|
||||||
TemplateListDto, TerminalClosedDto, TerminalSessionDto, UpdateAgentContextRequestDto,
|
TemplateListDto, TerminalClosedDto, TerminalSessionDto, UpdateAgentContextRequestDto,
|
||||||
UpdateTemplateRequestDto, WriteTerminalRequestDto,
|
UpdateTemplateRequestDto, WriteTerminalRequestDto, parse_skill_id, AssignSkillRequestDto,
|
||||||
|
CreateSkillRequestDto, SkillDto, SkillListDto, UnassignSkillRequestDto, UpdateSkillRequestDto,
|
||||||
};
|
};
|
||||||
|
use domain::{SkillRef, SkillScope};
|
||||||
use crate::pty::{PtyBridge, PtyChunk};
|
use crate::pty::{PtyBridge, PtyChunk};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@ -244,6 +248,69 @@ pub async fn close_terminal(
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `reattach_terminal` — re-bind a view to a **still-living** PTY without
|
||||||
|
/// re-spawning it.
|
||||||
|
///
|
||||||
|
/// Navigation (switching layout/tab) tears the xterm view down but must NOT kill
|
||||||
|
/// the backend PTY (the AI keeps running). When the view comes back it calls this
|
||||||
|
/// command, which:
|
||||||
|
/// 1. reads the session's retained **scrollback** so the terminal can repaint,
|
||||||
|
/// 2. registers the new per-session [`Channel`] in the [`PtyBridge`],
|
||||||
|
/// 3. starts a fresh output pump subscribed to the live PTY (re-subscribable
|
||||||
|
/// broadcast), so new bytes flow to the new channel.
|
||||||
|
///
|
||||||
|
/// Returns the scrollback bytes; the frontend writes them into xterm first, then
|
||||||
|
/// receives subsequent output over `on_output`.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND`/`PROCESS`
|
||||||
|
/// if the session is no longer alive).
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn reattach_terminal(
|
||||||
|
session_id: String,
|
||||||
|
on_output: Channel<PtyChunk>,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<ReattachResultDto, ErrorDto> {
|
||||||
|
let sid = parse_session_id(&session_id)?;
|
||||||
|
let handle = PtyHandle { session_id: sid };
|
||||||
|
|
||||||
|
// (1) Snapshot the scrollback. A NotFound here means the PTY is gone (was
|
||||||
|
// explicitly closed or exited) — surfaced as an error so the caller falls
|
||||||
|
// back to opening a fresh terminal.
|
||||||
|
let scrollback = state
|
||||||
|
.pty_port
|
||||||
|
.scrollback(&handle)
|
||||||
|
.map_err(|e| ErrorDto::from(AppError::from(e)))?;
|
||||||
|
|
||||||
|
// (2) Register the new output channel for this session, replacing any stale
|
||||||
|
// one from a previous attach.
|
||||||
|
state.pty_bridge.register(sid, on_output);
|
||||||
|
|
||||||
|
// (3) Subscribe afresh to the live byte stream and pump it to the channel.
|
||||||
|
match state.pty_port.subscribe_output(&handle) {
|
||||||
|
Ok(stream) => {
|
||||||
|
let bridge: std::sync::Arc<PtyBridge> = std::sync::Arc::clone(&state.pty_bridge);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
for chunk in stream {
|
||||||
|
if !bridge.send_output(&sid, chunk) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bridge.unregister(&sid);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
state.pty_bridge.unregister(&sid);
|
||||||
|
return Err(ErrorDto::from(AppError::from(e)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ReattachResultDto {
|
||||||
|
session_id,
|
||||||
|
scrollback,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Layout (L4)
|
// Layout (L4)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -1119,3 +1186,157 @@ pub async fn move_tab_to_new_window(
|
|||||||
|
|
||||||
Ok(MoveTabResultDto::from(out))
|
Ok(MoveTabResultDto::from(out))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Skills (L12)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// `create_skill` — create a skill in its scope's store.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] (`INVALID` for an empty name/content or malformed
|
||||||
|
/// project id, `NOT_FOUND` if the project is unknown, `STORE` on failure).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn create_skill(
|
||||||
|
request: CreateSkillRequestDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<SkillDto, ErrorDto> {
|
||||||
|
let project = resolve_project(&request.project_id, &state).await?;
|
||||||
|
state
|
||||||
|
.create_skill
|
||||||
|
.execute(CreateSkillInput {
|
||||||
|
name: request.name,
|
||||||
|
content: request.content,
|
||||||
|
scope: request.scope,
|
||||||
|
project_root: project.root,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map(SkillDto::from)
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `update_skill` — replace a skill's Markdown content.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] (`INVALID` for malformed ids or empty content,
|
||||||
|
/// `NOT_FOUND` if the project or skill is unknown, `STORE` on failure).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_skill(
|
||||||
|
request: UpdateSkillRequestDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<SkillDto, ErrorDto> {
|
||||||
|
let project = resolve_project(&request.project_id, &state).await?;
|
||||||
|
let skill_id = parse_skill_id(&request.skill_id)?;
|
||||||
|
state
|
||||||
|
.update_skill
|
||||||
|
.execute(UpdateSkillInput {
|
||||||
|
scope: request.scope,
|
||||||
|
skill_id,
|
||||||
|
content: request.content,
|
||||||
|
project_root: project.root,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map(SkillDto::from)
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `list_skills` — list the skills in one scope.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
|
||||||
|
/// project is unknown, `STORE` on failure).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_skills(
|
||||||
|
project_id: String,
|
||||||
|
scope: SkillScope,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<SkillListDto, ErrorDto> {
|
||||||
|
let project = resolve_project(&project_id, &state).await?;
|
||||||
|
state
|
||||||
|
.list_skills
|
||||||
|
.execute(ListSkillsInput {
|
||||||
|
scope,
|
||||||
|
project_root: project.root,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map(SkillListDto::from)
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `delete_skill` — remove a skill from its scope's store.
|
||||||
|
///
|
||||||
|
/// Agents that referenced it keep their `SkillRef`; injection simply skips the
|
||||||
|
/// now-absent skill.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] (`INVALID` for malformed ids, `NOT_FOUND` if the
|
||||||
|
/// project or skill is unknown, `STORE` on failure).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_skill(
|
||||||
|
project_id: String,
|
||||||
|
scope: SkillScope,
|
||||||
|
skill_id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), ErrorDto> {
|
||||||
|
let project = resolve_project(&project_id, &state).await?;
|
||||||
|
let id = parse_skill_id(&skill_id)?;
|
||||||
|
state
|
||||||
|
.delete_skill
|
||||||
|
.execute(DeleteSkillInput {
|
||||||
|
scope,
|
||||||
|
skill_id: id,
|
||||||
|
project_root: project.root,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `assign_skill_to_agent` — record a `SkillRef` in the agent's manifest entry.
|
||||||
|
/// Idempotent.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] (`INVALID` for malformed ids, `NOT_FOUND` if the
|
||||||
|
/// project or agent is unknown, `STORE` on failure).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn assign_skill_to_agent(
|
||||||
|
request: AssignSkillRequestDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), ErrorDto> {
|
||||||
|
let project = resolve_project(&request.project_id, &state).await?;
|
||||||
|
let agent_id = parse_agent_id(&request.agent_id)?;
|
||||||
|
let skill_id = parse_skill_id(&request.skill_id)?;
|
||||||
|
state
|
||||||
|
.assign_skill
|
||||||
|
.execute(AssignSkillToAgentInput {
|
||||||
|
project,
|
||||||
|
agent_id,
|
||||||
|
skill: SkillRef::new(skill_id, request.scope),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `unassign_skill_from_agent` — drop a skill assignment from an agent.
|
||||||
|
/// Idempotent.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] (`INVALID` for malformed ids, `NOT_FOUND` if the
|
||||||
|
/// project or agent is unknown, `STORE` on failure).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn unassign_skill_from_agent(
|
||||||
|
request: UnassignSkillRequestDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), ErrorDto> {
|
||||||
|
let project = resolve_project(&request.project_id, &state).await?;
|
||||||
|
let agent_id = parse_agent_id(&request.agent_id)?;
|
||||||
|
let skill_id = parse_skill_id(&request.skill_id)?;
|
||||||
|
state
|
||||||
|
.unassign_skill
|
||||||
|
.execute(UnassignSkillFromAgentInput {
|
||||||
|
project,
|
||||||
|
agent_id,
|
||||||
|
skill_id,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|||||||
@ -245,6 +245,18 @@ impl From<OpenTerminalOutput> for TerminalSessionDto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Response DTO for `reattach_terminal`: the retained scrollback of a still-live
|
||||||
|
/// session, repainted into the re-mounting xterm before the new output stream is
|
||||||
|
/// wired. Bytes are serialised as a number array, matching the PTY output channel.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ReattachResultDto {
|
||||||
|
/// The session that was re-attached (echoed back for the frontend).
|
||||||
|
pub session_id: String,
|
||||||
|
/// The most-recent retained output bytes (scrollback ring buffer).
|
||||||
|
pub scrollback: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Request DTO for `write_terminal`.
|
/// Request DTO for `write_terminal`.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -1330,3 +1342,107 @@ pub fn parse_tab_id(raw: &str) -> Result<TabId, ErrorDto> {
|
|||||||
message: format!("invalid tab id: {raw}"),
|
message: format!("invalid tab id: {raw}"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Skills (L12)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
use application::{CreateSkillOutput, ListSkillsOutput, UpdateSkillOutput};
|
||||||
|
use domain::{Skill, SkillId, SkillScope};
|
||||||
|
|
||||||
|
/// A skill crossing the wire. [`Skill`] already serialises camelCase
|
||||||
|
/// (`id`, `name`, `contentMd`, `scope` as `"global"`/`"project"`), so we embed
|
||||||
|
/// it directly — the TS mirror matches this shape.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct SkillDto(pub Skill);
|
||||||
|
|
||||||
|
/// A list of skills (transparent array on the wire).
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct SkillListDto(pub Vec<SkillDto>);
|
||||||
|
|
||||||
|
impl From<ListSkillsOutput> for SkillListDto {
|
||||||
|
fn from(out: ListSkillsOutput) -> Self {
|
||||||
|
Self(out.skills.into_iter().map(SkillDto).collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CreateSkillOutput> for SkillDto {
|
||||||
|
fn from(out: CreateSkillOutput) -> Self {
|
||||||
|
Self(out.skill)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<UpdateSkillOutput> for SkillDto {
|
||||||
|
fn from(out: UpdateSkillOutput) -> Self {
|
||||||
|
Self(out.skill)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request DTO for `create_skill`.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CreateSkillRequestDto {
|
||||||
|
/// Owning project (resolved to a root; ignored on disk for `Global`).
|
||||||
|
pub project_id: String,
|
||||||
|
/// Display name.
|
||||||
|
pub name: String,
|
||||||
|
/// Initial Markdown content.
|
||||||
|
pub content: String,
|
||||||
|
/// Scope the skill is created in.
|
||||||
|
pub scope: SkillScope,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request DTO for `update_skill`.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UpdateSkillRequestDto {
|
||||||
|
/// Owning project (resolved to a root; ignored on disk for `Global`).
|
||||||
|
pub project_id: String,
|
||||||
|
/// Id of the skill to update.
|
||||||
|
pub skill_id: String,
|
||||||
|
/// Scope the skill lives in.
|
||||||
|
pub scope: SkillScope,
|
||||||
|
/// New Markdown content.
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request DTO for `assign_skill_to_agent`.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AssignSkillRequestDto {
|
||||||
|
/// Owning project.
|
||||||
|
pub project_id: String,
|
||||||
|
/// Agent receiving the skill.
|
||||||
|
pub agent_id: String,
|
||||||
|
/// Skill to assign.
|
||||||
|
pub skill_id: String,
|
||||||
|
/// Scope of the skill (recorded alongside the ref).
|
||||||
|
pub scope: SkillScope,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request DTO for `unassign_skill_from_agent`.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UnassignSkillRequestDto {
|
||||||
|
/// Owning project.
|
||||||
|
pub project_id: String,
|
||||||
|
/// Agent losing the skill.
|
||||||
|
pub agent_id: String,
|
||||||
|
/// Skill to unassign.
|
||||||
|
pub skill_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a skill-id string (UUID) coming from the frontend.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID.
|
||||||
|
pub fn parse_skill_id(raw: &str) -> Result<SkillId, ErrorDto> {
|
||||||
|
uuid::Uuid::parse_str(raw)
|
||||||
|
.map(SkillId::from_uuid)
|
||||||
|
.map_err(|_| ErrorDto {
|
||||||
|
code: "INVALID".to_owned(),
|
||||||
|
message: format!("invalid skill id: {raw}"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -73,6 +73,16 @@ pub enum DomainEventDto {
|
|||||||
/// Version synced to.
|
/// Version synced to.
|
||||||
to: u64,
|
to: u64,
|
||||||
},
|
},
|
||||||
|
/// A skill was assigned to (or unassigned from) an agent.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
SkillAssigned {
|
||||||
|
/// Agent id.
|
||||||
|
agent_id: String,
|
||||||
|
/// Skill id.
|
||||||
|
skill_id: String,
|
||||||
|
/// `true` if assigned, `false` if unassigned.
|
||||||
|
assigned: bool,
|
||||||
|
},
|
||||||
/// A tab's layout changed.
|
/// A tab's layout changed.
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
LayoutChanged {
|
LayoutChanged {
|
||||||
@ -138,6 +148,15 @@ impl From<&DomainEvent> for DomainEventDto {
|
|||||||
agent_id: agent_id.to_string(),
|
agent_id: agent_id.to_string(),
|
||||||
to: to.get(),
|
to: to.get(),
|
||||||
},
|
},
|
||||||
|
DomainEvent::SkillAssigned {
|
||||||
|
agent_id,
|
||||||
|
skill_id,
|
||||||
|
assigned,
|
||||||
|
} => Self::SkillAssigned {
|
||||||
|
agent_id: agent_id.to_string(),
|
||||||
|
skill_id: skill_id.to_string(),
|
||||||
|
assigned: *assigned,
|
||||||
|
},
|
||||||
DomainEvent::LayoutChanged { project_id } => Self::LayoutChanged {
|
DomainEvent::LayoutChanged { project_id } => Self::LayoutChanged {
|
||||||
project_id: project_id.to_string(),
|
project_id: project_id.to_string(),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -48,6 +48,28 @@ pub fn run() {
|
|||||||
events::spawn_relay(app.handle().clone(), &app_state.event_bus);
|
events::spawn_relay(app.handle().clone(), &app_state.event_bus);
|
||||||
|
|
||||||
app.manage(app_state);
|
app.manage(app_state);
|
||||||
|
|
||||||
|
// Kill all live PTYs cleanly when the main window is closing. This is
|
||||||
|
// independent of the per-view (navigation/layout) lifecycle — those
|
||||||
|
// must NEVER kill a PTY — and only fires on a genuine app shutdown.
|
||||||
|
// A brutal crash is best-effort and out of scope.
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let handle = app.handle().clone();
|
||||||
|
window.on_window_event(move |event| {
|
||||||
|
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
||||||
|
if let Some(state) = handle.try_state::<AppState>() {
|
||||||
|
let pty = std::sync::Arc::clone(&state.pty_port);
|
||||||
|
let handles = state.terminal_sessions.handles();
|
||||||
|
tauri::async_runtime::block_on(async move {
|
||||||
|
for h in handles {
|
||||||
|
let _ = pty.kill(&h).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
@ -60,6 +82,7 @@ pub fn run() {
|
|||||||
commands::write_terminal,
|
commands::write_terminal,
|
||||||
commands::resize_terminal,
|
commands::resize_terminal,
|
||||||
commands::close_terminal,
|
commands::close_terminal,
|
||||||
|
commands::reattach_terminal,
|
||||||
commands::load_layout,
|
commands::load_layout,
|
||||||
commands::mutate_layout,
|
commands::mutate_layout,
|
||||||
commands::list_layouts,
|
commands::list_layouts,
|
||||||
@ -96,6 +119,12 @@ pub fn run() {
|
|||||||
commands::git_log,
|
commands::git_log,
|
||||||
commands::git_init,
|
commands::git_init,
|
||||||
commands::git_graph,
|
commands::git_graph,
|
||||||
|
commands::create_skill,
|
||||||
|
commands::update_skill,
|
||||||
|
commands::list_skills,
|
||||||
|
commands::delete_skill,
|
||||||
|
commands::assign_skill_to_agent,
|
||||||
|
commands::unassign_skill_from_agent,
|
||||||
commands::move_tab_to_new_window,
|
commands::move_tab_to_new_window,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
|
|||||||
@ -13,18 +13,19 @@ use application::{
|
|||||||
CreateAgentFromTemplate, CreateLayout, CreateProject, CreateTemplate, DeleteAgent,
|
CreateAgentFromTemplate, CreateLayout, CreateProject, CreateTemplate, DeleteAgent,
|
||||||
DeleteLayout, DeleteProfile, DeleteTemplate, DetectAgentDrift, DetectProfiles, FirstRunState,
|
DeleteLayout, DeleteProfile, DeleteTemplate, DetectAgentDrift, DetectProfiles, FirstRunState,
|
||||||
GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage,
|
GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage,
|
||||||
HealthUseCase, LaunchAgent, ListAgents, ListLayouts, ListProfiles, ListProjects, ListTemplates,
|
HealthUseCase, LaunchAgent, ListAgents, ListLayouts, ListProfiles, ListProjects, ListSkills,
|
||||||
LoadLayout, MoveTabToNewWindow, MutateLayout, OpenProject, OpenTerminal, ReadAgentContext,
|
ListTemplates, LoadLayout, MoveTabToNewWindow, MutateLayout, OpenProject, OpenTerminal,
|
||||||
ReferenceProfiles, RenameLayout, ResizeTerminal, SaveProfile, SetActiveLayout,
|
ReadAgentContext, ReferenceProfiles, RenameLayout, ResizeTerminal, SaveProfile, SetActiveLayout,
|
||||||
|
AssignSkillToAgent, CreateSkill, DeleteSkill, UnassignSkillFromAgent, UpdateSkill,
|
||||||
SyncAgentWithTemplate, TerminalSessions, UpdateAgentContext, UpdateTemplate, WriteToTerminal,
|
SyncAgentWithTemplate, TerminalSessions, UpdateAgentContext, UpdateTemplate, WriteToTerminal,
|
||||||
};
|
};
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AgentContextStore, AgentRuntime, Clock, EventBus, FileSystem, GitPort, IdGenerator,
|
AgentContextStore, AgentRuntime, Clock, EventBus, FileSystem, GitPort, IdGenerator,
|
||||||
ProcessSpawner, ProfileStore, ProjectStore, PtyPort, TemplateStore,
|
ProcessSpawner, ProfileStore, ProjectStore, PtyPort, SkillStore, TemplateStore,
|
||||||
};
|
};
|
||||||
|
|
||||||
use infrastructure::{
|
use infrastructure::{
|
||||||
CliAgentRuntime, FsProfileStore, FsProjectStore, FsTemplateStore, Git2Repository,
|
CliAgentRuntime, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, Git2Repository,
|
||||||
IdeaiContextStore, LocalFileSystem, LocalProcessSpawner, PortablePtyAdapter, SystemClock,
|
IdeaiContextStore, LocalFileSystem, LocalProcessSpawner, PortablePtyAdapter, SystemClock,
|
||||||
TokioBroadcastEventBus, UuidGenerator,
|
TokioBroadcastEventBus, UuidGenerator,
|
||||||
};
|
};
|
||||||
@ -147,6 +148,19 @@ pub struct AppState {
|
|||||||
pub git_init: Arc<GitInit>,
|
pub git_init: Arc<GitInit>,
|
||||||
/// Return the commit graph for all local branches.
|
/// Return the commit graph for all local branches.
|
||||||
pub git_graph: Arc<GitGraph>,
|
pub git_graph: Arc<GitGraph>,
|
||||||
|
// --- Skills (L12) ---
|
||||||
|
/// Create a skill in a scope's store.
|
||||||
|
pub create_skill: Arc<CreateSkill>,
|
||||||
|
/// Update a skill's content.
|
||||||
|
pub update_skill: Arc<UpdateSkill>,
|
||||||
|
/// List skills in a scope.
|
||||||
|
pub list_skills: Arc<ListSkills>,
|
||||||
|
/// Delete a skill from its scope's store.
|
||||||
|
pub delete_skill: Arc<DeleteSkill>,
|
||||||
|
/// Assign a skill to an agent (records a `SkillRef`).
|
||||||
|
pub assign_skill: Arc<AssignSkillToAgent>,
|
||||||
|
/// Unassign a skill from an agent.
|
||||||
|
pub unassign_skill: Arc<UnassignSkillFromAgent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
@ -287,6 +301,17 @@ impl AppState {
|
|||||||
let contexts = Arc::new(IdeaiContextStore::new(Arc::clone(&fs_port)));
|
let contexts = Arc::new(IdeaiContextStore::new(Arc::clone(&fs_port)));
|
||||||
let contexts_port = Arc::clone(&contexts) as Arc<dyn AgentContextStore>;
|
let contexts_port = Arc::clone(&contexts) as Arc<dyn AgentContextStore>;
|
||||||
|
|
||||||
|
// --- Skill store (L12) ---
|
||||||
|
// Global skills live in the machine-local app-data dir; project skills are
|
||||||
|
// resolved per call from each project's `.ideai/` (so one store serves all
|
||||||
|
// open projects). Shared by the skill use cases and the agent launcher
|
||||||
|
// (assigned-skill injection into the convention file, §14.2).
|
||||||
|
let skill_store = Arc::new(FsSkillStore::new(
|
||||||
|
Arc::clone(&fs_port),
|
||||||
|
app_data_dir.to_string_lossy().into_owned(),
|
||||||
|
));
|
||||||
|
let skill_store_port = Arc::clone(&skill_store) as Arc<dyn SkillStore>;
|
||||||
|
|
||||||
let create_agent = Arc::new(CreateAgentFromScratch::new(
|
let create_agent = Arc::new(CreateAgentFromScratch::new(
|
||||||
Arc::clone(&contexts_port),
|
Arc::clone(&contexts_port),
|
||||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||||
@ -307,6 +332,7 @@ impl AppState {
|
|||||||
Arc::clone(&runtime_port),
|
Arc::clone(&runtime_port),
|
||||||
Arc::clone(&fs_port),
|
Arc::clone(&fs_port),
|
||||||
Arc::clone(&pty_port),
|
Arc::clone(&pty_port),
|
||||||
|
Arc::clone(&skill_store_port),
|
||||||
Arc::clone(&terminal_sessions),
|
Arc::clone(&terminal_sessions),
|
||||||
Arc::clone(&events_port),
|
Arc::clone(&events_port),
|
||||||
));
|
));
|
||||||
@ -364,6 +390,25 @@ impl AppState {
|
|||||||
let git_init = Arc::new(GitInit::new(Arc::clone(&git_port), Arc::clone(&events_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)));
|
let git_graph = Arc::new(GitGraph::new(Arc::clone(&git_port)));
|
||||||
|
|
||||||
|
// --- Skill use cases (L12) ---
|
||||||
|
// Reuse the skill store (built above for the launcher) and the shared
|
||||||
|
// agent context store for the agent↔skill assignment.
|
||||||
|
let create_skill = Arc::new(CreateSkill::new(
|
||||||
|
Arc::clone(&skill_store_port),
|
||||||
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||||
|
));
|
||||||
|
let update_skill = Arc::new(UpdateSkill::new(Arc::clone(&skill_store_port)));
|
||||||
|
let list_skills = Arc::new(ListSkills::new(Arc::clone(&skill_store_port)));
|
||||||
|
let delete_skill = Arc::new(DeleteSkill::new(Arc::clone(&skill_store_port)));
|
||||||
|
let assign_skill = Arc::new(AssignSkillToAgent::new(
|
||||||
|
Arc::clone(&contexts_port),
|
||||||
|
Arc::clone(&events_port),
|
||||||
|
));
|
||||||
|
let unassign_skill = Arc::new(UnassignSkillFromAgent::new(
|
||||||
|
Arc::clone(&contexts_port),
|
||||||
|
Arc::clone(&events_port),
|
||||||
|
));
|
||||||
|
|
||||||
// --- Windows (L10) ---
|
// --- Windows (L10) ---
|
||||||
let move_tab = Arc::new(MoveTabToNewWindow::new(
|
let move_tab = Arc::new(MoveTabToNewWindow::new(
|
||||||
Arc::clone(&store_port),
|
Arc::clone(&store_port),
|
||||||
@ -422,6 +467,12 @@ impl AppState {
|
|||||||
git_log,
|
git_log,
|
||||||
git_init,
|
git_init,
|
||||||
git_graph,
|
git_graph,
|
||||||
|
create_skill,
|
||||||
|
update_skill,
|
||||||
|
list_skills,
|
||||||
|
delete_skill,
|
||||||
|
assign_skill,
|
||||||
|
unassign_skill,
|
||||||
move_tab,
|
move_tab,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
use app_tauri_lib::dto::{
|
use app_tauri_lib::dto::{
|
||||||
parse_node_id, parse_session_id, ErrorDto, HealthRequestDto, HealthResponseDto, LayoutDto,
|
parse_node_id, parse_session_id, ErrorDto, HealthRequestDto, HealthResponseDto, LayoutDto,
|
||||||
LayoutOperationDto, OpenTerminalRequestDto, ResizeTerminalRequestDto, TerminalClosedDto,
|
LayoutOperationDto, OpenTerminalRequestDto, ReattachResultDto, ResizeTerminalRequestDto,
|
||||||
WriteTerminalRequestDto,
|
TerminalClosedDto, WriteTerminalRequestDto,
|
||||||
};
|
};
|
||||||
use application::{CloseTerminalOutput, LayoutOperation, LoadLayoutOutput, OpenTerminalInput};
|
use application::{CloseTerminalOutput, LayoutOperation, LoadLayoutOutput, OpenTerminalInput};
|
||||||
use domain::{Direction, LayoutNode, LayoutTree, LeafCell, NodeId};
|
use domain::{Direction, LayoutNode, LayoutTree, LeafCell, NodeId};
|
||||||
@ -191,6 +191,16 @@ fn terminal_closed_dto_serialises_code_camel_case() {
|
|||||||
assert_eq!(serde_json::to_value(&none).unwrap(), json!({ "code": null }));
|
assert_eq!(serde_json::to_value(&none).unwrap(), json!({ "code": null }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reattach_result_dto_serialises_camel_case() {
|
||||||
|
let dto = ReattachResultDto {
|
||||||
|
session_id: "sess-1".into(),
|
||||||
|
scrollback: vec![104, 105],
|
||||||
|
};
|
||||||
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
|
assert_eq!(v, json!({ "sessionId": "sess-1", "scrollback": [104, 105] }));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_session_id_accepts_uuid_and_rejects_garbage() {
|
fn parse_session_id_accepts_uuid_and_rejects_garbage() {
|
||||||
let sid = SessionId::from_uuid(Uuid::nil());
|
let sid = SessionId::from_uuid(Uuid::nil());
|
||||||
|
|||||||
@ -53,7 +53,7 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
|||||||
ContextInjection::convention_file("CLAUDE.md")
|
ContextInjection::convention_file("CLAUDE.md")
|
||||||
.expect("CLAUDE.md is a valid convention target"),
|
.expect("CLAUDE.md is a valid convention target"),
|
||||||
Some("claude --version".to_owned()),
|
Some("claude --version".to_owned()),
|
||||||
"{projectRoot}",
|
"{agentRunDir}",
|
||||||
)
|
)
|
||||||
.expect("claude reference profile is valid"),
|
.expect("claude reference profile is valid"),
|
||||||
AgentProfile::new(
|
AgentProfile::new(
|
||||||
@ -64,7 +64,7 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
|||||||
ContextInjection::convention_file("AGENTS.md")
|
ContextInjection::convention_file("AGENTS.md")
|
||||||
.expect("AGENTS.md is a valid convention target"),
|
.expect("AGENTS.md is a valid convention target"),
|
||||||
Some("codex --version".to_owned()),
|
Some("codex --version".to_owned()),
|
||||||
"{projectRoot}",
|
"{agentRunDir}",
|
||||||
)
|
)
|
||||||
.expect("codex reference profile is valid"),
|
.expect("codex reference profile is valid"),
|
||||||
AgentProfile::new(
|
AgentProfile::new(
|
||||||
@ -75,7 +75,7 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
|||||||
ContextInjection::convention_file("GEMINI.md")
|
ContextInjection::convention_file("GEMINI.md")
|
||||||
.expect("GEMINI.md is a valid convention target"),
|
.expect("GEMINI.md is a valid convention target"),
|
||||||
Some("gemini --version".to_owned()),
|
Some("gemini --version".to_owned()),
|
||||||
"{projectRoot}",
|
"{agentRunDir}",
|
||||||
)
|
)
|
||||||
.expect("gemini reference profile is valid"),
|
.expect("gemini reference profile is valid"),
|
||||||
AgentProfile::new(
|
AgentProfile::new(
|
||||||
@ -86,7 +86,7 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
|||||||
ContextInjection::flag("--message-file {path}")
|
ContextInjection::flag("--message-file {path}")
|
||||||
.expect("aider flag template is non-empty"),
|
.expect("aider flag template is non-empty"),
|
||||||
Some("aider --version".to_owned()),
|
Some("aider --version".to_owned()),
|
||||||
"{projectRoot}",
|
"{agentRunDir}",
|
||||||
)
|
)
|
||||||
.expect("aider reference profile is valid"),
|
.expect("aider reference profile is valid"),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -15,11 +15,11 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, PreparedContext,
|
AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, PreparedContext,
|
||||||
ProfileStore, PtyPort, RemotePath, SpawnSpec,
|
ProfileStore, PtyPort, RemotePath, SkillStore, SpawnSpec, StoreError,
|
||||||
};
|
};
|
||||||
use domain::{
|
use domain::{
|
||||||
Agent, AgentId, AgentManifest, AgentOrigin, DomainEvent, ManifestEntry, MarkdownDoc, NodeId,
|
Agent, AgentId, AgentManifest, AgentOrigin, DomainEvent, ManifestEntry, MarkdownDoc, NodeId,
|
||||||
Project, ProfileId, ProjectPath, PtySize, SessionKind, SessionStatus, TerminalSession,
|
Project, ProfileId, ProjectPath, PtySize, SessionKind, SessionStatus, Skill, TerminalSession,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
@ -350,6 +350,7 @@ pub struct LaunchAgent {
|
|||||||
runtime: Arc<dyn AgentRuntime>,
|
runtime: Arc<dyn AgentRuntime>,
|
||||||
fs: Arc<dyn FileSystem>,
|
fs: Arc<dyn FileSystem>,
|
||||||
pty: Arc<dyn PtyPort>,
|
pty: Arc<dyn PtyPort>,
|
||||||
|
skills: Arc<dyn SkillStore>,
|
||||||
sessions: Arc<TerminalSessions>,
|
sessions: Arc<TerminalSessions>,
|
||||||
events: Arc<dyn EventBus>,
|
events: Arc<dyn EventBus>,
|
||||||
}
|
}
|
||||||
@ -364,6 +365,7 @@ impl LaunchAgent {
|
|||||||
runtime: Arc<dyn AgentRuntime>,
|
runtime: Arc<dyn AgentRuntime>,
|
||||||
fs: Arc<dyn FileSystem>,
|
fs: Arc<dyn FileSystem>,
|
||||||
pty: Arc<dyn PtyPort>,
|
pty: Arc<dyn PtyPort>,
|
||||||
|
skills: Arc<dyn SkillStore>,
|
||||||
sessions: Arc<TerminalSessions>,
|
sessions: Arc<TerminalSessions>,
|
||||||
events: Arc<dyn EventBus>,
|
events: Arc<dyn EventBus>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
@ -373,11 +375,35 @@ impl LaunchAgent {
|
|||||||
runtime,
|
runtime,
|
||||||
fs,
|
fs,
|
||||||
pty,
|
pty,
|
||||||
|
skills,
|
||||||
sessions,
|
sessions,
|
||||||
events,
|
events,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolves the Markdown bodies of an agent's assigned skills, in the
|
||||||
|
/// **manifest order** (deterministic). A skill that no longer exists in its
|
||||||
|
/// store (deleted out from under the assignment) is silently skipped — a
|
||||||
|
/// dangling [`domain::SkillRef`] must not block a launch.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`AppError::Store`] on any store failure other than a missing skill.
|
||||||
|
async fn resolve_skills(
|
||||||
|
&self,
|
||||||
|
agent: &Agent,
|
||||||
|
root: &ProjectPath,
|
||||||
|
) -> Result<Vec<Skill>, AppError> {
|
||||||
|
let mut out = Vec::with_capacity(agent.skills.len());
|
||||||
|
for skill_ref in &agent.skills {
|
||||||
|
match self.skills.get(skill_ref.scope, root, skill_ref.skill_id).await {
|
||||||
|
Ok(skill) => out.push(skill),
|
||||||
|
Err(StoreError::NotFound) => {}
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
/// Executes the launch.
|
/// Executes the launch.
|
||||||
///
|
///
|
||||||
/// Step order is contractually significant (and unit-tested): resolve the
|
/// Step order is contractually significant (and unit-tested): resolve the
|
||||||
@ -421,24 +447,40 @@ impl LaunchAgent {
|
|||||||
AppError::NotFound(format!("profile {} for agent", agent.profile_id))
|
AppError::NotFound(format!("profile {} for agent", agent.profile_id))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// 3. Prepare the invocation (pure): command + args + injection plan + cwd.
|
// 3. Compute and create the agent's isolated run directory
|
||||||
|
// `<root>/.ideai/run/<agent-id>/` (ARCHITECTURE §14.1). The PTY cwd is
|
||||||
|
// *never* the project root: each agent gets its own directory so that N
|
||||||
|
// instances of the same profile never collide on a single conventional
|
||||||
|
// file (CLAUDE.md, …). This is the only I/O in the cwd resolution; the
|
||||||
|
// runtime's `prepare_invocation` stays pure.
|
||||||
|
let run_dir = agent_run_dir(&input.project.root, &agent.id)
|
||||||
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||||
|
self.fs
|
||||||
|
.create_dir_all(&RemotePath::new(run_dir.as_str().to_owned()))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 4. Prepare the invocation (pure): command + args + injection plan + cwd.
|
||||||
|
// The run dir is passed as the cwd base; the profile's `{agentRunDir}`
|
||||||
|
// placeholder resolves against it.
|
||||||
let prepared = PreparedContext {
|
let prepared = PreparedContext {
|
||||||
content: content.clone(),
|
content: content.clone(),
|
||||||
relative_path: agent.context_path.clone(),
|
relative_path: agent.context_path.clone(),
|
||||||
};
|
};
|
||||||
let mut spec = self
|
let mut spec = self
|
||||||
.runtime
|
.runtime
|
||||||
.prepare_invocation(&profile, &prepared, &input.project.root)?;
|
.prepare_invocation(&profile, &prepared, &run_dir)?;
|
||||||
|
|
||||||
// 4. Apply the injection plan side effects *before* spawning.
|
// 5. Resolve the agent's assigned skills (their `.md` bodies), then apply
|
||||||
self.apply_injection(&input.project, &agent.context_path, &content, &mut spec)
|
// the injection plan side effects *before* spawning.
|
||||||
|
let skills = self.resolve_skills(&agent, &input.project.root).await?;
|
||||||
|
self.apply_injection(&input.project, &agent.context_path, &content, &skills, &mut spec)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// 5. Spawn the PTY at the resolved cwd; adopt its session id everywhere.
|
// 6. Spawn the PTY at the resolved cwd; adopt its session id everywhere.
|
||||||
let handle = self.pty.spawn(spec.clone(), size).await?;
|
let handle = self.pty.spawn(spec.clone(), size).await?;
|
||||||
let session_id = handle.session_id;
|
let session_id = handle.session_id;
|
||||||
|
|
||||||
// 6. For the Stdin strategy, pipe the context once the PTY is live.
|
// 7. For the Stdin strategy, pipe the context once the PTY is live.
|
||||||
if matches!(spec.context_plan, Some(ContextInjectionPlan::Stdin)) {
|
if matches!(spec.context_plan, Some(ContextInjectionPlan::Stdin)) {
|
||||||
self.pty.write(&handle, content.as_str().as_bytes())?;
|
self.pty.write(&handle, content.as_str().as_bytes())?;
|
||||||
}
|
}
|
||||||
@ -473,16 +515,22 @@ impl LaunchAgent {
|
|||||||
project: &Project,
|
project: &Project,
|
||||||
context_rel_path: &str,
|
context_rel_path: &str,
|
||||||
content: &MarkdownDoc,
|
content: &MarkdownDoc,
|
||||||
|
skills: &[Skill],
|
||||||
spec: &mut SpawnSpec,
|
spec: &mut SpawnSpec,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
match spec.context_plan.clone() {
|
match spec.context_plan.clone() {
|
||||||
Some(ContextInjectionPlan::File { target }) => {
|
Some(ContextInjectionPlan::File { target }) => {
|
||||||
// conventionFile spike (ARCHITECTURE §13.6): copy the context to the
|
// conventionFile (ARCHITECTURE §14.1): IdeA *generates* the
|
||||||
// conventional file (e.g. CLAUDE.md), overwriting any existing one.
|
// conventional file (e.g. CLAUDE.md) inside the agent's isolated
|
||||||
// A copy (not a symlink) is the portable choice — Windows symlinks
|
// run directory — `spec.cwd` is that run dir, never the project
|
||||||
// need privileges and SFTP/WSL symlink semantics differ.
|
// root, so there is zero collision between agents. The document is
|
||||||
|
// composed: an absolute project-root header (so the agent knows
|
||||||
|
// where to operate, since its cwd is *not* the root), the agent's
|
||||||
|
// persona `.md`, then the bodies of its assigned skills (§14.2).
|
||||||
|
let document =
|
||||||
|
compose_convention_file(project.root.as_str(), content.as_str(), skills);
|
||||||
let path = RemotePath::new(join(&spec.cwd, &target));
|
let path = RemotePath::new(join(&spec.cwd, &target));
|
||||||
self.fs.write(&path, content.as_str().as_bytes()).await?;
|
self.fs.write(&path, document.as_bytes()).await?;
|
||||||
}
|
}
|
||||||
Some(ContextInjectionPlan::Env { var }) => {
|
Some(ContextInjectionPlan::Env { var }) => {
|
||||||
// Hand the CLI the absolute path of the agent's `.md` (which lives at
|
// Hand the CLI the absolute path of the agent's `.md` (which lives at
|
||||||
@ -505,6 +553,55 @@ fn join(base: &ProjectPath, rel: &str) -> String {
|
|||||||
format!("{b}/{rel}")
|
format!("{b}/{rel}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Computes an agent's isolated run directory `<root>/.ideai/run/<agent-id>/`
|
||||||
|
/// (ARCHITECTURE §14.1). This is the PTY cwd for the agent — never the project
|
||||||
|
/// root — guaranteeing that two distinct agents on the same project root get two
|
||||||
|
/// distinct cwd (the anti-collision contract).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Propagates [`DomainError`](domain::error::DomainError) if the joined path is
|
||||||
|
/// not a valid [`ProjectPath`] (should not happen for an absolute project root).
|
||||||
|
fn agent_run_dir(root: &ProjectPath, agent_id: &AgentId) -> Result<ProjectPath, domain::error::DomainError> {
|
||||||
|
ProjectPath::new(join(root, &format!(".ideai/run/{agent_id}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Composes the convention file IdeA writes into an agent's run directory: an
|
||||||
|
/// absolute project-root header (the agent's cwd is the run dir, *not* the root,
|
||||||
|
/// so it must be told where to work), the agent's persona `.md`, then the bodies
|
||||||
|
/// of its assigned `skills` under a `# Skills` section (ARCHITECTURE §14.2).
|
||||||
|
///
|
||||||
|
/// Skills are emitted in the order given (the caller passes them in manifest
|
||||||
|
/// order, making the output deterministic); each is introduced by a `##` header
|
||||||
|
/// carrying its name. When `skills` is empty the section is omitted entirely, so
|
||||||
|
/// an agent with no skills gets exactly the previous document.
|
||||||
|
///
|
||||||
|
/// Kept as a **pure** function (no I/O) so it is unit-testable in isolation.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) fn compose_convention_file(project_root: &str, agent_md: &str, skills: &[Skill]) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
out.push_str("# Project root\n\n");
|
||||||
|
out.push_str(project_root);
|
||||||
|
out.push_str("\n\nTous tes travaux portent sur ce project root (chemin absolu ci-dessus). ");
|
||||||
|
out.push_str(
|
||||||
|
"Ton répertoire courant est un dossier d'exécution isolé (`.ideai/run/<agent>/`) ; \
|
||||||
|
opère sur le project root, pas sur ce dossier.\n\n",
|
||||||
|
);
|
||||||
|
out.push_str("---\n\n");
|
||||||
|
out.push_str(agent_md);
|
||||||
|
|
||||||
|
if !skills.is_empty() {
|
||||||
|
out.push_str("\n\n---\n\n# Skills\n");
|
||||||
|
for skill in skills {
|
||||||
|
out.push_str("\n## ");
|
||||||
|
out.push_str(&skill.name);
|
||||||
|
out.push_str("\n\n");
|
||||||
|
out.push_str(skill.content_md.as_str());
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Derives a unique, filesystem-safe `md_path` (`agents/<slug>.md`) for a new
|
/// Derives a unique, filesystem-safe `md_path` (`agents/<slug>.md`) for a new
|
||||||
/// agent, disambiguating against the manifest's existing paths with a numeric
|
/// agent, disambiguating against the manifest's existing paths with a numeric
|
||||||
/// suffix when needed. Shared with the template-driven agent creation (L7).
|
/// suffix when needed. Shared with the template-driven agent creation (L7).
|
||||||
@ -536,3 +633,71 @@ fn slugify(name: &str) -> String {
|
|||||||
}
|
}
|
||||||
out.trim_matches('-').to_owned()
|
out.trim_matches('-').to_owned()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_run_dir_is_under_ideai_run_and_unique_per_agent() {
|
||||||
|
let root = ProjectPath::new("/home/me/proj").unwrap();
|
||||||
|
let a = AgentId::from_uuid(uuid::Uuid::from_u128(1));
|
||||||
|
let b = AgentId::from_uuid(uuid::Uuid::from_u128(2));
|
||||||
|
|
||||||
|
let dir_a = agent_run_dir(&root, &a).unwrap();
|
||||||
|
let dir_b = agent_run_dir(&root, &b).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(dir_a.as_str(), format!("/home/me/proj/.ideai/run/{a}"));
|
||||||
|
assert_ne!(dir_a, dir_b, "distinct agents → distinct run dirs");
|
||||||
|
// Never the project root.
|
||||||
|
assert_ne!(dir_a.as_str(), "/home/me/proj");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compose_convention_file_carries_root_then_persona() {
|
||||||
|
let doc = compose_convention_file("/abs/project/root", "# Persona\n\nDo things.", &[]);
|
||||||
|
|
||||||
|
// Absolute project root present.
|
||||||
|
assert!(doc.contains("/abs/project/root"));
|
||||||
|
// Persona present.
|
||||||
|
assert!(doc.contains("# Persona"));
|
||||||
|
assert!(doc.contains("Do things."));
|
||||||
|
// Root header precedes the persona body (ordering of the composition).
|
||||||
|
let root_at = doc.find("/abs/project/root").unwrap();
|
||||||
|
let persona_at = doc.find("# Persona").unwrap();
|
||||||
|
assert!(root_at < persona_at, "root header must precede the persona");
|
||||||
|
// No skills ⇒ no Skills section.
|
||||||
|
assert!(!doc.contains("# Skills"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compose_convention_file_appends_assigned_skills_in_order() {
|
||||||
|
let s = |n: u128, name: &str, body: &str| {
|
||||||
|
Skill::new(
|
||||||
|
domain::SkillId::from_uuid(uuid::Uuid::from_u128(n)),
|
||||||
|
name,
|
||||||
|
MarkdownDoc::new(body),
|
||||||
|
domain::SkillScope::Global,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
let doc = compose_convention_file(
|
||||||
|
"/root",
|
||||||
|
"# Persona",
|
||||||
|
&[s(1, "refactor", "REFAC_BODY"), s(2, "review", "REVIEW_BODY")],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Both skill bodies present, after the persona.
|
||||||
|
assert!(doc.contains("REFAC_BODY"));
|
||||||
|
assert!(doc.contains("REVIEW_BODY"));
|
||||||
|
let persona_at = doc.find("# Persona").unwrap();
|
||||||
|
let refac_at = doc.find("REFAC_BODY").unwrap();
|
||||||
|
let review_at = doc.find("REVIEW_BODY").unwrap();
|
||||||
|
assert!(persona_at < refac_at, "skills come after the persona");
|
||||||
|
// Deterministic order: first assigned skill precedes the second.
|
||||||
|
assert!(refac_at < review_at, "skills emitted in the given order");
|
||||||
|
// Skill names surface as sub-headers.
|
||||||
|
assert!(doc.contains("## refactor"));
|
||||||
|
assert!(doc.contains("## review"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -18,6 +18,7 @@ pub mod health;
|
|||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
pub mod remote;
|
pub mod remote;
|
||||||
|
pub mod skill;
|
||||||
pub mod template;
|
pub mod template;
|
||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
pub mod window;
|
pub mod window;
|
||||||
@ -61,6 +62,12 @@ pub use template::{
|
|||||||
SyncAgentWithTemplateInput, SyncAgentWithTemplateOutput, UpdateTemplate, UpdateTemplateInput,
|
SyncAgentWithTemplateInput, SyncAgentWithTemplateOutput, UpdateTemplate, UpdateTemplateInput,
|
||||||
UpdateTemplateOutput,
|
UpdateTemplateOutput,
|
||||||
};
|
};
|
||||||
|
pub use skill::{
|
||||||
|
AssignSkillToAgent, AssignSkillToAgentInput, CreateSkill, CreateSkillInput, CreateSkillOutput,
|
||||||
|
DeleteSkill, DeleteSkillInput, ListSkills, ListSkillsInput, ListSkillsOutput,
|
||||||
|
UnassignSkillFromAgent, UnassignSkillFromAgentInput, UpdateSkill, UpdateSkillInput,
|
||||||
|
UpdateSkillOutput,
|
||||||
|
};
|
||||||
pub use terminal::{
|
pub use terminal::{
|
||||||
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, OpenTerminal, OpenTerminalInput,
|
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, OpenTerminal, OpenTerminalInput,
|
||||||
OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, TerminalSessions, WriteToTerminal,
|
OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, TerminalSessions, WriteToTerminal,
|
||||||
|
|||||||
21
crates/application/src/skill/mod.rs
Normal file
21
crates/application/src/skill/mod.rs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
//! Skill use cases (ARCHITECTURE §14.2; L12).
|
||||||
|
//!
|
||||||
|
//! Skills are reusable, model-agnostic workflows (IdeA's universal equivalent of
|
||||||
|
//! a CLI slash-command). This module owns their CRUD across both scopes
|
||||||
|
//! ([`domain::skill::SkillScope`]) and the agent↔skill assignment that records a
|
||||||
|
//! [`domain::skill::SkillRef`] in the project manifest. The actual injection of
|
||||||
|
//! an assigned skill's body into the generated convention file happens at agent
|
||||||
|
//! activation (L6).
|
||||||
|
//!
|
||||||
|
//! Every use case talks only to ports ([`domain::ports::SkillStore`],
|
||||||
|
//! [`domain::ports::AgentContextStore`], [`domain::ports::IdGenerator`],
|
||||||
|
//! [`domain::ports::EventBus`]).
|
||||||
|
|
||||||
|
mod usecases;
|
||||||
|
|
||||||
|
pub use usecases::{
|
||||||
|
AssignSkillToAgent, AssignSkillToAgentInput, CreateSkill, CreateSkillInput, CreateSkillOutput,
|
||||||
|
DeleteSkill, DeleteSkillInput, ListSkills, ListSkillsInput, ListSkillsOutput,
|
||||||
|
UnassignSkillFromAgent, UnassignSkillFromAgentInput, UpdateSkill, UpdateSkillInput,
|
||||||
|
UpdateSkillOutput,
|
||||||
|
};
|
||||||
339
crates/application/src/skill/usecases.rs
Normal file
339
crates/application/src/skill/usecases.rs
Normal file
@ -0,0 +1,339 @@
|
|||||||
|
//! Skill use cases (ARCHITECTURE §14.2; L12).
|
||||||
|
//!
|
||||||
|
//! - **CRUD** in either scope: [`CreateSkill`], [`UpdateSkill`], [`DeleteSkill`],
|
||||||
|
//! [`ListSkills`].
|
||||||
|
//! - **Assignment**: [`AssignSkillToAgent`] / [`UnassignSkillFromAgent`] mutate
|
||||||
|
//! the project manifest entry's `skills` and announce
|
||||||
|
//! [`DomainEvent::SkillAssigned`]. Both are idempotent.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::ports::{AgentContextStore, EventBus, IdGenerator, SkillStore};
|
||||||
|
use domain::{
|
||||||
|
AgentId, AgentManifest, DomainEvent, MarkdownDoc, Project, ProjectPath, Skill, SkillId,
|
||||||
|
SkillRef, SkillScope,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CreateSkill
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Input for [`CreateSkill::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CreateSkillInput {
|
||||||
|
/// Display name (also the `.md` stem on disk).
|
||||||
|
pub name: String,
|
||||||
|
/// Initial Markdown body.
|
||||||
|
pub content: String,
|
||||||
|
/// Scope the skill is created in (selects its backing store).
|
||||||
|
pub scope: SkillScope,
|
||||||
|
/// Active project root (used only for [`SkillScope::Project`]).
|
||||||
|
pub project_root: ProjectPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Output of [`CreateSkill::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CreateSkillOutput {
|
||||||
|
/// The created skill.
|
||||||
|
pub skill: Skill,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a skill in the store of its [`SkillScope`].
|
||||||
|
pub struct CreateSkill {
|
||||||
|
skills: Arc<dyn SkillStore>,
|
||||||
|
ids: Arc<dyn IdGenerator>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CreateSkill {
|
||||||
|
/// Builds the use case from its ports.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(skills: Arc<dyn SkillStore>, ids: Arc<dyn IdGenerator>) -> Self {
|
||||||
|
Self { skills, ids }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes creation.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// - [`AppError::Invalid`] if `name`/`content` is empty,
|
||||||
|
/// - [`AppError::Store`] on persistence failure.
|
||||||
|
pub async fn execute(&self, input: CreateSkillInput) -> Result<CreateSkillOutput, AppError> {
|
||||||
|
let id = SkillId::from_uuid(self.ids.new_uuid());
|
||||||
|
let skill = Skill::new(id, input.name, MarkdownDoc::new(input.content), input.scope)
|
||||||
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||||
|
self.skills.save(&skill, &input.project_root).await?;
|
||||||
|
Ok(CreateSkillOutput { skill })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// UpdateSkill
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Input for [`UpdateSkill::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct UpdateSkillInput {
|
||||||
|
/// Scope the skill lives in.
|
||||||
|
pub scope: SkillScope,
|
||||||
|
/// Skill to update.
|
||||||
|
pub skill_id: SkillId,
|
||||||
|
/// New Markdown body.
|
||||||
|
pub content: String,
|
||||||
|
/// Active project root (used only for [`SkillScope::Project`]).
|
||||||
|
pub project_root: ProjectPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Output of [`UpdateSkill::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct UpdateSkillOutput {
|
||||||
|
/// The updated skill.
|
||||||
|
pub skill: Skill,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces a skill's content (re-validating the non-empty invariant).
|
||||||
|
pub struct UpdateSkill {
|
||||||
|
skills: Arc<dyn SkillStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpdateSkill {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(skills: Arc<dyn SkillStore>) -> Self {
|
||||||
|
Self { skills }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes the update.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// - [`AppError::NotFound`] if the skill is unknown in that scope,
|
||||||
|
/// - [`AppError::Invalid`] if the new content is empty,
|
||||||
|
/// - [`AppError::Store`] on persistence failure.
|
||||||
|
pub async fn execute(&self, input: UpdateSkillInput) -> Result<UpdateSkillOutput, AppError> {
|
||||||
|
let current = self
|
||||||
|
.skills
|
||||||
|
.get(input.scope, &input.project_root, input.skill_id)
|
||||||
|
.await?;
|
||||||
|
let updated = current
|
||||||
|
.with_content(MarkdownDoc::new(input.content))
|
||||||
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||||
|
self.skills.save(&updated, &input.project_root).await?;
|
||||||
|
Ok(UpdateSkillOutput { skill: updated })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ListSkills / DeleteSkill
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Input for [`ListSkills::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ListSkillsInput {
|
||||||
|
/// Scope to list.
|
||||||
|
pub scope: SkillScope,
|
||||||
|
/// Active project root (used only for [`SkillScope::Project`]).
|
||||||
|
pub project_root: ProjectPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Output of [`ListSkills::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ListSkillsOutput {
|
||||||
|
/// All skills in the requested scope.
|
||||||
|
pub skills: Vec<Skill>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists the skills in one scope.
|
||||||
|
pub struct ListSkills {
|
||||||
|
skills: Arc<dyn SkillStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListSkills {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(skills: Arc<dyn SkillStore>) -> Self {
|
||||||
|
Self { skills }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists skills in `input.scope`.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`AppError::Store`] on persistence failure.
|
||||||
|
pub async fn execute(&self, input: ListSkillsInput) -> Result<ListSkillsOutput, AppError> {
|
||||||
|
Ok(ListSkillsOutput {
|
||||||
|
skills: self.skills.list(input.scope, &input.project_root).await?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Input for [`DeleteSkill::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct DeleteSkillInput {
|
||||||
|
/// Scope the skill lives in.
|
||||||
|
pub scope: SkillScope,
|
||||||
|
/// Skill to delete.
|
||||||
|
pub skill_id: SkillId,
|
||||||
|
/// Active project root (used only for [`SkillScope::Project`]).
|
||||||
|
pub project_root: ProjectPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a skill from its scope's store.
|
||||||
|
///
|
||||||
|
/// Agents that referenced it keep their [`SkillRef`]; the injection step simply
|
||||||
|
/// finds nothing to resolve for the now-absent skill and skips it.
|
||||||
|
pub struct DeleteSkill {
|
||||||
|
skills: Arc<dyn SkillStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeleteSkill {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(skills: Arc<dyn SkillStore>) -> Self {
|
||||||
|
Self { skills }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes the skill.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// - [`AppError::NotFound`] if the skill is unknown in that scope,
|
||||||
|
/// - [`AppError::Store`] on persistence failure.
|
||||||
|
pub async fn execute(&self, input: DeleteSkillInput) -> Result<(), AppError> {
|
||||||
|
self.skills
|
||||||
|
.delete(input.scope, &input.project_root, input.skill_id)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AssignSkillToAgent / UnassignSkillFromAgent
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Input for [`AssignSkillToAgent::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct AssignSkillToAgentInput {
|
||||||
|
/// The owning project.
|
||||||
|
pub project: Project,
|
||||||
|
/// The agent receiving the skill.
|
||||||
|
pub agent_id: AgentId,
|
||||||
|
/// The skill to assign.
|
||||||
|
pub skill: SkillRef,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assigns a skill to an agent by recording a [`SkillRef`] in its manifest entry.
|
||||||
|
/// Idempotent: re-assigning the same skill is a no-op (no duplicate).
|
||||||
|
pub struct AssignSkillToAgent {
|
||||||
|
contexts: Arc<dyn AgentContextStore>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AssignSkillToAgent {
|
||||||
|
/// Builds the use case from its ports.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(contexts: Arc<dyn AgentContextStore>, events: Arc<dyn EventBus>) -> Self {
|
||||||
|
Self { contexts, events }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes the assignment.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// - [`AppError::NotFound`] if the agent is unknown to the project,
|
||||||
|
/// - [`AppError::Invalid`] if the resulting manifest is invalid,
|
||||||
|
/// - [`AppError::Store`] on persistence failure.
|
||||||
|
pub async fn execute(&self, input: AssignSkillToAgentInput) -> Result<(), AppError> {
|
||||||
|
let mut manifest = self.contexts.load_manifest(&input.project).await?;
|
||||||
|
let entry = manifest
|
||||||
|
.entries
|
||||||
|
.iter_mut()
|
||||||
|
.find(|e| e.agent_id == input.agent_id)
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
|
||||||
|
|
||||||
|
// Mutate through the domain entity so the dedup invariant is enforced in
|
||||||
|
// one place, then fold the result back into the manifest entry.
|
||||||
|
let mut agent = entry.to_agent().map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||||
|
let changed = agent.assign_skill(input.skill);
|
||||||
|
*entry = domain::ManifestEntry::from_agent(&agent);
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
self.persist_and_announce(&input.project, manifest, input.agent_id, input.skill.skill_id, true)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves the manifest and announces the assignment change.
|
||||||
|
async fn persist_and_announce(
|
||||||
|
&self,
|
||||||
|
project: &Project,
|
||||||
|
manifest: AgentManifest,
|
||||||
|
agent_id: AgentId,
|
||||||
|
skill_id: SkillId,
|
||||||
|
assigned: bool,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let manifest = AgentManifest::new(manifest.version, manifest.entries)
|
||||||
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||||
|
self.contexts.save_manifest(project, &manifest).await?;
|
||||||
|
self.events.publish(DomainEvent::SkillAssigned {
|
||||||
|
agent_id,
|
||||||
|
skill_id,
|
||||||
|
assigned,
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Input for [`UnassignSkillFromAgent::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct UnassignSkillFromAgentInput {
|
||||||
|
/// The owning project.
|
||||||
|
pub project: Project,
|
||||||
|
/// The agent losing the skill.
|
||||||
|
pub agent_id: AgentId,
|
||||||
|
/// The skill to unassign.
|
||||||
|
pub skill_id: SkillId,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a skill assignment from an agent. Idempotent: unassigning a skill the
|
||||||
|
/// agent does not carry is a no-op.
|
||||||
|
pub struct UnassignSkillFromAgent {
|
||||||
|
contexts: Arc<dyn AgentContextStore>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UnassignSkillFromAgent {
|
||||||
|
/// Builds the use case from its ports.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(contexts: Arc<dyn AgentContextStore>, events: Arc<dyn EventBus>) -> Self {
|
||||||
|
Self { contexts, events }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes the un-assignment.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// - [`AppError::NotFound`] if the agent is unknown to the project,
|
||||||
|
/// - [`AppError::Invalid`] if the resulting manifest is invalid,
|
||||||
|
/// - [`AppError::Store`] on persistence failure.
|
||||||
|
pub async fn execute(&self, input: UnassignSkillFromAgentInput) -> Result<(), AppError> {
|
||||||
|
let mut manifest = self.contexts.load_manifest(&input.project).await?;
|
||||||
|
let entry = manifest
|
||||||
|
.entries
|
||||||
|
.iter_mut()
|
||||||
|
.find(|e| e.agent_id == input.agent_id)
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
|
||||||
|
|
||||||
|
let mut agent = entry.to_agent().map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||||
|
let changed = agent.unassign_skill(input.skill_id);
|
||||||
|
*entry = domain::ManifestEntry::from_agent(&agent);
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
let manifest = AgentManifest::new(manifest.version, manifest.entries)
|
||||||
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||||
|
self.contexts.save_manifest(&input.project, &manifest).await?;
|
||||||
|
self.events.publish(DomainEvent::SkillAssigned {
|
||||||
|
agent_id: input.agent_id,
|
||||||
|
skill_id: input.skill_id,
|
||||||
|
assigned: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -59,6 +59,18 @@ impl TerminalSessions {
|
|||||||
.and_then(|m| m.get(id).map(|e| e.session.clone()))
|
.and_then(|m| m.get(id).map(|e| e.session.clone()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the [`PtyHandle`]s of every currently-registered session.
|
||||||
|
///
|
||||||
|
/// Used at application shutdown to kill all live PTYs cleanly (the
|
||||||
|
/// `CloseRequested` hook), independently of the frontend's per-view lifecycle.
|
||||||
|
#[must_use]
|
||||||
|
pub fn handles(&self) -> Vec<PtyHandle> {
|
||||||
|
self.entries
|
||||||
|
.lock()
|
||||||
|
.map(|m| m.values().map(|e| e.handle.clone()).collect())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
/// Removes a session from the registry, returning its handle if present.
|
/// Removes a session from the registry, returning its handle if present.
|
||||||
pub fn remove(&self, id: &SessionId) -> Option<PtyHandle> {
|
pub fn remove(&self, id: &SessionId) -> Option<PtyHandle> {
|
||||||
self.entries
|
self.entries
|
||||||
|
|||||||
@ -26,12 +26,13 @@ use domain::markdown::MarkdownDoc;
|
|||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
|
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
|
||||||
ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore,
|
ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore,
|
||||||
PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SpawnSpec, StoreError,
|
PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SkillStore, SpawnSpec, StoreError,
|
||||||
};
|
};
|
||||||
use domain::profile::{AgentProfile, ContextInjection};
|
use domain::profile::{AgentProfile, ContextInjection};
|
||||||
use domain::project::{Project, ProjectPath};
|
use domain::project::{Project, ProjectPath};
|
||||||
use domain::remote::RemoteRef;
|
use domain::remote::RemoteRef;
|
||||||
use domain::{PtySize, SessionId};
|
use domain::skill::{Skill, SkillScope};
|
||||||
|
use domain::{PtySize, SessionId, SkillId, SkillRef};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
@ -177,6 +178,49 @@ impl ProfileStore for FakeProfiles {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// FakeSkills (SkillStore) — an in-memory store seeded with a few skills
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct FakeSkills(Arc<Vec<Skill>>);
|
||||||
|
|
||||||
|
impl FakeSkills {
|
||||||
|
fn with(skills: Vec<Skill>) -> Self {
|
||||||
|
Self(Arc::new(skills))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl SkillStore for FakeSkills {
|
||||||
|
async fn list(&self, scope: SkillScope, _root: &ProjectPath) -> Result<Vec<Skill>, StoreError> {
|
||||||
|
Ok(self.0.iter().filter(|s| s.scope == scope).cloned().collect())
|
||||||
|
}
|
||||||
|
async fn get(
|
||||||
|
&self,
|
||||||
|
scope: SkillScope,
|
||||||
|
_root: &ProjectPath,
|
||||||
|
id: SkillId,
|
||||||
|
) -> Result<Skill, StoreError> {
|
||||||
|
self.0
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.scope == scope && s.id == id)
|
||||||
|
.cloned()
|
||||||
|
.ok_or(StoreError::NotFound)
|
||||||
|
}
|
||||||
|
async fn save(&self, _skill: &Skill, _root: &ProjectPath) -> Result<(), StoreError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn delete(
|
||||||
|
&self,
|
||||||
|
_scope: SkillScope,
|
||||||
|
_root: &ProjectPath,
|
||||||
|
_id: SkillId,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// FakeRuntime (AgentRuntime) — records prepare + returns a configured plan
|
// FakeRuntime (AgentRuntime) — records prepare + returns a configured plan
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -221,6 +265,7 @@ impl AgentRuntime for FakeRuntime {
|
|||||||
struct FakeFs {
|
struct FakeFs {
|
||||||
trace: Trace,
|
trace: Trace,
|
||||||
writes: WriteLog<String>,
|
writes: WriteLog<String>,
|
||||||
|
created_dirs: Arc<Mutex<Vec<String>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FakeFs {
|
impl FakeFs {
|
||||||
@ -228,11 +273,15 @@ impl FakeFs {
|
|||||||
Self {
|
Self {
|
||||||
trace,
|
trace,
|
||||||
writes: Arc::new(Mutex::new(Vec::new())),
|
writes: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
created_dirs: Arc::new(Mutex::new(Vec::new())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn writes(&self) -> Vec<(String, Vec<u8>)> {
|
fn writes(&self) -> Vec<(String, Vec<u8>)> {
|
||||||
self.writes.lock().unwrap().clone()
|
self.writes.lock().unwrap().clone()
|
||||||
}
|
}
|
||||||
|
fn created_dirs(&self) -> Vec<String> {
|
||||||
|
self.created_dirs.lock().unwrap().clone()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@ -251,7 +300,11 @@ impl FileSystem for FakeFs {
|
|||||||
async fn exists(&self, _path: &RemotePath) -> Result<bool, FsError> {
|
async fn exists(&self, _path: &RemotePath) -> Result<bool, FsError> {
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
async fn create_dir_all(&self, _path: &RemotePath) -> Result<(), FsError> {
|
async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> {
|
||||||
|
self.created_dirs
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(path.as_str().to_owned());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
async fn list(&self, _path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
|
async fn list(&self, _path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
|
||||||
@ -313,6 +366,9 @@ impl PtyPort for FakePty {
|
|||||||
fn subscribe_output(&self, _handle: &PtyHandle) -> Result<OutputStream, PtyError> {
|
fn subscribe_output(&self, _handle: &PtyHandle) -> Result<OutputStream, PtyError> {
|
||||||
Ok(Box::new(std::iter::empty()))
|
Ok(Box::new(std::iter::empty()))
|
||||||
}
|
}
|
||||||
|
fn scrollback(&self, _handle: &PtyHandle) -> Result<Vec<u8>, PtyError> {
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
async fn kill(&self, _handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
|
async fn kill(&self, _handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
|
||||||
Ok(ExitStatus { code: Some(0) })
|
Ok(ExitStatus { code: Some(0) })
|
||||||
}
|
}
|
||||||
@ -386,7 +442,7 @@ fn profile(id: ProfileId, injection: ContextInjection) -> AgentProfile {
|
|||||||
Vec::new(),
|
Vec::new(),
|
||||||
injection,
|
injection,
|
||||||
Some("claude --version".to_owned()),
|
Some("claude --version".to_owned()),
|
||||||
"{projectRoot}",
|
"{agentRunDir}",
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
@ -565,6 +621,7 @@ fn launch_fixture(injection: ContextInjection, plan: Option<ContextInjectionPlan
|
|||||||
Arc::new(runtime),
|
Arc::new(runtime),
|
||||||
Arc::new(fs.clone()),
|
Arc::new(fs.clone()),
|
||||||
Arc::new(pty.clone()),
|
Arc::new(pty.clone()),
|
||||||
|
Arc::new(FakeSkills::default()),
|
||||||
Arc::clone(&sessions),
|
Arc::clone(&sessions),
|
||||||
Arc::new(bus.clone()),
|
Arc::new(bus.clone()),
|
||||||
);
|
);
|
||||||
@ -600,17 +657,32 @@ async fn launch_orders_prepare_then_injection_then_spawn() {
|
|||||||
"prepare → injection → spawn"
|
"prepare → injection → spawn"
|
||||||
);
|
);
|
||||||
|
|
||||||
// The conventionFile was written to <cwd>/CLAUDE.md with the context body.
|
// The conventionFile was written inside the agent's isolated run directory
|
||||||
|
// (`.ideai/run/<agent-id>/CLAUDE.md`) — NOT at the project root. Its content
|
||||||
|
// is the *composed* document: an absolute project-root header followed by the
|
||||||
|
// agent persona `.md`.
|
||||||
|
let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
|
||||||
let writes = fs.writes();
|
let writes = fs.writes();
|
||||||
assert_eq!(writes.len(), 1);
|
assert_eq!(writes.len(), 1);
|
||||||
assert_eq!(writes[0].0, "/home/me/proj/CLAUDE.md");
|
assert_eq!(writes[0].0, format!("{run_dir}/CLAUDE.md"));
|
||||||
assert_eq!(writes[0].1, b"# ctx body");
|
let written = String::from_utf8(writes[0].1.clone()).unwrap();
|
||||||
|
assert!(
|
||||||
|
written.contains("/home/me/proj"),
|
||||||
|
"convention file must carry the absolute project root, got: {written}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
written.contains("# ctx body"),
|
||||||
|
"convention file must carry the agent persona, got: {written}"
|
||||||
|
);
|
||||||
|
|
||||||
// Spawn happened at the resolved cwd with the profile command.
|
// The run directory was created (via the FileSystem port) before spawn.
|
||||||
|
assert_eq!(fs.created_dirs(), vec![run_dir.clone()]);
|
||||||
|
|
||||||
|
// Spawn happened at the isolated run dir with the profile command.
|
||||||
let spawns = pty.spawns();
|
let spawns = pty.spawns();
|
||||||
assert_eq!(spawns.len(), 1);
|
assert_eq!(spawns.len(), 1);
|
||||||
assert_eq!(spawns[0].command, "claude");
|
assert_eq!(spawns[0].command, "claude");
|
||||||
assert_eq!(spawns[0].cwd.as_str(), "/home/me/proj");
|
assert_eq!(spawns[0].cwd.as_str(), run_dir);
|
||||||
|
|
||||||
// The session adopts the PTY id, is Running, and is registered as an agent.
|
// The session adopts the PTY id, is Running, and is registered as an agent.
|
||||||
assert_eq!(out.session.id, sid(777));
|
assert_eq!(out.session.id, sid(777));
|
||||||
@ -630,6 +702,172 @@ async fn launch_orders_prepare_then_injection_then_spawn() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **Anti-collision (ARCHITECTURE §14.1)**: two distinct agents of the *same*
|
||||||
|
/// profile on the *same* project root must launch into two **distinct** cwd —
|
||||||
|
/// each its own `.ideai/run/<agent-id>/` — and each writes its convention file
|
||||||
|
/// inside its own run dir, never colliding at the project root.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn two_agents_same_root_get_distinct_run_dirs_no_collision() {
|
||||||
|
let injection = ContextInjection::convention_file("CLAUDE.md").unwrap();
|
||||||
|
let plan = Some(ContextInjectionPlan::File {
|
||||||
|
target: "CLAUDE.md".to_owned(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Two agents, same profile (pid(9)), same project root.
|
||||||
|
let agent_a = scratch_agent(aid(1), "Alpha", "agents/alpha.md", pid(9));
|
||||||
|
let agent_b = scratch_agent(aid(2), "Bravo", "agents/bravo.md", pid(9));
|
||||||
|
|
||||||
|
let contexts = FakeContexts::with_agent(&agent_a, "# alpha");
|
||||||
|
{
|
||||||
|
let mut inner = contexts.0.lock().unwrap();
|
||||||
|
inner
|
||||||
|
.manifest
|
||||||
|
.entries
|
||||||
|
.push(ManifestEntry::from_agent(&agent_b));
|
||||||
|
inner
|
||||||
|
.contents
|
||||||
|
.insert(agent_b.context_path.clone(), "# bravo".to_owned());
|
||||||
|
}
|
||||||
|
let profiles = FakeProfiles::new(vec![profile(pid(9), injection)]);
|
||||||
|
|
||||||
|
let tr = trace();
|
||||||
|
let fs = FakeFs::new(Arc::clone(&tr));
|
||||||
|
let pty = FakePty::new(Arc::clone(&tr), sid(777));
|
||||||
|
let sessions = Arc::new(TerminalSessions::new());
|
||||||
|
let launch = LaunchAgent::new(
|
||||||
|
Arc::new(contexts),
|
||||||
|
Arc::new(profiles),
|
||||||
|
Arc::new(FakeRuntime::new(Arc::clone(&tr), plan)),
|
||||||
|
Arc::new(fs.clone()),
|
||||||
|
Arc::new(pty.clone()),
|
||||||
|
Arc::new(FakeSkills::default()),
|
||||||
|
Arc::clone(&sessions),
|
||||||
|
Arc::new(SpyBus::default()),
|
||||||
|
);
|
||||||
|
|
||||||
|
launch.execute(launch_input(agent_a.id)).await.unwrap();
|
||||||
|
launch.execute(launch_input(agent_b.id)).await.unwrap();
|
||||||
|
|
||||||
|
let dir_a = format!("/home/me/proj/.ideai/run/{}", agent_a.id);
|
||||||
|
let dir_b = format!("/home/me/proj/.ideai/run/{}", agent_b.id);
|
||||||
|
assert_ne!(dir_a, dir_b, "the two agents must map to different run dirs");
|
||||||
|
|
||||||
|
// Two distinct run dirs were created.
|
||||||
|
assert_eq!(fs.created_dirs(), vec![dir_a.clone(), dir_b.clone()]);
|
||||||
|
|
||||||
|
// Two spawns at two distinct cwd — the core anti-collision guarantee.
|
||||||
|
let spawns = pty.spawns();
|
||||||
|
assert_eq!(spawns.len(), 2);
|
||||||
|
assert_eq!(spawns[0].cwd.as_str(), dir_a);
|
||||||
|
assert_eq!(spawns[1].cwd.as_str(), dir_b);
|
||||||
|
assert_ne!(spawns[0].cwd, spawns[1].cwd);
|
||||||
|
|
||||||
|
// Two convention files, each inside its own run dir (no shared root file).
|
||||||
|
let writes = fs.writes();
|
||||||
|
assert_eq!(writes.len(), 2);
|
||||||
|
assert_eq!(writes[0].0, format!("{dir_a}/CLAUDE.md"));
|
||||||
|
assert_eq!(writes[1].0, format!("{dir_b}/CLAUDE.md"));
|
||||||
|
assert_ne!(writes[0].0, writes[1].0);
|
||||||
|
// Neither writes to the project root.
|
||||||
|
assert!(writes.iter().all(|(p, _)| p != "/home/me/proj/CLAUDE.md"));
|
||||||
|
|
||||||
|
// Each convention file carries its own persona.
|
||||||
|
assert!(String::from_utf8(writes[0].1.clone()).unwrap().contains("# alpha"));
|
||||||
|
assert!(String::from_utf8(writes[1].1.clone()).unwrap().contains("# bravo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn launch_conventionfile_injects_assigned_skills_in_order() {
|
||||||
|
// An agent with two assigned global skills; the generated convention file must
|
||||||
|
// carry both skill bodies, after the persona, in assignment order (§14.2).
|
||||||
|
let skill_id = |n: u128| SkillId::from_uuid(Uuid::from_u128(n));
|
||||||
|
let skill = |n: u128, name: &str, body: &str| {
|
||||||
|
Skill::new(skill_id(n), name, MarkdownDoc::new(body), SkillScope::Global).unwrap()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||||
|
agent.assign_skill(SkillRef::new(skill_id(1), SkillScope::Global));
|
||||||
|
agent.assign_skill(SkillRef::new(skill_id(2), SkillScope::Global));
|
||||||
|
|
||||||
|
let contexts = FakeContexts::with_agent(&agent, "# persona");
|
||||||
|
let profiles = FakeProfiles::new(vec![profile(
|
||||||
|
pid(9),
|
||||||
|
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||||
|
)]);
|
||||||
|
let tr = trace();
|
||||||
|
let fs = FakeFs::new(Arc::clone(&tr));
|
||||||
|
let pty = FakePty::new(Arc::clone(&tr), sid(777));
|
||||||
|
let skills = FakeSkills::with(vec![
|
||||||
|
skill(1, "refactor", "REFAC_BODY"),
|
||||||
|
skill(2, "review", "REVIEW_BODY"),
|
||||||
|
]);
|
||||||
|
let launch = LaunchAgent::new(
|
||||||
|
Arc::new(contexts),
|
||||||
|
Arc::new(profiles),
|
||||||
|
Arc::new(FakeRuntime::new(
|
||||||
|
Arc::clone(&tr),
|
||||||
|
Some(ContextInjectionPlan::File {
|
||||||
|
target: "CLAUDE.md".to_owned(),
|
||||||
|
}),
|
||||||
|
)),
|
||||||
|
Arc::new(fs.clone()),
|
||||||
|
Arc::new(pty.clone()),
|
||||||
|
Arc::new(skills),
|
||||||
|
Arc::new(TerminalSessions::new()),
|
||||||
|
Arc::new(SpyBus::default()),
|
||||||
|
);
|
||||||
|
|
||||||
|
launch.execute(launch_input(agent.id)).await.unwrap();
|
||||||
|
|
||||||
|
let writes = fs.writes();
|
||||||
|
assert_eq!(writes.len(), 1);
|
||||||
|
let doc = String::from_utf8(writes[0].1.clone()).unwrap();
|
||||||
|
assert!(doc.contains("# persona"), "persona present: {doc}");
|
||||||
|
assert!(doc.contains("REFAC_BODY"), "first skill body present");
|
||||||
|
assert!(doc.contains("REVIEW_BODY"), "second skill body present");
|
||||||
|
// Deterministic order: persona before skills, skill 1 before skill 2.
|
||||||
|
let persona_at = doc.find("# persona").unwrap();
|
||||||
|
let refac_at = doc.find("REFAC_BODY").unwrap();
|
||||||
|
let review_at = doc.find("REVIEW_BODY").unwrap();
|
||||||
|
assert!(persona_at < refac_at && refac_at < review_at, "ordering: {doc}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn launch_skips_dangling_skill_ref_without_failing() {
|
||||||
|
// The agent references a skill that no longer exists in the store: launch must
|
||||||
|
// still succeed and simply omit it (no Skills section for a sole dangling ref).
|
||||||
|
let mut agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||||
|
agent.assign_skill(SkillRef::new(SkillId::from_uuid(Uuid::from_u128(99)), SkillScope::Global));
|
||||||
|
|
||||||
|
let contexts = FakeContexts::with_agent(&agent, "# persona");
|
||||||
|
let profiles = FakeProfiles::new(vec![profile(
|
||||||
|
pid(9),
|
||||||
|
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||||
|
)]);
|
||||||
|
let tr = trace();
|
||||||
|
let fs = FakeFs::new(Arc::clone(&tr));
|
||||||
|
let pty = FakePty::new(Arc::clone(&tr), sid(777));
|
||||||
|
let launch = LaunchAgent::new(
|
||||||
|
Arc::new(contexts),
|
||||||
|
Arc::new(profiles),
|
||||||
|
Arc::new(FakeRuntime::new(
|
||||||
|
Arc::clone(&tr),
|
||||||
|
Some(ContextInjectionPlan::File {
|
||||||
|
target: "CLAUDE.md".to_owned(),
|
||||||
|
}),
|
||||||
|
)),
|
||||||
|
Arc::new(fs.clone()),
|
||||||
|
Arc::new(pty.clone()),
|
||||||
|
Arc::new(FakeSkills::default()), // empty store ⇒ the ref is dangling
|
||||||
|
Arc::new(TerminalSessions::new()),
|
||||||
|
Arc::new(SpyBus::default()),
|
||||||
|
);
|
||||||
|
|
||||||
|
launch.execute(launch_input(agent.id)).await.expect("launch must succeed");
|
||||||
|
let doc = String::from_utf8(fs.writes()[0].1.clone()).unwrap();
|
||||||
|
assert!(!doc.contains("# Skills"), "no Skills section for a dangling ref: {doc}");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn launch_stdin_strategy_pipes_context_after_spawn() {
|
async fn launch_stdin_strategy_pipes_context_after_spawn() {
|
||||||
let (launch, agent, fs, pty, _bus, _sessions, tr) =
|
let (launch, agent, fs, pty, _bus, _sessions, tr) =
|
||||||
@ -671,6 +909,7 @@ async fn launch_unknown_profile_is_not_found() {
|
|||||||
Arc::new(FakeRuntime::new(Arc::clone(&tr), Some(ContextInjectionPlan::Stdin))),
|
Arc::new(FakeRuntime::new(Arc::clone(&tr), Some(ContextInjectionPlan::Stdin))),
|
||||||
Arc::new(FakeFs::new(Arc::clone(&tr))),
|
Arc::new(FakeFs::new(Arc::clone(&tr))),
|
||||||
Arc::new(pty.clone()),
|
Arc::new(pty.clone()),
|
||||||
|
Arc::new(FakeSkills::default()),
|
||||||
Arc::new(TerminalSessions::new()),
|
Arc::new(TerminalSessions::new()),
|
||||||
Arc::new(SpyBus::default()),
|
Arc::new(SpyBus::default()),
|
||||||
);
|
);
|
||||||
|
|||||||
395
crates/application/tests/skill_usecases.rs
Normal file
395
crates/application/tests/skill_usecases.rs
Normal file
@ -0,0 +1,395 @@
|
|||||||
|
//! L12 tests for the skill use cases with in-memory port fakes (no real
|
||||||
|
//! store/FS): CRUD across scopes (`CreateSkill`, `UpdateSkill`, `DeleteSkill`,
|
||||||
|
//! `ListSkills`) and the manifest-mutating assignment
|
||||||
|
//! (`AssignSkillToAgent` / `UnassignSkillFromAgent`), asserting the
|
||||||
|
//! `SkillAssigned` event and idempotence.
|
||||||
|
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::events::DomainEvent;
|
||||||
|
use domain::ids::{AgentId, ProfileId, ProjectId, SkillId};
|
||||||
|
use domain::markdown::MarkdownDoc;
|
||||||
|
use domain::ports::{
|
||||||
|
AgentContextStore, EventBus, EventStream, IdGenerator, SkillStore, StoreError,
|
||||||
|
};
|
||||||
|
use domain::skill::{Skill, SkillScope};
|
||||||
|
use domain::{AgentManifest, ManifestEntry, Project, ProjectPath, RemoteRef, SkillRef};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use application::{
|
||||||
|
AssignSkillToAgent, AssignSkillToAgentInput, CreateSkill, CreateSkillInput, DeleteSkill,
|
||||||
|
DeleteSkillInput, ListSkills, ListSkillsInput, UnassignSkillFromAgent,
|
||||||
|
UnassignSkillFromAgentInput, UpdateSkill, UpdateSkillInput,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fakes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// In-memory skill store keyed by `(scope, id)`, honouring scope isolation.
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct FakeSkills(Arc<Mutex<Vec<Skill>>>);
|
||||||
|
#[async_trait]
|
||||||
|
impl SkillStore for FakeSkills {
|
||||||
|
async fn list(&self, scope: SkillScope, _root: &ProjectPath) -> Result<Vec<Skill>, StoreError> {
|
||||||
|
Ok(self
|
||||||
|
.0
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.scope == scope)
|
||||||
|
.cloned()
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
async fn get(
|
||||||
|
&self,
|
||||||
|
scope: SkillScope,
|
||||||
|
_root: &ProjectPath,
|
||||||
|
id: SkillId,
|
||||||
|
) -> Result<Skill, StoreError> {
|
||||||
|
self.0
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.scope == scope && s.id == id)
|
||||||
|
.cloned()
|
||||||
|
.ok_or(StoreError::NotFound)
|
||||||
|
}
|
||||||
|
async fn save(&self, skill: &Skill, _root: &ProjectPath) -> Result<(), StoreError> {
|
||||||
|
let mut v = self.0.lock().unwrap();
|
||||||
|
if let Some(slot) = v.iter_mut().find(|s| s.scope == skill.scope && s.id == skill.id) {
|
||||||
|
*slot = skill.clone();
|
||||||
|
} else {
|
||||||
|
v.push(skill.clone());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn delete(
|
||||||
|
&self,
|
||||||
|
scope: SkillScope,
|
||||||
|
_root: &ProjectPath,
|
||||||
|
id: SkillId,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
let mut v = self.0.lock().unwrap();
|
||||||
|
let before = v.len();
|
||||||
|
v.retain(|s| !(s.scope == scope && s.id == id));
|
||||||
|
if v.len() == before {
|
||||||
|
return Err(StoreError::NotFound);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct FakeContexts(Arc<Mutex<AgentManifest>>);
|
||||||
|
impl FakeContexts {
|
||||||
|
fn new(entries: Vec<ManifestEntry>) -> Self {
|
||||||
|
Self(Arc::new(Mutex::new(AgentManifest { version: 1, entries })))
|
||||||
|
}
|
||||||
|
fn manifest(&self) -> AgentManifest {
|
||||||
|
self.0.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[async_trait]
|
||||||
|
impl AgentContextStore for FakeContexts {
|
||||||
|
async fn read_context(
|
||||||
|
&self,
|
||||||
|
_p: &Project,
|
||||||
|
_agent: &AgentId,
|
||||||
|
) -> Result<MarkdownDoc, StoreError> {
|
||||||
|
Err(StoreError::NotFound)
|
||||||
|
}
|
||||||
|
async fn write_context(
|
||||||
|
&self,
|
||||||
|
_p: &Project,
|
||||||
|
_agent: &AgentId,
|
||||||
|
_md: &MarkdownDoc,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn load_manifest(&self, _p: &Project) -> Result<AgentManifest, StoreError> {
|
||||||
|
Ok(self.manifest())
|
||||||
|
}
|
||||||
|
async fn save_manifest(&self, _p: &Project, m: &AgentManifest) -> Result<(), StoreError> {
|
||||||
|
*self.0.lock().unwrap() = m.clone();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Clone)]
|
||||||
|
struct SpyBus(Arc<Mutex<Vec<DomainEvent>>>);
|
||||||
|
impl SpyBus {
|
||||||
|
fn events(&self) -> Vec<DomainEvent> {
|
||||||
|
self.0.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl EventBus for SpyBus {
|
||||||
|
fn publish(&self, event: DomainEvent) {
|
||||||
|
self.0.lock().unwrap().push(event);
|
||||||
|
}
|
||||||
|
fn subscribe(&self) -> EventStream {
|
||||||
|
Box::new(std::iter::empty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SeqIds(Mutex<u128>);
|
||||||
|
impl SeqIds {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self(Mutex::new(1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl IdGenerator for SeqIds {
|
||||||
|
fn new_uuid(&self) -> Uuid {
|
||||||
|
let mut n = self.0.lock().unwrap();
|
||||||
|
let id = Uuid::from_u128(*n);
|
||||||
|
*n += 1;
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Builders
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn pid(n: u128) -> ProfileId {
|
||||||
|
ProfileId::from_uuid(Uuid::from_u128(n))
|
||||||
|
}
|
||||||
|
fn aid(n: u128) -> AgentId {
|
||||||
|
AgentId::from_uuid(Uuid::from_u128(n))
|
||||||
|
}
|
||||||
|
fn sid(n: u128) -> SkillId {
|
||||||
|
SkillId::from_uuid(Uuid::from_u128(n))
|
||||||
|
}
|
||||||
|
fn root() -> ProjectPath {
|
||||||
|
ProjectPath::new("/home/me/demo").unwrap()
|
||||||
|
}
|
||||||
|
fn project() -> Project {
|
||||||
|
Project::new(
|
||||||
|
ProjectId::from_uuid(Uuid::from_u128(1000)),
|
||||||
|
"demo",
|
||||||
|
root(),
|
||||||
|
RemoteRef::local(),
|
||||||
|
1_700_000_000_000,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
fn scratch_entry(agent: AgentId) -> ManifestEntry {
|
||||||
|
ManifestEntry::new(agent, "A", "agents/a.md", pid(1), None, false, None).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CRUD
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create_skill_persists_in_its_scope() {
|
||||||
|
let store = FakeSkills::default();
|
||||||
|
let out = CreateSkill::new(Arc::new(store.clone()), Arc::new(SeqIds::new()))
|
||||||
|
.execute(CreateSkillInput {
|
||||||
|
name: "refactor".to_owned(),
|
||||||
|
content: "# body".to_owned(),
|
||||||
|
scope: SkillScope::Project,
|
||||||
|
project_root: root(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(out.skill.scope, SkillScope::Project);
|
||||||
|
assert_eq!(
|
||||||
|
store.list(SkillScope::Project, &root()).await.unwrap().len(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert!(store
|
||||||
|
.list(SkillScope::Global, &root())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create_skill_rejects_empty_content() {
|
||||||
|
let store = FakeSkills::default();
|
||||||
|
let err = CreateSkill::new(Arc::new(store), Arc::new(SeqIds::new()))
|
||||||
|
.execute(CreateSkillInput {
|
||||||
|
name: "k".to_owned(),
|
||||||
|
content: String::new(),
|
||||||
|
scope: SkillScope::Global,
|
||||||
|
project_root: root(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(err.code(), "INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn update_skill_replaces_content() {
|
||||||
|
let store = FakeSkills::default();
|
||||||
|
store
|
||||||
|
.save(
|
||||||
|
&Skill::new(sid(1), "k", MarkdownDoc::new("v1"), SkillScope::Global).unwrap(),
|
||||||
|
&root(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let out = UpdateSkill::new(Arc::new(store.clone()))
|
||||||
|
.execute(UpdateSkillInput {
|
||||||
|
scope: SkillScope::Global,
|
||||||
|
skill_id: sid(1),
|
||||||
|
content: "v2".to_owned(),
|
||||||
|
project_root: root(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(out.skill.content_md.as_str(), "v2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn update_unknown_skill_is_not_found() {
|
||||||
|
let store = FakeSkills::default();
|
||||||
|
let err = UpdateSkill::new(Arc::new(store))
|
||||||
|
.execute(UpdateSkillInput {
|
||||||
|
scope: SkillScope::Global,
|
||||||
|
skill_id: sid(404),
|
||||||
|
content: "x".to_owned(),
|
||||||
|
project_root: root(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(err.code(), "NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_is_scope_filtered() {
|
||||||
|
let store = FakeSkills::default();
|
||||||
|
store
|
||||||
|
.save(
|
||||||
|
&Skill::new(sid(1), "g", MarkdownDoc::new("x"), SkillScope::Global).unwrap(),
|
||||||
|
&root(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.save(
|
||||||
|
&Skill::new(sid(2), "p", MarkdownDoc::new("x"), SkillScope::Project).unwrap(),
|
||||||
|
&root(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let listed = ListSkills::new(Arc::new(store))
|
||||||
|
.execute(ListSkillsInput {
|
||||||
|
scope: SkillScope::Global,
|
||||||
|
project_root: root(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(listed.skills.len(), 1);
|
||||||
|
assert_eq!(listed.skills[0].id, sid(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_then_delete_is_not_found() {
|
||||||
|
let store = FakeSkills::default();
|
||||||
|
store
|
||||||
|
.save(
|
||||||
|
&Skill::new(sid(1), "k", MarkdownDoc::new("x"), SkillScope::Project).unwrap(),
|
||||||
|
&root(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let uc = DeleteSkill::new(Arc::new(store));
|
||||||
|
uc.execute(DeleteSkillInput {
|
||||||
|
scope: SkillScope::Project,
|
||||||
|
skill_id: sid(1),
|
||||||
|
project_root: root(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let err = uc
|
||||||
|
.execute(DeleteSkillInput {
|
||||||
|
scope: SkillScope::Project,
|
||||||
|
skill_id: sid(1),
|
||||||
|
project_root: root(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(err.code(), "NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Assignment
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn assigned_event(events: &[DomainEvent]) -> Vec<(SkillId, bool)> {
|
||||||
|
events
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| match e {
|
||||||
|
DomainEvent::SkillAssigned {
|
||||||
|
skill_id, assigned, ..
|
||||||
|
} => Some((*skill_id, *assigned)),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn assign_mutates_manifest_emits_event_and_is_idempotent() {
|
||||||
|
let contexts = FakeContexts::new(vec![scratch_entry(aid(1))]);
|
||||||
|
let bus = SpyBus::default();
|
||||||
|
let uc = AssignSkillToAgent::new(Arc::new(contexts.clone()), Arc::new(bus.clone()));
|
||||||
|
let input = AssignSkillToAgentInput {
|
||||||
|
project: project(),
|
||||||
|
agent_id: aid(1),
|
||||||
|
skill: SkillRef::new(sid(9), SkillScope::Global),
|
||||||
|
};
|
||||||
|
|
||||||
|
uc.execute(input.clone()).await.unwrap();
|
||||||
|
// Re-assigning the same skill is a no-op (dedup).
|
||||||
|
uc.execute(input).await.unwrap();
|
||||||
|
|
||||||
|
let entry = &contexts.manifest().entries[0];
|
||||||
|
assert_eq!(entry.skills.len(), 1, "no duplicate assignment");
|
||||||
|
assert_eq!(entry.skills[0].skill_id, sid(9));
|
||||||
|
assert_eq!(
|
||||||
|
assigned_event(&bus.events()),
|
||||||
|
vec![(sid(9), true)],
|
||||||
|
"exactly one assign event despite double execute"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unassign_removes_and_emits_false_then_is_noop() {
|
||||||
|
let mut entry = scratch_entry(aid(1));
|
||||||
|
entry.skills.push(SkillRef::new(sid(9), SkillScope::Global));
|
||||||
|
let contexts = FakeContexts::new(vec![entry]);
|
||||||
|
let bus = SpyBus::default();
|
||||||
|
let uc = UnassignSkillFromAgent::new(Arc::new(contexts.clone()), Arc::new(bus.clone()));
|
||||||
|
let input = UnassignSkillFromAgentInput {
|
||||||
|
project: project(),
|
||||||
|
agent_id: aid(1),
|
||||||
|
skill_id: sid(9),
|
||||||
|
};
|
||||||
|
|
||||||
|
uc.execute(input.clone()).await.unwrap();
|
||||||
|
uc.execute(input).await.unwrap(); // already gone → no-op
|
||||||
|
|
||||||
|
assert!(contexts.manifest().entries[0].skills.is_empty());
|
||||||
|
assert_eq!(assigned_event(&bus.events()), vec![(sid(9), false)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn assign_to_unknown_agent_is_not_found() {
|
||||||
|
let contexts = FakeContexts::new(vec![]);
|
||||||
|
let bus = SpyBus::default();
|
||||||
|
let err = AssignSkillToAgent::new(Arc::new(contexts), Arc::new(bus))
|
||||||
|
.execute(AssignSkillToAgentInput {
|
||||||
|
project: project(),
|
||||||
|
agent_id: aid(404),
|
||||||
|
skill: SkillRef::new(sid(9), SkillScope::Global),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(err.code(), "NOT_FOUND");
|
||||||
|
}
|
||||||
@ -102,6 +102,10 @@ impl PtyPort for FakePty {
|
|||||||
Ok(Box::new(std::iter::empty()))
|
Ok(Box::new(std::iter::empty()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn scrollback(&self, _handle: &PtyHandle) -> Result<Vec<u8>, PtyError> {
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
async fn kill(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
|
async fn kill(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
|
||||||
let mut inner = self.0.lock().unwrap();
|
let mut inner = self.0.lock().unwrap();
|
||||||
inner.calls.push(Call::Kill {
|
inner.calls.push(Call::Kill {
|
||||||
|
|||||||
@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::error::DomainError;
|
use crate::error::DomainError;
|
||||||
use crate::ids::{AgentId, ProfileId, TemplateId};
|
use crate::ids::{AgentId, ProfileId, TemplateId};
|
||||||
|
use crate::skill::SkillRef;
|
||||||
use crate::template::TemplateVersion;
|
use crate::template::TemplateVersion;
|
||||||
|
|
||||||
/// Origin of an agent: created from scratch, or derived from a template.
|
/// Origin of an agent: created from scratch, or derived from a template.
|
||||||
@ -65,6 +66,10 @@ pub struct Agent {
|
|||||||
pub origin: AgentOrigin,
|
pub origin: AgentOrigin,
|
||||||
/// Whether the agent tracks its template (only valid for template origins).
|
/// Whether the agent tracks its template (only valid for template origins).
|
||||||
pub synchronized: bool,
|
pub synchronized: bool,
|
||||||
|
/// Skills assigned to this agent, injected into its convention file at
|
||||||
|
/// activation (ARCHITECTURE §14.2). Empty by default.
|
||||||
|
#[serde(default)]
|
||||||
|
pub skills: Vec<SkillRef>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Agent {
|
impl Agent {
|
||||||
@ -98,8 +103,37 @@ impl Agent {
|
|||||||
profile_id,
|
profile_id,
|
||||||
origin,
|
origin,
|
||||||
synchronized,
|
synchronized,
|
||||||
|
skills: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns a copy of this agent carrying the given assigned skills,
|
||||||
|
/// deduplicated by `skill_id` (keeping first occurrence).
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_skills(mut self, skills: Vec<SkillRef>) -> Self {
|
||||||
|
self.skills = Vec::new();
|
||||||
|
for skill in skills {
|
||||||
|
self.assign_skill(skill);
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assigns a skill to this agent. Idempotent: re-assigning the same
|
||||||
|
/// `skill_id` is a no-op (returns `false`); a new assignment returns `true`.
|
||||||
|
pub fn assign_skill(&mut self, skill: SkillRef) -> bool {
|
||||||
|
if self.skills.iter().any(|s| s.skill_id == skill.skill_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.skills.push(skill);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a skill assignment by id. Returns `true` if a skill was removed.
|
||||||
|
pub fn unassign_skill(&mut self, skill_id: crate::ids::SkillId) -> bool {
|
||||||
|
let before = self.skills.len();
|
||||||
|
self.skills.retain(|s| s.skill_id != skill_id);
|
||||||
|
self.skills.len() != before
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One entry in the project agent manifest (`.ideai/agents.json`).
|
/// One entry in the project agent manifest (`.ideai/agents.json`).
|
||||||
@ -135,6 +169,10 @@ pub struct ManifestEntry {
|
|||||||
/// Template version recorded at the last sync.
|
/// Template version recorded at the last sync.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub synced_template_version: Option<TemplateVersion>,
|
pub synced_template_version: Option<TemplateVersion>,
|
||||||
|
/// Skills assigned to this agent (ARCHITECTURE §14.2). Defaults to empty for
|
||||||
|
/// backward-compatible deserialisation of pre-L12 manifests.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub skills: Vec<SkillRef>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ManifestEntry {
|
impl ManifestEntry {
|
||||||
@ -172,6 +210,7 @@ impl ManifestEntry {
|
|||||||
template_id,
|
template_id,
|
||||||
synchronized,
|
synchronized,
|
||||||
synced_template_version,
|
synced_template_version,
|
||||||
|
skills: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -196,6 +235,7 @@ impl ManifestEntry {
|
|||||||
template_id,
|
template_id,
|
||||||
synchronized: agent.synchronized,
|
synchronized: agent.synchronized,
|
||||||
synced_template_version,
|
synced_template_version,
|
||||||
|
skills: agent.skills.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -213,14 +253,15 @@ impl ManifestEntry {
|
|||||||
},
|
},
|
||||||
_ => AgentOrigin::Scratch,
|
_ => AgentOrigin::Scratch,
|
||||||
};
|
};
|
||||||
Agent::new(
|
Ok(Agent::new(
|
||||||
self.agent_id,
|
self.agent_id,
|
||||||
self.name.clone(),
|
self.name.clone(),
|
||||||
self.md_path.clone(),
|
self.md_path.clone(),
|
||||||
self.profile_id,
|
self.profile_id,
|
||||||
origin,
|
origin,
|
||||||
self.synchronized,
|
self.synchronized,
|
||||||
)
|
)?
|
||||||
|
.with_skills(self.skills.clone()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
//! Domain events published on the [`crate::ports::EventBus`] and relayed to the
|
//! Domain events published on the [`crate::ports::EventBus`] and relayed to the
|
||||||
//! presentation layer (ARCHITECTURE §3.2).
|
//! presentation layer (ARCHITECTURE §3.2).
|
||||||
|
|
||||||
use crate::ids::{AgentId, ProjectId, SessionId, TemplateId};
|
use crate::ids::{AgentId, ProjectId, SessionId, SkillId, TemplateId};
|
||||||
use crate::template::TemplateVersion;
|
use crate::template::TemplateVersion;
|
||||||
|
|
||||||
/// Events emitted by the domain/application as state changes occur.
|
/// Events emitted by the domain/application as state changes occur.
|
||||||
@ -54,6 +54,15 @@ pub enum DomainEvent {
|
|||||||
/// Version it was brought up to.
|
/// Version it was brought up to.
|
||||||
to: TemplateVersion,
|
to: TemplateVersion,
|
||||||
},
|
},
|
||||||
|
/// A skill was assigned to (or unassigned from) an agent.
|
||||||
|
SkillAssigned {
|
||||||
|
/// The agent whose skill set changed.
|
||||||
|
agent_id: AgentId,
|
||||||
|
/// The skill involved.
|
||||||
|
skill_id: SkillId,
|
||||||
|
/// `true` if assigned, `false` if unassigned.
|
||||||
|
assigned: bool,
|
||||||
|
},
|
||||||
/// A tab's layout changed.
|
/// A tab's layout changed.
|
||||||
LayoutChanged {
|
LayoutChanged {
|
||||||
/// The project whose layout changed.
|
/// The project whose layout changed.
|
||||||
|
|||||||
@ -68,6 +68,10 @@ typed_id!(
|
|||||||
/// Identifies an [`crate::profile::AgentProfile`].
|
/// Identifies an [`crate::profile::AgentProfile`].
|
||||||
ProfileId
|
ProfileId
|
||||||
);
|
);
|
||||||
|
typed_id!(
|
||||||
|
/// Identifies a [`crate::skill::Skill`].
|
||||||
|
SkillId
|
||||||
|
);
|
||||||
typed_id!(
|
typed_id!(
|
||||||
/// Identifies a [`crate::terminal::TerminalSession`].
|
/// Identifies a [`crate::terminal::TerminalSession`].
|
||||||
SessionId
|
SessionId
|
||||||
|
|||||||
@ -41,6 +41,7 @@ pub mod ports;
|
|||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
pub mod remote;
|
pub mod remote;
|
||||||
|
pub mod skill;
|
||||||
pub mod template;
|
pub mod template;
|
||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
|
|
||||||
@ -53,13 +54,16 @@ mod validation;
|
|||||||
pub use error::DomainError;
|
pub use error::DomainError;
|
||||||
|
|
||||||
pub use ids::{
|
pub use ids::{
|
||||||
AgentId, LayoutId, NodeId, ProfileId, ProjectId, SessionId, TabId, TemplateId, WindowId,
|
AgentId, LayoutId, NodeId, ProfileId, ProjectId, SessionId, SkillId, TabId, TemplateId,
|
||||||
|
WindowId,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use project::{Project, ProjectPath};
|
pub use project::{Project, ProjectPath};
|
||||||
|
|
||||||
pub use agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
|
pub use agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
|
||||||
|
|
||||||
|
pub use skill::{Skill, SkillRef, SkillScope};
|
||||||
|
|
||||||
pub use template::{AgentTemplate, TemplateVersion};
|
pub use template::{AgentTemplate, TemplateVersion};
|
||||||
|
|
||||||
pub use profile::{AgentProfile, ContextInjection};
|
pub use profile::{AgentProfile, ContextInjection};
|
||||||
|
|||||||
@ -34,6 +34,7 @@ use crate::markdown::MarkdownDoc;
|
|||||||
use crate::profile::AgentProfile;
|
use crate::profile::AgentProfile;
|
||||||
use crate::project::{Project, ProjectPath};
|
use crate::project::{Project, ProjectPath};
|
||||||
use crate::remote::RemoteKind;
|
use crate::remote::RemoteKind;
|
||||||
|
use crate::skill::{Skill, SkillScope};
|
||||||
use crate::template::AgentTemplate;
|
use crate::template::AgentTemplate;
|
||||||
use crate::terminal::PtySize;
|
use crate::terminal::PtySize;
|
||||||
|
|
||||||
@ -209,7 +210,7 @@ pub enum FsError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Errors from persistence stores ([`TemplateStore`], [`ProjectStore`],
|
/// Errors from persistence stores ([`TemplateStore`], [`ProjectStore`],
|
||||||
/// [`AgentContextStore`]).
|
/// [`AgentContextStore`], [`SkillStore`]).
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||||
pub enum StoreError {
|
pub enum StoreError {
|
||||||
/// The requested item was not found.
|
/// The requested item was not found.
|
||||||
@ -334,10 +335,24 @@ pub trait PtyPort: Send + Sync {
|
|||||||
|
|
||||||
/// Subscribes to the PTY's byte output stream.
|
/// Subscribes to the PTY's byte output stream.
|
||||||
///
|
///
|
||||||
|
/// Re-subscribable: each call returns a fresh stream that receives every
|
||||||
|
/// chunk produced **from now on**. Combined with [`scrollback`](Self::scrollback)
|
||||||
|
/// this lets the presentation layer *re-attach* a view to a still-living PTY
|
||||||
|
/// after a navigation/layout change tore the previous view down — without
|
||||||
|
/// re-spawning the process.
|
||||||
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// [`PtyError`] if the handle is unknown.
|
/// [`PtyError`] if the handle is unknown.
|
||||||
fn subscribe_output(&self, handle: &PtyHandle) -> Result<OutputStream, PtyError>;
|
fn subscribe_output(&self, handle: &PtyHandle) -> Result<OutputStream, PtyError>;
|
||||||
|
|
||||||
|
/// Returns the recent output retained for the session (a bounded scrollback
|
||||||
|
/// ring buffer, the most recent bytes). Used to repaint a view that
|
||||||
|
/// re-attaches to a live PTY so the terminal isn't blank.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`PtyError`] if the handle is unknown.
|
||||||
|
fn scrollback(&self, handle: &PtyHandle) -> Result<Vec<u8>, PtyError>;
|
||||||
|
|
||||||
/// Kills the PTY's process, returning its exit status.
|
/// Kills the PTY's process, returning its exit status.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
@ -446,6 +461,57 @@ pub trait TemplateStore: Send + Sync {
|
|||||||
async fn delete(&self, id: crate::ids::TemplateId) -> Result<(), StoreError>;
|
async fn delete(&self, id: crate::ids::TemplateId) -> Result<(), StoreError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// CRUD for [`Skill`]s across both scopes ([`SkillScope::Global`] in the IDE
|
||||||
|
/// store, [`SkillScope::Project`] under `.ideai/skills/`), per ARCHITECTURE
|
||||||
|
/// §14.2 and L12.
|
||||||
|
///
|
||||||
|
/// The two scopes are **isolated**: a skill saved as `Project` never surfaces in
|
||||||
|
/// a `Global` listing and vice-versa. Each call carries the [`SkillScope`]
|
||||||
|
/// explicitly (or via the [`Skill`] for [`save`](Self::save)) so the adapter
|
||||||
|
/// resolves the right backing location.
|
||||||
|
///
|
||||||
|
/// `root` identifies the project whose `.ideai/skills/` to use for
|
||||||
|
/// [`SkillScope::Project`]; it is **ignored** for [`SkillScope::Global`] (which
|
||||||
|
/// lives in the machine-global IDE store). Passing the root per call — rather
|
||||||
|
/// than baking it into the adapter — keeps a single store instance correct
|
||||||
|
/// across every open project (mirroring [`AgentContextStore`]).
|
||||||
|
#[async_trait]
|
||||||
|
pub trait SkillStore: Send + Sync {
|
||||||
|
/// Lists all skills in `scope` (for `root`'s project when project-scoped).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`StoreError`] on failure.
|
||||||
|
async fn list(&self, scope: SkillScope, root: &ProjectPath) -> Result<Vec<Skill>, StoreError>;
|
||||||
|
|
||||||
|
/// Gets a skill by id within `scope`.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`StoreError::NotFound`] if absent in that scope.
|
||||||
|
async fn get(
|
||||||
|
&self,
|
||||||
|
scope: SkillScope,
|
||||||
|
root: &ProjectPath,
|
||||||
|
id: crate::ids::SkillId,
|
||||||
|
) -> Result<Skill, StoreError>;
|
||||||
|
|
||||||
|
/// Saves (creates or replaces by id) a skill in its own [`Skill::scope`].
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`StoreError`] on failure.
|
||||||
|
async fn save(&self, skill: &Skill, root: &ProjectPath) -> Result<(), StoreError>;
|
||||||
|
|
||||||
|
/// Deletes a skill by id within `scope`.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`StoreError::NotFound`] if absent in that scope.
|
||||||
|
async fn delete(
|
||||||
|
&self,
|
||||||
|
scope: SkillScope,
|
||||||
|
root: &ProjectPath,
|
||||||
|
id: crate::ids::SkillId,
|
||||||
|
) -> Result<(), StoreError>;
|
||||||
|
}
|
||||||
|
|
||||||
/// Persistence of the known-projects registry and the workspace.
|
/// Persistence of the known-projects registry and the workspace.
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait ProjectStore: Send + Sync {
|
pub trait ProjectStore: Send + Sync {
|
||||||
|
|||||||
@ -96,7 +96,10 @@ pub struct AgentProfile {
|
|||||||
pub context_injection: ContextInjection,
|
pub context_injection: ContextInjection,
|
||||||
/// Optional detection command (e.g. `claude --version`).
|
/// Optional detection command (e.g. `claude --version`).
|
||||||
pub detect: Option<String>,
|
pub detect: Option<String>,
|
||||||
/// Working-directory template (e.g. `"{projectRoot}"`).
|
/// Working-directory template. Always `"{agentRunDir}"` (ARCHITECTURE §14.1):
|
||||||
|
/// an agent's PTY cwd is its isolated `.ideai/run/<agent-id>/` directory,
|
||||||
|
/// **never** the project root, so that N agents of the same profile never
|
||||||
|
/// collide on a single conventional file (`CLAUDE.md`, …) at the root.
|
||||||
pub cwd_template: String,
|
pub cwd_template: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
111
crates/domain/src/skill.rs
Normal file
111
crates/domain/src/skill.rs
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
//! Skill entity — reusable, model-agnostic workflows assignable to agents.
|
||||||
|
//!
|
||||||
|
//! A [`Skill`] is IdeA's universal equivalent of a CLI's slash-command, but
|
||||||
|
//! without any dependency on a particular model's `/command` syntax
|
||||||
|
//! (ARCHITECTURE §14.2). Assigned skills are injected as plain text into the
|
||||||
|
//! agent's generated convention file at activation — there is no proprietary
|
||||||
|
//! CLI mechanism involved.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::DomainError;
|
||||||
|
use crate::ids::SkillId;
|
||||||
|
use crate::markdown::MarkdownDoc;
|
||||||
|
|
||||||
|
/// Where a skill lives, which also selects the store used to resolve it.
|
||||||
|
///
|
||||||
|
/// - [`SkillScope::Global`] skills are stored in the global IDE store
|
||||||
|
/// (`<app_data>/IdeA/skills/`) and reusable across projects.
|
||||||
|
/// - [`SkillScope::Project`] skills are stored under `.ideai/skills/` and are
|
||||||
|
/// specific to one project.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum SkillScope {
|
||||||
|
/// Reusable across projects (global IDE store).
|
||||||
|
Global,
|
||||||
|
/// Specific to a single project (`.ideai/skills/`).
|
||||||
|
Project,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A reusable workflow assignable to one or more agents.
|
||||||
|
///
|
||||||
|
/// Invariants enforced here:
|
||||||
|
/// - `name` non-empty,
|
||||||
|
/// - `content_md` non-empty (an empty skill carries no behaviour).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Skill {
|
||||||
|
/// Stable identifier.
|
||||||
|
pub id: SkillId,
|
||||||
|
/// Display name (also used as the `.md` file stem on disk).
|
||||||
|
pub name: String,
|
||||||
|
/// Markdown body — the workflow injected into an agent's convention file.
|
||||||
|
pub content_md: MarkdownDoc,
|
||||||
|
/// Scope (selects the backing store).
|
||||||
|
pub scope: SkillScope,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Skill {
|
||||||
|
/// Builds a validated skill.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// - [`DomainError::EmptyField`] if `name` is empty,
|
||||||
|
/// - [`DomainError::EmptyField`] if `content_md` is empty.
|
||||||
|
pub fn new(
|
||||||
|
id: SkillId,
|
||||||
|
name: impl Into<String>,
|
||||||
|
content_md: MarkdownDoc,
|
||||||
|
scope: SkillScope,
|
||||||
|
) -> Result<Self, DomainError> {
|
||||||
|
let name = name.into();
|
||||||
|
crate::validation::non_empty(&name, "skill.name")?;
|
||||||
|
if content_md.is_empty() {
|
||||||
|
return Err(DomainError::EmptyField {
|
||||||
|
field: "skill.content_md",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
content_md,
|
||||||
|
scope,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a copy of this skill with replaced content, re-validating the
|
||||||
|
/// non-empty invariant.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`DomainError::EmptyField`] if `content_md` is empty.
|
||||||
|
pub fn with_content(&self, content_md: MarkdownDoc) -> Result<Self, DomainError> {
|
||||||
|
Skill::new(self.id, self.name.clone(), content_md, self.scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A reference from an agent to one assigned skill.
|
||||||
|
///
|
||||||
|
/// Stored in the [`crate::agent::ManifestEntry`]: an agent carries 0..N of these.
|
||||||
|
/// The `scope` is kept alongside the id so the application layer knows which
|
||||||
|
/// store to resolve the skill from without a global lookup.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SkillRef {
|
||||||
|
/// The assigned skill.
|
||||||
|
pub skill_id: SkillId,
|
||||||
|
/// Scope of the assigned skill (selects its store).
|
||||||
|
pub scope: SkillScope,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SkillRef {
|
||||||
|
/// Builds a reference to an assigned skill.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(skill_id: SkillId, scope: SkillScope) -> Self {
|
||||||
|
Self { skill_id, scope }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&Skill> for SkillRef {
|
||||||
|
fn from(skill: &Skill) -> Self {
|
||||||
|
Self::new(skill.id, skill.scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,8 +5,8 @@ mod helpers;
|
|||||||
|
|
||||||
use domain::{
|
use domain::{
|
||||||
Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, DomainError,
|
Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, DomainError,
|
||||||
ManifestEntry, MarkdownDoc, ProfileId, Project, ProjectPath, PtySize, RemoteRef, SshAuth,
|
ManifestEntry, MarkdownDoc, ProfileId, Project, ProjectPath, PtySize, RemoteRef, Skill, SkillId,
|
||||||
TemplateId, TemplateVersion,
|
SkillRef, SkillScope, SshAuth, TemplateId, TemplateVersion,
|
||||||
};
|
};
|
||||||
use helpers::{AtomicSeqIdGenerator, FixedClock};
|
use helpers::{AtomicSeqIdGenerator, FixedClock};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@ -409,3 +409,121 @@ fn manifest_unique_md_paths_ok() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(AgentManifest::new(1, vec![e1, e2]).is_ok());
|
assert!(AgentManifest::new(1, vec![e1, e2]).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Skill invariants (L12, ARCHITECTURE §14.2)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn skill_id(n: u128) -> SkillId {
|
||||||
|
SkillId::from_uuid(Uuid::from_u128(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skill_valid_construction() {
|
||||||
|
let s = Skill::new(
|
||||||
|
skill_id(1),
|
||||||
|
"code-review",
|
||||||
|
MarkdownDoc::new("review the diff"),
|
||||||
|
SkillScope::Global,
|
||||||
|
);
|
||||||
|
assert!(s.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skill_rejects_empty_name() {
|
||||||
|
let err = Skill::new(
|
||||||
|
skill_id(1),
|
||||||
|
"",
|
||||||
|
MarkdownDoc::new("body"),
|
||||||
|
SkillScope::Project,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(err, DomainError::EmptyField { field: "skill.name" });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skill_rejects_empty_content() {
|
||||||
|
let err = Skill::new(skill_id(1), "x", MarkdownDoc::new(""), SkillScope::Global).unwrap_err();
|
||||||
|
assert_eq!(
|
||||||
|
err,
|
||||||
|
DomainError::EmptyField {
|
||||||
|
field: "skill.content_md"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skill_with_content_revalidates() {
|
||||||
|
let s = Skill::new(skill_id(1), "x", MarkdownDoc::new("a"), SkillScope::Global).unwrap();
|
||||||
|
assert!(s.with_content(MarkdownDoc::new("b")).is_ok());
|
||||||
|
assert!(s.with_content(MarkdownDoc::new("")).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_assign_skill_is_idempotent() {
|
||||||
|
let mut a = Agent::new(
|
||||||
|
agent_id(1),
|
||||||
|
"dev",
|
||||||
|
"agents/dev.md",
|
||||||
|
profile_id(),
|
||||||
|
AgentOrigin::Scratch,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let r = SkillRef::new(skill_id(7), SkillScope::Global);
|
||||||
|
assert!(a.assign_skill(r)); // first assignment
|
||||||
|
assert!(!a.assign_skill(r)); // duplicate ignored
|
||||||
|
assert_eq!(a.skills, vec![r]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_unassign_skill() {
|
||||||
|
let mut a = Agent::new(
|
||||||
|
agent_id(1),
|
||||||
|
"dev",
|
||||||
|
"agents/dev.md",
|
||||||
|
profile_id(),
|
||||||
|
AgentOrigin::Scratch,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let r = SkillRef::new(skill_id(7), SkillScope::Project);
|
||||||
|
a.assign_skill(r);
|
||||||
|
assert!(a.unassign_skill(skill_id(7)));
|
||||||
|
assert!(!a.unassign_skill(skill_id(7))); // already gone
|
||||||
|
assert!(a.skills.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_with_skills_dedups() {
|
||||||
|
let r = SkillRef::new(skill_id(7), SkillScope::Global);
|
||||||
|
let a = Agent::new(
|
||||||
|
agent_id(1),
|
||||||
|
"dev",
|
||||||
|
"agents/dev.md",
|
||||||
|
profile_id(),
|
||||||
|
AgentOrigin::Scratch,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.with_skills(vec![r, r]);
|
||||||
|
assert_eq!(a.skills, vec![r]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_entry_preserves_skills_through_agent_roundtrip() {
|
||||||
|
let r = SkillRef::new(skill_id(7), SkillScope::Project);
|
||||||
|
let agent = Agent::new(
|
||||||
|
agent_id(1),
|
||||||
|
"dev",
|
||||||
|
"agents/dev.md",
|
||||||
|
profile_id(),
|
||||||
|
AgentOrigin::Scratch,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.with_skills(vec![r]);
|
||||||
|
let entry = ManifestEntry::from_agent(&agent);
|
||||||
|
assert_eq!(entry.skills, vec![r]);
|
||||||
|
assert_eq!(entry.to_agent().unwrap(), agent);
|
||||||
|
}
|
||||||
|
|||||||
@ -6,7 +6,7 @@ mod helpers;
|
|||||||
use domain::{
|
use domain::{
|
||||||
Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, Direction,
|
Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, Direction,
|
||||||
LayoutNode, LayoutTree, LeafCell, ManifestEntry, MarkdownDoc, Project, ProjectPath, RemoteRef,
|
LayoutNode, LayoutTree, LeafCell, ManifestEntry, MarkdownDoc, Project, ProjectPath, RemoteRef,
|
||||||
SplitContainer, SshAuth, TemplateVersion, WeightedChild,
|
Skill, SkillId, SkillRef, SkillScope, SplitContainer, SshAuth, TemplateVersion, WeightedChild,
|
||||||
};
|
};
|
||||||
use helpers::{node, session};
|
use helpers::{node, session};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@ -229,6 +229,61 @@ fn manifest_roundtrip_and_camel_case() {
|
|||||||
assert!(!json.contains("\"templateId\":null"), "json was {json}");
|
assert!(!json.contains("\"templateId\":null"), "json was {json}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Skill (L12) — round-trip, camelCase scope tag, manifest skills back-compat
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn sid(n: u128) -> SkillId {
|
||||||
|
SkillId::from_uuid(Uuid::from_u128(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skill_roundtrip_and_camel_case_scope() {
|
||||||
|
let s = Skill::new(sid(1), "code-review", MarkdownDoc::new("body"), SkillScope::Global).unwrap();
|
||||||
|
assert_eq!(roundtrip(&s), s);
|
||||||
|
let json = serde_json::to_string(&s).unwrap();
|
||||||
|
assert!(json.contains("\"scope\":\"global\""), "json was {json}");
|
||||||
|
assert!(json.contains("\"contentMd\""), "json was {json}");
|
||||||
|
|
||||||
|
let p = Skill::new(sid(2), "simplify", MarkdownDoc::new("b"), SkillScope::Project).unwrap();
|
||||||
|
let pj = serde_json::to_string(&p).unwrap();
|
||||||
|
assert!(pj.contains("\"scope\":\"project\""), "json was {pj}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_entry_skills_roundtrip_and_camel_case() {
|
||||||
|
let entry = ManifestEntry::from_agent(
|
||||||
|
&Agent::new(
|
||||||
|
aid(1),
|
||||||
|
"dev",
|
||||||
|
"agents/dev.md",
|
||||||
|
profid(9),
|
||||||
|
AgentOrigin::Scratch,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.with_skills(vec![SkillRef::new(sid(5), SkillScope::Project)]),
|
||||||
|
);
|
||||||
|
assert_eq!(roundtrip(&entry), entry);
|
||||||
|
let json = serde_json::to_string(&entry).unwrap();
|
||||||
|
assert!(json.contains("\"skillId\""), "json was {json}");
|
||||||
|
assert!(json.contains("\"scope\":\"project\""), "json was {json}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_entry_without_skills_omits_field_and_deserialises() {
|
||||||
|
// An entry with no skills must not emit "skills" (skip_serializing_if),
|
||||||
|
// and a pre-L12 manifest JSON (no skills key) must deserialise to empty.
|
||||||
|
let entry =
|
||||||
|
ManifestEntry::new(aid(1), "dev", "agents/dev.md", profid(9), None, false, None).unwrap();
|
||||||
|
let json = serde_json::to_string(&entry).unwrap();
|
||||||
|
assert!(!json.contains("\"skills\""), "json was {json}");
|
||||||
|
|
||||||
|
let legacy = r#"{"agentId":"00000000-0000-0000-0000-000000000001","name":"dev","mdPath":"agents/dev.md","profileId":"00000000-0000-0000-0000-000000000009","synchronized":false}"#;
|
||||||
|
let parsed: ManifestEntry = serde_json::from_str(legacy).unwrap();
|
||||||
|
assert!(parsed.skills.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// LayoutTree (tagged enum: type/node)
|
// LayoutTree (tagged enum: type/node)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -32,4 +32,6 @@ pub use process::LocalProcessSpawner;
|
|||||||
pub use pty::PortablePtyAdapter;
|
pub use pty::PortablePtyAdapter;
|
||||||
pub use remote::{remote_host, LocalHost};
|
pub use remote::{remote_host, LocalHost};
|
||||||
pub use runtime::CliAgentRuntime;
|
pub use runtime::CliAgentRuntime;
|
||||||
pub use store::{FsProfileStore, FsProjectStore, FsTemplateStore, IdeaiContextStore};
|
pub use store::{
|
||||||
|
FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, IdeaiContextStore,
|
||||||
|
};
|
||||||
|
|||||||
@ -12,10 +12,15 @@
|
|||||||
//! domain never sees an OS handle (ARCHITECTURE §4).
|
//! domain never sees an OS handle (ARCHITECTURE §4).
|
||||||
//! - On [`spawn`](PtyPort::spawn) we open a PTY pair, spawn the command in the
|
//! - On [`spawn`](PtyPort::spawn) we open a PTY pair, spawn the command in the
|
||||||
//! slave, then start **one reader thread** that pumps bytes from the master
|
//! slave, then start **one reader thread** that pumps bytes from the master
|
||||||
//! into an [`std::sync::mpsc`] channel. [`subscribe_output`](PtyPort::subscribe_output)
|
//! into a shared [`Broadcast`] hub. The hub does two things with every chunk:
|
||||||
//! hands back the receiver wrapped as the domain's blocking [`OutputStream`]
|
//! it appends to a bounded **scrollback ring buffer** (~100 KB, most recent
|
||||||
//! iterator; the presentation layer drains it on its own thread and forwards
|
//! bytes) and it fans the chunk out to every *currently subscribed* receiver.
|
||||||
//! chunks to the per-session Tauri channel (the `PtyBridge`).
|
//! - [`subscribe_output`](PtyPort::subscribe_output) registers a fresh
|
||||||
|
//! subscriber and returns its receiver wrapped as the domain's blocking
|
||||||
|
//! [`OutputStream`] iterator. It is **re-subscribable**: after a view tears
|
||||||
|
//! down (navigation / layout change) a new view can re-attach to the *same*
|
||||||
|
//! live PTY by subscribing again and repainting the scrollback first — no
|
||||||
|
//! re-spawn. [`scrollback`](PtyPort::scrollback) returns that retained buffer.
|
||||||
//! - [`write`](PtyPort::write) / [`resize`](PtyPort::resize) act on the stored
|
//! - [`write`](PtyPort::write) / [`resize`](PtyPort::resize) act on the stored
|
||||||
//! writer / master. [`kill`](PtyPort::kill) terminates the child, joins the
|
//! writer / master. [`kill`](PtyPort::kill) terminates the child, joins the
|
||||||
//! reader thread, and returns the [`ExitStatus`].
|
//! reader thread, and returns the [`ExitStatus`].
|
||||||
@ -30,9 +35,10 @@
|
|||||||
//! Unix-only assumption (no raw fds, no signals) so it should port as-is.
|
//! Unix-only assumption (no raw fds, no signals) so it should port as-is.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::collections::VecDeque;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::sync::mpsc::{self, Receiver, Sender};
|
use std::sync::mpsc::{self, Receiver, Sender};
|
||||||
use std::sync::Mutex;
|
use std::sync::{Arc, Mutex};
|
||||||
use std::thread::JoinHandle;
|
use std::thread::JoinHandle;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@ -45,6 +51,68 @@ use domain::SessionId;
|
|||||||
/// Size of each read buffer pumped from the master PTY.
|
/// Size of each read buffer pumped from the master PTY.
|
||||||
const READ_BUF: usize = 8 * 1024;
|
const READ_BUF: usize = 8 * 1024;
|
||||||
|
|
||||||
|
/// Maximum number of bytes retained in a session's scrollback ring buffer
|
||||||
|
/// (~100 KB, "the most recent output"). When the buffer would exceed this, the
|
||||||
|
/// oldest bytes are dropped so a re-attaching view repaints recent history.
|
||||||
|
const SCROLLBACK_CAP: usize = 100 * 1024;
|
||||||
|
|
||||||
|
/// The shared output hub of one PTY: a bounded scrollback ring buffer plus the
|
||||||
|
/// set of currently-subscribed receivers. The reader thread feeds both; views
|
||||||
|
/// subscribe and unsubscribe freely over the PTY's lifetime (re-attach support).
|
||||||
|
///
|
||||||
|
/// Each subscriber is a [`Sender`]; a send failing (receiver dropped because the
|
||||||
|
/// view detached) prunes that subscriber on the next chunk. This is the
|
||||||
|
/// fan-out/broadcast that replaces the old single-take `output_rx`.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Broadcast {
|
||||||
|
/// Bounded ring buffer of the most recent output bytes.
|
||||||
|
scrollback: VecDeque<u8>,
|
||||||
|
/// Live subscribers; pruned lazily when their receiver is gone.
|
||||||
|
subscribers: Vec<Sender<Vec<u8>>>,
|
||||||
|
/// Set once the PTY hit EOF (process exited) — no more output will ever come.
|
||||||
|
eof: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Broadcast {
|
||||||
|
/// Appends a chunk to the scrollback (trimming to [`SCROLLBACK_CAP`]) and
|
||||||
|
/// fans it out to every live subscriber, dropping any that have gone away.
|
||||||
|
fn push(&mut self, chunk: &[u8]) {
|
||||||
|
self.scrollback.extend(chunk.iter().copied());
|
||||||
|
let overflow = self.scrollback.len().saturating_sub(SCROLLBACK_CAP);
|
||||||
|
if overflow > 0 {
|
||||||
|
self.scrollback.drain(0..overflow);
|
||||||
|
}
|
||||||
|
self.subscribers
|
||||||
|
.retain(|tx| tx.send(chunk.to_vec()).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers a new subscriber, returning its receiver.
|
||||||
|
///
|
||||||
|
/// If the PTY already hit EOF, the returned stream is immediately closed
|
||||||
|
/// (its sender is dropped) so a late re-attach to a finished session doesn't
|
||||||
|
/// block forever waiting for output that will never come.
|
||||||
|
fn subscribe(&mut self) -> Receiver<Vec<u8>> {
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
if !self.eof {
|
||||||
|
self.subscribers.push(tx);
|
||||||
|
}
|
||||||
|
rx
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the retained scrollback as a contiguous byte vector.
|
||||||
|
fn snapshot(&self) -> Vec<u8> {
|
||||||
|
self.scrollback.iter().copied().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops every subscriber's sender so their output streams end (EOF). Called
|
||||||
|
/// by the reader thread when the PTY hits EOF (process exit). The scrollback
|
||||||
|
/// is preserved so a late re-attach can still repaint the final output.
|
||||||
|
fn close_subscribers(&mut self) {
|
||||||
|
self.eof = true;
|
||||||
|
self.subscribers.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A live PTY owned by the adapter.
|
/// A live PTY owned by the adapter.
|
||||||
struct LivePty {
|
struct LivePty {
|
||||||
/// Master side — used for resize.
|
/// Master side — used for resize.
|
||||||
@ -53,8 +121,8 @@ struct LivePty {
|
|||||||
writer: Box<dyn Write + Send>,
|
writer: Box<dyn Write + Send>,
|
||||||
/// The spawned child process.
|
/// The spawned child process.
|
||||||
child: Box<dyn Child + Send + Sync>,
|
child: Box<dyn Child + Send + Sync>,
|
||||||
/// Receiver end of the output channel; taken once by `subscribe_output`.
|
/// Shared scrollback + subscriber hub, fed by the reader thread.
|
||||||
output_rx: Option<Receiver<Vec<u8>>>,
|
output: Arc<Mutex<Broadcast>>,
|
||||||
/// Handle of the reader thread, joined on kill.
|
/// Handle of the reader thread, joined on kill.
|
||||||
reader: Option<JoinHandle<()>>,
|
reader: Option<JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
@ -124,22 +192,29 @@ impl PtyPort for PortablePtyAdapter {
|
|||||||
.try_clone_reader()
|
.try_clone_reader()
|
||||||
.map_err(|e| PtyError::Io(e.to_string()))?;
|
.map_err(|e| PtyError::Io(e.to_string()))?;
|
||||||
|
|
||||||
// One reader thread per PTY pumps bytes into the output channel until EOF.
|
// One reader thread per PTY pumps bytes into the broadcast hub until EOF.
|
||||||
let (tx, rx): (Sender<Vec<u8>>, Receiver<Vec<u8>>) = mpsc::channel();
|
// The hub retains a scrollback ring buffer AND fans bytes out to every
|
||||||
|
// current subscriber, so views can detach/re-attach without re-spawning.
|
||||||
|
let output: Arc<Mutex<Broadcast>> = Arc::new(Mutex::new(Broadcast::default()));
|
||||||
|
let output_for_reader = Arc::clone(&output);
|
||||||
let reader_handle = std::thread::spawn(move || {
|
let reader_handle = std::thread::spawn(move || {
|
||||||
let mut buf = [0u8; READ_BUF];
|
let mut buf = [0u8; READ_BUF];
|
||||||
loop {
|
loop {
|
||||||
match reader.read(&mut buf) {
|
match reader.read(&mut buf) {
|
||||||
Ok(0) => break,
|
Ok(0) => break,
|
||||||
Ok(n) => {
|
Ok(n) => {
|
||||||
// Receiver gone (session closed) → stop pumping.
|
if let Ok(mut hub) = output_for_reader.lock() {
|
||||||
if tx.send(buf[..n].to_vec()).is_err() {
|
hub.push(&buf[..n]);
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// EOF (process exited): end every attached stream by dropping its
|
||||||
|
// sender, while preserving the scrollback for any late re-attach.
|
||||||
|
if let Ok(mut hub) = output_for_reader.lock() {
|
||||||
|
hub.close_subscribers();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// The PTY layer owns the handle identity: it mints a fresh session id and
|
// The PTY layer owns the handle identity: it mints a fresh session id and
|
||||||
@ -153,7 +228,7 @@ impl PtyPort for PortablePtyAdapter {
|
|||||||
master: pair.master,
|
master: pair.master,
|
||||||
writer,
|
writer,
|
||||||
child,
|
child,
|
||||||
output_rx: Some(rx),
|
output,
|
||||||
reader: Some(reader_handle),
|
reader: Some(reader_handle),
|
||||||
};
|
};
|
||||||
self.sessions
|
self.sessions
|
||||||
@ -189,18 +264,33 @@ impl PtyPort for PortablePtyAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn subscribe_output(&self, handle: &PtyHandle) -> Result<OutputStream, PtyError> {
|
fn subscribe_output(&self, handle: &PtyHandle) -> Result<OutputStream, PtyError> {
|
||||||
let mut map = self
|
let map = self
|
||||||
.sessions
|
.sessions
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?;
|
.map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?;
|
||||||
let live = map.get_mut(&handle.session_id).ok_or(PtyError::NotFound)?;
|
let live = map.get(&handle.session_id).ok_or(PtyError::NotFound)?;
|
||||||
let rx = live
|
let rx = live
|
||||||
.output_rx
|
.output
|
||||||
.take()
|
.lock()
|
||||||
.ok_or_else(|| PtyError::Io("output already subscribed".to_owned()))?;
|
.map_err(|_| PtyError::Io("pty output hub poisoned".to_owned()))?
|
||||||
|
.subscribe();
|
||||||
Ok(Box::new(rx.into_iter()))
|
Ok(Box::new(rx.into_iter()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn scrollback(&self, handle: &PtyHandle) -> Result<Vec<u8>, PtyError> {
|
||||||
|
let map = self
|
||||||
|
.sessions
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?;
|
||||||
|
let live = map.get(&handle.session_id).ok_or(PtyError::NotFound)?;
|
||||||
|
let snapshot = live
|
||||||
|
.output
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| PtyError::Io("pty output hub poisoned".to_owned()))?
|
||||||
|
.snapshot();
|
||||||
|
Ok(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
async fn kill(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
|
async fn kill(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
|
||||||
// Remove from the registry so the writer/master drop and the child is
|
// Remove from the registry so the writer/master drop and the child is
|
||||||
// fully owned here while we tear it down.
|
// fully owned here while we tear it down.
|
||||||
@ -220,7 +310,8 @@ impl PtyPort for PortablePtyAdapter {
|
|||||||
.map_err(|e| PtyError::Io(e.to_string()))?;
|
.map_err(|e| PtyError::Io(e.to_string()))?;
|
||||||
|
|
||||||
// Dropping master/writer closes the PTY; the reader thread then sees EOF.
|
// Dropping master/writer closes the PTY; the reader thread then sees EOF.
|
||||||
drop(live.output_rx.take());
|
// Dropping the broadcast hub drops every subscriber's sender, so any
|
||||||
|
// still-attached view's output stream ends cleanly too.
|
||||||
if let Some(reader) = live.reader.take() {
|
if let Some(reader) = live.reader.take() {
|
||||||
let _ = reader.join();
|
let _ = reader.join();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -80,16 +80,23 @@ impl CliAgentRuntime {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves the profile's `cwd_template` against the project root.
|
/// Resolves the profile's `cwd_template` against the supplied `base` cwd.
|
||||||
///
|
///
|
||||||
/// The only recognised placeholder is `{projectRoot}` (CONTEXT §9). An empty
|
/// The base is the agent's **run directory** (`.ideai/run/<agent-id>/`),
|
||||||
/// template defaults to the project root itself.
|
/// already computed (and created) by `LaunchAgent` and passed as the `cwd`
|
||||||
fn resolve_cwd(profile: &AgentProfile, root: &ProjectPath) -> Result<ProjectPath, RuntimeError> {
|
/// argument of [`prepare_invocation`](AgentRuntime::prepare_invocation). The
|
||||||
|
/// recognised placeholder is `{agentRunDir}` (ARCHITECTURE §14.1); the legacy
|
||||||
|
/// `{projectRoot}` is still substituted with the same base for backwards
|
||||||
|
/// compatibility (the caller always passes the run dir now). An empty template
|
||||||
|
/// defaults to the base itself.
|
||||||
|
fn resolve_cwd(profile: &AgentProfile, base: &ProjectPath) -> Result<ProjectPath, RuntimeError> {
|
||||||
let template = profile.cwd_template.trim();
|
let template = profile.cwd_template.trim();
|
||||||
if template.is_empty() {
|
if template.is_empty() {
|
||||||
return Ok(root.clone());
|
return Ok(base.clone());
|
||||||
}
|
}
|
||||||
let resolved = template.replace("{projectRoot}", root.as_str());
|
let resolved = template
|
||||||
|
.replace("{agentRunDir}", base.as_str())
|
||||||
|
.replace("{projectRoot}", base.as_str());
|
||||||
ProjectPath::new(resolved).map_err(|e| RuntimeError::Invocation(e.to_string()))
|
ProjectPath::new(resolved).map_err(|e| RuntimeError::Invocation(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,9 +7,11 @@
|
|||||||
mod context;
|
mod context;
|
||||||
mod profile;
|
mod profile;
|
||||||
mod project;
|
mod project;
|
||||||
|
mod skill;
|
||||||
mod template;
|
mod template;
|
||||||
|
|
||||||
pub use context::IdeaiContextStore;
|
pub use context::IdeaiContextStore;
|
||||||
pub use profile::FsProfileStore;
|
pub use profile::FsProfileStore;
|
||||||
pub use project::FsProjectStore;
|
pub use project::FsProjectStore;
|
||||||
|
pub use skill::FsSkillStore;
|
||||||
pub use template::FsTemplateStore;
|
pub use template::FsTemplateStore;
|
||||||
|
|||||||
263
crates/infrastructure/src/store/skill.rs
Normal file
263
crates/infrastructure/src/store/skill.rs
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
//! [`FsSkillStore`] — file implementation of the [`SkillStore`] port
|
||||||
|
//! (ARCHITECTURE §14.2, L12).
|
||||||
|
//!
|
||||||
|
//! Skills are reusable, model-agnostic workflows. They live in **two isolated
|
||||||
|
//! scopes**, each backed by its own directory but the same on-disk shape
|
||||||
|
//! (mirroring [`crate::store::FsTemplateStore`]): a small JSON index carrying the
|
||||||
|
//! metadata needed to list without parsing every `.md`, plus the Markdown bodies
|
||||||
|
//! under `md/`:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! <app_data_dir>/skills/ # SkillScope::Global (shared across projects)
|
||||||
|
//! <project_root>/.ideai/skills/ # SkillScope::Project (travels with the code)
|
||||||
|
//! ├── index.json # { version, skills: [{ id, name, contentHash }] }
|
||||||
|
//! └── md/
|
||||||
|
//! └── <id>.md # a skill's Markdown content
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! The two scopes never bleed into one another: a `Project` skill is invisible to
|
||||||
|
//! a `Global` listing and vice-versa, because each resolves to a different
|
||||||
|
//! directory. All I/O goes through the [`FileSystem`] port, so the adapter is
|
||||||
|
//! location-neutral (a project hosted over SSH/WSL works unchanged) and
|
||||||
|
//! Tauri-agnostic.
|
||||||
|
//!
|
||||||
|
//! Like the template store, [`delete`](SkillStore::delete) drops the index row
|
||||||
|
//! and leaves the orphaned `md/<id>.md` on disk (the [`FileSystem`] port exposes
|
||||||
|
//! no remove); since listing is index-driven, the skill is effectively gone.
|
||||||
|
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use domain::ids::SkillId;
|
||||||
|
use domain::markdown::MarkdownDoc;
|
||||||
|
use domain::ports::{FileSystem, RemotePath, SkillStore, StoreError};
|
||||||
|
use domain::project::ProjectPath;
|
||||||
|
use domain::skill::{Skill, SkillScope};
|
||||||
|
|
||||||
|
/// Directory (under app-data) holding the global skills store.
|
||||||
|
const GLOBAL_SKILLS_DIR: &str = "skills";
|
||||||
|
|
||||||
|
/// The `.ideai/` directory name inside a project root.
|
||||||
|
const IDEAI_DIR: &str = ".ideai";
|
||||||
|
|
||||||
|
/// Sub-path of the project-scoped skills store inside `.ideai/`.
|
||||||
|
const PROJECT_SKILLS_DIR: &str = "skills";
|
||||||
|
|
||||||
|
/// Index file name inside a skills dir.
|
||||||
|
const INDEX_FILE: &str = "index.json";
|
||||||
|
|
||||||
|
/// Current schema version of the index file.
|
||||||
|
const INDEX_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
/// One metadata row in `index.json` (the `.md` content lives separately).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct IndexEntry {
|
||||||
|
id: SkillId,
|
||||||
|
name: String,
|
||||||
|
content_hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// On-disk shape of a scope's `index.json`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct IndexDoc {
|
||||||
|
version: u32,
|
||||||
|
skills: Vec<IndexEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for IndexDoc {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
version: INDEX_VERSION,
|
||||||
|
skills: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stable, dependency-free digest of Markdown content for out-of-app edit
|
||||||
|
/// detection — deterministic across runs and platforms (fixed-key hasher).
|
||||||
|
fn content_hash(md: &MarkdownDoc) -> String {
|
||||||
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
md.as_str().hash(&mut hasher);
|
||||||
|
format!("{:016x}", hasher.finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// File-backed [`SkillStore`], composing a [`FileSystem`] port. Holds only the
|
||||||
|
/// machine-global app-data dir; the project root for [`SkillScope::Project`] is
|
||||||
|
/// supplied **per call**, so a single instance serves every open project
|
||||||
|
/// (mirroring [`crate::store::IdeaiContextStore`]).
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct FsSkillStore {
|
||||||
|
fs: Arc<dyn FileSystem>,
|
||||||
|
app_data_dir: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FsSkillStore {
|
||||||
|
/// Builds the store from an injected [`FileSystem`] and the global app-data
|
||||||
|
/// dir (used for [`SkillScope::Global`]). Directories are created on first
|
||||||
|
/// write.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(fs: Arc<dyn FileSystem>, app_data_dir: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
fs,
|
||||||
|
app_data_dir: app_data_dir.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Root directory of a scope's store. `root` is ignored for `Global`.
|
||||||
|
fn dir(&self, scope: SkillScope, root: &ProjectPath) -> String {
|
||||||
|
match scope {
|
||||||
|
SkillScope::Global => {
|
||||||
|
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
|
||||||
|
format!("{base}/{GLOBAL_SKILLS_DIR}")
|
||||||
|
}
|
||||||
|
SkillScope::Project => {
|
||||||
|
let base = root.as_str().trim_end_matches(['/', '\\']);
|
||||||
|
format!("{base}/{IDEAI_DIR}/{PROJECT_SKILLS_DIR}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<scope-dir>/index.json`.
|
||||||
|
fn index_path(&self, scope: SkillScope, root: &ProjectPath) -> RemotePath {
|
||||||
|
RemotePath::new(format!("{}/{INDEX_FILE}", self.dir(scope, root)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<scope-dir>/md/<id>.md`.
|
||||||
|
fn md_path(&self, scope: SkillScope, root: &ProjectPath, id: SkillId) -> RemotePath {
|
||||||
|
RemotePath::new(format!("{}/md/{id}.md", self.dir(scope, root)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a scope's index, returning an empty default if absent.
|
||||||
|
async fn read_index(
|
||||||
|
&self,
|
||||||
|
scope: SkillScope,
|
||||||
|
root: &ProjectPath,
|
||||||
|
) -> Result<IndexDoc, StoreError> {
|
||||||
|
match self.fs.read(&self.index_path(scope, root)).await {
|
||||||
|
Ok(bytes) => {
|
||||||
|
serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string()))
|
||||||
|
}
|
||||||
|
Err(domain::ports::FsError::NotFound(_)) => Ok(IndexDoc::default()),
|
||||||
|
Err(e) => Err(StoreError::Io(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes a scope's index, ensuring its directory exists.
|
||||||
|
async fn write_index(
|
||||||
|
&self,
|
||||||
|
scope: SkillScope,
|
||||||
|
root: &ProjectPath,
|
||||||
|
doc: &IndexDoc,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
self.fs
|
||||||
|
.create_dir_all(&RemotePath::new(self.dir(scope, root)))
|
||||||
|
.await
|
||||||
|
.map_err(|e| StoreError::Io(e.to_string()))?;
|
||||||
|
let bytes =
|
||||||
|
serde_json::to_vec_pretty(doc).map_err(|e| StoreError::Serialization(e.to_string()))?;
|
||||||
|
self.fs
|
||||||
|
.write(&self.index_path(scope, root), &bytes)
|
||||||
|
.await
|
||||||
|
.map_err(|e| StoreError::Io(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reconstructs the [`Skill`] for an index entry by reading its `.md`.
|
||||||
|
async fn load(
|
||||||
|
&self,
|
||||||
|
scope: SkillScope,
|
||||||
|
root: &ProjectPath,
|
||||||
|
entry: &IndexEntry,
|
||||||
|
) -> Result<Skill, StoreError> {
|
||||||
|
let bytes = self
|
||||||
|
.fs
|
||||||
|
.read(&self.md_path(scope, root, entry.id))
|
||||||
|
.await
|
||||||
|
.map_err(|e| match e {
|
||||||
|
domain::ports::FsError::NotFound(_) => StoreError::NotFound,
|
||||||
|
other => StoreError::Io(other.to_string()),
|
||||||
|
})?;
|
||||||
|
let content =
|
||||||
|
String::from_utf8(bytes).map_err(|e| StoreError::Serialization(e.to_string()))?;
|
||||||
|
Skill::new(entry.id, entry.name.clone(), MarkdownDoc::new(content), scope)
|
||||||
|
.map_err(|e| StoreError::Serialization(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl SkillStore for FsSkillStore {
|
||||||
|
async fn list(&self, scope: SkillScope, root: &ProjectPath) -> Result<Vec<Skill>, StoreError> {
|
||||||
|
let index = self.read_index(scope, root).await?;
|
||||||
|
let mut out = Vec::with_capacity(index.skills.len());
|
||||||
|
for entry in &index.skills {
|
||||||
|
out.push(self.load(scope, root, entry).await?);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(
|
||||||
|
&self,
|
||||||
|
scope: SkillScope,
|
||||||
|
root: &ProjectPath,
|
||||||
|
id: SkillId,
|
||||||
|
) -> Result<Skill, StoreError> {
|
||||||
|
let index = self.read_index(scope, root).await?;
|
||||||
|
let entry = index
|
||||||
|
.skills
|
||||||
|
.iter()
|
||||||
|
.find(|e| e.id == id)
|
||||||
|
.ok_or(StoreError::NotFound)?;
|
||||||
|
self.load(scope, root, entry).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save(&self, skill: &Skill, root: &ProjectPath) -> Result<(), StoreError> {
|
||||||
|
let scope = skill.scope;
|
||||||
|
// (1) Write the Markdown content.
|
||||||
|
self.fs
|
||||||
|
.create_dir_all(&RemotePath::new(format!("{}/md", self.dir(scope, root))))
|
||||||
|
.await
|
||||||
|
.map_err(|e| StoreError::Io(e.to_string()))?;
|
||||||
|
self.fs
|
||||||
|
.write(
|
||||||
|
&self.md_path(scope, root, skill.id),
|
||||||
|
skill.content_md.as_str().as_bytes(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| StoreError::Io(e.to_string()))?;
|
||||||
|
|
||||||
|
// (2) Upsert the index metadata.
|
||||||
|
let mut index = self.read_index(scope, root).await?;
|
||||||
|
let row = IndexEntry {
|
||||||
|
id: skill.id,
|
||||||
|
name: skill.name.clone(),
|
||||||
|
content_hash: content_hash(&skill.content_md),
|
||||||
|
};
|
||||||
|
if let Some(slot) = index.skills.iter_mut().find(|e| e.id == skill.id) {
|
||||||
|
*slot = row;
|
||||||
|
} else {
|
||||||
|
index.skills.push(row);
|
||||||
|
}
|
||||||
|
self.write_index(scope, root, &index).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(
|
||||||
|
&self,
|
||||||
|
scope: SkillScope,
|
||||||
|
root: &ProjectPath,
|
||||||
|
id: SkillId,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
let mut index = self.read_index(scope, root).await?;
|
||||||
|
let before = index.skills.len();
|
||||||
|
index.skills.retain(|e| e.id != id);
|
||||||
|
if index.skills.len() == before {
|
||||||
|
return Err(StoreError::NotFound);
|
||||||
|
}
|
||||||
|
// The orphaned `md/<id>.md` is left on disk (no FileSystem delete); the
|
||||||
|
// index no longer references it, so it is effectively gone.
|
||||||
|
self.write_index(scope, root, &index).await
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -217,15 +217,27 @@ fn prepare_substitutes_project_root_in_cwd_template() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn prepare_empty_cwd_template_defaults_to_root() {
|
fn prepare_empty_cwd_template_defaults_to_base() {
|
||||||
let rt = pure_runtime();
|
let rt = pure_runtime();
|
||||||
let p = profile(ContextInjection::stdin(), "");
|
let p = profile(ContextInjection::stdin(), "");
|
||||||
let root = ProjectPath::new("/home/me/proj").unwrap();
|
let base = ProjectPath::new("/home/me/proj").unwrap();
|
||||||
|
|
||||||
let spec = rt.prepare_invocation(&p, &ctx(), &root).unwrap();
|
let spec = rt.prepare_invocation(&p, &ctx(), &base).unwrap();
|
||||||
assert_eq!(spec.cwd.as_str(), "/home/me/proj");
|
assert_eq!(spec.cwd.as_str(), "/home/me/proj");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prepare_substitutes_agent_run_dir_in_cwd_template() {
|
||||||
|
// The canonical template (ARCHITECTURE §14.1): `{agentRunDir}` resolves to the
|
||||||
|
// base cwd the launcher passes — the agent's isolated run directory.
|
||||||
|
let rt = pure_runtime();
|
||||||
|
let p = profile(ContextInjection::convention_file("CLAUDE.md").unwrap(), "{agentRunDir}");
|
||||||
|
let run_dir = ProjectPath::new("/home/me/proj/.ideai/run/agent-1").unwrap();
|
||||||
|
|
||||||
|
let spec = rt.prepare_invocation(&p, &ctx(), &run_dir).unwrap();
|
||||||
|
assert_eq!(spec.cwd.as_str(), "/home/me/proj/.ideai/run/agent-1");
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// detection_spec (pure)
|
// detection_spec (pure)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -113,25 +113,113 @@ async fn write_is_echoed_back_through_output_stream() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn subscribe_output_twice_is_an_error() {
|
async fn subscribe_output_is_re_subscribable_for_reattach() {
|
||||||
|
// A live PTY can be subscribed to more than once over its lifetime: the
|
||||||
|
// first view detaches (drops its stream), a second view re-attaches and
|
||||||
|
// still receives subsequent output — the core of the no-kill navigation fix.
|
||||||
let pty = PortablePtyAdapter::new();
|
let pty = PortablePtyAdapter::new();
|
||||||
let handle = pty
|
let handle = pty
|
||||||
.spawn(sh_spec("sleep 0.2"), size())
|
.spawn(sh_spec("cat"), size())
|
||||||
|
.await
|
||||||
|
.expect("spawn cat");
|
||||||
|
|
||||||
|
// First attachment: subscribe, observe an echo, then drop the stream
|
||||||
|
// (simulating a view tearing down on navigation — NOT a kill).
|
||||||
|
{
|
||||||
|
let first = pty.subscribe_output(&handle).expect("first subscribe");
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
let worker = thread::spawn(move || {
|
||||||
|
let mut all = Vec::new();
|
||||||
|
for chunk in first {
|
||||||
|
all.extend_from_slice(&chunk);
|
||||||
|
if String::from_utf8_lossy(&all).contains("first-marker") {
|
||||||
|
let _ = tx.send(());
|
||||||
|
return; // drop the stream → detach
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
pty.write(&handle, b"first-marker\n").expect("write 1");
|
||||||
|
rx.recv_timeout(TIMEOUT).expect("first view saw its marker");
|
||||||
|
worker.join().expect("first worker joined");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second attachment to the SAME live PTY (no re-spawn): must still receive
|
||||||
|
// new output produced after re-subscription.
|
||||||
|
let second = pty.subscribe_output(&handle).expect("re-subscribe");
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
let worker = thread::spawn(move || {
|
||||||
|
let mut all = Vec::new();
|
||||||
|
for chunk in second {
|
||||||
|
all.extend_from_slice(&chunk);
|
||||||
|
if String::from_utf8_lossy(&all).contains("second-marker") {
|
||||||
|
let _ = tx.send(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
pty.write(&handle, b"second-marker\n").expect("write 2");
|
||||||
|
rx.recv_timeout(TIMEOUT)
|
||||||
|
.expect("re-attached view saw new output");
|
||||||
|
|
||||||
|
pty.kill(&handle).await.expect("kill cat");
|
||||||
|
worker.join().expect("second worker joined after kill");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scrollback_retains_recent_output_for_repaint() {
|
||||||
|
// After output is produced and the process exits, the scrollback still holds
|
||||||
|
// the recent bytes so a re-attaching view can repaint them.
|
||||||
|
let pty = PortablePtyAdapter::new();
|
||||||
|
let handle = pty
|
||||||
|
.spawn(sh_spec("printf scrollback-content"), size())
|
||||||
.await
|
.await
|
||||||
.expect("spawn");
|
.expect("spawn");
|
||||||
|
|
||||||
let first = pty.subscribe_output(&handle);
|
// Drain to EOF so all output has been pushed into the ring buffer.
|
||||||
assert!(first.is_ok(), "first subscribe succeeds");
|
let stream = pty.subscribe_output(&handle).expect("subscribe");
|
||||||
|
drain_with_timeout(stream, TIMEOUT);
|
||||||
|
|
||||||
let second = pty.subscribe_output(&handle);
|
let sb = pty.scrollback(&handle).expect("scrollback readable");
|
||||||
|
let text = String::from_utf8_lossy(&sb);
|
||||||
assert!(
|
assert!(
|
||||||
second.is_err(),
|
text.contains("scrollback-content"),
|
||||||
"second subscribe on the same session must error"
|
"scrollback should retain recent output, got {text:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Drain the first stream so the reader thread can finish, then tidy up.
|
let _ = pty.kill(&handle).await;
|
||||||
let stream = first.unwrap();
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scrollback_is_bounded_to_cap_and_keeps_most_recent_bytes() {
|
||||||
|
// Emit clearly more than 100 KB of deterministic output, then assert the
|
||||||
|
// retained scrollback is bounded and ends with the most recent bytes.
|
||||||
|
let pty = PortablePtyAdapter::new();
|
||||||
|
// 5000 lines of "....END<n>" → well over 100 KB; the tail is the freshest.
|
||||||
|
let script = "i=0; while [ $i -lt 5000 ]; do \
|
||||||
|
printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA-%d\\n' $i; \
|
||||||
|
i=$((i+1)); done; printf 'FINAL-LINE-MARKER'";
|
||||||
|
let handle = pty.spawn(sh_spec(script), size()).await.expect("spawn");
|
||||||
|
|
||||||
|
let stream = pty.subscribe_output(&handle).expect("subscribe");
|
||||||
drain_with_timeout(stream, TIMEOUT);
|
drain_with_timeout(stream, TIMEOUT);
|
||||||
|
|
||||||
|
let sb = pty.scrollback(&handle).expect("scrollback readable");
|
||||||
|
assert!(
|
||||||
|
sb.len() <= 100 * 1024,
|
||||||
|
"scrollback must be bounded to ~100 KB, was {} bytes",
|
||||||
|
sb.len()
|
||||||
|
);
|
||||||
|
// The newest output is retained even though the oldest was dropped.
|
||||||
|
let text = String::from_utf8_lossy(&sb);
|
||||||
|
assert!(
|
||||||
|
text.contains("FINAL-LINE-MARKER"),
|
||||||
|
"the most recent bytes must be kept in the ring buffer"
|
||||||
|
);
|
||||||
|
// And the very first lines must have been evicted.
|
||||||
|
assert!(
|
||||||
|
!text.contains("-0\n") || sb.len() < 100 * 1024,
|
||||||
|
"oldest bytes should be dropped once the cap is exceeded"
|
||||||
|
);
|
||||||
|
|
||||||
let _ = pty.kill(&handle).await;
|
let _ = pty.kill(&handle).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
211
crates/infrastructure/tests/skill_store.rs
Normal file
211
crates/infrastructure/tests/skill_store.rs
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
//! L12 integration tests for [`FsSkillStore`] against a real temp directory and a
|
||||||
|
//! real [`LocalFileSystem`]: md + `index.json` round-trip in **both** scopes,
|
||||||
|
//! scope isolation (a `Project` skill never appears under `Global` and vice
|
||||||
|
//! versa), upsert, delete, tolerant reads, and the on-disk layout.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::ids::SkillId;
|
||||||
|
use domain::markdown::MarkdownDoc;
|
||||||
|
use domain::ports::{FileSystem, RemotePath, SkillStore, StoreError};
|
||||||
|
use domain::project::ProjectPath;
|
||||||
|
use domain::skill::{Skill, SkillScope};
|
||||||
|
use infrastructure::{FsSkillStore, LocalFileSystem};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// A unique scratch directory under the OS temp dir, cleaned up on drop. It plays
|
||||||
|
/// **both** roles: its own path is the global app-data dir, and a `project/`
|
||||||
|
/// child is the project root (so the two scopes resolve to disjoint subtrees).
|
||||||
|
struct TempDir(PathBuf);
|
||||||
|
impl TempDir {
|
||||||
|
fn new() -> Self {
|
||||||
|
let p = std::env::temp_dir().join(format!("idea-l12-skill-{}", Uuid::new_v4()));
|
||||||
|
std::fs::create_dir_all(&p).unwrap();
|
||||||
|
Self(p)
|
||||||
|
}
|
||||||
|
fn app_data_dir(&self) -> String {
|
||||||
|
self.0.to_string_lossy().into_owned()
|
||||||
|
}
|
||||||
|
fn project_root(&self) -> ProjectPath {
|
||||||
|
ProjectPath::new(self.0.join("project").to_string_lossy().into_owned()).unwrap()
|
||||||
|
}
|
||||||
|
fn child(&self, rel: &str) -> RemotePath {
|
||||||
|
RemotePath::new(self.0.join(rel).to_string_lossy().into_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Drop for TempDir {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_dir_all(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store(tmp: &TempDir) -> FsSkillStore {
|
||||||
|
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
|
||||||
|
FsSkillStore::new(fs, tmp.app_data_dir())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sid(n: u128) -> SkillId {
|
||||||
|
SkillId::from_uuid(Uuid::from_u128(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn skill(id: SkillId, name: &str, content: &str, scope: SkillScope) -> Skill {
|
||||||
|
Skill::new(id, name, MarkdownDoc::new(content), scope).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn missing_index_lists_empty_in_both_scopes() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let s = store(&tmp);
|
||||||
|
let root = tmp.project_root();
|
||||||
|
assert!(s.list(SkillScope::Global, &root).await.unwrap().is_empty());
|
||||||
|
assert!(s.list(SkillScope::Project, &root).await.unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn global_roundtrip_save_get_list() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let s = store(&tmp);
|
||||||
|
let root = tmp.project_root();
|
||||||
|
|
||||||
|
let sk = skill(sid(1), "refactor", "# Refactor workflow", SkillScope::Global);
|
||||||
|
s.save(&sk, &root).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(s.get(SkillScope::Global, &root, sid(1)).await.unwrap(), sk);
|
||||||
|
assert_eq!(
|
||||||
|
s.list(SkillScope::Global, &root).await.unwrap(),
|
||||||
|
vec![sk.clone()]
|
||||||
|
);
|
||||||
|
|
||||||
|
// The Markdown landed under the global skills dir.
|
||||||
|
let fs = LocalFileSystem::new();
|
||||||
|
let bytes = fs
|
||||||
|
.read(&tmp.child(&format!("skills/md/{}.md", sid(1))))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(String::from_utf8(bytes).unwrap(), sk.content_md.as_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn project_roundtrip_lands_under_ideai() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let s = store(&tmp);
|
||||||
|
let root = tmp.project_root();
|
||||||
|
|
||||||
|
let sk = skill(sid(1), "review", "# Review workflow", SkillScope::Project);
|
||||||
|
s.save(&sk, &root).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(s.get(SkillScope::Project, &root, sid(1)).await.unwrap(), sk);
|
||||||
|
|
||||||
|
let fs = LocalFileSystem::new();
|
||||||
|
let bytes = fs
|
||||||
|
.read(&tmp.child(&format!("project/.ideai/skills/md/{}.md", sid(1))))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(String::from_utf8(bytes).unwrap(), sk.content_md.as_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scopes_are_isolated() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let s = store(&tmp);
|
||||||
|
let root = tmp.project_root();
|
||||||
|
|
||||||
|
s.save(&skill(sid(1), "g", "global body", SkillScope::Global), &root)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
s.save(
|
||||||
|
&skill(sid(2), "p", "project body", SkillScope::Project),
|
||||||
|
&root,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// A global skill never surfaces in the project scope and vice-versa.
|
||||||
|
let globals = s.list(SkillScope::Global, &root).await.unwrap();
|
||||||
|
let projects = s.list(SkillScope::Project, &root).await.unwrap();
|
||||||
|
assert_eq!(globals.len(), 1);
|
||||||
|
assert_eq!(projects.len(), 1);
|
||||||
|
assert_eq!(globals[0].id, sid(1));
|
||||||
|
assert_eq!(projects[0].id, sid(2));
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
s.get(SkillScope::Global, &root, sid(2)).await.unwrap_err(),
|
||||||
|
StoreError::NotFound
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
s.get(SkillScope::Project, &root, sid(1)).await.unwrap_err(),
|
||||||
|
StoreError::NotFound
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn save_upserts_content() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let s = store(&tmp);
|
||||||
|
let root = tmp.project_root();
|
||||||
|
|
||||||
|
s.save(&skill(sid(1), "k", "v1", SkillScope::Global), &root)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
s.save(&skill(sid(1), "k", "v2", SkillScope::Global), &root)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
s.get(SkillScope::Global, &root, sid(1))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.content_md
|
||||||
|
.as_str(),
|
||||||
|
"v2"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
s.list(SkillScope::Global, &root).await.unwrap().len(),
|
||||||
|
1,
|
||||||
|
"upsert, not append"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_removes_from_index_and_is_not_found_twice() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let s = store(&tmp);
|
||||||
|
let root = tmp.project_root();
|
||||||
|
s.save(&skill(sid(1), "k", "x", SkillScope::Project), &root)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
s.delete(SkillScope::Project, &root, sid(1)).await.unwrap();
|
||||||
|
assert!(s.list(SkillScope::Project, &root).await.unwrap().is_empty());
|
||||||
|
assert!(matches!(
|
||||||
|
s.delete(SkillScope::Project, &root, sid(1))
|
||||||
|
.await
|
||||||
|
.unwrap_err(),
|
||||||
|
StoreError::NotFound
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn index_is_camelcase_with_content_hash() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let s = store(&tmp);
|
||||||
|
let root = tmp.project_root();
|
||||||
|
s.save(&skill(sid(1), "refactor", "hello", SkillScope::Global), &root)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let fs = LocalFileSystem::new();
|
||||||
|
let bytes = fs.read(&tmp.child("skills/index.json")).await.unwrap();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||||
|
let entry = &json.get("skills").unwrap().as_array().unwrap()[0];
|
||||||
|
assert_eq!(entry.get("name").and_then(|v| v.as_str()), Some("refactor"));
|
||||||
|
assert!(
|
||||||
|
entry.get("contentHash").is_some(),
|
||||||
|
"camelCase contentHash present"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
entry.get("content_hash").is_none(),
|
||||||
|
"no snake_case leak"
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -19,8 +19,10 @@ import type {
|
|||||||
AgentGateway,
|
AgentGateway,
|
||||||
CreateAgentInput,
|
CreateAgentInput,
|
||||||
OpenTerminalOptions,
|
OpenTerminalOptions,
|
||||||
|
ReattachResult,
|
||||||
TerminalHandle,
|
TerminalHandle,
|
||||||
} from "@/ports";
|
} from "@/ports";
|
||||||
|
import { makeTerminalHandle } from "./terminal";
|
||||||
|
|
||||||
/** Wire shape returned by the `launch_agent` command (mirrors `open_terminal`). */
|
/** Wire shape returned by the `launch_agent` command (mirrors `open_terminal`). */
|
||||||
interface LaunchAgentResponse {
|
interface LaunchAgentResponse {
|
||||||
@ -87,22 +89,26 @@ export class TauriAgentGateway implements AgentGateway {
|
|||||||
onOutput: channel,
|
onOutput: channel,
|
||||||
});
|
});
|
||||||
|
|
||||||
const sessionId = res.sessionId;
|
return makeTerminalHandle(res.sessionId, channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
async reattach(
|
||||||
|
sessionId: string,
|
||||||
|
onData: (bytes: Uint8Array) => void,
|
||||||
|
): Promise<ReattachResult> {
|
||||||
|
// Agent sessions reattach through the same session-based `reattach_terminal`
|
||||||
|
// command as plain terminals (the PTY is identified by its session id).
|
||||||
|
const channel = new Channel<number[]>();
|
||||||
|
channel.onmessage = (chunk) => onData(Uint8Array.from(chunk));
|
||||||
|
|
||||||
|
const res = await invoke<{ sessionId: string; scrollback: number[] }>(
|
||||||
|
"reattach_terminal",
|
||||||
|
{ sessionId, onOutput: channel },
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessionId,
|
handle: makeTerminalHandle(res.sessionId, channel),
|
||||||
async write(data: Uint8Array): Promise<void> {
|
scrollback: Uint8Array.from(res.scrollback),
|
||||||
await invoke("write_terminal", {
|
|
||||||
request: { sessionId, data: Array.from(data) },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
async resize(rows: number, cols: number): Promise<void> {
|
|
||||||
await invoke("resize_terminal", {
|
|
||||||
request: { sessionId, rows, cols },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
async close(): Promise<void> {
|
|
||||||
await invoke("close_terminal", { sessionId });
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { TauriTerminalGateway } from "./terminal";
|
|||||||
import { TauriLayoutGateway } from "./layout";
|
import { TauriLayoutGateway } from "./layout";
|
||||||
import { TauriProfileGateway } from "./profile";
|
import { TauriProfileGateway } from "./profile";
|
||||||
import { TauriTemplateGateway } from "./template";
|
import { TauriTemplateGateway } from "./template";
|
||||||
|
import { TauriSkillGateway } from "./skill";
|
||||||
import { TauriGitGateway } from "./git";
|
import { TauriGitGateway } from "./git";
|
||||||
|
|
||||||
function notImplemented(what: string): never {
|
function notImplemented(what: string): never {
|
||||||
@ -47,6 +48,7 @@ export function createTauriGateways(): Gateways {
|
|||||||
remote: new TauriRemoteGateway(),
|
remote: new TauriRemoteGateway(),
|
||||||
profile: new TauriProfileGateway(),
|
profile: new TauriProfileGateway(),
|
||||||
template: new TauriTemplateGateway(),
|
template: new TauriTemplateGateway(),
|
||||||
|
skill: new TauriSkillGateway(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,5 +60,6 @@ export {
|
|||||||
TauriLayoutGateway,
|
TauriLayoutGateway,
|
||||||
TauriProfileGateway,
|
TauriProfileGateway,
|
||||||
TauriTemplateGateway,
|
TauriTemplateGateway,
|
||||||
|
TauriSkillGateway,
|
||||||
TauriGitGateway,
|
TauriGitGateway,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -23,12 +23,15 @@ import type {
|
|||||||
LayoutTree,
|
LayoutTree,
|
||||||
Project,
|
Project,
|
||||||
ProfileAvailability,
|
ProfileAvailability,
|
||||||
|
Skill,
|
||||||
|
SkillScope,
|
||||||
Template,
|
Template,
|
||||||
Unsubscribe,
|
Unsubscribe,
|
||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
import type {
|
import type {
|
||||||
AgentGateway,
|
AgentGateway,
|
||||||
CreateAgentInput,
|
CreateAgentInput,
|
||||||
|
CreateSkillInput,
|
||||||
CreateTemplateInput,
|
CreateTemplateInput,
|
||||||
Gateways,
|
Gateways,
|
||||||
GitGateway,
|
GitGateway,
|
||||||
@ -36,7 +39,9 @@ import type {
|
|||||||
OpenTerminalOptions,
|
OpenTerminalOptions,
|
||||||
ProfileGateway,
|
ProfileGateway,
|
||||||
ProjectGateway,
|
ProjectGateway,
|
||||||
|
ReattachResult,
|
||||||
RemoteGateway,
|
RemoteGateway,
|
||||||
|
SkillGateway,
|
||||||
SystemGateway,
|
SystemGateway,
|
||||||
TemplateGateway,
|
TemplateGateway,
|
||||||
TerminalGateway,
|
TerminalGateway,
|
||||||
@ -100,6 +105,47 @@ function slugify(name: string): string {
|
|||||||
return out.replace(/^-+|-+$/g, "");
|
return out.replace(/^-+|-+$/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A live in-memory mock PTY session: it retains a scrollback (everything written
|
||||||
|
* to `onData`) and tracks the *current* output sink so a view can detach (sink
|
||||||
|
* cleared, session stays alive) and later re-attach (new sink, scrollback
|
||||||
|
* replayed). Only `close` ends the session — mirroring the backend's decoupling
|
||||||
|
* of PTY lifecycle from view lifecycle.
|
||||||
|
*/
|
||||||
|
class MockPtySession {
|
||||||
|
/** Accumulated output (the scrollback ring; unbounded in the mock — fine for tests). */
|
||||||
|
private scrollback: number[] = [];
|
||||||
|
/** Current view sink; `null` while detached. */
|
||||||
|
private sink: ((bytes: Uint8Array) => void) | null = null;
|
||||||
|
/** Whether the session was explicitly closed (PTY killed). */
|
||||||
|
closed = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly sessionId: string,
|
||||||
|
sink: (bytes: Uint8Array) => void,
|
||||||
|
) {
|
||||||
|
this.sink = sink;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records output into the scrollback and forwards it to the current sink. */
|
||||||
|
emit(bytes: Uint8Array): void {
|
||||||
|
if (this.closed) return;
|
||||||
|
for (const b of bytes) this.scrollback.push(b);
|
||||||
|
this.sink?.(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detaches the current view: stop delivering, keep the session alive. */
|
||||||
|
detach(): void {
|
||||||
|
this.sink = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-attaches a new view, returning the retained scrollback to repaint. */
|
||||||
|
reattach(sink: (bytes: Uint8Array) => void): Uint8Array {
|
||||||
|
this.sink = sink;
|
||||||
|
return Uint8Array.from(this.scrollback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stateful in-memory agent gateway — mirrors the backend `CreateAgentFromScratch`,
|
* Stateful in-memory agent gateway — mirrors the backend `CreateAgentFromScratch`,
|
||||||
* `ListAgents`, `ReadAgentContext`, `UpdateAgentContext`, `DeleteAgent`, and
|
* `ListAgents`, `ReadAgentContext`, `UpdateAgentContext`, `DeleteAgent`, and
|
||||||
@ -115,6 +161,8 @@ export class MockAgentGateway implements AgentGateway {
|
|||||||
private contexts = new Map<string, string>();
|
private contexts = new Map<string, string>();
|
||||||
/** Monotonic session counter for deterministic session ids in tests. */
|
/** Monotonic session counter for deterministic session ids in tests. */
|
||||||
private sessionSeq = 0;
|
private sessionSeq = 0;
|
||||||
|
/** Live agent PTY sessions, kept across detach so reattach can find them. */
|
||||||
|
private sessions = new Map<string, MockPtySession>();
|
||||||
|
|
||||||
private getAgents(projectId: string): Agent[] {
|
private getAgents(projectId: string): Agent[] {
|
||||||
if (!this.agents.has(projectId)) this.agents.set(projectId, []);
|
if (!this.agents.has(projectId)) this.agents.set(projectId, []);
|
||||||
@ -147,6 +195,7 @@ export class MockAgentGateway implements AgentGateway {
|
|||||||
profileId: input.profileId,
|
profileId: input.profileId,
|
||||||
origin: { type: "scratch" },
|
origin: { type: "scratch" },
|
||||||
synchronized: false,
|
synchronized: false,
|
||||||
|
skills: [],
|
||||||
};
|
};
|
||||||
list.push(agent);
|
list.push(agent);
|
||||||
this.contexts.set(
|
this.contexts.set(
|
||||||
@ -238,6 +287,18 @@ export class MockAgentGateway implements AgentGateway {
|
|||||||
return this.getAgents(projectId);
|
return this.getAgents(projectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces an agent's assigned skills in-place.
|
||||||
|
* Used by `MockSkillGateway.assignSkill` / `unassignSkill` so both gateways
|
||||||
|
* share the same in-memory store.
|
||||||
|
*/
|
||||||
|
_setSkills(projectId: string, agentId: string, skills: Agent["skills"]): void {
|
||||||
|
const list = this.getAgents(projectId);
|
||||||
|
const idx = list.findIndex((a) => a.id === agentId);
|
||||||
|
if (idx === -1) return;
|
||||||
|
list[idx] = { ...list[idx], skills };
|
||||||
|
}
|
||||||
|
|
||||||
async launchAgent(
|
async launchAgent(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
@ -256,29 +317,64 @@ export class MockAgentGateway implements AgentGateway {
|
|||||||
const sessionId = `mock-agent-session-${this.sessionSeq}`;
|
const sessionId = `mock-agent-session-${this.sessionSeq}`;
|
||||||
const cwd = options.cwd;
|
const cwd = options.cwd;
|
||||||
const enc = new TextEncoder();
|
const enc = new TextEncoder();
|
||||||
|
const session = new MockPtySession(sessionId, onData);
|
||||||
|
this.sessions.set(sessionId, session);
|
||||||
// Greet so something is visible immediately (mirrors MockTerminalGateway).
|
// Greet so something is visible immediately (mirrors MockTerminalGateway).
|
||||||
queueMicrotask(() =>
|
queueMicrotask(() =>
|
||||||
onData(enc.encode(`agent ${agentId} @ ${cwd}\r\n`)),
|
session.emit(enc.encode(`agent ${agentId} @ ${cwd}\r\n`)),
|
||||||
);
|
);
|
||||||
let closed = false;
|
return makeMockHandle(session, () => this.sessions.delete(sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async reattach(
|
||||||
|
sessionId: string,
|
||||||
|
onData: (bytes: Uint8Array) => void,
|
||||||
|
): Promise<ReattachResult> {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session || session.closed) {
|
||||||
|
const err: GatewayError = {
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: `agent session ${sessionId} is not alive`,
|
||||||
|
};
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const scrollback = session.reattach(onData);
|
||||||
return {
|
return {
|
||||||
sessionId,
|
handle: makeMockHandle(session, () => this.sessions.delete(sessionId)),
|
||||||
|
scrollback,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a {@link TerminalHandle} over a {@link MockPtySession}. `write` echoes
|
||||||
|
* (cooked-terminal CRLF translation) through the session so the scrollback
|
||||||
|
* records it; `detach` keeps the session alive; `close` ends it and unregisters.
|
||||||
|
*/
|
||||||
|
function makeMockHandle(
|
||||||
|
session: MockPtySession,
|
||||||
|
unregister: () => void,
|
||||||
|
): TerminalHandle {
|
||||||
|
return {
|
||||||
|
sessionId: session.sessionId,
|
||||||
async write(data: Uint8Array): Promise<void> {
|
async write(data: Uint8Array): Promise<void> {
|
||||||
if (closed) return;
|
if (session.closed) return;
|
||||||
// Echo back, translating CR to CRLF like a cooked terminal.
|
|
||||||
const out: number[] = [];
|
const out: number[] = [];
|
||||||
for (const b of data) {
|
for (const b of data) {
|
||||||
if (b === 0x0d) out.push(0x0d, 0x0a);
|
if (b === 0x0d) out.push(0x0d, 0x0a);
|
||||||
else out.push(b);
|
else out.push(b);
|
||||||
}
|
}
|
||||||
onData(Uint8Array.from(out));
|
session.emit(Uint8Array.from(out));
|
||||||
},
|
},
|
||||||
async resize(): Promise<void> {},
|
async resize(): Promise<void> {},
|
||||||
|
detach(): void {
|
||||||
|
session.detach();
|
||||||
|
},
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
closed = true;
|
session.closed = true;
|
||||||
|
unregister();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -288,6 +384,8 @@ export class MockAgentGateway implements AgentGateway {
|
|||||||
*/
|
*/
|
||||||
export class MockTerminalGateway implements TerminalGateway {
|
export class MockTerminalGateway implements TerminalGateway {
|
||||||
private seq = 0;
|
private seq = 0;
|
||||||
|
/** Live sessions kept across detach so reattach can find them. */
|
||||||
|
private sessions = new Map<string, MockPtySession>();
|
||||||
|
|
||||||
async openTerminal(
|
async openTerminal(
|
||||||
options: OpenTerminalOptions,
|
options: OpenTerminalOptions,
|
||||||
@ -296,27 +394,31 @@ export class MockTerminalGateway implements TerminalGateway {
|
|||||||
this.seq += 1;
|
this.seq += 1;
|
||||||
const sessionId = `mock-session-${this.seq}`;
|
const sessionId = `mock-session-${this.seq}`;
|
||||||
const enc = new TextEncoder();
|
const enc = new TextEncoder();
|
||||||
|
const session = new MockPtySession(sessionId, onData);
|
||||||
|
this.sessions.set(sessionId, session);
|
||||||
// Greet so something is visible immediately.
|
// Greet so something is visible immediately.
|
||||||
queueMicrotask(() =>
|
queueMicrotask(() =>
|
||||||
onData(enc.encode(`mock terminal @ ${options.cwd}\r\n`)),
|
session.emit(enc.encode(`mock terminal @ ${options.cwd}\r\n`)),
|
||||||
);
|
);
|
||||||
let closed = false;
|
return makeMockHandle(session, () => this.sessions.delete(sessionId));
|
||||||
return {
|
|
||||||
sessionId,
|
|
||||||
async write(data: Uint8Array): Promise<void> {
|
|
||||||
if (closed) return;
|
|
||||||
// Echo back, translating CR to CRLF like a cooked terminal.
|
|
||||||
const out: number[] = [];
|
|
||||||
for (const b of data) {
|
|
||||||
if (b === 0x0d) out.push(0x0d, 0x0a);
|
|
||||||
else out.push(b);
|
|
||||||
}
|
}
|
||||||
onData(Uint8Array.from(out));
|
|
||||||
},
|
async reattach(
|
||||||
async resize(): Promise<void> {},
|
sessionId: string,
|
||||||
async close(): Promise<void> {
|
onData: (bytes: Uint8Array) => void,
|
||||||
closed = true;
|
): Promise<ReattachResult> {
|
||||||
},
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session || session.closed) {
|
||||||
|
const err: GatewayError = {
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: `terminal session ${sessionId} is not alive`,
|
||||||
|
};
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const scrollback = session.reattach(onData);
|
||||||
|
return {
|
||||||
|
handle: makeMockHandle(session, () => this.sessions.delete(sessionId)),
|
||||||
|
scrollback,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -846,6 +948,7 @@ export class MockTemplateGateway implements TemplateGateway {
|
|||||||
syncedTemplateVersion: template.version,
|
syncedTemplateVersion: template.version,
|
||||||
},
|
},
|
||||||
synchronized,
|
synchronized,
|
||||||
|
skills: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
this.agentGateway._insertAgent(projectId, agent, template.contentMd);
|
this.agentGateway._insertAgent(projectId, agent, template.contentMd);
|
||||||
@ -905,6 +1008,130 @@ export class MockTemplateGateway implements TemplateGateway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stateful in-memory skill gateway (L12).
|
||||||
|
*
|
||||||
|
* Skills are keyed by `${scope}` (project scope is further partitioned by
|
||||||
|
* `projectId`, mirroring the disjoint on-disk roots). Assignment mutates the
|
||||||
|
* agent record held by the injected {@link MockAgentGateway}, so both gateways
|
||||||
|
* share one store — exactly like {@link MockTemplateGateway}.
|
||||||
|
*/
|
||||||
|
export class MockSkillGateway implements SkillGateway {
|
||||||
|
// Global skills are project-independent; project skills live under their id.
|
||||||
|
private global: Skill[] = [];
|
||||||
|
private byProject = new Map<string, Skill[]>();
|
||||||
|
private seq = 0;
|
||||||
|
|
||||||
|
constructor(private readonly agentGateway: MockAgentGateway) {}
|
||||||
|
|
||||||
|
private bucket(projectId: string, scope: SkillScope): Skill[] {
|
||||||
|
if (scope === "global") return this.global;
|
||||||
|
let list = this.byProject.get(projectId);
|
||||||
|
if (!list) {
|
||||||
|
list = [];
|
||||||
|
this.byProject.set(projectId, list);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSkills(projectId: string, scope: SkillScope): Promise<Skill[]> {
|
||||||
|
return structuredClone(this.bucket(projectId, scope));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSkill(input: CreateSkillInput): Promise<Skill> {
|
||||||
|
this.seq += 1;
|
||||||
|
const skill: Skill = {
|
||||||
|
id: `mock-skill-${this.seq}`,
|
||||||
|
name: input.name,
|
||||||
|
contentMd: input.content,
|
||||||
|
scope: input.scope,
|
||||||
|
};
|
||||||
|
this.bucket(input.projectId, input.scope).push(skill);
|
||||||
|
return structuredClone(skill);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSkill(
|
||||||
|
projectId: string,
|
||||||
|
scope: SkillScope,
|
||||||
|
skillId: string,
|
||||||
|
content: string,
|
||||||
|
): Promise<Skill> {
|
||||||
|
const list = this.bucket(projectId, scope);
|
||||||
|
const idx = list.findIndex((s) => s.id === skillId);
|
||||||
|
if (idx === -1) {
|
||||||
|
const err: GatewayError = {
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: `skill ${skillId} not found`,
|
||||||
|
};
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
list[idx] = { ...list[idx], contentMd: content };
|
||||||
|
return structuredClone(list[idx]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSkill(
|
||||||
|
projectId: string,
|
||||||
|
scope: SkillScope,
|
||||||
|
skillId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const list = this.bucket(projectId, scope);
|
||||||
|
const idx = list.findIndex((s) => s.id === skillId);
|
||||||
|
if (idx === -1) {
|
||||||
|
const err: GatewayError = {
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: `skill ${skillId} not found`,
|
||||||
|
};
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
list.splice(idx, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async assignSkill(
|
||||||
|
projectId: string,
|
||||||
|
agentId: string,
|
||||||
|
skillId: string,
|
||||||
|
scope: SkillScope,
|
||||||
|
): Promise<void> {
|
||||||
|
const agent = this.agentGateway
|
||||||
|
._rawAgents(projectId)
|
||||||
|
.find((a) => a.id === agentId);
|
||||||
|
if (!agent) {
|
||||||
|
const err: GatewayError = {
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: `agent ${agentId} not found in project ${projectId}`,
|
||||||
|
};
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (agent.skills.some((s) => s.skillId === skillId)) return; // idempotent
|
||||||
|
this.agentGateway._setSkills(projectId, agentId, [
|
||||||
|
...agent.skills,
|
||||||
|
{ skillId, scope },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async unassignSkill(
|
||||||
|
projectId: string,
|
||||||
|
agentId: string,
|
||||||
|
skillId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const agent = this.agentGateway
|
||||||
|
._rawAgents(projectId)
|
||||||
|
.find((a) => a.id === agentId);
|
||||||
|
if (!agent) {
|
||||||
|
const err: GatewayError = {
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: `agent ${agentId} not found in project ${projectId}`,
|
||||||
|
};
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
this.agentGateway._setSkills(
|
||||||
|
projectId,
|
||||||
|
agentId,
|
||||||
|
agent.skills.filter((s) => s.skillId !== skillId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Builds the full set of mock gateways. */
|
/** Builds the full set of mock gateways. */
|
||||||
export function createMockGateways(): Gateways {
|
export function createMockGateways(): Gateways {
|
||||||
const agentGateway = new MockAgentGateway();
|
const agentGateway = new MockAgentGateway();
|
||||||
@ -918,6 +1145,7 @@ export function createMockGateways(): Gateways {
|
|||||||
remote: new MockRemoteGateway(),
|
remote: new MockRemoteGateway(),
|
||||||
profile: new MockProfileGateway(),
|
profile: new MockProfileGateway(),
|
||||||
template: new MockTemplateGateway(agentGateway),
|
template: new MockTemplateGateway(agentGateway),
|
||||||
|
skill: new MockSkillGateway(agentGateway),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import { createMockGateways, MockSystemGateway } from "./index";
|
|||||||
const gateways: Gateways = createMockGateways();
|
const gateways: Gateways = createMockGateways();
|
||||||
|
|
||||||
describe("createMockGateways", () => {
|
describe("createMockGateways", () => {
|
||||||
it("exposes all nine gateways", () => {
|
it("exposes all ten gateways", () => {
|
||||||
expect(Object.keys(gateways).sort()).toEqual([
|
expect(Object.keys(gateways).sort()).toEqual([
|
||||||
"agent",
|
"agent",
|
||||||
"git",
|
"git",
|
||||||
@ -20,6 +20,7 @@ describe("createMockGateways", () => {
|
|||||||
"profile",
|
"profile",
|
||||||
"project",
|
"project",
|
||||||
"remote",
|
"remote",
|
||||||
|
"skill",
|
||||||
"system",
|
"system",
|
||||||
"template",
|
"template",
|
||||||
"terminal",
|
"terminal",
|
||||||
|
|||||||
@ -111,4 +111,61 @@ describe("MockTerminalGateway", () => {
|
|||||||
// Exactly one delivery so far: the greeting.
|
// Exactly one delivery so far: the greeting.
|
||||||
expect(onData).toHaveBeenCalledTimes(1);
|
expect(onData).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("detach stops delivery to the old view but keeps the session alive", async () => {
|
||||||
|
const gw = new MockTerminalGateway();
|
||||||
|
const first: Uint8Array[] = [];
|
||||||
|
const handle = await gw.openTerminal(
|
||||||
|
{ cwd: "/c", rows: 24, cols: 80 },
|
||||||
|
(b) => first.push(b),
|
||||||
|
);
|
||||||
|
await flushMicrotasks();
|
||||||
|
first.length = 0;
|
||||||
|
|
||||||
|
handle.detach();
|
||||||
|
// Output produced after detach must NOT reach the detached view.
|
||||||
|
await handle.write(new TextEncoder().encode("after-detach"));
|
||||||
|
expect(first).toHaveLength(0);
|
||||||
|
|
||||||
|
// But the session is still alive: reattach succeeds.
|
||||||
|
await expect(
|
||||||
|
gw.reattach(handle.sessionId, () => {}),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reattach replays scrollback and resumes live output", async () => {
|
||||||
|
const gw = new MockTerminalGateway();
|
||||||
|
const handle = await gw.openTerminal(
|
||||||
|
{ cwd: "/work", rows: 24, cols: 80 },
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
await flushMicrotasks();
|
||||||
|
await handle.write(new TextEncoder().encode("typed"));
|
||||||
|
handle.detach();
|
||||||
|
|
||||||
|
const fresh: Uint8Array[] = [];
|
||||||
|
const { handle: h2, scrollback } = await gw.reattach(
|
||||||
|
handle.sessionId,
|
||||||
|
(b) => fresh.push(b),
|
||||||
|
);
|
||||||
|
// Scrollback carries the prior greeting + echoed input.
|
||||||
|
const sb = decode([scrollback]);
|
||||||
|
expect(sb).toContain("/work");
|
||||||
|
expect(sb).toContain("typed");
|
||||||
|
// New output now flows to the re-attached view.
|
||||||
|
await h2.write(new TextEncoder().encode("more"));
|
||||||
|
expect(decode(fresh)).toBe("more");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reattach to a closed session rejects (PTY is gone)", async () => {
|
||||||
|
const gw = new MockTerminalGateway();
|
||||||
|
const handle = await gw.openTerminal(
|
||||||
|
{ cwd: "/c", rows: 24, cols: 80 },
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
await handle.close();
|
||||||
|
await expect(gw.reattach(handle.sessionId, () => {})).rejects.toMatchObject({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
70
frontend/src/adapters/skill.ts
Normal file
70
frontend/src/adapters/skill.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Tauri adapter for {@link SkillGateway} (L12).
|
||||||
|
*
|
||||||
|
* Commands use snake_case (Tauri convention); payload keys are camelCase
|
||||||
|
* (matching the backend DTO `#[serde(rename_all = "camelCase")]`), consistent
|
||||||
|
* with the other adapters in this directory. The `scope` value (`"global"` /
|
||||||
|
* `"project"`) maps directly onto the backend `SkillScope` serde enum.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
import type { Skill, SkillScope } from "@/domain";
|
||||||
|
import type { CreateSkillInput, SkillGateway } from "@/ports";
|
||||||
|
|
||||||
|
export class TauriSkillGateway implements SkillGateway {
|
||||||
|
listSkills(projectId: string, scope: SkillScope): Promise<Skill[]> {
|
||||||
|
return invoke<Skill[]>("list_skills", { projectId, scope });
|
||||||
|
}
|
||||||
|
|
||||||
|
createSkill(input: CreateSkillInput): Promise<Skill> {
|
||||||
|
return invoke<Skill>("create_skill", {
|
||||||
|
request: {
|
||||||
|
projectId: input.projectId,
|
||||||
|
name: input.name,
|
||||||
|
content: input.content,
|
||||||
|
scope: input.scope,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSkill(
|
||||||
|
projectId: string,
|
||||||
|
scope: SkillScope,
|
||||||
|
skillId: string,
|
||||||
|
content: string,
|
||||||
|
): Promise<Skill> {
|
||||||
|
return invoke<Skill>("update_skill", {
|
||||||
|
request: { projectId, scope, skillId, content },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSkill(
|
||||||
|
projectId: string,
|
||||||
|
scope: SkillScope,
|
||||||
|
skillId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await invoke("delete_skill", { projectId, scope, skillId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async assignSkill(
|
||||||
|
projectId: string,
|
||||||
|
agentId: string,
|
||||||
|
skillId: string,
|
||||||
|
scope: SkillScope,
|
||||||
|
): Promise<void> {
|
||||||
|
await invoke("assign_skill_to_agent", {
|
||||||
|
request: { projectId, agentId, skillId, scope },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async unassignSkill(
|
||||||
|
projectId: string,
|
||||||
|
agentId: string,
|
||||||
|
skillId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await invoke("unassign_skill_from_agent", {
|
||||||
|
request: { projectId, agentId, skillId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -18,6 +18,7 @@ import { Channel, invoke } from "@tauri-apps/api/core";
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
OpenTerminalOptions,
|
OpenTerminalOptions,
|
||||||
|
ReattachResult,
|
||||||
TerminalGateway,
|
TerminalGateway,
|
||||||
TerminalHandle,
|
TerminalHandle,
|
||||||
} from "@/ports";
|
} from "@/ports";
|
||||||
@ -30,6 +31,46 @@ interface OpenTerminalResponse {
|
|||||||
cols: number;
|
cols: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Wire shape returned by the `reattach_terminal` command. */
|
||||||
|
interface ReattachResponse {
|
||||||
|
sessionId: string;
|
||||||
|
scrollback: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a {@link TerminalHandle} over a session and its local output
|
||||||
|
* {@link Channel}. `detach` stops the channel from delivering further bytes (the
|
||||||
|
* view is gone) without touching the backend PTY; `close` kills the PTY.
|
||||||
|
*
|
||||||
|
* Shared by `openTerminal` and `reattach` so both produce identical handles.
|
||||||
|
*/
|
||||||
|
export function makeTerminalHandle(
|
||||||
|
sessionId: string,
|
||||||
|
channel: Channel<number[]>,
|
||||||
|
): TerminalHandle {
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
async write(data: Uint8Array): Promise<void> {
|
||||||
|
await invoke("write_terminal", {
|
||||||
|
request: { sessionId, data: Array.from(data) },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async resize(rows: number, cols: number): Promise<void> {
|
||||||
|
await invoke("resize_terminal", {
|
||||||
|
request: { sessionId, rows, cols },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
detach(): void {
|
||||||
|
// Drop the local subscription: the backend PTY keeps running, but this
|
||||||
|
// view stops receiving output. A later `reattach` re-wires a fresh channel.
|
||||||
|
channel.onmessage = () => {};
|
||||||
|
},
|
||||||
|
async close(): Promise<void> {
|
||||||
|
await invoke("close_terminal", { sessionId });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export class TauriTerminalGateway implements TerminalGateway {
|
export class TauriTerminalGateway implements TerminalGateway {
|
||||||
async openTerminal(
|
async openTerminal(
|
||||||
options: OpenTerminalOptions,
|
options: OpenTerminalOptions,
|
||||||
@ -44,22 +85,24 @@ export class TauriTerminalGateway implements TerminalGateway {
|
|||||||
onOutput: channel,
|
onOutput: channel,
|
||||||
});
|
});
|
||||||
|
|
||||||
const sessionId = res.sessionId;
|
return makeTerminalHandle(res.sessionId, channel);
|
||||||
return {
|
}
|
||||||
|
|
||||||
|
async reattach(
|
||||||
|
sessionId: string,
|
||||||
|
onData: (bytes: Uint8Array) => void,
|
||||||
|
): Promise<ReattachResult> {
|
||||||
|
const channel = new Channel<number[]>();
|
||||||
|
channel.onmessage = (chunk) => onData(Uint8Array.from(chunk));
|
||||||
|
|
||||||
|
const res = await invoke<ReattachResponse>("reattach_terminal", {
|
||||||
sessionId,
|
sessionId,
|
||||||
async write(data: Uint8Array): Promise<void> {
|
onOutput: channel,
|
||||||
await invoke("write_terminal", {
|
|
||||||
request: { sessionId, data: Array.from(data) },
|
|
||||||
});
|
});
|
||||||
},
|
|
||||||
async resize(rows: number, cols: number): Promise<void> {
|
return {
|
||||||
await invoke("resize_terminal", {
|
handle: makeTerminalHandle(res.sessionId, channel),
|
||||||
request: { sessionId, rows, cols },
|
scrollback: Uint8Array.from(res.scrollback),
|
||||||
});
|
|
||||||
},
|
|
||||||
async close(): Promise<void> {
|
|
||||||
await invoke("close_terminal", { sessionId });
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -236,6 +236,35 @@ export interface Agent {
|
|||||||
profileId: string;
|
profileId: string;
|
||||||
origin: AgentOrigin;
|
origin: AgentOrigin;
|
||||||
synchronized: boolean;
|
synchronized: boolean;
|
||||||
|
/** Skills assigned to this agent (injected into its convention file). */
|
||||||
|
skills: SkillRef[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Skills (L12) — mirror of the domain `Skill` / `SkillRef`.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a skill lives (selects its backing store): `global` skills are reusable
|
||||||
|
* across projects; `project` skills are specific to one project's `.ideai/`.
|
||||||
|
*/
|
||||||
|
export type SkillScope = "global" | "project";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reusable, model-agnostic workflow assignable to agents (mirror of the
|
||||||
|
* backend `Skill` DTO, camelCase wire format).
|
||||||
|
*/
|
||||||
|
export interface Skill {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
contentMd: string;
|
||||||
|
scope: SkillScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A reference from an agent to one assigned skill (mirror of `SkillRef`). */
|
||||||
|
export interface SkillRef {
|
||||||
|
skillId: string;
|
||||||
|
scope: SkillScope;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -54,6 +54,50 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
|||||||
void templateGw.listTemplates().then(setTemplates).catch(() => {});
|
void templateGw.listTemplates().then(setTemplates).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skills available for assignment (global + project), loaded lazily once.
|
||||||
|
const skillGw = gateways.skill ?? null;
|
||||||
|
const [skills, setSkills] = useState<import("@/domain").Skill[]>([]);
|
||||||
|
const [skillsLoaded, setSkillsLoaded] = useState(false);
|
||||||
|
const [skillToAssign, setSkillToAssign] = useState("");
|
||||||
|
|
||||||
|
async function refreshSkills() {
|
||||||
|
if (!skillGw) return;
|
||||||
|
try {
|
||||||
|
const [globals, projects] = await Promise.all([
|
||||||
|
skillGw.listSkills(projectId, "global"),
|
||||||
|
skillGw.listSkills(projectId, "project"),
|
||||||
|
]);
|
||||||
|
setSkills([...projects, ...globals]);
|
||||||
|
} catch {
|
||||||
|
// Skills are optional — leave the list empty on failure.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!skillsLoaded && skillGw) {
|
||||||
|
setSkillsLoaded(true);
|
||||||
|
void refreshSkills();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAssignSkill() {
|
||||||
|
if (!skillGw || !vm.selectedAgentId || !skillToAssign) return;
|
||||||
|
const target = skills.find((s) => s.id === skillToAssign);
|
||||||
|
if (!target) return;
|
||||||
|
await skillGw.assignSkill(
|
||||||
|
projectId,
|
||||||
|
vm.selectedAgentId,
|
||||||
|
target.id,
|
||||||
|
target.scope,
|
||||||
|
);
|
||||||
|
setSkillToAssign("");
|
||||||
|
await vm.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUnassignSkill(skillId: string) {
|
||||||
|
if (!skillGw || !vm.selectedAgentId) return;
|
||||||
|
await skillGw.unassignSkill(projectId, vm.selectedAgentId, skillId);
|
||||||
|
await vm.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
// Context editor state — local copy before Save
|
// Context editor state — local copy before Save
|
||||||
const [editedContext, setEditedContext] = useState(vm.context);
|
const [editedContext, setEditedContext] = useState(vm.context);
|
||||||
|
|
||||||
@ -73,6 +117,15 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
|||||||
*/
|
*/
|
||||||
const [activeAgentId, setActiveAgentId] = useState<string | null>(null);
|
const [activeAgentId, setActiveAgentId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live PTY session id of the running agent terminal, keyed by agent id. Lets
|
||||||
|
* the terminal re-attach (rather than re-launch) when the panel re-mounts the
|
||||||
|
* view, so navigating away never kills the agent.
|
||||||
|
*/
|
||||||
|
const [agentSessions, setAgentSessions] = useState<Record<string, string>>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
const canCreate = newName.trim().length > 0 && !vm.busy;
|
const canCreate = newName.trim().length > 0 && !vm.busy;
|
||||||
|
|
||||||
async function handleCreate(e: React.FormEvent) {
|
async function handleCreate(e: React.FormEvent) {
|
||||||
@ -325,15 +378,96 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
|||||||
<div className="border-t border-border p-4">
|
<div className="border-t border-border p-4">
|
||||||
<div className="h-96 overflow-hidden rounded-lg border border-border bg-surface">
|
<div className="h-96 overflow-hidden rounded-lg border border-border bg-surface">
|
||||||
<TerminalView
|
<TerminalView
|
||||||
|
key={activeAgentId}
|
||||||
cwd={projectRoot}
|
cwd={projectRoot}
|
||||||
open={(opts, onData) =>
|
open={(opts, onData) =>
|
||||||
vm.launchAgent(activeAgentId, opts, onData)
|
vm.launchAgent(activeAgentId, opts, onData)
|
||||||
}
|
}
|
||||||
|
reattach={(sessionId, onData) =>
|
||||||
|
gateways.agent.reattach(sessionId, onData)
|
||||||
|
}
|
||||||
|
sessionId={agentSessions[activeAgentId] ?? null}
|
||||||
|
onSessionId={(sid) =>
|
||||||
|
setAgentSessions((prev) => ({ ...prev, [activeAgentId]: sid }))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Skill assignment ── */}
|
||||||
|
{selectedAgent && skillGw && (
|
||||||
|
<div className="flex flex-col gap-2 border-t border-border p-4">
|
||||||
|
<h4 className="text-sm font-semibold text-content">
|
||||||
|
Skills — {selectedAgent.name}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{/* Assigned skills */}
|
||||||
|
{selectedAgent.skills.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted">No skills assigned.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-wrap gap-1.5">
|
||||||
|
{selectedAgent.skills.map((ref) => {
|
||||||
|
const name =
|
||||||
|
skills.find((s) => s.id === ref.skillId)?.name ?? ref.skillId;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={ref.skillId}
|
||||||
|
className="flex items-center gap-1 rounded-full bg-raised px-2 py-0.5 text-xs text-content"
|
||||||
|
>
|
||||||
|
<span>{name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`unassign ${name}`}
|
||||||
|
disabled={vm.busy}
|
||||||
|
onClick={() => void handleUnassignSkill(ref.skillId)}
|
||||||
|
className="text-faint hover:text-danger"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Assign selector */}
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<select
|
||||||
|
aria-label="skill to assign"
|
||||||
|
value={skillToAssign}
|
||||||
|
onChange={(e) => setSkillToAssign(e.target.value)}
|
||||||
|
disabled={vm.busy}
|
||||||
|
className={cn(
|
||||||
|
"h-9 min-w-0 flex-1 rounded-md bg-raised px-3 text-sm text-content",
|
||||||
|
"border border-border outline-none transition-colors",
|
||||||
|
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<option value="">— assign a skill —</option>
|
||||||
|
{skills
|
||||||
|
.filter(
|
||||||
|
(s) =>
|
||||||
|
!selectedAgent.skills.some((r) => r.skillId === s.id),
|
||||||
|
)
|
||||||
|
.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name} ({s.scope})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
aria-label="assign skill"
|
||||||
|
disabled={vm.busy || !skillToAssign}
|
||||||
|
onClick={() => void handleAssignSkill()}
|
||||||
|
>
|
||||||
|
Assign
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Context editor ── */}
|
{/* ── Context editor ── */}
|
||||||
{selectedAgent && (
|
{selectedAgent && (
|
||||||
<div className="flex flex-col gap-2 border-t border-border p-4">
|
<div className="flex flex-col gap-2 border-t border-border p-4">
|
||||||
|
|||||||
@ -112,7 +112,7 @@ interface LeafViewProps {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function LeafView({ id, agent, cwd, vm, parentSplit, projectId }: LeafViewProps) {
|
function LeafView({ id, session, agent, cwd, vm, parentSplit, projectId }: LeafViewProps) {
|
||||||
const canMerge = parentSplit !== null && parentSplit.siblings > 1;
|
const canMerge = parentSplit !== null && parentSplit.siblings > 1;
|
||||||
const { agent: agentGateway } = useGateways();
|
const { agent: agentGateway } = useGateways();
|
||||||
|
|
||||||
@ -133,6 +133,12 @@ function LeafView({ id, agent, cwd, vm, parentSplit, projectId }: LeafViewProps)
|
|||||||
? (opts: Parameters<typeof agentGateway.launchAgent>[2], onData: (bytes: Uint8Array) => void) =>
|
? (opts: Parameters<typeof agentGateway.launchAgent>[2], onData: (bytes: Uint8Array) => void) =>
|
||||||
agentGateway.launchAgent(projectId, agentId, opts, onData)
|
agentGateway.launchAgent(projectId, agentId, opts, onData)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
// Agent cells re-attach through the agent gateway; plain cells fall back to
|
||||||
|
// the terminal gateway's reattach (handled by TerminalView's default).
|
||||||
|
const reattachOpener = agentGateway && agentId
|
||||||
|
? (sessionId: string, onData: (bytes: Uint8Array) => void) =>
|
||||||
|
agentGateway.reattach(sessionId, onData)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -215,8 +221,17 @@ function LeafView({ id, agent, cwd, vm, parentSplit, projectId }: LeafViewProps)
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Re-key terminal when the agent changes so xterm re-mounts with the right opener. */}
|
{/* Re-key terminal when the agent changes so xterm re-mounts with the right opener.
|
||||||
<TerminalView key={`${id}-${agentId ?? "plain"}`} cwd={cwd} open={terminalOpener} />
|
The cell's persisted session id drives reattach-vs-open so navigating
|
||||||
|
(layout/tab switch) never kills the PTY. */}
|
||||||
|
<TerminalView
|
||||||
|
key={`${id}-${agentId ?? "plain"}`}
|
||||||
|
cwd={cwd}
|
||||||
|
open={terminalOpener}
|
||||||
|
reattach={reattachOpener}
|
||||||
|
sessionId={session}
|
||||||
|
onSessionId={(sid) => void vm.setSession(id, sid)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,17 +34,19 @@ import type { LayoutInfo } from "@/domain";
|
|||||||
import { LayoutGrid, LayoutTabs } from "@/features/layout";
|
import { LayoutGrid, LayoutTabs } from "@/features/layout";
|
||||||
import { AgentsPanel } from "@/features/agents";
|
import { AgentsPanel } from "@/features/agents";
|
||||||
import { TemplatesPanel } from "@/features/templates";
|
import { TemplatesPanel } from "@/features/templates";
|
||||||
|
import { SkillsPanel } from "@/features/skills";
|
||||||
import { GitPanel, GitGraphView } from "@/features/git";
|
import { GitPanel, GitGraphView } from "@/features/git";
|
||||||
import { Button, Input, Panel, Tabs, cn } from "@/shared";
|
import { Button, Input, Panel, Tabs, cn } from "@/shared";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
import { useProjects } from "./useProjects";
|
import { useProjects } from "./useProjects";
|
||||||
|
|
||||||
type SidebarTab = "projects" | "agents" | "templates" | "git";
|
type SidebarTab = "projects" | "agents" | "templates" | "skills" | "git";
|
||||||
|
|
||||||
const SIDEBAR_TABS: { id: SidebarTab; label: string }[] = [
|
const SIDEBAR_TABS: { id: SidebarTab; label: string }[] = [
|
||||||
{ id: "projects", label: "Projects" },
|
{ id: "projects", label: "Projects" },
|
||||||
{ id: "agents", label: "Agents" },
|
{ id: "agents", label: "Agents" },
|
||||||
{ id: "templates", label: "Templates" },
|
{ id: "templates", label: "Templates" },
|
||||||
|
{ id: "skills", label: "Skills" },
|
||||||
{ id: "git", label: "Git" },
|
{ id: "git", label: "Git" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -259,6 +261,14 @@ export function ProjectsView() {
|
|||||||
<p className="text-sm text-muted">Open a project to manage templates.</p>
|
<p className="text-sm text-muted">Open a project to manage templates.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Skills panel */}
|
||||||
|
{sidebarTab === "skills" && active && (
|
||||||
|
<SkillsPanel projectId={active.id} />
|
||||||
|
)}
|
||||||
|
{sidebarTab === "skills" && !active && (
|
||||||
|
<p className="text-sm text-muted">Open a project to manage skills.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Git panel */}
|
{/* Git panel */}
|
||||||
{sidebarTab === "git" && active && (
|
{sidebarTab === "git" && active && (
|
||||||
<GitPanel projectId={active.id} />
|
<GitPanel projectId={active.id} />
|
||||||
|
|||||||
207
frontend/src/features/skills/SkillEditor.tsx
Normal file
207
frontend/src/features/skills/SkillEditor.tsx
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* `SkillEditor` — a fullscreen overlay for creating or editing skills (L12).
|
||||||
|
* Rendered on top of the rest of the UI (`fixed inset-0`).
|
||||||
|
*
|
||||||
|
* Provides:
|
||||||
|
* - Name field and scope selector (create-mode only; scope is fixed in edit-mode)
|
||||||
|
* - Two tabs: "Edit" (textarea) and "Preview" (react-markdown)
|
||||||
|
* - Save (create/update) and Cancel/Close buttons
|
||||||
|
*
|
||||||
|
* Pure presentation: all mutations are delegated to the callbacks supplied by
|
||||||
|
* the parent (`SkillsPanel`). Styled with `@/shared`; no inline styles.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
|
||||||
|
import type { Skill, SkillScope } from "@/domain";
|
||||||
|
import { Button, Input, cn } from "@/shared";
|
||||||
|
|
||||||
|
export interface SkillEditorProps {
|
||||||
|
/**
|
||||||
|
* When set, the editor is in edit-mode for the given skill; otherwise it is in
|
||||||
|
* create-mode (all fields start empty).
|
||||||
|
*/
|
||||||
|
skill?: Skill | null;
|
||||||
|
/** Called when the user submits the form. */
|
||||||
|
onSave: (name: string, content: string, scope: SkillScope) => Promise<void>;
|
||||||
|
/** Called when the user cancels / closes the overlay. */
|
||||||
|
onClose: () => void;
|
||||||
|
/** Whether a save operation is in flight. */
|
||||||
|
busy?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditorTab = "edit" | "preview";
|
||||||
|
|
||||||
|
export function SkillEditor({
|
||||||
|
skill,
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
busy = false,
|
||||||
|
}: SkillEditorProps) {
|
||||||
|
const [name, setName] = useState(skill?.name ?? "");
|
||||||
|
const [content, setContent] = useState(skill?.contentMd ?? "");
|
||||||
|
const [scope, setScope] = useState<SkillScope>(skill?.scope ?? "project");
|
||||||
|
const [tab, setTab] = useState<EditorTab>("edit");
|
||||||
|
|
||||||
|
const editing = skill != null;
|
||||||
|
const canSave = name.trim().length > 0 && content.trim().length > 0 && !busy;
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!canSave) return;
|
||||||
|
await onSave(name.trim(), content, scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex flex-col bg-canvas"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="skill editor"
|
||||||
|
>
|
||||||
|
{/* ── Header bar ── */}
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-b border-border bg-surface px-4 py-3">
|
||||||
|
<h2 className="text-sm font-semibold text-content">
|
||||||
|
{editing ? `Edit skill — ${skill.name}` : "New skill"}
|
||||||
|
</h2>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label="close skill editor"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Form body ── */}
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => void handleSubmit(e)}
|
||||||
|
className="flex flex-1 flex-col gap-0 overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* ── Meta fields ── */}
|
||||||
|
<div className="flex shrink-0 flex-wrap items-end gap-3 border-b border-border px-4 py-3">
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
|
<label htmlFor="se-name" className="text-xs font-medium text-muted">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="se-name"
|
||||||
|
aria-label="skill name"
|
||||||
|
placeholder="My skill"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
className="min-w-48"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
<label htmlFor="se-scope" className="text-xs font-medium text-muted">
|
||||||
|
Scope
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="se-scope"
|
||||||
|
aria-label="skill scope"
|
||||||
|
value={scope}
|
||||||
|
onChange={(e) => setScope(e.target.value as SkillScope)}
|
||||||
|
disabled={busy || editing}
|
||||||
|
className={cn(
|
||||||
|
"h-9 w-full rounded-md bg-raised px-3 text-sm text-content",
|
||||||
|
"border border-border outline-none transition-colors",
|
||||||
|
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<option value="project">Project</option>
|
||||||
|
<option value="global">Global</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Tab strip ── */}
|
||||||
|
<div className="flex shrink-0 items-center gap-0 border-b border-border px-4">
|
||||||
|
{(["edit", "preview"] as EditorTab[]).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-label={t === "edit" ? "Edit" : "Preview"}
|
||||||
|
aria-selected={tab === t}
|
||||||
|
onClick={() => setTab(t)}
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-2 text-sm font-medium capitalize transition-colors",
|
||||||
|
tab === t
|
||||||
|
? "border-b-2 border-primary text-content"
|
||||||
|
: "text-muted hover:text-content",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t === "edit" ? "Edit" : "Preview"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Edit / Preview pane ── */}
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden px-4 py-3">
|
||||||
|
{tab === "edit" ? (
|
||||||
|
<textarea
|
||||||
|
aria-label="skill content"
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 w-full rounded-md bg-raised px-3 py-2 text-sm text-content font-mono",
|
||||||
|
"border border-border outline-none transition-colors resize-none",
|
||||||
|
"focus:border-primary placeholder:text-faint",
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
)}
|
||||||
|
placeholder="# Skill workflow ..."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex-1 overflow-auto rounded-md border border-border bg-raised px-6 py-4",
|
||||||
|
"prose prose-sm prose-invert max-w-none",
|
||||||
|
"[&_h1]:text-content [&_h2]:text-content [&_h3]:text-content",
|
||||||
|
"[&_p]:text-content/90 [&_li]:text-content/90",
|
||||||
|
"[&_code]:bg-canvas [&_code]:text-primary [&_code]:rounded [&_code]:px-1",
|
||||||
|
"[&_pre]:bg-canvas [&_pre]:border [&_pre]:border-border [&_pre]:rounded-md",
|
||||||
|
"[&_a]:text-primary [&_a:hover]:underline",
|
||||||
|
"[&_blockquote]:border-l-2 [&_blockquote]:border-primary [&_blockquote]:text-muted",
|
||||||
|
"[&_hr]:border-border",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{content.trim() ? (
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted italic">
|
||||||
|
Nothing to preview yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Footer ── */}
|
||||||
|
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-border px-4 py-3">
|
||||||
|
<Button type="button" variant="ghost" onClick={onClose} disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
aria-label="Save skill"
|
||||||
|
disabled={!canSave}
|
||||||
|
loading={busy}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
frontend/src/features/skills/SkillsPanel.tsx
Normal file
154
frontend/src/features/skills/SkillsPanel.tsx
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* `SkillsPanel` — feature component for skill management (L12).
|
||||||
|
*
|
||||||
|
* Pure presentation: all behaviour comes from {@link useSkills}. Styled with
|
||||||
|
* `@/shared` design system tokens; no inline styles.
|
||||||
|
*
|
||||||
|
* Provides, per scope (Global + Project):
|
||||||
|
* - Skill list (name + scope)
|
||||||
|
* - "New skill" button that opens {@link SkillEditor} in create-mode
|
||||||
|
* - Per-skill "Edit" (opens the editor in edit-mode) and "Delete"
|
||||||
|
*
|
||||||
|
* Skills are assigned to agents from the `AgentsPanel`, not here — this panel is
|
||||||
|
* the authoring surface only ("no direct invoke": a skill is never run from the
|
||||||
|
* UI, only injected into an agent's convention file at activation).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import type { Skill, SkillScope } from "@/domain";
|
||||||
|
import { Button, Panel, Spinner } from "@/shared";
|
||||||
|
import { SkillEditor } from "./SkillEditor";
|
||||||
|
import { useSkills } from "./useSkills";
|
||||||
|
|
||||||
|
export interface SkillsPanelProps {
|
||||||
|
/** The project whose skills to manage. */
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Editor open-state: "create" or edit-mode for a specific skill. */
|
||||||
|
type EditorState = { mode: "create" } | { mode: "edit"; skill: Skill };
|
||||||
|
|
||||||
|
export function SkillsPanel({ projectId }: SkillsPanelProps) {
|
||||||
|
const vm = useSkills(projectId);
|
||||||
|
|
||||||
|
const [editorState, setEditorState] = useState<EditorState | null>(null);
|
||||||
|
const [editorBusy, setEditorBusy] = useState(false);
|
||||||
|
|
||||||
|
async function handleSave(name: string, content: string, scope: SkillScope) {
|
||||||
|
if (!editorState) return;
|
||||||
|
setEditorBusy(true);
|
||||||
|
try {
|
||||||
|
if (editorState.mode === "create") {
|
||||||
|
await vm.createSkill({ name, content, scope });
|
||||||
|
} else {
|
||||||
|
await vm.updateSkill(editorState.skill.scope, editorState.skill.id, content);
|
||||||
|
}
|
||||||
|
setEditorState(null);
|
||||||
|
} finally {
|
||||||
|
setEditorBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList(scope: SkillScope, skills: Skill[]) {
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
{skills.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted">No {scope} skills yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col divide-y divide-border">
|
||||||
|
{skills.map((s) => (
|
||||||
|
<li
|
||||||
|
key={s.id}
|
||||||
|
className="flex items-center justify-between gap-3 py-3 first:pt-0 last:pb-0"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 font-medium text-content">{s.name}</span>
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`edit skill ${s.name}`}
|
||||||
|
disabled={vm.busy}
|
||||||
|
onClick={() => setEditorState({ mode: "edit", skill: s })}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`delete skill ${s.name}`}
|
||||||
|
disabled={vm.busy}
|
||||||
|
onClick={() => void vm.deleteSkill(s.scope, s.id)}
|
||||||
|
className="text-danger hover:text-danger"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* ── Fullscreen skill editor overlay ── */}
|
||||||
|
{editorState !== null && (
|
||||||
|
<SkillEditor
|
||||||
|
skill={editorState.mode === "edit" ? editorState.skill : null}
|
||||||
|
onSave={handleSave}
|
||||||
|
onClose={() => setEditorState(null)}
|
||||||
|
busy={editorBusy}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Panel title="Skills" className="flex flex-col gap-0">
|
||||||
|
{vm.error && (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="mx-4 mt-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
||||||
|
>
|
||||||
|
{vm.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Create button ── */}
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||||||
|
Skills
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{vm.busy && <Spinner size={14} />}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
aria-label="create skill"
|
||||||
|
disabled={vm.busy}
|
||||||
|
onClick={() => setEditorState({ mode: "create" })}
|
||||||
|
>
|
||||||
|
New skill
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Project skills ── */}
|
||||||
|
<div className="border-b border-border px-4 pt-3">
|
||||||
|
<h5 className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||||||
|
Project
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
{renderList("project", vm.projectSkills)}
|
||||||
|
|
||||||
|
{/* ── Global skills ── */}
|
||||||
|
<div className="border-y border-border px-4 pt-3">
|
||||||
|
<h5 className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||||||
|
Global
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
{renderList("global", vm.globalSkills)}
|
||||||
|
</Panel>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
frontend/src/features/skills/index.ts
Normal file
6
frontend/src/features/skills/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/** Public surface of the skills feature (L12). */
|
||||||
|
export { SkillsPanel } from "./SkillsPanel";
|
||||||
|
export type { SkillsPanelProps } from "./SkillsPanel";
|
||||||
|
export { SkillEditor } from "./SkillEditor";
|
||||||
|
export { useSkills } from "./useSkills";
|
||||||
|
export type { SkillsViewModel } from "./useSkills";
|
||||||
297
frontend/src/features/skills/skills.test.tsx
Normal file
297
frontend/src/features/skills/skills.test.tsx
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
/**
|
||||||
|
* L12 — skills feature + agent assignment, wired to the stateful
|
||||||
|
* `MockSkillGateway` (sharing a `MockAgentGateway`) via the real `DIProvider`.
|
||||||
|
*
|
||||||
|
* Covers:
|
||||||
|
* - createSkill (project + global) → skill appears under the right scope
|
||||||
|
* - updateSkill → content persisted in the gateway
|
||||||
|
* - deleteSkill → skill removed from the list
|
||||||
|
* - assignSkill / unassignSkill in AgentsPanel → reflected on the agent record
|
||||||
|
* - guardrail "no direct invoke": SkillsPanel never offers a run/launch action
|
||||||
|
*
|
||||||
|
* Also includes MockSkillGateway unit tests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
MockAgentGateway,
|
||||||
|
MockProfileGateway,
|
||||||
|
MockSkillGateway,
|
||||||
|
} from "@/adapters/mock";
|
||||||
|
import type { Gateways } from "@/ports";
|
||||||
|
import { DIProvider } from "@/app/di";
|
||||||
|
import { SkillsPanel } from "./SkillsPanel";
|
||||||
|
import { AgentsPanel } from "@/features/agents/AgentsPanel";
|
||||||
|
|
||||||
|
const PROJECT_ID = "proj-skill-test";
|
||||||
|
|
||||||
|
function renderSkillsPanel(agent?: MockAgentGateway, skill?: MockSkillGateway) {
|
||||||
|
const a = agent ?? new MockAgentGateway();
|
||||||
|
const sk = skill ?? new MockSkillGateway(a);
|
||||||
|
const gateways = { agent: a, skill: sk } as unknown as Gateways;
|
||||||
|
return {
|
||||||
|
agent: a,
|
||||||
|
skill: sk,
|
||||||
|
...render(
|
||||||
|
<DIProvider gateways={gateways}>
|
||||||
|
<SkillsPanel projectId={PROJECT_ID} />
|
||||||
|
</DIProvider>,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAgentsPanel(agent: MockAgentGateway, skill: MockSkillGateway) {
|
||||||
|
const profile = new MockProfileGateway();
|
||||||
|
const gateways = { agent, profile, skill } as unknown as Gateways;
|
||||||
|
return render(
|
||||||
|
<DIProvider gateways={gateways}>
|
||||||
|
<AgentsPanel projectId={PROJECT_ID} projectRoot="/tmp/proj" />
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForSkillsIdle() {
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("button", { name: "create skill" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the SkillEditor, fills it, and saves. */
|
||||||
|
async function createSkill(
|
||||||
|
name: string,
|
||||||
|
content = "# Workflow",
|
||||||
|
scope: "project" | "global" = "project",
|
||||||
|
) {
|
||||||
|
await waitForSkillsIdle();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "create skill" }));
|
||||||
|
await waitFor(() => expect(screen.getByLabelText("skill name")).toBeTruthy());
|
||||||
|
fireEvent.change(screen.getByLabelText("skill name"), {
|
||||||
|
target: { value: name },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText("skill content"), {
|
||||||
|
target: { value: content },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText("skill scope"), {
|
||||||
|
target: { value: scope },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save skill" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SkillsPanel feature tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("SkillsPanel (with MockSkillGateway)", () => {
|
||||||
|
it("shows empty state for both scopes initially", async () => {
|
||||||
|
renderSkillsPanel();
|
||||||
|
await waitForSkillsIdle();
|
||||||
|
expect(screen.getByText("No project skills yet.")).toBeTruthy();
|
||||||
|
expect(screen.getByText("No global skills yet.")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creating a project skill adds it to the list", async () => {
|
||||||
|
const { skill } = renderSkillsPanel();
|
||||||
|
await createSkill("Refactor", "## steps", "project");
|
||||||
|
await screen.findByText("Refactor");
|
||||||
|
const projects = await skill.listSkills(PROJECT_ID, "project");
|
||||||
|
expect(projects).toHaveLength(1);
|
||||||
|
expect(projects[0].name).toBe("Refactor");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creating a global skill stores it under the global scope", async () => {
|
||||||
|
const { skill } = renderSkillsPanel();
|
||||||
|
await createSkill("Review", "## global", "global");
|
||||||
|
await screen.findByText("Review");
|
||||||
|
expect(await skill.listSkills(PROJECT_ID, "global")).toHaveLength(1);
|
||||||
|
expect(await skill.listSkills(PROJECT_ID, "project")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Save is disabled when name or content is empty", async () => {
|
||||||
|
renderSkillsPanel();
|
||||||
|
await waitForSkillsIdle();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "create skill" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole("button", { name: "Save skill" })).toBeTruthy(),
|
||||||
|
);
|
||||||
|
const save = screen.getByRole("button", {
|
||||||
|
name: "Save skill",
|
||||||
|
}) as HTMLButtonElement;
|
||||||
|
expect(save.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("editing a skill persists the new content", async () => {
|
||||||
|
const { skill } = renderSkillsPanel();
|
||||||
|
await createSkill("Editable", "old", "project");
|
||||||
|
await screen.findByText("Editable");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "edit skill Editable" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByLabelText("skill content")).toBeTruthy(),
|
||||||
|
);
|
||||||
|
fireEvent.change(screen.getByLabelText("skill content"), {
|
||||||
|
target: { value: "new content" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save skill" }));
|
||||||
|
|
||||||
|
await waitFor(async () => {
|
||||||
|
const list = await skill.listSkills(PROJECT_ID, "project");
|
||||||
|
expect(list[0].contentMd).toBe("new content");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleting a skill removes it from the list", async () => {
|
||||||
|
renderSkillsPanel();
|
||||||
|
await createSkill("ToDelete", "x", "project");
|
||||||
|
await screen.findByText("ToDelete");
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "delete skill ToDelete" }),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(screen.queryByText("ToDelete")).toBeNull());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("guardrail: never offers a run/launch action for a skill (no direct invoke)", async () => {
|
||||||
|
renderSkillsPanel();
|
||||||
|
await createSkill("Plain", "x", "project");
|
||||||
|
await screen.findByText("Plain");
|
||||||
|
// Only Edit/Delete are exposed; nothing that would "run" the skill.
|
||||||
|
expect(screen.queryByRole("button", { name: /^run/i })).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: /^launch/i })).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: /invoke/i })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Assignment integration (AgentsPanel)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("Skill assignment (AgentsPanel + MockSkillGateway)", () => {
|
||||||
|
it("assigns a skill to the selected agent and shows it as a chip", async () => {
|
||||||
|
const agent = new MockAgentGateway();
|
||||||
|
const skill = new MockSkillGateway(agent);
|
||||||
|
const created = await agent.createAgent(PROJECT_ID, {
|
||||||
|
name: "Worker",
|
||||||
|
profileId: "p1",
|
||||||
|
});
|
||||||
|
await skill.createSkill({
|
||||||
|
projectId: PROJECT_ID,
|
||||||
|
name: "Deploy",
|
||||||
|
content: "## deploy",
|
||||||
|
scope: "project",
|
||||||
|
});
|
||||||
|
|
||||||
|
renderAgentsPanel(agent, skill);
|
||||||
|
await waitFor(() => expect(screen.getByLabelText("agent name")).toBeTruthy());
|
||||||
|
|
||||||
|
// Select the agent
|
||||||
|
fireEvent.click(screen.getByText("Worker"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByLabelText("skill to assign")).toBeTruthy(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Choose the skill and assign
|
||||||
|
const sk = (await skill.listSkills(PROJECT_ID, "project"))[0];
|
||||||
|
fireEvent.change(screen.getByLabelText("skill to assign"), {
|
||||||
|
target: { value: sk.id },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "assign skill" }));
|
||||||
|
|
||||||
|
// The agent record now carries the skill ref
|
||||||
|
await waitFor(async () => {
|
||||||
|
const agents = await agent.listAgents(PROJECT_ID);
|
||||||
|
const a = agents.find((x) => x.id === created.id)!;
|
||||||
|
expect(a.skills.map((s) => s.skillId)).toContain(sk.id);
|
||||||
|
});
|
||||||
|
// Chip with an unassign control appears
|
||||||
|
expect(screen.getByRole("button", { name: "unassign Deploy" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unassigns a skill from the agent", async () => {
|
||||||
|
const agent = new MockAgentGateway();
|
||||||
|
const skill = new MockSkillGateway(agent);
|
||||||
|
const a = await agent.createAgent(PROJECT_ID, {
|
||||||
|
name: "Worker2",
|
||||||
|
profileId: "p1",
|
||||||
|
});
|
||||||
|
const sk = await skill.createSkill({
|
||||||
|
projectId: PROJECT_ID,
|
||||||
|
name: "Cleanup",
|
||||||
|
content: "## cleanup",
|
||||||
|
scope: "global",
|
||||||
|
});
|
||||||
|
await skill.assignSkill(PROJECT_ID, a.id, sk.id, sk.scope);
|
||||||
|
|
||||||
|
renderAgentsPanel(agent, skill);
|
||||||
|
await waitFor(() => expect(screen.getByLabelText("agent name")).toBeTruthy());
|
||||||
|
fireEvent.click(screen.getByText("Worker2"));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "unassign Cleanup" }),
|
||||||
|
).toBeTruthy(),
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "unassign Cleanup" }));
|
||||||
|
|
||||||
|
await waitFor(async () => {
|
||||||
|
const agents = await agent.listAgents(PROJECT_ID);
|
||||||
|
expect(agents.find((x) => x.id === a.id)!.skills).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// MockSkillGateway unit tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("MockSkillGateway (unit)", () => {
|
||||||
|
it("scopes are isolated: a project skill never appears in global and vice-versa", async () => {
|
||||||
|
const gw = new MockSkillGateway(new MockAgentGateway());
|
||||||
|
await gw.createSkill({
|
||||||
|
projectId: "p",
|
||||||
|
name: "P",
|
||||||
|
content: "x",
|
||||||
|
scope: "project",
|
||||||
|
});
|
||||||
|
await gw.createSkill({
|
||||||
|
projectId: "p",
|
||||||
|
name: "G",
|
||||||
|
content: "y",
|
||||||
|
scope: "global",
|
||||||
|
});
|
||||||
|
expect(await gw.listSkills("p", "project")).toHaveLength(1);
|
||||||
|
expect(await gw.listSkills("p", "global")).toHaveLength(1);
|
||||||
|
expect((await gw.listSkills("p", "project"))[0].name).toBe("P");
|
||||||
|
expect((await gw.listSkills("p", "global"))[0].name).toBe("G");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updateSkill throws NOT_FOUND for unknown skill", async () => {
|
||||||
|
const gw = new MockSkillGateway(new MockAgentGateway());
|
||||||
|
await expect(
|
||||||
|
gw.updateSkill("p", "project", "ghost", "x"),
|
||||||
|
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assignSkill is idempotent (no duplicate refs)", async () => {
|
||||||
|
const agentGw = new MockAgentGateway();
|
||||||
|
const gw = new MockSkillGateway(agentGw);
|
||||||
|
const a = await agentGw.createAgent("p", { name: "A", profileId: "p1" });
|
||||||
|
const s = await gw.createSkill({
|
||||||
|
projectId: "p",
|
||||||
|
name: "S",
|
||||||
|
content: "x",
|
||||||
|
scope: "project",
|
||||||
|
});
|
||||||
|
await gw.assignSkill("p", a.id, s.id, s.scope);
|
||||||
|
await gw.assignSkill("p", a.id, s.id, s.scope);
|
||||||
|
const agents = await agentGw.listAgents("p");
|
||||||
|
expect(agents[0].skills).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assignSkill throws NOT_FOUND for unknown agent", async () => {
|
||||||
|
const gw = new MockSkillGateway(new MockAgentGateway());
|
||||||
|
await expect(
|
||||||
|
gw.assignSkill("p", "ghost", "s", "project"),
|
||||||
|
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||||
|
});
|
||||||
|
});
|
||||||
145
frontend/src/features/skills/useSkills.ts
Normal file
145
frontend/src/features/skills/useSkills.ts
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* `useSkills` — view-model hook for the skills feature (L12).
|
||||||
|
*
|
||||||
|
* Owns the skill lists for both scopes (`global` + `project`) of one project and
|
||||||
|
* the CRUD actions. Consumes {@link SkillGateway} exclusively; never touches
|
||||||
|
* `invoke()` or `@tauri-apps/api`, keeping the component layer testable with
|
||||||
|
* mock gateways (ARCHITECTURE §1.3).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import type { GatewayError, Skill, SkillScope } from "@/domain";
|
||||||
|
import type { CreateSkillInput } from "@/ports";
|
||||||
|
import { useGateways } from "@/app/di";
|
||||||
|
|
||||||
|
/** What the skills UI needs from this hook. */
|
||||||
|
export interface SkillsViewModel {
|
||||||
|
/** Global skills (reusable across projects). */
|
||||||
|
globalSkills: Skill[];
|
||||||
|
/** Project-scoped skills. */
|
||||||
|
projectSkills: Skill[];
|
||||||
|
/** Last error message, or `null`. */
|
||||||
|
error: string | null;
|
||||||
|
/** Whether a request is in flight. */
|
||||||
|
busy: boolean;
|
||||||
|
/** Reloads both scope lists. */
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
/** Creates a new skill and refreshes its scope's list. */
|
||||||
|
createSkill: (input: Omit<CreateSkillInput, "projectId">) => Promise<void>;
|
||||||
|
/** Updates the content of an existing skill. */
|
||||||
|
updateSkill: (
|
||||||
|
scope: SkillScope,
|
||||||
|
skillId: string,
|
||||||
|
content: string,
|
||||||
|
) => Promise<void>;
|
||||||
|
/** Deletes a skill by id from its scope. */
|
||||||
|
deleteSkill: (scope: SkillScope, skillId: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describe(e: unknown): string {
|
||||||
|
if (e && typeof e === "object" && "message" in e) {
|
||||||
|
return String((e as GatewayError).message);
|
||||||
|
}
|
||||||
|
return String(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSkills(projectId: string): SkillsViewModel {
|
||||||
|
const { skill } = useGateways();
|
||||||
|
|
||||||
|
const [globalSkills, setGlobalSkills] = useState<Skill[]>([]);
|
||||||
|
const [projectSkills, setProjectSkills] = useState<Skill[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
if (!projectId) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [globals, projects] = await Promise.all([
|
||||||
|
skill.listSkills(projectId, "global"),
|
||||||
|
skill.listSkills(projectId, "project"),
|
||||||
|
]);
|
||||||
|
setGlobalSkills(globals);
|
||||||
|
setProjectSkills(projects);
|
||||||
|
} catch (e) {
|
||||||
|
setError(describe(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [skill, projectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const setter = (scope: SkillScope) =>
|
||||||
|
scope === "global" ? setGlobalSkills : setProjectSkills;
|
||||||
|
|
||||||
|
const createSkill = useCallback(
|
||||||
|
async (input: Omit<CreateSkillInput, "projectId">) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const created = await skill.createSkill({ ...input, projectId });
|
||||||
|
setter(input.scope)((prev) => [...prev, created]);
|
||||||
|
} catch (e) {
|
||||||
|
setError(describe(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[skill, projectId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateSkill = useCallback(
|
||||||
|
async (scope: SkillScope, skillId: string, content: string) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const updated = await skill.updateSkill(
|
||||||
|
projectId,
|
||||||
|
scope,
|
||||||
|
skillId,
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
setter(scope)((prev) =>
|
||||||
|
prev.map((s) => (s.id === skillId ? updated : s)),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
setError(describe(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[skill, projectId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteSkill = useCallback(
|
||||||
|
async (scope: SkillScope, skillId: string) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await skill.deleteSkill(projectId, scope, skillId);
|
||||||
|
setter(scope)((prev) => prev.filter((s) => s.id !== skillId));
|
||||||
|
} catch (e) {
|
||||||
|
setError(describe(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[skill, projectId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
globalSkills,
|
||||||
|
projectSkills,
|
||||||
|
error,
|
||||||
|
busy,
|
||||||
|
refresh,
|
||||||
|
createSkill,
|
||||||
|
updateSkill,
|
||||||
|
deleteSkill,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -5,20 +5,44 @@
|
|||||||
* Under jsdom xterm's `term.open` may bail gracefully (no real layout engine),
|
* Under jsdom xterm's `term.open` may bail gracefully (no real layout engine),
|
||||||
* so these tests assert the *wiring contract* (mounts without throwing, talks to
|
* so these tests assert the *wiring contract* (mounts without throwing, talks to
|
||||||
* the gateway port, tears down on unmount) rather than xterm's visual rendering.
|
* the gateway port, tears down on unmount) rather than xterm's visual rendering.
|
||||||
|
*
|
||||||
|
* The core lifecycle invariant tested here: unmounting the view (navigation /
|
||||||
|
* layout change) must **detach**, NEVER **close** — the backend PTY must survive
|
||||||
|
* so a running AI isn't cut off. Re-mounting with a known session re-attaches.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
import type { Gateways, TerminalGateway, TerminalHandle } from "@/ports";
|
import type {
|
||||||
|
Gateways,
|
||||||
|
ReattachResult,
|
||||||
|
TerminalGateway,
|
||||||
|
TerminalHandle,
|
||||||
|
} from "@/ports";
|
||||||
import { MockTerminalGateway } from "@/adapters/mock";
|
import { MockTerminalGateway } from "@/adapters/mock";
|
||||||
import { DIProvider } from "@/app/di";
|
import { DIProvider } from "@/app/di";
|
||||||
import { TerminalView } from "./TerminalView";
|
import { TerminalView } from "./TerminalView";
|
||||||
|
|
||||||
function renderView(terminal: TerminalGateway, cwd = "/home/me/proj") {
|
function makeHandle(overrides: Partial<TerminalHandle> = {}): TerminalHandle {
|
||||||
|
return {
|
||||||
|
sessionId: "s1",
|
||||||
|
write: vi.fn().mockResolvedValue(undefined),
|
||||||
|
resize: vi.fn().mockResolvedValue(undefined),
|
||||||
|
detach: vi.fn(),
|
||||||
|
close: vi.fn().mockResolvedValue(undefined),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderView(
|
||||||
|
terminal: TerminalGateway,
|
||||||
|
cwd = "/home/me/proj",
|
||||||
|
extra?: Partial<React.ComponentProps<typeof TerminalView>>,
|
||||||
|
) {
|
||||||
const gateways = { terminal } as unknown as Gateways;
|
const gateways = { terminal } as unknown as Gateways;
|
||||||
return render(
|
return render(
|
||||||
<DIProvider gateways={gateways}>
|
<DIProvider gateways={gateways}>
|
||||||
<TerminalView cwd={cwd} />
|
<TerminalView cwd={cwd} {...extra} />
|
||||||
</DIProvider>,
|
</DIProvider>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -49,20 +73,13 @@ describe("TerminalView (with MockTerminalGateway)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("consuming gateway output (onData) does not throw", async () => {
|
it("consuming gateway output (onData) does not throw", async () => {
|
||||||
// A gateway that immediately pushes bytes to the consumer, exercising the
|
const handle = makeHandle();
|
||||||
// gateway→term.write path. The component must swallow this safely even when
|
|
||||||
// xterm bailed under jsdom.
|
|
||||||
const handle: TerminalHandle = {
|
|
||||||
sessionId: "s1",
|
|
||||||
write: vi.fn().mockResolvedValue(undefined),
|
|
||||||
resize: vi.fn().mockResolvedValue(undefined),
|
|
||||||
close: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
const terminal: TerminalGateway = {
|
const terminal: TerminalGateway = {
|
||||||
openTerminal: vi.fn(async (_opts, onData) => {
|
openTerminal: vi.fn(async (_opts, onData) => {
|
||||||
onData(new TextEncoder().encode("hello\r\n"));
|
onData(new TextEncoder().encode("hello\r\n"));
|
||||||
return handle;
|
return handle;
|
||||||
}),
|
}),
|
||||||
|
reattach: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(() => renderView(terminal)).not.toThrow();
|
expect(() => renderView(terminal)).not.toThrow();
|
||||||
@ -71,21 +88,15 @@ describe("TerminalView (with MockTerminalGateway)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("closes the opened handle on unmount (cleanup)", async () => {
|
it("DETACHES (does not close) the handle on unmount — the PTY must survive", async () => {
|
||||||
|
const detach = vi.fn();
|
||||||
const close = vi.fn().mockResolvedValue(undefined);
|
const close = vi.fn().mockResolvedValue(undefined);
|
||||||
const handle: TerminalHandle = {
|
const handle = makeHandle({ detach, close });
|
||||||
sessionId: "s1",
|
|
||||||
write: vi.fn().mockResolvedValue(undefined),
|
|
||||||
resize: vi.fn().mockResolvedValue(undefined),
|
|
||||||
close,
|
|
||||||
};
|
|
||||||
const openTerminal = vi.fn(async () => handle);
|
const openTerminal = vi.fn(async () => handle);
|
||||||
const terminal: TerminalGateway = { openTerminal };
|
const terminal: TerminalGateway = { openTerminal, reattach: vi.fn() };
|
||||||
|
|
||||||
const { unmount } = renderView(terminal);
|
const { unmount } = renderView(terminal);
|
||||||
|
|
||||||
// Only assert close-on-unmount if the gateway was actually opened (i.e.
|
|
||||||
// xterm.open did not bail in this jsdom run).
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(openTerminal.mock.calls.length >= 0).toBe(true);
|
expect(openTerminal.mock.calls.length >= 0).toBe(true);
|
||||||
});
|
});
|
||||||
@ -95,11 +106,57 @@ describe("TerminalView (with MockTerminalGateway)", () => {
|
|||||||
|
|
||||||
if (wasOpened) {
|
if (wasOpened) {
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(close).toHaveBeenCalled();
|
expect(detach).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
// The cardinal invariant: navigating away must NOT kill the PTY.
|
||||||
|
expect(close).not.toHaveBeenCalled();
|
||||||
} else {
|
} else {
|
||||||
// Bailed render: unmount must still be clean (no throw, no close needed).
|
// Bailed render: unmount must still be clean (no close, no detach needed).
|
||||||
expect(close).not.toHaveBeenCalled();
|
expect(close).not.toHaveBeenCalled();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("REATTACHES to an existing session instead of opening a new PTY", async () => {
|
||||||
|
const handle = makeHandle({ sessionId: "live-1" });
|
||||||
|
const reattach = vi.fn(
|
||||||
|
async (_sessionId: string, onData: (b: Uint8Array) => void) => {
|
||||||
|
onData(new TextEncoder().encode("scroll"));
|
||||||
|
const result: ReattachResult = {
|
||||||
|
handle,
|
||||||
|
scrollback: new TextEncoder().encode("history"),
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const openTerminal = vi.fn(async () => handle);
|
||||||
|
const terminal: TerminalGateway = { openTerminal, reattach };
|
||||||
|
|
||||||
|
renderView(terminal, "/cwd", { sessionId: "live-1" });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// When xterm wired up, reattach must be used (with the known id) and a
|
||||||
|
// fresh open must NOT happen.
|
||||||
|
if (reattach.mock.calls.length > 0) {
|
||||||
|
expect(reattach.mock.calls[0][0]).toBe("live-1");
|
||||||
|
expect(openTerminal).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists a newly opened session id via onSessionId", async () => {
|
||||||
|
const handle = makeHandle({ sessionId: "new-99" });
|
||||||
|
const openTerminal = vi.fn(async () => handle);
|
||||||
|
const terminal: TerminalGateway = { openTerminal, reattach: vi.fn() };
|
||||||
|
const onSessionId = vi.fn();
|
||||||
|
|
||||||
|
renderView(terminal, "/cwd", { onSessionId });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
if (openTerminal.mock.calls.length > 0) {
|
||||||
|
expect(onSessionId).toHaveBeenCalledWith("new-99");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -12,6 +12,17 @@
|
|||||||
*
|
*
|
||||||
* An optional `open` prop can override the default `terminal.openTerminal` call,
|
* An optional `open` prop can override the default `terminal.openTerminal` call,
|
||||||
* enabling the agent terminal to reuse this component with `agent.launchAgent`.
|
* enabling the agent terminal to reuse this component with `agent.launchAgent`.
|
||||||
|
*
|
||||||
|
* **PTY lifecycle is decoupled from the view lifecycle.** Navigating (switching
|
||||||
|
* layout or project tab) tears this view down but must NEVER kill the backend
|
||||||
|
* PTY — otherwise running AIs would be cut off. So:
|
||||||
|
* - On unmount the cleanup only `detach`es (drops the local output subscription)
|
||||||
|
* and disposes xterm; it never calls `handle.close()`. Killing a PTY is an
|
||||||
|
* explicit user action handled elsewhere (the terminal's close button).
|
||||||
|
* - On mount, if a `sessionId` already exists for this cell (persisted by the
|
||||||
|
* caller via `onSessionId`), the view **re-attaches** to the still-running PTY
|
||||||
|
* — repainting its scrollback and resuming its output — instead of opening a
|
||||||
|
* fresh one. If the session is gone (was explicitly closed), it opens fresh.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
@ -21,7 +32,11 @@ import { FitAddon } from "@xterm/addon-fit";
|
|||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
import type { OpenTerminalOptions, TerminalHandle } from "@/ports";
|
import type {
|
||||||
|
OpenTerminalOptions,
|
||||||
|
ReattachResult,
|
||||||
|
TerminalHandle,
|
||||||
|
} from "@/ports";
|
||||||
|
|
||||||
interface TerminalViewProps {
|
interface TerminalViewProps {
|
||||||
/** Working directory the shell opens in (typically the project root). */
|
/** Working directory the shell opens in (typically the project root). */
|
||||||
@ -36,9 +51,35 @@ interface TerminalViewProps {
|
|||||||
options: OpenTerminalOptions,
|
options: OpenTerminalOptions,
|
||||||
onData: (bytes: Uint8Array) => void,
|
onData: (bytes: Uint8Array) => void,
|
||||||
) => Promise<TerminalHandle>;
|
) => Promise<TerminalHandle>;
|
||||||
|
/**
|
||||||
|
* Optional re-attach opener. When provided together with a {@link sessionId},
|
||||||
|
* the view re-binds to the existing live PTY instead of opening a new one.
|
||||||
|
* When absent, falls back to the terminal gateway's `reattach`.
|
||||||
|
*/
|
||||||
|
reattach?: (
|
||||||
|
sessionId: string,
|
||||||
|
onData: (bytes: Uint8Array) => void,
|
||||||
|
) => Promise<ReattachResult>;
|
||||||
|
/**
|
||||||
|
* Persisted session id for this cell, if a PTY is already running for it.
|
||||||
|
* Drives the reattach-vs-open decision at mount.
|
||||||
|
*/
|
||||||
|
sessionId?: string | null;
|
||||||
|
/**
|
||||||
|
* Called once a session is established (opened) so the caller can persist its
|
||||||
|
* id for this cell and re-attach to it on the next mount. Not called on
|
||||||
|
* reattach (the id is already known).
|
||||||
|
*/
|
||||||
|
onSessionId?: (sessionId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TerminalView({ cwd, open }: TerminalViewProps) {
|
export function TerminalView({
|
||||||
|
cwd,
|
||||||
|
open,
|
||||||
|
reattach,
|
||||||
|
sessionId,
|
||||||
|
onSessionId,
|
||||||
|
}: TerminalViewProps) {
|
||||||
const { terminal } = useGateways();
|
const { terminal } = useGateways();
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
@ -51,6 +92,12 @@ export function TerminalView({ cwd, open }: TerminalViewProps) {
|
|||||||
// so the correct opener is always captured at mount.
|
// so the correct opener is always captured at mount.
|
||||||
const openRef = useRef(open);
|
const openRef = useRef(open);
|
||||||
openRef.current = open;
|
openRef.current = open;
|
||||||
|
const reattachRef = useRef(reattach);
|
||||||
|
reattachRef.current = reattach;
|
||||||
|
const sessionIdRef = useRef(sessionId);
|
||||||
|
sessionIdRef.current = sessionId;
|
||||||
|
const onSessionIdRef = useRef(onSessionId);
|
||||||
|
onSessionIdRef.current = onSessionId;
|
||||||
const terminalRef = useRef(terminal);
|
const terminalRef = useRef(terminal);
|
||||||
terminalRef.current = terminal;
|
terminalRef.current = terminal;
|
||||||
|
|
||||||
@ -58,6 +105,7 @@ export function TerminalView({ cwd, open }: TerminalViewProps) {
|
|||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
const tgw = terminalRef.current;
|
const tgw = terminalRef.current;
|
||||||
const opener = openRef.current ?? tgw?.openTerminal.bind(tgw);
|
const opener = openRef.current ?? tgw?.openTerminal.bind(tgw);
|
||||||
|
const reattacher = reattachRef.current ?? tgw?.reattach.bind(tgw);
|
||||||
if (!container || !opener) return;
|
if (!container || !opener) return;
|
||||||
|
|
||||||
const term = new Terminal({
|
const term = new Terminal({
|
||||||
@ -94,15 +142,16 @@ export function TerminalView({ cwd, open }: TerminalViewProps) {
|
|||||||
else pending += data;
|
else pending += data;
|
||||||
});
|
});
|
||||||
|
|
||||||
opener(
|
const onData = (bytes: Uint8Array) => {
|
||||||
{ cwd, rows: term.rows, cols: term.cols },
|
|
||||||
(bytes) => {
|
|
||||||
if (!disposed) term.write(bytes);
|
if (!disposed) term.write(bytes);
|
||||||
},
|
};
|
||||||
)
|
|
||||||
.then((h) => {
|
// Adopt a freshly-established handle: flush buffered keystrokes. If the view
|
||||||
|
// was disposed before the promise resolved, just detach (NEVER close — the
|
||||||
|
// PTY must survive a transient mount/unmount).
|
||||||
|
const adopt = (h: TerminalHandle) => {
|
||||||
if (disposed) {
|
if (disposed) {
|
||||||
void h.close();
|
h.detach();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handle = h;
|
handle = h;
|
||||||
@ -110,14 +159,48 @@ export function TerminalView({ cwd, open }: TerminalViewProps) {
|
|||||||
void h.write(encoder.encode(pending));
|
void h.write(encoder.encode(pending));
|
||||||
pending = "";
|
pending = "";
|
||||||
}
|
}
|
||||||
})
|
};
|
||||||
.catch((e: unknown) => {
|
|
||||||
|
const onOpenError = (e: unknown) => {
|
||||||
if (!disposed) {
|
if (!disposed) {
|
||||||
term.write(
|
term.write(
|
||||||
`\r\n\x1b[31mfailed to open terminal: ${describe(e)}\x1b[0m\r\n`,
|
`\r\n\x1b[31mfailed to open terminal: ${describe(e)}\x1b[0m\r\n`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Re-attach to an existing live PTY when this cell already has a session;
|
||||||
|
// otherwise open a fresh one and persist its id for the next mount.
|
||||||
|
const existingSession = sessionIdRef.current;
|
||||||
|
if (existingSession && reattacher) {
|
||||||
|
reattacher(existingSession, onData)
|
||||||
|
.then(({ handle: h, scrollback }) => {
|
||||||
|
if (disposed) {
|
||||||
|
h.detach();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (scrollback.length > 0) term.write(scrollback);
|
||||||
|
adopt(h);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// The session is gone (explicitly closed / exited): fall back to a
|
||||||
|
// fresh terminal so the cell still works.
|
||||||
|
if (disposed) return;
|
||||||
|
opener({ cwd, rows: term.rows, cols: term.cols }, onData)
|
||||||
|
.then((h) => {
|
||||||
|
onSessionIdRef.current?.(h.sessionId);
|
||||||
|
adopt(h);
|
||||||
|
})
|
||||||
|
.catch(onOpenError);
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
opener({ cwd, rows: term.rows, cols: term.cols }, onData)
|
||||||
|
.then((h) => {
|
||||||
|
onSessionIdRef.current?.(h.sessionId);
|
||||||
|
adopt(h);
|
||||||
|
})
|
||||||
|
.catch(onOpenError);
|
||||||
|
}
|
||||||
|
|
||||||
// Refit + propagate size to the PTY on container resize.
|
// Refit + propagate size to the PTY on container resize.
|
||||||
const ro = new ResizeObserver(() => {
|
const ro = new ResizeObserver(() => {
|
||||||
@ -134,7 +217,10 @@ export function TerminalView({ cwd, open }: TerminalViewProps) {
|
|||||||
disposed = true;
|
disposed = true;
|
||||||
ro.disconnect();
|
ro.disconnect();
|
||||||
onKey.dispose();
|
onKey.dispose();
|
||||||
if (handle) void handle.close();
|
// DETACH, never close: tearing the view down (navigation / layout change)
|
||||||
|
// must leave the backend PTY running so the AI isn't cut off. Killing the
|
||||||
|
// PTY is an explicit user action handled elsewhere.
|
||||||
|
if (handle) handle.detach();
|
||||||
term.dispose();
|
term.dispose();
|
||||||
};
|
};
|
||||||
// Only re-open on cwd change (or mount). The opener is read from a ref, and
|
// Only re-open on cwd change (or mount). The opener is read from a ref, and
|
||||||
|
|||||||
@ -25,6 +25,8 @@ import type {
|
|||||||
LayoutTree,
|
LayoutTree,
|
||||||
Project,
|
Project,
|
||||||
ProfileAvailability,
|
ProfileAvailability,
|
||||||
|
Skill,
|
||||||
|
SkillScope,
|
||||||
Template,
|
Template,
|
||||||
Unsubscribe,
|
Unsubscribe,
|
||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
@ -72,6 +74,16 @@ export interface AgentGateway {
|
|||||||
options: OpenTerminalOptions,
|
options: OpenTerminalOptions,
|
||||||
onData: (bytes: Uint8Array) => void,
|
onData: (bytes: Uint8Array) => void,
|
||||||
): Promise<TerminalHandle>;
|
): Promise<TerminalHandle>;
|
||||||
|
/**
|
||||||
|
* Re-attaches to an agent's already-running PTY (same backend mechanism as
|
||||||
|
* {@link TerminalGateway.reattach}; agent sessions share the session-based
|
||||||
|
* terminal commands). Used when an agent cell's view re-mounts after a
|
||||||
|
* navigation/layout change, so the agent is never killed.
|
||||||
|
*/
|
||||||
|
reattach(
|
||||||
|
sessionId: string,
|
||||||
|
onData: (bytes: Uint8Array) => void,
|
||||||
|
): Promise<ReattachResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Options for opening a terminal. */
|
/** Options for opening a terminal. */
|
||||||
@ -98,7 +110,19 @@ export interface TerminalHandle {
|
|||||||
write(data: Uint8Array): Promise<void>;
|
write(data: Uint8Array): Promise<void>;
|
||||||
/** Resizes the PTY. */
|
/** Resizes the PTY. */
|
||||||
resize(rows: number, cols: number): Promise<void>;
|
resize(rows: number, cols: number): Promise<void>;
|
||||||
/** Kills the PTY and stops the output stream. */
|
/**
|
||||||
|
* Detaches the **view** from the PTY without killing it: stops the local
|
||||||
|
* output subscription so a torn-down view (navigation / layout change) stops
|
||||||
|
* receiving bytes, while the backend PTY keeps running. The session can later
|
||||||
|
* be re-attached via {@link TerminalGateway.reattach}.
|
||||||
|
*
|
||||||
|
* This is the lifecycle the view's cleanup must use — never {@link close}.
|
||||||
|
*/
|
||||||
|
detach(): void;
|
||||||
|
/**
|
||||||
|
* Kills the PTY and stops the output stream. Reserved for an **explicit** user
|
||||||
|
* action (closing the terminal); navigation must never call this.
|
||||||
|
*/
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -115,6 +139,27 @@ export interface TerminalGateway {
|
|||||||
options: OpenTerminalOptions,
|
options: OpenTerminalOptions,
|
||||||
onData: (bytes: Uint8Array) => void,
|
onData: (bytes: Uint8Array) => void,
|
||||||
): Promise<TerminalHandle>;
|
): Promise<TerminalHandle>;
|
||||||
|
/**
|
||||||
|
* Re-attaches to an **already-running** PTY identified by `sessionId` (after a
|
||||||
|
* view was torn down by navigation/layout change). Returns the live handle and
|
||||||
|
* the retained scrollback, which the caller repaints into xterm before the new
|
||||||
|
* output stream (`onData`) starts delivering subsequent bytes. Does NOT
|
||||||
|
* re-spawn the process.
|
||||||
|
*
|
||||||
|
* Rejects if the session is no longer alive (the caller then opens fresh).
|
||||||
|
*/
|
||||||
|
reattach(
|
||||||
|
sessionId: string,
|
||||||
|
onData: (bytes: Uint8Array) => void,
|
||||||
|
): Promise<ReattachResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The outcome of {@link TerminalGateway.reattach}. */
|
||||||
|
export interface ReattachResult {
|
||||||
|
/** The live terminal handle for the re-attached session. */
|
||||||
|
handle: TerminalHandle;
|
||||||
|
/** The retained scrollback bytes to repaint before the live stream resumes. */
|
||||||
|
scrollback: Uint8Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Projects: create/open/close/list (L2). */
|
/** Projects: create/open/close/list (L2). */
|
||||||
@ -217,6 +262,53 @@ export interface TemplateGateway {
|
|||||||
): Promise<{ synced: boolean; version: number | null }>;
|
): Promise<{ synced: boolean; version: number | null }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Input for {@link SkillGateway.createSkill}. */
|
||||||
|
export interface CreateSkillInput {
|
||||||
|
/** Owning project (resolved to a root; ignored on disk for `global`). */
|
||||||
|
projectId: string;
|
||||||
|
name: string;
|
||||||
|
content: string;
|
||||||
|
scope: SkillScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skills (L12): CRUD for reusable, model-agnostic workflows in either scope, and
|
||||||
|
* agent↔skill assignment. Assigned skills are injected into the agent's
|
||||||
|
* convention file at activation (handled backend-side, ARCHITECTURE §14.2).
|
||||||
|
*/
|
||||||
|
export interface SkillGateway {
|
||||||
|
/** Lists the skills in one scope for the given project. */
|
||||||
|
listSkills(projectId: string, scope: SkillScope): Promise<Skill[]>;
|
||||||
|
/** Creates a new skill; returns the created skill. */
|
||||||
|
createSkill(input: CreateSkillInput): Promise<Skill>;
|
||||||
|
/** Updates a skill's content; returns the updated skill. */
|
||||||
|
updateSkill(
|
||||||
|
projectId: string,
|
||||||
|
scope: SkillScope,
|
||||||
|
skillId: string,
|
||||||
|
content: string,
|
||||||
|
): Promise<Skill>;
|
||||||
|
/** Deletes a skill by id from its scope's store. */
|
||||||
|
deleteSkill(
|
||||||
|
projectId: string,
|
||||||
|
scope: SkillScope,
|
||||||
|
skillId: string,
|
||||||
|
): Promise<void>;
|
||||||
|
/** Assigns a skill to an agent (idempotent). */
|
||||||
|
assignSkill(
|
||||||
|
projectId: string,
|
||||||
|
agentId: string,
|
||||||
|
skillId: string,
|
||||||
|
scope: SkillScope,
|
||||||
|
): Promise<void>;
|
||||||
|
/** Unassigns a skill from an agent (idempotent). */
|
||||||
|
unassignSkill(
|
||||||
|
projectId: string,
|
||||||
|
agentId: string,
|
||||||
|
skillId: string,
|
||||||
|
): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI profiles & first-run (L5). Drives the first-run wizard and profile
|
* AI profiles & first-run (L5). Drives the first-run wizard and profile
|
||||||
* management: the pre-filled reference catalogue, detection of installed CLIs,
|
* management: the pre-filled reference catalogue, detection of installed CLIs,
|
||||||
@ -253,4 +345,5 @@ export interface Gateways {
|
|||||||
remote: RemoteGateway;
|
remote: RemoteGateway;
|
||||||
profile: ProfileGateway;
|
profile: ProfileGateway;
|
||||||
template: TemplateGateway;
|
template: TemplateGateway;
|
||||||
|
skill: SkillGateway;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user