Merge feature/modeles-locaux into develop
Sprint « Modeles locaux » terminé et vert : serveur de modèle local llama.cpp intégré (#35) et profils OpenCode locaux multiples (#36). Backend (domain/application/infra/app-tauri) + frontend. Tests verts (exécution réelle) : cargo build workspace Finished ; domain+application ; app-tauri dto_model_servers 3/3, dto_profiles 10/10 ; infra model_server 2/2 ; application model_server+profile_usecases 19/19 ; frontend tsc propre + vitest 608/608. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -73,3 +73,7 @@
|
||||
- [ticket14-local-lan-openai-adapter-scoping](ticket14-local-lan-openai-adapter-scoping.md) — memory note ticket14-local-lan-openai-adapter-scoping
|
||||
- [ticket28-firstrun-detect-hang-scoping](ticket28-firstrun-detect-hang-scoping.md) — Cadrage hexagonal du bug bloquant first-run (#28) — panic block_on imbriqué dans detect + découplage busy/détection, contrats et point de vérité QA.
|
||||
- [ticket28-f1-frontend-delivered](ticket28-f1-frontend-delivered.md) — Lot F1 du ticket #28 livré sur feature/ticket28-firstrun-detect-hang — invariant busy/detectProfiles, flag detecting, timeout UI, test de non-régression.
|
||||
- [opencode-llamacpp-replaces-ollama](opencode-llamacpp-replaces-ollama.md) — memory note opencode-llamacpp-replaces-ollama
|
||||
- [f36-multi-opencode-profiles-frontend](f36-multi-opencode-profiles-frontend.md) — Multiplicité des profils OpenCode locaux côté UI first-run wizard — livrée, verte, sans gap backend.
|
||||
- [f35-launch-status-done-config-crud-blocked](f35-launch-status-done-config-crud-blocked.md) — F35.1 UI statut de lancement du serveur local livrée+verte ; F35.2 config CRUD bloquée sur un DTO figé qui ne matche pas le wire réel + delete manquant.
|
||||
- [f35-config-crud-delivered](f35-config-crud-delivered.md) — F35.2 (config serveur local + sélection dans les profils OpenCode) livrée et verte sur feature/modeles-locaux ; clôt le sprint modèles locaux F34-F36.
|
||||
|
||||
13
.ideai/memory/f35-config-crud-delivered.md
Normal file
13
.ideai/memory/f35-config-crud-delivered.md
Normal file
@ -0,0 +1,13 @@
|
||||
---
|
||||
name: f35-config-crud-delivered
|
||||
description: F35.2 (config serveur local + sélection dans les profils OpenCode) livrée et verte sur feature/modeles-locaux ; clôt le sprint modèles locaux F34-F36.
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
Ticket #35 F35.2 LIVRÉ (vert) sur `feature/modeles-locaux`. Débloqué par le contrat PLAT d'Architect.
|
||||
|
||||
**Contrat backend** : `list_model_servers` / `save_model_server({request:{config}})` / `delete_model_server({serverId})`. DTO plat camelCase `LocalModelServerConfig { id, kind:"llamaCpp", name, baseURL, port, modelPath?, servedModelName, binaryPath?, args, autoStart, stopPolicy }`. PIÈGE intégré : `save_model_server` exige un UUID valide dans `config.id` même à la création (backend ne génère PAS l'id serveur, seulement le model.id interne caché) → le front minte l'UUID client-side (`newModelServerId`). Erreur suppression référencée = `code:"model_server_in_use"`.
|
||||
|
||||
**Livré** : `ModelServerGateway` (port + adapter Tauri `adapters/modelServer.ts` + `MockModelServerGateway` avec `markInUse`). Feature `features/model-servers/` : `useModelServers` (CRUD, traduit in-use en message FR actionnable), `ModelServersPanel` (déclarer/éditer/supprimer), `ModelServerSelect` (dropdown binding avec « None » = serveur externe + préserve un id orphelin « unknown/removed »). Dans `FirstRunWizard`, le champ texte `localModelServerId` (F36) est remplacé par `ModelServerSelect`.
|
||||
|
||||
Cohérent avec F35.1 (badge corrèle via localModelServerId). tsc 0 ; suite 608/608. Sprint modèles locaux (F34 backend, F35, F36) désormais complet côté frontend.
|
||||
16
.ideai/memory/f35-launch-status-done-config-crud-blocked.md
Normal file
16
.ideai/memory/f35-launch-status-done-config-crud-blocked.md
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
name: f35-launch-status-done-config-crud-blocked
|
||||
description: F35.1 UI statut de lancement du serveur local livrée+verte ; F35.2 config CRUD bloquée sur un DTO figé qui ne matche pas le wire réel + delete manquant.
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
Ticket #35 sur `feature/modeles-locaux`.
|
||||
|
||||
**F35.1 LIVRÉ (vert)** : événements `model_server_status_changed`/`agent_launch_failed` déjà exposés via `domain://event`. Consommés dans `useAgents` → `modelServerStatusByServer` (par serverId) + `launchFailureByAgent` (par agentId). Badge `ModelServerLaunchBadge` monté par ligne dans `AgentsPanel`, corrélé via `profile.opencode.localModelServerId`. Contrat réel ≠ DTO figé du ticket : `serverId` sur l'enveloppe (pas par état) ; états camelCase `notConfigured|probing|starting|ready{reused}|failed{code,message}` ; PAS de `baseURL`/`model` émis dans le statut. Je me suis aligné sur le wire réel.
|
||||
|
||||
**F35.2 BLOQUÉ — 3 gaps backend remontés à Main :**
|
||||
1. (bloquant) `save_model_server`/`list_model_servers` transmettent le domaine `LocalModelServerConfig` en `serde(transparent)`, forme IMBRIQUÉE : `{id, kind:"llamaCpp", name, endpoint:{baseURL,port}, model:{id,label,path?,servedName}, binary?, args, autoStart, stopPolicy}`. Le DTO figé du ticket est plat et FAUX (kind "llamacpp", baseURL/port/modelPath/servedModelName/binaryPath à plat). Pire : `model` exige `id`+`label` non-vides (LocalModelRef::new) absents du DTO figé → payload figé rejeté. Besoin arbitrage Architect : (A) couche DTO plate app-tauri qui remplit model.id/label, ou (B) MAJ du DTO figé vers la forme imbriquée + sémantique de model.id/label.
|
||||
2. Pas de commande `delete_model_server`.
|
||||
3. (côté moi) créer `ModelServerGateway`/port/adapter une fois le contrat tranché.
|
||||
|
||||
Saisie simple de `localModelServerId` (F36) laissée en place, pas de régression. tsc propre ; suite 593/593.
|
||||
17
.ideai/memory/f36-multi-opencode-profiles-frontend.md
Normal file
17
.ideai/memory/f36-multi-opencode-profiles-frontend.md
Normal file
@ -0,0 +1,17 @@
|
||||
---
|
||||
name: f36-multi-opencode-profiles-frontend
|
||||
description: Multiplicité des profils OpenCode locaux côté UI first-run wizard — livrée, verte, sans gap backend.
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
Ticket #36 (F36.1) livré côté frontend sur `feature/modeles-locaux`.
|
||||
|
||||
**Constat clé** : aucun gap backend. Le backend supporte déjà N profils OpenCode (identité = ProfileId) via `clone_opencode_profile_from_seed`, `list_profiles`, `save_profile`, `delete_profile`, `configure_profiles`. `OpenCodeConfig` DTO expose `localModelServerId` (camelCase).
|
||||
|
||||
**Ce qui manquait, purement front** : la commande clone n'était câblée dans aucun gateway, et le miroir TS `OpenCodeConfig` n'avait pas `localModelServerId`.
|
||||
|
||||
**Livré** : `ProfileGateway.cloneOpenCodeProfileFromSeed` (port + adapter Tauri + mock) ; le first-run wizard (`FirstRunWizard.tsx` + `useFirstRun.ts`) gère désormais une LISTE de profils OpenCode — bouton "Add OpenCode profile" + "Duplicate" par ligne, name éditable, cases reasoning/attachment, champ localModelServerId (saisie simple).
|
||||
|
||||
**Reste** : lot F35 = UI riche de config serveur local (remplacera la saisie simple de `localModelServerId`). Le wizard reste la surface de gestion de profils (`Settings ▸ Configure profiles` = `forceOpen`) ; il lit `firstRunState().referenceProfiles`, pas `list_profiles` — à vérifier au lot F35 pour l'édition de profils OpenCode déjà persistés.
|
||||
|
||||
tsc propre ; 62 tests first-run/mock verts ; suite complète 581/581.
|
||||
35
.ideai/memory/opencode-llamacpp-replaces-ollama.md
Normal file
35
.ideai/memory/opencode-llamacpp-replaces-ollama.md
Normal file
@ -0,0 +1,35 @@
|
||||
---
|
||||
name: opencode-llamacpp-replaces-ollama
|
||||
description: memory note opencode-llamacpp-replaces-ollama
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
---
|
||||
title: OpenCode local provider — llama.cpp remplace Ollama (chantier livré)
|
||||
type: decision
|
||||
description: Le provider local d'OpenCode passe d'Ollama à llama.cpp (llama-server OpenAI-compatible). Profil éditable baseURL/apiKey/model. Livré sur develop (merge 2e98f1f). Clôt le diagnostic tool-calling Ollama.
|
||||
---
|
||||
|
||||
# OpenCode local : llama.cpp remplace Ollama (2026-07-11)
|
||||
|
||||
## Pourquoi
|
||||
Le tool-calling des modèles locaux via **Ollama** ne s'est jamais déclenché nativement en live : OpenCode fuyait les appels d'outils en texte `<function=...>` (émulation par prompt), jamais parsés. Diagnostic utilisateur : la faute est dans la relation **OpenCode↔Ollama**. Décision : abandonner Ollama comme provider OpenCode et passer à **llama.cpp** (`llama-server`, endpoint OpenAI-compatible). Voir historique : [[opencode-config-model-agnostic-tool-diagnostic]], [[opencode-ollama-native-tool-call-flag]], [[opencode-ollama-tool-calling-surface-overload]] (contexte Ollama, désormais superseded).
|
||||
|
||||
## Contrat livré
|
||||
`OpenCodeConfig` (domaine + DTO app-tauri + type frontend) :
|
||||
`{ baseURL: String (requis, http/https), apiKey: Option<String>, model: String (requis), reasoning: Option<bool>=true, attachment: Option<bool>=false }`. Sérialisation wire camelCase (`baseURL` via serde rename), `apiKey`/`reasoning`/`attachment` `skip_serializing_if=None`.
|
||||
|
||||
Seed profil canonique : `opencode-ollama` → **`opencode-llamacpp`** ("OpenCode + llama.cpp"), command `opencode`, adapter `OpenCode`, defaults `baseURL=http://localhost:8080/v1`, `apiKey=sk-no-key`, `model=qwen3-coder-30b`.
|
||||
|
||||
`opencode_config_json` (crates/application/src/agent/lifecycle.rs) régénère un opencode.json MINIMAL : provider `llamacpp` (`npm=@ai-sdk/openai-compatible`, `options.baseURL/apiKey` du profil, def modèle `llamacpp/<model>` avec `tool_call:true,reasoning,attachment`), MCP `idea` inchangé, `permission bash/edit=ask`, `disabled_providers=["anthropic","openai","gemini","ollama"]`. `apiKey` omis (pas `null`) si absent. Plus aucun résidu `ollama`/préfixe `ollama/`/allow-list diagnostic.
|
||||
|
||||
Frontend : wizard profil OpenCode = 3 champs éditables **Base URL / Model / API key(optionnel)** (`FirstRunWizard.tsx`, `profile.ts`, mocks). L'embedder Ollama (`ollamaDetected`, mémoire vectorielle) est un AUTRE usage, NON touché.
|
||||
|
||||
## État
|
||||
- Fichiers : domain/profile.rs, application/{catalogue.rs,lifecycle.rs,tests/agent_lifecycle.rs}, infrastructure/session/opencode.rs ; frontend domain/index.ts, adapters/mock/*, features/first-run/*.
|
||||
- QA VERT : domain 244, application 81+64, infra 263/10 (les 10 = bind-port sandbox, identiques sur develop = non-régression), frontend 574, tsc propre. Claude/Codex non régressés.
|
||||
- Git : commit `4e70631`, merge `--no-ff` `2e98f1f` sur `develop`. Base branche = `eaba05d` (profil OpenCode process-backed). WIP diagnostic Ollama capsulé dans `3cdedf2` sur `feature/opencode-glm47-flash-tool-diagnostic`. PAS de push distant.
|
||||
- Tickets : #32 fermé ; #42/#43 (Ollama) retirés du store.
|
||||
|
||||
## Reste ouvert
|
||||
E2E live llama.cpp jamais exécuté (aucun `llama-server` joignable sur :8080 au moment QA ; `llama-server` 9859 + `opencode` 1.17.18 installés). À valider côté utilisateur : lancer llama-server, relancer l'Appointe rebuild, créer un agent profil llama.cpp, vérifier un vrai `tool_use` MCP (pas de `<function=` texte). Rebuild AppImage requis pour le live (binaire qui tourne = AppImage). Build via `NO_STRIP=true` cf [[appimage-build-no-strip-relr-dyn-fix]].
|
||||
@ -0,0 +1,11 @@
|
||||
---
|
||||
id: "883534aa-7fc1-4d83-a9c0-17cac4a4eea5"
|
||||
order: 4
|
||||
name: "Modeles locaux"
|
||||
status: "planned"
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"user"}
|
||||
createdAt: 1783757092388
|
||||
updatedAt: 1783757205620
|
||||
version: 2
|
||||
---
|
||||
@ -27,6 +27,15 @@
|
||||
"status": "planned",
|
||||
"updatedAt": 1783330421738,
|
||||
"version": 3
|
||||
},
|
||||
{
|
||||
"id": "883534aa-7fc1-4d83-a9c0-17cac4a4eea5",
|
||||
"path": "883534aa-7fc1-4d83-a9c0-17cac4a4eea5",
|
||||
"order": 4,
|
||||
"name": "Modeles locaux",
|
||||
"status": "planned",
|
||||
"updatedAt": 1783757205620,
|
||||
"version": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
issueRef: "#32"
|
||||
version: 5
|
||||
version: 6
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1783491681672
|
||||
updatedAt: 1783723378815
|
||||
---
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
id: "9291e7ad-a300-4f15-83b1-93f56f6e1b79"
|
||||
number: 32
|
||||
title: "[Bug] Erreur CLI Ollama profile"
|
||||
status: "inProgress"
|
||||
status: "closed"
|
||||
priority: "medium"
|
||||
sprint: null
|
||||
links: [{"target":"#14","kind":"relatesTo"}]
|
||||
@ -10,8 +10,8 @@ agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1783491571863
|
||||
updatedAt: 1783491681672
|
||||
version: 5
|
||||
updatedAt: 1783723378815
|
||||
version: 6
|
||||
---
|
||||
J'ai créé un profile IA Ollama et j'ai ajouté un agent avec ce profile (agent TestOllama), mais quand
|
||||
j'essaie d'afficher sa cli j'ai un bandeau rouge qui dit "Échec du lancement de l'agent : process error: pty spawn
|
||||
|
||||
6
.ideai/tickets/35/carnet.md
Normal file
6
.ideai/tickets/35/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#35"
|
||||
version: 5
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1783757457771
|
||||
---
|
||||
16
.ideai/tickets/35/issue.md
Normal file
16
.ideai/tickets/35/issue.md
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "77ef65a5-bbb8-44d2-a610-27bf15c54a0a"
|
||||
number: 35
|
||||
title: "Avoir llamacpp intégré"
|
||||
status: "inProgress"
|
||||
priority: "high"
|
||||
sprint: "883534aa-7fc1-4d83-a9c0-17cac4a4eea5"
|
||||
links: []
|
||||
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1783756863540
|
||||
updatedAt: 1783757457771
|
||||
version: 5
|
||||
---
|
||||
Nous avons llamacpp qui marche super avec OpencCode à présent. J'aimerais maintenant que llamacpp soit lancé automatiquement via IdeA avec le bon modele à la bonne url et bon port si celui-ci n'est pas encore lancé
|
||||
6
.ideai/tickets/36/carnet.md
Normal file
6
.ideai/tickets/36/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#36"
|
||||
version: 5
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1783757458773
|
||||
---
|
||||
16
.ideai/tickets/36/issue.md
Normal file
16
.ideai/tickets/36/issue.md
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "3bb071b9-08eb-4e1b-b4b4-dccee8114a7e"
|
||||
number: 36
|
||||
title: "[UI] Pouvoir déclarer plusieurs profils AI de modeles locaux"
|
||||
status: "inProgress"
|
||||
priority: "medium"
|
||||
sprint: "883534aa-7fc1-4d83-a9c0-17cac4a4eea5"
|
||||
links: []
|
||||
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1783757232381
|
||||
updatedAt: 1783757458773
|
||||
version: 5
|
||||
---
|
||||
Actuellement chaque type de profils ne peux déclarer qu'un seul profile AI. J'aimerais que le profile OpenCode puisse permetre d'en décalrer plusieurs différents.
|
||||
6
.ideai/tickets/37/carnet.md
Normal file
6
.ideai/tickets/37/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#37"
|
||||
version: 5
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedAt: 1783757520082
|
||||
---
|
||||
16
.ideai/tickets/37/issue.md
Normal file
16
.ideai/tickets/37/issue.md
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "a2b6212a-8725-4ace-b8a4-c3a41e2c2988"
|
||||
number: 37
|
||||
title: "[UI] garder les tickets dans les sprints, mais ajouter l'affichage du status et la possibiliter de filtrer les tickets"
|
||||
status: "open"
|
||||
priority: "low"
|
||||
sprint: "5afd6780-0f76-40d7-a10f-32ee52469d74"
|
||||
links: []
|
||||
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"user"}
|
||||
createdAt: 1783757421147
|
||||
updatedAt: 1783757520082
|
||||
version: 5
|
||||
---
|
||||
Actuelleemnt dans les sprints les tickets sont retirés une fois fait. J'aimerais que les tickets restent dans le sprints mais qu'on ajoute l'affichage du status de chaque ticket. J'aimerais aussi qu'on ai la possibilité de trier les tickets via leur status, leur priorité etc comme sur la liste des tickets
|
||||
6
.ideai/tickets/38/carnet.md
Normal file
6
.ideai/tickets/38/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#38"
|
||||
version: 5
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedAt: 1783757636999
|
||||
---
|
||||
16
.ideai/tickets/38/issue.md
Normal file
16
.ideai/tickets/38/issue.md
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "7e89bfc9-dc56-4656-a154-690f02e69756"
|
||||
number: 38
|
||||
title: "[UI] Pouvoir ajouter un ticket à un sprint pendant sa création"
|
||||
status: "open"
|
||||
priority: "low"
|
||||
sprint: "5afd6780-0f76-40d7-a10f-32ee52469d74"
|
||||
links: []
|
||||
agentRefs: []
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"user"}
|
||||
createdAt: 1783757548614
|
||||
updatedAt: 1783757636999
|
||||
version: 5
|
||||
---
|
||||
J'aimerais que dans la création de ticket on puisse lui attribuer un sprint via une popup comme celle du choix de tickets
|
||||
6
.ideai/tickets/39/carnet.md
Normal file
6
.ideai/tickets/39/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#39"
|
||||
version: 3
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedAt: 1783757851100
|
||||
---
|
||||
16
.ideai/tickets/39/issue.md
Normal file
16
.ideai/tickets/39/issue.md
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "02ff2d0c-4312-47c3-b84c-834029723fc4"
|
||||
number: 39
|
||||
title: "[UI] Fermer toutes les fernetres lors de la fermeture de la fenetre principale principale"
|
||||
status: "open"
|
||||
priority: "low"
|
||||
sprint: "5afd6780-0f76-40d7-a10f-32ee52469d74"
|
||||
links: []
|
||||
agentRefs: []
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"user"}
|
||||
createdAt: 1783757707896
|
||||
updatedAt: 1783757851100
|
||||
version: 3
|
||||
---
|
||||
Quand on ferme la fenetre principale d'IdeA, j'aimerais que toutes les fenêtres IdeA se ferment aussi.
|
||||
6
.ideai/tickets/40/carnet.md
Normal file
6
.ideai/tickets/40/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#40"
|
||||
version: 5
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedAt: 1783757857115
|
||||
---
|
||||
16
.ideai/tickets/40/issue.md
Normal file
16
.ideai/tickets/40/issue.md
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "6b04c8d1-177c-422a-9955-7fee2e8afdeb"
|
||||
number: 40
|
||||
title: "[UI] réouvrir les fenetres au lancement d'IdeA"
|
||||
status: "open"
|
||||
priority: "low"
|
||||
sprint: "5afd6780-0f76-40d7-a10f-32ee52469d74"
|
||||
links: [{"target":"#39","kind":"dependsOn"}]
|
||||
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"user"}
|
||||
createdAt: 1783757766786
|
||||
updatedAt: 1783757857115
|
||||
version: 5
|
||||
---
|
||||
Au lancement d'IdeA, j'aimerais que les fenêtres fermées lors de la précédentes fermeture de la fenêtre principale d'IdeA soient automatiqument réouvertes dans le même status, position, écran que quand elles ont été fermée
|
||||
6
.ideai/tickets/41/carnet.md
Normal file
6
.ideai/tickets/41/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#41"
|
||||
version: 4
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedAt: 1783757942954
|
||||
---
|
||||
16
.ideai/tickets/41/issue.md
Normal file
16
.ideai/tickets/41/issue.md
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "7d026901-333a-46a6-87f6-7384bf742310"
|
||||
number: 41
|
||||
title: "[UI] Selection multiple lors de la selection de tickets pour les sprints"
|
||||
status: "open"
|
||||
priority: "low"
|
||||
sprint: "5afd6780-0f76-40d7-a10f-32ee52469d74"
|
||||
links: []
|
||||
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"user"}
|
||||
createdAt: 1783757895085
|
||||
updatedAt: 1783757942954
|
||||
version: 4
|
||||
---
|
||||
Lorsque je selectionne les tickets pour mes sprints, j'iamerais que la selection me permette d'en choisir plusieurs à la fois pour ne pas avoir à réouvrir chaque fois la fenetre.
|
||||
@ -1,3 +1,3 @@
|
||||
{
|
||||
"nextNumber": 35
|
||||
"nextNumber": 42
|
||||
}
|
||||
@ -343,13 +343,13 @@
|
||||
"issueRef": "#32",
|
||||
"path": "32",
|
||||
"title": "[Bug] Erreur CLI Ollama profile",
|
||||
"status": "inProgress",
|
||||
"status": "closed",
|
||||
"priority": "medium",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [
|
||||
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||
],
|
||||
"updatedAt": 1783491681672
|
||||
"updatedAt": 1783723378815
|
||||
},
|
||||
{
|
||||
"issueRef": "#33",
|
||||
@ -370,6 +370,86 @@
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [],
|
||||
"updatedAt": 1783492562056
|
||||
},
|
||||
{
|
||||
"issueRef": "#35",
|
||||
"path": "35",
|
||||
"title": "Avoir llamacpp intégré",
|
||||
"status": "inProgress",
|
||||
"priority": "high",
|
||||
"sprint": "883534aa-7fc1-4d83-a9c0-17cac4a4eea5",
|
||||
"assignedAgentIds": [
|
||||
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||
],
|
||||
"updatedAt": 1783757457771
|
||||
},
|
||||
{
|
||||
"issueRef": "#36",
|
||||
"path": "36",
|
||||
"title": "[UI] Pouvoir déclarer plusieurs profils AI de modeles locaux",
|
||||
"status": "inProgress",
|
||||
"priority": "medium",
|
||||
"sprint": "883534aa-7fc1-4d83-a9c0-17cac4a4eea5",
|
||||
"assignedAgentIds": [
|
||||
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||
],
|
||||
"updatedAt": 1783757458773
|
||||
},
|
||||
{
|
||||
"issueRef": "#37",
|
||||
"path": "37",
|
||||
"title": "[UI] garder les tickets dans les sprints, mais ajouter l'affichage du status et la possibiliter de filtrer les tickets",
|
||||
"status": "open",
|
||||
"priority": "low",
|
||||
"sprint": "5afd6780-0f76-40d7-a10f-32ee52469d74",
|
||||
"assignedAgentIds": [
|
||||
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||
],
|
||||
"updatedAt": 1783757520082
|
||||
},
|
||||
{
|
||||
"issueRef": "#38",
|
||||
"path": "38",
|
||||
"title": "[UI] Pouvoir ajouter un ticket à un sprint pendant sa création",
|
||||
"status": "open",
|
||||
"priority": "low",
|
||||
"sprint": "5afd6780-0f76-40d7-a10f-32ee52469d74",
|
||||
"assignedAgentIds": [],
|
||||
"updatedAt": 1783757636999
|
||||
},
|
||||
{
|
||||
"issueRef": "#39",
|
||||
"path": "39",
|
||||
"title": "[UI] Fermer toutes les fernetres lors de la fermeture de la fenetre principale principale",
|
||||
"status": "open",
|
||||
"priority": "low",
|
||||
"sprint": "5afd6780-0f76-40d7-a10f-32ee52469d74",
|
||||
"assignedAgentIds": [],
|
||||
"updatedAt": 1783757851100
|
||||
},
|
||||
{
|
||||
"issueRef": "#40",
|
||||
"path": "40",
|
||||
"title": "[UI] réouvrir les fenetres au lancement d'IdeA",
|
||||
"status": "open",
|
||||
"priority": "low",
|
||||
"sprint": "5afd6780-0f76-40d7-a10f-32ee52469d74",
|
||||
"assignedAgentIds": [
|
||||
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||
],
|
||||
"updatedAt": 1783757857115
|
||||
},
|
||||
{
|
||||
"issueRef": "#41",
|
||||
"path": "41",
|
||||
"title": "[UI] Selection multiple lors de la selection de tickets pour les sprints",
|
||||
"status": "open",
|
||||
"priority": "low",
|
||||
"sprint": "5afd6780-0f76-40d7-a10f-32ee52469d74",
|
||||
"assignedAgentIds": [
|
||||
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||
],
|
||||
"updatedAt": 1783757942954
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -34,28 +34,30 @@ use crate::dto::{
|
||||
parse_task_id, parse_template_id, parse_ticket_id, AgentDriftListDto, AgentDto, AgentListDto,
|
||||
AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto,
|
||||
BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto,
|
||||
ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto,
|
||||
CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto,
|
||||
CreateProjectRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
|
||||
DeleteLayoutRequestDto, DeleteLayoutResultDto, DeliveredDelegationRequestDto,
|
||||
DetectProfilesRequestDto, DetectProfilesResponseDto, EffectivePermissionsDto,
|
||||
EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto,
|
||||
FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto,
|
||||
GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto,
|
||||
HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, InterruptAgentRequestDto,
|
||||
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
|
||||
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenTerminalRequestDto, ProfileDto,
|
||||
CloneOpenCodeProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
|
||||
CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto,
|
||||
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
|
||||
CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
|
||||
DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
|
||||
EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto,
|
||||
ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto,
|
||||
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
|
||||
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
||||
InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto,
|
||||
LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto,
|
||||
ModelServerConfigDto, ModelServerConfigListDto, OpenTerminalRequestDto, ProfileDto,
|
||||
ProfileListDto, ProjectDto, ProjectListDto, ProjectPermissionsDto, ProjectWorkStateDto,
|
||||
ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto,
|
||||
ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
|
||||
ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResumableAgentListDto,
|
||||
SaveEmbedderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto,
|
||||
SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto,
|
||||
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
|
||||
TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
|
||||
UpdateAgentContextRequestDto, UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto,
|
||||
UpdateProjectContextRequestDto, UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto,
|
||||
UpdateTemplateRequestDto, WriteTerminalRequestDto,
|
||||
SaveEmbedderProfileRequestDto, SaveModelServerRequestDto, SaveProfileRequestDto,
|
||||
SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto,
|
||||
StopLiveAgentRequestDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto,
|
||||
SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto,
|
||||
TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
|
||||
UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
||||
UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
|
||||
WriteTerminalRequestDto, parse_model_server_id, save_model_server_input,
|
||||
};
|
||||
use crate::pty::{PtyBridge, PtyChunk};
|
||||
use crate::state::AppState;
|
||||
@ -793,6 +795,25 @@ pub async fn save_profile(
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `clone_opencode_profile_from_seed` — create a new OpenCode profile instance
|
||||
/// from the canonical `opencode-llamacpp` seed/template.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] (`STORE` on profiles I/O failure, `INVALID` for a
|
||||
/// blank requested name).
|
||||
#[tauri::command]
|
||||
pub async fn clone_opencode_profile_from_seed(
|
||||
request: CloneOpenCodeProfileFromSeedRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ProfileDto, ErrorDto> {
|
||||
state
|
||||
.clone_opencode_profile_from_seed
|
||||
.execute(request.into())
|
||||
.await
|
||||
.map(ProfileDto::from)
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `delete_profile` — delete a profile by id.
|
||||
///
|
||||
/// # Errors
|
||||
@ -836,6 +857,73 @@ pub async fn configure_profiles(
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `list_model_servers` — list configured local model servers.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] on registry failure.
|
||||
#[tauri::command]
|
||||
pub async fn list_model_servers(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ModelServerConfigListDto, ErrorDto> {
|
||||
state
|
||||
.list_model_servers
|
||||
.execute()
|
||||
.await
|
||||
.map(ModelServerConfigListDto::from)
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `save_model_server` — upsert a local model server config.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] on registry failure.
|
||||
#[tauri::command]
|
||||
pub async fn save_model_server(
|
||||
request: SaveModelServerRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ModelServerConfigDto, ErrorDto> {
|
||||
let server_id = parse_model_server_id(&request.config.id)?;
|
||||
let existing = state
|
||||
.list_model_servers
|
||||
.execute()
|
||||
.await
|
||||
.map_err(ErrorDto::from)?
|
||||
.servers
|
||||
.into_iter()
|
||||
.find(|config| config.id == server_id);
|
||||
let input = save_model_server_input(request, existing.as_ref())?;
|
||||
state
|
||||
.save_model_server
|
||||
.execute(input)
|
||||
.await
|
||||
.map(ModelServerConfigDto::from)
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `delete_model_server` — delete a local model server config when unused.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns `model_server_in_use` if any OpenCode profile still references it.
|
||||
#[tauri::command]
|
||||
pub async fn delete_model_server(
|
||||
server_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), ErrorDto> {
|
||||
let server_id = parse_model_server_id(&server_id)?;
|
||||
state
|
||||
.delete_model_server
|
||||
.execute(application::DeleteModelServerInput { server_id })
|
||||
.await
|
||||
.map_err(model_server_command_error)
|
||||
}
|
||||
|
||||
fn model_server_command_error(err: AppError) -> ErrorDto {
|
||||
match err {
|
||||
AppError::ModelServer { code, message } => ErrorDto { code, message },
|
||||
other => ErrorDto::from(other),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Embedder profiles & engines (LOT C2 — §14.5.3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -733,11 +733,12 @@ pub struct SetActiveLayoutRequestDto {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use application::{
|
||||
ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfileInput, DetectProfilesInput,
|
||||
DetectProfilesOutput, FirstRunStateOutput, ListProfilesOutput, ProfileAvailability,
|
||||
ReferenceProfilesOutput, SaveProfileInput, SaveProfileOutput,
|
||||
CloneOpenCodeProfileFromSeedInput, CloneOpenCodeProfileFromSeedOutput, ConfigureProfilesInput,
|
||||
ConfigureProfilesOutput, DeleteProfileInput, DetectProfilesInput, DetectProfilesOutput,
|
||||
FirstRunStateOutput, ListProfilesOutput, ProfileAvailability, ReferenceProfilesOutput,
|
||||
SaveProfileInput, SaveProfileOutput,
|
||||
};
|
||||
use domain::profile::AgentProfile;
|
||||
use domain::profile::{AgentProfile, OpenCodeConfig};
|
||||
use domain::ProfileId;
|
||||
|
||||
/// A profile crossing the wire. [`AgentProfile`] already serialises camelCase
|
||||
@ -776,6 +777,12 @@ impl From<SaveProfileOutput> for ProfileDto {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CloneOpenCodeProfileFromSeedOutput> for ProfileDto {
|
||||
fn from(out: CloneOpenCodeProfileFromSeedOutput) -> Self {
|
||||
Self(out.profile)
|
||||
}
|
||||
}
|
||||
|
||||
/// Request DTO for `detect_profiles`: the candidate profiles to probe.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@ -838,6 +845,27 @@ impl From<SaveProfileRequestDto> for SaveProfileInput {
|
||||
}
|
||||
}
|
||||
|
||||
/// Request DTO for `clone_opencode_profile_from_seed`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CloneOpenCodeProfileFromSeedRequestDto {
|
||||
/// Optional display name for the new profile.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
/// Optional OpenCode config override. When omitted, the seed config is copied.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub opencode: Option<OpenCodeConfig>,
|
||||
}
|
||||
|
||||
impl From<CloneOpenCodeProfileFromSeedRequestDto> for CloneOpenCodeProfileFromSeedInput {
|
||||
fn from(dto: CloneOpenCodeProfileFromSeedRequestDto) -> Self {
|
||||
Self {
|
||||
name: dto.name,
|
||||
opencode: dto.opencode,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Request DTO for `configure_profiles` (closes the first run).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@ -879,6 +907,252 @@ impl From<FirstRunStateOutput> for FirstRunStateDto {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local model servers (B35)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use application::{ListModelServersOutput, SaveModelServerInput, SaveModelServerOutput};
|
||||
use domain::model_server::{
|
||||
ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath,
|
||||
ModelServerEndpoint,
|
||||
};
|
||||
use domain::{LocalModelServerId, StopPolicy};
|
||||
|
||||
/// Local model-server implementation on the IPC wire.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ModelServerKindDto {
|
||||
/// llama.cpp `llama-server`.
|
||||
LlamaCpp,
|
||||
}
|
||||
|
||||
impl From<LocalModelServerKind> for ModelServerKindDto {
|
||||
fn from(kind: LocalModelServerKind) -> Self {
|
||||
match kind {
|
||||
LocalModelServerKind::LlamaCpp => Self::LlamaCpp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ModelServerKindDto> for LocalModelServerKind {
|
||||
fn from(kind: ModelServerKindDto) -> Self {
|
||||
match kind {
|
||||
ModelServerKindDto::LlamaCpp => Self::LlamaCpp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop policy on the IPC wire.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum StopPolicyDto {
|
||||
/// Keep the process alive.
|
||||
KeepAlive,
|
||||
/// Stop when no longer used.
|
||||
StopWhenUnused,
|
||||
/// Stop when the application exits.
|
||||
StopOnAppExit,
|
||||
}
|
||||
|
||||
impl From<StopPolicy> for StopPolicyDto {
|
||||
fn from(policy: StopPolicy) -> Self {
|
||||
match policy {
|
||||
StopPolicy::KeepAlive => Self::KeepAlive,
|
||||
StopPolicy::StopWhenUnused => Self::StopWhenUnused,
|
||||
StopPolicy::StopOnAppExit => Self::StopOnAppExit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<StopPolicyDto> for StopPolicy {
|
||||
fn from(policy: StopPolicyDto) -> Self {
|
||||
match policy {
|
||||
StopPolicyDto::KeepAlive => Self::KeepAlive,
|
||||
StopPolicyDto::StopWhenUnused => Self::StopWhenUnused,
|
||||
StopPolicyDto::StopOnAppExit => Self::StopOnAppExit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A local model-server config crossing the wire.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ModelServerConfigDto {
|
||||
/// Stable local server config id.
|
||||
pub id: String,
|
||||
/// Server implementation kind.
|
||||
pub kind: ModelServerKindDto,
|
||||
/// Display name.
|
||||
pub name: String,
|
||||
/// OpenAI-compatible base URL.
|
||||
#[serde(rename = "baseURL")]
|
||||
pub base_url: String,
|
||||
/// TCP port.
|
||||
pub port: u16,
|
||||
/// Optional explicit `.gguf` model path.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model_path: Option<String>,
|
||||
/// Served model name exposed to OpenCode.
|
||||
pub served_model_name: String,
|
||||
/// Optional explicit `llama-server` executable path.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub binary_path: Option<String>,
|
||||
/// Extra argv entries.
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
/// Whether IdeA should start the server lazily.
|
||||
pub auto_start: bool,
|
||||
/// Stop policy.
|
||||
pub stop_policy: StopPolicyDto,
|
||||
}
|
||||
|
||||
impl ModelServerConfigDto {
|
||||
/// Maps a domain config to the flat IPC DTO.
|
||||
#[must_use]
|
||||
pub fn from_domain(config: LocalModelServerConfig) -> Self {
|
||||
Self {
|
||||
id: config.id.to_string(),
|
||||
kind: config.kind.into(),
|
||||
name: config.name,
|
||||
base_url: config.endpoint.base_url,
|
||||
port: config.endpoint.port,
|
||||
model_path: config
|
||||
.model
|
||||
.path
|
||||
.map(|path| path.as_str().to_owned()),
|
||||
served_model_name: config.model.served_name,
|
||||
binary_path: config.binary.map(|binary| binary.as_str().to_owned()),
|
||||
args: config.args,
|
||||
auto_start: config.auto_start,
|
||||
stop_policy: config.stop_policy.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps the flat IPC DTO into the domain config, preserving internal model id
|
||||
/// from the existing config when available.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ErrorDto`] with `INVALID` code if any domain invariant rejects the input.
|
||||
pub fn into_domain(
|
||||
self,
|
||||
existing: Option<&LocalModelServerConfig>,
|
||||
) -> Result<LocalModelServerConfig, ErrorDto> {
|
||||
let server_id = parse_model_server_id(&self.id)?;
|
||||
let endpoint = ModelServerEndpoint::new(self.base_url.clone(), self.port)
|
||||
.map_err(invalid_domain_error)?;
|
||||
let model_path = optional_non_empty(self.model_path)
|
||||
.map(ModelPath::new)
|
||||
.transpose()
|
||||
.map_err(invalid_domain_error)?;
|
||||
let model_id = existing
|
||||
.filter(|config| config.id == server_id)
|
||||
.map(|config| config.model.id.clone())
|
||||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||
let label = non_empty_or_fallback(&self.served_model_name, &self.name);
|
||||
let model = LocalModelRef::new(
|
||||
model_id,
|
||||
label,
|
||||
model_path,
|
||||
self.served_model_name.clone(),
|
||||
)
|
||||
.map_err(invalid_domain_error)?;
|
||||
let binary = optional_non_empty(self.binary_path)
|
||||
.map(ExecutablePath::new)
|
||||
.transpose()
|
||||
.map_err(invalid_domain_error)?;
|
||||
LocalModelServerConfig::new(
|
||||
server_id,
|
||||
self.kind.into(),
|
||||
self.name,
|
||||
endpoint,
|
||||
model,
|
||||
binary,
|
||||
self.args,
|
||||
self.auto_start,
|
||||
self.stop_policy.into(),
|
||||
)
|
||||
.map_err(invalid_domain_error)
|
||||
}
|
||||
}
|
||||
|
||||
/// List response for local model servers.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ModelServerConfigListDto(pub Vec<ModelServerConfigDto>);
|
||||
|
||||
impl From<ListModelServersOutput> for ModelServerConfigListDto {
|
||||
fn from(out: ListModelServersOutput) -> Self {
|
||||
Self(
|
||||
out.servers
|
||||
.into_iter()
|
||||
.map(ModelServerConfigDto::from_domain)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Request DTO for `save_model_server`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SaveModelServerRequestDto {
|
||||
/// Config to upsert.
|
||||
pub config: ModelServerConfigDto,
|
||||
}
|
||||
|
||||
impl From<SaveModelServerOutput> for ModelServerConfigDto {
|
||||
fn from(out: SaveModelServerOutput) -> Self {
|
||||
Self::from_domain(out.config)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a local model-server id string.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ErrorDto`] with `INVALID` code if the string is not a UUID.
|
||||
pub fn parse_model_server_id(raw: &str) -> Result<LocalModelServerId, ErrorDto> {
|
||||
uuid::Uuid::parse_str(raw)
|
||||
.map(LocalModelServerId::from_uuid)
|
||||
.map_err(|_| ErrorDto {
|
||||
code: "INVALID".to_owned(),
|
||||
message: format!("invalid model server id: {raw}"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds a save-model-server input after the caller has resolved the existing
|
||||
/// config, if any.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ErrorDto`] if DTO-to-domain validation fails.
|
||||
pub fn save_model_server_input(
|
||||
request: SaveModelServerRequestDto,
|
||||
existing: Option<&LocalModelServerConfig>,
|
||||
) -> Result<SaveModelServerInput, ErrorDto> {
|
||||
Ok(SaveModelServerInput {
|
||||
config: request.config.into_domain(existing)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn optional_non_empty(raw: Option<String>) -> Option<String> {
|
||||
raw.map(|value| value.trim().to_owned())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn non_empty_or_fallback(primary: &str, fallback: &str) -> String {
|
||||
let primary = primary.trim();
|
||||
if primary.is_empty() {
|
||||
fallback.trim().to_owned()
|
||||
} else {
|
||||
primary.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_domain_error(error: impl std::fmt::Display) -> ErrorDto {
|
||||
ErrorDto {
|
||||
code: "INVALID".to_owned(),
|
||||
message: error.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Embedder profiles & engines (LOT C2 — §14.5.3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -15,11 +15,81 @@ use tauri::{AppHandle, Emitter};
|
||||
use domain::conversation::ConversationParty;
|
||||
use domain::events::{DomainEvent, OrchestrationSource};
|
||||
use domain::input::AgentLiveness;
|
||||
use domain::model_server::ModelServerLifecycleStatus;
|
||||
use domain::{IssueLinkKind, IssuePriority, IssueStatus};
|
||||
use infrastructure::TokioBroadcastEventBus;
|
||||
|
||||
/// Name of the Tauri event carrying relayed [`DomainEvent`]s.
|
||||
pub const DOMAIN_EVENT: &str = "domain://event";
|
||||
/// Dedicated Tauri event for local model-server status changes.
|
||||
pub const MODEL_SERVER_STATUS_CHANGED: &str = "model_server_status_changed";
|
||||
/// Dedicated Tauri event for launch failures.
|
||||
pub const AGENT_LAUNCH_FAILED: &str = "agent_launch_failed";
|
||||
|
||||
/// Model-server lifecycle status on the Tauri wire.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "state", rename_all = "camelCase")]
|
||||
pub enum ModelServerStatusDto {
|
||||
/// No server is configured for this profile.
|
||||
NotConfigured,
|
||||
/// Readiness probing is in progress.
|
||||
Probing,
|
||||
/// IdeA is starting the process.
|
||||
Starting,
|
||||
/// Server is ready.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Ready {
|
||||
/// `true` when an existing server was reused.
|
||||
reused: bool,
|
||||
},
|
||||
/// Server preparation failed.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Failed {
|
||||
/// Stable failure code.
|
||||
code: String,
|
||||
/// Human-readable message.
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<&ModelServerLifecycleStatus> for ModelServerStatusDto {
|
||||
fn from(status: &ModelServerLifecycleStatus) -> Self {
|
||||
match status {
|
||||
ModelServerLifecycleStatus::NotConfigured => Self::NotConfigured,
|
||||
ModelServerLifecycleStatus::Probing => Self::Probing,
|
||||
ModelServerLifecycleStatus::Starting => Self::Starting,
|
||||
ModelServerLifecycleStatus::Ready { reused } => Self::Ready { reused: *reused },
|
||||
ModelServerLifecycleStatus::Failed { code, message } => Self::Failed {
|
||||
code: code.clone(),
|
||||
message: message.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Payload for [`MODEL_SERVER_STATUS_CHANGED`].
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ModelServerStatusChangedDto {
|
||||
/// Local model server id.
|
||||
pub server_id: String,
|
||||
/// Lifecycle status.
|
||||
pub status: ModelServerStatusDto,
|
||||
}
|
||||
|
||||
/// Payload for [`AGENT_LAUNCH_FAILED`].
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentLaunchFailedDto {
|
||||
/// Agent id.
|
||||
pub agent_id: String,
|
||||
/// Failure cause namespace.
|
||||
pub cause: String,
|
||||
/// Stable error code.
|
||||
pub code: String,
|
||||
/// Human-readable message.
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Serialisable mirror of [`DomainEvent`] for the IPC wire (camelCase, tagged).
|
||||
///
|
||||
@ -42,6 +112,26 @@ pub enum DomainEventDto {
|
||||
/// Session id.
|
||||
session_id: String,
|
||||
},
|
||||
/// An agent launch failed before runtime session creation.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AgentLaunchFailed {
|
||||
/// Agent id.
|
||||
agent_id: String,
|
||||
/// Failure cause namespace.
|
||||
cause: String,
|
||||
/// Stable error code.
|
||||
code: String,
|
||||
/// Human-readable message.
|
||||
message: String,
|
||||
},
|
||||
/// Local model-server status changed.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
ModelServerStatusChanged {
|
||||
/// Local model server id.
|
||||
server_id: String,
|
||||
/// Lifecycle status.
|
||||
status: ModelServerStatusDto,
|
||||
},
|
||||
/// An agent exited.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AgentExited {
|
||||
@ -571,6 +661,23 @@ impl From<&DomainEvent> for DomainEventDto {
|
||||
agent_id: agent_id.to_string(),
|
||||
session_id: session_id.to_string(),
|
||||
},
|
||||
DomainEvent::AgentLaunchFailed {
|
||||
agent_id,
|
||||
cause,
|
||||
code,
|
||||
message,
|
||||
} => Self::AgentLaunchFailed {
|
||||
agent_id: agent_id.to_string(),
|
||||
cause: cause.clone(),
|
||||
code: code.clone(),
|
||||
message: message.clone(),
|
||||
},
|
||||
DomainEvent::ModelServerStatusChanged { server_id, status } => {
|
||||
Self::ModelServerStatusChanged {
|
||||
server_id: server_id.to_string(),
|
||||
status: status.into(),
|
||||
}
|
||||
}
|
||||
DomainEvent::AgentExited { agent_id, code } => Self::AgentExited {
|
||||
agent_id: agent_id.to_string(),
|
||||
code: *code,
|
||||
@ -993,6 +1100,34 @@ pub fn spawn_relay(app: AppHandle, bus: &TokioBroadcastEventBus) {
|
||||
}
|
||||
let dto = DomainEventDto::from(&event);
|
||||
let _ = app.emit(DOMAIN_EVENT, dto);
|
||||
match &event {
|
||||
DomainEvent::ModelServerStatusChanged { server_id, status } => {
|
||||
let _ = app.emit(
|
||||
MODEL_SERVER_STATUS_CHANGED,
|
||||
ModelServerStatusChangedDto {
|
||||
server_id: server_id.to_string(),
|
||||
status: status.into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
DomainEvent::AgentLaunchFailed {
|
||||
agent_id,
|
||||
cause,
|
||||
code,
|
||||
message,
|
||||
} => {
|
||||
let _ = app.emit(
|
||||
AGENT_LAUNCH_FAILED,
|
||||
AgentLaunchFailedDto {
|
||||
agent_id: agent_id.to_string(),
|
||||
cause: cause.clone(),
|
||||
code: code.clone(),
|
||||
message: message.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// The bus dropped some events for this slow receiver; keep going.
|
||||
Err(RecvError::Lagged(_)) => continue,
|
||||
@ -1008,13 +1143,47 @@ mod tests {
|
||||
use super::*;
|
||||
use domain::ids::AgentId;
|
||||
use domain::mailbox::TicketId;
|
||||
use domain::ProjectId;
|
||||
use domain::{LocalModelServerId, ProjectId};
|
||||
use serde_json::json;
|
||||
|
||||
fn agent(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn server(n: u128) -> LocalModelServerId {
|
||||
LocalModelServerId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_server_status_changed_relays_ready_to_dto_and_wire() {
|
||||
let dto = DomainEventDto::from(&DomainEvent::ModelServerStatusChanged {
|
||||
server_id: server(35),
|
||||
status: ModelServerLifecycleStatus::Ready { reused: true },
|
||||
});
|
||||
|
||||
let json = serde_json::to_value(&dto).expect("serialisable");
|
||||
assert_eq!(json["type"], "modelServerStatusChanged");
|
||||
assert_eq!(json["serverId"], server(35).to_string());
|
||||
assert_eq!(json["status"]["state"], "ready");
|
||||
assert_eq!(json["status"]["reused"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_launch_failed_relays_model_server_cause() {
|
||||
let dto = DomainEventDto::from(&DomainEvent::AgentLaunchFailed {
|
||||
agent_id: agent(36),
|
||||
cause: "model_server".to_owned(),
|
||||
code: "path_not_accessible".to_owned(),
|
||||
message: "path not accessible: /models/missing.gguf".to_owned(),
|
||||
});
|
||||
|
||||
let json = serde_json::to_value(&dto).expect("serialisable");
|
||||
assert_eq!(json["type"], "agentLaunchFailed");
|
||||
assert_eq!(json["agentId"], agent(36).to_string());
|
||||
assert_eq!(json["cause"], "model_server");
|
||||
assert_eq!(json["code"], "path_not_accessible");
|
||||
}
|
||||
|
||||
/// Lot 2 : un `AgentLivenessChanged{Stalled}` du domaine se relaie en DTO
|
||||
/// `Stalled` portant le même agent, et se sérialise en `"stalled"` (le mot que
|
||||
/// le front badge). Garantit le câblage présentation de la détection de stall.
|
||||
|
||||
@ -106,6 +106,8 @@ pub fn run() {
|
||||
// If we killed first, the registry would be empty and every
|
||||
// agent would be persisted as "closed".
|
||||
let snapshot = std::sync::Arc::clone(&state.snapshot_running_agents);
|
||||
let model_servers =
|
||||
std::sync::Arc::clone(&state.ensure_local_model_server);
|
||||
let open_projects = state.open_project_ids();
|
||||
let handles = state.terminal_sessions.handles();
|
||||
tauri::async_runtime::block_on(async move {
|
||||
@ -119,6 +121,7 @@ pub fn run() {
|
||||
for h in handles {
|
||||
let _ = pty.kill(&h).await;
|
||||
}
|
||||
let _ = model_servers.stop_on_app_exit().await;
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -156,8 +159,12 @@ pub fn run() {
|
||||
commands::detect_profiles,
|
||||
commands::list_profiles,
|
||||
commands::save_profile,
|
||||
commands::clone_opencode_profile_from_seed,
|
||||
commands::delete_profile,
|
||||
commands::configure_profiles,
|
||||
commands::list_model_servers,
|
||||
commands::save_model_server,
|
||||
commands::delete_model_server,
|
||||
commands::list_embedder_profiles,
|
||||
commands::save_embedder_profile,
|
||||
commands::delete_embedder_profile,
|
||||
|
||||
@ -14,32 +14,32 @@ use std::sync::{Arc, Mutex};
|
||||
use application::{
|
||||
AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent,
|
||||
AssignTicketToSprint, AttachLiveAgent, BackgroundCommandArchive, CancelBackgroundTask,
|
||||
ChangeAgentProfile, CheckEmbedderSuggestion, CloseProject, CloseTab, CloseTerminal,
|
||||
CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch,
|
||||
CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill,
|
||||
CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout,
|
||||
DeleteMemory, DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate,
|
||||
DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion,
|
||||
FirstRunState, GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectWorkState,
|
||||
GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus,
|
||||
GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation, LaunchAgent,
|
||||
LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListEmbedderProfiles, ListIssues,
|
||||
ListLayouts, ListMemories, ListProfiles, ListProjects, ListResumableAgents, ListSkills,
|
||||
ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider,
|
||||
LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, MoveTabToNewWindow,
|
||||
MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant,
|
||||
OrchestratorService, PermissionProjectorRegistry, ProposeContext, ReadAgentContext,
|
||||
ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory, ReadMemoryIndex,
|
||||
ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, ReconcileLiveState,
|
||||
ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameLayout,
|
||||
RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks,
|
||||
RetryBackgroundTask, RotateConversationLog, SaveEmbedderProfile, SaveProfile,
|
||||
SessionLimitService, SetActiveLayout, SnapshotRunningAgents, SpawnBackgroundCommand,
|
||||
StopLiveAgent, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate,
|
||||
TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint, UnlinkIssues,
|
||||
UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState,
|
||||
UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate,
|
||||
WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||
ChangeAgentProfile, CheckEmbedderSuggestion, CloneOpenCodeProfileFromSeed, CloseProject,
|
||||
CloseTab, CloseTerminal, CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases,
|
||||
CreateAgentFromScratch, CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory,
|
||||
CreateProject, CreateSkill, CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile,
|
||||
DeleteIssue, DeleteLayout, DeleteMemory, DeleteModelServer, DeleteProfile, DeleteSkill,
|
||||
DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles,
|
||||
DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, GetLiveStateLean, GetMemory,
|
||||
GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph,
|
||||
GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase,
|
||||
InspectConversation, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput,
|
||||
ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers, ListProfiles,
|
||||
ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry,
|
||||
LiveSessions, LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout,
|
||||
McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
|
||||
OpenTicketAssistant, OrchestratorService, PermissionProjectorRegistry, ProposeContext,
|
||||
ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory,
|
||||
ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts,
|
||||
ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles,
|
||||
RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions,
|
||||
ResolveMemoryLinks, RetryBackgroundTask, RotateConversationLog, SaveEmbedderProfile,
|
||||
SaveModelServer, SaveProfile, SessionLimitService, SetActiveLayout, SnapshotRunningAgents,
|
||||
SpawnBackgroundCommand, StopLiveAgent, StructuredSessions, SuggestedThisSession,
|
||||
SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint,
|
||||
UnlinkIssues, UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet,
|
||||
UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill,
|
||||
UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{
|
||||
@ -69,10 +69,11 @@ use infrastructure::{
|
||||
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbedderEnvProbe,
|
||||
FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsEmbedderProfileStore,
|
||||
FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore,
|
||||
FsMemoryStore, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsProjectStore,
|
||||
FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore, Git2Repository,
|
||||
HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
||||
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
|
||||
FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore,
|
||||
FsProjectStore, FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore,
|
||||
Git2Repository, HeuristicHandoffSummarizer, HttpOpenAiCompatibleProbe, IdeaiContextStore,
|
||||
InMemoryConversationRegistry, InMemoryMailbox, LlamaCppRuntime, LocalFileSystem,
|
||||
LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
|
||||
OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory,
|
||||
SystemClock, SystemMillisClock, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler,
|
||||
ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL,
|
||||
@ -776,6 +777,8 @@ pub struct AppState {
|
||||
pub list_profiles: Arc<ListProfiles>,
|
||||
/// Save (upsert) a profile.
|
||||
pub save_profile: Arc<SaveProfile>,
|
||||
/// Create a new OpenCode profile instance from the canonical seed.
|
||||
pub clone_opencode_profile_from_seed: Arc<CloneOpenCodeProfileFromSeed>,
|
||||
/// Delete a profile.
|
||||
pub delete_profile: Arc<DeleteProfile>,
|
||||
/// Persist the batch of chosen profiles (closes the first run).
|
||||
@ -784,6 +787,14 @@ pub struct AppState {
|
||||
pub reference_profiles: Arc<ReferenceProfiles>,
|
||||
/// Whether the first-run wizard should show + the reference catalogue.
|
||||
pub first_run_state: Arc<FirstRunState>,
|
||||
/// Ensures local llama.cpp model servers for OpenCode profiles.
|
||||
pub ensure_local_model_server: Arc<EnsureLocalModelServer>,
|
||||
/// Lists local model server configurations.
|
||||
pub list_model_servers: Arc<ListModelServers>,
|
||||
/// Saves local model server configurations.
|
||||
pub save_model_server: Arc<SaveModelServer>,
|
||||
/// Deletes local model server configurations when unused.
|
||||
pub delete_model_server: Arc<DeleteModelServer>,
|
||||
/// The local PTY adapter, kept port-typed so the presentation layer can
|
||||
/// `subscribe_output` to pump bytes into the [`PtyBridge`] (it owns transport).
|
||||
pub pty_port: Arc<dyn PtyPort>,
|
||||
@ -1183,11 +1194,41 @@ impl AppState {
|
||||
let detect_profiles = Arc::new(DetectProfiles::new(Arc::clone(&runtime_port)));
|
||||
let list_profiles = Arc::new(ListProfiles::new(Arc::clone(&profile_store_port)));
|
||||
let save_profile = Arc::new(SaveProfile::new(Arc::clone(&profile_store_port)));
|
||||
let clone_opencode_profile_from_seed = Arc::new(CloneOpenCodeProfileFromSeed::new(
|
||||
Arc::clone(&profile_store_port),
|
||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||
));
|
||||
let delete_profile = Arc::new(DeleteProfile::new(Arc::clone(&profile_store_port)));
|
||||
let configure_profiles = Arc::new(ConfigureProfiles::new(Arc::clone(&profile_store_port)));
|
||||
let reference_profiles = Arc::new(ReferenceProfiles::new());
|
||||
let first_run_state = Arc::new(FirstRunState::new(Arc::clone(&profile_store_port)));
|
||||
|
||||
let model_server_registry = Arc::new(FsModelServerRegistry::new(
|
||||
Arc::clone(&fs_port),
|
||||
app_data_dir.to_string_lossy().into_owned(),
|
||||
));
|
||||
let ensure_local_model_server = Arc::new(EnsureLocalModelServer::new(
|
||||
Arc::clone(&model_server_registry) as Arc<dyn domain::ports::ModelServerRegistry>,
|
||||
Arc::new(HttpOpenAiCompatibleProbe::default())
|
||||
as Arc<dyn domain::ports::ModelServerProbe>,
|
||||
Arc::new(LocalManagedProcess::new()) as Arc<dyn domain::ports::ManagedProcess>,
|
||||
Arc::new(LlamaCppRuntime::new()) as Arc<dyn domain::ports::ModelServerRuntime>,
|
||||
Arc::clone(&fs_port),
|
||||
Arc::clone(&events_port),
|
||||
));
|
||||
let model_server_registry_port =
|
||||
Arc::clone(&model_server_registry) as Arc<dyn domain::ports::ModelServerRegistry>;
|
||||
let list_model_servers = Arc::new(ListModelServers::new(Arc::clone(
|
||||
&model_server_registry_port,
|
||||
)));
|
||||
let save_model_server = Arc::new(SaveModelServer::new(Arc::clone(
|
||||
&model_server_registry_port,
|
||||
)));
|
||||
let delete_model_server = Arc::new(DeleteModelServer::new(
|
||||
Arc::clone(&model_server_registry_port),
|
||||
Arc::clone(&profile_store_port),
|
||||
));
|
||||
|
||||
let pty_bridge = Arc::new(PtyBridge::new());
|
||||
// Twin of the PTY bridge for structured chat sessions (§17.7): routes a
|
||||
// turn's ReplyChunks to the owning chat cell and retains the conversation
|
||||
@ -1499,7 +1540,8 @@ impl AppState {
|
||||
// parse ⇒ section omise, jamais d'échec de lancement.
|
||||
.with_live_state_lean(Arc::new(AppLiveStateLeanProvider {
|
||||
clock: Arc::clone(&clock) as Arc<dyn Clock>,
|
||||
}) as Arc<dyn LiveStateLeanProvider>),
|
||||
}) as Arc<dyn LiveStateLeanProvider>)
|
||||
.with_local_model_server(Arc::clone(&ensure_local_model_server)),
|
||||
);
|
||||
|
||||
// Inter-agent launcher: same context, memory, permissions and live-state
|
||||
@ -1530,6 +1572,7 @@ impl AppState {
|
||||
.with_live_state_lean(Arc::new(AppLiveStateLeanProvider {
|
||||
clock: Arc::clone(&clock) as Arc<dyn Clock>,
|
||||
}) as Arc<dyn LiveStateLeanProvider>)
|
||||
.with_local_model_server(Arc::clone(&ensure_local_model_server))
|
||||
.with_structured(
|
||||
Arc::clone(&session_factory),
|
||||
Arc::clone(&structured_sessions),
|
||||
@ -2196,10 +2239,15 @@ impl AppState {
|
||||
detect_profiles,
|
||||
list_profiles,
|
||||
save_profile,
|
||||
clone_opencode_profile_from_seed,
|
||||
delete_profile,
|
||||
configure_profiles,
|
||||
reference_profiles,
|
||||
first_run_state,
|
||||
ensure_local_model_server,
|
||||
list_model_servers,
|
||||
save_model_server,
|
||||
delete_model_server,
|
||||
pty_port,
|
||||
terminal_sessions,
|
||||
event_bus,
|
||||
|
||||
111
crates/app-tauri/tests/dto_model_servers.rs
Normal file
111
crates/app-tauri/tests/dto_model_servers.rs
Normal file
@ -0,0 +1,111 @@
|
||||
//! DTO contract tests for local model-server IPC.
|
||||
|
||||
use app_tauri_lib::dto::{
|
||||
ModelServerConfigDto, ModelServerKindDto, SaveModelServerRequestDto, StopPolicyDto,
|
||||
save_model_server_input,
|
||||
};
|
||||
use domain::model_server::{
|
||||
ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath,
|
||||
ModelServerEndpoint,
|
||||
};
|
||||
use domain::{LocalModelServerId, StopPolicy};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn sid(n: u128) -> LocalModelServerId {
|
||||
LocalModelServerId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn domain_config(id: LocalModelServerId, model_id: &str) -> LocalModelServerConfig {
|
||||
LocalModelServerConfig::new(
|
||||
id,
|
||||
LocalModelServerKind::LlamaCpp,
|
||||
"Local Qwen",
|
||||
ModelServerEndpoint::new("http://localhost:8080", 8080).unwrap(),
|
||||
LocalModelRef::new(
|
||||
model_id,
|
||||
"Qwen",
|
||||
Some(ModelPath::new("/models/qwen.gguf").unwrap()),
|
||||
"qwen3-coder",
|
||||
)
|
||||
.unwrap(),
|
||||
Some(ExecutablePath::new("/usr/bin/llama-server").unwrap()),
|
||||
vec!["--ctx-size".to_owned(), "4096".to_owned()],
|
||||
true,
|
||||
StopPolicy::StopOnAppExit,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_server_dto_serialises_flat_camelcase_wire_shape() {
|
||||
let dto = ModelServerConfigDto::from_domain(domain_config(sid(35), "internal-model-id"));
|
||||
|
||||
let value = serde_json::to_value(dto).unwrap();
|
||||
|
||||
assert_eq!(value["id"], sid(35).to_string());
|
||||
assert_eq!(value["kind"], "llamaCpp");
|
||||
assert_eq!(value["baseURL"], "http://localhost:8080/v1");
|
||||
assert_eq!(value["modelPath"], "/models/qwen.gguf");
|
||||
assert_eq!(value["servedModelName"], "qwen3-coder");
|
||||
assert_eq!(value["binaryPath"], "/usr/bin/llama-server");
|
||||
assert_eq!(value["stopPolicy"], "stopOnAppExit");
|
||||
assert!(value.get("model").is_none(), "domain model must not leak");
|
||||
assert!(value.get("endpoint").is_none(), "domain endpoint must not leak");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_server_dto_deserialises_flat_wire_shape_and_generates_model_id() {
|
||||
let raw = json!({
|
||||
"id": sid(36).to_string(),
|
||||
"kind": "llamaCpp",
|
||||
"name": "Local Qwen",
|
||||
"baseURL": "http://localhost:8081",
|
||||
"port": 8081,
|
||||
"modelPath": "/models/qwen.gguf",
|
||||
"servedModelName": "qwen3-coder",
|
||||
"binaryPath": "/usr/bin/llama-server",
|
||||
"args": ["--ctx-size", "4096"],
|
||||
"autoStart": true,
|
||||
"stopPolicy": "stopOnAppExit"
|
||||
});
|
||||
|
||||
let request = SaveModelServerRequestDto {
|
||||
config: serde_json::from_value(raw).unwrap(),
|
||||
};
|
||||
let input = save_model_server_input(request, None).unwrap();
|
||||
|
||||
assert_eq!(input.config.id, sid(36));
|
||||
assert_eq!(input.config.kind, LocalModelServerKind::LlamaCpp);
|
||||
assert_eq!(input.config.endpoint.base_url, "http://localhost:8081/v1");
|
||||
assert_eq!(
|
||||
input.config.model.path.unwrap().as_str(),
|
||||
"/models/qwen.gguf"
|
||||
);
|
||||
assert_eq!(input.config.model.served_name, "qwen3-coder");
|
||||
assert_eq!(input.config.model.label, "qwen3-coder");
|
||||
assert!(!input.config.model.id.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_server_dto_preserves_existing_internal_model_id_on_upsert() {
|
||||
let existing = domain_config(sid(37), "stable-internal-id");
|
||||
let dto = ModelServerConfigDto {
|
||||
id: sid(37).to_string(),
|
||||
kind: ModelServerKindDto::LlamaCpp,
|
||||
name: "Updated Qwen".to_owned(),
|
||||
base_url: "http://localhost:8082/v1".to_owned(),
|
||||
port: 8082,
|
||||
model_path: Some("/models/qwen-updated.gguf".to_owned()),
|
||||
served_model_name: "qwen3-coder-updated".to_owned(),
|
||||
binary_path: Some("/usr/bin/llama-server".to_owned()),
|
||||
args: Vec::new(),
|
||||
auto_start: true,
|
||||
stop_policy: StopPolicyDto::StopOnAppExit,
|
||||
};
|
||||
|
||||
let config = dto.into_domain(Some(&existing)).unwrap();
|
||||
|
||||
assert_eq!(config.model.id, "stable-internal-id");
|
||||
assert_eq!(config.model.served_name, "qwen3-coder-updated");
|
||||
}
|
||||
@ -3,15 +3,16 @@
|
||||
//! error behaviour.
|
||||
|
||||
use app_tauri_lib::dto::{
|
||||
parse_delete_profile, parse_profile_id, ConfigureProfilesRequestDto, DetectProfilesRequestDto,
|
||||
DetectProfilesResponseDto, FirstRunStateDto, ProfileListDto, SaveProfileRequestDto,
|
||||
parse_delete_profile, parse_profile_id, CloneOpenCodeProfileFromSeedRequestDto,
|
||||
ConfigureProfilesRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
|
||||
FirstRunStateDto, ProfileListDto, SaveProfileRequestDto,
|
||||
};
|
||||
use application::{
|
||||
ConfigureProfilesInput, DetectProfilesInput, DetectProfilesOutput, FirstRunStateOutput,
|
||||
ProfileAvailability, SaveProfileInput,
|
||||
CloneOpenCodeProfileFromSeedInput, ConfigureProfilesInput, DetectProfilesInput,
|
||||
DetectProfilesOutput, FirstRunStateOutput, ProfileAvailability, SaveProfileInput,
|
||||
};
|
||||
use domain::ids::ProfileId;
|
||||
use domain::profile::{AgentProfile, ContextInjection};
|
||||
use domain::ids::{LocalModelServerId, ProfileId};
|
||||
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -94,6 +95,47 @@ fn save_request_deserialises_profile() {
|
||||
assert!(input.profile.detect.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_opencode_profile_from_seed_request_deserialises_camelcase_config() {
|
||||
let server_id = LocalModelServerId::from_uuid(Uuid::from_u128(35));
|
||||
let raw = json!({
|
||||
"name": "Local Qwen",
|
||||
"opencode": {
|
||||
"baseURL": "http://localhost:8081/v1",
|
||||
"model": "qwen3-coder-70b",
|
||||
"reasoning": false,
|
||||
"attachment": true,
|
||||
"localModelServerId": server_id.to_string()
|
||||
}
|
||||
});
|
||||
|
||||
let dto: CloneOpenCodeProfileFromSeedRequestDto = serde_json::from_value(raw).unwrap();
|
||||
let input: CloneOpenCodeProfileFromSeedInput = dto.into();
|
||||
assert_eq!(input.name.as_deref(), Some("Local Qwen"));
|
||||
let opencode = input.opencode.expect("config override");
|
||||
assert_eq!(opencode.base_url, "http://localhost:8081/v1");
|
||||
assert_eq!(opencode.api_key, None);
|
||||
assert_eq!(opencode.model, "qwen3-coder-70b");
|
||||
assert_eq!(opencode.reasoning, Some(false));
|
||||
assert_eq!(opencode.attachment, Some(true));
|
||||
assert_eq!(opencode.local_model_server_id, Some(server_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_config_dto_omits_local_model_server_id_when_none() {
|
||||
let config = OpenCodeConfig::new(
|
||||
"http://localhost:8080/v1",
|
||||
None,
|
||||
"qwen3-coder-30b",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let value = serde_json::to_value(&config).unwrap();
|
||||
assert!(value.get("localModelServerId").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configure_request_deserialises_profiles() {
|
||||
let raw = json!({ "profiles": [] });
|
||||
|
||||
@ -35,6 +35,7 @@ use domain::live_state::WorkStatus;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::layout::{persist_doc, resolve_doc};
|
||||
use crate::model_server::{EnsureLocalModelServer, EnsureLocalModelServerInput};
|
||||
use crate::project::project_context_path;
|
||||
use crate::terminal::{StructuredSessions, TerminalSessions};
|
||||
use crate::workstate::GetLiveStateLean;
|
||||
@ -1136,6 +1137,10 @@ pub struct LaunchAgent {
|
||||
/// injection (zéro régression). Best-effort strict : provider absent / erreur /
|
||||
/// parse ⇒ section omise, jamais d'échec de lancement.
|
||||
live_state_lean: Option<Arc<dyn LiveStateLeanProvider>>,
|
||||
/// Ensure local model servers referenced by OpenCode profiles before writing
|
||||
/// `opencode.json` (B35). Optional for legacy tests/wiring; required at runtime
|
||||
/// when `OpenCodeConfig.localModelServerId` is set.
|
||||
local_model_server: Option<Arc<EnsureLocalModelServer>>,
|
||||
}
|
||||
|
||||
impl LaunchAgent {
|
||||
@ -1174,9 +1179,17 @@ impl LaunchAgent {
|
||||
provider_sessions: None,
|
||||
projectors: None,
|
||||
live_state_lean: None,
|
||||
local_model_server: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Injects the local model-server ensure use case used by OpenCode profiles.
|
||||
#[must_use]
|
||||
pub fn with_local_model_server(mut self, ensure: Arc<EnsureLocalModelServer>) -> Self {
|
||||
self.local_model_server = Some(ensure);
|
||||
self
|
||||
}
|
||||
|
||||
/// Branche le provider de **live-state lean (lot LS4)** : au lancement, l'aperçu
|
||||
/// `# État du projet` (status + intent des autres agents) est injecté dans le
|
||||
/// convention file. Sans cet appel (cas legacy / tests existants), aucune section
|
||||
@ -1550,7 +1563,7 @@ impl LaunchAgent {
|
||||
.contexts
|
||||
.read_context(&input.project, &agent.id)
|
||||
.await?;
|
||||
let profile = self
|
||||
let mut profile = self
|
||||
.profiles
|
||||
.list()
|
||||
.await?
|
||||
@ -1649,6 +1662,9 @@ impl LaunchAgent {
|
||||
)
|
||||
.await?;
|
||||
|
||||
self.ensure_local_model_server_for_opencode(&agent, &mut profile)
|
||||
.await?;
|
||||
|
||||
// 5a. ── INJECTION DE LA CONF MCP (cadrage v3, Décision 3) ──
|
||||
// Strictement APRÈS le convention file (étape 5) et AVANT le spawn /
|
||||
// `factory.start` (étapes 5b/6). Si le profil porte une `McpCapability`,
|
||||
@ -2336,6 +2352,60 @@ impl LaunchAgent {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_local_model_server_for_opencode(
|
||||
&self,
|
||||
agent: &Agent,
|
||||
profile: &mut AgentProfile,
|
||||
) -> Result<(), AppError> {
|
||||
if profile.structured_adapter != Some(StructuredAdapter::OpenCode) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(opencode) = profile.opencode.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(server_id) = opencode.local_model_server_id else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(ensure) = self.local_model_server.as_ref() else {
|
||||
let err = AppError::ModelServer {
|
||||
code: "not_wired".to_owned(),
|
||||
message:
|
||||
"OpenCode profile references localModelServerId but no model server service is wired"
|
||||
.to_owned(),
|
||||
};
|
||||
self.publish_agent_launch_failed(agent.id, &err);
|
||||
return Err(err);
|
||||
};
|
||||
|
||||
match ensure
|
||||
.execute(EnsureLocalModelServerInput { server_id })
|
||||
.await
|
||||
{
|
||||
Ok(output) => {
|
||||
opencode.base_url = output.ready.base_url;
|
||||
opencode.model = output.ready.model;
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
self.publish_agent_launch_failed(agent.id, &err);
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn publish_agent_launch_failed(&self, agent_id: AgentId, err: &AppError) {
|
||||
let (cause, code) = match err {
|
||||
AppError::ModelServer { code, .. } => ("model_server", code.as_str()),
|
||||
_ => ("launch", err.code()),
|
||||
};
|
||||
self.events.publish(DomainEvent::AgentLaunchFailed {
|
||||
agent_id,
|
||||
cause: cause.to_owned(),
|
||||
code: code.to_owned(),
|
||||
message: err.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of the R0a discrimination between a legitimate **view reattach** and a
|
||||
|
||||
@ -41,8 +41,10 @@ pub use resume::{
|
||||
ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent,
|
||||
};
|
||||
pub use usecases::{
|
||||
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfile,
|
||||
DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState,
|
||||
FirstRunStateOutput, ListProfiles, ListProfilesOutput, ProfileAvailability, ReferenceProfiles,
|
||||
ReferenceProfilesOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
|
||||
CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
|
||||
CloneOpenCodeProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput,
|
||||
ConfigureProfilesOutput, DeleteProfile, DeleteProfileInput, DetectProfiles,
|
||||
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, ListProfiles,
|
||||
ListProfilesOutput, ProfileAvailability, ReferenceProfiles, ReferenceProfilesOutput,
|
||||
SaveProfile, SaveProfileInput, SaveProfileOutput,
|
||||
};
|
||||
|
||||
@ -13,12 +13,13 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{AgentRuntime, ProfileStore};
|
||||
use domain::profile::AgentProfile;
|
||||
use domain::ids::ProfileId;
|
||||
use domain::ports::{AgentRuntime, IdGenerator, ProfileStore};
|
||||
use domain::profile::{AgentProfile, OpenCodeConfig, StructuredAdapter};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
use super::catalogue::selectable_reference_profiles;
|
||||
use super::catalogue::{reference_profile_id, reference_profiles, selectable_reference_profiles};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DetectProfiles
|
||||
@ -133,6 +134,111 @@ pub struct SaveProfileOutput {
|
||||
pub profile: AgentProfile,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CloneOpenCodeProfileFromSeed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`CloneOpenCodeProfileFromSeed::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CloneOpenCodeProfileFromSeedInput {
|
||||
/// Optional display name for the cloned profile. When absent, a copy label is
|
||||
/// derived from the seed name.
|
||||
pub name: Option<String>,
|
||||
/// Optional OpenCode config override. When absent, the seed config is copied.
|
||||
pub opencode: Option<OpenCodeConfig>,
|
||||
}
|
||||
|
||||
/// Output of [`CloneOpenCodeProfileFromSeed::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CloneOpenCodeProfileFromSeedOutput {
|
||||
/// The newly persisted profile.
|
||||
pub profile: AgentProfile,
|
||||
}
|
||||
|
||||
/// Creates a new OpenCode profile instance from the canonical seed/template.
|
||||
///
|
||||
/// The persisted canonical `opencode-llamacpp` profile is preferred when present,
|
||||
/// so local edits are preserved as the clone template. If it is absent, the
|
||||
/// in-memory reference catalogue seed is used. The new profile always receives a
|
||||
/// fresh [`ProfileId`], which is the only identity constraint; multiple profiles
|
||||
/// with `StructuredAdapter::OpenCode` are therefore valid.
|
||||
pub struct CloneOpenCodeProfileFromSeed {
|
||||
store: Arc<dyn ProfileStore>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
}
|
||||
|
||||
impl CloneOpenCodeProfileFromSeed {
|
||||
/// Builds the use case from the profile store and id generator ports.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn ProfileStore>, ids: Arc<dyn IdGenerator>) -> Self {
|
||||
Self { store, ids }
|
||||
}
|
||||
|
||||
/// Clones the canonical OpenCode seed into a new persisted profile.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Store`] on persistence failure, [`AppError::Invalid`] if the
|
||||
/// requested name is blank, or [`AppError::Internal`] if the seed is malformed.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: CloneOpenCodeProfileFromSeedInput,
|
||||
) -> Result<CloneOpenCodeProfileFromSeedOutput, AppError> {
|
||||
let existing = self.store.list().await?;
|
||||
let seed_id = reference_profile_id("opencode-llamacpp");
|
||||
let seed = existing
|
||||
.iter()
|
||||
.find(|profile| profile.id == seed_id)
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
reference_profiles()
|
||||
.into_iter()
|
||||
.find(|profile| profile.id == seed_id)
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
AppError::Internal("canonical OpenCode seed `opencode-llamacpp` is missing".into())
|
||||
})?;
|
||||
|
||||
if seed.structured_adapter != Some(StructuredAdapter::OpenCode) || seed.opencode.is_none() {
|
||||
return Err(AppError::Internal(
|
||||
"canonical OpenCode seed is not an OpenCode profile".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut profile = seed;
|
||||
profile.id = fresh_profile_id(&*self.ids, &existing)?;
|
||||
profile.name = match input.name {
|
||||
Some(name) => {
|
||||
if name.trim().is_empty() {
|
||||
return Err(AppError::Invalid("profile.name must not be empty".into()));
|
||||
}
|
||||
name
|
||||
}
|
||||
None => format!("{} copy", profile.name),
|
||||
};
|
||||
if let Some(config) = input.opencode {
|
||||
profile.opencode = Some(config);
|
||||
}
|
||||
|
||||
self.store.save(&profile).await?;
|
||||
Ok(CloneOpenCodeProfileFromSeedOutput { profile })
|
||||
}
|
||||
}
|
||||
|
||||
fn fresh_profile_id(
|
||||
ids: &dyn IdGenerator,
|
||||
existing: &[AgentProfile],
|
||||
) -> Result<ProfileId, AppError> {
|
||||
for _ in 0..16 {
|
||||
let id = ProfileId::from_uuid(ids.new_uuid());
|
||||
if existing.iter().all(|profile| profile.id != id) {
|
||||
return Ok(id);
|
||||
}
|
||||
}
|
||||
Err(AppError::Internal(
|
||||
"could not allocate a unique profile id".into(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Persists (creates or replaces) a single profile.
|
||||
pub struct SaveProfile {
|
||||
store: Arc<dyn ProfileStore>,
|
||||
|
||||
@ -6,8 +6,8 @@
|
||||
//! with one error shape when building its `ErrorDTO`.
|
||||
|
||||
use domain::ports::{
|
||||
AgentSessionError, EmbedderError, FsError, GitError, MemoryError, ProcessError, PtyError,
|
||||
RemoteError, RuntimeError, StoreError,
|
||||
AgentSessionError, EmbedderError, FsError, GitError, MemoryError, ModelServerError,
|
||||
ProcessError, PtyError, RemoteError, RuntimeError, StoreError,
|
||||
};
|
||||
use domain::{AgentId, NodeId};
|
||||
use domain::{IssueStoreError, SprintStoreError};
|
||||
@ -38,6 +38,15 @@ pub enum AppError {
|
||||
#[error("process error: {0}")]
|
||||
Process(String),
|
||||
|
||||
/// A local model server could not be prepared for launch.
|
||||
#[error("model server error ({code}): {message}")]
|
||||
ModelServer {
|
||||
/// Stable model-server error code.
|
||||
code: String,
|
||||
/// Human-readable message.
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// A git operation failed.
|
||||
#[error("git error: {0}")]
|
||||
Git(String),
|
||||
@ -101,6 +110,7 @@ impl AppError {
|
||||
Self::FileSystem(_) => "FILESYSTEM",
|
||||
Self::Store(_) => "STORE",
|
||||
Self::Process(_) => "PROCESS",
|
||||
Self::ModelServer { .. } => "MODEL_SERVER",
|
||||
Self::Git(_) => "GIT",
|
||||
Self::Remote(_) => "REMOTE",
|
||||
Self::AgentAlreadyRunning { .. } => "AGENT_ALREADY_RUNNING",
|
||||
@ -183,6 +193,30 @@ impl From<ProcessError> for AppError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ModelServerError> for AppError {
|
||||
fn from(e: ModelServerError) -> Self {
|
||||
Self::ModelServer {
|
||||
code: model_server_error_code(&e).to_owned(),
|
||||
message: e.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn model_server_error_code(err: &ModelServerError) -> &'static str {
|
||||
match err {
|
||||
ModelServerError::NotConfigured => "not_configured",
|
||||
ModelServerError::Invalid(_) => "invalid",
|
||||
ModelServerError::PermissionDenied(_) => "permission_denied",
|
||||
ModelServerError::PathNotAccessible(_) => "path_not_accessible",
|
||||
ModelServerError::PortOccupied(_) => "port_occupied",
|
||||
ModelServerError::InUse(_) => "model_server_in_use",
|
||||
ModelServerError::Probe(_) => "probe",
|
||||
ModelServerError::Process(_) => "process",
|
||||
ModelServerError::Store(_) => "store",
|
||||
ModelServerError::Timeout => "timeout",
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RuntimeError> for AppError {
|
||||
fn from(e: RuntimeError) -> Self {
|
||||
Self::Process(e.to_string())
|
||||
|
||||
@ -22,6 +22,7 @@ pub mod health;
|
||||
pub mod issues;
|
||||
pub mod layout;
|
||||
pub mod memory;
|
||||
pub mod model_server;
|
||||
pub mod orchestrator;
|
||||
pub mod permission;
|
||||
pub mod project;
|
||||
@ -39,19 +40,20 @@ pub use agent::{
|
||||
drain_with_readiness_and_announcements, drain_with_readiness_outcome, reference_profile_id,
|
||||
reference_profiles, selectable_reference_profiles, send_blocking, AgentResumer,
|
||||
AnnouncementPublisher, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput,
|
||||
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, CreateAgentFromScratch,
|
||||
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, DeleteProfile,
|
||||
DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState,
|
||||
FirstRunStateOutput, HandoffProvider, InjectedLiveRow, InspectConversation,
|
||||
InspectConversationInput, InspectConversationOutput, LaunchAgent, LaunchAgentInput,
|
||||
LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, ListProfiles,
|
||||
ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput,
|
||||
LiveStateLeanProvider, McpRuntime, PermissionProjectorRegistry, ProfileAvailability,
|
||||
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
|
||||
ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile, SaveProfileInput,
|
||||
SaveProfileOutput, SessionLimitService, StructuredSessionDescriptor, TurnOutcome,
|
||||
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS,
|
||||
LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
|
||||
CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
|
||||
CloneOpenCodeProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput,
|
||||
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
|
||||
DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
|
||||
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, HandoffProvider,
|
||||
InjectedLiveRow, InspectConversation, InspectConversationInput, InspectConversationOutput,
|
||||
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
|
||||
ListAgentsOutput, ListProfiles, ListProfilesOutput, ListResumableAgents,
|
||||
ListResumableAgentsInput, ListResumableAgentsOutput, LiveStateLeanProvider, McpRuntime,
|
||||
PermissionProjectorRegistry, ProfileAvailability, ProviderSessionProvider, ReadAgentContext,
|
||||
ReadAgentContextInput, ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput,
|
||||
ResumableAgent, SaveProfile, SaveProfileInput, SaveProfileOutput, SessionLimitService,
|
||||
StructuredSessionDescriptor, TurnOutcome, UpdateAgentContext, UpdateAgentContextInput,
|
||||
AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS, LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
|
||||
};
|
||||
pub use background::{
|
||||
BackgroundCommandArchive, CancelBackgroundTask, CancelBackgroundTaskOutput,
|
||||
@ -102,6 +104,12 @@ pub use memory::{
|
||||
RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput, ResolveMemoryLinksOutput,
|
||||
UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
|
||||
};
|
||||
pub use model_server::{
|
||||
model_server_error_code, DeleteModelServer, DeleteModelServerInput, EnsureLocalModelServer,
|
||||
EnsureLocalModelServerInput, EnsureLocalModelServerOutput, ListModelServers,
|
||||
ListModelServersOutput, ReadinessPolicy as ModelServerReadinessPolicy, SaveModelServer,
|
||||
SaveModelServerInput, SaveModelServerOutput,
|
||||
};
|
||||
pub use orchestrator::{
|
||||
resolve_rendezvous_ceiling, resolve_rendezvous_window, run_inactivity_watchdog,
|
||||
AgentWakeService, AskLivenessProbe, ContextGuardUseCases, LiveStateProvider,
|
||||
|
||||
447
crates/application/src/model_server.rs
Normal file
447
crates/application/src/model_server.rs
Normal file
@ -0,0 +1,447 @@
|
||||
//! Use cases for local model servers.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use domain::events::DomainEvent;
|
||||
use domain::model_server::{
|
||||
LocalModelServerConfig, ModelServerLifecycleStatus, ModelServerReady, ModelServerStatus,
|
||||
};
|
||||
use domain::ports::{
|
||||
EventBus, FileSystem, ManagedProcess, ManagedProcessHandle, ModelServerError, ModelServerProbe,
|
||||
ModelServerRegistry, ModelServerRuntime, ProcessStatus, ProfileStore, RemotePath,
|
||||
};
|
||||
use domain::{LocalModelServerId, StopPolicy};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
/// Output of [`ListModelServers::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListModelServersOutput {
|
||||
/// Persisted local model-server configs.
|
||||
pub servers: Vec<LocalModelServerConfig>,
|
||||
}
|
||||
|
||||
/// Lists local model-server configurations.
|
||||
pub struct ListModelServers {
|
||||
registry: Arc<dyn ModelServerRegistry>,
|
||||
}
|
||||
|
||||
impl ListModelServers {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(registry: Arc<dyn ModelServerRegistry>) -> Self {
|
||||
Self { registry }
|
||||
}
|
||||
|
||||
/// Lists configs.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::ModelServer`] on registry failure.
|
||||
pub async fn execute(&self) -> Result<ListModelServersOutput, AppError> {
|
||||
Ok(ListModelServersOutput {
|
||||
servers: self.registry.list().await?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`SaveModelServer::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SaveModelServerInput {
|
||||
/// Config to upsert by id.
|
||||
pub config: LocalModelServerConfig,
|
||||
}
|
||||
|
||||
/// Output of [`SaveModelServer::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SaveModelServerOutput {
|
||||
/// Saved config.
|
||||
pub config: LocalModelServerConfig,
|
||||
}
|
||||
|
||||
/// Saves a local model-server configuration.
|
||||
pub struct SaveModelServer {
|
||||
registry: Arc<dyn ModelServerRegistry>,
|
||||
}
|
||||
|
||||
impl SaveModelServer {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(registry: Arc<dyn ModelServerRegistry>) -> Self {
|
||||
Self { registry }
|
||||
}
|
||||
|
||||
/// Saves a config.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::ModelServer`] on registry failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: SaveModelServerInput,
|
||||
) -> Result<SaveModelServerOutput, AppError> {
|
||||
self.registry.save(input.config.clone()).await?;
|
||||
Ok(SaveModelServerOutput {
|
||||
config: input.config,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`DeleteModelServer::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteModelServerInput {
|
||||
/// Config id to delete.
|
||||
pub server_id: LocalModelServerId,
|
||||
}
|
||||
|
||||
/// Deletes a local model-server config when no OpenCode profile still references it.
|
||||
pub struct DeleteModelServer {
|
||||
registry: Arc<dyn ModelServerRegistry>,
|
||||
profiles: Arc<dyn ProfileStore>,
|
||||
}
|
||||
|
||||
impl DeleteModelServer {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(registry: Arc<dyn ModelServerRegistry>, profiles: Arc<dyn ProfileStore>) -> Self {
|
||||
Self { registry, profiles }
|
||||
}
|
||||
|
||||
/// Deletes a config after checking profile references.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::ModelServer`] with `code=model_server_in_use` when referenced.
|
||||
pub async fn execute(&self, input: DeleteModelServerInput) -> Result<(), AppError> {
|
||||
let profiles = self.profiles.list().await?;
|
||||
if profiles.iter().any(|profile| {
|
||||
profile
|
||||
.opencode
|
||||
.as_ref()
|
||||
.and_then(|config| config.local_model_server_id)
|
||||
== Some(input.server_id)
|
||||
}) {
|
||||
return Err(ModelServerError::InUse(input.server_id.to_string()).into());
|
||||
}
|
||||
self.registry.delete(input.server_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`EnsureLocalModelServer::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EnsureLocalModelServerInput {
|
||||
/// Referenced local model server.
|
||||
pub server_id: LocalModelServerId,
|
||||
}
|
||||
|
||||
/// Output of [`EnsureLocalModelServer::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EnsureLocalModelServerOutput {
|
||||
/// Ready server data to inject into OpenCode config.
|
||||
pub ready: ModelServerReady,
|
||||
}
|
||||
|
||||
/// Readiness retry policy.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ReadinessPolicy {
|
||||
/// Number of probes after spawning.
|
||||
pub attempts: usize,
|
||||
/// Delay between attempts.
|
||||
pub backoff: Duration,
|
||||
}
|
||||
|
||||
impl Default for ReadinessPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
attempts: 20,
|
||||
backoff: Duration::from_millis(250),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ActiveServer {
|
||||
handle: ManagedProcessHandle,
|
||||
port: u16,
|
||||
stop_policy: StopPolicy,
|
||||
}
|
||||
|
||||
/// Ensures a configured local model server is reachable, starting it when allowed.
|
||||
pub struct EnsureLocalModelServer {
|
||||
registry: Arc<dyn ModelServerRegistry>,
|
||||
probe: Arc<dyn ModelServerProbe>,
|
||||
process: Arc<dyn ManagedProcess>,
|
||||
runtime: Arc<dyn ModelServerRuntime>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
events: Arc<dyn EventBus>,
|
||||
active: Mutex<HashMap<LocalModelServerId, ActiveServer>>,
|
||||
readiness: ReadinessPolicy,
|
||||
}
|
||||
|
||||
impl EnsureLocalModelServer {
|
||||
/// Builds the use case.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
registry: Arc<dyn ModelServerRegistry>,
|
||||
probe: Arc<dyn ModelServerProbe>,
|
||||
process: Arc<dyn ManagedProcess>,
|
||||
runtime: Arc<dyn ModelServerRuntime>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
events: Arc<dyn EventBus>,
|
||||
) -> Self {
|
||||
Self {
|
||||
registry,
|
||||
probe,
|
||||
process,
|
||||
runtime,
|
||||
fs,
|
||||
events,
|
||||
active: Mutex::new(HashMap::new()),
|
||||
readiness: ReadinessPolicy::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Overrides readiness policy, mainly for tests.
|
||||
#[must_use]
|
||||
pub fn with_readiness_policy(mut self, readiness: ReadinessPolicy) -> Self {
|
||||
self.readiness = readiness;
|
||||
self
|
||||
}
|
||||
|
||||
/// Ensures the server is reachable.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::ModelServer`] if the server cannot be prepared.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: EnsureLocalModelServerInput,
|
||||
) -> Result<EnsureLocalModelServerOutput, AppError> {
|
||||
let config = self
|
||||
.registry
|
||||
.get(&input.server_id)
|
||||
.await?
|
||||
.ok_or(ModelServerError::NotConfigured)?;
|
||||
|
||||
self.publish(config.id, ModelServerLifecycleStatus::Probing);
|
||||
let initial_probe = match self.probe.probe(&config.endpoint).await {
|
||||
Ok(status) => status,
|
||||
Err(err) => return self.fail(config.id, err),
|
||||
};
|
||||
match initial_probe {
|
||||
ModelServerStatus::ReadyReused | ModelServerStatus::ReadyStarted => {
|
||||
self.publish(
|
||||
config.id,
|
||||
ModelServerLifecycleStatus::Ready { reused: true },
|
||||
);
|
||||
return Ok(EnsureLocalModelServerOutput {
|
||||
ready: ready(&config, ModelServerStatus::ReadyReused),
|
||||
});
|
||||
}
|
||||
ModelServerStatus::Unreachable => {}
|
||||
}
|
||||
|
||||
if !config.auto_start {
|
||||
let err = ModelServerError::Probe("server unreachable and autoStart=false".to_owned());
|
||||
self.publish_failure(config.id, &err);
|
||||
return Err(err.into());
|
||||
}
|
||||
|
||||
self.ensure_model_path_accessible(&config).await?;
|
||||
self.ensure_no_active_port_collision(&config).await?;
|
||||
|
||||
self.publish(config.id, ModelServerLifecycleStatus::Starting);
|
||||
let spec = match self.runtime.build_spawn_spec(&config) {
|
||||
Ok(spec) => spec,
|
||||
Err(err) => return self.fail(config.id, err),
|
||||
};
|
||||
let handle = match self.process.spawn(spec).await {
|
||||
Ok(handle) => handle,
|
||||
Err(err) => return self.fail(config.id, err),
|
||||
};
|
||||
self.active.lock().unwrap().insert(
|
||||
config.id,
|
||||
ActiveServer {
|
||||
handle: handle.clone(),
|
||||
port: config.endpoint.port,
|
||||
stop_policy: config.stop_policy,
|
||||
},
|
||||
);
|
||||
|
||||
for attempt in 0..self.readiness.attempts {
|
||||
match self.probe.probe(&config.endpoint).await {
|
||||
Err(err) => {
|
||||
self.stop_started_server(config.id, &handle).await;
|
||||
return self.fail(config.id, err);
|
||||
}
|
||||
Ok(ModelServerStatus::ReadyReused | ModelServerStatus::ReadyStarted) => {
|
||||
self.publish(
|
||||
config.id,
|
||||
ModelServerLifecycleStatus::Ready { reused: false },
|
||||
);
|
||||
return Ok(EnsureLocalModelServerOutput {
|
||||
ready: ready(&config, ModelServerStatus::ReadyStarted),
|
||||
});
|
||||
}
|
||||
Ok(ModelServerStatus::Unreachable) => {
|
||||
if attempt + 1 < self.readiness.attempts && !self.readiness.backoff.is_zero() {
|
||||
tokio::time::sleep(self.readiness.backoff).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let err = ModelServerError::Timeout;
|
||||
self.stop_started_server(config.id, &handle).await;
|
||||
self.fail(config.id, err)
|
||||
}
|
||||
|
||||
/// Stops active servers whose policy is [`StopPolicy::StopOnAppExit`].
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns the first process error after attempting every eligible stop.
|
||||
pub async fn stop_on_app_exit(&self) -> Result<(), AppError> {
|
||||
let entries: Vec<(LocalModelServerId, ActiveServer)> = self
|
||||
.active
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|(id, active)| (*id, active.clone()))
|
||||
.collect();
|
||||
let mut first_error: Option<ModelServerError> = None;
|
||||
for (id, active) in entries {
|
||||
if active.stop_policy != StopPolicy::StopOnAppExit {
|
||||
continue;
|
||||
}
|
||||
if let Err(err) = self.process.kill(&active.handle).await {
|
||||
if first_error.is_none() {
|
||||
first_error = Some(err);
|
||||
}
|
||||
} else {
|
||||
self.active.lock().unwrap().remove(&id);
|
||||
}
|
||||
}
|
||||
if let Some(err) = first_error {
|
||||
Err(err.into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_model_path_accessible(
|
||||
&self,
|
||||
config: &LocalModelServerConfig,
|
||||
) -> Result<(), AppError> {
|
||||
let Some(path) = config.model.path.as_ref() else {
|
||||
let err = ModelServerError::PathNotAccessible("model.path missing".to_owned());
|
||||
self.publish_failure(config.id, &err);
|
||||
return Err(err.into());
|
||||
};
|
||||
match self
|
||||
.fs
|
||||
.exists(&RemotePath::new(path.as_str().to_owned()))
|
||||
.await
|
||||
{
|
||||
Ok(true) => Ok(()),
|
||||
Ok(false) => {
|
||||
let err = ModelServerError::PathNotAccessible(path.as_str().to_owned());
|
||||
self.publish_failure(config.id, &err);
|
||||
Err(err.into())
|
||||
}
|
||||
Err(domain::ports::FsError::PermissionDenied(p)) => {
|
||||
let err = ModelServerError::PermissionDenied(p);
|
||||
self.publish_failure(config.id, &err);
|
||||
Err(err.into())
|
||||
}
|
||||
Err(err) => {
|
||||
let err = ModelServerError::PathNotAccessible(err.to_string());
|
||||
self.publish_failure(config.id, &err);
|
||||
Err(err.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_no_active_port_collision(
|
||||
&self,
|
||||
config: &LocalModelServerConfig,
|
||||
) -> Result<(), AppError> {
|
||||
let active: Vec<(LocalModelServerId, ActiveServer)> = self
|
||||
.active
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|(id, active)| (*id, active.clone()))
|
||||
.collect();
|
||||
for (id, active) in active {
|
||||
if active.port != config.endpoint.port {
|
||||
continue;
|
||||
}
|
||||
match self.process.status(&active.handle).await {
|
||||
Ok(ProcessStatus::Running) => {
|
||||
let err = ModelServerError::PortOccupied(config.endpoint.port);
|
||||
self.publish_failure(config.id, &err);
|
||||
return Err(err.into());
|
||||
}
|
||||
Ok(ProcessStatus::Exited { .. } | ProcessStatus::Unknown) => {
|
||||
self.active.lock().unwrap().remove(&id);
|
||||
}
|
||||
Err(err) => return self.fail(config.id, err),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop_started_server(
|
||||
&self,
|
||||
server_id: LocalModelServerId,
|
||||
handle: &ManagedProcessHandle,
|
||||
) {
|
||||
let _ = self.process.kill(handle).await;
|
||||
self.active.lock().unwrap().remove(&server_id);
|
||||
}
|
||||
|
||||
fn publish(&self, server_id: LocalModelServerId, status: ModelServerLifecycleStatus) {
|
||||
self.events
|
||||
.publish(DomainEvent::ModelServerStatusChanged { server_id, status });
|
||||
}
|
||||
|
||||
fn fail<T>(&self, server_id: LocalModelServerId, err: ModelServerError) -> Result<T, AppError> {
|
||||
self.publish_failure(server_id, &err);
|
||||
Err(err.into())
|
||||
}
|
||||
|
||||
fn publish_failure(&self, server_id: LocalModelServerId, err: &ModelServerError) {
|
||||
self.publish(
|
||||
server_id,
|
||||
ModelServerLifecycleStatus::Failed {
|
||||
code: model_server_error_code(err).to_owned(),
|
||||
message: err.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn ready(config: &LocalModelServerConfig, status: ModelServerStatus) -> ModelServerReady {
|
||||
ModelServerReady {
|
||||
base_url: config.endpoint.base_url.clone(),
|
||||
model: config.model.served_name.clone(),
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable model-server error code for event/DTO mapping.
|
||||
#[must_use]
|
||||
pub fn model_server_error_code(err: &ModelServerError) -> &'static str {
|
||||
match err {
|
||||
ModelServerError::NotConfigured => "not_configured",
|
||||
ModelServerError::Invalid(_) => "invalid",
|
||||
ModelServerError::PermissionDenied(_) => "permission_denied",
|
||||
ModelServerError::PathNotAccessible(_) => "path_not_accessible",
|
||||
ModelServerError::PortOccupied(_) => "port_occupied",
|
||||
ModelServerError::InUse(_) => "model_server_in_use",
|
||||
ModelServerError::Probe(_) => "probe",
|
||||
ModelServerError::Process(_) => "process",
|
||||
ModelServerError::Store(_) => "store",
|
||||
ModelServerError::Timeout => "timeout",
|
||||
}
|
||||
}
|
||||
512
crates/application/tests/model_server.rs
Normal file
512
crates/application/tests/model_server.rs
Normal file
@ -0,0 +1,512 @@
|
||||
//! Unit tests for the local model-server ensure use case.
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use application::{
|
||||
DeleteModelServer, DeleteModelServerInput, EnsureLocalModelServer, EnsureLocalModelServerInput,
|
||||
ModelServerReadinessPolicy,
|
||||
};
|
||||
use domain::events::DomainEvent;
|
||||
use domain::model_server::{
|
||||
ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath,
|
||||
ModelServerEndpoint, ModelServerStatus, StopPolicy,
|
||||
};
|
||||
use domain::ports::{
|
||||
DirEntry, EventBus, EventStream, FileSystem, FsError, ManagedProcess, ManagedProcessHandle,
|
||||
ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus,
|
||||
ProfileStore, RemotePath, SpawnSpec, StoreError,
|
||||
};
|
||||
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, StructuredAdapter};
|
||||
use domain::{LocalModelServerId, ProfileId, ProjectPath};
|
||||
|
||||
fn sid(n: u128) -> LocalModelServerId {
|
||||
LocalModelServerId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn config(
|
||||
id: LocalModelServerId,
|
||||
port: u16,
|
||||
path: &str,
|
||||
auto_start: bool,
|
||||
) -> LocalModelServerConfig {
|
||||
LocalModelServerConfig::new(
|
||||
id,
|
||||
LocalModelServerKind::LlamaCpp,
|
||||
"llama.cpp",
|
||||
ModelServerEndpoint::new(format!("http://localhost:{port}"), port).unwrap(),
|
||||
LocalModelRef::new(
|
||||
"qwen",
|
||||
"Qwen",
|
||||
Some(ModelPath::new(path).unwrap()),
|
||||
"qwen3-coder-30b",
|
||||
)
|
||||
.unwrap(),
|
||||
Some(ExecutablePath::new("llama-server").unwrap()),
|
||||
Vec::new(),
|
||||
auto_start,
|
||||
StopPolicy::StopOnAppExit,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeRegistry(Mutex<HashMap<LocalModelServerId, LocalModelServerConfig>>);
|
||||
|
||||
#[async_trait]
|
||||
impl ModelServerRegistry for FakeRegistry {
|
||||
async fn get(
|
||||
&self,
|
||||
id: &LocalModelServerId,
|
||||
) -> Result<Option<LocalModelServerConfig>, ModelServerError> {
|
||||
Ok(self.0.lock().unwrap().get(id).cloned())
|
||||
}
|
||||
|
||||
async fn list(&self) -> Result<Vec<LocalModelServerConfig>, ModelServerError> {
|
||||
Ok(self.0.lock().unwrap().values().cloned().collect())
|
||||
}
|
||||
|
||||
async fn save(&self, config: LocalModelServerConfig) -> Result<(), ModelServerError> {
|
||||
self.0.lock().unwrap().insert(config.id, config);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: LocalModelServerId) -> Result<(), ModelServerError> {
|
||||
self.0.lock().unwrap().remove(&id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeProfiles(Mutex<Vec<AgentProfile>>);
|
||||
|
||||
#[async_trait]
|
||||
impl ProfileStore for FakeProfiles {
|
||||
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
|
||||
Ok(self.0.lock().unwrap().clone())
|
||||
}
|
||||
|
||||
async fn save(&self, profile: &AgentProfile) -> Result<(), StoreError> {
|
||||
self.0.lock().unwrap().push(profile.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, _id: ProfileId) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn is_configured(&self) -> Result<bool, StoreError> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn mark_configured(&self) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn opencode_profile(id: u128, server_id: LocalModelServerId) -> AgentProfile {
|
||||
AgentProfile::new(
|
||||
ProfileId::from_uuid(uuid::Uuid::from_u128(id)),
|
||||
"Local OpenCode",
|
||||
"opencode",
|
||||
Vec::new(),
|
||||
ContextInjection::stdin(),
|
||||
None,
|
||||
"{projectRoot}",
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
.with_structured_adapter(StructuredAdapter::OpenCode)
|
||||
.with_opencode(
|
||||
OpenCodeConfig::new("http://localhost:8080/v1", None, "qwen", None, None)
|
||||
.unwrap()
|
||||
.with_local_model_server_id(server_id),
|
||||
)
|
||||
}
|
||||
|
||||
struct FakeProbe(Mutex<VecDeque<ModelServerStatus>>);
|
||||
|
||||
impl FakeProbe {
|
||||
fn new(statuses: Vec<ModelServerStatus>) -> Self {
|
||||
Self(Mutex::new(statuses.into()))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ModelServerProbe for FakeProbe {
|
||||
async fn probe(
|
||||
&self,
|
||||
_endpoint: &ModelServerEndpoint,
|
||||
) -> Result<ModelServerStatus, ModelServerError> {
|
||||
Ok(self
|
||||
.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.unwrap_or(ModelServerStatus::Unreachable))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeProcess {
|
||||
spawns: Mutex<Vec<SpawnSpec>>,
|
||||
kills: Mutex<Vec<String>>,
|
||||
statuses: Mutex<HashMap<String, ProcessStatus>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ManagedProcess for FakeProcess {
|
||||
async fn spawn(&self, spec: SpawnSpec) -> Result<ManagedProcessHandle, ModelServerError> {
|
||||
self.spawns.lock().unwrap().push(spec);
|
||||
let handle = ManagedProcessHandle {
|
||||
id: format!("h{}", self.spawns.lock().unwrap().len()),
|
||||
};
|
||||
self.statuses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(handle.id.clone(), ProcessStatus::Running);
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
async fn kill(&self, handle: &ManagedProcessHandle) -> Result<(), ModelServerError> {
|
||||
self.kills.lock().unwrap().push(handle.id.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn status(
|
||||
&self,
|
||||
handle: &ManagedProcessHandle,
|
||||
) -> Result<ProcessStatus, ModelServerError> {
|
||||
Ok(*self
|
||||
.statuses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&handle.id)
|
||||
.unwrap_or(&ProcessStatus::Unknown))
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeRuntime;
|
||||
|
||||
impl ModelServerRuntime for FakeRuntime {
|
||||
fn build_spawn_spec(
|
||||
&self,
|
||||
config: &LocalModelServerConfig,
|
||||
) -> Result<SpawnSpec, ModelServerError> {
|
||||
Ok(SpawnSpec {
|
||||
command: config
|
||||
.binary
|
||||
.as_ref()
|
||||
.map(|binary| binary.as_str().to_owned())
|
||||
.unwrap_or_else(|| "llama-server".to_owned()),
|
||||
args: vec![
|
||||
"--model".to_owned(),
|
||||
config.model.path.as_ref().unwrap().as_str().to_owned(),
|
||||
"--port".to_owned(),
|
||||
config.endpoint.port.to_string(),
|
||||
],
|
||||
cwd: ProjectPath::new("/").unwrap(),
|
||||
env: Vec::new(),
|
||||
context_plan: None,
|
||||
sandbox: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeFs {
|
||||
existing: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FileSystem for FakeFs {
|
||||
async fn read(&self, _path: &RemotePath) -> Result<Vec<u8>, FsError> {
|
||||
Err(FsError::NotFound("unused".to_owned()))
|
||||
}
|
||||
|
||||
async fn write(&self, _path: &RemotePath, _data: &[u8]) -> Result<(), FsError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
|
||||
Ok(self
|
||||
.existing
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|p| p == path.as_str()))
|
||||
}
|
||||
|
||||
async fn create_dir_all(&self, _path: &RemotePath) -> Result<(), FsError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list(&self, _path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeEvents(Mutex<Vec<DomainEvent>>);
|
||||
|
||||
impl EventBus for FakeEvents {
|
||||
fn publish(&self, event: DomainEvent) {
|
||||
self.0.lock().unwrap().push(event);
|
||||
}
|
||||
|
||||
fn subscribe(&self) -> EventStream {
|
||||
Box::new(std::iter::empty())
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure(
|
||||
registry: Arc<FakeRegistry>,
|
||||
probe: Arc<FakeProbe>,
|
||||
process: Arc<FakeProcess>,
|
||||
fs: Arc<FakeFs>,
|
||||
events: Arc<FakeEvents>,
|
||||
) -> EnsureLocalModelServer {
|
||||
EnsureLocalModelServer::new(registry, probe, process, Arc::new(FakeRuntime), fs, events)
|
||||
.with_readiness_policy(ModelServerReadinessPolicy {
|
||||
attempts: 2,
|
||||
backoff: Duration::ZERO,
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reachable_server_is_reused_without_spawn() {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
registry
|
||||
.save(config(sid(1), 8080, "/models/qwen.gguf", true))
|
||||
.await
|
||||
.unwrap();
|
||||
let process = Arc::new(FakeProcess::default());
|
||||
let usecase = ensure(
|
||||
Arc::clone(®istry),
|
||||
Arc::new(FakeProbe::new(vec![ModelServerStatus::ReadyReused])),
|
||||
Arc::clone(&process),
|
||||
Arc::new(FakeFs::default()),
|
||||
Arc::new(FakeEvents::default()),
|
||||
);
|
||||
|
||||
let out = usecase
|
||||
.execute(EnsureLocalModelServerInput { server_id: sid(1) })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.ready.base_url, "http://localhost:8080/v1");
|
||||
assert_eq!(out.ready.model, "qwen3-coder-30b");
|
||||
assert!(process.spawns.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn absent_auto_start_spawns_and_waits_until_ready() {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
registry
|
||||
.save(config(sid(2), 8081, "/models/qwen.gguf", true))
|
||||
.await
|
||||
.unwrap();
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.existing
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push("/models/qwen.gguf".to_owned());
|
||||
let process = Arc::new(FakeProcess::default());
|
||||
let usecase = ensure(
|
||||
Arc::clone(®istry),
|
||||
Arc::new(FakeProbe::new(vec![
|
||||
ModelServerStatus::Unreachable,
|
||||
ModelServerStatus::ReadyReused,
|
||||
])),
|
||||
Arc::clone(&process),
|
||||
fs,
|
||||
Arc::new(FakeEvents::default()),
|
||||
);
|
||||
|
||||
let out = usecase
|
||||
.execute(EnsureLocalModelServerInput { server_id: sid(2) })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.ready.status, ModelServerStatus::ReadyStarted);
|
||||
let spawns = process.spawns.lock().unwrap();
|
||||
assert_eq!(spawns.len(), 1);
|
||||
assert_eq!(spawns[0].command, "llama-server");
|
||||
assert_eq!(
|
||||
spawns[0].args,
|
||||
vec!["--model", "/models/qwen.gguf", "--port", "8081"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_model_path_is_path_not_accessible() {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
registry
|
||||
.save(config(sid(3), 8082, "/models/missing.gguf", true))
|
||||
.await
|
||||
.unwrap();
|
||||
let usecase = ensure(
|
||||
Arc::clone(®istry),
|
||||
Arc::new(FakeProbe::new(vec![ModelServerStatus::Unreachable])),
|
||||
Arc::new(FakeProcess::default()),
|
||||
Arc::new(FakeFs::default()),
|
||||
Arc::new(FakeEvents::default()),
|
||||
);
|
||||
|
||||
let err = usecase
|
||||
.execute(EnsureLocalModelServerInput { server_id: sid(3) })
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.code(), "MODEL_SERVER");
|
||||
assert!(err.to_string().contains("path_not_accessible"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn active_managed_port_collision_is_explicit_error() {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
registry
|
||||
.save(config(sid(4), 8083, "/models/a.gguf", true))
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.save(config(sid(5), 8083, "/models/b.gguf", true))
|
||||
.await
|
||||
.unwrap();
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.existing
|
||||
.lock()
|
||||
.unwrap()
|
||||
.extend(["/models/a.gguf".to_owned(), "/models/b.gguf".to_owned()]);
|
||||
let process = Arc::new(FakeProcess::default());
|
||||
let events = Arc::new(FakeEvents::default());
|
||||
let usecase = ensure(
|
||||
Arc::clone(®istry),
|
||||
Arc::new(FakeProbe::new(vec![
|
||||
ModelServerStatus::Unreachable,
|
||||
ModelServerStatus::ReadyReused,
|
||||
ModelServerStatus::Unreachable,
|
||||
])),
|
||||
Arc::clone(&process),
|
||||
fs,
|
||||
events,
|
||||
);
|
||||
|
||||
usecase
|
||||
.execute(EnsureLocalModelServerInput { server_id: sid(4) })
|
||||
.await
|
||||
.unwrap();
|
||||
let err = usecase
|
||||
.execute(EnsureLocalModelServerInput { server_id: sid(5) })
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("port_occupied"));
|
||||
assert_eq!(process.spawns.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn readiness_timeout_kills_started_process() {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
registry
|
||||
.save(config(sid(6), 8084, "/models/qwen.gguf", true))
|
||||
.await
|
||||
.unwrap();
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.existing
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push("/models/qwen.gguf".to_owned());
|
||||
let process = Arc::new(FakeProcess::default());
|
||||
let usecase = ensure(
|
||||
Arc::clone(®istry),
|
||||
Arc::new(FakeProbe::new(vec![
|
||||
ModelServerStatus::Unreachable,
|
||||
ModelServerStatus::Unreachable,
|
||||
ModelServerStatus::Unreachable,
|
||||
])),
|
||||
Arc::clone(&process),
|
||||
fs,
|
||||
Arc::new(FakeEvents::default()),
|
||||
);
|
||||
|
||||
let err = usecase
|
||||
.execute(EnsureLocalModelServerInput { server_id: sid(6) })
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("timeout"));
|
||||
assert_eq!(process.spawns.lock().unwrap().len(), 1);
|
||||
assert_eq!(process.kills.lock().unwrap().as_slice(), ["h1"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_registry_entry_is_model_server_not_configured() {
|
||||
let usecase = ensure(
|
||||
Arc::new(FakeRegistry::default()),
|
||||
Arc::new(FakeProbe::new(Vec::new())),
|
||||
Arc::new(FakeProcess::default()),
|
||||
Arc::new(FakeFs::default()),
|
||||
Arc::new(FakeEvents::default()),
|
||||
);
|
||||
|
||||
let err = usecase
|
||||
.execute(EnsureLocalModelServerInput { server_id: sid(7) })
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.code(), "MODEL_SERVER");
|
||||
assert!(err.to_string().contains("not_configured"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_model_server_refuses_when_profile_references_it() {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
registry
|
||||
.save(config(sid(8), 8085, "/models/qwen.gguf", false))
|
||||
.await
|
||||
.unwrap();
|
||||
let profiles = Arc::new(FakeProfiles(Mutex::new(vec![opencode_profile(18, sid(8))])));
|
||||
let usecase = DeleteModelServer::new(registry, profiles);
|
||||
|
||||
let err = usecase
|
||||
.execute(DeleteModelServerInput { server_id: sid(8) })
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.code(), "MODEL_SERVER");
|
||||
match err {
|
||||
application::AppError::ModelServer { code, .. } => {
|
||||
assert_eq!(code, "model_server_in_use");
|
||||
}
|
||||
other => panic!("unexpected error: {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_model_server_removes_unused_config() {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
registry
|
||||
.save(config(sid(9), 8086, "/models/qwen.gguf", false))
|
||||
.await
|
||||
.unwrap();
|
||||
let profiles = Arc::new(FakeProfiles::default());
|
||||
let usecase = DeleteModelServer::new(
|
||||
Arc::clone(®istry) as Arc<dyn ModelServerRegistry>,
|
||||
profiles,
|
||||
);
|
||||
|
||||
usecase
|
||||
.execute(DeleteModelServerInput { server_id: sid(9) })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(registry.get(&sid(9)).await.unwrap().is_none());
|
||||
}
|
||||
@ -13,15 +13,17 @@ use async_trait::async_trait;
|
||||
|
||||
use domain::ids::ProfileId;
|
||||
use domain::ports::{
|
||||
AgentRuntime, PreparedContext, ProfileStore, RuntimeError, SessionPlan, SpawnSpec, StoreError,
|
||||
AgentRuntime, IdGenerator, PreparedContext, ProfileStore, RuntimeError, SessionPlan, SpawnSpec,
|
||||
StoreError,
|
||||
};
|
||||
use domain::profile::{AgentProfile, ContextInjection};
|
||||
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, StructuredAdapter};
|
||||
use domain::project::ProjectPath;
|
||||
|
||||
use application::{
|
||||
reference_profile_id, reference_profiles, ConfigureProfiles, ConfigureProfilesInput,
|
||||
DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState,
|
||||
ListProfiles, ReferenceProfiles, SaveProfile, SaveProfileInput, CODEX_SUBMIT_DELAY_MS,
|
||||
reference_profile_id, reference_profiles, CloneOpenCodeProfileFromSeed,
|
||||
CloneOpenCodeProfileFromSeedInput, ConfigureProfiles, ConfigureProfilesInput, DeleteProfile,
|
||||
DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState, ListProfiles,
|
||||
ReferenceProfiles, SaveProfile, SaveProfileInput, CODEX_SUBMIT_DELAY_MS,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -127,6 +129,20 @@ impl AgentRuntime for YieldingRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
struct SeqIds(Mutex<Vec<uuid::Uuid>>);
|
||||
|
||||
impl SeqIds {
|
||||
fn new(ids: Vec<uuid::Uuid>) -> Self {
|
||||
Self(Mutex::new(ids))
|
||||
}
|
||||
}
|
||||
|
||||
impl IdGenerator for SeqIds {
|
||||
fn new_uuid(&self) -> uuid::Uuid {
|
||||
self.0.lock().unwrap().remove(0)
|
||||
}
|
||||
}
|
||||
|
||||
fn profile(id: u128, name: &str, command: &str) -> AgentProfile {
|
||||
AgentProfile::new(
|
||||
ProfileId::from_uuid(uuid::Uuid::from_u128(id)),
|
||||
@ -327,6 +343,103 @@ async fn delete_unknown_is_not_found_error() {
|
||||
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clone_opencode_profile_from_seed_creates_distinct_open_code_instance() {
|
||||
let store = FakeProfileStore::default();
|
||||
let clone = CloneOpenCodeProfileFromSeed::new(
|
||||
Arc::new(store.clone()),
|
||||
Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3601)])),
|
||||
);
|
||||
|
||||
let out = clone
|
||||
.execute(CloneOpenCodeProfileFromSeedInput {
|
||||
name: Some("OpenCode llama.cpp 2".to_owned()),
|
||||
opencode: Some(
|
||||
OpenCodeConfig::new(
|
||||
"http://localhost:8081/v1",
|
||||
None,
|
||||
"qwen3-coder-70b",
|
||||
Some(false),
|
||||
Some(true),
|
||||
)
|
||||
.unwrap(),
|
||||
),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let seed_id = reference_profile_id("opencode-llamacpp");
|
||||
assert_ne!(out.profile.id, seed_id);
|
||||
assert_eq!(
|
||||
out.profile.id,
|
||||
ProfileId::from_uuid(uuid::Uuid::from_u128(3601))
|
||||
);
|
||||
assert_eq!(out.profile.name, "OpenCode llama.cpp 2");
|
||||
assert_eq!(
|
||||
out.profile.structured_adapter,
|
||||
Some(StructuredAdapter::OpenCode)
|
||||
);
|
||||
assert_eq!(
|
||||
out.profile
|
||||
.opencode
|
||||
.as_ref()
|
||||
.map(|config| config.base_url.as_str()),
|
||||
Some("http://localhost:8081/v1")
|
||||
);
|
||||
|
||||
let profiles = store.0.lock().unwrap().profiles.clone();
|
||||
assert_eq!(profiles.len(), 1);
|
||||
assert_eq!(profiles[0].id, out.profile.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clone_opencode_profile_prefers_persisted_seed_without_recreating_it() {
|
||||
let store = FakeProfileStore::default();
|
||||
let save = SaveProfile::new(Arc::new(store.clone()));
|
||||
let mut seed = reference_profiles()
|
||||
.into_iter()
|
||||
.find(|profile| profile.id == reference_profile_id("opencode-llamacpp"))
|
||||
.expect("seed exists");
|
||||
seed.name = "Edited local OpenCode seed".to_owned();
|
||||
save.execute(SaveProfileInput {
|
||||
profile: seed.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let clone = CloneOpenCodeProfileFromSeed::new(
|
||||
Arc::new(store.clone()),
|
||||
Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3602)])),
|
||||
);
|
||||
let out = clone
|
||||
.execute(CloneOpenCodeProfileFromSeedInput {
|
||||
name: None,
|
||||
opencode: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.profile.name, "Edited local OpenCode seed copy");
|
||||
assert_eq!(out.profile.opencode, seed.opencode);
|
||||
let profiles = store.0.lock().unwrap().profiles.clone();
|
||||
assert_eq!(
|
||||
profiles
|
||||
.iter()
|
||||
.filter(|profile| profile.id == reference_profile_id("opencode-llamacpp"))
|
||||
.count(),
|
||||
1,
|
||||
"the canonical seed must be preserved, not recreated as a duplicate"
|
||||
);
|
||||
assert_eq!(
|
||||
profiles
|
||||
.iter()
|
||||
.filter(|profile| profile.structured_adapter == Some(StructuredAdapter::OpenCode))
|
||||
.count(),
|
||||
2,
|
||||
"ProfileId is the identity: multiple OpenCode profiles can coexist"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ReferenceProfiles / catalogue
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -3,7 +3,8 @@
|
||||
|
||||
use crate::conversation::ConversationParty;
|
||||
use crate::ids::{
|
||||
AgentId, IssueId, ProfileId, ProjectId, SessionId, SkillId, SprintId, TaskId, TemplateId,
|
||||
AgentId, IssueId, LocalModelServerId, ProfileId, ProjectId, SessionId, SkillId, SprintId,
|
||||
TaskId, TemplateId,
|
||||
};
|
||||
use crate::issue::{IssueLinkKind, IssuePriority, IssueRef, IssueStatus, IssueVersion};
|
||||
use crate::mailbox::TicketId;
|
||||
@ -46,6 +47,24 @@ pub enum DomainEvent {
|
||||
/// The session it runs in.
|
||||
session_id: SessionId,
|
||||
},
|
||||
/// An agent launch failed before a runtime session could be created.
|
||||
AgentLaunchFailed {
|
||||
/// The agent whose launch failed.
|
||||
agent_id: AgentId,
|
||||
/// Failure cause namespace.
|
||||
cause: String,
|
||||
/// Stable error code.
|
||||
code: String,
|
||||
/// Human-readable message.
|
||||
message: String,
|
||||
},
|
||||
/// A local model-server status changed while preparing a launch.
|
||||
ModelServerStatusChanged {
|
||||
/// The local model server.
|
||||
server_id: LocalModelServerId,
|
||||
/// New lifecycle status.
|
||||
status: crate::model_server::ModelServerLifecycleStatus,
|
||||
},
|
||||
/// A first-class background task started running.
|
||||
BackgroundTaskStarted {
|
||||
/// The owning project.
|
||||
|
||||
@ -68,6 +68,10 @@ typed_id!(
|
||||
/// Identifies an [`crate::profile::AgentProfile`].
|
||||
ProfileId
|
||||
);
|
||||
typed_id!(
|
||||
/// Identifies a local model server configuration.
|
||||
LocalModelServerId
|
||||
);
|
||||
typed_id!(
|
||||
/// Identifies a [`crate::skill::Skill`].
|
||||
SkillId
|
||||
|
||||
@ -49,6 +49,7 @@ pub mod mailbox;
|
||||
pub mod markdown;
|
||||
pub mod memory;
|
||||
pub mod memory_harvest;
|
||||
pub mod model_server;
|
||||
pub mod orchestrator;
|
||||
pub mod permission;
|
||||
pub mod ports;
|
||||
@ -72,8 +73,8 @@ mod validation;
|
||||
pub use error::DomainError;
|
||||
|
||||
pub use ids::{
|
||||
AgentId, IssueId, LayoutId, NodeId, ProfileId, ProjectId, ScheduleId, SessionId, SkillId,
|
||||
SprintId, TabId, TaskId, TemplateId, WindowId,
|
||||
AgentId, IssueId, LayoutId, LocalModelServerId, NodeId, ProfileId, ProjectId, ScheduleId,
|
||||
SessionId, SkillId, SprintId, TabId, TaskId, TemplateId, WindowId,
|
||||
};
|
||||
|
||||
pub use project::{Project, ProjectPath};
|
||||
@ -153,6 +154,12 @@ pub use memory_harvest::{
|
||||
MAX_BLOCK_BYTES, MAX_DESCRIPTION_CHARS,
|
||||
};
|
||||
|
||||
pub use model_server::{
|
||||
ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath,
|
||||
ModelServerEndpoint, ModelServerLifecycleStatus, ModelServerReady, ModelServerStatus,
|
||||
StopPolicy,
|
||||
};
|
||||
|
||||
pub use remote::{RemoteKind, RemoteRef, SshAuth};
|
||||
|
||||
pub use terminal::{PtySize, SessionKind, SessionStatus, TerminalSession};
|
||||
|
||||
368
crates/domain/src/model_server.rs
Normal file
368
crates/domain/src/model_server.rs
Normal file
@ -0,0 +1,368 @@
|
||||
//! Local model-server configuration and status.
|
||||
//!
|
||||
//! These value objects describe a local OpenAI-compatible server that can back an
|
||||
//! OpenCode profile. They are pure persisted configuration: probing, process
|
||||
//! spawning and filesystem checks live behind ports.
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::DomainError;
|
||||
use crate::ids::LocalModelServerId;
|
||||
|
||||
/// Local model server implementation family.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum LocalModelServerKind {
|
||||
/// `llama-server` from llama.cpp.
|
||||
LlamaCpp,
|
||||
}
|
||||
|
||||
/// HTTP endpoint for an OpenAI-compatible model server.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ModelServerEndpoint {
|
||||
/// Normalised base URL, always ending in `/v1`.
|
||||
#[serde(rename = "baseURL")]
|
||||
pub base_url: String,
|
||||
/// TCP port expected for the local server.
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl ModelServerEndpoint {
|
||||
/// Builds a validated endpoint and normalises its base URL to `/v1`.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`DomainError`] if the URL is blank, non-HTTP(S), or the port is 0.
|
||||
pub fn new(base_url: impl Into<String>, port: u16) -> Result<Self, DomainError> {
|
||||
let base_url = normalise_base_url(base_url.into())?;
|
||||
if port == 0 {
|
||||
return Err(DomainError::Invariant(
|
||||
"modelServer.endpoint.port must be in 1..=65535".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(Self { base_url, port })
|
||||
}
|
||||
}
|
||||
|
||||
fn normalise_base_url(raw: String) -> Result<String, DomainError> {
|
||||
let trimmed = raw.trim().trim_end_matches('/').to_owned();
|
||||
crate::validation::non_empty(&trimmed, "modelServer.endpoint.baseURL")?;
|
||||
if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) {
|
||||
return Err(DomainError::Invariant(
|
||||
"modelServer.endpoint.baseURL must start with http:// or https://".to_owned(),
|
||||
));
|
||||
}
|
||||
if !has_local_host_authority(&trimmed) {
|
||||
return Err(DomainError::Invariant(
|
||||
"modelServer.endpoint.baseURL must target localhost in V1".to_owned(),
|
||||
));
|
||||
}
|
||||
if trimmed.ends_with("/v1") {
|
||||
Ok(trimmed)
|
||||
} else {
|
||||
Ok(format!("{trimmed}/v1"))
|
||||
}
|
||||
}
|
||||
|
||||
fn has_local_host_authority(url: &str) -> bool {
|
||||
let Some(authority_and_path) = url.split_once("://").map(|(_, rest)| rest) else {
|
||||
return false;
|
||||
};
|
||||
let authority = authority_and_path.split('/').next().unwrap_or_default();
|
||||
let host = authority
|
||||
.rsplit_once('@')
|
||||
.map(|(_, host)| host)
|
||||
.unwrap_or(authority);
|
||||
let host = host
|
||||
.strip_prefix('[')
|
||||
.and_then(|ipv6| ipv6.split_once(']').map(|(addr, _)| addr))
|
||||
.unwrap_or_else(|| host.split(':').next().unwrap_or_default());
|
||||
host.eq_ignore_ascii_case("localhost") || host == "::1" || is_ipv4_loopback(host)
|
||||
}
|
||||
|
||||
fn is_ipv4_loopback(host: &str) -> bool {
|
||||
host.parse::<Ipv4Addr>()
|
||||
.map(|addr| addr.octets()[0] == 127)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Absolute local path to a model file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ModelPath(pub String);
|
||||
|
||||
impl ModelPath {
|
||||
/// Builds a validated absolute model path.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`DomainError`] if the path is blank or relative.
|
||||
pub fn new(path: impl Into<String>) -> Result<Self, DomainError> {
|
||||
let path = path.into();
|
||||
crate::validation::non_empty(&path, "modelServer.model.path")?;
|
||||
if !is_absolute_path(&path) {
|
||||
return Err(DomainError::Invariant(
|
||||
"modelServer.model.path must be absolute".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(Self(path))
|
||||
}
|
||||
|
||||
/// Returns the path as a string slice.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Absolute local path to an executable.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ExecutablePath(pub String);
|
||||
|
||||
impl ExecutablePath {
|
||||
/// Builds a validated executable path.
|
||||
///
|
||||
/// Absolute paths are accepted directly. Bare command names are accepted so
|
||||
/// infrastructure can resolve them through `PATH` without shell interpolation.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`DomainError`] if the path is blank.
|
||||
pub fn new(path: impl Into<String>) -> Result<Self, DomainError> {
|
||||
let path = path.into();
|
||||
crate::validation::non_empty(&path, "modelServer.binary")?;
|
||||
Ok(Self(path))
|
||||
}
|
||||
|
||||
/// Returns the path as a string slice.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
fn is_absolute_path(path: &str) -> bool {
|
||||
path.starts_with('/')
|
||||
|| path.starts_with('\\')
|
||||
|| (path.len() >= 3
|
||||
&& path.as_bytes()[1] == b':'
|
||||
&& path.as_bytes()[0].is_ascii_alphabetic()
|
||||
&& matches!(path.as_bytes()[2], b'/' | b'\\'))
|
||||
}
|
||||
|
||||
/// Model served by a local model server.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocalModelRef {
|
||||
/// Stable model id in IdeA configuration.
|
||||
pub id: String,
|
||||
/// Human display label.
|
||||
pub label: String,
|
||||
/// Explicit absolute `.gguf` path. Required for auto-start.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<ModelPath>,
|
||||
/// Name exposed by the server and used in `OpenCodeConfig.model`.
|
||||
pub served_name: String,
|
||||
}
|
||||
|
||||
impl LocalModelRef {
|
||||
/// Builds a validated local model reference.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`DomainError`] if `id`, `label` or `served_name` is blank.
|
||||
pub fn new(
|
||||
id: impl Into<String>,
|
||||
label: impl Into<String>,
|
||||
path: Option<ModelPath>,
|
||||
served_name: impl Into<String>,
|
||||
) -> Result<Self, DomainError> {
|
||||
let id = id.into();
|
||||
let label = label.into();
|
||||
let served_name = served_name.into();
|
||||
crate::validation::non_empty(&id, "modelServer.model.id")?;
|
||||
crate::validation::non_empty(&label, "modelServer.model.label")?;
|
||||
crate::validation::non_empty(&served_name, "modelServer.model.servedName")?;
|
||||
Ok(Self {
|
||||
id,
|
||||
label,
|
||||
path,
|
||||
served_name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop policy for a managed local model server.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum StopPolicy {
|
||||
/// Leave the process running.
|
||||
KeepAlive,
|
||||
/// Stop when no profile uses it. Not used aggressively in V1.
|
||||
StopWhenUnused,
|
||||
/// Stop on application shutdown.
|
||||
StopOnAppExit,
|
||||
}
|
||||
|
||||
/// Persisted configuration for a local model server.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocalModelServerConfig {
|
||||
/// Stable identity.
|
||||
pub id: LocalModelServerId,
|
||||
/// Server implementation family.
|
||||
pub kind: LocalModelServerKind,
|
||||
/// Display name.
|
||||
pub name: String,
|
||||
/// OpenAI-compatible endpoint.
|
||||
pub endpoint: ModelServerEndpoint,
|
||||
/// Served model.
|
||||
pub model: LocalModelRef,
|
||||
/// Optional executable path or bare command name.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub binary: Option<ExecutablePath>,
|
||||
/// Extra argv passed to the runtime.
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
/// Whether IdeA may start the server lazily.
|
||||
pub auto_start: bool,
|
||||
/// Process stop policy.
|
||||
#[serde(default = "default_stop_policy")]
|
||||
pub stop_policy: StopPolicy,
|
||||
}
|
||||
|
||||
fn default_stop_policy() -> StopPolicy {
|
||||
StopPolicy::StopOnAppExit
|
||||
}
|
||||
|
||||
impl LocalModelServerConfig {
|
||||
/// Builds a validated local server configuration.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`DomainError`] if names are blank or auto-start invariants are not
|
||||
/// satisfied by static data.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
id: LocalModelServerId,
|
||||
kind: LocalModelServerKind,
|
||||
name: impl Into<String>,
|
||||
endpoint: ModelServerEndpoint,
|
||||
model: LocalModelRef,
|
||||
binary: Option<ExecutablePath>,
|
||||
args: Vec<String>,
|
||||
auto_start: bool,
|
||||
stop_policy: StopPolicy,
|
||||
) -> Result<Self, DomainError> {
|
||||
let name = name.into();
|
||||
crate::validation::non_empty(&name, "modelServer.name")?;
|
||||
if auto_start {
|
||||
if kind != LocalModelServerKind::LlamaCpp {
|
||||
return Err(DomainError::Invariant(
|
||||
"modelServer.autoStart requires kind=LlamaCpp".to_owned(),
|
||||
));
|
||||
}
|
||||
if model.path.is_none() {
|
||||
return Err(DomainError::Invariant(
|
||||
"modelServer.autoStart requires model.path".to_owned(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
id,
|
||||
kind,
|
||||
name,
|
||||
endpoint,
|
||||
model,
|
||||
binary,
|
||||
args,
|
||||
auto_start,
|
||||
stop_policy,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Observed model-server status.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ModelServerStatus {
|
||||
/// The server is reachable and was already running.
|
||||
ReadyReused,
|
||||
/// The server is reachable after IdeA started it.
|
||||
ReadyStarted,
|
||||
/// The server is not reachable.
|
||||
Unreachable,
|
||||
}
|
||||
|
||||
/// Successful ensure result used by agent launch.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ModelServerReady {
|
||||
/// Base URL to use in OpenCode config.
|
||||
pub base_url: String,
|
||||
/// Served model name to use in OpenCode config.
|
||||
pub model: String,
|
||||
/// Whether the server was reused or started.
|
||||
pub status: ModelServerStatus,
|
||||
}
|
||||
|
||||
/// Lifecycle status emitted for presentation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ModelServerLifecycleStatus {
|
||||
/// No local model server is configured for the profile.
|
||||
NotConfigured,
|
||||
/// A readiness probe is in progress.
|
||||
Probing,
|
||||
/// IdeA is starting the server.
|
||||
Starting,
|
||||
/// The server is ready.
|
||||
Ready {
|
||||
/// `true` when an already-running server was reused.
|
||||
reused: bool,
|
||||
},
|
||||
/// The server failed to become ready.
|
||||
Failed {
|
||||
/// Stable error code.
|
||||
code: String,
|
||||
/// Human-readable message.
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn endpoint_normalises_to_v1() {
|
||||
let endpoint = ModelServerEndpoint::new("http://localhost:8080", 8080).unwrap();
|
||||
assert_eq!(endpoint.base_url, "http://localhost:8080/v1");
|
||||
let endpoint = ModelServerEndpoint::new("http://localhost:8080/v1/", 8080).unwrap();
|
||||
assert_eq!(endpoint.base_url, "http://localhost:8080/v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_rejects_remote_host() {
|
||||
let err = ModelServerEndpoint::new("http://example.com:8080", 8080).unwrap_err();
|
||||
assert!(err.to_string().contains("localhost"));
|
||||
let err =
|
||||
ModelServerEndpoint::new("http://127.0.0.1.evil.com:8080", 8080).unwrap_err();
|
||||
assert!(err.to_string().contains("localhost"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_start_requires_model_path() {
|
||||
let endpoint = ModelServerEndpoint::new("http://localhost:8080/v1", 8080).unwrap();
|
||||
let model = LocalModelRef::new("qwen", "Qwen", None, "qwen").unwrap();
|
||||
let err = LocalModelServerConfig::new(
|
||||
LocalModelServerId::from_uuid(uuid::Uuid::nil()),
|
||||
LocalModelServerKind::LlamaCpp,
|
||||
"llama.cpp",
|
||||
endpoint,
|
||||
model,
|
||||
None,
|
||||
Vec::new(),
|
||||
true,
|
||||
StopPolicy::StopOnAppExit,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("model.path"));
|
||||
}
|
||||
}
|
||||
@ -34,12 +34,15 @@ use crate::background_task::{
|
||||
BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy,
|
||||
};
|
||||
use crate::events::DomainEvent;
|
||||
use crate::ids::{AgentId, NodeId, ProjectId, ScheduleId, SessionId, SprintId, TaskId};
|
||||
use crate::ids::{
|
||||
AgentId, LocalModelServerId, NodeId, ProjectId, ScheduleId, SessionId, SprintId, TaskId,
|
||||
};
|
||||
use crate::issue::{
|
||||
Issue, IssueCarnet, IssueIndexEntry, IssueListFilter, IssueNumber, IssueRef, IssueVersion,
|
||||
};
|
||||
use crate::markdown::MarkdownDoc;
|
||||
use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
|
||||
use crate::model_server::{LocalModelServerConfig, ModelServerEndpoint, ModelServerStatus};
|
||||
use crate::permission::ProjectPermissions;
|
||||
use crate::profile::{AgentProfile, EmbedderProfile};
|
||||
use crate::project::{Project, ProjectPath};
|
||||
@ -206,6 +209,27 @@ pub struct Output {
|
||||
pub stderr: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Opaque handle for a managed local process.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct ManagedProcessHandle {
|
||||
/// Stable local process identity as assigned by the adapter.
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
/// Observed status of a managed local process.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProcessStatus {
|
||||
/// Process is still running.
|
||||
Running,
|
||||
/// Process has exited.
|
||||
Exited {
|
||||
/// Exit code, when available.
|
||||
code: Option<i32>,
|
||||
},
|
||||
/// Handle is unknown to the adapter.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// A location-neutral path used by [`FileSystem`] (local, SFTP, or WSL).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct RemotePath(pub String);
|
||||
@ -502,6 +526,41 @@ pub enum ProcessError {
|
||||
Io(String),
|
||||
}
|
||||
|
||||
/// Errors from local model-server ports.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum ModelServerError {
|
||||
/// The referenced configuration does not exist.
|
||||
#[error("not configured")]
|
||||
NotConfigured,
|
||||
/// Server or configuration is invalid.
|
||||
#[error("invalid: {0}")]
|
||||
Invalid(String),
|
||||
/// Filesystem permission denied.
|
||||
#[error("permission denied: {0}")]
|
||||
PermissionDenied(String),
|
||||
/// Required path is not accessible.
|
||||
#[error("path not accessible: {0}")]
|
||||
PathNotAccessible(String),
|
||||
/// The configured port is already in use by another managed server.
|
||||
#[error("port occupied: {0}")]
|
||||
PortOccupied(u16),
|
||||
/// The server is still referenced by another configuration object.
|
||||
#[error("model server in use: {0}")]
|
||||
InUse(String),
|
||||
/// Readiness probe failed unexpectedly.
|
||||
#[error("probe failed: {0}")]
|
||||
Probe(String),
|
||||
/// Process operation failed.
|
||||
#[error("process failed: {0}")]
|
||||
Process(String),
|
||||
/// Store operation failed.
|
||||
#[error("store failed: {0}")]
|
||||
Store(String),
|
||||
/// Readiness timed out.
|
||||
#[error("readiness timed out")]
|
||||
Timeout,
|
||||
}
|
||||
|
||||
/// Errors from [`FileSystem`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum FsError {
|
||||
@ -914,6 +973,88 @@ pub trait ProcessSpawner: Send + Sync {
|
||||
async fn run(&self, spec: SpawnSpec) -> Result<Output, ProcessError>;
|
||||
}
|
||||
|
||||
/// Probe readiness of an OpenAI-compatible model server.
|
||||
#[async_trait]
|
||||
pub trait ModelServerProbe: Send + Sync {
|
||||
/// Probes `endpoint` and returns the observed status.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ModelServerError`] on unexpected probe failure. An unreachable server
|
||||
/// should normally return [`ModelServerStatus::Unreachable`].
|
||||
async fn probe(
|
||||
&self,
|
||||
endpoint: &ModelServerEndpoint,
|
||||
) -> Result<ModelServerStatus, ModelServerError>;
|
||||
}
|
||||
|
||||
/// Manages local long-lived child processes.
|
||||
#[async_trait]
|
||||
pub trait ManagedProcess: Send + Sync {
|
||||
/// Spawns a process from an argv-structured spec.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ModelServerError`] on spawn failure.
|
||||
async fn spawn(&self, spec: SpawnSpec) -> Result<ManagedProcessHandle, ModelServerError>;
|
||||
|
||||
/// Kills a process.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ModelServerError`] on kill failure.
|
||||
async fn kill(&self, handle: &ManagedProcessHandle) -> Result<(), ModelServerError>;
|
||||
|
||||
/// Returns the status of a process.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ModelServerError`] on status failure.
|
||||
async fn status(
|
||||
&self,
|
||||
handle: &ManagedProcessHandle,
|
||||
) -> Result<ProcessStatus, ModelServerError>;
|
||||
}
|
||||
|
||||
/// Builds the argv-structured spawn spec for a local model server.
|
||||
pub trait ModelServerRuntime: Send + Sync {
|
||||
/// Builds a spawn spec from a validated local-server config.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ModelServerError`] if the config cannot be launched.
|
||||
fn build_spawn_spec(
|
||||
&self,
|
||||
config: &LocalModelServerConfig,
|
||||
) -> Result<SpawnSpec, ModelServerError>;
|
||||
}
|
||||
|
||||
/// Persists local model-server configuration in the global IdeA store.
|
||||
#[async_trait]
|
||||
pub trait ModelServerRegistry: Send + Sync {
|
||||
/// Returns one server config by id.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ModelServerError`] on store failure.
|
||||
async fn get(
|
||||
&self,
|
||||
id: &LocalModelServerId,
|
||||
) -> Result<Option<LocalModelServerConfig>, ModelServerError>;
|
||||
|
||||
/// Lists all server configs.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ModelServerError`] on store failure.
|
||||
async fn list(&self) -> Result<Vec<LocalModelServerConfig>, ModelServerError>;
|
||||
|
||||
/// Saves one server config.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ModelServerError`] on store failure.
|
||||
async fn save(&self, config: LocalModelServerConfig) -> Result<(), ModelServerError>;
|
||||
|
||||
/// Deletes one server config.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ModelServerError`] on store failure.
|
||||
async fn delete(&self, id: LocalModelServerId) -> Result<(), ModelServerError>;
|
||||
}
|
||||
|
||||
/// Location-neutral filesystem access.
|
||||
#[async_trait]
|
||||
pub trait FileSystem: Send + Sync {
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::DomainError;
|
||||
use crate::ids::ProfileId;
|
||||
use crate::ids::{LocalModelServerId, ProfileId};
|
||||
use crate::permission::ProjectorKey;
|
||||
|
||||
/// Strategy for injecting an agent's `.md` context into the launched CLI.
|
||||
@ -295,6 +295,11 @@ pub struct OpenCodeConfig {
|
||||
/// Active les attachments côté modèle OpenCode. Défaut effectif : `false`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub attachment: Option<bool>,
|
||||
/// Serveur local managé associé, quand ce profil dépend d'un lifecycle IdeA.
|
||||
///
|
||||
/// `None` garde le comportement existant : profil OpenCode manuel/externe.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub local_model_server_id: Option<LocalModelServerId>,
|
||||
}
|
||||
|
||||
impl OpenCodeConfig {
|
||||
@ -325,9 +330,17 @@ impl OpenCodeConfig {
|
||||
model,
|
||||
reasoning,
|
||||
attachment,
|
||||
local_model_server_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Builder : associe cette configuration à un serveur local managé.
|
||||
#[must_use]
|
||||
pub const fn with_local_model_server_id(mut self, id: LocalModelServerId) -> Self {
|
||||
self.local_model_server_id = Some(id);
|
||||
self
|
||||
}
|
||||
|
||||
/// Valeur effective de `reasoning` quand le profil ne la porte pas.
|
||||
#[must_use]
|
||||
pub fn reasoning_enabled(&self) -> bool {
|
||||
@ -1107,7 +1120,7 @@ impl AgentProfile {
|
||||
#[cfg(test)]
|
||||
mod mcp_tests {
|
||||
use super::*;
|
||||
use crate::ids::ProfileId;
|
||||
use crate::ids::{LocalModelServerId, ProfileId};
|
||||
|
||||
/// A reference profile without any MCP capability (the historical shape).
|
||||
fn profile_without_mcp() -> AgentProfile {
|
||||
@ -1225,6 +1238,46 @@ mod mcp_tests {
|
||||
assert!(OpenCodeConfig::new("http://localhost:8080/v1", None, " ", None, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_config_omits_local_model_server_id_when_absent() {
|
||||
let config = OpenCodeConfig::new(
|
||||
"http://localhost:8080/v1",
|
||||
None,
|
||||
"qwen3-coder-30b",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let json = serde_json::to_string(&config).expect("serialise");
|
||||
assert!(
|
||||
!json.contains("localModelServerId"),
|
||||
"manual external OpenCode profiles must keep the legacy JSON shape: {json}"
|
||||
);
|
||||
let back: OpenCodeConfig = serde_json::from_str(&json).expect("deserialise");
|
||||
assert_eq!(back.local_model_server_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_config_serialises_local_model_server_id_camelcase() {
|
||||
let server_id = LocalModelServerId::from_uuid(uuid::Uuid::from_u128(35));
|
||||
let config = OpenCodeConfig::new(
|
||||
"http://localhost:8080/v1",
|
||||
None,
|
||||
"qwen3-coder-30b",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
.with_local_model_server_id(server_id);
|
||||
|
||||
let value = serde_json::to_value(&config).expect("serialise");
|
||||
assert_eq!(value["localModelServerId"], server_id.to_string());
|
||||
assert!(value.get("local_model_server_id").is_none());
|
||||
let back: OpenCodeConfig = serde_json::from_value(value).expect("deserialise");
|
||||
assert_eq!(back.local_model_server_id, Some(server_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_compatible_stays_non_mcp_native_even_with_mcp_declared() {
|
||||
let profile = profile_without_mcp()
|
||||
|
||||
@ -26,6 +26,7 @@ pub mod input;
|
||||
pub mod inspector;
|
||||
pub mod issues;
|
||||
pub mod mailbox;
|
||||
pub mod model_server;
|
||||
pub mod orchestrator;
|
||||
pub mod permission;
|
||||
pub mod process;
|
||||
@ -64,6 +65,9 @@ pub use inspector::{
|
||||
};
|
||||
pub use issues::{FsIssueNumberAllocator, FsIssueStore};
|
||||
pub use mailbox::InMemoryMailbox;
|
||||
pub use model_server::{
|
||||
FsModelServerRegistry, HttpOpenAiCompatibleProbe, LlamaCppRuntime, LocalManagedProcess,
|
||||
};
|
||||
pub use orchestrator::mcp::{
|
||||
McpServer, MemoryTransport, StdioTransport, TicketToolError, TicketToolProvider,
|
||||
ToolPolicyRegistry,
|
||||
|
||||
315
crates/infrastructure/src/model_server/mod.rs
Normal file
315
crates/infrastructure/src/model_server/mod.rs
Normal file
@ -0,0 +1,315 @@
|
||||
//! Infrastructure adapters for local model servers.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::process::{Child, Command};
|
||||
|
||||
use domain::model_server::{LocalModelServerConfig, LocalModelServerKind, ModelServerEndpoint};
|
||||
use domain::ports::{
|
||||
FileSystem, ManagedProcess, ManagedProcessHandle, ModelServerError, ModelServerProbe,
|
||||
ModelServerRegistry, ModelServerRuntime, ProcessStatus, RemotePath, SpawnSpec,
|
||||
};
|
||||
use domain::{LocalModelServerId, ProjectPath};
|
||||
|
||||
/// HTTP readiness probe for OpenAI-compatible servers.
|
||||
#[derive(Clone)]
|
||||
pub struct HttpOpenAiCompatibleProbe {
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl Default for HttpOpenAiCompatibleProbe {
|
||||
fn default() -> Self {
|
||||
Self::new(Duration::from_secs(2))
|
||||
}
|
||||
}
|
||||
|
||||
impl HttpOpenAiCompatibleProbe {
|
||||
/// Builds the probe with a request timeout.
|
||||
#[must_use]
|
||||
pub fn new(timeout: Duration) -> Self {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(timeout)
|
||||
.build()
|
||||
.expect("reqwest client with timeout builds");
|
||||
Self { client }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ModelServerProbe for HttpOpenAiCompatibleProbe {
|
||||
async fn probe(
|
||||
&self,
|
||||
endpoint: &ModelServerEndpoint,
|
||||
) -> Result<domain::ModelServerStatus, ModelServerError> {
|
||||
let models = format!("{}/models", endpoint.base_url.trim_end_matches('/'));
|
||||
if is_ready(self.client.get(models).send().await) {
|
||||
return Ok(domain::ModelServerStatus::ReadyReused);
|
||||
}
|
||||
let root = endpoint.base_url.trim_end_matches("/v1").to_owned();
|
||||
if is_ready(self.client.get(root).send().await) {
|
||||
return Ok(domain::ModelServerStatus::ReadyReused);
|
||||
}
|
||||
Ok(domain::ModelServerStatus::Unreachable)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_ready(result: Result<reqwest::Response, reqwest::Error>) -> bool {
|
||||
result
|
||||
.map(|response| response.status().is_success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Builds `llama-server` argv without shell interpolation.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct LlamaCppRuntime;
|
||||
|
||||
impl LlamaCppRuntime {
|
||||
/// Creates the runtime.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelServerRuntime for LlamaCppRuntime {
|
||||
fn build_spawn_spec(
|
||||
&self,
|
||||
config: &LocalModelServerConfig,
|
||||
) -> Result<SpawnSpec, ModelServerError> {
|
||||
if config.kind != LocalModelServerKind::LlamaCpp {
|
||||
return Err(ModelServerError::Invalid(
|
||||
"only LlamaCpp local model servers are supported".to_owned(),
|
||||
));
|
||||
}
|
||||
let model_path =
|
||||
config.model.path.as_ref().ok_or_else(|| {
|
||||
ModelServerError::PathNotAccessible("model.path missing".to_owned())
|
||||
})?;
|
||||
let command = resolve_binary(
|
||||
config
|
||||
.binary
|
||||
.as_ref()
|
||||
.map(|binary| binary.as_str())
|
||||
.unwrap_or("llama-server"),
|
||||
)?;
|
||||
let mut args = vec![
|
||||
"--model".to_owned(),
|
||||
model_path.as_str().to_owned(),
|
||||
"--port".to_owned(),
|
||||
config.endpoint.port.to_string(),
|
||||
];
|
||||
args.extend(config.args.clone());
|
||||
Ok(SpawnSpec {
|
||||
command,
|
||||
args,
|
||||
cwd: ProjectPath::new("/").map_err(|e| ModelServerError::Invalid(e.to_string()))?,
|
||||
env: Vec::new(),
|
||||
context_plan: None,
|
||||
sandbox: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_binary(raw: &str) -> Result<String, ModelServerError> {
|
||||
if is_path_like(raw) {
|
||||
let path = Path::new(raw);
|
||||
if path.is_file() {
|
||||
return Ok(raw.to_owned());
|
||||
}
|
||||
return Err(ModelServerError::PathNotAccessible(raw.to_owned()));
|
||||
}
|
||||
if let Some(path) = find_in_path(raw) {
|
||||
return Ok(path.to_string_lossy().into_owned());
|
||||
}
|
||||
Err(ModelServerError::PathNotAccessible(format!(
|
||||
"{raw} not found in PATH"
|
||||
)))
|
||||
}
|
||||
|
||||
fn is_path_like(raw: &str) -> bool {
|
||||
raw.contains('/') || raw.contains('\\') || Path::new(raw).is_absolute()
|
||||
}
|
||||
|
||||
fn find_in_path(command: &str) -> Option<PathBuf> {
|
||||
std::env::var_os("PATH").and_then(|path| {
|
||||
std::env::split_paths(&path)
|
||||
.map(|dir| dir.join(command))
|
||||
.find(|candidate| candidate.is_file())
|
||||
})
|
||||
}
|
||||
|
||||
/// Local child-process manager for long-lived model servers.
|
||||
#[derive(Default)]
|
||||
pub struct LocalManagedProcess {
|
||||
children: Mutex<HashMap<String, Child>>,
|
||||
}
|
||||
|
||||
impl LocalManagedProcess {
|
||||
/// Creates an empty process manager.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ManagedProcess for LocalManagedProcess {
|
||||
async fn spawn(&self, spec: SpawnSpec) -> Result<ManagedProcessHandle, ModelServerError> {
|
||||
let mut command = Command::new(&spec.command);
|
||||
command.args(&spec.args);
|
||||
if spec.cwd.as_str() != "/" {
|
||||
command.current_dir(spec.cwd.as_str());
|
||||
}
|
||||
for (key, value) in &spec.env {
|
||||
command.env(key, value);
|
||||
}
|
||||
let child = command
|
||||
.spawn()
|
||||
.map_err(|e| ModelServerError::Process(format!("{}: {e}", spec.command)))?;
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
self.children.lock().unwrap().insert(id.clone(), child);
|
||||
Ok(ManagedProcessHandle { id })
|
||||
}
|
||||
|
||||
async fn kill(&self, handle: &ManagedProcessHandle) -> Result<(), ModelServerError> {
|
||||
let Some(mut child) = self.children.lock().unwrap().remove(&handle.id) else {
|
||||
return Ok(());
|
||||
};
|
||||
child
|
||||
.start_kill()
|
||||
.map_err(|e| ModelServerError::Process(e.to_string()))
|
||||
}
|
||||
|
||||
async fn status(
|
||||
&self,
|
||||
handle: &ManagedProcessHandle,
|
||||
) -> Result<ProcessStatus, ModelServerError> {
|
||||
let mut children = self.children.lock().unwrap();
|
||||
let Some(child) = children.get_mut(&handle.id) else {
|
||||
return Ok(ProcessStatus::Unknown);
|
||||
};
|
||||
match child
|
||||
.try_wait()
|
||||
.map_err(|e| ModelServerError::Process(e.to_string()))?
|
||||
{
|
||||
Some(status) => Ok(ProcessStatus::Exited {
|
||||
code: status.code(),
|
||||
}),
|
||||
None => Ok(ProcessStatus::Running),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// File name of the global local-model-server registry.
|
||||
const MODEL_SERVERS_FILE: &str = "model-servers.json";
|
||||
const MODEL_SERVERS_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ModelServersDoc {
|
||||
version: u32,
|
||||
servers: Vec<LocalModelServerConfig>,
|
||||
}
|
||||
|
||||
impl Default for ModelServersDoc {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: MODEL_SERVERS_VERSION,
|
||||
servers: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Filesystem-backed global model-server registry.
|
||||
#[derive(Clone)]
|
||||
pub struct FsModelServerRegistry {
|
||||
fs: std::sync::Arc<dyn FileSystem>,
|
||||
app_data_dir: String,
|
||||
}
|
||||
|
||||
impl FsModelServerRegistry {
|
||||
/// Builds the registry from a filesystem and global app-data dir.
|
||||
#[must_use]
|
||||
pub fn new(fs: std::sync::Arc<dyn FileSystem>, app_data_dir: impl Into<String>) -> Self {
|
||||
Self {
|
||||
fs,
|
||||
app_data_dir: app_data_dir.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn path(&self) -> RemotePath {
|
||||
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
|
||||
RemotePath::new(format!("{base}/{MODEL_SERVERS_FILE}"))
|
||||
}
|
||||
|
||||
async fn read_doc(&self) -> Result<ModelServersDoc, ModelServerError> {
|
||||
match self.fs.read(&self.path()).await {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes)
|
||||
.map_err(|e| ModelServerError::Store(format!("serialization failed: {e}"))),
|
||||
Err(domain::ports::FsError::NotFound(_)) => Ok(ModelServersDoc::default()),
|
||||
Err(domain::ports::FsError::PermissionDenied(p)) => {
|
||||
Err(ModelServerError::PermissionDenied(p))
|
||||
}
|
||||
Err(e) => Err(ModelServerError::Store(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_doc(&self, doc: &ModelServersDoc) -> Result<(), ModelServerError> {
|
||||
let dir = RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned());
|
||||
self.fs.create_dir_all(&dir).await.map_err(|e| match e {
|
||||
domain::ports::FsError::PermissionDenied(p) => ModelServerError::PermissionDenied(p),
|
||||
other => ModelServerError::Store(other.to_string()),
|
||||
})?;
|
||||
let bytes = serde_json::to_vec_pretty(doc)
|
||||
.map_err(|e| ModelServerError::Store(format!("serialization failed: {e}")))?;
|
||||
self.fs
|
||||
.write(&self.path(), &bytes)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
domain::ports::FsError::PermissionDenied(p) => {
|
||||
ModelServerError::PermissionDenied(p)
|
||||
}
|
||||
other => ModelServerError::Store(other.to_string()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ModelServerRegistry for FsModelServerRegistry {
|
||||
async fn get(
|
||||
&self,
|
||||
id: &LocalModelServerId,
|
||||
) -> Result<Option<LocalModelServerConfig>, ModelServerError> {
|
||||
Ok(self
|
||||
.read_doc()
|
||||
.await?
|
||||
.servers
|
||||
.into_iter()
|
||||
.find(|server| &server.id == id))
|
||||
}
|
||||
|
||||
async fn list(&self) -> Result<Vec<LocalModelServerConfig>, ModelServerError> {
|
||||
Ok(self.read_doc().await?.servers)
|
||||
}
|
||||
|
||||
async fn save(&self, config: LocalModelServerConfig) -> Result<(), ModelServerError> {
|
||||
let mut doc = self.read_doc().await?;
|
||||
if let Some(slot) = doc.servers.iter_mut().find(|server| server.id == config.id) {
|
||||
*slot = config;
|
||||
} else {
|
||||
doc.servers.push(config);
|
||||
}
|
||||
self.write_doc(&doc).await
|
||||
}
|
||||
|
||||
async fn delete(&self, id: LocalModelServerId) -> Result<(), ModelServerError> {
|
||||
let mut doc = self.read_doc().await?;
|
||||
doc.servers.retain(|server| server.id != id);
|
||||
self.write_doc(&doc).await
|
||||
}
|
||||
}
|
||||
111
crates/infrastructure/tests/model_server.rs
Normal file
111
crates/infrastructure/tests/model_server.rs
Normal file
@ -0,0 +1,111 @@
|
||||
//! Infrastructure tests for local model-server adapters.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::model_server::{
|
||||
ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath,
|
||||
ModelServerEndpoint, StopPolicy,
|
||||
};
|
||||
use domain::ports::{FileSystem, ModelServerRegistry, ModelServerRuntime, RemotePath};
|
||||
use domain::LocalModelServerId;
|
||||
use infrastructure::{FsModelServerRegistry, LlamaCppRuntime, LocalFileSystem};
|
||||
use uuid::Uuid;
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!("idea-model-server-{}", Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn app_data_dir(&self) -> String {
|
||||
self.0.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn child(&self, name: &str) -> RemotePath {
|
||||
RemotePath::new(self.0.join(name).to_string_lossy().into_owned())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn sid(n: u128) -> LocalModelServerId {
|
||||
LocalModelServerId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn config(id: LocalModelServerId, port: u16) -> LocalModelServerConfig {
|
||||
let binary = std::env::current_exe()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
LocalModelServerConfig::new(
|
||||
id,
|
||||
LocalModelServerKind::LlamaCpp,
|
||||
"Local Qwen",
|
||||
ModelServerEndpoint::new(format!("http://localhost:{port}/v1"), port).unwrap(),
|
||||
LocalModelRef::new(
|
||||
"qwen",
|
||||
"Qwen",
|
||||
Some(ModelPath::new("/models/qwen.gguf").unwrap()),
|
||||
"qwen3-coder-30b",
|
||||
)
|
||||
.unwrap(),
|
||||
Some(ExecutablePath::new(binary).unwrap()),
|
||||
vec!["--ctx-size".to_owned(), "8192".to_owned()],
|
||||
true,
|
||||
StopPolicy::StopOnAppExit,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llamacpp_runtime_builds_structured_argv() {
|
||||
let spec = LlamaCppRuntime::new()
|
||||
.build_spawn_spec(&config(sid(1), 8080))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
spec.command,
|
||||
std::env::current_exe().unwrap().to_string_lossy()
|
||||
);
|
||||
assert_eq!(
|
||||
spec.args,
|
||||
vec![
|
||||
"--model",
|
||||
"/models/qwen.gguf",
|
||||
"--port",
|
||||
"8080",
|
||||
"--ctx-size",
|
||||
"8192"
|
||||
]
|
||||
);
|
||||
assert!(spec.context_plan.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fs_model_server_registry_roundtrips_global_json() {
|
||||
let tmp = TempDir::new();
|
||||
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
|
||||
let registry = FsModelServerRegistry::new(Arc::clone(&fs), tmp.app_data_dir());
|
||||
|
||||
let first = config(sid(2), 8081);
|
||||
registry.save(first.clone()).await.unwrap();
|
||||
|
||||
assert_eq!(registry.get(&first.id).await.unwrap(), Some(first.clone()));
|
||||
assert_eq!(registry.list().await.unwrap(), vec![first.clone()]);
|
||||
assert!(
|
||||
fs.exists(&tmp.child("model-servers.json")).await.unwrap(),
|
||||
"registry must live in global app-data dir"
|
||||
);
|
||||
|
||||
let updated = config(sid(2), 8082);
|
||||
registry.save(updated.clone()).await.unwrap();
|
||||
assert_eq!(registry.list().await.unwrap(), vec![updated]);
|
||||
}
|
||||
@ -19,6 +19,7 @@ import { TauriProjectGateway } from "./project";
|
||||
import { TauriTerminalGateway } from "./terminal";
|
||||
import { TauriLayoutGateway } from "./layout";
|
||||
import { TauriProfileGateway } from "./profile";
|
||||
import { TauriModelServerGateway } from "./modelServer";
|
||||
import { TauriTemplateGateway } from "./template";
|
||||
import { TauriSkillGateway } from "./skill";
|
||||
import { TauriMemoryGateway } from "./memory";
|
||||
@ -56,6 +57,7 @@ export function createTauriGateways(): Gateways {
|
||||
git: new TauriGitGateway(),
|
||||
remote: new TauriRemoteGateway(),
|
||||
profile: new TauriProfileGateway(),
|
||||
modelServer: new TauriModelServerGateway(),
|
||||
template: new TauriTemplateGateway(),
|
||||
skill: new TauriSkillGateway(),
|
||||
memory: new TauriMemoryGateway(),
|
||||
@ -76,6 +78,7 @@ export {
|
||||
TauriTerminalGateway,
|
||||
TauriLayoutGateway,
|
||||
TauriProfileGateway,
|
||||
TauriModelServerGateway,
|
||||
TauriTemplateGateway,
|
||||
TauriSkillGateway,
|
||||
TauriMemoryGateway,
|
||||
|
||||
@ -23,6 +23,7 @@ import type {
|
||||
LayoutList,
|
||||
LayoutOperation,
|
||||
LayoutTree,
|
||||
LocalModelServerConfig,
|
||||
Memory,
|
||||
MemoryIndexEntry,
|
||||
MemoryLink,
|
||||
@ -57,6 +58,7 @@ import type {
|
||||
ConversationGateway,
|
||||
ConversationPageRequest,
|
||||
ConversationDetails,
|
||||
CloneOpenCodeProfileFromSeedInput,
|
||||
CreateAgentInput,
|
||||
CreateMemoryInput,
|
||||
CreateSkillInput,
|
||||
@ -68,6 +70,7 @@ import type {
|
||||
MemoryGateway,
|
||||
GitGateway,
|
||||
LayoutGateway,
|
||||
ModelServerGateway,
|
||||
OpenTerminalOptions,
|
||||
ProfileGateway,
|
||||
ProjectGateway,
|
||||
@ -1185,6 +1188,8 @@ export const MOCK_REFERENCE_PROFILES: AgentProfile[] = [
|
||||
export class MockProfileGateway implements ProfileGateway {
|
||||
private profiles: AgentProfile[] = [];
|
||||
private configured = false;
|
||||
/** Monotonic suffix so cloned OpenCode profiles get distinct ids/names. */
|
||||
private cloneCounter = 0;
|
||||
|
||||
async firstRunState(): Promise<FirstRunState> {
|
||||
return {
|
||||
@ -1228,6 +1233,61 @@ export class MockProfileGateway implements ProfileGateway {
|
||||
this.configured = true;
|
||||
return structuredClone(profiles);
|
||||
}
|
||||
|
||||
async cloneOpenCodeProfileFromSeed(
|
||||
input: CloneOpenCodeProfileFromSeedInput = {},
|
||||
): Promise<AgentProfile> {
|
||||
const seed = MOCK_REFERENCE_PROFILES.find(
|
||||
(p) => p.structuredAdapter === "openCode",
|
||||
);
|
||||
if (!seed) throw new Error("no OpenCode seed profile");
|
||||
this.cloneCounter += 1;
|
||||
return structuredClone({
|
||||
...seed,
|
||||
id: `mock-opencode-clone-${this.cloneCounter}`,
|
||||
name: input.name ?? `${seed.name} (copy ${this.cloneCounter})`,
|
||||
opencode: input.opencode ?? seed.opencode,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory local model server registry (F35). Tracks declared servers and can
|
||||
* be told which ids are still referenced by a profile, so `deleteModelServer`
|
||||
* reproduces the backend `model_server_in_use` rejection offline.
|
||||
*/
|
||||
export class MockModelServerGateway implements ModelServerGateway {
|
||||
private servers: LocalModelServerConfig[] = [];
|
||||
private readonly inUse = new Set<string>();
|
||||
|
||||
async listModelServers(): Promise<LocalModelServerConfig[]> {
|
||||
return structuredClone(this.servers);
|
||||
}
|
||||
|
||||
async saveModelServer(
|
||||
config: LocalModelServerConfig,
|
||||
): Promise<LocalModelServerConfig> {
|
||||
const i = this.servers.findIndex((s) => s.id === config.id);
|
||||
if (i >= 0) this.servers[i] = structuredClone(config);
|
||||
else this.servers.push(structuredClone(config));
|
||||
return structuredClone(config);
|
||||
}
|
||||
|
||||
async deleteModelServer(serverId: string): Promise<void> {
|
||||
if (this.inUse.has(serverId)) {
|
||||
const err: GatewayError = {
|
||||
code: "model_server_in_use",
|
||||
message: "A profile still references this server.",
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
this.servers = this.servers.filter((s) => s.id !== serverId);
|
||||
}
|
||||
|
||||
/** Test hook: mark a server id as still referenced (delete then rejects). */
|
||||
markInUse(serverId: string): void {
|
||||
this.inUse.add(serverId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -2454,6 +2514,7 @@ export function createMockGateways(): Gateways {
|
||||
git: new MockGitGateway(),
|
||||
remote: new MockRemoteGateway(),
|
||||
profile: new MockProfileGateway(),
|
||||
modelServer: new MockModelServerGateway(),
|
||||
template: new MockTemplateGateway(agentGateway),
|
||||
skill: new MockSkillGateway(agentGateway),
|
||||
memory: new MockMemoryGateway(),
|
||||
|
||||
@ -21,6 +21,7 @@ describe("createMockGateways", () => {
|
||||
"input",
|
||||
"layout",
|
||||
"memory",
|
||||
"modelServer",
|
||||
"permission",
|
||||
"profile",
|
||||
"project",
|
||||
|
||||
@ -92,4 +92,33 @@ describe("MockProfileGateway", () => {
|
||||
await gw.configureProfiles([]);
|
||||
expect((await gw.firstRunState()).isFirstRun).toBe(false);
|
||||
});
|
||||
|
||||
it("cloneOpenCodeProfileFromSeed mints distinct ids and copies the seed config (F36)", async () => {
|
||||
const gw = new MockProfileGateway();
|
||||
|
||||
const a = await gw.cloneOpenCodeProfileFromSeed();
|
||||
const b = await gw.cloneOpenCodeProfileFromSeed();
|
||||
|
||||
expect(a.structuredAdapter).toBe("openCode");
|
||||
expect(a.opencode?.baseURL).toBe("http://localhost:8080/v1");
|
||||
// Identity is the id — two clones must never collide.
|
||||
expect(a.id).not.toBe(b.id);
|
||||
});
|
||||
|
||||
it("cloneOpenCodeProfileFromSeed honours name/config overrides (Duplicate path)", async () => {
|
||||
const gw = new MockProfileGateway();
|
||||
const cloned = await gw.cloneOpenCodeProfileFromSeed({
|
||||
name: "My local model",
|
||||
opencode: {
|
||||
baseURL: "http://localhost:9999/v1",
|
||||
model: "custom-model",
|
||||
localModelServerId: "srv-7",
|
||||
},
|
||||
});
|
||||
|
||||
expect(cloned.name).toBe("My local model");
|
||||
expect(cloned.opencode?.baseURL).toBe("http://localhost:9999/v1");
|
||||
expect(cloned.opencode?.model).toBe("custom-model");
|
||||
expect(cloned.opencode?.localModelServerId).toBe("srv-7");
|
||||
});
|
||||
});
|
||||
|
||||
31
frontend/src/adapters/modelServer.ts
Normal file
31
frontend/src/adapters/modelServer.ts
Normal file
@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Tauri adapter for {@link ModelServerGateway} (F35). One of the only places that
|
||||
* calls `invoke()`; features reach it exclusively through the port.
|
||||
*
|
||||
* Commands and payload keys are camelCase, matching the backend DTO convention.
|
||||
* The flat `LocalModelServerConfig` wire shape is the DTO frozen by Architect —
|
||||
* no nesting.
|
||||
*/
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type { LocalModelServerConfig } from "@/domain";
|
||||
import type { ModelServerGateway } from "@/ports";
|
||||
|
||||
export class TauriModelServerGateway implements ModelServerGateway {
|
||||
listModelServers(): Promise<LocalModelServerConfig[]> {
|
||||
return invoke<LocalModelServerConfig[]>("list_model_servers");
|
||||
}
|
||||
|
||||
saveModelServer(
|
||||
config: LocalModelServerConfig,
|
||||
): Promise<LocalModelServerConfig> {
|
||||
return invoke<LocalModelServerConfig>("save_model_server", {
|
||||
request: { config },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteModelServer(serverId: string): Promise<void> {
|
||||
await invoke("delete_model_server", { serverId });
|
||||
}
|
||||
}
|
||||
@ -9,7 +9,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type { AgentProfile, FirstRunState, ProfileAvailability } from "@/domain";
|
||||
import type { ProfileGateway } from "@/ports";
|
||||
import type { CloneOpenCodeProfileFromSeedInput, ProfileGateway } from "@/ports";
|
||||
|
||||
export class TauriProfileGateway implements ProfileGateway {
|
||||
firstRunState(): Promise<FirstRunState> {
|
||||
@ -43,4 +43,12 @@ export class TauriProfileGateway implements ProfileGateway {
|
||||
request: { profiles },
|
||||
});
|
||||
}
|
||||
|
||||
cloneOpenCodeProfileFromSeed(
|
||||
input: CloneOpenCodeProfileFromSeedInput = {},
|
||||
): Promise<AgentProfile> {
|
||||
return invoke<AgentProfile>("clone_opencode_profile_from_seed", {
|
||||
request: { name: input.name, opencode: input.opencode },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,11 +13,92 @@ export interface HealthReport {
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle status of a local model server during an agent launch (F35, mirror
|
||||
* of the backend `ModelServerStatusDto`, tagged on `state`, camelCase wire).
|
||||
*
|
||||
* Contract note — the backend event carries the `serverId` on the *envelope*
|
||||
* ({@link DomainEvent} `modelServerStatusChanged`), not inside the status, and it
|
||||
* does NOT emit `baseURL`/`model` in the status payload: the UI reads what the
|
||||
* backend actually sends. `reused` (on `ready`) tells apart a freshly-started
|
||||
* server from a reused running one.
|
||||
*/
|
||||
export type ModelServerStatus =
|
||||
| { state: "notConfigured" }
|
||||
| { state: "probing" }
|
||||
| { state: "starting" }
|
||||
| { state: "ready"; reused: boolean }
|
||||
| { state: "failed"; code: string; message: string };
|
||||
|
||||
/**
|
||||
* Stop policy of a managed local model server (mirror of the backend
|
||||
* `StopPolicyDto`, camelCase wire):
|
||||
* - `keepAlive`: leave the process running,
|
||||
* - `stopOnAppExit`: stop when IdeA exits (the effective default),
|
||||
* - `stopWhenUnused`: stop when no profile references it.
|
||||
*/
|
||||
export type StopPolicy = "keepAlive" | "stopOnAppExit" | "stopWhenUnused";
|
||||
|
||||
/**
|
||||
* A declared local model server (F35, mirror of the backend flat
|
||||
* `LocalModelServerConfigDto`, camelCase wire). Global to IdeA (not project
|
||||
* scoped). Referenced by an OpenCode profile through
|
||||
* `OpenCodeConfig.localModelServerId` (= this `id`).
|
||||
*
|
||||
* Contract notes:
|
||||
* - `id` is a UUID **minted client-side** on create (the backend requires a valid
|
||||
* UUID; it does not generate the server id, only an internal model id it hides).
|
||||
* - `servedModelName` is what the user configures and what is sent to OpenCode as
|
||||
* the `model` — the backend's internal `model.id`/`model.label` are not exposed.
|
||||
* - `modelPath` is required by the backend when `autoStart` is `true`.
|
||||
*/
|
||||
export interface LocalModelServerConfig {
|
||||
id: string;
|
||||
/** Only `llamaCpp` is supported today (camelCase, not "llamacpp"). */
|
||||
kind: "llamaCpp";
|
||||
name: string;
|
||||
/** OpenAI-compatible base URL served by `llama-server` (normalised to `/v1`). */
|
||||
baseURL: string;
|
||||
port: number;
|
||||
/** Absolute `.gguf` path. Required when `autoStart` is `true`. */
|
||||
modelPath?: string;
|
||||
/** Model name exposed by the server and sent to OpenCode as `model`. */
|
||||
servedModelName: string;
|
||||
/** Explicit `llama-server` executable path/command (optional). */
|
||||
binaryPath?: string;
|
||||
args: string[];
|
||||
autoStart: boolean;
|
||||
stopPolicy: StopPolicy;
|
||||
}
|
||||
|
||||
/** A domain event relayed from the backend (tagged union on `type`). */
|
||||
export type DomainEvent =
|
||||
| { type: "projectCreated"; projectId: string }
|
||||
| { type: "agentLaunched"; agentId: string; sessionId: string }
|
||||
| { type: "agentExited"; agentId: string; code: number }
|
||||
| {
|
||||
/**
|
||||
* A local model server changed lifecycle state while (re)launching an
|
||||
* agent whose OpenCode profile binds `localModelServerId` (F35). Keyed by
|
||||
* `serverId`; the frontend correlates it back to the agent through the
|
||||
* profile's `opencode.localModelServerId`.
|
||||
*/
|
||||
type: "modelServerStatusChanged";
|
||||
serverId: string;
|
||||
status: ModelServerStatus;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* An agent launch failed before a runtime session was created (F35). Unlike
|
||||
* a generic "agent failed", this carries an actionable `message` and a
|
||||
* stable `code`; `cause` namespaces the failure (e.g. `"model_server"`).
|
||||
*/
|
||||
type: "agentLaunchFailed";
|
||||
agentId: string;
|
||||
cause: string;
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
| { type: "agentProfileChanged"; agentId: string; profileId: string }
|
||||
| { type: "agentBusyChanged"; agentId: string; busy: boolean }
|
||||
| {
|
||||
@ -687,6 +768,13 @@ export interface OpenCodeConfig {
|
||||
reasoning?: boolean;
|
||||
/** Enables attachments. Effective default: `false`. */
|
||||
attachment?: boolean;
|
||||
/**
|
||||
* Optional id of a managed local model server (F35) this profile binds to.
|
||||
* When set, IdeA can start/stop the referenced `llama-server` for the profile;
|
||||
* omitted when the profile points at an externally-managed endpoint (mirrors
|
||||
* the backend `skip_serializing_if`). Opaque string id on the wire.
|
||||
*/
|
||||
localModelServerId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -23,6 +23,7 @@ import { useDrift } from "@/features/templates/useDrift";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { useAgents } from "./useAgents";
|
||||
import { AgentLimitBadge } from "./AgentLimitBadge";
|
||||
import { ModelServerLaunchBadge } from "./ModelServerLaunchBadge";
|
||||
|
||||
export interface AgentsPanelProps {
|
||||
/** The project whose agents to manage. */
|
||||
@ -331,6 +332,15 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
const delegationSource = vm.delegationSourceByRequester[a.id];
|
||||
// Session-limit state (ARCHITECTURE §21), if the agent is limited.
|
||||
const limitState = vm.limitByAgent[a.id];
|
||||
// F35 — local model server launch state, correlated from the
|
||||
// agent's profile binding (`opencode.localModelServerId`), plus the
|
||||
// agent's last launch failure (if any).
|
||||
const boundServerId = vm.profiles.find((p) => p.id === a.profileId)
|
||||
?.opencode?.localModelServerId;
|
||||
const modelServerStatus = boundServerId
|
||||
? vm.modelServerStatusByServer[boundServerId]
|
||||
: undefined;
|
||||
const launchFailure = vm.launchFailureByAgent[a.id];
|
||||
return (
|
||||
<li
|
||||
key={a.id}
|
||||
@ -388,6 +398,12 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* F35 — local model server launch state / actionable failure. */}
|
||||
<ModelServerLaunchBadge
|
||||
status={modelServerStatus}
|
||||
failure={launchFailure}
|
||||
/>
|
||||
|
||||
{/* F2 (ticket #4): announcements this agent is waiting on while
|
||||
it talks to another agent (requester == this row's id). Sits
|
||||
just above the agent drop-list. */}
|
||||
|
||||
100
frontend/src/features/agents/ModelServerLaunchBadge.test.tsx
Normal file
100
frontend/src/features/agents/ModelServerLaunchBadge.test.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
/**
|
||||
* F35.1 — presentational tests for {@link ModelServerLaunchBadge}. Pure render
|
||||
* of the folded launch state: lifecycle labels, the reused/fresh distinction,
|
||||
* the actionable failure (with a details toggle that hides the code by default),
|
||||
* and the "render nothing" cases for unmanaged rows.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
|
||||
import { ModelServerLaunchBadge } from "./ModelServerLaunchBadge";
|
||||
|
||||
describe("ModelServerLaunchBadge (F35.1)", () => {
|
||||
it("renders nothing without a status or a failure", () => {
|
||||
const { container } = render(<ModelServerLaunchBadge />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing for a notConfigured status (unmanaged endpoint)", () => {
|
||||
const { container } = render(
|
||||
<ModelServerLaunchBadge status={{ state: "notConfigured" }} />,
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the probing and starting lifecycle labels", () => {
|
||||
const { rerender } = render(
|
||||
<ModelServerLaunchBadge status={{ state: "probing" }} />,
|
||||
);
|
||||
expect(screen.getByLabelText("model server status").textContent).toMatch(
|
||||
/vérification/i,
|
||||
);
|
||||
rerender(<ModelServerLaunchBadge status={{ state: "starting" }} />);
|
||||
expect(screen.getByLabelText("model server status").textContent).toMatch(
|
||||
/démarrage/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("distinguishes a reused server from a freshly started one", () => {
|
||||
const { rerender } = render(
|
||||
<ModelServerLaunchBadge status={{ state: "ready", reused: true }} />,
|
||||
);
|
||||
expect(screen.getByLabelText("model server status").textContent).toMatch(
|
||||
/réutilisé/i,
|
||||
);
|
||||
rerender(
|
||||
<ModelServerLaunchBadge status={{ state: "ready", reused: false }} />,
|
||||
);
|
||||
const label = screen.getByLabelText("model server status").textContent ?? "";
|
||||
expect(label).toMatch(/prêt/i);
|
||||
expect(label).not.toMatch(/réutilisé/i);
|
||||
});
|
||||
|
||||
it("shows an actionable failure message, not a bare 'agent failed'", () => {
|
||||
render(
|
||||
<ModelServerLaunchBadge
|
||||
failure={{
|
||||
cause: "model_server",
|
||||
code: "SERVER_UNREACHABLE",
|
||||
message: "llama-server did not become ready on :8080",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const alert = screen.getByRole("alert");
|
||||
expect(alert.textContent).toMatch(/did not become ready on :8080/);
|
||||
// The code is not exposed by default (logs/detail stay hidden).
|
||||
expect(screen.queryByLabelText("launch failure detail")).toBeNull();
|
||||
});
|
||||
|
||||
it("reveals the stable code/cause only when Détails is toggled", () => {
|
||||
render(
|
||||
<ModelServerLaunchBadge
|
||||
failure={{ cause: "model_server", code: "SPAWN", message: "boom" }}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText("toggle launch failure detail"));
|
||||
const detail = screen.getByLabelText("launch failure detail");
|
||||
expect(detail.textContent).toMatch(/SPAWN/);
|
||||
expect(detail.textContent).toMatch(/model_server/);
|
||||
});
|
||||
|
||||
it("prefers the agent-scoped failure over a failed status", () => {
|
||||
render(
|
||||
<ModelServerLaunchBadge
|
||||
status={{ state: "failed", code: "S1", message: "status-level" }}
|
||||
failure={{ cause: "model_server", code: "A1", message: "agent-level" }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("alert").textContent).toMatch(/agent-level/);
|
||||
});
|
||||
|
||||
it("falls back to a failed status when there is no agent failure", () => {
|
||||
render(
|
||||
<ModelServerLaunchBadge
|
||||
status={{ state: "failed", code: "S1", message: "status-level boom" }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("alert").textContent).toMatch(/status-level boom/);
|
||||
});
|
||||
});
|
||||
115
frontend/src/features/agents/ModelServerLaunchBadge.tsx
Normal file
115
frontend/src/features/agents/ModelServerLaunchBadge.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
/**
|
||||
* `ModelServerLaunchBadge` — presentational launch-state indicator for an agent
|
||||
* whose OpenCode profile binds a local model server (F35.1). All state is folded
|
||||
* in {@link useAgents} (`modelServerStatusByServer` + `launchFailureByAgent`);
|
||||
* this component only renders it.
|
||||
*
|
||||
* It shows, in order of severity:
|
||||
* - a launch **failure** with its actionable message (not a bare "agent failed")
|
||||
* — the full logs are never shown by default; a "Détails" toggle reveals the
|
||||
* stable code/cause only;
|
||||
* - otherwise the model-server lifecycle: `probing`, `starting`, `ready`
|
||||
* (distinguishing a reused server from a freshly-started one).
|
||||
*
|
||||
* Renders nothing when there is neither a status to show nor a failure, so a
|
||||
* plain (non-local-model) agent row stays untouched.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { cn } from "@/shared";
|
||||
import type { ModelServerStatus } from "@/domain";
|
||||
import type { AgentLaunchFailure } from "./useAgents";
|
||||
|
||||
export interface ModelServerLaunchBadgeProps {
|
||||
/** The bound server's lifecycle status, when one has been observed. */
|
||||
status?: ModelServerStatus;
|
||||
/** The agent's last launch failure, when one occurred. */
|
||||
failure?: AgentLaunchFailure;
|
||||
}
|
||||
|
||||
/** Human label + tone for a non-failed lifecycle state. */
|
||||
function describeStatus(
|
||||
status: ModelServerStatus,
|
||||
): { label: string; tone: string; role?: "status" } | null {
|
||||
switch (status.state) {
|
||||
case "notConfigured":
|
||||
return null; // Nothing to surface for an unmanaged endpoint.
|
||||
case "probing":
|
||||
return { label: "vérification du serveur…", tone: "bg-muted/20 text-muted", role: "status" };
|
||||
case "starting":
|
||||
return { label: "démarrage du serveur…", tone: "bg-warning/20 text-warning", role: "status" };
|
||||
case "ready":
|
||||
return status.reused
|
||||
? { label: "serveur prêt (réutilisé)", tone: "bg-success/20 text-success" }
|
||||
: { label: "serveur prêt", tone: "bg-success/20 text-success" };
|
||||
case "failed":
|
||||
// Handled by the failure branch below (richer, actionable).
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function ModelServerLaunchBadge({
|
||||
status,
|
||||
failure,
|
||||
}: ModelServerLaunchBadgeProps) {
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
|
||||
// A launch failure (from `agentLaunchFailed`) or a `failed` status both mean
|
||||
// the launch could not complete — prefer the agent-scoped failure (it carries
|
||||
// the actionable message); fall back to the server `failed` status.
|
||||
const failed: { code: string; message: string; cause?: string } | null =
|
||||
failure
|
||||
? failure
|
||||
: status?.state === "failed"
|
||||
? { code: status.code, message: status.message }
|
||||
: null;
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<span className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span
|
||||
role="alert"
|
||||
aria-label="model server launch failed"
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
"bg-danger/20 text-danger",
|
||||
)}
|
||||
>
|
||||
Échec du lancement : {failed.message}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="toggle launch failure detail"
|
||||
onClick={() => setShowDetail((v) => !v)}
|
||||
className="text-xs text-muted underline hover:text-content"
|
||||
>
|
||||
Détails
|
||||
</button>
|
||||
{showDetail && (
|
||||
<span aria-label="launch failure detail" className="text-xs text-faint">
|
||||
code {failed.code}
|
||||
{failed.cause ? ` · cause ${failed.cause}` : ""}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status) return null;
|
||||
const described = describeStatus(status);
|
||||
if (!described) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
role={described.role}
|
||||
aria-label="model server status"
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
described.tone,
|
||||
)}
|
||||
>
|
||||
{described.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -5,7 +5,9 @@
|
||||
export { AgentsPanel } from "./AgentsPanel";
|
||||
export type { AgentsPanelProps } from "./AgentsPanel";
|
||||
export { useAgents } from "./useAgents";
|
||||
export type { AgentsViewModel } from "./useAgents";
|
||||
export type { AgentsViewModel, AgentLaunchFailure } from "./useAgents";
|
||||
export { ModelServerLaunchBadge } from "./ModelServerLaunchBadge";
|
||||
export type { ModelServerLaunchBadgeProps } from "./ModelServerLaunchBadge";
|
||||
export { ResumeProjectPanel } from "./ResumeProjectPanel";
|
||||
export type { ResumeProjectPanelProps } from "./ResumeProjectPanel";
|
||||
export { useResumeProject } from "./useResumeProject";
|
||||
|
||||
@ -13,6 +13,7 @@ import type {
|
||||
Agent,
|
||||
AgentProfile,
|
||||
GatewayError,
|
||||
ModelServerStatus,
|
||||
TerminalSession,
|
||||
} from "@/domain";
|
||||
import type { LiveAgent, OpenTerminalOptions, TerminalHandle } from "@/ports";
|
||||
@ -41,6 +42,17 @@ export interface AgentLimitState {
|
||||
suspected?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* An agent-launch failure surfaced to the UI (F35), from `agentLaunchFailed`.
|
||||
* `cause` namespaces the origin (e.g. `"model_server"`); `code`/`message` are
|
||||
* the stable, actionable failure details.
|
||||
*/
|
||||
export interface AgentLaunchFailure {
|
||||
cause: string;
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** What the agents UI needs from this hook. */
|
||||
export interface AgentsViewModel {
|
||||
/** All agents known to the project. */
|
||||
@ -67,6 +79,19 @@ export interface AgentsViewModel {
|
||||
* absent from the map is unlimited.
|
||||
*/
|
||||
limitByAgent: Record<string, AgentLimitState>;
|
||||
/**
|
||||
* Local model-server lifecycle status keyed by `serverId` (F35), folded from
|
||||
* `modelServerStatusChanged`. Correlate an agent to its server through the
|
||||
* profile's `opencode.localModelServerId`. A server absent from the map has no
|
||||
* observed status yet.
|
||||
*/
|
||||
modelServerStatusByServer: Record<string, ModelServerStatus>;
|
||||
/**
|
||||
* Last agent-launch failure keyed by agent id (F35), folded from
|
||||
* `agentLaunchFailed`. Carries an actionable `message`/`code`/`cause`. Cleared
|
||||
* for an agent as soon as it launches successfully (`agentLaunched`).
|
||||
*/
|
||||
launchFailureByAgent: Record<string, AgentLaunchFailure>;
|
||||
/** Last error message, or `null`. */
|
||||
error: string | null;
|
||||
/** Whether a request is in flight. */
|
||||
@ -154,6 +179,12 @@ export function useAgents(projectId: string): AgentsViewModel {
|
||||
const [limitByAgent, setLimitByAgent] = useState<
|
||||
Record<string, AgentLimitState>
|
||||
>({});
|
||||
const [modelServerStatusByServer, setModelServerStatusByServer] = useState<
|
||||
Record<string, ModelServerStatus>
|
||||
>({});
|
||||
const [launchFailureByAgent, setLaunchFailureByAgent] = useState<
|
||||
Record<string, AgentLaunchFailure>
|
||||
>({});
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setBusy(true);
|
||||
@ -208,6 +239,15 @@ export function useAgents(projectId: string): AgentsViewModel {
|
||||
void refresh();
|
||||
void refreshLiveAgents();
|
||||
}
|
||||
// A successful launch supersedes any prior launch failure for that agent
|
||||
// (F35): drop its stale error banner.
|
||||
if (event.type === "agentLaunched") {
|
||||
setLaunchFailureByAgent((prev) => {
|
||||
if (!(event.agentId in prev)) return prev;
|
||||
const { [event.agentId]: _cleared, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
}
|
||||
// Record which door a delegation came through (mcp vs file). Absent
|
||||
// `source` (older backend) leaves the map untouched → no badge.
|
||||
if (
|
||||
@ -269,6 +309,24 @@ export function useAgents(projectId: string): AgentsViewModel {
|
||||
},
|
||||
}));
|
||||
break;
|
||||
// F35 — local model server lifecycle during launch, keyed by server id.
|
||||
case "modelServerStatusChanged":
|
||||
setModelServerStatusByServer((prev) => ({
|
||||
...prev,
|
||||
[event.serverId]: event.status,
|
||||
}));
|
||||
break;
|
||||
// F35 — a launch failed with an actionable cause/message, keyed by agent.
|
||||
case "agentLaunchFailed":
|
||||
setLaunchFailureByAgent((prev) => ({
|
||||
...prev,
|
||||
[event.agentId]: {
|
||||
cause: event.cause,
|
||||
code: event.code,
|
||||
message: event.message,
|
||||
},
|
||||
}));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@ -475,6 +533,8 @@ export function useAgents(projectId: string): AgentsViewModel {
|
||||
liveAgents,
|
||||
delegationSourceByRequester,
|
||||
limitByAgent,
|
||||
modelServerStatusByServer,
|
||||
launchFailureByAgent,
|
||||
error,
|
||||
busy,
|
||||
runningAgentId,
|
||||
|
||||
129
frontend/src/features/agents/useAgentsModelServer.test.tsx
Normal file
129
frontend/src/features/agents/useAgentsModelServer.test.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
/**
|
||||
* F35.1 — local model server launch state in {@link useAgents}.
|
||||
*
|
||||
* Drives the hook behind the real {@link DIProvider} with an in-memory
|
||||
* {@link MockSystemGateway} to emit the two F35 domain events, and verifies they
|
||||
* fold into `modelServerStatusByServer` (keyed by serverId) and
|
||||
* `launchFailureByAgent` (keyed by agentId).
|
||||
*
|
||||
* Cases:
|
||||
* - `modelServerStatusChanged` → status by server id (probing → starting → ready)
|
||||
* - `agentLaunchFailed` → actionable failure by agent id
|
||||
* - `agentLaunched` clears a prior launch failure for that agent
|
||||
* - a `failed` status is retained per server (independent of the agent map)
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
|
||||
import type { DomainEvent } from "@/domain";
|
||||
import type { Gateways } from "@/ports";
|
||||
import { MockSystemGateway } from "@/adapters/mock";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { useAgents } from "./useAgents";
|
||||
|
||||
const PROJECT_ID = "proj-modelserver-001";
|
||||
const AGENT = "agent-oc";
|
||||
const SERVER = "srv-42";
|
||||
|
||||
function setup() {
|
||||
const system = new MockSystemGateway();
|
||||
const agent = {
|
||||
listAgents: async () => [],
|
||||
listLiveAgents: async () => [],
|
||||
};
|
||||
const profile = { listProfiles: async () => [] };
|
||||
const gateways = { system, agent, profile } as unknown as Gateways;
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<DIProvider gateways={gateways}>{children}</DIProvider>
|
||||
);
|
||||
const view = renderHook(() => useAgents(PROJECT_ID), { wrapper });
|
||||
return { system, view };
|
||||
}
|
||||
|
||||
async function emit(system: MockSystemGateway, event: DomainEvent) {
|
||||
await act(async () => {
|
||||
system.emit(event);
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe("useAgents — local model server launch state (F35.1)", () => {
|
||||
it("folds modelServerStatusChanged into modelServerStatusByServer by serverId", async () => {
|
||||
const { system, view } = setup();
|
||||
|
||||
await emit(system, {
|
||||
type: "modelServerStatusChanged",
|
||||
serverId: SERVER,
|
||||
status: { state: "probing" },
|
||||
});
|
||||
expect(view.result.current.modelServerStatusByServer[SERVER]).toEqual({
|
||||
state: "probing",
|
||||
});
|
||||
|
||||
await emit(system, {
|
||||
type: "modelServerStatusChanged",
|
||||
serverId: SERVER,
|
||||
status: { state: "starting" },
|
||||
});
|
||||
await emit(system, {
|
||||
type: "modelServerStatusChanged",
|
||||
serverId: SERVER,
|
||||
status: { state: "ready", reused: true },
|
||||
});
|
||||
expect(view.result.current.modelServerStatusByServer[SERVER]).toEqual({
|
||||
state: "ready",
|
||||
reused: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("folds agentLaunchFailed into launchFailureByAgent by agentId", async () => {
|
||||
const { system, view } = setup();
|
||||
await emit(system, {
|
||||
type: "agentLaunchFailed",
|
||||
agentId: AGENT,
|
||||
cause: "model_server",
|
||||
code: "SERVER_UNREACHABLE",
|
||||
message: "llama-server did not become ready on :8080",
|
||||
});
|
||||
expect(view.result.current.launchFailureByAgent[AGENT]).toEqual({
|
||||
cause: "model_server",
|
||||
code: "SERVER_UNREACHABLE",
|
||||
message: "llama-server did not become ready on :8080",
|
||||
});
|
||||
});
|
||||
|
||||
it("a successful agentLaunched clears the agent's prior launch failure", async () => {
|
||||
const { system, view } = setup();
|
||||
await emit(system, {
|
||||
type: "agentLaunchFailed",
|
||||
agentId: AGENT,
|
||||
cause: "model_server",
|
||||
code: "SERVER_UNREACHABLE",
|
||||
message: "boom",
|
||||
});
|
||||
expect(view.result.current.launchFailureByAgent[AGENT]).toBeTruthy();
|
||||
|
||||
await emit(system, {
|
||||
type: "agentLaunched",
|
||||
agentId: AGENT,
|
||||
sessionId: "sess-1",
|
||||
});
|
||||
expect(view.result.current.launchFailureByAgent[AGENT]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps a per-server failed status independent of the agent failure map", async () => {
|
||||
const { system, view } = setup();
|
||||
await emit(system, {
|
||||
type: "modelServerStatusChanged",
|
||||
serverId: SERVER,
|
||||
status: { state: "failed", code: "SPAWN", message: "binary not found" },
|
||||
});
|
||||
expect(view.result.current.modelServerStatusByServer[SERVER]).toEqual({
|
||||
state: "failed",
|
||||
code: "SPAWN",
|
||||
message: "binary not found",
|
||||
});
|
||||
expect(view.result.current.launchFailureByAgent[AGENT]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@ -13,8 +13,8 @@ import {
|
||||
fireEvent,
|
||||
} from "@testing-library/react";
|
||||
|
||||
import { MockProfileGateway } from "@/adapters/mock";
|
||||
import type { ProfileAvailability } from "@/domain";
|
||||
import { MockModelServerGateway, MockProfileGateway } from "@/adapters/mock";
|
||||
import type { LocalModelServerConfig, ProfileAvailability } from "@/domain";
|
||||
import type { Gateways } from "@/ports";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { FirstRunWizard } from "./FirstRunWizard";
|
||||
@ -23,10 +23,12 @@ import { DETECT_TIMEOUT_MS } from "./useFirstRun";
|
||||
function renderWizard(
|
||||
profile: MockProfileGateway = new MockProfileGateway(),
|
||||
onDone = vi.fn(),
|
||||
modelServer: MockModelServerGateway = new MockModelServerGateway(),
|
||||
) {
|
||||
const gateways = { profile } as unknown as Gateways;
|
||||
const gateways = { profile, modelServer } as unknown as Gateways;
|
||||
return {
|
||||
profile,
|
||||
modelServer,
|
||||
onDone,
|
||||
...render(
|
||||
<DIProvider gateways={gateways}>
|
||||
@ -277,6 +279,182 @@ describe("FirstRunWizard — OpenCode + llama.cpp local profile", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("FirstRunWizard — several local OpenCode profiles (F36)", () => {
|
||||
const OPENCODE = "OpenCode + llama.cpp";
|
||||
const CLONE1 = `${OPENCODE} (copy 1)`;
|
||||
|
||||
it('"Add OpenCode profile" clones the seed into a new, editable, pre-selected row', async () => {
|
||||
renderWizard();
|
||||
await waitForLoaded();
|
||||
|
||||
// Only one OpenCode row to begin with (the seed reference).
|
||||
expect(screen.getAllByText(OPENCODE).length).toBe(1);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Add OpenCode profile" }),
|
||||
);
|
||||
|
||||
// A second OpenCode row appears, pre-filled from the seed and pre-selected.
|
||||
const added = await screen.findByLabelText(`use ${CLONE1}`);
|
||||
expect((added as HTMLInputElement).checked).toBe(true);
|
||||
expect(
|
||||
(screen.getByLabelText(`${CLONE1} base url`) as HTMLInputElement).value,
|
||||
).toBe("http://localhost:8080/v1");
|
||||
// Its name is editable per profile (identity is the id, not the name).
|
||||
expect(screen.getByLabelText(`${CLONE1} name`)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("persists two distinct OpenCode profiles when both are selected", async () => {
|
||||
const { profile } = renderWizard();
|
||||
await waitForLoaded();
|
||||
|
||||
// Select the seed row and edit it.
|
||||
fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`));
|
||||
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
|
||||
target: { value: "qwen3-coder-14b" },
|
||||
});
|
||||
|
||||
// Add a second one and give it a different endpoint.
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Add OpenCode profile" }),
|
||||
);
|
||||
await screen.findByLabelText(`use ${CLONE1}`);
|
||||
fireEvent.change(screen.getByLabelText(`${CLONE1} base url`), {
|
||||
target: { value: "http://localhost:9191/v1" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const saved = await profile.listProfiles();
|
||||
const opencode = saved.filter((p) => p.structuredAdapter === "openCode");
|
||||
expect(opencode.length).toBe(2);
|
||||
// Distinct ids (identity) and distinct endpoints.
|
||||
expect(new Set(opencode.map((p) => p.id)).size).toBe(2);
|
||||
expect(opencode.map((p) => p.opencode?.baseURL).sort()).toEqual([
|
||||
"http://localhost:8080/v1",
|
||||
"http://localhost:9191/v1",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("Duplicate clones a row carrying its current config over", async () => {
|
||||
const { profile } = renderWizard();
|
||||
await waitForLoaded();
|
||||
|
||||
// Edit the seed row, then duplicate it.
|
||||
fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`));
|
||||
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
|
||||
target: { value: "qwen3-coder-7b" },
|
||||
});
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: `duplicate ${OPENCODE}` }),
|
||||
);
|
||||
|
||||
const dupName = `${OPENCODE} (copy)`;
|
||||
await screen.findByLabelText(`use ${dupName}`);
|
||||
// The duplicate inherits the edited model.
|
||||
expect(
|
||||
(screen.getByLabelText(`${dupName} model`) as HTMLInputElement).value,
|
||||
).toBe("qwen3-coder-7b");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||
await waitFor(async () => {
|
||||
const saved = await profile.listProfiles();
|
||||
const models = saved
|
||||
.filter((p) => p.structuredAdapter === "openCode")
|
||||
.map((p) => p.opencode?.model);
|
||||
expect(models).toEqual(["qwen3-coder-7b", "qwen3-coder-7b"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("editing the name round-trips on save", async () => {
|
||||
const { profile } = renderWizard();
|
||||
await waitForLoaded();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Add OpenCode profile" }),
|
||||
);
|
||||
await screen.findByLabelText(`use ${CLONE1}`);
|
||||
fireEvent.change(screen.getByLabelText(`${CLONE1} name`), {
|
||||
target: { value: "Fast local model" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||
await waitFor(async () => {
|
||||
const saved = await profile.listProfiles();
|
||||
const oc = saved.find((p) => p.structuredAdapter === "openCode");
|
||||
expect(oc?.name).toBe("Fast local model");
|
||||
});
|
||||
});
|
||||
|
||||
it("reasoning/attachment round-trip on save (F36)", async () => {
|
||||
const { profile } = renderWizard();
|
||||
await waitForLoaded();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Add OpenCode profile" }),
|
||||
);
|
||||
await screen.findByLabelText(`use ${CLONE1}`);
|
||||
|
||||
// Reasoning defaults on (backend effective default true); turn it off.
|
||||
const reasoning = screen.getByLabelText(
|
||||
`${CLONE1} reasoning`,
|
||||
) as HTMLInputElement;
|
||||
expect(reasoning.checked).toBe(true);
|
||||
fireEvent.click(reasoning);
|
||||
// Attachments default off; turn them on.
|
||||
fireEvent.click(screen.getByLabelText(`${CLONE1} attachment`));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||
await waitFor(async () => {
|
||||
const saved = await profile.listProfiles();
|
||||
const oc = saved.find((p) => p.structuredAdapter === "openCode");
|
||||
expect(oc?.opencode?.reasoning).toBe(false);
|
||||
expect(oc?.opencode?.attachment).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("binds localModelServerId via the server dropdown (F35.2), replacing free text", async () => {
|
||||
// Seed a declared server so the dropdown offers it.
|
||||
const modelServer = new MockModelServerGateway();
|
||||
const server: LocalModelServerConfig = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
kind: "llamaCpp",
|
||||
name: "Local A",
|
||||
baseURL: "http://localhost:8080/v1",
|
||||
port: 8080,
|
||||
servedModelName: "qwen3-coder-30b",
|
||||
args: [],
|
||||
autoStart: false,
|
||||
stopPolicy: "stopOnAppExit",
|
||||
};
|
||||
await modelServer.saveModelServer(server);
|
||||
|
||||
const { profile } = renderWizard(new MockProfileGateway(), vi.fn(), modelServer);
|
||||
await waitForLoaded();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Add OpenCode profile" }),
|
||||
);
|
||||
await screen.findByLabelText(`use ${CLONE1}`);
|
||||
|
||||
// The old free-text field is gone; a dropdown replaces it.
|
||||
expect(
|
||||
screen.queryByLabelText(`${CLONE1} local model server id`),
|
||||
).toBeNull();
|
||||
const select = await screen.findByLabelText(`${CLONE1} local model server`);
|
||||
fireEvent.change(select, { target: { value: server.id } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||
await waitFor(async () => {
|
||||
const saved = await profile.listProfiles();
|
||||
const oc = saved.find((p) => p.structuredAdapter === "openCode");
|
||||
expect(oc?.opencode?.localModelServerId).toBe(server.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Ticket #28 — the wizard must survive a detection that never answers. When the
|
||||
// backend `detect_profiles` command panics, the `invoke` promise is neither
|
||||
// resolved nor rejected: nothing after `await detectProfiles(...)` ever runs.
|
||||
|
||||
@ -15,8 +15,18 @@
|
||||
* `./profile`.
|
||||
*/
|
||||
|
||||
import type { AgentProfile, HttpChatConfig, OpenCodeConfig } from "@/domain";
|
||||
import type {
|
||||
AgentProfile,
|
||||
HttpChatConfig,
|
||||
LocalModelServerConfig,
|
||||
OpenCodeConfig,
|
||||
} from "@/domain";
|
||||
import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared";
|
||||
import {
|
||||
ModelServersPanel,
|
||||
ModelServerSelect,
|
||||
useModelServers,
|
||||
} from "@/features/model-servers";
|
||||
import { useFirstRun, type WizardEntry } from "./useFirstRun";
|
||||
import {
|
||||
defaultHttpChatConfig,
|
||||
@ -48,6 +58,7 @@ export function FirstRunWizard({
|
||||
forceOpen?: boolean;
|
||||
}) {
|
||||
const vm = useFirstRun();
|
||||
const modelServers = useModelServers();
|
||||
|
||||
if (vm.isFirstRun === null) return null;
|
||||
if (!forceOpen && vm.isFirstRun === false) return null;
|
||||
@ -93,16 +104,40 @@ export function FirstRunWizard({
|
||||
Detecting…
|
||||
</span>
|
||||
)}
|
||||
{/* F36: declare several local OpenCode profiles. Each click clones the
|
||||
canonical `opencode-llamacpp` seed into a new, editable row. */}
|
||||
<Button
|
||||
onClick={() => void vm.addOpenCodeProfile()}
|
||||
disabled={vm.busy}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
Add OpenCode profile
|
||||
</Button>
|
||||
</Toolbar>
|
||||
|
||||
{/* F35.2 — declare/edit/delete the local llama.cpp servers an OpenCode
|
||||
profile can bind to. Sits above the profile list so a server exists
|
||||
before it is picked in the OpenCode dropdown. */}
|
||||
<ModelServersPanel vm={modelServers} />
|
||||
|
||||
<ul className="flex list-none flex-col gap-3 p-0">
|
||||
{vm.entries.map((entry) => (
|
||||
<ProfileRow
|
||||
key={entry.profile.id}
|
||||
entry={entry}
|
||||
servers={modelServers.servers}
|
||||
onToggle={() => vm.toggle(entry.profile.id)}
|
||||
onChange={(p) => vm.updateProfile(entry.profile.id, p)}
|
||||
onRemove={() => vm.remove(entry.profile.id)}
|
||||
onDuplicate={
|
||||
entry.profile.structuredAdapter === "openCode"
|
||||
? () =>
|
||||
vm.addOpenCodeProfile({
|
||||
name: `${entry.profile.name} (copy)`,
|
||||
opencode: entry.profile.opencode,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
@ -120,17 +155,24 @@ export function FirstRunWizard({
|
||||
/** One editable candidate row: select, edit command/args, see availability. */
|
||||
function ProfileRow({
|
||||
entry,
|
||||
servers,
|
||||
onToggle,
|
||||
onChange,
|
||||
onRemove,
|
||||
onDuplicate,
|
||||
}: {
|
||||
entry: WizardEntry;
|
||||
/** Declared local model servers (F35.2), for the OpenCode binding dropdown. */
|
||||
servers: LocalModelServerConfig[];
|
||||
onToggle: () => void;
|
||||
onChange: (p: AgentProfile) => void;
|
||||
onRemove: () => void;
|
||||
/** Present only for OpenCode rows: clone this row into a new profile (F36). */
|
||||
onDuplicate?: () => void;
|
||||
}) {
|
||||
const { profile, selected, available } = entry;
|
||||
const errors = validateProfile(profile);
|
||||
const isOpenCode = profile.structuredAdapter === "openCode";
|
||||
|
||||
return (
|
||||
<li className="flex flex-col gap-2 rounded-md border border-border bg-raised p-3">
|
||||
@ -158,16 +200,41 @@ function ProfileRow({
|
||||
>
|
||||
{available === null ? "—" : available ? "✓ installed" : "✗ not found"}
|
||||
</span>
|
||||
<IconButton
|
||||
size="sm"
|
||||
aria-label={`remove ${profile.name}`}
|
||||
onClick={onRemove}
|
||||
className="ml-auto"
|
||||
>
|
||||
×
|
||||
</IconButton>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{onDuplicate && (
|
||||
<Button
|
||||
size="sm"
|
||||
aria-label={`duplicate ${profile.name}`}
|
||||
onClick={onDuplicate}
|
||||
>
|
||||
Duplicate
|
||||
</Button>
|
||||
)}
|
||||
<IconButton
|
||||
size="sm"
|
||||
aria-label={`remove ${profile.name}`}
|
||||
onClick={onRemove}
|
||||
>
|
||||
×
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* F36: an OpenCode profile's name is identity-neutral but user-facing, so
|
||||
it is editable per profile (several local models coexist). */}
|
||||
{isOpenCode && (
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Name</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} name`}
|
||||
value={profile.name}
|
||||
invalid={Boolean(errors.name)}
|
||||
onChange={(e) => onChange({ ...profile, name: e.target.value })}
|
||||
/>
|
||||
{errors.name && <small className="text-xs text-danger">{errors.name}</small>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Command</Caption>
|
||||
<Input
|
||||
@ -193,7 +260,12 @@ function ProfileRow({
|
||||
)}
|
||||
|
||||
{profile.structuredAdapter === "openCode" && (
|
||||
<OpenCodeFields profile={profile} errors={errors} onChange={onChange} />
|
||||
<OpenCodeFields
|
||||
profile={profile}
|
||||
errors={errors}
|
||||
servers={servers}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
@ -208,10 +280,13 @@ function ProfileRow({
|
||||
function OpenCodeFields({
|
||||
profile,
|
||||
errors,
|
||||
servers,
|
||||
onChange,
|
||||
}: {
|
||||
profile: AgentProfile;
|
||||
errors: ProfileErrors;
|
||||
/** Declared local model servers (F35.2), for the binding dropdown. */
|
||||
servers: LocalModelServerConfig[];
|
||||
onChange: (p: AgentProfile) => void;
|
||||
}) {
|
||||
const opencode = profile.opencode ?? defaultOpenCodeConfig();
|
||||
@ -262,6 +337,44 @@ function OpenCodeFields({
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`${profile.name} reasoning`}
|
||||
// Effective backend default is `true`; treat omitted as enabled.
|
||||
checked={opencode.reasoning ?? true}
|
||||
onChange={(e) => patch({ reasoning: e.target.checked })}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<Caption>Reasoning</Caption>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`${profile.name} attachment`}
|
||||
// Effective backend default is `false`.
|
||||
checked={opencode.attachment ?? false}
|
||||
onChange={(e) => patch({ attachment: e.target.checked })}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<Caption>Attachments</Caption>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>
|
||||
Local model server (bind to a managed llama.cpp server, or none)
|
||||
</Caption>
|
||||
<ModelServerSelect
|
||||
ariaLabel={`${profile.name} local model server`}
|
||||
servers={servers}
|
||||
value={opencode.localModelServerId}
|
||||
onChange={(serverId) => patch({ localModelServerId: serverId })}
|
||||
/>
|
||||
</label>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@ import type {
|
||||
AgentProfile,
|
||||
FirstRunState,
|
||||
GatewayError,
|
||||
OpenCodeConfig,
|
||||
ProfileAvailability,
|
||||
} from "@/domain";
|
||||
import { useGateways } from "@/app/di";
|
||||
@ -50,6 +51,15 @@ export interface FirstRunViewModel {
|
||||
toggle: (id: string) => void;
|
||||
/** Replaces a candidate's profile (edited command/args/injection). */
|
||||
updateProfile: (id: string, profile: AgentProfile) => void;
|
||||
/**
|
||||
* Mints a new OpenCode profile from the seed (F36 — several local OpenCode
|
||||
* profiles) and appends it, pre-selected, to the rows. Optionally overrides the
|
||||
* name/config (used by "Duplicate" to carry a row's endpoint over).
|
||||
*/
|
||||
addOpenCodeProfile: (input?: {
|
||||
name?: string;
|
||||
opencode?: OpenCodeConfig;
|
||||
}) => Promise<void>;
|
||||
/** Removes a candidate by id. */
|
||||
remove: (id: string) => void;
|
||||
/** Runs detection over all candidates, filling availability. */
|
||||
@ -187,6 +197,24 @@ export function useFirstRun(): FirstRunViewModel {
|
||||
);
|
||||
}, []);
|
||||
|
||||
const addOpenCodeProfile = useCallback(
|
||||
async (input?: { name?: string; opencode?: OpenCodeConfig }) => {
|
||||
setError(null);
|
||||
try {
|
||||
const created = await profile.cloneOpenCodeProfileFromSeed(input);
|
||||
// Freshly cloned profiles start selected (the user opted in by adding
|
||||
// one) and available=null (detection re-probes them on demand).
|
||||
setEntries((prev) => [
|
||||
...prev,
|
||||
{ profile: created, selected: true, available: null },
|
||||
]);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
}
|
||||
},
|
||||
[profile],
|
||||
);
|
||||
|
||||
const remove = useCallback((id: string) => {
|
||||
setEntries((prev) => prev.filter((e) => e.profile.id !== id));
|
||||
}, []);
|
||||
@ -221,6 +249,7 @@ export function useFirstRun(): FirstRunViewModel {
|
||||
detecting,
|
||||
toggle,
|
||||
updateProfile,
|
||||
addOpenCodeProfile,
|
||||
remove,
|
||||
detect,
|
||||
finish,
|
||||
|
||||
57
frontend/src/features/model-servers/ModelServerSelect.tsx
Normal file
57
frontend/src/features/model-servers/ModelServerSelect.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
/**
|
||||
* `ModelServerSelect` (F35.2) — a dropdown to bind an OpenCode profile to one of
|
||||
* the declared local model servers, or to none ("external endpoint"). Replaces
|
||||
* the free-text `localModelServerId` input posed in F36. Presentational: the
|
||||
* server list and the current value are passed in.
|
||||
*/
|
||||
|
||||
import type { LocalModelServerConfig } from "@/domain";
|
||||
import { cn } from "@/shared";
|
||||
|
||||
/** The sentinel option value standing for "no managed server". */
|
||||
const NONE = "";
|
||||
|
||||
export interface ModelServerSelectProps {
|
||||
/** The declared servers to choose from. */
|
||||
servers: LocalModelServerConfig[];
|
||||
/** The currently-bound server id, or `undefined` for none. */
|
||||
value?: string;
|
||||
/** Called with the new server id, or `undefined` when "none" is chosen. */
|
||||
onChange: (serverId: string | undefined) => void;
|
||||
/** Accessible label (defaults to a generic one). */
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export function ModelServerSelect({
|
||||
servers,
|
||||
value,
|
||||
onChange,
|
||||
ariaLabel = "local model server",
|
||||
}: ModelServerSelectProps) {
|
||||
// A previously-bound server that no longer exists (deleted): keep it visible
|
||||
// so the binding isn't silently dropped from the UI.
|
||||
const missing =
|
||||
value !== undefined && !servers.some((s) => s.id === value);
|
||||
|
||||
return (
|
||||
<select
|
||||
aria-label={ariaLabel}
|
||||
value={value ?? NONE}
|
||||
onChange={(e) => onChange(e.target.value === NONE ? undefined : e.target.value)}
|
||||
className={cn(
|
||||
"h-8 rounded-md bg-raised px-2 text-xs text-content",
|
||||
"border border-border outline-none focus:border-primary",
|
||||
)}
|
||||
>
|
||||
<option value={NONE}>None (external endpoint)</option>
|
||||
{servers.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name} — {s.servedModelName}
|
||||
</option>
|
||||
))}
|
||||
{missing && (
|
||||
<option value={value}>{value} (unknown / removed)</option>
|
||||
)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
323
frontend/src/features/model-servers/ModelServersPanel.tsx
Normal file
323
frontend/src/features/model-servers/ModelServersPanel.tsx
Normal file
@ -0,0 +1,323 @@
|
||||
/**
|
||||
* `ModelServersPanel` (F35.2) — declare / edit / delete the local `llama.cpp`
|
||||
* servers an OpenCode profile can bind to. Presentational: all I/O state comes
|
||||
* from a {@link ModelServersViewModel} passed in (`useModelServers`), so the
|
||||
* panel renders and is testable in isolation.
|
||||
*
|
||||
* A single editable draft is shown at a time (add or edit); the list underneath
|
||||
* offers Edit / Delete per server. The `model_server_in_use` rejection surfaces
|
||||
* as the vm's error banner (a referenced server can't be removed).
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import type { LocalModelServerConfig, StopPolicy } from "@/domain";
|
||||
import { Button, IconButton, Input, Panel, cn } from "@/shared";
|
||||
import type { ModelServersViewModel } from "./useModelServers";
|
||||
import {
|
||||
emptyModelServer,
|
||||
parseArgs,
|
||||
validateModelServer,
|
||||
type ModelServerErrors,
|
||||
} from "./modelServer";
|
||||
|
||||
/** Small caption above a control. */
|
||||
function Caption({ children }: { children: React.ReactNode }) {
|
||||
return <span className="text-xs font-medium text-muted">{children}</span>;
|
||||
}
|
||||
|
||||
const STOP_POLICIES: { value: StopPolicy; label: string }[] = [
|
||||
{ value: "stopOnAppExit", label: "Stop on app exit" },
|
||||
{ value: "keepAlive", label: "Keep alive" },
|
||||
{ value: "stopWhenUnused", label: "Stop when unused" },
|
||||
];
|
||||
|
||||
export interface ModelServersPanelProps {
|
||||
/** The model-server registry view-model (from `useModelServers`). */
|
||||
vm: ModelServersViewModel;
|
||||
}
|
||||
|
||||
export function ModelServersPanel({ vm }: ModelServersPanelProps) {
|
||||
// The server being edited, or a fresh draft when "Add" is clicked. `null` ⇒
|
||||
// no editor open (list-only).
|
||||
const [draft, setDraft] = useState<LocalModelServerConfig | null>(null);
|
||||
|
||||
function startAdd() {
|
||||
vm.clearError();
|
||||
setDraft(emptyModelServer());
|
||||
}
|
||||
|
||||
function startEdit(server: LocalModelServerConfig) {
|
||||
vm.clearError();
|
||||
setDraft({ ...server });
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!draft) return;
|
||||
const saved = await vm.save(draft);
|
||||
if (saved) setDraft(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel
|
||||
aria-label="local model servers"
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-content">
|
||||
Local model servers
|
||||
</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
aria-label="add model server"
|
||||
onClick={startAdd}
|
||||
className="ml-auto"
|
||||
>
|
||||
Add server
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{vm.error && (
|
||||
<p role="alert" className="text-sm text-danger">
|
||||
{vm.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{vm.servers.length === 0 && !draft && (
|
||||
<p className="text-xs text-muted">
|
||||
No local server declared yet. Add one to auto-manage a `llama-server`,
|
||||
or keep using an external endpoint.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul className="flex list-none flex-col gap-2 p-0">
|
||||
{vm.servers.map((server) => (
|
||||
<li
|
||||
key={server.id}
|
||||
className="flex items-center gap-2 rounded-md border border-border bg-raised p-2"
|
||||
>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<strong className="truncate text-sm text-content">
|
||||
{server.name}
|
||||
</strong>
|
||||
<span className="truncate text-xs text-muted">
|
||||
{server.baseURL} · {server.servedModelName}
|
||||
{server.autoStart ? " · auto-start" : ""}
|
||||
</span>
|
||||
</span>
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
aria-label={`edit ${server.name}`}
|
||||
onClick={() => startEdit(server)}
|
||||
disabled={vm.busy}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<IconButton
|
||||
size="sm"
|
||||
aria-label={`delete ${server.name}`}
|
||||
onClick={() => void vm.remove(server.id)}
|
||||
disabled={vm.busy}
|
||||
>
|
||||
×
|
||||
</IconButton>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{draft && (
|
||||
<ServerEditor
|
||||
draft={draft}
|
||||
busy={vm.busy}
|
||||
onChange={setDraft}
|
||||
onCancel={() => {
|
||||
vm.clearError();
|
||||
setDraft(null);
|
||||
}}
|
||||
onSubmit={() => void submit()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
/** The add/edit form for one server draft. */
|
||||
function ServerEditor({
|
||||
draft,
|
||||
busy,
|
||||
onChange,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: {
|
||||
draft: LocalModelServerConfig;
|
||||
busy: boolean;
|
||||
onChange: (next: LocalModelServerConfig) => void;
|
||||
onCancel: () => void;
|
||||
onSubmit: () => void;
|
||||
}) {
|
||||
const errors: ModelServerErrors = validateModelServer(draft);
|
||||
const valid = Object.keys(errors).length === 0;
|
||||
const patch = (next: Partial<LocalModelServerConfig>) =>
|
||||
onChange({ ...draft, ...next });
|
||||
|
||||
return (
|
||||
<fieldset
|
||||
aria-label="model server editor"
|
||||
className="flex flex-col gap-2 rounded-md border border-primary/40 p-3"
|
||||
>
|
||||
<legend className="px-1 text-xs font-medium text-muted">
|
||||
{draft.name.trim().length > 0 ? draft.name : "New server"}
|
||||
</legend>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Name</Caption>
|
||||
<Input
|
||||
aria-label="server name"
|
||||
value={draft.name}
|
||||
invalid={Boolean(errors.name)}
|
||||
onChange={(e) => patch({ name: e.target.value })}
|
||||
/>
|
||||
{errors.name && <small className="text-xs text-danger">{errors.name}</small>}
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<label className="flex min-w-[12rem] flex-1 flex-col gap-1">
|
||||
<Caption>Base URL</Caption>
|
||||
<Input
|
||||
aria-label="server base url"
|
||||
value={draft.baseURL}
|
||||
placeholder="http://localhost:8080/v1"
|
||||
invalid={Boolean(errors.baseURL)}
|
||||
onChange={(e) => patch({ baseURL: e.target.value })}
|
||||
/>
|
||||
{errors.baseURL && (
|
||||
<small className="text-xs text-danger">{errors.baseURL}</small>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label className="flex min-w-[6rem] flex-col gap-1">
|
||||
<Caption>Port</Caption>
|
||||
<Input
|
||||
aria-label="server port"
|
||||
value={Number.isNaN(draft.port) ? "" : draft.port.toString()}
|
||||
inputMode="numeric"
|
||||
invalid={Boolean(errors.port)}
|
||||
onChange={(e) => patch({ port: Number.parseInt(e.target.value, 10) })}
|
||||
/>
|
||||
{errors.port && <small className="text-xs text-danger">{errors.port}</small>}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Served model name (sent to OpenCode as `model`)</Caption>
|
||||
<Input
|
||||
aria-label="server served model name"
|
||||
value={draft.servedModelName}
|
||||
placeholder="qwen3-coder-30b"
|
||||
invalid={Boolean(errors.servedModelName)}
|
||||
onChange={(e) => patch({ servedModelName: e.target.value })}
|
||||
/>
|
||||
{errors.servedModelName && (
|
||||
<small className="text-xs text-danger">{errors.servedModelName}</small>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Model path (absolute `.gguf` — required for auto-start)</Caption>
|
||||
<Input
|
||||
aria-label="server model path"
|
||||
value={draft.modelPath ?? ""}
|
||||
placeholder="/models/qwen3-coder-30b.gguf"
|
||||
invalid={Boolean(errors.modelPath)}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
patch({ modelPath: v.length === 0 ? undefined : v });
|
||||
}}
|
||||
/>
|
||||
{errors.modelPath && (
|
||||
<small className="text-xs text-danger">{errors.modelPath}</small>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Binary path (optional — `llama-server` command)</Caption>
|
||||
<Input
|
||||
aria-label="server binary path"
|
||||
value={draft.binaryPath ?? ""}
|
||||
placeholder="llama-server"
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
patch({ binaryPath: v.length === 0 ? undefined : v });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Extra arguments</Caption>
|
||||
<Input
|
||||
aria-label="server args"
|
||||
value={draft.args.join(" ")}
|
||||
placeholder="--ctx-size 8192 --n-gpu-layers 99"
|
||||
onChange={(e) => patch({ args: parseArgs(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="server auto start"
|
||||
checked={draft.autoStart}
|
||||
onChange={(e) => patch({ autoStart: e.target.checked })}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<Caption>Auto-start</Caption>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<Caption>Stop policy</Caption>
|
||||
<select
|
||||
aria-label="server stop policy"
|
||||
value={draft.stopPolicy}
|
||||
onChange={(e) => patch({ stopPolicy: e.target.value as StopPolicy })}
|
||||
className={cn(
|
||||
"h-8 rounded-md bg-raised px-2 text-xs text-content",
|
||||
"border border-border outline-none focus:border-primary",
|
||||
)}
|
||||
>
|
||||
{STOP_POLICIES.map((p) => (
|
||||
<option key={p.value} value={p.value}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
aria-label="save model server"
|
||||
onClick={onSubmit}
|
||||
disabled={busy || !valid}
|
||||
>
|
||||
Save server
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="cancel model server"
|
||||
onClick={onCancel}
|
||||
disabled={busy}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
19
frontend/src/features/model-servers/index.ts
Normal file
19
frontend/src/features/model-servers/index.ts
Normal file
@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Local model servers feature — public surface (F35.2).
|
||||
*/
|
||||
|
||||
export { useModelServers } from "./useModelServers";
|
||||
export type { ModelServersViewModel } from "./useModelServers";
|
||||
export { ModelServersPanel } from "./ModelServersPanel";
|
||||
export type { ModelServersPanelProps } from "./ModelServersPanel";
|
||||
export { ModelServerSelect } from "./ModelServerSelect";
|
||||
export type { ModelServerSelectProps } from "./ModelServerSelect";
|
||||
export {
|
||||
emptyModelServer,
|
||||
newModelServerId,
|
||||
validateModelServer,
|
||||
isModelServerValid,
|
||||
isAbsolutePath,
|
||||
parseArgs,
|
||||
type ModelServerErrors,
|
||||
} from "./modelServer";
|
||||
97
frontend/src/features/model-servers/modelServer.ts
Normal file
97
frontend/src/features/model-servers/modelServer.ts
Normal file
@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Pure, framework-free helpers for the local model server feature (F35). Draft
|
||||
* validation mirrors the backend invariants (`ModelServerEndpoint::new`,
|
||||
* `LocalModelRef::new`, `LocalModelServerConfig::new`) so the form can surface
|
||||
* errors before any `invoke`. No React, no Tauri here.
|
||||
*/
|
||||
|
||||
import type { LocalModelServerConfig } from "@/domain";
|
||||
|
||||
/** A field-keyed validation error map (empty ⇒ valid). */
|
||||
export type ModelServerErrors = Partial<
|
||||
Record<
|
||||
"name" | "baseURL" | "port" | "servedModelName" | "modelPath",
|
||||
string
|
||||
>
|
||||
>;
|
||||
|
||||
/** Whether a string is a valid `http://`/`https://` URL (mirror of backend). */
|
||||
function isValidHttpUrl(v: string): boolean {
|
||||
if (v.trim().length === 0) return false;
|
||||
return v.startsWith("http://") || v.startsWith("https://");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a path looks absolute (mirror of the backend `is_absolute_path`:
|
||||
* POSIX `/…` or a Windows drive/UNC). Blank is handled by the caller.
|
||||
*/
|
||||
export function isAbsolutePath(path: string): boolean {
|
||||
if (path.startsWith("/") || path.startsWith("\\")) return true;
|
||||
// Windows drive `C:/` or `C:\`.
|
||||
return /^[a-zA-Z]:[/\\]/.test(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a {@link LocalModelServerConfig} draft the way the backend would.
|
||||
* Returns an empty object when the draft is valid.
|
||||
*/
|
||||
export function validateModelServer(c: LocalModelServerConfig): ModelServerErrors {
|
||||
const errors: ModelServerErrors = {};
|
||||
if (c.name.trim().length === 0) errors.name = "Name is required.";
|
||||
if (!isValidHttpUrl(c.baseURL)) {
|
||||
errors.baseURL = "Base URL must start with http:// or https://.";
|
||||
}
|
||||
if (!Number.isInteger(c.port) || c.port < 1 || c.port > 65_535) {
|
||||
errors.port = "Port must be an integer between 1 and 65535.";
|
||||
}
|
||||
if (c.servedModelName.trim().length === 0) {
|
||||
errors.servedModelName = "Served model name is required.";
|
||||
}
|
||||
const modelPath = c.modelPath?.trim() ?? "";
|
||||
if (modelPath.length > 0 && !isAbsolutePath(modelPath)) {
|
||||
errors.modelPath = "Model path must be absolute.";
|
||||
}
|
||||
// The backend rejects auto-start without an absolute model path.
|
||||
if (c.autoStart && modelPath.length === 0) {
|
||||
errors.modelPath = "Auto-start requires an absolute model path.";
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** Whether a draft is valid (no errors). */
|
||||
export function isModelServerValid(c: LocalModelServerConfig): boolean {
|
||||
return Object.keys(validateModelServer(c)).length === 0;
|
||||
}
|
||||
|
||||
/** Generates a UUID for a client-created server (crypto when available). */
|
||||
export function newModelServerId(): string {
|
||||
const c = globalThis.crypto as Crypto | undefined;
|
||||
if (c && typeof c.randomUUID === "function") return c.randomUUID();
|
||||
return `srv-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh model-server draft (id minted client-side) pre-filled with a sensible
|
||||
* local `llama-server` endpoint, so the form starts valid apart from the name.
|
||||
*/
|
||||
export function emptyModelServer(): LocalModelServerConfig {
|
||||
return {
|
||||
id: newModelServerId(),
|
||||
kind: "llamaCpp",
|
||||
name: "",
|
||||
baseURL: "http://localhost:8080/v1",
|
||||
port: 8080,
|
||||
servedModelName: "qwen3-coder-30b",
|
||||
args: [],
|
||||
autoStart: false,
|
||||
stopPolicy: "stopOnAppExit",
|
||||
};
|
||||
}
|
||||
|
||||
/** Parses a whitespace-separated args string into a trimmed, non-empty list. */
|
||||
export function parseArgs(raw: string): string[] {
|
||||
return raw
|
||||
.split(/\s+/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
}
|
||||
251
frontend/src/features/model-servers/modelServers.test.tsx
Normal file
251
frontend/src/features/model-servers/modelServers.test.tsx
Normal file
@ -0,0 +1,251 @@
|
||||
/**
|
||||
* F35.2 — local model server registry: the pure validation helpers, the
|
||||
* {@link useModelServers} hook behind the real DIProvider + mock gateway, the
|
||||
* {@link ModelServersPanel} CRUD UI (incl. the `model_server_in_use` rejection),
|
||||
* and the {@link ModelServerSelect} binding dropdown.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { act, render, renderHook, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
|
||||
import type { Gateways } from "@/ports";
|
||||
import type { LocalModelServerConfig } from "@/domain";
|
||||
import { MockModelServerGateway } from "@/adapters/mock";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { useModelServers } from "./useModelServers";
|
||||
import { ModelServersPanel } from "./ModelServersPanel";
|
||||
import { ModelServerSelect } from "./ModelServerSelect";
|
||||
import {
|
||||
emptyModelServer,
|
||||
isAbsolutePath,
|
||||
validateModelServer,
|
||||
} from "./modelServer";
|
||||
|
||||
const SERVER: LocalModelServerConfig = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
kind: "llamaCpp",
|
||||
name: "Local A",
|
||||
baseURL: "http://localhost:8080/v1",
|
||||
port: 8080,
|
||||
servedModelName: "qwen3-coder-30b",
|
||||
args: [],
|
||||
autoStart: false,
|
||||
stopPolicy: "stopOnAppExit",
|
||||
};
|
||||
|
||||
function setup(modelServer = new MockModelServerGateway()) {
|
||||
const gateways = { modelServer } as unknown as Gateways;
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<DIProvider gateways={gateways}>{children}</DIProvider>
|
||||
);
|
||||
const view = renderHook(() => useModelServers(), { wrapper });
|
||||
return { modelServer, view };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("modelServer helpers (F35.2)", () => {
|
||||
it("emptyModelServer starts valid apart from the name", () => {
|
||||
const draft = emptyModelServer();
|
||||
expect(validateModelServer(draft)).toEqual({ name: "Name is required." });
|
||||
});
|
||||
|
||||
it("flags a non-http base URL and an out-of-range port", () => {
|
||||
const errors = validateModelServer({
|
||||
...SERVER,
|
||||
baseURL: "ftp://oops",
|
||||
port: 70_000,
|
||||
});
|
||||
expect(errors.baseURL).toBeTruthy();
|
||||
expect(errors.port).toBeTruthy();
|
||||
});
|
||||
|
||||
it("requires an absolute model path, and requires it when auto-start is on", () => {
|
||||
expect(validateModelServer({ ...SERVER, modelPath: "relative/x.gguf" }).modelPath).toBeTruthy();
|
||||
expect(validateModelServer({ ...SERVER, autoStart: true }).modelPath).toMatch(
|
||||
/auto-start/i,
|
||||
);
|
||||
expect(
|
||||
validateModelServer({ ...SERVER, autoStart: true, modelPath: "/m/x.gguf" }).modelPath,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("recognises absolute POSIX and Windows paths", () => {
|
||||
expect(isAbsolutePath("/models/x.gguf")).toBe(true);
|
||||
expect(isAbsolutePath("C:/models/x.gguf")).toBe(true);
|
||||
expect(isAbsolutePath("models/x.gguf")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("useModelServers (F35.2)", () => {
|
||||
it("saves and lists servers", async () => {
|
||||
const { view } = setup();
|
||||
await act(async () => {
|
||||
await view.result.current.save(SERVER);
|
||||
});
|
||||
expect(view.result.current.servers).toHaveLength(1);
|
||||
expect(view.result.current.servers[0].name).toBe("Local A");
|
||||
});
|
||||
|
||||
it("removes a server", async () => {
|
||||
const { view } = setup();
|
||||
await act(async () => {
|
||||
await view.result.current.save(SERVER);
|
||||
});
|
||||
await act(async () => {
|
||||
const ok = await view.result.current.remove(SERVER.id);
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
expect(view.result.current.servers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("surfaces model_server_in_use as an actionable error and keeps the server", async () => {
|
||||
const modelServer = new MockModelServerGateway();
|
||||
const { view } = setup(modelServer);
|
||||
await act(async () => {
|
||||
await view.result.current.save(SERVER);
|
||||
});
|
||||
modelServer.markInUse(SERVER.id);
|
||||
await act(async () => {
|
||||
const ok = await view.result.current.remove(SERVER.id);
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
expect(view.result.current.error).toMatch(/encore utilisé par un profil/i);
|
||||
expect(view.result.current.servers).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderPanel(modelServer = new MockModelServerGateway()) {
|
||||
const gateways = { modelServer } as unknown as Gateways;
|
||||
function Harness() {
|
||||
const vm = useModelServers();
|
||||
return <ModelServersPanel vm={vm} />;
|
||||
}
|
||||
return {
|
||||
modelServer,
|
||||
...render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<Harness />
|
||||
</DIProvider>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe("ModelServersPanel (F35.2)", () => {
|
||||
it("declares a new server end-to-end", async () => {
|
||||
const { modelServer } = renderPanel();
|
||||
fireEvent.click(screen.getByLabelText("add model server"));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("server name"), {
|
||||
target: { value: "Local A" },
|
||||
});
|
||||
// A valid draft (pre-filled endpoint + servedModelName) enables Save once
|
||||
// the initial passive load has settled.
|
||||
const save = screen.getByLabelText("save model server") as HTMLButtonElement;
|
||||
await waitFor(() => expect(save.disabled).toBe(false));
|
||||
fireEvent.click(save);
|
||||
|
||||
await waitFor(async () => {
|
||||
const saved = await modelServer.listModelServers();
|
||||
expect(saved.map((s) => s.name)).toEqual(["Local A"]);
|
||||
});
|
||||
// The editor closed and the server shows in the list.
|
||||
expect(screen.getByLabelText("edit Local A")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("blocks Save while the draft is invalid (blank name)", () => {
|
||||
renderPanel();
|
||||
fireEvent.click(screen.getByLabelText("add model server"));
|
||||
// Invalid draft (blank name) keeps Save disabled regardless of busy.
|
||||
expect(
|
||||
(screen.getByLabelText("save model server") as HTMLButtonElement).disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("shows an actionable banner when deleting a referenced server", async () => {
|
||||
const modelServer = new MockModelServerGateway();
|
||||
await modelServer.saveModelServer(SERVER);
|
||||
modelServer.markInUse(SERVER.id);
|
||||
renderPanel(modelServer);
|
||||
|
||||
await screen.findByLabelText("edit Local A");
|
||||
fireEvent.click(screen.getByLabelText("delete Local A"));
|
||||
|
||||
expect((await screen.findByRole("alert")).textContent).toMatch(
|
||||
/encore utilisé par un profil/i,
|
||||
);
|
||||
// The server is still listed (delete was refused).
|
||||
expect(screen.getByLabelText("edit Local A")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("edits an existing server's served model name", async () => {
|
||||
const modelServer = new MockModelServerGateway();
|
||||
await modelServer.saveModelServer(SERVER);
|
||||
renderPanel(modelServer);
|
||||
|
||||
fireEvent.click(await screen.findByLabelText("edit Local A"));
|
||||
fireEvent.change(screen.getByLabelText("server served model name"), {
|
||||
target: { value: "qwen3-coder-7b" },
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("save model server"));
|
||||
|
||||
await waitFor(async () => {
|
||||
const saved = await modelServer.listModelServers();
|
||||
expect(saved[0].servedModelName).toBe("qwen3-coder-7b");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Select
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ModelServerSelect (F35.2)", () => {
|
||||
it("offers a 'none' option plus each declared server, and emits the id", () => {
|
||||
let picked: string | undefined = "sentinel";
|
||||
render(
|
||||
<ModelServerSelect
|
||||
servers={[SERVER]}
|
||||
value={undefined}
|
||||
onChange={(id) => (picked = id)}
|
||||
/>,
|
||||
);
|
||||
const select = screen.getByLabelText("local model server") as HTMLSelectElement;
|
||||
// "None" is selected when value is undefined.
|
||||
expect(select.value).toBe("");
|
||||
fireEvent.change(select, { target: { value: SERVER.id } });
|
||||
expect(picked).toBe(SERVER.id);
|
||||
});
|
||||
|
||||
it("emits undefined when 'none' is chosen", () => {
|
||||
let picked: string | undefined = SERVER.id;
|
||||
render(
|
||||
<ModelServerSelect
|
||||
servers={[SERVER]}
|
||||
value={SERVER.id}
|
||||
onChange={(id) => (picked = id)}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("local model server"), {
|
||||
target: { value: "" },
|
||||
});
|
||||
expect(picked).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps a removed/unknown bound id visible rather than dropping it", () => {
|
||||
render(
|
||||
<ModelServerSelect servers={[]} value="ghost-id" onChange={() => {}} />,
|
||||
);
|
||||
expect(screen.getByText(/ghost-id \(unknown \/ removed\)/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
126
frontend/src/features/model-servers/useModelServers.ts
Normal file
126
frontend/src/features/model-servers/useModelServers.ts
Normal file
@ -0,0 +1,126 @@
|
||||
/**
|
||||
* `useModelServers` — view-model for the local model server registry (F35.2).
|
||||
* Owns the declared-servers list and the CRUD actions, consuming the
|
||||
* {@link ModelServerGateway} exclusively (never `invoke()`), so the UI is
|
||||
* testable with a mock.
|
||||
*
|
||||
* Delete surfaces the backend `model_server_in_use` rejection as a dedicated,
|
||||
* human-readable message (a server still referenced by a profile cannot be
|
||||
* removed).
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { GatewayError, LocalModelServerConfig } from "@/domain";
|
||||
import { useGateways } from "@/app/di";
|
||||
|
||||
/** What the model-server UI needs from this hook. */
|
||||
export interface ModelServersViewModel {
|
||||
/** The declared local model servers. */
|
||||
servers: LocalModelServerConfig[];
|
||||
/** Last error message, or `null`. */
|
||||
error: string | null;
|
||||
/** Whether a request is in flight. */
|
||||
busy: boolean;
|
||||
/** Reloads the server list. */
|
||||
reload: () => Promise<void>;
|
||||
/** Creates or updates a server; returns the persisted config (or `null` on error). */
|
||||
save: (config: LocalModelServerConfig) => Promise<LocalModelServerConfig | null>;
|
||||
/** Deletes a server by id; returns `true` on success. */
|
||||
remove: (serverId: string) => Promise<boolean>;
|
||||
/** Clears the current error banner. */
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
function describe(e: unknown): string {
|
||||
if (e && typeof e === "object" && "message" in e) {
|
||||
return String((e as GatewayError).message);
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
|
||||
function codeOf(e: unknown): string | undefined {
|
||||
if (e && typeof e === "object" && "code" in e) {
|
||||
return String((e as GatewayError).code);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function useModelServers(): ModelServersViewModel {
|
||||
const { modelServer } = useGateways();
|
||||
const [servers, setServers] = useState<LocalModelServerConfig[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setServers(await modelServer.listModelServers());
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [modelServer]);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
const save = useCallback(
|
||||
async (config: LocalModelServerConfig) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const saved = await modelServer.saveModelServer(config);
|
||||
setServers((prev) => {
|
||||
const i = prev.findIndex((s) => s.id === saved.id);
|
||||
if (i >= 0) {
|
||||
const next = prev.slice();
|
||||
next[i] = saved;
|
||||
return next;
|
||||
}
|
||||
return [...prev, saved];
|
||||
});
|
||||
return saved;
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
return null;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[modelServer],
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
async (serverId: string) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await modelServer.deleteModelServer(serverId);
|
||||
setServers((prev) => prev.filter((s) => s.id !== serverId));
|
||||
return true;
|
||||
} catch (e) {
|
||||
// A referenced server cannot be deleted — turn the stable backend code
|
||||
// into an actionable message rather than a raw error string.
|
||||
if (codeOf(e) === "model_server_in_use") {
|
||||
setError(
|
||||
"Ce serveur est encore utilisé par un profil : détache-le d'abord (choisis « aucun » ou un autre serveur) avant de le supprimer.",
|
||||
);
|
||||
} else {
|
||||
setError(describe(e));
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[modelServer],
|
||||
);
|
||||
|
||||
const clearError = useCallback(() => setError(null), []);
|
||||
|
||||
return { servers, error, busy, reload, save, remove, clearError };
|
||||
}
|
||||
@ -25,10 +25,12 @@ import type {
|
||||
LayoutList,
|
||||
LayoutOperation,
|
||||
LayoutTree,
|
||||
LocalModelServerConfig,
|
||||
Memory,
|
||||
MemoryIndexEntry,
|
||||
MemoryLink,
|
||||
MemoryType,
|
||||
OpenCodeConfig,
|
||||
EffectivePermissions,
|
||||
PermissionSet,
|
||||
PageDirection,
|
||||
@ -624,6 +626,45 @@ export interface ProfileGateway {
|
||||
deleteProfile(profileId: string): Promise<void>;
|
||||
/** Persists the batch of chosen profiles, closing the first run. */
|
||||
configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]>;
|
||||
/**
|
||||
* Mints a new OpenCode profile from the canonical `opencode-llamacpp` seed
|
||||
* (F36 — several local OpenCode profiles per project, identity = id). `name`
|
||||
* overrides the seed display name; `opencode` overrides the seed config (used
|
||||
* by "Duplicate" to carry an existing row's endpoint). Returns the new,
|
||||
* freshly-id'd profile — it is not persisted until the batch is saved.
|
||||
*/
|
||||
cloneOpenCodeProfileFromSeed(
|
||||
input?: CloneOpenCodeProfileFromSeedInput,
|
||||
): Promise<AgentProfile>;
|
||||
}
|
||||
|
||||
/** Input for {@link ProfileGateway.cloneOpenCodeProfileFromSeed}. */
|
||||
export interface CloneOpenCodeProfileFromSeedInput {
|
||||
/** Optional display name for the new profile. */
|
||||
name?: string;
|
||||
/** Optional OpenCode config override; when omitted, the seed config is copied. */
|
||||
opencode?: OpenCodeConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local model servers (F35). CRUD over the global registry of declared
|
||||
* `llama.cpp` servers an OpenCode profile can bind to via
|
||||
* `OpenCodeConfig.localModelServerId`. Mirrors the three backend commands
|
||||
* (`list_model_servers`, `save_model_server`, `delete_model_server`).
|
||||
*/
|
||||
export interface ModelServerGateway {
|
||||
/** Lists the declared local model servers. */
|
||||
listModelServers(): Promise<LocalModelServerConfig[]>;
|
||||
/**
|
||||
* Upserts a server config (create when `id` is a fresh client-minted UUID,
|
||||
* update otherwise); returns the persisted config.
|
||||
*/
|
||||
saveModelServer(config: LocalModelServerConfig): Promise<LocalModelServerConfig>;
|
||||
/**
|
||||
* Deletes a server by id. Rejects with a `GatewayError` whose `code` is
|
||||
* `model_server_in_use` when a profile still references it.
|
||||
*/
|
||||
deleteModelServer(serverId: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** Input for {@link EmbedderGateway.saveEmbedderProfile}. */
|
||||
@ -933,6 +974,7 @@ export interface Gateways {
|
||||
git: GitGateway;
|
||||
remote: RemoteGateway;
|
||||
profile: ProfileGateway;
|
||||
modelServer: ModelServerGateway;
|
||||
template: TemplateGateway;
|
||||
skill: SkillGateway;
|
||||
memory: MemoryGateway;
|
||||
|
||||
Reference in New Issue
Block a user