Merge feature/ticket54-model-download-progress into develop (#54)
Stretch B2/F2 : progression fine du téléchargement du modèle llamacpp, par-dessus le MVP (B1/F1) déjà intégré. Tests verts (application + infrastructure + vitest, tsc clean). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
0
.ideai/agents/qwen3-5-9b.md
Normal file
0
.ideai/agents/qwen3-5-9b.md
Normal file
0
.ideai/agents/qwen3-code-30b.md
Normal file
0
.ideai/agents/qwen3-code-30b.md
Normal file
0
.ideai/agents/testllamacpp.md
Normal file
0
.ideai/agents/testllamacpp.md
Normal file
3587
.ideai/background-tasks/97b49ac2-8376-4aa3-8ea9-bf3ac81d0023.json
Normal file
3587
.ideai/background-tasks/97b49ac2-8376-4aa3-8ea9-bf3ac81d0023.json
Normal file
File diff suppressed because one or more lines are too long
@ -52,3 +52,4 @@
|
||||
- [inter-agent-announcements-feature-and-codex-final-bug](inter-agent-announcements-feature-and-codex-final-bug.md) — Design figé des annonces inter-agent (UI live) + cause racine du bug Final Codex, validé utilisateur le 2026-07-02.
|
||||
- [inter-agent-live-context-shared-per-agent](inter-agent-live-context-shared-per-agent.md) — Décision d'architecture figée : le contexte live inter-agent est PAR AGENT (partagé), pas par paire (validée utilisateur 2026-07-02).
|
||||
- [ticket4-announcements-frontend-f1f2f3](ticket4-announcements-frontend-f1f2f3.md) — Topologie du frontend des annonces inter-agent (store borné, preview requester filtré, overlay cible piloté par le busy/idle par agent) — réintégré sur base develop.
|
||||
- [ticket54-f1-model-download-overlay-frontend](ticket54-f1-model-download-overlay-frontend.md) — Overlay plein-cellule de préparation du serveur modèle local + progression (barre/%/bytes/source), corrélation factorisée, priorité de voile.
|
||||
|
||||
15
.ideai/memory/ticket54-f1-model-download-overlay-frontend.md
Normal file
15
.ideai/memory/ticket54-f1-model-download-overlay-frontend.md
Normal file
@ -0,0 +1,15 @@
|
||||
---
|
||||
name: ticket54-f1-model-download-overlay-frontend
|
||||
description: Overlay plein-cellule de préparation du serveur modèle local + progression (barre/%/bytes/source), corrélation factorisée, priorité de voile.
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
Ticket #54 livré et vert côté frontend (F1 = overlay, F2 = progression).
|
||||
|
||||
**Contrat** : `ModelServerStatus.downloading { downloadedBytes|totalBytes|percent|source: number|string|null }` (`frontend/src/domain/index.ts`), miroir exact de `ModelServerStatusDto` (`crates/app-tauri/src/events.rs`). Event `modelServerStatusChanged` passé tel quel — pas de mapping de désérialisation.
|
||||
|
||||
**Factorisation** dans `frontend/src/features/agents/modelServerLaunch.ts` : `correlateModelServerStatus` (chaîne `agentId→profileId→opencode.localModelServerId→serverId`), `modelServerOverlayText` (titre+gating de voile), `useModelServerLaunchState(projectId)` (source autonome pour LeafView), et F2 : `formatBytes` (SI base 1000 : o/Ko/Mo/Go) + `describeModelServerDownload` → `{percent(clamp 0..100 ou null=indéterminé), bytesLabel, source}` (null hors `downloading`). AgentsPanel réutilise le helper.
|
||||
|
||||
**Overlay** dans `LeafView`/`LayoutGrid.tsx` (`data-testid=model-server-overlay`, `zIndex CELL_Z.veil`) : titre + barre `role=progressbar` (aria-valuenow seulement en déterminé, sinon barre indéterminée via keyframe `model-server-indeterminate` dans `theme.css`) + label `"X % · A Mo / B Mo"` + ligne source HF. `starting`/`probing` = titre seul, pas de barre. **Priorité : voile modèle au-dessus** des voiles write-portal + F3 (les deux gatés sur `!modelServerOverlay`). Voir [[ticket4-overlay-composition-leafview]].
|
||||
|
||||
Tests : `LayoutGrid.modelServerOverlay.test.tsx` (F1+F2, dont multi-cellules & indéterminé) + unitaire pur `modelServerLaunch.test.ts`. Non-régression `src/features/agents src/features/layout` = 202 verts ; `npx tsc --noEmit` clean.
|
||||
@ -0,0 +1,11 @@
|
||||
---
|
||||
id: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2"
|
||||
order: 5
|
||||
name: "Gestion des bugs"
|
||||
status: "planned"
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"user"}
|
||||
createdAt: 1783923775150
|
||||
updatedAt: 1783923775150
|
||||
version: 1
|
||||
---
|
||||
@ -36,6 +36,15 @@
|
||||
"status": "planned",
|
||||
"updatedAt": 1783757205620,
|
||||
"version": 2
|
||||
},
|
||||
{
|
||||
"id": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2",
|
||||
"path": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2",
|
||||
"order": 5,
|
||||
"name": "Gestion des bugs",
|
||||
"status": "planned",
|
||||
"updatedAt": 1783923775150,
|
||||
"version": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,6 +1,15 @@
|
||||
---
|
||||
issueRef: "#15"
|
||||
version: 2
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedAt: 1783329462104
|
||||
version: 3
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1783940786085
|
||||
---
|
||||
## Cadrage Architect — DIFFÉRÉ (2026-07-13)
|
||||
|
||||
#15 (cas DÉLÉGUÉ : A→B via idea_ask_agent, B limité → parquer le waiter de A et re-livrer au reset) est **distinct de #30** (cas direct) et **différé hors sprint**.
|
||||
|
||||
**Motif (Architect)** : le faire maintenant en in-memory serait une rustine fragile — réintroduit un blocage long côté A et ne tient pas les scénarios reboot/crash/redémarrage IdeA déjà identifiés.
|
||||
|
||||
**Prérequis bloquant** : un modèle fiable de *délégation durable runtime-agent identity* (stockage waiter, corrélation requester/target/request, re-livraison idempotente, reprise après reset, comportement si A meurt / B redémarre / IdeA redémarre, expiration/annulation). → design `durable-delegation-runtime-agent-identity-design`.
|
||||
|
||||
**Statut** : reste ouvert, priorité low/stretch conservée. Dépendance baseline #7 maintenue + à rattacher au redesign délégation durable. Ne pas mélanger avec #30.
|
||||
@ -8,10 +8,10 @@ sprint: "d8f3f37b-87ca-4509-9116-45a99bc711df"
|
||||
links: [{"target":"#7","kind":"dependsOn"}]
|
||||
agentRefs: []
|
||||
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1783208599385
|
||||
updatedAt: 1783329462104
|
||||
version: 2
|
||||
updatedAt: 1783940786085
|
||||
version: 3
|
||||
---
|
||||
Extrait du cadrage Architect du ticket #7 (baseline livrée : propagation inter-agent B1→B3 + F1/F2). Stretch non retenu dans #7.
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
issueRef: "#26"
|
||||
version: 4
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedAt: 1783491079604
|
||||
version: 6
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1783861758866
|
||||
---
|
||||
|
||||
@ -2,15 +2,15 @@
|
||||
id: "2201b898-9c45-4057-9014-5284897507ad"
|
||||
number: 26
|
||||
title: "[UI] refonte totale de la gestion des menus via agent UX"
|
||||
status: "open"
|
||||
status: "closed"
|
||||
priority: "medium"
|
||||
sprint: "5afd6780-0f76-40d7-a10f-32ee52469d74"
|
||||
links: []
|
||||
agentRefs: []
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1783437525356
|
||||
updatedAt: 1783491079604
|
||||
version: 4
|
||||
updatedAt: 1783861758866
|
||||
version: 6
|
||||
---
|
||||
J'aimerais une refonte totale de l'UX et de l'UI d'IdeA pour tout ce qui touche aux menus. Je n'aime pas la disposition actuelle. Il est important de garder l'aspect fenêtre flottantes, le fait de pouvoir anchor les fenêtres sur le côté d'IdeA, mais je n'aime pas le fait qu'il y ai deux listes déroulantes View et Window. Onne comprends pas vraiemnt la différence. De plus, l'affichage de l'onglet du projet avec le nom "Projet : IdeA" sur lequel cliquer pour changer de projet je n'aime pas. Je pense peut être plutot ajouter un + à côté de l'onglet du projet pour pouvoir ouvrir plusieurs projet en parallèle ? (A l''approbation de UX). Par contre je ne veux pas qu'on touche aux cellules des agents, elles sont très bien comme ça, on se contente des menus et des fenêtres.
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
issueRef: "#27"
|
||||
version: 3
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedAt: 1783440116446
|
||||
version: 6
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1783926649550
|
||||
---
|
||||
|
||||
@ -2,15 +2,15 @@
|
||||
id: "d7459ae0-31ae-49b2-bd93-2419397b0343"
|
||||
number: 27
|
||||
title: "[Bug] L'outil assistance IA n'a pas les commandes MCP"
|
||||
status: "open"
|
||||
status: "closed"
|
||||
priority: "high"
|
||||
sprint: null
|
||||
sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2"
|
||||
links: []
|
||||
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1783439818722
|
||||
updatedAt: 1783440116446
|
||||
version: 3
|
||||
updatedAt: 1783926649550
|
||||
version: 6
|
||||
---
|
||||
Il semblerait que l'outil assistance IA des tickets n'ait pas connaissance des tools MCP pour l'edition des tickets. J'ai essayé de demandé à l'assistant de modifié le ticket pour que ça aille dans le sens de la conversation que j'ai eu avec lui et il m'a dabord donné un .md. Quand je lui ai demandé de modifier lui même le ticket, il est allé dans le fichier directement pour apporter des modifications au lieu d'utiliser le tool MCP d'IdeA
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
issueRef: "#29"
|
||||
version: 4
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedAt: 1783491123831
|
||||
version: 5
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1783921595742
|
||||
---
|
||||
|
||||
@ -2,15 +2,15 @@
|
||||
id: "51f19ca1-6b67-491b-b750-735c48a89d4b"
|
||||
number: 29
|
||||
title: "[UI] Garder les filtres des tickets entre deux redemarrage"
|
||||
status: "open"
|
||||
status: "closed"
|
||||
priority: "low"
|
||||
sprint: "5afd6780-0f76-40d7-a10f-32ee52469d74"
|
||||
links: []
|
||||
agentRefs: []
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1783491008871
|
||||
updatedAt: 1783491123831
|
||||
version: 4
|
||||
updatedAt: 1783921595742
|
||||
version: 5
|
||||
---
|
||||
J'aimerais garder les filtres des tickets en mémoire entre deux redemarrage ou ouverture de la fenetre ou d ela popup de choix de tickets.
|
||||
@ -1,6 +1,25 @@
|
||||
---
|
||||
issueRef: "#30"
|
||||
version: 3
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedAt: 1783491223434
|
||||
version: 8
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1783941648176
|
||||
---
|
||||
## Cadrage Architect (2026-07-13) — cas DIRECT, actionnable
|
||||
|
||||
Débloqué : #27 fermé. #30 ≠ #15 (#15 = cas délégué, différé).
|
||||
|
||||
**Périmètre** : le chemin direct de session agent (agent directement adressé, ex. Main) ne remonte pas la limite vers `SessionLimitService`. Fix = accrocher le détecteur sur le chemin direct, avant résolution finale du tour.
|
||||
|
||||
**Frontière hexagonale** : infra détecte (adapter provider/profil déclaratif extrait la limite du flux structuré/regex → `ReplyEvent::RateLimited { resets_at_ms? }`) ; application arme la reprise (`SessionLimitService::on_rate_limited(agent_id,node_id,conversation_id,resets_at_ms)`) ; app-tauri relaie ; React affiche/annule. Aucune logique provider dans domaine/application.
|
||||
|
||||
**Contrat d'événements** :
|
||||
- `DomainEvent::AgentRateLimited { agent_id, resets_at_ms }`
|
||||
- `DomainEvent::AgentResumeScheduled { agent_id, fire_at_ms }` si reprise armée
|
||||
- reprise auto au reset, annulable via `cancel_resume` (contrat existant conservé)
|
||||
- si heure de reset non récupérable : fallback niveau humain déjà cadré (état suspect + saisie d'heure, pas de silence)
|
||||
|
||||
**Point clé** : identité runtime — résoudre l'agent courant + `node_id` vivant + `conversation_id` via le registre/session meta existant (pas de convention UI). Sans `conversation_id`, ne PAS prétendre la reprise armée.
|
||||
|
||||
**Découpage** : B1 tap `ReplyEvent::RateLimited → on_rate_limited` dans le runner direct ; B2 résolution identité/session (agent_id/node_id/conversation_id) pour l'agent adressé ; B3 tests application/app-tauri (flux direct rate-limit avec resets_at_ms → AgentRateLimited puis AgentResumeScheduled) ; F1 régression badge existant (pas de nouveau contrat) ; F2 QA réelle (limite provoquée/mockée sur Main → heure affichée, annulation, reprise planifiée).
|
||||
|
||||
**Sous-lot adjacent** (ferme #7/F2, mémoire `ticket7-f2-delegated-limit-no-agentratelimited`) : la branche rendez-vous délégué publie seulement `DelegationRateLimited` et doit aussi armer `on_rate_limited(target,…)`. Traité dans la même branche mais en commit séparé.
|
||||
@ -2,15 +2,15 @@
|
||||
id: "f2c4ed9c-ba54-42c1-90aa-0345d4ff4c6b"
|
||||
number: 30
|
||||
title: "[Bug] Le handle de limite session n'est pas fonctionnel quand c'est Main qui limite session"
|
||||
status: "open"
|
||||
status: "closed"
|
||||
priority: "medium"
|
||||
sprint: null
|
||||
sprint: "d8f3f37b-87ca-4509-9116-45a99bc711df"
|
||||
links: [{"target":"#27","kind":"blockedBy"}]
|
||||
agentRefs: []
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1783491181648
|
||||
updatedAt: 1783491223434
|
||||
version: 3
|
||||
updatedAt: 1783941648176
|
||||
version: 8
|
||||
---
|
||||
Lorsque l'agent qui atteint sa limite est celui à qui ont parle, la limite de session n'est pas récupérée par IdeA et n'est pas gérée
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
issueRef: "#35"
|
||||
version: 5
|
||||
version: 6
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1783757457771
|
||||
updatedAt: 1783777898736
|
||||
---
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
id: "77ef65a5-bbb8-44d2-a610-27bf15c54a0a"
|
||||
number: 35
|
||||
title: "Avoir llamacpp intégré"
|
||||
status: "inProgress"
|
||||
status: "closed"
|
||||
priority: "high"
|
||||
sprint: "883534aa-7fc1-4d83-a9c0-17cac4a4eea5"
|
||||
links: []
|
||||
@ -10,7 +10,7 @@ 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
|
||||
updatedAt: 1783777898736
|
||||
version: 6
|
||||
---
|
||||
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é
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
issueRef: "#36"
|
||||
version: 5
|
||||
version: 6
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1783757458773
|
||||
updatedAt: 1783777899737
|
||||
---
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
id: "3bb071b9-08eb-4e1b-b4b4-dccee8114a7e"
|
||||
number: 36
|
||||
title: "[UI] Pouvoir déclarer plusieurs profils AI de modeles locaux"
|
||||
status: "inProgress"
|
||||
status: "closed"
|
||||
priority: "medium"
|
||||
sprint: "883534aa-7fc1-4d83-a9c0-17cac4a4eea5"
|
||||
links: []
|
||||
@ -10,7 +10,7 @@ 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
|
||||
updatedAt: 1783777899737
|
||||
version: 6
|
||||
---
|
||||
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.
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
issueRef: "#37"
|
||||
version: 5
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedAt: 1783757520082
|
||||
version: 7
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1783878976369
|
||||
---
|
||||
|
||||
@ -2,15 +2,15 @@
|
||||
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"
|
||||
status: "closed"
|
||||
priority: "low"
|
||||
sprint: "5afd6780-0f76-40d7-a10f-32ee52469d74"
|
||||
links: []
|
||||
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1783757421147
|
||||
updatedAt: 1783757520082
|
||||
version: 5
|
||||
updatedAt: 1783878976369
|
||||
version: 7
|
||||
---
|
||||
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
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
issueRef: "#38"
|
||||
version: 5
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedAt: 1783757636999
|
||||
version: 7
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1783879443381
|
||||
---
|
||||
|
||||
@ -2,15 +2,15 @@
|
||||
id: "7e89bfc9-dc56-4656-a154-690f02e69756"
|
||||
number: 38
|
||||
title: "[UI] Pouvoir ajouter un ticket à un sprint pendant sa création"
|
||||
status: "open"
|
||||
status: "closed"
|
||||
priority: "low"
|
||||
sprint: "5afd6780-0f76-40d7-a10f-32ee52469d74"
|
||||
links: []
|
||||
agentRefs: []
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1783757548614
|
||||
updatedAt: 1783757636999
|
||||
version: 5
|
||||
updatedAt: 1783879443381
|
||||
version: 7
|
||||
---
|
||||
J'aimerais que dans la création de ticket on puisse lui attribuer un sprint via une popup comme celle du choix de tickets
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
issueRef: "#39"
|
||||
version: 3
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedAt: 1783757851100
|
||||
version: 5
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1783921595316
|
||||
---
|
||||
|
||||
@ -2,15 +2,15 @@
|
||||
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"
|
||||
status: "closed"
|
||||
priority: "low"
|
||||
sprint: "5afd6780-0f76-40d7-a10f-32ee52469d74"
|
||||
links: []
|
||||
agentRefs: []
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1783757707896
|
||||
updatedAt: 1783757851100
|
||||
version: 3
|
||||
updatedAt: 1783921595316
|
||||
version: 5
|
||||
---
|
||||
Quand on ferme la fenetre principale d'IdeA, j'aimerais que toutes les fenêtres IdeA se ferment aussi.
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
issueRef: "#40"
|
||||
version: 5
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedAt: 1783757857115
|
||||
version: 6
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1783922451268
|
||||
---
|
||||
|
||||
@ -2,15 +2,15 @@
|
||||
id: "6b04c8d1-177c-422a-9955-7fee2e8afdeb"
|
||||
number: 40
|
||||
title: "[UI] réouvrir les fenetres au lancement d'IdeA"
|
||||
status: "open"
|
||||
status: "closed"
|
||||
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"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1783757766786
|
||||
updatedAt: 1783757857115
|
||||
version: 5
|
||||
updatedAt: 1783922451268
|
||||
version: 6
|
||||
---
|
||||
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
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
issueRef: "#41"
|
||||
version: 4
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedAt: 1783757942954
|
||||
version: 6
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1783878363957
|
||||
---
|
||||
|
||||
@ -2,15 +2,15 @@
|
||||
id: "7d026901-333a-46a6-87f6-7384bf742310"
|
||||
number: 41
|
||||
title: "[UI] Selection multiple lors de la selection de tickets pour les sprints"
|
||||
status: "open"
|
||||
status: "closed"
|
||||
priority: "low"
|
||||
sprint: "5afd6780-0f76-40d7-a10f-32ee52469d74"
|
||||
links: []
|
||||
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1783757895085
|
||||
updatedAt: 1783757942954
|
||||
version: 4
|
||||
updatedAt: 1783878363957
|
||||
version: 6
|
||||
---
|
||||
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.
|
||||
6
.ideai/tickets/42/carnet.md
Normal file
6
.ideai/tickets/42/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#42"
|
||||
version: 2
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1783877612067
|
||||
---
|
||||
16
.ideai/tickets/42/issue.md
Normal file
16
.ideai/tickets/42/issue.md
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "239aff64-a131-47eb-9a95-cc56c8c369c6"
|
||||
number: 42
|
||||
title: "[UI] Clarifier les contrôles d'en-tête de panneau (DockControls) — icônes libellées + tooltips alignés sur le menu Panneaux"
|
||||
status: "closed"
|
||||
priority: "low"
|
||||
sprint: null
|
||||
links: [{"target":"#26","kind":"relatesTo"}]
|
||||
agentRefs: []
|
||||
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1783862180616
|
||||
updatedAt: 1783877612067
|
||||
version: 2
|
||||
---
|
||||
Refinement UX suite à #26. Remplacer les glyphes peu explicites des en-têtes de panneaux docké/flottant (◧ ◨ ⧉ ⤢ ✕) par des boutons-icônes libellés + tooltips (title natif), alignés strictement sur les libellés du menu Panneaux. Spec UX figé : IconButton existant (@/shared, pas de lib externe, pas de Tooltip custom), ordre [⇤ Ancré à gauche][⇥ Ancré à droite][□ Flottant][↗ Fenêtre détachée][× Fermé] ; title + aria-label "<action> — <titre panneau>" ; placement courant = actif (aria-pressed=true, bg-raised text-content, ring-1 ring-border-strong) et NON désactivé ; "Fenêtre détachée" masqué pour projects et désactivé sans projet actif ; conteneur flex shrink-0 items-center gap-1. Périmètre strict : DockControls dans ProjectsView.tsx uniquement, aucun changement LayoutGrid/LeafView/cellules agents/write-portal/F3/DockRegion/FloatingWindow/ViewWindow.
|
||||
6
.ideai/tickets/43/carnet.md
Normal file
6
.ideai/tickets/43/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#43"
|
||||
version: 1
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedAt: 1783879315858
|
||||
---
|
||||
15
.ideai/tickets/43/issue.md
Normal file
15
.ideai/tickets/43/issue.md
Normal file
@ -0,0 +1,15 @@
|
||||
---
|
||||
id: "63e80352-8ce1-4116-9ec3-b4cb3fc5c077"
|
||||
number: 43
|
||||
title: "Systeme de plugins"
|
||||
status: "open"
|
||||
priority: "medium"
|
||||
sprint: null
|
||||
links: []
|
||||
agentRefs: []
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"user"}
|
||||
createdAt: 1783879315858
|
||||
updatedAt: 1783879315858
|
||||
version: 1
|
||||
---
|
||||
25
.ideai/tickets/44/carnet.md
Normal file
25
.ideai/tickets/44/carnet.md
Normal file
@ -0,0 +1,25 @@
|
||||
---
|
||||
issueRef: "#44"
|
||||
version: 8
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1783976073932
|
||||
---
|
||||
## Résolution (2026-07-13)
|
||||
|
||||
Le first-run wizard tourne désormais en deux **modes explicites** passés en argument (jamais inférés de `isFirstRun`) :
|
||||
- `firstRun` : catalogue de référence seul, tout désélectionné, availability inconnue (détection auto pré-coche les CLI installés).
|
||||
- `edit` (rouvert via *Settings ▸ Configure profiles*, `forceOpen`) : les profils déjà configurés reviennent **pré-sélectionnés avec leur vraie config**, en tête ; les profils de référence dont l'`id` est déjà configuré sont **dédupliqués par `profile.id` uniquement** (plusieurs OpenCode locaux partagent command/adapter mais diffèrent par endpoint/model). La détection auto ne touche PAS la sélection initiale (`preselect:false`).
|
||||
|
||||
Logique pure isolée dans `wizardEntries.ts` (`buildWizardEntries`) pour tester les invariants unitairement.
|
||||
|
||||
### Fichiers
|
||||
- `frontend/src/features/first-run/wizardEntries.ts` (+ `.test.ts`) — nouveaux
|
||||
- `useFirstRun.ts` — `mode` param, charge `listProfiles()` en edit via `Promise.all`
|
||||
- `FirstRunWizard.tsx` — `mode = forceOpen ? "edit" : "firstRun"`
|
||||
- `FirstRunWizard.test.tsx` — étendu
|
||||
|
||||
### QA — vert
|
||||
`npx vitest run wizardEntries.test.ts FirstRunWizard.test.tsx` → 37 passed ; `npx tsc --noEmit` → exit 0.
|
||||
|
||||
### Git
|
||||
Commit `8cdc44d` sur `feature/ticket44-firstrun-keep-profile-info` (base develop), mergé `--no-ff` vers `develop` (`c67ec4f`). Aucune action sortante.
|
||||
16
.ideai/tickets/44/issue.md
Normal file
16
.ideai/tickets/44/issue.md
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "d7c17017-f6fb-4e55-8119-8fbc42641f48"
|
||||
number: 44
|
||||
title: "Garder les infos du profils courant sur l'affichage d'edition des profiles"
|
||||
status: "closed"
|
||||
priority: "medium"
|
||||
sprint: "5afd6780-0f76-40d7-a10f-32ee52469d74"
|
||||
links: []
|
||||
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1783879634794
|
||||
updatedAt: 1783976073932
|
||||
version: 8
|
||||
---
|
||||
Si je vais dans l'edition des profiles, le pré-remplissage des profiles ne correspond pas aux profiles existants. C'est a dire que par exemple pour le profiles OpenCode, j'aimerais que si j'en ai déjà un de créé, celui (ou ceux d'affichés) correspondent à celui ou ceux qui existent déjà.
|
||||
6
.ideai/tickets/45/carnet.md
Normal file
6
.ideai/tickets/45/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#45"
|
||||
version: 6
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1783925599109
|
||||
---
|
||||
16
.ideai/tickets/45/issue.md
Normal file
16
.ideai/tickets/45/issue.md
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "b4e30445-b972-400a-879f-193c9c7aa10e"
|
||||
number: 45
|
||||
title: "[Bug] Erreur OpenCode au lancement IdeA"
|
||||
status: "closed"
|
||||
priority: "medium"
|
||||
sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2"
|
||||
links: []
|
||||
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1783920311014
|
||||
updatedAt: 1783925599109
|
||||
version: 6
|
||||
---
|
||||
Quand je lance IdeA avec un layout qui contient opencode, j'ai un beandeau d'erreur dans la cellule opencode qui dit : Échec du lancement de l'agent : model server error (port_occupied): port occupied: 8080. Par contre si je relance une cellule une fois IdeA lancé, ça fonctionne et la cellule s'affiche correctement.
|
||||
6
.ideai/tickets/46/carnet.md
Normal file
6
.ideai/tickets/46/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#46"
|
||||
version: 6
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1783939380889
|
||||
---
|
||||
16
.ideai/tickets/46/issue.md
Normal file
16
.ideai/tickets/46/issue.md
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "1fe837f0-bc97-4b8e-945e-940fdd3c0249"
|
||||
number: 46
|
||||
title: "[Bug] Plus de boutons pour ajouter un projet en onglet"
|
||||
status: "closed"
|
||||
priority: "low"
|
||||
sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2"
|
||||
links: []
|
||||
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1783923416403
|
||||
updatedAt: 1783939380889
|
||||
version: 6
|
||||
---
|
||||
Il manque un bouton + dans la barre des onglets des projets pour pouvoir ajouter un projet en onglet
|
||||
6
.ideai/tickets/47/carnet.md
Normal file
6
.ideai/tickets/47/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#47"
|
||||
version: 6
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1783939089770
|
||||
---
|
||||
16
.ideai/tickets/47/issue.md
Normal file
16
.ideai/tickets/47/issue.md
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "0591d2d3-b03e-41bc-8c6b-349fc4ddf1a5"
|
||||
number: 47
|
||||
title: "[Bug] Réouverture de fenêtres non lié au projet"
|
||||
status: "closed"
|
||||
priority: "medium"
|
||||
sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2"
|
||||
links: []
|
||||
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1783923465694
|
||||
updatedAt: 1783939089770
|
||||
version: 6
|
||||
---
|
||||
Si je suis sur un projet A, et que j'ouvre par exemple le panneau des agents. Si je ferme IdeA et que je réouvre IdeA, comme convenui j'ai ma fenêtre des agents qui s'affiche. Mais il m'affiche directement les agents de mon projet A, alors que je n'ai encore ouvert aucun projet. J'aimerais alors qu'il ne m'affiche rien avec juste un message qui me demande d'ouvrir un projet pour qu'il puisse afficher ce qu'il faut dedans et que ça soit toujours lié au projet en focus
|
||||
6
.ideai/tickets/48/carnet.md
Normal file
6
.ideai/tickets/48/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#48"
|
||||
version: 6
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1783924961311
|
||||
---
|
||||
16
.ideai/tickets/48/issue.md
Normal file
16
.ideai/tickets/48/issue.md
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "0e449e11-2af9-47ca-83da-03568d9af100"
|
||||
number: 48
|
||||
title: "[Bug] Bandeau d'affichage d'erreur au dessus des boutons de gestion de cellule"
|
||||
status: "closed"
|
||||
priority: "medium"
|
||||
sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2"
|
||||
links: []
|
||||
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1783923674564
|
||||
updatedAt: 1783924961311
|
||||
version: 6
|
||||
---
|
||||
Les bandeaux d'affichage d'erreur de cellule passent par dessus les boutons de changement d'agent, de split de cellule ou de fermeture de cellule ce qui rend la gestion de la cellule impossible s'il y a une erreur. Il faudrait que les boutons soient par dessus le bandeau d'erreur pour être accessibles
|
||||
20
.ideai/tickets/49/carnet.md
Normal file
20
.ideai/tickets/49/carnet.md
Normal file
@ -0,0 +1,20 @@
|
||||
---
|
||||
issueRef: "#49"
|
||||
version: 5
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1783940643502
|
||||
---
|
||||
## Nettoyage mémoire — fait (Main, 2026-07-13)
|
||||
|
||||
Store passé de ~83 notes à **52 notes**, index `MEMORY.md` remis en parité stricte (52 entrées = 52 fichiers, 0 lien cassé, 0 orphelin).
|
||||
|
||||
**Supprimé (31, récupérable via git)** — notes transitoires / supersédées :
|
||||
- 20 `checkpoint-*` (snapshots de work-state de sprints livrés).
|
||||
- Campagne de tests MCP/backstop clôturée (programme live-state fermé, cf. `live-state-persistence-program-closed-ls8`) : `mcp-functional-test-plan-2026-06-24`, `mcp-functional-tests-t1-t9-green-live-2026-06-24`, `mcp-t7-backstop-...`, `mcp-t10a-...`, `mcp-t10b-...`, `mcp-e2e-findings-...`, `backstop-no-reply-live-test-failed-...`, `backstop-fires-on-intra-task-turn-rootcause`, `reconcile-live-state-implemented-feature-branch`, `resume-after-appimage-rebuild-mcp-e2e`.
|
||||
- `f35-launch-status-done-config-crud-blocked` (supersédé par `f35-config-crud-delivered`).
|
||||
|
||||
**Conservé** : toutes les notes de design/contrat/référence durables + gotchas (build, focus-trap, sandbox, ports/contrats).
|
||||
|
||||
**Restauré à l'index** (fichiers présents mais index dérivé/désync) : `develop-realigned-to-cli-ui-baseline-2026-07-02`, `feature-agent-skill-awareness-design`, `inter-agent-announcements-feature-and-codex-final-bug`, `inter-agent-live-context-shared-per-agent`, `ticket4-announcements-frontend-f1f2f3`.
|
||||
|
||||
Commit du diff `.ideai/memory/` à confier à l'agent Git.
|
||||
16
.ideai/tickets/49/issue.md
Normal file
16
.ideai/tickets/49/issue.md
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "a6552142-1c48-49b0-85a7-56b6ebaaacff"
|
||||
number: 49
|
||||
title: "[Misc] clean les memory qui ne sont plus valable ou qui n'ont plus de raisons d'être"
|
||||
status: "closed"
|
||||
priority: "medium"
|
||||
sprint: null
|
||||
links: []
|
||||
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1783935372750
|
||||
updatedAt: 1783940643502
|
||||
version: 5
|
||||
---
|
||||
Il y a beaucoup de memories dans la liste Memory. J'iamerais qu'un nettoyage soit fait au cas ou certaines ne soient plus d'actualité.
|
||||
6
.ideai/tickets/50/carnet.md
Normal file
6
.ideai/tickets/50/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#50"
|
||||
version: 7
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1783959345112
|
||||
---
|
||||
16
.ideai/tickets/50/issue.md
Normal file
16
.ideai/tickets/50/issue.md
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "9975f15a-2140-4c0b-8c15-2672a13d8af4"
|
||||
number: 50
|
||||
title: "[UI] Erreur d'affichage sur les fenetre ouvertes a l'ouverture d'IdeA"
|
||||
status: "closed"
|
||||
priority: "low"
|
||||
sprint: "5afd6780-0f76-40d7-a10f-32ee52469d74"
|
||||
links: []
|
||||
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1783940995092
|
||||
updatedAt: 1783959345112
|
||||
version: 7
|
||||
---
|
||||
Si une fenetre s'ouvre avec l'ouverture d'IdeA, dans les menus, la fenetre est affichées comme non ouverte, la pastille bleu est sur "Fermé"
|
||||
6
.ideai/tickets/51/carnet.md
Normal file
6
.ideai/tickets/51/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#51"
|
||||
version: 1
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedAt: 1783941336721
|
||||
---
|
||||
15
.ideai/tickets/51/issue.md
Normal file
15
.ideai/tickets/51/issue.md
Normal file
@ -0,0 +1,15 @@
|
||||
---
|
||||
id: "6b74084c-0323-4f87-83f0-d9f944c364dc"
|
||||
number: 51
|
||||
title: "Clean des session headless"
|
||||
status: "open"
|
||||
priority: "medium"
|
||||
sprint: null
|
||||
links: []
|
||||
agentRefs: []
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"user"}
|
||||
createdAt: 1783941336721
|
||||
updatedAt: 1783941336721
|
||||
version: 1
|
||||
---
|
||||
6
.ideai/tickets/52/carnet.md
Normal file
6
.ideai/tickets/52/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#52"
|
||||
version: 6
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1783958689416
|
||||
---
|
||||
16
.ideai/tickets/52/issue.md
Normal file
16
.ideai/tickets/52/issue.md
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "fe674f4b-bc3e-4fb3-8849-edd8f9450a4c"
|
||||
number: 52
|
||||
title: "[UI] mettre le bouton plus pour ajouter un projet juste a droite des onglet déjà ouvert comme pour les layouts"
|
||||
status: "closed"
|
||||
priority: "low"
|
||||
sprint: "5afd6780-0f76-40d7-a10f-32ee52469d74"
|
||||
links: []
|
||||
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1783942134638
|
||||
updatedAt: 1783958689416
|
||||
version: 6
|
||||
---
|
||||
J'iamerais que le + qui est actuellement tout a droite de la fenetre pour ajouter un onglet projet, soit juste à droite des onglets déjà ouvert comme pour les layouts
|
||||
27
.ideai/tickets/54/carnet.md
Normal file
27
.ideai/tickets/54/carnet.md
Normal file
@ -0,0 +1,27 @@
|
||||
---
|
||||
issueRef: "#54"
|
||||
version: 5
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1783976286882
|
||||
---
|
||||
## Cadrage Architect (2026-07-13)
|
||||
|
||||
**Seam actuel du timeout** : `EnsureLocalModelServer` dans `crates/application/src/model_server.rs` — boucle de readiness HTTP `ReadinessPolicy` par défaut `20 × 250ms` (~5s), puis `Failed{code:"timeout"}` + kill du process (model_server.rs:336/359). Démarrage déclenché depuis `lifecycle.rs:1665` (`ensure_local_model_server_for_opencode`). Adapter concret `LocalManagedProcess` (`crates/infrastructure/src/model_server/mod.rs`) : spawn sans capture stdout/stderr ; `LlamaCppRuntime` construit `-hf <repo>` ou `--model <path>` (mod.rs:101). Le probe HTTP `/v1/models` ne distingue que prêt/pas-joignable — pas de source « téléchargement » fiable aujourd'hui.
|
||||
|
||||
**Frontend** : event `modelServerStatusChanged` existe déjà (`domain/index.ts`), replié par `serverId` dans `useAgents.ts:313`, affiché surtout dans AgentsPanel — pas en overlay cellule. Overlay à brancher dans `LeafView`/`LayoutGrid.tsx:883` (même niveau que les overlays plein-cellule existants), en corrélant `agentId → profileId → opencode.localModelServerId → statut serveur`.
|
||||
|
||||
**Contrat DTO (réutilise le stream existant, pas de nouveau)** — étendre `ModelServerLifecycleStatus` :
|
||||
```
|
||||
Downloading { downloaded_bytes: Option<u64>, total_bytes: Option<u64>, percent: Option<f32>, source: Option<String> }
|
||||
Ready { reused: bool }
|
||||
Failed { code, message }
|
||||
```
|
||||
Miroir TS : `{ state: "downloading"; downloadedBytes?; totalBytes?; percent?; source? } | ...`
|
||||
|
||||
### Découpage
|
||||
- **B1 (backend MVP)** : étendre le DTO status ; pour source HF auto-start, ne PAS traiter 5s de process vivant comme erreur → publier `Downloading`/`Preparing`, continuer à sonder tant que le process vit, échouer seulement si exit/probe terminal/port collision/deadline longue configurable.
|
||||
- **F1 (frontend MVP)** : étendre `ModelServerStatus` TS ; overlay plein-cellule dans LeafView pour cellules dont l'agent référence le serveur en `downloading/starting/probing` (« Téléchargement du modèle… » / « Chargement du serveur… »).
|
||||
- **B2 (stretch)** : progression fine réelle — court terme PTY + parse progress llama.cpp ; long terme port `ModelArtifactDownloader` (pré-DL HF avec callbacks, puis spawn `--model <path>`).
|
||||
- **F2 (stretch)** : barre/%, bytes, source.
|
||||
|
||||
**Ordre** : B1 → F1 → QA (MVP). Stretch B2/F2 séparable et décidé après.
|
||||
16
.ideai/tickets/54/issue.md
Normal file
16
.ideai/tickets/54/issue.md
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "18641885-1b38-43c1-a81b-42f4c407690d"
|
||||
number: 54
|
||||
title: "[UI] Ajouter le handle du téléchargement des modeles lors du démarage llamacpp"
|
||||
status: "inProgress"
|
||||
priority: "medium"
|
||||
sprint: "5afd6780-0f76-40d7-a10f-32ee52469d74"
|
||||
links: []
|
||||
agentRefs: []
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1783965036142
|
||||
updatedAt: 1783976286882
|
||||
version: 5
|
||||
---
|
||||
Lorsqu'un serveur llamacpp se démarre, dans le cas ou le modele se télécharge, il faudrait que le téléchargement soit affiché au lieu d'afficher un timeout, avec en idéal l'affichage de la préogression du téléchargement en supperposition de la (ou les cellules) qui cherche à afficher le modèle
|
||||
@ -1,3 +1,3 @@
|
||||
{
|
||||
"nextNumber": 42
|
||||
"nextNumber": 55
|
||||
}
|
||||
@ -177,7 +177,7 @@
|
||||
"priority": "low",
|
||||
"sprint": "d8f3f37b-87ca-4509-9116-45a99bc711df",
|
||||
"assignedAgentIds": [],
|
||||
"updatedAt": 1783329462104
|
||||
"updatedAt": 1783940786085
|
||||
},
|
||||
{
|
||||
"issueRef": "#16",
|
||||
@ -281,23 +281,23 @@
|
||||
"issueRef": "#26",
|
||||
"path": "26",
|
||||
"title": "[UI] refonte totale de la gestion des menus via agent UX",
|
||||
"status": "open",
|
||||
"status": "closed",
|
||||
"priority": "medium",
|
||||
"sprint": "5afd6780-0f76-40d7-a10f-32ee52469d74",
|
||||
"assignedAgentIds": [],
|
||||
"updatedAt": 1783491079604
|
||||
"updatedAt": 1783861758866
|
||||
},
|
||||
{
|
||||
"issueRef": "#27",
|
||||
"path": "27",
|
||||
"title": "[Bug] L'outil assistance IA n'a pas les commandes MCP",
|
||||
"status": "open",
|
||||
"status": "closed",
|
||||
"priority": "high",
|
||||
"sprint": null,
|
||||
"sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2",
|
||||
"assignedAgentIds": [
|
||||
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||
],
|
||||
"updatedAt": 1783440116446
|
||||
"updatedAt": 1783926649550
|
||||
},
|
||||
{
|
||||
"issueRef": "#28",
|
||||
@ -313,21 +313,21 @@
|
||||
"issueRef": "#29",
|
||||
"path": "29",
|
||||
"title": "[UI] Garder les filtres des tickets entre deux redemarrage",
|
||||
"status": "open",
|
||||
"status": "closed",
|
||||
"priority": "low",
|
||||
"sprint": "5afd6780-0f76-40d7-a10f-32ee52469d74",
|
||||
"assignedAgentIds": [],
|
||||
"updatedAt": 1783491123831
|
||||
"updatedAt": 1783921595742
|
||||
},
|
||||
{
|
||||
"issueRef": "#30",
|
||||
"path": "30",
|
||||
"title": "[Bug] Le handle de limite session n'est pas fonctionnel quand c'est Main qui limite session",
|
||||
"status": "open",
|
||||
"status": "closed",
|
||||
"priority": "medium",
|
||||
"sprint": null,
|
||||
"sprint": "d8f3f37b-87ca-4509-9116-45a99bc711df",
|
||||
"assignedAgentIds": [],
|
||||
"updatedAt": 1783491223434
|
||||
"updatedAt": 1783941648176
|
||||
},
|
||||
{
|
||||
"issueRef": "#31",
|
||||
@ -375,81 +375,217 @@
|
||||
"issueRef": "#35",
|
||||
"path": "35",
|
||||
"title": "Avoir llamacpp intégré",
|
||||
"status": "inProgress",
|
||||
"status": "closed",
|
||||
"priority": "high",
|
||||
"sprint": "883534aa-7fc1-4d83-a9c0-17cac4a4eea5",
|
||||
"assignedAgentIds": [
|
||||
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||
],
|
||||
"updatedAt": 1783757457771
|
||||
"updatedAt": 1783777898736
|
||||
},
|
||||
{
|
||||
"issueRef": "#36",
|
||||
"path": "36",
|
||||
"title": "[UI] Pouvoir déclarer plusieurs profils AI de modeles locaux",
|
||||
"status": "inProgress",
|
||||
"status": "closed",
|
||||
"priority": "medium",
|
||||
"sprint": "883534aa-7fc1-4d83-a9c0-17cac4a4eea5",
|
||||
"assignedAgentIds": [
|
||||
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||
],
|
||||
"updatedAt": 1783757458773
|
||||
"updatedAt": 1783777899737
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"status": "closed",
|
||||
"priority": "low",
|
||||
"sprint": "5afd6780-0f76-40d7-a10f-32ee52469d74",
|
||||
"assignedAgentIds": [
|
||||
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||
],
|
||||
"updatedAt": 1783757520082
|
||||
"updatedAt": 1783878976369
|
||||
},
|
||||
{
|
||||
"issueRef": "#38",
|
||||
"path": "38",
|
||||
"title": "[UI] Pouvoir ajouter un ticket à un sprint pendant sa création",
|
||||
"status": "open",
|
||||
"status": "closed",
|
||||
"priority": "low",
|
||||
"sprint": "5afd6780-0f76-40d7-a10f-32ee52469d74",
|
||||
"assignedAgentIds": [],
|
||||
"updatedAt": 1783757636999
|
||||
"updatedAt": 1783879443381
|
||||
},
|
||||
{
|
||||
"issueRef": "#39",
|
||||
"path": "39",
|
||||
"title": "[UI] Fermer toutes les fernetres lors de la fermeture de la fenetre principale principale",
|
||||
"status": "open",
|
||||
"status": "closed",
|
||||
"priority": "low",
|
||||
"sprint": "5afd6780-0f76-40d7-a10f-32ee52469d74",
|
||||
"assignedAgentIds": [],
|
||||
"updatedAt": 1783757851100
|
||||
"updatedAt": 1783921595316
|
||||
},
|
||||
{
|
||||
"issueRef": "#40",
|
||||
"path": "40",
|
||||
"title": "[UI] réouvrir les fenetres au lancement d'IdeA",
|
||||
"status": "open",
|
||||
"status": "closed",
|
||||
"priority": "low",
|
||||
"sprint": "5afd6780-0f76-40d7-a10f-32ee52469d74",
|
||||
"assignedAgentIds": [
|
||||
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||
],
|
||||
"updatedAt": 1783757857115
|
||||
"updatedAt": 1783922451268
|
||||
},
|
||||
{
|
||||
"issueRef": "#41",
|
||||
"path": "41",
|
||||
"title": "[UI] Selection multiple lors de la selection de tickets pour les sprints",
|
||||
"status": "open",
|
||||
"status": "closed",
|
||||
"priority": "low",
|
||||
"sprint": "5afd6780-0f76-40d7-a10f-32ee52469d74",
|
||||
"assignedAgentIds": [
|
||||
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||
],
|
||||
"updatedAt": 1783757942954
|
||||
"updatedAt": 1783878363957
|
||||
},
|
||||
{
|
||||
"issueRef": "#42",
|
||||
"path": "42",
|
||||
"title": "[UI] Clarifier les contrôles d'en-tête de panneau (DockControls) — icônes libellées + tooltips alignés sur le menu Panneaux",
|
||||
"status": "closed",
|
||||
"priority": "low",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [],
|
||||
"updatedAt": 1783877612067
|
||||
},
|
||||
{
|
||||
"issueRef": "#43",
|
||||
"path": "43",
|
||||
"title": "Systeme de plugins",
|
||||
"status": "open",
|
||||
"priority": "medium",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [],
|
||||
"updatedAt": 1783879315858
|
||||
},
|
||||
{
|
||||
"issueRef": "#44",
|
||||
"path": "44",
|
||||
"title": "Garder les infos du profils courant sur l'affichage d'edition des profiles",
|
||||
"status": "closed",
|
||||
"priority": "medium",
|
||||
"sprint": "5afd6780-0f76-40d7-a10f-32ee52469d74",
|
||||
"assignedAgentIds": [
|
||||
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||
],
|
||||
"updatedAt": 1783976073932
|
||||
},
|
||||
{
|
||||
"issueRef": "#45",
|
||||
"path": "45",
|
||||
"title": "[Bug] Erreur OpenCode au lancement IdeA",
|
||||
"status": "closed",
|
||||
"priority": "medium",
|
||||
"sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2",
|
||||
"assignedAgentIds": [
|
||||
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||
],
|
||||
"updatedAt": 1783925599109
|
||||
},
|
||||
{
|
||||
"issueRef": "#46",
|
||||
"path": "46",
|
||||
"title": "[Bug] Plus de boutons pour ajouter un projet en onglet",
|
||||
"status": "closed",
|
||||
"priority": "low",
|
||||
"sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2",
|
||||
"assignedAgentIds": [
|
||||
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||
],
|
||||
"updatedAt": 1783939380889
|
||||
},
|
||||
{
|
||||
"issueRef": "#47",
|
||||
"path": "47",
|
||||
"title": "[Bug] Réouverture de fenêtres non lié au projet",
|
||||
"status": "closed",
|
||||
"priority": "medium",
|
||||
"sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2",
|
||||
"assignedAgentIds": [
|
||||
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||
],
|
||||
"updatedAt": 1783939089770
|
||||
},
|
||||
{
|
||||
"issueRef": "#48",
|
||||
"path": "48",
|
||||
"title": "[Bug] Bandeau d'affichage d'erreur au dessus des boutons de gestion de cellule",
|
||||
"status": "closed",
|
||||
"priority": "medium",
|
||||
"sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2",
|
||||
"assignedAgentIds": [
|
||||
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||
],
|
||||
"updatedAt": 1783924961311
|
||||
},
|
||||
{
|
||||
"issueRef": "#49",
|
||||
"path": "49",
|
||||
"title": "[Misc] clean les memory qui ne sont plus valable ou qui n'ont plus de raisons d'être",
|
||||
"status": "closed",
|
||||
"priority": "medium",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [
|
||||
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||
],
|
||||
"updatedAt": 1783940643502
|
||||
},
|
||||
{
|
||||
"issueRef": "#50",
|
||||
"path": "50",
|
||||
"title": "[UI] Erreur d'affichage sur les fenetre ouvertes a l'ouverture d'IdeA",
|
||||
"status": "closed",
|
||||
"priority": "low",
|
||||
"sprint": "5afd6780-0f76-40d7-a10f-32ee52469d74",
|
||||
"assignedAgentIds": [
|
||||
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||
],
|
||||
"updatedAt": 1783959345112
|
||||
},
|
||||
{
|
||||
"issueRef": "#51",
|
||||
"path": "51",
|
||||
"title": "Clean des session headless",
|
||||
"status": "open",
|
||||
"priority": "medium",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [],
|
||||
"updatedAt": 1783941336721
|
||||
},
|
||||
{
|
||||
"issueRef": "#52",
|
||||
"path": "52",
|
||||
"title": "[UI] mettre le bouton plus pour ajouter un projet juste a droite des onglet déjà ouvert comme pour les layouts",
|
||||
"status": "closed",
|
||||
"priority": "low",
|
||||
"sprint": "5afd6780-0f76-40d7-a10f-32ee52469d74",
|
||||
"assignedAgentIds": [
|
||||
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||
],
|
||||
"updatedAt": 1783958689416
|
||||
},
|
||||
{
|
||||
"issueRef": "#54",
|
||||
"path": "54",
|
||||
"title": "[UI] Ajouter le handle du téléchargement des modeles lors du démarage llamacpp",
|
||||
"status": "inProgress",
|
||||
"priority": "medium",
|
||||
"sprint": "5afd6780-0f76-40d7-a10f-32ee52469d74",
|
||||
"assignedAgentIds": [],
|
||||
"updatedAt": 1783976286882
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -74,10 +74,10 @@ use infrastructure::{
|
||||
FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore,
|
||||
FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore,
|
||||
FsProjectStore, FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore,
|
||||
FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, HttpOpenAiCompatibleProbe,
|
||||
IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, LlamaCppRuntime,
|
||||
LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox,
|
||||
NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard,
|
||||
FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
|
||||
HttpOpenAiCompatibleProbe, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
||||
LlamaCppRuntime, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer,
|
||||
MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard,
|
||||
StructuredSessionFactory, SystemClock, SystemMillisClock, TicketAssistantEnvironmentPreparer,
|
||||
TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator,
|
||||
VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
|
||||
@ -1234,7 +1234,11 @@ impl AppState {
|
||||
Arc::clone(&fs_port),
|
||||
app_data_dir.to_string_lossy().into_owned(),
|
||||
));
|
||||
let ensure_local_model_server = Arc::new(EnsureLocalModelServer::new(
|
||||
let model_artifact_downloader = Arc::new(HfModelArtifactDownloader::new(
|
||||
app_data_dir.join("hf-model-artifacts"),
|
||||
));
|
||||
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>,
|
||||
@ -1242,7 +1246,11 @@ impl AppState {
|
||||
Arc::new(LlamaCppRuntime::new()) as Arc<dyn domain::ports::ModelServerRuntime>,
|
||||
Arc::clone(&fs_port),
|
||||
Arc::clone(&events_port),
|
||||
));
|
||||
)
|
||||
.with_model_artifact_downloader(
|
||||
model_artifact_downloader as Arc<dyn domain::ports::ModelArtifactDownloader>,
|
||||
),
|
||||
);
|
||||
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(
|
||||
|
||||
@ -213,6 +213,7 @@ fn model_server_error_code(err: &ModelServerError) -> &'static str {
|
||||
ModelServerError::Probe(_) => "probe",
|
||||
ModelServerError::Process(_) => "process",
|
||||
ModelServerError::Store(_) => "store",
|
||||
ModelServerError::Cancelled => "cancelled",
|
||||
ModelServerError::Timeout => "timeout",
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use std::time::Instant as StdInstant;
|
||||
|
||||
use domain::events::DomainEvent;
|
||||
use domain::model_server::{
|
||||
@ -10,7 +11,8 @@ use domain::model_server::{
|
||||
ModelSource,
|
||||
};
|
||||
use domain::ports::{
|
||||
EventBus, FileSystem, ManagedProcess, ManagedProcessHandle, ModelServerError, ModelServerProbe,
|
||||
EventBus, FileSystem, ManagedProcess, ManagedProcessHandle, ModelArtifactCancel,
|
||||
ModelArtifactDownloader, ModelArtifactProgress, ModelServerError, ModelServerProbe,
|
||||
ModelServerRegistry, ModelServerRuntime, ProcessStatus, ProfileStore, RemotePath,
|
||||
};
|
||||
use domain::{LocalModelServerId, StopPolicy};
|
||||
@ -215,8 +217,10 @@ pub struct EnsureLocalModelServer {
|
||||
events: Arc<dyn EventBus>,
|
||||
active: Mutex<HashMap<LocalModelServerId, ActiveServer>>,
|
||||
inflight: AsyncMutex<HashMap<LocalModelServerId, Arc<InflightEnsure>>>,
|
||||
download_cancels: Mutex<HashMap<LocalModelServerId, ModelArtifactCancel>>,
|
||||
readiness: ReadinessPolicy,
|
||||
hf_download_deadline: Duration,
|
||||
model_artifact_downloader: Option<Arc<dyn ModelArtifactDownloader>>,
|
||||
}
|
||||
|
||||
impl EnsureLocalModelServer {
|
||||
@ -240,8 +244,10 @@ impl EnsureLocalModelServer {
|
||||
events,
|
||||
active: Mutex::new(HashMap::new()),
|
||||
inflight: AsyncMutex::new(HashMap::new()),
|
||||
download_cancels: Mutex::new(HashMap::new()),
|
||||
readiness: ReadinessPolicy::default(),
|
||||
hf_download_deadline: DEFAULT_HF_DOWNLOAD_DEADLINE,
|
||||
model_artifact_downloader: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -259,6 +265,16 @@ impl EnsureLocalModelServer {
|
||||
self
|
||||
}
|
||||
|
||||
/// Enables pre-resolution/download of Hugging Face artifacts.
|
||||
#[must_use]
|
||||
pub fn with_model_artifact_downloader(
|
||||
mut self,
|
||||
downloader: Arc<dyn ModelArtifactDownloader>,
|
||||
) -> Self {
|
||||
self.model_artifact_downloader = Some(downloader);
|
||||
self
|
||||
}
|
||||
|
||||
/// Ensures the server is reachable.
|
||||
///
|
||||
/// # Errors
|
||||
@ -332,8 +348,13 @@ impl EnsureLocalModelServer {
|
||||
self.ensure_model_path_accessible(&config).await?;
|
||||
self.ensure_no_active_port_collision(&config).await?;
|
||||
|
||||
let spawn_config = match self.prepare_spawn_config(config.clone()).await {
|
||||
Ok(config) => config,
|
||||
Err(err) => return self.fail(config.id, err),
|
||||
};
|
||||
|
||||
self.publish(config.id, ModelServerLifecycleStatus::Starting);
|
||||
let spec = match self.runtime.build_spawn_spec(&config) {
|
||||
let spec = match self.runtime.build_spawn_spec(&spawn_config) {
|
||||
Ok(spec) => spec,
|
||||
Err(err) => return self.fail(config.id, err),
|
||||
};
|
||||
@ -345,16 +366,71 @@ impl EnsureLocalModelServer {
|
||||
config.id,
|
||||
ActiveServer {
|
||||
handle: handle.clone(),
|
||||
port: config.endpoint.port,
|
||||
stop_policy: config.stop_policy,
|
||||
port: spawn_config.endpoint.port,
|
||||
stop_policy: spawn_config.stop_policy,
|
||||
},
|
||||
);
|
||||
|
||||
let hf_source = hf_source(&config);
|
||||
self.wait_for_started_server(&config, &handle, hf_source)
|
||||
let hf_source = hf_source(&spawn_config);
|
||||
self.wait_for_started_server(&spawn_config, &handle, hf_source)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn prepare_spawn_config(
|
||||
&self,
|
||||
config: LocalModelServerConfig,
|
||||
) -> Result<LocalModelServerConfig, ModelServerError> {
|
||||
let Some(ModelSource::HuggingFace { repo }) = config.model.source.clone() else {
|
||||
return Ok(config);
|
||||
};
|
||||
let Some(downloader) = self.model_artifact_downloader.as_ref() else {
|
||||
return Ok(config);
|
||||
};
|
||||
|
||||
let cancel = ModelArtifactCancel::new();
|
||||
self.download_cancels
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(config.id, cancel.clone());
|
||||
|
||||
let events = Arc::clone(&self.events);
|
||||
let source = repo.as_str().to_owned();
|
||||
let server_id = config.id;
|
||||
let debouncer = Arc::new(Mutex::new(DownloadProgressDebouncer::new(
|
||||
server_id,
|
||||
source.clone(),
|
||||
events,
|
||||
)));
|
||||
let progress = {
|
||||
let debouncer = Arc::clone(&debouncer);
|
||||
Arc::new(move |progress: ModelArtifactProgress| {
|
||||
debouncer.lock().unwrap().on_progress(progress);
|
||||
}) as Arc<dyn Fn(ModelArtifactProgress) + Send + Sync>
|
||||
};
|
||||
|
||||
let result = tokio::time::timeout(
|
||||
self.hf_download_deadline,
|
||||
downloader.resolve_hf_model(&repo, progress, cancel.clone()),
|
||||
)
|
||||
.await;
|
||||
self.download_cancels.lock().unwrap().remove(&config.id);
|
||||
|
||||
let resolution = match result {
|
||||
Ok(Ok(resolution)) => resolution,
|
||||
Ok(Err(err)) => return Err(err),
|
||||
Err(_) => {
|
||||
cancel.cancel();
|
||||
return Err(ModelServerError::Timeout);
|
||||
}
|
||||
};
|
||||
|
||||
let mut resolved = config;
|
||||
resolved.model.source = Some(ModelSource::LocalPath {
|
||||
path: resolution.path,
|
||||
});
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
async fn wait_for_started_server(
|
||||
&self,
|
||||
config: &LocalModelServerConfig,
|
||||
@ -468,6 +544,9 @@ impl EnsureLocalModelServer {
|
||||
/// # Errors
|
||||
/// Returns the first process error after attempting every eligible stop.
|
||||
pub async fn stop_on_app_exit(&self) -> Result<(), AppError> {
|
||||
for cancel in self.download_cancels.lock().unwrap().values() {
|
||||
cancel.cancel();
|
||||
}
|
||||
let entries: Vec<(LocalModelServerId, ActiveServer)> = self
|
||||
.active
|
||||
.lock()
|
||||
@ -626,6 +705,109 @@ pub fn model_server_error_code(err: &ModelServerError) -> &'static str {
|
||||
ModelServerError::Probe(_) => "probe",
|
||||
ModelServerError::Process(_) => "process",
|
||||
ModelServerError::Store(_) => "store",
|
||||
ModelServerError::Cancelled => "cancelled",
|
||||
ModelServerError::Timeout => "timeout",
|
||||
}
|
||||
}
|
||||
|
||||
struct DownloadProgressDebouncer {
|
||||
server_id: LocalModelServerId,
|
||||
fallback_source: String,
|
||||
events: Arc<dyn EventBus>,
|
||||
last_emit: Option<StdInstant>,
|
||||
last_percent: Option<f32>,
|
||||
window_start: StdInstant,
|
||||
window_count: u32,
|
||||
}
|
||||
|
||||
impl DownloadProgressDebouncer {
|
||||
fn new(
|
||||
server_id: LocalModelServerId,
|
||||
fallback_source: String,
|
||||
events: Arc<dyn EventBus>,
|
||||
) -> Self {
|
||||
let now = StdInstant::now();
|
||||
Self {
|
||||
server_id,
|
||||
fallback_source,
|
||||
events,
|
||||
last_emit: None,
|
||||
last_percent: None,
|
||||
window_start: now,
|
||||
window_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_progress(&mut self, progress: ModelArtifactProgress) {
|
||||
let percent = progress_percent(&progress);
|
||||
let final_progress = is_final_progress(&progress);
|
||||
if !self.should_publish(percent, final_progress) {
|
||||
return;
|
||||
}
|
||||
self.mark_published(percent);
|
||||
self.events.publish(DomainEvent::ModelServerStatusChanged {
|
||||
server_id: self.server_id,
|
||||
status: ModelServerLifecycleStatus::Downloading {
|
||||
downloaded_bytes: progress.downloaded_bytes,
|
||||
total_bytes: progress.total_bytes,
|
||||
percent,
|
||||
source: progress
|
||||
.source
|
||||
.or_else(|| Some(self.fallback_source.clone())),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
fn should_publish(&mut self, percent: Option<f32>, final_progress: bool) -> bool {
|
||||
let now = StdInstant::now();
|
||||
if now.duration_since(self.window_start) >= Duration::from_secs(1) {
|
||||
self.window_start = now;
|
||||
self.window_count = 0;
|
||||
}
|
||||
if self.last_emit.is_none() || final_progress {
|
||||
return true;
|
||||
}
|
||||
if self.window_count >= 10 {
|
||||
return false;
|
||||
}
|
||||
if self
|
||||
.last_emit
|
||||
.is_some_and(|last| now.duration_since(last) < Duration::from_millis(100))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if self
|
||||
.last_emit
|
||||
.is_some_and(|last| now.duration_since(last) >= Duration::from_millis(500))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
match (self.last_percent, percent) {
|
||||
(Some(last), Some(current)) => current - last >= 1.0,
|
||||
(None, Some(_)) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_published(&mut self, percent: Option<f32>) {
|
||||
self.last_emit = Some(StdInstant::now());
|
||||
self.last_percent = percent.or(self.last_percent);
|
||||
self.window_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn progress_percent(progress: &ModelArtifactProgress) -> Option<f32> {
|
||||
let downloaded = progress.downloaded_bytes?;
|
||||
let total = progress.total_bytes?;
|
||||
if total == 0 {
|
||||
return None;
|
||||
}
|
||||
Some((downloaded as f32 / total as f32) * 100.0)
|
||||
}
|
||||
|
||||
fn is_final_progress(progress: &ModelArtifactProgress) -> bool {
|
||||
matches!(
|
||||
(progress.downloaded_bytes, progress.total_bytes),
|
||||
(Some(downloaded), Some(total)) if total > 0 && downloaded == total
|
||||
)
|
||||
}
|
||||
|
||||
@ -18,6 +18,7 @@ use domain::model_server::{
|
||||
};
|
||||
use domain::ports::{
|
||||
DirEntry, EventBus, EventStream, FileSystem, FsError, ManagedProcess, ManagedProcessHandle,
|
||||
ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, ModelArtifactResolution,
|
||||
ModelServerArgv, ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime,
|
||||
ProcessStatus, ProfileStore, RemotePath, SpawnSpec, StoreError,
|
||||
};
|
||||
@ -280,6 +281,74 @@ impl ModelServerRuntime for FakeRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum FakeDownloadOutcome {
|
||||
Resolve {
|
||||
progress: Vec<ModelArtifactProgress>,
|
||||
path: &'static str,
|
||||
cache_hit: bool,
|
||||
},
|
||||
WaitForCancel,
|
||||
Sleep(Duration),
|
||||
}
|
||||
|
||||
struct FakeModelArtifactDownloader {
|
||||
outcome: Mutex<FakeDownloadOutcome>,
|
||||
}
|
||||
|
||||
impl FakeModelArtifactDownloader {
|
||||
fn new(outcome: FakeDownloadOutcome) -> Self {
|
||||
Self {
|
||||
outcome: Mutex::new(outcome),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ModelArtifactDownloader for FakeModelArtifactDownloader {
|
||||
async fn resolve_hf_model(
|
||||
&self,
|
||||
repo: &HfModelRef,
|
||||
progress: Arc<dyn Fn(ModelArtifactProgress) + Send + Sync>,
|
||||
cancel: ModelArtifactCancel,
|
||||
) -> Result<ModelArtifactResolution, ModelServerError> {
|
||||
let outcome = self.outcome.lock().unwrap().clone();
|
||||
match outcome {
|
||||
FakeDownloadOutcome::Resolve {
|
||||
progress: events,
|
||||
path,
|
||||
cache_hit,
|
||||
} => {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(ModelServerError::Cancelled);
|
||||
}
|
||||
if !cache_hit {
|
||||
for event in events {
|
||||
progress(ModelArtifactProgress {
|
||||
source: event.source.or_else(|| Some(repo.as_str().to_owned())),
|
||||
..event
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(ModelArtifactResolution {
|
||||
path: ModelPath::new(path).unwrap(),
|
||||
cache_hit,
|
||||
})
|
||||
}
|
||||
FakeDownloadOutcome::WaitForCancel => loop {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(ModelServerError::Cancelled);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(1)).await;
|
||||
},
|
||||
FakeDownloadOutcome::Sleep(duration) => {
|
||||
tokio::time::sleep(duration).await;
|
||||
Err(ModelServerError::Probe("unexpected wake".to_owned()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeFs {
|
||||
existing: Mutex<Vec<String>>,
|
||||
@ -345,6 +414,26 @@ fn ensure(
|
||||
.with_hf_download_deadline(Duration::from_secs(5))
|
||||
}
|
||||
|
||||
fn ensure_with_downloader(
|
||||
registry: Arc<FakeRegistry>,
|
||||
probe: Arc<FakeProbe>,
|
||||
process: Arc<FakeProcess>,
|
||||
fs: Arc<FakeFs>,
|
||||
events: Arc<FakeEvents>,
|
||||
downloader: Arc<FakeModelArtifactDownloader>,
|
||||
) -> EnsureLocalModelServer {
|
||||
ensure(registry, probe, process, fs, events)
|
||||
.with_model_artifact_downloader(downloader as Arc<dyn ModelArtifactDownloader>)
|
||||
}
|
||||
|
||||
fn progress(downloaded: Option<u64>, total: Option<u64>) -> ModelArtifactProgress {
|
||||
ModelArtifactProgress {
|
||||
downloaded_bytes: downloaded,
|
||||
total_bytes: total,
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reachable_server_is_reused_without_spawn() {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
@ -738,6 +827,270 @@ async fn hf_download_phase_fails_when_process_exits() {
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hf_downloader_publishes_debounced_progress_and_spawns_resolved_model_path() {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
registry
|
||||
.save(hf_config(
|
||||
sid(14),
|
||||
8094,
|
||||
"Qwen/Qwen3-Coder-30B-A3B-Instruct-GGUF",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let process = Arc::new(FakeProcess::default());
|
||||
let events = Arc::new(FakeEvents::default());
|
||||
let downloader = Arc::new(FakeModelArtifactDownloader::new(
|
||||
FakeDownloadOutcome::Resolve {
|
||||
progress: vec![
|
||||
progress(Some(10), Some(100)),
|
||||
progress(Some(50), Some(100)),
|
||||
progress(Some(100), Some(100)),
|
||||
],
|
||||
path: "/cache/model.gguf",
|
||||
cache_hit: false,
|
||||
},
|
||||
));
|
||||
let usecase = ensure_with_downloader(
|
||||
Arc::clone(®istry),
|
||||
Arc::new(FakeProbe::new(vec![
|
||||
ModelServerStatus::Unreachable,
|
||||
ModelServerStatus::ReadyStarted,
|
||||
])),
|
||||
Arc::clone(&process),
|
||||
Arc::new(FakeFs::default()),
|
||||
Arc::clone(&events),
|
||||
downloader,
|
||||
);
|
||||
|
||||
let out = usecase
|
||||
.execute(EnsureLocalModelServerInput { server_id: sid(14) })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.ready.status, ModelServerStatus::ReadyStarted);
|
||||
let statuses: Vec<ModelServerLifecycleStatus> = events
|
||||
.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
DomainEvent::ModelServerStatusChanged { status, .. } => Some(status.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(matches!(
|
||||
statuses.as_slice(),
|
||||
[
|
||||
ModelServerLifecycleStatus::Probing,
|
||||
ModelServerLifecycleStatus::Downloading {
|
||||
downloaded_bytes: Some(10),
|
||||
total_bytes: Some(100),
|
||||
percent: Some(10.0),
|
||||
..
|
||||
},
|
||||
ModelServerLifecycleStatus::Downloading {
|
||||
downloaded_bytes: Some(100),
|
||||
total_bytes: Some(100),
|
||||
percent: Some(100.0),
|
||||
..
|
||||
},
|
||||
ModelServerLifecycleStatus::Starting,
|
||||
ModelServerLifecycleStatus::Ready { reused: false },
|
||||
]
|
||||
));
|
||||
let spawns = process.spawns.lock().unwrap();
|
||||
assert_eq!(spawns.len(), 1);
|
||||
assert_eq!(spawns[0].args[0..2], ["--model", "/cache/model.gguf"]);
|
||||
assert!(!spawns[0].args.iter().any(|arg| arg == "-hf"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hf_downloader_cache_hit_skips_downloading_events() {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
registry
|
||||
.save(hf_config(sid(15), 8095, "Qwen/Qwen3-Coder:Q4_K_M"))
|
||||
.await
|
||||
.unwrap();
|
||||
let process = Arc::new(FakeProcess::default());
|
||||
let events = Arc::new(FakeEvents::default());
|
||||
let downloader = Arc::new(FakeModelArtifactDownloader::new(
|
||||
FakeDownloadOutcome::Resolve {
|
||||
progress: Vec::new(),
|
||||
path: "/cache/q4.gguf",
|
||||
cache_hit: true,
|
||||
},
|
||||
));
|
||||
let usecase = ensure_with_downloader(
|
||||
Arc::clone(®istry),
|
||||
Arc::new(FakeProbe::new(vec![
|
||||
ModelServerStatus::Unreachable,
|
||||
ModelServerStatus::ReadyStarted,
|
||||
])),
|
||||
Arc::clone(&process),
|
||||
Arc::new(FakeFs::default()),
|
||||
Arc::clone(&events),
|
||||
downloader,
|
||||
);
|
||||
|
||||
usecase
|
||||
.execute(EnsureLocalModelServerInput { server_id: sid(15) })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let statuses: Vec<ModelServerLifecycleStatus> = events
|
||||
.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
DomainEvent::ModelServerStatusChanged { status, .. } => Some(status.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(matches!(
|
||||
statuses.as_slice(),
|
||||
[
|
||||
ModelServerLifecycleStatus::Probing,
|
||||
ModelServerLifecycleStatus::Starting,
|
||||
ModelServerLifecycleStatus::Ready { reused: false },
|
||||
]
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hf_downloader_unknown_total_publishes_no_percent() {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
registry
|
||||
.save(hf_config(sid(16), 8096, "Qwen/Qwen3-Coder:Q4_K_M"))
|
||||
.await
|
||||
.unwrap();
|
||||
let process = Arc::new(FakeProcess::default());
|
||||
let events = Arc::new(FakeEvents::default());
|
||||
let downloader = Arc::new(FakeModelArtifactDownloader::new(
|
||||
FakeDownloadOutcome::Resolve {
|
||||
progress: vec![progress(Some(10), None)],
|
||||
path: "/cache/q4.gguf",
|
||||
cache_hit: false,
|
||||
},
|
||||
));
|
||||
let usecase = ensure_with_downloader(
|
||||
Arc::clone(®istry),
|
||||
Arc::new(FakeProbe::new(vec![
|
||||
ModelServerStatus::Unreachable,
|
||||
ModelServerStatus::ReadyStarted,
|
||||
])),
|
||||
Arc::clone(&process),
|
||||
Arc::new(FakeFs::default()),
|
||||
Arc::clone(&events),
|
||||
downloader,
|
||||
);
|
||||
|
||||
usecase
|
||||
.execute(EnsureLocalModelServerInput { server_id: sid(16) })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(events.0.lock().unwrap().iter().any(|event| {
|
||||
matches!(
|
||||
event,
|
||||
DomainEvent::ModelServerStatusChanged {
|
||||
status: ModelServerLifecycleStatus::Downloading {
|
||||
downloaded_bytes: Some(10),
|
||||
total_bytes: None,
|
||||
percent: None,
|
||||
..
|
||||
},
|
||||
..
|
||||
}
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hf_downloader_cancel_fails_without_process_spawn() {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
registry
|
||||
.save(hf_config(sid(17), 8097, "Qwen/Qwen3-Coder:Q4_K_M"))
|
||||
.await
|
||||
.unwrap();
|
||||
let process = Arc::new(FakeProcess::default());
|
||||
let events = Arc::new(FakeEvents::default());
|
||||
let downloader = Arc::new(FakeModelArtifactDownloader::new(
|
||||
FakeDownloadOutcome::WaitForCancel,
|
||||
));
|
||||
let usecase = Arc::new(ensure_with_downloader(
|
||||
Arc::clone(®istry),
|
||||
Arc::new(FakeProbe::new(vec![ModelServerStatus::Unreachable])),
|
||||
Arc::clone(&process),
|
||||
Arc::new(FakeFs::default()),
|
||||
Arc::clone(&events),
|
||||
downloader,
|
||||
));
|
||||
let task_usecase = Arc::clone(&usecase);
|
||||
let task = tokio::spawn(async move {
|
||||
task_usecase
|
||||
.execute(EnsureLocalModelServerInput { server_id: sid(17) })
|
||||
.await
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
|
||||
usecase.stop_on_app_exit().await.unwrap();
|
||||
let err = task.await.unwrap().unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("cancelled"));
|
||||
assert!(process.spawns.lock().unwrap().is_empty());
|
||||
assert!(events.0.lock().unwrap().iter().any(|event| {
|
||||
matches!(
|
||||
event,
|
||||
DomainEvent::ModelServerStatusChanged {
|
||||
status: ModelServerLifecycleStatus::Failed { code, .. },
|
||||
..
|
||||
} if code == "cancelled"
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hf_downloader_timeout_fails_without_process_spawn() {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
registry
|
||||
.save(hf_config(sid(18), 8098, "Qwen/Qwen3-Coder:Q4_K_M"))
|
||||
.await
|
||||
.unwrap();
|
||||
let process = Arc::new(FakeProcess::default());
|
||||
let events = Arc::new(FakeEvents::default());
|
||||
let downloader = Arc::new(FakeModelArtifactDownloader::new(
|
||||
FakeDownloadOutcome::Sleep(Duration::from_millis(50)),
|
||||
));
|
||||
let usecase = ensure_with_downloader(
|
||||
Arc::clone(®istry),
|
||||
Arc::new(FakeProbe::new(vec![ModelServerStatus::Unreachable])),
|
||||
Arc::clone(&process),
|
||||
Arc::new(FakeFs::default()),
|
||||
Arc::clone(&events),
|
||||
downloader,
|
||||
)
|
||||
.with_hf_download_deadline(Duration::from_millis(1));
|
||||
|
||||
let err = usecase
|
||||
.execute(EnsureLocalModelServerInput { server_id: sid(18) })
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("timeout"));
|
||||
assert!(process.spawns.lock().unwrap().is_empty());
|
||||
assert!(events.0.lock().unwrap().iter().any(|event| {
|
||||
matches!(
|
||||
event,
|
||||
DomainEvent::ModelServerStatusChanged {
|
||||
status: ModelServerLifecycleStatus::Failed { code, .. },
|
||||
..
|
||||
} if code == "timeout"
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_registry_entry_is_model_server_not_configured() {
|
||||
let usecase = ensure(
|
||||
|
||||
@ -199,10 +199,11 @@ pub use ports::{
|
||||
EmbedderEnvReport, EmbedderError, EmbedderProfileStore, EmbedderPromptDismissal,
|
||||
EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem, FsError, GitCommitInfo,
|
||||
GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator, IssueNumberAllocator, IssueStore,
|
||||
IssueStoreError, LiveStateStore, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output,
|
||||
OutputStream, PermissionStore, PreparedContext, ProcessError, ProcessSpawner, ProfileStore,
|
||||
ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError,
|
||||
ScheduledTask, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError,
|
||||
IssueStoreError, LiveStateStore, MemoryError, MemoryQuery, MemoryRecall, MemoryStore,
|
||||
ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, ModelArtifactResolution,
|
||||
Output, OutputStream, PermissionStore, PreparedContext, ProcessError, ProcessSpawner,
|
||||
ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath,
|
||||
RuntimeError, ScheduledTask, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError,
|
||||
StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, TemplateStore,
|
||||
WindowStateStore,
|
||||
};
|
||||
|
||||
@ -22,6 +22,7 @@
|
||||
//! Each port is **fine-grained** (Interface Segregation): `FileSystem`,
|
||||
//! `PtyPort`, `ProcessSpawner` are separate, never a `System` god-trait.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
@ -42,7 +43,9 @@ use crate::issue::{
|
||||
};
|
||||
use crate::markdown::MarkdownDoc;
|
||||
use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
|
||||
use crate::model_server::{LocalModelServerConfig, ModelServerEndpoint, ModelServerStatus};
|
||||
use crate::model_server::{
|
||||
HfModelRef, LocalModelServerConfig, ModelPath, ModelServerEndpoint, ModelServerStatus,
|
||||
};
|
||||
use crate::permission::ProjectPermissions;
|
||||
use crate::profile::{AgentProfile, EmbedderProfile};
|
||||
use crate::project::{Project, ProjectPath};
|
||||
@ -582,6 +585,9 @@ pub enum ModelServerError {
|
||||
/// Store operation failed.
|
||||
#[error("store failed: {0}")]
|
||||
Store(String),
|
||||
/// Operation was cancelled by the caller.
|
||||
#[error("cancelled")]
|
||||
Cancelled,
|
||||
/// Readiness timed out.
|
||||
#[error("readiness timed out")]
|
||||
Timeout,
|
||||
@ -1013,6 +1019,66 @@ pub trait ModelServerProbe: Send + Sync {
|
||||
) -> Result<ModelServerStatus, ModelServerError>;
|
||||
}
|
||||
|
||||
/// Progress observed while resolving/downloading a model artifact.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ModelArtifactProgress {
|
||||
/// Downloaded bytes when known.
|
||||
pub downloaded_bytes: Option<u64>,
|
||||
/// Total bytes when known.
|
||||
pub total_bytes: Option<u64>,
|
||||
/// Source being resolved, usually the Hugging Face repo reference.
|
||||
pub source: Option<String>,
|
||||
}
|
||||
|
||||
/// Resolved local model artifact.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ModelArtifactResolution {
|
||||
/// Absolute local path to the resolved `.gguf` file.
|
||||
pub path: ModelPath,
|
||||
/// `true` when the artifact was already available in cache.
|
||||
pub cache_hit: bool,
|
||||
}
|
||||
|
||||
/// Cooperative cancellation token for model artifact resolution.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ModelArtifactCancel {
|
||||
cancelled: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl ModelArtifactCancel {
|
||||
/// Creates a non-cancelled token.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Requests cancellation.
|
||||
pub fn cancel(&self) {
|
||||
self.cancelled.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Returns whether cancellation was requested.
|
||||
#[must_use]
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.cancelled.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves or downloads a model artifact before starting a model server.
|
||||
#[async_trait]
|
||||
pub trait ModelArtifactDownloader: Send + Sync {
|
||||
/// Resolves a Hugging Face model to a local artifact path.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ModelServerError`] when resolution/download fails or is cancelled.
|
||||
async fn resolve_hf_model(
|
||||
&self,
|
||||
repo: &HfModelRef,
|
||||
progress: Arc<dyn Fn(ModelArtifactProgress) + Send + Sync>,
|
||||
cancel: ModelArtifactCancel,
|
||||
) -> Result<ModelArtifactResolution, ModelServerError>;
|
||||
}
|
||||
|
||||
/// Manages local long-lived child processes.
|
||||
#[async_trait]
|
||||
pub trait ManagedProcess: Send + Sync {
|
||||
|
||||
@ -66,7 +66,8 @@ pub use inspector::{
|
||||
pub use issues::{FsIssueNumberAllocator, FsIssueStore};
|
||||
pub use mailbox::InMemoryMailbox;
|
||||
pub use model_server::{
|
||||
FsModelServerRegistry, HttpOpenAiCompatibleProbe, LlamaCppRuntime, LocalManagedProcess,
|
||||
FsModelServerRegistry, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, LlamaCppRuntime,
|
||||
LocalManagedProcess,
|
||||
};
|
||||
pub use orchestrator::mcp::{
|
||||
McpServer, MemoryTransport, StdioTransport, TicketToolError, TicketToolProvider,
|
||||
|
||||
@ -6,15 +6,18 @@ use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures_util::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::process::{Child, Command};
|
||||
|
||||
use domain::model_server::{
|
||||
ExecutablePath, LlamaCppOptions, LocalModelRef, LocalModelServerConfig, LocalModelServerKind,
|
||||
ModelPath, ModelServerEndpoint, ModelSource,
|
||||
ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig,
|
||||
LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelSource,
|
||||
};
|
||||
use domain::ports::{
|
||||
FileSystem, ManagedProcess, ManagedProcessHandle, ModelServerArgv, ModelServerError,
|
||||
FileSystem, ManagedProcess, ManagedProcessHandle, ModelArtifactCancel, ModelArtifactDownloader,
|
||||
ModelArtifactProgress, ModelArtifactResolution, ModelServerArgv, ModelServerError,
|
||||
ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus, RemotePath,
|
||||
SpawnSpec,
|
||||
};
|
||||
@ -68,6 +71,210 @@ fn is_ready(result: Result<reqwest::Response, reqwest::Error>) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Hugging Face artifact resolver/downloader backed by an IdeA-owned cache dir.
|
||||
#[derive(Clone)]
|
||||
pub struct HfModelArtifactDownloader {
|
||||
cache_dir: PathBuf,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl HfModelArtifactDownloader {
|
||||
/// Builds a downloader that stores artifacts under `cache_dir`.
|
||||
#[must_use]
|
||||
pub fn new(cache_dir: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
cache_dir: cache_dir.into(),
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic cache path for a Hugging Face model reference.
|
||||
#[must_use]
|
||||
pub fn cache_path_for(&self, repo: &HfModelRef) -> PathBuf {
|
||||
let (base, quant) = split_hf_ref(repo);
|
||||
let file_stem = quant.unwrap_or("model");
|
||||
self.cache_dir
|
||||
.join(base.replace('/', "--"))
|
||||
.join(format!("{}.gguf", safe_cache_component(file_stem)))
|
||||
}
|
||||
|
||||
async fn resolve_remote_filename(&self, repo: &HfModelRef) -> Result<String, ModelServerError> {
|
||||
let (base, quant) = split_hf_ref(repo);
|
||||
let url = format!("https://huggingface.co/api/models/{base}");
|
||||
let response = self
|
||||
.client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ModelServerError::Probe(e.to_string()))?;
|
||||
if !response.status().is_success() {
|
||||
return Err(ModelServerError::Probe(format!(
|
||||
"huggingface model metadata returned {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
let metadata: HfModelMetadata = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ModelServerError::Probe(e.to_string()))?;
|
||||
select_gguf_file(&metadata.siblings, quant).ok_or_else(|| {
|
||||
ModelServerError::PathNotAccessible(format!(
|
||||
"no matching .gguf artifact found for {}",
|
||||
repo.as_str()
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ModelArtifactDownloader for HfModelArtifactDownloader {
|
||||
async fn resolve_hf_model(
|
||||
&self,
|
||||
repo: &HfModelRef,
|
||||
progress: std::sync::Arc<dyn Fn(ModelArtifactProgress) + Send + Sync>,
|
||||
cancel: ModelArtifactCancel,
|
||||
) -> Result<ModelArtifactResolution, ModelServerError> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(ModelServerError::Cancelled);
|
||||
}
|
||||
let path = self.cache_path_for(repo);
|
||||
if path.is_file() {
|
||||
return Ok(ModelArtifactResolution {
|
||||
path: model_path_from_pathbuf(path)?,
|
||||
cache_hit: true,
|
||||
});
|
||||
}
|
||||
|
||||
let filename = self.resolve_remote_filename(repo).await?;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(ModelServerError::Cancelled);
|
||||
}
|
||||
let (base, _) = split_hf_ref(repo);
|
||||
let url = format!(
|
||||
"https://huggingface.co/{base}/resolve/main/{}",
|
||||
url_path_segment(&filename)
|
||||
);
|
||||
let response = self
|
||||
.client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ModelServerError::Probe(e.to_string()))?;
|
||||
if !response.status().is_success() {
|
||||
return Err(ModelServerError::Probe(format!(
|
||||
"huggingface artifact download returned {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|e| ModelServerError::Store(e.to_string()))?;
|
||||
}
|
||||
let tmp_path = path.with_extension("gguf.part");
|
||||
let mut file = tokio::fs::File::create(&tmp_path)
|
||||
.await
|
||||
.map_err(|e| ModelServerError::Store(e.to_string()))?;
|
||||
let total = response.content_length();
|
||||
let mut downloaded = 0_u64;
|
||||
let mut stream = response.bytes_stream();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
if cancel.is_cancelled() {
|
||||
let _ = tokio::fs::remove_file(&tmp_path).await;
|
||||
return Err(ModelServerError::Cancelled);
|
||||
}
|
||||
let chunk = chunk.map_err(|e| ModelServerError::Probe(e.to_string()))?;
|
||||
file.write_all(&chunk)
|
||||
.await
|
||||
.map_err(|e| ModelServerError::Store(e.to_string()))?;
|
||||
downloaded += chunk.len() as u64;
|
||||
progress(ModelArtifactProgress {
|
||||
downloaded_bytes: Some(downloaded),
|
||||
total_bytes: total,
|
||||
source: Some(repo.as_str().to_owned()),
|
||||
});
|
||||
}
|
||||
file.flush()
|
||||
.await
|
||||
.map_err(|e| ModelServerError::Store(e.to_string()))?;
|
||||
drop(file);
|
||||
tokio::fs::rename(&tmp_path, &path)
|
||||
.await
|
||||
.map_err(|e| ModelServerError::Store(e.to_string()))?;
|
||||
Ok(ModelArtifactResolution {
|
||||
path: model_path_from_pathbuf(path)?,
|
||||
cache_hit: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HfModelMetadata {
|
||||
#[serde(default)]
|
||||
siblings: Vec<HfSibling>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HfSibling {
|
||||
#[serde(rename = "rfilename")]
|
||||
filename: String,
|
||||
}
|
||||
|
||||
fn split_hf_ref(repo: &HfModelRef) -> (&str, Option<&str>) {
|
||||
repo.as_str()
|
||||
.split_once(':')
|
||||
.map_or((repo.as_str(), None), |(base, quant)| (base, Some(quant)))
|
||||
}
|
||||
|
||||
fn select_gguf_file(siblings: &[HfSibling], quant: Option<&str>) -> Option<String> {
|
||||
let mut ggufs: Vec<&str> = siblings
|
||||
.iter()
|
||||
.map(|sibling| sibling.filename.as_str())
|
||||
.filter(|filename| filename.ends_with(".gguf"))
|
||||
.collect();
|
||||
ggufs.sort_unstable();
|
||||
let Some(quant) = quant else {
|
||||
return ggufs.first().map(|filename| (*filename).to_owned());
|
||||
};
|
||||
let quant = quant.to_ascii_lowercase();
|
||||
ggufs
|
||||
.into_iter()
|
||||
.find(|filename| filename.to_ascii_lowercase().contains(&quant))
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
fn safe_cache_component(raw: &str) -> String {
|
||||
raw.chars()
|
||||
.map(|ch| match ch {
|
||||
'A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '_' | '-' => ch,
|
||||
_ => '_',
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn url_path_segment(raw: &str) -> String {
|
||||
raw.split('/')
|
||||
.map(url_component)
|
||||
.collect::<Vec<_>>()
|
||||
.join("/")
|
||||
}
|
||||
|
||||
fn url_component(raw: &str) -> String {
|
||||
raw.bytes()
|
||||
.flat_map(|byte| match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'.' | b'_' | b'-' => {
|
||||
vec![byte as char]
|
||||
}
|
||||
other => format!("%{other:02X}").chars().collect(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn model_path_from_pathbuf(path: PathBuf) -> Result<ModelPath, ModelServerError> {
|
||||
ModelPath::new(path.to_string_lossy().into_owned())
|
||||
.map_err(|e| ModelServerError::Invalid(e.to_string()))
|
||||
}
|
||||
|
||||
/// Builds `llama-server` argv without shell interpolation.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct LlamaCppRuntime;
|
||||
|
||||
@ -7,9 +7,14 @@ use domain::model_server::{
|
||||
ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig,
|
||||
LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelSource, StopPolicy,
|
||||
};
|
||||
use domain::ports::{FileSystem, ModelServerRegistry, ModelServerRuntime, RemotePath};
|
||||
use domain::ports::{
|
||||
FileSystem, ModelArtifactCancel, ModelArtifactDownloader, ModelServerRegistry,
|
||||
ModelServerRuntime, RemotePath,
|
||||
};
|
||||
use domain::LocalModelServerId;
|
||||
use infrastructure::{FsModelServerRegistry, LlamaCppRuntime, LocalFileSystem};
|
||||
use infrastructure::{
|
||||
FsModelServerRegistry, HfModelArtifactDownloader, LlamaCppRuntime, LocalFileSystem,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
@ -28,6 +33,10 @@ impl TempDir {
|
||||
fn child(&self, name: &str) -> RemotePath {
|
||||
RemotePath::new(self.0.join(name).to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
fn path(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
@ -189,3 +198,29 @@ async fn fs_model_server_registry_migrates_v1_json_to_v2() {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hf_model_artifact_downloader_resolves_deterministic_local_cache_hit_without_network() {
|
||||
let tmp = TempDir::new();
|
||||
let downloader = HfModelArtifactDownloader::new(tmp.path());
|
||||
let repo = HfModelRef::new("Qwen/Qwen3-Coder:Q4_K_M").unwrap();
|
||||
let path = downloader.cache_path_for(&repo);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, b"gguf").unwrap();
|
||||
|
||||
let resolution = downloader
|
||||
.resolve_hf_model(
|
||||
&repo,
|
||||
Arc::new(|_| panic!("cache hit must not report progress")),
|
||||
ModelArtifactCancel::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(resolution.cache_hit);
|
||||
assert_eq!(resolution.path.as_str(), path.to_string_lossy());
|
||||
assert_eq!(
|
||||
path.strip_prefix(tmp.path()).unwrap(),
|
||||
std::path::Path::new("Qwen--Qwen3-Coder").join("Q4_K_M.gguf")
|
||||
);
|
||||
}
|
||||
|
||||
@ -11,9 +11,14 @@ export type { ModelServerLaunchBadgeProps } from "./ModelServerLaunchBadge";
|
||||
export {
|
||||
correlateModelServerStatus,
|
||||
modelServerOverlayText,
|
||||
describeModelServerDownload,
|
||||
formatBytes,
|
||||
useModelServerLaunchState,
|
||||
} from "./modelServerLaunch";
|
||||
export type { ModelServerLaunchState } from "./modelServerLaunch";
|
||||
export type {
|
||||
ModelServerLaunchState,
|
||||
ModelServerDownloadProgress,
|
||||
} from "./modelServerLaunch";
|
||||
export { ResumeProjectPanel } from "./ResumeProjectPanel";
|
||||
export type { ResumeProjectPanelProps } from "./ResumeProjectPanel";
|
||||
export { useResumeProject } from "./useResumeProject";
|
||||
|
||||
109
frontend/src/features/agents/modelServerLaunch.test.ts
Normal file
109
frontend/src/features/agents/modelServerLaunch.test.ts
Normal file
@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Ticket #54 F2 — pure download-progress formatting for the model-server overlay.
|
||||
* These lock the wire→display mapping independently of React: byte formatting,
|
||||
* the determinate/indeterminate split, and the "no fake %" rule.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import type { ModelServerStatus } from "@/domain";
|
||||
import {
|
||||
formatBytes,
|
||||
describeModelServerDownload,
|
||||
modelServerOverlayText,
|
||||
} from "./modelServerLaunch";
|
||||
|
||||
function downloading(
|
||||
fields: Partial<{
|
||||
downloadedBytes: number | null;
|
||||
totalBytes: number | null;
|
||||
percent: number | null;
|
||||
source: string | null;
|
||||
}> = {},
|
||||
): ModelServerStatus {
|
||||
return {
|
||||
state: "downloading",
|
||||
downloadedBytes: null,
|
||||
totalBytes: null,
|
||||
percent: null,
|
||||
source: null,
|
||||
...fields,
|
||||
};
|
||||
}
|
||||
|
||||
describe("formatBytes", () => {
|
||||
it("shows whole bytes and SI-scaled larger units", () => {
|
||||
expect(formatBytes(0)).toBe("0 o");
|
||||
expect(formatBytes(512)).toBe("512 o");
|
||||
expect(formatBytes(1000)).toBe("1 Ko");
|
||||
expect(formatBytes(1_500_000)).toBe("1.5 Mo");
|
||||
expect(formatBytes(4_200_000_000)).toBe("4.2 Go");
|
||||
});
|
||||
|
||||
it("guards non-finite / negative", () => {
|
||||
expect(formatBytes(-5)).toBe("0 o");
|
||||
expect(formatBytes(Number.NaN)).toBe("0 o");
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeModelServerDownload", () => {
|
||||
it("returns null for every non-downloading state", () => {
|
||||
expect(describeModelServerDownload(undefined)).toBeNull();
|
||||
expect(describeModelServerDownload({ state: "probing" })).toBeNull();
|
||||
expect(describeModelServerDownload({ state: "starting" })).toBeNull();
|
||||
expect(
|
||||
describeModelServerDownload({ state: "ready", reused: false }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("exposes percent + a 'X / Y' bytes label when both bounds are known", () => {
|
||||
const p = describeModelServerDownload(
|
||||
downloading({
|
||||
downloadedBytes: 1_500_000,
|
||||
totalBytes: 3_000_000,
|
||||
percent: 50,
|
||||
source: "unsloth/Qwen3-Coder-30B",
|
||||
}),
|
||||
);
|
||||
expect(p).toEqual({
|
||||
percent: 50,
|
||||
bytesLabel: "1.5 Mo / 3 Mo",
|
||||
source: "unsloth/Qwen3-Coder-30B",
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps percent to 0..100", () => {
|
||||
expect(describeModelServerDownload(downloading({ percent: 137 }))?.percent).toBe(
|
||||
100,
|
||||
);
|
||||
expect(describeModelServerDownload(downloading({ percent: -3 }))?.percent).toBe(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it("is indeterminate (percent null) when the total is unknown", () => {
|
||||
const p = describeModelServerDownload(
|
||||
downloading({ downloadedBytes: 800_000, totalBytes: null, percent: null }),
|
||||
);
|
||||
expect(p).toEqual({ percent: null, bytesLabel: "800 Ko", source: null });
|
||||
});
|
||||
|
||||
it("has no bytes label when nothing about size is known", () => {
|
||||
expect(describeModelServerDownload(downloading())).toEqual({
|
||||
percent: null,
|
||||
bytesLabel: null,
|
||||
source: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("modelServerOverlayText (unchanged F1 titles)", () => {
|
||||
it("keeps the download / loading titles", () => {
|
||||
expect(modelServerOverlayText(downloading())).toBe(
|
||||
"Téléchargement du modèle…",
|
||||
);
|
||||
expect(modelServerOverlayText({ state: "starting" })).toBe(
|
||||
"Chargement du serveur…",
|
||||
);
|
||||
expect(modelServerOverlayText({ state: "ready", reused: true })).toBeNull();
|
||||
});
|
||||
});
|
||||
@ -37,9 +37,9 @@ export function correlateModelServerStatus(
|
||||
* - `downloading` → "Téléchargement du modèle…",
|
||||
* - `starting`/`probing` → "Chargement du serveur…".
|
||||
*
|
||||
* F1 MVP ignores the progress fields (bytes/percent) — the bar/% is the F2
|
||||
* stretch. This is the single predicate that decides whether the launch overlay
|
||||
* covers a cell.
|
||||
* This is the single predicate that decides whether the launch overlay covers a
|
||||
* cell (and supplies its title). The download *progress* (bar/%/bytes, F2) is a
|
||||
* separate, additive concern — see {@link describeModelServerDownload}.
|
||||
*/
|
||||
export function modelServerOverlayText(
|
||||
status: ModelServerStatus | undefined,
|
||||
@ -56,6 +56,60 @@ export function modelServerOverlayText(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable byte size, decimal (SI) units so it matches what download UIs
|
||||
* and llama.cpp report (`1 Mo = 1000 Ko`). Bytes are shown whole; larger units
|
||||
* keep one decimal, trailing `.0` trimmed. Guards against non-finite/negative
|
||||
* inputs (→ "0 o").
|
||||
*/
|
||||
export function formatBytes(n: number): string {
|
||||
if (!Number.isFinite(n) || n <= 0) return "0 o";
|
||||
const units = ["o", "Ko", "Mo", "Go", "To"];
|
||||
let value = n;
|
||||
let unit = 0;
|
||||
while (value >= 1000 && unit < units.length - 1) {
|
||||
value /= 1000;
|
||||
unit += 1;
|
||||
}
|
||||
const rounded =
|
||||
unit === 0 ? Math.round(value) : Math.round(value * 10) / 10;
|
||||
return `${rounded} ${units[unit]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download progress detail extracted from a `downloading` status (F2), or `null`
|
||||
* for any other state (`starting`/`probing`/`ready`/`failed` → no bar). Mirrors
|
||||
* the wire shape without inventing data:
|
||||
* - `percent`: the backend's completion % clamped to `0..100` when the total is
|
||||
* known, else `null` → the overlay shows an **indeterminate** bar (no fake %);
|
||||
* - `bytesLabel`: `"X Mo / Y Mo"` when both bounds are known, `"X Mo"` when only
|
||||
* the downloaded amount is, else `null`;
|
||||
* - `source`: the remote model ref (e.g. HF `namespace/repo`) when present.
|
||||
*/
|
||||
export interface ModelServerDownloadProgress {
|
||||
percent: number | null;
|
||||
bytesLabel: string | null;
|
||||
source: string | null;
|
||||
}
|
||||
|
||||
export function describeModelServerDownload(
|
||||
status: ModelServerStatus | undefined,
|
||||
): ModelServerDownloadProgress | null {
|
||||
if (!status || status.state !== "downloading") return null;
|
||||
const { downloadedBytes, totalBytes, percent, source } = status;
|
||||
const bytesLabel =
|
||||
downloadedBytes != null && totalBytes != null
|
||||
? `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}`
|
||||
: downloadedBytes != null
|
||||
? formatBytes(downloadedBytes)
|
||||
: null;
|
||||
return {
|
||||
percent: percent != null ? Math.min(100, Math.max(0, percent)) : null,
|
||||
bytesLabel,
|
||||
source: source ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** What {@link useModelServerLaunchState} exposes to a consumer. */
|
||||
export interface ModelServerLaunchState {
|
||||
/**
|
||||
|
||||
@ -12,7 +12,17 @@
|
||||
* event on that server. xterm is stubbed so the terminal mounts under jsdom.
|
||||
*/
|
||||
import { beforeEach, describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, waitFor, act } from "@testing-library/react";
|
||||
import { render, screen, waitFor, configure } from "@testing-library/react";
|
||||
|
||||
// Delivery of a `modelServerStatusChanged` event is made deterministic by
|
||||
// `trackSubscriptions` (no lost event). What remains is *propagation* latency:
|
||||
// the overlay only shows once the hook's async profile + agent loads resolve, so
|
||||
// the assertion is a guaranteed-eventual state. Under the full-suite parallel
|
||||
// run (22 files) the worker's event loop can be starved past the 1000 ms default
|
||||
// async-util budget — a scheduling delay, not a logic race. A generous timeout
|
||||
// removes that flake without weakening any assertion (the state is guaranteed to
|
||||
// arrive; we only wait long enough for it under load).
|
||||
configure({ asyncUtilTimeout: 5000 });
|
||||
|
||||
vi.mock("@xterm/xterm", () => ({
|
||||
Terminal: class {
|
||||
@ -108,16 +118,35 @@ function renderGrid(gateways: Gateways) {
|
||||
);
|
||||
}
|
||||
|
||||
async function emit(
|
||||
/**
|
||||
* Emit `status` and KEEP re-emitting it until `check` passes (the emit runs
|
||||
* inside `waitFor`, whose async wrapper already provides `act`). This kills the
|
||||
* emit-vs-subscribe race at the root — with no need to identify *which*
|
||||
* `onDomainEvent` subscriber is the overlay hook's.
|
||||
*
|
||||
* Why the previous `waitSubscribed(count > 0)` gate was insufficient: several
|
||||
* components subscribe to the event stream, and the ones outside `LeafView`
|
||||
* mount *before* the async layout load brings `LeafView` (and its overlay hook)
|
||||
* into the tree — so the global subscriber count crosses 0 while the overlay
|
||||
* hook's own handler is not yet registered, and a single emit gated on it is
|
||||
* still dropped. Re-emitting is safe because the reducer stores an idempotent
|
||||
* per-server value: whichever attempt lands *after* the overlay hook subscribed
|
||||
* and its profile/agent loads resolved is the one that makes `check` pass; the
|
||||
* earlier (possibly dropped) attempts are no-ops. Used for BOTH appearance and
|
||||
* retraction assertions so no case can regress into the race.
|
||||
*/
|
||||
async function emitUntil(
|
||||
systemGateway: MockSystemGateway,
|
||||
status: ModelServerStatus,
|
||||
check: () => void,
|
||||
): Promise<void> {
|
||||
await act(async () => {
|
||||
await waitFor(() => {
|
||||
systemGateway.emit({
|
||||
type: "modelServerStatusChanged",
|
||||
serverId: SERVER_ID,
|
||||
status,
|
||||
});
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
@ -147,29 +176,28 @@ describe("LayoutGrid — model-server launch overlay (ticket #54)", () => {
|
||||
});
|
||||
|
||||
renderGrid(gateways);
|
||||
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
|
||||
|
||||
// No overlay before any status.
|
||||
// The cell exists but no status has been emitted yet → no overlay.
|
||||
await screen.findByTestId("layout-leaf");
|
||||
expect(screen.queryByTestId("model-server-overlay")).toBeNull();
|
||||
|
||||
// Downloading → overlay with the download wording.
|
||||
await emit(systemGateway, {
|
||||
await emitUntil(
|
||||
systemGateway,
|
||||
{
|
||||
state: "downloading",
|
||||
downloadedBytes: null,
|
||||
totalBytes: null,
|
||||
percent: null,
|
||||
source: null,
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("model-server-overlay")).toBeTruthy(),
|
||||
);
|
||||
expect(screen.getByTestId("model-server-overlay").textContent).toContain(
|
||||
"Téléchargement du modèle",
|
||||
},
|
||||
() =>
|
||||
expect(
|
||||
screen.getByTestId("model-server-overlay").textContent,
|
||||
).toContain("Téléchargement du modèle"),
|
||||
);
|
||||
|
||||
// Ready → overlay gone.
|
||||
await emit(systemGateway, { state: "ready", reused: false });
|
||||
await waitFor(() =>
|
||||
await emitUntil(systemGateway, { state: "ready", reused: false }, () =>
|
||||
expect(screen.queryByTestId("model-server-overlay")).toBeNull(),
|
||||
);
|
||||
});
|
||||
@ -194,27 +222,25 @@ describe("LayoutGrid — model-server launch overlay (ticket #54)", () => {
|
||||
});
|
||||
|
||||
renderGrid(gateways);
|
||||
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
|
||||
|
||||
await emit(systemGateway, { state: "probing" });
|
||||
await waitFor(() =>
|
||||
await emitUntil(systemGateway, { state: "probing" }, () =>
|
||||
expect(
|
||||
screen.getByTestId("model-server-overlay").textContent,
|
||||
).toContain("Chargement du serveur"),
|
||||
);
|
||||
|
||||
await emit(systemGateway, { state: "starting" });
|
||||
expect(screen.getByTestId("model-server-overlay").textContent).toContain(
|
||||
"Chargement du serveur",
|
||||
await emitUntil(systemGateway, { state: "starting" }, () =>
|
||||
expect(
|
||||
screen.getByTestId("model-server-overlay").textContent,
|
||||
).toContain("Chargement du serveur"),
|
||||
);
|
||||
// No progressbar for a non-download preparing state.
|
||||
expect(screen.queryByTestId("model-server-progress")).toBeNull();
|
||||
|
||||
await emit(systemGateway, {
|
||||
state: "failed",
|
||||
code: "spawn",
|
||||
message: "boom",
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("model-server-overlay")).toBeNull(),
|
||||
await emitUntil(
|
||||
systemGateway,
|
||||
{ state: "failed", code: "spawn", message: "boom" },
|
||||
() => expect(screen.queryByTestId("model-server-overlay")).toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
@ -255,25 +281,156 @@ describe("LayoutGrid — model-server launch overlay (ticket #54)", () => {
|
||||
});
|
||||
|
||||
renderGrid(gateways);
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByTestId("layout-leaf").length).toBe(2),
|
||||
);
|
||||
|
||||
await emit(systemGateway, {
|
||||
// Both cells carry the overlay from a single server event (re-emitted until
|
||||
// both cells' overlay hooks have subscribed and correlated the server).
|
||||
await emitUntil(
|
||||
systemGateway,
|
||||
{
|
||||
state: "downloading",
|
||||
downloadedBytes: null,
|
||||
totalBytes: null,
|
||||
percent: null,
|
||||
source: null,
|
||||
});
|
||||
// Both cells carry the overlay from a single server event.
|
||||
await waitFor(() =>
|
||||
},
|
||||
() =>
|
||||
expect(screen.getAllByTestId("model-server-overlay").length).toBe(2),
|
||||
);
|
||||
|
||||
await emit(systemGateway, { state: "ready", reused: true });
|
||||
await waitFor(() =>
|
||||
await emitUntil(systemGateway, { state: "ready", reused: true }, () =>
|
||||
expect(screen.queryAllByTestId("model-server-overlay").length).toBe(0),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/** Render a single pinned-agent cell and return its system gateway. */
|
||||
async function renderSinglePinnedCell(): Promise<MockSystemGateway> {
|
||||
const profileGateway = new MockProfileGateway();
|
||||
await profileGateway.saveProfile(localModelProfile("opencode-local"));
|
||||
const agentGateway = new MockAgentGateway();
|
||||
const agent = await agentGateway.createAgent("p1", {
|
||||
name: "Worker",
|
||||
profileId: "opencode-local",
|
||||
});
|
||||
const systemGateway = new MockSystemGateway();
|
||||
const gateways = makeGateways(agentGateway, profileGateway, systemGateway);
|
||||
const layout = gateways.layout as MockLayoutGateway;
|
||||
const leafId = leaves(await layout.loadLayout("p1"))[0].id;
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "setCellAgent",
|
||||
target: leafId,
|
||||
agent: agent.id,
|
||||
});
|
||||
renderGrid(gateways);
|
||||
// No subscription gate needed: every emission goes through `emitUntil`, which
|
||||
// re-emits until the overlay hook has subscribed and correlated the server.
|
||||
return systemGateway;
|
||||
}
|
||||
|
||||
describe("LayoutGrid — download progress (ticket #54 F2)", () => {
|
||||
it("known percent → determinate progressbar + % + formatted bytes + source", async () => {
|
||||
const systemGateway = await renderSinglePinnedCell();
|
||||
|
||||
await emitUntil(
|
||||
systemGateway,
|
||||
{
|
||||
state: "downloading",
|
||||
downloadedBytes: 1_500_000,
|
||||
totalBytes: 3_000_000,
|
||||
percent: 50,
|
||||
source: "unsloth/Qwen3-Coder-30B",
|
||||
},
|
||||
() =>
|
||||
expect(
|
||||
screen.getByTestId("model-server-progress").getAttribute("aria-valuenow"),
|
||||
).toBe("50"),
|
||||
);
|
||||
|
||||
const bar = screen.getByTestId("model-server-progress");
|
||||
expect(bar.getAttribute("role")).toBe("progressbar");
|
||||
expect(bar.getAttribute("aria-valuemin")).toBe("0");
|
||||
expect(bar.getAttribute("aria-valuemax")).toBe("100");
|
||||
|
||||
const label = screen.getByTestId("model-server-progress-label");
|
||||
expect(label.textContent).toContain("50 %");
|
||||
expect(label.textContent).toContain("1.5 Mo / 3 Mo");
|
||||
|
||||
expect(screen.getByTestId("model-server-source").textContent).toBe(
|
||||
"unsloth/Qwen3-Coder-30B",
|
||||
);
|
||||
});
|
||||
|
||||
it("unknown total → indeterminate bar, NO fake %, shows downloaded bytes", async () => {
|
||||
const systemGateway = await renderSinglePinnedCell();
|
||||
|
||||
await emitUntil(
|
||||
systemGateway,
|
||||
{
|
||||
state: "downloading",
|
||||
downloadedBytes: 800_000,
|
||||
totalBytes: null,
|
||||
percent: null,
|
||||
source: null,
|
||||
},
|
||||
() => expect(screen.getByTestId("model-server-progress")).toBeTruthy(),
|
||||
);
|
||||
|
||||
const bar = screen.getByTestId("model-server-progress");
|
||||
// Indeterminate: no aria-valuenow set.
|
||||
expect(bar.hasAttribute("aria-valuenow")).toBe(false);
|
||||
|
||||
const label = screen.getByTestId("model-server-progress-label");
|
||||
expect(label.textContent).not.toContain("%");
|
||||
expect(label.textContent).toContain("800 Ko");
|
||||
// No source line when the backend sends none.
|
||||
expect(screen.queryByTestId("model-server-source")).toBeNull();
|
||||
});
|
||||
|
||||
it("starting/probing show the title but NO progressbar", async () => {
|
||||
const systemGateway = await renderSinglePinnedCell();
|
||||
|
||||
await emitUntil(systemGateway, { state: "starting" }, () =>
|
||||
expect(
|
||||
screen.getByTestId("model-server-overlay").textContent,
|
||||
).toContain("Chargement du serveur"),
|
||||
);
|
||||
expect(screen.queryByTestId("model-server-progress")).toBeNull();
|
||||
});
|
||||
|
||||
it("later progress events update the bar in place", async () => {
|
||||
const systemGateway = await renderSinglePinnedCell();
|
||||
|
||||
await emitUntil(
|
||||
systemGateway,
|
||||
{
|
||||
state: "downloading",
|
||||
downloadedBytes: 1_000_000,
|
||||
totalBytes: 4_000_000,
|
||||
percent: 25,
|
||||
source: null,
|
||||
},
|
||||
() =>
|
||||
expect(
|
||||
screen.getByTestId("model-server-progress").getAttribute("aria-valuenow"),
|
||||
).toBe("25"),
|
||||
);
|
||||
|
||||
await emitUntil(
|
||||
systemGateway,
|
||||
{
|
||||
state: "downloading",
|
||||
downloadedBytes: 3_000_000,
|
||||
totalBytes: 4_000_000,
|
||||
percent: 75,
|
||||
source: null,
|
||||
},
|
||||
() =>
|
||||
expect(
|
||||
screen.getByTestId("model-server-progress").getAttribute("aria-valuenow"),
|
||||
).toBe("75"),
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId("model-server-progress-label").textContent,
|
||||
).toContain("3 Mo / 4 Mo");
|
||||
});
|
||||
});
|
||||
|
||||
@ -40,6 +40,7 @@ import {
|
||||
} from "@/features/announcements";
|
||||
import {
|
||||
modelServerOverlayText,
|
||||
describeModelServerDownload,
|
||||
useModelServerLaunchState,
|
||||
} from "@/features/agents";
|
||||
import { leaves, normalizeWeights, resizeAdjacent } from "./layout";
|
||||
@ -382,7 +383,12 @@ function LeafView({
|
||||
const pinnedAgent = agentId
|
||||
? agents.find((a) => a.id === agentId)
|
||||
: undefined;
|
||||
const modelServerOverlay = modelServerOverlayText(statusForAgent(pinnedAgent));
|
||||
const modelServerStatus = statusForAgent(pinnedAgent);
|
||||
const modelServerOverlay = modelServerOverlayText(modelServerStatus);
|
||||
// F2 — download progress (bar/%/bytes/source) when the status carries it; null
|
||||
// for every non-`downloading` state, so `starting`/`probing` show just the
|
||||
// title. An indeterminate download (unknown total) yields `percent: null`.
|
||||
const modelServerDownload = describeModelServerDownload(modelServerStatus);
|
||||
const agentWork = agentId
|
||||
? workState?.agents.find((row) => row.agentId === agentId)
|
||||
: undefined;
|
||||
@ -976,16 +982,101 @@ function LeafView({
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 6,
|
||||
minWidth: 0,
|
||||
maxWidth: "80%",
|
||||
background: "var(--color-surface, #1e1e1e)",
|
||||
border: "1px solid var(--color-border, #3a3a3a)",
|
||||
borderRadius: 4,
|
||||
padding: "6px 12px",
|
||||
padding: "8px 14px",
|
||||
}}
|
||||
>
|
||||
{modelServerOverlay}
|
||||
<span>{modelServerOverlay}</span>
|
||||
{modelServerDownload && (
|
||||
<>
|
||||
{/* Determinate bar when the total is known; an indeterminate
|
||||
track (no aria-valuenow → ARIA indeterminate) otherwise. */}
|
||||
<div
|
||||
data-testid="model-server-progress"
|
||||
role="progressbar"
|
||||
aria-label="progression du téléchargement"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={
|
||||
modelServerDownload.percent != null
|
||||
? Math.round(modelServerDownload.percent)
|
||||
: undefined
|
||||
}
|
||||
style={{
|
||||
position: "relative",
|
||||
width: 220,
|
||||
maxWidth: "100%",
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
overflow: "hidden",
|
||||
background: "var(--color-raised, #2a2a2a)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={
|
||||
modelServerDownload.percent != null
|
||||
? {
|
||||
height: "100%",
|
||||
width: `${modelServerDownload.percent}%`,
|
||||
background: "var(--color-primary, #5b9bd5)",
|
||||
transition: "width 120ms linear",
|
||||
}
|
||||
: {
|
||||
// Indeterminate: a sliding sliver.
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: "40%",
|
||||
background: "var(--color-primary, #5b9bd5)",
|
||||
animation:
|
||||
"model-server-indeterminate 1.1s ease-in-out infinite",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{(modelServerDownload.percent != null ||
|
||||
modelServerDownload.bytesLabel) && (
|
||||
<span
|
||||
data-testid="model-server-progress-label"
|
||||
style={{ fontSize: 12, color: "var(--color-content-muted, #9a9a9a)" }}
|
||||
>
|
||||
{modelServerDownload.percent != null
|
||||
? `${Math.round(modelServerDownload.percent)} %`
|
||||
: ""}
|
||||
{modelServerDownload.percent != null &&
|
||||
modelServerDownload.bytesLabel
|
||||
? " · "
|
||||
: ""}
|
||||
{modelServerDownload.bytesLabel ?? ""}
|
||||
</span>
|
||||
)}
|
||||
{modelServerDownload.source && (
|
||||
<span
|
||||
data-testid="model-server-source"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--color-content-muted, #9a9a9a)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
title={modelServerDownload.source}
|
||||
>
|
||||
{modelServerDownload.source}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -74,3 +74,14 @@
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Indeterminate progress sliver (ticket #54 F2): a model download whose total
|
||||
size is unknown slides a sliver back and forth instead of showing a fake %. */
|
||||
@keyframes model-server-indeterminate {
|
||||
0% {
|
||||
left: -40%;
|
||||
}
|
||||
100% {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user