Merge feature/ticket43-plugin-system into develop
Système de plugins (#43) : backend (domaine/application/infrastructure, lots B1-B4) + frontend (runtime, menus, layouts custom, lots F1-F4). Validé vert par QA (cargo test + npm typecheck/test 947/947). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
38
.ideai/mcp-tool-permissions.json
Normal file
38
.ideai/mcp-tool-permissions.json
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"projectDefault": {
|
||||||
|
"allowedTools": [
|
||||||
|
"idea_list_agents",
|
||||||
|
"idea_context_read",
|
||||||
|
"idea_memory_read",
|
||||||
|
"idea_skill_read",
|
||||||
|
"idea_workstate_read",
|
||||||
|
"idea_ticket_read",
|
||||||
|
"idea_ticket_list",
|
||||||
|
"idea_ticket_read_carnet",
|
||||||
|
"idea_sprint_list",
|
||||||
|
"idea_template_list",
|
||||||
|
"idea_template_read",
|
||||||
|
"idea_ask_agent",
|
||||||
|
"idea_run_in_background",
|
||||||
|
"idea_launch_agent",
|
||||||
|
"idea_stop_agent",
|
||||||
|
"idea_update_context",
|
||||||
|
"idea_context_propose",
|
||||||
|
"idea_memory_write",
|
||||||
|
"idea_workstate_set",
|
||||||
|
"idea_create_skill",
|
||||||
|
"idea_ticket_create",
|
||||||
|
"idea_ticket_update",
|
||||||
|
"idea_ticket_update_status",
|
||||||
|
"idea_ticket_update_priority",
|
||||||
|
"idea_ticket_update_carnet",
|
||||||
|
"idea_ticket_link",
|
||||||
|
"idea_ticket_unlink",
|
||||||
|
"idea_template_create",
|
||||||
|
"idea_template_update",
|
||||||
|
"idea_template_delete"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"agents": []
|
||||||
|
}
|
||||||
@ -65,3 +65,5 @@
|
|||||||
- [web-client-is-single-column-no-desktop-shell](web-client-is-single-column-no-desktop-shell.md) — memory note web-client-is-single-column-no-desktop-shell
|
- [web-client-is-single-column-no-desktop-shell](web-client-is-single-column-no-desktop-shell.md) — memory note web-client-is-single-column-no-desktop-shell
|
||||||
- [sandbox-eperm-bind-false-green-web-server](sandbox-eperm-bind-false-green-web-server.md) — memory note sandbox-eperm-bind-false-green-web-server
|
- [sandbox-eperm-bind-false-green-web-server](sandbox-eperm-bind-false-green-web-server.md) — memory note sandbox-eperm-bind-false-green-web-server
|
||||||
- [ticket74-f1-web-bundle-transport-seam](ticket74-f1-web-bundle-transport-seam.md) — Deux artefacts Vite (dist/dist-web), mode `web` portable Windows, et le câblage anti-divergence de la constante de transport.
|
- [ticket74-f1-web-bundle-transport-seam](ticket74-f1-web-bundle-transport-seam.md) — Deux artefacts Vite (dist/dist-web), mode `web` portable Windows, et le câblage anti-divergence de la constante de transport.
|
||||||
|
- [ticket43-backend-b1-b4-qa-validation](ticket43-backend-b1-b4-qa-validation.md) — memory note ticket43-backend-b1-b4-qa-validation
|
||||||
|
- [ticket43-plugin-system-final-qa-verdict](ticket43-plugin-system-final-qa-verdict.md) — memory note ticket43-plugin-system-final-qa-verdict
|
||||||
|
|||||||
@ -6,7 +6,7 @@ metadata:
|
|||||||
---
|
---
|
||||||
---
|
---
|
||||||
name: appimage-build-no-strip-relr-dyn-fix
|
name: appimage-build-no-strip-relr-dyn-fix
|
||||||
description: Le build AppImage échoue sur cette machine (CachyOS/Arch) à l'étape linuxdeploy/strip (.relr.dyn) ; correctif = NO_STRIP=true. Explique probablement les "builds qui ne produisent rien".
|
description: Le build AppImage échoue sur cette machine (CachyOS/Arch) à l'étape linuxdeploy/strip (.relr.dyn) ; correctif = NO_STRIP=true. Séquence de build mise à jour après #74 (beforeBuildCommand = build:bundle).
|
||||||
metadata:
|
metadata:
|
||||||
type: reference
|
type: reference
|
||||||
---
|
---
|
||||||
@ -30,7 +30,7 @@ section ELF moderne `.relr.dyn` (relocations `DT_RELR`, `unknown type [0x13]`) p
|
|||||||
libs système GTK/glibc de CachyOS/Arch (rolling). Le plugin `gtk` de linuxdeploy strip ces libs
|
libs système GTK/glibc de CachyOS/Arch (rolling). Le plugin `gtk` de linuxdeploy strip ces libs
|
||||||
→ échec → tout le bundling casse.
|
→ échec → tout le bundling casse.
|
||||||
|
|
||||||
## Correctif VALIDÉ (2026-07-02)
|
## Correctif VALIDÉ (2026-07-02, toujours valable 2026-07-17)
|
||||||
Lancer le build avec `NO_STRIP=true` (le binaire release est déjà strippé par cargo) :
|
Lancer le build avec `NO_STRIP=true` (le binaire release est déjà strippé par cargo) :
|
||||||
```
|
```
|
||||||
cd crates/app-tauri
|
cd crates/app-tauri
|
||||||
@ -38,15 +38,40 @@ NO_STRIP=true ../../frontend/node_modules/.bin/tauri build --bundles appimage
|
|||||||
```
|
```
|
||||||
Résultat : `Finished 1 bundle at target/release/bundle/appimage/IdeA_0.3.0_amd64.AppImage`.
|
Résultat : `Finished 1 bundle at target/release/bundle/appimage/IdeA_0.3.0_amd64.AppImage`.
|
||||||
|
|
||||||
## Séquence de build complète (rappel, pas de beforeBuildCommand dans tauri.conf.json)
|
## Séquence de build — MISE À JOUR après #74 (2026-07-17)
|
||||||
1. `npm --prefix frontend run build` (produit `frontend/dist`).
|
**Attention : l'ancienne version de cette note disait « pas de beforeBuildCommand dans
|
||||||
2. depuis `crates/app-tauri` : `NO_STRIP=true <cli tauri> build --bundles appimage`.
|
tauri.conf.json » et imposait un `npm run build` manuel préalable. C'est FAUX depuis #74.**
|
||||||
CLI tauri = `frontend/node_modules/.bin/tauri` (@tauri-apps/cli). Config = crates/app-tauri/tauri.conf.json.
|
|
||||||
|
`crates/app-tauri/tauri.conf.json` a maintenant :
|
||||||
|
- `build.beforeBuildCommand: "npm --prefix ../frontend run build:bundle"`
|
||||||
|
- `bundle.resources: { "../../frontend/dist-web": "web" }`
|
||||||
|
|
||||||
|
et `frontend/package.json` définit :
|
||||||
|
```
|
||||||
|
"build:bundle": "npm run typecheck && vite build --outDir dist --emptyOutDir && vite build --mode web --outDir dist-web --emptyOutDir"
|
||||||
|
```
|
||||||
|
|
||||||
|
Donc **une seule commande suffit**, le frontend est construit automatiquement (les DEUX bundles) :
|
||||||
|
```
|
||||||
|
cd crates/app-tauri
|
||||||
|
NO_STRIP=true ../../frontend/node_modules/.bin/tauri build --bundles appimage
|
||||||
|
```
|
||||||
|
|
||||||
|
`build:bundle` produit **deux** bundles distincts, et c'est le cœur du fix #74 :
|
||||||
|
- `frontend/dist` → bundle **desktop**, transport `tauri` (= `build.frontendDist`).
|
||||||
|
- `frontend/dist-web` → bundle **web**, transport `http` (= packagé en ressource `web/`).
|
||||||
|
|
||||||
|
Un seul `dist` ne peut pas servir les deux : le transport est figé **au build** par Vite
|
||||||
|
(`resolveTransport()` lit `import.meta.env.VITE_TRANSPORT`, constant-folded). C'est ce qui
|
||||||
|
causait `__TAURI_INTERNALS__ is undefined` dans le navigateur (#74).
|
||||||
|
|
||||||
|
Garde-fou : `npm run test:bundle-transport` vérifie que `dist` = tauri et `dist-web` = http.
|
||||||
|
Le lancer après un build si un doute subsiste sur ce qui est servi au navigateur.
|
||||||
|
|
||||||
## Implication produit
|
## Implication produit
|
||||||
C'est très probablement la vraie cause des « builds AppImage qui ne produisent rien / attente
|
C'est très probablement la vraie cause des « builds AppImage qui ne produisent rien / attente
|
||||||
sans résultat » signalés par l'utilisateur. À intégrer idéalement dans le flux de build d'IdeA
|
sans résultat » signalés par l'utilisateur. À intégrer idéalement dans le flux de build d'IdeA
|
||||||
(passer NO_STRIP quand on cible AppImage sur Arch, ou vendoriser un linuxdeploy récent).
|
(passer NO_STRIP quand on cible AppImage sur Arch, ou vendoriser un linuxdeploy récent).
|
||||||
|
|
||||||
Lien : [[mcp-bridge-and-delegation-runtime-notes]],
|
Lien : [[mcp-bridge-and-delegation-runtime-notes]] (le binaire qui tourne = AppImage, pas les
|
||||||
[[checkpoint-b2-bootstrap-applied-await-codex-reset-1430]].
|
sources — rebuild obligatoire pour tester un changement backend live).
|
||||||
54
.ideai/memory/ticket43-backend-b1-b4-qa-validation.md
Normal file
54
.ideai/memory/ticket43-backend-b1-b4-qa-validation.md
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
name: ticket43-backend-b1-b4-qa-validation
|
||||||
|
description: memory note ticket43-backend-b1-b4-qa-validation
|
||||||
|
metadata:
|
||||||
|
type: project
|
||||||
|
---
|
||||||
|
---
|
||||||
|
title: Validation QA backend B1-B4 du système de plugins #43
|
||||||
|
type: reference
|
||||||
|
description: Verdict QA du périmètre backend plugin B1-B4 sur feature/ticket43-plugin-system, tests ajoutés et limites sandbox constatées.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Validation QA backend B1-B4 du système de plugins #43
|
||||||
|
|
||||||
|
Sur `feature/ticket43-plugin-system`, QA a validé le périmètre backend B1-B4 sans modifier le frontend.
|
||||||
|
|
||||||
|
Tests/ajustements QA ajoutés :
|
||||||
|
|
||||||
|
- `crates/domain/tests/layout.rs` : les helpers de tests existants ignorent explicitement `LayoutNode::CustomPluginLayout(_)`, ce qui rétablit l'exhaustivité de compilation des tests domaine après l'ajout du layout plugin custom.
|
||||||
|
- `crates/application/src/plugin/mod.rs` : tests in-memory des ports plugin pour vérifier :
|
||||||
|
- catalogue runtime vide pour `Disabled`, `PendingUninstall`, `Invalid` ;
|
||||||
|
- réconciliation MCP limitée aux plugins enabled + serveurs `autoStart`, identité `plugin:<pluginId>:<serverId>` et résolution sous `pluginRoot` ;
|
||||||
|
- disable stoppe le MCP, publie `PluginDisabled`, retire le plugin du runtime catalog ;
|
||||||
|
- uninstall stoppe le MCP, retire registry + package et publie `PluginUninstalled`.
|
||||||
|
|
||||||
|
Commandes vertes constatées :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo test -p application plugin --offline --no-fail-fast
|
||||||
|
# 7 passed; 0 failed
|
||||||
|
|
||||||
|
cargo test -p domain -p application -p infrastructure -p backend -p app-tauri plugin --offline --no-fail-fast
|
||||||
|
# plugin-filtered targeted suite green; application 7 plugin tests, domain 3 plugin tests,
|
||||||
|
# serde roundtrip custom plugin layout, infrastructure 3 plugin tests, dto plugin filtered test all ok.
|
||||||
|
|
||||||
|
cargo test -p app-tauri --test dto_plugins --offline --no-fail-fast
|
||||||
|
# 2 passed; 0 failed
|
||||||
|
|
||||||
|
cargo fmt --check
|
||||||
|
# OK
|
||||||
|
```
|
||||||
|
|
||||||
|
Workspace complet :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo test --workspace --offline --no-fail-fast -- --test-threads=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Résultat KO attendu dans ce sandbox, hors périmètre plugin :
|
||||||
|
|
||||||
|
- `-p infrastructure --lib` : 10 échecs `session::openai_compat::*` sur `bind: Os { code: 1, kind: PermissionDenied, message: "Operation not permitted" }`.
|
||||||
|
- `-p web-server --lib` : 2 échecs de bind `127.0.0.1:0 Operation not permitted` et 5 scénarios websocket/terminal recevant `error` au lieu de `terminal.attached`, cohérents avec restrictions loopback/PTY du sandbox.
|
||||||
|
|
||||||
|
Verdict : backend plugins B1-B4 validé QA avec réserve uniquement environnementale sur le workspace global.
|
||||||
52
.ideai/memory/ticket43-plugin-system-final-qa-verdict.md
Normal file
52
.ideai/memory/ticket43-plugin-system-final-qa-verdict.md
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
name: ticket43-plugin-system-final-qa-verdict
|
||||||
|
description: memory note ticket43-plugin-system-final-qa-verdict
|
||||||
|
metadata:
|
||||||
|
type: project
|
||||||
|
---
|
||||||
|
---
|
||||||
|
title: Verdict QA final du ticket #43 système de plugins
|
||||||
|
type: reference
|
||||||
|
description: Validation finale QA de #43 après carnet v5, corrections F4/F3/B4 et exécutions réelles backend/frontend.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Verdict QA final du ticket #43 système de plugins
|
||||||
|
|
||||||
|
Branche validée : `feature/ticket43-plugin-system`.
|
||||||
|
|
||||||
|
Contexte : après verdict QA orange initial, Architect a figé le carnet v5 : `customPluginLayout` top-level canonique, `gitRepository` obligatoire en F3, `${appDataDir}` obligatoire en B4, et `agentSelected`/`terminalFocused`/`layoutCellFocused` + persistance backend state layout plugin acceptés comme dette v1.
|
||||||
|
|
||||||
|
Résultats réels exécutés par QA :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo test -p domain -p application -p infrastructure -p backend -p app-tauri plugin --offline --no-fail-fast
|
||||||
|
# OK
|
||||||
|
# application plugin: 9 passed
|
||||||
|
# domain plugin: 3 passed
|
||||||
|
# infrastructure plugin: 5 passed
|
||||||
|
# custom_plugin_layout_roundtrips_with_opaque_state: ok
|
||||||
|
|
||||||
|
cd frontend && npm run typecheck
|
||||||
|
# exit 0
|
||||||
|
|
||||||
|
cd frontend && npm test -- --run
|
||||||
|
# Test Files 104 passed (104)
|
||||||
|
# Tests 947 passed (947)
|
||||||
|
```
|
||||||
|
|
||||||
|
Contrôles ciblés confirmés :
|
||||||
|
|
||||||
|
- Frontend `LayoutNode` inclut `{ type: "customPluginLayout"; node: CustomPluginLayoutCell }` top-level, sans contrat `LeafCell.pluginLayout`.
|
||||||
|
- `LayoutGrid` route `customPluginLayout` vers `PluginLayoutCellView`.
|
||||||
|
- `onStateChange`, `onOpenPlugins`, `onChooseAnotherLayout` sont câblés in-session (`setPluginLayoutState`, navigation settings plugins, remplacement par terminal).
|
||||||
|
- `ProjectsView` câble `gitRepository` via `GitGateway.branches(active.id)` ; `agentSelected`, `terminalFocused`, `layoutCellFocused` restent explicitement `false` en dette v1.
|
||||||
|
- Backend application substitue `${pluginRoot}` et `${appDataDir}` dans command/args/env/cwd des specs MCP plugin, avec test `reconcile_mcp_substitutes_app_data_dir_in_plugin_server_specs`.
|
||||||
|
|
||||||
|
Verdict QA : VERT pour merge local du ticket #43.
|
||||||
|
|
||||||
|
Dette v1 assumée :
|
||||||
|
|
||||||
|
- `agentSelected`, `terminalFocused`, `layoutCellFocused` non câblés, figés à `false` jusqu'à un lot focus/selection dédié.
|
||||||
|
- Persistance backend du `state` de layout plugin non implémentée ; F4 validé en in-session selon arbitrage Architect.
|
||||||
|
|
||||||
|
Aucun nouveau blocage détecté dans les suites demandées.
|
||||||
@ -1,8 +1,8 @@
|
|||||||
---
|
---
|
||||||
issueRef: "#14"
|
issueRef: "#14"
|
||||||
version: 7
|
version: 8
|
||||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
updatedBy: {"kind":"user"}
|
||||||
updatedAt: 1783456579694
|
updatedAt: 1784449099987
|
||||||
---
|
---
|
||||||
## Production #14 — livrée dans develop (validation e2e live restante)
|
## Production #14 — livrée dans develop (validation e2e live restante)
|
||||||
|
|
||||||
|
|||||||
@ -2,16 +2,16 @@
|
|||||||
id: "0c1f1991-b6db-47b7-bbef-2478bc7874ab"
|
id: "0c1f1991-b6db-47b7-bbef-2478bc7874ab"
|
||||||
number: 14
|
number: 14
|
||||||
title: "Integration de profils IA locaux/LAN comme profils IdeA canoniques"
|
title: "Integration de profils IA locaux/LAN comme profils IdeA canoniques"
|
||||||
status: "qa"
|
status: "closed"
|
||||||
priority: "medium"
|
priority: "medium"
|
||||||
sprint: null
|
sprint: null
|
||||||
links: []
|
links: []
|
||||||
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
||||||
createdBy: {"kind":"user"}
|
createdBy: {"kind":"user"}
|
||||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
updatedBy: {"kind":"user"}
|
||||||
createdAt: 1783184495846
|
createdAt: 1783184495846
|
||||||
updatedAt: 1783456579694
|
updatedAt: 1784449099987
|
||||||
version: 7
|
version: 8
|
||||||
---
|
---
|
||||||
Objectif : permettre à IdeA d'utiliser des modèles hébergés localement ou sur le réseau local, par exemple Qwen via Ollama ou un runtime Docker exposant une API, avec le même niveau d'intégration produit que les profils Claude Code et OpenAI Codex CLI.
|
Objectif : permettre à IdeA d'utiliser des modèles hébergés localement ou sur le réseau local, par exemple Qwen via Ollama ou un runtime Docker exposant une API, avec le même niveau d'intégration produit que les profils Claude Code et OpenAI Codex CLI.
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
issueRef: "#3"
|
issueRef: "#3"
|
||||||
version: 6
|
version: 7
|
||||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
updatedBy: {"kind":"user"}
|
||||||
updatedAt: 1784093835732
|
updatedAt: 1784377533777
|
||||||
---
|
---
|
||||||
|
|||||||
@ -2,16 +2,16 @@
|
|||||||
id: "fedd343e-f9c1-467b-bab4-455f51907de7"
|
id: "fedd343e-f9c1-467b-bab4-455f51907de7"
|
||||||
number: 3
|
number: 3
|
||||||
title: "[Différé — design à mûrir] Tâches de fond — dette B : retry durable après reboot"
|
title: "[Différé — design à mûrir] Tâches de fond — dette B : retry durable après reboot"
|
||||||
status: "open"
|
status: "closed"
|
||||||
priority: "low"
|
priority: "low"
|
||||||
sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2"
|
sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2"
|
||||||
links: []
|
links: []
|
||||||
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
||||||
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
updatedBy: {"kind":"user"}
|
||||||
createdAt: 1783082860758
|
createdAt: 1783082860758
|
||||||
updatedAt: 1784093835732
|
updatedAt: 1784377533777
|
||||||
version: 6
|
version: 7
|
||||||
---
|
---
|
||||||
Ticket #3 — Tâches de fond : retry après redémarrage sans fuite de secrets dans le projet.
|
Ticket #3 — Tâches de fond : retry après redémarrage sans fuite de secrets dans le projet.
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,349 @@
|
|||||||
---
|
---
|
||||||
issueRef: "#43"
|
issueRef: "#43"
|
||||||
version: 1
|
version: 5
|
||||||
updatedBy: {"kind":"user"}
|
updatedBy: {"kind":"agent","agent_id":"dce19c75-9669-4e45-b8de-9950025157da"}
|
||||||
updatedAt: 1783879315858
|
updatedAt: 1784667145901
|
||||||
---
|
---
|
||||||
|
# Ticket #43 — Cadrage consolidé v2 du système de plugins
|
||||||
|
|
||||||
|
> Mise à jour Architect du 2026-07-21 après retour QA F4. Ce carnet v2 annule les ambiguïtés du cadrage initial, en particulier sur le schéma de layout custom plugin.
|
||||||
|
|
||||||
|
## 0. Décisions discovery conservées
|
||||||
|
|
||||||
|
- Plugins **full-trust** v1 : pas de sandbox UI.
|
||||||
|
- Bundle **JS/ESM pré-compilé**, importé dynamiquement ; IdeA ne compile pas de TS/TSX.
|
||||||
|
- Installation **globale** dans le dossier données utilisateur de l'application, pas dans les projets.
|
||||||
|
- Distribution v1 locale : dossier ou archive locale ; packaging compatible archive partageable future type `.vsix`.
|
||||||
|
- Contributions v1 : menus top-level, items de menus existants, layouts custom React arbitraires, serveurs MCP externes.
|
||||||
|
- MCP plugin = process serveur MCP externe déclaré par manifeste et supervisé via le pont MCP existant.
|
||||||
|
- Propreté retrait : après uninstall + redémarrage, aucune contribution, aucun process, aucune entrée fantôme, aucun état plugin-specific.
|
||||||
|
|
||||||
|
## 1. Store global et cycle de vie
|
||||||
|
|
||||||
|
Store global :
|
||||||
|
|
||||||
|
```text
|
||||||
|
{app_data_dir}/plugins/
|
||||||
|
registry.json
|
||||||
|
installed/
|
||||||
|
<pluginId>/
|
||||||
|
idea-plugin.json
|
||||||
|
dist/index.js
|
||||||
|
assets/...
|
||||||
|
servers/...
|
||||||
|
```
|
||||||
|
|
||||||
|
États v1 persistés :
|
||||||
|
|
||||||
|
```text
|
||||||
|
enabled
|
||||||
|
disabled
|
||||||
|
pending-enable
|
||||||
|
pending-disable
|
||||||
|
pending-uninstall
|
||||||
|
invalid
|
||||||
|
```
|
||||||
|
|
||||||
|
Règles :
|
||||||
|
|
||||||
|
- Installer depuis archive ou dossier local copie toujours un snapshot dans `installed/<pluginId>/`.
|
||||||
|
- `registry.json` porte seulement l'état global nécessaire ; une désinstallation réussie supprime l'entrée registry.
|
||||||
|
- Disable/uninstall en session masque les contributions UI et arrête MCP immédiatement si possible, mais le code ESM déjà importé n'est purgé strictement qu'au redémarrage.
|
||||||
|
- Au boot, le frontend reconstruit une registry plugin vide puis charge uniquement les plugins `enabled` et non `pending-uninstall`.
|
||||||
|
|
||||||
|
## 2. Manifeste v1
|
||||||
|
|
||||||
|
Fichier obligatoire : `idea-plugin.json`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ideaPluginManifestVersion": 1,
|
||||||
|
"id": "dev.acme.gitgraph",
|
||||||
|
"displayName": "Git Graph",
|
||||||
|
"publisher": "Acme DevTools",
|
||||||
|
"version": "1.2.3",
|
||||||
|
"engines": { "idea": ">=0.1.0 <1.0.0" },
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"icon": "assets/icon.svg",
|
||||||
|
"trustLevel": "full",
|
||||||
|
"capabilities": ["ui", "mcp"],
|
||||||
|
"contributes": {
|
||||||
|
"menus": [],
|
||||||
|
"menuItems": [],
|
||||||
|
"layouts": [],
|
||||||
|
"mcpServers": []
|
||||||
|
},
|
||||||
|
"archive": {
|
||||||
|
"files": ["idea-plugin.json", "dist/**", "assets/**", "servers/**"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Validation :
|
||||||
|
|
||||||
|
- `trustLevel` vaut uniquement `full` en v1.
|
||||||
|
- `main`, `icon`, assets et commandes MCP relatives ne doivent contenir ni chemin absolu ni `..`.
|
||||||
|
- `id` stable, unique, reverse-DNS recommandé.
|
||||||
|
- `version` SemVer.
|
||||||
|
- `engines.idea` incompatible => plugin `invalid`, jamais chargé.
|
||||||
|
|
||||||
|
## 3. Contrat définitif des layouts custom plugin
|
||||||
|
|
||||||
|
### 3.1 Décision ferme
|
||||||
|
|
||||||
|
`CustomPluginLayout` est une **vraie variante top-level de `LayoutNode`**, pas un champ optionnel de `LeafCell`.
|
||||||
|
|
||||||
|
Motif : un layout plugin occupe une zone de l'arbre au même niveau conceptuel qu'une leaf terminal, un split ou une grid. Le mettre dans `LeafCell` mélangerait deux responsabilités incompatibles : terminal/session/agent d'un côté, composant React arbitraire et état opaque plugin de l'autre.
|
||||||
|
|
||||||
|
### 3.2 Schéma JSON canonique
|
||||||
|
|
||||||
|
Le layout tree garde le format serde existant `#[serde(tag = "type", content = "node")]`.
|
||||||
|
|
||||||
|
Union canonique :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type LayoutNode =
|
||||||
|
| { type: "leaf"; node: LeafCell }
|
||||||
|
| { type: "split"; node: SplitContainer }
|
||||||
|
| { type: "grid"; node: GridContainer }
|
||||||
|
| { type: "customPluginLayout"; node: CustomPluginLayoutCell };
|
||||||
|
```
|
||||||
|
|
||||||
|
Payload canonique :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface CustomPluginLayoutCell {
|
||||||
|
id: string;
|
||||||
|
pluginId: string;
|
||||||
|
layoutType: string;
|
||||||
|
state: unknown;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Exemple complet :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"root": {
|
||||||
|
"type": "customPluginLayout",
|
||||||
|
"node": {
|
||||||
|
"id": "018f0c5a-2b4b-70d4-a7c2-300000000001",
|
||||||
|
"pluginId": "dev.acme.gitgraph",
|
||||||
|
"layoutType": "dev.acme.gitgraph.layout",
|
||||||
|
"state": {
|
||||||
|
"branchFilter": "main"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Dans un split :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "split",
|
||||||
|
"node": {
|
||||||
|
"id": "split-1",
|
||||||
|
"direction": "row",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"weight": 1,
|
||||||
|
"node": {
|
||||||
|
"type": "leaf",
|
||||||
|
"node": { "id": "terminal-1" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"weight": 1,
|
||||||
|
"node": {
|
||||||
|
"type": "customPluginLayout",
|
||||||
|
"node": {
|
||||||
|
"id": "plugin-cell-1",
|
||||||
|
"pluginId": "dev.acme.gitgraph",
|
||||||
|
"layoutType": "dev.acme.gitgraph.layout",
|
||||||
|
"state": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Dans une grid, `GridCell.node` peut pareillement être `{ type: "customPluginLayout", node: ... }`.
|
||||||
|
|
||||||
|
### 3.3 Ce qui est explicitement interdit
|
||||||
|
|
||||||
|
Cette forme n'est **pas** contractuelle et ne doit plus être produite ni consommée comme modèle canonique :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "leaf",
|
||||||
|
"node": {
|
||||||
|
"id": "leaf-1",
|
||||||
|
"pluginLayout": {
|
||||||
|
"pluginId": "dev.acme.gitgraph",
|
||||||
|
"layoutType": "dev.acme.gitgraph.layout",
|
||||||
|
"state": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`LeafCell.pluginLayout` est une divergence frontend issue de l'ambiguïté initiale. Elle doit être supprimée du modèle domaine TS ou limitée à une migration locale temporaire de tests/mocks ; elle ne fait pas partie de l'IPC ni de la persistance.
|
||||||
|
|
||||||
|
### 3.4 Disponibilité et fallback
|
||||||
|
|
||||||
|
La disponibilité n'est pas stockée dans le layout. Elle est dérivée côté UI à partir du plugin registry/runtime catalog :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type PluginLayoutAvailability =
|
||||||
|
| "available"
|
||||||
|
| "plugin-disabled"
|
||||||
|
| "plugin-missing"
|
||||||
|
| "incompatible";
|
||||||
|
```
|
||||||
|
|
||||||
|
Rendu :
|
||||||
|
|
||||||
|
- `available` : rendre le composant React enregistré pour `layoutType`.
|
||||||
|
- `plugin-disabled`, `plugin-missing`, `incompatible` : rendre le fallback non destructif `Layout indisponible`.
|
||||||
|
- Le fallback ne transforme pas automatiquement le nœud et ne supprime jamais `state`.
|
||||||
|
|
||||||
|
### 3.5 Ajustements requis
|
||||||
|
|
||||||
|
Verdict convergence F4 : **frontend à ajuster, backend à conserver**.
|
||||||
|
|
||||||
|
Backend :
|
||||||
|
|
||||||
|
- L'implémentation actuelle `LayoutNode::CustomPluginLayout(CustomPluginLayoutCell)` sérialisée `type: "customPluginLayout"` est le contrat canonique.
|
||||||
|
- À vérifier seulement : roundtrip serde et DTO Tauri exposent bien `pluginId`, `layoutType`, `state` en camelCase.
|
||||||
|
|
||||||
|
Frontend :
|
||||||
|
|
||||||
|
- Ajouter la variante `{ type: "customPluginLayout"; node: CustomPluginLayoutCell }` à `LayoutNode`.
|
||||||
|
- Retirer `pluginLayout?: CustomPluginLayoutCell` de `LeafCell` comme contrat domaine.
|
||||||
|
- Adapter `LayoutGrid`/renderer récursif pour router `type === "customPluginLayout"` vers `PluginLayoutCellView`.
|
||||||
|
- Adapter `layoutAvailability`, fallback et tests pour recevoir le payload depuis `node.node` de la variante top-level.
|
||||||
|
- Ajouter un test de parsing/rendu avec un layout JSON produit par Rust contenant `type: "customPluginLayout"`.
|
||||||
|
|
||||||
|
## 4. Contribution points menus et `when`
|
||||||
|
|
||||||
|
Menus v1 :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type MenuTargetId = "panels" | "settings" | `plugin:${string}`;
|
||||||
|
```
|
||||||
|
|
||||||
|
Items v1 :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface PluginMenuItemContribution {
|
||||||
|
id: string;
|
||||||
|
targetMenuId: MenuTargetId;
|
||||||
|
label: string;
|
||||||
|
command: string;
|
||||||
|
order?: number;
|
||||||
|
icon?: string;
|
||||||
|
when?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`when` v1 utilise le mini-langage booléen `&&`, `||`, `!`, parenthèses.
|
||||||
|
|
||||||
|
Variables :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type WhenVariable =
|
||||||
|
| "projectOpen"
|
||||||
|
| "gitRepository"
|
||||||
|
| "agentSelected"
|
||||||
|
| "terminalFocused"
|
||||||
|
| "layoutCellFocused";
|
||||||
|
```
|
||||||
|
|
||||||
|
Arbitrage QA sur les variables actuellement figées à `false` :
|
||||||
|
|
||||||
|
- `projectOpen` : obligatoire v1, doit refléter l'état réel.
|
||||||
|
- `gitRepository` : **bloquant avant clôture #43/F3**. Le manifeste exemple et le cas GitGraph dépendent de `projectOpen && gitRepository`; le laisser à `false` rend des contributions valides inatteignables.
|
||||||
|
- `agentSelected`, `terminalFocused`, `layoutCellFocused` : dette acceptable v1 si elles restent explicitement documentées comme **best-effort non câblé** et donc `false` jusqu'à un lot focus/selection dédié. Ce n'est pas bloquant pour F4 ni pour la clean-removal QA, sauf si un plugin de validation les utilise dans son manifeste.
|
||||||
|
|
||||||
|
Ajustement recommandé : DevFrontend câble au minimum `gitRepository` depuis l'état projet/git déjà disponible, ou retire temporairement `gitRepository` des manifests/tests de validation. La préférence architecture est de le câbler, car il est déjà annoncé comme variable v1.
|
||||||
|
|
||||||
|
## 5. Contribution MCP et `${appDataDir}`
|
||||||
|
|
||||||
|
Manifest MCP :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface PluginMcpServerContribution {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
command: string;
|
||||||
|
args?: string[];
|
||||||
|
env?: Record<string, string>;
|
||||||
|
cwd?: "${pluginRoot}" | "${appDataDir}" | string;
|
||||||
|
transport: "stdio";
|
||||||
|
autoStart?: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Variables de substitution contractuelles dans `command`, `args`, `env` et `cwd` :
|
||||||
|
|
||||||
|
- `${pluginRoot}` : obligatoire v1.
|
||||||
|
- `${appDataDir}` : **obligatoire v1 si la spec continue de l'accepter**.
|
||||||
|
- `${projectRoot}` : interdit v1.
|
||||||
|
|
||||||
|
Arbitrage QA sur `${appDataDir}` non implémenté :
|
||||||
|
|
||||||
|
- Pas bloquant pour F4 layout.
|
||||||
|
- **Bloquant pour clôture B4/#43** si le manifeste continue de documenter `${appDataDir}` comme disponible. Un contrat annoncé mais non substitué crée des specs MCP fausses.
|
||||||
|
- Deux sorties acceptables, par ordre de préférence :
|
||||||
|
1. DevBackend implémente la substitution `${appDataDir}` dans les specs MCP plugin et ajoute tests args/env/cwd.
|
||||||
|
2. Si le coût est refusé pour v1, retirer `${appDataDir}` du contrat manifeste et du validator, puis documenter explicitement `${pluginRoot}` comme seule variable v1.
|
||||||
|
|
||||||
|
Décision architecture par défaut : garder `${appDataDir}` et le faire implémenter par DevBackend, car le store global est déjà résolu côté backend et le coût est borné.
|
||||||
|
|
||||||
|
## 6. Lots et responsabilités actualisés
|
||||||
|
|
||||||
|
### F4 — convergence layout custom plugin
|
||||||
|
|
||||||
|
Owner : DevFrontend.
|
||||||
|
|
||||||
|
Livrables :
|
||||||
|
|
||||||
|
- Union TS `LayoutNode` alignée sur Rust avec `customPluginLayout` top-level.
|
||||||
|
- Renderer/fallback branchés sur cette variante.
|
||||||
|
- Suppression du contrat `LeafCell.pluginLayout`.
|
||||||
|
- Test frontend à partir d'un JSON Rust réel.
|
||||||
|
|
||||||
|
Backend F4 : pas de refonte attendue ; seulement vérifier/maintenir les tests serde existants.
|
||||||
|
|
||||||
|
### F3 — `when.gitRepository`
|
||||||
|
|
||||||
|
Owner : DevFrontend.
|
||||||
|
|
||||||
|
Livrable : `gitRepository` ne doit plus être figé à `false` pour un projet Git réel avant clôture #43/F3.
|
||||||
|
|
||||||
|
### B4 — `${appDataDir}` MCP
|
||||||
|
|
||||||
|
Owner : DevBackend.
|
||||||
|
|
||||||
|
Livrable : substitution `${appDataDir}` dans `command`, `args`, `env`, `cwd` des specs MCP plugin, ou retrait explicite du contrat si arbitré à la baisse. Par défaut : implémenter.
|
||||||
|
|
||||||
|
### QA — non-régression à ajouter
|
||||||
|
|
||||||
|
- Layout JSON backend `customPluginLayout` top-level rendu côté frontend.
|
||||||
|
- Fallback atteint si plugin missing/disabled avec nœud top-level.
|
||||||
|
- Aucun fallback basé sur `leaf.node.pluginLayout` ne compte comme validation du contrat.
|
||||||
|
- MCP spec avec `${pluginRoot}` et `${appDataDir}`.
|
||||||
|
- Menu item `when: "projectOpen && gitRepository"` actif dans un projet Git.
|
||||||
|
|
||||||
|
## 7. Non-objectifs maintenus v1
|
||||||
|
|
||||||
|
- Pas de sandbox UI.
|
||||||
|
- Pas de hot-unload mémoire garanti dans la même session.
|
||||||
|
- Pas de marketplace distant.
|
||||||
|
- Pas de compilation TS/TSX.
|
||||||
|
- Pas de `${projectRoot}` pour serveurs MCP plugin globaux.
|
||||||
|
- Pas de garantie v1 sur `agentSelected`, `terminalFocused`, `layoutCellFocused` tant qu'un lot focus/selection n'a pas été cadré.
|
||||||
@ -8,8 +8,9 @@ sprint: null
|
|||||||
links: []
|
links: []
|
||||||
agentRefs: []
|
agentRefs: []
|
||||||
createdBy: {"kind":"user"}
|
createdBy: {"kind":"user"}
|
||||||
updatedBy: {"kind":"user"}
|
updatedBy: {"kind":"agent","agent_id":"dce19c75-9669-4e45-b8de-9950025157da"}
|
||||||
createdAt: 1783879315858
|
createdAt: 1783879315858
|
||||||
updatedAt: 1783879315858
|
updatedAt: 1784667145901
|
||||||
version: 1
|
version: 5
|
||||||
---
|
---
|
||||||
|
Système de plugins/extensions pour IdeA (à la VSCode/Visual Studio) : permettre d'ajouter des menus (dropdown façon Panneaux/Paramètres), des items dans des menus existants, des types de layout custom (à la GitGraph), et des tools MCP.
|
||||||
@ -1,8 +1,8 @@
|
|||||||
---
|
---
|
||||||
issueRef: "#55"
|
issueRef: "#55"
|
||||||
version: 11
|
version: 12
|
||||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
updatedBy: {"kind":"user"}
|
||||||
updatedAt: 1784097757001
|
updatedAt: 1784649948611
|
||||||
---
|
---
|
||||||
## Réouverture (2026-07-15) — le fix 141c13d ne suffit pas
|
## Réouverture (2026-07-15) — le fix 141c13d ne suffit pas
|
||||||
|
|
||||||
|
|||||||
@ -2,16 +2,16 @@
|
|||||||
id: "9ab1eb21-d6dd-435a-b088-377f0ec7e04f"
|
id: "9ab1eb21-d6dd-435a-b088-377f0ec7e04f"
|
||||||
number: 55
|
number: 55
|
||||||
title: "[Bug] Error on loading local model"
|
title: "[Bug] Error on loading local model"
|
||||||
status: "inProgress"
|
status: "closed"
|
||||||
priority: "high"
|
priority: "high"
|
||||||
sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2"
|
sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2"
|
||||||
links: []
|
links: []
|
||||||
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
||||||
createdBy: {"kind":"user"}
|
createdBy: {"kind":"user"}
|
||||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
updatedBy: {"kind":"user"}
|
||||||
createdAt: 1784045935720
|
createdAt: 1784045935720
|
||||||
updatedAt: 1784097757001
|
updatedAt: 1784649948611
|
||||||
version: 11
|
version: 12
|
||||||
---
|
---
|
||||||
Quand jke cherche a lancer un modele local sur une cellule, il commence apr charger le serveur, ce qui est bon mais au bout de quelques seconde, le chargement du serveur disparait et j'ai cette erreur qui s'affiche en bandeau rouge: Échec du lancement de l'agent : model server error (timeout): readiness timed out.
|
Quand jke cherche a lancer un modele local sur une cellule, il commence apr charger le serveur, ce qui est bon mais au bout de quelques seconde, le chargement du serveur disparait et j'ai cette erreur qui s'affiche en bandeau rouge: Échec du lancement de l'agent : model server error (timeout): readiness timed out.
|
||||||
Si j'insiste assez en changeant d'agent et en remettant l'agent, au bourt d'un moment ça fini par fonctionner
|
Si j'insiste assez en changeant d'agent et en remettant l'agent, au bourt d'un moment ça fini par fonctionner
|
||||||
@ -1,6 +1,21 @@
|
|||||||
---
|
---
|
||||||
issueRef: "#60"
|
issueRef: "#60"
|
||||||
version: 3
|
version: 5
|
||||||
updatedBy: {"kind":"user"}
|
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
updatedAt: 1784194089605
|
updatedAt: 1784360139966
|
||||||
---
|
---
|
||||||
|
## Réconciliation (2026-07-18) — déjà livré et mergé, statut désynchronisé
|
||||||
|
|
||||||
|
Le fix Lot A (backend + frontend) a en réalité été implémenté et mergé sur `develop` **avant** cette relance de sprint : commit `2f7f111` (`fix(ticket-assistant): rendre visible l'échec « aucune réponse » de l'assistant de ticket (#60)`), fusionné via `ebad5ff` (`Merge feature/ticket60-ticket-assistant-no-reply into develop (#60)`). `ebad5ff` est ancêtre de `develop` HEAD actuel (`17d6baf`) — plusieurs sprints livrés depuis sans régression signalée.
|
||||||
|
|
||||||
|
Contenu du fix (conforme au périmètre cadré) :
|
||||||
|
- domain/ports : variant `ReplyEvent::Error { message }`.
|
||||||
|
- infra/session : parse `reasoning_content` en fallback quand `content` est vide.
|
||||||
|
- app-tauri : mapping `ReplyEvent::Error → ReplyChunk::Error`, Final vide converti en Error visible.
|
||||||
|
- frontend : `ReplyChunk` type `error`, rendu dans `useTicketAssistant` (+ test dédié), `busy` ne retombe plus prématurément.
|
||||||
|
|
||||||
|
Reconfirmé le 2026-07-18 par DevFrontend sur l'arbre de travail courant : `npx vitest run src/features/tickets/useTicketAssistant.test.tsx` → 3/3 vert, y compris le cas error-chunk.
|
||||||
|
|
||||||
|
Le ticket était resté `inProgress` avec un carnet vide par désynchronisation d'état (le commit d'origine portait la note « #60 reste inProgress » avant la vérification hors-sandbox qui a permis le merge ensuite). Clôture sur constat de fait, pas de nouveau travail nécessaire.
|
||||||
|
|
||||||
|
Branche orpheline `feature/ticket60-assistant-mute-reply` créée par erreur lors de cette relance (rien à committer, identique à `develop`) — à supprimer par Git, non utilisée.
|
||||||
@ -2,16 +2,16 @@
|
|||||||
id: "623b1719-c71d-448e-a0ef-3c7d5823b8a3"
|
id: "623b1719-c71d-448e-a0ef-3c7d5823b8a3"
|
||||||
number: 60
|
number: 60
|
||||||
title: "[Bug] Assistant IA d'édition de ticket : aucune réponse dans la conversation (stream sans Final non signalé)"
|
title: "[Bug] Assistant IA d'édition de ticket : aucune réponse dans la conversation (stream sans Final non signalé)"
|
||||||
status: "inProgress"
|
status: "closed"
|
||||||
priority: "high"
|
priority: "high"
|
||||||
sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2"
|
sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2"
|
||||||
links: [{"target":"#25","kind":"relatesTo"},{"target":"#27","kind":"relatesTo"}]
|
links: [{"target":"#25","kind":"relatesTo"},{"target":"#27","kind":"relatesTo"}]
|
||||||
agentRefs: []
|
agentRefs: []
|
||||||
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
updatedBy: {"kind":"user"}
|
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
createdAt: 1784094260499
|
createdAt: 1784094260499
|
||||||
updatedAt: 1784194089605
|
updatedAt: 1784360139966
|
||||||
version: 3
|
version: 5
|
||||||
---
|
---
|
||||||
Rapporté par l'utilisateur (2026-07-15) : dans l'édition d'un ticket, le bouton « Assistant IA » ouvre une conversation, mais l'envoi d'un message ne produit JAMAIS de réponse visible — la conversation reste muette. PROFIL UTILISÉ : modèle local OpenAI-compatible (llamacpp).
|
Rapporté par l'utilisateur (2026-07-15) : dans l'édition d'un ticket, le bouton « Assistant IA » ouvre une conversation, mais l'envoi d'un message ne produit JAMAIS de réponse visible — la conversation reste muette. PROFIL UTILISÉ : modèle local OpenAI-compatible (llamacpp).
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,19 @@
|
|||||||
---
|
---
|
||||||
issueRef: "#61"
|
issueRef: "#61"
|
||||||
version: 5
|
version: 11
|
||||||
updatedBy: {"kind":"user"}
|
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
updatedAt: 1784182133142
|
updatedAt: 1784661823324
|
||||||
---
|
---
|
||||||
|
Un premier fix (commit 445ecaf, "fix(frontend): refit différé des cellules terminal après mutation de layout (#61)", mergé via a69c5db) a couvert le cas desktop LayoutGrid/TerminalView (split/merge déclenchant un refit explicite via requestAnimationFrame).
|
||||||
|
|
||||||
|
## Réouverture (2026-07-21)
|
||||||
|
|
||||||
|
L'utilisateur signale que le problème persiste :
|
||||||
|
- Ouverture d'une nouvelle cellule → la CLI déjà affichée ne se redimensionne pas correctement tant qu'un resize manuel n'est pas déclenché.
|
||||||
|
- Reproduit aussi sur la version **Web** : sur PC, un redimensionnement manuel de la fenêtre du navigateur corrige le scaling. Sur mobile, impossible de redimensionner la fenêtre → le bug reste bloquant, pas de contournement possible.
|
||||||
|
|
||||||
|
Hypothèse de travail : le fix précédent (445ecaf) est probablement scopé à `LayoutGrid`/`useLayout` (desktop, split/merge d'un arbre de layout). Le workspace web n'utilise pas ce chemin — il a sa propre surface (`WebAgentCell`/`LiveProjectPanel`, cf. déviation notée par DevFrontend sur le ticket #90) qui ouvre/remplace des cellules terminal autrement. Le signal de refit explicite ajouté pour split/merge ne se déclenche donc probablement jamais côté web, et `TerminalView` retombe uniquement sur son `ResizeObserver` local — insuffisant selon le timing de montage/redimensionnement de la surface web.
|
||||||
|
|
||||||
|
À vérifier par Architect/DevFrontend : le chemin exact d'ouverture/fermeture de cellule côté web (WebAgentCell, WebWorkspace, WebMenuSheet le cas échéant après #90), et si le mécanisme de refit du fix 445ecaf peut être réutilisé/étendu à ce chemin plutôt que dupliqué.
|
||||||
|
|
||||||
|
Périmètre : frontend pur, desktop non régressé, web (PC et mobile) doit refit sans action manuelle de l'utilisateur.
|
||||||
@ -2,15 +2,15 @@
|
|||||||
id: "b0568a4e-4804-480c-ada4-bc9d6b7b40c6"
|
id: "b0568a4e-4804-480c-ada4-bc9d6b7b40c6"
|
||||||
number: 61
|
number: 61
|
||||||
title: "[UI] rafraichissement des cellule"
|
title: "[UI] rafraichissement des cellule"
|
||||||
status: "open"
|
status: "closed"
|
||||||
priority: "low"
|
priority: "low"
|
||||||
sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2"
|
sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2"
|
||||||
links: []
|
links: []
|
||||||
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
||||||
createdBy: {"kind":"user"}
|
createdBy: {"kind":"user"}
|
||||||
updatedBy: {"kind":"user"}
|
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
createdAt: 1784095163730
|
createdAt: 1784095163730
|
||||||
updatedAt: 1784182133142
|
updatedAt: 1784661823324
|
||||||
version: 5
|
version: 11
|
||||||
---
|
---
|
||||||
Lorsque j'ajoute une nouvelle cellule ou que j'en enlève une, je suis obligé de redimentionner un coup la fenêtre ou les cellule pour rafraichir le scaling des cli
|
Lorsque j'ajoute une nouvelle cellule ou que j'en enlève une, je suis obligé de redimentionner un coup la fenêtre ou les cellule pour rafraichir le scaling des cli
|
||||||
@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
issueRef: "#62"
|
issueRef: "#62"
|
||||||
version: 1
|
version: 2
|
||||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
updatedAt: 1784095187734
|
updatedAt: 1784406936659
|
||||||
---
|
---
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
id: "dd897d74-4e88-4d34-a6fb-d7e7f330094f"
|
id: "dd897d74-4e88-4d34-a6fb-d7e7f330094f"
|
||||||
number: 62
|
number: 62
|
||||||
title: "[Sécurité/Cohérence] Identité requester explicite pour les sessions structurées + policy des tools OpenAI-compatible"
|
title: "[Sécurité/Cohérence] Identité requester explicite pour les sessions structurées + policy des tools OpenAI-compatible"
|
||||||
status: "open"
|
status: "closed"
|
||||||
priority: "medium"
|
priority: "medium"
|
||||||
sprint: null
|
sprint: null
|
||||||
links: [{"target":"#60","kind":"relatesTo"}]
|
links: [{"target":"#60","kind":"relatesTo"}]
|
||||||
@ -10,8 +10,8 @@ agentRefs: []
|
|||||||
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
createdAt: 1784095187734
|
createdAt: 1784095187734
|
||||||
updatedAt: 1784095187734
|
updatedAt: 1784406936659
|
||||||
version: 1
|
version: 2
|
||||||
---
|
---
|
||||||
Sorti de #60 (assistant IA de ticket muet) — volet distinct, touchant un port figé et la sécurité des tool calls.
|
Sorti de #60 (assistant IA de ticket muet) — volet distinct, touchant un port figé et la sécurité des tool calls.
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
---
|
---
|
||||||
issueRef: "#69"
|
issueRef: "#69"
|
||||||
version: 11
|
version: 12
|
||||||
updatedBy: {"kind":"agent","agent_id":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"}
|
updatedBy: {"kind":"user"}
|
||||||
updatedAt: 1784207736267
|
updatedAt: 1784449061397
|
||||||
---
|
---
|
||||||
# Ticket #69 — Adaptibilité client téléphone (carnet de chantier)
|
# Ticket #69 — Adaptibilité client téléphone (carnet de chantier)
|
||||||
|
|
||||||
|
|||||||
@ -2,15 +2,15 @@
|
|||||||
id: "7f5a40cf-837f-46f7-8c5f-653c80975396"
|
id: "7f5a40cf-837f-46f7-8c5f-653c80975396"
|
||||||
number: 69
|
number: 69
|
||||||
title: "Adaptibilité client téléphone"
|
title: "Adaptibilité client téléphone"
|
||||||
status: "qa"
|
status: "closed"
|
||||||
priority: "medium"
|
priority: "medium"
|
||||||
sprint: "028179b1-eaf4-41e9-9c1f-7c37125117e6"
|
sprint: "028179b1-eaf4-41e9-9c1f-7c37125117e6"
|
||||||
links: []
|
links: []
|
||||||
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
||||||
createdBy: {"kind":"user"}
|
createdBy: {"kind":"user"}
|
||||||
updatedBy: {"kind":"agent","agent_id":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"}
|
updatedBy: {"kind":"user"}
|
||||||
createdAt: 1784193391258
|
createdAt: 1784193391258
|
||||||
updatedAt: 1784207736267
|
updatedAt: 1784449061397
|
||||||
version: 11
|
version: 12
|
||||||
---
|
---
|
||||||
J'aimerais un format vertical qui rende l'app client compatible téléphone
|
J'aimerais un format vertical qui rende l'app client compatible téléphone
|
||||||
@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
issueRef: "#75"
|
issueRef: "#75"
|
||||||
version: 2
|
version: 3
|
||||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
updatedBy: {"kind":"user"}
|
||||||
updatedAt: 1784277530792
|
updatedAt: 1784449082160
|
||||||
---
|
---
|
||||||
|
|||||||
@ -2,16 +2,16 @@
|
|||||||
id: "d54a1f20-03e6-4925-982b-923a37331749"
|
id: "d54a1f20-03e6-4925-982b-923a37331749"
|
||||||
number: 75
|
number: 75
|
||||||
title: "Écran d'appairage web : clavier numérique sur mobile alors que le code contient des lettres"
|
title: "Écran d'appairage web : clavier numérique sur mobile alors que le code contient des lettres"
|
||||||
status: "qa"
|
status: "closed"
|
||||||
priority: "medium"
|
priority: "medium"
|
||||||
sprint: null
|
sprint: null
|
||||||
links: [{"target":"#69","kind":"relatesTo"},{"target":"#74","kind":"relatesTo"}]
|
links: [{"target":"#69","kind":"relatesTo"},{"target":"#74","kind":"relatesTo"}]
|
||||||
agentRefs: []
|
agentRefs: []
|
||||||
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
updatedBy: {"kind":"user"}
|
||||||
createdAt: 1784277353320
|
createdAt: 1784277353320
|
||||||
updatedAt: 1784277530792
|
updatedAt: 1784449082160
|
||||||
version: 2
|
version: 3
|
||||||
---
|
---
|
||||||
## Symptôme (constaté live, téléphone)
|
## Symptôme (constaté live, téléphone)
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
issueRef: "#76"
|
issueRef: "#76"
|
||||||
version: 1
|
version: 2
|
||||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
updatedAt: 1784277540034
|
updatedAt: 1784287679986
|
||||||
---
|
---
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
id: "c91f547f-96f0-4c9c-9fde-dbe7d2772dbc"
|
id: "c91f547f-96f0-4c9c-9fde-dbe7d2772dbc"
|
||||||
number: 76
|
number: 76
|
||||||
title: "Appairage : la comparaison du code est sensible à la casse côté serveur"
|
title: "Appairage : la comparaison du code est sensible à la casse côté serveur"
|
||||||
status: "open"
|
status: "closed"
|
||||||
priority: "low"
|
priority: "low"
|
||||||
sprint: null
|
sprint: null
|
||||||
links: [{"target":"#75","kind":"relatesTo"}]
|
links: [{"target":"#75","kind":"relatesTo"}]
|
||||||
@ -10,8 +10,8 @@ agentRefs: []
|
|||||||
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
createdAt: 1784277540034
|
createdAt: 1784277540034
|
||||||
updatedAt: 1784277540034
|
updatedAt: 1784287679986
|
||||||
version: 1
|
version: 2
|
||||||
---
|
---
|
||||||
## Constat
|
## Constat
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
---
|
---
|
||||||
issueRef: "#77"
|
issueRef: "#77"
|
||||||
version: 4
|
version: 5
|
||||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
updatedBy: {"kind":"user"}
|
||||||
updatedAt: 1784287453378
|
updatedAt: 1784449075576
|
||||||
---
|
---
|
||||||
# Carnet #77 — cadrage UX + Architecture (2026-07-17)
|
# Carnet #77 — cadrage UX + Architecture (2026-07-17)
|
||||||
|
|
||||||
|
|||||||
@ -2,16 +2,16 @@
|
|||||||
id: "eecf77ee-dfcb-436c-aa5f-0c7033c7bdfa"
|
id: "eecf77ee-dfcb-436c-aa5f-0c7033c7bdfa"
|
||||||
number: 77
|
number: 77
|
||||||
title: "Appairage : appareils enregistrés persistants, révocables, et code éphémère à usage unique"
|
title: "Appairage : appareils enregistrés persistants, révocables, et code éphémère à usage unique"
|
||||||
status: "qa"
|
status: "closed"
|
||||||
priority: "high"
|
priority: "high"
|
||||||
sprint: null
|
sprint: null
|
||||||
links: [{"target":"#76","kind":"relatesTo"},{"target":"#75","kind":"relatesTo"},{"target":"#68","kind":"relatesTo"}]
|
links: [{"target":"#76","kind":"relatesTo"},{"target":"#75","kind":"relatesTo"},{"target":"#68","kind":"relatesTo"}]
|
||||||
agentRefs: []
|
agentRefs: []
|
||||||
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
updatedBy: {"kind":"user"}
|
||||||
createdAt: 1784279214815
|
createdAt: 1784279214815
|
||||||
updatedAt: 1784287453378
|
updatedAt: 1784449075576
|
||||||
version: 4
|
version: 5
|
||||||
---
|
---
|
||||||
## Besoin utilisateur
|
## Besoin utilisateur
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,70 @@
|
|||||||
---
|
---
|
||||||
issueRef: "#78"
|
issueRef: "#78"
|
||||||
version: 1
|
version: 3
|
||||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
updatedAt: 1784284746767
|
updatedAt: 1784379475877
|
||||||
---
|
---
|
||||||
|
## Décision UX — langue de l'UI IdeA
|
||||||
|
|
||||||
|
Règle tranchée : l'UI humaine d'IdeA est en français par défaut, de façon uniforme sur toute une surface. Une même surface ne mélange pas des libellés français et anglais pour des éléments de navigation, titres, actions, états ou textes d'aide.
|
||||||
|
|
||||||
|
Exceptions admises : noms propres de produits/protocoles (`IdeA`, `OpenAI`, `OpenCode`, `Claude Code`, `HTTPS`, `LAN`, `URL`, `IP/CIDR`, `API`, `CLI`, etc.), chemins techniques, commandes, variables, valeurs de configuration et termes backend affichés comme données. Ces éléments restent dans leur forme technique si la traduction réduit la reconnaissance ou la précision.
|
||||||
|
|
||||||
|
Conséquence : pas d'i18n à introduire pour ce ticket. #78 est un correctif frontend pur d'alignement rédactionnel de la surface Settings desktop vers le français. L'i18n multi-langue serait un chantier produit séparé, non requis ici.
|
||||||
|
|
||||||
|
## Libellés à renommer pour #78
|
||||||
|
|
||||||
|
Périmètre minimal attendu : la surface desktop `Settings` et ses sections actuellement visibles.
|
||||||
|
|
||||||
|
- `Settings` → `Paramètres` quand le mot est affiché à l'utilisateur dans le menu, la surface ou les renvois internes.
|
||||||
|
- `Close Settings` → `Fermer les paramètres`.
|
||||||
|
- `AI Profiles` → `Profils IA` dans la navigation de sections et comme titre de panneau.
|
||||||
|
- `Configure profiles` → `Configurer les profils`.
|
||||||
|
- `No profiles configured.` → `Aucun profil configuré.`.
|
||||||
|
- `Delete` → `Supprimer` pour les actions de suppression de profil.
|
||||||
|
- `delete {name}` → `supprimer {name}` pour le libellé accessible correspondant.
|
||||||
|
- `Deployment` → `Déploiement` dans la navigation et comme titre de panneau.
|
||||||
|
- `deployment settings` → `paramètres de déploiement` pour les libellés accessibles.
|
||||||
|
- `Loading…` → `Chargement…`.
|
||||||
|
- `Server` → `Serveur`.
|
||||||
|
- `Start` → `Démarrer`.
|
||||||
|
- `Stop` → `Arrêter`.
|
||||||
|
- `Stopped` → `Arrêté`.
|
||||||
|
- `Starting…` → `Démarrage…`.
|
||||||
|
- `Running` → `En cours d'exécution`.
|
||||||
|
- `Stopping…` → `Arrêt…`.
|
||||||
|
- `Failed` → `Échec`.
|
||||||
|
- `Exposure` → `Exposition réseau` ou `Accès réseau` ; préférence UX : `Accès réseau`, plus compréhensible pour l'utilisateur.
|
||||||
|
- `exposure mode` → `mode d'accès réseau`.
|
||||||
|
- `This computer only` → `Cet ordinateur uniquement`.
|
||||||
|
- `For using IdeA on this desktop only. Remote devices cannot connect.` → `Pour utiliser IdeA uniquement sur cet ordinateur. Les appareils distants ne peuvent pas se connecter.`
|
||||||
|
- `Remote access, proxy on this computer` → `Accès distant, proxy sur cet ordinateur`.
|
||||||
|
- `Use this when your HTTPS proxy runs on the same machine as IdeA Desktop.` → `À utiliser quand le proxy HTTPS tourne sur la même machine qu'IdeA Desktop.`
|
||||||
|
- `Remote access, proxy on another machine` → `Accès distant, proxy sur une autre machine`.
|
||||||
|
- `Use this when the HTTPS proxy runs on another machine. IdeA will only accept traffic from that proxy.` → `À utiliser quand le proxy HTTPS tourne sur une autre machine. IdeA n'acceptera que le trafic provenant de ce proxy.`
|
||||||
|
- `Public origin` → `Origine publique`.
|
||||||
|
- `Example: https://idea.example.com` → `Exemple : https://idea.example.com`.
|
||||||
|
- `LAN address to bind` → `Adresse LAN d'écoute`.
|
||||||
|
- `The address on this machine that the proxy will connect to.` → `L'adresse de cette machine à laquelle le proxy se connectera.`
|
||||||
|
- `Select an address…` → `Sélectionner une adresse…`.
|
||||||
|
- `Authorized proxy IP/CIDR` → `IP/CIDR du proxy autorisé`.
|
||||||
|
- `This is not where IdeA listens. It is the machine allowed to contact IdeA.` → `Ce n'est pas l'adresse d'écoute d'IdeA. C'est la machine autorisée à contacter IdeA.`
|
||||||
|
- `If your proxy is not on this computer, choose this mode. Otherwise the proxy may time out without an IdeA error.` → `Si votre proxy n'est pas sur cet ordinateur, choisissez ce mode. Sinon, le proxy peut expirer sans erreur IdeA.`
|
||||||
|
- `Proxy setup` → `Configuration du proxy`.
|
||||||
|
- `Point your HTTPS reverse proxy at this upstream.` → `Faites pointer votre proxy inverse HTTPS vers cet upstream.`
|
||||||
|
- `copy upstream url` → `copier l'URL upstream`.
|
||||||
|
- `Copy` → `Copier`.
|
||||||
|
- `Copied` → `Copié`.
|
||||||
|
- `Complete the settings above to get the upstream URL.` → `Complétez les paramètres ci-dessus pour obtenir l'URL upstream.`
|
||||||
|
- `Pairing` → `Appairage`.
|
||||||
|
- `Pairing is managed in Settings → Appareils, where you can generate a code and revoke devices.` → `L'appairage est géré dans Paramètres → Appareils, où vous pouvez générer un code et révoquer des appareils.`
|
||||||
|
|
||||||
|
Libellés déjà conformes : `Appareils`, `Appairer`, `Code d'appairage`, `Chargement des appareils…`, `Aucun appareil appairé.`, `Révoquer...` restent en français.
|
||||||
|
|
||||||
|
## Critères d'acceptation UX
|
||||||
|
|
||||||
|
- La navigation des paramètres affiche `Profils IA`, `Déploiement`, `Appareils` sans mélange anglais/français.
|
||||||
|
- Les titres, boutons, états et textes d'aide visibles dans ces sections sont en français, hors exceptions techniques listées plus haut.
|
||||||
|
- Les libellés accessibles suivent la même langue que le libellé visible correspondant.
|
||||||
|
- Aucun mécanisme i18n n'est introduit pour #78 ; il s'agit uniquement de remplacer les chaînes frontend existantes.
|
||||||
|
- Les termes `URL`, `LAN`, `IP/CIDR`, `HTTPS` et `upstream` peuvent rester tels quels car ils désignent des objets techniques reconnus par la cible utilisateur.
|
||||||
@ -2,7 +2,7 @@
|
|||||||
id: "8d8bc65a-50f6-4fab-bb02-486b6145205f"
|
id: "8d8bc65a-50f6-4fab-bb02-486b6145205f"
|
||||||
number: 78
|
number: 78
|
||||||
title: "Settings desktop : sections en langues mélangées (« Appareils » à côté de « AI Profiles », « Deployment »)"
|
title: "Settings desktop : sections en langues mélangées (« Appareils » à côté de « AI Profiles », « Deployment »)"
|
||||||
status: "open"
|
status: "closed"
|
||||||
priority: "low"
|
priority: "low"
|
||||||
sprint: null
|
sprint: null
|
||||||
links: [{"target":"#77","kind":"relatesTo"}]
|
links: [{"target":"#77","kind":"relatesTo"}]
|
||||||
@ -10,8 +10,8 @@ agentRefs: []
|
|||||||
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
createdAt: 1784284746767
|
createdAt: 1784284746767
|
||||||
updatedAt: 1784284746767
|
updatedAt: 1784379475877
|
||||||
version: 1
|
version: 3
|
||||||
---
|
---
|
||||||
## Constat
|
## Constat
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
issueRef: "#79"
|
issueRef: "#79"
|
||||||
version: 1
|
version: 2
|
||||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
updatedAt: 1784287450082
|
updatedAt: 1784379476483
|
||||||
---
|
---
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
id: "05a70dc5-fd28-4c58-8ba1-be6058ae3cfc"
|
id: "05a70dc5-fd28-4c58-8ba1-be6058ae3cfc"
|
||||||
number: 79
|
number: 79
|
||||||
title: "Test flaky : PermissionsPanel « saves project defaults » échoue par intermittence"
|
title: "Test flaky : PermissionsPanel « saves project defaults » échoue par intermittence"
|
||||||
status: "open"
|
status: "closed"
|
||||||
priority: "low"
|
priority: "low"
|
||||||
sprint: null
|
sprint: null
|
||||||
links: []
|
links: []
|
||||||
@ -10,8 +10,8 @@ agentRefs: []
|
|||||||
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
createdAt: 1784287450082
|
createdAt: 1784287450082
|
||||||
updatedAt: 1784287450082
|
updatedAt: 1784379476483
|
||||||
version: 1
|
version: 2
|
||||||
---
|
---
|
||||||
## Constat
|
## Constat
|
||||||
|
|
||||||
|
|||||||
6
.ideai/tickets/80/carnet.md
Normal file
6
.ideai/tickets/80/carnet.md
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
issueRef: "#80"
|
||||||
|
version: 1
|
||||||
|
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
|
updatedAt: 1784287697560
|
||||||
|
---
|
||||||
56
.ideai/tickets/80/issue.md
Normal file
56
.ideai/tickets/80/issue.md
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
id: "f36ada98-0ca1-41e6-94c1-24eb81731fee"
|
||||||
|
number: 80
|
||||||
|
title: "L'environnement de QA ne peut pas ouvrir de socket — il ne peut pas valider les features réseau"
|
||||||
|
status: "open"
|
||||||
|
priority: "medium"
|
||||||
|
sprint: null
|
||||||
|
links: [{"target":"#77","kind":"relatesTo"}]
|
||||||
|
agentRefs: []
|
||||||
|
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
|
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
|
createdAt: 1784287697560
|
||||||
|
updatedAt: 1784287697560
|
||||||
|
version: 1
|
||||||
|
---
|
||||||
|
## Constat
|
||||||
|
|
||||||
|
Relevé pendant #77, confirmé indépendamment par Main et par Git.
|
||||||
|
|
||||||
|
L'agent QA exécute ses tests dans un environnement dont le sandbox **interdit les binds réseau** :
|
||||||
|
|
||||||
|
```text
|
||||||
|
failed to bind 127.0.0.1:0: Operation not permitted (os error 1)
|
||||||
|
bind: Os { code: 1, kind: PermissionDenied, message: "Operation not permitted" }
|
||||||
|
Failed building the Runtime: Os { code: 24, kind: Uncategorized, message: "Too many open files" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Sur #77, QA a rapporté :
|
||||||
|
|
||||||
|
- `cargo test -p web-server` → 73 passed / **6 failed** (2 binds + 4 tests WebSocket en cascade),
|
||||||
|
- `cargo test -p domain -p application -p infrastructure -p web-server` → 275 passed / **10 failed** (tous `infrastructure::session::openai_compat`).
|
||||||
|
|
||||||
|
**Les mêmes suites passent intégralement hors de ce sandbox** : 79/79 sur `web-server`, exit 0 sur la commande combinée (vérifié par Main, puis rejoué par Git dans son propre environnement). DevBackend observe le même artefact de son côté.
|
||||||
|
|
||||||
|
## Pourquoi c'est un problème et pas une curiosité
|
||||||
|
|
||||||
|
Le cœur de #77 était précisément **la révocation de WebSockets vivants** — un durcissement de sécurité non négociable. QA ne pouvait structurellement pas le valider de bout en bout : l'environnement qui doit prouver que la feature marche est celui qui ne peut pas l'exécuter.
|
||||||
|
|
||||||
|
Le coût réel se paie deux fois :
|
||||||
|
|
||||||
|
1. **Faux rouges** : QA a bloqué son verdict sur des échecs qui n'existaient pas, et il a fallu du temps à Main pour établir que c'était l'environnement. C'était le bon réflexe de sa part — le problème n'est pas QA, c'est son bac à sable.
|
||||||
|
2. **Faux verts potentiels**, plus grave : un test réseau qui ne s'exécute jamais chez QA ne peut pas y échouer non plus. La prochaine feature réseau rejouera la scène, et rien ne garantit qu'on la relira aussi attentivement.
|
||||||
|
|
||||||
|
## Attendu
|
||||||
|
|
||||||
|
Que l'environnement de QA puisse ouvrir des sockets sur la loopback, ou à défaut que la limitation soit **explicite et connue** (QA sait ce qu'il ne peut pas valider, et le dit dans son verdict au lieu de le rapporter en échec).
|
||||||
|
|
||||||
|
À regarder aussi : le `Too many open files` (`code: 24`), qui suggère une limite de descripteurs de fichiers trop basse en plus de la restriction réseau.
|
||||||
|
|
||||||
|
## Note
|
||||||
|
|
||||||
|
Voir la mémoire `permissions-sandbox-system-state` pour l'état du système de permissions/sandbox et le risque résiduel déjà documenté.
|
||||||
|
|
||||||
|
## Périmètre
|
||||||
|
|
||||||
|
Infrastructure d'agents / configuration de sandbox. Pas de code applicatif.
|
||||||
223
.ideai/tickets/81/carnet.md
Normal file
223
.ideai/tickets/81/carnet.md
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
---
|
||||||
|
issueRef: "#81"
|
||||||
|
version: 5
|
||||||
|
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
|
updatedAt: 1784565918809
|
||||||
|
---
|
||||||
|
# Cadrage — ticket #81
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Permettre aux agents IdeA d'administrer les templates d'agents via MCP, sans surface humaine nouvelle : créer, éditer et supprimer des templates globaux IdeA depuis des tools `idea_*`.
|
||||||
|
|
||||||
|
UX n'est pas concerné pour ce ticket : il n'y a pas d'écran, de workflow humain ni de libellés UI à concevoir. La seule surface est le catalogue MCP exposé aux agents.
|
||||||
|
|
||||||
|
## État réel du système templates
|
||||||
|
|
||||||
|
Surfaces inspectées :
|
||||||
|
|
||||||
|
- Domaine : `crates/domain/src/template.rs`
|
||||||
|
- Port : `TemplateStore` dans `crates/domain/src/ports.rs`
|
||||||
|
- Use cases : `crates/application/src/template/usecases.rs`
|
||||||
|
- Store : `crates/infrastructure/src/store/template.rs`
|
||||||
|
- Commands Tauri : `crates/app-tauri/src/commands.rs`
|
||||||
|
- DTO : `crates/backend/src/dto.rs` / `crates/app-tauri/src/dto.rs`
|
||||||
|
- Frontend existant : `frontend/src/features/templates/*`, `frontend/src/adapters/template.ts`
|
||||||
|
- MCP + permissions #82 : `crates/infrastructure/src/orchestrator/mcp/tools.rs`, `server.rs`, `backend/src/openai_tools.rs`, `app-tauri/src/openai_tools.rs`
|
||||||
|
|
||||||
|
Templates existants :
|
||||||
|
|
||||||
|
```text
|
||||||
|
AgentTemplate {
|
||||||
|
id: TemplateId,
|
||||||
|
name: String,
|
||||||
|
content_md: MarkdownDoc,
|
||||||
|
version: TemplateVersion,
|
||||||
|
default_profile_id: ProfileId,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Invariants existants :
|
||||||
|
|
||||||
|
- `name` non vide ;
|
||||||
|
- `version` démarre à `1` ;
|
||||||
|
- `version` est bumpée par `AgentTemplate::with_updated_content`, donc par changement de contenu Markdown ;
|
||||||
|
- `default_profile_id` sert aux futurs agents créés depuis le template.
|
||||||
|
|
||||||
|
Stockage existant : global app-data, pas projet :
|
||||||
|
|
||||||
|
```text
|
||||||
|
<app_data_dir>/templates/
|
||||||
|
├── index.json
|
||||||
|
└── md/<template-id>.md
|
||||||
|
```
|
||||||
|
|
||||||
|
`index.json` porte les métadonnées (`id`, `name`, `version`, `contentHash`, `defaultProfileId`) ; le Markdown vit dans `md/<id>.md`. Le store est déjà derrière `TemplateStore`, donc Tauri-agnostique côté infra.
|
||||||
|
|
||||||
|
## Synchronisation template → agents
|
||||||
|
|
||||||
|
Les agents créés depuis un template copient le `content_md` dans leur propre contexte `.md` projet et gardent dans le manifeste :
|
||||||
|
|
||||||
|
- `template_id`
|
||||||
|
- `synchronized`
|
||||||
|
- `synced_template_version`
|
||||||
|
|
||||||
|
`UpdateTemplate` actuel met à jour le template global, bump la version et publie `DomainEvent::TemplateUpdated`. Il ne modifie pas directement les agents existants.
|
||||||
|
|
||||||
|
`DetectAgentDrift` compare `template.version > synced_template_version` pour les agents `synchronized == true` et publie `AgentDriftDetected`.
|
||||||
|
|
||||||
|
`SyncAgentWithTemplate` est l'opération explicite qui remplace le `.md` de l'agent synchronisé par le contenu courant du template et met à jour `synced_template_version`.
|
||||||
|
|
||||||
|
Conclusion : si un agent édite un template via MCP, les agents synchronisés qui l'utilisent ne sont pas impactés immédiatement. Ils deviennent en drift jusqu'à appel explicite de sync. Au lancement suivant, ils relisent leur `.md` agent existant, pas automatiquement le template global. Ce comportement doit rester tel quel pour #81, sauf arbitrage produit séparé.
|
||||||
|
|
||||||
|
## Tools MCP à ajouter
|
||||||
|
|
||||||
|
Ajouter une surface templates explicite ; `idea_skill_read`, `idea_context_read` et les tools existants ne couvrent pas les templates. Les templates ne sont ni des skills ni des contextes agent.
|
||||||
|
|
||||||
|
Proposition de catalogue :
|
||||||
|
|
||||||
|
### Lecture, autorisée par défaut (#82)
|
||||||
|
|
||||||
|
- `idea_template_list`
|
||||||
|
- Rôle : lister les templates globaux disponibles.
|
||||||
|
- Payload conseillé : templates complets ou résumés. Pour l'ergonomie agent, retour complet acceptable au premier lot (`id`, `name`, `contentMd`, `version`, `defaultProfileId`) car `ListTemplates` renvoie déjà les entités complètes.
|
||||||
|
|
||||||
|
- `idea_template_read`
|
||||||
|
- Rôle : lire un template par id.
|
||||||
|
- Input : `{ "templateId": "..." }`
|
||||||
|
- Retour : même DTO qu'un template Tauri.
|
||||||
|
- Nécessite un thin use case `ReadTemplate` ou un provider qui appelle `TemplateStore::get` via un use case applicatif dédié.
|
||||||
|
|
||||||
|
### Écriture/action, refusée par défaut (#82)
|
||||||
|
|
||||||
|
- `idea_template_create`
|
||||||
|
- Input : `{ name, content, defaultProfileId }`
|
||||||
|
- Réutilise `CreateTemplate`.
|
||||||
|
- Retour : template créé.
|
||||||
|
|
||||||
|
- `idea_template_update`
|
||||||
|
- Input minimal aligné existant : `{ templateId, content }`.
|
||||||
|
- Réutilise `UpdateTemplate` actuel, qui met à jour le contenu et bump la version.
|
||||||
|
- Extension recommandée si on veut couvrir pleinement "éditer" : accepter aussi `name?` et `defaultProfileId?`, avec bump de version seulement si `content` change. Cela nécessite d'étendre le domaine/use case car aujourd'hui l'UI elle-même ne persiste pas le changement de nom/profil en mode edit.
|
||||||
|
- Option de sûreté à arbitrer : ajouter `expectedVersion` pour éviter les écrasements concurrents entre agents. Le store/use case Tauri actuel n'a pas d'optimistic concurrency sur templates, donc ce serait une extension de contrat, pas une simple exposition MCP.
|
||||||
|
|
||||||
|
- `idea_template_delete`
|
||||||
|
- Input : `{ templateId }`
|
||||||
|
- Réutilise `DeleteTemplate`.
|
||||||
|
- Effet existant : supprime de l'index global ; le fichier Markdown orphelin peut rester sur disque car le port FS n'a pas de delete. Les agents créés depuis ce template gardent leur `.md`; drift detection ignore le template absent.
|
||||||
|
|
||||||
|
## Intégration obligatoire avec #82
|
||||||
|
|
||||||
|
#82 a introduit la classification canonique dans `crates/infrastructure/src/orchestrator/mcp/tools.rs` :
|
||||||
|
|
||||||
|
- `READ_ONLY_TOOLS`
|
||||||
|
- `WRITE_ACTION_TOOLS`
|
||||||
|
- `tool_access`
|
||||||
|
- test garde-fou `catalogue_tools_have_explicit_read_or_write_access`
|
||||||
|
|
||||||
|
Tout tool ajouté au catalogue doit être classé immédiatement, sinon le test doit échouer.
|
||||||
|
|
||||||
|
Classification #81 attendue :
|
||||||
|
|
||||||
|
```text
|
||||||
|
READ_ONLY_TOOLS += [
|
||||||
|
"idea_template_list",
|
||||||
|
"idea_template_read",
|
||||||
|
]
|
||||||
|
|
||||||
|
WRITE_ACTION_TOOLS += [
|
||||||
|
"idea_template_create",
|
||||||
|
"idea_template_update",
|
||||||
|
"idea_template_delete",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Comportement policy attendu :
|
||||||
|
|
||||||
|
- agent sans override #82 : peut lire/lister les templates, ne peut pas créer/éditer/supprimer ;
|
||||||
|
- agent avec override incluant un ou plusieurs tools `idea_template_*` d'écriture : peut seulement appeler ceux explicitement autorisés ;
|
||||||
|
- le refus doit arriver avant effet applicatif, sur le serveur MCP stdio et sur l'invoker OpenAI-compatible.
|
||||||
|
|
||||||
|
Ce comportement est cohérent avec la demande #82 : par défaut seuls les tools de lecture sont autorisés. Pas d'arbitrage produit nécessaire sauf si l'utilisateur veut que certains agents aient une permission template-write préconfigurée par défaut.
|
||||||
|
|
||||||
|
## Point d'implémentation recommandé
|
||||||
|
|
||||||
|
Ne pas ajouter ces opérations au `OrchestratorCommand` sauf nécessité. Les templates sont une famille CRUD applicative, comme les tickets, pas un protocole d'orchestration inter-agent. Le pattern le plus local est donc de créer un provider MCP dédié, parallèle à `TicketToolProvider` :
|
||||||
|
|
||||||
|
- `crates/infrastructure/src/orchestrator/mcp/templates.rs`
|
||||||
|
- `TemplateToolProvider`
|
||||||
|
- `TemplateToolError`
|
||||||
|
- `is_template_tool(name)`
|
||||||
|
- `catalogue()` des tools templates
|
||||||
|
|
||||||
|
- `crates/infrastructure/src/orchestrator/mcp/tools.rs`
|
||||||
|
- étendre `catalogue()` avec `templates::catalogue()` ;
|
||||||
|
- ajouter `is_template_tool` ;
|
||||||
|
- ajouter les 5 tools dans les classifications #82 ;
|
||||||
|
- si besoin, faire retourner `tool_returns_reply` pour les read/create/update/list/delete selon convention (delete peut retourner un ACK JSON).
|
||||||
|
|
||||||
|
- `crates/infrastructure/src/orchestrator/mcp/server.rs`
|
||||||
|
- ajouter `template_tools: Option<Arc<dyn TemplateToolProvider>>` ;
|
||||||
|
- dans `tools_call`, après enforcement #82 et avant `map_tool_call`, router `is_template_tool` vers le provider, comme les tickets ;
|
||||||
|
- garder l'enforcement durable/éphémère avant provider.
|
||||||
|
|
||||||
|
- Composition root backend/app-tauri
|
||||||
|
- créer `LateBoundTemplateToolProvider` si besoin pour casser les cycles comme `LateBoundTicketToolProvider` ;
|
||||||
|
- binder un `AppTemplateToolProvider` construit avec `create_template`, `list_templates`, `update_template`, `delete_template` et le nouveau `read_template` si ajouté ;
|
||||||
|
- injecter ce provider dans `McpServer::new(...).with_template_tools(...)` ;
|
||||||
|
- injecter aussi dans `AppOpenAiToolInvoker` pour parité OpenAI-compatible.
|
||||||
|
|
||||||
|
Alternative possible : ajouter des variants `OrchestratorCommand::Template*` et mapper les tools via `map_tool_call`. Je ne la recommande pas en premier choix : cela gonfle l'orchestrateur avec un CRUD global qui n'est pas une coordination agent-agent, alors que le précédent ticket système a déjà accepté le pattern provider pour les tickets.
|
||||||
|
|
||||||
|
## Lots proposés
|
||||||
|
|
||||||
|
### Lot B1 — Catalogue MCP + classification #82 + provider squelette
|
||||||
|
|
||||||
|
- Ajouter `templates.rs` côté MCP infra.
|
||||||
|
- Ajouter les 5 tool defs.
|
||||||
|
- Classer immédiatement `idea_template_list/read` en lecture et `create/update/delete` en écriture/action.
|
||||||
|
- Tests : garde-fou catalogue/classification vert ; `tools/list` expose les templates selon policy #82.
|
||||||
|
|
||||||
|
### Lot B2 — Use cases/DTO/provider templates
|
||||||
|
|
||||||
|
- Ajouter `ReadTemplate` use case fin si on garde `idea_template_read`.
|
||||||
|
- Implémenter `AppTemplateToolProvider` avec les use cases existants.
|
||||||
|
- Mapper erreurs : `notFound`, `invalid`, `store`, `internal`.
|
||||||
|
- Tests provider : create/list/read/update/delete sur fakes, version bump sur update contenu.
|
||||||
|
|
||||||
|
### Lot B3 — Enforcement policy + OpenAI-compatible parity
|
||||||
|
|
||||||
|
- Vérifier MCP stdio : un agent default read-only peut `idea_template_list/read`, mais `idea_template_create/update/delete` est refusé avant provider.
|
||||||
|
- Vérifier override #82 : autoriser seulement `idea_template_update` n'autorise pas create/delete.
|
||||||
|
- Répliquer le dispatch provider dans `AppOpenAiToolInvoker`, avec même policy durable avant effet.
|
||||||
|
|
||||||
|
### Lot B4 — Édition complète optionnelle
|
||||||
|
|
||||||
|
À faire seulement si le produit veut que "éditer" couvre autre chose que le contenu Markdown :
|
||||||
|
|
||||||
|
- étendre `UpdateTemplateInput` avec `name?`, `defaultProfileId?`, `content?` ;
|
||||||
|
- préserver l'invariant : bump version uniquement quand `content_md` change ;
|
||||||
|
- décider si changement de `defaultProfileId` doit avoir un effet sur agents existants (recommandation : non, seulement futurs `CreateAgentFromTemplate`) ;
|
||||||
|
- ajouter éventuellement `expectedVersion` pour update/delete, avec erreur de conflit.
|
||||||
|
|
||||||
|
## Frontières
|
||||||
|
|
||||||
|
Frontend/UX : hors périmètre. Aucune surface humaine nouvelle.
|
||||||
|
|
||||||
|
Domaine templates : réutiliser `AgentTemplate`, `TemplateVersion`, `TemplateStore`. Extension domaine seulement si B4 est retenu.
|
||||||
|
|
||||||
|
Projet `.ideai/agents.json` : hors périmètre pour CRUD templates. Les agents créés depuis template et leur sync existante restent inchangés.
|
||||||
|
|
||||||
|
Synchronisation automatique : hors périmètre. #81 ne doit pas auto-écraser les `.md` des agents synchronisés après update template ; laisser `DetectAgentDrift`/`SyncAgentWithTemplate` piloter cela.
|
||||||
|
|
||||||
|
Permissions #82 : dans le périmètre obligatoire. Aucun tool template ne doit entrer dans le catalogue sans classification explicite et sans enforcement identique MCP stdio/OpenAI-compatible.
|
||||||
|
|
||||||
|
## Critères d'acceptation
|
||||||
|
|
||||||
|
- Les agents voient les tools template dans le catalogue MCP selon leur policy #82.
|
||||||
|
- Les tools lecture template sont accessibles à un agent sans override.
|
||||||
|
- Les tools create/update/delete sont refusés à un agent sans override, avant mutation.
|
||||||
|
- Une policy agent qui autorise un write template permet uniquement ce write.
|
||||||
|
- `idea_template_update` bump la version quand le contenu change et publie `TemplateUpdated` via le use case existant.
|
||||||
|
- Les agents synchronisés au template passent en drift, mais ne sont pas modifiés tant qu'un sync explicite n'est pas demandé.
|
||||||
|
- Les chemins MCP stdio et OpenAI-compatible ont la même sémantique et les mêmes refus.
|
||||||
16
.ideai/tickets/81/issue.md
Normal file
16
.ideai/tickets/81/issue.md
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
id: "e9c936c5-4540-42ab-a482-c64e6f9b9f8c"
|
||||||
|
number: 81
|
||||||
|
title: "MCP d'edition de templates"
|
||||||
|
status: "closed"
|
||||||
|
priority: "medium"
|
||||||
|
sprint: null
|
||||||
|
links: []
|
||||||
|
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
||||||
|
createdBy: {"kind":"user"}
|
||||||
|
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
|
createdAt: 1784296848609
|
||||||
|
updatedAt: 1784565918809
|
||||||
|
version: 5
|
||||||
|
---
|
||||||
|
J'aimerais que les agents aient la possibilité de créer, supprimer et editer des templates d'agent IdeA
|
||||||
383
.ideai/tickets/82/carnet.md
Normal file
383
.ideai/tickets/82/carnet.md
Normal file
@ -0,0 +1,383 @@
|
|||||||
|
---
|
||||||
|
issueRef: "#82"
|
||||||
|
version: 6
|
||||||
|
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
|
updatedAt: 1784410760329
|
||||||
|
---
|
||||||
|
# Cadrage — ticket #82
|
||||||
|
|
||||||
|
## Objectif produit
|
||||||
|
|
||||||
|
Donner à chaque agent IdeA une policy explicite d'utilisation des tools MCP IdeA. Par défaut, un agent ne doit pouvoir appeler que les tools de lecture. L'utilisateur doit ensuite pouvoir accorder ou retirer des permissions agent par agent. La surface d'édition est laissée à UX/frontend et n'est pas dans le lot backend de base.
|
||||||
|
|
||||||
|
## État réel du code
|
||||||
|
|
||||||
|
Surfaces inspectées :
|
||||||
|
|
||||||
|
- `crates/infrastructure/src/orchestrator/mcp/tools.rs` : catalogue MCP actuel, 25 tools exposés.
|
||||||
|
- `crates/infrastructure/src/orchestrator/mcp/server.rs` : `tools/call` applique déjà une policy optionnelle avant dispatch (`tool_policies.get(requester)` puis `enforce_tool_policy`).
|
||||||
|
- `crates/domain/src/agent_tool_policy.rs` : modèle existant `AgentToolPolicy { allow, bound_issue, deny_others }`.
|
||||||
|
- `crates/infrastructure/src/orchestrator/mcp/policy.rs` : `ToolPolicyRegistry` in-memory par requester.
|
||||||
|
- `crates/application/src/ticket_assistant.rs` : l'assistant de ticket pose une allowlist éphémère bornée à un ticket.
|
||||||
|
- `crates/backend/src/openai_tools.rs` : l'invoker OpenAI-compatible expose le même catalogue et dispatch via `OrchestratorService`, mais ne consulte pas la policy avant appel.
|
||||||
|
- `crates/domain/src/permission.rs` + `crates/infrastructure/src/store/permission.rs` : système permissions/sandbox existant, persisté dans `.ideai/permissions.json`, orienté fichiers/commandes/sandbox/projections CLI.
|
||||||
|
|
||||||
|
Conclusion : il existe déjà un point d'application côté MCP stdio, mais pas de policy durable par agent et pas de default-deny pour les tools d'écriture. Le modèle existant est réutilisable comme base conceptuelle, mais il est actuellement trop spécialisé pour les assistants de ticket et stocké en mémoire.
|
||||||
|
|
||||||
|
## Classification lecture / écriture des tools MCP IdeA
|
||||||
|
|
||||||
|
Lecture autorisée par défaut :
|
||||||
|
|
||||||
|
- `idea_list_agents`
|
||||||
|
- `idea_context_read`
|
||||||
|
- `idea_memory_read`
|
||||||
|
- `idea_skill_read`
|
||||||
|
- `idea_workstate_read`
|
||||||
|
- `idea_ticket_read`
|
||||||
|
- `idea_ticket_list`
|
||||||
|
- `idea_ticket_read_carnet`
|
||||||
|
- `idea_sprint_list`
|
||||||
|
|
||||||
|
Écriture / action / exécution à refuser par défaut :
|
||||||
|
|
||||||
|
- `idea_ask_agent` : délégation active, peut lancer/réattacher une cible et produire des effets indirects.
|
||||||
|
- `idea_run_in_background` : exécute une commande et crée une tâche.
|
||||||
|
- `idea_launch_agent` : lance/attache une session agent.
|
||||||
|
- `idea_stop_agent` : tue une session.
|
||||||
|
- `idea_update_context` : écrit le contexte d'un agent.
|
||||||
|
- `idea_context_propose` : écrit/propose du contexte ; avec `target` agent, c'est une écriture directe du `.md` agent.
|
||||||
|
- `idea_memory_write` : écrit la mémoire projet.
|
||||||
|
- `idea_workstate_set` : écrit la ligne live-state du requester.
|
||||||
|
- `idea_create_skill` : crée un skill.
|
||||||
|
- `idea_ticket_create`
|
||||||
|
- `idea_ticket_update`
|
||||||
|
- `idea_ticket_update_status`
|
||||||
|
- `idea_ticket_update_priority`
|
||||||
|
- `idea_ticket_update_carnet`
|
||||||
|
- `idea_ticket_link`
|
||||||
|
- `idea_ticket_unlink`
|
||||||
|
|
||||||
|
Règle de maintenance : la classification doit vivre à côté du catalogue MCP, pas dans l'UI, pour que tout nouveau tool doive choisir explicitement `read` ou `write/action`.
|
||||||
|
|
||||||
|
## Cause racine / besoin architectural
|
||||||
|
|
||||||
|
Aujourd'hui, l'absence de policy pour un requester signifie "autorisé" sur le serveur MCP, parce que l'enforcement ne s'exécute que si `registry.get(requester)` retourne une policy. C'était acceptable pour l'ancien cas ticket-assistant, où la policy éphémère était posée avant ouverture. Ce n'est pas acceptable pour des agents IdeA généraux : l'absence de configuration doit se résoudre en policy par défaut lecture seule.
|
||||||
|
|
||||||
|
Le besoin n'est pas couvert par Landlock/sandbox : Landlock protège le système de fichiers et l'exécution de commandes au niveau OS/PTY/structured process. Les tools MCP IdeA sont des capabilities applicatives internes (`memory`, `context`, `tickets`, `delegation`, `workstate`, etc.) qui doivent être refusées avant dispatch applicatif. Un sandbox peut empêcher certains effets externes, mais ne sait pas qu'un appel `idea_memory_write` ou `idea_ask_agent` est interdit.
|
||||||
|
|
||||||
|
## Modèle de données proposé
|
||||||
|
|
||||||
|
Créer une policy MCP durable par projet, sparse par agent, distincte du modèle `PermissionSet` fichiers/commandes.
|
||||||
|
|
||||||
|
Option recommandée : nouveau document `.ideai/mcp-tool-permissions.json` plutôt qu'étendre `.ideai/permissions.json`.
|
||||||
|
|
||||||
|
Raison : `.ideai/permissions.json` a un sens précis et déjà chargé : permissions de fichiers/commandes + projection CLI + compilation Landlock. Mélanger les tools MCP avec ces capabilities risquerait de rendre confus un modèle qui n'a pas le même point d'application ni la même sémantique de sandbox.
|
||||||
|
|
||||||
|
Schéma conceptuel :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ProjectMcpToolPermissions {
|
||||||
|
version: 1,
|
||||||
|
projectDefault: McpToolPolicy? // absent => default lecture seule canonique
|
||||||
|
agents: Vec<AgentMcpToolPolicyOverride>
|
||||||
|
}
|
||||||
|
|
||||||
|
AgentMcpToolPolicyOverride {
|
||||||
|
agentId: AgentId,
|
||||||
|
policy: McpToolPolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
McpToolPolicy {
|
||||||
|
allowedTools: Vec<String>, // noms exacts du catalogue MCP
|
||||||
|
deniedTools: Vec<String>?, // optionnel si UX veut exprimer un retrait explicite
|
||||||
|
mode: AllowListed | ReadOnlyPlus // à arbitrer avec UX, mais backend peut commencer simple
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Simplification backend acceptable pour un premier lot : stocker directement `allowedTools` par agent, et résoudre ainsi :
|
||||||
|
|
||||||
|
1. si override agent existe, il remplace le défaut projet ;
|
||||||
|
2. sinon si défaut projet existe, l'utiliser ;
|
||||||
|
3. sinon utiliser `READ_ONLY_TOOLS` canonique ;
|
||||||
|
4. refuser tout tool absent de l'allowlist effective.
|
||||||
|
|
||||||
|
Le modèle doit valider les noms contre le catalogue connu ou au minimum rejeter les chaînes vides/dupliquées. Les tools inconnus ne doivent pas devenir des permissions latentes silencieuses.
|
||||||
|
|
||||||
|
## Point d'application de la policy
|
||||||
|
|
||||||
|
Point principal : `McpServer::tools_call`, avant tout dispatch, exactement à l'endroit où l'enforcement éphémère existe déjà aujourd'hui.
|
||||||
|
|
||||||
|
À faire :
|
||||||
|
|
||||||
|
- remplacer/compléter `registry.get(requester)` par une résolution effective : requester handshake -> `AgentId` -> policy durable projet -> fallback lecture seule ;
|
||||||
|
- si le requester est vide/legacy `"mcp"`, appliquer aussi le fallback lecture seule, ou refuser les écritures fail-closed ;
|
||||||
|
- refuser via erreur MCP lisible (`isError`/JsonRpcError cohérent) avant `TicketToolProvider` et avant `OrchestratorService::dispatch` ;
|
||||||
|
- publier éventuellement `OrchestratorRequestProcessed { ok: false }` pour garder une trace UI/diagnostic des refus, sans exécuter le tool.
|
||||||
|
|
||||||
|
Point secondaire obligatoire pour éviter le contournement : `AppOpenAiToolInvoker` doit appliquer la même résolution avant `map_tool_call`/`dispatch`. C'est le chevauchement direct avec #62 : #62 demande déjà la parité OpenAI-compatible + identité requester explicite. #82 dépend fonctionnellement de ce point ; sinon un agent utilisant un profil OpenAI-compatible pourrait contourner la policy MCP stdio.
|
||||||
|
|
||||||
|
## Relation avec #62 et #60
|
||||||
|
|
||||||
|
#60 a fermé le bug de conversation muette et a sorti #62 comme dette sécurité adjacente.
|
||||||
|
|
||||||
|
#62 reste pertinent et doit être traité avant ou dans le premier lot de #82 :
|
||||||
|
|
||||||
|
- passer une identité requester explicite aux sessions structurées ;
|
||||||
|
- `LaunchAgent` doit utiliser l'agent id comme requester, pas une dérivation fragile du run dir ;
|
||||||
|
- l'assistant de ticket doit garder `ticket-assistant:<project>:<issue>` ;
|
||||||
|
- l'invoker OpenAI-compatible doit consulter la même policy que le serveur MCP stdio.
|
||||||
|
|
||||||
|
#82 généralise ensuite la policy à tous les agents déclarés, avec un défaut lecture seule durable. Les policies éphémères du ticket-assistant peuvent rester comme cas spécial plus restrictif/borné à un ticket, mais elles ne doivent pas masquer la policy globale agent si un assistant normal est lancé.
|
||||||
|
|
||||||
|
## Frontières avec le système permissions/sandbox existant
|
||||||
|
|
||||||
|
À ne pas faire dans #82 :
|
||||||
|
|
||||||
|
- ne pas modifier la compilation Landlock ;
|
||||||
|
- ne pas ajouter de `Capability::McpTool` dans le modèle fichiers/commandes sans arbitrage Architecture ;
|
||||||
|
- ne pas projeter cette policy dans les settings Claude/Codex ;
|
||||||
|
- ne pas compter sur les prompts natifs des CLIs pour autoriser/refuser les tools IdeA.
|
||||||
|
|
||||||
|
Le système existant reste responsable de ce que le process agent peut faire au niveau OS. #82 est une policy applicative IdeA, appliquée côté serveur/bridge avant use case.
|
||||||
|
|
||||||
|
## Découpage recommandé
|
||||||
|
|
||||||
|
### Lot B1 — Domaine + catalogue + store durable
|
||||||
|
|
||||||
|
- Ajouter un modèle pur `McpToolPermissionPolicy` / `ProjectMcpToolPermissions` avec fallback lecture seule.
|
||||||
|
- Déplacer la classification read/write dans une source backend canonique proche du catalogue MCP.
|
||||||
|
- Ajouter un port `McpToolPermissionStore` et un store FS sous `.ideai/mcp-tool-permissions.json`.
|
||||||
|
- Tests domaine : fallback lecture seule, override agent, refus tool inconnu, nouveau catalogue sans classification explicite détecté par test.
|
||||||
|
|
||||||
|
### Lot B2 — Enforcement MCP stdio
|
||||||
|
|
||||||
|
- Injecter le resolver/store dans `McpServer` ou dans un service de policy appelé par `tools_call`.
|
||||||
|
- Appliquer la policy avant ticket provider et avant orchestrator dispatch.
|
||||||
|
- Remplacer le comportement "pas de policy => tout passe" par "pas de policy => lecture seule" pour les agents généraux.
|
||||||
|
- Garder la policy ticket-assistant bornée au ticket comme restriction éphémère additionnelle ou cas de requester dédié.
|
||||||
|
- Tests : agent sans override peut `idea_memory_read`/`idea_ticket_list`, mais pas `idea_memory_write`, `idea_ask_agent`, `idea_ticket_update_carnet`, `idea_run_in_background`.
|
||||||
|
|
||||||
|
### Lot B3 — Parité OpenAI-compatible / dépendance #62
|
||||||
|
|
||||||
|
- Faire passer l'identité requester explicite jusqu'à tous les appels tools structurés.
|
||||||
|
- Brancher la même policy resolver dans `AppOpenAiToolInvoker`.
|
||||||
|
- Tests : un profil OpenAI-compatible refusé sur `idea_memory_write` l'est de la même manière que via MCP stdio ; un tool lecture passe.
|
||||||
|
|
||||||
|
### Lot B4 — API backend pour future UI
|
||||||
|
|
||||||
|
- Ajouter des use cases read/update de permissions MCP par agent/projet.
|
||||||
|
- Ajouter DTO/commands Tauri ou endpoints web selon la surface existante.
|
||||||
|
- Ne pas concevoir l'UI ici ; seulement exposer un contrat stable à UX/DevFrontend.
|
||||||
|
|
||||||
|
### Lot UX/F — séparé
|
||||||
|
|
||||||
|
- UX décide la surface de modification agent par agent.
|
||||||
|
- Frontend consomme les APIs B4.
|
||||||
|
|
||||||
|
## Rétrocompatibilité
|
||||||
|
|
||||||
|
Agents déjà déclarés : aucun champ à ajouter dans `agents.json`. En absence de document `.ideai/mcp-tool-permissions.json` ou d'override agent, ils deviennent lecture seule pour les tools MCP IdeA. C'est un changement volontaire demandé par l'utilisateur.
|
||||||
|
|
||||||
|
Attention migration : des workflows existants qui s'appuient sur `idea_ask_agent`, `idea_memory_write`, `idea_context_propose`, `idea_workstate_set` ou `idea_ticket_update_carnet` devront être explicitement autorisés agent par agent après livraison. Pour limiter la casse pendant le développement, prévoir un message de refus clair indiquant le tool refusé et l'agent/requester concerné.
|
||||||
|
|
||||||
|
## Critères d'acceptation backend
|
||||||
|
|
||||||
|
- Un agent sans override ne peut appeler que les tools listés en lecture.
|
||||||
|
- Les tools d'écriture/action sont refusés avant effet applicatif.
|
||||||
|
- Une allowlist agent permet explicitement un tool d'écriture choisi.
|
||||||
|
- Un retrait/absence d'allowlist retire effectivement le droit au prochain appel, sans relancer l'application si possible.
|
||||||
|
- Le serveur MCP stdio et l'invoker OpenAI-compatible appliquent la même décision.
|
||||||
|
- Les permissions filesystem/commandes et le sandbox Landlock restent inchangés.
|
||||||
|
|
||||||
|
## Conception UX/F — surface permissions MCP par agent
|
||||||
|
|
||||||
|
### Décision de placement
|
||||||
|
|
||||||
|
La modification des permissions MCP IdeA vit dans le panneau projet `Permissions`, pas dans `Settings` et pas uniquement dans la fiche d'un agent.
|
||||||
|
|
||||||
|
Raison UX : ce réglage est une matrice de capacités applicatives par agent dans le projet courant. Il doit être consultable et comparable au même endroit que les permissions/sandbox existantes, sans polluer les paramètres globaux desktop (`Settings`) ni cacher un droit critique dans une fiche agent isolée. La fiche/liste d'agent peut afficher un raccourci ou un badge, mais l'édition canonique reste `Permissions`.
|
||||||
|
|
||||||
|
Le panneau `Permissions` devient une surface à deux onglets internes :
|
||||||
|
|
||||||
|
- `Système` : permissions fichiers/commandes/sandbox existantes.
|
||||||
|
- `Tools MCP IdeA` : nouveau réglage #82.
|
||||||
|
|
||||||
|
La colonne de gauche reste le sélecteur de cible : `Défaut projet`, puis les agents. Le panneau de droite change selon l'onglet sélectionné.
|
||||||
|
|
||||||
|
### Layout attendu
|
||||||
|
|
||||||
|
```text
|
||||||
|
Permissions
|
||||||
|
[ Système ] [ Tools MCP IdeA ] [Actualiser]
|
||||||
|
|
||||||
|
┌──────────────────────────────┬──────────────────────────────────────────────┐
|
||||||
|
│ Défaut projet │ Tools MCP IdeA — DevFrontend │
|
||||||
|
│ Lecture seule │ Hérite du défaut projet │
|
||||||
|
│ │ [Utiliser le défaut projet v] │
|
||||||
|
│ Agents │ │
|
||||||
|
│ Main Hérité │ Résumé effectif │
|
||||||
|
│ Architect Override │ 9 lecture autorisés · 2 écriture autorisés │
|
||||||
|
│ DevFrontend Override │ │
|
||||||
|
│ QA Hérité │ Accord rapide │
|
||||||
|
│ Git Hérité │ [ ] Déléguer à un agent │
|
||||||
|
│ │ [x] Modifier les tickets │
|
||||||
|
│ │ [ ] Écrire la mémoire │
|
||||||
|
│ │ │
|
||||||
|
│ │ Détail des tools │
|
||||||
|
│ │ ▾ Lecture, autorisés par défaut (9) │
|
||||||
|
│ │ ✓ idea_ticket_read │
|
||||||
|
│ │ ✓ idea_context_read │
|
||||||
|
│ │ ▾ Écriture et actions (16) │
|
||||||
|
│ │ Tickets │
|
||||||
|
│ │ [x] idea_ticket_update_carnet │
|
||||||
|
│ │ [ ] idea_ticket_update_status │
|
||||||
|
│ │ Agents │
|
||||||
|
│ │ [ ] idea_ask_agent │
|
||||||
|
│ │ [ ] idea_launch_agent │
|
||||||
|
│ │ [Réinitialiser l'override] [Enregistrer] │
|
||||||
|
└──────────────────────────────┴──────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Sur desktop large : deux colonnes comme le panneau permissions actuel, avec la liste des cibles à gauche et l'éditeur à droite. Sur largeur contrainte : la cible sélectionnée reste au-dessus de l'éditeur, puis les groupes de tools s'empilent ; les actions restent en bas du panneau, non flottantes.
|
||||||
|
|
||||||
|
### Modèle mental affiché
|
||||||
|
|
||||||
|
L'utilisateur ne manipule pas une liste brute de 25 cases. Il voit trois niveaux :
|
||||||
|
|
||||||
|
1. Cible : `Défaut projet` ou un agent précis.
|
||||||
|
2. Mode : `Utiliser le défaut projet` ou `Override personnalisé`.
|
||||||
|
3. Capabilités groupées : lecture, tickets, agents, contexte, mémoire, workstate, skills, exécution.
|
||||||
|
|
||||||
|
Pour un agent sans override, l'éditeur est en lecture de l'état hérité jusqu'à ce que l'utilisateur choisisse `Créer un override`. Les contrôles hérités sont visibles mais atténués, avec la mention `Hérité du défaut projet`. Cela permet de comprendre l'état effectif avant de modifier.
|
||||||
|
|
||||||
|
Pour un agent avec override, le badge de la colonne gauche affiche `Override`. Le panneau de droite affiche `Override personnalisé` et un bouton `Réinitialiser l'override` qui remet l'agent sur le défaut projet.
|
||||||
|
|
||||||
|
### Présentation du catalogue
|
||||||
|
|
||||||
|
Les tools sont groupés par domaine fonctionnel, à partir des métadonnées du catalogue retourné par `get_mcp_tool_permissions` si disponibles côté backend, sinon par mapping frontend local strictement présentationnel. La classification `read`/`write` reste backend-canonique.
|
||||||
|
|
||||||
|
Groupes UX recommandés :
|
||||||
|
|
||||||
|
- `Lecture projet` : `idea_list_agents`, `idea_context_read`, `idea_memory_read`, `idea_skill_read`, `idea_workstate_read`.
|
||||||
|
- `Lecture tickets` : `idea_ticket_read`, `idea_ticket_list`, `idea_ticket_read_carnet`, `idea_sprint_list`.
|
||||||
|
- `Délégation agents` : `idea_ask_agent`, `idea_launch_agent`, `idea_stop_agent`.
|
||||||
|
- `Contexte et mémoire` : `idea_update_context`, `idea_context_propose`, `idea_memory_write`.
|
||||||
|
- `Tickets` : `idea_ticket_create`, `idea_ticket_update`, `idea_ticket_update_status`, `idea_ticket_update_priority`, `idea_ticket_update_carnet`, `idea_ticket_link`, `idea_ticket_unlink`.
|
||||||
|
- `Travail et exécution` : `idea_run_in_background`, `idea_workstate_set`.
|
||||||
|
- `Skills` : `idea_create_skill`.
|
||||||
|
|
||||||
|
Chaque groupe affiche un compteur : `3/7 autorisés`, et peut être replié/déplié. Les groupes lecture sont ouverts par défaut dans `Défaut projet`, mais repliés par défaut dans l'édition agent pour réduire le bruit. Les groupes écriture/action sont ouverts par défaut, car ce sont les décisions à risque.
|
||||||
|
|
||||||
|
Chaque ligne de tool contient :
|
||||||
|
|
||||||
|
- le nom exact monospace (`idea_ticket_update_carnet`) ;
|
||||||
|
- un libellé humain court (`Modifier le carnet d'un ticket`) ;
|
||||||
|
- un badge `Lecture` ou `Écriture` ;
|
||||||
|
- un état `Autorisé`, `Refusé`, ou `Hérité` ;
|
||||||
|
- une case à cocher uniquement quand la cible est éditable.
|
||||||
|
|
||||||
|
Les checkboxes sont réservées aux tools individuels. Les groupes utilisent un bouton discret `Tout autoriser dans ce groupe` / `Tout retirer dans ce groupe`, jamais une checkbox tri-state ambiguë.
|
||||||
|
|
||||||
|
### Défaut projet vs overrides agent
|
||||||
|
|
||||||
|
Le `Défaut projet` est le point de départ appliqué à tous les agents sans override. Son état initial est `Lecture seule` : tous les tools classifiés lecture sont autorisés, tous les tools écriture/action sont refusés.
|
||||||
|
|
||||||
|
Pour un agent, afficher explicitement :
|
||||||
|
|
||||||
|
- `Hérite du défaut projet` si aucun override n'existe.
|
||||||
|
- `Override personnalisé` si une allowlist agent existe.
|
||||||
|
- `Diffère du défaut : +2 écriture, -1 lecture` quand l'API permet de comparer l'allowlist effective au défaut.
|
||||||
|
|
||||||
|
Dans les lignes de tool agent :
|
||||||
|
|
||||||
|
- un tool hérité autorisé affiche une coche grisée + `Hérité` ;
|
||||||
|
- un tool ajouté par override affiche une coche active + badge `Ajouté` ;
|
||||||
|
- un tool retiré par override affiche une case vide + badge `Retiré` si le backend expose une notion de retrait par remplacement complet ; sinon afficher simplement l'état effectif `Refusé`.
|
||||||
|
|
||||||
|
Important : comme l'API `update_agent_mcp_tool_permissions` accepte une allowlist complète de noms de tools, l'UI doit traiter l'override agent comme un remplacement de l'état effectif, pas comme une série de patches implicites. Au moment où l'utilisateur crée un override depuis l'état hérité, la draft est préremplie avec l'allowlist effective courante.
|
||||||
|
|
||||||
|
### Parcours principal — accorder un tool d'écriture
|
||||||
|
|
||||||
|
1. L'utilisateur ouvre le panneau `Permissions` depuis la barre de panneaux projet.
|
||||||
|
2. Il sélectionne l'onglet `Tools MCP IdeA`.
|
||||||
|
3. Il clique l'agent cible dans la colonne gauche, par exemple `DevFrontend`.
|
||||||
|
4. Si l'agent hérite du défaut, il clique `Créer un override`. La liste devient éditable et reprend l'état effectif actuel.
|
||||||
|
5. Il ouvre le groupe concerné, par exemple `Tickets`.
|
||||||
|
6. Il coche `idea_ticket_update_carnet — Modifier le carnet d'un ticket`.
|
||||||
|
7. Le résumé en haut passe à `9 lecture autorisés · 1 écriture autorisé` et une barre d'actions affiche `Modifications non enregistrées`.
|
||||||
|
8. Il clique `Enregistrer`.
|
||||||
|
9. Après succès, le badge de l'agent passe à `Override` et la ligne du tool affiche `Ajouté`.
|
||||||
|
|
||||||
|
### Parcours principal — retirer un tool d'écriture
|
||||||
|
|
||||||
|
1. L'utilisateur sélectionne un agent avec badge `Override`.
|
||||||
|
2. Il ouvre le groupe contenant le tool autorisé.
|
||||||
|
3. Il décoche le tool d'écriture.
|
||||||
|
4. Le résumé et le compteur du groupe se mettent à jour immédiatement dans la draft.
|
||||||
|
5. Il clique `Enregistrer`.
|
||||||
|
6. Si l'override devient identique au défaut projet, proposer après sauvegarde de le nettoyer avec une action secondaire `Supprimer l'override inutile`. Ne pas le faire automatiquement sans retour visuel.
|
||||||
|
|
||||||
|
### Défaut projet
|
||||||
|
|
||||||
|
Le défaut projet est éditable dans le même onglet, mais avec une friction légère pour les tools d'écriture/action : quand l'utilisateur active un tool d'écriture au niveau défaut projet, afficher une confirmation inline avant sauvegarde :
|
||||||
|
|
||||||
|
`Ce tool sera autorisé pour tous les agents sans override. Confirmer cette modification ?`
|
||||||
|
|
||||||
|
Cette confirmation ne bloque pas l'édition agent par agent, car le cas utilisateur principal est d'accorder des tools d'écriture spécifiques à un agent donné.
|
||||||
|
|
||||||
|
### États et feedback
|
||||||
|
|
||||||
|
- Chargement : skeleton compact dans la colonne cible et dans les groupes, pas de spinner plein écran.
|
||||||
|
- Erreur de chargement : message inline en haut du panneau avec bouton `Réessayer`.
|
||||||
|
- Erreur de sauvegarde : conserver la draft locale, afficher l'erreur au-dessus des actions, garder `Enregistrer` disponible.
|
||||||
|
- Aucune agent : état vide dans la colonne gauche `Aucun agent dans ce projet.` ; le défaut projet reste éditable.
|
||||||
|
- Tool inconnu dans une allowlist existante : afficher dans un groupe `Tools inconnus` avec badge `Inconnu`, désactivé par défaut, et demander à DevFrontend de ne pas permettre de ré-enregistrer silencieusement une permission inconnue comme si elle était valide. Si le backend rejette les inconnus, afficher l'erreur telle quelle.
|
||||||
|
- Modifications non enregistrées : actions `Annuler` et `Enregistrer` visibles dans l'éditeur ; changement de cible avec draft modifiée demande confirmation.
|
||||||
|
- Sauvegarde réussie : feedback discret `Permissions enregistrées` pendant environ 2 secondes.
|
||||||
|
|
||||||
|
### Accessibilité
|
||||||
|
|
||||||
|
- Les onglets `Système` / `Tools MCP IdeA` utilisent `role="tablist"`, `role="tab"`, `aria-selected` et navigation clavier gauche/droite.
|
||||||
|
- Chaque groupe repliable expose un bouton avec `aria-expanded` et un nom incluant le compteur, par exemple `Tickets, 1 sur 7 autorisé`.
|
||||||
|
- Chaque checkbox a un label complet incluant le libellé humain et le nom du tool, par exemple `Modifier le carnet d'un ticket, idea_ticket_update_carnet`.
|
||||||
|
- Les badges couleur (`Lecture`, `Écriture`, `Hérité`, `Override`) ne doivent jamais être le seul signal : le texte doit porter l'information.
|
||||||
|
- Cibles tactiles et souris : 32 px minimum pour les lignes compactes, 40 px pour les actions principales.
|
||||||
|
- Focus visible sur onglets, lignes de cible, boutons de groupe, checkboxes et actions.
|
||||||
|
|
||||||
|
### Ton et libellés
|
||||||
|
|
||||||
|
Conformément à la règle UX #78, les libellés humains sont en français. Les noms exacts des tools restent en anglais/monospace car ce sont des identifiants techniques.
|
||||||
|
|
||||||
|
Libellés principaux :
|
||||||
|
|
||||||
|
- `Permissions`
|
||||||
|
- `Système`
|
||||||
|
- `Tools MCP IdeA`
|
||||||
|
- `Défaut projet`
|
||||||
|
- `Lecture seule`
|
||||||
|
- `Hérite du défaut projet`
|
||||||
|
- `Override personnalisé`
|
||||||
|
- `Créer un override`
|
||||||
|
- `Réinitialiser l'override`
|
||||||
|
- `Modifications non enregistrées`
|
||||||
|
- `Annuler`
|
||||||
|
- `Enregistrer`
|
||||||
|
- `Autorisé`
|
||||||
|
- `Refusé`
|
||||||
|
- `Hérité`
|
||||||
|
- `Ajouté`
|
||||||
|
- `Retiré`
|
||||||
|
|
||||||
|
### Critères d'acceptation UX/frontend
|
||||||
|
|
||||||
|
- Les permissions MCP sont éditables depuis le panneau projet `Permissions`, onglet `Tools MCP IdeA`.
|
||||||
|
- L'utilisateur peut sélectionner `Défaut projet` ou un agent dans une colonne/listing de cibles.
|
||||||
|
- Un agent sans override affiche clairement qu'il hérite du défaut projet, et ses contrôles ne deviennent éditables qu'après `Créer un override`.
|
||||||
|
- Un agent avec override est identifiable dans la liste par un badge textuel `Override`.
|
||||||
|
- Les ~25 tools ne sont pas affichés comme une liste plate : ils sont groupés par domaine, avec compteurs et sections repliables.
|
||||||
|
- Les tools lecture et écriture/action sont distingués par badges textuels et par hiérarchie visuelle.
|
||||||
|
- Le parcours d'autorisation d'un tool d'écriture agent par agent prend au maximum : ouvrir `Permissions`, onglet `Tools MCP IdeA`, choisir l'agent, créer/éditer l'override, cocher le tool, enregistrer.
|
||||||
|
- Le retrait d'un tool d'écriture existant est symétrique : décocher puis enregistrer.
|
||||||
|
- Les changements non sauvegardés sont visibles et protégés lors d'un changement de cible.
|
||||||
|
- L'UI consomme `get_mcp_tool_permissions`, `update_project_mcp_tool_permissions`, `update_agent_mcp_tool_permissions` sans hardcoder la classification lecture/écriture comme source de vérité métier.
|
||||||
|
- Aucun changement de backend ou d'i18n n'est requis pour ce lot frontend.
|
||||||
16
.ideai/tickets/82/issue.md
Normal file
16
.ideai/tickets/82/issue.md
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
id: "4904e380-b9a8-49d4-a06f-032024909be4"
|
||||||
|
number: 82
|
||||||
|
title: "Ajouter des permissions d'utilisations des tools MCP d'IdeA par agent"
|
||||||
|
status: "closed"
|
||||||
|
priority: "critical"
|
||||||
|
sprint: null
|
||||||
|
links: []
|
||||||
|
agentRefs: []
|
||||||
|
createdBy: {"kind":"user"}
|
||||||
|
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
|
createdAt: 1784296923284
|
||||||
|
updatedAt: 1784410760329
|
||||||
|
version: 6
|
||||||
|
---
|
||||||
|
J'aimerais avoir la possibilité de modifier les permissions d'utilisation des outils MCP IdeA de mes agents IdeA. J'aimerais que par défaut seuls les outils MCP de lectures soient autorisés, puis j'aimerais avoir un moyen (a faire determiner par l'agent UX), de modifier ces permissions agent par agent, et autoriser ou enlever des permissions sur les tools MCP proposés par IdeA
|
||||||
222
.ideai/tickets/83/carnet.md
Normal file
222
.ideai/tickets/83/carnet.md
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
---
|
||||||
|
issueRef: "#83"
|
||||||
|
version: 5
|
||||||
|
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
|
updatedAt: 1784567952845
|
||||||
|
---
|
||||||
|
# Cadrage technique — contrainte pour UX
|
||||||
|
|
||||||
|
## Constats dans le code existant
|
||||||
|
|
||||||
|
La fermeture de la fenêtre principale est déjà interceptée côté Rust/Tauri dans `crates/app-tauri/src/lib.rs` via `window.on_window_event(...)` et `tauri::WindowEvent::CloseRequested`.
|
||||||
|
|
||||||
|
Le handler actuel ne bloque pas la fermeture : il exécute directement le teardown applicatif au moment du `CloseRequested` :
|
||||||
|
|
||||||
|
- snapshot des fenêtres ouvertes (`SnapshotOpenWindowsInput`) ;
|
||||||
|
- snapshot des agents en cours (`SnapshotRunningAgentsInput`) pour persister `agent_was_running` avant destruction des PTY ;
|
||||||
|
- kill de tous les handles PTY vivants ;
|
||||||
|
- arrêt des serveurs de modèles locaux ;
|
||||||
|
- arrêt du serveur embedded ;
|
||||||
|
- fermeture des fenêtres webview secondaires.
|
||||||
|
|
||||||
|
Ce flux correspond aux livraisons liées à la fermeture globale de l'application (#39) et aux fenêtres détachées (#50). Les fenêtres détachées ont aussi un handler `CloseRequested`, mais il sert seulement à émettre le lifecycle `closed` pour le panneau concerné ; il ne doit pas porter la confirmation globale.
|
||||||
|
|
||||||
|
Point important pour UX/dev : une interception uniquement côté React avec `onCloseRequested` serait fragile tant que le handler Rust actuel continue à faire le teardown immédiatement. Même si le frontend appelle `preventDefault`, le handler Rust peut déjà avoir snapshot/kill les sessions. Il faut donc déplacer/garder la décision de fermeture dans le flux backend, ou au minimum rendre le handler Rust conscient du guard.
|
||||||
|
|
||||||
|
## Interception de fermeture recommandée
|
||||||
|
|
||||||
|
Le point d'application robuste est le handler Rust de la fenêtre `main` :
|
||||||
|
|
||||||
|
1. Sur `WindowEvent::CloseRequested { api, .. }`, calculer si un travail détectable est en cours.
|
||||||
|
2. Si aucun travail n'est en cours, laisser passer le chemin actuel de shutdown.
|
||||||
|
3. Si du travail est en cours et qu'aucune confirmation n'a déjà été donnée, appeler `api.prevent_close()` / équivalent Tauri v2, puis demander au frontend d'afficher la popup UX.
|
||||||
|
4. Si l'utilisateur confirme, relancer une fermeture programmatique via une commande backend dédiée, avec un flag `confirmed_exit`/`exit_guard_bypassed` pour éviter une boucle de confirmation.
|
||||||
|
5. Le shutdown réel doit réutiliser exactement l'ordre actuel : snapshot fenêtres, snapshot agents, kill PTY, stop model servers, stop embedded server, fermeture des fenêtres secondaires.
|
||||||
|
|
||||||
|
Implémentation conseillée : extraire le teardown actuellement inline dans `lib.rs` vers une fonction/service interne réutilisable, par exemple `shutdown_app_after_confirm(app_handle)`. La commande appelée après confirmation doit soit :
|
||||||
|
|
||||||
|
- positionner le bypass puis appeler `main.close()` pour repasser par le handler et exécuter le teardown partagé ;
|
||||||
|
- soit exécuter explicitement le teardown partagé puis quitter l'application.
|
||||||
|
|
||||||
|
À éviter : appeler `destroy()` côté frontend ou contourner le handler Rust, car cela risquerait de sauter le snapshot `agent_was_running` et le nettoyage PTY/serveurs.
|
||||||
|
|
||||||
|
## Définition technique de « travail en cours »
|
||||||
|
|
||||||
|
Définition fiable recommandée pour déclencher la confirmation :
|
||||||
|
|
||||||
|
- au moins un agent a `busy.state == "busy"` dans le workstate ;
|
||||||
|
- ou au moins une tâche de fond non terminale existe : `Queued`, `Running` ou `Waiting` dans `BackgroundTaskState`.
|
||||||
|
|
||||||
|
Signaux disponibles et niveau de fiabilité :
|
||||||
|
|
||||||
|
- `GetProjectWorkState` / commande Tauri `get_project_work_state(project_id)` : source déjà agrégée par projet. Elle expose les agents du manifeste, leur `live`, leur `busy`, leurs tickets, leurs tâches de fond et les conversations résumées.
|
||||||
|
- `AgentBusyState` (`crates/domain/src/input.rs`) : signal le plus fiable pour un tour d'agent réellement en vol. `Busy { ticket, since_ms }` signifie qu'un tour a démarré et n'a pas encore rendu la main.
|
||||||
|
- `BackgroundTaskStore` / `BackgroundTaskState` (`crates/domain/src/background_task.rs`) : fiable pour les travaux asynchrones. Les états non terminaux sont `Queued`, `Running`, `Waiting`. Les états `Completed`, `Failed`, `Cancelled`, `Expired` sont terminaux et ne doivent pas compter comme « travail en cours » pour éviter les faux positifs. Les complétions non livrées peuvent mériter une notification UX, mais elles ne sont plus un travail actif.
|
||||||
|
- `LiveAgentReadModel` / champ `live` du workstate : indique une session agent vivante, pas nécessairement active. Une session vivante peut être idle ; ne pas l'utiliser seule comme déclencheur, sauf arbitrage produit explicite « prévenir dès qu'une session agent serait arrêtée ».
|
||||||
|
- PTY actifs (`PtyBridge.active_sessions()` / registre `terminal_sessions`) : utile pour le teardown et le snapshot, mais mauvais critère UX seul. Un terminal shell ouvert peut être idle ; compter tous les PTY provoquerait beaucoup de confirmations inutiles.
|
||||||
|
- Sessions structurées/chat (`ChatBridge.active_sessions()` ou sessions structurées ouvertes) : une session ouverte seule ne prouve pas un travail actif. À inclure seulement si un état backend indique un tour structuré en cours. Si ce signal n'est pas encore centralisé, il doit être ajouté au même snapshot backend plutôt que déduit depuis l'existence de la session.
|
||||||
|
|
||||||
|
Donc, pour la première livraison, le critère technique le plus défendable est :
|
||||||
|
|
||||||
|
`has_work_in_progress = any(agent.busy.is_busy()) || any(background_task.state in {Queued, Running, Waiting})`
|
||||||
|
|
||||||
|
Option produit à arbitrer avec UX/PO : faut-il aussi prévenir lorsqu'il existe des agents/PTY simplement vivants mais idle, car ils seront arrêtés à la fermeture ? Techniquement possible, mais cela change le sens de la popup de « travail en cours » vers « sessions ouvertes qui vont être interrompues » et augmente les faux positifs.
|
||||||
|
|
||||||
|
## Frontend pur ou aller-retour backend ?
|
||||||
|
|
||||||
|
Ce n'est pas un changement purement frontend.
|
||||||
|
|
||||||
|
La popup et son wording relèvent bien de l'UX/frontend, mais la décision fiable doit faire un aller-retour backend, idéalement depuis le handler de fermeture Rust lui-même, pour trois raisons :
|
||||||
|
|
||||||
|
1. Le backend possède la vérité complète sur tous les projets ouverts via `AppState::open_project_ids()`. Le frontend ne voit pas forcément un état frais pour tous les onglets/projets, notamment si seul le projet actif rafraîchit son `useProjectWorkState`.
|
||||||
|
2. Les tâches de fond et l'état `busy` sont des états applicatifs/backend. Lire un cache frontend peut rater une tâche démarrée hors vue active ou compter un état obsolète.
|
||||||
|
3. Le handler Rust actuel exécute déjà le teardown au `CloseRequested`. Il doit donc être modifié pour empêcher la fermeture avant teardown lorsque la confirmation est requise.
|
||||||
|
|
||||||
|
Surface backend proposée :
|
||||||
|
|
||||||
|
- ajouter un use case/commande de lecture `get_app_exit_work_guard_state` ou équivalent, qui agrège tous les `ProjectWorkState` des `open_project_ids()` et retourne un résumé minimal pour la popup : `hasWorkInProgress`, nombre d'agents busy, nombre de tâches de fond actives, éventuellement noms/projets pour affichage si UX le souhaite ;
|
||||||
|
- utiliser cette même logique dans le handler `CloseRequested` de `main` avant d'appeler le teardown ;
|
||||||
|
- exposer une commande `confirm_app_exit` / `request_app_exit_after_confirmation` qui bypass le guard et exécute le shutdown existant.
|
||||||
|
|
||||||
|
Frontière de lot :
|
||||||
|
|
||||||
|
- Backend/Tauri nécessaire : guard `CloseRequested`, agrégation fiable du work in progress, bypass confirmé, factorisation du teardown existant.
|
||||||
|
- Frontend/UX : rendu de la popup, libellés, hiérarchie d'information, boutons, éventuel détail des agents/tâches concernés.
|
||||||
|
- Aucun impact backend métier profond attendu : on réutilise `GetProjectWorkState`, `AgentBusyState`, `BackgroundTaskStore`, `open_project_ids()` et le shutdown existant. Pas d'impact DB/schema prévu, sauf si l'on choisit de persister une préférence utilisateur du type « ne plus demander » (hors demande actuelle).
|
||||||
|
|
||||||
|
## Critères d'acceptation techniques proposés
|
||||||
|
|
||||||
|
- Fermer la fenêtre principale sans travail actif garde le comportement actuel : snapshot, kill PTY, arrêt serveurs, fermeture globale.
|
||||||
|
- Fermer la fenêtre principale avec au moins un agent `Busy` empêche la fermeture et déclenche la demande de confirmation avant tout kill PTY.
|
||||||
|
- Fermer la fenêtre principale avec au moins une tâche de fond `Queued`/`Running`/`Waiting` empêche la fermeture et déclenche la confirmation.
|
||||||
|
- Annuler la popup laisse l'application et les sessions intactes : aucun PTY tué, aucun snapshot de fermeture forcé, serveurs toujours actifs.
|
||||||
|
- Confirmer exécute le shutdown existant dans le même ordre qu'aujourd'hui.
|
||||||
|
- Les fenêtres détachées ne déclenchent pas la confirmation globale ; seule la fenêtre `main` porte ce guard.
|
||||||
|
|
||||||
|
## Conception UX — confirmation de fermeture avec travail en cours
|
||||||
|
|
||||||
|
### Décision produit
|
||||||
|
|
||||||
|
Afficher une popup modale uniquement lorsque la fermeture de la fenêtre principale d'IdeA interrompt un travail actif détecté par le guard technique : agents en tour `busy` et/ou tâches de fond non terminales (`Queued`, `Running`, `Waiting`). Ne pas déclencher la popup pour une session agent simplement vivante mais idle dans la première livraison : le libellé demandé parle de « travail en cours », et compter les sessions ouvertes créerait trop de confirmations inutiles.
|
||||||
|
|
||||||
|
Il ne doit pas y avoir d'option `Ne plus avertir`. Cette confirmation protège contre une perte ou interruption volontairement coûteuse ; la rendre désactivable localement affaiblit la sécurité produit et crée une préférence à maintenir. Le seul bypass est l'action explicite `Quitter quand même` pour cette tentative de fermeture.
|
||||||
|
|
||||||
|
### Rôle de la popup
|
||||||
|
|
||||||
|
La popup doit communiquer trois choses, dans cet ordre :
|
||||||
|
|
||||||
|
1. IdeA a détecté du travail actif.
|
||||||
|
2. Quitter maintenant interrompra ce travail.
|
||||||
|
3. L'utilisateur peut annuler pour revenir à l'application, ou confirmer une fermeture volontaire.
|
||||||
|
|
||||||
|
La popup ne doit pas essayer de prédire si le travail est récupérable. Elle ne promet pas de sauvegarde, de reprise ou de livraison du résultat après fermeture. Elle indique seulement l'effet immédiat : les agents et tâches en cours seront interrompus pendant la fermeture.
|
||||||
|
|
||||||
|
### Libellé exact
|
||||||
|
|
||||||
|
Titre : `Du travail est encore en cours`
|
||||||
|
|
||||||
|
Corps, version avec un seul élément actif :
|
||||||
|
|
||||||
|
`1 travail actif sera interrompu si vous quittez IdeA maintenant.`
|
||||||
|
|
||||||
|
Corps, version plurielle :
|
||||||
|
|
||||||
|
`{count} travaux actifs seront interrompus si vous quittez IdeA maintenant.`
|
||||||
|
|
||||||
|
Phrase secondaire :
|
||||||
|
|
||||||
|
`Annulez la fermeture pour laisser les agents et les tâches se terminer.`
|
||||||
|
|
||||||
|
Si l'agrégat distingue les types, préférer une phrase plus informative :
|
||||||
|
|
||||||
|
- `1 agent travaille encore.`
|
||||||
|
- `{agentCount} agents travaillent encore.`
|
||||||
|
- `1 tâche de fond est encore active.`
|
||||||
|
- `{taskCount} tâches de fond sont encore actives.`
|
||||||
|
|
||||||
|
Exemple combiné :
|
||||||
|
|
||||||
|
`2 agents travaillent encore et 1 tâche de fond est encore active. Ces travaux seront interrompus si vous quittez IdeA maintenant.`
|
||||||
|
|
||||||
|
Actions :
|
||||||
|
|
||||||
|
- Action principale, non destructive : `Annuler`
|
||||||
|
- Action destructive secondaire : `Quitter quand même`
|
||||||
|
|
||||||
|
Ordre visuel recommandé : `Annuler` à gauche ou en premier, `Quitter quand même` à droite ou en dernier avec style danger. Le focus initial va sur `Annuler`.
|
||||||
|
|
||||||
|
### Détail affiché
|
||||||
|
|
||||||
|
Afficher un résumé court visible immédiatement :
|
||||||
|
|
||||||
|
- `{agentCount} agent(s) en cours`
|
||||||
|
- `{backgroundTaskCount} tâche(s) de fond active(s)`
|
||||||
|
|
||||||
|
Afficher ensuite une liste compacte des éléments concernés, limitée à 5 lignes maximum pour garder la popup lisible. Si plus de 5 éléments sont actifs, afficher les 5 premiers puis `+ {remainingCount} autre(s)`.
|
||||||
|
|
||||||
|
Format des lignes :
|
||||||
|
|
||||||
|
- Agent busy : `Agent {agentName} — {projectName}` ; si un ticket est connu, ajouter ` — #{ticketNumber}`.
|
||||||
|
- Tâche de fond : `{taskLabel} — {projectName}` ; si aucun libellé humain n'est disponible, utiliser `Tâche de fond {shortTaskId}`.
|
||||||
|
|
||||||
|
Exemples :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Du travail est encore en cours
|
||||||
|
|
||||||
|
2 agents travaillent encore et 1 tâche de fond est encore active. Ces travaux seront interrompus si vous quittez IdeA maintenant.
|
||||||
|
|
||||||
|
Agents
|
||||||
|
• DevFrontend — IdeA — #82
|
||||||
|
• QA — IdeA
|
||||||
|
|
||||||
|
Tâches de fond
|
||||||
|
• npm test — IdeA
|
||||||
|
|
||||||
|
[Annuler] [Quitter quand même]
|
||||||
|
```
|
||||||
|
|
||||||
|
Si les noms ne sont pas disponibles côté backend au moment du guard, afficher seulement les compteurs. Ne pas bloquer la feature UX sur l'affichage détaillé, mais le contrat backend recommandé est de fournir au moins `projectName`, `agentName` et un label de tâche quand disponibles.
|
||||||
|
|
||||||
|
### États et interactions
|
||||||
|
|
||||||
|
- Ouverture : la popup apparaît après tentative de fermeture, avant tout teardown.
|
||||||
|
- `Annuler` : ferme la popup, ne ferme pas l'application, ne tue aucune session, ne modifie aucun état métier.
|
||||||
|
- `Échap` : équivalent à `Annuler`.
|
||||||
|
- Clic hors popup : désactivé ou équivalent à `Annuler`, selon le composant modal existant ; préférence UX : ne pas fermer par clic extérieur pour éviter une décision ambiguë.
|
||||||
|
- `Quitter quand même` : lance le flux backend confirmé. Pendant l'appel, désactiver les deux boutons et afficher l'état `Fermeture…` sur le bouton danger.
|
||||||
|
- Échec de la fermeture confirmée : rester dans la popup et afficher un message d'erreur inline : `IdeA n'a pas pu quitter correctement. Réessayez ou consultez les logs.` Le bouton `Quitter quand même` redevient disponible.
|
||||||
|
- Si le travail se termine pendant que la popup est ouverte, ne pas fermer automatiquement la popup. Actualiser le résumé si l'événement arrive facilement ; sinon garder l'état initial. L'utilisateur peut annuler puis fermer à nouveau sans confirmation.
|
||||||
|
|
||||||
|
### Hiérarchie visuelle
|
||||||
|
|
||||||
|
La popup est une alerte de confirmation destructive, pas un panneau de diagnostic :
|
||||||
|
|
||||||
|
- largeur cible : 440 à 520 px ;
|
||||||
|
- titre en `text-content`, taille modale standard ;
|
||||||
|
- résumé en texte normal, pas en rouge ;
|
||||||
|
- action `Quitter quand même` en variante danger ;
|
||||||
|
- liste des éléments dans un bloc compact `bg-raised` ou équivalent, sans tableau ;
|
||||||
|
- éviter les grands paragraphes et les détails techniques (`busy.state`, `Queued`, ids longs).
|
||||||
|
|
||||||
|
### Accessibilité
|
||||||
|
|
||||||
|
- Utiliser un vrai dialogue modal avec `role="alertdialog"` ou le composant modal existant configuré comme confirmation destructive.
|
||||||
|
- `aria-labelledby` pointe sur le titre `Du travail est encore en cours`.
|
||||||
|
- `aria-describedby` pointe sur le résumé de l'impact.
|
||||||
|
- Focus initial sur `Annuler`.
|
||||||
|
- Tabulation piégée dans la popup tant qu'elle est ouverte.
|
||||||
|
- `Entrée` active le bouton focusé, pas automatiquement `Quitter quand même`.
|
||||||
|
- `Échap` annule.
|
||||||
|
- Les compteurs et détails ne doivent pas dépendre uniquement de la couleur.
|
||||||
|
|
||||||
|
### Critères d'acceptation UX/frontend
|
||||||
|
|
||||||
|
- La popup n'apparaît que pour la fermeture de la fenêtre principale avec travail actif détecté.
|
||||||
|
- Le titre exact est `Du travail est encore en cours`.
|
||||||
|
- La popup indique le nombre total de travaux actifs et, quand disponible, le détail agents/tâches limité à 5 lignes.
|
||||||
|
- Les actions exactes sont `Annuler` et `Quitter quand même`.
|
||||||
|
- `Annuler` est l'action par défaut/focus initial et laisse l'application intacte.
|
||||||
|
- `Quitter quand même` est visuellement destructive et déclenche le flux backend confirmé.
|
||||||
|
- Aucune option `Ne plus avertir` n'est proposée.
|
||||||
|
- Les libellés visibles sont en français conformément à la décision UX #78.
|
||||||
16
.ideai/tickets/83/issue.md
Normal file
16
.ideai/tickets/83/issue.md
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
id: "18a92510-3af4-4aef-84e8-e23cd7422118"
|
||||||
|
number: 83
|
||||||
|
title: "Popup pour vérifier la volonté de quitter l'app"
|
||||||
|
status: "closed"
|
||||||
|
priority: "medium"
|
||||||
|
sprint: null
|
||||||
|
links: []
|
||||||
|
agentRefs: []
|
||||||
|
createdBy: {"kind":"user"}
|
||||||
|
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
|
createdAt: 1784360573747
|
||||||
|
updatedAt: 1784567952845
|
||||||
|
version: 5
|
||||||
|
---
|
||||||
|
Dans le cas ou un travail est en cours, j'aimerais qu'une popup qui demande la confirmation qu'on veut quitter IdeA malgré le travail en cours, apparaisse
|
||||||
6
.ideai/tickets/84/carnet.md
Normal file
6
.ideai/tickets/84/carnet.md
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
issueRef: "#84"
|
||||||
|
version: 1
|
||||||
|
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
|
updatedAt: 1784377833773
|
||||||
|
---
|
||||||
20
.ideai/tickets/84/issue.md
Normal file
20
.ideai/tickets/84/issue.md
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
id: "beab1811-2fd7-4563-ac39-6a354fbd1feb"
|
||||||
|
number: 84
|
||||||
|
title: "[Bug] Reprise auto après limite de session ne se déclenche pas pour l'orchestrator (Main, profil Claude)"
|
||||||
|
status: "open"
|
||||||
|
priority: "high"
|
||||||
|
sprint: null
|
||||||
|
links: [{"target":"#7","kind":"relatesTo"},{"target":"#15","kind":"relatesTo"}]
|
||||||
|
agentRefs: []
|
||||||
|
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
|
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
|
createdAt: 1784377833773
|
||||||
|
updatedAt: 1784377833773
|
||||||
|
version: 1
|
||||||
|
---
|
||||||
|
Rapporté par l'utilisateur (2026-07-18) sur l'AppImage courante (buildée depuis develop, contient #7 baseline + #30 fix chemin direct Main) : quand l'orchestrator (Main) atteint une limite de session sur un profil Claude, la reprise automatique au reset n'a PAS lieu en pratique, alors que la feature #7 (mergée d7041c5, 2026-06-17) et le fix #30 dédié précisément au cas "Main qui limite session" (commit 9430c65, 2026-07-13, "brancher le handle de limite sur le chemin direct") sont censés couvrir ce cas.
|
||||||
|
|
||||||
|
Écart constaté : design/tests verts ≠ comportement live observé par l'utilisateur. À investiguer : soit régression depuis 9430c65, soit un chemin non couvert par les tests d'intégration existants (session_limit_wiring.rs), soit une limite du "EN MEMOIRE uniquement" (mémoire session-limit-handling-design : le réveil auto ne joue que tant qu'IdeA reste ouvert — si l'utilisateur a fermé/rouvert IdeA entre la limite et le reset, c'est le comportement attendu, pas un bug).
|
||||||
|
|
||||||
|
Première question à trancher par Architect : est-ce une vraie régression du chemin direct Main, ou un cas hors-couverture connu (IdeA fermé/rouvert pendant la fenêtre de reset) ? Nécessite de faire préciser à l'utilisateur les conditions exactes de repro (IdeA resté ouvert ou non pendant l'attente du reset).
|
||||||
6
.ideai/tickets/85/carnet.md
Normal file
6
.ideai/tickets/85/carnet.md
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
issueRef: "#85"
|
||||||
|
version: 1
|
||||||
|
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
|
updatedAt: 1784379436323
|
||||||
|
---
|
||||||
27
.ideai/tickets/85/issue.md
Normal file
27
.ideai/tickets/85/issue.md
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
id: "34b23044-4b82-4e7e-b999-cca7cbef7861"
|
||||||
|
number: 85
|
||||||
|
title: "Test flaky : setCellAgent « changing the agent kills the previous PTY (Bug #3) » échoue en exécution shuffled"
|
||||||
|
status: "open"
|
||||||
|
priority: "low"
|
||||||
|
sprint: null
|
||||||
|
links: [{"target":"#79","kind":"relatesTo"}]
|
||||||
|
agentRefs: []
|
||||||
|
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
|
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
|
createdAt: 1784379436323
|
||||||
|
updatedAt: 1784379436323
|
||||||
|
version: 1
|
||||||
|
---
|
||||||
|
Découvert en marge de #79 (DevFrontend puis confirmé par QA sur `npx vitest run --sequence.shuffle`, ~2 échecs sur 3 passages) : `src/features/layout/setCellAgent.test.tsx` → « changing the agent kills the previous PTY (Bug #3) ».
|
||||||
|
|
||||||
|
```
|
||||||
|
FAIL src/features/layout/setCellAgent.test.tsx
|
||||||
|
changing the agent kills the previous PTY (Bug #3)
|
||||||
|
AssertionError: expected "closeTerminal" to be called with arguments: [ 'old-session' ]
|
||||||
|
Number of calls: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Vert isolément (`npx vitest run src/features/layout/setCellAgent.test.tsx` → 9/9) et vert en suite complète non-shuffled (850/850). Rouge uniquement en ordre shuffled — dépendance d'ordre/état partagé entre fichiers de test, pas un bug du code applicatif a priori (à confirmer). Sans rapport avec #78/#79, ne pas mélanger.
|
||||||
|
|
||||||
|
Attendu : identifier la source de la dépendance d'ordre (état partagé, mock non réinitialisé, timer) et rendre le test déterministe, comme pour #79 — ne pas neutraliser ni élargir un timeout pour faire taire.
|
||||||
336
.ideai/tickets/86/carnet.md
Normal file
336
.ideai/tickets/86/carnet.md
Normal file
@ -0,0 +1,336 @@
|
|||||||
|
---
|
||||||
|
issueRef: "#86"
|
||||||
|
version: 7
|
||||||
|
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
|
updatedAt: 1784612705899
|
||||||
|
---
|
||||||
|
# Conception UX — tickets et sprints dans le client web
|
||||||
|
|
||||||
|
## Intention
|
||||||
|
|
||||||
|
La version web doit permettre de consulter et modifier les tickets/sprints sans reproduire le shell desktop. Le client web existant est une surface autonome en colonne verticale unique : après appairage, l'utilisateur choisit un projet, puis voit l'état live et les cellules agents. #86 doit ajouter une surface de pilotage tickets/sprints cohérente avec ce modèle mobile/web, pas importer les docks, fenêtres flottantes et overlays desktop.
|
||||||
|
|
||||||
|
Décision UX : réutiliser les contrats métier et les hooks transport-neutres autant que possible (`useTickets`, `useTicketDetail`, recherche, filtres, événements live), mais créer une présentation web dédiée. Ce n'est pas une version appauvrie en capacités, c'est une adaptation de navigation et de densité.
|
||||||
|
|
||||||
|
La cible prioritaire web/mobile est l'intervention rapide : lire, trier, changer statut/priorité, corriger titre/description/carnet, créer un ticket simple, assigner/désassigner, déplacer dans un sprint, créer/renommer/supprimer un sprint. Les opérations complexes restent disponibles mais rangées derrière des panneaux dédiés : liens entre tickets, carnet long, gestion complète des sprints.
|
||||||
|
|
||||||
|
## Placement dans le client web
|
||||||
|
|
||||||
|
Dans `WebWorkspace`, après ouverture d'un projet, ajouter une navigation de projet compacte au-dessus des surfaces projet :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Projet: IdeA [Rafraîchir]
|
||||||
|
[ Live ] [ Tickets ] [ Sprints ]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `Live` : surface actuelle `État live`, inchangée dans son rôle.
|
||||||
|
- `Tickets` : nouvelle liste et édition des tickets.
|
||||||
|
- `Sprints` : gestion des sprints.
|
||||||
|
|
||||||
|
Sur mobile, ces onglets sont des boutons segmentés scrollables horizontalement si nécessaire. Sur desktop web large, ils restent dans la même colonne contrainte, pas de dock ni layout grid.
|
||||||
|
|
||||||
|
Ne pas placer les tickets sous la liste des agents : ce sont deux surfaces de projet au même niveau. L'utilisateur web doit pouvoir passer de l'état live au backlog sans chercher dans une carte agent.
|
||||||
|
|
||||||
|
## Vue Tickets — layout
|
||||||
|
|
||||||
|
La vue `Tickets` est une pile verticale : barre d'actions, recherche/filtres compacts, liste groupée par sprint, puis écran de détail lorsqu'un ticket est ouvert.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Tickets [+ Ticket]
|
||||||
|
[Recherche…]
|
||||||
|
[Statut ▼] [Priorité ▼] [Assigné ▼] [Tri ▼]
|
||||||
|
|
||||||
|
Sprint courant (4)
|
||||||
|
#86 medium open Ajouter tickets/sprints au web
|
||||||
|
#83 medium open Confirmation fermeture
|
||||||
|
|
||||||
|
Sans sprint (2)
|
||||||
|
#78 low open Langue uniforme Settings
|
||||||
|
|
||||||
|
[Charger plus]
|
||||||
|
```
|
||||||
|
|
||||||
|
Sur petit écran, un ticket s'affiche comme une ligne compacte à deux étages :
|
||||||
|
|
||||||
|
```text
|
||||||
|
#86 Ajouter tickets/sprints au web
|
||||||
|
open · medium · Sprint courant · Main
|
||||||
|
```
|
||||||
|
|
||||||
|
La ligne entière ouvre le détail. Les badges doivent rester textuels et lisibles ; ne pas empiler 5 pastilles colorées si elles provoquent un retour ligne incohérent à 360 px.
|
||||||
|
|
||||||
|
## Actions prioritaires tickets
|
||||||
|
|
||||||
|
Priorité haute sur web :
|
||||||
|
|
||||||
|
- consulter la liste ;
|
||||||
|
- rechercher et filtrer ;
|
||||||
|
- ouvrir un ticket ;
|
||||||
|
- changer statut et priorité ;
|
||||||
|
- éditer titre, description et carnet ;
|
||||||
|
- créer un ticket simple ;
|
||||||
|
- supprimer un ticket avec confirmation ;
|
||||||
|
- assigner/désassigner des agents ;
|
||||||
|
- changer le sprint d'un ticket.
|
||||||
|
|
||||||
|
Priorité secondaire, mais à conserver si les endpoints existent :
|
||||||
|
|
||||||
|
- gérer les liens entre tickets ;
|
||||||
|
- utiliser le `TicketPicker` adapté mobile pour lier/ajouter des tickets à un sprint ;
|
||||||
|
- copier le contexte de délégation.
|
||||||
|
|
||||||
|
Hors périmètre recommandé pour la première livraison web : assistant IA de ticket. Le desktop peut garder cette affordance avancée ; sur mobile/web, elle ajoute du coût UI et des permissions sans être nécessaire à la demande utilisateur.
|
||||||
|
|
||||||
|
## Création de ticket
|
||||||
|
|
||||||
|
Le bouton `+ Ticket` ouvre un panneau plein écran mobile ou une section expansée desktop web, pas un petit formulaire inline permanent. Le formulaire est court :
|
||||||
|
|
||||||
|
- `Titre` obligatoire ;
|
||||||
|
- `Priorité` ;
|
||||||
|
- `Sprint` avec picker ;
|
||||||
|
- `Description` optionnelle, textarea repliable ou sous le champ titre ;
|
||||||
|
- actions `Annuler` et `Créer`.
|
||||||
|
|
||||||
|
Après création : ouvrir automatiquement le détail du ticket créé pour permettre de compléter carnet, assignations ou liens. Si l'affectation sprint échoue après création, garder le ticket créé et afficher l'erreur sans perdre le résultat.
|
||||||
|
|
||||||
|
Libellés : `Nouveau ticket`, `Titre`, `Description`, `Priorité`, `Sprint`, `Sans sprint`, `Créer`.
|
||||||
|
|
||||||
|
## Détail ticket sur petit écran
|
||||||
|
|
||||||
|
Sur web/mobile, le détail ticket remplace temporairement la liste dans la colonne, au lieu d'utiliser la fenêtre flottante desktop. Navigation : bouton retour en haut.
|
||||||
|
|
||||||
|
```text
|
||||||
|
[← Tickets] #86 [⋯]
|
||||||
|
Ajouter tickets/sprints au web
|
||||||
|
open · medium · Sprint courant
|
||||||
|
|
||||||
|
[Résumé]
|
||||||
|
Titre
|
||||||
|
Description
|
||||||
|
[Enregistrer]
|
||||||
|
|
||||||
|
[Statut et priorité]
|
||||||
|
Statut [open ▼]
|
||||||
|
Priorité [medium ▼]
|
||||||
|
Sprint [Sprint courant ▼]
|
||||||
|
|
||||||
|
[Carnet]
|
||||||
|
textarea Markdown
|
||||||
|
[Enregistrer le carnet]
|
||||||
|
|
||||||
|
[Agents assignés]
|
||||||
|
Main DevFrontend [+]
|
||||||
|
|
||||||
|
[Liens]
|
||||||
|
relatesTo #83 [+ Lier]
|
||||||
|
```
|
||||||
|
|
||||||
|
Les sections du détail sont accordéons ou blocs empilés. Ouverts par défaut : `Résumé`, `Statut et priorité`, `Carnet`. Repliés par défaut : `Agents assignés`, `Liens`, `Zone dangereuse`.
|
||||||
|
|
||||||
|
Le bouton de suppression vit dans une section `Zone dangereuse` ou dans un menu `⋯`, jamais dans la première ligne d'actions. Confirmation obligatoire :
|
||||||
|
|
||||||
|
Titre : `Supprimer ce ticket ?`
|
||||||
|
Corps : `Le ticket {ref} sera supprimé définitivement. Cette action ne supprime pas les sprints ni les agents assignés.`
|
||||||
|
Actions : `Annuler` / `Supprimer`
|
||||||
|
|
||||||
|
Gestion des changements non enregistrés : si l'utilisateur revient à la liste avec des modifications locales non sauvegardées, afficher la confirmation existante adaptée en français : `Des modifications ne sont pas enregistrées.` avec `Continuer l'édition`, `Ignorer`, `Enregistrer et quitter` si techniquement disponible.
|
||||||
|
|
||||||
|
## Édition rapide
|
||||||
|
|
||||||
|
Pour les champs à faible risque (`statut`, `priorité`, `sprint`), la modification peut être enregistrée immédiatement au changement de select, avec spinner discret sur la ligne/section. Pour les champs texte (`titre`, `description`, `carnet`), garder une action explicite `Enregistrer` afin d'éviter les sauvegardes involontaires sur mobile.
|
||||||
|
|
||||||
|
En cas de conflit de version : afficher un message inline en haut du détail : `Ce ticket a été modifié ailleurs et rechargé. Réappliquez votre modification.` Ne pas écraser silencieusement le brouillon local.
|
||||||
|
|
||||||
|
## Vue Sprints — layout
|
||||||
|
|
||||||
|
La vue `Sprints` est une surface dédiée, pas un overlay plein écran au-dessus de la liste comme sur desktop. Elle est accessible depuis l'onglet projet `Sprints` et depuis le bouton `Gérer les sprints` dans la vue tickets si ce raccourci est conservé.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Sprints [+ Sprint]
|
||||||
|
|
||||||
|
#1 Sprint courant [⋯]
|
||||||
|
4 tickets
|
||||||
|
[Voir tickets] [Ajouter tickets]
|
||||||
|
|
||||||
|
#2 Backlog client web [⋯]
|
||||||
|
7 tickets
|
||||||
|
[Voir tickets] [Ajouter tickets]
|
||||||
|
```
|
||||||
|
|
||||||
|
Créer un sprint : bouton `+ Sprint`, champ `Nom du sprint`, action `Créer`. Si le nom est vide, soit désactiver `Créer`, soit reprendre le comportement desktop de nom automatique `Sprint N`; préférence UX web : désactiver tant qu'un nom n'est pas saisi, car le mobile bénéficie d'une décision explicite.
|
||||||
|
|
||||||
|
Renommer : action dans menu `⋯`, ouvre une ligne d'édition inline dans la carte sprint ou un panneau bas sur petit écran. Actions `Annuler` / `Enregistrer`.
|
||||||
|
|
||||||
|
Réordonner : boutons `Monter` / `Descendre` dans le menu ou dans une section `Ordre`. Pas de drag-and-drop requis sur mobile.
|
||||||
|
|
||||||
|
Supprimer : confirmation obligatoire :
|
||||||
|
|
||||||
|
Titre : `Supprimer le sprint « {name} » ?`
|
||||||
|
Corps : `Les tickets de ce sprint seront conservés et passeront en « Sans sprint ».`
|
||||||
|
Actions : `Annuler` / `Supprimer`
|
||||||
|
|
||||||
|
Ajouter des tickets à un sprint : ouvrir un picker mobile de tickets, avec recherche et multi-sélection. Action finale `Ajouter {count} ticket(s)`. Les tickets déjà dans le sprint sont exclus ou affichés cochés et désactivés ; ne pas laisser l'utilisateur croire qu'ils seront ajoutés une seconde fois.
|
||||||
|
|
||||||
|
Retirer un ticket d'un sprint : depuis la carte sprint, dans la liste compacte des tickets, action `Retirer du sprint` accessible via menu ligne. Cela ne supprime pas le ticket.
|
||||||
|
|
||||||
|
## Pickers et popups sur mobile/web
|
||||||
|
|
||||||
|
Les pickers desktop (`TicketPicker`, `SprintPicker`) ne doivent pas apparaître comme de petites modales centrées sur téléphone. Adapter leur chrome :
|
||||||
|
|
||||||
|
- mobile : panneau plein écran ou bottom sheet haute, avec header fixe, recherche en haut, liste scrollable, actions en bas ;
|
||||||
|
- desktop web large : modal centrée acceptable, mais largeur limitée et focus trap ;
|
||||||
|
- toujours garder `Échap` / retour / bouton `Fermer` selon plateforme.
|
||||||
|
|
||||||
|
Le même contenu métier peut être réutilisé : recherche, filtres, sélection simple ou multiple. Seul le contenant responsive change.
|
||||||
|
|
||||||
|
## Cohérence avec le web existant
|
||||||
|
|
||||||
|
Respecter les patrons #69 :
|
||||||
|
|
||||||
|
- une seule colonne verticale dans `WebWorkspace` ;
|
||||||
|
- pas de `LayoutGrid`, docks, fenêtres flottantes desktop ou tabs projet desktop ;
|
||||||
|
- padding avec safe-area ;
|
||||||
|
- contrôles qui wrap proprement à 360 px ;
|
||||||
|
- hauteurs basées sur `dvh` pour les panneaux longs ;
|
||||||
|
- reconnect banner existante conservée au-dessus des surfaces.
|
||||||
|
|
||||||
|
Les surfaces tickets/sprints doivent continuer à se resynchroniser sur les événements domaine via le transport web live, comme `État live` le fait déjà. En cas de reconnexion, refetch complet du snapshot/listes plutôt qu'application de deltas manqués.
|
||||||
|
|
||||||
|
## Langue et libellés
|
||||||
|
|
||||||
|
Conformément à la règle UX #78, les libellés humains sont en français. Les valeurs métier techniques peuvent rester celles du domaine si elles sont déjà exposées comme enums (`open`, `closed`, `medium`) seulement si leur traduction demanderait un chantier transversal ; préférence UX : afficher `Ouvert`, `Fermé`, `Faible`, `Moyenne`, `Haute`, `Critique` dans l'UI.
|
||||||
|
|
||||||
|
Libellés principaux :
|
||||||
|
|
||||||
|
- `Tickets`
|
||||||
|
- `Sprints`
|
||||||
|
- `Nouveau ticket`
|
||||||
|
- `Créer un ticket`
|
||||||
|
- `Gérer les sprints`
|
||||||
|
- `Nouveau sprint`
|
||||||
|
- `Créer un sprint`
|
||||||
|
- `Modifier`
|
||||||
|
- `Enregistrer`
|
||||||
|
- `Annuler`
|
||||||
|
- `Supprimer`
|
||||||
|
- `Sans sprint`
|
||||||
|
- `Charger plus`
|
||||||
|
- `Aucun ticket.`
|
||||||
|
- `Aucun sprint.`
|
||||||
|
- `Tickets liés`
|
||||||
|
- `Agents assignés`
|
||||||
|
- `Carnet`
|
||||||
|
- `Zone dangereuse`
|
||||||
|
|
||||||
|
## États et erreurs
|
||||||
|
|
||||||
|
- Chargement liste tickets : `Chargement des tickets…` avec spinner compact.
|
||||||
|
- Liste vide sans filtre : `Aucun ticket dans ce projet.` + bouton `Créer un ticket`.
|
||||||
|
- Liste vide avec filtres : `Aucun ticket ne correspond aux filtres.` + `Réinitialiser les filtres`.
|
||||||
|
- Chargement sprints : `Chargement des sprints…`.
|
||||||
|
- Aucun sprint : `Aucun sprint.` + bouton `Créer un sprint`.
|
||||||
|
- Erreur réseau/session : message inline en haut de la surface, avec `Réessayer`.
|
||||||
|
- Déconnexion live : conserver la bannière existante `Connexion perdue — reconnexion en cours…` ; les formulaires peuvent rester éditables, mais sauvegarder doit afficher l'erreur réelle si le transport est indisponible.
|
||||||
|
- Suppression réussie : retour à la liste, ticket/sprint retiré après événement ou refresh.
|
||||||
|
|
||||||
|
## Accessibilité
|
||||||
|
|
||||||
|
- Les onglets `Live`, `Tickets`, `Sprints` utilisent `role="tablist"`, `role="tab"`, `aria-selected` et navigation clavier gauche/droite.
|
||||||
|
- Chaque ticket de liste est un bouton ou lien avec nom accessible complet : `{ref}, {title}, statut {status}, priorité {priority}`.
|
||||||
|
- Les formulaires ont labels visibles ou labels accessibles explicites ; ne dépendre d'aucun placeholder comme seul label.
|
||||||
|
- Les confirmations de suppression utilisent `role="alertdialog"`, focus initial sur `Annuler`, action destructive textuelle.
|
||||||
|
- Les menus `⋯` ont un label explicite : `Actions du ticket {ref}` ou `Actions du sprint {name}`.
|
||||||
|
- Les zones scrollables longues conservent le focus et ne piègent pas la navigation clavier hors modal.
|
||||||
|
|
||||||
|
## Critères d'acceptation UX/frontend
|
||||||
|
|
||||||
|
- Après ouverture d'un projet web, l'utilisateur peut basculer entre `Live`, `Tickets` et `Sprints` sans shell desktop.
|
||||||
|
- La vue web reste une colonne verticale responsive et ne monte pas le layout grid/docks/floating windows desktop.
|
||||||
|
- L'utilisateur peut lister, filtrer, créer, ouvrir, modifier et supprimer des tickets depuis le web.
|
||||||
|
- L'utilisateur peut créer, renommer, réordonner, supprimer des sprints et ajouter/retirer des tickets d'un sprint depuis le web.
|
||||||
|
- L'édition ticket sur mobile se fait dans une vue détail pleine colonne avec retour explicite, pas dans une petite popup desktop.
|
||||||
|
- Les suppressions ticket/sprint demandent confirmation et précisent ce qui est supprimé ou conservé.
|
||||||
|
- Les pickers ticket/sprint sont adaptés mobile : plein écran ou bottom sheet, recherche en haut, actions claires en bas.
|
||||||
|
- Les changements texte nécessitent `Enregistrer`; les changements statut/priorité/sprint peuvent être immédiats avec feedback de sauvegarde.
|
||||||
|
- Les libellés visibles sont en français.
|
||||||
|
- L'assistant IA de ticket n'est pas requis pour la première livraison web de #86.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Cadrage technique — contrat web-server tickets/sprints
|
||||||
|
|
||||||
|
## Résultat de l'audit
|
||||||
|
|
||||||
|
Le socle applicatif tickets/sprints existe déjà dans `BackendCore` : création, lecture, liste, update, suppression, carnet, liens, assignation agent, création/liste/rename/reorder/suppression sprint, assignation/désassignation ticket→sprint. Le problème #86 n'est donc pas un manque de use case application ni de store : c'est un écart de driving adapter web.
|
||||||
|
|
||||||
|
Côté desktop, `crates/app-tauri/src/lib.rs` enregistre les commandes UI implémentées dans `crates/app-tauri/src/tickets.rs`. Côté web, `crates/web-server/src/lib.rs` expose seulement quelques commandes dans le dispatcher `POST /api/invoke` (`health`, `list_projects`, `open_project`, `get_project_work_state`, tâches de fond). Aucune commande `ticket_*` ou `sprint_*` n'y est reconnue aujourd'hui, donc le `HttpTicketGateway` web tombe en `UNKNOWN_COMMAND`.
|
||||||
|
|
||||||
|
Le frontend web est déjà prêt côté transport : `frontend/src/adapters/http/streamGateways.ts` contient `HttpTicketGateway`, câblé dans `frontend/src/adapters/http/index.ts`, et il appelle les mêmes noms de commandes que le desktop via `/api/invoke`. Le blocage principal est donc backend/contrat, pas layout/UI.
|
||||||
|
|
||||||
|
## Commandes web-server à ajouter
|
||||||
|
|
||||||
|
Rester sur le style RPC existant : ajouter des branches à `POST /api/invoke`, pas créer une nouvelle API REST `/api/tickets`.
|
||||||
|
|
||||||
|
Tickets :
|
||||||
|
|
||||||
|
- `ticket_create` — `{ request: { projectId, title, description?, priority?, status?, assignedAgentIds?, links? } }` → `TicketDto`.
|
||||||
|
- `ticket_read` — `{ request: { projectId, ref, includeCarnet? } }` → `TicketDto`.
|
||||||
|
- `ticket_list` — `{ request: { projectId, statuses?, priorities?, assignedAgentId?, sprintId?, text?, sort?, limit?, cursor? } }` → `TicketListDto`.
|
||||||
|
- `ticket_update` — `{ request: { projectId, ref, title?, description?, status?, priority?, assignedAgentIds?, expectedVersion } }` → `TicketDto`. Cette commande couvre l'édition statut/priorité côté UI ; les commandes séparées `idea_ticket_update_status` / `idea_ticket_update_priority` sont des tools MCP agent, pas des commandes UI Tauri.
|
||||||
|
- `ticket_delete` — `{ request: { projectId, ref } }` → vide/null.
|
||||||
|
- `ticket_read_carnet` — `{ request: { projectId, ref } }` → `TicketCarnetDto`.
|
||||||
|
- `ticket_update_carnet` — `{ request: { projectId, ref, carnet, expectedVersion } }` → `TicketDto`.
|
||||||
|
- `ticket_link` — `{ request: { projectId, ref, targetRef, kind, expectedVersion } }` → `TicketDto`.
|
||||||
|
- `ticket_unlink` — `{ request: { projectId, ref, targetRef, kind?, expectedVersion } }` → `TicketDto`.
|
||||||
|
- `ticket_assign` — `{ request: { projectId, ref, agentId, assigned, expectedVersion } }` → `TicketDto`.
|
||||||
|
- `ticket_assign_sprint` — `{ request: { projectId, ref, sprintId, expectedVersion } }` → `TicketDto`.
|
||||||
|
- `ticket_unassign_sprint` — `{ request: { projectId, ref, expectedVersion } }` → `TicketDto`.
|
||||||
|
|
||||||
|
Sprints :
|
||||||
|
|
||||||
|
- `sprint_create` — `{ request: { projectId, name, status? } }` → `SprintDto`.
|
||||||
|
- `sprint_list` — `{ request: { projectId } }` → `SprintListDto { items }`.
|
||||||
|
- `sprint_rename` — `{ request: { projectId, sprintId, name, expectedVersion } }` → `SprintDto`.
|
||||||
|
- `sprint_reorder` — `{ request: { projectId, orderedIds } }` → `SprintListDto { items }`.
|
||||||
|
- `sprint_delete` — `{ request: { projectId, sprintId } }` → vide/null ; le comportement existant conserve les tickets et les repasse en `Sans sprint`.
|
||||||
|
|
||||||
|
Commandes assistant ticket proches mais hors minimum #86 : `open_ticket_chat`, `close_ticket_chat`, puis le flux `sendTicketChat`/`chat.*`. Recommandation : ne pas les inclure dans le lot initial, car la demande vise l'édition tickets/sprints et le flux chat web est une surface live distincte.
|
||||||
|
|
||||||
|
## Factorisation nécessaire
|
||||||
|
|
||||||
|
Les DTO publics tickets/sprints (`TicketDto`, `TicketSummaryDto`, `TicketListDto`, `TicketCarnetDto`, `SprintDto`, `SprintListDto`, request DTOs, pagination/tri/parse helpers) vivent aujourd'hui dans `crates/app-tauri/src/tickets.rs`. `web-server` ne doit pas dépendre de `app-tauri`.
|
||||||
|
|
||||||
|
Option recommandée : déplacer le contrat pur tickets/sprints vers `backend` (`backend::dto` ou `backend::tickets`) puis faire consommer ce module par les deux driving adapters :
|
||||||
|
|
||||||
|
- `app-tauri` garde uniquement les wrappers `#[tauri::command]` ;
|
||||||
|
- `web-server` ajoute des helpers `invoke_ticket_*` / `invoke_sprint_*` ;
|
||||||
|
- les tests de pagination/tri/conflit actuellement proches du module Tauri migrent avec la logique pure.
|
||||||
|
|
||||||
|
Option minimale possible mais moins saine : dupliquer DTO/conversions dans `web-server`. À éviter, car cela crée deux contrats wire desktop/web à maintenir.
|
||||||
|
|
||||||
|
## Auth et sécurité web (#77)
|
||||||
|
|
||||||
|
Les nouvelles commandes doivent rester derrière le gate existant de `POST /api/invoke` : pairing device, cookie de session HttpOnly, contrôle d'origine/CORS, révocation device et logs sécurité. Ne pas exposer de mutation ticket/sprint en GET ni hors allowlist.
|
||||||
|
|
||||||
|
Point à arbitrer produit/sécurité : un device web appairé pourra modifier les fichiers projet `.ideai/tickets` et `.ideai/sprints`. C'est cohérent avec la demande, mais il n'existe pas actuellement de permission fine par device lecture seule/écriture. Si cette granularité est souhaitée, c'est un chantier séparé de policy device, pas un prérequis technique au #86.
|
||||||
|
|
||||||
|
## Événements live
|
||||||
|
|
||||||
|
Les domain events `issue*` et `sprint*` existent déjà dans `backend::events::DomainEventDto`, et le web-server relaie les événements domaine sur websocket `event.domain` en excluant seulement `PtyOutput`. Après exposition des commandes, les vues web peuvent réutiliser les helpers frontend `isTicketEvent` / `isSprintEvent` et refaire un refetch, sans nouveau canal événementiel.
|
||||||
|
|
||||||
|
## Tests backend attendus
|
||||||
|
|
||||||
|
- Non authentifié : `ticket_list` et une mutation ticket/sprint retournent `UNAUTHORIZED`.
|
||||||
|
- Authentifié : `ticket_list` et `sprint_list` retournent le même shape que Tauri.
|
||||||
|
- Cycle ticket : create → read → update titre/statut/priorité → carnet → link/unlink → delete.
|
||||||
|
- Cycle sprint : create → list → rename → reorder → assign ticket → unassign ticket → delete.
|
||||||
|
- Conflit `expectedVersion` propagé en `ErrorDto` cohérent avec le desktop.
|
||||||
|
- Les commandes hors allowlist restent `UNKNOWN_COMMAND`.
|
||||||
|
|
||||||
|
## Découpe proposée
|
||||||
|
|
||||||
|
Lot 1 — backend/contrat web : factorisation DTO, ajout des 17 commandes `ticket_*`/`sprint_*` dans `/api/invoke`, tests auth/lecture/mutation/conflit.
|
||||||
|
|
||||||
|
Lot 2 — surface web UX/frontend : implémenter les vues décrites ci-dessus sur le `HttpTicketGateway` existant, gérer responsive, confirmations et conflits.
|
||||||
|
|
||||||
|
Lot 3 optionnel — assistant ticket web : exposer et finaliser le flux chat seulement si la surface assistant est explicitement demandée.
|
||||||
16
.ideai/tickets/86/issue.md
Normal file
16
.ideai/tickets/86/issue.md
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
id: "fd0fa9e2-cdf3-4807-919a-f562c9b21026"
|
||||||
|
number: 86
|
||||||
|
title: "[UI] ajouter l'affichage et l'dition des tickets et des sprints à la version web"
|
||||||
|
status: "closed"
|
||||||
|
priority: "medium"
|
||||||
|
sprint: "028179b1-eaf4-41e9-9c1f-7c37125117e6"
|
||||||
|
links: []
|
||||||
|
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
||||||
|
createdBy: {"kind":"user"}
|
||||||
|
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
|
createdAt: 1784449151968
|
||||||
|
updatedAt: 1784612705899
|
||||||
|
version: 7
|
||||||
|
---
|
||||||
|
J'iamerais que la version web me permette aussi d'editer les tickets, d'en ajouter et d'en supprimer, ainsi que d'editer, ajouter ou supprimer des sprints
|
||||||
6
.ideai/tickets/87/carnet.md
Normal file
6
.ideai/tickets/87/carnet.md
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
issueRef: "#87"
|
||||||
|
version: 7
|
||||||
|
updatedBy: {"kind":"user"}
|
||||||
|
updatedAt: 1784650196560
|
||||||
|
---
|
||||||
16
.ideai/tickets/87/issue.md
Normal file
16
.ideai/tickets/87/issue.md
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
id: "0fabb0dd-9a83-4bc1-bbad-d96c794faed5"
|
||||||
|
number: 87
|
||||||
|
title: "[UI] sur l'affichage Web, la liste des background task polluent l'affchage"
|
||||||
|
status: "closed"
|
||||||
|
priority: "high"
|
||||||
|
sprint: "028179b1-eaf4-41e9-9c1f-7c37125117e6"
|
||||||
|
links: []
|
||||||
|
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
||||||
|
createdBy: {"kind":"user"}
|
||||||
|
updatedBy: {"kind":"user"}
|
||||||
|
createdAt: 1784451329374
|
||||||
|
updatedAt: 1784650196560
|
||||||
|
version: 7
|
||||||
|
---
|
||||||
|
Sur l'affichage Web, la liste qui affiche toutes les background task polluent l'affichage. Il faudrait que la liste soit par défaut repliée de façon a n'avoir dans un premier temps que la liste des agents
|
||||||
6
.ideai/tickets/89/carnet.md
Normal file
6
.ideai/tickets/89/carnet.md
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
issueRef: "#89"
|
||||||
|
version: 5
|
||||||
|
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
|
updatedAt: 1784661583231
|
||||||
|
---
|
||||||
16
.ideai/tickets/89/issue.md
Normal file
16
.ideai/tickets/89/issue.md
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
id: "1f3e913f-0896-43be-818a-614e75d1fe3f"
|
||||||
|
number: 89
|
||||||
|
title: "Pouvoir lancer le serveur au lancement IdeA"
|
||||||
|
status: "closed"
|
||||||
|
priority: "medium"
|
||||||
|
sprint: null
|
||||||
|
links: []
|
||||||
|
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
||||||
|
createdBy: {"kind":"user"}
|
||||||
|
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
|
createdAt: 1784649907432
|
||||||
|
updatedAt: 1784661583231
|
||||||
|
version: 5
|
||||||
|
---
|
||||||
|
J'aiemrais avoir une option pour pouvoir lancer le serveur web IdeA au lancement d'IdeA
|
||||||
6
.ideai/tickets/90/carnet.md
Normal file
6
.ideai/tickets/90/carnet.md
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
issueRef: "#90"
|
||||||
|
version: 5
|
||||||
|
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
|
updatedAt: 1784661583085
|
||||||
|
---
|
||||||
16
.ideai/tickets/90/issue.md
Normal file
16
.ideai/tickets/90/issue.md
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
id: "828e32cc-83af-4a59-99b2-d0dd3e496c5f"
|
||||||
|
number: 90
|
||||||
|
title: "Ajouter les menus manquantes à la version Web d'IdeA"
|
||||||
|
status: "closed"
|
||||||
|
priority: "high"
|
||||||
|
sprint: null
|
||||||
|
links: []
|
||||||
|
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
||||||
|
createdBy: {"kind":"user"}
|
||||||
|
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||||
|
createdAt: 1784649988741
|
||||||
|
updatedAt: 1784661583085
|
||||||
|
version: 5
|
||||||
|
---
|
||||||
|
J'iamerais que les menus qui ne sont pas encore disponibles dans la version Web le deviennent (J'aimerais que tous els menus du menus "Panneaux" soient disponibles dans la version web, ainsi que le setup des profils AI du menu Settings)
|
||||||
6
.ideai/tickets/91/carnet.md
Normal file
6
.ideai/tickets/91/carnet.md
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
issueRef: "#91"
|
||||||
|
version: 3
|
||||||
|
updatedBy: {"kind":"user"}
|
||||||
|
updatedAt: 1784652150003
|
||||||
|
---
|
||||||
16
.ideai/tickets/91/issue.md
Normal file
16
.ideai/tickets/91/issue.md
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
id: "2d54c254-d2e8-44b3-ac12-3bf20f818d23"
|
||||||
|
number: 91
|
||||||
|
title: "Sur la notification de fin de tache backend, mettre plutot les deux agents en conversation"
|
||||||
|
status: "open"
|
||||||
|
priority: "medium"
|
||||||
|
sprint: null
|
||||||
|
links: []
|
||||||
|
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
||||||
|
createdBy: {"kind":"user"}
|
||||||
|
updatedBy: {"kind":"user"}
|
||||||
|
createdAt: 1784652125081
|
||||||
|
updatedAt: 1784652150003
|
||||||
|
version: 3
|
||||||
|
---
|
||||||
|
Sur la notification de fin de tache backend, mettre plutot les deux agents en conveersation et qui a lancé l'appel (par exemple Main->DevBackend) pour que ça soit un peu plus explicite
|
||||||
@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"nextNumber": 80
|
"nextNumber": 92
|
||||||
}
|
}
|
||||||
@ -29,13 +29,13 @@
|
|||||||
"issueRef": "#3",
|
"issueRef": "#3",
|
||||||
"path": "3",
|
"path": "3",
|
||||||
"title": "[Différé — design à mûrir] Tâches de fond — dette B : retry durable après reboot",
|
"title": "[Différé — design à mûrir] Tâches de fond — dette B : retry durable après reboot",
|
||||||
"status": "open",
|
"status": "closed",
|
||||||
"priority": "low",
|
"priority": "low",
|
||||||
"sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2",
|
"sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2",
|
||||||
"assignedAgentIds": [
|
"assignedAgentIds": [
|
||||||
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||||
],
|
],
|
||||||
"updatedAt": 1784093835732
|
"updatedAt": 1784377533777
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"issueRef": "#4",
|
"issueRef": "#4",
|
||||||
@ -161,13 +161,13 @@
|
|||||||
"issueRef": "#14",
|
"issueRef": "#14",
|
||||||
"path": "14",
|
"path": "14",
|
||||||
"title": "Integration de profils IA locaux/LAN comme profils IdeA canoniques",
|
"title": "Integration de profils IA locaux/LAN comme profils IdeA canoniques",
|
||||||
"status": "qa",
|
"status": "closed",
|
||||||
"priority": "medium",
|
"priority": "medium",
|
||||||
"sprint": null,
|
"sprint": null,
|
||||||
"assignedAgentIds": [
|
"assignedAgentIds": [
|
||||||
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||||
],
|
],
|
||||||
"updatedAt": 1783456579694
|
"updatedAt": 1784449099987
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"issueRef": "#15",
|
"issueRef": "#15",
|
||||||
@ -469,7 +469,7 @@
|
|||||||
"priority": "medium",
|
"priority": "medium",
|
||||||
"sprint": null,
|
"sprint": null,
|
||||||
"assignedAgentIds": [],
|
"assignedAgentIds": [],
|
||||||
"updatedAt": 1783879315858
|
"updatedAt": 1784667145901
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"issueRef": "#44",
|
"issueRef": "#44",
|
||||||
@ -591,13 +591,13 @@
|
|||||||
"issueRef": "#55",
|
"issueRef": "#55",
|
||||||
"path": "55",
|
"path": "55",
|
||||||
"title": "[Bug] Error on loading local model",
|
"title": "[Bug] Error on loading local model",
|
||||||
"status": "inProgress",
|
"status": "closed",
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
"sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2",
|
"sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2",
|
||||||
"assignedAgentIds": [
|
"assignedAgentIds": [
|
||||||
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||||
],
|
],
|
||||||
"updatedAt": 1784097757001
|
"updatedAt": 1784649948611
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"issueRef": "#56",
|
"issueRef": "#56",
|
||||||
@ -625,33 +625,33 @@
|
|||||||
"issueRef": "#60",
|
"issueRef": "#60",
|
||||||
"path": "60",
|
"path": "60",
|
||||||
"title": "[Bug] Assistant IA d'édition de ticket : aucune réponse dans la conversation (stream sans Final non signalé)",
|
"title": "[Bug] Assistant IA d'édition de ticket : aucune réponse dans la conversation (stream sans Final non signalé)",
|
||||||
"status": "inProgress",
|
"status": "closed",
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
"sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2",
|
"sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2",
|
||||||
"assignedAgentIds": [],
|
"assignedAgentIds": [],
|
||||||
"updatedAt": 1784194089605
|
"updatedAt": 1784360139966
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"issueRef": "#61",
|
"issueRef": "#61",
|
||||||
"path": "61",
|
"path": "61",
|
||||||
"title": "[UI] rafraichissement des cellule",
|
"title": "[UI] rafraichissement des cellule",
|
||||||
"status": "open",
|
"status": "closed",
|
||||||
"priority": "low",
|
"priority": "low",
|
||||||
"sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2",
|
"sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2",
|
||||||
"assignedAgentIds": [
|
"assignedAgentIds": [
|
||||||
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||||
],
|
],
|
||||||
"updatedAt": 1784182133142
|
"updatedAt": 1784661823324
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"issueRef": "#62",
|
"issueRef": "#62",
|
||||||
"path": "62",
|
"path": "62",
|
||||||
"title": "[Sécurité/Cohérence] Identité requester explicite pour les sessions structurées + policy des tools OpenAI-compatible",
|
"title": "[Sécurité/Cohérence] Identité requester explicite pour les sessions structurées + policy des tools OpenAI-compatible",
|
||||||
"status": "open",
|
"status": "closed",
|
||||||
"priority": "medium",
|
"priority": "medium",
|
||||||
"sprint": null,
|
"sprint": null,
|
||||||
"assignedAgentIds": [],
|
"assignedAgentIds": [],
|
||||||
"updatedAt": 1784095187734
|
"updatedAt": 1784406936659
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"issueRef": "#63",
|
"issueRef": "#63",
|
||||||
@ -719,13 +719,13 @@
|
|||||||
"issueRef": "#69",
|
"issueRef": "#69",
|
||||||
"path": "69",
|
"path": "69",
|
||||||
"title": "Adaptibilité client téléphone",
|
"title": "Adaptibilité client téléphone",
|
||||||
"status": "qa",
|
"status": "closed",
|
||||||
"priority": "medium",
|
"priority": "medium",
|
||||||
"sprint": "028179b1-eaf4-41e9-9c1f-7c37125117e6",
|
"sprint": "028179b1-eaf4-41e9-9c1f-7c37125117e6",
|
||||||
"assignedAgentIds": [
|
"assignedAgentIds": [
|
||||||
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||||
],
|
],
|
||||||
"updatedAt": 1784207736267
|
"updatedAt": 1784449061397
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"issueRef": "#70",
|
"issueRef": "#70",
|
||||||
@ -783,51 +783,173 @@
|
|||||||
"issueRef": "#75",
|
"issueRef": "#75",
|
||||||
"path": "75",
|
"path": "75",
|
||||||
"title": "Écran d'appairage web : clavier numérique sur mobile alors que le code contient des lettres",
|
"title": "Écran d'appairage web : clavier numérique sur mobile alors que le code contient des lettres",
|
||||||
"status": "qa",
|
"status": "closed",
|
||||||
"priority": "medium",
|
"priority": "medium",
|
||||||
"sprint": null,
|
"sprint": null,
|
||||||
"assignedAgentIds": [],
|
"assignedAgentIds": [],
|
||||||
"updatedAt": 1784277530792
|
"updatedAt": 1784449082160
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"issueRef": "#76",
|
"issueRef": "#76",
|
||||||
"path": "76",
|
"path": "76",
|
||||||
"title": "Appairage : la comparaison du code est sensible à la casse côté serveur",
|
"title": "Appairage : la comparaison du code est sensible à la casse côté serveur",
|
||||||
"status": "open",
|
"status": "closed",
|
||||||
"priority": "low",
|
"priority": "low",
|
||||||
"sprint": null,
|
"sprint": null,
|
||||||
"assignedAgentIds": [],
|
"assignedAgentIds": [],
|
||||||
"updatedAt": 1784277540034
|
"updatedAt": 1784287679986
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"issueRef": "#77",
|
"issueRef": "#77",
|
||||||
"path": "77",
|
"path": "77",
|
||||||
"title": "Appairage : appareils enregistrés persistants, révocables, et code éphémère à usage unique",
|
"title": "Appairage : appareils enregistrés persistants, révocables, et code éphémère à usage unique",
|
||||||
"status": "qa",
|
"status": "closed",
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
"sprint": null,
|
"sprint": null,
|
||||||
"assignedAgentIds": [],
|
"assignedAgentIds": [],
|
||||||
"updatedAt": 1784287453378
|
"updatedAt": 1784449075576
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"issueRef": "#78",
|
"issueRef": "#78",
|
||||||
"path": "78",
|
"path": "78",
|
||||||
"title": "Settings desktop : sections en langues mélangées (« Appareils » à côté de « AI Profiles », « Deployment »)",
|
"title": "Settings desktop : sections en langues mélangées (« Appareils » à côté de « AI Profiles », « Deployment »)",
|
||||||
"status": "open",
|
"status": "closed",
|
||||||
"priority": "low",
|
"priority": "low",
|
||||||
"sprint": null,
|
"sprint": null,
|
||||||
"assignedAgentIds": [],
|
"assignedAgentIds": [],
|
||||||
"updatedAt": 1784284746767
|
"updatedAt": 1784379475877
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"issueRef": "#79",
|
"issueRef": "#79",
|
||||||
"path": "79",
|
"path": "79",
|
||||||
"title": "Test flaky : PermissionsPanel « saves project defaults » échoue par intermittence",
|
"title": "Test flaky : PermissionsPanel « saves project defaults » échoue par intermittence",
|
||||||
|
"status": "closed",
|
||||||
|
"priority": "low",
|
||||||
|
"sprint": null,
|
||||||
|
"assignedAgentIds": [],
|
||||||
|
"updatedAt": 1784379476483
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"issueRef": "#80",
|
||||||
|
"path": "80",
|
||||||
|
"title": "L'environnement de QA ne peut pas ouvrir de socket — il ne peut pas valider les features réseau",
|
||||||
|
"status": "open",
|
||||||
|
"priority": "medium",
|
||||||
|
"sprint": null,
|
||||||
|
"assignedAgentIds": [],
|
||||||
|
"updatedAt": 1784287697560
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"issueRef": "#81",
|
||||||
|
"path": "81",
|
||||||
|
"title": "MCP d'edition de templates",
|
||||||
|
"status": "closed",
|
||||||
|
"priority": "medium",
|
||||||
|
"sprint": null,
|
||||||
|
"assignedAgentIds": [
|
||||||
|
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||||
|
],
|
||||||
|
"updatedAt": 1784565918809
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"issueRef": "#82",
|
||||||
|
"path": "82",
|
||||||
|
"title": "Ajouter des permissions d'utilisations des tools MCP d'IdeA par agent",
|
||||||
|
"status": "closed",
|
||||||
|
"priority": "critical",
|
||||||
|
"sprint": null,
|
||||||
|
"assignedAgentIds": [],
|
||||||
|
"updatedAt": 1784410760329
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"issueRef": "#83",
|
||||||
|
"path": "83",
|
||||||
|
"title": "Popup pour vérifier la volonté de quitter l'app",
|
||||||
|
"status": "closed",
|
||||||
|
"priority": "medium",
|
||||||
|
"sprint": null,
|
||||||
|
"assignedAgentIds": [],
|
||||||
|
"updatedAt": 1784567952845
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"issueRef": "#84",
|
||||||
|
"path": "84",
|
||||||
|
"title": "[Bug] Reprise auto après limite de session ne se déclenche pas pour l'orchestrator (Main, profil Claude)",
|
||||||
|
"status": "open",
|
||||||
|
"priority": "high",
|
||||||
|
"sprint": null,
|
||||||
|
"assignedAgentIds": [],
|
||||||
|
"updatedAt": 1784377833773
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"issueRef": "#85",
|
||||||
|
"path": "85",
|
||||||
|
"title": "Test flaky : setCellAgent « changing the agent kills the previous PTY (Bug #3) » échoue en exécution shuffled",
|
||||||
"status": "open",
|
"status": "open",
|
||||||
"priority": "low",
|
"priority": "low",
|
||||||
"sprint": null,
|
"sprint": null,
|
||||||
"assignedAgentIds": [],
|
"assignedAgentIds": [],
|
||||||
"updatedAt": 1784287450082
|
"updatedAt": 1784379436323
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"issueRef": "#86",
|
||||||
|
"path": "86",
|
||||||
|
"title": "[UI] ajouter l'affichage et l'dition des tickets et des sprints à la version web",
|
||||||
|
"status": "closed",
|
||||||
|
"priority": "medium",
|
||||||
|
"sprint": "028179b1-eaf4-41e9-9c1f-7c37125117e6",
|
||||||
|
"assignedAgentIds": [
|
||||||
|
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||||
|
],
|
||||||
|
"updatedAt": 1784612705899
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"issueRef": "#87",
|
||||||
|
"path": "87",
|
||||||
|
"title": "[UI] sur l'affichage Web, la liste des background task polluent l'affchage",
|
||||||
|
"status": "closed",
|
||||||
|
"priority": "high",
|
||||||
|
"sprint": "028179b1-eaf4-41e9-9c1f-7c37125117e6",
|
||||||
|
"assignedAgentIds": [
|
||||||
|
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||||
|
],
|
||||||
|
"updatedAt": 1784650196560
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"issueRef": "#89",
|
||||||
|
"path": "89",
|
||||||
|
"title": "Pouvoir lancer le serveur au lancement IdeA",
|
||||||
|
"status": "closed",
|
||||||
|
"priority": "medium",
|
||||||
|
"sprint": null,
|
||||||
|
"assignedAgentIds": [
|
||||||
|
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||||
|
],
|
||||||
|
"updatedAt": 1784661583231
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"issueRef": "#90",
|
||||||
|
"path": "90",
|
||||||
|
"title": "Ajouter les menus manquantes à la version Web d'IdeA",
|
||||||
|
"status": "closed",
|
||||||
|
"priority": "high",
|
||||||
|
"sprint": null,
|
||||||
|
"assignedAgentIds": [
|
||||||
|
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||||
|
],
|
||||||
|
"updatedAt": 1784661583085
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"issueRef": "#91",
|
||||||
|
"path": "91",
|
||||||
|
"title": "Sur la notification de fin de tache backend, mettre plutot les deux agents en conversation",
|
||||||
|
"status": "open",
|
||||||
|
"priority": "medium",
|
||||||
|
"sprint": null,
|
||||||
|
"assignedAgentIds": [
|
||||||
|
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||||
|
],
|
||||||
|
"updatedAt": 1784652150003
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -1971,6 +1971,7 @@ dependencies = [
|
|||||||
"fastembed",
|
"fastembed",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"git2",
|
"git2",
|
||||||
|
"hex",
|
||||||
"landlock",
|
"landlock",
|
||||||
"notify",
|
"notify",
|
||||||
"portable-pty",
|
"portable-pty",
|
||||||
@ -1978,6 +1979,7 @@ dependencies = [
|
|||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
|||||||
@ -26,6 +26,7 @@ hex = "0.4"
|
|||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
subtle = "2"
|
subtle = "2"
|
||||||
getrandom = "0.3"
|
getrandom = "0.3"
|
||||||
|
http = "1"
|
||||||
# Local git via libgit2. Network features (https/ssh → openssl) are off for L8:
|
# Local git via libgit2. Network features (https/ssh → openssl) are off for L8:
|
||||||
# only local operations (status/commit/branch/checkout/log) are in scope; remote
|
# only local operations (status/commit/branch/checkout/log) are in scope; remote
|
||||||
# push/pull and static vendoring for the AppImage are deferred to L9/L11.
|
# push/pull and static vendoring for the AppImage are deferred to L9/L11.
|
||||||
|
|||||||
@ -32,12 +32,12 @@ tauri-plugin-dialog = { workspace = true }
|
|||||||
tokio = { workspace = true, features = ["io-std", "rt", "net"] }
|
tokio = { workspace = true, features = ["io-std", "rt", "net"] }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
http = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
bytes = "1.11"
|
bytes = "1.11"
|
||||||
cookie = "0.18"
|
cookie = "0.18"
|
||||||
http = "1.4"
|
|
||||||
http-body-util = "0.1"
|
http-body-util = "0.1"
|
||||||
# `AppAgentResumer` implements the application's async `AgentResumer` port (LS7).
|
# `AppAgentResumer` implements the application's async `AgentResumer` port (LS7).
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
|
|||||||
@ -21,6 +21,7 @@ pub mod events;
|
|||||||
pub mod mcp_bridge;
|
pub mod mcp_bridge;
|
||||||
pub mod mcp_endpoint;
|
pub mod mcp_endpoint;
|
||||||
pub mod openai_tools;
|
pub mod openai_tools;
|
||||||
|
pub mod plugins;
|
||||||
pub mod pty;
|
pub mod pty;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
@ -133,6 +134,9 @@ pub fn dispatch() -> ExitCode {
|
|||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.register_uri_scheme_protocol("idea-plugin", |ctx, request| {
|
||||||
|
plugins::plugin_asset_protocol(ctx.app_handle(), request)
|
||||||
|
})
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
// Resolve the machine-local IDE data directory (ARCHITECTURE §9.2)
|
// Resolve the machine-local IDE data directory (ARCHITECTURE §9.2)
|
||||||
// and build the composition root once the app handle exists, so the
|
// and build the composition root once the app handle exists, so the
|
||||||
@ -154,6 +158,7 @@ pub fn run() {
|
|||||||
events::spawn_relay(app.handle().clone(), &app_state.event_bus);
|
events::spawn_relay(app.handle().clone(), &app_state.event_bus);
|
||||||
|
|
||||||
let embedded_server = Arc::clone(&app_state.embedded_server);
|
let embedded_server = Arc::clone(&app_state.embedded_server);
|
||||||
|
let plugin_mcp_reconcile = Arc::clone(&app_state.reconcile_plugin_mcp_servers);
|
||||||
let core = app_state.core();
|
let core = app_state.core();
|
||||||
app.manage(app_state);
|
app.manage(app_state);
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
@ -165,6 +170,11 @@ pub fn run() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
if let Err(err) = plugin_mcp_reconcile.execute().await {
|
||||||
|
application::diag!("[plugins] MCP reconcile at boot failed: {err}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Kill all live PTYs cleanly when the main window is closing. This is
|
// Kill all live PTYs cleanly when the main window is closing. This is
|
||||||
// independent of the per-view (navigation/layout) lifecycle — those
|
// independent of the per-view (navigation/layout) lifecycle — those
|
||||||
@ -334,6 +344,14 @@ pub fn run() {
|
|||||||
commands::cancel_background_task,
|
commands::cancel_background_task,
|
||||||
commands::retry_background_task,
|
commands::retry_background_task,
|
||||||
commands::list_background_tasks,
|
commands::list_background_tasks,
|
||||||
|
plugins::plugin_list_plugins,
|
||||||
|
plugins::plugin_review_package,
|
||||||
|
plugins::plugin_install_from_archive,
|
||||||
|
plugins::plugin_install_from_directory,
|
||||||
|
plugins::plugin_set_enabled,
|
||||||
|
plugins::plugin_uninstall,
|
||||||
|
plugins::plugin_list_runtime_contributions,
|
||||||
|
plugins::plugin_open_plugins_folder,
|
||||||
commands::get_server_exposure_settings,
|
commands::get_server_exposure_settings,
|
||||||
commands::save_server_exposure_settings,
|
commands::save_server_exposure_settings,
|
||||||
commands::preview_server_exposure_settings,
|
commands::preview_server_exposure_settings,
|
||||||
|
|||||||
293
crates/app-tauri/src/plugins.rs
Normal file
293
crates/app-tauri/src/plugins.rs
Normal file
@ -0,0 +1,293 @@
|
|||||||
|
//! Plugin Tauri commands and asset protocol.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use application::{ReviewPluginPackageInput, SetPluginEnabledInput, UninstallPluginInput};
|
||||||
|
use backend::dto::{
|
||||||
|
ErrorDto, PluginAdminDto, PluginInstallResultDto, PluginReviewDto,
|
||||||
|
PluginRuntimeContributionCatalogDto, PluginUninstallResultDto, ReviewPluginPackageDto,
|
||||||
|
};
|
||||||
|
use domain::ports::{PluginManifestValidator, PluginPackageStore, PluginRegistryStore};
|
||||||
|
use domain::{PluginId, RelativePath};
|
||||||
|
use http::{header, Response, StatusCode};
|
||||||
|
use tauri::{AppHandle, Manager, State};
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// Lists installed plugins for the admin surface.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn plugin_list_plugins(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<Vec<PluginAdminDto>, ErrorDto> {
|
||||||
|
state
|
||||||
|
.list_plugins
|
||||||
|
.execute()
|
||||||
|
.await
|
||||||
|
.map(|plugins| plugins.into_iter().map(PluginAdminDto::from).collect())
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reviews a local plugin package without committing it.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn plugin_review_package(
|
||||||
|
input: ReviewPluginPackageDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<PluginReviewDto, ErrorDto> {
|
||||||
|
let input = match input.source_kind.as_str() {
|
||||||
|
"archive" => ReviewPluginPackageInput::Archive { path: input.path },
|
||||||
|
"directory" => ReviewPluginPackageInput::Directory { path: input.path },
|
||||||
|
other => {
|
||||||
|
return Err(ErrorDto::invalid(format!(
|
||||||
|
"invalid plugin source kind: {other}"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
state
|
||||||
|
.review_plugin_package
|
||||||
|
.execute(input)
|
||||||
|
.await
|
||||||
|
.map(PluginReviewDto::from)
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs a plugin from a local archive.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn plugin_install_from_archive(
|
||||||
|
path: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<PluginInstallResultDto, ErrorDto> {
|
||||||
|
state
|
||||||
|
.install_plugin_from_archive
|
||||||
|
.execute(path)
|
||||||
|
.await
|
||||||
|
.map(PluginInstallResultDto::from)
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs a plugin from a local directory snapshot.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn plugin_install_from_directory(
|
||||||
|
path: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<PluginInstallResultDto, ErrorDto> {
|
||||||
|
state
|
||||||
|
.install_plugin_from_directory
|
||||||
|
.execute(path)
|
||||||
|
.await
|
||||||
|
.map(PluginInstallResultDto::from)
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enables or disables a plugin.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn plugin_set_enabled(
|
||||||
|
plugin_id: String,
|
||||||
|
enabled: bool,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<PluginAdminDto, ErrorDto> {
|
||||||
|
state
|
||||||
|
.set_plugin_enabled
|
||||||
|
.execute(SetPluginEnabledInput { plugin_id, enabled })
|
||||||
|
.await
|
||||||
|
.map(PluginAdminDto::from)
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uninstalls a plugin.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn plugin_uninstall(
|
||||||
|
plugin_id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<PluginUninstallResultDto, ErrorDto> {
|
||||||
|
state
|
||||||
|
.uninstall_plugin
|
||||||
|
.execute(UninstallPluginInput { plugin_id })
|
||||||
|
.await
|
||||||
|
.map(PluginUninstallResultDto::from)
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists active runtime contributions for frontend bootstrap.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn plugin_list_runtime_contributions(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<PluginRuntimeContributionCatalogDto, ErrorDto> {
|
||||||
|
state
|
||||||
|
.list_plugin_runtime_contributions
|
||||||
|
.execute()
|
||||||
|
.await
|
||||||
|
.map(PluginRuntimeContributionCatalogDto::from)
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the plugin store folder, or one plugin folder when an id is provided.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn plugin_open_plugins_folder(
|
||||||
|
plugin_id: Option<String>,
|
||||||
|
app: AppHandle,
|
||||||
|
) -> Result<(), ErrorDto> {
|
||||||
|
let mut path = app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| ErrorDto::invalid(e.to_string()))?
|
||||||
|
.join("plugins");
|
||||||
|
if let Some(id) = plugin_id {
|
||||||
|
let id = PluginId::new(id).map_err(|e| ErrorDto::invalid(e.to_string()))?;
|
||||||
|
path = path.join("installed").join(id.as_str());
|
||||||
|
}
|
||||||
|
open_folder(&path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serves `idea-plugin://<pluginId>/<version>/<contentHash>/<relativePath>` after
|
||||||
|
/// checking registry state, hash and root confinement.
|
||||||
|
pub fn plugin_asset_protocol(
|
||||||
|
app: &AppHandle,
|
||||||
|
request: http::Request<Vec<u8>>,
|
||||||
|
) -> Response<Vec<u8>> {
|
||||||
|
match plugin_asset_response(app, request) {
|
||||||
|
Ok(response) => response,
|
||||||
|
Err((status, message)) => Response::builder()
|
||||||
|
.status(status)
|
||||||
|
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
|
||||||
|
.body(message.into_bytes())
|
||||||
|
.expect("valid protocol error response"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plugin_asset_response(
|
||||||
|
app: &AppHandle,
|
||||||
|
request: http::Request<Vec<u8>>,
|
||||||
|
) -> Result<Response<Vec<u8>>, (StatusCode, String)> {
|
||||||
|
let uri = request.uri();
|
||||||
|
let plugin_id = uri
|
||||||
|
.host()
|
||||||
|
.ok_or_else(|| (StatusCode::BAD_REQUEST, "missing plugin id".to_owned()))
|
||||||
|
.and_then(|raw| {
|
||||||
|
PluginId::new(raw.to_owned()).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))
|
||||||
|
})?;
|
||||||
|
let mut parts = uri.path().trim_start_matches('/').splitn(3, '/');
|
||||||
|
let version_segment = parts.next().unwrap_or_default();
|
||||||
|
let hash = parts.next().unwrap_or_default();
|
||||||
|
let rel = parts.next().unwrap_or_default();
|
||||||
|
if version_segment.is_empty() || hash.is_empty() {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"invalid plugin asset URL".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let rel =
|
||||||
|
RelativePath::new(rel.to_owned()).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
|
||||||
|
let state = app.state::<AppState>();
|
||||||
|
let allowed = tauri::async_runtime::block_on(asset_allowed(
|
||||||
|
&plugin_id,
|
||||||
|
hash,
|
||||||
|
&rel,
|
||||||
|
state.plugin_registry_store.as_ref(),
|
||||||
|
state.plugin_package_store.as_ref(),
|
||||||
|
state.plugin_manifest_validator.as_ref(),
|
||||||
|
))?;
|
||||||
|
if !allowed {
|
||||||
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"plugin asset is not active".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let app_data = app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
let root = app_data
|
||||||
|
.join("plugins")
|
||||||
|
.join("installed")
|
||||||
|
.join(plugin_id.as_str());
|
||||||
|
let target = root.join(rel.as_str());
|
||||||
|
let root = root
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
|
||||||
|
let target = target
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
|
||||||
|
if !target.starts_with(&root) {
|
||||||
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"asset escapes plugin root".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let bytes = std::fs::read(&target).map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header(header::CONTENT_TYPE, content_type(&target))
|
||||||
|
.body(bytes)
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn asset_allowed(
|
||||||
|
plugin_id: &PluginId,
|
||||||
|
hash: &str,
|
||||||
|
rel: &RelativePath,
|
||||||
|
registry_store: &dyn PluginRegistryStore,
|
||||||
|
package_store: &dyn PluginPackageStore,
|
||||||
|
validator: &dyn PluginManifestValidator,
|
||||||
|
) -> Result<bool, (StatusCode, String)> {
|
||||||
|
let registry = registry_store
|
||||||
|
.load_registry()
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
let Some(entry) = registry.find(plugin_id) else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
if !entry.lifecycle_state.is_runtime_active() || entry.content_hash.as_str() != hash {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let package = domain::PluginPackageRef {
|
||||||
|
plugin_id: Some(plugin_id.clone()),
|
||||||
|
root: plugin_id.as_str().to_owned(),
|
||||||
|
};
|
||||||
|
let manifest_bytes = package_store
|
||||||
|
.read_manifest(&package)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
|
||||||
|
let manifest = validator
|
||||||
|
.validate(&manifest_bytes.bytes, &package)
|
||||||
|
.map_err(|e| (StatusCode::FORBIDDEN, e.to_string()))?;
|
||||||
|
let declared_icon = manifest.icon.as_ref() == Some(rel);
|
||||||
|
let declared_main = manifest.main == *rel;
|
||||||
|
Ok(declared_main || declared_icon || rel.as_str().starts_with("assets/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn content_type(path: &Path) -> &'static str {
|
||||||
|
match path
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
{
|
||||||
|
"js" | "mjs" => "text/javascript; charset=utf-8",
|
||||||
|
"json" => "application/json; charset=utf-8",
|
||||||
|
"svg" => "image/svg+xml",
|
||||||
|
"png" => "image/png",
|
||||||
|
"jpg" | "jpeg" => "image/jpeg",
|
||||||
|
"webp" => "image/webp",
|
||||||
|
"css" => "text/css; charset=utf-8",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_folder(path: &PathBuf) -> Result<(), ErrorDto> {
|
||||||
|
let mut command = if cfg!(target_os = "macos") {
|
||||||
|
let mut c = std::process::Command::new("open");
|
||||||
|
c.arg(path);
|
||||||
|
c
|
||||||
|
} else if cfg!(target_os = "windows") {
|
||||||
|
let mut c = std::process::Command::new("cmd");
|
||||||
|
c.args(["/C", "start", ""]).arg(path);
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
let mut c = std::process::Command::new("xdg-open");
|
||||||
|
c.arg(path);
|
||||||
|
c
|
||||||
|
};
|
||||||
|
command
|
||||||
|
.spawn()
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| ErrorDto::invalid(e.to_string()))
|
||||||
|
}
|
||||||
65
crates/app-tauri/tests/dto_plugins.rs
Normal file
65
crates/app-tauri/tests/dto_plugins.rs
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
use app_tauri_lib::dto::{
|
||||||
|
PluginAdminDto, PluginContributionSummaryDto, PluginRuntimeContributionCatalogDto,
|
||||||
|
PluginRuntimePluginDto,
|
||||||
|
};
|
||||||
|
use domain::{PluginContributionSet, PluginLifecycleState, PluginTrustLevel};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plugin_admin_dto_serialises_exact_contract_shape() {
|
||||||
|
let dto = PluginAdminDto {
|
||||||
|
id: "dev.acme.gitgraph".to_owned(),
|
||||||
|
display_name: "Git Graph".to_owned(),
|
||||||
|
publisher: Some("Acme".to_owned()),
|
||||||
|
version: "1.2.3".to_owned(),
|
||||||
|
description: Some("Graph".to_owned()),
|
||||||
|
icon_url: Some("idea-plugin://dev.acme.gitgraph/1.2.3/abc/assets/icon.svg".to_owned()),
|
||||||
|
source_kind: "archive".to_owned(),
|
||||||
|
source_label: Some("/tmp/gitgraph.ideaplug".to_owned()),
|
||||||
|
lifecycle_state: PluginLifecycleState::PendingDisable,
|
||||||
|
enabled: false,
|
||||||
|
pending_enable_state: Some(false),
|
||||||
|
pending_uninstall: false,
|
||||||
|
restart_required: true,
|
||||||
|
trust_level: PluginTrustLevel::Full,
|
||||||
|
contribution_summary: PluginContributionSummaryDto {
|
||||||
|
top_level_menus: 1,
|
||||||
|
menu_items: 2,
|
||||||
|
layouts: 3,
|
||||||
|
mcp_servers: 4,
|
||||||
|
},
|
||||||
|
error: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let value = serde_json::to_value(dto).unwrap();
|
||||||
|
assert_eq!(value["displayName"], "Git Graph");
|
||||||
|
assert_eq!(value["lifecycleState"], "pending-disable");
|
||||||
|
assert_eq!(value["trustLevel"], "full");
|
||||||
|
assert_eq!(value["contributionSummary"]["topLevelMenus"], 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_catalog_dto_carries_bundle_hash_and_contributions() {
|
||||||
|
let dto = PluginRuntimeContributionCatalogDto {
|
||||||
|
plugins: vec![PluginRuntimePluginDto {
|
||||||
|
id: "dev.acme.gitgraph".to_owned(),
|
||||||
|
display_name: "Git Graph".to_owned(),
|
||||||
|
publisher: None,
|
||||||
|
version: "1.2.3".to_owned(),
|
||||||
|
bundle_url: "idea-plugin://dev.acme.gitgraph/1.2.3/abc/dist/index.js".to_owned(),
|
||||||
|
icon_url: None,
|
||||||
|
content_hash: "abc".to_owned(),
|
||||||
|
contributes: PluginContributionSet::default(),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
let value = serde_json::to_value(dto).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
value["plugins"][0]["bundleUrl"],
|
||||||
|
"idea-plugin://dev.acme.gitgraph/1.2.3/abc/dist/index.js"
|
||||||
|
);
|
||||||
|
assert_eq!(value["plugins"][0]["contentHash"], "abc");
|
||||||
|
assert!(value["plugins"][0]["contributes"]["menus"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.is_empty());
|
||||||
|
}
|
||||||
@ -27,6 +27,7 @@ pub mod memory;
|
|||||||
pub mod model_server;
|
pub mod model_server;
|
||||||
pub mod orchestrator;
|
pub mod orchestrator;
|
||||||
pub mod permission;
|
pub mod permission;
|
||||||
|
pub mod plugin;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
pub mod remote;
|
pub mod remote;
|
||||||
pub mod skill;
|
pub mod skill;
|
||||||
@ -139,6 +140,13 @@ pub use permission::{
|
|||||||
UpdateAgentPermissions, UpdateAgentPermissionsInput, UpdateProjectPermissions,
|
UpdateAgentPermissions, UpdateAgentPermissionsInput, UpdateProjectPermissions,
|
||||||
UpdateProjectPermissionsInput,
|
UpdateProjectPermissionsInput,
|
||||||
};
|
};
|
||||||
|
pub use plugin::{
|
||||||
|
InstallPluginFromArchive, InstallPluginFromDirectory, JsonPluginManifestValidator,
|
||||||
|
ListPluginRuntimeContributions, ListPlugins, PluginAdmin, PluginContributionSummary,
|
||||||
|
PluginInstallResult, PluginReview, PluginRuntimeCatalog, PluginRuntimePlugin,
|
||||||
|
ReconcilePluginMcpServers, ReviewPluginPackage, ReviewPluginPackageInput, SetPluginEnabled,
|
||||||
|
SetPluginEnabledInput, UninstallPlugin, UninstallPluginInput, UninstallPluginResult,
|
||||||
|
};
|
||||||
pub use project::{
|
pub use project::{
|
||||||
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
|
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
|
||||||
CreateProjectInput, CreateProjectOutput, ListProjects, ListProjectsOutput, OpenProject,
|
CreateProjectInput, CreateProjectOutput, ListProjects, ListProjectsOutput, OpenProject,
|
||||||
|
|||||||
1714
crates/application/src/plugin/mod.rs
Normal file
1714
crates/application/src/plugin/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
@ -629,6 +629,7 @@ fn leaf_state(fs: &FakeFs, node: NodeId) -> Option<(Option<String>, bool)> {
|
|||||||
Some((l.conversation_id.clone(), l.agent_was_running))
|
Some((l.conversation_id.clone(), l.agent_was_running))
|
||||||
}
|
}
|
||||||
LayoutNode::Leaf(_) => None,
|
LayoutNode::Leaf(_) => None,
|
||||||
|
LayoutNode::CustomPluginLayout(_) => None,
|
||||||
LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)),
|
LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)),
|
||||||
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)),
|
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)),
|
||||||
}
|
}
|
||||||
@ -645,6 +646,7 @@ fn leaf_engine_session(fs: &FakeFs, node: NodeId) -> Option<Option<String>> {
|
|||||||
match n {
|
match n {
|
||||||
LayoutNode::Leaf(l) if l.id == target => Some(l.engine_session_id.clone()),
|
LayoutNode::Leaf(l) if l.id == target => Some(l.engine_session_id.clone()),
|
||||||
LayoutNode::Leaf(_) => None,
|
LayoutNode::Leaf(_) => None,
|
||||||
|
LayoutNode::CustomPluginLayout(_) => None,
|
||||||
LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)),
|
LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)),
|
||||||
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)),
|
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -218,6 +218,7 @@ fn was_running(fs: &FakeFs, node: NodeId) -> Option<bool> {
|
|||||||
match node {
|
match node {
|
||||||
LayoutNode::Leaf(l) if l.id == target => Some(l.agent_was_running),
|
LayoutNode::Leaf(l) if l.id == target => Some(l.agent_was_running),
|
||||||
LayoutNode::Leaf(_) => None,
|
LayoutNode::Leaf(_) => None,
|
||||||
|
LayoutNode::CustomPluginLayout(_) => None,
|
||||||
LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)),
|
LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)),
|
||||||
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)),
|
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -222,6 +222,7 @@ fn was_running(fs: &FakeFs, node: NodeId) -> Option<bool> {
|
|||||||
match node {
|
match node {
|
||||||
LayoutNode::Leaf(l) if l.id == target => Some(l.agent_was_running),
|
LayoutNode::Leaf(l) if l.id == target => Some(l.agent_was_running),
|
||||||
LayoutNode::Leaf(_) => None,
|
LayoutNode::Leaf(_) => None,
|
||||||
|
LayoutNode::CustomPluginLayout(_) => None,
|
||||||
LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)),
|
LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)),
|
||||||
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)),
|
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,253 @@ use domain::{AgentBusyState, PageCursor, PageDirection, Project, ProjectId, Turn
|
|||||||
|
|
||||||
pub use crate::ticket_dto::*;
|
pub use crate::ticket_dto::*;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugins (#43)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Plugin contribution summary DTO.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginContributionSummaryDto {
|
||||||
|
/// Top-level menus count.
|
||||||
|
pub top_level_menus: usize,
|
||||||
|
/// Menu items count.
|
||||||
|
pub menu_items: usize,
|
||||||
|
/// Layouts count.
|
||||||
|
pub layouts: usize,
|
||||||
|
/// MCP servers count.
|
||||||
|
pub mcp_servers: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<application::PluginContributionSummary> for PluginContributionSummaryDto {
|
||||||
|
fn from(value: application::PluginContributionSummary) -> Self {
|
||||||
|
Self {
|
||||||
|
top_level_menus: value.top_level_menus,
|
||||||
|
menu_items: value.menu_items,
|
||||||
|
layouts: value.layouts,
|
||||||
|
mcp_servers: value.mcp_servers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Admin plugin DTO.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginAdminDto {
|
||||||
|
/// Plugin id.
|
||||||
|
pub id: String,
|
||||||
|
/// Display name.
|
||||||
|
pub display_name: String,
|
||||||
|
/// Publisher.
|
||||||
|
pub publisher: Option<String>,
|
||||||
|
/// Version.
|
||||||
|
pub version: String,
|
||||||
|
/// Description.
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// Icon URL.
|
||||||
|
pub icon_url: Option<String>,
|
||||||
|
/// Source kind.
|
||||||
|
pub source_kind: String,
|
||||||
|
/// Source label.
|
||||||
|
pub source_label: Option<String>,
|
||||||
|
/// Lifecycle state.
|
||||||
|
pub lifecycle_state: domain::PluginLifecycleState,
|
||||||
|
/// Enabled flag.
|
||||||
|
pub enabled: bool,
|
||||||
|
/// Pending enable state.
|
||||||
|
pub pending_enable_state: Option<bool>,
|
||||||
|
/// Pending uninstall flag.
|
||||||
|
pub pending_uninstall: bool,
|
||||||
|
/// Restart required flag.
|
||||||
|
pub restart_required: bool,
|
||||||
|
/// Trust level.
|
||||||
|
pub trust_level: domain::PluginTrustLevel,
|
||||||
|
/// Contribution summary.
|
||||||
|
pub contribution_summary: PluginContributionSummaryDto,
|
||||||
|
/// Optional error.
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<application::PluginAdmin> for PluginAdminDto {
|
||||||
|
fn from(value: application::PluginAdmin) -> Self {
|
||||||
|
Self {
|
||||||
|
id: value.id,
|
||||||
|
display_name: value.display_name,
|
||||||
|
publisher: value.publisher,
|
||||||
|
version: value.version,
|
||||||
|
description: value.description,
|
||||||
|
icon_url: value.icon_url,
|
||||||
|
source_kind: value.source_kind,
|
||||||
|
source_label: value.source_label,
|
||||||
|
lifecycle_state: value.lifecycle_state,
|
||||||
|
enabled: value.enabled,
|
||||||
|
pending_enable_state: value.pending_enable_state,
|
||||||
|
pending_uninstall: value.pending_uninstall,
|
||||||
|
restart_required: value.restart_required,
|
||||||
|
trust_level: value.trust_level,
|
||||||
|
contribution_summary: value.contribution_summary.into(),
|
||||||
|
error: value.error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Review request DTO.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ReviewPluginPackageDto {
|
||||||
|
/// Source kind: `archive` or `directory`.
|
||||||
|
pub source_kind: String,
|
||||||
|
/// Local source path.
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin review DTO.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginReviewDto {
|
||||||
|
/// Manifest id.
|
||||||
|
pub id: String,
|
||||||
|
/// Display name.
|
||||||
|
pub display_name: String,
|
||||||
|
/// Publisher.
|
||||||
|
pub publisher: Option<String>,
|
||||||
|
/// Version.
|
||||||
|
pub version: String,
|
||||||
|
/// Description.
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// Source kind.
|
||||||
|
pub source_kind: String,
|
||||||
|
/// Source label.
|
||||||
|
pub source_label: Option<String>,
|
||||||
|
/// Content hash.
|
||||||
|
pub content_hash: String,
|
||||||
|
/// Trust level.
|
||||||
|
pub trust_level: domain::PluginTrustLevel,
|
||||||
|
/// Summary.
|
||||||
|
pub contribution_summary: PluginContributionSummaryDto,
|
||||||
|
/// Manifest contributions.
|
||||||
|
pub contributes: domain::PluginContributionSet,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<application::PluginReview> for PluginReviewDto {
|
||||||
|
fn from(value: application::PluginReview) -> Self {
|
||||||
|
Self {
|
||||||
|
id: value.manifest.id.as_str().to_owned(),
|
||||||
|
display_name: value.manifest.display_name,
|
||||||
|
publisher: value.manifest.publisher,
|
||||||
|
version: value.manifest.version.as_str().to_owned(),
|
||||||
|
description: value.manifest.description,
|
||||||
|
source_kind: value.source.kind().to_owned(),
|
||||||
|
source_label: Some(value.source.label().to_owned()),
|
||||||
|
content_hash: value.content_hash,
|
||||||
|
trust_level: value.trust_level,
|
||||||
|
contribution_summary: value.contribution_summary.into(),
|
||||||
|
contributes: value.manifest.contributes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin install result DTO.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginInstallResultDto {
|
||||||
|
/// Installed plugin.
|
||||||
|
pub plugin: PluginAdminDto,
|
||||||
|
/// Review.
|
||||||
|
pub review: PluginReviewDto,
|
||||||
|
/// Restart required.
|
||||||
|
pub restart_required: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<application::PluginInstallResult> for PluginInstallResultDto {
|
||||||
|
fn from(value: application::PluginInstallResult) -> Self {
|
||||||
|
Self {
|
||||||
|
plugin: value.plugin.into(),
|
||||||
|
review: value.review.into(),
|
||||||
|
restart_required: value.restart_required,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin uninstall result DTO.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginUninstallResultDto {
|
||||||
|
/// Plugin id.
|
||||||
|
pub plugin_id: String,
|
||||||
|
/// Removal outcome.
|
||||||
|
pub removal_outcome: domain::RemovalOutcome,
|
||||||
|
/// Restart required.
|
||||||
|
pub restart_required: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<application::UninstallPluginResult> for PluginUninstallResultDto {
|
||||||
|
fn from(value: application::UninstallPluginResult) -> Self {
|
||||||
|
Self {
|
||||||
|
plugin_id: value.plugin_id,
|
||||||
|
removal_outcome: value.removal_outcome,
|
||||||
|
restart_required: value.restart_required,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runtime catalog DTO.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginRuntimeContributionCatalogDto {
|
||||||
|
/// Runtime plugins.
|
||||||
|
pub plugins: Vec<PluginRuntimePluginDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runtime plugin DTO.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginRuntimePluginDto {
|
||||||
|
/// Plugin id.
|
||||||
|
pub id: String,
|
||||||
|
/// Display name.
|
||||||
|
pub display_name: String,
|
||||||
|
/// Publisher.
|
||||||
|
pub publisher: Option<String>,
|
||||||
|
/// Version.
|
||||||
|
pub version: String,
|
||||||
|
/// Bundle URL.
|
||||||
|
pub bundle_url: String,
|
||||||
|
/// Icon URL.
|
||||||
|
pub icon_url: Option<String>,
|
||||||
|
/// Content hash.
|
||||||
|
pub content_hash: String,
|
||||||
|
/// Contributions.
|
||||||
|
pub contributes: domain::PluginContributionSet,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<application::PluginRuntimeCatalog> for PluginRuntimeContributionCatalogDto {
|
||||||
|
fn from(value: application::PluginRuntimeCatalog) -> Self {
|
||||||
|
Self {
|
||||||
|
plugins: value
|
||||||
|
.plugins
|
||||||
|
.into_iter()
|
||||||
|
.map(PluginRuntimePluginDto::from)
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<application::PluginRuntimePlugin> for PluginRuntimePluginDto {
|
||||||
|
fn from(value: application::PluginRuntimePlugin) -> Self {
|
||||||
|
Self {
|
||||||
|
id: value.id,
|
||||||
|
display_name: value.display_name,
|
||||||
|
publisher: value.publisher,
|
||||||
|
version: value.version,
|
||||||
|
bundle_url: value.bundle_url,
|
||||||
|
icon_url: value.icon_url,
|
||||||
|
content_hash: value.content_hash,
|
||||||
|
contributes: value.contributes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Request DTO for the `health` command.
|
/// Request DTO for the `health` command.
|
||||||
#[derive(Debug, Clone, Default, Deserialize)]
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|||||||
@ -119,6 +119,60 @@ pub enum DomainEventDto {
|
|||||||
/// Project id (UUID string).
|
/// Project id (UUID string).
|
||||||
project_id: String,
|
project_id: String,
|
||||||
},
|
},
|
||||||
|
/// A plugin was installed.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
PluginInstalled {
|
||||||
|
/// Plugin id.
|
||||||
|
plugin_id: String,
|
||||||
|
/// Installed version.
|
||||||
|
version: String,
|
||||||
|
},
|
||||||
|
/// A plugin was enabled.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
PluginEnabled {
|
||||||
|
/// Plugin id.
|
||||||
|
plugin_id: String,
|
||||||
|
},
|
||||||
|
/// A plugin was disabled.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
PluginDisabled {
|
||||||
|
/// Plugin id.
|
||||||
|
plugin_id: String,
|
||||||
|
/// Whether a restart is needed for full JS purge.
|
||||||
|
restart_required: bool,
|
||||||
|
},
|
||||||
|
/// A plugin was uninstalled.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
PluginUninstalled {
|
||||||
|
/// Plugin id.
|
||||||
|
plugin_id: String,
|
||||||
|
/// Whether a restart is needed for full JS purge.
|
||||||
|
restart_required: bool,
|
||||||
|
},
|
||||||
|
/// A plugin failed to load.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
PluginLoadFailed {
|
||||||
|
/// Plugin id.
|
||||||
|
plugin_id: String,
|
||||||
|
/// Failure reason.
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
/// A plugin MCP server started.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
PluginMcpServerStarted {
|
||||||
|
/// Plugin id.
|
||||||
|
plugin_id: String,
|
||||||
|
/// Server id.
|
||||||
|
server_id: String,
|
||||||
|
},
|
||||||
|
/// A plugin MCP server stopped.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
PluginMcpServerStopped {
|
||||||
|
/// Plugin id.
|
||||||
|
plugin_id: String,
|
||||||
|
/// Server id.
|
||||||
|
server_id: String,
|
||||||
|
},
|
||||||
/// An agent was launched.
|
/// An agent was launched.
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
AgentLaunched {
|
AgentLaunched {
|
||||||
@ -677,6 +731,45 @@ impl From<&DomainEvent> for DomainEventDto {
|
|||||||
DomainEvent::ProjectCreated { project_id } => Self::ProjectCreated {
|
DomainEvent::ProjectCreated { project_id } => Self::ProjectCreated {
|
||||||
project_id: project_id.to_string(),
|
project_id: project_id.to_string(),
|
||||||
},
|
},
|
||||||
|
DomainEvent::PluginInstalled { plugin_id, version } => Self::PluginInstalled {
|
||||||
|
plugin_id: plugin_id.to_string(),
|
||||||
|
version: version.as_str().to_owned(),
|
||||||
|
},
|
||||||
|
DomainEvent::PluginEnabled { plugin_id } => Self::PluginEnabled {
|
||||||
|
plugin_id: plugin_id.to_string(),
|
||||||
|
},
|
||||||
|
DomainEvent::PluginDisabled {
|
||||||
|
plugin_id,
|
||||||
|
restart_required,
|
||||||
|
} => Self::PluginDisabled {
|
||||||
|
plugin_id: plugin_id.to_string(),
|
||||||
|
restart_required: *restart_required,
|
||||||
|
},
|
||||||
|
DomainEvent::PluginUninstalled {
|
||||||
|
plugin_id,
|
||||||
|
restart_required,
|
||||||
|
} => Self::PluginUninstalled {
|
||||||
|
plugin_id: plugin_id.to_string(),
|
||||||
|
restart_required: *restart_required,
|
||||||
|
},
|
||||||
|
DomainEvent::PluginLoadFailed { plugin_id, reason } => Self::PluginLoadFailed {
|
||||||
|
plugin_id: plugin_id.to_string(),
|
||||||
|
reason: reason.clone(),
|
||||||
|
},
|
||||||
|
DomainEvent::PluginMcpServerStarted {
|
||||||
|
plugin_id,
|
||||||
|
server_id,
|
||||||
|
} => Self::PluginMcpServerStarted {
|
||||||
|
plugin_id: plugin_id.to_string(),
|
||||||
|
server_id: server_id.as_str().to_owned(),
|
||||||
|
},
|
||||||
|
DomainEvent::PluginMcpServerStopped {
|
||||||
|
plugin_id,
|
||||||
|
server_id,
|
||||||
|
} => Self::PluginMcpServerStopped {
|
||||||
|
plugin_id: plugin_id.to_string(),
|
||||||
|
server_id: server_id.as_str().to_owned(),
|
||||||
|
},
|
||||||
DomainEvent::AgentLaunched {
|
DomainEvent::AgentLaunched {
|
||||||
agent_id,
|
agent_id,
|
||||||
session_id,
|
session_id,
|
||||||
|
|||||||
@ -24,27 +24,30 @@ use application::{
|
|||||||
EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState, GetLiveStateLean, GetMemory,
|
EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState, GetLiveStateLean, GetMemory,
|
||||||
GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph,
|
GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph,
|
||||||
GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase,
|
GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase,
|
||||||
InspectConversation, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput,
|
InspectConversation, InstallPluginFromArchive, InstallPluginFromDirectory,
|
||||||
ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers,
|
JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents,
|
||||||
ListProfiles, ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates,
|
ListAgentsInput, ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories,
|
||||||
LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, LiveStateProvider,
|
ListModelServers, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects,
|
||||||
LiveStateReadProvider, LoadLayout, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow,
|
ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions,
|
||||||
MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant,
|
LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime,
|
||||||
OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry,
|
McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject,
|
||||||
ProposeContext, ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue,
|
OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
|
||||||
ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, ReadProjectContext,
|
PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext,
|
||||||
ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, ReconcileLiveState,
|
ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory,
|
||||||
ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameDevice,
|
ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts,
|
||||||
RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions,
|
ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn,
|
||||||
ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, RevokeAllDevices, RevokeDevice,
|
RecordTurnProvider, ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint,
|
||||||
|
ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks,
|
||||||
|
RestoreOpenWindows, RetryBackgroundTask, ReviewPluginPackage, RevokeAllDevices, RevokeDevice,
|
||||||
RotateConversationLog, SaveEmbedderProfile, SaveModelServer, SaveProfile, SessionLimitService,
|
RotateConversationLog, SaveEmbedderProfile, SaveModelServer, SaveProfile, SessionLimitService,
|
||||||
SetActiveLayout, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand,
|
SetActiveLayout, SetPluginEnabled, SnapshotOpenWindows, SnapshotRunningAgents,
|
||||||
StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession,
|
SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions,
|
||||||
SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent,
|
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
|
||||||
UnassignTicketFromSprint, UnlinkIssues, UpdateAgentContext, UpdateAgentMcpToolPermissions,
|
UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues,
|
||||||
UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory,
|
UpdateAgentContext, UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateIssue,
|
||||||
UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateSkill,
|
UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext,
|
||||||
UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateSkill, UpdateTemplate,
|
||||||
|
WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
@ -53,9 +56,10 @@ use domain::ports::{
|
|||||||
BackgroundTaskStore, Clock, DeviceSessionStore, Embedder, EmbedderEnvInspector,
|
BackgroundTaskStore, Clock, DeviceSessionStore, Embedder, EmbedderEnvInspector,
|
||||||
EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator,
|
EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator,
|
||||||
IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall, MemoryStore,
|
IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall, MemoryStore,
|
||||||
PermissionStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, ScheduledTask, Scheduler,
|
PermissionStore, PluginManifestValidator, PluginMcpSupervisor, PluginPackageStore,
|
||||||
SkillStore, SprintStore, StructuredSessionEnvironmentPreparer, TemplateStore, ToolInvoker,
|
PluginRegistryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, ScheduledTask,
|
||||||
WakeError, WakeReason, WindowStateStore,
|
Scheduler, SkillStore, SprintStore, StructuredSessionEnvironmentPreparer, TemplateStore,
|
||||||
|
ToolInvoker, WakeError, WakeReason, WindowStateStore,
|
||||||
};
|
};
|
||||||
use domain::profile::{
|
use domain::profile::{
|
||||||
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
||||||
@ -73,10 +77,11 @@ use infrastructure::{
|
|||||||
embedder_from_profile, AdaptiveMemoryRecall, BackgroundCompletionSink,
|
embedder_from_profile, AdaptiveMemoryRecall, BackgroundCompletionSink,
|
||||||
BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector,
|
BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector,
|
||||||
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbedderEnvProbe,
|
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbedderEnvProbe,
|
||||||
FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsDeviceSessionStore,
|
ExternalMcpPluginSupervisor, FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog,
|
||||||
FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator,
|
FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore,
|
||||||
FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry,
|
FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore,
|
||||||
FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsProjectStore,
|
FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore,
|
||||||
|
FsPluginPackageStore, FsPluginRegistryStore, FsProfileStore, FsProjectStore,
|
||||||
FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore, FsWindowStateStore,
|
FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore, FsWindowStateStore,
|
||||||
Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
|
Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
|
||||||
HttpOpenAiCompatibleProbe, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
HttpOpenAiCompatibleProbe, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
||||||
@ -1062,6 +1067,29 @@ pub struct BackendCore {
|
|||||||
pub retry_background_task: Arc<RetryBackgroundTask>,
|
pub retry_background_task: Arc<RetryBackgroundTask>,
|
||||||
/// Store handle used by `list_background_tasks` to read the task read-model.
|
/// Store handle used by `list_background_tasks` to read the task read-model.
|
||||||
pub background_task_store: Arc<dyn BackgroundTaskStore>,
|
pub background_task_store: Arc<dyn BackgroundTaskStore>,
|
||||||
|
// --- Plugins (#43) ---
|
||||||
|
/// Review a local plugin package before install.
|
||||||
|
pub review_plugin_package: Arc<ReviewPluginPackage>,
|
||||||
|
/// Install a plugin archive.
|
||||||
|
pub install_plugin_from_archive: Arc<InstallPluginFromArchive>,
|
||||||
|
/// Install a plugin directory snapshot.
|
||||||
|
pub install_plugin_from_directory: Arc<InstallPluginFromDirectory>,
|
||||||
|
/// List installed plugins for admin UI.
|
||||||
|
pub list_plugins: Arc<ListPlugins>,
|
||||||
|
/// Enable or disable a plugin.
|
||||||
|
pub set_plugin_enabled: Arc<SetPluginEnabled>,
|
||||||
|
/// Uninstall a plugin.
|
||||||
|
pub uninstall_plugin: Arc<UninstallPlugin>,
|
||||||
|
/// List runtime contributions for UI bootstrap.
|
||||||
|
pub list_plugin_runtime_contributions: Arc<ListPluginRuntimeContributions>,
|
||||||
|
/// Reconcile external MCP plugin servers.
|
||||||
|
pub reconcile_plugin_mcp_servers: Arc<ReconcilePluginMcpServers>,
|
||||||
|
/// Package store exposed for the Tauri asset protocol adapter.
|
||||||
|
pub plugin_package_store: Arc<FsPluginPackageStore>,
|
||||||
|
/// Registry store exposed for the Tauri asset protocol adapter.
|
||||||
|
pub plugin_registry_store: Arc<dyn PluginRegistryStore>,
|
||||||
|
/// Manifest validator exposed for the Tauri asset protocol adapter.
|
||||||
|
pub plugin_manifest_validator: Arc<dyn PluginManifestValidator>,
|
||||||
// --- Templates & sync (L7) ---
|
// --- Templates & sync (L7) ---
|
||||||
/// Create a template in the global store.
|
/// Create a template in the global store.
|
||||||
pub create_template: Arc<CreateTemplate>,
|
pub create_template: Arc<CreateTemplate>,
|
||||||
@ -1233,6 +1261,17 @@ impl BackendCore {
|
|||||||
let pair_attempt_limiter_port =
|
let pair_attempt_limiter_port =
|
||||||
Arc::clone(&pair_attempt_limiter) as Arc<dyn PairAttemptLimiter>;
|
Arc::clone(&pair_attempt_limiter) as Arc<dyn PairAttemptLimiter>;
|
||||||
let events_port = Arc::clone(&event_bus) as Arc<dyn EventBus>;
|
let events_port = Arc::clone(&event_bus) as Arc<dyn EventBus>;
|
||||||
|
let plugin_packages = Arc::new(FsPluginPackageStore::new(app_data_dir.clone()));
|
||||||
|
let plugin_registry = Arc::new(FsPluginRegistryStore::new(app_data_dir.clone()));
|
||||||
|
let plugin_validator =
|
||||||
|
Arc::new(JsonPluginManifestValidator::new(env!("CARGO_PKG_VERSION")));
|
||||||
|
let plugin_mcp_supervisor = Arc::new(ExternalMcpPluginSupervisor::new());
|
||||||
|
let plugin_package_store = Arc::clone(&plugin_packages) as Arc<dyn PluginPackageStore>;
|
||||||
|
let plugin_registry_store = Arc::clone(&plugin_registry) as Arc<dyn PluginRegistryStore>;
|
||||||
|
let plugin_manifest_validator =
|
||||||
|
Arc::clone(&plugin_validator) as Arc<dyn PluginManifestValidator>;
|
||||||
|
let plugin_mcp_supervisor_port =
|
||||||
|
Arc::clone(&plugin_mcp_supervisor) as Arc<dyn PluginMcpSupervisor>;
|
||||||
|
|
||||||
// --- Use cases (ports injected as Arc<dyn Port>) ---
|
// --- Use cases (ports injected as Arc<dyn Port>) ---
|
||||||
let health = Arc::new(HealthUseCase::new(
|
let health = Arc::new(HealthUseCase::new(
|
||||||
@ -2089,6 +2128,53 @@ impl BackendCore {
|
|||||||
Arc::clone(&background_runner) as Arc<dyn BackgroundCommandArchive>,
|
Arc::clone(&background_runner) as Arc<dyn BackgroundCommandArchive>,
|
||||||
Arc::clone(&spawn_background_command),
|
Arc::clone(&spawn_background_command),
|
||||||
));
|
));
|
||||||
|
let review_plugin_package = Arc::new(ReviewPluginPackage::new(
|
||||||
|
Arc::clone(&plugin_package_store),
|
||||||
|
Arc::clone(&plugin_manifest_validator),
|
||||||
|
));
|
||||||
|
let list_plugins = Arc::new(ListPlugins::new(
|
||||||
|
Arc::clone(&plugin_package_store),
|
||||||
|
Arc::clone(&plugin_registry_store),
|
||||||
|
Arc::clone(&plugin_manifest_validator),
|
||||||
|
));
|
||||||
|
let install_plugin_from_archive = Arc::new(InstallPluginFromArchive::new(
|
||||||
|
Arc::clone(&plugin_package_store),
|
||||||
|
Arc::clone(&plugin_registry_store),
|
||||||
|
Arc::clone(&plugin_manifest_validator),
|
||||||
|
Arc::clone(&events_port),
|
||||||
|
Arc::clone(&plugin_mcp_supervisor_port),
|
||||||
|
));
|
||||||
|
let install_plugin_from_directory = Arc::new(InstallPluginFromDirectory::new(
|
||||||
|
Arc::clone(&plugin_package_store),
|
||||||
|
Arc::clone(&plugin_registry_store),
|
||||||
|
Arc::clone(&plugin_manifest_validator),
|
||||||
|
Arc::clone(&events_port),
|
||||||
|
Arc::clone(&plugin_mcp_supervisor_port),
|
||||||
|
));
|
||||||
|
let set_plugin_enabled = Arc::new(SetPluginEnabled::new(
|
||||||
|
Arc::clone(&plugin_package_store),
|
||||||
|
Arc::clone(&plugin_registry_store),
|
||||||
|
Arc::clone(&plugin_manifest_validator),
|
||||||
|
Arc::clone(&events_port),
|
||||||
|
Arc::clone(&plugin_mcp_supervisor_port),
|
||||||
|
));
|
||||||
|
let uninstall_plugin = Arc::new(UninstallPlugin::new(
|
||||||
|
Arc::clone(&plugin_package_store),
|
||||||
|
Arc::clone(&plugin_registry_store),
|
||||||
|
Arc::clone(&events_port),
|
||||||
|
Arc::clone(&plugin_mcp_supervisor_port),
|
||||||
|
));
|
||||||
|
let list_plugin_runtime_contributions = Arc::new(ListPluginRuntimeContributions::new(
|
||||||
|
Arc::clone(&plugin_package_store),
|
||||||
|
Arc::clone(&plugin_registry_store),
|
||||||
|
Arc::clone(&plugin_manifest_validator),
|
||||||
|
));
|
||||||
|
let reconcile_plugin_mcp_servers = Arc::new(ReconcilePluginMcpServers::new(
|
||||||
|
Arc::clone(&plugin_package_store),
|
||||||
|
Arc::clone(&plugin_registry_store),
|
||||||
|
Arc::clone(&plugin_manifest_validator),
|
||||||
|
Arc::clone(&plugin_mcp_supervisor_port),
|
||||||
|
));
|
||||||
let background_wake = Arc::new(AgentWakeService::new(
|
let background_wake = Arc::new(AgentWakeService::new(
|
||||||
Arc::clone(&mediated_inbox) as Arc<dyn AgentInbox>,
|
Arc::clone(&mediated_inbox) as Arc<dyn AgentInbox>,
|
||||||
Arc::clone(&input_mediator),
|
Arc::clone(&input_mediator),
|
||||||
@ -2613,6 +2699,17 @@ impl BackendCore {
|
|||||||
cancel_background_task,
|
cancel_background_task,
|
||||||
retry_background_task,
|
retry_background_task,
|
||||||
background_task_store: Arc::clone(&background_tasks_port),
|
background_task_store: Arc::clone(&background_tasks_port),
|
||||||
|
review_plugin_package,
|
||||||
|
install_plugin_from_archive,
|
||||||
|
install_plugin_from_directory,
|
||||||
|
list_plugins,
|
||||||
|
set_plugin_enabled,
|
||||||
|
uninstall_plugin,
|
||||||
|
list_plugin_runtime_contributions,
|
||||||
|
reconcile_plugin_mcp_servers,
|
||||||
|
plugin_package_store: Arc::clone(&plugin_packages),
|
||||||
|
plugin_registry_store: Arc::clone(&plugin_registry_store),
|
||||||
|
plugin_manifest_validator: Arc::clone(&plugin_manifest_validator),
|
||||||
ticket_tool_binder,
|
ticket_tool_binder,
|
||||||
template_tool_binder,
|
template_tool_binder,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ use crate::ids::{
|
|||||||
use crate::issue::{IssueLinkKind, IssuePriority, IssueRef, IssueStatus, IssueVersion};
|
use crate::issue::{IssueLinkKind, IssuePriority, IssueRef, IssueStatus, IssueVersion};
|
||||||
use crate::mailbox::TicketId;
|
use crate::mailbox::TicketId;
|
||||||
use crate::memory::MemorySlug;
|
use crate::memory::MemorySlug;
|
||||||
|
use crate::plugin::{PluginId, PluginMcpServerId, PluginVersion};
|
||||||
use crate::sprint::{SprintOrder, SprintVersion};
|
use crate::sprint::{SprintOrder, SprintVersion};
|
||||||
use crate::template::TemplateVersion;
|
use crate::template::TemplateVersion;
|
||||||
|
|
||||||
@ -41,6 +42,53 @@ pub enum DomainEvent {
|
|||||||
/// The new project.
|
/// The new project.
|
||||||
project_id: ProjectId,
|
project_id: ProjectId,
|
||||||
},
|
},
|
||||||
|
/// A plugin was installed.
|
||||||
|
PluginInstalled {
|
||||||
|
/// Plugin id.
|
||||||
|
plugin_id: PluginId,
|
||||||
|
/// Installed version.
|
||||||
|
version: PluginVersion,
|
||||||
|
},
|
||||||
|
/// A plugin was enabled.
|
||||||
|
PluginEnabled {
|
||||||
|
/// Plugin id.
|
||||||
|
plugin_id: PluginId,
|
||||||
|
},
|
||||||
|
/// A plugin was disabled.
|
||||||
|
PluginDisabled {
|
||||||
|
/// Plugin id.
|
||||||
|
plugin_id: PluginId,
|
||||||
|
/// Whether a restart is needed for full JS purge.
|
||||||
|
restart_required: bool,
|
||||||
|
},
|
||||||
|
/// A plugin was uninstalled.
|
||||||
|
PluginUninstalled {
|
||||||
|
/// Plugin id.
|
||||||
|
plugin_id: PluginId,
|
||||||
|
/// Whether a restart is needed for full JS purge.
|
||||||
|
restart_required: bool,
|
||||||
|
},
|
||||||
|
/// A plugin failed to load.
|
||||||
|
PluginLoadFailed {
|
||||||
|
/// Plugin id.
|
||||||
|
plugin_id: PluginId,
|
||||||
|
/// Failure reason.
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
/// A plugin MCP server started.
|
||||||
|
PluginMcpServerStarted {
|
||||||
|
/// Plugin id.
|
||||||
|
plugin_id: PluginId,
|
||||||
|
/// Server id.
|
||||||
|
server_id: PluginMcpServerId,
|
||||||
|
},
|
||||||
|
/// A plugin MCP server stopped.
|
||||||
|
PluginMcpServerStopped {
|
||||||
|
/// Plugin id.
|
||||||
|
plugin_id: PluginId,
|
||||||
|
/// Server id.
|
||||||
|
server_id: PluginMcpServerId,
|
||||||
|
},
|
||||||
/// An agent was launched in a terminal.
|
/// An agent was launched in a terminal.
|
||||||
AgentLaunched {
|
AgentLaunched {
|
||||||
/// The agent.
|
/// The agent.
|
||||||
|
|||||||
@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::ids::{AgentId, NodeId, SessionId, TabId, WindowId};
|
use crate::ids::{AgentId, NodeId, SessionId, TabId, WindowId};
|
||||||
|
use crate::plugin::{PluginId, PluginLayoutType};
|
||||||
|
|
||||||
/// Direction of a [`SplitContainer`].
|
/// Direction of a [`SplitContainer`].
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@ -160,6 +161,24 @@ pub struct GridContainer {
|
|||||||
pub cells: Vec<GridCell>,
|
pub cells: Vec<GridCell>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persisted custom layout cell provided by an installed plugin.
|
||||||
|
///
|
||||||
|
/// The domain stores only the stable provider identity, layout type, and opaque
|
||||||
|
/// state. It never stores or resolves the React component used to render it.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CustomPluginLayoutCell {
|
||||||
|
/// Node identifier.
|
||||||
|
pub id: NodeId,
|
||||||
|
/// Provider plugin id.
|
||||||
|
pub plugin_id: PluginId,
|
||||||
|
/// Persisted layout type declared by the provider plugin.
|
||||||
|
pub layout_type: PluginLayoutType,
|
||||||
|
/// Opaque plugin-owned state.
|
||||||
|
#[serde(default)]
|
||||||
|
pub state: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
/// A node in the layout tree.
|
/// A node in the layout tree.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase", tag = "type", content = "node")]
|
#[serde(rename_all = "camelCase", tag = "type", content = "node")]
|
||||||
@ -170,6 +189,8 @@ pub enum LayoutNode {
|
|||||||
Split(SplitContainer),
|
Split(SplitContainer),
|
||||||
/// A spreadsheet-style grid.
|
/// A spreadsheet-style grid.
|
||||||
Grid(GridContainer),
|
Grid(GridContainer),
|
||||||
|
/// A plugin-provided custom layout leaf.
|
||||||
|
CustomPluginLayout(CustomPluginLayoutCell),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The root of a layout (one per tab).
|
/// The root of a layout (one per tab).
|
||||||
@ -663,6 +684,7 @@ impl LayoutTree {
|
|||||||
out.push((leaf.id, agent));
|
out.push((leaf.id, agent));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
LayoutNode::CustomPluginLayout(_) => {}
|
||||||
LayoutNode::Split(split) => {
|
LayoutNode::Split(split) => {
|
||||||
for child in &split.children {
|
for child in &split.children {
|
||||||
walk(&child.node, out);
|
walk(&child.node, out);
|
||||||
@ -770,6 +792,7 @@ impl LayoutTree {
|
|||||||
match n {
|
match n {
|
||||||
LayoutNode::Leaf(leaf) if leaf.id == id => Some(leaf),
|
LayoutNode::Leaf(leaf) if leaf.id == id => Some(leaf),
|
||||||
LayoutNode::Leaf(_) => None,
|
LayoutNode::Leaf(_) => None,
|
||||||
|
LayoutNode::CustomPluginLayout(_) => None,
|
||||||
LayoutNode::Split(split) => split.children.iter().find_map(|c| find(&c.node, id)),
|
LayoutNode::Split(split) => split.children.iter().find_map(|c| find(&c.node, id)),
|
||||||
LayoutNode::Grid(grid) => grid.cells.iter().find_map(|c| find(&c.node, id)),
|
LayoutNode::Grid(grid) => grid.cells.iter().find_map(|c| find(&c.node, id)),
|
||||||
}
|
}
|
||||||
@ -784,6 +807,7 @@ impl LayoutTree {
|
|||||||
match node {
|
match node {
|
||||||
LayoutNode::Leaf(leaf) if leaf.id == id => Some(leaf.session),
|
LayoutNode::Leaf(leaf) if leaf.id == id => Some(leaf.session),
|
||||||
LayoutNode::Leaf(_) => None,
|
LayoutNode::Leaf(_) => None,
|
||||||
|
LayoutNode::CustomPluginLayout(_) => None,
|
||||||
LayoutNode::Split(split) => split.children.iter().find_map(|c| find(&c.node, id)),
|
LayoutNode::Split(split) => split.children.iter().find_map(|c| find(&c.node, id)),
|
||||||
LayoutNode::Grid(grid) => grid.cells.iter().find_map(|c| find(&c.node, id)),
|
LayoutNode::Grid(grid) => grid.cells.iter().find_map(|c| find(&c.node, id)),
|
||||||
}
|
}
|
||||||
@ -797,6 +821,7 @@ impl LayoutTree {
|
|||||||
fn map_node(node: &LayoutNode, f: &mut impl FnMut(&LayoutNode) -> LayoutNode) -> LayoutNode {
|
fn map_node(node: &LayoutNode, f: &mut impl FnMut(&LayoutNode) -> LayoutNode) -> LayoutNode {
|
||||||
let rebuilt = match node {
|
let rebuilt = match node {
|
||||||
LayoutNode::Leaf(_) => node.clone(),
|
LayoutNode::Leaf(_) => node.clone(),
|
||||||
|
LayoutNode::CustomPluginLayout(_) => node.clone(),
|
||||||
LayoutNode::Split(split) => LayoutNode::Split(SplitContainer {
|
LayoutNode::Split(split) => LayoutNode::Split(SplitContainer {
|
||||||
id: split.id,
|
id: split.id,
|
||||||
direction: split.direction,
|
direction: split.direction,
|
||||||
@ -844,6 +869,7 @@ fn validate_node(
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
LayoutNode::CustomPluginLayout(_) => Ok(()),
|
||||||
LayoutNode::Split(split) => {
|
LayoutNode::Split(split) => {
|
||||||
if split.children.is_empty() {
|
if split.children.is_empty() {
|
||||||
return Err(LayoutError::EmptySplit);
|
return Err(LayoutError::EmptySplit);
|
||||||
|
|||||||
@ -54,6 +54,7 @@ pub mod memory_harvest;
|
|||||||
pub mod model_server;
|
pub mod model_server;
|
||||||
pub mod orchestrator;
|
pub mod orchestrator;
|
||||||
pub mod permission;
|
pub mod permission;
|
||||||
|
pub mod plugin;
|
||||||
pub mod ports;
|
pub mod ports;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
@ -178,10 +179,10 @@ pub use terminal::{PtySize, SessionKind, SessionStatus, TerminalSession};
|
|||||||
pub use git::GitRepository;
|
pub use git::GitRepository;
|
||||||
|
|
||||||
pub use layout::{
|
pub use layout::{
|
||||||
Direction, GridCell, GridContainer, LayoutError, LayoutNode, LayoutTree, LeafCell,
|
CustomPluginLayoutCell, Direction, GridCell, GridContainer, LayoutError, LayoutNode,
|
||||||
PersistedMonitorState, PersistedWindowKind, PersistedWindowPosition, PersistedWindowSize,
|
LayoutTree, LeafCell, PersistedMonitorState, PersistedWindowKind, PersistedWindowPosition,
|
||||||
PersistedWindowState, SplitContainer, Tab, WeightedChild, Window, WindowStateSnapshot,
|
PersistedWindowSize, PersistedWindowState, SplitContainer, Tab, WeightedChild, Window,
|
||||||
Workspace, WINDOW_STATE_SNAPSHOT_VERSION,
|
WindowStateSnapshot, Workspace, WINDOW_STATE_SNAPSHOT_VERSION,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use events::{DomainEvent, OrchestrationSource};
|
pub use events::{DomainEvent, OrchestrationSource};
|
||||||
@ -193,6 +194,16 @@ pub use permission::{
|
|||||||
ProjectPermissions, ProjectedFile, ProjectionContext, ProjectorKey, PERMISSIONS_VERSION,
|
ProjectPermissions, ProjectedFile, ProjectionContext, ProjectorKey, PERMISSIONS_VERSION,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub use plugin::{
|
||||||
|
ContentHash, CustomPluginLayout, PluginBundleUrl, PluginCapability, PluginCommandId,
|
||||||
|
PluginContributionSet, PluginDescriptor, PluginError, PluginId, PluginInstallSource,
|
||||||
|
PluginLayoutContribution, PluginLayoutType, PluginLifecycleState, PluginManifest,
|
||||||
|
PluginMcpServerContribution, PluginMcpServerId, PluginMcpServerSpec, PluginMcpStatus,
|
||||||
|
PluginMcpStatusSet, PluginMenuItemContribution, PluginPackageRef, PluginRegistry,
|
||||||
|
PluginRegistryEntry, PluginTopLevelMenuContribution, PluginTrustLevel, PluginVersion,
|
||||||
|
RelativePath, RemovalOutcome, StagedPluginPackage,
|
||||||
|
};
|
||||||
|
|
||||||
pub use sandbox::{
|
pub use sandbox::{
|
||||||
compile_sandbox_plan, PathAccess, PathGrant, SandboxContext, SandboxEnforcer, SandboxError,
|
compile_sandbox_plan, PathAccess, PathGrant, SandboxContext, SandboxEnforcer, SandboxError,
|
||||||
SandboxKind, SandboxPlan, SandboxStatus,
|
SandboxKind, SandboxPlan, SandboxStatus,
|
||||||
@ -210,11 +221,13 @@ pub use ports::{
|
|||||||
EmbedderEnvReport, EmbedderError, EmbedderProfileStore, EmbedderPromptDismissal,
|
EmbedderEnvReport, EmbedderError, EmbedderProfileStore, EmbedderPromptDismissal,
|
||||||
EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem, FsError, GitCommitInfo,
|
EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem, FsError, GitCommitInfo,
|
||||||
GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator, IssueNumberAllocator, IssueStore,
|
GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator, IssueNumberAllocator, IssueStore,
|
||||||
IssueStoreError, LiveStateStore, McpToolPermissionStore, MemoryError, MemoryQuery,
|
IssueStoreError, LiveStateStore, LocalPath, McpToolPermissionStore, MemoryError, MemoryQuery,
|
||||||
MemoryRecall, MemoryStore, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress,
|
MemoryRecall, MemoryStore, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress,
|
||||||
ModelArtifactResolution, Output, OutputStream, PermissionStore, PreparedContext, ProcessError,
|
ModelArtifactResolution, Output, OutputStream, PermissionStore, PluginManifestBytes,
|
||||||
ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError,
|
PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor,
|
||||||
RemoteHost, RemotePath, RuntimeError, ScheduledTask, Scheduler, SpawnSpec, SprintStore,
|
PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStoreError,
|
||||||
SprintStoreError, StoreError, StructuredSessionEnvironment,
|
PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle,
|
||||||
|
PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, ScheduledTask, Scheduler,
|
||||||
|
SpawnSpec, SprintStore, SprintStoreError, StoreError, StructuredSessionEnvironment,
|
||||||
StructuredSessionEnvironmentPreparer, TemplateStore, WindowStateStore,
|
StructuredSessionEnvironmentPreparer, TemplateStore, WindowStateStore,
|
||||||
};
|
};
|
||||||
|
|||||||
681
crates/domain/src/plugin.rs
Normal file
681
crates/domain/src/plugin.rs
Normal file
@ -0,0 +1,681 @@
|
|||||||
|
//! Plugin domain model and validated manifest image.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Plugin domain validation error.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||||
|
pub enum PluginError {
|
||||||
|
/// A required field is empty or malformed.
|
||||||
|
#[error("invalid plugin field {field}: {reason}")]
|
||||||
|
InvalidField {
|
||||||
|
/// Field name.
|
||||||
|
field: &'static str,
|
||||||
|
/// Human-readable reason.
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
/// A path escaped the plugin root.
|
||||||
|
#[error("invalid plugin path {field}: {path}")]
|
||||||
|
InvalidPath {
|
||||||
|
/// Field name.
|
||||||
|
field: &'static str,
|
||||||
|
/// Offending path.
|
||||||
|
path: String,
|
||||||
|
},
|
||||||
|
/// A contribution id is duplicated within the same plugin.
|
||||||
|
#[error("duplicate contribution id: {0}")]
|
||||||
|
DuplicateContribution(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable plugin identifier.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct PluginId(String);
|
||||||
|
|
||||||
|
impl PluginId {
|
||||||
|
/// Validates and creates a plugin id.
|
||||||
|
pub fn new(raw: impl Into<String>) -> Result<Self, PluginError> {
|
||||||
|
let raw = raw.into();
|
||||||
|
let valid_len = (3..=128).contains(&raw.len());
|
||||||
|
let mut chars = raw.chars();
|
||||||
|
let first = chars.next().unwrap_or('\0');
|
||||||
|
let valid_first = first.is_ascii_lowercase() || first.is_ascii_digit();
|
||||||
|
let valid_rest =
|
||||||
|
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '-');
|
||||||
|
if valid_len && valid_first && valid_rest {
|
||||||
|
Ok(Self(raw))
|
||||||
|
} else {
|
||||||
|
Err(PluginError::InvalidField {
|
||||||
|
field: "id",
|
||||||
|
reason: "expected [a-z0-9][a-z0-9.-]{2,127}".to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the raw id.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for PluginId {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
self.0.fmt(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SemVer-like plugin version.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct PluginVersion(String);
|
||||||
|
|
||||||
|
impl PluginVersion {
|
||||||
|
/// Validates and creates a version.
|
||||||
|
pub fn new(raw: impl Into<String>) -> Result<Self, PluginError> {
|
||||||
|
let raw = raw.into();
|
||||||
|
let core = raw.split_once('-').map_or(raw.as_str(), |(a, _)| a);
|
||||||
|
let parts: Vec<&str> = core.split('.').collect();
|
||||||
|
let ok = parts.len() == 3
|
||||||
|
&& parts.iter().all(|p| {
|
||||||
|
!p.is_empty()
|
||||||
|
&& p.chars().all(|c| c.is_ascii_digit())
|
||||||
|
&& (p == &"0" || !p.starts_with('0'))
|
||||||
|
});
|
||||||
|
if ok {
|
||||||
|
Ok(Self(raw))
|
||||||
|
} else {
|
||||||
|
Err(PluginError::InvalidField {
|
||||||
|
field: "version",
|
||||||
|
reason: "expected semantic version MAJOR.MINOR.PATCH".to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the raw version.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Relative path inside a plugin package.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct RelativePath(String);
|
||||||
|
|
||||||
|
impl RelativePath {
|
||||||
|
/// Validates a manifest path as relative, normalized, and root-confined.
|
||||||
|
pub fn new(raw: impl Into<String>) -> Result<Self, PluginError> {
|
||||||
|
let raw = raw.into();
|
||||||
|
if is_safe_relative_path(&raw) {
|
||||||
|
Ok(Self(raw.replace('\\', "/")))
|
||||||
|
} else {
|
||||||
|
Err(PluginError::InvalidPath {
|
||||||
|
field: "path",
|
||||||
|
path: raw,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the relative path.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_safe_relative_path(raw: &str) -> bool {
|
||||||
|
if raw.is_empty()
|
||||||
|
|| raw.starts_with('/')
|
||||||
|
|| raw.starts_with('\\')
|
||||||
|
|| raw.contains('\0')
|
||||||
|
|| raw.contains(':')
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
raw.replace('\\', "/")
|
||||||
|
.split('/')
|
||||||
|
.all(|p| !p.is_empty() && p != "." && p != "..")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Content hash of a plugin bundle/package.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct ContentHash(String);
|
||||||
|
|
||||||
|
impl ContentHash {
|
||||||
|
/// Creates a content hash value.
|
||||||
|
pub fn new(raw: impl Into<String>) -> Result<Self, PluginError> {
|
||||||
|
let raw = raw.into();
|
||||||
|
if !raw.is_empty() && raw.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||||
|
Ok(Self(raw))
|
||||||
|
} else {
|
||||||
|
Err(PluginError::InvalidField {
|
||||||
|
field: "contentHash",
|
||||||
|
reason: "expected non-empty hexadecimal hash".to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the hash.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// URL exposed to the frontend for a plugin bundle or asset.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct PluginBundleUrl(String);
|
||||||
|
|
||||||
|
impl PluginBundleUrl {
|
||||||
|
/// Creates a bundle URL.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(raw: impl Into<String>) -> Self {
|
||||||
|
Self(raw.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the URL.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Package reference managed by the store adapter.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginPackageRef {
|
||||||
|
/// Plugin id if the package is already committed.
|
||||||
|
pub plugin_id: Option<PluginId>,
|
||||||
|
/// Opaque adapter-owned root path label.
|
||||||
|
pub root: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Staged package produced by install/review.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct StagedPluginPackage {
|
||||||
|
/// Opaque staging root path.
|
||||||
|
pub root: String,
|
||||||
|
/// Original source label.
|
||||||
|
pub source: PluginInstallSource,
|
||||||
|
/// Hash of package contents at staging time.
|
||||||
|
pub content_hash: ContentHash,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin install source persisted for admin display.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||||
|
pub enum PluginInstallSource {
|
||||||
|
/// Local archive source.
|
||||||
|
Archive {
|
||||||
|
/// Human-readable path label.
|
||||||
|
path_label: String,
|
||||||
|
},
|
||||||
|
/// Local directory source.
|
||||||
|
Directory {
|
||||||
|
/// Human-readable path label.
|
||||||
|
path_label: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PluginInstallSource {
|
||||||
|
/// Returns the stable DTO source kind.
|
||||||
|
#[must_use]
|
||||||
|
pub fn kind(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Archive { .. } => "archive",
|
||||||
|
Self::Directory { .. } => "directory",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the optional label.
|
||||||
|
#[must_use]
|
||||||
|
pub fn label(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::Archive { path_label } | Self::Directory { path_label } => path_label,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin lifecycle state.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum PluginLifecycleState {
|
||||||
|
/// Installed and enabled.
|
||||||
|
Enabled,
|
||||||
|
/// Installed and disabled.
|
||||||
|
Disabled,
|
||||||
|
/// Enable requested.
|
||||||
|
PendingEnable,
|
||||||
|
/// Disable requested.
|
||||||
|
PendingDisable,
|
||||||
|
/// Uninstall requested.
|
||||||
|
PendingUninstall,
|
||||||
|
/// Invalid manifest/incompatible engine.
|
||||||
|
Invalid,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PluginLifecycleState {
|
||||||
|
/// Whether the plugin can expose runtime contributions.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_runtime_active(self) -> bool {
|
||||||
|
matches!(self, Self::Enabled | Self::PendingEnable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trust level supported by v1.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum PluginTrustLevel {
|
||||||
|
/// Full-trust plugin.
|
||||||
|
Full,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Declared plugin capability.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum PluginCapability {
|
||||||
|
/// UI bundle/contributions.
|
||||||
|
Ui,
|
||||||
|
/// External MCP server declarations.
|
||||||
|
Mcp,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin command id.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct PluginCommandId(String);
|
||||||
|
|
||||||
|
impl PluginCommandId {
|
||||||
|
/// Validates and creates a command id.
|
||||||
|
pub fn new(raw: impl Into<String>) -> Result<Self, PluginError> {
|
||||||
|
let raw = raw.into();
|
||||||
|
if raw.len() >= 3
|
||||||
|
&& raw
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':'))
|
||||||
|
{
|
||||||
|
Ok(Self(raw))
|
||||||
|
} else {
|
||||||
|
Err(PluginError::InvalidField {
|
||||||
|
field: "command",
|
||||||
|
reason: "invalid command id".to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the raw id.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin layout type.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct PluginLayoutType(String);
|
||||||
|
|
||||||
|
impl PluginLayoutType {
|
||||||
|
/// Validates and creates a layout type.
|
||||||
|
pub fn new(raw: impl Into<String>) -> Result<Self, PluginError> {
|
||||||
|
PluginCommandId::new(raw).map(|v| Self(v.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the raw type.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin MCP server id.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct PluginMcpServerId(String);
|
||||||
|
|
||||||
|
impl PluginMcpServerId {
|
||||||
|
/// Validates and creates a server id.
|
||||||
|
pub fn new(raw: impl Into<String>) -> Result<Self, PluginError> {
|
||||||
|
PluginCommandId::new(raw).map(|v| Self(v.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the raw id.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Top-level menu contribution.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginTopLevelMenuContribution {
|
||||||
|
/// Menu id.
|
||||||
|
pub id: String,
|
||||||
|
/// Display label.
|
||||||
|
pub label: String,
|
||||||
|
/// Must be true for this contribution kind.
|
||||||
|
pub top_level: bool,
|
||||||
|
/// Sort order.
|
||||||
|
#[serde(default)]
|
||||||
|
pub order: Option<i32>,
|
||||||
|
/// Optional icon path.
|
||||||
|
#[serde(default)]
|
||||||
|
pub icon: Option<RelativePath>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Menu item contribution.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginMenuItemContribution {
|
||||||
|
/// Item id.
|
||||||
|
pub id: String,
|
||||||
|
/// Target menu id.
|
||||||
|
pub target_menu_id: String,
|
||||||
|
/// Display label.
|
||||||
|
pub label: String,
|
||||||
|
/// Command id.
|
||||||
|
pub command: PluginCommandId,
|
||||||
|
/// Sort order.
|
||||||
|
#[serde(default)]
|
||||||
|
pub order: Option<i32>,
|
||||||
|
/// Optional icon path.
|
||||||
|
#[serde(default)]
|
||||||
|
pub icon: Option<RelativePath>,
|
||||||
|
/// Optional declarative condition.
|
||||||
|
#[serde(default)]
|
||||||
|
pub when: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Layout contribution.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginLayoutContribution {
|
||||||
|
/// Persisted layout type.
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub layout_type: PluginLayoutType,
|
||||||
|
/// Display label.
|
||||||
|
pub label: String,
|
||||||
|
/// Component export name.
|
||||||
|
pub component: String,
|
||||||
|
/// Sort order.
|
||||||
|
#[serde(default)]
|
||||||
|
pub order: Option<i32>,
|
||||||
|
/// Optional icon path.
|
||||||
|
#[serde(default)]
|
||||||
|
pub icon: Option<RelativePath>,
|
||||||
|
/// Optional declarative condition.
|
||||||
|
#[serde(default)]
|
||||||
|
pub when: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MCP server contribution.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginMcpServerContribution {
|
||||||
|
/// Server id.
|
||||||
|
pub id: PluginMcpServerId,
|
||||||
|
/// Display name.
|
||||||
|
pub display_name: String,
|
||||||
|
/// Executable command path or absolute command when allowed.
|
||||||
|
pub command: String,
|
||||||
|
/// Arguments.
|
||||||
|
#[serde(default)]
|
||||||
|
pub args: Vec<String>,
|
||||||
|
/// Environment variables.
|
||||||
|
#[serde(default)]
|
||||||
|
pub env: Vec<(String, String)>,
|
||||||
|
/// Working directory.
|
||||||
|
#[serde(default)]
|
||||||
|
pub cwd: Option<String>,
|
||||||
|
/// Transport, v1 only `stdio`.
|
||||||
|
pub transport: String,
|
||||||
|
/// Auto start flag.
|
||||||
|
#[serde(default)]
|
||||||
|
pub auto_start: bool,
|
||||||
|
/// Development-only escape hatch for absolute commands.
|
||||||
|
#[serde(default)]
|
||||||
|
pub allow_absolute_command: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validated contribution set.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginContributionSet {
|
||||||
|
/// Top-level menus.
|
||||||
|
#[serde(default)]
|
||||||
|
pub menus: Vec<PluginTopLevelMenuContribution>,
|
||||||
|
/// Menu items.
|
||||||
|
#[serde(default)]
|
||||||
|
pub menu_items: Vec<PluginMenuItemContribution>,
|
||||||
|
/// Layout contributions.
|
||||||
|
#[serde(default)]
|
||||||
|
pub layouts: Vec<PluginLayoutContribution>,
|
||||||
|
/// MCP server contributions.
|
||||||
|
#[serde(default)]
|
||||||
|
pub mcp_servers: Vec<PluginMcpServerContribution>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validated plugin manifest.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginManifest {
|
||||||
|
/// Manifest schema version.
|
||||||
|
pub idea_plugin_manifest_version: u32,
|
||||||
|
/// Plugin id.
|
||||||
|
pub id: PluginId,
|
||||||
|
/// Display name.
|
||||||
|
pub display_name: String,
|
||||||
|
/// Publisher.
|
||||||
|
#[serde(default)]
|
||||||
|
pub publisher: Option<String>,
|
||||||
|
/// Version.
|
||||||
|
pub version: PluginVersion,
|
||||||
|
/// Description.
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// Engine constraint for IdeA.
|
||||||
|
#[serde(default)]
|
||||||
|
pub engine_idea: Option<String>,
|
||||||
|
/// Main ESM bundle.
|
||||||
|
pub main: RelativePath,
|
||||||
|
/// Optional icon path.
|
||||||
|
#[serde(default)]
|
||||||
|
pub icon: Option<RelativePath>,
|
||||||
|
/// Trust level.
|
||||||
|
pub trust_level: PluginTrustLevel,
|
||||||
|
/// Capabilities.
|
||||||
|
#[serde(default)]
|
||||||
|
pub capabilities: Vec<PluginCapability>,
|
||||||
|
/// Contributions.
|
||||||
|
pub contributes: PluginContributionSet,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persisted registry entry.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginRegistryEntry {
|
||||||
|
/// Plugin id.
|
||||||
|
pub id: PluginId,
|
||||||
|
/// Lifecycle state.
|
||||||
|
pub lifecycle_state: PluginLifecycleState,
|
||||||
|
/// Source.
|
||||||
|
pub source: PluginInstallSource,
|
||||||
|
/// Content hash.
|
||||||
|
pub content_hash: ContentHash,
|
||||||
|
/// Restart required marker.
|
||||||
|
#[serde(default)]
|
||||||
|
pub restart_required: bool,
|
||||||
|
/// Optional error.
|
||||||
|
#[serde(default)]
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persisted global registry.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginRegistry {
|
||||||
|
/// Schema version.
|
||||||
|
pub version: u32,
|
||||||
|
/// Registry entries.
|
||||||
|
pub plugins: Vec<PluginRegistryEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PluginRegistry {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
version: 1,
|
||||||
|
plugins: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PluginRegistry {
|
||||||
|
/// Finds a plugin entry.
|
||||||
|
#[must_use]
|
||||||
|
pub fn find(&self, id: &PluginId) -> Option<&PluginRegistryEntry> {
|
||||||
|
self.plugins.iter().find(|p| &p.id == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upserts a registry entry, preserving id uniqueness.
|
||||||
|
pub fn upsert(&mut self, entry: PluginRegistryEntry) {
|
||||||
|
if let Some(slot) = self.plugins.iter_mut().find(|p| p.id == entry.id) {
|
||||||
|
*slot = entry;
|
||||||
|
} else {
|
||||||
|
self.plugins.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes an entry.
|
||||||
|
pub fn remove(&mut self, id: &PluginId) -> Option<PluginRegistryEntry> {
|
||||||
|
let index = self.plugins.iter().position(|p| &p.id == id)?;
|
||||||
|
Some(self.plugins.remove(index))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Admin descriptor assembled from manifest + registry.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginDescriptor {
|
||||||
|
/// Manifest.
|
||||||
|
pub manifest: PluginManifest,
|
||||||
|
/// Registry entry.
|
||||||
|
pub registry: PluginRegistryEntry,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PluginDescriptor {
|
||||||
|
/// Returns true if runtime contributions are active.
|
||||||
|
#[must_use]
|
||||||
|
pub fn exposes_runtime_contributions(&self) -> bool {
|
||||||
|
self.registry.lifecycle_state.is_runtime_active()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of removing package files.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum RemovalOutcome {
|
||||||
|
/// Files were removed.
|
||||||
|
Removed,
|
||||||
|
/// Files were moved to trash for later cleanup.
|
||||||
|
Tombstoned,
|
||||||
|
/// No package files existed.
|
||||||
|
NotFound,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolved MCP server spec passed to the supervisor.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginMcpServerSpec {
|
||||||
|
/// Plugin id.
|
||||||
|
pub plugin_id: PluginId,
|
||||||
|
/// Manifest server id.
|
||||||
|
pub server_id: PluginMcpServerId,
|
||||||
|
/// Stable external identity `plugin:<pluginId>:<serverId>`.
|
||||||
|
pub identity: String,
|
||||||
|
/// Display name.
|
||||||
|
pub display_name: String,
|
||||||
|
/// Command.
|
||||||
|
pub command: String,
|
||||||
|
/// Args.
|
||||||
|
pub args: Vec<String>,
|
||||||
|
/// Env.
|
||||||
|
pub env: Vec<(String, String)>,
|
||||||
|
/// Cwd.
|
||||||
|
pub cwd: String,
|
||||||
|
/// Transport.
|
||||||
|
pub transport: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MCP server status.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginMcpStatus {
|
||||||
|
/// Identity.
|
||||||
|
pub identity: String,
|
||||||
|
/// Running flag.
|
||||||
|
pub running: bool,
|
||||||
|
/// Optional error.
|
||||||
|
#[serde(default)]
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MCP status set.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginMcpStatusSet {
|
||||||
|
/// Statuses.
|
||||||
|
pub servers: Vec<PluginMcpStatus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Custom plugin layout persisted in a layout leaf's opaque content.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CustomPluginLayout {
|
||||||
|
/// Provider plugin id.
|
||||||
|
pub plugin_id: PluginId,
|
||||||
|
/// Provider display name if known.
|
||||||
|
#[serde(default)]
|
||||||
|
pub provider_plugin_display_name: Option<String>,
|
||||||
|
/// Layout type.
|
||||||
|
pub layout_type: PluginLayoutType,
|
||||||
|
/// Opaque plugin state.
|
||||||
|
#[serde(default)]
|
||||||
|
pub state: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plugin_id_pattern_is_enforced() {
|
||||||
|
assert!(PluginId::new("dev.acme.gitgraph").is_ok());
|
||||||
|
assert!(PluginId::new("De.acme").is_err());
|
||||||
|
assert!(PluginId::new("a").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relative_paths_cannot_escape_root() {
|
||||||
|
assert_eq!(
|
||||||
|
RelativePath::new("dist/index.js").unwrap().as_str(),
|
||||||
|
"dist/index.js"
|
||||||
|
);
|
||||||
|
assert!(RelativePath::new("../dist/index.js").is_err());
|
||||||
|
assert!(RelativePath::new("/tmp/index.js").is_err());
|
||||||
|
assert!(RelativePath::new("dist/../index.js").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disabled_and_pending_uninstall_are_not_runtime_active() {
|
||||||
|
assert!(!PluginLifecycleState::Disabled.is_runtime_active());
|
||||||
|
assert!(!PluginLifecycleState::PendingUninstall.is_runtime_active());
|
||||||
|
assert!(PluginLifecycleState::Enabled.is_runtime_active());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -49,6 +49,11 @@ use crate::model_server::{
|
|||||||
HfModelRef, LocalModelServerConfig, ModelPath, ModelServerEndpoint, ModelServerStatus,
|
HfModelRef, LocalModelServerConfig, ModelPath, ModelServerEndpoint, ModelServerStatus,
|
||||||
};
|
};
|
||||||
use crate::permission::ProjectPermissions;
|
use crate::permission::ProjectPermissions;
|
||||||
|
use crate::plugin::{
|
||||||
|
ContentHash, PluginBundleUrl, PluginId, PluginManifest, PluginMcpServerSpec,
|
||||||
|
PluginMcpStatusSet, PluginPackageRef, PluginRegistry, RelativePath, RemovalOutcome,
|
||||||
|
StagedPluginPackage,
|
||||||
|
};
|
||||||
use crate::profile::{AgentProfile, EmbedderProfile};
|
use crate::profile::{AgentProfile, EmbedderProfile};
|
||||||
use crate::project::{Project, ProjectPath};
|
use crate::project::{Project, ProjectPath};
|
||||||
use crate::remote::RemoteKind;
|
use crate::remote::RemoteKind;
|
||||||
@ -279,6 +284,166 @@ impl RemotePath {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opaque local path supplied by a driving adapter.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct LocalPath(pub String);
|
||||||
|
|
||||||
|
impl LocalPath {
|
||||||
|
/// Wraps a raw path.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(p: impl Into<String>) -> Self {
|
||||||
|
Self(p.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the path as a string slice.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw manifest bytes.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct PluginManifestBytes {
|
||||||
|
/// Bytes of `idea-plugin.json`.
|
||||||
|
pub bytes: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin package store errors.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||||
|
pub enum PluginStoreError {
|
||||||
|
/// Package was not found.
|
||||||
|
#[error("plugin package not found")]
|
||||||
|
NotFound,
|
||||||
|
/// Invalid input/source.
|
||||||
|
#[error("invalid plugin package: {0}")]
|
||||||
|
Invalid(String),
|
||||||
|
/// I/O error.
|
||||||
|
#[error("plugin package I/O error: {0}")]
|
||||||
|
Io(String),
|
||||||
|
/// Serialization or archive error.
|
||||||
|
#[error("plugin package format error: {0}")]
|
||||||
|
Format(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin registry errors.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||||
|
pub enum PluginRegistryError {
|
||||||
|
/// I/O error.
|
||||||
|
#[error("plugin registry I/O error: {0}")]
|
||||||
|
Io(String),
|
||||||
|
/// Serialization error.
|
||||||
|
#[error("plugin registry serialization error: {0}")]
|
||||||
|
Serialization(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manifest validation errors.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||||
|
pub enum PluginManifestError {
|
||||||
|
/// Invalid JSON.
|
||||||
|
#[error("invalid plugin manifest JSON: {0}")]
|
||||||
|
Json(String),
|
||||||
|
/// Invalid manifest data.
|
||||||
|
#[error("invalid plugin manifest: {0}")]
|
||||||
|
Invalid(String),
|
||||||
|
/// Incompatible engine.
|
||||||
|
#[error("incompatible IdeA engine: {0}")]
|
||||||
|
IncompatibleEngine(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin MCP errors.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||||
|
pub enum PluginMcpError {
|
||||||
|
/// Process/supervisor failure.
|
||||||
|
#[error("plugin MCP error: {0}")]
|
||||||
|
Process(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store for installed plugin packages under the global app data directory.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait PluginPackageStore: Send + Sync {
|
||||||
|
/// Lists committed package roots.
|
||||||
|
async fn list_installed(&self) -> Result<Vec<PluginPackageRef>, PluginStoreError>;
|
||||||
|
|
||||||
|
/// Reads `idea-plugin.json` from a package root.
|
||||||
|
async fn read_manifest(
|
||||||
|
&self,
|
||||||
|
package: &PluginPackageRef,
|
||||||
|
) -> Result<PluginManifestBytes, PluginStoreError>;
|
||||||
|
|
||||||
|
/// Stages an archive for validation.
|
||||||
|
async fn install_from_archive(
|
||||||
|
&self,
|
||||||
|
archive: &LocalPath,
|
||||||
|
) -> Result<StagedPluginPackage, PluginStoreError>;
|
||||||
|
|
||||||
|
/// Stages a directory snapshot for validation.
|
||||||
|
async fn install_from_directory(
|
||||||
|
&self,
|
||||||
|
dir: &LocalPath,
|
||||||
|
) -> Result<StagedPluginPackage, PluginStoreError>;
|
||||||
|
|
||||||
|
/// Commits a staged install into `installed/<pluginId>`.
|
||||||
|
async fn commit_install(
|
||||||
|
&self,
|
||||||
|
staged: StagedPluginPackage,
|
||||||
|
plugin_id: &PluginId,
|
||||||
|
) -> Result<PluginPackageRef, PluginStoreError>;
|
||||||
|
|
||||||
|
/// Removes a committed package.
|
||||||
|
async fn remove_package(
|
||||||
|
&self,
|
||||||
|
plugin_id: &PluginId,
|
||||||
|
) -> Result<RemovalOutcome, PluginStoreError>;
|
||||||
|
|
||||||
|
/// Builds a protocol URL for a bundle/asset.
|
||||||
|
fn bundle_url(
|
||||||
|
&self,
|
||||||
|
plugin_id: &PluginId,
|
||||||
|
entry: &RelativePath,
|
||||||
|
hash: &ContentHash,
|
||||||
|
) -> Result<PluginBundleUrl, PluginStoreError>;
|
||||||
|
|
||||||
|
/// Returns the global application data directory label when the adapter can
|
||||||
|
/// expose it for manifest variable substitution.
|
||||||
|
fn app_data_dir_label(&self) -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store for the global plugin registry.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait PluginRegistryStore: Send + Sync {
|
||||||
|
/// Loads the registry, returning an empty document if missing.
|
||||||
|
async fn load_registry(&self) -> Result<PluginRegistry, PluginRegistryError>;
|
||||||
|
|
||||||
|
/// Persists the registry.
|
||||||
|
async fn save_registry(&self, registry: &PluginRegistry) -> Result<(), PluginRegistryError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manifest parser and validator.
|
||||||
|
pub trait PluginManifestValidator: Send + Sync {
|
||||||
|
/// Validates raw manifest bytes.
|
||||||
|
fn validate(
|
||||||
|
&self,
|
||||||
|
bytes: &[u8],
|
||||||
|
package_root: &PluginPackageRef,
|
||||||
|
) -> Result<PluginManifest, PluginManifestError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supervisor for external MCP servers declared by plugins.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait PluginMcpSupervisor: Send + Sync {
|
||||||
|
/// Reconciles the desired active server set.
|
||||||
|
async fn reconcile(
|
||||||
|
&self,
|
||||||
|
active_servers: Vec<PluginMcpServerSpec>,
|
||||||
|
) -> Result<PluginMcpStatusSet, PluginMcpError>;
|
||||||
|
|
||||||
|
/// Stops every server owned by one plugin.
|
||||||
|
async fn stop_plugin(&self, plugin_id: &PluginId) -> Result<(), PluginMcpError>;
|
||||||
|
}
|
||||||
|
|
||||||
/// A single directory entry returned by [`FileSystem::list`].
|
/// A single directory entry returned by [`FileSystem::list`].
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct DirEntry {
|
pub struct DirEntry {
|
||||||
|
|||||||
@ -44,6 +44,7 @@ fn leaf_for(tree: &LayoutTree, id: domain::NodeId) -> Option<LeafCell> {
|
|||||||
match n {
|
match n {
|
||||||
LayoutNode::Leaf(l) if l.id == id => Some(l.clone()),
|
LayoutNode::Leaf(l) if l.id == id => Some(l.clone()),
|
||||||
LayoutNode::Leaf(_) => None,
|
LayoutNode::Leaf(_) => None,
|
||||||
|
LayoutNode::CustomPluginLayout(_) => None,
|
||||||
LayoutNode::Split(s) => s.children.iter().find_map(|c| walk(&c.node, id)),
|
LayoutNode::Split(s) => s.children.iter().find_map(|c| walk(&c.node, id)),
|
||||||
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| walk(&c.node, id)),
|
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| walk(&c.node, id)),
|
||||||
}
|
}
|
||||||
@ -235,6 +236,7 @@ fn session_for(tree: &LayoutTree, id: domain::NodeId) -> Option<domain::SessionI
|
|||||||
match n {
|
match n {
|
||||||
LayoutNode::Leaf(l) if l.id == id => Some(l.session),
|
LayoutNode::Leaf(l) if l.id == id => Some(l.session),
|
||||||
LayoutNode::Leaf(_) => None,
|
LayoutNode::Leaf(_) => None,
|
||||||
|
LayoutNode::CustomPluginLayout(_) => None,
|
||||||
LayoutNode::Split(s) => s.children.iter().find_map(|c| walk(&c.node, id)),
|
LayoutNode::Split(s) => s.children.iter().find_map(|c| walk(&c.node, id)),
|
||||||
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| walk(&c.node, id)),
|
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| walk(&c.node, id)),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,10 +4,10 @@
|
|||||||
mod helpers;
|
mod helpers;
|
||||||
|
|
||||||
use domain::{
|
use domain::{
|
||||||
Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, Direction,
|
Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection,
|
||||||
LayoutNode, LayoutTree, LeafCell, ManifestEntry, MarkdownDoc, Project, ProjectPath, RemoteRef,
|
CustomPluginLayoutCell, Direction, LayoutNode, LayoutTree, LeafCell, ManifestEntry,
|
||||||
SessionStrategy, Skill, SkillId, SkillRef, SkillScope, SplitContainer, SshAuth,
|
MarkdownDoc, PluginId, PluginLayoutType, Project, ProjectPath, RemoteRef, SessionStrategy,
|
||||||
TemplateVersion, WeightedChild,
|
Skill, SkillId, SkillRef, SkillScope, SplitContainer, SshAuth, TemplateVersion, WeightedChild,
|
||||||
};
|
};
|
||||||
use helpers::{node, session};
|
use helpers::{node, session};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@ -472,3 +472,29 @@ fn leaf_with_agent_roundtrip_and_omits_null() {
|
|||||||
"agent field should be omitted when None; json was {json2}"
|
"agent field should be omitted when None; json was {json2}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_plugin_layout_roundtrips_with_opaque_state() {
|
||||||
|
let tree = LayoutTree::new(LayoutNode::CustomPluginLayout(CustomPluginLayoutCell {
|
||||||
|
id: node(43),
|
||||||
|
plugin_id: PluginId::new("dev.acme.gitgraph").unwrap(),
|
||||||
|
layout_type: PluginLayoutType::new("dev.acme.gitgraph.layout").unwrap(),
|
||||||
|
state: serde_json::json!({"branch":"main","zoom":2}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert_eq!(roundtrip(&tree), tree);
|
||||||
|
let json = serde_json::to_string(&tree).unwrap();
|
||||||
|
assert!(
|
||||||
|
json.contains("\"type\":\"customPluginLayout\""),
|
||||||
|
"json was {json}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
json.contains("\"pluginId\":\"dev.acme.gitgraph\""),
|
||||||
|
"json was {json}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
json.contains("\"layoutType\":\"dev.acme.gitgraph.layout\""),
|
||||||
|
"json was {json}"
|
||||||
|
);
|
||||||
|
assert!(json.contains("\"state\""), "json was {json}");
|
||||||
|
}
|
||||||
|
|||||||
@ -21,6 +21,8 @@ futures-util = { workspace = true }
|
|||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
sha2 = { workspace = true }
|
||||||
|
hex = { workspace = true }
|
||||||
# Moteur regex du détecteur de limite de session niveau 2 (ARCHITECTURE §21.2-T2) :
|
# Moteur regex du détecteur de limite de session niveau 2 (ARCHITECTURE §21.2-T2) :
|
||||||
# le DOMAINE ne porte que la donnée du motif (`RateLimitPattern`) ; le moteur regex
|
# le DOMAINE ne porte que la donnée du motif (`RateLimitPattern`) ; le moteur regex
|
||||||
# vit ICI, jamais dans `domain` (qui reste dépendance-zéro). Version alignée sur
|
# vit ICI, jamais dans `domain` (qui reste dépendance-zéro). Version alignée sur
|
||||||
@ -49,6 +51,7 @@ fastembed = { version = "5", default-features = false, features = ["hf-hub-rustl
|
|||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
landlock = "0.4.5"
|
landlock = "0.4.5"
|
||||||
|
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# Real HTTP-backed embedders (`localServer` Ollama/llama.cpp, `api` OpenAI/Voyage…).
|
# Real HTTP-backed embedders (`localServer` Ollama/llama.cpp, `api` OpenAI/Voyage…).
|
||||||
# OFF by default: the founding posture is `none` ⇒ naïve recall, zero dependency.
|
# OFF by default: the founding posture is `none` ⇒ naïve recall, zero dependency.
|
||||||
|
|||||||
@ -30,6 +30,7 @@ pub mod model_server;
|
|||||||
pub mod orchestrator;
|
pub mod orchestrator;
|
||||||
pub mod pair_attempt_limiter;
|
pub mod pair_attempt_limiter;
|
||||||
pub mod permission;
|
pub mod permission;
|
||||||
|
pub mod plugin;
|
||||||
pub mod process;
|
pub mod process;
|
||||||
pub mod pty;
|
pub mod pty;
|
||||||
pub mod ratelimit;
|
pub mod ratelimit;
|
||||||
@ -80,6 +81,7 @@ pub use orchestrator::{
|
|||||||
};
|
};
|
||||||
pub use pair_attempt_limiter::InMemoryPairAttemptLimiter;
|
pub use pair_attempt_limiter::InMemoryPairAttemptLimiter;
|
||||||
pub use permission::{ClaudePermissionProjector, CodexPermissionProjector};
|
pub use permission::{ClaudePermissionProjector, CodexPermissionProjector};
|
||||||
|
pub use plugin::{ExternalMcpPluginSupervisor, FsPluginPackageStore, FsPluginRegistryStore};
|
||||||
pub use process::LocalProcessSpawner;
|
pub use process::LocalProcessSpawner;
|
||||||
pub use pty::PortablePtyAdapter;
|
pub use pty::PortablePtyAdapter;
|
||||||
pub use ratelimit::RateLimitParser;
|
pub use ratelimit::RateLimitParser;
|
||||||
|
|||||||
755
crates/infrastructure/src/plugin/mod.rs
Normal file
755
crates/infrastructure/src/plugin/mod.rs
Normal file
@ -0,0 +1,755 @@
|
|||||||
|
//! Filesystem plugin stores and external MCP supervisor.
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
|
use std::fs;
|
||||||
|
use std::io::Read;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Stdio;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::ports::{
|
||||||
|
LocalPath, PluginManifestBytes, PluginMcpError, PluginMcpSupervisor, PluginPackageStore,
|
||||||
|
PluginRegistryError, PluginRegistryStore, PluginStoreError,
|
||||||
|
};
|
||||||
|
use domain::{
|
||||||
|
ContentHash, PluginBundleUrl, PluginId, PluginInstallSource, PluginMcpServerSpec,
|
||||||
|
PluginMcpStatus, PluginMcpStatusSet, PluginPackageRef, PluginRegistry, RelativePath,
|
||||||
|
RemovalOutcome, StagedPluginPackage,
|
||||||
|
};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use tokio::process::Child;
|
||||||
|
|
||||||
|
const REGISTRY_FILE: &str = "registry.json";
|
||||||
|
const MANIFEST_FILE: &str = "idea-plugin.json";
|
||||||
|
|
||||||
|
/// Filesystem package store under app-data `plugins/`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct FsPluginPackageStore {
|
||||||
|
root: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FsPluginPackageStore {
|
||||||
|
/// Builds the store.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(app_data_dir: impl Into<PathBuf>) -> Self {
|
||||||
|
Self {
|
||||||
|
root: app_data_dir.into().join("plugins"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn installed_dir(&self) -> PathBuf {
|
||||||
|
self.root.join("installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the global plugin store root.
|
||||||
|
#[must_use]
|
||||||
|
pub fn plugins_root(&self) -> PathBuf {
|
||||||
|
self.root.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the managed directory for one installed plugin id.
|
||||||
|
#[must_use]
|
||||||
|
pub fn installed_plugin_dir(&self, plugin_id: &PluginId) -> PathBuf {
|
||||||
|
self.installed_dir().join(plugin_id.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn staging_dir(&self) -> PathBuf {
|
||||||
|
self.root.join("_staging")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trash_dir(&self) -> PathBuf {
|
||||||
|
self.root.join("_trash")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn package_root(&self, package: &PluginPackageRef) -> PathBuf {
|
||||||
|
match &package.plugin_id {
|
||||||
|
Some(id) => self.installed_dir().join(id.as_str()),
|
||||||
|
None => PathBuf::from(&package.root),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves an asset path if the request is root-confined.
|
||||||
|
pub fn resolve_asset_path(
|
||||||
|
&self,
|
||||||
|
plugin_id: &PluginId,
|
||||||
|
path: &RelativePath,
|
||||||
|
) -> Result<PathBuf, PluginStoreError> {
|
||||||
|
let root = self.installed_dir().join(plugin_id.as_str());
|
||||||
|
let candidate = root.join(path.as_str());
|
||||||
|
let canonical_root = root
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||||
|
let canonical = candidate
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||||
|
if canonical.starts_with(canonical_root) {
|
||||||
|
Ok(canonical)
|
||||||
|
} else {
|
||||||
|
Err(PluginStoreError::Invalid(
|
||||||
|
"plugin asset escapes package root".to_owned(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stage_root(&self) -> Result<PathBuf, PluginStoreError> {
|
||||||
|
let ts = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map_err(|e| PluginStoreError::Io(e.to_string()))?
|
||||||
|
.as_nanos();
|
||||||
|
let path = self.staging_dir().join(format!("stage-{ts}"));
|
||||||
|
fs::create_dir_all(&path).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl PluginPackageStore for FsPluginPackageStore {
|
||||||
|
async fn list_installed(&self) -> Result<Vec<PluginPackageRef>, PluginStoreError> {
|
||||||
|
let dir = self.installed_dir();
|
||||||
|
if !dir.exists() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for entry in fs::read_dir(dir).map_err(|e| PluginStoreError::Io(e.to_string()))? {
|
||||||
|
let entry = entry.map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||||
|
if !entry
|
||||||
|
.file_type()
|
||||||
|
.map_err(|e| PluginStoreError::Io(e.to_string()))?
|
||||||
|
.is_dir()
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = entry.file_name().to_string_lossy().into_owned();
|
||||||
|
if let Ok(id) = PluginId::new(name) {
|
||||||
|
out.push(PluginPackageRef {
|
||||||
|
plugin_id: Some(id),
|
||||||
|
root: entry.path().to_string_lossy().into_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_manifest(
|
||||||
|
&self,
|
||||||
|
package: &PluginPackageRef,
|
||||||
|
) -> Result<PluginManifestBytes, PluginStoreError> {
|
||||||
|
let bytes = fs::read(self.package_root(package).join(MANIFEST_FILE)).map_err(|e| {
|
||||||
|
if e.kind() == std::io::ErrorKind::NotFound {
|
||||||
|
PluginStoreError::NotFound
|
||||||
|
} else {
|
||||||
|
PluginStoreError::Io(e.to_string())
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
Ok(PluginManifestBytes { bytes })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn install_from_archive(
|
||||||
|
&self,
|
||||||
|
archive: &LocalPath,
|
||||||
|
) -> Result<StagedPluginPackage, PluginStoreError> {
|
||||||
|
let stage = self.stage_root()?;
|
||||||
|
let status = std::process::Command::new("unzip")
|
||||||
|
.arg("-q")
|
||||||
|
.arg(archive.as_str())
|
||||||
|
.arg("-d")
|
||||||
|
.arg(&stage)
|
||||||
|
.status()
|
||||||
|
.map_err(|e| PluginStoreError::Io(format!("failed to run unzip: {e}")))?;
|
||||||
|
if !status.success() {
|
||||||
|
return Err(PluginStoreError::Format(format!(
|
||||||
|
"unzip exited with status {status}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
ensure_manifest(&stage)?;
|
||||||
|
let content_hash = hash_dir(&stage)?;
|
||||||
|
Ok(StagedPluginPackage {
|
||||||
|
root: stage.to_string_lossy().into_owned(),
|
||||||
|
source: PluginInstallSource::Archive {
|
||||||
|
path_label: archive.as_str().to_owned(),
|
||||||
|
},
|
||||||
|
content_hash,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn install_from_directory(
|
||||||
|
&self,
|
||||||
|
dir: &LocalPath,
|
||||||
|
) -> Result<StagedPluginPackage, PluginStoreError> {
|
||||||
|
let source = PathBuf::from(dir.as_str());
|
||||||
|
if !source.is_dir() {
|
||||||
|
return Err(PluginStoreError::NotFound);
|
||||||
|
}
|
||||||
|
let stage = self.stage_root()?;
|
||||||
|
copy_dir_all(&source, &stage)?;
|
||||||
|
ensure_manifest(&stage)?;
|
||||||
|
let content_hash = hash_dir(&stage)?;
|
||||||
|
Ok(StagedPluginPackage {
|
||||||
|
root: stage.to_string_lossy().into_owned(),
|
||||||
|
source: PluginInstallSource::Directory {
|
||||||
|
path_label: dir.as_str().to_owned(),
|
||||||
|
},
|
||||||
|
content_hash,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn commit_install(
|
||||||
|
&self,
|
||||||
|
staged: StagedPluginPackage,
|
||||||
|
plugin_id: &PluginId,
|
||||||
|
) -> Result<PluginPackageRef, PluginStoreError> {
|
||||||
|
fs::create_dir_all(self.installed_dir())
|
||||||
|
.map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||||
|
let target = self.installed_dir().join(plugin_id.as_str());
|
||||||
|
let tmp = self
|
||||||
|
.installed_dir()
|
||||||
|
.join(format!(".{}-new", plugin_id.as_str()));
|
||||||
|
if tmp.exists() {
|
||||||
|
fs::remove_dir_all(&tmp).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||||
|
}
|
||||||
|
fs::rename(&staged.root, &tmp).or_else(|_| {
|
||||||
|
copy_dir_all(Path::new(&staged.root), &tmp)?;
|
||||||
|
fs::remove_dir_all(&staged.root).map_err(|e| PluginStoreError::Io(e.to_string()))
|
||||||
|
})?;
|
||||||
|
if target.exists() {
|
||||||
|
fs::remove_dir_all(&target).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||||
|
}
|
||||||
|
fs::rename(&tmp, &target).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||||
|
Ok(PluginPackageRef {
|
||||||
|
plugin_id: Some(plugin_id.clone()),
|
||||||
|
root: target.to_string_lossy().into_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_package(
|
||||||
|
&self,
|
||||||
|
plugin_id: &PluginId,
|
||||||
|
) -> Result<RemovalOutcome, PluginStoreError> {
|
||||||
|
let target = self.installed_dir().join(plugin_id.as_str());
|
||||||
|
if !target.exists() {
|
||||||
|
return Ok(RemovalOutcome::NotFound);
|
||||||
|
}
|
||||||
|
match fs::remove_dir_all(&target) {
|
||||||
|
Ok(()) => Ok(RemovalOutcome::Removed),
|
||||||
|
Err(_) => {
|
||||||
|
fs::create_dir_all(self.trash_dir())
|
||||||
|
.map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||||
|
let ts = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map_err(|e| PluginStoreError::Io(e.to_string()))?
|
||||||
|
.as_secs();
|
||||||
|
let tombstone = self
|
||||||
|
.trash_dir()
|
||||||
|
.join(format!("{}-{ts}", plugin_id.as_str()));
|
||||||
|
fs::rename(&target, tombstone).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||||
|
Ok(RemovalOutcome::Tombstoned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bundle_url(
|
||||||
|
&self,
|
||||||
|
plugin_id: &PluginId,
|
||||||
|
entry: &RelativePath,
|
||||||
|
hash: &ContentHash,
|
||||||
|
) -> Result<PluginBundleUrl, PluginStoreError> {
|
||||||
|
Ok(PluginBundleUrl::new(format!(
|
||||||
|
"idea-plugin://{}/current/{}{}{}",
|
||||||
|
plugin_id.as_str(),
|
||||||
|
hash.as_str(),
|
||||||
|
"/",
|
||||||
|
entry.as_str()
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app_data_dir_label(&self) -> Option<String> {
|
||||||
|
self.root
|
||||||
|
.parent()
|
||||||
|
.map(|p| p.to_string_lossy().into_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_manifest(root: &Path) -> Result<(), PluginStoreError> {
|
||||||
|
if root.join(MANIFEST_FILE).is_file() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(PluginStoreError::NotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_dir_all(source: &Path, target: &Path) -> Result<(), PluginStoreError> {
|
||||||
|
fs::create_dir_all(target).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||||
|
for entry in fs::read_dir(source).map_err(|e| PluginStoreError::Io(e.to_string()))? {
|
||||||
|
let entry = entry.map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||||
|
let ty = entry
|
||||||
|
.file_type()
|
||||||
|
.map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||||
|
let dest = target.join(entry.file_name());
|
||||||
|
if ty.is_dir() {
|
||||||
|
copy_dir_all(&entry.path(), &dest)?;
|
||||||
|
} else if ty.is_file() {
|
||||||
|
fs::copy(entry.path(), dest).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_dir(root: &Path) -> Result<ContentHash, PluginStoreError> {
|
||||||
|
let mut files = Vec::new();
|
||||||
|
collect_files(root, &mut files)?;
|
||||||
|
files.sort();
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
for path in files {
|
||||||
|
let rel = path
|
||||||
|
.strip_prefix(root)
|
||||||
|
.map_err(|e| PluginStoreError::Io(e.to_string()))?
|
||||||
|
.to_string_lossy()
|
||||||
|
.replace('\\', "/");
|
||||||
|
hasher.update(rel.as_bytes());
|
||||||
|
hasher.update([0]);
|
||||||
|
let mut file = fs::File::open(&path).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
file.read_to_end(&mut buf)
|
||||||
|
.map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||||
|
hasher.update(buf);
|
||||||
|
hasher.update([0]);
|
||||||
|
}
|
||||||
|
ContentHash::new(hex::encode(hasher.finalize()))
|
||||||
|
.map_err(|e| PluginStoreError::Invalid(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_files(root: &Path, files: &mut Vec<PathBuf>) -> Result<(), PluginStoreError> {
|
||||||
|
for entry in fs::read_dir(root).map_err(|e| PluginStoreError::Io(e.to_string()))? {
|
||||||
|
let entry = entry.map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||||
|
let ty = entry
|
||||||
|
.file_type()
|
||||||
|
.map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||||
|
if ty.is_dir() {
|
||||||
|
collect_files(&entry.path(), files)?;
|
||||||
|
} else if ty.is_file() {
|
||||||
|
files.push(entry.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filesystem registry store.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct FsPluginRegistryStore {
|
||||||
|
root: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FsPluginRegistryStore {
|
||||||
|
/// Builds the store.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(app_data_dir: impl Into<PathBuf>) -> Self {
|
||||||
|
Self {
|
||||||
|
root: app_data_dir.into().join("plugins"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path(&self) -> PathBuf {
|
||||||
|
self.root.join(REGISTRY_FILE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl PluginRegistryStore for FsPluginRegistryStore {
|
||||||
|
async fn load_registry(&self) -> Result<PluginRegistry, PluginRegistryError> {
|
||||||
|
match fs::read(self.path()) {
|
||||||
|
Ok(bytes) => serde_json::from_slice(&bytes)
|
||||||
|
.map_err(|e| PluginRegistryError::Serialization(e.to_string())),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(PluginRegistry::default()),
|
||||||
|
Err(e) => Err(PluginRegistryError::Io(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_registry(&self, registry: &PluginRegistry) -> Result<(), PluginRegistryError> {
|
||||||
|
fs::create_dir_all(&self.root).map_err(|e| PluginRegistryError::Io(e.to_string()))?;
|
||||||
|
let bytes = serde_json::to_vec_pretty(registry)
|
||||||
|
.map_err(|e| PluginRegistryError::Serialization(e.to_string()))?;
|
||||||
|
fs::write(self.path(), bytes).map_err(|e| PluginRegistryError::Io(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
trait ExternalMcpServerHandle: Send {
|
||||||
|
async fn stop(&mut self) -> Result<(), PluginMcpError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
trait ExternalMcpServerBridge: Send + Sync {
|
||||||
|
async fn start(
|
||||||
|
&self,
|
||||||
|
spec: &PluginMcpServerSpec,
|
||||||
|
) -> Result<Box<dyn ExternalMcpServerHandle>, PluginMcpError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProcessMcpServerHandle {
|
||||||
|
child: Child,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ExternalMcpServerHandle for ProcessMcpServerHandle {
|
||||||
|
async fn stop(&mut self) -> Result<(), PluginMcpError> {
|
||||||
|
self.child
|
||||||
|
.kill()
|
||||||
|
.await
|
||||||
|
.map_err(|e| PluginMcpError::Process(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct StdioExternalMcpServerBridge;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ExternalMcpServerBridge for StdioExternalMcpServerBridge {
|
||||||
|
async fn start(
|
||||||
|
&self,
|
||||||
|
spec: &PluginMcpServerSpec,
|
||||||
|
) -> Result<Box<dyn ExternalMcpServerHandle>, PluginMcpError> {
|
||||||
|
if spec.transport != "stdio" {
|
||||||
|
return Err(PluginMcpError::Process(format!(
|
||||||
|
"unsupported plugin MCP transport: {}",
|
||||||
|
spec.transport
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let mut cmd = tokio::process::Command::new(&spec.command);
|
||||||
|
cmd.args(&spec.args)
|
||||||
|
.current_dir(&spec.cwd)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
let env: BTreeMap<_, _> = spec.env.iter().cloned().collect();
|
||||||
|
cmd.envs(env);
|
||||||
|
let child = cmd
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| PluginMcpError::Process(e.to_string()))?;
|
||||||
|
Ok(Box::new(ProcessMcpServerHandle { child }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// External process supervisor for plugin MCP servers.
|
||||||
|
pub struct ExternalMcpPluginSupervisor {
|
||||||
|
bridge: Arc<dyn ExternalMcpServerBridge>,
|
||||||
|
children: Mutex<HashMap<String, Box<dyn ExternalMcpServerHandle>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExternalMcpPluginSupervisor {
|
||||||
|
/// Builds the supervisor.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
bridge: Arc::new(StdioExternalMcpServerBridge),
|
||||||
|
children: Mutex::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn with_bridge(bridge: Arc<dyn ExternalMcpServerBridge>) -> Self {
|
||||||
|
Self {
|
||||||
|
bridge,
|
||||||
|
children: Mutex::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ExternalMcpPluginSupervisor {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl PluginMcpSupervisor for ExternalMcpPluginSupervisor {
|
||||||
|
async fn reconcile(
|
||||||
|
&self,
|
||||||
|
active_servers: Vec<PluginMcpServerSpec>,
|
||||||
|
) -> Result<PluginMcpStatusSet, PluginMcpError> {
|
||||||
|
let desired: HashSet<String> = active_servers.iter().map(|s| s.identity.clone()).collect();
|
||||||
|
let to_stop = {
|
||||||
|
let children = self
|
||||||
|
.children
|
||||||
|
.lock()
|
||||||
|
.expect("plugin mcp supervisor poisoned");
|
||||||
|
children
|
||||||
|
.keys()
|
||||||
|
.filter(|id| !desired.contains(*id))
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
for id in to_stop {
|
||||||
|
let child = self
|
||||||
|
.children
|
||||||
|
.lock()
|
||||||
|
.expect("plugin mcp supervisor poisoned")
|
||||||
|
.remove(&id);
|
||||||
|
if let Some(mut child) = child {
|
||||||
|
let _ = child.stop().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut statuses = Vec::new();
|
||||||
|
for spec in active_servers {
|
||||||
|
let already = self
|
||||||
|
.children
|
||||||
|
.lock()
|
||||||
|
.expect("plugin mcp supervisor poisoned")
|
||||||
|
.contains_key(&spec.identity);
|
||||||
|
if already {
|
||||||
|
statuses.push(PluginMcpStatus {
|
||||||
|
identity: spec.identity,
|
||||||
|
running: true,
|
||||||
|
error: None,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match self.bridge.start(&spec).await {
|
||||||
|
Ok(handle) => {
|
||||||
|
self.children
|
||||||
|
.lock()
|
||||||
|
.expect("plugin mcp supervisor poisoned")
|
||||||
|
.insert(spec.identity.clone(), handle);
|
||||||
|
statuses.push(PluginMcpStatus {
|
||||||
|
identity: spec.identity,
|
||||||
|
running: true,
|
||||||
|
error: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => statuses.push(PluginMcpStatus {
|
||||||
|
identity: spec.identity,
|
||||||
|
running: false,
|
||||||
|
error: Some(e.to_string()),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(PluginMcpStatusSet { servers: statuses })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stop_plugin(&self, plugin_id: &PluginId) -> Result<(), PluginMcpError> {
|
||||||
|
let prefix = format!("plugin:{}:", plugin_id.as_str());
|
||||||
|
let ids = {
|
||||||
|
let children = self
|
||||||
|
.children
|
||||||
|
.lock()
|
||||||
|
.expect("plugin mcp supervisor poisoned");
|
||||||
|
children
|
||||||
|
.keys()
|
||||||
|
.filter(|id| id.starts_with(&prefix))
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
for id in ids {
|
||||||
|
let child = self
|
||||||
|
.children
|
||||||
|
.lock()
|
||||||
|
.expect("plugin mcp supervisor poisoned")
|
||||||
|
.remove(&id);
|
||||||
|
if let Some(mut child) = child {
|
||||||
|
child.stop().await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use domain::ports::PluginPackageStore;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
fn temp_dir(label: &str) -> PathBuf {
|
||||||
|
let n = TEMP_COUNTER.fetch_add(1, Ordering::SeqCst);
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"idea-plugin-test-{label}-{}-{n}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let _ = fs::remove_dir_all(&path);
|
||||||
|
fs::create_dir_all(&path).unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_plugin(root: &Path, main_body: &str) {
|
||||||
|
fs::create_dir_all(root.join("dist")).unwrap();
|
||||||
|
fs::write(root.join("dist/index.js"), main_body).unwrap();
|
||||||
|
fs::write(
|
||||||
|
root.join(MANIFEST_FILE),
|
||||||
|
r#"{"ideaPluginManifestVersion":1,"id":"dev.acme.test","displayName":"Test","version":"1.0.0","main":"dist/index.js","trustLevel":"full","contributes":{}}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct RecordingBridge {
|
||||||
|
started: Mutex<Vec<PluginMcpServerSpec>>,
|
||||||
|
stopped: Arc<Mutex<Vec<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RecordingHandle {
|
||||||
|
identity: String,
|
||||||
|
stopped: Arc<Mutex<Vec<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ExternalMcpServerHandle for RecordingHandle {
|
||||||
|
async fn stop(&mut self) -> Result<(), PluginMcpError> {
|
||||||
|
self.stopped.lock().unwrap().push(self.identity.clone());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ExternalMcpServerBridge for RecordingBridge {
|
||||||
|
async fn start(
|
||||||
|
&self,
|
||||||
|
spec: &PluginMcpServerSpec,
|
||||||
|
) -> Result<Box<dyn ExternalMcpServerHandle>, PluginMcpError> {
|
||||||
|
assert_eq!(spec.transport, "stdio");
|
||||||
|
self.started.lock().unwrap().push(spec.clone());
|
||||||
|
Ok(Box::new(RecordingHandle {
|
||||||
|
identity: spec.identity.clone(),
|
||||||
|
stopped: Arc::clone(&self.stopped),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mcp_spec(plugin_id: &str, server_id: &str) -> PluginMcpServerSpec {
|
||||||
|
let plugin_id = PluginId::new(plugin_id).unwrap();
|
||||||
|
let server_id = domain::PluginMcpServerId::new(server_id).unwrap();
|
||||||
|
PluginMcpServerSpec {
|
||||||
|
identity: format!("plugin:{}:{}", plugin_id.as_str(), server_id.as_str()),
|
||||||
|
plugin_id,
|
||||||
|
server_id,
|
||||||
|
display_name: "Plugin Tools".to_owned(),
|
||||||
|
command: "/plugin/servers/tool".to_owned(),
|
||||||
|
args: vec!["--stdio".to_owned()],
|
||||||
|
env: vec![("PLUGIN_ROOT".to_owned(), "/plugin".to_owned())],
|
||||||
|
cwd: "/plugin".to_owned(),
|
||||||
|
transport: "stdio".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn installs_directory_snapshot_and_uninstalls_cleanly() {
|
||||||
|
let app = temp_dir("app");
|
||||||
|
let source = temp_dir("source");
|
||||||
|
write_plugin(&source, "one");
|
||||||
|
let store = FsPluginPackageStore::new(&app);
|
||||||
|
let staged = store
|
||||||
|
.install_from_directory(&LocalPath::new(source.to_string_lossy()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_ne!(staged.root, source.to_string_lossy());
|
||||||
|
let id = PluginId::new("dev.acme.test").unwrap();
|
||||||
|
let package = store.commit_install(staged, &id).await.unwrap();
|
||||||
|
assert!(PathBuf::from(package.root).join(MANIFEST_FILE).exists());
|
||||||
|
assert_eq!(
|
||||||
|
store.remove_package(&id).await.unwrap(),
|
||||||
|
RemovalOutcome::Removed
|
||||||
|
);
|
||||||
|
assert!(!app.join("plugins/installed/dev.acme.test").exists());
|
||||||
|
let _ = fs::remove_dir_all(app);
|
||||||
|
let _ = fs::remove_dir_all(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn content_hash_changes_when_bundle_changes() {
|
||||||
|
let app = temp_dir("hash-app");
|
||||||
|
let source = temp_dir("hash-source");
|
||||||
|
write_plugin(&source, "one");
|
||||||
|
let store = FsPluginPackageStore::new(&app);
|
||||||
|
let first = store
|
||||||
|
.install_from_directory(&LocalPath::new(source.to_string_lossy()))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.content_hash;
|
||||||
|
fs::write(source.join("dist/index.js"), "two").unwrap();
|
||||||
|
let second = store
|
||||||
|
.install_from_directory(&LocalPath::new(source.to_string_lossy()))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.content_hash;
|
||||||
|
assert_ne!(first, second);
|
||||||
|
let _ = fs::remove_dir_all(app);
|
||||||
|
let _ = fs::remove_dir_all(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn extracts_archive_without_path_escape() {
|
||||||
|
if std::process::Command::new("zip")
|
||||||
|
.arg("-h")
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status()
|
||||||
|
.is_err()
|
||||||
|
|| std::process::Command::new("unzip")
|
||||||
|
.arg("-h")
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status()
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let app = temp_dir("archive-app");
|
||||||
|
let source = temp_dir("archive-source");
|
||||||
|
write_plugin(&source, "bundle");
|
||||||
|
let archive_path = app.join("plugin.ideaplug");
|
||||||
|
{
|
||||||
|
let status = std::process::Command::new("zip")
|
||||||
|
.arg("-qr")
|
||||||
|
.arg(&archive_path)
|
||||||
|
.arg(".")
|
||||||
|
.current_dir(&source)
|
||||||
|
.status()
|
||||||
|
.unwrap();
|
||||||
|
assert!(status.success());
|
||||||
|
}
|
||||||
|
let store = FsPluginPackageStore::new(app.join("data"));
|
||||||
|
let staged = store
|
||||||
|
.install_from_archive(&LocalPath::new(archive_path.to_string_lossy()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(PathBuf::from(staged.root).join(MANIFEST_FILE).exists());
|
||||||
|
let _ = fs::remove_dir_all(app);
|
||||||
|
let _ = fs::remove_dir_all(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn supervisor_delegates_stdio_servers_to_external_mcp_bridge() {
|
||||||
|
let bridge = Arc::new(RecordingBridge::default());
|
||||||
|
let supervisor = ExternalMcpPluginSupervisor::with_bridge(bridge.clone());
|
||||||
|
let spec = mcp_spec("dev.acme.gitgraph", "dev.acme.gitgraph.mcp");
|
||||||
|
|
||||||
|
let statuses = supervisor.reconcile(vec![spec.clone()]).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(statuses.servers.len(), 1);
|
||||||
|
assert_eq!(statuses.servers[0].identity, spec.identity);
|
||||||
|
assert!(statuses.servers[0].running);
|
||||||
|
assert_eq!(&*bridge.started.lock().unwrap(), &[spec]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn supervisor_stop_plugin_stops_only_matching_plugin_servers() {
|
||||||
|
let bridge = Arc::new(RecordingBridge::default());
|
||||||
|
let supervisor = ExternalMcpPluginSupervisor::with_bridge(bridge.clone());
|
||||||
|
let target = mcp_spec("dev.acme.gitgraph", "dev.acme.gitgraph.mcp");
|
||||||
|
let other = mcp_spec("dev.other.tools", "dev.other.tools.mcp");
|
||||||
|
supervisor
|
||||||
|
.reconcile(vec![target.clone(), other.clone()])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
supervisor.stop_plugin(&target.plugin_id).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(&*bridge.stopped.lock().unwrap(), &[target.identity.clone()]);
|
||||||
|
let statuses = supervisor.reconcile(vec![other.clone()]).await.unwrap();
|
||||||
|
assert_eq!(statuses.servers.len(), 1);
|
||||||
|
assert_eq!(statuses.servers[0].identity, other.identity);
|
||||||
|
assert_eq!(bridge.started.lock().unwrap().len(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -45,6 +45,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
WebDesktopServerGateway,
|
WebDesktopServerGateway,
|
||||||
WebFocusedProjectGateway,
|
WebFocusedProjectGateway,
|
||||||
|
WebPluginGateway,
|
||||||
WebRemoteGateway,
|
WebRemoteGateway,
|
||||||
WebWindowGateway,
|
WebWindowGateway,
|
||||||
} from "./unsupported";
|
} from "./unsupported";
|
||||||
@ -139,6 +140,7 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway
|
|||||||
focusedProject: new WebFocusedProjectGateway(),
|
focusedProject: new WebFocusedProjectGateway(),
|
||||||
// Frontend-owned UI prefs are transport-neutral (localStorage) — reuse as-is.
|
// Frontend-owned UI prefs are transport-neutral (localStorage) — reuse as-is.
|
||||||
uiPreferences: new LocalStorageUiPreferencesGateway(),
|
uiPreferences: new LocalStorageUiPreferencesGateway(),
|
||||||
|
plugin: new WebPluginGateway(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -116,6 +116,11 @@ export class HttpSystemGateway implements SystemGateway {
|
|||||||
return unsupportedOnWeb("Native folder picker");
|
return unsupportedOnWeb("Native folder picker");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pickArchiveFile(): Promise<string | null> {
|
||||||
|
// Desktop-only, same rationale as `pickFolder`.
|
||||||
|
return unsupportedOnWeb("Native file picker");
|
||||||
|
}
|
||||||
|
|
||||||
onAppExitWorkGuard(
|
onAppExitWorkGuard(
|
||||||
_handler: (state: AppExitWorkGuardState) => void,
|
_handler: (state: AppExitWorkGuardState) => void,
|
||||||
): Promise<Unsubscribe> {
|
): Promise<Unsubscribe> {
|
||||||
|
|||||||
@ -12,6 +12,11 @@
|
|||||||
import type {
|
import type {
|
||||||
EmbeddedServerStatus,
|
EmbeddedServerStatus,
|
||||||
GatewayError,
|
GatewayError,
|
||||||
|
PluginAdmin,
|
||||||
|
PluginInstallResult,
|
||||||
|
PluginReview,
|
||||||
|
PluginRuntimeContributionCatalog,
|
||||||
|
PluginUninstallResult,
|
||||||
ServerExposurePreview,
|
ServerExposurePreview,
|
||||||
ServerExposureSettings,
|
ServerExposureSettings,
|
||||||
Unsubscribe,
|
Unsubscribe,
|
||||||
@ -20,7 +25,9 @@ import type {
|
|||||||
DesktopServerGateway,
|
DesktopServerGateway,
|
||||||
FocusedProject,
|
FocusedProject,
|
||||||
FocusedProjectGateway,
|
FocusedProjectGateway,
|
||||||
|
PluginGateway,
|
||||||
RemoteGateway,
|
RemoteGateway,
|
||||||
|
ReviewPluginPackageInput,
|
||||||
ViewWindowClosed,
|
ViewWindowClosed,
|
||||||
ViewWindowSnapshot,
|
ViewWindowSnapshot,
|
||||||
WindowGateway,
|
WindowGateway,
|
||||||
@ -119,3 +126,37 @@ export class WebDesktopServerGateway implements DesktopServerGateway {
|
|||||||
return Promise.resolve(() => {});
|
return Promise.resolve(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web stub: the plugin system (#43) installs/loads full-trust local ESM
|
||||||
|
* bundles from the desktop filesystem — no server-side equivalent in V1. The
|
||||||
|
* web client must not manage or load plugins on behalf of the desktop app.
|
||||||
|
*/
|
||||||
|
export class WebPluginGateway implements PluginGateway {
|
||||||
|
async listPlugins(): Promise<PluginAdmin[]> {
|
||||||
|
return unsupportedOnWeb("Plugin management");
|
||||||
|
}
|
||||||
|
async reviewPackage(_input: ReviewPluginPackageInput): Promise<PluginReview> {
|
||||||
|
return unsupportedOnWeb("Plugin management");
|
||||||
|
}
|
||||||
|
async installFromArchive(_path: string): Promise<PluginInstallResult> {
|
||||||
|
return unsupportedOnWeb("Plugin management");
|
||||||
|
}
|
||||||
|
async installFromDirectory(_path: string): Promise<PluginInstallResult> {
|
||||||
|
return unsupportedOnWeb("Plugin management");
|
||||||
|
}
|
||||||
|
async setEnabled(_pluginId: string, _enabled: boolean): Promise<PluginAdmin> {
|
||||||
|
return unsupportedOnWeb("Plugin management");
|
||||||
|
}
|
||||||
|
async uninstall(_pluginId: string): Promise<PluginUninstallResult> {
|
||||||
|
return unsupportedOnWeb("Plugin management");
|
||||||
|
}
|
||||||
|
async listRuntimeContributions(): Promise<PluginRuntimeContributionCatalog> {
|
||||||
|
// No plugin bundles are ever loaded on the web client — an empty catalog
|
||||||
|
// lets the bootstrap loader run unconditionally without a transport check.
|
||||||
|
return { plugins: [] };
|
||||||
|
}
|
||||||
|
async openPluginsFolder(_pluginId?: string): Promise<void> {
|
||||||
|
return unsupportedOnWeb("Plugin management");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -34,6 +34,7 @@ import { TauriTicketGateway } from "./ticket";
|
|||||||
import { TauriWindowGateway } from "./window";
|
import { TauriWindowGateway } from "./window";
|
||||||
import { TauriFocusedProjectGateway } from "./focusedProject";
|
import { TauriFocusedProjectGateway } from "./focusedProject";
|
||||||
import { LocalStorageUiPreferencesGateway } from "./uiPreferences";
|
import { LocalStorageUiPreferencesGateway } from "./uiPreferences";
|
||||||
|
import { TauriPluginGateway } from "./plugin";
|
||||||
|
|
||||||
function notImplemented(what: string): never {
|
function notImplemented(what: string): never {
|
||||||
const err: GatewayError = {
|
const err: GatewayError = {
|
||||||
@ -75,6 +76,7 @@ export function createTauriGateways(): Gateways {
|
|||||||
window: new TauriWindowGateway(),
|
window: new TauriWindowGateway(),
|
||||||
focusedProject: new TauriFocusedProjectGateway(),
|
focusedProject: new TauriFocusedProjectGateway(),
|
||||||
uiPreferences: new LocalStorageUiPreferencesGateway(),
|
uiPreferences: new LocalStorageUiPreferencesGateway(),
|
||||||
|
plugin: new TauriPluginGateway(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,4 +103,5 @@ export {
|
|||||||
TauriWindowGateway,
|
TauriWindowGateway,
|
||||||
TauriFocusedProjectGateway,
|
TauriFocusedProjectGateway,
|
||||||
LocalStorageUiPreferencesGateway,
|
LocalStorageUiPreferencesGateway,
|
||||||
|
TauriPluginGateway,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -38,6 +38,13 @@ import type {
|
|||||||
PairedDevice,
|
PairedDevice,
|
||||||
PairingCode,
|
PairingCode,
|
||||||
PermissionSet,
|
PermissionSet,
|
||||||
|
PluginAdmin,
|
||||||
|
PluginContributionSummary,
|
||||||
|
PluginInstallResult,
|
||||||
|
PluginLifecycleState,
|
||||||
|
PluginReview,
|
||||||
|
PluginRuntimeContributionCatalog,
|
||||||
|
PluginUninstallResult,
|
||||||
Project,
|
Project,
|
||||||
ProjectMcpToolPermissions,
|
ProjectMcpToolPermissions,
|
||||||
ProjectPermissions,
|
ProjectPermissions,
|
||||||
@ -88,8 +95,10 @@ import type {
|
|||||||
ProfileGateway,
|
ProfileGateway,
|
||||||
ProjectGateway,
|
ProjectGateway,
|
||||||
PermissionGateway,
|
PermissionGateway,
|
||||||
|
PluginGateway,
|
||||||
ReattachResult,
|
ReattachResult,
|
||||||
RemoteGateway,
|
RemoteGateway,
|
||||||
|
ReviewPluginPackageInput,
|
||||||
SkillGateway,
|
SkillGateway,
|
||||||
StoppedLiveAgent,
|
StoppedLiveAgent,
|
||||||
SystemGateway,
|
SystemGateway,
|
||||||
@ -184,6 +193,11 @@ export class MockSystemGateway implements SystemGateway {
|
|||||||
return "/home/user/mock-project";
|
return "/home/user/mock-project";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns a deterministic fake path — never opens a native dialog. */
|
||||||
|
async pickArchiveFile(): Promise<string | null> {
|
||||||
|
return "/home/user/mock-plugin.ideaplug";
|
||||||
|
}
|
||||||
|
|
||||||
private exitGuardListeners = new Set<(state: AppExitWorkGuardState) => void>();
|
private exitGuardListeners = new Set<(state: AppExitWorkGuardState) => void>();
|
||||||
/** Count of `confirmAppExit()` calls, for test assertions. */
|
/** Count of `confirmAppExit()` calls, for test assertions. */
|
||||||
confirmAppExitCallCount = 0;
|
confirmAppExitCallCount = 0;
|
||||||
@ -2923,6 +2937,114 @@ function mostRestrictive(
|
|||||||
return rank[agent] >= rank[fallback] ? agent : fallback;
|
return rank[agent] >= rank[fallback] ? agent : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory plugin store (ticket #43, F1). Mirrors the carnet contract closely
|
||||||
|
* enough to develop/test F1-F4 without the backend (B1-B4, landing in
|
||||||
|
* parallel): review before commit, install/enable/disable/uninstall, and a
|
||||||
|
* runtime catalog that only ever lists `enabled && !pendingUninstall` plugins.
|
||||||
|
*/
|
||||||
|
export class MockPluginGateway implements PluginGateway {
|
||||||
|
private plugins: PluginAdmin[] = [];
|
||||||
|
private seq = 0;
|
||||||
|
|
||||||
|
private summaryFor(_pluginId: string): PluginContributionSummary {
|
||||||
|
return { topLevelMenus: 0, menuItems: 0, layouts: 0, mcpServers: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test/dev helper: seed a plugin directly, bypassing install. */
|
||||||
|
_seedPlugin(plugin: PluginAdmin): void {
|
||||||
|
this.plugins.push(plugin);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPlugins(): Promise<PluginAdmin[]> {
|
||||||
|
return structuredClone(this.plugins);
|
||||||
|
}
|
||||||
|
|
||||||
|
async reviewPackage(input: ReviewPluginPackageInput): Promise<PluginReview> {
|
||||||
|
this.seq += 1;
|
||||||
|
const label = input.path.split("/").pop() ?? input.path;
|
||||||
|
return {
|
||||||
|
id: `mock.plugin.${this.seq}`,
|
||||||
|
displayName: label.replace(/\.(ideaplug|zip|vsix)$/i, ""),
|
||||||
|
publisher: "Mock Publisher",
|
||||||
|
version: "0.1.0",
|
||||||
|
description: `Reviewed from ${input.sourceKind}: ${input.path}`,
|
||||||
|
trustLevel: "full",
|
||||||
|
contributionSummary: this.summaryFor(`mock.plugin.${this.seq}`),
|
||||||
|
issues: [],
|
||||||
|
installable: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async install(
|
||||||
|
sourceKind: "archive" | "directory",
|
||||||
|
path: string,
|
||||||
|
): Promise<PluginInstallResult> {
|
||||||
|
this.seq += 1;
|
||||||
|
const label = path.split("/").pop() ?? path;
|
||||||
|
const plugin: PluginAdmin = {
|
||||||
|
id: `mock.plugin.${this.seq}`,
|
||||||
|
displayName: label.replace(/\.(ideaplug|zip|vsix)$/i, ""),
|
||||||
|
publisher: "Mock Publisher",
|
||||||
|
version: "0.1.0",
|
||||||
|
sourceKind,
|
||||||
|
sourceLabel: path,
|
||||||
|
lifecycleState: "enabled",
|
||||||
|
enabled: true,
|
||||||
|
pendingUninstall: false,
|
||||||
|
restartRequired: true,
|
||||||
|
trustLevel: "full",
|
||||||
|
contributionSummary: this.summaryFor(`mock.plugin.${this.seq}`),
|
||||||
|
};
|
||||||
|
this.plugins.push(plugin);
|
||||||
|
return { plugin: structuredClone(plugin), restartRequired: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
installFromArchive(path: string): Promise<PluginInstallResult> {
|
||||||
|
return this.install("archive", path);
|
||||||
|
}
|
||||||
|
|
||||||
|
installFromDirectory(path: string): Promise<PluginInstallResult> {
|
||||||
|
return this.install("directory", path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setEnabled(pluginId: string, enabled: boolean): Promise<PluginAdmin> {
|
||||||
|
const plugin = this.plugins.find((p) => p.id === pluginId);
|
||||||
|
if (!plugin) {
|
||||||
|
const err: GatewayError = { code: "NOT_FOUND", message: `plugin ${pluginId} not found` };
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const state: PluginLifecycleState = enabled ? "enabled" : "disabled";
|
||||||
|
plugin.enabled = enabled;
|
||||||
|
plugin.lifecycleState = state;
|
||||||
|
plugin.restartRequired = true;
|
||||||
|
return structuredClone(plugin);
|
||||||
|
}
|
||||||
|
|
||||||
|
async uninstall(pluginId: string): Promise<PluginUninstallResult> {
|
||||||
|
const idx = this.plugins.findIndex((p) => p.id === pluginId);
|
||||||
|
if (idx === -1) {
|
||||||
|
const err: GatewayError = { code: "NOT_FOUND", message: `plugin ${pluginId} not found` };
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
this.plugins.splice(idx, 1);
|
||||||
|
return { pluginId, restartRequired: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRuntimeContributions(): Promise<PluginRuntimeContributionCatalog> {
|
||||||
|
// Only enabled, non-pending-uninstall plugins are loadable at bootstrap
|
||||||
|
// (carnet §1.3) — the mock has no bundle to actually import, so this
|
||||||
|
// starts empty; tests seed `plugins` on `PluginRuntimeContributionCatalog`
|
||||||
|
// directly via a `MockPluginGateway` subclass/test double when a loader
|
||||||
|
// round-trip is needed.
|
||||||
|
return { plugins: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async openPluginsFolder(_pluginId?: string): Promise<void> {
|
||||||
|
// No filesystem in the mock — no-op.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 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();
|
||||||
@ -2951,6 +3073,7 @@ export function createMockGateways(): Gateways {
|
|||||||
window: new MockWindowGateway(),
|
window: new MockWindowGateway(),
|
||||||
focusedProject: new MockFocusedProjectGateway(),
|
focusedProject: new MockFocusedProjectGateway(),
|
||||||
uiPreferences: new MockUiPreferencesGateway(),
|
uiPreferences: new MockUiPreferencesGateway(),
|
||||||
|
plugin: new MockPluginGateway(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -26,6 +26,7 @@ describe("createMockGateways", () => {
|
|||||||
"memory",
|
"memory",
|
||||||
"modelServer",
|
"modelServer",
|
||||||
"permission",
|
"permission",
|
||||||
|
"plugin",
|
||||||
"profile",
|
"profile",
|
||||||
"project",
|
"project",
|
||||||
"remote",
|
"remote",
|
||||||
|
|||||||
58
frontend/src/adapters/plugin.ts
Normal file
58
frontend/src/adapters/plugin.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Tauri adapter for {@link PluginGateway} (ticket #43, F1).
|
||||||
|
*
|
||||||
|
* Commands use snake_case (Tauri convention); payload keys are camelCase,
|
||||||
|
* consistent with the other adapters in this directory. Command names and
|
||||||
|
* envelope match the carnet §5 exactly — no contract improvised here.
|
||||||
|
*
|
||||||
|
* NOTE: The Tauri commands wired here are defined in the backend `app-tauri`
|
||||||
|
* crate (lots B1-B4, in progress in parallel on this branch). The mock gateway
|
||||||
|
* covers tests and offline dev today.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
PluginAdmin,
|
||||||
|
PluginInstallResult,
|
||||||
|
PluginReview,
|
||||||
|
PluginRuntimeContributionCatalog,
|
||||||
|
PluginUninstallResult,
|
||||||
|
} from "@/domain";
|
||||||
|
import type { PluginGateway, ReviewPluginPackageInput } from "@/ports";
|
||||||
|
|
||||||
|
export class TauriPluginGateway implements PluginGateway {
|
||||||
|
listPlugins(): Promise<PluginAdmin[]> {
|
||||||
|
return invoke<PluginAdmin[]>("plugin_list_plugins");
|
||||||
|
}
|
||||||
|
|
||||||
|
reviewPackage(input: ReviewPluginPackageInput): Promise<PluginReview> {
|
||||||
|
return invoke<PluginReview>("plugin_review_package", {
|
||||||
|
request: { sourceKind: input.sourceKind, path: input.path },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
installFromArchive(path: string): Promise<PluginInstallResult> {
|
||||||
|
return invoke<PluginInstallResult>("plugin_install_from_archive", { path });
|
||||||
|
}
|
||||||
|
|
||||||
|
installFromDirectory(path: string): Promise<PluginInstallResult> {
|
||||||
|
return invoke<PluginInstallResult>("plugin_install_from_directory", { path });
|
||||||
|
}
|
||||||
|
|
||||||
|
setEnabled(pluginId: string, enabled: boolean): Promise<PluginAdmin> {
|
||||||
|
return invoke<PluginAdmin>("plugin_set_enabled", { pluginId, enabled });
|
||||||
|
}
|
||||||
|
|
||||||
|
uninstall(pluginId: string): Promise<PluginUninstallResult> {
|
||||||
|
return invoke<PluginUninstallResult>("plugin_uninstall", { pluginId });
|
||||||
|
}
|
||||||
|
|
||||||
|
listRuntimeContributions(): Promise<PluginRuntimeContributionCatalog> {
|
||||||
|
return invoke<PluginRuntimeContributionCatalog>("plugin_list_runtime_contributions");
|
||||||
|
}
|
||||||
|
|
||||||
|
async openPluginsFolder(pluginId?: string): Promise<void> {
|
||||||
|
await invoke("plugin_open_plugins_folder", { pluginId: pluginId ?? null });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -41,6 +41,15 @@ export class TauriSystemGateway implements SystemGateway {
|
|||||||
return typeof result === "string" ? result : null;
|
return typeof result === "string" ? result : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async pickArchiveFile(): Promise<string | null> {
|
||||||
|
const result = await open({
|
||||||
|
directory: false,
|
||||||
|
multiple: false,
|
||||||
|
filters: [{ name: "Plugin archive", extensions: ["ideaplug", "zip", "vsix"] }],
|
||||||
|
});
|
||||||
|
return typeof result === "string" ? result : null;
|
||||||
|
}
|
||||||
|
|
||||||
async onAppExitWorkGuard(
|
async onAppExitWorkGuard(
|
||||||
handler: (state: AppExitWorkGuardState) => void,
|
handler: (state: AppExitWorkGuardState) => void,
|
||||||
): Promise<Unsubscribe> {
|
): Promise<Unsubscribe> {
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { ProjectsView } from "@/features/projects";
|
|||||||
import { FirstRunWizard } from "@/features/first-run";
|
import { FirstRunWizard } from "@/features/first-run";
|
||||||
import { AnnouncementsProvider } from "@/features/announcements";
|
import { AnnouncementsProvider } from "@/features/announcements";
|
||||||
import { AppExitConfirmDialog } from "@/features/appExit";
|
import { AppExitConfirmDialog } from "@/features/appExit";
|
||||||
|
import { PluginRuntimeProvider } from "@/features/plugins";
|
||||||
import { Panel, Spinner, Toolbar } from "@/shared";
|
import { Panel, Spinner, Toolbar } from "@/shared";
|
||||||
import { useGateways, shouldUseMock } from "./di";
|
import { useGateways, shouldUseMock } from "./di";
|
||||||
|
|
||||||
@ -65,6 +66,7 @@ export function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AnnouncementsProvider>
|
<AnnouncementsProvider>
|
||||||
|
<PluginRuntimeProvider>
|
||||||
<div className="flex h-full flex-col bg-canvas text-content">
|
<div className="flex h-full flex-col bg-canvas text-content">
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<header className="flex shrink-0 items-center justify-between border-b border-border px-6 py-3">
|
<header className="flex shrink-0 items-center justify-between border-b border-border px-6 py-3">
|
||||||
@ -116,6 +118,7 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AppExitConfirmDialog />
|
<AppExitConfirmDialog />
|
||||||
|
</PluginRuntimeProvider>
|
||||||
</AnnouncementsProvider>
|
</AnnouncementsProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -837,11 +837,18 @@ export interface GridContainer {
|
|||||||
/**
|
/**
|
||||||
* A node in the layout tree. Tagged on `type` with the payload under `node`,
|
* A node in the layout tree. Tagged on `type` with the payload under `node`,
|
||||||
* matching the backend `#[serde(tag = "type", content = "node")]`.
|
* matching the backend `#[serde(tag = "type", content = "node")]`.
|
||||||
|
*
|
||||||
|
* `customPluginLayout` (#43, F4, carnet v2 §3) is a true top-level variant —
|
||||||
|
* a plugin-provided layout occupies a slot in the tree at the same conceptual
|
||||||
|
* level as a terminal leaf, a split or a grid, never a field bolted onto
|
||||||
|
* `LeafCell` (that shape was tried and explicitly retired by Architect after
|
||||||
|
* QA flagged the ambiguity — see `CustomPluginLayoutCell`'s doc comment).
|
||||||
*/
|
*/
|
||||||
export type LayoutNode =
|
export type LayoutNode =
|
||||||
| { type: "leaf"; node: LeafCell }
|
| { type: "leaf"; node: LeafCell }
|
||||||
| { type: "split"; node: SplitContainer }
|
| { type: "split"; node: SplitContainer }
|
||||||
| { type: "grid"; node: GridContainer };
|
| { type: "grid"; node: GridContainer }
|
||||||
|
| { type: "customPluginLayout"; node: CustomPluginLayoutCell };
|
||||||
|
|
||||||
/** The root of a tab's terminal layout. */
|
/** The root of a tab's terminal layout. */
|
||||||
export interface LayoutTree {
|
export interface LayoutTree {
|
||||||
@ -1448,3 +1455,190 @@ export interface PairingCode {
|
|||||||
export function normalizePairingCode(raw: string): string {
|
export function normalizePairingCode(raw: string): string {
|
||||||
return raw.replace(/[\s-]+/g, "").toUpperCase();
|
return raw.replace(/[\s-]+/g, "").toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugin system (ticket #43) — mirrors of the backend DTOs described in the
|
||||||
|
// carnet §§2, 5, 6, 7. Full-trust, locally-installed, globally-scoped plugins
|
||||||
|
// contributing menus/menu-items/layouts/MCP servers via a pre-compiled ESM
|
||||||
|
// bundle loaded at bootstrap. See `@/plugins/runtime` for the loader/registry
|
||||||
|
// and `@/features/plugins` for the admin surface + menu/layout integration.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Where an installed plugin package came from (admin display only). */
|
||||||
|
export type PluginSourceKind = "archive" | "directory";
|
||||||
|
|
||||||
|
/** Persisted lifecycle state of an installed plugin (carnet §1.4). */
|
||||||
|
export type PluginLifecycleState =
|
||||||
|
| "enabled"
|
||||||
|
| "disabled"
|
||||||
|
| "pending-enable"
|
||||||
|
| "pending-disable"
|
||||||
|
| "pending-uninstall"
|
||||||
|
| "invalid";
|
||||||
|
|
||||||
|
/** Counts of what a plugin declares, shown in the admin list (carnet §5). */
|
||||||
|
export interface PluginContributionSummary {
|
||||||
|
topLevelMenus: number;
|
||||||
|
menuItems: number;
|
||||||
|
layouts: number;
|
||||||
|
mcpServers: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One installed plugin, as shown in `Paramètres > Plugins` (mirror of `PluginAdminDto`). */
|
||||||
|
export interface PluginAdmin {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
publisher?: string;
|
||||||
|
version: string;
|
||||||
|
description?: string;
|
||||||
|
iconUrl?: string;
|
||||||
|
sourceKind: PluginSourceKind;
|
||||||
|
sourceLabel?: string;
|
||||||
|
lifecycleState: PluginLifecycleState;
|
||||||
|
enabled: boolean;
|
||||||
|
pendingEnableState?: boolean;
|
||||||
|
pendingUninstall: boolean;
|
||||||
|
restartRequired: boolean;
|
||||||
|
trustLevel: "full";
|
||||||
|
contributionSummary: PluginContributionSummary;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A manifest validation issue surfaced during pre-install review. */
|
||||||
|
export interface PluginReviewIssue {
|
||||||
|
severity: "error" | "warning";
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pre-install review of a candidate package (carnet §4, `ReviewPluginPackage`). */
|
||||||
|
export interface PluginReview {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
publisher?: string;
|
||||||
|
version: string;
|
||||||
|
description?: string;
|
||||||
|
trustLevel: "full";
|
||||||
|
contributionSummary: PluginContributionSummary;
|
||||||
|
issues: PluginReviewIssue[];
|
||||||
|
/** False when an `error`-severity issue makes install unsafe/impossible. */
|
||||||
|
installable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Outcome of `install_from_archive` / `install_from_directory`. */
|
||||||
|
export interface PluginInstallResult {
|
||||||
|
plugin: PluginAdmin;
|
||||||
|
restartRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Outcome of `uninstall`. */
|
||||||
|
export interface PluginUninstallResult {
|
||||||
|
pluginId: string;
|
||||||
|
restartRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Contribution declarations for one plugin, as consumed by the runtime loader. */
|
||||||
|
export interface PluginContributionDto {
|
||||||
|
menus: PluginTopLevelMenuContribution[];
|
||||||
|
menuItems: PluginMenuItemContribution[];
|
||||||
|
layouts: PluginLayoutContribution[];
|
||||||
|
mcpServers: PluginMcpServerSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One entry of `plugin_list_runtime_contributions` (mirror of `PluginRuntimePluginDto`). */
|
||||||
|
export interface PluginRuntimePlugin {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
publisher?: string;
|
||||||
|
version: string;
|
||||||
|
bundleUrl: string;
|
||||||
|
iconUrl?: string;
|
||||||
|
contentHash: string;
|
||||||
|
contributes: PluginContributionDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bootstrap catalog fetched once, before loading any plugin bundle. */
|
||||||
|
export interface PluginRuntimeContributionCatalog {
|
||||||
|
plugins: PluginRuntimePlugin[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Manifest declaration of a top-level menu (carnet §7.1). */
|
||||||
|
export interface PluginTopLevelMenuContribution {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
topLevel: true;
|
||||||
|
order?: number;
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Native + plugin menu ids an item can target (carnet §7.2). */
|
||||||
|
export type MenuTargetId = "panels" | "settings" | `plugin:${string}`;
|
||||||
|
|
||||||
|
/** Manifest declaration of a menu item contributed into an existing/plugin menu. */
|
||||||
|
export interface PluginMenuItemContribution {
|
||||||
|
id: string;
|
||||||
|
targetMenuId: MenuTargetId;
|
||||||
|
label: string;
|
||||||
|
command: string;
|
||||||
|
order?: number;
|
||||||
|
icon?: string;
|
||||||
|
when?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A menu item resolved for rendering — enablement already evaluated (carnet §7.2). */
|
||||||
|
export interface ResolvedPluginMenuItem {
|
||||||
|
id: string;
|
||||||
|
pluginId: string;
|
||||||
|
pluginDisplayName: string;
|
||||||
|
targetMenuId: MenuTargetId;
|
||||||
|
label: string;
|
||||||
|
command: string;
|
||||||
|
enabled: boolean;
|
||||||
|
disabledReason?: string;
|
||||||
|
groupLabel?: string;
|
||||||
|
order: number;
|
||||||
|
iconUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Manifest declaration of a custom React layout (carnet §7.3). */
|
||||||
|
export interface PluginLayoutContribution {
|
||||||
|
type: string;
|
||||||
|
label: string;
|
||||||
|
component: string;
|
||||||
|
order?: number;
|
||||||
|
icon?: string;
|
||||||
|
when?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a persisted plugin layout can currently be rendered by its provider. */
|
||||||
|
export type PluginLayoutAvailability =
|
||||||
|
| "available"
|
||||||
|
| "plugin-disabled"
|
||||||
|
| "plugin-missing"
|
||||||
|
| "incompatible";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opaque, domain-persisted identity + state of a `customPluginLayout`
|
||||||
|
* top-level {@link LayoutNode} variant (#43, carnet v2 §3.2 — the canonical
|
||||||
|
* schema, mirroring the backend `CustomPluginLayoutCell` exactly: `id`,
|
||||||
|
* `pluginId`, `layoutType`, `state`, camelCase). No display name travels on
|
||||||
|
* the wire — the UI derives it from the plugin runtime registry/admin list at
|
||||||
|
* render time, never persists it here.
|
||||||
|
*
|
||||||
|
* A previous frontend-only shape nested this under `LeafCell.pluginLayout`
|
||||||
|
* with a `kind`/`nodeId`/`providerPluginId` field naming; that shape was
|
||||||
|
* never part of the IPC/persistence contract and has been retired — do not
|
||||||
|
* reintroduce it.
|
||||||
|
*/
|
||||||
|
export interface CustomPluginLayoutCell {
|
||||||
|
id: string;
|
||||||
|
pluginId: string;
|
||||||
|
layoutType: string;
|
||||||
|
state: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Manifest declaration of an MCP server the plugin supervises (carnet §7.4, summary only). */
|
||||||
|
export interface PluginMcpServerSummary {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
autoStart?: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@ -38,6 +38,7 @@ import {
|
|||||||
TargetAnnouncementsOverlay,
|
TargetAnnouncementsOverlay,
|
||||||
useTargetAnnouncements,
|
useTargetAnnouncements,
|
||||||
} from "@/features/announcements";
|
} from "@/features/announcements";
|
||||||
|
import { PluginLayoutCellView } from "@/features/plugins";
|
||||||
import {
|
import {
|
||||||
modelServerOverlayText,
|
modelServerOverlayText,
|
||||||
describeModelServerDownload,
|
describeModelServerDownload,
|
||||||
@ -79,9 +80,21 @@ interface LayoutGridProps {
|
|||||||
layoutId?: string;
|
layoutId?: string;
|
||||||
/** Opens the read-only canonical transcript for a conversation. */
|
/** Opens the read-only canonical transcript for a conversation. */
|
||||||
onOpenConversation?: (conversationId: string) => void;
|
onOpenConversation?: (conversationId: string) => void;
|
||||||
|
/**
|
||||||
|
* Navigates to `Paramètres > Plugins` (#43, F4) — the "Ouvrir Plugins" action
|
||||||
|
* of a plugin layout's unavailable fallback. Threaded down to
|
||||||
|
* `PluginLayoutCellView` the same way `onOpenConversation` is.
|
||||||
|
*/
|
||||||
|
onOpenPluginsSettings?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LayoutGrid({ projectId, cwd, layoutId, onOpenConversation }: LayoutGridProps) {
|
export function LayoutGrid({
|
||||||
|
projectId,
|
||||||
|
cwd,
|
||||||
|
layoutId,
|
||||||
|
onOpenConversation,
|
||||||
|
onOpenPluginsSettings,
|
||||||
|
}: LayoutGridProps) {
|
||||||
const vm = useLayout(projectId, layoutId);
|
const vm = useLayout(projectId, layoutId);
|
||||||
const work = useProjectWorkState(projectId);
|
const work = useProjectWorkState(projectId);
|
||||||
|
|
||||||
@ -117,6 +130,7 @@ export function LayoutGrid({ projectId, cwd, layoutId, onOpenConversation }: Lay
|
|||||||
workState={work.state}
|
workState={work.state}
|
||||||
refreshWorkState={work.refresh}
|
refreshWorkState={work.refresh}
|
||||||
onOpenConversation={onOpenConversation}
|
onOpenConversation={onOpenConversation}
|
||||||
|
onOpenPluginsSettings={onOpenPluginsSettings}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -132,6 +146,7 @@ interface NodeViewProps {
|
|||||||
workState: ProjectWorkState | null;
|
workState: ProjectWorkState | null;
|
||||||
refreshWorkState: () => Promise<void>;
|
refreshWorkState: () => Promise<void>;
|
||||||
onOpenConversation?: (conversationId: string) => void;
|
onOpenConversation?: (conversationId: string) => void;
|
||||||
|
onOpenPluginsSettings?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function NodeView({
|
function NodeView({
|
||||||
@ -143,8 +158,24 @@ function NodeView({
|
|||||||
workState,
|
workState,
|
||||||
refreshWorkState,
|
refreshWorkState,
|
||||||
onOpenConversation,
|
onOpenConversation,
|
||||||
|
onOpenPluginsSettings,
|
||||||
}: NodeViewProps) {
|
}: NodeViewProps) {
|
||||||
switch (node.type) {
|
switch (node.type) {
|
||||||
|
case "customPluginLayout":
|
||||||
|
// A true top-level `LayoutNode` variant (#43, F4, carnet v2 §3) — a
|
||||||
|
// plugin layout occupies a slot in the tree at the same level as a
|
||||||
|
// terminal leaf, split or grid. Rendered separately from `LeafView`
|
||||||
|
// (which owns a lot of terminal-only concerns — write-portal,
|
||||||
|
// model-server overlay, agent dropdown — none of which apply here).
|
||||||
|
return (
|
||||||
|
<PluginLayoutCellView
|
||||||
|
projectId={projectId}
|
||||||
|
cell={node.node}
|
||||||
|
onStateChange={(nextState) => vm.setPluginLayoutState(node.node.id, nextState)}
|
||||||
|
onOpenPlugins={() => onOpenPluginsSettings?.()}
|
||||||
|
onChooseAnotherLayout={() => vm.replacePluginLayoutWithTerminal(node.node.id)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case "leaf":
|
case "leaf":
|
||||||
return (
|
return (
|
||||||
<LeafView
|
<LeafView
|
||||||
@ -172,6 +203,7 @@ function NodeView({
|
|||||||
workState={workState}
|
workState={workState}
|
||||||
refreshWorkState={refreshWorkState}
|
refreshWorkState={refreshWorkState}
|
||||||
onOpenConversation={onOpenConversation}
|
onOpenConversation={onOpenConversation}
|
||||||
|
onOpenPluginsSettings={onOpenPluginsSettings}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "grid":
|
case "grid":
|
||||||
@ -184,6 +216,7 @@ function NodeView({
|
|||||||
workState={workState}
|
workState={workState}
|
||||||
refreshWorkState={refreshWorkState}
|
refreshWorkState={refreshWorkState}
|
||||||
onOpenConversation={onOpenConversation}
|
onOpenConversation={onOpenConversation}
|
||||||
|
onOpenPluginsSettings={onOpenPluginsSettings}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1153,6 +1186,7 @@ interface SplitViewProps {
|
|||||||
workState: ProjectWorkState | null;
|
workState: ProjectWorkState | null;
|
||||||
refreshWorkState: () => Promise<void>;
|
refreshWorkState: () => Promise<void>;
|
||||||
onOpenConversation?: (conversationId: string) => void;
|
onOpenConversation?: (conversationId: string) => void;
|
||||||
|
onOpenPluginsSettings?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SplitView({
|
function SplitView({
|
||||||
@ -1163,6 +1197,7 @@ function SplitView({
|
|||||||
workState,
|
workState,
|
||||||
refreshWorkState,
|
refreshWorkState,
|
||||||
onOpenConversation,
|
onOpenConversation,
|
||||||
|
onOpenPluginsSettings,
|
||||||
}: SplitViewProps) {
|
}: SplitViewProps) {
|
||||||
const isRow = split.direction === "row";
|
const isRow = split.direction === "row";
|
||||||
const baseWeights = split.children.map((c) => c.weight);
|
const baseWeights = split.children.map((c) => c.weight);
|
||||||
@ -1207,6 +1242,7 @@ function SplitView({
|
|||||||
workState={workState}
|
workState={workState}
|
||||||
refreshWorkState={refreshWorkState}
|
refreshWorkState={refreshWorkState}
|
||||||
onOpenConversation={onOpenConversation}
|
onOpenConversation={onOpenConversation}
|
||||||
|
onOpenPluginsSettings={onOpenPluginsSettings}
|
||||||
parentSplit={{
|
parentSplit={{
|
||||||
container: split.id,
|
container: split.id,
|
||||||
index: i,
|
index: i,
|
||||||
@ -1301,6 +1337,7 @@ interface GridViewProps {
|
|||||||
workState: ProjectWorkState | null;
|
workState: ProjectWorkState | null;
|
||||||
refreshWorkState: () => Promise<void>;
|
refreshWorkState: () => Promise<void>;
|
||||||
onOpenConversation?: (conversationId: string) => void;
|
onOpenConversation?: (conversationId: string) => void;
|
||||||
|
onOpenPluginsSettings?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function GridView({
|
function GridView({
|
||||||
@ -1311,6 +1348,7 @@ function GridView({
|
|||||||
workState,
|
workState,
|
||||||
refreshWorkState,
|
refreshWorkState,
|
||||||
onOpenConversation,
|
onOpenConversation,
|
||||||
|
onOpenPluginsSettings,
|
||||||
}: GridViewProps) {
|
}: GridViewProps) {
|
||||||
const cols = normalizeWeights(grid.colWeights)
|
const cols = normalizeWeights(grid.colWeights)
|
||||||
.map((p) => `${p}fr`)
|
.map((p) => `${p}fr`)
|
||||||
@ -1351,6 +1389,7 @@ function GridView({
|
|||||||
workState={workState}
|
workState={workState}
|
||||||
refreshWorkState={refreshWorkState}
|
refreshWorkState={refreshWorkState}
|
||||||
onOpenConversation={onOpenConversation}
|
onOpenConversation={onOpenConversation}
|
||||||
|
onOpenPluginsSettings={onOpenPluginsSettings}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
|
|
||||||
import type { LayoutInfo } from "@/domain";
|
import type { LayoutInfo } from "@/domain";
|
||||||
import { cn } from "@/shared";
|
import { cn } from "@/shared";
|
||||||
|
import { PluginLayoutSelectorSection, usePluginRuntime } from "@/features/plugins";
|
||||||
import { useLayouts } from "./useLayouts";
|
import { useLayouts } from "./useLayouts";
|
||||||
|
|
||||||
interface LayoutTabsProps {
|
interface LayoutTabsProps {
|
||||||
@ -43,6 +44,10 @@ export function LayoutTabs({ projectId, onActiveLayoutChange }: LayoutTabsProps)
|
|||||||
const renameInputRef = useRef<HTMLInputElement | null>(null);
|
const renameInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
// Show/hide the create-kind dropdown.
|
// Show/hide the create-kind dropdown.
|
||||||
const [showCreateMenu, setShowCreateMenu] = useState(false);
|
const [showCreateMenu, setShowCreateMenu] = useState(false);
|
||||||
|
const { registry: pluginRegistry } = usePluginRuntime();
|
||||||
|
// Plugin layouts need a `create_layout` backend extension that doesn't exist
|
||||||
|
// yet (#43, F4 open point) — surfaced rather than silently swallowed.
|
||||||
|
const [pluginLayoutNotice, setPluginLayoutNotice] = useState<string | null>(null);
|
||||||
|
|
||||||
async function handleSelect(id: string) {
|
async function handleSelect(id: string) {
|
||||||
// The effect above propagates the new active layout (id + kind) to the parent.
|
// The effect above propagates the new active layout (id + kind) to the parent.
|
||||||
@ -196,13 +201,22 @@ export function LayoutTabs({ projectId, onActiveLayoutChange }: LayoutTabsProps)
|
|||||||
>
|
>
|
||||||
⎇ Git graph
|
⎇ Git graph
|
||||||
</button>
|
</button>
|
||||||
|
<PluginLayoutSelectorSection
|
||||||
|
registry={pluginRegistry}
|
||||||
|
onSelect={(choice) => {
|
||||||
|
setShowCreateMenu(false);
|
||||||
|
setPluginLayoutNotice(
|
||||||
|
`« ${choice.layout.label} » (${choice.pluginDisplayName}) : la création de layouts plugins nécessite une extension backend pas encore livrée.`,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{vm.error && (
|
{(vm.error || pluginLayoutNotice) && (
|
||||||
<span className="ml-2 text-xs text-danger" role="alert">
|
<span className="ml-2 text-xs text-danger" role="alert">
|
||||||
{vm.error}
|
{vm.error ?? pluginLayoutNotice}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -13,7 +13,9 @@ import {
|
|||||||
droppedSessions,
|
droppedSessions,
|
||||||
leaves,
|
leaves,
|
||||||
normalizeWeights,
|
normalizeWeights,
|
||||||
|
replaceCustomPluginLayoutWithTerminal,
|
||||||
resizeAdjacent,
|
resizeAdjacent,
|
||||||
|
setCustomPluginLayoutState,
|
||||||
singleLeafTree,
|
singleLeafTree,
|
||||||
splitOp,
|
splitOp,
|
||||||
} from "./layout";
|
} from "./layout";
|
||||||
@ -403,3 +405,109 @@ describe("splitOp", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #43, F4 — the `customPluginLayout` top-level `LayoutNode` variant, cadré
|
||||||
|
// (carnet v2 §3.2) as the exact backend serde shape
|
||||||
|
// (`#[serde(tag = "type", content = "node")]`, camelCase). These JSON literals
|
||||||
|
// are the carnet's own worked examples, parsed with `JSON.parse` + a type
|
||||||
|
// assertion (never hand-built as TS object literals) so this test actually
|
||||||
|
// exercises "a layout JSON produced by Rust", not just the TS type shape.
|
||||||
|
describe("customPluginLayout — parsing a backend-shaped JSON tree", () => {
|
||||||
|
const ROOT_PLUGIN_LAYOUT_JSON = `{
|
||||||
|
"root": {
|
||||||
|
"type": "customPluginLayout",
|
||||||
|
"node": {
|
||||||
|
"id": "018f0c5a-2b4b-70d4-a7c2-300000000001",
|
||||||
|
"pluginId": "dev.acme.gitgraph",
|
||||||
|
"layoutType": "dev.acme.gitgraph.layout",
|
||||||
|
"state": { "branchFilter": "main" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const SPLIT_WITH_PLUGIN_LAYOUT_JSON = `{
|
||||||
|
"root": {
|
||||||
|
"type": "split",
|
||||||
|
"node": {
|
||||||
|
"id": "split-1",
|
||||||
|
"direction": "row",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"weight": 1,
|
||||||
|
"node": { "type": "leaf", "node": { "id": "terminal-1" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"weight": 1,
|
||||||
|
"node": {
|
||||||
|
"type": "customPluginLayout",
|
||||||
|
"node": {
|
||||||
|
"id": "plugin-cell-1",
|
||||||
|
"pluginId": "dev.acme.gitgraph",
|
||||||
|
"layoutType": "dev.acme.gitgraph.layout",
|
||||||
|
"state": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
it("parses a root-level customPluginLayout node with its opaque state intact", () => {
|
||||||
|
const tree = JSON.parse(ROOT_PLUGIN_LAYOUT_JSON) as LayoutTree;
|
||||||
|
expect(tree.root.type).toBe("customPluginLayout");
|
||||||
|
if (tree.root.type !== "customPluginLayout") throw new Error("unreachable");
|
||||||
|
expect(tree.root.node).toEqual({
|
||||||
|
id: "018f0c5a-2b4b-70d4-a7c2-300000000001",
|
||||||
|
pluginId: "dev.acme.gitgraph",
|
||||||
|
layoutType: "dev.acme.gitgraph.layout",
|
||||||
|
state: { branchFilter: "main" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a customPluginLayout node nested as a split child alongside a terminal leaf", () => {
|
||||||
|
const tree = JSON.parse(SPLIT_WITH_PLUGIN_LAYOUT_JSON) as LayoutTree;
|
||||||
|
expect(tree.root.type).toBe("split");
|
||||||
|
if (tree.root.type !== "split") throw new Error("unreachable");
|
||||||
|
const [terminalChild, pluginChild] = tree.root.node.children;
|
||||||
|
expect(terminalChild.node).toEqual({ type: "leaf", node: { id: "terminal-1" } });
|
||||||
|
expect(pluginChild.node.type).toBe("customPluginLayout");
|
||||||
|
if (pluginChild.node.type !== "customPluginLayout") throw new Error("unreachable");
|
||||||
|
expect(pluginChild.node.node.pluginId).toBe("dev.acme.gitgraph");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves() does not pick up a customPluginLayout node as a terminal leaf", () => {
|
||||||
|
const tree = JSON.parse(SPLIT_WITH_PLUGIN_LAYOUT_JSON) as LayoutTree;
|
||||||
|
expect(leaves(tree).map((l) => l.id)).toEqual(["terminal-1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setCustomPluginLayoutState patches only the matching node's state, leaving the rest of the tree untouched", () => {
|
||||||
|
const tree = JSON.parse(SPLIT_WITH_PLUGIN_LAYOUT_JSON) as LayoutTree;
|
||||||
|
const updated = setCustomPluginLayoutState(tree, "plugin-cell-1", { branchFilter: "feature/x" });
|
||||||
|
if (updated.root.type !== "split") throw new Error("unreachable");
|
||||||
|
const [terminalChild, pluginChild] = updated.root.node.children;
|
||||||
|
expect(terminalChild.node).toEqual({ type: "leaf", node: { id: "terminal-1" } });
|
||||||
|
if (pluginChild.node.type !== "customPluginLayout") throw new Error("unreachable");
|
||||||
|
expect(pluginChild.node.node.state).toEqual({ branchFilter: "feature/x" });
|
||||||
|
expect(pluginChild.node.node.id).toBe("plugin-cell-1");
|
||||||
|
expect(pluginChild.node.node.pluginId).toBe("dev.acme.gitgraph");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setCustomPluginLayoutState is a no-op when the node id isn't found", () => {
|
||||||
|
const tree = JSON.parse(ROOT_PLUGIN_LAYOUT_JSON) as LayoutTree;
|
||||||
|
const updated = setCustomPluginLayoutState(tree, "does-not-exist", { x: 1 });
|
||||||
|
expect(updated).toEqual(tree);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaceCustomPluginLayoutWithTerminal swaps the node for a blank terminal leaf of the same id", () => {
|
||||||
|
const tree = JSON.parse(ROOT_PLUGIN_LAYOUT_JSON) as LayoutTree;
|
||||||
|
const updated = replaceCustomPluginLayoutWithTerminal(
|
||||||
|
tree,
|
||||||
|
"018f0c5a-2b4b-70d4-a7c2-300000000001",
|
||||||
|
);
|
||||||
|
expect(updated.root).toEqual({
|
||||||
|
type: "leaf",
|
||||||
|
node: { id: "018f0c5a-2b4b-70d4-a7c2-300000000001" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -91,6 +91,10 @@ function mapNode(node: LayoutNode, f: (n: LayoutNode) => LayoutNode): LayoutNode
|
|||||||
case "leaf":
|
case "leaf":
|
||||||
rebuilt = node;
|
rebuilt = node;
|
||||||
break;
|
break;
|
||||||
|
case "customPluginLayout":
|
||||||
|
// No children to recurse into — same leaf-like treatment as "leaf".
|
||||||
|
rebuilt = node;
|
||||||
|
break;
|
||||||
case "split":
|
case "split":
|
||||||
rebuilt = {
|
rebuilt = {
|
||||||
type: "split",
|
type: "split",
|
||||||
@ -319,6 +323,52 @@ export function droppedSessions(
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locally patches a `customPluginLayout` node's opaque `state` (#43, F4).
|
||||||
|
*
|
||||||
|
* Client-side only: the backend has no `LayoutOperation` variant to persist
|
||||||
|
* plugin layout state yet (carnet v2 §3.5 — no backend refonte expected for
|
||||||
|
* F4), so this does NOT call `LayoutGateway.mutateLayout`. It's the same
|
||||||
|
* "real, in-session, not yet cross-restart-persisted" contract every plugin
|
||||||
|
* component's `setState` gets: the tree re-renders with the new state
|
||||||
|
* immediately, but a reload re-fetches the last **persisted** value from the
|
||||||
|
* backend. Returns `tree` unchanged if no such node is found.
|
||||||
|
*/
|
||||||
|
export function setCustomPluginLayoutState(
|
||||||
|
tree: LayoutTree,
|
||||||
|
nodeId: string,
|
||||||
|
state: unknown,
|
||||||
|
): LayoutTree {
|
||||||
|
return {
|
||||||
|
root: mapNode(tree.root, (n) => {
|
||||||
|
if (n.type === "customPluginLayout" && n.node.id === nodeId) {
|
||||||
|
return { type: "customPluginLayout", node: { ...n.node, state } };
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locally replaces a `customPluginLayout` node with a blank terminal leaf of
|
||||||
|
* the same id (#43, F4 "Choisir un autre layout" fallback action). Same
|
||||||
|
* client-side-only contract as {@link setCustomPluginLayoutState} — no
|
||||||
|
* backend operation exists to persist the node-kind change yet.
|
||||||
|
*/
|
||||||
|
export function replaceCustomPluginLayoutWithTerminal(
|
||||||
|
tree: LayoutTree,
|
||||||
|
nodeId: string,
|
||||||
|
): LayoutTree {
|
||||||
|
return {
|
||||||
|
root: mapNode(tree.root, (n) => {
|
||||||
|
if (n.type === "customPluginLayout" && n.node.id === nodeId) {
|
||||||
|
return { type: "leaf", node: { id: nodeId } };
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Convenience: builds a `split` operation splitting `target` in `direction`. */
|
/** Convenience: builds a `split` operation splitting `target` in `direction`. */
|
||||||
export function splitOp(target: string, direction: Direction): LayoutOperation {
|
export function splitOp(target: string, direction: Direction): LayoutOperation {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -17,7 +17,12 @@ import type {
|
|||||||
LayoutTree,
|
LayoutTree,
|
||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
import { leaves, splitOp } from "./layout";
|
import {
|
||||||
|
leaves,
|
||||||
|
replaceCustomPluginLayoutWithTerminal,
|
||||||
|
setCustomPluginLayoutState,
|
||||||
|
splitOp,
|
||||||
|
} from "./layout";
|
||||||
|
|
||||||
/** What the layout grid UI needs from this hook. */
|
/** What the layout grid UI needs from this hook. */
|
||||||
export interface LayoutViewModel {
|
export interface LayoutViewModel {
|
||||||
@ -59,6 +64,19 @@ export interface LayoutViewModel {
|
|||||||
* to persist the id assigned at first launch so the next open resumes it.
|
* to persist the id assigned at first launch so the next open resumes it.
|
||||||
*/
|
*/
|
||||||
setCellConversation: (target: string, conversationId: string | null) => Promise<void>;
|
setCellConversation: (target: string, conversationId: string | null) => Promise<void>;
|
||||||
|
/**
|
||||||
|
* Patches a `customPluginLayout` node's opaque state locally (#43, F4) —
|
||||||
|
* in-session only, no backend `LayoutOperation` for this exists yet (carnet
|
||||||
|
* v2 §3.5). The tree re-renders immediately; a reload re-fetches the last
|
||||||
|
* value actually persisted by the backend.
|
||||||
|
*/
|
||||||
|
setPluginLayoutState: (nodeId: string, state: unknown) => void;
|
||||||
|
/**
|
||||||
|
* "Choisir un autre layout" fallback action (#43, F4): locally swaps a
|
||||||
|
* `customPluginLayout` node for a blank terminal leaf of the same id.
|
||||||
|
* Same client-side-only contract as {@link setPluginLayoutState}.
|
||||||
|
*/
|
||||||
|
replacePluginLayoutWithTerminal: (nodeId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function describe(e: unknown): string {
|
function describe(e: unknown): string {
|
||||||
@ -246,6 +264,19 @@ export function useLayout(
|
|||||||
[mutate],
|
[mutate],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const setPluginLayoutState = useCallback(
|
||||||
|
(nodeId: string, state: unknown) => {
|
||||||
|
setLayout((prev) => (prev ? setCustomPluginLayoutState(prev, nodeId, state) : prev));
|
||||||
|
setLayoutVersion((v) => v + 1);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const replacePluginLayoutWithTerminal = useCallback((nodeId: string) => {
|
||||||
|
setLayout((prev) => (prev ? replaceCustomPluginLayoutWithTerminal(prev, nodeId) : prev));
|
||||||
|
setLayoutVersion((v) => v + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
layout,
|
layout,
|
||||||
layoutVersion,
|
layoutVersion,
|
||||||
@ -259,5 +290,7 @@ export function useLayout(
|
|||||||
setCellAgent,
|
setCellAgent,
|
||||||
attachLiveAgentToCell,
|
attachLiveAgentToCell,
|
||||||
setCellConversation,
|
setCellConversation,
|
||||||
|
setPluginLayoutState,
|
||||||
|
replacePluginLayoutWithTerminal,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
76
frontend/src/features/plugins/PluginConfirmDialog.tsx
Normal file
76
frontend/src/features/plugins/PluginConfirmDialog.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* A small modal confirmation, local to the plugins feature (ticket #43, F2).
|
||||||
|
* Mirrors `features/devices/ConfirmDialog` (not shared — that dialog is itself
|
||||||
|
* feature-local by design, see its header comment); duplicated rather than
|
||||||
|
* cross-imported to keep each feature's public surface to its own `index.ts`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import { Button, zIndex } from "@/shared";
|
||||||
|
|
||||||
|
interface PluginConfirmDialogProps {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
danger?: boolean;
|
||||||
|
busy?: boolean;
|
||||||
|
onConfirm: () => void | Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PluginConfirmDialog({
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
confirmLabel,
|
||||||
|
danger = false,
|
||||||
|
busy = false,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: PluginConfirmDialogProps) {
|
||||||
|
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
cancelRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onCancel();
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, [onCancel]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 flex items-center justify-center bg-black/50 p-4"
|
||||||
|
style={{ zIndex: zIndex.floatingWindow }}
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={title}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="flex w-full max-w-md flex-col gap-3 rounded-lg border border-border bg-raised p-4 shadow-xl"
|
||||||
|
>
|
||||||
|
<h3 className="text-sm font-semibold text-content">{title}</h3>
|
||||||
|
<p className="whitespace-pre-line text-sm text-muted">{body}</p>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button ref={cancelRef} size="sm" variant="ghost" onClick={onCancel}>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={danger ? "danger" : "primary"}
|
||||||
|
loading={busy}
|
||||||
|
onClick={() => void onConfirm()}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
frontend/src/features/plugins/PluginLayoutCellView.test.tsx
Normal file
113
frontend/src/features/plugins/PluginLayoutCellView.test.tsx
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* F4 — `PluginLayoutCellView` (ticket #43, carnet §10 F4 acceptance criteria:
|
||||||
|
* "rendu composant mock, state roundtrip, fallback sans mutation du layout").
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
|
||||||
|
import type { CustomPluginLayoutCell } from "@/domain";
|
||||||
|
import type { Gateways } from "@/ports";
|
||||||
|
import { DIProvider } from "@/app/di";
|
||||||
|
import { createMockGateways } from "@/adapters/mock";
|
||||||
|
import {
|
||||||
|
PluginCommandRegistry,
|
||||||
|
PluginLayoutRegistry,
|
||||||
|
PluginMenuRegistry,
|
||||||
|
PluginRuntimeRegistry,
|
||||||
|
type LoadedPlugin,
|
||||||
|
type PluginLayoutProps,
|
||||||
|
} from "@/plugins/runtime";
|
||||||
|
import { PluginRuntimeProvider } from "./PluginRuntimeProvider";
|
||||||
|
import { PluginLayoutCellView } from "./PluginLayoutCellView";
|
||||||
|
|
||||||
|
function cell(overrides: Partial<CustomPluginLayoutCell> = {}): CustomPluginLayoutCell {
|
||||||
|
return {
|
||||||
|
id: "leaf-1",
|
||||||
|
pluginId: "dev.acme.gitgraph",
|
||||||
|
layoutType: "dev.acme.gitgraph.layout",
|
||||||
|
state: { commits: 3 },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function MockLayoutComponent(props: PluginLayoutProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p data-testid="state">{JSON.stringify(props.state)}</p>
|
||||||
|
<button onClick={() => props.setState({ commits: 4 })}>bump</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubPlugin(): LoadedPlugin {
|
||||||
|
const contributes = {
|
||||||
|
menus: [],
|
||||||
|
menuItems: [],
|
||||||
|
layouts: [{ type: "dev.acme.gitgraph.layout", label: "Git Graph", component: "GitGraphLayout" }],
|
||||||
|
mcpServers: [],
|
||||||
|
};
|
||||||
|
const layouts = new PluginLayoutRegistry("dev.acme.gitgraph", new Set(["dev.acme.gitgraph.layout"]));
|
||||||
|
layouts.register({ type: "dev.acme.gitgraph.layout", component: MockLayoutComponent });
|
||||||
|
return {
|
||||||
|
pluginId: "dev.acme.gitgraph",
|
||||||
|
displayName: "Git Graph",
|
||||||
|
contributes,
|
||||||
|
commands: new PluginCommandRegistry("dev.acme.gitgraph", new Set()),
|
||||||
|
layouts,
|
||||||
|
menu: new PluginMenuRegistry("dev.acme.gitgraph"),
|
||||||
|
dispose: async () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCell(
|
||||||
|
registry: PluginRuntimeRegistry,
|
||||||
|
props: Partial<{ cell: CustomPluginLayoutCell; onStateChange: (s: unknown) => void }> = {},
|
||||||
|
) {
|
||||||
|
const gateways: Gateways = createMockGateways();
|
||||||
|
return render(
|
||||||
|
<DIProvider gateways={gateways}>
|
||||||
|
<PluginRuntimeProvider value={{ registry, failures: [], loading: false }}>
|
||||||
|
<PluginLayoutCellView
|
||||||
|
projectId="proj-1"
|
||||||
|
cell={props.cell ?? cell()}
|
||||||
|
onStateChange={props.onStateChange ?? (() => {})}
|
||||||
|
onOpenPlugins={() => {}}
|
||||||
|
onChooseAnotherLayout={() => {}}
|
||||||
|
/>
|
||||||
|
</PluginRuntimeProvider>
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PluginLayoutCellView", () => {
|
||||||
|
it("renders the mock registered component when the provider is loaded", () => {
|
||||||
|
const registry = new PluginRuntimeRegistry();
|
||||||
|
registry.add(stubPlugin());
|
||||||
|
renderCell(registry);
|
||||||
|
expect(screen.getByTestId("state").textContent).toBe(JSON.stringify({ commits: 3 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips state through setState → onStateChange", () => {
|
||||||
|
const registry = new PluginRuntimeRegistry();
|
||||||
|
registry.add(stubPlugin());
|
||||||
|
let lastState: unknown = null;
|
||||||
|
renderCell(registry, {
|
||||||
|
onStateChange: (s) => {
|
||||||
|
lastState = s;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "bump" }));
|
||||||
|
expect(lastState).toEqual({ commits: 4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the non-destructive fallback when the provider is not loaded, without mutating the cell", () => {
|
||||||
|
const registry = new PluginRuntimeRegistry(); // empty — provider not loaded
|
||||||
|
const testCell = cell();
|
||||||
|
renderCell(registry, { cell: testCell });
|
||||||
|
|
||||||
|
expect(screen.getByText("Layout indisponible")).toBeTruthy();
|
||||||
|
// The cell object itself is untouched — the domain identity/state survive.
|
||||||
|
expect(testCell).toEqual(cell());
|
||||||
|
});
|
||||||
|
});
|
||||||
73
frontend/src/features/plugins/PluginLayoutCellView.tsx
Normal file
73
frontend/src/features/plugins/PluginLayoutCellView.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* `PluginLayoutCellView` — renders a `customPluginLayout` top-level
|
||||||
|
* {@link LayoutNode} (ticket #43, F4, carnet v2 §3): the plugin's registered
|
||||||
|
* React component when its provider is loaded and declares the type,
|
||||||
|
* otherwise {@link PluginLayoutFallback}.
|
||||||
|
*
|
||||||
|
* State is opaque to the domain (`unknown`) and round-trips through
|
||||||
|
* `LayoutGateway` exactly like any other node field — `setState` here is the
|
||||||
|
* only way a plugin component is meant to persist it (never direct gateway
|
||||||
|
* calls from inside a plugin component).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CustomPluginLayoutCell, PluginAdmin } from "@/domain";
|
||||||
|
import { useGateways } from "@/app/di";
|
||||||
|
import { usePluginRuntime } from "./PluginRuntimeProvider";
|
||||||
|
import { PluginLayoutFallback } from "./PluginLayoutFallback";
|
||||||
|
import { resolvePluginLayoutAvailability } from "./layoutAvailability";
|
||||||
|
|
||||||
|
interface PluginLayoutCellViewProps {
|
||||||
|
projectId: string;
|
||||||
|
cell: CustomPluginLayoutCell;
|
||||||
|
installedPlugins?: PluginAdmin[];
|
||||||
|
onStateChange: (nextState: unknown) => void;
|
||||||
|
onOpenPlugins: () => void;
|
||||||
|
onChooseAnotherLayout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PluginLayoutCellView({
|
||||||
|
projectId,
|
||||||
|
cell,
|
||||||
|
installedPlugins,
|
||||||
|
onStateChange,
|
||||||
|
onOpenPlugins,
|
||||||
|
onChooseAnotherLayout,
|
||||||
|
}: PluginLayoutCellViewProps) {
|
||||||
|
const { registry } = usePluginRuntime();
|
||||||
|
const gateways = useGateways();
|
||||||
|
const availability = resolvePluginLayoutAvailability(registry, cell, installedPlugins);
|
||||||
|
|
||||||
|
if (availability !== "available") {
|
||||||
|
const providerDisplayName =
|
||||||
|
registry.get(cell.pluginId)?.displayName ??
|
||||||
|
installedPlugins?.find((p) => p.id === cell.pluginId)?.displayName;
|
||||||
|
return (
|
||||||
|
<PluginLayoutFallback
|
||||||
|
cell={cell}
|
||||||
|
availability={availability}
|
||||||
|
providerDisplayName={providerDisplayName}
|
||||||
|
onOpenPlugins={onOpenPlugins}
|
||||||
|
onChooseAnotherLayout={onChooseAnotherLayout}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Component = registry.layoutComponent(cell.pluginId, cell.layoutType)!;
|
||||||
|
return (
|
||||||
|
<Component
|
||||||
|
projectId={projectId}
|
||||||
|
nodeId={cell.id}
|
||||||
|
layoutType={cell.layoutType}
|
||||||
|
state={cell.state}
|
||||||
|
setState={onStateChange}
|
||||||
|
availability="available"
|
||||||
|
gateways={{
|
||||||
|
project: gateways.project,
|
||||||
|
git: gateways.git,
|
||||||
|
terminal: gateways.terminal,
|
||||||
|
agents: gateways.agent,
|
||||||
|
system: gateways.system,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
frontend/src/features/plugins/PluginLayoutFallback.tsx
Normal file
56
frontend/src/features/plugins/PluginLayoutFallback.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* `PluginLayoutFallback` — shown in place of a plugin-provided layout when its
|
||||||
|
* provider is unavailable (ticket #43, F4, carnet v2 §3.4): "l'UI rend `Layout
|
||||||
|
* indisponible`, provenance si connue, actions `Ouvrir Plugins` et `Choisir un
|
||||||
|
* autre layout`." Never mutates the persisted layout — the domain keeps the
|
||||||
|
* cell's opaque identity/state untouched so it recovers automatically if the
|
||||||
|
* plugin comes back (re-enabled/reinstalled) on a later restart.
|
||||||
|
*
|
||||||
|
* The canonical `CustomPluginLayoutCell` payload (carnet v2 §3.2) carries no
|
||||||
|
* display name on the wire — only `pluginId`. The caller resolves a friendly
|
||||||
|
* name from the plugin admin list/runtime registry when it can; this falls
|
||||||
|
* back to the raw `pluginId` so provenance is still always shown ("si
|
||||||
|
* connue" is satisfied by the id itself, which is always known).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CustomPluginLayoutCell, PluginLayoutAvailability } from "@/domain";
|
||||||
|
import { Button, Panel } from "@/shared";
|
||||||
|
|
||||||
|
const REASON_LABEL: Record<Exclude<PluginLayoutAvailability, "available">, string> = {
|
||||||
|
"plugin-disabled": "Le plugin fournisseur est désactivé.",
|
||||||
|
"plugin-missing": "Le plugin fournisseur n'est pas installé.",
|
||||||
|
incompatible: "Le plugin fournisseur ne déclare plus ce type de layout.",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PluginLayoutFallbackProps {
|
||||||
|
cell: CustomPluginLayoutCell;
|
||||||
|
availability: Exclude<PluginLayoutAvailability, "available">;
|
||||||
|
/** Friendly provider name, resolved by the caller; falls back to `cell.pluginId`. */
|
||||||
|
providerDisplayName?: string;
|
||||||
|
onOpenPlugins: () => void;
|
||||||
|
onChooseAnotherLayout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PluginLayoutFallback({
|
||||||
|
cell,
|
||||||
|
availability,
|
||||||
|
providerDisplayName,
|
||||||
|
onOpenPlugins,
|
||||||
|
onChooseAnotherLayout,
|
||||||
|
}: PluginLayoutFallbackProps) {
|
||||||
|
return (
|
||||||
|
<Panel className="flex h-full flex-col items-center justify-center gap-2 text-center">
|
||||||
|
<p className="text-sm font-semibold text-content">Layout indisponible</p>
|
||||||
|
<p className="text-xs text-muted">{REASON_LABEL[availability]}</p>
|
||||||
|
<p className="text-xs text-faint">Fourni par « {providerDisplayName ?? cell.pluginId} »</p>
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<Button size="sm" variant="secondary" onClick={onOpenPlugins}>
|
||||||
|
Ouvrir Plugins
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={onChooseAnotherLayout}>
|
||||||
|
Choisir un autre layout
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* F4 — `PluginLayoutSelectorSection` (carnet §10 F4 acceptance criteria:
|
||||||
|
* "layout disabled non proposé comme nouveau choix").
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
PluginCommandRegistry,
|
||||||
|
PluginLayoutRegistry,
|
||||||
|
PluginMenuRegistry,
|
||||||
|
PluginRuntimeRegistry,
|
||||||
|
type LoadedPlugin,
|
||||||
|
} from "@/plugins/runtime";
|
||||||
|
import { PluginLayoutSelectorSection, listPluginLayoutChoices } from "./PluginLayoutSelectorSection";
|
||||||
|
|
||||||
|
function stubPlugin(pluginId: string, displayName: string, layoutType: string): LoadedPlugin {
|
||||||
|
const contributes = {
|
||||||
|
menus: [],
|
||||||
|
menuItems: [],
|
||||||
|
layouts: [{ type: layoutType, label: `${displayName} layout`, component: "X" }],
|
||||||
|
mcpServers: [],
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
pluginId,
|
||||||
|
displayName,
|
||||||
|
contributes,
|
||||||
|
commands: new PluginCommandRegistry(pluginId, new Set()),
|
||||||
|
layouts: new PluginLayoutRegistry(pluginId, new Set([layoutType])),
|
||||||
|
menu: new PluginMenuRegistry(pluginId),
|
||||||
|
dispose: async () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("listPluginLayoutChoices / PluginLayoutSelectorSection", () => {
|
||||||
|
it("only lists loaded (enabled) plugins' layouts — a disabled plugin is never in the registry", () => {
|
||||||
|
const registry = new PluginRuntimeRegistry();
|
||||||
|
registry.add(stubPlugin("dev.acme.one", "One", "dev.acme.one.layout"));
|
||||||
|
// "dev.acme.two" is disabled ⇒ never loaded ⇒ never added to the registry
|
||||||
|
// (carnet §1.3) — nothing to filter here beyond what's already loaded.
|
||||||
|
|
||||||
|
const choices = listPluginLayoutChoices(registry);
|
||||||
|
expect(choices).toHaveLength(1);
|
||||||
|
expect(choices[0].pluginId).toBe("dev.acme.one");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing when there are no plugin layouts", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<PluginLayoutSelectorSection registry={new PluginRuntimeRegistry()} onSelect={() => {}} />,
|
||||||
|
);
|
||||||
|
expect(container.innerHTML).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onSelect with the chosen plugin layout", () => {
|
||||||
|
const registry = new PluginRuntimeRegistry();
|
||||||
|
registry.add(stubPlugin("dev.acme.one", "One", "dev.acme.one.layout"));
|
||||||
|
let selected: string | null = null;
|
||||||
|
render(
|
||||||
|
<PluginLayoutSelectorSection
|
||||||
|
registry={registry}
|
||||||
|
onSelect={(choice) => {
|
||||||
|
selected = choice.layout.type;
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("menuitem", { name: /One layout/ }));
|
||||||
|
expect(selected).toBe("dev.acme.one.layout");
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user