Merge feature/server-client-packaging into develop (#13)

Transport HTTP/WS et surface web du ticket #13, validé par l'utilisateur en
lancement Desktop (comportement conforme).

Contenu : endpoint PTY WebSocket authentifié et relais live-state/background
côté serveur (idea --serve), client web read-only, xterm.js sur WebSocket avec
reconnexion et scrollback, surfaces agent et live web, service same-origin de
dist et durcissement (logout, logs de sécurité, static hardening).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 10:32:46 +02:00
76 changed files with 8652 additions and 294 deletions

File diff suppressed because one or more lines are too long

View File

@ -53,3 +53,12 @@
- [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.
- [ticket56-visibleelsewhere-derives-from-layout](ticket56-visibleelsewhere-derives-from-layout.md) — Fix frontend du sélecteur d'agent : la désactivation « visible ailleurs » doit venir du layout courant, pas du dernier nodeId du live registry.
- [ticket13-f0-frontend-transport-inventory](ticket13-f0-frontend-transport-inventory.md) — Résultat de l'inventaire B0/F0 frontend du chantier client/serveur #13 — la frontière gateways transport-neutres existe déjà, F1 = ajout d'adapters HTTP+WS.
- [ticket13-f1-http-ws-adapter-delivered](ticket13-f1-http-ws-adapter-delivered.md) — Le 3e jeu d'adapters frontend (HTTP request/response + squelette WebSocket) du chantier client/serveur #13 est livré et vert, derrière les ports inchangés.
- [ticket13-f2-web-readonly-client-delivered](ticket13-f2-web-readonly-client-delivered.md) — Le client web read-only (pairing cookie, liste projets, ouverture read-only + snapshot work-state) du chantier client/serveur #13 est livré et vert.
- [ticket13-f3-xterm-websocket-delivered](ticket13-f3-xterm-websocket-delivered.md) — L'adapter WS terminal (round-trip xterm, reconnexion+replay, états connexion) du chantier client/serveur #13 est finalisé et vert côté frontend.
- [ticket13-f4-web-agent-surface-delivered](ticket13-f4-web-agent-surface-delivered.md) — L'agent CLI web (agent.launch → terminal.attached, réattache sans relance, cellule agent réutilisant TerminalView) du chantier client/serveur #13 est livré et vert côté frontend.
- [ticket13-f5-web-live-surfaces-delivered](ticket13-f5-web-live-surfaces-delivered.md) — Les surfaces live web (workstate live via event.domain, background tasks cancel/retry, inbox, re-synchro au reconnect) du chantier client/serveur #13 sont livrées et vertes.
- [frontend-uses-npm-not-pnpm](frontend-uses-npm-not-pnpm.md) — memory note frontend-uses-npm-not-pnpm
- [idea-distribution-strategy-desktop-vs-docker](idea-distribution-strategy-desktop-vs-docker.md) — memory note idea-distribution-strategy-desktop-vs-docker

View File

@ -0,0 +1,14 @@
---
name: frontend-uses-npm-not-pnpm
description: memory note frontend-uses-npm-not-pnpm
metadata:
type: project
---
Le dossier `frontend/` s'installe et se build avec **npm**, jamais pnpm (lockfile `package-lock.json`).
**Ne jamais lancer `pnpm` (ni `corepack pnpm build`, ni le wrapper `pnpm --dir frontend build`) dans `frontend/`** :
- `pnpm --dir frontend build` échoue d'abord sur un pré-check `pnpm install` (`ERR_PNPM_IGNORED_BUILDS` sur esbuild).
- Surtout, `pnpm install` **clobber** le `node_modules` npm : pnpm aplatit `vite@5.4.21` au top-level alors que `vitest@4.1.x` exige `vite ^6||^7||^8`. Résultat : `ERR_PACKAGE_PATH_NOT_EXPORTED: './module-runner'` dès que vitest spawn son worker pool (>3 fichiers de test). Avec npm, `vite@8` est imbriqué sous `node_modules/vitest/node_modules/vite`, donc tout marche.
- Réparation si le clobber arrive : `rm -f frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml && cd frontend && npm install`, puis `git checkout -- frontend/package-lock.json`.
Commandes correctes sans le wrapper cassé : `cd frontend && npx tsc --noEmit && npx vite build` (build) et `npx vitest run` (tests). Le script `pnpm build` du package.json ne doit pas être utilisé tel quel dans cet environnement.

View File

@ -0,0 +1,19 @@
---
name: idea-distribution-strategy-desktop-vs-docker
description: memory note idea-distribution-strategy-desktop-vs-docker
metadata:
type: project
---
Stratégie de distribution IdeA validée utilisateur (2026-07-16, suite #13) : DEUX offres au-dessus du MÊME cœur backend (pas de fork métier).
**Offre 1 — Full desktop** : binaire Tauri `app-tauri` actuel. AppImage Linux (existe, inchangée). Windows explicitement REPORTÉ (garder la portabilité à l'esprit, ne rien introduire de non-portable ; PTY = ConPTY à retester le jour venu). L'AppImage reste desktop PUR — elle ne bundle pas les assets web.
**Offre 2 — Serveur/client** : image Docker, bâtie sur un binaire serveur HEADLESS `idea-serve` SANS dépendance Tauri/WebKit (pas de dockerisation du binaire Tauri qui traînerait GTK/WebKit pour rien). Cadré faisable en hexagonal par Architect.
**Point pivot technique** : `server.rs` vit dans `crates/app-tauri` et réutilise indirectement la présentation Tauri via `crate::state::AppState`, `crate::dto::*`, `crate::events::DomainEventDto`, `crate::pty::PtyChunk`, `crate::mcp_endpoint::*`, `ResumeContext`. Le protocole HTTP/WS lui est déjà autonome. Le vrai travail = découpler les DTO (→ crate partagé `presentation-dto`/`backend-api`) puis extraire `crates/web-server` (sur `BackendCore`, pas `AppState`) puis le bin `idea-serve`.
**Tickets** : #65 (headless, high, L1 DTO→L2 web-server→L3 idea-serve), #66 (Docker, medium, L4 build web http→L5 Dockerfile volumes /data+/workspace→L6 agents CLI conteneur), #64 (folder browser web, dependsOn #66 pour /workspace), #67 (lock inter-process app-data-dir, low).
**Topologie de branches (décision utilisateur)** : chantier packaging sur `feature/server-client-packaging` (créée depuis `c246875`, tête de `feature/ticket13-pty-websocket`). PAS de merge dans develop tant que l'utilisateur n'a pas validé lui-même la NON-RÉGRESSION desktop-only. Flux final : `feature/server-client-packaging``feature/ticket13-pty-websocket``develop`. Git rebase la branche packaging si #13 avance.
Invariants : desktop AppImage ne perd RIEN ; aucun import Tauri dans le bin headless ; même cœur/use cases/stores ; contrat HTTP/WS inchangé sauf lot versionné. Voir [[frontend-uses-npm-not-pnpm]], [[appimage-build-no-strip-relr-dyn-fix]].

View File

@ -0,0 +1,23 @@
---
name: ticket13-f0-frontend-transport-inventory
description: Résultat de l'inventaire B0/F0 frontend du chantier client/serveur #13 — la frontière gateways transport-neutres existe déjà, F1 = ajout d'adapters HTTP+WS.
metadata:
type: reference
---
Inventaire B0/F0 (ticket #13 server/client mode), côté frontend TS/React. Doc : `docs/ticket13-f0-frontend-transport-inventory.md`.
**Constat clé** : la frontière « gateways TS transport-neutres » du plan est DÉJÀ réalisée et testée.
- `src/ports/index.ts` = 21 gateways sans Tauri ; toute la couche `features/`+`app/` en dépend via DI (`useGateways()`).
- `src/adapters/*` = seul lieu important `@tauri-apps/api`.
- Garde L1 `src/app/no-direct-invoke.test.ts` casse la CI si un fichier hors `adapters` importe Tauri / appelle `invoke(`.
- `src/app/di.tsx` `resolveGateways()` bifurque déjà Tauri vs mock → F1 = ajouter `createHttpWsGateways()` (3ᵉ impl).
**Donc F1 = écrire un 2ᵉ jeu d'adapters derrière des ports inchangés, aucun composant métier à réécrire.**
**Flux Channel (→ WebSocket)** : (1) `terminal.ts` PTY, (2) `agent.ts` PTY agent (réutilise `makeTerminalHandle`), (3) `ticket.ts` `sendTicketChat` `Channel<ReplyChunk>`. Tous modélisés côté port par callback `onData`/`onChunk`. Le contrat PTY WS du carnet mappe 1-pour-1 sur `TerminalHandle` (write/resize/detach/close, detach≠close, scrollback au reattach) → port inchangé pour F3. `TerminalView.tsx` ne connaît que le port.
**Event portable** : `system.onDomainEvent("domain://event")` (bus domaine) → WS serveur→client.
**Desktop-only à cadrer** : `system.pickFolder` (dossier = machine serveur ≠ client web → file-picker serveur), `window.ts` (WebviewWindow OS) et `focusedProject.ts` (multi-fenêtres OS) probablement hors V1 web. `uiPreferences` (localStorage) marche tel quel.
Convention DTO à préserver côté adapter HTTP : commandes snake_case, payloads camelCase souvent enveloppés `{ request: {...} }`.

View File

@ -0,0 +1,17 @@
---
name: ticket13-f1-http-ws-adapter-delivered
description: Le 3e jeu d'adapters frontend (HTTP request/response + squelette WebSocket) du chantier client/serveur #13 est livré et vert, derrière les ports inchangés.
metadata:
type: reference
---
Ticket #13 lot F1 livré sur feature/ticket13-server-client-mode. Nouveau dossier `frontend/src/adapters/http/` = 3e implémentation des gateways, à côté de Tauri (desktop) et mock.
**Fichiers** : `httpInvoker.ts` (POST /api/invoke {command,args}, ErrorDto→GatewayError, fetch injectable), `frames.ts` (contrat frames WS B0 + base64), `wsLiveClient.ts` (squelette WS multiplexé : corrélation id↔replyTo, routage terminal.output par session, dispatch event.domain), `requestResponseGateways.ts` (13 gateways R/R, commandes/enveloppes IDENTIQUES aux adapters Tauri), `streamGateways.ts` (HttpSystem/Agent/Ticket/Terminal + makeWsTerminalHandle detach≠close), `unsupported.ts` (Web{Window,FocusedProject,Remote}Gateway, code UNSUPPORTED_ON_WEB), `index.ts` (createHttpWsGateways(config?), endpoints via window.location). Tests : httpInvoker.test.ts, wsLiveClient.test.ts.
**Seam DI** : `app/di.tsx``resolveTransport()` 3-way (mock > http > tauri), web sélectionné par `VITE_TRANSPORT="http"`. Tauri reste défaut (desktop inchangé).
**Décision de contrat clé (à confirmer DevBackend)** : transport RPC générique `POST /api/invoke {command,args}` choisi PLUTÔT que l'arbre REST du brouillon B0 — préserve 1:1 tous les DTO Tauri, zéro divergence, bascule REST future ne touche que httpInvoker.ts. Autres points à trancher : placement token WS (pas en URL ; navigateur ne peut pas fixer d'en-tête upgrade), forme réponse terminal.open/agent.launch (ack terminal.attached avec session.sessionId), projectId absent du port openTerminal.
**Reporté F3/B5/B6** (TODO(F3/B5)) : round-trip xterm réel, reconnexion/backpressure, replay seq/gap, sink chat par-session pour sendTicketChat, agents structurés. Pas de serveur avant B3/B4.
État : build vert (tsc+vite), garde no-direct-invoke verte, 77 fichiers/724 tests verts. Voir [[ticket13-f0-frontend-transport-inventory]].

View File

@ -0,0 +1,19 @@
---
name: ticket13-f2-web-readonly-client-delivered
description: Le client web read-only (pairing cookie, liste projets, ouverture read-only + snapshot work-state) du chantier client/serveur #13 est livré et vert.
metadata:
type: reference
---
Ticket #13 lot F2 livré sur feature/ticket13-server-client-mode. Clôture frontend du premier incrément livrable (B0→B4/F2).
**Nouveau** : `adapters/http/webSession.ts` (WebSession : flag localStorage « paired » — PAS le cookie HttpOnly ; `pair(code)`→POST /api/pair ; `notifyUnauthorized()` clear+notify ; singleton `getWebSession()`), `features/web/` (PairingScreen, WebWorkspace read-only, WebApp gate). Tests : webSession.test.ts, WebApp.test.tsx.
**Modifié** : httpInvoker (credentials same-origin + callback onUnauthorized sur 401), http/index.ts (câble onUnauthorized→webSession), app/main.tsx (monte <WebApp/> si resolveTransport()==="http", desktop inchangé).
**Flux** : non-paired→PairingScreen ; pair OK (cookie HttpOnly posé serveur)→WebWorkspace ; 401 d'un /api/invoke→retour pairing ; « se déconnecter »=clear flag local (révocation serveur=B8). Cookie jamais lisible en JS (HttpOnly) : on se fie au 200 + flag de routage.
**Read-only** : WebWorkspace n'appelle QUE list_projects, open_project, get_project_work_state (via gateways DI). N'appelle PAS onDomainEvent/health/firstRunState → aucune commande hors-allowlist, pas de WS (live update = F3/B5, snapshot ponctuel pour l'instant).
**À confirmer B4** : get_project_work_state dans l'allowlist ; open_project sans effet de bord dangereux en read-only ; 401 (pas 403) sur cookie manquant ; code HTTP mauvais code /api/pair (401/403 → « Code d'appairage invalide »).
Build vert, garde no-direct-invoke verte, 79 fichiers/736 tests verts, desktop inchangé. Suite de [[ticket13-f1-http-ws-adapter-delivered]] ; inventaire [[ticket13-f0-frontend-transport-inventory]].

View File

@ -0,0 +1,19 @@
---
name: ticket13-f3-xterm-websocket-delivered
description: L'adapter WS terminal (round-trip xterm, reconnexion+replay, états connexion) du chantier client/serveur #13 est finalisé et vert côté frontend.
metadata:
type: reference
---
Ticket #13 lot F3 livré sur feature/ticket13-pty-websocket. Finalise l'adapter WS terminal depuis le squelette F1. Travail 100% dans frontend/src/adapters/http/ ; port TerminalGateway/TerminalHandle et TerminalView INCHANGÉS.
**wsLiveClient.ts** : machine d'état connexion (connecting/connected/reconnecting/closed, getConnectionState()+onConnectionStateChange), reconnexion auto (backoff, setTimeout injectable) → re-attach_terminal avec lastSeq + repaint scrollback borné + notices « déconnecté »/« reconnecté » écrites dans xterm via le sink. Suivi des sessions terminales (map `terminals`) pour le replay. Routage terminal.status exited → notice + onStatus + untrack. openTerminal/attachTerminal/detachTerminal/closeTerminalSession haut-niveau. API bas-niveau F1 (setOutputSink/send/domain events) conservée pour agent/system gateways.
**streamGateways.ts** : HttpTerminalGateway délègue aux nouvelles méthodes ; makeWsTerminalHandle.detach→detachTerminal (untrack, PTY vivant), close→closeTerminalSession (tue).
**frames.ts** : AttachedPayload.status? + StatusPayload.
**Contrat B5 (server.rs) confirmé** : frames terminal.open{cwd,rows,cols} (pas de projectId)/attach{sessionId,rows,cols,lastSeq}/input{sessionId,bytesBase64}/resize/detach/close/ping ↔ terminal.attached{session,scrollback:[{seq,bytesBase64}],nextSeq,status,gap,assignedConversationId}/output{sessionId,seq,bytesBase64}/status{sessionId,status,exitCode}/error/pong. Ack unifié terminal.attached. Scrollback = 1 entrée seq:0 (tous octets) ou vide.
**Écarts B5 à arbitrer** : (1) pas de replay delta — le serveur rejoue TOUT le scrollback, lastSeq ne sert qu'au flag gap ⇒ duplication possible à la reconnexion (conforme « V1 scrollback borné »). (2) multi-onglets « dernier gagne » SILENCIEUX — l'attachement évincé ne reçoit aucune frame (pas de crash, mais pas d'indication). (3) indication d'état = notices dans xterm (port inchangé).
**Tests** : terminalGateway.test.ts (round-trip), wsLiveClientReconnect.test.ts (états/reconnexion/exited). Build vert, garde no-direct-invoke verte, 81 fichiers/743 tests verts, desktop inchangé.
**Bloqueur run live** (dette carnet, hors F3) : `idea --serve` ne sert pas les assets web same-origin ⇒ app web pas lançable en navigateur tant que le lot « servir dist/ » n'est pas fait. Suite de [[ticket13-f1-http-ws-adapter-delivered]], [[ticket13-f2-web-readonly-client-delivered]].

View File

@ -0,0 +1,19 @@
---
name: ticket13-f4-web-agent-surface-delivered
description: L'agent CLI web (agent.launch → terminal.attached, réattache sans relance, cellule agent réutilisant TerminalView) du chantier client/serveur #13 est livré et vert côté frontend.
metadata:
type: reference
---
Ticket #13 lot F4 livré sur feature/ticket13-pty-websocket. Rend la surface agent fonctionnelle en mode web via les gateways DI ; CLI serveur, web = affichage.
**Adapter** : wsLiveClient.launchAgent(params) réutilise attachInternal de F3 → frame agent.launch, ack unifié terminal.attached, session trackée dans la map `terminals` (reconnexion/replay/exited comme un terminal), renvoie {sessionId, scrollback, assignedConversationId, status}. HttpAgentGateway.launchAgent → ws.launchAgent (pose assignedConversationId sur le handle) ; HttpAgentGateway.reattach → ws.attachTerminal (frame terminal.attach, PAS de relance). Chemin bas-niveau F1 dupliqué supprimé.
**UI (câblage, pas de nouveau composant terminal)** : features/web/WebAgentCell.tsx réutilise TerminalView (agentMode) avec agent gateway DI comme open=launchAgent/reattach=reattach, persiste sessionId. WebWorkspace : affordance « Ouvrir » par agent du snapshot work-state → monte WebAgentCell.
**Contrat B6 (server.rs) confirmé, aucun écart** : agent.launch payload plat camelCase {projectId,agentId,nodeId,rows,cols,conversationId} → ack terminal.attached{assignedConversationId}. Agent structuré → erreur UNSUPPORTED (canal PTY-only) surfacée par la bannière TerminalView. Réattache = terminal.attach (no respawn). Singleton guard AGENT_ALREADY_RUNNING déjà géré par TerminalView.
**Points UI à signaler** : (1) l'affordance liste les agents du snapshot work-state ; lister tous les agents exigerait list_agents sur l'allowlist B4. (2) write-portal (injection délégation) NON câblé en web V1 (affichage + frappe seulement). (3) WebAgentCell ne gère pas de nœud layout.
**Tests** : adapters/http/agentGateway.test.ts (launch/assignedConversationId/reattach/input/output/resize/UNSUPPORTED/NOT_FOUND), WebApp.test.tsx (+ouverture cellule). Build vert, garde no-direct-invoke verte, 82 fichiers/749 tests verts, desktop inchangé.
**Bloqueur run live** (dette carnet, hors F4) : `idea --serve` ne sert pas les assets web same-origin ⇒ round-trip navigateur pas validable tant que le lot « servir dist/ » n'est pas fait. Suite de [[ticket13-f3-xterm-websocket-delivered]], [[ticket13-f1-http-ws-adapter-delivered]], [[ticket13-f2-web-readonly-client-delivered]].

View File

@ -0,0 +1,17 @@
---
name: ticket13-f5-web-live-surfaces-delivered
description: Les surfaces live web (workstate live via event.domain, background tasks cancel/retry, inbox, re-synchro au reconnect) du chantier client/serveur #13 sont livrées et vertes.
metadata:
type: reference
---
Ticket #13 lot F5 livré sur feature/ticket13-pty-websocket. Rend les surfaces live fonctionnelles en mode web.
**Réutilisation clé** : le hook desktop transport-neutre `features/workstate/useProjectWorkState` (refresh du read-model sur event.domain) est réutilisé TEL QUEL — c'est lui qui donne la parité live. PAS de réutilisation de ProjectWorkStatePanel (dépend de useLayout + attach/stop-vers-cellule, spécifiques au grid desktop, hors read-only). WebWorkspace rend une vue lean : agents live/idle/busy, background tasks (Cancel/Retry via workState gateway), inbox par agent, + cellule agent F4.
**Adapter** : wsLiveClient.needsReconnect() = terminals.size>0 OU domainEventHandler!=null → la reconnexion marche aussi pour un abonnement live sans terminal (le handler domaine persiste à travers la reconnexion). Nouveau webLive.ts = singleton getWebLiveClient()/setWebLiveClient() (createHttpWsGateways enregistre le ws) exposant l'état de connexion au feature web SANS le mettre dans le port SystemGateway. Nouveau hook features/web/useLiveReconnect.ts : re-refresh du read-model sur transition reconnecting→connected (events manqués pendant coupure ; récupération = re-fetch complet du snapshot, pas de replay serveur).
**À confirmer B7** : (1) cancel_background_task/retry_background_task sur l'allowlist web write. (2) inbox surfacée via le read-model workstate, PAS via un flux notifications distinct (si B7 en a un, non câblé). (3) re-synchro = re-fetch complet (workstate = snapshot complet), pas de delta par event.
**Tests** : WebWorkspaceLive.test.tsx (event.domain→refresh, background render+cancel, reconnect re-sync), wsLiveClientReconnect (reconnexion pour abonnement domaine). Build vert, garde no-direct-invoke verte, 83 fichiers/753 tests verts, desktop inchangé.
**Bloqueur run live** (dette carnet, hors F5) : `idea --serve` ne sert pas les assets web same-origin. Suite de [[ticket13-f4-web-agent-surface-delivered]], [[ticket13-f3-xterm-websocket-delivered]], [[ticket13-f2-web-readonly-client-delivered]].

View File

@ -0,0 +1,19 @@
---
name: ticket56-visibleelsewhere-derives-from-layout
description: Fix frontend du sélecteur d'agent : la désactivation « visible ailleurs » doit venir du layout courant, pas du dernier nodeId du live registry.
metadata:
type: reference
---
Bug #56 : agent X affiché en cellule A → A bascule sur Y → X désactivé (« visible ailleurs ») en cellule B alors que X n'est plus affiché nulle part.
Cause : le live registry garde X→nodeId A même après le swap ; `LayoutGrid.visibleElsewhere` lisait `live.nodeId` comme « affiché ici ».
Fix (frontend pur, ne tue pas la session) dans `frontend/src/features/layout/LayoutGrid.tsx` :
- `visibleElsewhere(candidate)` dérive du **layout courant** : truthy uniquement s'il existe une feuille visible ≠ cellule courante avec `leaf.agent === candidate`. Retourne `{ nodeId }`.
- `backgroundLive` utilise `!visibleElsewhere(candidate)` au lieu de `!visibleNodeIds.has(live.nodeId)` ; garde `live.nodeId === id` (anti-boucle self-launch).
- onChange du select : même dérivation ; sélection inchangée (attachLiveAgent si sessionId sinon setCellAgent).
- Prop `visibleNodeIds` supprimé (devenu mort) de tout le threading LayoutGrid→NodeView→Split/Grid/Leaf.
Invariant préservé : agent réellement épinglé dans une cellule visible reste NON sélectionnable ailleurs.
Piège de test : un test qui mocke `listLiveAgents` avec un nodeId visible mais **sans** épingler l'agent dans ce leaf ne désactive plus rien — il faut `setCellAgent` réel. Tests dans `singletonAgent.test.tsx` (describe « ticket #56 » + ajustement R0d). Suite frontend : 706 verts.

View File

@ -1,6 +1,54 @@
---
issueRef: "#13"
version: 4
updatedBy: {"kind":"user"}
updatedAt: 1783329397368
version: 18
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1784184002362
---
# Ticket #13 — Server/client mode (carnet de chantier)
Ticket gated : review agent-utilisateur faite 2026-07-15. Base develop. Aucune action sortante.
## Arbitrages produit validés (utilisateur)
- Remote perso, 1 SEUL user. Pas de multi-tenant. CLI natif via WS PTY, xterm.js inchangé, PTY serveur. SSH écarté.
- Desktop Tauri ET client/serveur sur cœur backend commun. Exposition Internet ⇒ TLS/reverse proxy obligatoire.
- Packaging `idea --serve`, artefact unique. Auth pairing code + cookie session, jamais dans l'URL.
- Multi-fenêtres OS HORS V1 web. B6→B8 + servir dist, validation live groupée à la fin.
## Contrat de transport figé (Architect)
- `POST /api/invoke {command,args}` (allowlist read-only). Cookie session `HttpOnly,Secure,SameSite=Strict` au pairing (`POST /api/pair`). `POST /api/logout` = révocation (B8). Cookie invalide→401 ; mauvais code→403 ; Origin refusée→403.
- Frames WS JSON base64, ack unifié `terminal.attached`, output APRÈS l'ack. structured→UNSUPPORTED.
- Flags : `--listen`, `--public-origin`, `--allow-remote`, `--trust-reverse-proxy`, `--app-data-dir`, `--web-root` (B8).
## Plan de lots — TOUS LES LOTS DEV LIVRÉS ✅
B0→B8 + F1→F6 livrés/verts/committés. + correctifs live écarts 1/2. Reste : finir la validation live groupée (utilisateur) puis merge develop + clôture.
## Livrés VERT
- develop (e457152) : B0 5505acc · B1 955db79 · B2 c8fef2a · F1 e0cdb4a · B3 4ed0b16 · B4 fa353f6 · F2 e500e31.
- feature/ticket13-pty-websocket : B5 917be99 · F3 7487902 · B6 6391d1c · F4 5254f16 · B7 1bc5217 · F5 dd1d083 · B8 d538808 · F6 6117177 · **fix live écarts 1+2 c9ce3d7**.
- B8 : sert dist/ same-origin, serve_static durci, `/api/logout`, logs sécurité, doc remote. 57/57.
- F6 : logout, 401→pairing, bannière reconnexion, serveur indispo. build+vitest 758/758.
## ⚙️ Validation live 1er passage (utilisateur) — 3 écarts remontés
1. **Erreur `__TAURI_INTERNALS__ undefined` + projets vides** = MA commande de test était incomplète (build sans `VITE_TRANSPORT=http` → build desktop dans le navigateur ; et `--app-data-dir` non pointé sur le dossier desktop). PAS un bug de code.
2. **Défaut app-data-dir désaligné** (RÉSOLU c9ce3d7) : `default_app_data_dir()` retournait `~/.local/share/IdeA` alors que le desktop = identifier tauri `app.idea.ide` (`~/.local/share/app.idea.ide`). Corrigé : précédence `--app-data-dir` > `IDEA_APP_DATA_DIR` > `$XDG_DATA_HOME/app.idea.ide` > `$HOME/.local/share/app.idea.ide` + log démarrage `idea --serve: app data dir = <path>`. Doc corrigée (npm + VITE_TRANSPORT=http + avertissement double-writer). Garde-fou lock inter-process app-data-dir = ticket court à créer AVANT d'ouvrir des écritures web.
3. **Bouton Browse (créer/ajouter projet) inopérant en web** = `pickFolder()``UNSUPPORTED_ON_WEB` (dialogue natif OS absent du navigateur). PAS régression : report documenté F1. Sélection d'un projet EXISTANT couverte par list_projects/open_project (OK une fois écart 2 corrigé). Créer un NOUVEAU projet en web = nouveau lot (folder browser serveur sandboxé + create_project write) → **ticket #64 créé** (dependsOn #13), cadré par Architect, hors #13.
## ⚠️ RESTE : re-passage validation live (utilisateur, HORS SANDBOX)
Commande corrigée :
1. `cd frontend && VITE_TRANSPORT=http npx vite build` (npm, jamais pnpm — voir mémoire `frontend-uses-npm-not-pnpm`).
2. Fermer l'app desktop, puis `cargo run --release -p app-tauri -- --serve --web-root frontend/dist --app-data-dir ~/.local/share/app.idea.ide` (pairing code + app data dir sur stderr).
3. Navigateur `http://127.0.0.1:17373` → écran pairing → code.
4. Vérifier : liste projets (existants visibles), ouvrir projet, terminal CLI (frappe→serveur, reload→réattache/scrollback), cellule agent, surfaces live (workstate/background Cancel-Retry/inbox), reconnexion, logout→pairing.
## Réserves / écarts V1 (acceptés)
- Replay V1 = repaint scrollback complet. Multi-onglets « dernier gagne » silencieux. F4 write-portal NON câblé web. Browse créer-projet web → #64. Lock double-writer app-data-dir → ticket court à créer.
## Branches
`feature/ticket13-pty-websocket` (courante). À merger dans develop après validation live OK.
## Prochaine étape
Re-passage validation live (commande corrigée ci-dessus). Si OK → Git merge dans develop → clôture #13. #64 (Browse web) à planifier après. Aucune action sortante sans validation utilisateur.
## Dette hors chantier
Warnings clippy pré-existants crates/domain (fileguard/profile/sprint).

View File

@ -2,15 +2,15 @@
id: "036783fa-9856-4643-8c28-653b93ac36e1"
number: 13
title: "Server/client mode"
status: "open"
status: "inProgress"
priority: "low"
sprint: "028179b1-eaf4-41e9-9c1f-7c37125117e6"
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: 1783184188272
updatedAt: 1783329397368
version: 4
updatedAt: 1784184002362
version: 18
---
J'aimerais pouvoir utiliser IdeA sous forme de client/server. C'est a dire que IdeA aurait son backend sur une machine et son interface qui serait un frontend web. Les agents etc seraient tous côté server. C'est a dire que la cli de Claude serait celle côté serveur, l'interface client ne serait que l'affichage frontend, une UI. Je pense que tout est faisable, il faudrait cependant parler du côté CLI. Est ce qu'il serait possible de garde rle CLI natif comme il est actuellement, de l'afficher côté client tout en gardant l'installation claude code, codex etc côté serveur ? Peut etre qu'en ssh ça passerait ? Ce ticket ne pourra pas être fait en autonomie par un agent sans une review agent-utilisateur avant.

View File

@ -1,8 +1,8 @@
---
issueRef: "#15"
version: 3
version: 5
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1783940786085
updatedAt: 1784093861343
---
## Cadrage Architect — DIFFÉRÉ (2026-07-13)

View File

@ -1,22 +1,28 @@
---
id: "5de121f3-1cd1-4a73-bcab-b0a43a7c257f"
number: 15
title: "Limites de session — re-livraison automatique parquée au reset (stretch B5, délégation durable)"
title: "[Bloqué par #7 — design durable] Limites de session — re-livraison auto parquée au reset (stretch B5)"
status: "open"
priority: "low"
sprint: "d8f3f37b-87ca-4509-9116-45a99bc711df"
sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2"
links: [{"target":"#7","kind":"dependsOn"}]
agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
createdAt: 1783208599385
updatedAt: 1783940786085
version: 3
updatedAt: 1784093861343
version: 5
---
Extrait du cadrage Architect du ticket #7 (baseline livrée : propagation inter-agent B1→B3 + F1/F2). Stretch non retenu dans #7.
⛔ STATUT (2026-07-15) : BLOQUÉ / PARQUÉ. Dépend de #7 qui est encore en QA (non stabilisé). Architect (triage sprint « Gestion des bugs ») : « le waiter A→B n'est pas parqué/re-livré, l'ask retourne encore une erreur Process ; à traiter APRÈS stabilisation QA de #7/LS7 ». De plus c'est un item de « délégation durable » (in-memory fragile aux redémarrages) qui relève d'un design dédié, pas d'une rustine. Non lançable tant que #7 n'est pas vert et que le design durable-delegation n'est pas cadré. Sorti de la production active du sprint.
--- Cadrage conservé ---
Objectif : quand A délègue à B via idea_ask_agent et que B atteint sa limite, au lieu de demander à A de ré-interroger après le reset, PARQUER le waiter de A par (requester,target) ; au execute_resume de B (reprise auto au reset), RE-LIVRER la tâche d'origine et router la complétion vers le waiter de A s'il est encore vivant (sinon drop, contrainte in-memory).
Coût/risque (Architect) : rapproche du modèle de « délégation durable » tout en restant in-memory → fragile aux redémarrages/interruptions de A, ré-introduit une forme de blocage long côté A. À rattacher au design durable-delegation-runtime-agent-identity-design plutôt que de le traiter en rustine in-memory.
État actuel (triage) : le rendez-vous délégué détecte RateLimited et arme une reprise de la cible (crates/application/src/orchestrator/service.rs:1854), execute_resume relance l'agent (crates/application/src/agent/session_limit.rs:226) ; mais le waiter A→B n'est pas parqué/re-livré.
Coût/risque (Architect) : rapproche du modèle « délégation durable » tout en restant in-memory → fragile aux redémarrages/interruptions de A, ré-introduit un blocage long côté A. À rattacher au design durable-delegation-runtime-agent-identity-design plutôt que traité en rustine in-memory.
Dépend de : #7 (baseline inter-agent B1→B3). Voir mémoire ticket7-session-limit-interagent-cadrage.

View File

@ -1,6 +1,6 @@
---
issueRef: "#2"
version: 2
updatedBy: {"kind":"user"}
updatedAt: 1783184142093
version: 6
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1784065385002
---

View File

@ -1,24 +1,39 @@
---
id: "0a492d45-e195-4df7-a2ad-65649372abf0"
number: 2
title: "Tâches de fond — dette A : PtyPort::wait/try_wait + tee live UI + robustesse détection de fin"
status: "open"
title: "Tâches de fond — dette A : découpler fin de process et flux output PTY (PtyPort::wait/try_wait)"
status: "closed"
priority: "low"
sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2"
links: []
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"user"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
createdAt: 1783082860742
updatedAt: 1783184142093
version: 2
updatedAt: 1784065385002
version: 6
---
Dette issue de l'arbitrage B8 (mémoire projet: b8-arbitration-outcomes, point 1).
Ticket #2 — Tâches de fond : découpler fin de process et flux output PTY.
B8 a été accepté sans rendu live des tâches de fond. Le cœur complétion→wake fonctionne, mais la détection de fin du runner repose sur l'EOF du stream PTY single-consumer, ce qui est fragile et empêche un tee UI (une re-souscription UI casserait la détection EOF).
REQUALIFIÉ (2026-07-14) : le hub broadcast PTY existe désormais (crates/infrastructure/src/pty/mod.rs), donc l'ancienne contrainte « pas de broadcast multi-consommateur » est OBSOLÈTE. Le volet « tee live UI » est sorti en sous-ticket B/F séparé (voir lien blocks). La dette restante ici est purement backend.
Reste à faire :
- Ajouter `PtyPort::wait` / `try_wait` pour découpler la détection d'exit de la consommation d'output (retirer l'EOF-comme-proxy-de-fin).
- Une fois découplé, brancher un tee de la sortie PTY vers l'UI pour le rendu live des tâches de fond (F-live).
- NE PAS introduire de broadcast multi-consommateur (décision Architect).
Constat : `CommandBackgroundRunner` détecte encore la fin d'une tâche en drainant `PtyPort::subscribe_output` jusqu'à EOF (crates/infrastructure/src/background_task/runner.rs:187), ce qui couple la lifecycle du process à la consommation de sortie et empêche un tee sans casser la détection.
Périmètre touchant un port figé (PtyPort) : cadrage Architect requis avant implémentation.
Attendu :
- Ajouter au port figé `PtyPort` (crates/domain/src/ports.rs:952) deux opérations explicites :
- `async fn wait(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError>` (attend la fin naturelle + status ; documenter l'idempotence : premier wait consomme, suivants retournent le status mémorisé).
- `fn try_wait(&self, handle: &PtyHandle) -> Result<Option<ExitStatus>, PtyError>` (non bloquant : Ok(None) si vivant, Ok(Some(status)) si terminé).
- `kill` reste « forcer l'arrêt puis retourner status » ; si déjà terminé, retourne le status mémorisé. NotFound si handle inconnu/purgé. L'exit status est mémorisé dans le registre live tant que la session existe (cohérence wait/try_wait/kill).
- Implémenter dans `PortablePtyAdapter` (état d'exit partagé, attendre le child sans dépendre du reader EOF, éviter double wait/kill) et dans tous les fakes de test.
- Migrer le runner : remplacer l'attente EOF (runner.rs:187) par une attente sur `pty.wait(&handle)`, concurrencée avec cancel/deadline. La sortie de completion reste prise depuis le scrollback borné ; le runner n'a plus besoin de subscribe_output pour savoir si le process est fini.
Impact contractuel : port figé modifié → changement source-breaking intra-workspace (tous les adapters/fakes ajoutent wait/try_wait), mais PAS de breaking IPC/front, pas de migration de données.
Lots : B1 wait/try_wait au port + fakes ; B2 impl PortablePtyAdapter ; B3 migrer CommandBackgroundRunner vers wait ; B4 tests. Taille M.
QA (point de vérité) :
- Fake PtyPort dont l'output n'est jamais drainé (ou consommé par 2 subscribers) mais `wait` résout → le runner complète quand même.
- Fake avec subscriber UI actif + runner → completion via `wait`, pas via EOF.
- Deadline : si `wait` ne résout pas avant deadline → runner retourne Expired et tue/cleanup correctement.
- Cancel : cancel gagne même si l'output continue.
- Portable PTY réel : commande courte (`echo hi`) → completion exit 0 sans dépendre d'un drain output.

View File

@ -1,6 +1,6 @@
---
issueRef: "#20"
version: 2
version: 5
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1783331176736
updatedAt: 1784049665491
---

View File

@ -2,16 +2,16 @@
id: "1be19ee5-fd03-42ea-8df6-da18704807a9"
number: 20
title: "[Backend] Dette ticket_list — matching #ref dans text + curseur opaque/stable"
status: "open"
status: "closed"
priority: "low"
sprint: null
sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2"
links: [{"target":"#18","kind":"relatesTo"}]
agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
createdAt: 1783331172226
updatedAt: 1783331176736
version: 2
updatedAt: 1784049665491
version: 5
---
Dette backend identifiée pendant le cadrage du sprint UI rework (gate G4), non bloquante pour la popup #18 qui démarre sur le contrat actuel.

View File

@ -1,6 +1,6 @@
---
issueRef: "#3"
version: 2
updatedBy: {"kind":"user"}
updatedAt: 1783184146749
version: 6
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1784093835732
---

View File

@ -1,22 +1,28 @@
---
id: "fedd343e-f9c1-467b-bab4-455f51907de7"
number: 3
title: "Tâches de fond — dette B : persistance sûre de l'invocation (retry-after-reboot, secrets) + énumération terminale/projet du store"
title: "[Différé — design à mûrir] Tâches de fond — dette B : retry durable après reboot"
status: "open"
priority: "low"
sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2"
links: []
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"user"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
createdAt: 1783082860758
updatedAt: 1783184146749
version: 2
updatedAt: 1784093835732
version: 6
---
Dette issue de l'arbitrage B8 (mémoire projet: b8-arbitration-outcomes, points 2 et 5).
Ticket #3 — Tâches de fond : retry après redémarrage sans fuite de secrets dans le projet.
En V1, le registre d'invocations pour le retry est in-memory et session-scoped (le SpawnSpec porte des secrets, interdit dans .ideai/background-tasks/*.json qui voyage avec le projet). Conséquences à résorber :
⛔ DÉCISION UTILISATEUR (2026-07-15) : DIFFÉRÉ. On ne persiste PAS de secrets en clair machine-local pour l'instant. À reprendre plus tard avec un vrai design sécurisé (keyring OS multiplateforme — Secret Service / Keychain / Credential Manager — ou modèle d'invocation déclaratif avec secret-refs re-résolus au retry depuis profil/env). Le retry-after-reboot reste indisponible en attendant. Ticket sorti de la production active du sprint « Gestion des bugs ».
- Retry-after-reboot : persistance SÛRE de l'invocation (redaction des secrets et/ou store machine-local hors projet) pour permettre le retry après un redémarrage d'IdeA.
- Énumération store : aujourd'hui `list_background_tasks` sans agentId ne renvoie que les complétions non livrées du projet ; le store n'énumère pas les tâches terminales/ouvertes par projet (historique completed/failed partiel). Enrichir BackgroundTaskStore pour l'énumération terminale/projet.
--- Cadrage conservé pour la reprise ---
Cadrage Architect requis (persistance de secrets = sensible).
REQUALIFIÉ (2026-07-14) :
- Volet « énumération terminale/projet du BackgroundTaskStore » : RÉSOLU par l'implémentation actuelle (store durable par projet sous <root>/.ideai/background-tasks, listes open par agent + completions non livrées, routage par projet). RETIRÉ du périmètre.
- Reste uniquement le retry après redémarrage. L'invocation d'origine (SpawnSpec) est conservée dans un registre in-memory session-scoped (BackgroundCommandArchive, crates/application/src/background/mod.rs:203) volontairement non persistée dans .ideai car elle peut contenir des secrets. Après reboot, une tâche persistée ne peut donc pas être relancée.
Option A (écartée pour l'instant par décision utilisateur) : store machine-local hors projet (app-data OS/Tauri), SpawnSpec complet par (project_id, task_id), permissions 0700/0600 best-effort, JSON projet sans secret, retry échouant explicitement si invocation absente. Simple (M) mais persiste des secrets en clair local → refusée à ce stade.
Piste retenue pour la reprise : keyring OS multiplateforme OU secret-refs déclaratifs (chantier plus large que « low », à cadrer comme design dédié le moment venu).

View File

@ -1,6 +1,6 @@
---
issueRef: "#31"
version: 1
version: 4
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1783491197541
updatedAt: 1784064442657
---

View File

@ -2,16 +2,16 @@
id: "83d320f1-766b-4744-b518-8e39a2518e9c"
number: 31
title: "Détection de profils séquentielle : N × 800 ms au pire, résultat potentiellement partiel"
status: "open"
status: "closed"
priority: "low"
sprint: null
sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2"
links: [{"target":"#28","kind":"relatesTo"}]
agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
createdAt: 1783491197541
updatedAt: 1783491197541
version: 1
updatedAt: 1784064442657
version: 4
---
## Origine
Relevé par Git en revue du diff du ticket #28 (merge 710fa8f), hors périmètre du fix.

View File

@ -1,6 +1,6 @@
---
issueRef: "#33"
version: 1
version: 4
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1783492190391
updatedAt: 1784048928225
---

View File

@ -2,16 +2,16 @@
id: "c3e1fbcc-cedc-48a5-a00e-b22ea8dc90de"
number: 33
title: "[Dette] Fallthrough silencieux du routage structuré (lifecycle.rs:1705)"
status: "open"
status: "closed"
priority: "high"
sprint: null
sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2"
links: [{"target":"#32","kind":"relatesTo"},{"target":"#14","kind":"relatesTo"}]
agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
createdAt: 1783492190391
updatedAt: 1783492190391
version: 1
updatedAt: 1784048928225
version: 4
---
Cause racine structurelle identifiée par Architect lors du cadrage de #32.

View File

@ -1,6 +1,6 @@
---
issueRef: "#34"
version: 1
version: 4
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1783492562056
updatedAt: 1784050010085
---

View File

@ -2,16 +2,16 @@
id: "8dca00bf-ec0f-4da1-a231-46b27c711b78"
number: 34
title: "[Risque] ChatBridge.scrollback non borné (croissance mémoire)"
status: "open"
status: "closed"
priority: "medium"
sprint: null
sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2"
links: [{"target":"#32","kind":"relatesTo"}]
agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
createdAt: 1783492562056
updatedAt: 1783492562056
version: 1
updatedAt: 1784050010085
version: 4
---
Identifié par Architect lors du cadrage de #32 (variante B).

View File

@ -1,8 +1,8 @@
---
issueRef: "#54"
version: 5
version: 6
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1783976286882
updatedAt: 1784018300921
---
## Cadrage Architect (2026-07-13)

View File

@ -2,7 +2,7 @@
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"
status: "closed"
priority: "medium"
sprint: "5afd6780-0f76-40d7-a10f-32ee52469d74"
links: []
@ -10,7 +10,7 @@ agentRefs: []
createdBy: {"kind":"user"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
createdAt: 1783965036142
updatedAt: 1783976286882
version: 5
updatedAt: 1784018300921
version: 6
---
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

View File

@ -0,0 +1,28 @@
---
issueRef: "#55"
version: 11
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1784097757001
---
## Réouverture (2026-07-15) — le fix 141c13d ne suffit pas
Le fix #55 (commit 141c13d) a supprimé le timeout **prématuré** (~5 s) en distinguant process vivant-en-warmup / mort, avec `ReadinessPolicy::warmup_deadline = 120 s`. Mais l'utilisateur reproduit toujours « model server error (timeout): readiness timed out » **au lancement d'IdeA**, et ça finit par marcher après plusieurs essais.
### Cause racine
`crates/application/src/model_server.rs``wait_for_started_server` (~l.437) : tant que process `Running` + endpoint `Unreachable`, poll jusqu'à `deadline = now + warmup_deadline` (120 s), puis `stop_started_server` + `ModelServerError::Timeout`.
- **1er lancement à froid** : page cache OS froid, GGUF multi-Go, chargement RAM/VRAM, I/O disque en concurrence avec le reste du boot IdeA ⇒ warmup > 120 s ⇒ timeout + kill.
- **Essais suivants** : fichier en page cache chaud ⇒ chargement < 120 s succès. Explique le « au bout de plusieurs essais ça finit ».
### Fix Lot 1 backend (livré, commit f7cae4c sur feature/ticket55-model-warmup-deadline)
Cadré par Architect (mémoire `architecture-local-model-readiness-timeout`), approche A configurable :
- Défaut `ReadinessPolicy::warmup_deadline` : 120 s **600 s** (`crates/application/src/model_server.rs`).
- Nouveau `LocalModelServerConfig::warmup_deadline_secs: Option<u64>`, rétrocompatible, validation stricte `[30, 1800]` (`crates/domain/src/model_server.rs`).
- Policy effective dérivée **par config serveur** (deadline par-serveur), `probe`/`backoff` gardés séparés. DTO IPC `warmupDeadlineSecs` (`app-tauri/dto.rs`).
- Contrat readiness inchangé : Exitedéchec rapide ; Running+HTTP OKprêt ; Running+HTTP KOcontinuer jusqu'à deadline ; Running au-delàstop+Timeout.
### QA — VERT (ports mockés)
`cargo test -p domain` (252), `-p application` (81 + 22 model_server ciblés), `-p app-tauri --test dto_model_servers` (6), `-p infrastructure model_server` (2), build OK. Tests clés : slow_local_warmup...succeeds_without_stop, warmup_deadline_reached_returns_timeout_and_stops, process_exit_during_warmup_fails_fast, effective_policy_uses_configured_deadline, default_is_ten_minutes, hors-bornesINVALID.
### RESTE À FAIRE — vérification manuelle utilisateur (bloquant merge)
Les tests prouvent le **mécanisme**, PAS que 600 s guérit le vrai cold-start `llama-server` (bind réel hors sandbox). Git a committé mais **retient le merge sur develop** jusqu'à confirmation « cold-start OK » de l'utilisateur (vrai lancement à froid, après reboot / cache purgé). develop non divergé (ce5aa28) merge --no-ff trivial au feu vert.

View File

@ -0,0 +1,17 @@
---
id: "9ab1eb21-d6dd-435a-b088-377f0ec7e04f"
number: 55
title: "[Bug] Error on loading local model"
status: "inProgress"
priority: "high"
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: 1784045935720
updatedAt: 1784097757001
version: 11
---
Quand jke cherche a lancer un modele local sur une cellule, il commence apr charger le serveur, ce qui est bon mais au bout de quelques seconde, le chargement du serveur disparait et j'ai cette erreur qui s'affiche en bandeau rouge: Échec du lancement de l'agent : model server error (timeout): readiness timed out.
Si j'insiste assez en changeant d'agent et en remettant l'agent, au bourt d'un moment ça fini par fonctionner

View File

@ -0,0 +1,6 @@
---
issueRef: "#56"
version: 6
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1784048212123
---

View File

@ -0,0 +1,16 @@
---
id: "12943bca-4c29-46ec-8cad-936c30a95d5e"
number: 56
title: "[Bug] selection desactivée d'agent non affichés"
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: 1784046090493
updatedAt: 1784048212123
version: 6
---
Si j'affiche un agent dans une cellule, que dans cette même cellule je change d'agent et que dans une autre cellule j'essaie d'afficher le premier agent, la selection d el'agent est désactivée comme si cet agent était déjà affiché dans une cellule alors que ce n'est pas le cas

View File

@ -0,0 +1,6 @@
---
issueRef: "#58"
version: 1
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1784064521992
---

View File

@ -0,0 +1,26 @@
---
id: "495699aa-d5d1-409f-8830-cdfaba7a4be3"
number: 58
title: "[B/F] Rendu live des tâches de fond — subscriber UI + canal IPC attachable au task output"
status: "open"
priority: "low"
sprint: null
links: [{"target":"#2","kind":"dependsOn"}]
agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
createdAt: 1784064521992
updatedAt: 1784064521992
version: 1
---
Sorti du ticket #2 lors de sa requalification (2026-07-14).
Le hub broadcast PTY (crates/infrastructure/src/pty/mod.rs) rend désormais possible un tee de la sortie d'une tâche de fond vers l'UI sans voler le flux au runner. Une fois #2 livré (découplage fin-de-process via PtyPort::wait, l'ajout d'un subscriber UI ne casse plus la détection de fin), brancher le rendu live.
Attendu :
- Backend : créer un subscriber `pty.subscribe_output(&handle)` pour la tâche de fond ; canal dédié par task vers le frontend (idéalement Tauri `Channel`, comme les terminaux) ; commande type `attach_background_task_output(taskId, channel)` (ou intégration au panneau workstate).
- Frontend : composant/UX affichant le flux live, gérant attach/detach, repaint via `scrollback` au (ré)attachement, état terminal.
QA : deux subscribers reçoivent les mêmes chunks ; le runner complète même pendant qu'un subscriber UI reste attaché ; reattach UI récupère le scrollback puis reçoit les nouveaux chunks.
Dépend de #2 (PtyPort::wait/try_wait + découplage runner).

View File

@ -0,0 +1,6 @@
---
issueRef: "#60"
version: 2
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1784095176619
---

View File

@ -0,0 +1,35 @@
---
id: "623b1719-c71d-448e-a0ef-3c7d5823b8a3"
number: 60
title: "[Bug] Assistant IA d'édition de ticket : aucune réponse dans la conversation (stream sans Final non signalé)"
status: "inProgress"
priority: "high"
sprint: null
links: [{"target":"#25","kind":"relatesTo"},{"target":"#27","kind":"relatesTo"}]
agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
createdAt: 1784094260499
updatedAt: 1784095176619
version: 2
---
Rapporté par l'utilisateur (2026-07-15) : dans l'édition d'un ticket, le bouton « Assistant IA » ouvre une conversation, mais l'envoi d'un message ne produit JAMAIS de réponse visible — la conversation reste muette. PROFIL UTILISÉ : modèle local OpenAI-compatible (llamacpp).
CAUSE (Architect) :
1. CONFIRMÉ code — silence sur stream sans Final : agent_send draine le flux et détache le canal SANS rien émettre si aucun ReplyChunk::Final n'est produit (commands.rs:1661-1691). Le frontend ne termine que sur `final` (useTicketAssistant.ts:138-144). + finally setBusy(false) prématuré (useTicketAssistant.ts:147-151).
2. PROBABLE runtime (local OpenAI-compatible) — Final VIDE : le chemin HTTP émet bien un Final quand le serveur renvoie choices[0].message.content ou SSE delta.content (openai_compat.rs:348-397/517-538/286-292), mais llama.cpp peut renvoyer un flux sans delta.content utile (reasoning_content / tool_calls / contenu vide) → ReplyEvent::Final { content: "" } → le frontend remplace le tour par une chaîne vide (useTicketAssistant.ts:138-140) = « muet ».
PÉRIMÈTRE DE CE TICKET (#60) — Lot A robustesse + gestion Final vide, B+F, S/M :
- Backend : étendre ReplyChunk avec Error { message } (crates/app-tauri/src/dto.rs). agent_send : si le stream finit sans Final → émettre ReplyChunk::Error avant detach_if. Traiter Final { content: "" } comme terminal visible (Error « réponse vide du modèle » ou fallback explicite). Si l'adapter dispose d'un reasoning_content non vide, envisager de le surfacer plutôt que de le jeter (à confirmer selon payload).
- Frontend : ReplyChunk TS + useTicketAssistant gère `error`, termine le pending, affiche le message, busy tombe sur final OU error, suppression du finally setBusy(false) prématuré.
Effet attendu : l'assistant ne reste JAMAIS muet — soit une réponse, soit une erreur/vide explicite visible.
QA :
- Backend agent_send : fake AgentSession renvoie Heartbeat puis EOF sans Final → le channel reçoit ReplyChunk::Error.
- Backend OpenAI-compatible : fake HTTP renvoie SSE [DONE] sans delta.content → terminal visible (erreur/non vide), jamais un tour muet.
- Frontend : sendTicketChat reçoit `error` → tour non pending, busy=false, message visible.
HORS PÉRIMÈTRE (→ ticket de suivi) : identité requester explicite (port AgentSessionFactory::start, factory.rs:153-157 dérive le requester du cwd → devient le n° de ticket au lieu de ticket-assistant:<project>:<issue>) + branchement de ToolPolicyRegistry sur l'invoker OpenAI-compatible (openai_tools.rs:92-145) + éventuel local_model_server_id sur HttpChatConfig. Concerne un port figé + la sécurité des tool calls.
Relations : #25, #27 (mêmes surfaces, déjà fermées).

View File

@ -0,0 +1,6 @@
---
issueRef: "#61"
version: 5
updatedBy: {"kind":"user"}
updatedAt: 1784182133142
---

View File

@ -0,0 +1,16 @@
---
id: "b0568a4e-4804-480c-ada4-bc9d6b7b40c6"
number: 61
title: "[UI] rafraichissement des cellule"
status: "open"
priority: "low"
sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2"
links: []
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
createdBy: {"kind":"user"}
updatedBy: {"kind":"user"}
createdAt: 1784095163730
updatedAt: 1784182133142
version: 5
---
Lorsque j'ajoute une nouvelle cellule ou que j'en enlève une, je suis obligé de redimentionner un coup la fenêtre ou les cellule pour rafraichir le scaling des cli

View File

@ -0,0 +1,6 @@
---
issueRef: "#62"
version: 1
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1784095187734
---

View File

@ -0,0 +1,31 @@
---
id: "dd897d74-4e88-4d34-a6fb-d7e7f330094f"
number: 62
title: "[Sécurité/Cohérence] Identité requester explicite pour les sessions structurées + policy des tools OpenAI-compatible"
status: "open"
priority: "medium"
sprint: null
links: [{"target":"#60","kind":"relatesTo"}]
agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
createdAt: 1784095187734
updatedAt: 1784095187734
version: 1
---
Sorti de #60 (assistant IA de ticket muet) — volet distinct, touchant un port figé et la sécurité des tool calls.
CONSTAT (Architect, cadrage #60) :
1. Le requester d'une session structurée est dérivé implicitement du nom du cwd/run dir (crates/infrastructure/src/session/factory.rs:153-157). Pour l'assistant de ticket, le requester devient donc le NUMÉRO DU TICKET, alors que la policy MCP est posée sur ticket-assistant:<project>:<issue> (crates/application/src/ticket_assistant.rs:100-119). Mismatch → incohérence d'attribution et policy potentiellement non appliquée.
2. L'invoker de tools OpenAI-compatible (AppOpenAiToolInvoker, openai_tools.rs:92-145) dispatch directement SANS consulter ToolPolicyRegistry, alors que le serveur MCP stdio applique bien la policy (mcp/server.rs:349-355, :470-512). Les tool calls d'un profil OpenAI-compatible ne sont donc pas soumis à la policy.
ATTENDU :
- Faire évoluer le port AgentSessionFactory::start pour recevoir une identité requester explicite (Option<&str> ou petit SessionIdentity) ; fallback sur cwd.file_name() seulement si absente. Impact : port figé → propagation aux impls/fakes.
- OpenTicketAssistant passe requester = ticket-assistant:<project>:<issue>.
- LaunchAgent passe l'agent id comme requester pour les cellules normales (au lieu de dépendre du nom du run dir).
- Brancher ToolPolicyRegistry sur l'invoker OpenAI-compatible (parité avec le serveur MCP stdio).
- Décider pour le modèle local managé : étendre HttpChatConfig avec local_model_server_id (appeler EnsureLocalModelServer avant OpenAiCompatibleSession::new) OU documenter que OpenAI-compatible = endpoint externe.
Cadrage Architect requis (port figé + sécurité). QA : un assistant ticket OpenAI-compatible qui appelle idea_ticket_update reçoit __ideaRequester = ticket-assistant:<project>:<issue> et la policy ticket est appliquée ; un tool refusé par la policy l'est aussi sur le chemin OpenAI-compatible.
Dépend de / relié à #60.

View File

@ -0,0 +1,6 @@
---
issueRef: "#63"
version: 1
updatedBy: {"kind":"user"}
updatedAt: 1784098438671
---

View File

@ -0,0 +1,15 @@
---
id: "b5753734-1474-48d5-af6c-4a41b78005dc"
number: 63
title: "Systeme de test de l'UI"
status: "open"
priority: "medium"
sprint: null
links: []
agentRefs: []
createdBy: {"kind":"user"}
updatedBy: {"kind":"user"}
createdAt: 1784098438671
updatedAt: 1784098438671
version: 1
---

View File

@ -0,0 +1,6 @@
---
issueRef: "#64"
version: 2
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1784187610934
---

View File

@ -0,0 +1,31 @@
---
id: "c8f1c5b3-7674-4c6c-bba5-bb2c5faf201b"
number: 64
title: "Web : créer/ajouter un projet depuis le navigateur (folder browser serveur)"
status: "open"
priority: "medium"
sprint: null
links: [{"target":"#13","kind":"dependsOn"},{"target":"#66","kind":"dependsOn"}]
agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
createdAt: 1784183727404
updatedAt: 1784187610934
version: 2
---
Issu de la validation live de #13 (mode client/serveur web). En web, le bouton « Browse » (choisir un dossier de projet) est inopérant : `pickFolder()` renvoie `UNSUPPORTED_ON_WEB` car il s'appuie sur le dialogue natif OS de Tauri, absent du navigateur. Sélectionner un projet EXISTANT est déjà couvert par la liste (list_projects/open_project). Il manque CRÉER/AJOUTER un projet = choisir un dossier côté SERVEUR.
Cadrage Architect (post-#13) : nouvelle surface backend exposant le filesystem serveur au navigateur, à sandboxer.
Backend :
- Commande read `list_server_dir` (racine parcourable explicite via `--server-browse-root PATH` / `IDEA_SERVER_BROWSE_ROOT` ; défaut prudent : aucun browsing ou $HOME en loopback ; jamais `/` implicite).
- `create_project` sur l'allowlist WRITE, avec sa propre validation de racine (ne fait pas confiance au folder browser).
- Invariants sécurité (mêmes gardes que serve_static) : canonicalisation browse_root + cible, refus si hors racine, refus `..`/segments vides/backslash/`%2e`/`%2f`/`%5c`, dossiers seulement, pas de symlink escape, dotdirs masqués (`hidden:true`), réponse bornée + tri stable.
DTO :
- ListServerDirRequest { path?: string } → ListServerDirResponse { root, current, parent|null, entries: [{name,path,kind:'directory',hidden}], canCreateProjectHere }
- CreateProjectRequest { name, root(absolu serveur validé) }
Découpage : B1 list_server_dir + tests traversal/symlink/bounds ; B2 allowlist write create_project + validation racine ; F1 nouveau port UI `ServerFolderGateway` (ne pas surcharger `SystemGateway.pickFolder()`) ; F2 composant folder browser web (desktop garde le picker natif) ; F3 tests adapter HTTP + vue projet.
Dépend de #13 (mode web) et du garde-fou lock inter-process app-data-dir (à créer) avant d'activer les écritures web.

View File

@ -0,0 +1,6 @@
---
issueRef: "#65"
version: 1
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1784187583229
---

View File

@ -0,0 +1,27 @@
---
id: "f9f7067b-92b7-4737-ad99-4487ad8c8b13"
number: 65
title: "Serveur headless : extraire idea-serve du binaire Tauri (cœur partagé)"
status: "open"
priority: "high"
sprint: null
links: [{"target":"#13","kind":"dependsOn"}]
agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
createdAt: 1784187583229
updatedAt: 1784187583229
version: 1
---
Objectif : produire un binaire serveur headless (`idea-serve`) SANS dépendance Tauri/WebKit, bâti sur le même cœur backend que le desktop (pas de fork). Prérequis de l'image Docker serveur/client. Cadré par Architect (suite #13).
Point pivot : `server.rs` vit aujourd'hui dans `crates/app-tauri` et réutilise indirectement la présentation Tauri via `crate::state::AppState`, `crate::dto::*`, `crate::events::DomainEventDto`, `crate::pty::PtyChunk`, `crate::mcp_endpoint::*`, `ResumeContext`. Le protocole HTTP/WS lui est déjà autonome.
Plan de lots (Architect) :
- **L1 — Extraire les DTO transport-neutres** dans un module/crate partagé (ex. `crates/presentation-dto` ou `backend-api`) : ErrorDto, Health*Dto, ProjectDto/ProjectListDto, ProjectWorkStateDto, BackgroundTaskDto, TerminalSessionDto, LaunchAgentRequestDto, OpenTerminalRequestDto, parseurs d'IDs, DomainEventDto. Adapter app-tauri::commands ET server.rs pour les consommer. JSON INCHANGÉ.
- **L2 — Extraire le serveur HTTP/WS** dans `crates/web-server` (lib) sans dépendance Tauri : déplacer server.rs, remplacer `AppState` par `BackendCore`, garder strictement routes + allowlist + sécurité B8 (POST /api/pair|invoke|logout, /api/ws, static same-origin). Aucune modif frontend.
- **L3 — Créer le bin `idea-serve`** (crate bin) : parse CLI/env → config → `web_server::run(config)`. Flags identiques à `idea --serve` (--listen/--app-data-dir/--web-root/--allow-remote/--public-origin/--trust-reverse-proxy). Défaut local = même app-data-dir que desktop (identifier app.idea.ide). Validation : le build/`ldd` ne tire PAS WebKitGTK. Garder éventuellement `app-tauri --serve` en compat dev, mais qui appelle `web-server`.
Invariants : desktop AppImage ne perd RIEN, aucun import Tauri dans le bin headless, même cœur/use cases/stores, contrat HTTP/WS inchangé. Non-régression desktop validée avant tout merge.
Dépend de #13.

View File

@ -0,0 +1,6 @@
---
issueRef: "#66"
version: 1
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1784187597004
---

View File

@ -0,0 +1,27 @@
---
id: "f805cd9b-8ecd-401a-8f03-665a27fe73cb"
number: 66
title: "Image Docker serveur/client IdeA"
status: "open"
priority: "medium"
sprint: null
links: [{"target":"#65","kind":"dependsOn"}]
agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
createdAt: 1784187597004
updatedAt: 1784187597004
version: 1
---
Objectif : livrer une image Docker exécutant le mode serveur/client d'IdeA, bâtie sur le binaire headless `idea-serve` (#65), pas sur le binaire Tauri. Cadré par Architect (suite #13).
Plan de lots (Architect) :
- **L4 — Build web transport HTTP** : produire les assets client Vite en `VITE_TRANSPORT=http` (npm, jamais pnpm), vérifier qu'aucun import Tauri ne fuit dans ce mode, packager dans `/usr/share/idea/web`.
- **L5 — Docker runtime** : Dockerfile multi-stage (build Rust headless + build frontend + runtime minimal Debian/Ubuntu selon deps PTY/process). Défauts : `IDEA_APP_DATA_DIR=/data`, `--listen 0.0.0.0:17373`, `--web-root /usr/share/idea/web`. Volumes `/data` (app-data : projects/profiles/templates/tasks/logs) et `/workspace` (projets manipulés par les agents). Entrypoint `idea-serve`. Healthcheck HTTP local. Conteneur reste HTTP interne ; reverse proxy TLS externe obligatoire en prod distante (`--allow-remote`/`--public-origin https`/`--trust-reverse-proxy`, doc B8). Pas de TLS applicatif V1.
- **L6 — Agents CLI en conteneur** : décider image minimale (profils détectés au runtime, exécutables attendus dans PATH) vs image `idea-server-agents` (CLIs redistribuables installées si licence OK). Seed profils compatibles conteneur. Documenter env (OPENAI_API_KEY, ANTHROPIC_API_KEY, vars opencode) et montages de credentials. Tester au moins un agent bout en bout. Hors périmètre : installer automatiquement des CLIs propriétaires sans validation licence.
Lock app-data-dir : peu critique en Docker mono-conteneur mono-writer ; multi-conteneurs sur le même /data explicitement hors support sans lock distribué.
Hors périmètre V1 : Kubernetes, multi-tenant, auth externe, TLS intégré.
Dépend de #65 (binaire headless).

View File

@ -0,0 +1,6 @@
---
issueRef: "#67"
version: 1
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1784187618710
---

View File

@ -0,0 +1,22 @@
---
id: "e3d16c70-01c5-448e-9b5e-37ba2ccbbb58"
number: 67
title: "Lock inter-process de l'app-data-dir (desktop ↔ idea --serve)"
status: "open"
priority: "low"
sprint: null
links: [{"target":"#13","kind":"dependsOn"}]
agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
createdAt: 1784187618710
updatedAt: 1784187618710
version: 1
---
Issu de la validation live #13. `idea --serve` et l'app desktop peuvent écrire le même app-data-dir (`~/.local/share/app.idea.ide`) simultanément — pas de lock inter-process → risque de corruption d'état (projects.json/profiles.json/tasks). Aujourd'hui contourné par une simple consigne de doc (« ne pas lancer les deux en même temps »).
À faire : garde-fou lock inter-process partagé desktop/serveur sur l'app-data-dir (le desktop DOIT le prendre aussi, un lock côté serveur seul ne suffit pas). À poser AVANT d'ouvrir des commandes write web au-delà de l'existant (cancel/retry background) — notamment avant `create_project` (#64).
Peu critique en Docker mono-conteneur mono-writer (cf. #66) ; surtout pertinent pour l'usage AppImage `--serve` local sur la même machine que le desktop. Cadrage lock détaillé à confirmer avec Architect au démarrage.
Dépend de #13.

View File

@ -1,3 +1,3 @@
{
"nextNumber": 55
"nextNumber": 68
}

View File

@ -16,26 +16,26 @@
{
"issueRef": "#2",
"path": "2",
"title": "Tâches de fond — dette A : PtyPort::wait/try_wait + tee live UI + robustesse détection de fin",
"status": "open",
"title": "Tâches de fond — dette A : découpler fin de process et flux output PTY (PtyPort::wait/try_wait)",
"status": "closed",
"priority": "low",
"sprint": null,
"sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2",
"assignedAgentIds": [
"a6ced819-b893-4213-b003-9e9dc79b9641"
],
"updatedAt": 1783184142093
"updatedAt": 1784065385002
},
{
"issueRef": "#3",
"path": "3",
"title": "Tâches de fond — dette B : persistance sûre de l'invocation (retry-after-reboot, secrets) + énumération terminale/projet du store",
"title": "[Différé — design à mûrir] Tâches de fond — dette B : retry durable après reboot",
"status": "open",
"priority": "low",
"sprint": null,
"sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2",
"assignedAgentIds": [
"a6ced819-b893-4213-b003-9e9dc79b9641"
],
"updatedAt": 1783184146749
"updatedAt": 1784093835732
},
{
"issueRef": "#4",
@ -149,13 +149,13 @@
"issueRef": "#13",
"path": "13",
"title": "Server/client mode",
"status": "open",
"status": "inProgress",
"priority": "low",
"sprint": "028179b1-eaf4-41e9-9c1f-7c37125117e6",
"assignedAgentIds": [
"a6ced819-b893-4213-b003-9e9dc79b9641"
],
"updatedAt": 1783329397368
"updatedAt": 1784184002362
},
{
"issueRef": "#14",
@ -172,12 +172,12 @@
{
"issueRef": "#15",
"path": "15",
"title": "Limites de session — re-livraison automatique parquée au reset (stretch B5, délégation durable)",
"title": "[Bloqué par #7 — design durable] Limites de session — re-livraison auto parquée au reset (stretch B5)",
"status": "open",
"priority": "low",
"sprint": "d8f3f37b-87ca-4509-9116-45a99bc711df",
"sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2",
"assignedAgentIds": [],
"updatedAt": 1783940786085
"updatedAt": 1784093861343
},
{
"issueRef": "#16",
@ -229,11 +229,11 @@
"issueRef": "#20",
"path": "20",
"title": "[Backend] Dette ticket_list — matching #ref dans text + curseur opaque/stable",
"status": "open",
"status": "closed",
"priority": "low",
"sprint": null,
"sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2",
"assignedAgentIds": [],
"updatedAt": 1783331176736
"updatedAt": 1784049665491
},
{
"issueRef": "#21",
@ -333,11 +333,11 @@
"issueRef": "#31",
"path": "31",
"title": "Détection de profils séquentielle : N × 800 ms au pire, résultat potentiellement partiel",
"status": "open",
"status": "closed",
"priority": "low",
"sprint": null,
"sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2",
"assignedAgentIds": [],
"updatedAt": 1783491197541
"updatedAt": 1784064442657
},
{
"issueRef": "#32",
@ -355,21 +355,21 @@
"issueRef": "#33",
"path": "33",
"title": "[Dette] Fallthrough silencieux du routage structuré (lifecycle.rs:1705)",
"status": "open",
"status": "closed",
"priority": "high",
"sprint": null,
"sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2",
"assignedAgentIds": [],
"updatedAt": 1783492190391
"updatedAt": 1784048928225
},
{
"issueRef": "#34",
"path": "34",
"title": "[Risque] ChatBridge.scrollback non borné (croissance mémoire)",
"status": "open",
"status": "closed",
"priority": "medium",
"sprint": null,
"sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2",
"assignedAgentIds": [],
"updatedAt": 1783492562056
"updatedAt": 1784050010085
},
{
"issueRef": "#35",
@ -581,11 +581,127 @@
"issueRef": "#54",
"path": "54",
"title": "[UI] Ajouter le handle du téléchargement des modeles lors du démarage llamacpp",
"status": "inProgress",
"status": "closed",
"priority": "medium",
"sprint": "5afd6780-0f76-40d7-a10f-32ee52469d74",
"assignedAgentIds": [],
"updatedAt": 1783976286882
"updatedAt": 1784018300921
},
{
"issueRef": "#55",
"path": "55",
"title": "[Bug] Error on loading local model",
"status": "inProgress",
"priority": "high",
"sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2",
"assignedAgentIds": [
"a6ced819-b893-4213-b003-9e9dc79b9641"
],
"updatedAt": 1784097757001
},
{
"issueRef": "#56",
"path": "56",
"title": "[Bug] selection desactivée d'agent non affichés",
"status": "closed",
"priority": "low",
"sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2",
"assignedAgentIds": [
"a6ced819-b893-4213-b003-9e9dc79b9641"
],
"updatedAt": 1784048212123
},
{
"issueRef": "#58",
"path": "58",
"title": "[B/F] Rendu live des tâches de fond — subscriber UI + canal IPC attachable au task output",
"status": "open",
"priority": "low",
"sprint": null,
"assignedAgentIds": [],
"updatedAt": 1784064521992
},
{
"issueRef": "#60",
"path": "60",
"title": "[Bug] Assistant IA d'édition de ticket : aucune réponse dans la conversation (stream sans Final non signalé)",
"status": "inProgress",
"priority": "high",
"sprint": null,
"assignedAgentIds": [],
"updatedAt": 1784095176619
},
{
"issueRef": "#61",
"path": "61",
"title": "[UI] rafraichissement des cellule",
"status": "open",
"priority": "low",
"sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2",
"assignedAgentIds": [
"a6ced819-b893-4213-b003-9e9dc79b9641"
],
"updatedAt": 1784182133142
},
{
"issueRef": "#62",
"path": "62",
"title": "[Sécurité/Cohérence] Identité requester explicite pour les sessions structurées + policy des tools OpenAI-compatible",
"status": "open",
"priority": "medium",
"sprint": null,
"assignedAgentIds": [],
"updatedAt": 1784095187734
},
{
"issueRef": "#63",
"path": "63",
"title": "Systeme de test de l'UI",
"status": "open",
"priority": "medium",
"sprint": null,
"assignedAgentIds": [],
"updatedAt": 1784098438671
},
{
"issueRef": "#64",
"path": "64",
"title": "Web : créer/ajouter un projet depuis le navigateur (folder browser serveur)",
"status": "open",
"priority": "medium",
"sprint": null,
"assignedAgentIds": [],
"updatedAt": 1784187610934
},
{
"issueRef": "#65",
"path": "65",
"title": "Serveur headless : extraire idea-serve du binaire Tauri (cœur partagé)",
"status": "open",
"priority": "high",
"sprint": null,
"assignedAgentIds": [],
"updatedAt": 1784187583229
},
{
"issueRef": "#66",
"path": "66",
"title": "Image Docker serveur/client IdeA",
"status": "open",
"priority": "medium",
"sprint": null,
"assignedAgentIds": [],
"updatedAt": 1784187597004
},
{
"issueRef": "#67",
"path": "67",
"title": "Lock inter-process de l'app-data-dir (desktop ↔ idea --serve)",
"status": "open",
"priority": "low",
"sprint": null,
"assignedAgentIds": [],
"updatedAt": 1784187618710
}
]
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,66 @@
# IdeA Server/Client Remote Deployment
`idea --serve` serves the web UI and the API from the same origin. The frontend
build must be available as a web root containing `index.html`.
## Local Development
The web build **must** be produced with the `http` transport, otherwise the
served `dist/` is a desktop (Tauri) build that fails in a plain browser with
`window.__TAURI_INTERNALS__ is undefined`. The frontend uses **npm**, not pnpm:
```bash
cd frontend && VITE_TRANSPORT=http npx vite build && cd ..
idea --serve --web-root frontend/dist --app-data-dir "$HOME/.local/share/app.idea.ide"
```
The default local listener is `127.0.0.1:17373`. Loopback development may use
plain HTTP; the session cookie is still `HttpOnly` and `SameSite=Strict`.
### App data directory (must match the desktop app)
`idea --serve` reads/writes the same on-disk data as the desktop app
(projects, profiles, templates). It resolves the app-data directory in this
order:
1. `--app-data-dir PATH`
2. `IDEA_APP_DATA_DIR`
3. platform default for the Tauri identifier `app.idea.ide`
(`$XDG_DATA_HOME/app.idea.ide`, else `$HOME/.local/share/app.idea.ide`)
The resolved path is printed at startup (`idea --serve: app data dir = …`).
> **Do not run the desktop app and `idea --serve` on the same app-data
> directory at the same time.** There is no inter-process lock yet, so two
> concurrent writers can corrupt state. Close the desktop app while serving.
## Packaged Artifact
The release artifact is still a single IdeA binary/AppImage. Packaging must copy
the frontend build output (`frontend/dist`) into the packaged `web/` resource
next to the binary. `idea --serve` resolves assets in this order:
1. `--web-root PATH`
2. `IDEA_WEB_ROOT`
3. packaged `web/`, then packaged `frontend/dist/`
4. development fallback `frontend/dist` relative to the current directory
If no `index.html` is found, the server refuses to start.
## Remote HTTPS
Public exposure requires a reverse proxy that terminates HTTPS:
```bash
idea --serve \
--listen 127.0.0.1:17373 \
--allow-remote \
--public-origin https://idea.example.com \
--trust-reverse-proxy
```
The proxy must forward the same origin for the SPA, `/api/*`, and `/api/ws`.
Do not expose the backend on a public non-loopback address without TLS proxying.
Secrets must never be placed in URLs; pairing uses `POST /api/pair` and then an
`HttpOnly`, `Secure`, `SameSite=Strict` session cookie.

View File

@ -0,0 +1,172 @@
/**
* F4 — the WS agent gateway round-trip against the B6 frame contract:
* `agent.launch` → unified `terminal.attached` (sessionId + assignedConversationId
* consumed), then output/input/resize reuse the terminal mechanics; agent
* re-attach uses `terminal.attach` (no relaunch) and returns scrollback; a
* structured agent (`UNSUPPORTED`) and a dead session (`NOT_FOUND`) reject clearly.
*/
import { describe, it, expect, vi } from "vitest";
import { HttpAgentGateway } from "./streamGateways";
import { HttpInvoker } from "./httpInvoker";
import { WsLiveClient, type WebSocketLike } from "./wsLiveClient";
import { bytesToBase64 } from "./frames";
class FakeSocket implements WebSocketLike {
sent: string[] = [];
onopen: (() => void) | null = null;
onmessage: ((event: { data: string }) => void) | null = null;
onerror: ((event: unknown) => void) | null = null;
onclose: (() => void) | null = null;
send(data: string): void {
this.sent.push(data);
}
close(): void {
this.onclose?.();
}
receive(frame: unknown): void {
this.onmessage?.({ data: JSON.stringify(frame) });
}
}
function gateway(): { gw: HttpAgentGateway; sockets: FakeSocket[] } {
const sockets: FakeSocket[] = [];
const ws = new WsLiveClient({
wsUrl: "wss://host",
socketFactory: () => {
const s = new FakeSocket();
sockets.push(s);
queueMicrotask(() => s.onopen?.());
return s;
},
});
// The HTTP invoker is unused by the WS paths under test.
const gw = new HttpAgentGateway(new HttpInvoker({ baseUrl: "https://host" }), ws);
return { gw, sockets };
}
async function replyToLast(
socket: FakeSocket,
index: number,
reply: (id: string) => unknown,
): Promise<Record<string, unknown>> {
await vi.waitFor(() => expect(socket.sent.length).toBeGreaterThan(index));
const sent = JSON.parse(socket.sent[index]);
socket.receive(reply(sent.id));
return sent;
}
function attachedAck(
id: string,
sessionId: string,
opts: { scroll?: Uint8Array; assignedConversationId?: string } = {},
) {
return {
kind: "terminal.attached",
replyTo: id,
payload: {
session: { sessionId, rows: 24, cols: 80 },
scrollback: opts.scroll ? [{ seq: 0, bytesBase64: bytesToBase64(opts.scroll) }] : [],
nextSeq: opts.scroll ? 1 : 0,
status: "running",
gap: false,
assignedConversationId: opts.assignedConversationId,
},
};
}
const OPTS = { cwd: "/srv/app", rows: 24, cols: 80, nodeId: "node-1" };
describe("HttpAgentGateway WS round-trip (B6 frames)", () => {
it("launch → attached: conforming agent.launch frame, assignedConversationId consumed", async () => {
const { gw, sockets } = gateway();
const chunks: Uint8Array[] = [];
const handlePromise = gw.launchAgent("proj-1", "agent-1", { ...OPTS, conversationId: "resume-9" }, (b) =>
chunks.push(b),
);
const sent = await replyToLast(sockets[0], 0, (id) =>
attachedAck(id, "sess-1", { assignedConversationId: "conv-1" }),
);
const handle = await handlePromise;
// B6 payload: flat camelCase, no cwd (agent launch resolves the project).
expect(sent.kind).toBe("agent.launch");
expect(sent.payload).toEqual({
projectId: "proj-1",
agentId: "agent-1",
nodeId: "node-1",
rows: 24,
cols: 80,
conversationId: "resume-9",
});
expect(handle.sessionId).toBe("sess-1");
// The conversation id minted by the launch is surfaced on the handle.
expect(handle.assignedConversationId).toBe("conv-1");
// Fresh launch: nothing repainted.
expect(chunks).toHaveLength(0);
// Output/input/resize reuse the terminal mechanics.
sockets[0].receive({
kind: "terminal.output",
payload: { sessionId: "sess-1", seq: 1, bytesBase64: bytesToBase64(new Uint8Array([65])) },
});
expect(chunks).toEqual([new Uint8Array([65])]);
const base = sockets[0].sent.length;
await handle.write(new Uint8Array([13]));
await handle.resize(30, 100);
expect(JSON.parse(sockets[0].sent[base]).kind).toBe("terminal.input");
expect(JSON.parse(sockets[0].sent[base + 1])).toMatchObject({
kind: "terminal.resize",
payload: { sessionId: "sess-1", rows: 30, cols: 100 },
});
});
it("omits assignedConversationId on the handle when the launch minted none", async () => {
const { gw, sockets } = gateway();
const handlePromise = gw.launchAgent("p", "a", OPTS, () => {});
await replyToLast(sockets[0], 0, (id) => attachedAck(id, "sess-x"));
const handle = await handlePromise;
expect(handle.assignedConversationId).toBeUndefined();
});
it("re-attaches to an existing agent via terminal.attach (no relaunch) and returns scrollback", async () => {
const { gw, sockets } = gateway();
const chunks: Uint8Array[] = [];
const scroll = new Uint8Array([104, 105]);
const resPromise = gw.reattach("sess-7", (b) => chunks.push(b));
const sent = await replyToLast(sockets[0], 0, (id) => attachedAck(id, "sess-7", { scroll }));
const res = await resPromise;
// Re-attach must NOT relaunch: the frame is terminal.attach, not agent.launch.
expect(sent.kind).toBe("terminal.attach");
expect(sent.payload).toMatchObject({ sessionId: "sess-7" });
expect(res.scrollback).toEqual(scroll);
// Scrollback returned for the view to repaint, not double-pushed via onData.
expect(chunks).toHaveLength(0);
});
it("rejects a structured agent with UNSUPPORTED (no crash)", async () => {
const { gw, sockets } = gateway();
const handlePromise = gw.launchAgent("p", "a", OPTS, () => {});
await replyToLast(sockets[0], 0, (id) => ({
kind: "error",
replyTo: id,
payload: { code: "UNSUPPORTED", message: "structured agent sessions do not stream over the PTY websocket" },
}));
await expect(handlePromise).rejects.toMatchObject({ code: "UNSUPPORTED" });
});
it("rejects re-attach to a missing/exited session with NOT_FOUND", async () => {
const { gw, sockets } = gateway();
const resPromise = gw.reattach("gone", () => {});
await replyToLast(sockets[0], 0, (id) => ({
kind: "error",
replyTo: id,
payload: { code: "NOT_FOUND", message: "terminal session not found" },
}));
await expect(resPromise).rejects.toMatchObject({ code: "NOT_FOUND" });
});
});

View File

@ -94,7 +94,7 @@ export interface ServerFrame {
payload: Record<string, unknown>;
}
/** Payload of a `terminal.attached` acknowledgement. */
/** Payload of a `terminal.attached` acknowledgement (B5, `server.rs`). */
export interface AttachedPayload {
session: {
sessionId: string;
@ -104,8 +104,15 @@ export interface AttachedPayload {
cols: number;
};
nextSeq: number;
/**
* Bounded scrollback replayed at (re)attach. B5 sends a single entry (`seq:0`)
* carrying all retained bytes, or an empty array when there is nothing to
* replay.
*/
scrollback: { seq: number; bytesBase64: string }[];
gap: boolean;
/** Lifecycle status carried on the ack (B5 sends `"running"`). */
status?: string;
/** Conversation id minted by an `agent.launch` (mirrors `assignedConversationId`). */
assignedConversationId?: string;
}
@ -117,6 +124,16 @@ export interface OutputPayload {
bytesBase64: string;
}
/**
* Payload of a `terminal.status` frame (B5). `status` is a lifecycle transition
* (`"exited"` on close); `exitCode` is present for `"exited"` (may be `null`).
*/
export interface StatusPayload {
sessionId: string;
status: string;
exitCode?: number | null;
}
/** Payload of a `chat.output` frame (structured assistant stream). */
export interface ChatOutputPayload {
sessionId: string;

View File

@ -7,9 +7,37 @@
import { describe, it, expect, vi } from "vitest";
import type { FetchLike } from "./httpInvoker";
import { HttpInvoker } from "./httpInvoker";
import { HttpInvoker, defaultFetch } from "./httpInvoker";
import { HttpProjectGateway, HttpGitGateway } from "./requestResponseGateways";
/**
* Installs a global `fetch` that records its receiver (`this`), runs `body`, then
* restores the original. A real browser's `fetch` rejects a non-global receiver
* (Firefox: "'fetch' called on an object that does not implement interface
* Window"); jsdom does not enforce it, so the receiver is asserted explicitly.
*/
async function withRecordingGlobalFetch(
body: () => Promise<void>,
): Promise<unknown[]> {
const original = globalThis.fetch;
const receivers: unknown[] = [];
globalThis.fetch = function (this: unknown) {
receivers.push(this);
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({}),
text: async () => "{}",
});
} as unknown as typeof globalThis.fetch;
try {
await body();
} finally {
globalThis.fetch = original;
}
return receivers;
}
/** Builds a fake fetch that records the request and returns `body`. */
function fakeFetch(
body: unknown,
@ -110,6 +138,52 @@ describe("HttpInvoker", () => {
code: "TRANSPORT_ERROR",
});
});
// Regression: the default global `fetch` was stored unbound, so calling it as
// `this.fetchImpl(…)` handed it the invoker as receiver — a TypeError in
// Firefox/Chrome at pairing. It must stay bound to `globalThis`.
it("calls the default global fetch with globalThis as receiver", async () => {
const receivers = await withRecordingGlobalFetch(async () => {
const http = new HttpInvoker({ baseUrl: "https://host" });
await http.invoke("health");
});
expect(receivers).toHaveLength(1);
expect(receivers[0]).toBe(globalThis);
});
it("leaves an injected fetchImpl untouched (no receiver rebinding)", async () => {
const { fetchImpl, calls } = fakeFetch({ ok: true });
const http = new HttpInvoker({ baseUrl: "https://host", fetchImpl });
await http.invoke("health");
expect(calls).toHaveLength(1);
});
});
describe("defaultFetch", () => {
it("returns the global fetch bound to globalThis", async () => {
const receivers = await withRecordingGlobalFetch(async () => {
const fn = defaultFetch();
// Called as a bare reference (worst case: no receiver at all).
await fn("https://host/x");
});
expect(receivers).toEqual([globalThis]);
});
it("passes through a missing global fetch instead of throwing at construction", () => {
const original = globalThis.fetch;
// @ts-expect-error — simulating an environment without a global fetch.
delete globalThis.fetch;
try {
// Must not throw here; the failure surfaces at call time, as before.
expect(defaultFetch()).toBeUndefined();
} finally {
globalThis.fetch = original;
}
});
});
describe("request/response gateways preserve the Tauri command contract", () => {

View File

@ -41,6 +41,29 @@ export type FetchLike = (
text(): Promise<string>;
}>;
/**
* Resolves the default `fetch`, **bound to `globalThis`**.
*
* WHATWG `fetch` is a method of the global object and checks its receiver: once
* stored in a field and called as `this.fetchImpl(…)`, its `this` is the owning
* adapter instance, not `window`. A real browser rejects that — Firefox with
* `TypeError: 'fetch' called on an object that does not implement interface
* Window`, Chrome with "Illegal invocation". jsdom does not enforce the receiver,
* so the unit tests never caught it; the bind is what keeps the fallback callable
* from a field. An *injected* `fetchImpl` is left untouched (a test stub is a
* plain function and needs no receiver).
*
* When there is no global `fetch` (SSR / bare Node), the value is returned as-is
* so the failure still happens at call time, exactly as before, rather than
* throwing during construction.
*/
export function defaultFetch(): FetchLike {
const globalFetch = globalThis.fetch;
return (
typeof globalFetch === "function" ? globalFetch.bind(globalThis) : globalFetch
) as unknown as FetchLike;
}
/** Configuration for the HTTP invoker. */
export interface HttpInvokerConfig {
/** Absolute base URL of the backend, e.g. `https://host:port`. No trailing slash. */
@ -85,10 +108,8 @@ export class HttpInvoker {
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
this.token = config.token;
this.onUnauthorized = config.onUnauthorized;
// `globalThis.fetch` exists in the browser (and jsdom); the cast narrows it
// to the minimal shape used here.
this.fetchImpl =
config.fetchImpl ?? (globalThis.fetch as unknown as FetchLike);
// The global fallback must stay bound to `globalThis` — see `defaultFetch`.
this.fetchImpl = config.fetchImpl ?? defaultFetch();
}
/**

View File

@ -19,6 +19,7 @@ import { LocalStorageUiPreferencesGateway } from "../uiPreferences";
import { HttpInvoker } from "./httpInvoker";
import { WsLiveClient } from "./wsLiveClient";
import { getWebSession } from "./webSession";
import { setWebLiveClient } from "./webLive";
import {
HttpConversationGateway,
HttpEmbedderGateway,
@ -78,14 +79,36 @@ function resolveEndpoints(config: HttpWsGatewaysConfig): {
*/
export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateways {
const { baseUrl, wsUrl } = resolveEndpoints(config);
// Route 401s back to the pairing screen via the shared web session (F2).
// Route 401s back to the pairing screen via the shared web session (F2/F6).
const session = getWebSession();
// `ws` is referenced by the invoker's 401 handler (declared below); the closure
// only runs after both are constructed, so the forward reference is safe.
let ws: WsLiveClient;
const http = new HttpInvoker({
baseUrl,
token: config.token,
onUnauthorized: () => session.notifyUnauthorized(),
onUnauthorized: () => {
// F6: a 401 means the session cookie is gone/revoked. Route back to pairing
// AND tear down the live socket so it stops its (now hopeless) reconnect
// loop. Re-pairing brings the same client back up cleanly.
session.notifyUnauthorized();
ws?.disconnect();
},
});
const ws = new WsLiveClient({ wsUrl, token: config.token });
ws = new WsLiveClient({
wsUrl,
token: config.token,
// F6: when a WS reconnect fails we cannot read its HTTP status, so probe with
// a cheap `health` call. A revoked session answers `401` → the invoker's
// `onUnauthorized` above bounces to pairing; a network error is swallowed and
// the WS backoff keeps retrying.
onReconnectFailed: () => {
void http.invoke("health", { request: null }).catch(() => {});
},
});
// Share the live client with the web feature so live surfaces can re-sync on
// reconnect (F5). Inert on desktop (never called there).
setWebLiveClient(ws);
return {
system: new HttpSystemGateway(http, ws),
@ -116,3 +139,5 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway
export { HttpInvoker } from "./httpInvoker";
export { WsLiveClient } from "./wsLiveClient";
export { WebSession, getWebSession, setWebSessionForTests } from "./webSession";
export { getWebLiveClient, setWebLiveClient, disconnectWebLive } from "./webLive";
export type { ConnectionState } from "./wsLiveClient";

View File

@ -52,22 +52,9 @@ import type {
} from "@/ports";
import type { HttpInvoker } from "./httpInvoker";
import { WsLiveClient } from "./wsLiveClient";
import { base64ToBytes, type AttachedPayload, type ChatOutputPayload } from "./frames";
import { type ChatOutputPayload } from "./frames";
import { unsupportedOnWeb } from "./unsupported";
/** Concatenates a scrollback frame list into a single byte buffer. */
function attachedToScrollback(payload: AttachedPayload): Uint8Array {
const chunks = payload.scrollback.map((c) => base64ToBytes(c.bytesBase64));
const total = chunks.reduce((n, c) => n + c.length, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const c of chunks) {
out.set(c, offset);
offset += c.length;
}
return out;
}
/**
* Builds a {@link TerminalHandle} whose control operations are WS frames. The
* output stream is delivered through the sink the gateway registered on the
@ -93,12 +80,11 @@ export function makeWsTerminalHandle(
detach(): void {
// Stop delivering output locally and tell the server the view is gone. The
// backend PTY keeps running (detach ≠ close), matching the Tauri handle.
ws.removeOutputSink(sessionId);
void ws.sendFireAndForget("terminal.detach", { sessionId });
// Untracking also disables reconnection re-attach for this session.
void ws.detachTerminal(sessionId);
},
async close(): Promise<void> {
ws.removeOutputSink(sessionId);
await ws.send("terminal.close", { sessionId });
await ws.closeTerminalSession(sessionId);
},
};
}
@ -137,37 +123,33 @@ export class HttpTerminalGateway implements TerminalGateway {
options: OpenTerminalOptions,
onData: (bytes: Uint8Array) => void,
): Promise<TerminalHandle> {
const ack = await this.ws.send("terminal.open", {
projectId: undefined, // TODO(F3/B5): the port lacks projectId; confirm contract.
nodeId: options.nodeId ?? null,
// B5: `terminal.open` carries no projectId; `cwd` is a server-validated path.
// A fresh open has empty scrollback (the view does not repaint on open).
const res = await this.ws.openTerminal({
cwd: options.cwd,
rows: options.rows,
cols: options.cols,
nodeId: options.nodeId ?? null,
onData,
});
const payload = ack.payload as unknown as AttachedPayload;
const sessionId = payload.session.sessionId;
this.ws.setOutputSink(sessionId, onData);
const scrollback = attachedToScrollback(payload);
if (scrollback.length > 0) onData(scrollback);
return makeWsTerminalHandle(sessionId, this.ws);
return makeWsTerminalHandle(res.sessionId, this.ws);
}
async reattach(
sessionId: string,
onData: (bytes: Uint8Array) => void,
): Promise<ReattachResult> {
const ack = await this.ws.send("terminal.attach", { sessionId, lastSeq: null });
const payload = ack.payload as unknown as AttachedPayload;
this.ws.setOutputSink(sessionId, onData);
// The view repaints the returned scrollback itself (TerminalView contract);
// the client does not also push it through `onData` on the initial attach.
const res = await this.ws.attachTerminal({ sessionId, onData });
return {
handle: makeWsTerminalHandle(sessionId, this.ws),
scrollback: attachedToScrollback(payload),
handle: makeWsTerminalHandle(res.sessionId, this.ws),
scrollback: res.scrollback,
};
}
async closeTerminal(sessionId: string): Promise<void> {
this.ws.removeOutputSink(sessionId);
await this.ws.send("terminal.close", { sessionId });
await this.ws.closeTerminalSession(sessionId);
}
}
@ -240,34 +222,34 @@ export class HttpAgentGateway implements AgentGateway {
options: OpenTerminalOptions,
onData: (bytes: Uint8Array) => void,
): Promise<TerminalHandle> {
// Raw-CLI agents stream over the same WS PTY channel (B0). Structured
// sessions do not use this channel — that branch is B6.
const ack = await this.ws.send("agent.launch", {
// B6: a raw-CLI agent streams over the same PTY channel as a terminal. The
// WS client tracks the session (reconnection/replay/status parity with F3);
// the unified `terminal.attached` ack yields the sessionId + the conversation
// id minted by this launch. Structured agents are refused server-side with
// `UNSUPPORTED` — the rejection propagates to the caller (a clear cell error,
// no crash). A fresh launch has empty scrollback (no repaint on open).
const res = await this.ws.launchAgent({
projectId,
agentId,
nodeId: options.nodeId ?? null,
rows: options.rows,
cols: options.cols,
conversationId: options.conversationId ?? null,
onData,
});
const payload = ack.payload as unknown as AttachedPayload;
const sessionId = payload.session.sessionId;
this.ws.setOutputSink(sessionId, onData);
const scrollback = attachedToScrollback(payload);
if (scrollback.length > 0) onData(scrollback);
return makeWsTerminalHandle(sessionId, this.ws, payload.assignedConversationId);
return makeWsTerminalHandle(res.sessionId, this.ws, res.assignedConversationId);
}
async reattach(
sessionId: string,
onData: (bytes: Uint8Array) => void,
): Promise<ReattachResult> {
const ack = await this.ws.send("terminal.attach", { sessionId, lastSeq: null });
const payload = ack.payload as unknown as AttachedPayload;
this.ws.setOutputSink(sessionId, onData);
// Agent sessions re-attach through the same `terminal.attach` mechanics as a
// terminal — no relaunch, bounded scrollback replayed for the view to repaint.
const res = await this.ws.attachTerminal({ sessionId, onData });
return {
handle: makeWsTerminalHandle(sessionId, this.ws),
scrollback: attachedToScrollback(payload),
handle: makeWsTerminalHandle(res.sessionId, this.ws),
scrollback: res.scrollback,
};
}
}

View File

@ -0,0 +1,144 @@
/**
* F3 — the WS terminal gateway + handle round-trip against the B5 frame contract:
* open→attached (empty scrollback), attach→attached (scrollback returned for the
* view to repaint, not double-pushed), input/resize emit conforming frames,
* detach ≠ close, and `terminal.output` bytes are base64-decoded into the sink.
*/
import { describe, it, expect, vi } from "vitest";
import { HttpTerminalGateway } from "./streamGateways";
import { WsLiveClient, type WebSocketLike } from "./wsLiveClient";
import { bytesToBase64 } from "./frames";
class FakeSocket implements WebSocketLike {
sent: string[] = [];
onopen: (() => void) | null = null;
onmessage: ((event: { data: string }) => void) | null = null;
onerror: ((event: unknown) => void) | null = null;
onclose: (() => void) | null = null;
send(data: string): void {
this.sent.push(data);
}
close(): void {
this.onclose?.();
}
receive(frame: unknown): void {
this.onmessage?.({ data: JSON.stringify(frame) });
}
}
/** Client whose socket auto-opens on the next microtask. */
function client(): { ws: WsLiveClient; sockets: FakeSocket[] } {
const sockets: FakeSocket[] = [];
const ws = new WsLiveClient({
wsUrl: "wss://host",
socketFactory: () => {
const s = new FakeSocket();
sockets.push(s);
queueMicrotask(() => s.onopen?.());
return s;
},
});
return { ws, sockets };
}
/** Awaits the frame the client just sent, then replies with `reply(id)`. */
async function replyToLast(
socket: FakeSocket,
index: number,
reply: (id: string) => unknown,
): Promise<Record<string, unknown>> {
await vi.waitFor(() => expect(socket.sent.length).toBeGreaterThan(index));
const sent = JSON.parse(socket.sent[index]);
socket.receive(reply(sent.id));
return sent;
}
function attachedAck(id: string, sessionId: string, scrollbackBytes?: Uint8Array) {
return {
kind: "terminal.attached",
replyTo: id,
payload: {
session: { sessionId, rows: 30, cols: 120 },
scrollback: scrollbackBytes
? [{ seq: 0, bytesBase64: bytesToBase64(scrollbackBytes) }]
: [],
nextSeq: scrollbackBytes ? 1 : 0,
status: "running",
gap: false,
},
};
}
describe("HttpTerminalGateway round-trip (B5 frames)", () => {
it("open → attached with empty scrollback; output is decoded into the sink", async () => {
const { ws, sockets } = client();
const gw = new HttpTerminalGateway(ws);
const chunks: Uint8Array[] = [];
const handlePromise = gw.openTerminal({ cwd: "/srv/app", rows: 30, cols: 120 }, (b) =>
chunks.push(b),
);
const sent = await replyToLast(sockets[0], 0, (id) => attachedAck(id, "s1"));
const handle = await handlePromise;
// Conforming open frame (no projectId; cwd is server-validated).
expect(sent.kind).toBe("terminal.open");
expect(sent.payload).toMatchObject({ cwd: "/srv/app", rows: 30, cols: 120 });
expect(handle.sessionId).toBe("s1");
// No scrollback pushed on a fresh open.
expect(chunks).toHaveLength(0);
// A terminal.output frame is base64-decoded and written to the sink.
sockets[0].receive({
kind: "terminal.output",
payload: { sessionId: "s1", seq: 1, bytesBase64: bytesToBase64(new Uint8Array([104, 105])) },
});
expect(chunks).toEqual([new Uint8Array([104, 105])]);
});
it("attach → attached returns the scrollback for the view (not double-pushed)", async () => {
const { ws, sockets } = client();
const gw = new HttpTerminalGateway(ws);
const chunks: Uint8Array[] = [];
const scroll = new Uint8Array([65, 66, 67]);
const resPromise = gw.reattach("s7", (b) => chunks.push(b));
const sent = await replyToLast(sockets[0], 0, (id) => attachedAck(id, "s7", scroll));
const res = await resPromise;
expect(sent.kind).toBe("terminal.attach");
expect(sent.payload).toMatchObject({ sessionId: "s7" });
// Scrollback is returned for TerminalView to repaint…
expect(res.scrollback).toEqual(scroll);
// …and NOT also pushed through onData (no double paint on the initial attach).
expect(chunks).toHaveLength(0);
});
it("input and resize emit conforming frames; detach ≠ close", async () => {
const { ws, sockets } = client();
const gw = new HttpTerminalGateway(ws);
const handlePromise = gw.openTerminal({ cwd: "/srv", rows: 24, cols: 80 }, () => {});
await replyToLast(sockets[0], 0, (id) => attachedAck(id, "s1"));
const handle = await handlePromise;
const base = sockets[0].sent.length;
await handle.write(new Uint8Array([13]));
await handle.resize(40, 140);
const input = JSON.parse(sockets[0].sent[base]);
expect(input.kind).toBe("terminal.input");
expect(input.payload).toEqual({ sessionId: "s1", bytesBase64: bytesToBase64(new Uint8Array([13])) });
const resize = JSON.parse(sockets[0].sent[base + 1]);
expect(resize.kind).toBe("terminal.resize");
expect(resize.payload).toEqual({ sessionId: "s1", rows: 40, cols: 140 });
// detach keeps the PTY alive (terminal.detach); close kills it (terminal.close).
handle.detach();
await vi.waitFor(() =>
expect(JSON.parse(sockets[0].sent[base + 2]).kind).toBe("terminal.detach"),
);
});
});

View File

@ -0,0 +1,37 @@
/**
* Web live-connection accessor — ticket #13, lot F5.
*
* The web gateways share a single {@link WsLiveClient} (created in
* `createHttpWsGateways`). The live surfaces (workstate / background /
* notifications) need to know when that socket **reconnects** after an outage so
* they can re-synchronise their read-models (events emitted while disconnected
* were missed). The `SystemGateway` port intentionally does not expose transport
* connection state, so — rather than leak it through the port — the web client is
* registered here as a module singleton and consumed only by the web feature
* (`useLiveReconnect`). Desktop never registers a client, so this is inert there.
*
* Lives in `src/adapters/**`; touches no `@tauri-apps/api`.
*/
import type { WsLiveClient } from "./wsLiveClient";
let liveClient: WsLiveClient | null = null;
/** Registers the shared web live client (called by `createHttpWsGateways`). */
export function setWebLiveClient(client: WsLiveClient | null): void {
liveClient = client;
}
/** Returns the shared web live client, or `null` outside web transport. */
export function getWebLiveClient(): WsLiveClient | null {
return liveClient;
}
/**
* Soft-disconnects the shared live client (sign-out / auth bounce — F6): tears
* down the socket and stops the reconnect loop but keeps the client reusable, so
* re-pairing reconnects cleanly. Inert (no client registered) on desktop.
*/
export function disconnectWebLive(): void {
liveClient?.disconnect();
}

View File

@ -90,3 +90,70 @@ describe("WebSession unauthorized handling", () => {
expect(session.isPaired()).toBe(false);
});
});
describe("WebSession default fetch receiver", () => {
/**
* Regression (live Firefox, ticket #13): the default global `fetch` was stored
* unbound, so `this.fetchImpl(…)` called it with the WebSession as receiver ⇒
* `TypeError: 'fetch' called on an object that does not implement interface
* Window` at pairing. jsdom does not enforce the receiver, so assert it here.
*/
async function pairWithRecordingGlobalFetch(): Promise<unknown[]> {
const original = globalThis.fetch;
const receivers: unknown[] = [];
globalThis.fetch = function (this: unknown) {
receivers.push(this);
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({ ok: true }),
text: async () => "{}",
});
} as unknown as typeof globalThis.fetch;
try {
// No `fetchImpl` injected ⇒ the global fallback is used.
const session = new WebSession({ baseUrl: "https://host", store: memStore() });
await session.pair("4821-93");
} finally {
globalThis.fetch = original;
}
return receivers;
}
it("pairs using the global fetch bound to globalThis, not the session", async () => {
const receivers = await pairWithRecordingGlobalFetch();
expect(receivers).toHaveLength(1);
expect(receivers[0]).toBe(globalThis);
expect(receivers[0]).not.toBeInstanceOf(WebSession);
});
});
describe("WebSession logout", () => {
it("POSTs /api/logout same-origin and clears the flag on success", async () => {
const store = memStore();
const { fetchImpl, calls } = fetchReturning(200, { revoked: true });
const session = new WebSession({ baseUrl: "https://host/", fetchImpl, store });
session.markPaired();
await session.logout();
expect(session.isPaired()).toBe(false);
expect(calls[0].url).toBe("https://host/api/logout");
const init = calls[0].init as { method: string; credentials: string };
expect(init.method).toBe("POST");
expect(init.credentials).toBe("same-origin");
});
it("still clears the flag when the server is unreachable (best-effort)", async () => {
const store = memStore();
const fetchImpl: FetchLike = async () => {
throw new Error("network down");
};
const session = new WebSession({ baseUrl: "https://host", fetchImpl, store });
session.markPaired();
await expect(session.logout()).resolves.toBeUndefined();
expect(session.isPaired()).toBe(false);
});
});

View File

@ -17,7 +17,7 @@
*/
import type { GatewayError, Unsubscribe } from "@/domain";
import type { FetchLike } from "./httpInvoker";
import { defaultFetch, type FetchLike } from "./httpInvoker";
const PAIRED_FLAG_KEY = "idea.web.paired";
@ -77,7 +77,8 @@ export class WebSession {
constructor(config: WebSessionConfig = {}) {
this.baseUrl = (config.baseUrl ?? defaultBaseUrl()).replace(/\/+$/, "");
this.fetchImpl = config.fetchImpl ?? (globalThis.fetch as unknown as FetchLike);
// The global fallback must stay bound to `globalThis` — see `defaultFetch`.
this.fetchImpl = config.fetchImpl ?? defaultFetch();
this.store = config.store ?? defaultStore();
}
@ -96,6 +97,26 @@ export class WebSession {
this.store.removeItem(PAIRED_FLAG_KEY);
}
/**
* Full sign-out (F6): asks the server to revoke the session cookie
* (`POST /api/logout`, same-origin so the cookie rides along) and then clears
* the local flag. Best-effort — a network failure or an already-expired session
* still clears the flag locally, so the UI always returns to pairing without a
* dead end. Disconnecting the live WS is the caller's job (composition seam).
*/
async logout(): Promise<void> {
try {
await this.fetchImpl(`${this.baseUrl}/api/logout`, {
method: "POST",
credentials: "same-origin",
});
} catch {
// Server unreachable / already gone: fall through to the local clear.
} finally {
this.forget();
}
}
/**
* Performs the pairing handshake: `POST /api/pair {code}`. On success the
* server sets the session cookie and this records the paired flag. A wrong code

View File

@ -46,6 +46,49 @@ function connectedClient(): { client: WsLiveClient; socket: FakeSocket } {
return { client, socket };
}
/** Captures the URLs the client asks its socket factory to open. */
function urlCapturingClient(token?: string): { client: WsLiveClient; urls: string[] } {
const urls: string[] = [];
const client = new WsLiveClient({
wsUrl: "wss://host",
token,
socketFactory: (url) => {
urls.push(url);
const socket = new FakeSocket();
queueMicrotask(() => socket.onopen?.());
return socket;
},
});
return { client, urls };
}
/**
* Regression (live validation, ticket #13): the client connected to `/ws/live`
* while the server's `WS_PATH` is `/api/ws`. An unknown path is not treated as an
* upgrade — it falls through to the SPA static fallback (`200 index.html`) — so
* the socket never opened and the UI sat behind a permanent reconnection banner.
* No test asserted the URL (every `socketFactory` ignored its argument), which is
* exactly how the mismatch shipped. These pin the path to the server contract.
*/
describe("WsLiveClient live socket URL matches the server WS_PATH", () => {
it("connects to {wsUrl}/api/ws", async () => {
const { client, urls } = urlCapturingClient();
await client.ensureConnected();
expect(urls).toEqual(["wss://host/api/ws"]);
});
it("keeps the /api/ws path when the dev/test token is used", async () => {
const { client, urls } = urlCapturingClient("dev tok/en");
await client.ensureConnected();
expect(urls).toHaveLength(1);
// Path is unchanged; the token is a URL-encoded query convenience.
expect(new URL(urls[0]).pathname).toBe("/api/ws");
expect(urls[0]).toBe("wss://host/api/ws?token=dev%20tok%2Fen");
});
});
describe("base64 helpers round-trip binary", () => {
it("encodes and decodes arbitrary bytes", () => {
const bytes = new Uint8Array([0, 13, 27, 255, 128, 10]);

View File

@ -1,30 +1,57 @@
/**
* WebSocket live client skeleton for the web transport — ticket #13, lot F1.
* WebSocket live client for the web transport — ticket #13, lot F3 (finalised
* from the F1 skeleton).
*
* Owns a single WS connection to `{wsUrl}/ws/live` and multiplexes over it, per
* the B0 draft: PTY output, structured chat output and the low-frequency domain
* event stream (`event.domain`, replacing Tauri `listen("domain://event")`).
* Owns a single WS connection to `{wsUrl}/api/ws` (authenticated by the session
* cookie at the upgrade — same-origin, no URL secret) and multiplexes over it,
* aligned on the **B5 frozen frame contract** (`crates/app-tauri/src/server.rs`):
*
* **F1 scope = skeleton.** The connection, frame envelope, request/reply
* correlation by `id`, and the per-session output routing are implemented and
* unit-testable (inject a fake WebSocket factory). What is deliberately deferred
* to **F3/B5** (when a server exists — B3/B4): reconnection/backpressure, exact
* `open`/`launch` reply shape, sequence-gap replay policy, and the full xterm
* round-trip. Those are marked `TODO(F3/B5)`.
* - client→server: `terminal.open` `{cwd,rows,cols}`, `terminal.attach`
* `{sessionId,rows?,cols?,lastSeq?}`, `terminal.input` `{sessionId,bytesBase64}`,
* `terminal.resize` `{sessionId,rows,cols}`, `terminal.detach` `{sessionId}`,
* `terminal.close` `{sessionId}`, `ping`.
* - server→client: `terminal.attached` (ack: `{session,scrollback,nextSeq,status,
* gap,assignedConversationId}`), `terminal.output` `{sessionId,seq,bytesBase64}`,
* `terminal.status` `{sessionId,status,exitCode}`, `error`, `pong`, plus the
* low-frequency `event.domain` stream (domain events).
*
* Lives in `src/adapters/**`; touches no `@tauri-apps/api`.
* F3 additions over F1: a **connection state machine** (connecting / connected /
* reconnecting / closed) with automatic reconnection + re-attach and bounded
* scrollback repaint, and per-session **status routing** (`exited`). The terminal
* lifecycle is owned here so a browser reload / network drop transparently
* re-attaches the surviving server-side PTY. Multi-tab "last attach wins" is
* silent on the wire (the evicted attachment simply stops receiving output — see
* the F3 report), so there is nothing to crash on; the socket-close path handles
* disconnection uniformly.
*
* Lives in `src/adapters/**`; touches no `@tauri-apps/api`. The port
* (`TerminalGateway`/`TerminalHandle`) and `TerminalView` are unchanged; the UI
* indication of connection state is written into xterm through the session's own
* output sink (a notice line), so no component needs to know about this client.
*/
import type { DomainEvent } from "@/domain";
import type { DomainEvent, Unsubscribe } from "@/domain";
import {
base64ToBytes,
bytesToBase64,
type AttachedPayload,
type ClientFrame,
type ClientFrameKind,
type OutputPayload,
type ServerFrame,
type StatusPayload,
} from "./frames";
/**
* Live WebSocket route — must match the server's `WS_PATH`
* (`crates/app-tauri/src/server.rs`). Any other path is not recognised as an
* upgrade and falls through to the SPA static fallback (a `200 index.html`), so
* the socket never opens and the client reconnects forever behind a permanent
* "connexion perdue" banner. Named once so the token/no-token branches below
* cannot drift apart.
*/
const WS_PATH = "/api/ws";
/** Minimal WebSocket surface used here; injectable so tests pass a fake. */
export interface WebSocketLike {
send(data: string): void;
@ -38,14 +65,56 @@ export interface WebSocketLike {
/** Factory building a {@link WebSocketLike} for a URL (defaults to global WS). */
export type WebSocketFactory = (url: string) => WebSocketLike;
/** Injectable deferred-call primitives (defaults to global timers). */
export type SetTimeoutLike = (fn: () => void, ms: number) => unknown;
export type ClearTimeoutLike = (handle: unknown) => void;
/** UI-facing connection state of the live socket. */
export type ConnectionState = "connecting" | "connected" | "reconnecting" | "closed";
/** Configuration for the live client. */
export interface WsLiveClientConfig {
/** Base WS URL, e.g. `wss://host:port`. No trailing slash. */
wsUrl: string;
/** Bearer token (ticket #13 auth). B0 note: prefer header/cookie over URL. */
/** Bearer token (dev/tests only). Prod auth is the session cookie at upgrade. */
token?: string;
/** Injected WebSocket factory (defaults to `new WebSocket(url)`). */
socketFactory?: WebSocketFactory;
/** Base reconnect delay in ms (exponential backoff, capped). Default 500. */
reconnectBaseMs?: number;
/** Max reconnect delay in ms. Default 10000. */
reconnectMaxMs?: number;
/** Injected `setTimeout` (tests drive reconnection deterministically). */
setTimeoutImpl?: SetTimeoutLike;
/** Injected `clearTimeout`. */
clearTimeoutImpl?: ClearTimeoutLike;
/**
* Called after a reconnection attempt fails. The browser cannot read the HTTP
* status of a rejected WS upgrade, so an auth-revoked socket looks like any
* other outage from here. F6 wires this to a cheap HTTP probe (`health`): a
* `401` there routes back to pairing (via the invoker's `onUnauthorized`),
* while a network error just lets the backoff keep retrying.
*/
onReconnectFailed?: () => void;
}
/** A live terminal subscription tracked for output routing + reconnection replay. */
interface TerminalSubscription {
/** Delivers raw PTY bytes (and adapter notices) to the xterm view. */
onData: (bytes: Uint8Array) => void;
/** Optional lifecycle callback (`exited`), for tests / future UI. */
onStatus?: (status: string, exitCode?: number | null) => void;
/** Last output sequence seen — sent as `lastSeq` when re-attaching. */
lastSeq: number;
}
/** Result of opening/attaching a terminal over the WS. */
export interface TerminalAttachResult {
sessionId: string;
/** Bounded scrollback bytes to repaint (empty on a fresh open). */
scrollback: Uint8Array;
assignedConversationId?: string;
status?: string;
}
let frameCounter = 0;
@ -55,20 +124,51 @@ function nextFrameId(): string {
return `c${frameCounter}`;
}
const encoder = new TextEncoder();
/** Encodes a human notice line to bytes for writing into xterm. */
function notice(text: string): Uint8Array {
return encoder.encode(`\r\n\x1b[2m[${text}]\x1b[0m\r\n`);
}
/** Concatenates an attached-frame scrollback list into a single byte buffer. */
function scrollbackBytes(payload: AttachedPayload): Uint8Array {
const chunks = payload.scrollback.map((c) => base64ToBytes(c.bytesBase64));
const total = chunks.reduce((n, c) => n + c.length, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const c of chunks) {
out.set(c, offset);
offset += c.length;
}
return out;
}
/**
* Manages the single live WebSocket. Callers subscribe an output sink per PTY
* session and a single domain-event handler; the client routes inbound frames.
* Manages the single live WebSocket, the connection state machine, and the live
* terminal sessions (so reconnection re-attaches them transparently).
*/
export class WsLiveClient {
private readonly wsUrl: string;
private readonly token?: string;
private readonly socketFactory: WebSocketFactory;
private readonly reconnectBaseMs: number;
private readonly reconnectMaxMs: number;
private readonly setTimeoutImpl: SetTimeoutLike;
private readonly clearTimeoutImpl: ClearTimeoutLike;
private readonly onReconnectFailed?: () => void;
private socket: WebSocketLike | null = null;
private opening: Promise<void> | null = null;
private disposed = false;
private everConnected = false;
private connState: ConnectionState = "connecting";
private reconnectAttempt = 0;
private reconnectHandle: unknown = null;
/** Per-session PTY output sinks (sessionId → onData). */
/** Per-session PTY output sinks (sessionId → onData). Low-level routing map. */
private readonly outputSinks = new Map<string, (bytes: Uint8Array) => void>();
/** Live terminal subscriptions tracked for reconnection replay + status. */
private readonly terminals = new Map<string, TerminalSubscription>();
/** Pending command acknowledgements, keyed by client frame id. */
private readonly pending = new Map<
string,
@ -76,6 +176,8 @@ export class WsLiveClient {
>();
/** The single domain-event handler (set by the system gateway). */
private domainEventHandler: ((event: DomainEvent) => void) | null = null;
/** Connection-state listeners (UI / tests). */
private readonly connListeners = new Set<(state: ConnectionState) => void>();
constructor(config: WsLiveClientConfig) {
this.wsUrl = config.wsUrl.replace(/\/+$/, "");
@ -83,65 +185,288 @@ export class WsLiveClient {
this.socketFactory =
config.socketFactory ??
((url: string) => new WebSocket(url) as unknown as WebSocketLike);
this.reconnectBaseMs = config.reconnectBaseMs ?? 500;
this.reconnectMaxMs = config.reconnectMaxMs ?? 10000;
this.setTimeoutImpl =
config.setTimeoutImpl ?? ((fn, ms) => setTimeout(fn, ms));
this.clearTimeoutImpl =
config.clearTimeoutImpl ?? ((h) => clearTimeout(h as ReturnType<typeof setTimeout>));
this.onReconnectFailed = config.onReconnectFailed;
}
/** Registers the domain-event handler (replaces any previous one). */
// -------------------------------------------------------------------------
// Connection state
// -------------------------------------------------------------------------
/** Current connection state. */
getConnectionState(): ConnectionState {
return this.connState;
}
/** Subscribes to connection-state transitions (returns an unsubscribe). */
onConnectionStateChange(listener: (state: ConnectionState) => void): Unsubscribe {
this.connListeners.add(listener);
return () => this.connListeners.delete(listener);
}
private setConnState(state: ConnectionState): void {
if (this.connState === state) return;
this.connState = state;
for (const listener of this.connListeners) listener(state);
}
// -------------------------------------------------------------------------
// Domain events (system gateway)
// -------------------------------------------------------------------------
setDomainEventHandler(handler: (event: DomainEvent) => void): void {
this.domainEventHandler = handler;
}
/** Clears the domain-event handler. */
clearDomainEventHandler(): void {
this.domainEventHandler = null;
}
/** Registers a per-session PTY output sink. */
// -------------------------------------------------------------------------
// Low-level output sink map (used by the agent gateway too)
// -------------------------------------------------------------------------
setOutputSink(sessionId: string, onData: (bytes: Uint8Array) => void): void {
this.outputSinks.set(sessionId, onData);
}
/** Drops a per-session PTY output sink (view detached). */
removeOutputSink(sessionId: string): void {
this.outputSinks.delete(sessionId);
}
// -------------------------------------------------------------------------
// Connection lifecycle
// -------------------------------------------------------------------------
/** Ensures the socket is connected, connecting on first use. */
async ensureConnected(): Promise<void> {
if (this.socket) return;
if (this.opening) return this.opening;
return this.connect();
}
private connect(): Promise<void> {
this.setConnState(this.everConnected ? "reconnecting" : "connecting");
this.opening = new Promise<void>((resolve, reject) => {
// B0 auth note: token should ride an `Authorization` header / secure
// cookie at upgrade, NOT a URL secret. Browsers can't set WS upgrade
// headers, so the token placement is a contract point to confirm (see the
// F1 report). The query below is only a placeholder for the skeleton.
// Prod: the session cookie rides the upgrade (same-origin). The optional
// token is a dev/test convenience only (never a prod secret in the URL).
const url = this.token
? `${this.wsUrl}/ws/live?token=${encodeURIComponent(this.token)}`
: `${this.wsUrl}/ws/live`;
? `${this.wsUrl}${WS_PATH}?token=${encodeURIComponent(this.token)}`
: `${this.wsUrl}${WS_PATH}`;
const socket = this.socketFactory(url);
socket.onopen = () => {
this.socket = socket;
this.opening = null;
this.everConnected = true;
this.reconnectAttempt = 0;
this.setConnState("connected");
resolve();
};
socket.onerror = (event) => {
this.opening = null;
reject(event);
};
socket.onclose = () => {
// TODO(F3/B5): reconnection + re-attach of live sessions. For F1 we drop
// the socket so a later call reconnects fresh.
this.socket = null;
this.rejectAllPending({ code: "WS_CLOSED", message: "socket closed" });
};
socket.onclose = () => this.handleSocketClosed();
socket.onmessage = (event) => this.handleMessage(event.data);
});
return this.opening;
}
private handleSocketClosed(): void {
this.socket = null;
this.opening = null;
this.rejectAllPending({ code: "WS_CLOSED", message: "socket closed" });
if (this.disposed) {
this.setConnState("closed");
return;
}
this.setConnState("reconnecting");
// Reconnect while anything still needs the socket: a live terminal to
// re-attach (F3) OR an active domain-event subscription (F5 live surfaces).
// Otherwise stay idle until the next explicit use.
if (this.terminals.size > 0) {
for (const sub of this.terminals.values()) sub.onData(notice("déconnecté — reconnexion…"));
}
if (this.needsReconnect()) this.scheduleReconnect();
}
/** Whether the socket should auto-reconnect (a terminal or a live subscription). */
needsReconnect(): boolean {
return this.terminals.size > 0 || this.domainEventHandler !== null;
}
private scheduleReconnect(): void {
if (this.disposed || this.reconnectHandle) return;
const delay = Math.min(
this.reconnectMaxMs,
this.reconnectBaseMs * 2 ** this.reconnectAttempt,
);
this.reconnectAttempt += 1;
this.reconnectHandle = this.setTimeoutImpl(() => {
this.reconnectHandle = null;
void this.reconnectNow();
}, delay);
}
private async reconnectNow(): Promise<void> {
if (this.disposed) return;
try {
await this.connect();
await this.reattachAll();
} catch {
// Still down: probe (an auth-revoked upgrade is indistinguishable from an
// outage here — the probe surfaces a 401 as a pairing bounce), then back
// off and retry (unless nothing needs the socket anymore).
this.onReconnectFailed?.();
if (this.needsReconnect()) this.scheduleReconnect();
}
}
/** Re-attaches every tracked terminal after a reconnect and repaints scrollback. */
private async reattachAll(): Promise<void> {
for (const [sessionId, sub] of this.terminals) {
try {
const ack = await this.send("terminal.attach", {
sessionId,
lastSeq: sub.lastSeq,
});
const payload = ack.payload as unknown as AttachedPayload;
sub.onData(notice("reconnecté"));
const bytes = scrollbackBytes(payload);
if (bytes.length > 0) sub.onData(bytes);
if (typeof payload.nextSeq === "number") {
sub.lastSeq = Math.max(0, payload.nextSeq - 1);
}
} catch {
// A single session failing to re-attach (e.g. server killed it) must not
// block the others; leave it tracked for the next reconnect cycle.
}
}
}
// -------------------------------------------------------------------------
// Terminal sessions (high-level; used by HttpTerminalGateway)
// -------------------------------------------------------------------------
/** Opens a fresh terminal (`terminal.open`) and starts tracking it. */
openTerminal(params: {
cwd: string;
rows: number;
cols: number;
nodeId?: string | null;
onData: (bytes: Uint8Array) => void;
onStatus?: (status: string, exitCode?: number | null) => void;
}): Promise<TerminalAttachResult> {
return this.attachInternal(
"terminal.open",
{ cwd: params.cwd, rows: params.rows, cols: params.cols, nodeId: params.nodeId ?? null },
{ onData: params.onData, onStatus: params.onStatus, lastSeq: 0 },
);
}
/**
* Sends a client frame and resolves with its acknowledgement frame (the server
* frame whose `replyTo` equals this frame's `id`). Fire-and-forget frames
* (input/resize/detach) can ignore the returned promise.
* Launches a CLI agent (`agent.launch`) over the same PTY channel and tracks
* it exactly like a terminal (B6): the ack is a unified `terminal.attached`
* carrying `sessionId` + `assignedConversationId`, then output/input/resize and
* reconnection replay reuse the terminal mechanics. Structured agents are
* refused server-side with `UNSUPPORTED` (this channel is PTY-only); the
* rejected `send` surfaces that error to the caller unchanged.
*/
launchAgent(params: {
projectId: string;
agentId: string;
rows: number;
cols: number;
nodeId?: string | null;
conversationId?: string | null;
onData: (bytes: Uint8Array) => void;
onStatus?: (status: string, exitCode?: number | null) => void;
}): Promise<TerminalAttachResult> {
return this.attachInternal(
"agent.launch",
{
projectId: params.projectId,
agentId: params.agentId,
nodeId: params.nodeId ?? null,
rows: params.rows,
cols: params.cols,
conversationId: params.conversationId ?? null,
},
{ onData: params.onData, onStatus: params.onStatus, lastSeq: 0 },
);
}
/** Re-attaches to an existing terminal (`terminal.attach`) and tracks it. */
attachTerminal(params: {
sessionId: string;
rows?: number;
cols?: number;
lastSeq?: number;
onData: (bytes: Uint8Array) => void;
onStatus?: (status: string, exitCode?: number | null) => void;
}): Promise<TerminalAttachResult> {
return this.attachInternal(
"terminal.attach",
{
sessionId: params.sessionId,
rows: params.rows,
cols: params.cols,
lastSeq: params.lastSeq ?? null,
},
{ onData: params.onData, onStatus: params.onStatus, lastSeq: params.lastSeq ?? 0 },
params.sessionId,
);
}
private async attachInternal(
kind: ClientFrameKind,
payload: Record<string, unknown>,
sub: TerminalSubscription,
knownSessionId?: string,
): Promise<TerminalAttachResult> {
const ack = await this.send(kind, payload);
const ap = ack.payload as unknown as AttachedPayload;
const sessionId = knownSessionId ?? ap.session.sessionId;
if (typeof ap.nextSeq === "number") sub.lastSeq = Math.max(0, ap.nextSeq - 1);
this.terminals.set(sessionId, sub);
// Route output to the subscription's onData (the low-level map is the single
// source of routing; lastSeq is tracked in handleMessage).
this.setOutputSink(sessionId, sub.onData);
return {
sessionId,
scrollback: scrollbackBytes(ap),
assignedConversationId: ap.assignedConversationId,
status: ap.status,
};
}
/** Detaches the view (server PTY keeps running): stop tracking + `terminal.detach`. */
async detachTerminal(sessionId: string): Promise<void> {
this.removeTerminal(sessionId);
await this.sendFireAndForget("terminal.detach", { sessionId });
}
/** Kills the PTY (`terminal.close`) and stops tracking. */
async closeTerminalSession(sessionId: string): Promise<void> {
this.removeTerminal(sessionId);
await this.send("terminal.close", { sessionId });
}
private removeTerminal(sessionId: string): void {
this.terminals.delete(sessionId);
this.outputSinks.delete(sessionId);
}
// -------------------------------------------------------------------------
// Frame I/O
// -------------------------------------------------------------------------
/**
* Sends a client frame and resolves with its acknowledgement (the server frame
* whose `replyTo` equals this frame's `id`).
*/
async send(
kind: ClientFrameKind,
@ -150,8 +475,7 @@ export class WsLiveClient {
await this.ensureConnected();
const socket = this.socket;
if (!socket) {
const err = { code: "WS_CLOSED", message: "socket not connected" };
throw err;
throw { code: "WS_CLOSED", message: "socket not connected" };
}
const id = nextFrameId();
const frame: ClientFrame = { id, kind, payload };
@ -178,14 +502,52 @@ export class WsLiveClient {
return bytesToBase64(bytes);
}
/**
* Soft teardown for a sign-out / auth bounce (F6): closes the socket, drops all
* live state and stops the reconnect loop, but — unlike {@link dispose} — leaves
* the client reusable. A later `ensureConnected` (after re-pairing) reconnects
* as a fresh `connecting`. The socket's own `onclose` is detached first so it
* cannot flip the state back to `reconnecting`.
*/
disconnect(): void {
if (this.reconnectHandle) {
this.clearTimeoutImpl(this.reconnectHandle);
this.reconnectHandle = null;
}
this.rejectAllPending({ code: "WS_CLOSED", message: "client disconnected" });
this.outputSinks.clear();
this.terminals.clear();
this.domainEventHandler = null;
const socket = this.socket;
if (socket) {
socket.onopen = null;
socket.onmessage = null;
socket.onerror = null;
socket.onclose = null;
socket.close();
}
this.socket = null;
this.opening = null;
this.everConnected = false;
this.reconnectAttempt = 0;
this.setConnState("closed");
}
/** Closes the socket and rejects everything pending. */
dispose(): void {
this.disposed = true;
if (this.reconnectHandle) {
this.clearTimeoutImpl(this.reconnectHandle);
this.reconnectHandle = null;
}
this.rejectAllPending({ code: "WS_CLOSED", message: "client disposed" });
this.outputSinks.clear();
this.terminals.clear();
this.domainEventHandler = null;
this.socket?.close();
this.socket = null;
this.opening = null;
this.setConnState("closed");
}
private rejectAllPending(err: unknown): void {
@ -198,20 +560,25 @@ export class WsLiveClient {
try {
frame = JSON.parse(data) as ServerFrame;
} catch {
return; // Ignore malformed frames in the skeleton.
return; // Ignore malformed frames.
}
// Route unsolicited streams first (no replyTo).
switch (frame.kind) {
case "terminal.output": {
const payload = frame.payload as unknown as OutputPayload;
const sub = this.terminals.get(payload.sessionId);
if (sub && typeof payload.seq === "number") sub.lastSeq = payload.seq;
const sink = this.outputSinks.get(payload.sessionId);
// TODO(F3/B5): honour `seq` ordering + gap detection for precise replay.
if (sink) sink(base64ToBytes(payload.bytesBase64));
return;
}
case "terminal.status": {
const payload = frame.payload as unknown as StatusPayload;
this.handleStatus(payload);
return;
}
case "event.domain": {
// `payload` is a DomainEventDto (kind-tagged); forward as-is.
this.domainEventHandler?.(frame.payload as unknown as DomainEvent);
return;
}
@ -229,4 +596,25 @@ export class WsLiveClient {
}
}
}
private handleStatus(payload: StatusPayload): void {
const sub = this.terminals.get(payload.sessionId);
if (!sub) return;
if (payload.status === "exited" || payload.status === "closed") {
const code = payload.exitCode;
sub.onData(
notice(
code === null || code === undefined
? "session terminée"
: `session terminée (code ${code})`,
),
);
sub.onStatus?.(payload.status, payload.exitCode);
// The PTY is gone: stop tracking so a later disconnect does not try to
// re-attach a dead session.
this.removeTerminal(payload.sessionId);
} else {
sub.onStatus?.(payload.status, payload.exitCode);
}
}
}

View File

@ -0,0 +1,255 @@
/**
* F3 — the WS live client connection state machine + reconnection:
* - disconnected (socket close) ⇒ `reconnecting`, a notice is written to xterm;
* - on reconnect, the tracked terminal is re-attached with its `lastSeq` and the
* bounded scrollback is repainted; state returns to `connected`;
* - a `terminal.status` `exited` frame notifies the session and stops tracking
* (so a later disconnect does not try to re-attach a dead PTY).
*/
import { describe, it, expect, vi } from "vitest";
import { WsLiveClient, type WebSocketLike, type ConnectionState } from "./wsLiveClient";
import { bytesToBase64 } from "./frames";
class FakeSocket implements WebSocketLike {
sent: string[] = [];
onopen: (() => void) | null = null;
onmessage: ((event: { data: string }) => void) | null = null;
onerror: ((event: unknown) => void) | null = null;
onclose: (() => void) | null = null;
send(data: string): void {
this.sent.push(data);
}
close(): void {
this.onclose?.();
}
receive(frame: unknown): void {
this.onmessage?.({ data: JSON.stringify(frame) });
}
}
function harness() {
const sockets: FakeSocket[] = [];
let timerFn: (() => void) | null = null;
const ws = new WsLiveClient({
wsUrl: "wss://host",
reconnectBaseMs: 1,
socketFactory: () => {
const s = new FakeSocket();
sockets.push(s);
queueMicrotask(() => s.onopen?.());
return s;
},
setTimeoutImpl: (fn) => {
timerFn = fn;
return 1;
},
clearTimeoutImpl: () => {
timerFn = null;
},
});
const states: ConnectionState[] = [];
ws.onConnectionStateChange((s) => states.push(s));
return {
ws,
sockets,
states,
fireTimer: () => {
const fn = timerFn;
timerFn = null;
fn?.();
},
hasTimer: () => timerFn !== null,
};
}
function attachedAck(id: string, sessionId: string, nextSeq: number, scroll?: Uint8Array) {
return {
kind: "terminal.attached",
replyTo: id,
payload: {
session: { sessionId, rows: 30, cols: 120 },
scrollback: scroll ? [{ seq: 0, bytesBase64: bytesToBase64(scroll) }] : [],
nextSeq,
status: "running",
gap: false,
},
};
}
async function attach(
ws: WsLiveClient,
sockets: FakeSocket[],
sessionId: string,
onData: (b: Uint8Array) => void,
onStatus?: (s: string, c?: number | null) => void,
): Promise<void> {
const p = ws.attachTerminal({ sessionId, onData, onStatus });
// The socket is created lazily by the factory inside ensureConnected.
await vi.waitFor(() => expect(sockets[0]?.sent.length ?? 0).toBeGreaterThan(0));
const sent = JSON.parse(sockets[0].sent[0]);
sockets[0].receive(attachedAck(sent.id, sessionId, 0));
await p;
}
const decode = (b: Uint8Array) => new TextDecoder().decode(b);
describe("WsLiveClient connection state + reconnection", () => {
it("goes connecting → connected on the first attach", async () => {
const h = harness();
await attach(h.ws, h.sockets, "s1", () => {});
expect(h.ws.getConnectionState()).toBe("connected");
expect(h.states).toContain("connected");
});
it("on socket close it reconnects, re-attaches with lastSeq and repaints scrollback", async () => {
const h = harness();
const chunks: Uint8Array[] = [];
await attach(h.ws, h.sockets, "s1", (b) => chunks.push(b));
// Advance lastSeq via an output frame (seq 5).
h.sockets[0].receive({
kind: "terminal.output",
payload: { sessionId: "s1", seq: 5, bytesBase64: bytesToBase64(new Uint8Array([120])) },
});
// Drop the socket → reconnecting + a "déconnecté" notice.
h.sockets[0].close();
expect(h.ws.getConnectionState()).toBe("reconnecting");
expect(chunks.some((c) => decode(c).includes("déconnecté"))).toBe(true);
expect(h.hasTimer()).toBe(true);
// Fire the reconnect timer → a new socket opens and re-attaches.
h.fireTimer();
await vi.waitFor(() => expect(h.sockets.length).toBe(2));
await vi.waitFor(() => expect(h.sockets[1].sent.length).toBeGreaterThan(0));
const reattach = JSON.parse(h.sockets[1].sent[0]);
expect(reattach.kind).toBe("terminal.attach");
// lastSeq carries the last output seq seen (5) for precise replay.
expect(reattach.payload).toMatchObject({ sessionId: "s1", lastSeq: 5 });
// Server replies with fresh scrollback; the client repaints it + "reconnecté".
const scroll = new Uint8Array([82, 69]);
h.sockets[1].receive(attachedAck(reattach.id, "s1", 3, scroll));
await vi.waitFor(() => expect(h.ws.getConnectionState()).toBe("connected"));
// The repaint happens after the re-attach ack resolves (a microtask later).
await vi.waitFor(() =>
expect(chunks.some((c) => decode(c).includes("reconnecté"))).toBe(true),
);
expect(chunks.some((c) => c.length === 2 && c[0] === 82 && c[1] === 69)).toBe(true);
});
it("routes terminal.status exited to onStatus and stops tracking the session", async () => {
const h = harness();
const chunks: Uint8Array[] = [];
const onStatus = vi.fn();
await attach(h.ws, h.sockets, "s1", (b) => chunks.push(b), onStatus);
h.sockets[0].receive({
kind: "terminal.status",
payload: { sessionId: "s1", status: "exited", exitCode: 0 },
});
expect(onStatus).toHaveBeenCalledWith("exited", 0);
expect(chunks.some((c) => decode(c).includes("session terminée"))).toBe(true);
// Session untracked ⇒ a later socket close does not schedule a reconnect.
h.sockets[0].close();
expect(h.hasTimer()).toBe(false);
});
it("reconnects for a live domain-event subscription even with no terminal (F5)", async () => {
const h = harness();
// A live surface subscribes to domain events (no terminal open).
h.ws.setDomainEventHandler(() => {});
await h.ws.ensureConnected();
await vi.waitFor(() => expect(h.ws.getConnectionState()).toBe("connected"));
// Drop the socket → must schedule a reconnect (the subscription still needs it).
h.sockets[0].close();
expect(h.ws.getConnectionState()).toBe("reconnecting");
expect(h.hasTimer()).toBe(true);
h.fireTimer();
await vi.waitFor(() => expect(h.sockets.length).toBe(2));
await vi.waitFor(() => expect(h.ws.getConnectionState()).toBe("connected"));
// Events resume flowing to the persisted handler after reconnect.
const events: unknown[] = [];
h.ws.setDomainEventHandler((e) => events.push(e));
h.sockets[1].receive({ kind: "event.domain", payload: { type: "agentBusyChanged", agentId: "a1", busy: true } });
expect(events).toHaveLength(1);
});
it("dispose() moves to closed and clears any pending reconnect", async () => {
const h = harness();
await attach(h.ws, h.sockets, "s1", () => {});
h.sockets[0].close();
expect(h.ws.getConnectionState()).toBe("reconnecting");
h.ws.dispose();
expect(h.ws.getConnectionState()).toBe("closed");
expect(h.hasTimer()).toBe(false);
});
it("disconnect() closes and stops reconnecting but stays reusable (F6 sign-out)", async () => {
const h = harness();
h.ws.setDomainEventHandler(() => {});
await h.ws.ensureConnected();
await vi.waitFor(() => expect(h.ws.getConnectionState()).toBe("connected"));
// Sign-out: soft teardown. State is closed, the reconnect loop is dropped, and
// the detached socket's onclose cannot flip the state back to reconnecting.
h.ws.disconnect();
expect(h.ws.getConnectionState()).toBe("closed");
expect(h.hasTimer()).toBe(false);
expect(h.ws.needsReconnect()).toBe(false);
// Reusable: a later ensureConnected reconnects as a fresh socket (re-pairing).
await h.ws.ensureConnected();
await vi.waitFor(() => expect(h.sockets.length).toBe(2));
await vi.waitFor(() => expect(h.ws.getConnectionState()).toBe("connected"));
});
it("calls onReconnectFailed when a reconnect attempt fails (auth probe seam)", async () => {
const failed = vi.fn();
let openNextSocket = true;
let timerFn: (() => void) | null = null;
const sockets: FakeSocket[] = [];
const ws = new WsLiveClient({
wsUrl: "wss://host",
reconnectBaseMs: 1,
onReconnectFailed: failed,
socketFactory: () => {
const s = new FakeSocket();
sockets.push(s);
// First socket connects; the reconnect socket errors (upgrade rejected).
if (openNextSocket) queueMicrotask(() => s.onopen?.());
else queueMicrotask(() => s.onerror?.(new Error("upgrade rejected")));
return s;
},
setTimeoutImpl: (fn) => {
timerFn = fn;
return 1;
},
clearTimeoutImpl: () => {
timerFn = null;
},
});
ws.setDomainEventHandler(() => {});
await ws.ensureConnected();
await vi.waitFor(() => expect(ws.getConnectionState()).toBe("connected"));
// Drop the socket; the next connect attempt will fail the upgrade.
openNextSocket = false;
sockets[0].close();
expect(ws.getConnectionState()).toBe("reconnecting");
const fire = timerFn as (() => void) | null;
timerFn = null;
fire?.();
// The failed reconnect fires the probe hook and schedules another attempt.
await vi.waitFor(() => expect(failed).toHaveBeenCalled());
expect(timerFn).not.toBeNull();
});
});

View File

@ -0,0 +1,76 @@
/**
* Web agent cell — ticket #13, lot F4. Thin wiring (no new terminal logic) that
* reuses the existing, transport-neutral {@link TerminalView} to render a CLI
* agent over the WebSocket in web mode.
*
* The agent's CLI runs server-side (B6); the browser is display-only. This
* component just supplies `TerminalView` with the DI **agent gateway** as the
* opener/reattacher:
* - `open` → `agent.launchAgent(projectId, agentId, …)` (frame `agent.launch`,
* unified `terminal.attached` ack; the minted conversation id is carried on the
* returned handle),
* - `reattach` → `agent.reattach(sessionId, …)` (frame `terminal.attach`, no
* relaunch, bounded scrollback repainted) — so a browser reload/reconnect
* resumes the surviving server-side PTY.
*
* The session id is persisted in component state so a re-mount re-attaches rather
* than relaunching, matching the desktop cell's lifecycle. A structured agent is
* refused server-side (`UNSUPPORTED`) and surfaced by `TerminalView`'s own error
* banner — no crash. It touches only gateways via DI (no `@tauri-apps/api`).
*/
import { useCallback, useState } from "react";
import type {
OpenTerminalOptions,
ReattachResult,
TerminalHandle,
} from "@/ports";
import { useGateways } from "@/app/di";
import { TerminalView } from "@/features/terminals";
interface WebAgentCellProps {
/** Owning project id (resolved server-side by `agent.launch`). */
projectId: string;
/** Agent to launch/attach. */
agentId: string;
/** Working directory for the cell (typically the project root). */
cwd: string;
/** Layout leaf id, when the caller tracks one (drives the singleton guard). */
nodeId?: string;
}
export function WebAgentCell({ projectId, agentId, cwd, nodeId }: WebAgentCellProps) {
const { agent } = useGateways();
const [sessionId, setSessionId] = useState<string | null>(null);
const open = useCallback(
(options: OpenTerminalOptions, onData: (bytes: Uint8Array) => void): Promise<TerminalHandle> =>
agent.launchAgent(
projectId,
agentId,
nodeId ? { ...options, nodeId } : options,
onData,
),
[agent, projectId, agentId, nodeId],
);
const reattach = useCallback(
(sid: string, onData: (bytes: Uint8Array) => void): Promise<ReattachResult> =>
agent.reattach(sid, onData),
[agent],
);
return (
<div data-testid="web-agent-cell" className="h-64 w-full">
<TerminalView
cwd={cwd}
agentMode
open={open}
reattach={reattach}
sessionId={sessionId}
onSessionId={setSessionId}
/>
</div>
);
}

View File

@ -31,6 +31,16 @@ const okFetch: FetchLike = async () => ({
text: async () => "{}",
});
/** Records fetch calls so a test can assert the logout POST. */
function recordingFetch(): { fetchImpl: FetchLike; calls: string[] } {
const calls: string[] = [];
const fetchImpl: FetchLike = async (url) => {
calls.push(url);
return { ok: true, status: 200, json: async () => ({ ok: true }), text: async () => "{}" };
};
return { fetchImpl, calls };
}
const rejectFetch: FetchLike = async () => ({
ok: false,
status: 401,
@ -102,7 +112,38 @@ describe("WebApp pairing routing", () => {
const snapshot = await screen.findByTestId("web-workstate");
expect(snapshot.textContent).toContain("Archi");
expect(snapshot.textContent).toContain("idle");
expect(snapshot.textContent).toContain("Idle");
});
it("opens a live agent cell for a work-state agent (F4 affordance)", async () => {
const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() });
session.markPaired();
renderWebApp(session, await seededGateways());
fireEvent.click(await screen.findByText("Demo"));
await screen.findByTestId("web-workstate");
// The per-agent "Ouvrir" affordance mounts the reusable TerminalView cell,
// wired to the DI agent gateway (the CLI runs server-side).
expect(screen.queryByTestId("web-agent-cell")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Ouvrir" }));
expect(await screen.findByTestId("web-agent-cell")).toBeTruthy();
});
it("signs out via POST /api/logout and returns to pairing", async () => {
const { fetchImpl, calls } = recordingFetch();
const session = new WebSession({ baseUrl: "https://h", fetchImpl, store: memStore() });
session.markPaired();
renderWebApp(session, await seededGateways());
expect(await screen.findByText("Projets")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Se déconnecter" }));
await waitFor(() => expect(screen.getByText("Appairer cet appareil")).toBeTruthy());
expect(calls).toContain("https://h/api/logout");
expect(session.isPaired()).toBe(false);
expect(screen.queryByText("Projets")).toBeNull();
});
it("returns to pairing when the session reports unauthorized (401)", async () => {

View File

@ -5,14 +5,15 @@
* Routes on the {@link WebSession} paired flag: not paired ⇒ {@link PairingScreen};
* paired ⇒ the read-only {@link WebWorkspace}. It subscribes to the session's
* `onUnauthorized` signal so a `401` from any `/api/invoke` (expired/missing
* cookie) drops back to pairing automatically. A best-effort "Se déconnecter"
* clears the local flag (server-side cookie revocation is B8).
* cookie) drops back to pairing automatically. "Se déconnecter" (F6) revokes the
* server session (`POST /api/logout`), tears down the live WS singleton, and
* returns to pairing.
*/
import { useEffect, useState } from "react";
import { Button } from "@/shared";
import { getWebSession, type WebSession } from "@/adapters/http";
import { disconnectWebLive, getWebSession, type WebSession } from "@/adapters/http";
import { PairingScreen } from "./PairingScreen";
import { WebWorkspace } from "./WebWorkspace";
@ -24,15 +25,26 @@ interface WebAppProps {
export function WebApp({ session }: WebAppProps = {}) {
const webSession = session ?? getWebSession();
const [paired, setPaired] = useState(() => webSession.isPaired());
const [signingOut, setSigningOut] = useState(false);
useEffect(() => {
// A 401 anywhere clears the flag and fires this: return to pairing.
// A 401 anywhere clears the flag and fires this: return to pairing. The live
// WS is torn down by the composition-root 401 handler (F6).
return webSession.onUnauthorized(() => setPaired(false));
}, [webSession]);
function signOut(): void {
webSession.forget();
setPaired(false);
async function signOut(): Promise<void> {
if (signingOut) return;
setSigningOut(true);
try {
// Revoke the server session (best-effort), then drop the live socket so it
// stops reconnecting, and return to pairing regardless of the outcome.
await webSession.logout();
} finally {
disconnectWebLive();
setSigningOut(false);
setPaired(false);
}
}
return (
@ -45,7 +57,7 @@ export function WebApp({ session }: WebAppProps = {}) {
</span>
</div>
{paired && (
<Button variant="ghost" size="sm" onClick={signOut}>
<Button variant="ghost" size="sm" loading={signingOut} onClick={() => void signOut()}>
Se déconnecter
</Button>
)}

View File

@ -1,22 +1,34 @@
/**
* Read-only web workspace — ticket #13, lot F2 (first shippable increment).
* Live web workspace — ticket #13, lots F2 (read-only) + F5 (live surfaces).
*
* The minimal post-pairing surface: list the projects, open one **read-only**,
* and show a snapshot of its live/work state. No PTY (xterm over WS = F3), no
* mutation — every call goes through the existing transport-neutral gateways
* (`project`, `workState`) via DI, so no component touches `@tauri-apps/api`.
* After pairing: list projects, open one read-only, and show its **live**
* work-state — agents (live/idle/busy), background tasks (with cancel/retry) and
* per-agent inbox — updated in real time. The live mechanism is the desktop's own
* transport-neutral {@link useProjectWorkState} hook (refreshes the read-model on
* relevant `event.domain` events pushed over the WS, B7); {@link useLiveReconnect}
* re-synchronises after a WS outage. Background cancel/retry go through the
* {@link WorkStateGateway} — writes via DI, no direct transport. A CLI agent can
* be opened into a live cell (F4). No component touches `@tauri-apps/api`.
*
* It reuses the frozen read-model types (`ProjectWorkState`) and only calls the
* commands B4 puts on the read-only allowlist: `list_projects`, `open_project`,
* `get_project_work_state`. A deliberately small surface — the full IDE (layout,
* agents, terminals) is out of scope until the streaming lots.
* Read-only allowlist used (B4/B7): `list_projects`, `open_project`,
* `get_project_work_state`, plus the background `cancel`/`retry` commands and the
* `event.domain` live stream.
*/
import { useCallback, useEffect, useState } from "react";
import type { GatewayError, Project, ProjectWorkState } from "@/domain";
import type {
AgentWorkState,
BackgroundCompletion,
GatewayError,
Project,
} from "@/domain";
import { useGateways } from "@/app/di";
import { Button, Panel, Spinner } from "@/shared";
import { useProjectWorkState } from "@/features/workstate/useProjectWorkState";
import { Button, Panel, Spinner, cn } from "@/shared";
import { WebAgentCell } from "./WebAgentCell";
import { useLiveReconnect } from "./useLiveReconnect";
import { useLiveConnectionState } from "./useLiveConnectionState";
function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
@ -26,12 +38,12 @@ function describe(e: unknown): string {
}
export function WebWorkspace() {
const { project, workState } = useGateways();
const { project } = useGateways();
const [projects, setProjects] = useState<Project[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [openId, setOpenId] = useState<string | null>(null);
const [snapshot, setSnapshot] = useState<ProjectWorkState | null>(null);
const [loadingSnapshot, setLoadingSnapshot] = useState(false);
const openRoot = projects?.find((p) => p.id === openId)?.root ?? null;
const refresh = useCallback(async () => {
setError(null);
@ -49,25 +61,22 @@ export function WebWorkspace() {
const openReadOnly = useCallback(
async (projectId: string) => {
setError(null);
setLoadingSnapshot(true);
setOpenId(projectId);
setSnapshot(null);
setOpenId(null);
try {
// Read-only: open resolves the project server-side, then we read the
// live/work-state snapshot. No layout/agents/PTY are mounted.
// Read-only: resolve the project server-side; the live panel then streams
// its work-state. No layout/PTY is mounted here.
await project.openProject(projectId);
setSnapshot(await workState.getProjectWorkState(projectId));
setOpenId(projectId);
} catch (e) {
setError(describe(e));
} finally {
setLoadingSnapshot(false);
}
},
[project, workState],
[project],
);
return (
<div className="flex h-full flex-col gap-4 overflow-y-auto p-6">
<ReconnectBanner />
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold tracking-tight">Projets</h2>
<Button variant="ghost" size="sm" onClick={() => void refresh()}>
@ -106,51 +115,227 @@ export function WebWorkspace() {
)}
{openId && (
<Panel className="mt-2">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold">
État (lecture seule)
<span className="ml-2 rounded bg-canvas px-1.5 py-0.5 text-[0.6rem] uppercase text-muted">
read-only
</span>
</h3>
{loadingSnapshot && <Spinner size={12} />}
</div>
{snapshot ? (
<WorkStateSnapshot snapshot={snapshot} />
) : loadingSnapshot ? (
<p className="text-xs text-muted">Chargement de l'état</p>
) : (
<p className="text-xs text-muted">Aucun état disponible.</p>
)}
</Panel>
<LiveProjectPanel projectId={openId} root={openRoot} />
)}
</div>
);
}
/** Pure render of the read-only work-state snapshot. */
function WorkStateSnapshot({ snapshot }: { snapshot: ProjectWorkState }) {
if (snapshot.agents.length === 0) {
return <p className="text-xs text-muted">Aucun agent actif.</p>;
}
/**
* Global reconnection banner (F6). The F3 terminal path writes a "déconnecté"
* notice into xterm, but live-only surfaces (no terminal open) had no visible
* signal. This surfaces the shared live socket's `reconnecting` state so the user
* always knows the view may be momentarily stale; `useLiveReconnect` re-syncs the
* read-model on recovery. Inert on desktop (hook returns `null`).
*/
function ReconnectBanner() {
const connection = useLiveConnectionState();
if (connection !== "reconnecting") return null;
return (
<ul className="flex flex-col gap-1.5" data-testid="web-workstate">
{snapshot.agents.map((a) => (
<li key={a.agentId} className="flex items-center justify-between text-xs">
<span className="text-content">{a.name}</span>
<span className="flex items-center gap-2 text-faint">
<span>{a.live ? `live · ${a.live.kind}` : "offline"}</span>
<span
className={
a.busy.state === "busy" ? "text-warning" : "text-success"
}
>
{a.busy.state === "busy" ? "busy" : "idle"}
</span>
</span>
</li>
))}
</ul>
<div
role="status"
data-testid="web-reconnect-banner"
className="flex items-center gap-2 rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning"
>
<Spinner size={12} />
Connexion perdue reconnexion en cours
</div>
);
}
/** Live work-state + background + inbox for the opened project (F5). */
function LiveProjectPanel({ projectId, root }: { projectId: string; root: string | null }) {
const vm = useProjectWorkState(projectId);
// Re-sync the read-model when the WS reconnects (events missed while offline).
useLiveReconnect(vm.refresh);
const [openAgentId, setOpenAgentId] = useState<string | null>(null);
const agents = vm.state?.agents ?? [];
return (
<Panel className="mt-2">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold">
État live
<span className="ml-2 rounded bg-canvas px-1.5 py-0.5 text-[0.6rem] uppercase text-muted">
read-only
</span>
</h3>
<Button variant="ghost" size="sm" loading={vm.busy} onClick={() => void vm.refresh()}>
Rafraîchir
</Button>
</div>
{vm.error && <p role="alert" className="mb-2 text-xs text-danger">{vm.error}</p>}
{vm.busy && vm.state === null ? (
<p className="text-xs text-muted">Chargement de l'état</p>
) : agents.length === 0 ? (
<p className="text-xs text-muted">Aucun agent actif.</p>
) : (
<ul className="flex flex-col divide-y divide-border" data-testid="web-workstate">
{agents.map((a) => (
<AgentLiveRow
key={a.agentId}
agent={a}
open={openAgentId === a.agentId}
onToggleOpen={() =>
setOpenAgentId((cur) => (cur === a.agentId ? null : a.agentId))
}
onRefresh={vm.refresh}
/>
))}
</ul>
)}
{root && openAgentId && (
<div className="mt-3">
<div className="mb-1 flex items-center justify-between">
<span className="text-xs font-semibold text-muted">Agent</span>
<Button variant="ghost" size="sm" onClick={() => setOpenAgentId(null)}>
Fermer
</Button>
</div>
<WebAgentCell key={openAgentId} projectId={projectId} agentId={openAgentId} cwd={root} />
</div>
)}
</Panel>
);
}
/** One agent row: live/idle/busy + inbox + background tasks + open affordance. */
function AgentLiveRow({
agent,
open,
onToggleOpen,
onRefresh,
}: {
agent: AgentWorkState;
open: boolean;
onToggleOpen: () => void;
onRefresh: () => Promise<void>;
}) {
const live = agent.live !== undefined;
const busy = agent.busy?.state === "busy";
const inbox = [...(agent.inbox ?? [])].sort((a, b) => a.createdAtMs - b.createdAtMs);
const backgroundTasks = [...(agent.backgroundTasks ?? [])].sort(
(a, b) => b.updatedAtMs - a.updatedAtMs || a.taskId.localeCompare(b.taskId),
);
return (
<li className="py-2 first:pt-0 last:pb-0">
<div className="flex items-center justify-between gap-2">
<span className="min-w-0 truncate text-sm text-content">{agent.name}</span>
<span className="flex shrink-0 items-center gap-1.5 text-xs">
<span className={cn("rounded-full px-2 py-0.5 font-medium", live ? "bg-success/15 text-success" : "bg-raised text-muted")}>
{live ? "Live" : "Offline"}
</span>
<span className={cn("rounded-full px-2 py-0.5 font-medium", busy ? "bg-warning/15 text-warning" : "bg-raised text-muted")}>
{busy ? "Busy" : "Idle"}
</span>
<Button variant="ghost" size="sm" aria-pressed={open} onClick={onToggleOpen}>
{open ? "Fermer" : "Ouvrir"}
</Button>
</span>
</div>
{inbox.length > 0 && (
<div className="mt-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-faint">Inbox</p>
<ul aria-label={`${agent.name} inbox`} className="mt-1 flex flex-col gap-1">
{inbox.map((item) => (
<li key={item.id} className="flex min-w-0 items-start gap-2 text-xs text-muted">
<span className="mt-0.5 shrink-0 rounded-full bg-raised px-1.5 py-0.5 font-medium">{item.kind}</span>
<span className="min-w-0 flex-1 break-words">{item.body}</span>
</li>
))}
</ul>
</div>
)}
{backgroundTasks.length > 0 && (
<div className="mt-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-faint">Background tasks</p>
<ul aria-label={`${agent.name} background tasks`} className="mt-1 flex flex-col gap-1">
{backgroundTasks.map((task) => (
<WebBackgroundTaskRow key={task.taskId} task={task} onRefresh={onRefresh} />
))}
</ul>
</div>
)}
</li>
);
}
const TASK_STATUS_LABEL: Record<BackgroundCompletion["status"], string> = {
running: "Running",
completed: "Completed",
failed: "Failed",
cancelled: "Cancelled",
pending: "Pending",
delivered: "Delivered",
};
function taskStatusClass(status: BackgroundCompletion["status"]): string {
if (status === "running" || status === "pending") return "bg-warning/15 text-warning";
if (status === "completed" || status === "delivered") return "bg-success/10 text-success";
if (status === "failed" || status === "cancelled") return "bg-danger/10 text-danger";
return "bg-raised text-muted";
}
/** One background task with live status + cancel/retry via the DI gateway. */
function WebBackgroundTaskRow({
task,
onRefresh,
}: {
task: BackgroundCompletion;
onRefresh: () => Promise<void>;
}) {
const { workState } = useGateways();
const [actionBusy, setActionBusy] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const canCancel = task.status === "running" || task.status === "pending";
const canRetry = task.status === "failed" || task.status === "cancelled";
async function runAction(action: (taskId: string) => Promise<void>): Promise<void> {
setActionBusy(true);
setMessage(null);
try {
await action(task.taskId);
await onRefresh();
} catch (e) {
setMessage(describe(e));
} finally {
setActionBusy(false);
}
}
return (
<li className="min-w-0 text-xs text-muted">
<div className="flex min-w-0 items-center gap-2">
<span className={cn("shrink-0 rounded-full px-1.5 py-0.5 font-medium", taskStatusClass(task.status))}>
{TASK_STATUS_LABEL[task.status]}
</span>
<span className="min-w-0 flex-1 truncate text-content">{task.kind}</span>
<Button
size="sm"
variant="ghost"
disabled={!canCancel || actionBusy}
loading={actionBusy}
onClick={() => void runAction((id) => workState.cancelBackgroundTask(id))}
>
Cancel
</Button>
<Button
size="sm"
variant="ghost"
disabled={!canRetry || actionBusy}
loading={actionBusy}
onClick={() => void runAction((id) => workState.retryBackgroundTask(id))}
>
Retry
</Button>
</div>
{message && <p role="alert" className="mt-1 text-danger">{message}</p>}
</li>
);
}

View File

@ -0,0 +1,140 @@
/**
* F5 — the live web surfaces: a `event.domain` event refreshes the displayed
* work-state; background tasks render with status + cancel/retry wired to the
* gateway; a WS reconnect re-synchronises the read-model.
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { Gateways } from "@/ports";
import type { BackgroundCompletion, ProjectWorkState } from "@/domain";
import { DIProvider } from "@/app/di";
import { createMockGateways, MockSystemGateway, MockWorkStateGateway } from "@/adapters/mock";
import {
WsLiveClient,
setWebLiveClient,
WebSession,
} from "@/adapters/http";
import { WebApp } from "./WebApp";
afterEach(() => setWebLiveClient(null));
const okFetch = async () => ({
ok: true,
status: 200,
json: async () => ({ ok: true }),
text: async () => "{}",
});
function memStore() {
const map = new Map<string, string>();
return {
getItem: (k: string) => map.get(k) ?? null,
setItem: (k: string, v: string) => void map.set(k, v),
removeItem: (k: string) => void map.delete(k),
};
}
function state(agents: ProjectWorkState["agents"]): ProjectWorkState {
return { agents, conversations: [] };
}
async function seeded(initial: ProjectWorkState): Promise<{ gateways: Gateways; projectId: string }> {
const gateways = createMockGateways();
const project = await gateways.project.createProject("Demo", "/srv/demo");
(gateways.workState as MockWorkStateGateway)._setProjectWorkState(project.id, initial);
return { gateways, projectId: project.id };
}
function renderPaired(gateways: Gateways) {
const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() });
session.markPaired();
return render(
<DIProvider gateways={gateways}>
<WebApp session={session} />
</DIProvider>,
);
}
const AGENT_IDLE = { agentId: "a1", name: "Archi", profileId: "p1", busy: { state: "idle" as const }, tickets: [] };
describe("WebWorkspace live surfaces (F5)", () => {
it("refreshes the work-state on a relevant event.domain event", async () => {
const { gateways, projectId } = await seeded(state([AGENT_IDLE]));
renderPaired(gateways);
fireEvent.click(await screen.findByText("Demo"));
const panel = await screen.findByTestId("web-workstate");
expect(panel.textContent).toContain("Idle");
// The agent goes busy server-side; the read-model now reflects it…
(gateways.workState as MockWorkStateGateway)._setProjectWorkState(
projectId,
state([{ ...AGENT_IDLE, busy: { state: "busy", ticket: "t-1", sinceMs: 1 } }]),
);
// …and an `agentBusyChanged` domain event drives a live refresh.
act(() => {
(gateways.system as MockSystemGateway).emit({ type: "agentBusyChanged", agentId: "a1", busy: true });
});
await waitFor(() =>
expect(screen.getByTestId("web-workstate").textContent).toContain("Busy"),
);
});
it("renders background tasks and wires cancel through the gateway", async () => {
const task: BackgroundCompletion = {
taskId: "bt-1",
ownerAgentId: "a1",
projectId: "p",
kind: "shell",
status: "running",
exitCode: null,
summary: null,
stdoutTail: null,
stderrTail: null,
updatedAtMs: 1,
};
const { gateways } = await seeded(state([{ ...AGENT_IDLE, backgroundTasks: [task] }]));
const cancelSpy = vi.spyOn(gateways.workState, "cancelBackgroundTask");
renderPaired(gateways);
fireEvent.click(await screen.findByText("Demo"));
const tasks = await screen.findByLabelText("Archi background tasks");
expect(tasks.textContent).toContain("Running");
expect(tasks.textContent).toContain("shell");
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(cancelSpy).toHaveBeenCalledWith("bt-1"));
});
it("re-synchronises the read-model when the WS reconnects", async () => {
const { gateways, projectId } = await seeded(state([AGENT_IDLE]));
// Register a live client whose connection state we can drive.
let notify: ((s: "connecting" | "connected" | "reconnecting" | "closed") => void) | null = null;
const fakeClient = {
getConnectionState: () => "connected" as const,
onConnectionStateChange: (l: (s: "connecting" | "connected" | "reconnecting" | "closed") => void) => {
notify = l;
return () => {
notify = null;
};
},
};
setWebLiveClient(fakeClient as unknown as WsLiveClient);
const refreshSpy = vi.spyOn(gateways.workState, "getProjectWorkState");
renderPaired(gateways);
fireEvent.click(await screen.findByText("Demo"));
await screen.findByTestId("web-workstate");
const callsAfterOpen = refreshSpy.mock.calls.length;
expect(projectId).toBeTruthy();
// Simulate a reconnect: reconnecting → connected must re-fetch the read-model.
act(() => {
notify?.("reconnecting");
notify?.("connected");
});
await waitFor(() => expect(refreshSpy.mock.calls.length).toBeGreaterThan(callsAfterOpen));
});
});

View File

@ -6,3 +6,4 @@
export { WebApp } from "./WebApp";
export { PairingScreen } from "./PairingScreen";
export { WebWorkspace } from "./WebWorkspace";
export { WebAgentCell } from "./WebAgentCell";

View File

@ -0,0 +1,29 @@
/**
* `useLiveConnectionState` — ticket #13, lot F6. Web-only hook exposing the shared
* live WebSocket's current {@link ConnectionState} to the UI so a reconnection
* banner can be shown even when no terminal is open (the F3 xterm notice only
* covers terminal cells; live-only surfaces had no visible signal).
*
* Inert outside web transport (no client registered), so it is safe to mount
* unconditionally: it returns `null` on desktop.
*/
import { useEffect, useState } from "react";
import { getWebLiveClient, type ConnectionState } from "@/adapters/http";
export function useLiveConnectionState(): ConnectionState | null {
const [state, setState] = useState<ConnectionState | null>(
() => getWebLiveClient()?.getConnectionState() ?? null,
);
useEffect(() => {
const client = getWebLiveClient();
if (!client) return;
// Sync in case the state changed between the initial render and this effect.
setState(client.getConnectionState());
return client.onConnectionStateChange(setState);
}, []);
return state;
}

View File

@ -0,0 +1,27 @@
/**
* `useLiveReconnect` — ticket #13, lot F5. Web-only hook that re-synchronises a
* live read-model when the shared WebSocket **reconnects** after an outage.
*
* During a disconnection, domain events emitted by the server are missed, so a
* plain event-driven refresh (the desktop `useProjectWorkState` mechanism) would
* leave the UI stale until the next event. This hook watches the web live
* client's connection state and calls `onReconnect` on each `reconnecting →
* connected` transition. It is inert outside web transport (no client
* registered), so it is safe to mount unconditionally.
*/
import { useEffect } from "react";
import { getWebLiveClient } from "@/adapters/http";
export function useLiveReconnect(onReconnect: () => void): void {
useEffect(() => {
const client = getWebLiveClient();
if (!client) return;
let previous = client.getConnectionState();
return client.onConnectionStateChange((state) => {
if (state === "connected" && previous === "reconnecting") onReconnect();
previous = state;
});
}, [onReconnect]);
}