diff --git a/.gitignore b/.gitignore index b9ce045..6922d76 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,9 @@ target/ # Dependencies and build output (package-lock.json IS committed). frontend/node_modules/ frontend/dist/ +# Sortie du build web (VITE_TRANSPORT=http) servie par idea-serve : meme +# classe que dist/, rebuildable depuis les sources — not versioned (#65). +frontend/dist-web/ # Root-level node_modules (dev tooling installs at repo root — never versioned). /node_modules/ # npm/yarn/pnpm debug logs diff --git a/.ideai/background-tasks/97b49ac2-8376-4aa3-8ea9-bf3ac81d0023.json b/.ideai/background-tasks/97b49ac2-8376-4aa3-8ea9-bf3ac81d0023.json index 1979297..4e1961c 100644 --- a/.ideai/background-tasks/97b49ac2-8376-4aa3-8ea9-bf3ac81d0023.json +++ b/.ideai/background-tasks/97b49ac2-8376-4aa3-8ea9-bf3ac81d0023.json @@ -6643,10 +6643,374 @@ "ticket_id": "218b09a6-1c44-4cde-81b7-9e281abbe33c", "conversation_id": "6bc594e8-a37c-0dbd-1de6-6e3b73002cb4" }, - "state": "running", + "state": "completed", "wakePolicy": "recordOnly", "createdAtMs": 1784190715546, - "updatedAtMs": 1784190715546, + "updatedAtMs": 1784190787705, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784190787705, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "Merge terminé, sans conflit, strictement en local.\n\n## Branche source\n\n`feature/server-client-packaging` — et non un HEAD détaché : le snapshot du git status était trompeur, `.git/HEAD` pointait bien sur `ref: refs/heads/feature/server-client-packaging`. J'ai trouvé une deuxième branche, `feature/ticket13-pty-websocket`, sur le même commit `c246875` (doublon de nommage du même travail).\n\n## Décision de topologie\n\nLa branche était **strictement 11 commits en avance** sur `develop`, sans divergence (`git rev-list --left-right --count` → `0 11`), la merge-base étant exactement le sommet de `develop`. Donc **pas de rebase préalable** : la base n'avait pas bougé, l'historique était déjà linéaire.\n\nJ'ai choisi un **merge `--no-ff`** plutôt que le fast-forward pourtant possible. C'est la convention du dépôt : le commit de merge garde la trace de l'intégration de la feature dans `develop` et rend le lot #13 identifiable comme un ensemble, là où un fast-forward aurait dilué les 12 commits dans la ligne principale.\n\nAvant de merger, j'ai committé à part l'état `.ideai/` (tickets #55 à #67, index/compteur, notes mémoire F0–F5, journal des tâches de fond) dans `8e481ae`, un commit `chore(ideai):` séparé du code applicatif — conformément aux précédents `ad1f225` et `a244f32`. J'ai vérifié ces fichiers avant de les stager : la seule alerte du scan de secrets était `.ideai/tickets/66/issue.md`, qui ne mentionne que des *noms* de variables d'environnement (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`), sans aucune valeur.\n\n## Commandes exécutées\n\n```\ngit merge --no-ff feature/server-client-packaging -m \"Merge feature/server-client-packaging into develop (#13) …\"\n→ Merge made by the 'ort' strategy.\n 76 files changed, 8778 insertions(+), 420 deletions(-)\n\ngit branch -d feature/server-client-packaging feature/ticket13-pty-websocket\n→ Branche feature/server-client-packaging supprimée (précédemment 8e481ae).\n→ Branche feature/ticket13-pty-websocket supprimée (précédemment c246875).\n```\n\nAucun conflit. J'ai supprimé les deux branches de feature après avoir confirmé via `git branch --contains c246875 --merged develop` qu'elles étaient bien intégrées — y compris le doublon `feature/ticket13-pty-websocket`, devenu redondant.\n\n## État final de `develop`\n\nSommet à **`506d589`** (`Merge feature/server-client-packaging into develop (#13)`), working tree propre. Le merge apporte le serveur (`crates/app-tauri/src/server.rs`, ~3050 lignes), la surface web (`frontend/src/features/web/`, `frontend/src/adapters/http/`) et la doc `docs/server-client-mode-remote.md`.\n\n`develop` est maintenant **en avance de 84 commits sur `origin/develop`**. Conformément à ton instruction, je n'ai fait aucune action sortante : pas de push, pas de PR. La synchro remote reste à ta main quand tu la valideras.", + "stderr_tail": null + }, + "completionDelivered": true + }, + { + "id": "aeb0a995-ef9a-4529-8193-6e528ebaad7f", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "dce19c75-9669-4e45-b8de-9950025157da", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "dce19c75-9669-4e45-b8de-9950025157da", + "ticket_id": "41816679-9f38-4a2f-bdfc-c80acbd2e146", + "conversation_id": "7a2f446c-2efa-0c56-08dd-07cdc5cac19b" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784193658720, + "updatedAtMs": 1784193757740, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784193757740, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "**Verdict global**\nTous les tickets restent valables après le merge #13. Aucun n’est déjà couvert. Les corrections principales sont : #68 doit être recadré comme consommation du futur `web-server` partagé, #64 ne doit pas dépendre de #66, et #67 devient un garde-fou ciblé “multi-process”, pas une condition générale du serveur embarqué desktop.\n\n**Validité Par Ticket**\n#64 — Valable, mais lien à corriger. Le besoin existe bien : `pickFolder()` est web-unsupported et `create_project` est encore bloqué côté allowlist HTTP. Garder le cadrage “folder browser serveur sandboxé + validation indépendante de `create_project`”. Retirer `dependsOn #66`. Ajouter plutôt `dependsOn #67` seulement si on active `create_project` dans un serveur headless séparé partageant l’app-data desktop.\n\n#65 — Valable et prioritaire. Ajustement de formulation : il existe déjà un crate `backend` transport-neutre, donc le travail n’est pas “extraire tout le cœur”, mais extraire les DTO/protocole et `server.rs` hors `app-tauri`. Le serveur importe encore `crate::dto`, `crate::events`, `crate::pty`, `crate::state::AppState`, donc le ticket reste nécessaire.\n\n#66 — Valable. Il dépend réellement de #65 : l’image doit embarquer `idea-serve`, pas le binaire Tauri. Le lien #65 est correct. Le lien inverse depuis #64 est à retirer.\n\n#67 — Valable, mais périmètre réduit. Il protège uniquement le cas multi-process : desktop + `idea-serve`/ancien `idea --serve` sur le même app-data-dir. Si le serveur est embarqué in-process dans le desktop (#68), ce risque disparaît pour ce mode-là. Le ticket reste utile comme garde-fou pour le binaire headless local, pas critique en Docker mono-conteneur.\n\n#68 — Valable, mais à recadrer avant implémentation. Ne pas faire maintenant par branchement direct sur `app-tauri/server.rs`, sauf besoin démo jetable. L’ordre propre est après #65, ou dans le même train technique que la fin de #65, pour éviter de payer deux fois.\n\n#69 — Valable et indépendant côté backend. Ce n’est pas un changement de contrats HTTP/WS/Rust. Ce n’est pas forcément “CSS pur” : le layout terminal, les docks, les fenêtres flottantes et xterm.js devront avoir un mode mobile, mais cela reste frontend/UI.\n\n**Arbitrages**\na) #68 vs #65 : faire #65 d’abord. Aujourd’hui #68 serait rapide mais accrocherait le desktop à une surface qui va être déplacée. La solution propre : #65 extrait `web-server` en lib, puis #68 ajoute dans le desktop un contrôleur start/stop/status qui consomme cette lib. Variante acceptable : ajouter dans #65 un petit point d’extension `web_server::run_embedded(...)` préparant #68.\n\nb) #68 vs #67 : oui, #68 réduit le périmètre de #67. Serveur desktop in-process = un seul writer app-data. #67 reste nécessaire pour `idea-serve` lancé séparément sur la même machine. Pour #64, si la création web est livrée uniquement via serveur embarqué, #67 n’est pas bloquant. Si elle est livrée aussi via headless local partageant l’app-data desktop, #67 doit précéder #64.\n\nc) #64 dependsOn #66 : à retirer. Le browser serveur ne dépend pas de Docker. Docker introduira des conventions `/workspace`, mais #64 doit déjà fonctionner avec un `browse_root` configurable. #66 pourra documenter `IDEA_SERVER_BROWSE_ROOT=/workspace`, pas bloquer #64.\n\nd) #69 : confirmé frontend-pur. Les contrats layout/terminal ne changent pas, mais la présentation doit probablement passer en mode vertical : une cellule terminal active à la fois ou navigation compacte des cellules, docks transformés en panneaux/bottom sheets, xterm refit sur resize et clavier mobile.\n\n**Cadrage #68**\nLots recommandés :\n1. API `web-server` embeddable : config partagée, handle start/stop, URL effective, pairing code, état, shutdown propre.\n2. Desktop commands/UI : activer/désactiver serveur, afficher URL locale/LAN, pairing code, erreurs de bind, statut.\n3. Sécurité : défaut loopback seulement ; exposition réseau explicite avec les mêmes contraintes que #13 (`allow_remote`, origin public, proxy TLS en distant réel).\n4. Persistance légère : dernier port/mode éventuellement, mais démarrage automatique seulement si explicitement validé.\n\nContrat : aucun fork serveur desktop/headless. Desktop consomme `web-server` comme lib.\n\n**Cadrage #69**\nLots recommandés :\n1. Shell responsive web : menu, tabs projet, pairing, toasts, panneaux.\n2. Mode panneaux mobile : remplacer docks gauche/droite/floating par pile verticale, drawer ou bottom sheet.\n3. Terminal mobile : xterm `fit()` fiable, hauteur `100dvh`, clavier virtuel, toolbar d’actions, sélection cellule/layout.\n4. Tests viewport : Playwright ou équivalent sur largeur téléphone, plus tests React pour absence de panneaux desktop-only.\n\nContrat : pas de changement DTO, pas de changement use cases, pas de changement modèle `LayoutTree`.\n\n**Dépendances Corrigées**\n- #64 : `dependsOn #13`, et éventuellement `dependsOn #67` selon stratégie d’activation write headless. Retirer `dependsOn #66`.\n- #65 : `dependsOn #13`, inchangé.\n- #66 : `dependsOn #65`, inchangé.\n- #67 : `dependsOn #13`, inchangé.\n- #68 : ajouter `dependsOn #65`.\n- #69 : aucun lien obligatoire. Peut dépendre seulement de #13 implicitement puisqu’il cible le client web livré par #13.\n\n**Ordre Recommandé**\n1. #65 d’abord. C’est le pivot architectural.\n2. #68 juste après #65, ou comme lot final de #65 si vous voulez sortir l’intégration desktop rapidement sans dette.\n3. #67 avant d’ouvrir davantage d’écritures web en mode headless local.\n4. #64 après #65 et après décision #67. Retirer la dépendance Docker.\n5. #66 après #65, parallélisable avec #64 si l’API serveur est stable.\n6. #69 parallélisable dès maintenant avec #65/#67, car frontend-pur, mais à éviter en même temps qu’un gros remaniement frontend de #64 sur les mêmes composants projet.", + "stderr_tail": null + }, + "completionDelivered": true + }, + { + "id": "36a1a6b9-8041-49fe-8575-d986dfd9fcd6", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5", + "ticket_id": "3c2d67cf-c9dc-4079-a934-777e8561b500", + "conversation_id": "6bc594e8-a37c-0dbd-1de6-6e3b73002cb4" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784193778767, + "updatedAtMs": 1784193788314, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784193788314, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "You've hit your session limit · resets 1pm (Europe/Paris)", + "stderr_tail": null + }, + "completionDelivered": true + }, + { + "id": "adea82a1-ddea-40eb-8963-62cb3d04324a", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5", + "ticket_id": "305f2480-766d-4d42-8a04-ecb3a34bf92e", + "conversation_id": "6bc594e8-a37c-0dbd-1de6-6e3b73002cb4" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784200055479, + "updatedAtMs": 1784200148860, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784200148860, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "Les deux branches sont créées, parallélisation validée — mais le risque de conflit n'est pas là où on l'attendait.\n\n## Branches\n\n| Ticket | Branche | Point de départ |\n|---|---|---|\n| #65 | `feature/ticket65-idea-serve-headless` | `develop` @ `506d589` |\n| #69 | `feature/ticket69-mobile-responsive-client` | `develop` @ `506d589` |\n\n```\ngit branch feature/ticket65-idea-serve-headless develop\ngit branch feature/ticket69-mobile-responsive-client develop\n→ feature/ticket65-idea-serve-headless 506d589 Merge feature/server-client-packaging into develop (#13)\n→ feature/ticket69-mobile-responsive-client 506d589 Merge feature/server-client-packaging into develop (#13)\n```\n\nNommage aligné sur les précédents du dépôt (`feature/ticket54-model-download-progress`, `feature/ticket13-server-client-mode`). Les deux branches partent du même commit ; aucun agent n'est encore dessus, à chacun de faire son `switch`.\n\n## Sur la disjonction : vérifiée, mais pas là où tu la pensais\n\nJ'ai vérifié plutôt que supposer. Le code est bien disjoint : le seul couplage #65 ↔ frontend est un **contrat runtime**, pas un fichier partagé — `server.rs` résout `web_root` vers `frontend/dist` (un artefact de build, ignoré par `.gitignore:11`, donc jamais en conflit) et expose les routes `/api/{ws,pair,invoke,logout}` que le frontend consomme. Or #65 gèle explicitement ce contrat (\"contrat HTTP/WS inchangé\", \"Aucune modif frontend\"). Aucun fichier source en commun. **Parallélisation sans réserve côté code.**\n\nLe vrai risque de conflit est **`.ideai/`**, que personne n'avait listé. Les deux chantiers vont faire tourner des agents qui écrivent tous dans les mêmes fichiers d'état :\n\n- **`.ideai/tickets/counter.json`** — 2 lignes, un seul `nextNumber` (à 71). Si les deux branches créent un ticket, elles éditent *la même ligne* : **conflit garanti**.\n- **`.ideai/tickets/index.json`** (742 lignes) et **`.ideai/background-tasks/*.json`** (6732 lignes, journal en append par projet) : conflits probables et pénibles à arbitrer.\n\n**Ma décision de topologie :** l'état `.ideai/` **ne monte pas sur les branches de feature**. C'est de la métadonnée d'orchestration last-writer-wins, sans impact build — je la committerai directement sur `develop` en `chore(ideai):` aux bornes de sprint. Les branches de feature ne portent que du code. Ça supprime la surface de conflit à la racine au lieu de la subir au merge. Concrètement : les agents travaillent normalement, je trie au moment de committer.\n\n## Séquencement : non\n\nAucune raison de séquencer. #69 ne dépend pas de #65, et #65 ne touche rien que #69 lise. Le premier vert et validé se merge, le second rebase sur `develop` avant son merge — rebase qui sera trivial vu la disjonction. Si les deux sont prêts en même temps, je passerai #69 en premier (petit, frontend-pur, faible risque) avant le gros lot structurant.\n\nUne réserve sur #65 : `Cargo.toml` (workspace `members`) et la non-régression desktop AppImage sont le point sensible. Je ne mergerai pas #65 sans une QA réelle sur les deux binaires — le desktop ne doit rien perdre.\n\n## Deux points à te signaler\n\n**`origin/develop` a été poussé à `506d589`** — le reflog dit `update by push`. Ce n'est pas moi, je suis resté strictement local comme demandé ; tu as dû pousser de ton côté. Conséquence : `develop` est publié, donc toute réécriture de son historique est désormais exclue (les rebases restent cantonnés aux branches de feature avant merge).\n\n**Le working tree est sale** (`.ideai/tickets`, background-tasks, carnets) — des agents ont tourné depuis mon merge. Créer les branches n'y a pas touché. Dis-moi si tu veux que je committe cet état maintenant sur `develop` ou que je le laisse s'accumuler jusqu'à la borne de sprint.", + "stderr_tail": null + }, + "completionDelivered": true + }, + { + "id": "f119b9ab-42eb-45f2-a423-cff25bc1d58e", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "ticket_id": "eaa34f9b-199e-44ba-a432-c0dc2921f926", + "conversation_id": "d5068bc8-786e-0428-1d14-83b93961a15e" + }, + "state": "failed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784200175708, + "updatedAtMs": 1784200775861, + "deadlineMs": null, + "result": { + "outcome": "failure", + "finished_at_ms": 1784200775861, + "exit_code": null, + "error": "Timeout: rendezvous inactivity window expired for target DevBackend", + "stdout_tail": null, + "stderr_tail": null + }, + "completionDelivered": true + }, + { + "id": "57121716-5edd-4dca-b485-6e0c0017edc8", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "af7f86da-76bc-48e1-9900-71f45a624800", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "af7f86da-76bc-48e1-9900-71f45a624800", + "ticket_id": "919bd2ad-e8f1-4435-801f-1d1ac95f9965", + "conversation_id": "09b15ec3-ce2f-0af2-2903-ef699df9de41" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784200775901, + "updatedAtMs": 1784201394235, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784201394235, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "Livré sur `feature/ticket69-mobile-responsive-client`, 3 commits, local uniquement, aucun push.\n\n## L'écart principal : le lot 2 était sans objet\n\n**Le client web n'a jamais eu de docks, de fenêtres flottantes ni de `LayoutTree`.** Le cadrage prévoyait « remplacer les docks gauche/droite/floating par une pile verticale, drawer ou bottom sheet » — il n'y a rien à remplacer. `main.tsx` route le mode `http` vers `WebApp`, une surface autonome livrée par #13 : pairing **ou** `WebWorkspace`, qui est déjà une colonne verticale unique. Les seuls imports `@/features/*` de tout `features/web/` sont `terminals` et `workstate` — le shell desktop n'est pas atteignable depuis le web.\n\n**Conséquence produit : la question « bottom sheet vs drawer » ne se pose pas**, il n'y a pas de panneau desktop à convertir. Je n'ai donc pas de décision UX à faire valider sur ce point. L'app était déjà verticale ; ce qui manquait pour le téléphone était ailleurs. J'ai épinglé la frontière dans un test (`WebMobile.test.tsx`) et noté le constat en mémoire projet (`web-client-is-single-column-no-desktop-shell`), parce que le cadrage repartira sinon du même modèle faux.\n\n## Ce qui était réellement cassé sur téléphone\n\nLe vrai blocage n'était pas le layout mais le terminal : **le clavier virtuel n'a ni Esc, ni Tab, ni Ctrl, ni flèches**, donc on pouvait taper un prompt à un agent CLI mais pas l'interrompre, compléter un chemin, sortir d'un éditeur ou rappeler l'historique. C'est le cœur du lot 3.\n\n- **Lot 1 (shell)** : `100dvh` (le `height: 100%` se résolvait sur le *large* viewport, donc la barre d'URL rétractable masquait le bas de l'app), `viewport-fit=cover` + safe-area insets, padding responsive, `inputMode=\"numeric\"` sur le code d'appairage. Zoom laissé libre volontairement (accessibilité).\n- **Lot 2 (réinterprété)** : les rangées qui débordaient vraiment à 360px — `AgentLiveRow` et surtout la rangée de tâche de fond, où statut + kind + Cancel + Retry ne tenaient pas. Elles s'empilent sur téléphone et retrouvent leur ligne unique dès `sm`.\n- **Lot 3 (terminal)** : `TerminalView` expose un `onReady(api)` **optionnel** (desktop inchangé), et `api.send` passe par `term.input()` — le même chemin qu'une frappe réelle, donc le relais PTY et la suspension du write-portal s'appliquent à l'identique ; écrire sur le handle aurait court-circuité le portal. La `TerminalKeyBar` rend Esc/Tab/Ctrl-C/Ctrl-D/flèches en chips 44px, et **chaque tap annule le déplacement de focus** puis refocalise xterm — sinon le clavier virtuel se referme à chaque touche. La cellule passe de `h-64` fixe (une letterbox d'~20 lignes) à `h-[60dvh]`.\n\n## Sorties réelles\n\n```\n########## TYPECHECK ########## typecheck exit=0\n########## VITEST (full) ##########\n Test Files 85 passed (85)\n Tests 774 passed (774)\n########## BUILD (VITE_TRANSPORT=http) ##########\ndist/assets/index-CEKN1shI.css 39.50 kB │ gzip: 8.65 kB\n✓ built in 2.47s\n```\n\nJ'ai vérifié que le CSS mobile atterrit vraiment dans le build (`60dvh`, `100dvh`, les 4 `safe-area-inset-*`, `viewport-fit=cover`) — les valeurs arbitraires Tailwind avec `env()` sont exactement le genre de chose qui no-ope en silence. Et j'ai rendu l'app pour de vrai en Chrome headless à 360×780 : l'écran d'appairage tient sans débordement horizontal.\n\nDeux erreurs de ma part en route, corrigées : un test qui matchait `h-64` en sous-chaîne (`min-h-64` le contient), et un import `vi` inutilisé passé sous le radar parce que j'avais typecheck **avant** d'écrire les tests — or `npm run build` est `tsc --noEmit && vite build`, donc ça cassait le build, pas juste le lint.\n\n## Ce que je n'ai pas fait, et pourquoi\n\n- **Playwright non installé.** Le lot 4 disait « Playwright ou équivalent » ; j'ai livré l'équivalent (vitest/jsdom, structurel). Installer Playwright, c'est des binaires navigateur et une nouvelle surface CI — une décision de dépendance qui revient à Architect/Main, pas à moi unilatéralement. **À arbitrer si vous voulez de vrais tests visuels**, car jsdom n'évalue pas les media queries : mes tests vérifient la structure, pas le rendu aux breakpoints.\n- **Workspace + keybar non vérifiés visuellement à 360px** : ça demande un serveur appairé avec un PTY vivant (territoire #65). Couvert par les tests unitaires (séquences d'octets, focus) et les tests structurels, mais pas par un œil. C'est l'angle mort restant.\n\nCommits : `e22ea5b` (shell), `8f15ad6` (terminal + tests), `6ed0087` (fix typecheck). Aucun `.ideai/` ni `crates/` committé — vérifié, les commits ne touchent que `frontend/`.", + "stderr_tail": null + }, + "completionDelivered": true + }, + { + "id": "c3f1dfe0-fb17-4259-94a2-b2dc5405678a", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "ticket_id": "d4e1f869-08f5-44cd-84b7-62c1848007aa", + "conversation_id": "d5068bc8-786e-0428-1d14-83b93961a15e" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784201414642, + "updatedAtMs": 1784201574380, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784201574380, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "État factuel : chantier **encore en cours**, pas livré proprement sur `feature/ticket65-idea-serve-headless`.\n\n**Lots**\n- **L1 DTO transport-neutres** : en cours. `app-tauri/src/dto.rs` est réduit à `pub use backend::dto::*`, et `backend/src/dto.rs` existe, mais il est encore **non suivi**. Les events transport-neutres existent aussi dans `backend/src/events.rs`, mais `app-tauri/src/events.rs` garde encore une copie complète.\n- **L2 crate `web-server`** : en cours. `crates/web-server/` existe, compile et teste vert, mais reste **non suivi**.\n- **L3 bin `idea-serve`** : en cours. `crates/web-server/src/bin/idea-serve.rs` existe et compile, mais reste **non suivi**.\n\n**Branche / commits**\n- Le repo est actuellement sur `feature/ticket69-mobile-responsive-client`, pas sur `feature/ticket65-idea-serve-headless`.\n- `feature/ticket65-idea-serve-headless` existe mais pointe sur `develop` @ `506d589`; `git log develop..feature/ticket65-idea-serve-headless` ne retourne aucun commit.\n- Commits présents sur la branche courante, hors #65 :\n - `e22ea5b feat(frontend): shell web responsive pour viewport téléphone (#69)`\n - `8f15ad6 feat(frontend): terminal pilotable au doigt sur téléphone (#69)`\n - `6ed0087 fix(frontend): retirer l'import \\`vi\\` inutilisé du test de la key bar (#69)`\n\n**Working tree hors `.ideai/`**\n```text\n## feature/ticket69-mobile-responsive-client\n M Cargo.lock\n M Cargo.toml\n M crates/app-tauri/Cargo.toml\n M crates/app-tauri/src/dto.rs\n M crates/app-tauri/src/server.rs\n M crates/app-tauri/src/tickets.rs\n M crates/backend/src/lib.rs\n M crates/backend/src/mcp_endpoint.rs\n?? crates/backend/src/dto.rs\n?? crates/backend/src/events.rs\n?? crates/web-server/\n```\n\n**Build / test exécutés**\n```text\n$ cargo build -p web-server --bin idea-serve\n Compiling web-server v0.3.0 (/home/anthony/Documents/Projects/IdeA/crates/web-server)\n Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.72s\n```\n\n```text\n$ cargo build -p app-tauri --bin app-tauri\n Blocking waiting for file lock on artifact directory\n Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.70s\n```\n\n```text\n$ ldd target/debug/idea-serve | rg -i 'webkit|javascriptcore|gtk|gdk|soup|tauri|wry|webkitgtk' || true\n\n```\n\nDonc le binaire headless `idea-serve` **ne tire pas WebKitGTK**.\n\nContrôle comparatif desktop :\n```text\n$ ldd target/debug/app-tauri | rg -i 'webkit|javascriptcore|gtk|gdk|soup|tauri|wry|webkitgtk' || true\nlibgdk-3.so.0 ...\nlibgdk_pixbuf-2.0.so.0 ...\nlibwebkit2gtk-4.1.so.0 ...\nlibgtk-3.so.0 ...\nlibsoup-3.0.so.0 ...\nlibjavascriptcoregtk-4.1.so.0 ...\n```\n\nTests ciblés :\n```text\n$ cargo test -p backend\ntest result: ok. 41 passed; 0 failed; 7 ignored\nDoc-tests backend: ok\n```\n\n```text\n$ cargo test -p web-server\ntest result: ok. 55 passed; 0 failed\nsrc/bin/idea-serve.rs: 0 passed; 0 failed\nDoc-tests web_server: ok\n```\n\n```text\n$ cargo test -p app-tauri\ntest result: ok. 45 passed; 0 failed; 1 ignored\n+ tests intégration DTO/chat/etc. verts, total affiché par suites toutes ok\n```\n\nValidation globale :\n```text\n$ cargo test --workspace\nerror: test failed, to rerun pass `-p infrastructure --lib`\n```\nÉchec sur 10 tests `infrastructure` liés au bind local :\n```text\nbind: Os { code: 1, kind: PermissionDenied, message: \"Operation not permitted\" }\n```\nTests concernés notamment `session::openai_compat::*` et `session::tests::factory_routes_openai_compatible_to_http_session`. Ça ressemble au sandbox local, pas à `idea-serve`.\n\n**Écarts / arbitrages Architect**\n- Le travail #65 est posé sur la mauvaise branche (`ticket69`) et non committé.\n- À arbitrer : propriétaire canonique des DTO/events transport-neutres. J’ai mis `dto`/`events` dans `backend`; si Architect veut un crate contrat dédié plutôt que `backend`, il faut recadrer avant commit.\n- `app-tauri/src/events.rs` duplique encore `backend::events`; L1 n’est donc pas complètement assaini côté events.\n\n**Reste à faire**\n- Reposer proprement ce diff sur `feature/ticket65-idea-serve-headless`.\n- Ajouter les fichiers non suivis, finaliser l’extraction events ou acter l’arbitrage.\n- `cargo fmt`, rerun builds/tests ciblés, documenter l’échec workspace sandbox si inchangé.\n- Committer uniquement le code projet, sans `.ideai/`.", + "stderr_tail": null + }, + "completionDelivered": true + }, + { + "id": "ea9ed06b-965a-41f4-b7fc-15f48a2296c9", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5", + "ticket_id": "67e41ade-589c-4a17-8c42-05998d6febd6", + "conversation_id": "08336578-5b47-09d2-2f41-5cc483f101f4" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784201574407, + "updatedAtMs": 1784201703218, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784201703218, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "**Verdict QA : vert, avec réserve de couverture réelle mobile.**\n\nToutes les commandes demandées sont vertes en exécution locale, via npm/npx uniquement. Toutes les commandes shell ont préfixé la sortie avec ce bruit environnemental réel :\n\n```text\nFatal Python error: Failed to import encodings module\nPython runtime state: core initialized\nModuleNotFoundError: No module named 'encodings'\n...\n```\n\n**Commandes exécutées**\n\n```bash\ncd frontend && npm run typecheck\n```\n\n```text\n> idea-frontend@0.3.0 typecheck\n> tsc --noEmit\n```\n\nExit `0`.\n\n```bash\ncd frontend && npx vitest run\n```\n\n```text\nRUN v4.1.8 /home/anthony/Documents/Projects/IdeA/frontend\n\nTest Files 85 passed (85)\n Tests 774 passed (774)\n Start at 13:33:38\n Duration 15.51s ...\n```\n\nExit `0`.\n\n```bash\ncd frontend && VITE_TRANSPORT=http npx vite build\n```\n\n```text\nvite v5.4.21 building for production...\n✓ 437 modules transformed.\ndist/index.html 0.72 kB │ gzip: 0.45 kB\ndist/assets/index-CEKN1shI.css 39.50 kB │ gzip: 8.65 kB\ndist/assets/index-B3LDhwfl.js 949.89 kB │ gzip: 258.39 kB\n✓ built in 2.47s\n```\n\nExit `0`.\n\nVérification build CSS/HTML :\n\n```bash\nfor p in '100dvh' '60dvh' 'safe-area-inset-top' 'safe-area-inset-right' 'safe-area-inset-bottom' 'safe-area-inset-left' 'viewport-fit=cover'; do printf '%s: ' \"$p\"; rg -c --fixed-strings \"$p\" dist/index.html dist/assets/index-CEKN1shI.css; done\n```\n\n```text\n100dvh: dist/assets/index-CEKN1shI.css:1\n60dvh: dist/assets/index-CEKN1shI.css:1\nsafe-area-inset-top: dist/assets/index-CEKN1shI.css:1\nsafe-area-inset-right: dist/assets/index-CEKN1shI.css:1\nsafe-area-inset-bottom: dist/assets/index-CEKN1shI.css:1\nsafe-area-inset-left: dist/assets/index-CEKN1shI.css:1\nviewport-fit=cover: dist/index.html:2\n```\n\n**Points challengés**\n\n1. **Non-régression desktop : vert.** `TerminalView.onReady` est optionnel ; le desktop `LayoutGrid` ne le passe pas. Le chemin desktop garde `agentMode`, `portal`, `open`, `reattach`, `sessionId`, `onSessionId` comme avant. L’effet de `TerminalView` reste dépendant de `[cwd]`, donc pas de relance liée à `onReady`.\n\n2. **`api.send` via `term.input()` : vert par inspection, réserve de test.** Le code publie `send: data => term.input(data)` après installation du `term.onData`. Le handler `onData` existant applique toujours `portal.onHumanData(data)`, puis `portal.isSuspended()`, puis `handle.write(...)` ou buffering. Donc l’affirmation est cohérente. Réserve : les tests actuels vérifient séparément `TerminalKeyBar.send(...)` et le relais `onData`/portal, mais pas explicitement que `TerminalInputApi.send()` déclenche ce même `onData` sous mock xterm.\n\n3. **CSS mobile dans le build : vert.** Le build contient `100dvh`, `60dvh`, les quatre `safe-area-inset-*`, `viewport-fit=cover`, et la règle minifiée inclut bien `.h-\\[60dvh\\]{height:60dvh}` ainsi que le media `sm:h-80`.\n\n4. **Trou jsdom/media queries : oui, trou réel mais pas rouge.** Les tests sont structurels. Ils ne prouvent pas le rendu aux breakpoints, l’effet réel de `dvh`, les safe areas, ni le comportement clavier virtuel/focus sur navigateur mobile. Pour ce ticket, ce n’est pas un échec fonctionnel observé, mais c’est une limite sérieuse de couverture.\n\n**Avis Playwright**\n\nL’absence de Playwright n’est pas bloquante pour valider ce lot en local vu les contraintes de dépendance et le build confirmé. En revanche, Vitest/jsdom n’est pas un équivalent complet pour un ticket dont le risque principal est visuel mobile. Mon avis : acceptable pour merger avec réserve explicite, mais il faut un follow-up de rendu réel aux breakpoints si le projet accepte une dépendance navigateur/CI.", + "stderr_tail": null + }, + "completionDelivered": true + }, + { + "id": "c9f01020-8c34-4982-b42b-6b6f24a928a4", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5", + "ticket_id": "15ab6ca4-a5d9-4972-b7d5-3f4992c52a30", + "conversation_id": "6bc594e8-a37c-0dbd-1de6-6e3b73002cb4" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784201738788, + "updatedAtMs": 1784201820600, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784201820600, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "Rapatriement terminé. **Aucun travail perdu — vérifié par empreinte, pas par confiance.**\n\n## Vérification d'intégrité\n\nLes deux empreintes SHA-256 prises avant la manœuvre sont identiques après :\n\n| | Avant | Après |\n|---|---|---|\n| Diff des fichiers suivis | `c1aecea8…c499d3b` | `c1aecea8…c499d3b` ✅ |\n| Contenu des non suivis | `701bc7ee…4d6ff016` | `701bc7ee…4d6ff016` ✅ |\n\nLes 11 entrées de `git status` sont exactement celles rapportées par DevBackend, et les 8987 lignes des fichiers non suivis sont intactes.\n\n## Manœuvre exécutée\n\nJ'ai d'abord vérifié la claim dont dépendait tout le reste : `git diff --name-only develop..feature/ticket69` ne sort **rien** hors `frontend/` — les 3 commits #69 sont bien 100 % frontend. C'est ce qui rendait l'opération sûre : l'arbre Rust étant identique entre les deux branches, le transfert ne pouvait pas conflicter.\n\n```\ngit stash push -u -m \"wip(#65): extraction idea-serve — rapatriement depuis ticket69\" -- Cargo.lock Cargo.toml crates/\n→ Arbre de travail et état de l'index sauvegardés dans On feature/ticket69-mobile-responsive-client: wip(#65)…\n\ngit checkout feature/ticket65-idea-serve-headless\n→ Basculement sur la branche 'feature/ticket65-idea-serve-headless'\n\ngit stash apply stash@{0}\n→ (aucun conflit)\n```\n\nTrois choix délibérés. Le `stash push` est **limité aux chemins Rust** (`Cargo.lock Cargo.toml crates/`) pour ne pas embarquer le churn `.ideai/`, conformément à la règle que j'ai posée au sprint précédent. Le `-u` était indispensable : l'essentiel du travail est dans des fichiers **non suivis** — `server.rs` a été vidé (−7514 lignes) au profit de `crates/web-server/`, encore untracked. Enfin j'ai utilisé **`apply` et non `pop`** : l'entrée `stash@{0}` reste en place comme filet.\n\n## État final\n\n- **`feature/ticket65-idea-serve-headless`** — branche courante, toujours sur `506d589`, **aucun commit** (comme demandé). Le travail #65 y est présent en working tree, non committé.\n- **`feature/ticket69-mobile-responsive-client`** — `6ed0087`, ses **3 commits frontend uniquement**, plus aucun résidu Rust.\n- Aucune action sortante. Rien de poussé.\n\n## Sur « non committé, est-ce le plus sûr ? »\n\nNon — et c'est mon seul désaccord. Du travail non committé est l'état le plus fragile qui soit : un `checkout` malheureux ou un `clean -fd` sur des fichiers **non suivis** l'efface sans recours, et git ne peut rien restaurer. J'ai donc respecté ta consigne (pas de commit) mais je l'ai doublée de **deux filets indépendants** :\n\n1. **`stash@{0}`** conservé (d'où le `apply`), qui restitue tout même en cas de fausse manœuvre sur le working tree.\n2. **Une sauvegarde physique hors git** : `/tmp/idea65-backup/ticket65-worktree.tgz` (1,5 Mo) et `tracked.patch` (7648 lignes).\n\nMa recommandation quand l'arbitrage Architect tombera : même si l'arborescence doit bouger (DTO dans `backend` vs crate contrat dédié), un **commit `wip(#65):` sur la branche #65** serait plus sûr que le statu quo. Une arborescence qu'on veut réorganiser se réorganise très bien par-dessus un commit — et un commit, lui, ne se perd pas. Dis-moi si tu veux que je le pose ; `/tmp` ne survivra pas à un reboot.", + "stderr_tail": null + }, + "completionDelivered": true + }, + { + "id": "c48c3417-a599-4fd6-9cf5-030adc59a82f", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5", + "ticket_id": "0da51e91-7585-4b4e-8bad-a62079ad2778", + "conversation_id": "6bc594e8-a37c-0dbd-1de6-6e3b73002cb4" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784201833400, + "updatedAtMs": 1784201918449, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784201918449, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "Commit posé et vérifié. Les 8987 lignes qui vivaient dans des fichiers non suivis sont maintenant dans l'historique git.\n\n## Le commit\n\n**`82e8e77`** — `wip(#65): extraction idea-serve — état intermédiaire non figé`\n\nLe message porte explicitement l'avertissement, pour que personne ne prenne ce commit pour un contrat : extraction des events incomplète (`app-tauri/src/events.rs` duplique encore `backend::events`), propriétaire canonique des DTO/events en cours d'arbitrage Architect (`backend` retenu ici vs crate contrat dédié), arborescence susceptible de bouger. Il précise aussi que c'est un commit de sûreté qui ne fige rien.\n\n13 fichiers, 100 % Rust : les 8 fichiers modifiés (`Cargo.toml`/`Cargo.lock`, `app-tauri` ×4, `backend` ×2) et 5 ajouts (`crates/web-server/` ×3, `backend/src/dto.rs`, `backend/src/events.rs`). J'ai staged avec des chemins explicites puis contrôlé le résultat : **aucun `.ideai/`**, aucun `target/` ni binaire.\n\n## Correspondance aux empreintes\n\n**Empreinte 1 — fichiers suivis** : `git diff develop HEAD` sur les 8 fichiers donne `c1aecea8…c499d3b`, identique à l'empreinte prise avant le rapatriement. ✅\n\n**Empreinte 2 — ex-fichiers non suivis** : `701bc7ee…4d6ff016` sur disque, identique. ✅ Et `git status --porcelain -- Cargo.lock Cargo.toml crates/` ne retourne **rien**, ce qui prouve que le working tree est identique à `HEAD` sur ces chemins — donc le contenu committé *est* ce contenu vérifié. `git ls-tree` confirme les 5 fichiers présents dans le commit.\n\nUne transparence sur ma vérification : ma première tentative sur l'empreinte 2 a sorti `85c2d2eb…`, qui ressemblait à une divergence. Ce n'en était pas une — ma commande `tar --to-command=sha256sum` hashait les contenus sans les noms de fichiers, elle n'était donc pas comparable à la méthode d'origine. La commande était fausse, pas le contenu. Je l'ai remplacée par la vérification ci-dessus, qui est plus directe et plus concluante.\n\n## État final\n\n- **`feature/ticket65-idea-serve-headless`** — branche courante, sommet **`82e8e77`**, un seul commit au-dessus de `develop` (`506d589`). Working tree propre côté Rust ; ne reste que le churn `.ideai/`, laissé de côté comme convenu.\n- **`feature/ticket69-mobile-responsive-client`** — `6ed0087`, intacte, ses 3 commits frontend uniquement.\n- Aucune action sortante.\n\n## Filets\n\nJe garde les deux pour l'instant : `stash@{0}` et `/tmp/idea65-backup/`. Maintenant que le travail est dans un commit, ils sont redondants — la vraie protection, c'est `82e8e77`. Je nettoierai le stash quand l'arbitrage Architect sera tranché et le travail #65 reparti sur des rails stables ; dis-moi si tu préfères que je le fasse tout de suite.", + "stderr_tail": null + }, + "completionDelivered": true + }, + { + "id": "ac8cd342-0ea0-422d-b34f-cb7a8e58bb29", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "dce19c75-9669-4e45-b8de-9950025157da", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "dce19c75-9669-4e45-b8de-9950025157da", + "ticket_id": "eca1443d-aa3d-4bf6-9884-a116495732ac", + "conversation_id": "7a2f446c-2efa-0c56-08dd-07cdc5cac19b" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784201918472, + "updatedAtMs": 1784201952917, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784201952917, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "**Verdict DTO/events : `backend` est le propriétaire canonique.**\n\nStructure cible exacte :\n- `crates/backend/src/dto.rs` : DTO request/response transport-neutres, formes JSON stables, conversions application/domain.\n- `crates/backend/src/events.rs` : DTO d’events transport-neutres, conversions depuis `DomainEvent`.\n- `crates/backend/src/lib.rs` expose `pub mod dto; pub mod events;`.\n- `app-tauri/src/dto.rs` devient un shim compat :\n ```rust\n pub use backend::dto::*;\n ```\n- `app-tauri/src/events.rs` ne doit garder que le relay Tauri et les constantes/handlers spécifiques Tauri, en important les DTO depuis `backend::events`.\n- `web-server` consomme directement `backend::{dto, events}`.\n\nJustification hexagonale : ce n’est pas une violation bloquante. Le crate `backend` est déjà le composition root transport-neutre et la façade des use cases pour adapters entrants. Des DTO “API backend” n’appartiennent pas au domaine ni à l’application pure ; ils appartiennent à la frontière de présentation/backend. Les mettre dans `backend` est acceptable tant qu’ils ne tirent pas Tauri, HTTP, WebSocket, cookies, Axum/Hyper, ni UI. Le fait que `web-server` et `app-tauri` les consomment tous les deux renforce ce choix : ils deviennent le contrat canonique partagé entre deux driving adapters.\n\nJe ne créerais pas `presentation-dto`/`backend-api` maintenant. Ce serait plus pur si le crate `backend` devenait trop gros ou si on voulait publier un contrat indépendant, mais pour #65 ça ajoute un crate-frontière de plus sans gain immédiat. La frontière importante est : `domain/application` ne dépendent pas des DTO ; `app-tauri/web-server` dépendent du contrat backend.\n\n**Events : à finir dans L1, pas en dette.**\n\nSi `app-tauri/src/events.rs` duplique encore `backend::events`, L1 n’est pas propre. Il faut dédupliquer avant commit propre, parce que les events font partie du contrat transport-neutre au même titre que les DTO invoke. Sinon on merge deux sources de vérité exactement au moment où #65 prétend stabiliser le contrat partagé.\n\nDette acceptable uniquement pour les morceaux réellement Tauri-spécifiques : relay `app_handle.emit`, noms d’events Tauri si nécessaire, wiring runtime. Pas pour les structs/enums sérialisés ni les conversions `DomainEvent -> EventDto`.\n\n**Échec `cargo test --workspace` : faux négatif sandbox très probable, pas bloqueur #65.**\n\nLe signal clé est `bind: Os { code: 1, kind: PermissionDenied }` dans des tests `infrastructure::session::openai_compat::*`. Ces tests ouvrent vraisemblablement un serveur HTTP local de test ; un refus au `bind` indique l’environnement/sandbox qui interdit l’écoute réseau, pas une régression d’extraction `idea-serve`.\n\nJe ne le rattache pas à la mémoire `permissions-sandbox-system-state` comme preuve directe : cette note parle surtout de Landlock et d’enforcement agent. Mais le diagnostic DevBackend est cohérent avec les symptômes et avec le fait que :\n- `web-server` passe ses tests,\n- `app-tauri` passe ses tests,\n- le binaire `idea-serve` ne tire pas WebKitGTK,\n- l’échec est localisé sur des tests infra HTTP qui bindent.\n\nConclusion pratique : ne bloquez pas #65 là-dessus. Marquer dans la validation : `cargo test --workspace` non concluant dans cette sandbox à cause d’un refus de bind local ; validation retenue par crates ciblés + éventuellement relancer workspace hors sandbox réseau.", + "stderr_tail": null + }, + "completionDelivered": true + }, + { + "id": "8d1bbf30-bf36-4577-a8f4-b16f4a39c215", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "ticket_id": "212808cf-038d-4726-a2a4-476f58f35221", + "conversation_id": "d5068bc8-786e-0428-1d14-83b93961a15e" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784201982652, + "updatedAtMs": 1784202269656, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784202269656, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "**État Final**\nBranche vérifiée : `feature/ticket65-idea-serve-headless`.\n\nLots :\n- L1 DTO/events transport-neutres : livré. `backend::{dto, events}` est canonique ; `app-tauri/src/dto.rs` et `app-tauri/src/events.rs` sont des shims/relay, sans duplication des DTO events.\n- L2 crate `web-server` : livré.\n- L3 bin `idea-serve` : livré.\n- `web_server::run_embedded(...)` : présent, avec `ServerConfig`, `EmbeddedServerHandle`, `url()`, `pairing_code()`, `state()`, `stop()` et shutdown propre.\n\nCommits sur la branche :\n```text\nddbea7b fix(backend): dedupliquer les DTO events partages (#65)\n82e8e77 wip(#65): extraction idea-serve — état intermédiaire non figé\n```\n\nLe commit final a été posé par l’agent Git parce que mon sandbox ne pouvait pas écrire `.git/index.lock`. Il a committé uniquement :\n```text\ncrates/app-tauri/src/events.rs\ncrates/backend/src/dto.rs\n```\nAucun `.ideai/` committé. Le working tree code est propre ; seuls des fichiers `.ideai/` restent modifiés/non suivis.\n\n**Validation**\n```text\n$ cargo fmt\n\n```\n\n```text\n$ cargo build -p web-server --bin idea-serve\nFinished `dev` profile [unoptimized + debuginfo] target(s) in 0.12s\n```\n\n```text\n$ cargo build -p app-tauri --bin app-tauri\nFinished `dev` profile [unoptimized + debuginfo] target(s) in 0.20s\n```\n\n```text\n$ cargo test -p backend\ntest result: ok. 41 passed; 0 failed; 7 ignored\nDoc-tests backend: ok\n```\n\n```text\n$ cargo test -p web-server\ntest result: ok. 55 passed; 0 failed\nsrc/bin/idea-serve.rs: 0 passed; 0 failed\nDoc-tests web_server: ok\n```\n\n```text\n$ cargo test -p app-tauri\ntest result: ok. 35 passed; 0 failed; 1 ignored\nToutes les suites d’intégration app-tauri affichées sont ok.\nDoc-tests app_tauri_lib: ok\n```\n\n`cargo test --workspace` reste non concluant dans cette sandbox, comme arbitré :\n```text\nerror: test failed, to rerun pass `-p infrastructure --lib`\ntest result: FAILED. 270 passed; 10 failed\nbind: Os { code: 1, kind: PermissionDenied, message: \"Operation not permitted\" }\n```\nÉchecs limités aux tests `infrastructure::session::openai_compat::*` et `factory_routes_openai_compatible_to_http_session`, par refus de bind local.\n\n**ldd**\nHeadless :\n```text\n$ ldd target/debug/idea-serve | rg -i 'webkit|javascriptcore|gtk|gdk|soup|tauri|wry|webkitgtk' || true\n\n```\nDonc `idea-serve` ne tire pas WebKitGTK/GTK.\n\nComparatif desktop :\n```text\n$ ldd target/debug/app-tauri | rg -i 'webkit|javascriptcore|gtk|gdk|soup|tauri|wry|webkitgtk' || true\nlibgdk-3.so.0\nlibgdk_pixbuf-2.0.so.0\nlibwebkit2gtk-4.1.so.0\nlibgtk-3.so.0\nlibsoup-3.0.so.0\nlibjavascriptcoregtk-4.1.so.0\n```\n\nÉcart restant : aucun écart de contrat identifié côté #65. Le seul point non vert est le `cargo test --workspace` bloqué par la politique réseau du sandbox, non bloquant selon arbitrage.", + "stderr_tail": null + }, + "completionDelivered": true + }, + { + "id": "754696d2-cf8f-4959-b3dd-675f0a64fcd8", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "target_agent_id": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5", + "ticket_id": "5777a1cc-a190-4d81-9fcf-d2b8f32d60ff", + "conversation_id": "bec31f20-db12-0995-00f2-ed824a618dea" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784202192251, + "updatedAtMs": 1784202228838, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784202228838, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "Commit posé : **`ddbea7be2c82d0bd62071b9e60df991e709ddded`** (`ddbea7b`) — `fix(backend): dedupliquer les DTO events partages (#65)`.\n\nExactement les deux fichiers demandés, rien d'autre : `crates/app-tauri/src/events.rs` (−1343 lignes) et `crates/backend/src/dto.rs`. Aucun `.ideai/` staged — vérifié avant et après. Tes modifications `.ideai/` non liées sont intactes dans le working tree, je n'y ai pas touché.\n\n`feature/ticket65-idea-serve-headless` est maintenant à `ddbea7b`, deux commits au-dessus de `develop`, working tree propre côté code.\n\nDeux remarques sur ce que j'ai vu en vérifiant le contenu :\n\nCe commit **comble l'écart signalé dans `82e8e77`** — la duplication `app-tauri/src/events.rs` ↔ `backend::events` que j'avais documentée comme travail non terminé. `events.rs` ne garde plus que le relais Tauri et ré-exporte les DTO stables ; c'est une dédup propre, pas une suppression de code. Je l'ai mentionné dans le corps du message.\n\nLe diff de `crates/backend/src/dto.rs` était un changement de doc-comment (retrait d'une référence à `tauri::ipc::Channel`, incohérente dans un crate qui doit être sans dépendance Tauri), pas un changement de contrat. Je l'ai committé tel quel puisque tu le demandais explicitement, mais le message `fix(backend)` porte surtout sur `events.rs` — le second fichier est cosmétique.\n\nÀ noter : ce commit touche au périmètre encore sous arbitrage Architect (propriétaire canonique des DTO/events, `backend` vs crate contrat dédié). Il ne fige rien, c'est une branche de feature, mais si l'arbitrage tranche pour `presentation-dto`/`backend-api`, ces deux commits seront à réorganiser ensemble.", + "stderr_tail": null + }, + "completionDelivered": true + }, + { + "id": "180719d1-be68-4778-9b5c-3ce54f1a8c19", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5", + "ticket_id": "161ab62e-2a58-4154-8d9e-0be5a32c9175", + "conversation_id": "08336578-5b47-09d2-2f41-5cc483f101f4" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784202296132, + "updatedAtMs": 1784202701552, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784202701552, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "**Verdict global #65 : vert, avec une réserve de couverture sur `run_embedded().stop()`.**\n\nToutes les commandes ciblées annoncées par DevBackend sont vertes. Toutes les commandes shell continuent d’émettre le préfixe environnemental parasite `Fatal Python error: Failed to import encodings module ...`; je ne le retire pas du constat.\n\n**Commandes clés exécutées**\n\n```bash\ncargo build -p web-server --bin idea-serve\n```\n\n```text\nFinished `dev` profile [unoptimized + debuginfo] target(s) in 0.38s\n```\n\n```bash\ncargo build -p app-tauri --bin app-tauri\n```\n\n```text\nFinished `dev` profile [unoptimized + debuginfo] target(s) in 0.57s\n```\n\n```bash\ncargo test -p backend\n```\n\n```text\nrunning 48 tests\n...\ntest result: ok. 41 passed; 0 failed; 7 ignored; 0 measured; 0 filtered out\n```\n\n```bash\ncargo test -p web-server\n```\n\n```text\nrunning 55 tests\n...\ntest result: ok. 55 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out\n```\n\n```bash\ncargo test -p app-tauri\n```\n\n```text\nrunning 36 tests\n...\ntest result: ok. 35 passed; 0 failed; 1 ignored\n...\ntests/chat_bridge.rs: 24 passed\ntests/dto.rs: 26 passed\ntests/dto_agents.rs: 25 passed\n...\ntests/session_limit_wiring.rs: 6 passed\n```\n\n**Point prioritaire : les tests “disparus”**\n\nVert. Pas perdus.\n\nComparaison `506d589` vs branche :\n\n```bash\ncargo test -p app-tauri -- --list\n```\n\nSur `506d589`, `src/lib.rs` listait `104 tests`. Sur la branche, `src/lib.rs` liste `36 tests`. L’écart réel depuis develop est donc `68` tests déplacés :\n\n- `55` tests `server::tests::*` déplacés vers `web-server::tests::*`.\n- `10` tests `events::tests::*` déplacés vers `backend::events::tests::*`.\n- `3` tests `dto::ls6_pagination_tests::*` déplacés vers `backend::dto::ls6_pagination_tests::*`.\n\nLes 10 qui expliquent le passage suspect `45 -> 35` sont les `events::tests::*`; ils sont présents et verts dans `backend`.\n\n**Autres points**\n\n1. **Non-régression desktop : vert.** `app-tauri/src/dto.rs` est bien un shim `pub use backend::dto::*;`. `app-tauri/src/events.rs` ne garde que le relay Tauri et ré-exporte les DTO events depuis `backend::events`. `cargo build -p app-tauri --bin app-tauri` et `cargo test -p app-tauri` sont verts.\n\n2. **Aucun import Tauri dans headless : vert.**\n\n```bash\nldd target/debug/idea-serve | rg -i 'webkit|javascriptcore|gtk|gdk|soup|tauri|wry'\n```\n\nSortie : aucune, exit `1` attendu pour “no match”.\n\n```bash\nldd target/debug/app-tauri | rg -i 'webkit|javascriptcore|gtk|gdk|soup|tauri|wry'\n```\n\nSortie réelle pertinente :\n\n```text\nlibgdk-3.so.0\nlibgdk_pixbuf-2.0.so.0\nlibwebkit2gtk-4.1.so.0\nlibgtk-3.so.0\nlibsoup-3.0.so.0\nlibjavascriptcoregtk-4.1.so.0\n```\n\n3. **Contrat HTTP/WS inchangé : vert.** Les 55 tests `web-server` couvrent `POST /api/pair`, `invoke`, `logout`, `/api/ws`, static same-origin, allowlist, sécurité B8, WS attach/launch/reattach/backpressure. Tous verts.\n\n4. **Frontières hexagonales : vert.** `backend/Cargo.toml` ne dépend pas de Tauri/Axum/Hyper/cookie/UI ; scan ciblé des imports dans `backend/src/dto.rs`, `backend/src/events.rs`, `domain`, `application` ne remonte pas d’import Tauri/HTTP/UI. `domain`/`application` ne dépendent pas de `backend::dto` ni `crate::dto`.\n\n5. **`web_server::run_embedded(...)` : vert structurel, réserve test.** Présent avec `ServerConfig`, `EmbeddedServerHandle`, `url()`, `pairing_code()`, `state()`, `stop()`. Le shutdown passe par `oneshot` et `run_listener` sort sur réception. Réserve : je n’ai trouvé aucun test dédié à `run_embedded().stop()` ; en sandbox, un test réel de bind serait probablement soumis à la même restriction réseau.\n\n6. **Aucune modif frontend : vert.**\n\n```bash\ngit diff --name-only 506d589...HEAD -- frontend\n```\n\nSortie : vide.\n\n**Workspace**\n\nSur la branche :\n\n```bash\ncargo test --workspace\n```\n\nÉchec attendu, exactement les 10 tests connus :\n\n```text\nsession::openai_compat::tests::http_400_404_422_map_to_start_without_body_leak\nsession::openai_compat::tests::max_tool_iterations_returns_single_degraded_final\nsession::openai_compat::tests::non_streaming_completion_persists_and_reloads_transcript\nsession::openai_compat::tests::normal_completion_returns_exactly_one_terminal_final\nsession::openai_compat::tests::openai_compatible_conversation_id_is_none\nsession::openai_compat::tests::timeout_after_successful_contact_is_io_error\nsession::openai_compat::tests::tool_loop_invokes_tool_and_reposts_result\nsession::openai_compat::tests::tool_rejection_disables_tools_retries_once_and_keeps_conversing\nsession::openai_compat::tests::unreachable_endpoint_on_first_contact_is_start_error\nsession::tests::factory_routes_openai_compatible_to_http_session\n```\n\nMessage réel répété :\n\n```text\nbind: Os { code: 1, kind: PermissionDenied, message: \"Operation not permitted\" }\n```\n\nMême liste confirmée sur `506d589` avec `TMPDIR` déplacé hors `/tmp`.", + "stderr_tail": null + }, + "completionDelivered": true + }, + { + "id": "01c166f9-dc66-44f6-9217-3bd45c44ae18", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5", + "ticket_id": "36f9d4e8-fdb1-4e8d-9b35-56d36e7d20ee", + "conversation_id": "6bc594e8-a37c-0dbd-1de6-6e3b73002cb4" + }, + "state": "running", + "wakePolicy": "recordOnly", + "createdAtMs": 1784206763128, + "updatedAtMs": 1784206763128, "deadlineMs": null, "result": null, "completionDelivered": false diff --git a/.ideai/memory/MEMORY.md b/.ideai/memory/MEMORY.md index 8b9cca5..b0c7b91 100644 --- a/.ideai/memory/MEMORY.md +++ b/.ideai/memory/MEMORY.md @@ -62,3 +62,4 @@ - [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 +- [web-client-is-single-column-no-desktop-shell](web-client-is-single-column-no-desktop-shell.md) — memory note web-client-is-single-column-no-desktop-shell diff --git a/.ideai/memory/web-client-is-single-column-no-desktop-shell.md b/.ideai/memory/web-client-is-single-column-no-desktop-shell.md new file mode 100644 index 0000000..61ccc10 --- /dev/null +++ b/.ideai/memory/web-client-is-single-column-no-desktop-shell.md @@ -0,0 +1,44 @@ +--- +name: web-client-is-single-column-no-desktop-shell +description: memory note web-client-is-single-column-no-desktop-shell +metadata: + type: project +--- +# Le client web n'a pas de shell desktop (ni docks, ni layout grid) + +Constat établi en livrant le ticket #69 (adaptibilité téléphone), sur `develop @ 506d589`. + +## Le fait + +Le client web livré par #13 (`VITE_TRANSPORT=http`) **n'a jamais eu** de docks +gauche/droite, de fenêtres flottantes, de `LayoutTree`, de tabs projet, de menus ni +de toasts. `main.tsx` route `isWeb` vers `WebApp`, qui est une surface autonome : + +- `WebApp` → `PairingScreen` **ou** `WebWorkspace` (rien d'autre) ; +- `WebWorkspace` → une **colonne verticale unique** (`flex flex-col overflow-y-auto`) ; +- `WebAgentCell` → `TerminalView`. + +Les seuls imports `@/features/*` de tout `features/web/` sont `terminals` et +`workstate/useProjectWorkState`. Le shell desktop (`App`, layout, docks) n'est +atteignable que par la branche non-web de `main.tsx`. + +## Pourquoi ça compte + +Le cadrage de #69 prévoyait un lot « remplacer les docks gauche/droite/floating par +une pile verticale, drawer ou bottom sheet ». Ce lot était **sans objet** : il n'y a +pas de dock à remplacer, et l'arbitrage produit « bottom sheet vs drawer » ne se pose +pas sur cette surface. Le vrai travail téléphone était ailleurs (terminal xterm +pilotable au doigt, `dvh`, safe-area, rangées qui débordent à 360px). + +**Avant de cadrer un ticket « responsive / mobile » sur le client web, partir de +`features/web/`, pas du shell desktop.** Les deux surfaces ne partagent que +`TerminalView` et les hooks transport-neutres. + +## Garde en place + +`frontend/src/features/web/WebMobile.test.tsx` épingle la frontière : le client web +ne doit monter aucun `layout-grid` / `layout-grid-container` / `layout-split` / +`layout-tabs`. Si un jour on veut du desktop-shell dans le web, ce test tombe — et +c'est le signal qu'il faut re-cadrer, pas le supprimer. + +Voir aussi [[ui-rework-sprint-scoping-contracts]]. \ No newline at end of file diff --git a/.ideai/tickets/13/carnet.md b/.ideai/tickets/13/carnet.md index 7936db5..3044a7d 100644 --- a/.ideai/tickets/13/carnet.md +++ b/.ideai/tickets/13/carnet.md @@ -1,8 +1,8 @@ --- issueRef: "#13" -version: 18 +version: 19 updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} -updatedAt: 1784184002362 +updatedAt: 1784193634690 --- # Ticket #13 — Server/client mode (carnet de chantier) diff --git a/.ideai/tickets/13/issue.md b/.ideai/tickets/13/issue.md index 4a7fed0..9ad1d00 100644 --- a/.ideai/tickets/13/issue.md +++ b/.ideai/tickets/13/issue.md @@ -2,7 +2,7 @@ id: "036783fa-9856-4643-8c28-653b93ac36e1" number: 13 title: "Server/client mode" -status: "inProgress" +status: "closed" priority: "low" sprint: "028179b1-eaf4-41e9-9c1f-7c37125117e6" links: [] @@ -10,7 +10,7 @@ agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"} createdBy: {"kind":"user"} updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} createdAt: 1783184188272 -updatedAt: 1784184002362 -version: 18 +updatedAt: 1784193634690 +version: 19 --- 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. \ No newline at end of file diff --git a/.ideai/tickets/60/carnet.md b/.ideai/tickets/60/carnet.md index 35c10fa..bcff97f 100644 --- a/.ideai/tickets/60/carnet.md +++ b/.ideai/tickets/60/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#60" -version: 2 -updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} -updatedAt: 1784095176619 +version: 3 +updatedBy: {"kind":"user"} +updatedAt: 1784194089605 --- diff --git a/.ideai/tickets/60/issue.md b/.ideai/tickets/60/issue.md index 1a50b46..bb17758 100644 --- a/.ideai/tickets/60/issue.md +++ b/.ideai/tickets/60/issue.md @@ -4,14 +4,14 @@ 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 +sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2" 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"} +updatedBy: {"kind":"user"} createdAt: 1784094260499 -updatedAt: 1784095176619 -version: 2 +updatedAt: 1784194089605 +version: 3 --- 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). diff --git a/.ideai/tickets/64/carnet.md b/.ideai/tickets/64/carnet.md index 16df86f..43bff21 100644 --- a/.ideai/tickets/64/carnet.md +++ b/.ideai/tickets/64/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#64" -version: 2 +version: 5 updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} -updatedAt: 1784187610934 +updatedAt: 1784193766706 --- diff --git a/.ideai/tickets/64/issue.md b/.ideai/tickets/64/issue.md index 1f7dd1e..435869f 100644 --- a/.ideai/tickets/64/issue.md +++ b/.ideai/tickets/64/issue.md @@ -4,14 +4,14 @@ 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"}] +sprint: "028179b1-eaf4-41e9-9c1f-7c37125117e6" +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: 1784183727404 -updatedAt: 1784187610934 -version: 2 +updatedAt: 1784193766706 +version: 5 --- 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. diff --git a/.ideai/tickets/65/carnet.md b/.ideai/tickets/65/carnet.md index 1e4cb7d..085d365 100644 --- a/.ideai/tickets/65/carnet.md +++ b/.ideai/tickets/65/carnet.md @@ -1,6 +1,46 @@ --- issueRef: "#65" -version: 1 +version: 6 updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} -updatedAt: 1784187583229 +updatedAt: 1784202758668 --- +# Ticket #65 — Serveur headless `idea-serve` (carnet de chantier) + +Statut : **QA — en attente de validation live utilisateur (non-régression desktop)**. +Branche : `feature/ticket65-idea-serve-headless` (base `develop` @ `506d589`). Local, aucune action sortante. + +## Arbitrages Architect (tranchés) +- **DTO/events → propriétaire canonique = crate `backend`.** PAS de crate `presentation-dto`/`backend-api` : `backend` est déjà le composition root transport-neutre et la façade des use cases pour adapters entrants. `web-server` ET `app-tauri` le consomment ⇒ contrat canonique partagé entre deux driving adapters. +- **Frontière** : les DTO dans `backend` ne tirent NI Tauri, NI HTTP/WS/cookies, NI Axum/Hyper, NI UI. `domain`/`application` ne dépendent PAS des DTO. Vérifié QA. +- **Dédup events = dans L1, pas en dette** (sinon deux sources de vérité au moment où #65 stabilise le contrat). Dette acceptable uniquement pour le Tauri-spécifique (relay `app_handle.emit`, wiring runtime). + +## Structure cible (livrée) +- `crates/backend/src/dto.rs` + `crates/backend/src/events.rs` ; `lib.rs` expose `pub mod dto; pub mod events;` +- `app-tauri/src/dto.rs` = shim `pub use backend::dto::*;` +- `app-tauri/src/events.rs` = relay Tauri uniquement, DTO importés de `backend::events`. +- `web-server` consomme `backend::{dto, events}`. + +## Livré — L1/L2/L3 + run_embedded +Commits : `82e8e77` (wip de sûreté, ne fige rien) · `ddbea7b` (dédup DTO events). +`web_server::run_embedded(...)` présent : `ServerConfig`, `EmbeddedServerHandle`, `url()`, `pairing_code()`, `state()`, `stop()`, shutdown via `oneshot`. **C'est le point d'extension qui prépare #68** (le desktop consommera `web-server` comme lib, pas de fork). + +## Vérif QA (exécution réelle) — VERT +- `cargo build -p web-server --bin idea-serve` + `-p app-tauri --bin app-tauri` : OK. +- `cargo test -p backend` 41 passed · `-p web-server` 55 passed · `-p app-tauri` 35 passed. +- **Invariant clé** : `ldd target/debug/idea-serve | rg -i 'webkit|javascriptcore|gtk|gdk|soup|tauri|wry'` → **aucune sortie**. Comparatif `app-tauri` tire bien libwebkit2gtk/libgtk/libsoup/libjavascriptcore. +- Contrat HTTP/WS inchangé (55 tests web-server : pair/invoke/logout/ws/static/allowlist/B8). `git diff --name-only 506d589...HEAD -- frontend` → **vide**. + +## Faux mystère élucidé (à ne pas refaire paniquer) +`cargo test -p app-tauri` est passé de **45 → 35** : les tests ne sont PAS perdus, ils ont MIGRÉ. Écart réel depuis develop = **68 tests déplacés** (`src/lib.rs` : 104 → 36) : 55 `server::tests::*` → `web-server`, 10 `events::tests::*` → `backend::events`, 3 `dto::ls6_pagination_tests::*` → `backend::dto`. Vérifié par `cargo test -p app-tauri -- --list` comparé sur `506d589`. + +## Faux négatif connu (arbitré NON bloquant) +`cargo test --workspace` : 10 échecs `infrastructure::session::openai_compat::*` + `factory_routes_openai_compatible_to_http_session` sur `bind: Os { code: 1, kind: PermissionDenied }` = la sandbox interdit l'écoute réseau. **Confirmé préexistant sur `506d589`** (même liste). Validation retenue par crates ciblés. + +## Réserve QA restante +Aucun test dédié à `run_embedded().stop()` (un test de bind réel serait soumis à la même restriction sandbox). À couvrir dans #68, qui consommera ce handle. + +## Incident de topologie (résolu — leçon) +DevBackend a d'abord travaillé sur la branche de #69 sans committer. Git a rapatrié le diff Rust (stash `-u` limité à `Cargo.lock Cargo.toml crates/`), intégrité vérifiée par empreinte SHA-256 avant/après (`c1aecea8…c499d3b` / `701bc7ee…4d6ff016`), rien perdu. **Leçon : vérifier la branche courante AVANT de commencer.** + +## Prochaine étape +Validation live utilisateur : (1) le desktop ne perd rien, (2) `idea-serve` sert le client web. Si OK → Git merge dans develop (Git refuse de merger sans QA réelle sur les DEUX binaires ; point sensible = workspace `Cargo.toml` members). Débloque ensuite #68 (via `run_embedded`) et #66 (Docker). diff --git a/.ideai/tickets/65/issue.md b/.ideai/tickets/65/issue.md index 4a6ef70..fb61750 100644 --- a/.ideai/tickets/65/issue.md +++ b/.ideai/tickets/65/issue.md @@ -2,16 +2,16 @@ id: "f9f7067b-92b7-4737-ad99-4487ad8c8b13" number: 65 title: "Serveur headless : extraire idea-serve du binaire Tauri (cœur partagé)" -status: "open" +status: "qa" priority: "high" -sprint: null +sprint: "028179b1-eaf4-41e9-9c1f-7c37125117e6" 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 +updatedAt: 1784202758668 +version: 6 --- 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). diff --git a/.ideai/tickets/66/carnet.md b/.ideai/tickets/66/carnet.md index b95cf27..a9e8413 100644 --- a/.ideai/tickets/66/carnet.md +++ b/.ideai/tickets/66/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#66" -version: 1 -updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} -updatedAt: 1784187597004 +version: 2 +updatedBy: {"kind":"user"} +updatedAt: 1784193460805 --- diff --git a/.ideai/tickets/66/issue.md b/.ideai/tickets/66/issue.md index 0661a14..ce6e6c3 100644 --- a/.ideai/tickets/66/issue.md +++ b/.ideai/tickets/66/issue.md @@ -4,14 +4,14 @@ number: 66 title: "Image Docker serveur/client IdeA" status: "open" priority: "medium" -sprint: null +sprint: "028179b1-eaf4-41e9-9c1f-7c37125117e6" 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"} +updatedBy: {"kind":"user"} createdAt: 1784187597004 -updatedAt: 1784187597004 -version: 1 +updatedAt: 1784193460805 +version: 2 --- 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). diff --git a/.ideai/tickets/67/carnet.md b/.ideai/tickets/67/carnet.md index e2adad8..42d8577 100644 --- a/.ideai/tickets/67/carnet.md +++ b/.ideai/tickets/67/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#67" -version: 1 -updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} -updatedAt: 1784187618710 +version: 2 +updatedBy: {"kind":"user"} +updatedAt: 1784193467784 --- diff --git a/.ideai/tickets/67/issue.md b/.ideai/tickets/67/issue.md index 928fac7..b914898 100644 --- a/.ideai/tickets/67/issue.md +++ b/.ideai/tickets/67/issue.md @@ -4,14 +4,14 @@ number: 67 title: "Lock inter-process de l'app-data-dir (desktop ↔ idea --serve)" status: "open" priority: "low" -sprint: null +sprint: "028179b1-eaf4-41e9-9c1f-7c37125117e6" 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"} +updatedBy: {"kind":"user"} createdAt: 1784187618710 -updatedAt: 1784187618710 -version: 1 +updatedAt: 1784193467784 +version: 2 --- 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 »). diff --git a/.ideai/tickets/68/carnet.md b/.ideai/tickets/68/carnet.md new file mode 100644 index 0000000..fcaf7a7 --- /dev/null +++ b/.ideai/tickets/68/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#68" +version: 5 +updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} +updatedAt: 1784193767292 +--- diff --git a/.ideai/tickets/68/issue.md b/.ideai/tickets/68/issue.md new file mode 100644 index 0000000..6e9bdc0 --- /dev/null +++ b/.ideai/tickets/68/issue.md @@ -0,0 +1,16 @@ +--- +id: "13821b24-f566-402e-bc10-02ac6e8a5f7c" +number: 68 +title: "Ajouter dans l'app desktop, la possibilité d'activer le serveur" +status: "open" +priority: "medium" +sprint: "028179b1-eaf4-41e9-9c1f-7c37125117e6" +links: [{"target":"#65","kind":"dependsOn"}] +agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} +createdAt: 1784193318419 +updatedAt: 1784193767292 +version: 5 +--- +J'iamerais que depuis l'app desktop, on puisse quand même embarquer le serveur. Comme ça un utilisateur pourrait continuer son travail en cours en remote \ No newline at end of file diff --git a/.ideai/tickets/69/carnet.md b/.ideai/tickets/69/carnet.md new file mode 100644 index 0000000..dbdf33c --- /dev/null +++ b/.ideai/tickets/69/carnet.md @@ -0,0 +1,41 @@ +--- +issueRef: "#69" +version: 9 +updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} +updatedAt: 1784202783353 +--- +# Ticket #69 — Adaptibilité client téléphone (carnet de chantier) + +Statut : **QA — en attente de validation live utilisateur sur un VRAI téléphone**. +Branche : `feature/ticket69-mobile-responsive-client` (base `develop` @ `506d589`). Commits : `e22ea5b` (shell), `8f15ad6` (terminal + tests), `6ed0087` (fix typecheck). Frontend uniquement — vérifié. + +## Cadrage Architect +Ticket **frontend-pur** : aucun changement DTO, use case, ni modèle `LayoutTree`. Contrats HTTP/WS et layout/terminal inchangés. Mais PAS du « CSS pur ». + +## ⚠️ Le cadrage initial reposait sur un modèle FAUX (constat majeur) +Le lot 2 prévoyait « remplacer les docks gauche/droite/floating par une pile verticale/drawer/bottom sheet ». **Il n'y a rien à remplacer** : le client web n'a JAMAIS eu de docks, de fenêtres flottantes ni de `LayoutTree`. `main.tsx` route le mode `http` vers `WebApp`, surface autonome livrée par #13 : pairing **ou** `WebWorkspace`, déjà une colonne verticale unique. Les seuls imports `@/features/*` de tout `features/web/` sont `terminals` et `workstate` — le shell desktop n'est pas atteignable depuis le web. +⇒ **La question « bottom sheet vs drawer » ne se pose pas.** Frontière épinglée par un test (`WebMobile.test.tsx`) + mémoire `web-client-is-single-column-no-desktop-shell`, pour que le cadrage ne reparte pas du même modèle faux. + +## Le vrai blocage était le terminal, pas le layout +Le clavier virtuel n'a ni Esc, ni Tab, ni Ctrl, ni flèches : on pouvait taper un prompt à un agent CLI mais pas l'interrompre, compléter un chemin, sortir d'un éditeur ou rappeler l'historique. + +## Livré +- **Lot 1 (shell)** : `100dvh` (le `height:100%` se résolvait sur le *large* viewport ⇒ la barre d'URL rétractable masquait le bas), `viewport-fit=cover` + safe-area insets, padding responsive, `inputMode="numeric"` sur le code d'appairage. Zoom laissé libre (accessibilité). +- **Lot 2 (réinterprété)** : `AgentLiveRow` + rangée de tâche de fond (statut/kind/Cancel/Retry ne tenaient pas à 360px) s'empilent sous `sm`, ligne unique dès `sm`. +- **Lot 3 (terminal)** : `TerminalView` expose `onReady(api)` **optionnel** (desktop inchangé) ; `api.send` passe par `term.input()` — même chemin qu'une frappe réelle, donc relais PTY + suspension du write-portal s'appliquent à l'identique (écrire sur le handle aurait court-circuité le portal). `TerminalKeyBar` : Esc/Tab/Ctrl-C/Ctrl-D/flèches en chips 44px ; **chaque tap annule le déplacement de focus** puis refocalise xterm (sinon le clavier virtuel se referme à chaque touche). Cellule `h-64` → `h-[60dvh]`. + +## Vérif QA (exécution réelle) — VERT +- `npm run typecheck` exit 0 · `npx vitest run` → 85 files / **774 passed** · `VITE_TRANSPORT=http npx vite build` OK. +- **CSS mobile confirmé DANS le bundle** (les valeurs arbitraires Tailwind avec `env()` no-opent en silence) : `100dvh`, `60dvh`, les 4 `safe-area-inset-*`, `viewport-fit=cover`, `.h-\[60dvh\]{height:60dvh}`. +- Non-régression desktop : `LayoutGrid` ne passe pas `onReady` ; chemin desktop inchangé. +- Rendu réel Chrome headless 360×780 : pairing tient sans débordement horizontal. + +## Réserve ASSUMÉE — trou de couverture réel +**jsdom n'évalue PAS les media queries** : les tests sont structurels, pas visuels. Ils ne prouvent NI le rendu aux breakpoints, NI l'effet réel de `dvh`, NI les safe areas, NI le clavier virtuel/focus sur navigateur mobile. QA : « acceptable pour merger avec réserve explicite », pas rouge. **C'est exactement ce que la validation utilisateur doit couvrir.** +Workspace + keybar non vérifiés visuellement à 360px (demande un serveur appairé + PTY vivant = territoire #65) — angle mort restant. + +## Écart de périmètre à arbitrer (Playwright) +Lot 4 demandait « Playwright ou équivalent ». DevFrontend a livré l'équivalent (vitest/jsdom) et REFUSÉ d'installer Playwright unilatéralement (binaires navigateur + surface CI = décision de dépendance qui remonte à Architect/Main). Avis QA : non bloquant pour ce lot, mais vitest/jsdom **n'est pas un équivalent complet** pour un ticket dont le risque principal est visuel mobile. ⇒ **Follow-up à décider** : ticket « tests de rendu réel aux breakpoints » si le projet accepte une dépendance navigateur/CI. + +## Prochaine étape +Validation live utilisateur sur un vrai téléphone (build `VITE_TRANSPORT=http`, servi par `idea --serve`/`idea-serve`, accès LAN). Si OK → Git merge dans develop (Git passera #69 avant #65 si les deux sont prêts : petit, frontend-pur, faible risque). diff --git a/.ideai/tickets/69/issue.md b/.ideai/tickets/69/issue.md new file mode 100644 index 0000000..da8a444 --- /dev/null +++ b/.ideai/tickets/69/issue.md @@ -0,0 +1,16 @@ +--- +id: "7f5a40cf-837f-46f7-8c5f-653c80975396" +number: 69 +title: "Adaptibilité client téléphone" +status: "qa" +priority: "medium" +sprint: "028179b1-eaf4-41e9-9c1f-7c37125117e6" +links: [] +agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} +createdAt: 1784193391258 +updatedAt: 1784202783353 +version: 9 +--- +J'aimerais un format vertical qui rende l'app client compatible téléphone \ No newline at end of file diff --git a/.ideai/tickets/70/carnet.md b/.ideai/tickets/70/carnet.md new file mode 100644 index 0000000..4b0137e --- /dev/null +++ b/.ideai/tickets/70/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#70" +version: 4 +updatedBy: {"kind":"user"} +updatedAt: 1784194049447 +--- diff --git a/.ideai/tickets/70/issue.md b/.ideai/tickets/70/issue.md new file mode 100644 index 0000000..945c969 --- /dev/null +++ b/.ideai/tickets/70/issue.md @@ -0,0 +1,16 @@ +--- +id: "e73ed25f-8e22-48a5-84ea-521212ea80b5" +number: 70 +title: "Pouvoir gérer les models AI locaux téléchargés via les serveur llamacpp" +status: "open" +priority: "low" +sprint: null +links: [] +agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1784194016529 +updatedAt: 1784194049447 +version: 4 +--- +Avec les serveurs de models locaux llamacpp, on télécharge des modeles, j'aimerais aussi pouvoir les supprimer \ No newline at end of file diff --git a/.ideai/tickets/counter.json b/.ideai/tickets/counter.json index 4e3cd23..d70a5bd 100644 --- a/.ideai/tickets/counter.json +++ b/.ideai/tickets/counter.json @@ -1,3 +1,3 @@ { - "nextNumber": 68 + "nextNumber": 71 } \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index c264716..0390165 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -149,13 +149,13 @@ "issueRef": "#13", "path": "13", "title": "Server/client mode", - "status": "inProgress", + "status": "closed", "priority": "low", "sprint": "028179b1-eaf4-41e9-9c1f-7c37125117e6", "assignedAgentIds": [ "a6ced819-b893-4213-b003-9e9dc79b9641" ], - "updatedAt": 1784184002362 + "updatedAt": 1784193634690 }, { "issueRef": "#14", @@ -627,9 +627,9 @@ "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, + "sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2", "assignedAgentIds": [], - "updatedAt": 1784095176619 + "updatedAt": 1784194089605 }, { "issueRef": "#61", @@ -669,19 +669,19 @@ "title": "Web : créer/ajouter un projet depuis le navigateur (folder browser serveur)", "status": "open", "priority": "medium", - "sprint": null, + "sprint": "028179b1-eaf4-41e9-9c1f-7c37125117e6", "assignedAgentIds": [], - "updatedAt": 1784187610934 + "updatedAt": 1784193766706 }, { "issueRef": "#65", "path": "65", "title": "Serveur headless : extraire idea-serve du binaire Tauri (cœur partagé)", - "status": "open", + "status": "qa", "priority": "high", - "sprint": null, + "sprint": "028179b1-eaf4-41e9-9c1f-7c37125117e6", "assignedAgentIds": [], - "updatedAt": 1784187583229 + "updatedAt": 1784202758668 }, { "issueRef": "#66", @@ -689,9 +689,9 @@ "title": "Image Docker serveur/client IdeA", "status": "open", "priority": "medium", - "sprint": null, + "sprint": "028179b1-eaf4-41e9-9c1f-7c37125117e6", "assignedAgentIds": [], - "updatedAt": 1784187597004 + "updatedAt": 1784193460805 }, { "issueRef": "#67", @@ -699,9 +699,45 @@ "title": "Lock inter-process de l'app-data-dir (desktop ↔ idea --serve)", "status": "open", "priority": "low", - "sprint": null, + "sprint": "028179b1-eaf4-41e9-9c1f-7c37125117e6", "assignedAgentIds": [], - "updatedAt": 1784187618710 + "updatedAt": 1784193467784 + }, + { + "issueRef": "#68", + "path": "68", + "title": "Ajouter dans l'app desktop, la possibilité d'activer le serveur", + "status": "open", + "priority": "medium", + "sprint": "028179b1-eaf4-41e9-9c1f-7c37125117e6", + "assignedAgentIds": [ + "a6ced819-b893-4213-b003-9e9dc79b9641" + ], + "updatedAt": 1784193767292 + }, + { + "issueRef": "#69", + "path": "69", + "title": "Adaptibilité client téléphone", + "status": "qa", + "priority": "medium", + "sprint": "028179b1-eaf4-41e9-9c1f-7c37125117e6", + "assignedAgentIds": [ + "a6ced819-b893-4213-b003-9e9dc79b9641" + ], + "updatedAt": 1784202783353 + }, + { + "issueRef": "#70", + "path": "70", + "title": "Pouvoir gérer les models AI locaux téléchargés via les serveur llamacpp", + "status": "open", + "priority": "low", + "sprint": null, + "assignedAgentIds": [ + "a6ced819-b893-4213-b003-9e9dc79b9641" + ], + "updatedAt": 1784194049447 } ] } \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 68c30c7..f66cdb9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -90,6 +90,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "uuid", + "web-server", ] [[package]] @@ -5236,6 +5237,24 @@ dependencies = [ "semver", ] +[[package]] +name = "web-server" +version = "0.3.0" +dependencies = [ + "application", + "backend", + "base64 0.22.1", + "bytes", + "cookie", + "domain", + "http", + "http-body-util", + "serde", + "serde_json", + "tokio", + "uuid", +] + [[package]] name = "web-sys" version = "0.3.99" diff --git a/Cargo.toml b/Cargo.toml index ff5bd0c..a1fe65e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/application", "crates/infrastructure", "crates/backend", + "crates/web-server", "crates/app-tauri", ] @@ -31,6 +32,7 @@ domain = { path = "crates/domain" } application = { path = "crates/application" } infrastructure = { path = "crates/infrastructure" } backend = { path = "crates/backend" } +web-server = { path = "crates/web-server" } # Tauri v2 tauri = { version = "2", features = [] } diff --git a/crates/app-tauri/Cargo.toml b/crates/app-tauri/Cargo.toml index 219c52e..7ed3c2d 100644 --- a/crates/app-tauri/Cargo.toml +++ b/crates/app-tauri/Cargo.toml @@ -24,6 +24,7 @@ domain = { workspace = true } application = { workspace = true } infrastructure = { workspace = true } backend = { workspace = true } +web-server = { workspace = true } tauri = { workspace = true } tauri-plugin-dialog = { workspace = true } # `io-std` gives the headless `mcp-server` bridge access to diff --git a/crates/app-tauri/src/dto.rs b/crates/app-tauri/src/dto.rs index dae6ce4..229a908 100644 --- a/crates/app-tauri/src/dto.rs +++ b/crates/app-tauri/src/dto.rs @@ -1,3484 +1,7 @@ -//! Data Transfer Objects crossing the IPC boundary. +//! Compatibility re-export for the historical desktop DTO module. //! -//! Convention (frozen here for L1, see L1-ipc-bridge.md "points d'attention"): -//! **all IPC payloads are `camelCase`** via `#[serde(rename_all = "camelCase")]`. -//! Rust uses `snake_case` fields; serde renames them on the wire so the -//! TypeScript side sees idiomatic camelCase. This matches the persisted-domain -//! JSON convention already used in the domain (`agents.json` etc.). +//! The stable JSON contracts now live in `backend::dto` so the headless +//! `web-server` crate and the Tauri commands consume the same transport-neutral +//! DTO definitions. -use serde::{Deserialize, Serialize}; - -use application::{ - AgentBackgroundTaskState, AgentTicketState, AppError, AttachLiveAgentOutput, - BackgroundTaskKindLabel, ConversationPreviewStatus, ConversationTurnWorkPreview, - ConversationWorkSummary, CreateProjectInput, CreateProjectOutput, GitGraphOutput, HealthInput, - HealthReport, LayoutKind, ListProjectsOutput, LiveSessionKind, LiveSessionSnapshot, - OpenProjectOutput, ProjectWorkState, StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, - TurnPage, TurnSource, TurnView, -}; -use domain::{AgentBusyState, PageCursor, PageDirection, Project, ProjectId, TurnRole}; - -/// Request DTO for the `health` command. -#[derive(Debug, Clone, Default, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct HealthRequestDto { - /// Optional note echoed back by the use case. - #[serde(default)] - pub note: Option, -} - -impl From for HealthInput { - fn from(dto: HealthRequestDto) -> Self { - Self { note: dto.note } - } -} - -/// Response DTO for the `health` command. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct HealthResponseDto { - /// Application version. - pub version: String, - /// Liveness flag. - pub alive: bool, - /// Server time in epoch milliseconds. - pub time_millis: i64, - /// Correlation id for this call. - pub correlation_id: String, - /// Echoed note, if any. - pub note: Option, -} - -impl From for HealthResponseDto { - fn from(r: HealthReport) -> Self { - Self { - version: r.version, - alive: r.alive, - time_millis: r.time_millis, - correlation_id: r.correlation_id, - note: r.note, - } - } -} - -/// Error DTO returned to the frontend in the `Err` arm of every command. -/// -/// `code` is a stable machine-readable string (see [`AppError::code`]); the -/// frontend branches on it without parsing `message`. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ErrorDto { - /// Stable error code, e.g. `NOT_FOUND`, `INVALID`. - pub code: String, - /// Human-readable message. - pub message: String, -} - -impl From for ErrorDto { - fn from(e: AppError) -> Self { - Self { - code: e.code().to_owned(), - message: e.to_string(), - } - } -} - -// --------------------------------------------------------------------------- -// Projects (L2) -// --------------------------------------------------------------------------- - -/// A project as seen by the frontend (camelCase wire shape). -/// -/// `remote` is the domain [`domain::RemoteRef`], which already serialises -/// camelCase + tagged (`kind`), so we embed it directly. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ProjectDto { - /// Stable project id (UUID string). - pub id: String, - /// Display name. - pub name: String, - /// Absolute project root. - pub root: String, - /// Where the project lives (`{ "kind": "local" }`, `ssh`, `wsl`). - pub remote: domain::RemoteRef, - /// Creation timestamp, epoch milliseconds. - pub created_at: i64, -} - -impl From for ProjectDto { - fn from(p: Project) -> Self { - Self { - id: p.id.to_string(), - name: p.name, - root: p.root.as_str().to_owned(), - remote: p.remote, - created_at: p.created_at, - } - } -} - -/// Request DTO for `create_project`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateProjectRequestDto { - /// Display name. - pub name: String, - /// Absolute project root. - pub root: String, - /// Optional remote reference; defaults to local when omitted. - #[serde(default)] - pub remote: Option, - /// Optional default profile id. - #[serde(default)] - pub default_profile_id: Option, -} - -impl From for CreateProjectInput { - fn from(dto: CreateProjectRequestDto) -> Self { - Self { - name: dto.name, - root: dto.root, - remote: dto.remote, - default_profile_id: dto.default_profile_id, - } - } -} - -impl From for ProjectDto { - fn from(out: CreateProjectOutput) -> Self { - out.project.into() - } -} - -impl From for ProjectDto { - fn from(out: OpenProjectOutput) -> Self { - out.project.into() - } -} - -/// Parses a project-id string (UUID) coming from the frontend. -/// -/// # Errors -/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. -pub fn parse_project_id(raw: &str) -> Result { - uuid::Uuid::parse_str(raw) - .map(ProjectId::from_uuid) - .map_err(|_| ErrorDto { - code: "INVALID".to_owned(), - message: format!("invalid project id: {raw}"), - }) -} - -/// Response DTO for `list_projects`. -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct ProjectListDto(pub Vec); - -impl From for ProjectListDto { - fn from(out: ListProjectsOutput) -> Self { - Self(out.projects.into_iter().map(ProjectDto::from).collect()) - } -} - -// --------------------------------------------------------------------------- -// Terminals (L3) -// --------------------------------------------------------------------------- - -use application::{ - CloseTerminalInput, CloseTerminalOutput, OpenTerminalInput, OpenTerminalOutput, - ResizeTerminalInput, WriteToTerminalInput, -}; -use domain::SessionId; - -/// Request DTO for `open_terminal`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct OpenTerminalRequestDto { - /// Working directory (typically the project root). - pub cwd: String, - /// Initial terminal height in rows. - pub rows: u16, - /// Initial terminal width in columns. - pub cols: u16, - /// Optional explicit command; defaults to the platform shell when omitted. - #[serde(default)] - pub command: Option, - /// Optional arguments for the command. - #[serde(default)] - pub args: Vec, -} - -impl From for OpenTerminalInput { - fn from(dto: OpenTerminalRequestDto) -> Self { - Self { - cwd: dto.cwd, - rows: dto.rows, - cols: dto.cols, - command: dto.command, - args: dto.args, - node_id: None, - } - } -} - -/// Response DTO for `open_terminal`: the freshly-opened session. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct TerminalSessionDto { - /// Stable session id (UUID string) — used for write/resize/close + the - /// output channel. - pub session_id: String, - /// Working directory the shell runs in. - pub cwd: String, - /// Current rows. - pub rows: u16, - /// Current cols. - pub cols: u16, - /// Conversation id **assigned** by this launch, when the agent's profile - /// supports session assignment and the hosting cell had none yet (T4b). The - /// front persists it on the leaf (`setCellConversation`) so the next open - /// resumes. `None` for a plain terminal, a resume, or a degraded launch. - #[serde(skip_serializing_if = "Option::is_none")] - pub assigned_conversation_id: Option, - /// **Id de session moteur** (resumable du provider courant) exposé par ce - /// lancement, **distinct** de l'id de paire `assignedConversationId` (ARCHITECTURE - /// §19.7, lot P8a). Le front le range dans le **cache** `engineSessionId` de la - /// cellule (jamais sur `conversationId`). Absent pour un terminal nu, une reprise, - /// ou quand le moteur n'expose encore aucun id. La source de vérité du resumable - /// est `providers.json` (lot P8b). - #[serde(skip_serializing_if = "Option::is_none")] - pub engine_session_id: Option, - /// How the frontend should render the cell hosting this session (§17.6): - /// [`CellKind::Chat`] for a structured AI session (an `AgentChatView` driven by - /// `agent_send`/`reattach_agent_chat`), [`CellKind::Pty`] for a raw terminal - /// (xterm). **Derived**, not a layout field: it follows the presence of a - /// structured session descriptor on the launch output — a single source of - /// truth. Plain terminals and the non-launch construction paths default to - /// [`CellKind::Pty`] (the historical, non-breaking shape: a PTY session DTO - /// always serialises with `cellKind: "pty"`). - pub cell_kind: CellKind, -} - -/// Whether a session's hosting cell renders as a structured chat view or a raw -/// terminal (ARCHITECTURE §17.6). Serialises as `"chat"` / `"pty"` on the wire; -/// the frontend `LayoutGrid` switches `AgentChatView` vs `TerminalView` on it. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum CellKind { - /// A raw PTY terminal cell (xterm) — the historical default. - Pty, - /// A structured AI chat cell (`AgentChatView`), backed by an `AgentSession`. - Chat, -} - -impl From for TerminalSessionDto { - fn from(out: OpenTerminalOutput) -> Self { - let s = out.session; - Self { - session_id: s.id.to_string(), - cwd: s.cwd.as_str().to_owned(), - rows: s.pty_size.rows, - cols: s.pty_size.cols, - assigned_conversation_id: None, - engine_session_id: None, - cell_kind: CellKind::Pty, - } - } -} - -/// Response DTO for `reattach_terminal`: the retained scrollback of a still-live -/// session, repainted into the re-mounting xterm before the new output stream is -/// wired. Bytes are serialised as a number array, matching the PTY output channel. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ReattachResultDto { - /// The session that was re-attached (echoed back for the frontend). - pub session_id: String, - /// The most-recent retained output bytes (scrollback ring buffer). - pub scrollback: Vec, -} - -/// Request DTO for `write_terminal`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct WriteTerminalRequestDto { - /// Target session id. - pub session_id: String, - /// Bytes to write (xterm keystrokes). - pub data: Vec, -} - -impl WriteTerminalRequestDto { - /// Converts to the use-case input, parsing the session id. - /// - /// # Errors - /// [`ErrorDto`] with code `INVALID` if the id is malformed. - pub fn into_input(self) -> Result { - Ok(WriteToTerminalInput { - session_id: parse_session_id(&self.session_id)?, - data: self.data, - }) - } -} - -/// Request DTO for `resize_terminal`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ResizeTerminalRequestDto { - /// Target session id. - pub session_id: String, - /// New rows. - pub rows: u16, - /// New cols. - pub cols: u16, -} - -impl ResizeTerminalRequestDto { - /// Converts to the use-case input, parsing the session id. - /// - /// # Errors - /// [`ErrorDto`] with code `INVALID` if the id is malformed. - pub fn into_input(self) -> Result { - Ok(ResizeTerminalInput { - session_id: parse_session_id(&self.session_id)?, - rows: self.rows, - cols: self.cols, - }) - } -} - -/// Response DTO for `close_terminal`. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct TerminalClosedDto { - /// Exit code, if the process reported one. - pub code: Option, -} - -impl From for TerminalClosedDto { - fn from(out: CloseTerminalOutput) -> Self { - Self { code: out.code } - } -} - -/// Builds a [`CloseTerminalInput`] from a raw session-id string. -/// -/// # Errors -/// [`ErrorDto`] with code `INVALID` if the id is malformed. -pub fn parse_close_terminal(raw: &str) -> Result { - Ok(CloseTerminalInput { - session_id: parse_session_id(raw)?, - }) -} - -/// Parses a session-id string (UUID) coming from the frontend. -/// -/// # Errors -/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. -pub fn parse_session_id(raw: &str) -> Result { - uuid::Uuid::parse_str(raw) - .map(SessionId::from_uuid) - .map_err(|_| ErrorDto { - code: "INVALID".to_owned(), - message: format!("invalid session id: {raw}"), - }) -} - -// --------------------------------------------------------------------------- -// Layout (L4 + #4 management + #3 per-cell agent) -// --------------------------------------------------------------------------- - -use application::{ - CreateLayoutOutput, DeleteLayoutOutput, LayoutInfo, LayoutOperation, ListLayoutsOutput, - LoadLayoutOutput, MutateLayoutOutput, SetActiveLayoutOutput, -}; -use domain::{AgentId, Direction, LayoutId, LayoutTree, NodeId}; - -/// Response DTO carrying a layout tree. -/// -/// [`LayoutTree`] already serialises camelCase + tagged (its enum uses -/// `#[serde(tag = "type", content = "node")]`), so we embed it directly; the -/// TypeScript mirror in `@/domain` matches this shape. -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct LayoutDto(pub LayoutTree); - -impl From for LayoutDto { - fn from(out: LoadLayoutOutput) -> Self { - Self(out.layout) - } -} - -impl From for LayoutDto { - fn from(out: MutateLayoutOutput) -> Self { - Self(out.layout) - } -} - -/// A layout operation as sent by the frontend (tagged on `type`, camelCase). -/// -/// Mirrors [`LayoutOperation`]; node/session ids cross the wire as UUID strings -/// and are parsed here. `direction` reuses the domain [`Direction`] (which -/// already serialises `"row"`/`"column"`). -#[derive(Debug, Clone, Deserialize)] -#[serde(tag = "type", rename_all = "camelCase")] -pub enum LayoutOperationDto { - /// Split a leaf into a two-child split. - #[serde(rename_all = "camelCase")] - Split { - /// Leaf to split. - target: String, - /// Row (columns) or Column (rows). - direction: Direction, - /// Id for the new sibling leaf. - new_leaf: String, - /// Id for the wrapping split container. - container: String, - }, - /// Collapse a split container back to one child. - #[serde(rename_all = "camelCase")] - Merge { - /// Split container to collapse. - container: String, - /// Index of the child to keep. - keep_index: usize, - }, - /// Reassign a split's child weights. - #[serde(rename_all = "camelCase")] - Resize { - /// Split container to resize. - container: String, - /// New weights (one per child). - weights: Vec, - }, - /// Move a session from one leaf to another. - #[serde(rename_all = "camelCase")] - Move { - /// Source leaf. - from: String, - /// Target (empty) leaf. - to: String, - }, - /// Attach/detach a session to/from a leaf. - #[serde(rename_all = "camelCase")] - SetSession { - /// Hosting leaf. - target: String, - /// Session id, or `null` to clear. - #[serde(default)] - session: Option, - }, - /// Attach/detach an agent to/from a leaf (#3 per-cell agent). - #[serde(rename_all = "camelCase")] - SetCellAgent { - /// Hosting leaf. - target: String, - /// Agent id, or `null` to clear. - #[serde(default)] - agent: Option, - }, - /// Record/clear the persistent CLI conversation id on a leaf (T4b). - #[serde(rename_all = "camelCase")] - SetCellConversation { - /// Hosting leaf. - target: String, - /// Conversation id, or `null` to clear. - #[serde(default)] - conversation_id: Option, - }, -} - -impl LayoutOperationDto { - /// Converts to the use-case operation, parsing all ids. - /// - /// # Errors - /// [`ErrorDto`] with code `INVALID` if any id is malformed. - pub fn into_operation(self) -> Result { - Ok(match self { - Self::Split { - target, - direction, - new_leaf, - container, - } => LayoutOperation::Split { - target: parse_node_id(&target)?, - direction, - new_leaf: parse_node_id(&new_leaf)?, - container: parse_node_id(&container)?, - }, - Self::Merge { - container, - keep_index, - } => LayoutOperation::Merge { - container: parse_node_id(&container)?, - keep_index, - }, - Self::Resize { container, weights } => LayoutOperation::Resize { - container: parse_node_id(&container)?, - weights, - }, - Self::Move { from, to } => LayoutOperation::Move { - from: parse_node_id(&from)?, - to: parse_node_id(&to)?, - }, - Self::SetSession { target, session } => LayoutOperation::SetSession { - target: parse_node_id(&target)?, - session: session.as_deref().map(parse_session_id).transpose()?, - }, - Self::SetCellAgent { target, agent } => LayoutOperation::SetCellAgent { - target: parse_node_id(&target)?, - agent: agent.as_deref().map(parse_agent_id).transpose()?, - }, - Self::SetCellConversation { - target, - conversation_id, - } => LayoutOperation::SetCellConversation { - target: parse_node_id(&target)?, - conversation_id, - }, - }) - } -} - -/// Parses a node-id string (UUID) coming from the frontend. -/// -/// # Errors -/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. -pub fn parse_node_id(raw: &str) -> Result { - uuid::Uuid::parse_str(raw) - .map(NodeId::from_uuid) - .map_err(|_| ErrorDto { - code: "INVALID".to_owned(), - message: format!("invalid node id: {raw}"), - }) -} - -/// Parses a layout-id string (UUID) coming from the frontend. -/// -/// # Errors -/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. -pub fn parse_layout_id(raw: &str) -> Result { - uuid::Uuid::parse_str(raw) - .map(LayoutId::from_uuid) - .map_err(|_| ErrorDto { - code: "INVALID".to_owned(), - message: format!("invalid layout id: {raw}"), - }) -} - -// --------------------------------------------------------------------------- -// Layouts (#4) — management DTOs -// --------------------------------------------------------------------------- - -/// Lightweight layout descriptor (id + name + kind), for the tab bar. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct LayoutInfoDto { - /// Stable layout id (UUID string). - pub id: String, - /// Display name. - pub name: String, - /// Layout kind: `"terminal"` or `"gitGraph"`. - pub kind: String, -} - -impl From for LayoutInfoDto { - fn from(info: LayoutInfo) -> Self { - let kind = match info.kind { - LayoutKind::Terminal => "terminal", - LayoutKind::GitGraph => "gitGraph", - } - .to_owned(); - Self { - id: info.id.to_string(), - name: info.name, - kind, - } - } -} - -/// Response DTO for `list_layouts`. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ListLayoutsDto { - /// All named layouts (id + name), in order. - pub layouts: Vec, - /// The id of the currently active layout. - pub active_id: String, -} - -impl From for ListLayoutsDto { - fn from(out: ListLayoutsOutput) -> Self { - Self { - layouts: out.layouts.into_iter().map(LayoutInfoDto::from).collect(), - active_id: out.active_id.to_string(), - } - } -} - -/// Response DTO for `create_layout`. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateLayoutResultDto { - /// The id minted for the new layout. - pub layout_id: String, -} - -impl From for CreateLayoutResultDto { - fn from(out: CreateLayoutOutput) -> Self { - Self { - layout_id: out.layout_id.to_string(), - } - } -} - -/// Response DTO for `delete_layout`. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct DeleteLayoutResultDto { - /// The active layout after the deletion. - pub active_id: String, -} - -impl From for DeleteLayoutResultDto { - fn from(out: DeleteLayoutOutput) -> Self { - Self { - active_id: out.active_id.to_string(), - } - } -} - -/// Request DTO for `create_layout`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateLayoutRequestDto { - /// Owning project id. - pub project_id: String, - /// Display name for the new layout. - pub name: String, - /// Optional layout kind: `"terminal"` (default) or `"gitGraph"`. - #[serde(default)] - pub kind: Option, -} - -impl CreateLayoutRequestDto { - /// Parses the optional `kind` string into a [`LayoutKind`]. - /// - /// # Errors - /// [`ErrorDto`] with code `INVALID` if the value is not a known kind. - pub fn parse_kind(&self) -> Result { - match self.kind.as_deref() { - None | Some("terminal") => Ok(LayoutKind::Terminal), - Some("gitGraph") => Ok(LayoutKind::GitGraph), - Some(other) => Err(ErrorDto { - code: "INVALID".to_owned(), - message: format!("unknown layout kind: {other}"), - }), - } - } -} - -/// Request DTO for `rename_layout`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RenameLayoutRequestDto { - /// Owning project id. - pub project_id: String, - /// Layout to rename. - pub layout_id: String, - /// New display name. - pub name: String, -} - -/// Request DTO for `delete_layout`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DeleteLayoutRequestDto { - /// Owning project id. - pub project_id: String, - /// Layout to delete. - pub layout_id: String, -} - -/// Response DTO for `set_active_layout`. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SetActiveLayoutResultDto { - /// The layout actually activated — equals the requested id when valid, else - /// the unchanged current active id (self-healing fallback). Authoritative. - pub active_id: String, -} - -impl From for SetActiveLayoutResultDto { - fn from(out: SetActiveLayoutOutput) -> Self { - Self { - active_id: out.active_id.to_string(), - } - } -} - -/// Request DTO for `set_active_layout`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SetActiveLayoutRequestDto { - /// Owning project id. - pub project_id: String, - /// Layout to make active. - pub layout_id: String, -} - -// --------------------------------------------------------------------------- -// Profiles & first-run (L5) -// --------------------------------------------------------------------------- - -use application::{ - CloneOpenCodeProfileFromSeedInput, CloneOpenCodeProfileFromSeedOutput, ConfigureProfilesInput, - ConfigureProfilesOutput, DeleteProfileInput, DetectProfilesInput, DetectProfilesOutput, - FirstRunStateOutput, ListProfilesOutput, ProfileAvailability, ReferenceProfilesOutput, - SaveProfileInput, SaveProfileOutput, -}; -use domain::profile::{AgentProfile, OpenCodeConfig}; -use domain::ProfileId; - -/// A profile crossing the wire. [`AgentProfile`] already serialises camelCase -/// (id, name, command, args, `contextInjection{strategy,…}`, detect, -/// `cwdTemplate`), so we embed it directly — the TS mirror matches this shape. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(transparent)] -pub struct ProfileDto(pub AgentProfile); - -/// A list of profiles (camelCase array on the wire). -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct ProfileListDto(pub Vec); - -impl From> for ProfileListDto { - fn from(v: Vec) -> Self { - Self(v.into_iter().map(ProfileDto).collect()) - } -} - -impl From for ProfileListDto { - fn from(out: ListProfilesOutput) -> Self { - out.profiles.into() - } -} - -impl From for ProfileListDto { - fn from(out: ReferenceProfilesOutput) -> Self { - out.profiles.into() - } -} - -impl From for ProfileDto { - fn from(out: SaveProfileOutput) -> Self { - Self(out.profile) - } -} - -impl From for ProfileDto { - fn from(out: CloneOpenCodeProfileFromSeedOutput) -> Self { - Self(out.profile) - } -} - -/// Request DTO for `detect_profiles`: the candidate profiles to probe. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DetectProfilesRequestDto { - /// Candidate profiles whose `detect` command should be run. - pub candidates: Vec, -} - -impl From for DetectProfilesInput { - fn from(dto: DetectProfilesRequestDto) -> Self { - Self { - candidates: dto.candidates, - } - } -} - -/// One availability result (`profile` + whether its CLI is installed). -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ProfileAvailabilityDto { - /// The probed profile. - pub profile: AgentProfile, - /// Whether the CLI was detected (exit code 0). - pub available: bool, -} - -impl From for ProfileAvailabilityDto { - fn from(a: ProfileAvailability) -> Self { - Self { - profile: a.profile, - available: a.available, - } - } -} - -/// Response DTO for `detect_profiles`. -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct DetectProfilesResponseDto(pub Vec); - -impl From for DetectProfilesResponseDto { - fn from(out: DetectProfilesOutput) -> Self { - Self(out.results.into_iter().map(Into::into).collect()) - } -} - -/// Request DTO for `save_profile`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SaveProfileRequestDto { - /// The profile to upsert. - pub profile: AgentProfile, -} - -impl From for SaveProfileInput { - fn from(dto: SaveProfileRequestDto) -> Self { - Self { - profile: dto.profile, - } - } -} - -/// Request DTO for `clone_opencode_profile_from_seed`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CloneOpenCodeProfileFromSeedRequestDto { - /// Optional display name for the new profile. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub name: Option, - /// Optional OpenCode config override. When omitted, the seed config is copied. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub opencode: Option, -} - -impl From for CloneOpenCodeProfileFromSeedInput { - fn from(dto: CloneOpenCodeProfileFromSeedRequestDto) -> Self { - Self { - name: dto.name, - opencode: dto.opencode, - } - } -} - -/// Request DTO for `configure_profiles` (closes the first run). -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ConfigureProfilesRequestDto { - /// All profiles the user chose to keep. - pub profiles: Vec, -} - -impl From for ConfigureProfilesInput { - fn from(dto: ConfigureProfilesRequestDto) -> Self { - Self { - profiles: dto.profiles, - } - } -} - -impl From for ProfileListDto { - fn from(out: ConfigureProfilesOutput) -> Self { - out.profiles.into() - } -} - -/// Response DTO for `first_run_state`. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct FirstRunStateDto { - /// `true` when the first-run wizard should be shown. - pub is_first_run: bool, - /// Pre-filled reference catalogue to seed the wizard. - pub reference_profiles: Vec, -} - -impl From for FirstRunStateDto { - fn from(out: FirstRunStateOutput) -> Self { - Self { - is_first_run: out.is_first_run, - reference_profiles: out.reference_profiles, - } - } -} - -// --------------------------------------------------------------------------- -// Local model servers (B35) -// --------------------------------------------------------------------------- - -use application::{ListModelServersOutput, SaveModelServerInput, SaveModelServerOutput}; -use domain::model_server::{ - ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig, - LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelSource, -}; -use domain::{LocalModelServerId, StopPolicy}; - -/// Local model-server implementation on the IPC wire. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ModelServerKindDto { - /// llama.cpp `llama-server`. - LlamaCpp, -} - -impl From for ModelServerKindDto { - fn from(kind: LocalModelServerKind) -> Self { - match kind { - LocalModelServerKind::LlamaCpp => Self::LlamaCpp, - } - } -} - -impl From for LocalModelServerKind { - fn from(kind: ModelServerKindDto) -> Self { - match kind { - ModelServerKindDto::LlamaCpp => Self::LlamaCpp, - } - } -} - -/// Stop policy on the IPC wire. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum StopPolicyDto { - /// Keep the process alive. - KeepAlive, - /// Stop when no longer used. - StopWhenUnused, - /// Stop when the application exits. - StopOnAppExit, -} - -impl From for StopPolicyDto { - fn from(policy: StopPolicy) -> Self { - match policy { - StopPolicy::KeepAlive => Self::KeepAlive, - StopPolicy::StopWhenUnused => Self::StopWhenUnused, - StopPolicy::StopOnAppExit => Self::StopOnAppExit, - } - } -} - -impl From for StopPolicy { - fn from(policy: StopPolicyDto) -> Self { - match policy { - StopPolicyDto::KeepAlive => Self::KeepAlive, - StopPolicyDto::StopWhenUnused => Self::StopWhenUnused, - StopPolicyDto::StopOnAppExit => Self::StopOnAppExit, - } - } -} - -/// Model source on the IPC wire. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", tag = "type")] -pub enum ModelSourceDto { - /// Local `.gguf` path. - LocalPath { - /// Absolute local path. - path: String, - }, - /// Hugging Face `namespace/repo[:quant]` reference. - HuggingFace { - /// Repository reference. - repo: String, - }, -} - -impl From for ModelSourceDto { - fn from(source: ModelSource) -> Self { - match source { - ModelSource::LocalPath { path } => Self::LocalPath { path: path.0 }, - ModelSource::HuggingFace { repo } => Self::HuggingFace { repo: repo.0 }, - } - } -} - -impl ModelSourceDto { - fn into_domain(self) -> Result { - match self { - Self::LocalPath { path } => Ok(ModelSource::LocalPath { - path: ModelPath::new(path).map_err(invalid_domain_error)?, - }), - Self::HuggingFace { repo } => Ok(ModelSource::HuggingFace { - repo: HfModelRef::new(repo).map_err(invalid_domain_error)?, - }), - } - } -} - -/// A local model-server config crossing the wire. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ModelServerConfigDto { - /// Stable local server config id. - pub id: String, - /// Server implementation kind. - pub kind: ModelServerKindDto, - /// Display name. - pub name: String, - /// OpenAI-compatible base URL. - #[serde(rename = "baseURL")] - pub base_url: String, - /// TCP port. - pub port: u16, - /// Optional explicit model source. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub model_source: Option, - /// Legacy V1 input alias for `modelSource:{type:"localPath",path}`. - #[serde(default, skip_serializing)] - pub model_path: Option, - /// Served model name exposed to OpenCode. - pub served_model_name: String, - /// Optional explicit `llama-server` executable path. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub binary_path: Option, - /// llama.cpp host passed to `--host`. - #[serde(default = "default_llamacpp_host")] - pub host: String, - /// llama.cpp GPU layers passed to `-ngl`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub gpu_layers: Option, - /// llama.cpp context size passed to `-c`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub context_size: Option, - /// Whether to pass `--jinja`. - #[serde(default)] - pub jinja: bool, - /// Extra argv entries. - #[serde(default)] - pub args: Vec, - /// Whether IdeA should start the server lazily. - pub auto_start: bool, - /// Stop policy. - pub stop_policy: StopPolicyDto, - /// Optional readiness warmup deadline override in seconds. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub warmup_deadline_secs: Option, -} - -impl ModelServerConfigDto { - /// Maps a domain config to the flat IPC DTO. - #[must_use] - pub fn from_domain(config: LocalModelServerConfig) -> Self { - Self { - id: config.id.to_string(), - kind: config.kind.into(), - name: config.name, - base_url: config.endpoint.base_url, - port: config.endpoint.port, - model_source: config.model.source.map(Into::into), - model_path: None, - served_model_name: config.model.served_name, - binary_path: config.binary.map(|binary| binary.as_str().to_owned()), - host: config.options.host, - gpu_layers: config.options.gpu_layers, - context_size: config.options.context_size, - jinja: config.options.jinja, - args: config.args, - auto_start: config.auto_start, - stop_policy: config.stop_policy.into(), - warmup_deadline_secs: config.warmup_deadline_secs, - } - } - - /// Maps the flat IPC DTO into the domain config, preserving internal model id - /// from the existing config when available. - /// - /// # Errors - /// [`ErrorDto`] with `INVALID` code if any domain invariant rejects the input. - pub fn into_domain( - self, - existing: Option<&LocalModelServerConfig>, - ) -> Result { - let server_id = parse_model_server_id(&self.id)?; - let endpoint = ModelServerEndpoint::new(self.base_url.clone(), self.port) - .map_err(invalid_domain_error)?; - let source = resolve_model_source(self.model_source, self.model_path)?; - let model_id = existing - .filter(|config| config.id == server_id) - .map(|config| config.model.id.clone()) - .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); - let label = non_empty_or_fallback(&self.served_model_name, &self.name); - let model = LocalModelRef::new(model_id, label, source, self.served_model_name.clone()) - .map_err(invalid_domain_error)?; - let binary = optional_non_empty(self.binary_path) - .map(ExecutablePath::new) - .transpose() - .map_err(invalid_domain_error)?; - let options = - LlamaCppOptions::new(self.host, self.gpu_layers, self.context_size, self.jinja) - .map_err(invalid_domain_error)?; - LocalModelServerConfig::new( - server_id, - self.kind.into(), - self.name, - endpoint, - model, - binary, - options, - self.args, - self.auto_start, - self.stop_policy.into(), - ) - .and_then(|config| config.with_warmup_deadline_secs(self.warmup_deadline_secs)) - .map_err(invalid_domain_error) - } -} - -/// Response DTO for `preview_model_server_command`. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PreviewModelServerCommandDto { - /// Executable command. - pub command: String, - /// Arguments without shell parsing. - pub args: Vec, - /// Human-readable escaped command line. - pub display: String, -} - -/// List response for local model servers. -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct ModelServerConfigListDto(pub Vec); - -impl From for ModelServerConfigListDto { - fn from(out: ListModelServersOutput) -> Self { - Self( - out.servers - .into_iter() - .map(ModelServerConfigDto::from_domain) - .collect(), - ) - } -} - -/// Request DTO for `save_model_server`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SaveModelServerRequestDto { - /// Config to upsert. - pub config: ModelServerConfigDto, -} - -impl From for ModelServerConfigDto { - fn from(out: SaveModelServerOutput) -> Self { - Self::from_domain(out.config) - } -} - -/// Parses a local model-server id string. -/// -/// # Errors -/// [`ErrorDto`] with `INVALID` code if the string is not a UUID. -pub fn parse_model_server_id(raw: &str) -> Result { - uuid::Uuid::parse_str(raw) - .map(LocalModelServerId::from_uuid) - .map_err(|_| ErrorDto { - code: "INVALID".to_owned(), - message: format!("invalid model server id: {raw}"), - }) -} - -/// Builds a save-model-server input after the caller has resolved the existing -/// config, if any. -/// -/// # Errors -/// [`ErrorDto`] if DTO-to-domain validation fails. -pub fn save_model_server_input( - request: SaveModelServerRequestDto, - existing: Option<&LocalModelServerConfig>, -) -> Result { - Ok(SaveModelServerInput { - config: request.config.into_domain(existing)?, - }) -} - -/// Converts a model-server DTO to domain using the same path as save. -/// -/// # Errors -/// [`ErrorDto`] if DTO-to-domain validation fails. -pub fn model_server_config_domain( - config: ModelServerConfigDto, - existing: Option<&LocalModelServerConfig>, -) -> Result { - config.into_domain(existing) -} - -fn resolve_model_source( - model_source: Option, - model_path: Option, -) -> Result, ErrorDto> { - let legacy_path = optional_non_empty(model_path); - match (model_source, legacy_path) { - (None, None) => Ok(None), - (None, Some(path)) => Ok(Some(ModelSource::LocalPath { - path: ModelPath::new(path).map_err(invalid_domain_error)?, - })), - (Some(source), None) => Ok(Some(source.into_domain()?)), - (Some(source), Some(path)) => { - let domain_source = source.into_domain()?; - match &domain_source { - ModelSource::LocalPath { path: source_path } if source_path.as_str() == path => { - Ok(Some(domain_source)) - } - _ => Err(ErrorDto { - code: "INVALID".to_owned(), - message: "modelPath and modelSource differ".to_owned(), - }), - } - } - } -} - -fn optional_non_empty(raw: Option) -> Option { - raw.map(|value| value.trim().to_owned()) - .filter(|value| !value.is_empty()) -} - -fn default_llamacpp_host() -> String { - "127.0.0.1".to_owned() -} - -fn non_empty_or_fallback(primary: &str, fallback: &str) -> String { - let primary = primary.trim(); - if primary.is_empty() { - fallback.trim().to_owned() - } else { - primary.to_owned() - } -} - -fn invalid_domain_error(error: impl std::fmt::Display) -> ErrorDto { - ErrorDto { - code: "INVALID".to_owned(), - message: error.to_string(), - } -} - -// --------------------------------------------------------------------------- -// Embedder profiles & engines (LOT C2 — §14.5.3) -// --------------------------------------------------------------------------- - -use application::{ - DismissChoice, DismissEmbedderSuggestionInput, EmbedderEnginesView, ListEmbedderProfilesOutput, - OnnxModelView, SaveEmbedderProfileInput, SaveEmbedderProfileOutput, -}; -use domain::profile::EmbedderProfile; - -/// An embedder profile crossing the wire. [`EmbedderProfile`] already serialises -/// camelCase (`id, name, strategy, model?, endpoint?, apiKeyEnv?, dimension`), so we -/// embed it directly — the TS mirror matches this shape. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(transparent)] -pub struct EmbedderProfileDto(pub EmbedderProfile); - -/// A list of embedder profiles (camelCase array on the wire). -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct EmbedderProfileListDto(pub Vec); - -impl From> for EmbedderProfileListDto { - fn from(v: Vec) -> Self { - Self(v.into_iter().map(EmbedderProfileDto).collect()) - } -} - -impl From for EmbedderProfileListDto { - fn from(out: ListEmbedderProfilesOutput) -> Self { - out.profiles.into() - } -} - -impl From for EmbedderProfileDto { - fn from(out: SaveEmbedderProfileOutput) -> Self { - Self(out.profile) - } -} - -/// Request DTO for `save_embedder_profile`: the embedder profile to upsert. -/// -/// Carries the [`EmbedderProfile`] fields directly (the entity validates them in the -/// use case). Deserialised camelCase to match the persisted/domain shape. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SaveEmbedderProfileRequestDto { - /// The embedder profile to upsert. - pub profile: EmbedderProfile, -} - -impl From for SaveEmbedderProfileInput { - fn from(dto: SaveEmbedderProfileRequestDto) -> Self { - let EmbedderProfile { - id, - name, - strategy, - model, - endpoint, - api_key_env, - dimension, - } = dto.profile; - Self { - id, - name, - strategy, - model, - endpoint, - api_key_env, - dimension, - } - } -} - -/// One recommendable local ONNX model on the wire (camelCase). -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct OnnxModelInfoDto { - /// Stable model id accepted by a `localOnnx` profile's `model` field. - pub id: String, - /// Human-readable name for the UI. - pub display_name: String, - /// Length of the vectors this model produces. - pub dimension: usize, - /// Approximate download/disk size in megabytes. - pub approx_size_mb: u32, - /// Whether this is the recommended default model. - pub recommended: bool, -} - -impl From for OnnxModelInfoDto { - fn from(m: OnnxModelView) -> Self { - Self { - id: m.id, - display_name: m.display_name, - dimension: m.dimension, - approx_size_mb: m.approx_size_mb, - recommended: m.recommended, - } - } -} - -/// Response DTO for `describe_embedder_engines` (drives the "configure an embedder?" -/// UI). All-camelCase wire shape. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct EmbedderEnginesDto { - /// The curated catalogue of recommendable local ONNX models. - pub recommended_onnx: Vec, - /// Whether an Ollama-style local embedding server was detected (best-effort). - pub ollama_detected: bool, - /// Ids of the recommended ONNX models already present in the local cache. - pub onnx_cached_models: Vec, - /// Whether the HTTP capability (`localServer`/`api`) is compiled into this binary. - pub vector_http_enabled: bool, - /// Whether the in-process ONNX capability (`localOnnx`) is compiled into this binary. - pub vector_onnx_enabled: bool, -} - -impl From for EmbedderEnginesDto { - fn from(v: EmbedderEnginesView) -> Self { - Self { - recommended_onnx: v.recommended_onnx.into_iter().map(Into::into).collect(), - ollama_detected: v.ollama_detected, - onnx_cached_models: v.onnx_cached_models, - vector_http_enabled: v.vector_http_enabled, - vector_onnx_enabled: v.vector_onnx_enabled, - } - } -} - -// --------------------------------------------------------------------------- -// Embedder suggestion (LOT C3 — §14.5.5) -// --------------------------------------------------------------------------- - -/// The user's response to the embedder suggestion, on the wire (camelCase: -/// `"later"` | `"never"`). -#[derive(Debug, Clone, Copy, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum DismissChoiceDto { - /// "Plus tard" — re-proposable next session. - Later, - /// "Ne plus demander" — never again. - Never, -} - -impl From for DismissChoice { - fn from(c: DismissChoiceDto) -> Self { - match c { - DismissChoiceDto::Later => Self::Later, - DismissChoiceDto::Never => Self::Never, - } - } -} - -/// Request DTO for `dismiss_embedder_suggestion`. The `project_id` is resolved to a -/// project root by the command; `choice` is the user's dismissal. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DismissEmbedderSuggestionRequestDto { - /// The project the suggestion concerned (UUID string). - pub project_id: String, - /// The user's choice. - pub choice: DismissChoiceDto, -} - -impl DismissEmbedderSuggestionRequestDto { - /// Builds the use-case input from a resolved project root + the DTO choice. - #[must_use] - pub fn into_input(self, project_root: domain::ProjectPath) -> DismissEmbedderSuggestionInput { - DismissEmbedderSuggestionInput { - project_root, - choice: self.choice.into(), - } - } -} - -/// Builds a [`DeleteProfileInput`] from a raw profile-id string. -/// -/// # Errors -/// [`ErrorDto`] with code `INVALID` if the id is malformed. -pub fn parse_delete_profile(raw: &str) -> Result { - Ok(DeleteProfileInput { - id: parse_profile_id(raw)?, - }) -} - -/// Parses a profile-id string (UUID) coming from the frontend. -/// -/// # Errors -/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. -pub fn parse_profile_id(raw: &str) -> Result { - uuid::Uuid::parse_str(raw) - .map(ProfileId::from_uuid) - .map_err(|_| ErrorDto { - code: "INVALID".to_owned(), - message: format!("invalid profile id: {raw}"), - }) -} - -// --------------------------------------------------------------------------- -// Agents (L6) -// --------------------------------------------------------------------------- - -use application::{ - ChangeAgentProfileOutput, CreateAgentOutput, InspectConversationOutput, LaunchAgentOutput, - ListAgentsOutput, ReadAgentContextOutput, -}; -use domain::{Agent, EffectivePermissions, PermissionSet, ProjectPermissions, TerminalSession}; - -/// An agent crossing the wire. [`Agent`] already serialises camelCase -/// (`id`, `name`, `contextPath`, `profileId`, `origin` tagged, `synchronized`), -/// so we embed it directly — the TS mirror matches this shape. -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct AgentDto(pub Agent); - -/// A list of agents (camelCase array on the wire). -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct AgentListDto(pub Vec); - -impl From for AgentListDto { - fn from(out: ListAgentsOutput) -> Self { - Self(out.agents.into_iter().map(AgentDto).collect()) - } -} - -impl From for AgentDto { - fn from(out: CreateAgentOutput) -> Self { - Self(out.agent) - } -} - -/// Request DTO for `create_agent`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateAgentRequestDto { - /// Id of the owning project. - pub project_id: String, - /// Display name for the new agent. - pub name: String, - /// Runtime profile id. - pub profile_id: String, - /// Initial Markdown content (empty when absent). - #[serde(default)] - pub initial_content: Option, -} - -/// Response DTO for `read_agent_context`. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ReadAgentContextResponseDto { - /// The agent's Markdown context content. - pub content: String, -} - -impl From for ReadAgentContextResponseDto { - fn from(out: ReadAgentContextOutput) -> Self { - Self { - content: out.content.into_string(), - } - } -} - -/// Request DTO for `update_agent_context`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpdateAgentContextRequestDto { - /// Id of the owning project. - pub project_id: String, - /// Id of the agent to update. - pub agent_id: String, - /// New Markdown content. - pub content: String, -} - -// --------------------------------------------------------------------------- -// Permissions (LP1) -// --------------------------------------------------------------------------- - -/// Full project permission document crossing the wire. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(transparent)] -pub struct ProjectPermissionsDto(pub ProjectPermissions); - -/// Effective permissions crossing the wire. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(transparent)] -pub struct EffectivePermissionsDto(pub EffectivePermissions); - -/// Request DTO for updating project default permissions. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpdateProjectPermissionsRequestDto { - /// Id of the owning project. - pub project_id: String, - /// New project defaults. `null` removes defaults. - pub permissions: Option, -} - -/// Request DTO for updating one agent permission override. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpdateAgentPermissionsRequestDto { - /// Id of the owning project. - pub project_id: String, - /// Target agent id. - pub agent_id: String, - /// New override. `null` removes the override. - pub permissions: Option, -} - -/// Request DTO for resolving one agent's effective permissions. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ResolveAgentPermissionsRequestDto { - /// Id of the owning project. - pub project_id: String, - /// Target agent id. - pub agent_id: String, -} - -/// Request DTO for `update_project_context`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpdateProjectContextRequestDto { - /// Id of the owning project. - pub project_id: String, - /// New project-level Markdown context, stored under `.ideai/CONTEXT.md`. - pub content: String, -} - -/// Request DTO for `launch_agent`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct LaunchAgentRequestDto { - /// Id of the owning project. - pub project_id: String, - /// Id of the agent to launch. - pub agent_id: String, - /// Initial terminal height in rows. - pub rows: u16, - /// Initial terminal width in columns. - pub cols: u16, - /// Persistent CLI conversation id currently recorded on the hosting cell, if - /// any. `Some` ⇒ the launch resumes it; absent/`None` ⇒ a fresh cell (the - /// launch may assign a new id when the profile supports it). - #[serde(default)] - pub conversation_id: Option, - /// The layout leaf (node) hosting this launch. Enforces the "one live session - /// per agent" invariant: a launch into a node different from where the agent - /// is already running is refused (`AGENT_ALREADY_RUNNING`); the same node is - /// idempotent. Absent ⇒ a fresh node is minted (and any already-live agent is - /// refused). - #[serde(default)] - pub node_id: Option, -} - -impl From for TerminalSessionDto { - fn from(out: LaunchAgentOutput) -> Self { - let assigned_conversation_id = out.assigned_conversation_id; - let engine_session_id = out.engine_session_id; - // §17.6: the cell kind is *derived* from the launch routing. A structured - // descriptor (`structured: Some(..)`) means LaunchAgent routed to an - // AgentSession ⇒ chat cell; its absence means the PTY/terminal path ⇒ - // terminal cell. Single source of truth, no layout migration. - let cell_kind = if out.structured.is_some() { - CellKind::Chat - } else { - CellKind::Pty - }; - let s = out.session; - Self { - session_id: s.id.to_string(), - cwd: s.cwd.as_str().to_owned(), - rows: s.pty_size.rows, - cols: s.pty_size.cols, - assigned_conversation_id, - engine_session_id, - cell_kind, - } - } -} - -/// Request DTO for `interrupt_agent` (cadrage C4 §4.2): the Interrompre path. The -/// frontend sends `{ request: { projectId, agentId } }`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct InterruptAgentRequestDto { - /// Id of the owning project. - pub project_id: String, - /// Id of the agent whose running turn to preempt. - pub agent_id: String, -} - -/// Request DTO for `delegation_delivered` (ARCHITECTURE §20.3): the frontend write- -/// portal acks that it **physically wrote** a delegation `ticket` into the agent's -/// native PTY. Best-effort observability — it never changes correlation (the `ask` is -/// still woken by `idea_reply`). The frontend sends `{ request: { projectId, agentId, -/// ticket } }`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DeliveredDelegationRequestDto { - /// Id of the owning project. - pub project_id: String, - /// Id of the agent whose terminal received the delegation. - pub agent_id: String, - /// Id of the delivered mailbox ticket. - pub ticket: String, -} - -/// Request DTO for `set_front_attached`: the write-portal of an agent cell reports -/// whether a **frontend terminal cell is mounted** for `agentId` (`true` on mount, -/// `false` on unmount). The mediator uses it to choose, at delivery time, between -/// publishing `DelegationReady` (a cell will write it) and writing the turn into the -/// PTY itself (headless/background-delegated agent with no cell). The frontend sends -/// `{ request: { agentId, attached } }`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct FrontAttachedRequestDto { - /// Id of the agent whose terminal cell mounted/unmounted. - pub agent_id: String, - /// `true` when the cell's write-portal is now active, `false` on teardown. - pub attached: bool, -} - -/// Request DTO for `change_agent_profile` (§15.1): hot-swap an agent's runtime -/// profile, optionally relaunching its live session in place. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ChangeAgentProfileRequestDto { - /// Id of the owning project. - pub project_id: String, - /// Id of the agent whose runtime profile to change. - pub agent_id: String, - /// Id of the new runtime profile. - pub profile_id: String, - /// Terminal height in rows for a possible hot relaunch. - pub rows: u16, - /// Terminal width in columns for a possible hot relaunch. - pub cols: u16, -} - -/// Response DTO for `change_agent_profile`: the mutated agent plus the freshly -/// relaunched session when a live session was hot-swapped (absent otherwise). -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ChangeAgentProfileDto { - /// The agent now carrying the new profile. - pub agent: AgentDto, - /// The relaunched session, present only when a live session was swapped. - #[serde(skip_serializing_if = "Option::is_none")] - pub relaunched_session: Option, -} - -impl From for ChangeAgentProfileDto { - fn from(out: ChangeAgentProfileOutput) -> Self { - Self { - agent: AgentDto(out.agent), - relaunched_session: out.relaunched.map(TerminalSessionDto::from), - } - } -} - -impl From for TerminalSessionDto { - fn from(s: TerminalSession) -> Self { - Self { - session_id: s.id.to_string(), - cwd: s.cwd.as_str().to_owned(), - rows: s.pty_size.rows, - cols: s.pty_size.cols, - assigned_conversation_id: None, - engine_session_id: None, - cell_kind: CellKind::Pty, - } - } -} - -// --------------------------------------------------------------------------- -// Structured chat sessions (§17 — D4) -// --------------------------------------------------------------------------- - -/// One incremental chunk of a structured agent reply, streamed over the chat -/// session's [`tauri::ipc::Channel`] (ARCHITECTURE §17.7). The serialised wire -/// twin of a [`domain::ports::ReplyEvent`]: the `agent_send` pump maps each turn -/// event to one of these and pushes it to the frontend `AgentChatView`. -/// -/// Tagged on `kind` (camelCase: `"textDelta"` | `"toolActivity"` | `"final"` | -/// `"error"`), so the front branches without positional parsing. `Final` is the -/// normal deterministic terminal chunk of a turn; `Error` is a visible terminal -/// fallback for an otherwise silent/empty turn. `Deserialize` is derived too so -/// tests (and a mock gateway) can round-trip it. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "camelCase")] -pub enum ReplyChunk { - /// An assistant text fragment (incremental chat rendering). - #[serde(rename_all = "camelCase")] - TextDelta { - /// The text fragment. - text: String, - }, - /// A human-readable tool-activity badge (best-effort observability). - #[serde(rename_all = "camelCase")] - ToolActivity { - /// The human-readable activity label. - label: String, - }, - /// The deterministic end-of-turn chunk carrying the aggregated final content. - #[serde(rename_all = "camelCase")] - Final { - /// The aggregated final content of the turn. - content: String, - }, - /// A visible terminal fallback when the model stream produced no usable answer. - #[serde(rename_all = "camelCase")] - Error { - /// Human-readable explanation to render in the chat cell. - message: String, - }, -} - -/// Response DTO for `reattach_agent_chat`: the retained **conversation -/// scrollback** of a still-live structured session, replayed into the -/// re-mounting `AgentChatView` before the new reply stream is wired (§17.6). The -/// typed twin of [`ReattachResultDto`] (PTY scrollback bytes) — here the -/// scrollback is the ordered list of [`ReplyChunk`]s already streamed. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ReattachChatDto { - /// The session that was re-attached (echoed back for the frontend). - pub session_id: String, - /// The chunks already streamed for this conversation, in order. The frontend - /// replays them to rebuild the visible turns, then receives subsequent chunks - /// over the freshly-registered channel. - pub scrollback: Vec, -} - -/// Request DTO for `inspect_conversation` (T7). -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct InspectConversationRequestDto { - /// Id of the owning project. - pub project_id: String, - /// Id of the agent whose conversation is inspected. - pub agent_id: String, - /// The conversation id recorded on the hosting cell. - pub conversation_id: String, -} - -/// Response DTO for `inspect_conversation` (T7): the best-effort enriched -/// details for a resume popup. Both fields are optional and **omitted from the -/// wire when `None`** (`skip_serializing_if`), so the TypeScript side sees an -/// absent key — not `null` — for a degraded inspection. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ConversationDetailsDto { - /// A short, best-effort label for the conversation (last user message, - /// truncated). Absent when it could not be extracted. - #[serde(skip_serializing_if = "Option::is_none")] - pub last_topic: Option, - /// A best-effort cumulative token count. Absent when no usage info exists. - #[serde(skip_serializing_if = "Option::is_none")] - pub token_count: Option, -} - -impl From for ConversationDetailsDto { - fn from(out: InspectConversationOutput) -> Self { - Self { - last_topic: out.details.last_topic, - token_count: out.details.token_count, - } - } -} - -/// One currently-live agent and the cell hosting it, for `list_live_agents`. -/// -/// Lets the UI disable an agent already running in another cell (it cannot be -/// launched a second time — the "one live session per agent" invariant). -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct LiveAgentDto { - /// The live agent's id (UUID string). - pub agent_id: String, - /// The hosting layout leaf's node id (UUID string). - pub node_id: String, - /// The live PTY session id, used to reattach a newly-opened cell without - /// respawning the agent. - pub session_id: String, - /// Runtime family that owns the session (`pty`/`structured`). - pub kind: LiveWorkSessionKindDto, -} - -/// Response DTO for `list_live_agents` (transparent array on the wire). -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct LiveAgentListDto(pub Vec); - -impl LiveAgentListDto { - /// Builds the wire list from the registry's live-session snapshots. - /// - /// De-duplicates by `agent_id`: the "one live session per agent" invariant - /// guarantees an agent is live in at most one registry (PTY **or** - /// structured), but the aggregated input could in theory carry the same agent - /// twice; the UI contract is a dup-free set (each agent appears once), so we - /// keep the first occurrence and drop any later one for the same agent. - #[must_use] - pub fn from_snapshots(pairs: Vec) -> Self { - let mut seen = std::collections::HashSet::new(); - Self( - pairs - .into_iter() - .filter(|snapshot| seen.insert(snapshot.agent_id)) - .map(|snapshot| LiveAgentDto { - agent_id: snapshot.agent_id.to_string(), - node_id: snapshot.node_id.to_string(), - session_id: snapshot.session_id.to_string(), - kind: snapshot.kind.into(), - }) - .collect(), - ) - } -} - -/// Runtime family of a live work-state session. -#[derive(Debug, Clone, Copy, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum LiveWorkSessionKindDto { - /// Raw PTY-backed CLI session. - Pty, - /// Structured agent-session backend. - Structured, -} - -impl From for LiveWorkSessionKindDto { - fn from(kind: LiveSessionKind) -> Self { - match kind { - LiveSessionKind::Pty => Self::Pty, - LiveSessionKind::Structured => Self::Structured, - } - } -} - -/// Live session coordinates in the project work-state read model. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct LiveWorkSessionDto { - /// The layout node currently hosting the session view. - pub node_id: String, - /// The live session id. - pub session_id: String, - /// Runtime family that owns the session. - pub kind: LiveWorkSessionKindDto, -} - -/// Derived processing status of a queued ticket. -#[derive(Debug, Clone, Copy, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum TicketWorkStatusDto { - /// The agent's current busy turn is running this ticket. - InProgress, - /// Waiting behind the head / the agent is idle. - Queued, -} - -impl From for TicketWorkStatusDto { - fn from(status: TicketWorkStatus) -> Self { - match status { - TicketWorkStatus::InProgress => Self::InProgress, - TicketWorkStatus::Queued => Self::Queued, - } - } -} - -/// Origin of a queued ticket (human operator or a delegating agent). -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase", tag = "kind")] -pub enum TicketWorkSourceDto { - /// The human operator. - Human, - /// Another agent delegating via `idea_ask_agent`. - #[serde(rename_all = "camelCase")] - Agent { - /// The delegating agent id. - agent_id: String, - }, -} - -impl From for TicketWorkSourceDto { - fn from(source: TicketWorkSource) -> Self { - match source { - TicketWorkSource::Human => Self::Human, - TicketWorkSource::Agent { agent_id } => Self::Agent { - agent_id: agent_id.to_string(), - }, - } - } -} - -/// One queued/in-progress delegation ticket for an agent. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentTicketStateDto { - /// Stable id of the queued ticket. - pub ticket_id: String, - /// Conversation thread this task enters. - pub conversation_id: String, - /// FIFO position at snapshot time (`0` = head). - pub position: u32, - /// Derived status (in-progress vs queued). - pub status: TicketWorkStatusDto, - /// Origin of the ticket. - pub source: TicketWorkSourceDto, - /// Display label of the requester. - pub requester_label: String, - /// Bounded excerpt of the task. - pub task_preview: String, - /// Character length of the original (un-truncated) task. - pub task_len: usize, -} - -impl From for AgentTicketStateDto { - fn from(ticket: AgentTicketState) -> Self { - Self { - ticket_id: ticket.ticket_id.to_string(), - conversation_id: ticket.conversation_id.to_string(), - position: ticket.position, - status: ticket.status.into(), - source: ticket.source.into(), - requester_label: ticket.requester_label, - task_preview: ticket.task_preview, - task_len: ticket.task_len, - } - } -} - -/// One background task owned by an agent in the Work panel read model. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentBackgroundTaskStateDto { - /// Stable task id (UUID string). - pub task_id: String, - /// Kind discriminant. - pub kind: String, - /// Lifecycle state. - pub state: String, - /// Process exit code, when the terminal result carries one. - #[serde(skip_serializing_if = "Option::is_none")] - pub exit_code: Option, - /// Human-readable summary / error / reason of the terminal result. - #[serde(skip_serializing_if = "Option::is_none")] - pub summary: Option, - /// Bounded stdout tail. - #[serde(skip_serializing_if = "Option::is_none")] - pub stdout_tail: Option, - /// Bounded stderr tail. - #[serde(skip_serializing_if = "Option::is_none")] - pub stderr_tail: Option, - /// Creation timestamp, epoch milliseconds. - pub created_at_ms: u64, - /// Last update timestamp, epoch milliseconds. - pub updated_at_ms: u64, -} - -impl From for AgentBackgroundTaskStateDto { - fn from(task: AgentBackgroundTaskState) -> Self { - Self { - task_id: task.task_id.to_string(), - kind: background_kind_label_from_work_state(task.kind).to_owned(), - state: background_state_label(task.state).to_owned(), - exit_code: task.exit_code, - summary: task.summary, - stdout_tail: task.stdout_tail, - stderr_tail: task.stderr_tail, - created_at_ms: task.created_at_ms, - updated_at_ms: task.updated_at_ms, - } - } -} - -/// One manifest agent's current live/busy state. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentWorkStateDto { - /// Agent id. - pub agent_id: String, - /// Agent display name. - pub name: String, - /// Runtime profile id assigned to the agent. - pub profile_id: String, - /// Live session, if any. - pub live: Option, - /// Current mediated-input busy state. - pub busy: AgentBusyState, - /// Pending/in-progress delegation tickets, in FIFO order. - pub tickets: Vec, - /// Best-effort first-class background tasks owned by this agent. - pub background_tasks: Vec, -} - -/// How much of a [`ConversationWorkSummaryDto`] could be derived, best-effort. -#[derive(Debug, Clone, Copy, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum ConversationPreviewStatusDto { - /// A handoff was present and usable. - Ready, - /// No handoff yet; `recentTurns` may carry the log fallback. - Missing, - /// The handoff was unreadable but the log fallback was readable. - Partial, - /// Neither the handoff nor the log could be read. - Unavailable, -} - -impl From for ConversationPreviewStatusDto { - fn from(status: ConversationPreviewStatus) -> Self { - match status { - ConversationPreviewStatus::Ready => Self::Ready, - ConversationPreviewStatus::Missing => Self::Missing, - ConversationPreviewStatus::Partial => Self::Partial, - ConversationPreviewStatus::Unavailable => Self::Unavailable, - } - } -} - -/// One recent turn surfaced in a conversation summary's log fallback. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ConversationTurnWorkPreviewDto { - /// Nature of the turn (`prompt`/`response`/`toolActivity`). - pub role: TurnRole, - /// Origin of the turn (human operator or a delegating agent). - pub source: TicketWorkSourceDto, - /// Timestamp (epoch milliseconds) of the turn. - pub at_ms: u64, - /// Bounded excerpt of the turn text. - pub text_preview: String, - /// Character length of the original (un-truncated) turn text. - pub text_len: usize, -} - -impl From for ConversationTurnWorkPreviewDto { - fn from(turn: ConversationTurnWorkPreview) -> Self { - Self { - role: turn.role, - source: turn.source.into(), - at_ms: turn.at_ms, - text_preview: turn.text_preview, - text_len: turn.text_len, - } - } -} - -/// Best-effort, read-only summary of one conversation visible through the tickets. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ConversationWorkSummaryDto { - /// The conversation (pair) this summary describes. - pub conversation_id: String, - /// How much could be derived. - pub status: ConversationPreviewStatusDto, - /// Bounded excerpt of the handoff objective, when present. - pub objective_preview: Option, - /// Bounded excerpt of the handoff summary, when present. - pub summary_preview: Option, - /// Character length of the original (un-truncated) handoff summary (`0` when none). - pub summary_len: usize, - /// Cursor (last turn id) covered by the handoff summary, when present. - pub up_to: Option, - /// Bounded, recent turns from the log fallback. - pub recent_turns: Vec, -} - -impl From for ConversationWorkSummaryDto { - fn from(summary: ConversationWorkSummary) -> Self { - Self { - conversation_id: summary.conversation_id.to_string(), - status: summary.status.into(), - objective_preview: summary.objective_preview, - summary_preview: summary.summary_preview, - summary_len: summary.summary_len, - up_to: summary.up_to.map(|cursor| cursor.to_string()), - recent_turns: summary - .recent_turns - .into_iter() - .map(ConversationTurnWorkPreviewDto::from) - .collect(), - } - } -} - -/// Project-level read model for conversation/delegation UX. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ProjectWorkStateDto { - /// Manifest agents in manifest order, enriched with live/busy state. - pub agents: Vec, - /// Best-effort summaries of the conversations referenced by the tickets, - /// joined frontend-side via `tickets[].conversationId`. - pub conversations: Vec, -} - -impl From for ProjectWorkStateDto { - fn from(state: ProjectWorkState) -> Self { - Self { - agents: state - .agents - .into_iter() - .map(|agent| AgentWorkStateDto { - agent_id: agent.agent_id.to_string(), - name: agent.name, - profile_id: agent.profile_id.to_string(), - live: agent.live.map(|live| LiveWorkSessionDto { - node_id: live.node_id.to_string(), - session_id: live.session_id.to_string(), - kind: live.kind.into(), - }), - busy: agent.busy, - tickets: agent - .tickets - .into_iter() - .map(AgentTicketStateDto::from) - .collect(), - background_tasks: agent - .background_tasks - .into_iter() - .map(AgentBackgroundTaskStateDto::from) - .collect(), - }) - .collect(), - conversations: state - .conversations - .into_iter() - .map(ConversationWorkSummaryDto::from) - .collect(), - } - } -} - -/// Request DTO for `attach_live_agent`: bind an already-running agent session to -/// a visible layout cell without spawning a new process. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AttachLiveAgentRequestDto { - /// Id of the owning project (validated for symmetry and future scoping). - pub project_id: String, - /// Id of the already-running agent. - pub agent_id: String, - /// Layout leaf that should display the live session. - pub node_id: String, -} - -/// Response DTO for `attach_live_agent`: the rebound live session's coordinates. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AttachLiveAgentResponseDto { - /// The rebound agent's id. - pub agent_id: String, - /// The node now hosting the session view. - pub node_id: String, - /// The (unchanged) live session id. - pub session_id: String, - /// Runtime family that owns the session (`pty`/`structured`). - pub kind: LiveWorkSessionKindDto, -} - -impl From for AttachLiveAgentResponseDto { - fn from(out: AttachLiveAgentOutput) -> Self { - Self { - agent_id: out.agent_id.to_string(), - node_id: out.node_id.to_string(), - session_id: out.session_id.to_string(), - kind: out.kind.into(), - } - } -} - -/// Request DTO for `stop_live_agent`: tear down an already-running agent's live -/// session by agent id (no spawn, agent entity preserved). -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct StopLiveAgentRequestDto { - /// Id of the owning project (validated for symmetry and future scoping). - pub project_id: String, - /// Id of the running agent to stop. - pub agent_id: String, -} - -/// Response DTO for `stop_live_agent`: the torn-down session's coordinates. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct StopLiveAgentResponseDto { - /// The stopped agent's id. - pub agent_id: String, - /// The id of the session that was torn down. - pub session_id: String, - /// Runtime family that owned the session (`pty`/`structured`). - pub kind: LiveWorkSessionKindDto, -} - -impl From for StopLiveAgentResponseDto { - fn from(out: StopLiveAgentOutput) -> Self { - Self { - agent_id: out.agent_id.to_string(), - session_id: out.session_id.to_string(), - kind: out.kind.into(), - } - } -} - -/// Parses an agent-id string (UUID) coming from the frontend. -/// -/// # Errors -/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. -pub fn parse_agent_id(raw: &str) -> Result { - uuid::Uuid::parse_str(raw) - .map(AgentId::from_uuid) - .map_err(|_| ErrorDto { - code: "INVALID".to_owned(), - message: format!("invalid agent id: {raw}"), - }) -} - -/// Parses a ticket-id string (UUID) coming from the frontend (`delegation_delivered`). -/// -/// # Errors -/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. -pub fn parse_ticket_id(raw: &str) -> Result { - uuid::Uuid::parse_str(raw) - .map(domain::TicketId::from_uuid) - .map_err(|_| ErrorDto { - code: "INVALID".to_owned(), - message: format!("invalid ticket id: {raw}"), - }) -} - -// --------------------------------------------------------------------------- -// Resumable agents (§15.2 — Chantier B2) -// --------------------------------------------------------------------------- - -use application::{ListResumableAgentsOutput, ResumableAgent}; - -/// One resumable agent cell, as seen by the frontend (§15.2). -/// -/// Mirrors [`ResumableAgent`]: the agent's identity + its host cell, the CLI -/// conversation id to resume (absent ⇒ fresh relaunch), the `was_running` flag -/// frozen at close, and whether the agent's profile can resume a CLI -/// conversation. `conversationId` is **omitted from the wire when `None`** -/// (`skip_serializing_if`), so the TypeScript side sees an absent key — not -/// `null`. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ResumableAgentDto { - /// The resumable agent's id (UUID string). - pub agent_id: String, - /// The agent's display name (resolved from the manifest). - pub name: String, - /// The host layout leaf where the agent is relaunched/resumed (UUID string). - pub node_id: String, - /// Persistent CLI conversation id carried by the cell. Absent ⇒ fresh - /// relaunch (no history to resume). - #[serde(skip_serializing_if = "Option::is_none")] - pub conversation_id: Option, - /// The `agent_was_running` flag frozen at the cell's close. - pub was_running: bool, - /// `true` when the agent's profile carries a usable resume strategy. - pub resume_supported: bool, -} - -impl From for ResumableAgentDto { - fn from(r: ResumableAgent) -> Self { - Self { - agent_id: r.agent_id.to_string(), - name: r.name, - node_id: r.node_id.to_string(), - conversation_id: r.conversation_id, - was_running: r.was_running, - resume_supported: r.resume_supported, - } - } -} - -/// Response DTO for `list_resumable_agents` (§15.2). -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ResumableAgentListDto { - /// The resumable agent cells, in layout-traversal order. - pub resumable: Vec, -} - -impl From for ResumableAgentListDto { - fn from(out: ListResumableAgentsOutput) -> Self { - Self { - resumable: out - .resumable - .into_iter() - .map(ResumableAgentDto::from) - .collect(), - } - } -} - -// --------------------------------------------------------------------------- -// Templates & sync (L7) -// --------------------------------------------------------------------------- - -use application::{ - AgentDrift, CreateAgentFromTemplateInput, CreateTemplateInput, CreateTemplateOutput, - DetectAgentDriftOutput, ListTemplatesOutput, SyncAgentWithTemplateOutput, UpdateTemplateInput, - UpdateTemplateOutput, -}; -use domain::{AgentTemplate, TemplateId}; - -/// A template crossing the wire. [`AgentTemplate`] already serialises camelCase -/// (`id`, `name`, `contentMd`, `version` as a number, `defaultProfileId`), -/// so we embed it directly — the TS mirror matches this shape. -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct TemplateDto(pub AgentTemplate); - -/// A list of templates (transparent array on the wire). -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct TemplateListDto(pub Vec); - -impl From for TemplateListDto { - fn from(out: ListTemplatesOutput) -> Self { - Self(out.templates.into_iter().map(TemplateDto).collect()) - } -} - -impl From for TemplateDto { - fn from(out: CreateTemplateOutput) -> Self { - Self(out.template) - } -} - -impl From for TemplateDto { - fn from(out: UpdateTemplateOutput) -> Self { - Self(out.template) - } -} - -/// Request DTO for `create_template`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateTemplateRequestDto { - /// Display name. - pub name: String, - /// Initial Markdown content. - pub content: String, - /// Default runtime profile id for agents created from this template. - pub default_profile_id: String, -} - -impl CreateTemplateRequestDto { - /// Converts to the use-case input, parsing the profile id. - /// - /// # Errors - /// [`ErrorDto`] with code `INVALID` if the profile id is malformed. - pub fn into_input(self) -> Result { - Ok(CreateTemplateInput { - name: self.name, - content: self.content, - default_profile_id: parse_profile_id(&self.default_profile_id)?, - }) - } -} - -/// Request DTO for `update_template`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpdateTemplateRequestDto { - /// Id of the template to update. - pub template_id: String, - /// New Markdown content. - pub content: String, -} - -impl UpdateTemplateRequestDto { - /// Converts to the use-case input, parsing the template id. - /// - /// # Errors - /// [`ErrorDto`] with code `INVALID` if the template id is malformed. - pub fn into_input(self) -> Result { - Ok(UpdateTemplateInput { - template_id: parse_template_id(&self.template_id)?, - content: self.content, - }) - } -} - -/// Parses a template-id string (UUID) coming from the frontend. -/// -/// # Errors -/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. -pub fn parse_template_id(raw: &str) -> Result { - uuid::Uuid::parse_str(raw) - .map(TemplateId::from_uuid) - .map_err(|_| ErrorDto { - code: "INVALID".to_owned(), - message: format!("invalid template id: {raw}"), - }) -} - -/// Request DTO for `create_agent_from_template`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateAgentFromTemplateRequestDto { - /// Id of the owning project. - pub project_id: String, - /// Source template id. - pub template_id: String, - /// Optional agent name; defaults to the template's name when absent. - #[serde(default)] - pub name: Option, - /// Whether the agent tracks the template for future syncs. - pub synchronized: bool, -} - -impl CreateAgentFromTemplateRequestDto { - /// Converts to the use-case input, given the resolved project. - /// - /// # Errors - /// [`ErrorDto`] with code `INVALID` if the template id is malformed. - pub fn into_input( - self, - project: domain::Project, - ) -> Result { - Ok(CreateAgentFromTemplateInput { - project, - template_id: parse_template_id(&self.template_id)?, - name: self.name, - synchronized: self.synchronized, - }) - } -} - -/// One drifting agent, as seen by the frontend. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentDriftDto { - /// The drifting agent id (UUID string). - pub agent_id: String, - /// Version the agent is currently synced to. - pub from: u64, - /// Version available from the template. - pub to: u64, -} - -impl From for AgentDriftDto { - fn from(d: AgentDrift) -> Self { - Self { - agent_id: d.agent_id.to_string(), - from: d.from.get(), - to: d.to.get(), - } - } -} - -/// Response DTO for `detect_agent_drift` (transparent array on the wire). -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct AgentDriftListDto(pub Vec); - -impl From for AgentDriftListDto { - fn from(out: DetectAgentDriftOutput) -> Self { - Self(out.drifts.into_iter().map(AgentDriftDto::from).collect()) - } -} - -/// Response DTO for `sync_agent_with_template`. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SyncResultDto { - /// Whether a sync was actually applied. - pub synced: bool, - /// The version the agent is now at (`null` when no sync happened). - pub version: Option, -} - -impl From for SyncResultDto { - fn from(out: SyncAgentWithTemplateOutput) -> Self { - Self { - synced: out.synced, - version: out.version.map(|v| v.get()), - } - } -} - -/// Request DTO for `sync_agent_with_template`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SyncAgentWithTemplateRequestDto { - /// Id of the owning project. - pub project_id: String, - /// Id of the agent to sync. - pub agent_id: String, -} - -// --------------------------------------------------------------------------- -// Git (L8) -// --------------------------------------------------------------------------- - -use application::{GitBranchesOutput, GitCommitOutput, GitLogOutput, GitStatusOutput}; -use domain::ports::{GitCommitInfo, GitFileStatus, GraphCommit}; - -/// One changed path returned by `git_status`. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct GitFileStatusDto { - /// Repo-relative path. - pub path: String, - /// Whether the change is staged. - pub staged: bool, -} - -impl From for GitFileStatusDto { - fn from(s: GitFileStatus) -> Self { - Self { - path: s.path, - staged: s.staged, - } - } -} - -/// Response DTO for `git_status` (transparent array on the wire). -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct GitStatusListDto(pub Vec); - -impl From for GitStatusListDto { - fn from(out: GitStatusOutput) -> Self { - Self( - out.entries - .into_iter() - .map(GitFileStatusDto::from) - .collect(), - ) - } -} - -/// A single commit summary crossing the wire. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct GitCommitDto { - /// Commit hash. - pub hash: String, - /// Commit message summary. - pub summary: String, -} - -impl From for GitCommitDto { - fn from(c: GitCommitInfo) -> Self { - Self { - hash: c.hash, - summary: c.summary, - } - } -} - -impl From for GitCommitDto { - fn from(out: GitCommitOutput) -> Self { - Self::from(out.commit) - } -} - -/// Response DTO for `git_log` (transparent array on the wire). -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct GitCommitListDto(pub Vec); - -impl From for GitCommitListDto { - fn from(out: GitLogOutput) -> Self { - Self(out.commits.into_iter().map(GitCommitDto::from).collect()) - } -} - -/// Response DTO for `git_branches`. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct GitBranchesDto { - /// All local branches. - pub branches: Vec, - /// The current branch (`null` when detached or unborn). - pub current: Option, -} - -impl From for GitBranchesDto { - fn from(out: GitBranchesOutput) -> Self { - Self { - branches: out.branches, - current: out.current, - } - } -} - -/// A single commit enriched for graph display. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct GraphCommitDto { - /// Full commit hash. - pub hash: String, - /// First line of the commit message. - pub summary: String, - /// Parent commit hashes. - pub parents: Vec, - /// Ref labels pointing at this commit (e.g. `"main"`, `"tag: v1.0"`). - pub refs: Vec, - /// Author name. - pub author: String, - /// Author timestamp in Unix seconds. - pub timestamp: i64, -} - -impl From for GraphCommitDto { - fn from(c: GraphCommit) -> Self { - Self { - hash: c.hash, - summary: c.summary, - parents: c.parents, - refs: c.refs, - author: c.author, - timestamp: c.timestamp, - } - } -} - -/// Response DTO for `git_graph` (transparent array on the wire). -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct GraphCommitListDto(pub Vec); - -impl From for GraphCommitListDto { - fn from(out: GitGraphOutput) -> Self { - Self(out.commits.into_iter().map(GraphCommitDto::from).collect()) - } -} - -/// Request DTO for `git_stage` / `git_unstage`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GitStageRequestDto { - /// Id of the owning project. - pub project_id: String, - /// Repo-relative path to (un)stage. - pub path: String, -} - -/// Request DTO for `git_commit`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GitCommitRequestDto { - /// Id of the owning project. - pub project_id: String, - /// Commit message. - pub message: String, -} - -/// Request DTO for `git_checkout`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GitCheckoutRequestDto { - /// Id of the owning project. - pub project_id: String, - /// Branch to check out. - pub branch: String, -} - -// --------------------------------------------------------------------------- -// Windows (L10) -// --------------------------------------------------------------------------- - -use application::MoveTabToNewWindowOutput; -use domain::ids::TabId; - -/// Response DTO for `move_tab_to_new_window`: the id minted for the new window -/// (used as the new OS window's label). -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct MoveTabResultDto { - /// The new window id (UUID string). - pub new_window_id: String, -} - -impl From for MoveTabResultDto { - fn from(out: MoveTabToNewWindowOutput) -> Self { - Self { - new_window_id: out.new_window_id.to_string(), - } - } -} - -/// Parses a tab-id string (UUID) coming from the frontend. -/// -/// # Errors -/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. -pub fn parse_tab_id(raw: &str) -> Result { - uuid::Uuid::parse_str(raw) - .map(TabId::from_uuid) - .map_err(|_| ErrorDto { - code: "INVALID".to_owned(), - message: format!("invalid tab id: {raw}"), - }) -} - -// --------------------------------------------------------------------------- -// Skills (L12) -// --------------------------------------------------------------------------- - -use application::{CreateSkillOutput, ListSkillsOutput, UpdateSkillOutput}; -use domain::{Skill, SkillId, SkillScope}; - -/// A skill crossing the wire. [`Skill`] already serialises camelCase -/// (`id`, `name`, `contentMd`, `scope` as `"global"`/`"project"`), so we embed -/// it directly — the TS mirror matches this shape. -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct SkillDto(pub Skill); - -/// A list of skills (transparent array on the wire). -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct SkillListDto(pub Vec); - -impl From for SkillListDto { - fn from(out: ListSkillsOutput) -> Self { - Self(out.skills.into_iter().map(SkillDto).collect()) - } -} - -impl From for SkillDto { - fn from(out: CreateSkillOutput) -> Self { - Self(out.skill) - } -} - -impl From for SkillDto { - fn from(out: UpdateSkillOutput) -> Self { - Self(out.skill) - } -} - -/// Request DTO for `create_skill`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateSkillRequestDto { - /// Owning project (resolved to a root; ignored on disk for `Global`). - pub project_id: String, - /// Display name. - pub name: String, - /// Initial Markdown content. - pub content: String, - /// Scope the skill is created in. - pub scope: SkillScope, -} - -/// Request DTO for `update_skill`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpdateSkillRequestDto { - /// Owning project (resolved to a root; ignored on disk for `Global`). - pub project_id: String, - /// Id of the skill to update. - pub skill_id: String, - /// Scope the skill lives in. - pub scope: SkillScope, - /// New Markdown content. - pub content: String, -} - -/// Request DTO for `assign_skill_to_agent`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AssignSkillRequestDto { - /// Owning project. - pub project_id: String, - /// Agent receiving the skill. - pub agent_id: String, - /// Skill to assign. - pub skill_id: String, - /// Scope of the skill (recorded alongside the ref). - pub scope: SkillScope, -} - -/// Request DTO for `unassign_skill_from_agent`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UnassignSkillRequestDto { - /// Owning project. - pub project_id: String, - /// Agent losing the skill. - pub agent_id: String, - /// Skill to unassign. - pub skill_id: String, -} - -/// Parses a skill-id string (UUID) coming from the frontend. -/// -/// # Errors -/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. -pub fn parse_skill_id(raw: &str) -> Result { - uuid::Uuid::parse_str(raw) - .map(SkillId::from_uuid) - .map_err(|_| ErrorDto { - code: "INVALID".to_owned(), - message: format!("invalid skill id: {raw}"), - }) -} - -// --------------------------------------------------------------------------- -// Memory (LOT A — §14.5.1) -// --------------------------------------------------------------------------- - -use application::{ - CreateMemoryOutput, GetMemoryOutput, ListMemoriesOutput, ReadMemoryIndexOutput, - RecallMemoryOutput, ResolveMemoryLinksOutput, UpdateMemoryOutput, -}; -use domain::{Memory, MemoryIndexEntry, MemorySlug, MemoryType}; - -/// A memory note crossing the wire. -/// -/// Built explicitly from [`Memory`] (its `body` is [`domain::MarkdownDoc`], not -/// directly a JSON string) so the TS mirror gets a flat camelCase shape. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct MemoryDto { - /// The note's slug (its identity, also the file stem). - pub name: String, - /// One-line description (the index hook). - pub description: String, - /// The note's kind. - pub r#type: MemoryType, - /// Markdown body of the note. - pub content: String, -} - -impl From for MemoryDto { - fn from(memory: Memory) -> Self { - Self { - name: memory.frontmatter.name.as_str().to_owned(), - description: memory.frontmatter.description, - r#type: memory.frontmatter.r#type, - content: memory.body.into_string(), - } - } -} - -impl From for MemoryDto { - fn from(out: CreateMemoryOutput) -> Self { - Self::from(out.memory) - } -} - -impl From for MemoryDto { - fn from(out: UpdateMemoryOutput) -> Self { - Self::from(out.memory) - } -} - -impl From for MemoryDto { - fn from(out: GetMemoryOutput) -> Self { - Self::from(out.memory) - } -} - -/// A list of memory notes (transparent array on the wire). -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct MemoryListDto(pub Vec); - -impl From for MemoryListDto { - fn from(out: ListMemoriesOutput) -> Self { - Self(out.memories.into_iter().map(MemoryDto::from).collect()) - } -} - -/// One row of the structured `MEMORY.md` index crossing the wire. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct MemoryIndexEntryDto { - /// The note's slug. - pub slug: String, - /// The note's display title. - pub title: String, - /// The one-line hook (the frontmatter description). - pub hook: String, - /// The note's kind. - pub r#type: MemoryType, -} - -impl From for MemoryIndexEntryDto { - fn from(entry: MemoryIndexEntry) -> Self { - Self { - slug: entry.slug.as_str().to_owned(), - title: entry.title, - hook: entry.hook, - r#type: entry.r#type, - } - } -} - -/// The structured memory index (transparent array on the wire). -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct MemoryIndexDto(pub Vec); - -impl From for MemoryIndexDto { - fn from(out: ReadMemoryIndexOutput) -> Self { - Self( - out.entries - .into_iter() - .map(MemoryIndexEntryDto::from) - .collect(), - ) - } -} - -impl From for MemoryIndexDto { - fn from(out: RecallMemoryOutput) -> Self { - Self( - out.entries - .into_iter() - .map(MemoryIndexEntryDto::from) - .collect(), - ) - } -} - -/// A note's resolved outgoing links — the target slugs (transparent array). -#[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct MemoryLinksDto(pub Vec); - -impl From for MemoryLinksDto { - fn from(out: ResolveMemoryLinksOutput) -> Self { - Self( - out.links - .into_iter() - .map(|link| link.target.as_str().to_owned()) - .collect(), - ) - } -} - -/// Request DTO for `create_memory`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateMemoryRequestDto { - /// Owning project (resolved to a root). - pub project_id: String, - /// Raw slug for the new note. - pub name: String, - /// One-line description (the index hook). - pub description: String, - /// The note's kind. - pub r#type: MemoryType, - /// Markdown body of the note. - pub content: String, -} - -/// Request DTO for `update_memory`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpdateMemoryRequestDto { - /// Owning project (resolved to a root). - pub project_id: String, - /// Slug of the note to replace. - pub slug: String, - /// New description (the index hook). - pub description: String, - /// New kind. - pub r#type: MemoryType, - /// New Markdown body. - pub content: String, -} - -/// Request DTO for `recall_memory` (LOT B — §14.5.2). -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RecallMemoryRequestDto { - /// Owning project (resolved to a root). - pub project_id: String, - /// The recall query (often the agent's current working context). - pub text: String, - /// Approximate token budget bounding the recalled entries (`0` ⇒ empty). - pub token_budget: usize, -} - -/// Parses a memory slug string coming from the frontend. -/// -/// # Errors -/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a valid -/// kebab-case slug. -pub fn parse_memory_slug(raw: &str) -> Result { - MemorySlug::new(raw).map_err(|_| ErrorDto { - code: "INVALID".to_owned(), - message: format!("invalid memory slug: {raw}"), - }) -} - -// ── Conversation pagination (lot LS6) ──────────────────────────────────────── - -/// Request DTO for `read_conversation_page` — a human, paginated transcript read. -/// -/// `anchor` is a turn id to paginate around (omit to start from a thread end); -/// `direction` is `"forward"` (towards newer) or `"backward"` (towards older, -/// default — the human view opens on the latest page); `limit` is clamped by the -/// domain to `[1, 200]` (omit/`0` ⇒ default 50). -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ReadConversationPageRequestDto { - /// The owning project's id. - pub project_id: String, - /// The conversation (pair) id to read. - pub conversation_id: String, - /// Optional anchor turn id; omit to start from a thread end. - #[serde(default)] - pub anchor: Option, - /// Pagination direction (`"forward"` or `"backward"`); defaults to backward. - #[serde(default)] - pub direction: Option, - /// Requested page size (omit/`0` ⇒ default; clamped to `[1, 200]`). - #[serde(default)] - pub limit: Option, -} - -impl ReadConversationPageRequestDto { - /// Builds the domain [`PageCursor`] from the request (default direction backward, - /// an unparseable anchor degrades to `None` = a thread-end page). - #[must_use] - pub fn cursor(&self) -> PageCursor { - let direction = match self.direction.as_deref() { - Some(d) if d.eq_ignore_ascii_case("forward") => PageDirection::Forward, - _ => PageDirection::Backward, - }; - let anchor = self - .anchor - .as_deref() - .and_then(|raw| uuid::Uuid::parse_str(raw).ok()) - .map(domain::TurnId::from_uuid); - PageCursor { anchor, direction } - } -} - -/// Origin of a turn in the human transcript view (mirrors [`TurnSource`]). -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase", tag = "kind")] -pub enum TurnSourceDto { - /// The human operator. - Human, - /// Another agent (delegation via `idea_ask_agent`). - #[serde(rename_all = "camelCase")] - Agent { - /// The originating agent id. - agent_id: String, - }, -} - -impl From for TurnSourceDto { - fn from(source: TurnSource) -> Self { - match source { - TurnSource::Human => Self::Human, - TurnSource::Agent { agent_id } => Self::Agent { - agent_id: agent_id.to_string(), - }, - } - } -} - -/// One turn in the human transcript — **full text, never truncated**. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct TurnViewDto { - /// Stable turn id (also the pagination anchor). - pub id: String, - /// Timestamp (epoch milliseconds). - pub at_ms: u64, - /// Turn nature (prompt, response, tool activity). - pub role: TurnRole, - /// Origin (human or delegating agent). - pub source: TurnSourceDto, - /// The turn's **complete** text (not truncated). - pub text: String, - /// Character length of the text. - pub text_len: usize, -} - -impl From for TurnViewDto { - fn from(turn: TurnView) -> Self { - Self { - id: turn.id.to_string(), - at_ms: turn.at_ms, - role: turn.role, - source: turn.source.into(), - text: turn.text, - text_len: turn.text_len, - } - } -} - -/// A page of the human transcript, oldest-to-newest. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct TurnPageDto { - /// The page's turns, oldest to newest. - pub turns: Vec, - /// Whether more turns exist beyond the page in the travel direction. - pub has_more: bool, - /// Last turn id of the page (anchor for the next request), or `None` if empty. - pub next_anchor: Option, -} - -impl From for TurnPageDto { - fn from(page: TurnPage) -> Self { - Self { - turns: page.turns.into_iter().map(TurnViewDto::from).collect(), - has_more: page.has_more, - next_anchor: page.next_anchor.map(|id| id.to_string()), - } - } -} - -#[cfg(test)] -mod ls6_pagination_tests { - //! LS6 — DTO de la lecture humaine paginée : parsing du curseur + mapping riche - //! `TurnPage → TurnPageDto` (texte complet non borné, source Human/Agent, `has_more`, - //! `next_anchor`). C'est le « round-trip d'une page » testable sans `State` Tauri. - - use super::*; - use application::{TurnPage, TurnSource, TurnView}; - - fn tid(n: u128) -> domain::TurnId { - domain::TurnId::from_uuid(uuid::Uuid::from_u128(n)) - } - - #[test] - fn request_cursor_defaults_to_backward_and_parses_anchor() { - // Pas de direction ⇒ backward (la vue humaine ouvre sur la page la plus récente). - let req = ReadConversationPageRequestDto { - project_id: "p".into(), - conversation_id: "c".into(), - anchor: None, - direction: None, - limit: None, - }; - let cur = req.cursor(); - assert_eq!(cur.direction, PageDirection::Backward); - assert_eq!(cur.anchor, None); - - // Forward (insensible à la casse) + ancre UUID valide. - let u = uuid::Uuid::from_u128(9); - let req = ReadConversationPageRequestDto { - project_id: "p".into(), - conversation_id: "c".into(), - anchor: Some(u.to_string()), - direction: Some("Forward".into()), - limit: Some(10), - }; - let cur = req.cursor(); - assert_eq!(cur.direction, PageDirection::Forward); - assert_eq!(cur.anchor, Some(domain::TurnId::from_uuid(u))); - - // Ancre non-UUID ⇒ dégrade en None (page depuis un bout du fil), jamais d'erreur. - let req = ReadConversationPageRequestDto { - project_id: "p".into(), - conversation_id: "c".into(), - anchor: Some("not-a-uuid".into()), - direction: Some("backward".into()), - limit: None, - }; - assert_eq!(req.cursor().anchor, None); - } - - #[test] - fn turn_page_dto_round_trips_full_text_and_maps_sources() { - let big = "Z".repeat(40_000); - let page = TurnPage { - turns: vec![ - TurnView { - id: tid(1), - at_ms: 1_700_000_000_000, - role: TurnRole::Prompt, - source: TurnSource::Human, - text: big.clone(), - text_len: big.chars().count(), - }, - TurnView { - id: tid(2), - at_ms: 1_700_000_000_001, - role: TurnRole::Response, - source: TurnSource::Agent { - agent_id: domain::AgentId::from_uuid(uuid::Uuid::from_u128(7)), - }, - text: "réponse".to_owned(), - text_len: 7, - }, - ], - has_more: true, - next_anchor: Some(tid(2)), - }; - - let dto = TurnPageDto::from(page); - // Texte complet préservé (non borné) + text_len. - assert_eq!(dto.turns[0].text.chars().count(), 40_000); - assert_eq!(dto.turns[0].text_len, 40_000); - // next_anchor stringifié, has_more porté. - assert!(dto.has_more); - assert_eq!( - dto.next_anchor.as_deref(), - Some(tid(2).to_string().as_str()) - ); - - // Round-trip JSON (la « page » telle qu'envoyée au front) : camelCase + source. - let json = serde_json::to_value(&dto).unwrap(); - assert_eq!(json["hasMore"], serde_json::json!(true)); - assert_eq!(json["nextAnchor"], serde_json::json!(tid(2).to_string())); - assert_eq!( - json["turns"][0]["source"]["kind"], - serde_json::json!("human") - ); - assert_eq!( - json["turns"][1]["source"]["kind"], - serde_json::json!("agent") - ); - assert_eq!( - json["turns"][1]["source"]["agentId"], - serde_json::json!(uuid::Uuid::from_u128(7).to_string()) - ); - // Le texte intégral survit au passage JSON. - assert_eq!( - json["turns"][0]["text"].as_str().unwrap().chars().count(), - 40_000 - ); - } - - #[test] - fn empty_turn_page_dto_has_no_next_anchor() { - let dto = TurnPageDto::from(TurnPage { - turns: Vec::new(), - has_more: false, - next_anchor: None, - }); - assert!(dto.turns.is_empty() && !dto.has_more && dto.next_anchor.is_none()); - } -} - -// --------------------------------------------------------------------------- -// Background tasks (B8 — first-class background command) -// --------------------------------------------------------------------------- - -use domain::{ - BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskState, TaskId, -}; - -/// One first-class background task as seen by the frontend (camelCase wire shape). -/// -/// Mirrors the persisted [`BackgroundTask`], flattening the terminal -/// [`BackgroundTaskResult`] into optional `exitCode` / `summary` / tails so the -/// UI has a single shape for every lifecycle state. Optional fields are omitted -/// from the wire when absent (`skip_serializing_if`). -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct BackgroundTaskDto { - /// Stable task id (UUID string). - pub task_id: String, - /// Owning agent id (UUID string). - pub owner_agent_id: String, - /// Owning project id (UUID string). - pub project_id: String, - /// Kind discriminant (`"command"`, `"headlessRendezvous"`, `"sessionResume"`, - /// `"maintenance"`). - pub kind: String, - /// Lifecycle state (`"queued"`, `"running"`, `"waiting"`, `"completed"`, - /// `"failed"`, `"cancelled"`, `"expired"`). - pub state: String, - /// Process exit code, when the terminal result carries one. - #[serde(skip_serializing_if = "Option::is_none")] - pub exit_code: Option, - /// Human-readable summary / error / reason of the terminal result. - #[serde(skip_serializing_if = "Option::is_none")] - pub summary: Option, - /// Bounded stdout tail (merged output for PTY-backed commands). - #[serde(skip_serializing_if = "Option::is_none")] - pub stdout_tail: Option, - /// Bounded stderr tail (unset for PTY-backed commands, which merge streams). - #[serde(skip_serializing_if = "Option::is_none")] - pub stderr_tail: Option, - /// Creation timestamp, epoch milliseconds. - pub created_at_ms: u64, - /// Last update timestamp, epoch milliseconds. - pub updated_at_ms: u64, -} - -/// Kind discriminant string for a [`BackgroundTaskKind`]. -fn background_kind_label(kind: &BackgroundTaskKind) -> &'static str { - match kind { - BackgroundTaskKind::Command { .. } => "command", - BackgroundTaskKind::HeadlessRendezvous { .. } => "headlessRendezvous", - BackgroundTaskKind::SessionResume { .. } => "sessionResume", - BackgroundTaskKind::Maintenance { .. } => "maintenance", - } -} - -/// Kind discriminant string for a work-state [`BackgroundTaskKindLabel`]. -fn background_kind_label_from_work_state(kind: BackgroundTaskKindLabel) -> &'static str { - match kind { - BackgroundTaskKindLabel::Command => "command", - BackgroundTaskKindLabel::HeadlessRendezvous => "headlessRendezvous", - BackgroundTaskKindLabel::SessionResume => "sessionResume", - BackgroundTaskKindLabel::Maintenance => "maintenance", - } -} - -/// Lifecycle state string for a [`BackgroundTaskState`]. -fn background_state_label(state: BackgroundTaskState) -> &'static str { - match state { - BackgroundTaskState::Queued => "queued", - BackgroundTaskState::Running => "running", - BackgroundTaskState::Waiting => "waiting", - BackgroundTaskState::Completed => "completed", - BackgroundTaskState::Failed => "failed", - BackgroundTaskState::Cancelled => "cancelled", - BackgroundTaskState::Expired => "expired", - } -} - -impl From for BackgroundTaskDto { - fn from(task: BackgroundTask) -> Self { - let (exit_code, summary, stdout_tail, stderr_tail) = match &task.result { - Some(BackgroundTaskResult::Success { - exit_code, - summary, - stdout_tail, - stderr_tail, - .. - }) => ( - *exit_code, - Some(summary.clone()), - stdout_tail.clone(), - stderr_tail.clone(), - ), - Some(BackgroundTaskResult::Failure { - exit_code, - error, - stdout_tail, - stderr_tail, - .. - }) => ( - *exit_code, - Some(error.clone()), - stdout_tail.clone(), - stderr_tail.clone(), - ), - Some(BackgroundTaskResult::Cancelled { reason, .. }) - | Some(BackgroundTaskResult::Expired { reason, .. }) => { - (None, Some(reason.clone()), None, None) - } - None => (None, None, None, None), - }; - Self { - task_id: task.id.to_string(), - owner_agent_id: task.owner_agent_id.to_string(), - project_id: task.project_id.to_string(), - kind: background_kind_label(&task.kind).to_owned(), - state: background_state_label(task.state).to_owned(), - exit_code, - summary, - stdout_tail, - stderr_tail, - created_at_ms: task.created_at_ms, - updated_at_ms: task.updated_at_ms, - } - } -} - -/// Parses a task-id string (UUID) coming from the frontend. -/// -/// # Errors -/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. -pub fn parse_task_id(raw: &str) -> Result { - uuid::Uuid::parse_str(raw) - .map(TaskId::from_uuid) - .map_err(|_| ErrorDto { - code: "INVALID".to_owned(), - message: format!("invalid task id: {raw}"), - }) -} - -/// Request DTO for `spawn_background_command`. -/// -/// Builds a command-backed [`BackgroundTask`]: the command line runs under the -/// project host's PTY and its completion is delivered to `ownerAgentId`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SpawnBackgroundCommandRequestDto { - /// Owning project id (UUID string). - pub project_id: String, - /// Agent that owns completion delivery (UUID string). - pub owner_agent_id: String, - /// Human-facing label. - pub label: String, - /// Executable to run. - pub command: String, - /// Arguments. - #[serde(default)] - pub args: Vec, - /// Working directory (absolute path). - pub cwd: String, - /// Extra environment variables. - #[serde(default)] - pub env: Vec<(String, String)>, - /// When `true` (the default), wake the owner on completion; otherwise only - /// record it. - #[serde(default)] - pub record_only: bool, - /// Optional absolute deadline, epoch milliseconds. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub deadline_ms: Option, -} +pub use backend::dto::*; diff --git a/crates/app-tauri/src/events.rs b/crates/app-tauri/src/events.rs index caf0478..3f353cc 100644 --- a/crates/app-tauri/src/events.rs +++ b/crates/app-tauri/src/events.rs @@ -1,1114 +1,22 @@ -//! `TauriEventRelay` — bridges the domain [`EventBus`] to Tauri events -//! (backend → frontend push channel, ARCHITECTURE §2 "Events"). +//! Tauri event relay. //! -//! The relay subscribes to the bus and re-emits each [`DomainEvent`] as a Tauri -//! event named [`DOMAIN_EVENT`], carrying a serialisable [`DomainEventDto`] -//! payload (the domain event itself is deliberately not `Serialize`; the wire -//! format is owned here, the infrastructure/presentation layer). -//! -//! High-frequency `PtyOutput` is intentionally *not* relayed through this global -//! event; it goes through per-session [`crate::pty::PtyBridge`] channels instead. +//! The stable serialisable event DTOs live in `backend::events`. This module +//! owns only the Tauri-specific relay: subscribing to the domain event bus and +//! emitting named Tauri events. -use serde::Serialize; +pub use backend::events::{ + AgentLaunchFailedDto, DomainEventDto, ModelServerStatusChangedDto, OrchestrationSourceDto, + AGENT_LAUNCH_FAILED, DOMAIN_EVENT, MODEL_SERVER_STATUS_CHANGED, +}; +use domain::events::DomainEvent; +use infrastructure::TokioBroadcastEventBus; use tauri::{AppHandle, Emitter}; -use domain::conversation::ConversationParty; -use domain::events::{DomainEvent, OrchestrationSource}; -use domain::input::AgentLiveness; -use domain::model_server::ModelServerLifecycleStatus; -use domain::{IssueLinkKind, IssuePriority, IssueStatus}; -use infrastructure::TokioBroadcastEventBus; - -/// Name of the Tauri event carrying relayed [`DomainEvent`]s. -pub const DOMAIN_EVENT: &str = "domain://event"; -/// Dedicated Tauri event for local model-server status changes. -pub const MODEL_SERVER_STATUS_CHANGED: &str = "model_server_status_changed"; -/// Dedicated Tauri event for launch failures. -pub const AGENT_LAUNCH_FAILED: &str = "agent_launch_failed"; - -/// Model-server lifecycle status on the Tauri wire. -#[derive(Debug, Clone, Serialize)] -#[serde(tag = "state", rename_all = "camelCase")] -pub enum ModelServerStatusDto { - /// No server is configured for this profile. - NotConfigured, - /// Readiness probing is in progress. - Probing, - /// IdeA is starting the process. - Starting, - /// Server is downloading/preparing a remote model. - #[serde(rename_all = "camelCase")] - Downloading { - /// Downloaded bytes when known. - downloaded_bytes: Option, - /// Total bytes when known. - total_bytes: Option, - /// Completion percentage when known. - percent: Option, - /// Remote model source. - source: Option, - }, - /// Server is ready. - #[serde(rename_all = "camelCase")] - Ready { - /// `true` when an existing server was reused. - reused: bool, - }, - /// Server preparation failed. - #[serde(rename_all = "camelCase")] - Failed { - /// Stable failure code. - code: String, - /// Human-readable message. - message: String, - }, -} - -impl From<&ModelServerLifecycleStatus> for ModelServerStatusDto { - fn from(status: &ModelServerLifecycleStatus) -> Self { - match status { - ModelServerLifecycleStatus::NotConfigured => Self::NotConfigured, - ModelServerLifecycleStatus::Probing => Self::Probing, - ModelServerLifecycleStatus::Starting => Self::Starting, - ModelServerLifecycleStatus::Downloading { - downloaded_bytes, - total_bytes, - percent, - source, - } => Self::Downloading { - downloaded_bytes: *downloaded_bytes, - total_bytes: *total_bytes, - percent: *percent, - source: source.clone(), - }, - ModelServerLifecycleStatus::Ready { reused } => Self::Ready { reused: *reused }, - ModelServerLifecycleStatus::Failed { code, message } => Self::Failed { - code: code.clone(), - message: message.clone(), - }, - } - } -} - -/// Payload for [`MODEL_SERVER_STATUS_CHANGED`]. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ModelServerStatusChangedDto { - /// Local model server id. - pub server_id: String, - /// Lifecycle status. - pub status: ModelServerStatusDto, -} - -/// Payload for [`AGENT_LAUNCH_FAILED`]. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentLaunchFailedDto { - /// Agent id. - pub agent_id: String, - /// Failure cause namespace. - pub cause: String, - /// Stable error code. - pub code: String, - /// Human-readable message. - pub message: String, -} - -/// Serialisable mirror of [`DomainEvent`] for the IPC wire (camelCase, tagged). -/// -/// `type` is the discriminant; payload fields are flattened per variant. This is -/// the single owner of the event wire format on the backend side. -#[derive(Debug, Clone, Serialize)] -#[serde(tag = "type", rename_all = "camelCase")] -pub enum DomainEventDto { - /// A project was created. - #[serde(rename_all = "camelCase")] - ProjectCreated { - /// Project id (UUID string). - project_id: String, - }, - /// An agent was launched. - #[serde(rename_all = "camelCase")] - AgentLaunched { - /// Agent id. - agent_id: String, - /// Session id. - session_id: String, - }, - /// An agent launch failed before runtime session creation. - #[serde(rename_all = "camelCase")] - AgentLaunchFailed { - /// Agent id. - agent_id: String, - /// Failure cause namespace. - cause: String, - /// Stable error code. - code: String, - /// Human-readable message. - message: String, - }, - /// Local model-server status changed. - #[serde(rename_all = "camelCase")] - ModelServerStatusChanged { - /// Local model server id. - server_id: String, - /// Lifecycle status. - status: ModelServerStatusDto, - }, - /// An agent exited. - #[serde(rename_all = "camelCase")] - AgentExited { - /// Agent id. - agent_id: String, - /// Exit code. - code: i32, - }, - /// A ticket assistant chat was opened. - #[serde(rename_all = "camelCase")] - TicketAssistantOpened { - /// Bound ticket reference. - issue_ref: String, - /// Runtime profile id. - profile_id: String, - }, - /// A ticket assistant chat was closed. - #[serde(rename_all = "camelCase")] - TicketAssistantClosed { - /// Bound ticket reference. - issue_ref: String, - }, - /// An agent's busy/idle state changed (cadrage C4 §4.2). The frontend dims - /// "Envoyer" while `busy` is `true`. - #[serde(rename_all = "camelCase")] - AgentBusyChanged { - /// Agent id. - agent_id: String, - /// `true` when a turn is in flight, `false` when idle. - busy: bool, - }, - /// A delegation is ready to be injected into the agent's **native terminal** - /// (ARCHITECTURE §20). The frontend write-portal writes `text` + `submitSequence` - /// (default `"\r"`) after `submitDelayMs` (default ~60) once the human line is empty, - /// then acks via the `delegation_delivered` command. The backend no longer PTY-writes - /// the turn. - #[serde(rename_all = "camelCase")] - DelegationReady { - /// Target agent id (UUID string). - agent_id: String, - /// Mailbox ticket id (UUID string) to ack back via `delegation_delivered`. - ticket: String, - /// Task text to inject (written without a trailing newline by the portal). - text: String, - /// Profile's submit sequence; `null` ⇒ the front applies its default (`"\r"`). - #[serde(skip_serializing_if = "Option::is_none")] - submit_sequence: Option, - /// Profile's submit delay in ms; `null` ⇒ the front default (~60 ms). - #[serde(skip_serializing_if = "Option::is_none")] - submit_delay_ms: Option, - }, - /// A target agent produced a synchronous reply to an inter-agent `ask` (§17.4). - #[serde(rename_all = "camelCase")] - AgentReplied { - /// Target agent id. - agent_id: String, - /// Reply length in bytes (metric, not the payload). - reply_len: usize, - }, - /// Intermediate assistant announcement emitted live during an inter-agent turn. - #[serde(rename_all = "camelCase")] - AgentAnnouncement { - /// Project id. - project_id: String, - /// Requester party (`"user"` or requester agent id). - requester: String, - /// Target agent id. - target: String, - /// FIFO ticket id. - ticket_id: String, - /// Announcement text. - text: String, - /// Wall-clock timestamp in epoch milliseconds. - at_ms: u64, - }, - /// An agent's liveness (alive/stalled) changed (lot 2, readiness/heartbeat). The - /// frontend can badge a frozen agent; `"stalled"` while no proof of liveness arrived - /// for longer than the profile's `stallAfterMs`, back to `"alive"` on a late - /// battement or when the turn ends. - #[serde(rename_all = "camelCase")] - AgentLivenessChanged { - /// Agent id (UUID string). - agent_id: String, - /// New liveness, as a lowercase string (`"alive"` / `"stalled"`). - liveness: AgentLivenessDto, - }, - /// A background task lifecycle/delivery event occurred. - #[serde(rename_all = "camelCase")] - BackgroundTaskChanged { - /// Project id. - project_id: String, - /// Task id. - task_id: String, - /// Owner agent id. - agent_id: String, - /// Lightweight event/state label. - state: String, - }, - /// An agent inbox queue depth changed. - #[serde(rename_all = "camelCase")] - AgentInboxChanged { - /// Agent id. - agent_id: String, - /// Queue depth after the operation. - depth: usize, - /// Queue operation label. - action: String, - }, - /// An owner wake event occurred. - #[serde(rename_all = "camelCase")] - AgentWakeChanged { - /// Project id. - project_id: String, - /// Agent id. - agent_id: String, - /// Wake operation label. - action: String, - /// Failure reason, when any. - #[serde(skip_serializing_if = "Option::is_none")] - reason: Option, - }, - /// An agent's runtime profile was changed (hot-swap of the AI engine). - #[serde(rename_all = "camelCase")] - AgentProfileChanged { - /// Agent id. - agent_id: String, - /// The new runtime profile id. - profile_id: String, - }, - /// A template was updated. - #[serde(rename_all = "camelCase")] - TemplateUpdated { - /// Template id. - template_id: String, - /// New version. - version: u64, - }, - /// A synchronized agent drifted from its template. - #[serde(rename_all = "camelCase")] - AgentDriftDetected { - /// Agent id. - agent_id: String, - /// Current version. - from: u64, - /// Available version. - to: u64, - }, - /// A synchronized agent was brought up to date. - #[serde(rename_all = "camelCase")] - AgentSynced { - /// Agent id. - agent_id: String, - /// Version synced to. - to: u64, - }, - /// A skill was assigned to (or unassigned from) an agent. - #[serde(rename_all = "camelCase")] - SkillAssigned { - /// Agent id. - agent_id: String, - /// Skill id. - skill_id: String, - /// `true` if assigned, `false` if unassigned. - assigned: bool, - }, - /// A tab's layout changed. - #[serde(rename_all = "camelCase")] - LayoutChanged { - /// Project id. - project_id: String, - }, - /// A remote connection was established. - #[serde(rename_all = "camelCase")] - RemoteConnected { - /// Project id. - project_id: String, - }, - /// Git state changed. - #[serde(rename_all = "camelCase")] - GitStateChanged { - /// Project id. - project_id: String, - }, - /// An issue-backed public ticket was created. - #[serde(rename_all = "camelCase")] - IssueCreated { - /// Issue id. - issue_id: String, - /// Public ticket reference (`#N`). - issue_ref: String, - }, - /// An issue-backed public ticket was updated. - #[serde(rename_all = "camelCase")] - IssueUpdated { - /// Public ticket reference (`#N`). - issue_ref: String, - /// New optimistic version. - version: u64, - }, - /// An issue-backed public ticket was deleted. - #[serde(rename_all = "camelCase")] - IssueDeleted { - /// Project id. - project_id: String, - /// Deleted public ticket reference (`#N`). - issue_ref: String, - /// Released sprint id, when any. - freed_sprint: Option, - }, - /// A public ticket status changed. - #[serde(rename_all = "camelCase")] - IssueStatusChanged { - /// Public ticket reference (`#N`). - issue_ref: String, - /// New status. - status: String, - /// New optimistic version. - version: u64, - }, - /// A public ticket priority changed. - #[serde(rename_all = "camelCase")] - IssuePriorityChanged { - /// Public ticket reference (`#N`). - issue_ref: String, - /// New priority. - priority: String, - /// New optimistic version. - version: u64, - }, - /// A public ticket carnet changed. - #[serde(rename_all = "camelCase")] - IssueCarnetUpdated { - /// Public ticket reference (`#N`). - issue_ref: String, - /// New optimistic version. - version: u64, - }, - /// A public ticket link was added. - #[serde(rename_all = "camelCase")] - IssueLinked { - /// Public source ticket reference (`#N`). - issue_ref: String, - /// Public target ticket reference (`#N`). - target: String, - /// Link kind. - kind: String, - /// New optimistic version. - version: u64, - }, - /// A public ticket link was removed. - #[serde(rename_all = "camelCase")] - IssueUnlinked { - /// Public source ticket reference (`#N`). - issue_ref: String, - /// Public target ticket reference (`#N`). - target: String, - /// Link kind. - kind: String, - /// New optimistic version. - version: u64, - }, - /// An agent was assigned to a public ticket. - #[serde(rename_all = "camelCase")] - IssueAgentAssigned { - /// Public ticket reference (`#N`). - issue_ref: String, - /// Assigned agent id. - agent_id: String, - /// New optimistic version. - version: u64, - }, - /// An agent was unassigned from a public ticket. - #[serde(rename_all = "camelCase")] - IssueAgentUnassigned { - /// Public ticket reference (`#N`). - issue_ref: String, - /// Unassigned agent id. - agent_id: String, - /// New optimistic version. - version: u64, - }, - /// A sprint was created. - #[serde(rename_all = "camelCase")] - SprintCreated { - /// Stable sprint id. - sprint_id: String, - /// Reorderable order. - order: u32, - }, - /// A sprint was renamed. - #[serde(rename_all = "camelCase")] - SprintRenamed { - /// Stable sprint id. - sprint_id: String, - /// New name. - name: String, - /// New optimistic version. - version: u64, - }, - /// A sprint was reordered. - #[serde(rename_all = "camelCase")] - SprintReordered { - /// Stable sprint id. - sprint_id: String, - /// New order. - order: u32, - /// New optimistic version. - version: u64, - }, - /// A sprint was deleted. - #[serde(rename_all = "camelCase")] - SprintDeleted { - /// Stable sprint id. - sprint_id: String, - }, - /// A ticket changed sprint membership. - #[serde(rename_all = "camelCase")] - IssueSprintChanged { - /// Public ticket reference (`#N`). - issue_ref: String, - /// Previous sprint id. - from: Option, - /// New sprint id. - to: Option, - /// New optimistic version. - version: u64, - }, - /// An orchestrator request was processed on behalf of a requester agent. - #[serde(rename_all = "camelCase")] - OrchestratorRequestProcessed { - /// Id of the requesting (orchestrator) agent. - requester_id: String, - /// The action that was processed. - action: String, - /// Whether IdeA handled it successfully. - ok: bool, - /// Which entry door the request arrived through (`"file"` watcher vs - /// `"mcp"` server). Serialised as a lowercase string so the frontend can - /// badge the source. - source: OrchestrationSourceDto, - }, - /// The project's orchestrator designation changed (T1). - #[serde(rename_all = "camelCase")] - OrchestratorChanged { - /// The project whose designation changed. - project_id: String, - /// The newly designated orchestrator agent, or `None` for the default - /// (the oldest agent orchestrates). - #[serde(skip_serializing_if = "Option::is_none")] - orchestrator: Option, - }, - /// A memory note was created or updated. - #[serde(rename_all = "camelCase")] - MemorySaved { - /// The saved note's slug. - slug: String, - }, - /// A memory note was deleted. - #[serde(rename_all = "camelCase")] - MemoryDeleted { - /// The deleted note's slug. - slug: String, - }, - /// The aggregated `MEMORY.md` index was rebuilt. - #[serde(rename_all = "camelCase")] - MemoryIndexRebuilt { - /// Project id. - project_id: String, - }, - /// A project's memory crossed the recall budget while no embedder is configured - /// (LOT C3 — §14.5.5): a one-time, dismissible "configure an embedder?" hint. - #[serde(rename_all = "camelCase")] - EmbedderSuggested { - /// Project id. - project_id: String, - /// Whether a local Ollama-style embedding server was detected. - ollama_detected: bool, - /// Ids of recommended ONNX models already present in the local cache. - onnx_cached: Vec, - /// Whether the HTTP capability is compiled in. - vector_http_enabled: bool, - /// Whether the in-process ONNX capability is compiled in. - vector_onnx_enabled: bool, - }, - /// An agent entered a **session/rate limit** (ARCHITECTURE §21). Low-frequency, - /// model-agnostic badge: carries only the neutral "limited, maybe resets at T" - /// fact. The frontend badges "limité jusqu'à HH:MM". - #[serde(rename_all = "camelCase")] - AgentRateLimited { - /// Agent id (UUID string). - agent_id: String, - /// Reset instant in **epoch-milliseconds**; `null` ⇒ unknown (no auto-resume). - #[serde(skip_serializing_if = "Option::is_none")] - resets_at_ms: Option, - }, - /// An **auto-resume** was armed for a rate-limited agent (ARCHITECTURE §21). The - /// frontend shows the countdown + the "Annuler la reprise" button (cancellable - /// window). - #[serde(rename_all = "camelCase")] - AgentResumeScheduled { - /// Agent id (UUID string). - agent_id: String, - /// Wake-up deadline in **epoch-milliseconds**. - fire_at_ms: i64, - }, - /// An agent's **auto-resume** was **cancelled** (ARCHITECTURE §21): the user - /// clicked "Annuler la reprise". The frontend removes the countdown. - #[serde(rename_all = "camelCase")] - AgentResumeCancelled { - /// Agent id (UUID string). - agent_id: String, - }, - /// An agent was effectively **resumed** after a limit (ARCHITECTURE §21): the - /// wake-up fired (or immediate resume). The frontend clears the "limited" state. - #[serde(rename_all = "camelCase")] - AgentResumed { - /// Agent id (UUID string). - agent_id: String, - }, - /// **Human net (level 3)**: a session limit is **suspected** without any reliable - /// reset time (ARCHITECTURE §21.1). IdeA never resumes blind: this asks the front - /// to **prompt the user** ("limit detected but time unknown — resume at?"). - #[serde(rename_all = "camelCase")] - AgentRateLimitSuspected { - /// Agent id (UUID string). - agent_id: String, - /// Reset instant in **epoch-milliseconds** if an estimate exists, else `null`. - #[serde(skip_serializing_if = "Option::is_none")] - resets_at_ms: Option, - }, - /// Raw PTY output (normally routed to a per-session channel, not here). - #[serde(rename_all = "camelCase")] - PtyOutput { - /// Session id. - session_id: String, - /// Output bytes. - bytes: Vec, - }, -} - -/// Wire mirror of [`OrchestrationSource`]: which entry door a processed -/// orchestration request arrived through. Serialised as a lowercase string -/// (`"file"` / `"mcp"`) the frontend badges on the event. -#[derive(Debug, Clone, Copy, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum OrchestrationSourceDto { - /// A `.ideai/requests` JSON file (filesystem watcher). - File, - /// A `tools/call` on the MCP server. - Mcp, -} - -/// Wire mirror of [`AgentLiveness`]: the alive/stalled liveness of an agent (lot 2), -/// serialised as a lowercase string (`"alive"` / `"stalled"`) the frontend badges. -#[derive(Debug, Clone, Copy, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum AgentLivenessDto { - /// The agent shows proof of liveness (or is idle). - Alive, - /// No proof of liveness past the profile's `stallAfterMs` threshold. - Stalled, -} - -impl From for AgentLivenessDto { - fn from(liveness: AgentLiveness) -> Self { - match liveness { - AgentLiveness::Alive => Self::Alive, - AgentLiveness::Stalled => Self::Stalled, - } - } -} - -impl From for OrchestrationSourceDto { - fn from(source: OrchestrationSource) -> Self { - match source { - OrchestrationSource::File => Self::File, - OrchestrationSource::Mcp => Self::Mcp, - } - } -} - -fn issue_status_wire(status: IssueStatus) -> &'static str { - match status { - IssueStatus::Open => "open", - IssueStatus::InProgress => "inProgress", - IssueStatus::Qa => "QA", - IssueStatus::Closed => "closed", - } -} - -fn issue_priority_wire(priority: IssuePriority) -> &'static str { - match priority { - IssuePriority::Low => "low", - IssuePriority::Medium => "medium", - IssuePriority::High => "high", - IssuePriority::Critical => "critical", - } -} - -fn issue_link_kind_wire(kind: IssueLinkKind) -> &'static str { - match kind { - IssueLinkKind::RelatesTo => "relatesTo", - IssueLinkKind::Blocks => "blocks", - IssueLinkKind::BlockedBy => "blockedBy", - IssueLinkKind::Duplicates => "duplicates", - IssueLinkKind::DependsOn => "dependsOn", - } -} - -fn conversation_party_wire(party: ConversationParty) -> String { - match party { - ConversationParty::User => "user".to_owned(), - ConversationParty::Agent { agent_id } => agent_id.to_string(), - } -} - -impl From<&DomainEvent> for DomainEventDto { - fn from(e: &DomainEvent) -> Self { - match e { - DomainEvent::ProjectCreated { project_id } => Self::ProjectCreated { - project_id: project_id.to_string(), - }, - DomainEvent::AgentLaunched { - agent_id, - session_id, - } => Self::AgentLaunched { - agent_id: agent_id.to_string(), - session_id: session_id.to_string(), - }, - DomainEvent::AgentLaunchFailed { - agent_id, - cause, - code, - message, - } => Self::AgentLaunchFailed { - agent_id: agent_id.to_string(), - cause: cause.clone(), - code: code.clone(), - message: message.clone(), - }, - DomainEvent::ModelServerStatusChanged { server_id, status } => { - Self::ModelServerStatusChanged { - server_id: server_id.to_string(), - status: status.into(), - } - } - DomainEvent::AgentExited { agent_id, code } => Self::AgentExited { - agent_id: agent_id.to_string(), - code: *code, - }, - DomainEvent::TicketAssistantOpened { - issue_ref, - profile_id, - } => Self::TicketAssistantOpened { - issue_ref: issue_ref.to_string(), - profile_id: profile_id.to_string(), - }, - DomainEvent::TicketAssistantClosed { issue_ref } => Self::TicketAssistantClosed { - issue_ref: issue_ref.to_string(), - }, - DomainEvent::AgentBusyChanged { agent_id, busy } => Self::AgentBusyChanged { - agent_id: agent_id.to_string(), - busy: *busy, - }, - DomainEvent::DelegationReady { - agent_id, - ticket, - text, - submit_sequence, - submit_delay_ms, - } => Self::DelegationReady { - agent_id: agent_id.to_string(), - ticket: ticket.to_string(), - text: text.clone(), - submit_sequence: submit_sequence.clone(), - submit_delay_ms: *submit_delay_ms, - }, - DomainEvent::AgentReplied { - agent_id, - reply_len, - } => Self::AgentReplied { - agent_id: agent_id.to_string(), - reply_len: *reply_len, - }, - DomainEvent::AgentAnnouncement { - project_id, - requester, - target, - ticket, - text, - at_ms, - } => Self::AgentAnnouncement { - project_id: project_id.to_string(), - requester: conversation_party_wire(*requester), - target: target.to_string(), - ticket_id: ticket.to_string(), - text: text.clone(), - at_ms: *at_ms, - }, - DomainEvent::AgentLivenessChanged { agent_id, liveness } => { - Self::AgentLivenessChanged { - agent_id: agent_id.to_string(), - liveness: (*liveness).into(), - } - } - DomainEvent::BackgroundTaskStarted { - project_id, - task_id, - owner_agent_id, - } => Self::BackgroundTaskChanged { - project_id: project_id.to_string(), - task_id: task_id.to_string(), - agent_id: owner_agent_id.to_string(), - state: "started".to_owned(), - }, - DomainEvent::BackgroundTaskStateChanged { - project_id, - task_id, - owner_agent_id, - state, - } => Self::BackgroundTaskChanged { - project_id: project_id.to_string(), - task_id: task_id.to_string(), - agent_id: owner_agent_id.to_string(), - state: format!("{state:?}"), - }, - DomainEvent::BackgroundTaskCompleted { - project_id, - task_id, - owner_agent_id, - } => Self::BackgroundTaskChanged { - project_id: project_id.to_string(), - task_id: task_id.to_string(), - agent_id: owner_agent_id.to_string(), - state: "completed".to_owned(), - }, - DomainEvent::BackgroundTaskFailed { - project_id, - task_id, - owner_agent_id, - } => Self::BackgroundTaskChanged { - project_id: project_id.to_string(), - task_id: task_id.to_string(), - agent_id: owner_agent_id.to_string(), - state: "failed".to_owned(), - }, - DomainEvent::BackgroundTaskCancelled { - project_id, - task_id, - owner_agent_id, - } => Self::BackgroundTaskChanged { - project_id: project_id.to_string(), - task_id: task_id.to_string(), - agent_id: owner_agent_id.to_string(), - state: "cancelled".to_owned(), - }, - DomainEvent::BackgroundTaskCompletionDeliveryPending { - project_id, - task_id, - owner_agent_id, - } => Self::BackgroundTaskChanged { - project_id: project_id.to_string(), - task_id: task_id.to_string(), - agent_id: owner_agent_id.to_string(), - state: "deliveryPending".to_owned(), - }, - DomainEvent::BackgroundTaskCompletionDelivered { - project_id, - task_id, - owner_agent_id, - } => Self::BackgroundTaskChanged { - project_id: project_id.to_string(), - task_id: task_id.to_string(), - agent_id: owner_agent_id.to_string(), - state: "delivered".to_owned(), - }, - DomainEvent::AgentInboxQueued { agent_id, depth } => Self::AgentInboxChanged { - agent_id: agent_id.to_string(), - depth: *depth, - action: "queued".to_owned(), - }, - DomainEvent::AgentInboxDrained { agent_id, depth } => Self::AgentInboxChanged { - agent_id: agent_id.to_string(), - depth: *depth, - action: "drained".to_owned(), - }, - DomainEvent::AgentWakeScheduled { - project_id, - agent_id, - } => Self::AgentWakeChanged { - project_id: project_id.to_string(), - agent_id: agent_id.to_string(), - action: "scheduled".to_owned(), - reason: None, - }, - DomainEvent::AgentWakeStarted { - project_id, - agent_id, - } => Self::AgentWakeChanged { - project_id: project_id.to_string(), - agent_id: agent_id.to_string(), - action: "started".to_owned(), - reason: None, - }, - DomainEvent::AgentWakeFailed { - project_id, - agent_id, - reason, - } => Self::AgentWakeChanged { - project_id: project_id.to_string(), - agent_id: agent_id.to_string(), - action: "failed".to_owned(), - reason: Some(reason.clone()), - }, - DomainEvent::AgentProfileChanged { - agent_id, - profile_id, - } => Self::AgentProfileChanged { - agent_id: agent_id.to_string(), - profile_id: profile_id.to_string(), - }, - DomainEvent::TemplateUpdated { - template_id, - version, - } => Self::TemplateUpdated { - template_id: template_id.to_string(), - version: version.get(), - }, - DomainEvent::AgentDriftDetected { agent_id, from, to } => Self::AgentDriftDetected { - agent_id: agent_id.to_string(), - from: from.get(), - to: to.get(), - }, - DomainEvent::AgentSynced { agent_id, to } => Self::AgentSynced { - agent_id: agent_id.to_string(), - to: to.get(), - }, - DomainEvent::SkillAssigned { - agent_id, - skill_id, - assigned, - } => Self::SkillAssigned { - agent_id: agent_id.to_string(), - skill_id: skill_id.to_string(), - assigned: *assigned, - }, - DomainEvent::LayoutChanged { project_id } => Self::LayoutChanged { - project_id: project_id.to_string(), - }, - DomainEvent::RemoteConnected { project_id } => Self::RemoteConnected { - project_id: project_id.to_string(), - }, - DomainEvent::GitStateChanged { project_id } => Self::GitStateChanged { - project_id: project_id.to_string(), - }, - DomainEvent::IssueCreated { - issue_id, - issue_ref, - } => Self::IssueCreated { - issue_id: issue_id.to_string(), - issue_ref: issue_ref.to_string(), - }, - DomainEvent::IssueUpdated { issue_ref, version } => Self::IssueUpdated { - issue_ref: issue_ref.to_string(), - version: version.get(), - }, - DomainEvent::IssueDeleted { - project_id, - issue_ref, - freed_sprint, - } => Self::IssueDeleted { - project_id: project_id.to_string(), - issue_ref: issue_ref.to_string(), - freed_sprint: freed_sprint.map(|sprint| sprint.to_string()), - }, - DomainEvent::IssueStatusChanged { - issue_ref, - status, - version, - } => Self::IssueStatusChanged { - issue_ref: issue_ref.to_string(), - status: issue_status_wire(*status).to_owned(), - version: version.get(), - }, - DomainEvent::IssuePriorityChanged { - issue_ref, - priority, - version, - } => Self::IssuePriorityChanged { - issue_ref: issue_ref.to_string(), - priority: issue_priority_wire(*priority).to_owned(), - version: version.get(), - }, - DomainEvent::IssueCarnetUpdated { issue_ref, version } => Self::IssueCarnetUpdated { - issue_ref: issue_ref.to_string(), - version: version.get(), - }, - DomainEvent::IssueLinked { - issue_ref, - target, - kind, - version, - } => Self::IssueLinked { - issue_ref: issue_ref.to_string(), - target: target.to_string(), - kind: issue_link_kind_wire(*kind).to_owned(), - version: version.get(), - }, - DomainEvent::IssueUnlinked { - issue_ref, - target, - kind, - version, - } => Self::IssueUnlinked { - issue_ref: issue_ref.to_string(), - target: target.to_string(), - kind: issue_link_kind_wire(*kind).to_owned(), - version: version.get(), - }, - DomainEvent::IssueAgentAssigned { - issue_ref, - agent_id, - version, - } => Self::IssueAgentAssigned { - issue_ref: issue_ref.to_string(), - agent_id: agent_id.to_string(), - version: version.get(), - }, - DomainEvent::IssueAgentUnassigned { - issue_ref, - agent_id, - version, - } => Self::IssueAgentUnassigned { - issue_ref: issue_ref.to_string(), - agent_id: agent_id.to_string(), - version: version.get(), - }, - DomainEvent::SprintCreated { sprint_id, order } => Self::SprintCreated { - sprint_id: sprint_id.to_string(), - order: order.get(), - }, - DomainEvent::SprintRenamed { - sprint_id, - name, - version, - } => Self::SprintRenamed { - sprint_id: sprint_id.to_string(), - name: name.clone(), - version: version.get(), - }, - DomainEvent::SprintReordered { - sprint_id, - order, - version, - } => Self::SprintReordered { - sprint_id: sprint_id.to_string(), - order: order.get(), - version: version.get(), - }, - DomainEvent::SprintDeleted { sprint_id } => Self::SprintDeleted { - sprint_id: sprint_id.to_string(), - }, - DomainEvent::IssueSprintChanged { - issue_ref, - from, - to, - version, - } => Self::IssueSprintChanged { - issue_ref: issue_ref.to_string(), - from: from.map(|id| id.to_string()), - to: to.map(|id| id.to_string()), - version: version.get(), - }, - DomainEvent::OrchestratorRequestProcessed { - requester_id, - action, - ok, - source, - } => Self::OrchestratorRequestProcessed { - requester_id: requester_id.clone(), - action: action.clone(), - ok: *ok, - source: (*source).into(), - }, - DomainEvent::OrchestratorChanged { - project_id, - orchestrator, - } => Self::OrchestratorChanged { - project_id: project_id.to_string(), - orchestrator: orchestrator.as_ref().map(|a| a.to_string()), - }, - DomainEvent::MemorySaved { slug } => Self::MemorySaved { - slug: slug.as_str().to_string(), - }, - DomainEvent::MemoryDeleted { slug } => Self::MemoryDeleted { - slug: slug.as_str().to_string(), - }, - DomainEvent::MemoryIndexRebuilt { project_id } => Self::MemoryIndexRebuilt { - project_id: project_id.to_string(), - }, - DomainEvent::EmbedderSuggested { - project_id, - ollama_detected, - onnx_cached, - vector_http_enabled, - vector_onnx_enabled, - } => Self::EmbedderSuggested { - project_id: project_id.to_string(), - ollama_detected: *ollama_detected, - onnx_cached: onnx_cached.clone(), - vector_http_enabled: *vector_http_enabled, - vector_onnx_enabled: *vector_onnx_enabled, - }, - DomainEvent::AgentRateLimited { - agent_id, - resets_at_ms, - } => Self::AgentRateLimited { - agent_id: agent_id.to_string(), - resets_at_ms: *resets_at_ms, - }, - DomainEvent::AgentResumeScheduled { - agent_id, - fire_at_ms, - } => Self::AgentResumeScheduled { - agent_id: agent_id.to_string(), - fire_at_ms: *fire_at_ms, - }, - DomainEvent::AgentResumeCancelled { agent_id } => Self::AgentResumeCancelled { - agent_id: agent_id.to_string(), - }, - DomainEvent::AgentResumed { agent_id } => Self::AgentResumed { - agent_id: agent_id.to_string(), - }, - DomainEvent::AgentRateLimitSuspected { - agent_id, - resets_at_ms, - } => Self::AgentRateLimitSuspected { - agent_id: agent_id.to_string(), - resets_at_ms: *resets_at_ms, - }, - DomainEvent::PtyOutput { session_id, bytes } => Self::PtyOutput { - session_id: session_id.to_string(), - bytes: bytes.clone(), - }, - } - } -} - /// Subscribes the relay to the bus and spawns a background task that forwards -/// every [`DomainEvent`] to the frontend as a [`DOMAIN_EVENT`] Tauri event. +/// every non-PTY [`DomainEvent`] to the frontend as Tauri events. /// -/// Uses the bus's raw async broadcast receiver so the relay runs cooperatively -/// on the Tokio runtime (no blocking thread). Returns immediately; the spawned -/// task lives for the duration of the app. +/// High-frequency `PtyOutput` is intentionally not relayed through this global +/// event; it goes through per-session PTY channels instead. pub fn spawn_relay(app: AppHandle, bus: &TokioBroadcastEventBus) { use tokio::sync::broadcast::error::RecvError; @@ -1117,12 +25,13 @@ pub fn spawn_relay(app: AppHandle, bus: &TokioBroadcastEventBus) { loop { match rx.recv().await { Ok(event) => { - // Skip high-frequency PTY output on the global channel. if matches!(event, DomainEvent::PtyOutput { .. }) { continue; } + let dto = DomainEventDto::from(&event); let _ = app.emit(DOMAIN_EVENT, dto); + match &event { DomainEvent::ModelServerStatusChanged { server_id, status } => { let _ = app.emit( @@ -1152,229 +61,9 @@ pub fn spawn_relay(app: AppHandle, bus: &TokioBroadcastEventBus) { _ => {} } } - // The bus dropped some events for this slow receiver; keep going. Err(RecvError::Lagged(_)) => continue, - // The bus was dropped (app shutting down); stop the relay. Err(RecvError::Closed) => break, } } }); } - -#[cfg(test)] -mod tests { - use super::*; - use domain::ids::AgentId; - use domain::mailbox::TicketId; - use domain::{LocalModelServerId, ProjectId}; - use serde_json::json; - - fn agent(n: u128) -> AgentId { - AgentId::from_uuid(uuid::Uuid::from_u128(n)) - } - - fn server(n: u128) -> LocalModelServerId { - LocalModelServerId::from_uuid(uuid::Uuid::from_u128(n)) - } - - #[test] - fn model_server_status_changed_relays_ready_to_dto_and_wire() { - let dto = DomainEventDto::from(&DomainEvent::ModelServerStatusChanged { - server_id: server(35), - status: ModelServerLifecycleStatus::Ready { reused: true }, - }); - - let json = serde_json::to_value(&dto).expect("serialisable"); - assert_eq!(json["type"], "modelServerStatusChanged"); - assert_eq!(json["serverId"], server(35).to_string()); - assert_eq!(json["status"]["state"], "ready"); - assert_eq!(json["status"]["reused"], true); - } - - #[test] - fn model_server_status_changed_relays_downloading_to_dto_and_wire() { - let dto = DomainEventDto::from(&DomainEvent::ModelServerStatusChanged { - server_id: server(37), - status: ModelServerLifecycleStatus::Downloading { - downloaded_bytes: None, - total_bytes: None, - percent: None, - source: Some("Qwen/Qwen3-Coder-30B-A3B-Instruct-GGUF".to_owned()), - }, - }); - - let json = serde_json::to_value(&dto).expect("serialisable"); - assert_eq!(json["type"], "modelServerStatusChanged"); - assert_eq!(json["serverId"], server(37).to_string()); - assert_eq!(json["status"]["state"], "downloading"); - assert_eq!(json["status"]["downloadedBytes"], serde_json::Value::Null); - assert_eq!(json["status"]["totalBytes"], serde_json::Value::Null); - assert_eq!(json["status"]["percent"], serde_json::Value::Null); - assert_eq!( - json["status"]["source"], - "Qwen/Qwen3-Coder-30B-A3B-Instruct-GGUF" - ); - } - - #[test] - fn agent_launch_failed_relays_model_server_cause() { - let dto = DomainEventDto::from(&DomainEvent::AgentLaunchFailed { - agent_id: agent(36), - cause: "model_server".to_owned(), - code: "path_not_accessible".to_owned(), - message: "path not accessible: /models/missing.gguf".to_owned(), - }); - - let json = serde_json::to_value(&dto).expect("serialisable"); - assert_eq!(json["type"], "agentLaunchFailed"); - assert_eq!(json["agentId"], agent(36).to_string()); - assert_eq!(json["cause"], "model_server"); - assert_eq!(json["code"], "path_not_accessible"); - } - - /// Lot 2 : un `AgentLivenessChanged{Stalled}` du domaine se relaie en DTO - /// `Stalled` portant le même agent, et se sérialise en `"stalled"` (le mot que - /// le front badge). Garantit le câblage présentation de la détection de stall. - #[test] - fn liveness_changed_stalled_relays_to_dto_and_wire() { - let dto = DomainEventDto::from(&DomainEvent::AgentLivenessChanged { - agent_id: agent(7), - liveness: AgentLiveness::Stalled, - }); - let json = serde_json::to_value(&dto).expect("serialisable"); - assert_eq!(json["type"], "agentLivenessChanged"); - assert_eq!(json["agentId"], agent(7).to_string()); - assert_eq!(json["liveness"], "stalled"); - } - - /// La reprise `Stalled→Alive` se relaie en DTO `Alive` ⇒ wire `"alive"`. - #[test] - fn liveness_changed_alive_relays_to_dto_and_wire() { - let dto = DomainEventDto::from(&DomainEvent::AgentLivenessChanged { - agent_id: agent(3), - liveness: AgentLiveness::Alive, - }); - let json = serde_json::to_value(&dto).expect("serialisable"); - assert_eq!(json["type"], "agentLivenessChanged"); - assert_eq!(json["liveness"], "alive"); - } - - #[test] - fn agent_announcement_relays_to_camel_case_wire_with_requester_party() { - let project_id = ProjectId::from_uuid(uuid::Uuid::from_u128(1)); - let requester_agent = agent(2); - let target = agent(3); - let ticket = TicketId::from_uuid(uuid::Uuid::from_u128(4)); - - let human = DomainEventDto::from(&DomainEvent::AgentAnnouncement { - project_id, - requester: ConversationParty::User, - target, - ticket, - text: "statut humain".into(), - at_ms: 123_456, - }); - assert_eq!( - serde_json::to_value(&human).unwrap(), - json!({ - "type": "agentAnnouncement", - "projectId": project_id.to_string(), - "requester": "user", - "target": target.to_string(), - "ticketId": ticket.to_string(), - "text": "statut humain", - "atMs": 123456, - }) - ); - - let agent_requester = DomainEventDto::from(&DomainEvent::AgentAnnouncement { - project_id, - requester: ConversationParty::agent(requester_agent), - target, - ticket, - text: "statut agent".into(), - at_ms: 654_321, - }); - assert_eq!( - serde_json::to_value(&agent_requester).unwrap(), - json!({ - "type": "agentAnnouncement", - "projectId": project_id.to_string(), - "requester": requester_agent.to_string(), - "target": target.to_string(), - "ticketId": ticket.to_string(), - "text": "statut agent", - "atMs": 654321, - }) - ); - } - - /// LS6 : un `AgentRateLimited` du domaine se relaie en DTO portant le même agent - /// et l'heure de reset (époche-ms), et se sérialise en `"agentRateLimited"` avec - /// `resetsAtMs` — le fait neutre que le front badge « limité jusqu'à HH:MM ». - #[test] - fn rate_limited_relays_to_dto_and_wire() { - let dto = DomainEventDto::from(&DomainEvent::AgentRateLimited { - agent_id: agent(11), - resets_at_ms: Some(1_700_000_000_000), - }); - let json = serde_json::to_value(&dto).expect("serialisable"); - assert_eq!(json["type"], "agentRateLimited"); - assert_eq!(json["agentId"], agent(11).to_string()); - assert_eq!(json["resetsAtMs"], 1_700_000_000_000_i64); - } - - #[test] - fn issue_sprint_changed_relays_to_dto_and_wire() { - let from = domain::SprintId::from_uuid(uuid::Uuid::from_u128(41)); - let to = domain::SprintId::from_uuid(uuid::Uuid::from_u128(42)); - let dto = DomainEventDto::from(&DomainEvent::IssueSprintChanged { - issue_ref: domain::IssueRef::from(domain::IssueNumber::new(7).unwrap()), - from: Some(from), - to: Some(to), - version: domain::IssueVersion::new(3).unwrap(), - }); - let json = serde_json::to_value(&dto).expect("serialisable"); - assert_eq!(json["type"], "issueSprintChanged"); - assert_eq!(json["issueRef"], "#7"); - assert_eq!(json["from"], from.to_string()); - assert_eq!(json["to"], to.to_string()); - assert_eq!(json["version"], 3); - } - - #[test] - fn issue_deleted_relays_to_dto_and_wire() { - let project_id = domain::ProjectId::from_uuid(uuid::Uuid::from_u128(40)); - let sprint_id = domain::SprintId::from_uuid(uuid::Uuid::from_u128(42)); - let dto = DomainEventDto::from(&DomainEvent::IssueDeleted { - project_id, - issue_ref: domain::IssueRef::from(domain::IssueNumber::new(7).unwrap()), - freed_sprint: Some(sprint_id), - }); - let json = serde_json::to_value(&dto).expect("serialisable"); - assert_eq!(json["type"], "issueDeleted"); - assert_eq!(json["projectId"], project_id.to_string()); - assert_eq!(json["issueRef"], "#7"); - assert_eq!(json["freedSprint"], sprint_id.to_string()); - } - - #[test] - fn ticket_assistant_events_relay_to_dto_and_wire() { - let profile_id = domain::ProfileId::from_uuid(uuid::Uuid::from_u128(9)); - let opened = DomainEventDto::from(&DomainEvent::TicketAssistantOpened { - issue_ref: domain::IssueRef::from(domain::IssueNumber::new(7).unwrap()), - profile_id, - }); - let opened = serde_json::to_value(&opened).expect("serialisable"); - assert_eq!(opened["type"], "ticketAssistantOpened"); - assert_eq!(opened["issueRef"], "#7"); - assert_eq!(opened["profileId"], profile_id.to_string()); - - let closed = DomainEventDto::from(&DomainEvent::TicketAssistantClosed { - issue_ref: domain::IssueRef::from(domain::IssueNumber::new(7).unwrap()), - }); - let closed = serde_json::to_value(&closed).expect("serialisable"); - assert_eq!(closed["type"], "ticketAssistantClosed"); - assert_eq!(closed["issueRef"], "#7"); - } -} diff --git a/crates/app-tauri/src/server.rs b/crates/app-tauri/src/server.rs index 24d3be5..40a13ae 100644 --- a/crates/app-tauri/src/server.rs +++ b/crates/app-tauri/src/server.rs @@ -1,4029 +1,12 @@ -//! Secure HTTP driving adapter for `idea --serve`. +//! Compatibility wrapper for the historical `idea --serve` desktop binary path. //! -//! The shared backend core stays unaware of HTTP, cookies, origins and -//! WebSocket framing; this module owns the secure web driving adapter. +//! The HTTP/WebSocket adapter itself lives in `web-server`, so the standalone +//! `idea-serve` binary can be built without Tauri/WebKit dependencies. -use std::collections::HashSet; -use std::env; -use std::net::SocketAddr; -use std::path::{Path, PathBuf}; use std::process::ExitCode; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; - -use backend::stream::{OutputBridge, OutputSink, OutputSinkError}; -use bytes::Bytes; -use cookie::{Cookie, SameSite}; -use domain::events::DomainEvent; -use domain::ports::BackgroundTaskPortError; -use http::header::{HeaderValue, CONTENT_TYPE, COOKIE, ORIGIN, SET_COOKIE}; -#[cfg(test)] -use http::Request; -use http::{HeaderMap, Method, Response, StatusCode, Uri}; -use http_body_util::{BodyExt, Full}; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; -use tokio::sync::mpsc; -use uuid::Uuid; - -use application::{ - CloseTerminalInput, GetProjectWorkStateInput, LaunchAgentInput, McpRuntime, OpenProjectInput, - ResizeTerminalInput, RotateConversationLogInput, WriteToTerminalInput, -}; -use domain::ports::PtyHandle; -use domain::{Project, SessionId}; - -use crate::dto::{ - parse_agent_id, parse_node_id, parse_project_id, parse_session_id, parse_task_id, - BackgroundTaskDto, ErrorDto, HealthRequestDto, HealthResponseDto, LaunchAgentRequestDto, - OpenTerminalRequestDto, ProjectDto, ProjectListDto, ProjectWorkStateDto, TerminalSessionDto, -}; -use crate::events::DomainEventDto; -use crate::pty::PtyChunk; -use crate::state::AppState; - -const DEFAULT_LISTEN: &str = "127.0.0.1:17373"; -const SESSION_COOKIE: &str = "idea_session"; -const WS_PATH: &str = "/api/ws"; -const WS_MAGIC: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; -const WS_MAX_PAYLOAD: usize = 64 * 1024; -const WS_OUTPUT_BUFFER: usize = 512; - -type ResponseBody = Full; /// Runs the `idea --serve` subcommand from already-split CLI arguments. +#[must_use] pub fn run_from_args(args: Vec) -> ExitCode { - let config = match ServerConfig::from_args(args).and_then(|config| { - config.validate()?; - Ok(config) - }) { - Ok(config) => config, - Err(err) => { - eprintln!("idea --serve: {err}"); - return ExitCode::from(2); - } - }; - - let state = Arc::new(ServerState::new(config.clone())); - eprintln!("IdeA pairing code: {}", state.pairing_code()); - eprintln!( - "idea --serve: app data dir = {}", - config.app_data_dir.display() - ); - eprintln!("IdeA server listening on {}", config.listen); - - match tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - { - Ok(runtime) => match runtime.block_on(run_server(config, state)) { - Ok(()) => ExitCode::SUCCESS, - Err(err) => { - eprintln!("idea --serve: {err}"); - ExitCode::from(1) - } - }, - Err(err) => { - eprintln!("idea --serve: failed to build runtime: {err}"); - ExitCode::from(1) - } - } -} - -#[derive(Clone)] -struct ServerConfig { - listen: SocketAddr, - public_origin: Option, - allow_remote: bool, - trust_reverse_proxy: bool, - app_data_dir: PathBuf, - web_root: PathBuf, -} - -impl ServerConfig { - fn from_args(args: Vec) -> Result { - let mut listen = DEFAULT_LISTEN - .parse::() - .expect("default listen address is valid"); - let mut public_origin = None; - let mut allow_remote = false; - let mut trust_reverse_proxy = false; - let mut app_data_dir = default_app_data_dir(); - let mut web_root = None; - - let mut it = args.into_iter(); - while let Some(arg) = it.next() { - match arg.as_str() { - "--listen" => { - let value = it - .next() - .ok_or_else(|| "--listen requires an address".to_owned())?; - listen = value - .parse() - .map_err(|_| format!("invalid --listen address: {value}"))?; - } - "--public-origin" => { - let value = it - .next() - .ok_or_else(|| "--public-origin requires an origin".to_owned())?; - public_origin = Some(value); - } - "--allow-remote" => allow_remote = true, - "--trust-reverse-proxy" => trust_reverse_proxy = true, - "--app-data-dir" => { - let value = it - .next() - .ok_or_else(|| "--app-data-dir requires a path".to_owned())?; - app_data_dir = PathBuf::from(value); - } - "--web-root" => { - let value = it - .next() - .ok_or_else(|| "--web-root requires a path".to_owned())?; - web_root = Some(PathBuf::from(value)); - } - "--help" | "-h" => return Err(Self::usage()), - other => return Err(format!("unknown --serve argument: {other}")), - } - } - let web_root = resolve_web_root(web_root)?; - - Ok(Self { - listen, - public_origin, - allow_remote, - trust_reverse_proxy, - app_data_dir, - web_root, - }) - } - - fn validate(&self) -> Result<(), String> { - if let Some(origin) = &self.public_origin { - validate_origin(origin)?; - } - - if !self.listen.ip().is_loopback() && !self.allow_remote { - return Err( - "refusing non-loopback bind without --allow-remote and HTTPS proxy config" - .to_owned(), - ); - } - - if self.allow_remote { - let Some(origin) = &self.public_origin else { - return Err("--allow-remote requires --public-origin https://...".to_owned()); - }; - if !origin.starts_with("https://") { - return Err("--allow-remote requires an HTTPS public origin".to_owned()); - } - if !self.trust_reverse_proxy { - return Err("--allow-remote requires --trust-reverse-proxy".to_owned()); - } - } - - Ok(()) - } - - fn secure_cookie(&self) -> bool { - self.allow_remote - || self - .public_origin - .as_deref() - .is_some_and(|o| o.starts_with("https://")) - } - - fn usage() -> String { - "usage: idea --serve [--listen IP:PORT] [--app-data-dir PATH] [--web-root PATH] [--allow-remote --public-origin https://host --trust-reverse-proxy]".to_owned() - } -} - -fn resolve_web_root(explicit: Option) -> Result { - if let Some(path) = explicit { - return validate_web_root(path, "--web-root"); - } - if let Some(path) = env::var_os("IDEA_WEB_ROOT").map(PathBuf::from) { - return validate_web_root(path, "IDEA_WEB_ROOT"); - } - - let mut candidates = Vec::new(); - if let Ok(exe) = env::current_exe() { - if let Some(dir) = exe.parent() { - candidates.push(dir.join("web")); - candidates.push(dir.join("frontend").join("dist")); - } - } - if let Ok(cwd) = env::current_dir() { - candidates.push(cwd.join("frontend").join("dist")); - } - - for candidate in candidates { - if candidate.join("index.html").is_file() { - return Ok(candidate); - } - } - - Err("web assets not found: build frontend/dist or pass --web-root PATH".to_owned()) -} - -fn validate_web_root(path: PathBuf, source: &str) -> Result { - if path.join("index.html").is_file() { - Ok(path) - } else { - Err(format!( - "{source} does not contain an index.html: {}", - path.display() - )) - } -} - -struct ServerState { - config: ServerConfig, - app: AppState, - pairing_code: String, - sessions: Mutex>, - ws_pty_bridge: Arc>, - security_logger: Arc, -} - -impl ServerState { - fn new(config: ServerConfig) -> Self { - Self { - app: AppState::build(config.app_data_dir.clone()), - config, - pairing_code: new_pairing_code(), - sessions: Mutex::new(HashSet::new()), - ws_pty_bridge: Arc::new(OutputBridge::new()), - security_logger: Arc::new(StderrSecurityLogger), - } - } - - #[cfg(test)] - fn new_for_test(config: ServerConfig, pairing_code: impl Into) -> Self { - Self::new_for_test_with_logger(config, pairing_code, Arc::new(NoopSecurityLogger)) - } - - #[cfg(test)] - fn new_for_test_with_logger( - config: ServerConfig, - pairing_code: impl Into, - security_logger: Arc, - ) -> Self { - Self { - app: AppState::build(config.app_data_dir.clone()), - config, - pairing_code: pairing_code.into(), - sessions: Mutex::new(HashSet::new()), - ws_pty_bridge: Arc::new(OutputBridge::new()), - security_logger, - } - } - - fn pairing_code(&self) -> &str { - &self.pairing_code - } - - fn create_session(&self) -> String { - let token = new_session_token(); - if let Ok(mut sessions) = self.sessions.lock() { - sessions.insert(token.clone()); - } - token - } - - fn has_session(&self, token: &str) -> bool { - self.sessions - .lock() - .map(|sessions| sessions.contains(token)) - .unwrap_or(false) - } - - fn revoke_session(&self, token: &str) -> bool { - self.sessions - .lock() - .map(|mut sessions| sessions.remove(token)) - .unwrap_or(false) - } - - fn log_security(&self, event: SecurityLogEvent) { - self.security_logger.log(event); - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum SecurityLogEvent { - PairingSucceeded { - origin: Option, - }, - PairingFailed { - origin: Option, - reason: &'static str, - }, - OriginRejected { - origin: Option, - route: String, - }, - WsUpgradeRejected { - origin: Option, - reason: &'static str, - }, - SessionRevoked { - origin: Option, - }, -} - -trait SecurityLogger: Send + Sync { - fn log(&self, event: SecurityLogEvent); -} - -struct StderrSecurityLogger; - -impl SecurityLogger for StderrSecurityLogger { - fn log(&self, event: SecurityLogEvent) { - eprintln!("idea --serve security: {}", security_log_line(&event)); - } -} - -#[cfg(test)] -struct NoopSecurityLogger; - -#[cfg(test)] -impl SecurityLogger for NoopSecurityLogger { - fn log(&self, _event: SecurityLogEvent) {} -} - -fn security_log_line(event: &SecurityLogEvent) -> String { - match event { - SecurityLogEvent::PairingSucceeded { origin } => { - format!("pairing succeeded origin={}", origin_label(origin)) - } - SecurityLogEvent::PairingFailed { origin, reason } => { - format!( - "pairing failed reason={reason} origin={}", - origin_label(origin) - ) - } - SecurityLogEvent::OriginRejected { origin, route } => { - format!( - "origin rejected route={} origin={}", - route, - origin_label(origin) - ) - } - SecurityLogEvent::WsUpgradeRejected { origin, reason } => { - format!( - "websocket upgrade rejected reason={reason} origin={}", - origin_label(origin) - ) - } - SecurityLogEvent::SessionRevoked { origin } => { - format!("session revoked origin={}", origin_label(origin)) - } - } -} - -fn origin_label(origin: &Option) -> &str { - origin.as_deref().unwrap_or("") -} - -async fn run_server(config: ServerConfig, state: Arc) -> Result<(), String> { - let listener = TcpListener::bind(config.listen) - .await - .map_err(|err| format!("failed to bind {}: {err}", config.listen))?; - - loop { - let (stream, _) = listener - .accept() - .await - .map_err(|err| format!("failed to accept connection: {err}"))?; - let state = Arc::clone(&state); - tokio::spawn(async move { - if let Err(err) = handle_tcp_connection(stream, state).await { - eprintln!("idea --serve: connection error: {err}"); - } - }); - } -} - -async fn handle_tcp_connection( - mut stream: tokio::net::TcpStream, - state: Arc, -) -> Result<(), String> { - let mut buffer = Vec::with_capacity(8192); - let mut chunk = [0_u8; 2048]; - let header_end = loop { - let read = stream - .read(&mut chunk) - .await - .map_err(|err| format!("failed to read request: {err}"))?; - if read == 0 { - return Ok(()); - } - buffer.extend_from_slice(&chunk[..read]); - if buffer.len() > 1024 * 1024 { - return Err("request too large".to_owned()); - } - if let Some(pos) = find_header_end(&buffer) { - break pos; - } - }; - - let (method, uri, headers, content_length) = parse_http_request_head(&buffer[..header_end])?; - if method == Method::GET && uri.path() == WS_PATH { - return handle_ws_upgrade(stream, headers, state).await; - } - - let body_start = header_end + 4; - while buffer.len() < body_start + content_length { - let read = stream - .read(&mut chunk) - .await - .map_err(|err| format!("failed to read request body: {err}"))?; - if read == 0 { - break; - } - buffer.extend_from_slice(&chunk[..read]); - if buffer.len() > 1024 * 1024 { - return Err("request too large".to_owned()); - } - } - if buffer.len() < body_start + content_length { - return Err("truncated request body".to_owned()); - } - - let response = dispatch_http( - method, - uri, - headers, - Bytes::copy_from_slice(&buffer[body_start..body_start + content_length]), - state, - ) - .await; - write_http_response(&mut stream, response).await -} - -#[cfg(test)] -async fn handle_request( - req: Request, - state: Arc, -) -> Response { - let (parts, body) = req.into_parts(); - let body = match body.collect().await { - Ok(body) => body.to_bytes(), - Err(err) => { - return error_response( - StatusCode::BAD_REQUEST, - "INVALID", - format!("invalid request body: {err}"), - None, - ); - } - }; - - dispatch_http( - parts.method, - parts.uri, - parts.headers, - body, - Arc::clone(&state), - ) - .await -} - -fn find_header_end(buffer: &[u8]) -> Option { - buffer.windows(4).position(|window| window == b"\r\n\r\n") -} - -fn parse_http_request_head(head: &[u8]) -> Result<(Method, Uri, HeaderMap, usize), String> { - let text = std::str::from_utf8(head).map_err(|_| "request head is not UTF-8".to_owned())?; - let mut lines = text.split("\r\n"); - let request_line = lines - .next() - .ok_or_else(|| "missing request line".to_owned())?; - let mut request_parts = request_line.split_whitespace(); - let method = request_parts - .next() - .ok_or_else(|| "missing method".to_owned())? - .parse::() - .map_err(|_| "invalid method".to_owned())?; - let uri = request_parts - .next() - .ok_or_else(|| "missing uri".to_owned())? - .parse::() - .map_err(|_| "invalid uri".to_owned())?; - let mut headers = HeaderMap::new(); - let mut content_length = 0; - - for line in lines { - if line.is_empty() { - continue; - } - let Some((name, value)) = line.split_once(':') else { - return Err("invalid header line".to_owned()); - }; - let name = http::header::HeaderName::from_bytes(name.trim().as_bytes()) - .map_err(|_| "invalid header name".to_owned())?; - let value = - HeaderValue::from_str(value.trim()).map_err(|_| "invalid header value".to_owned())?; - if name == http::header::CONTENT_LENGTH { - content_length = value - .to_str() - .ok() - .and_then(|raw| raw.parse::().ok()) - .ok_or_else(|| "invalid content-length".to_owned())?; - } - headers.insert(name, value); - } - - Ok((method, uri, headers, content_length)) -} - -async fn write_http_response( - stream: &mut tokio::net::TcpStream, - response: Response, -) -> Result<(), String> { - let status = response.status(); - let headers = response.headers().clone(); - let body = response - .into_body() - .collect() - .await - .map_err(|err| format!("failed to collect response: {err}"))? - .to_bytes(); - let reason = status.canonical_reason().unwrap_or("Unknown"); - let mut bytes = format!("HTTP/1.1 {} {}\r\n", status.as_u16(), reason).into_bytes(); - for (name, value) in &headers { - bytes.extend_from_slice(name.as_str().as_bytes()); - bytes.extend_from_slice(b": "); - bytes.extend_from_slice(value.as_bytes()); - bytes.extend_from_slice(b"\r\n"); - } - bytes.extend_from_slice(format!("content-length: {}\r\n", body.len()).as_bytes()); - bytes.extend_from_slice(b"connection: close\r\n\r\n"); - bytes.extend_from_slice(&body); - stream - .write_all(&bytes) - .await - .map_err(|err| format!("failed to write response: {err}")) -} - -async fn handle_ws_upgrade( - mut stream: tokio::net::TcpStream, - headers: HeaderMap, - state: Arc, -) -> Result<(), String> { - let accept = match validate_ws_upgrade(&headers, &state) { - Ok(accept) => accept, - Err(response) => return write_http_response(&mut stream, *response).await, - }; - - let response = format!( - "HTTP/1.1 101 Switching Protocols\r\n\ - upgrade: websocket\r\n\ - connection: Upgrade\r\n\ - sec-websocket-accept: {accept}\r\n\r\n" - ); - stream - .write_all(response.as_bytes()) - .await - .map_err(|err| format!("failed to write websocket upgrade: {err}"))?; - run_ws_connection(stream, state).await -} - -fn validate_ws_upgrade( - headers: &HeaderMap, - state: &ServerState, -) -> Result>> { - let origin = match validate_request_origin(headers, &state.config) { - Ok(origin) => origin, - Err(response) => { - state.log_security(SecurityLogEvent::OriginRejected { - origin: request_origin(headers), - route: WS_PATH.to_owned(), - }); - return Err(response); - } - }; - let Some(token) = session_cookie(headers) else { - state.log_security(SecurityLogEvent::WsUpgradeRejected { - origin: origin.clone(), - reason: "missing_session", - }); - return Err(Box::new(error_response( - StatusCode::UNAUTHORIZED, - "UNAUTHORIZED", - "missing session cookie", - origin.as_deref(), - ))); - }; - if !state.has_session(&token) { - state.log_security(SecurityLogEvent::WsUpgradeRejected { - origin: origin.clone(), - reason: "invalid_session", - }); - return Err(Box::new(error_response( - StatusCode::UNAUTHORIZED, - "UNAUTHORIZED", - "invalid session cookie", - origin.as_deref(), - ))); - } - if !header_contains_token(headers, "upgrade", "websocket") - || !header_contains_token(headers, "connection", "upgrade") - { - state.log_security(SecurityLogEvent::WsUpgradeRejected { - origin: origin.clone(), - reason: "invalid_upgrade", - }); - return Err(Box::new(error_response( - StatusCode::BAD_REQUEST, - "INVALID", - "missing websocket upgrade headers", - origin.as_deref(), - ))); - } - let Some(key) = headers - .get("sec-websocket-key") - .and_then(|value| value.to_str().ok()) - else { - state.log_security(SecurityLogEvent::WsUpgradeRejected { - origin: origin.clone(), - reason: "missing_key", - }); - return Err(Box::new(error_response( - StatusCode::BAD_REQUEST, - "INVALID", - "missing Sec-WebSocket-Key", - origin.as_deref(), - ))); - }; - Ok(websocket_accept(key)) -} - -fn header_contains_token(headers: &HeaderMap, name: &str, needle: &str) -> bool { - headers - .get(name) - .and_then(|value| value.to_str().ok()) - .is_some_and(|value| { - value - .split(',') - .any(|token| token.trim().eq_ignore_ascii_case(needle)) - }) -} - -async fn run_ws_connection( - stream: tokio::net::TcpStream, - state: Arc, -) -> Result<(), String> { - let (mut reader, mut writer) = stream.into_split(); - let (tx, mut rx) = mpsc::channel::(WS_OUTPUT_BUFFER); - let owned = Arc::new(Mutex::new(Vec::<(SessionId, u64)>::new())); - let event_relay_task = - spawn_ws_domain_event_relay(state.app.event_bus.raw_receiver(), tx.clone()); - let writer_task = tokio::spawn(async move { - while let Some(frame) = rx.recv().await { - let text = serde_json::to_vec(&frame) - .map_err(|err| format!("failed to encode server frame: {err}"))?; - let bytes = encode_ws_frame(WsOpcode::Text, &text); - writer - .write_all(&bytes) - .await - .map_err(|err| format!("failed to write websocket frame: {err}"))?; - } - Ok::<(), String>(()) - }); - - loop { - let frame = match read_ws_frame(&mut reader).await { - Ok(frame) => frame, - Err(err) => { - let _ = tx - .send(ServerFrame::error( - None, - None, - "WS_PROTOCOL", - err.to_string(), - )) - .await; - break; - } - }; - match frame.opcode { - WsOpcode::Text => { - let parsed = serde_json::from_slice::(&frame.payload) - .map_err(|err| format!("invalid client frame JSON: {err}")); - match parsed { - Ok(frame) => { - handle_client_frame(frame, &state, &tx, &owned).await; - } - Err(err) => { - let _ = tx - .send(ServerFrame::error(None, None, "INVALID", err)) - .await; - } - } - } - WsOpcode::Ping => { - let _ = tx.send(ServerFrame::pong()).await; - } - WsOpcode::Close => break, - WsOpcode::Pong => {} - WsOpcode::Binary => { - let _ = tx - .send(ServerFrame::error( - None, - None, - "UNSUPPORTED", - "binary websocket frames are not supported", - )) - .await; - } - } - } - - if let Ok(owned) = owned.lock() { - for (session, gen) in owned.iter() { - state.ws_pty_bridge.unregister_if(session, *gen); - } - } - event_relay_task.abort(); - drop(tx); - writer_task - .await - .map_err(|err| format!("websocket writer task failed: {err}"))? -} - -fn spawn_ws_domain_event_relay( - mut rx: tokio::sync::broadcast::Receiver, - tx: mpsc::Sender, -) -> tokio::task::JoinHandle<()> { - use tokio::sync::broadcast::error::RecvError; - - tokio::spawn(async move { - loop { - match rx.recv().await { - Ok(event) => { - if matches!(event, DomainEvent::PtyOutput { .. }) { - continue; - } - match tx.try_send(ServerFrame::domain_event(&event)) { - Ok(()) | Err(mpsc::error::TrySendError::Full(_)) => {} - Err(mpsc::error::TrySendError::Closed(_)) => break, - } - } - Err(RecvError::Lagged(_)) => continue, - Err(RecvError::Closed) => break, - } - } - }) -} - -async fn handle_client_frame( - frame: ClientFrame, - state: &Arc, - tx: &mpsc::Sender, - owned: &Arc>>, -) { - let result = match frame.kind.as_str() { - "terminal.open" | "open_terminal" => ws_open_terminal(&frame, state, tx, owned).await, - "agent.launch" | "launch_agent" => ws_launch_agent(&frame, state, tx, owned).await, - "terminal.attach" | "attach_terminal" => ws_attach_terminal(&frame, state, tx, owned).await, - "terminal.input" | "input" => ws_input(&frame, state), - "terminal.resize" | "resize" => ws_resize(&frame, state), - "terminal.detach" | "detach" => ws_detach(&frame, state, owned), - "terminal.close" | "close" => match ws_close(&frame, state, owned).await { - Ok((session_id, exit_code)) => { - let _ = tx - .send(ServerFrame::status(session_id, "exited", exit_code)) - .await; - Ok(()) - } - Err(err) => Err(err), - }, - "ping" => { - let _ = tx.send(ServerFrame::pong()).await; - Ok(()) - } - _ => Err(ErrorDto { - code: "UNKNOWN_FRAME".to_owned(), - message: format!("unknown websocket frame kind: {}", frame.kind), - }), - }; - - if let Err(err) = result { - let _ = tx - .send(ServerFrame::error( - Some(frame.id), - payload_session_id(&frame.payload), - err.code, - err.message, - )) - .await; - } -} - -async fn ws_open_terminal( - frame: &ClientFrame, - state: &Arc, - tx: &mpsc::Sender, - owned: &Arc>>, -) -> Result<(), ErrorDto> { - let request_value = frame - .payload - .get("request") - .cloned() - .unwrap_or_else(|| frame.payload.clone()); - let request: OpenTerminalRequestDto = - serde_json::from_value(request_value).map_err(invalid_args_error)?; - let output = state - .app - .open_terminal - .execute(request.into()) - .await - .map_err(ErrorDto::from)?; - let dto = TerminalSessionDto::from(output); - let sid = parse_session_id(&dto.session_id)?; - let sink = WsPtySink::new(sid, tx.clone(), 0); - tx.send(ServerFrame::attached(&frame.id, &dto, Vec::new(), 0, false)) - .await - .map_err(|_| ErrorDto { - code: "WS_CLOSED".to_owned(), - message: "websocket output closed".to_owned(), - })?; - attach_sink_and_pump(state, sid, sink, owned) -} - -async fn ws_launch_agent( - frame: &ClientFrame, - state: &Arc, - tx: &mpsc::Sender, - owned: &Arc>>, -) -> Result<(), ErrorDto> { - let request_value = frame - .payload - .get("request") - .cloned() - .unwrap_or_else(|| frame.payload.clone()); - let request: LaunchAgentRequestDto = - serde_json::from_value(request_value).map_err(invalid_args_error)?; - let output = execute_launch_agent_for_ws(state, request).await?; - send_launch_agent_attached(frame, state, tx, owned, output).await -} - -async fn send_launch_agent_attached( - frame: &ClientFrame, - state: &Arc, - tx: &mpsc::Sender, - owned: &Arc>>, - output: application::LaunchAgentOutput, -) -> Result<(), ErrorDto> { - if output.structured.is_some() { - return Err(ErrorDto { - code: "UNSUPPORTED".to_owned(), - message: "structured agent sessions do not stream over the PTY websocket".to_owned(), - }); - } - let session_id = output.session.id; - let dto = TerminalSessionDto::from(output); - let sid = parse_session_id(&dto.session_id)?; - let sink = WsPtySink::new(sid, tx.clone(), 0); - tx.send(ServerFrame::attached(&frame.id, &dto, Vec::new(), 0, false)) - .await - .map_err(|_| ErrorDto { - code: "WS_CLOSED".to_owned(), - message: "websocket output closed".to_owned(), - })?; - attach_sink_and_pump(state, session_id, sink, owned) -} - -async fn execute_launch_agent_for_ws( - state: &Arc, - request: LaunchAgentRequestDto, -) -> Result { - let project = resolve_project_readonly(&request.project_id, &state.app).await?; - let agent_id = parse_agent_id(&request.agent_id)?; - let node_id = request.node_id.as_deref().map(parse_node_id).transpose()?; - let mcp_runtime = crate::mcp_endpoint::idea_exe_path().map(|exe| McpRuntime { - exe, - endpoint: crate::mcp_endpoint::mcp_endpoint(&project.id) - .as_cli_arg() - .to_owned(), - project_id: project.id.as_uuid().simple().to_string(), - requester: agent_id.to_string(), - }); - - state.app.reconcile_claude_run_dirs(&project).await; - - let resume_project = project.clone(); - let rotation_root = project.root.clone(); - let watch_root = project.root.clone(); - let output = state - .app - .launch_agent - .execute(LaunchAgentInput { - project, - agent_id, - rows: request.rows, - cols: request.cols, - node_id, - conversation_id: request.conversation_id.clone(), - mcp_runtime, - allow_structured_alongside_pty: false, - }) - .await - .map_err(ErrorDto::from)?; - - if let Ok(mut contexts) = state.app.resume_contexts.lock() { - contexts.insert( - agent_id, - crate::state::ResumeContext { - project: resume_project, - rows: request.rows, - cols: request.cols, - }, - ); - } - - if let Some(profile) = output.profile.as_ref() { - state.app.arm_turn_watch( - &watch_root, - agent_id, - profile, - output.assigned_conversation_id.clone(), - ); - } - - if let Some(conversation) = request - .conversation_id - .as_deref() - .and_then(|raw| uuid::Uuid::parse_str(raw).ok()) - .map(domain::ConversationId::from_uuid) - { - let rotate = Arc::clone(&state.app.rotate_conversation_log); - tokio::spawn(async move { - let _ = rotate - .execute(RotateConversationLogInput { - project_root: rotation_root, - conversation, - }) - .await; - }); - } - - Ok(output) -} - -async fn ws_attach_terminal( - frame: &ClientFrame, - state: &Arc, - tx: &mpsc::Sender, - owned: &Arc>>, -) -> Result<(), ErrorDto> { - let session_id = payload_string(&frame.payload, "sessionId")?; - let sid = parse_session_id(&session_id)?; - let handle = PtyHandle { session_id: sid }; - let scrollback = state - .app - .pty_port - .scrollback(&handle) - .map_err(|err| ErrorDto::from(application::AppError::from(err)))?; - let rows = payload_u16(&frame.payload, "rows").unwrap_or(24); - let cols = payload_u16(&frame.payload, "cols").unwrap_or(80); - let dto = TerminalSessionDto { - session_id, - cwd: String::new(), - rows, - cols, - assigned_conversation_id: None, - engine_session_id: None, - cell_kind: crate::dto::CellKind::Pty, - }; - let next_seq = u64::from(!scrollback.is_empty()); - tx.send(ServerFrame::attached( - &frame.id, - &dto, - scrollback.clone(), - next_seq, - frame.payload.get("lastSeq").is_some(), - )) - .await - .map_err(|_| ErrorDto { - code: "WS_CLOSED".to_owned(), - message: "websocket output closed".to_owned(), - })?; - let sink = WsPtySink::new(sid, tx.clone(), next_seq); - attach_sink_and_pump(state, sid, sink, owned) -} - -fn attach_sink_and_pump( - state: &Arc, - sid: SessionId, - sink: WsPtySink, - owned: &Arc>>, -) -> Result<(), ErrorDto> { - let gen = state.ws_pty_bridge.register(sid, Arc::new(sink)); - if let Ok(mut owned) = owned.lock() { - owned.push((sid, gen)); - } - let handle = PtyHandle { session_id: sid }; - let stream = state - .app - .pty_port - .subscribe_output(&handle) - .map_err(|err| ErrorDto::from(application::AppError::from(err)))?; - let bridge = Arc::clone(&state.ws_pty_bridge); - std::thread::spawn(move || { - for chunk in stream { - if !bridge.send_output(&sid, chunk) { - break; - } - } - bridge.unregister_if(&sid, gen); - }); - Ok(()) -} - -fn ws_input(frame: &ClientFrame, state: &Arc) -> Result<(), ErrorDto> { - let sid = parse_session_id(&payload_string(&frame.payload, "sessionId")?)?; - let bytes = payload_bytes(&frame.payload)?; - state - .app - .write_terminal - .execute(WriteToTerminalInput { - session_id: sid, - data: bytes, - }) - .map_err(ErrorDto::from) -} - -fn ws_resize(frame: &ClientFrame, state: &Arc) -> Result<(), ErrorDto> { - let sid = parse_session_id(&payload_string(&frame.payload, "sessionId")?)?; - let rows = payload_u16(&frame.payload, "rows").ok_or_else(|| ErrorDto { - code: "INVALID".to_owned(), - message: "resize requires rows".to_owned(), - })?; - let cols = payload_u16(&frame.payload, "cols").ok_or_else(|| ErrorDto { - code: "INVALID".to_owned(), - message: "resize requires cols".to_owned(), - })?; - state - .app - .resize_terminal - .execute(ResizeTerminalInput { - session_id: sid, - rows, - cols, - }) - .map_err(ErrorDto::from) -} - -fn ws_detach( - frame: &ClientFrame, - state: &Arc, - owned: &Arc>>, -) -> Result<(), ErrorDto> { - let sid = parse_session_id(&payload_string(&frame.payload, "sessionId")?)?; - if let Ok(mut owned) = owned.lock() { - if let Some(index) = owned.iter().rposition(|(session, _)| *session == sid) { - let (_, gen) = owned.remove(index); - state.ws_pty_bridge.unregister_if(&sid, gen); - } - } - Ok(()) -} - -async fn ws_close( - frame: &ClientFrame, - state: &Arc, - owned: &Arc>>, -) -> Result<(SessionId, Option), ErrorDto> { - let sid = parse_session_id(&payload_string(&frame.payload, "sessionId")?)?; - ws_detach(frame, state, owned)?; - let output = state - .app - .close_terminal - .execute(CloseTerminalInput { session_id: sid }) - .await - .map_err(ErrorDto::from)?; - Ok((sid, output.code)) -} - -async fn dispatch_http( - method: Method, - uri: Uri, - headers: HeaderMap, - body: Bytes, - state: Arc, -) -> Response { - if has_forbidden_query_secret(&uri) { - return error_response( - StatusCode::BAD_REQUEST, - "INVALID", - "secrets in URL query strings are forbidden", - None, - ); - } - - if !is_api_path(uri.path()) { - return serve_static(&state.config.web_root, method, uri.path()).await; - } - - let origin = match validate_request_origin(&headers, &state.config) { - Ok(origin) => origin, - Err(response) => { - state.log_security(SecurityLogEvent::OriginRejected { - origin: request_origin(&headers), - route: uri.path().to_owned(), - }); - return *response; - } - }; - - if method == Method::OPTIONS { - return cors_response(StatusCode::NO_CONTENT, origin.as_deref()); - } - - match (method, uri.path()) { - (Method::POST, "/api/pair") => pair(body, state, origin.as_deref()).await, - (Method::POST, "/api/logout") => { - let Some(token) = session_cookie(&headers) else { - return error_response( - StatusCode::UNAUTHORIZED, - "UNAUTHORIZED", - "missing session cookie", - origin.as_deref(), - ); - }; - if !state.revoke_session(&token) { - return error_response( - StatusCode::UNAUTHORIZED, - "UNAUTHORIZED", - "invalid session cookie", - origin.as_deref(), - ); - } - state.log_security(SecurityLogEvent::SessionRevoked { - origin: origin.clone(), - }); - logout_response(&state, origin.as_deref()) - } - (Method::POST, "/api/invoke") => { - let Some(token) = session_cookie(&headers) else { - return error_response( - StatusCode::UNAUTHORIZED, - "UNAUTHORIZED", - "missing session cookie", - origin.as_deref(), - ); - }; - if !state.has_session(&token) { - return error_response( - StatusCode::UNAUTHORIZED, - "UNAUTHORIZED", - "invalid session cookie", - origin.as_deref(), - ); - } - invoke(body, state, origin.as_deref()).await - } - (_, "/api/pair" | "/api/invoke" | "/api/logout") => error_response( - StatusCode::METHOD_NOT_ALLOWED, - "METHOD_NOT_ALLOWED", - "method not allowed", - origin.as_deref(), - ), - _ if is_api_path(uri.path()) => error_response( - StatusCode::NOT_FOUND, - "NOT_FOUND", - "route not found", - origin.as_deref(), - ), - _ => error_response(StatusCode::NOT_FOUND, "NOT_FOUND", "route not found", None), - } -} - -fn is_api_path(path: &str) -> bool { - path == "/api" || path.starts_with("/api/") -} - -async fn serve_static(web_root: &Path, method: Method, path: &str) -> Response { - if !matches!(method, Method::GET | Method::HEAD) { - return error_response( - StatusCode::METHOD_NOT_ALLOWED, - "METHOD_NOT_ALLOWED", - "method not allowed", - None, - ); - } - - let Some(route) = static_route(web_root, path) else { - return error_response(StatusCode::NOT_FOUND, "NOT_FOUND", "route not found", None); - }; - - let file = if route.candidate.is_file() { - route.candidate - } else if route.spa_fallback { - web_root.join("index.html") - } else { - return error_response(StatusCode::NOT_FOUND, "NOT_FOUND", "route not found", None); - }; - - let bytes = match tokio::fs::read(&file).await { - Ok(bytes) => bytes, - Err(_) => { - return error_response(StatusCode::NOT_FOUND, "NOT_FOUND", "route not found", None); - } - }; - static_file_response(&file, bytes, method == Method::HEAD) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct StaticRoute { - candidate: PathBuf, - spa_fallback: bool, -} - -fn static_route(web_root: &Path, request_path: &str) -> Option { - if !request_path.starts_with('/') || request_path.contains('\\') { - return None; - } - let lower = request_path.to_ascii_lowercase(); - if lower.contains("%2e") || lower.contains("%2f") || lower.contains("%5c") { - return None; - } - - let mut candidate = web_root.to_path_buf(); - let mut last = ""; - for segment in request_path - .split('/') - .filter(|segment| !segment.is_empty()) - { - if segment == "." || segment == ".." || segment.starts_with('.') || segment.contains('%') { - return None; - } - candidate.push(segment); - last = segment; - } - - if request_path == "/" { - return Some(StaticRoute { - candidate: web_root.join("index.html"), - spa_fallback: false, - }); - } - - Some(StaticRoute { - candidate, - spa_fallback: !last.contains('.'), - }) -} - -fn static_file_response(path: &Path, bytes: Vec, head_only: bool) -> Response { - let is_index = path - .file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name == "index.html"); - let body = if head_only { Vec::new() } else { bytes }; - let mut response = Response::builder() - .status(StatusCode::OK) - .header(CONTENT_TYPE, content_type(path)) - .header("x-content-type-options", "nosniff") - .body(Full::new(Bytes::from(body))) - .expect("static response is valid"); - if is_index { - response.headers_mut().insert( - "content-security-policy", - HeaderValue::from_static( - "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; base-uri 'self'; frame-ancestors 'none'", - ), - ); - } - response -} - -fn content_type(path: &Path) -> &'static str { - match path.extension().and_then(|ext| ext.to_str()).unwrap_or("") { - "html" => "text/html; charset=utf-8", - "js" | "mjs" => "text/javascript; charset=utf-8", - "css" => "text/css; charset=utf-8", - "json" => "application/json; charset=utf-8", - "svg" => "image/svg+xml", - "png" => "image/png", - "jpg" | "jpeg" => "image/jpeg", - "gif" => "image/gif", - "webp" => "image/webp", - "ico" => "image/x-icon", - "wasm" => "application/wasm", - "txt" => "text/plain; charset=utf-8", - _ => "application/octet-stream", - } -} - -#[derive(Deserialize)] -struct PairRequest { - code: String, -} - -async fn pair( - body: Bytes, - state: Arc, - origin: Option<&str>, -) -> Response { - let request = match serde_json::from_slice::(&body) { - Ok(request) => request, - Err(err) => { - return error_response( - StatusCode::BAD_REQUEST, - "INVALID", - format!("invalid pairing request: {err}"), - origin, - ); - } - }; - - if request.code != state.pairing_code() { - state.log_security(SecurityLogEvent::PairingFailed { - origin: origin.map(str::to_owned), - reason: "wrong_code", - }); - return error_response( - StatusCode::FORBIDDEN, - "FORBIDDEN", - "invalid pairing code", - origin, - ); - } - - let token = state.create_session(); - state.log_security(SecurityLogEvent::PairingSucceeded { - origin: origin.map(str::to_owned), - }); - let cookie = Cookie::build((SESSION_COOKIE, token)) - .path("/") - .http_only(true) - .secure(state.config.secure_cookie()) - .same_site(SameSite::Strict) - .build(); - let mut response = json_response(StatusCode::OK, &json!({ "paired": true }), origin); - response.headers_mut().insert( - SET_COOKIE, - HeaderValue::from_str(&cookie.to_string()).expect("session cookie is header-safe"), - ); - response -} - -fn logout_response(state: &ServerState, origin: Option<&str>) -> Response { - let mut response = json_response(StatusCode::OK, &json!({ "revoked": true }), origin); - let secure = if state.config.secure_cookie() { - "; Secure" - } else { - "" - }; - let cookie = format!("{SESSION_COOKIE}=; Path=/; HttpOnly{secure}; SameSite=Strict; Max-Age=0"); - response.headers_mut().insert( - SET_COOKIE, - HeaderValue::from_str(&cookie).expect("session clearing cookie is header-safe"), - ); - response -} - -#[derive(Deserialize)] -struct InvokeRequest { - command: String, - #[serde(default)] - args: Value, -} - -async fn invoke( - body: Bytes, - state: Arc, - origin: Option<&str>, -) -> Response { - let request = match serde_json::from_slice::(&body) { - Ok(request) => request, - Err(err) => { - return error_response( - StatusCode::BAD_REQUEST, - "INVALID", - format!("invalid invoke request: {err}"), - origin, - ); - } - }; - - let result = match request.command.as_str() { - "health" => invoke_health(&request.args, &state.app), - "list_projects" => invoke_list_projects(&state.app).await, - "open_project" => invoke_open_project(&request.args, &state.app).await, - "get_project_work_state" => invoke_get_project_work_state(&request.args, &state.app).await, - "list_background_tasks" => invoke_list_background_tasks(&request.args, &state.app).await, - "cancel_background_task" => invoke_cancel_background_task(&request.args, &state.app).await, - "retry_background_task" => invoke_retry_background_task(&request.args, &state.app).await, - _ => Err(ErrorDto { - code: "UNKNOWN_COMMAND".to_owned(), - message: format!("unknown command: {}", request.command), - }), - }; - - match result { - Ok(value) => json_response(StatusCode::OK, &value, origin), - Err(error) => error_dto_response(status_for_error(&error), error, origin), - } -} - -fn invoke_health(args: &Value, state: &AppState) -> Result { - let request = optional_request::(args)?; - let output = state - .health - .execute(request.unwrap_or_default().into()) - .map(HealthResponseDto::from) - .map_err(ErrorDto::from)?; - serde_json::to_value(output).map_err(serialization_error) -} - -async fn invoke_list_projects(state: &AppState) -> Result { - let output = state - .list_projects - .execute() - .await - .map(ProjectListDto::from) - .map_err(ErrorDto::from)?; - serde_json::to_value(output).map_err(serialization_error) -} - -async fn invoke_open_project(args: &Value, state: &AppState) -> Result { - let project_id = args - .get("projectId") - .and_then(Value::as_str) - .ok_or_else(|| ErrorDto { - code: "INVALID".to_owned(), - message: "open_project requires args.projectId".to_owned(), - })?; - let project = resolve_project_readonly(project_id, state).await?; - serde_json::to_value(ProjectDto::from(project)).map_err(serialization_error) -} - -async fn invoke_get_project_work_state(args: &Value, state: &AppState) -> Result { - let project_id = args - .get("projectId") - .and_then(Value::as_str) - .ok_or_else(|| ErrorDto { - code: "INVALID".to_owned(), - message: "get_project_work_state requires args.projectId".to_owned(), - })?; - let project = resolve_project_readonly(project_id, state).await?; - let output = state - .get_project_work_state - .execute(GetProjectWorkStateInput { project }) - .await - .map(ProjectWorkStateDto::from) - .map_err(ErrorDto::from)?; - serde_json::to_value(output).map_err(serialization_error) -} - -async fn invoke_list_background_tasks(args: &Value, state: &AppState) -> Result { - let project_id = args - .get("projectId") - .and_then(Value::as_str) - .ok_or_else(|| ErrorDto { - code: "INVALID".to_owned(), - message: "list_background_tasks requires args.projectId".to_owned(), - }) - .and_then(parse_project_id)?; - let agent_id = args - .get("agentId") - .and_then(Value::as_str) - .map(parse_agent_id) - .transpose()?; - - let mut by_id = std::collections::HashMap::::new(); - if let Some(agent_id) = agent_id { - for task in state - .background_task_store - .list_open_for_agent(agent_id) - .await - .map_err(background_error)? - { - by_id.insert(task.id, task); - } - } - for task in state - .background_task_store - .list_undelivered_completions() - .await - .map_err(background_error)? - { - by_id.entry(task.id).or_insert(task); - } - - let mut tasks = by_id - .into_values() - .filter(|task| task.project_id == project_id) - .filter(|task| agent_id.map_or(true, |agent_id| task.owner_agent_id == agent_id)) - .collect::>(); - tasks.sort_by_key(|task| (task.created_at_ms, task.id)); - let dto = tasks - .into_iter() - .map(BackgroundTaskDto::from) - .collect::>(); - serde_json::to_value(dto).map_err(serialization_error) -} - -async fn invoke_cancel_background_task(args: &Value, state: &AppState) -> Result { - let task_id = task_id_arg(args, "cancel_background_task")?; - verify_background_task_project(args, state, task_id).await?; - let output = state - .cancel_background_task - .execute(task_id) - .await - .map_err(ErrorDto::from)?; - serde_json::to_value(output.task.map(BackgroundTaskDto::from)).map_err(serialization_error) -} - -async fn invoke_retry_background_task(args: &Value, state: &AppState) -> Result { - let task_id = task_id_arg(args, "retry_background_task")?; - verify_background_task_project(args, state, task_id).await?; - let output = state - .retry_background_task - .execute(task_id) - .await - .map_err(ErrorDto::from)?; - serde_json::to_value(BackgroundTaskDto::from(output.task)).map_err(serialization_error) -} - -fn task_id_arg(args: &Value, command: &str) -> Result { - args.get("taskId") - .and_then(Value::as_str) - .ok_or_else(|| ErrorDto { - code: "INVALID".to_owned(), - message: format!("{command} requires args.taskId"), - }) - .and_then(parse_task_id) -} - -async fn verify_background_task_project( - args: &Value, - state: &AppState, - task_id: domain::TaskId, -) -> Result<(), ErrorDto> { - let Some(project_id) = args.get("projectId").and_then(Value::as_str) else { - return Ok(()); - }; - let project_id = parse_project_id(project_id)?; - let Some(task) = state - .background_task_store - .get(task_id) - .await - .map_err(background_error)? - else { - return Err(ErrorDto { - code: "NOT_FOUND".to_owned(), - message: format!("background task not found: {task_id}"), - }); - }; - if task.project_id != project_id { - return Err(ErrorDto { - code: "FORBIDDEN".to_owned(), - message: "background task does not belong to requested project".to_owned(), - }); - } - Ok(()) -} - -fn background_error(err: BackgroundTaskPortError) -> ErrorDto { - let code = match &err { - BackgroundTaskPortError::NotFound => "NOT_FOUND", - BackgroundTaskPortError::AlreadyExists | BackgroundTaskPortError::Invalid(_) => "INVALID", - BackgroundTaskPortError::Runner(_) => "PROCESS", - BackgroundTaskPortError::Store(_) => "STORE", - }; - ErrorDto { - code: code.to_owned(), - message: err.to_string(), - } -} - -async fn resolve_project_readonly(project_id: &str, state: &AppState) -> Result { - let id = parse_project_id(project_id)?; - state - .open_project - .execute(OpenProjectInput { project_id: id }) - .await - .map(|output| output.project) - .map_err(ErrorDto::from) -} - -fn optional_request(args: &Value) -> Result, ErrorDto> -where - T: for<'de> Deserialize<'de>, -{ - match args { - Value::Object(map) => match map.get("request") { - Some(value) => serde_json::from_value(value.clone()) - .map(Some) - .map_err(invalid_args_error), - None if map.is_empty() => Ok(None), - None => serde_json::from_value(args.clone()) - .map(Some) - .map_err(invalid_args_error), - }, - Value::Null => Ok(None), - _ => Err(ErrorDto { - code: "INVALID".to_owned(), - message: "args must be an object".to_owned(), - }), - } -} - -fn validate_request_origin( - headers: &HeaderMap, - config: &ServerConfig, -) -> Result, Box>> { - let Some(origin) = headers.get(ORIGIN).and_then(|value| value.to_str().ok()) else { - return Err(Box::new(error_response( - StatusCode::FORBIDDEN, - "FORBIDDEN", - "missing Origin header", - None, - ))); - }; - - if origin_allowed(origin, config) { - Ok(Some(origin.to_owned())) - } else { - Err(Box::new(error_response( - StatusCode::FORBIDDEN, - "FORBIDDEN", - "origin not allowed", - None, - ))) - } -} - -fn request_origin(headers: &HeaderMap) -> Option { - headers - .get(ORIGIN) - .and_then(|value| value.to_str().ok()) - .map(str::to_owned) -} - -fn origin_allowed(origin: &str, config: &ServerConfig) -> bool { - if let Some(public_origin) = &config.public_origin { - return origin == public_origin; - } - - if !config.listen.ip().is_loopback() { - return false; - } - - let port = config.listen.port(); - origin == format!("http://127.0.0.1:{port}") - || origin == format!("http://localhost:{port}") - || origin == format!("http://[::1]:{port}") -} - -fn has_forbidden_query_secret(uri: &Uri) -> bool { - uri.query().is_some_and(|query| { - query.split('&').any(|pair| { - let key = pair.split_once('=').map_or(pair, |(key, _)| key); - matches!( - key.to_ascii_lowercase().as_str(), - "token" | "secret" | "session" | "code" - ) - }) - }) -} - -fn session_cookie(headers: &HeaderMap) -> Option { - headers - .get(COOKIE) - .and_then(|value| value.to_str().ok()) - .and_then(|raw| { - raw.split(';').find_map(|part| { - let (name, value) = part.trim().split_once('=')?; - (name == SESSION_COOKIE).then(|| value.to_owned()) - }) - }) -} - -fn json_response( - status: StatusCode, - value: &Value, - origin: Option<&str>, -) -> Response { - let body = serde_json::to_vec(value).expect("JSON value serializes"); - let mut response = Response::new(Full::new(Bytes::from(body))); - *response.status_mut() = status; - response - .headers_mut() - .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - add_cors_headers(&mut response, origin); - response -} - -fn error_dto_response( - status: StatusCode, - error: ErrorDto, - origin: Option<&str>, -) -> Response { - let value = serde_json::to_value(error).expect("ErrorDto serializes"); - json_response(status, &value, origin) -} - -fn error_response( - status: StatusCode, - code: impl Into, - message: impl Into, - origin: Option<&str>, -) -> Response { - error_dto_response( - status, - ErrorDto { - code: code.into(), - message: message.into(), - }, - origin, - ) -} - -fn cors_response(status: StatusCode, origin: Option<&str>) -> Response { - let mut response = Response::new(Full::new(Bytes::new())); - *response.status_mut() = status; - add_cors_headers(&mut response, origin); - response -} - -fn add_cors_headers(response: &mut Response, origin: Option<&str>) { - if let Some(origin) = origin { - if let Ok(origin) = HeaderValue::from_str(origin) { - response - .headers_mut() - .insert("access-control-allow-origin", origin); - response.headers_mut().insert( - "access-control-allow-credentials", - HeaderValue::from_static("true"), - ); - response.headers_mut().insert( - "access-control-allow-headers", - HeaderValue::from_static("content-type"), - ); - response.headers_mut().insert( - "access-control-allow-methods", - HeaderValue::from_static("POST, OPTIONS"), - ); - } - } -} - -fn status_for_error(error: &ErrorDto) -> StatusCode { - match error.code.as_str() { - "UNKNOWN_COMMAND" => StatusCode::BAD_REQUEST, - "INVALID" => StatusCode::BAD_REQUEST, - "NOT_FOUND" => StatusCode::NOT_FOUND, - "FORBIDDEN" => StatusCode::FORBIDDEN, - "UNAUTHORIZED" => StatusCode::UNAUTHORIZED, - _ => StatusCode::INTERNAL_SERVER_ERROR, - } -} - -fn validate_origin(origin: &str) -> Result<(), String> { - if origin == "*" || origin.contains('*') { - return Err("--public-origin must be exact, not a wildcard".to_owned()); - } - if origin.ends_with('/') { - return Err("--public-origin must not end with '/'".to_owned()); - } - if !(origin.starts_with("https://") || origin.starts_with("http://")) { - return Err("--public-origin must be an HTTP(S) origin".to_owned()); - } - Ok(()) -} - -fn default_app_data_dir() -> PathBuf { - if let Some(path) = env::var_os("IDEA_APP_DATA_DIR") { - return PathBuf::from(path); - } - if let Some(path) = env::var_os("XDG_DATA_HOME") { - return PathBuf::from(path).join("app.idea.ide"); - } - if let Some(home) = env::var_os("HOME") { - return PathBuf::from(home).join(".local/share/app.idea.ide"); - } - env::current_dir() - .unwrap_or_else(|_| PathBuf::from(".")) - .join(".ideai/app-data") -} - -fn new_pairing_code() -> String { - Uuid::new_v4() - .simple() - .to_string() - .chars() - .take(8) - .collect::() - .to_ascii_uppercase() -} - -fn new_session_token() -> String { - format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()) -} - -fn invalid_args_error(err: serde_json::Error) -> ErrorDto { - ErrorDto { - code: "INVALID".to_owned(), - message: format!("invalid command args: {err}"), - } -} - -fn serialization_error(err: serde_json::Error) -> ErrorDto { - ErrorDto { - code: "SERIALIZATION".to_owned(), - message: format!("failed to serialize response: {err}"), - } -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -struct ClientFrame { - id: String, - kind: String, - #[serde(default)] - payload: Value, -} - -#[derive(Debug, Clone, Serialize)] -struct ServerFrame { - kind: String, - #[serde(skip_serializing_if = "Option::is_none", rename = "replyTo")] - reply_to: Option, - payload: Value, -} - -impl ServerFrame { - fn attached( - request_id: &str, - session: &TerminalSessionDto, - scrollback: Vec, - next_seq: u64, - gap: bool, - ) -> Self { - let scrollback = if scrollback.is_empty() { - Vec::new() - } else { - vec![json!({ - "seq": 0, - "bytesBase64": base64_encode(&scrollback), - })] - }; - Self { - kind: "terminal.attached".to_owned(), - reply_to: Some(request_id.to_owned()), - payload: json!({ - "session": { - "sessionId": session.session_id, - "rows": session.rows, - "cols": session.cols, - }, - "scrollback": scrollback, - "nextSeq": next_seq, - "status": "running", - "gap": gap, - "assignedConversationId": session.assigned_conversation_id, - }), - } - } - - fn output(session_id: SessionId, seq: u64, bytes: Vec) -> Self { - Self { - kind: "terminal.output".to_owned(), - reply_to: None, - payload: json!({ - "sessionId": session_id.to_string(), - "seq": seq, - "bytesBase64": base64_encode(&bytes), - }), - } - } - - fn status(session_id: SessionId, status: &str, exit_code: Option) -> Self { - Self { - kind: "terminal.status".to_owned(), - reply_to: None, - payload: json!({ - "sessionId": session_id.to_string(), - "status": status, - "exitCode": exit_code, - }), - } - } - - fn domain_event(event: &DomainEvent) -> Self { - Self { - kind: "event.domain".to_owned(), - reply_to: None, - payload: serde_json::to_value(DomainEventDto::from(event)) - .expect("DomainEventDto serializes"), - } - } - - fn error( - request_id: Option, - session_id: Option, - code: impl Into, - message: impl Into, - ) -> Self { - Self { - kind: "error".to_owned(), - reply_to: request_id, - payload: json!({ - "sessionId": session_id, - "code": code.into(), - "message": message.into(), - }), - } - } - - fn pong() -> Self { - Self { - kind: "pong".to_owned(), - reply_to: None, - payload: json!({}), - } - } -} - -struct WsPtySink { - session_id: SessionId, - tx: mpsc::Sender, - seq: AtomicU64, -} - -impl WsPtySink { - fn new(session_id: SessionId, tx: mpsc::Sender, next_seq: u64) -> Self { - Self { - session_id, - tx, - seq: AtomicU64::new(next_seq), - } - } -} - -impl OutputSink for WsPtySink { - fn send(&self, item: PtyChunk) -> Result<(), OutputSinkError> { - let seq = self.seq.fetch_add(1, Ordering::Relaxed); - self.tx - .try_send(ServerFrame::output(self.session_id, seq, item)) - .map_err(|err| match err { - mpsc::error::TrySendError::Full(_) => OutputSinkError::Full, - mpsc::error::TrySendError::Closed(_) => OutputSinkError::Closed, - }) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum WsOpcode { - Text, - Binary, - Close, - Ping, - Pong, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct WsFrame { - opcode: WsOpcode, - payload: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum WsFrameError { - Incomplete, - Protocol(String), - TooLarge, -} - -impl std::fmt::Display for WsFrameError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Incomplete => f.write_str("incomplete websocket frame"), - Self::Protocol(message) => write!(f, "websocket protocol error: {message}"), - Self::TooLarge => f.write_str("websocket frame payload too large"), - } - } -} - -async fn read_ws_frame(reader: &mut R) -> Result -where - R: AsyncRead + Unpin, -{ - let mut header = [0_u8; 2]; - reader - .read_exact(&mut header) - .await - .map_err(|_| WsFrameError::Incomplete)?; - let len_code = header[1] & 0x7f; - let masked = header[1] & 0x80 != 0; - let mut rest = Vec::new(); - let extra_len = match len_code { - 126 => 2, - 127 => 8, - _ => 0, - }; - rest.resize(extra_len + usize::from(masked) * 4, 0); - if !rest.is_empty() { - reader - .read_exact(&mut rest) - .await - .map_err(|_| WsFrameError::Incomplete)?; - } - let mut raw = Vec::with_capacity(2 + rest.len()); - raw.extend_from_slice(&header); - raw.extend_from_slice(&rest); - let payload_len = decoded_payload_len(&raw)?; - if payload_len > WS_MAX_PAYLOAD { - return Err(WsFrameError::TooLarge); - } - let mut payload = vec![0_u8; payload_len]; - reader - .read_exact(&mut payload) - .await - .map_err(|_| WsFrameError::Incomplete)?; - raw.extend_from_slice(&payload); - decode_client_ws_frame(&raw) -} - -fn decode_client_ws_frame(raw: &[u8]) -> Result { - if raw.len() < 2 { - return Err(WsFrameError::Incomplete); - } - let fin = raw[0] & 0x80 != 0; - if !fin { - return Err(WsFrameError::Protocol( - "fragmented frames are not supported".to_owned(), - )); - } - let opcode = match raw[0] & 0x0f { - 0x1 => WsOpcode::Text, - 0x2 => WsOpcode::Binary, - 0x8 => WsOpcode::Close, - 0x9 => WsOpcode::Ping, - 0xA => WsOpcode::Pong, - other => { - return Err(WsFrameError::Protocol(format!( - "unsupported opcode {other}" - ))) - } - }; - let masked = raw[1] & 0x80 != 0; - if !masked { - return Err(WsFrameError::Protocol( - "client frames must be masked".to_owned(), - )); - } - let len_code = raw[1] & 0x7f; - let mut offset = 2; - let len = match len_code { - 126 => { - if raw.len() < offset + 2 { - return Err(WsFrameError::Incomplete); - } - let len = u16::from_be_bytes([raw[offset], raw[offset + 1]]) as usize; - offset += 2; - len - } - 127 => { - if raw.len() < offset + 8 { - return Err(WsFrameError::Incomplete); - } - let len = u64::from_be_bytes(raw[offset..offset + 8].try_into().expect("8 bytes")); - offset += 8; - usize::try_from(len).map_err(|_| WsFrameError::TooLarge)? - } - len => usize::from(len), - }; - if len > WS_MAX_PAYLOAD { - return Err(WsFrameError::TooLarge); - } - if raw.len() < offset + 4 + len { - return Err(WsFrameError::Incomplete); - } - let mask = &raw[offset..offset + 4]; - offset += 4; - let mut payload = raw[offset..offset + len].to_vec(); - for (i, byte) in payload.iter_mut().enumerate() { - *byte ^= mask[i % 4]; - } - Ok(WsFrame { opcode, payload }) -} - -fn decoded_payload_len(raw_header: &[u8]) -> Result { - let len_code = raw_header[1] & 0x7f; - let offset = 2; - let len = match len_code { - 126 => { - if raw_header.len() < offset + 2 { - return Err(WsFrameError::Incomplete); - } - u16::from_be_bytes([raw_header[offset], raw_header[offset + 1]]) as usize - } - 127 => { - if raw_header.len() < offset + 8 { - return Err(WsFrameError::Incomplete); - } - let len = - u64::from_be_bytes(raw_header[offset..offset + 8].try_into().expect("8 bytes")); - usize::try_from(len).map_err(|_| WsFrameError::TooLarge)? - } - len => usize::from(len), - }; - Ok(len) -} - -fn encode_ws_frame(opcode: WsOpcode, payload: &[u8]) -> Vec { - let opcode = match opcode { - WsOpcode::Text => 0x1, - WsOpcode::Binary => 0x2, - WsOpcode::Close => 0x8, - WsOpcode::Ping => 0x9, - WsOpcode::Pong => 0xA, - }; - let mut out = vec![0x80 | opcode]; - match payload.len() { - 0..=125 => out.push(payload.len() as u8), - 126..=65535 => { - out.push(126); - out.extend_from_slice(&(payload.len() as u16).to_be_bytes()); - } - len => { - out.push(127); - out.extend_from_slice(&(len as u64).to_be_bytes()); - } - } - out.extend_from_slice(payload); - out -} - -fn websocket_accept(key: &str) -> String { - let mut input = Vec::with_capacity(key.len() + WS_MAGIC.len()); - input.extend_from_slice(key.as_bytes()); - input.extend_from_slice(WS_MAGIC.as_bytes()); - base64_encode(&sha1_digest(&input)) -} - -fn base64_encode(bytes: &[u8]) -> String { - use base64::Engine as _; - base64::engine::general_purpose::STANDARD.encode(bytes) -} - -fn base64_decode(raw: &str) -> Result, ErrorDto> { - use base64::Engine as _; - base64::engine::general_purpose::STANDARD - .decode(raw) - .map_err(|err| ErrorDto { - code: "INVALID".to_owned(), - message: format!("invalid base64 bytes: {err}"), - }) -} - -fn sha1_digest(input: &[u8]) -> [u8; 20] { - let mut h0: u32 = 0x67452301; - let mut h1: u32 = 0xEFCDAB89; - let mut h2: u32 = 0x98BADCFE; - let mut h3: u32 = 0x10325476; - let mut h4: u32 = 0xC3D2E1F0; - - let bit_len = (input.len() as u64) * 8; - let mut msg = input.to_vec(); - msg.push(0x80); - while (msg.len() % 64) != 56 { - msg.push(0); - } - msg.extend_from_slice(&bit_len.to_be_bytes()); - - for chunk in msg.chunks(64) { - let mut w = [0_u32; 80]; - for (i, word) in w.iter_mut().take(16).enumerate() { - let j = i * 4; - *word = u32::from_be_bytes([chunk[j], chunk[j + 1], chunk[j + 2], chunk[j + 3]]); - } - for i in 16..80 { - w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1); - } - let mut a = h0; - let mut b = h1; - let mut c = h2; - let mut d = h3; - let mut e = h4; - for (i, word) in w.iter().enumerate() { - let (f, k) = match i { - 0..=19 => ((b & c) | ((!b) & d), 0x5A827999), - 20..=39 => (b ^ c ^ d, 0x6ED9EBA1), - 40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1BBCDC), - _ => (b ^ c ^ d, 0xCA62C1D6), - }; - let temp = a - .rotate_left(5) - .wrapping_add(f) - .wrapping_add(e) - .wrapping_add(k) - .wrapping_add(*word); - e = d; - d = c; - c = b.rotate_left(30); - b = a; - a = temp; - } - h0 = h0.wrapping_add(a); - h1 = h1.wrapping_add(b); - h2 = h2.wrapping_add(c); - h3 = h3.wrapping_add(d); - h4 = h4.wrapping_add(e); - } - - let mut out = [0_u8; 20]; - out[0..4].copy_from_slice(&h0.to_be_bytes()); - out[4..8].copy_from_slice(&h1.to_be_bytes()); - out[8..12].copy_from_slice(&h2.to_be_bytes()); - out[12..16].copy_from_slice(&h3.to_be_bytes()); - out[16..20].copy_from_slice(&h4.to_be_bytes()); - out -} - -fn payload_string(payload: &Value, key: &str) -> Result { - payload - .get(key) - .and_then(Value::as_str) - .map(ToOwned::to_owned) - .ok_or_else(|| ErrorDto { - code: "INVALID".to_owned(), - message: format!("payload requires {key}"), - }) -} - -fn payload_u16(payload: &Value, key: &str) -> Option { - payload - .get(key) - .and_then(Value::as_u64) - .and_then(|value| u16::try_from(value).ok()) -} - -fn payload_bytes(payload: &Value) -> Result, ErrorDto> { - if let Some(raw) = payload.get("bytesBase64").and_then(Value::as_str) { - base64_decode(raw) - } else if let Some(raw) = payload.get("bytes").and_then(Value::as_str) { - base64_decode(raw) - } else { - Err(ErrorDto { - code: "INVALID".to_owned(), - message: "payload requires bytesBase64".to_owned(), - }) - } -} - -fn payload_session_id(payload: &Value) -> Option { - payload - .get("sessionId") - .and_then(Value::as_str) - .map(ToOwned::to_owned) -} - -#[cfg(test)] -mod tests { - use super::*; - use application::{ - CreateAgentInput, CreateProjectInput, LaunchAgentOutput, SaveProfileInput, - StructuredSessionDescriptor, - }; - use domain::ports::EventBus; - use domain::{ - AgentId, AgentProfile, BackgroundTask, BackgroundTaskKind, BackgroundTaskWakePolicy, - ContextInjection, NodeId, ProfileId, ProjectId, ProjectPath, PtySize, SessionKind, - SessionStatus, SessionStrategy, TaskId, TerminalSession, - }; - use http::header::HeaderName; - use std::time::Duration; - - static ENV_LOCK: Mutex<()> = Mutex::new(()); - - struct EnvVarGuard { - saved: Vec<(&'static str, Option)>, - } - - impl EnvVarGuard { - fn new(keys: &[&'static str]) -> Self { - Self { - saved: keys - .iter() - .map(|key| (*key, std::env::var_os(key))) - .collect(), - } - } - } - - impl Drop for EnvVarGuard { - fn drop(&mut self) { - for (key, value) in self.saved.iter().rev() { - if let Some(value) = value { - std::env::set_var(key, value); - } else { - std::env::remove_var(key); - } - } - } - } - - #[derive(Default)] - struct RecordingSecurityLogger { - events: Mutex>, - } - - impl RecordingSecurityLogger { - fn events(&self) -> Vec { - self.events.lock().unwrap().clone() - } - } - - impl SecurityLogger for RecordingSecurityLogger { - fn log(&self, event: SecurityLogEvent) { - self.events.lock().unwrap().push(event); - } - } - - fn create_web_root() -> PathBuf { - let root = std::env::temp_dir().join(format!("idea-web-root-{}", Uuid::new_v4())); - std::fs::create_dir_all(root.join("assets")).unwrap(); - std::fs::write( - root.join("index.html"), - r#"
"#, - ) - .unwrap(); - std::fs::write(root.join("assets").join("app.js"), "console.log('idea');").unwrap(); - std::fs::write(root.join("assets").join("style.css"), "body{margin:0}").unwrap(); - root - } - - fn test_config() -> ServerConfig { - ServerConfig { - listen: "127.0.0.1:17373".parse().unwrap(), - public_origin: None, - allow_remote: false, - trust_reverse_proxy: false, - app_data_dir: std::env::temp_dir().join(format!("idea-server-test-{}", Uuid::new_v4())), - web_root: create_web_root(), - } - } - - #[test] - fn default_app_data_dir_uses_tauri_identifier_with_env_precedence() { - let _lock = ENV_LOCK.lock().unwrap(); - let _guard = EnvVarGuard::new(&["IDEA_APP_DATA_DIR", "XDG_DATA_HOME", "HOME"]); - let root = std::env::temp_dir().join(format!("idea-app-data-env-{}", Uuid::new_v4())); - let home = root.join("home"); - let xdg = root.join("xdg"); - let override_dir = root.join("override"); - - std::env::remove_var("IDEA_APP_DATA_DIR"); - std::env::remove_var("XDG_DATA_HOME"); - std::env::set_var("HOME", &home); - assert_eq!( - default_app_data_dir(), - home.join(".local/share/app.idea.ide") - ); - - std::env::set_var("XDG_DATA_HOME", &xdg); - assert_eq!(default_app_data_dir(), xdg.join("app.idea.ide")); - - std::env::set_var("IDEA_APP_DATA_DIR", &override_dir); - assert_eq!(default_app_data_dir(), override_dir); - } - - fn state() -> Arc { - Arc::new(ServerState::new_for_test(test_config(), "PAIR1234")) - } - - async fn request( - state: Arc, - method: Method, - uri: &str, - body: Value, - extra_headers: &[(&str, &str)], - ) -> Response { - let mut builder = Request::builder() - .method(method) - .uri(uri) - .header(ORIGIN, "http://127.0.0.1:17373") - .header(CONTENT_TYPE, "application/json"); - for (name, value) in extra_headers { - builder = builder.header(HeaderName::from_bytes(name.as_bytes()).unwrap(), *value); - } - handle_request( - builder - .body(Full::new(Bytes::from(serde_json::to_vec(&body).unwrap()))) - .unwrap(), - state, - ) - .await - } - - async fn response_json(response: Response) -> (StatusCode, Value, HeaderMap) { - let status = response.status(); - let headers = response.headers().clone(); - let body = response.into_body().collect().await.unwrap().to_bytes(); - let value = if body.is_empty() { - Value::Null - } else { - serde_json::from_slice(&body).unwrap() - }; - (status, value, headers) - } - - async fn response_bytes(response: Response) -> (StatusCode, Bytes, HeaderMap) { - let status = response.status(); - let headers = response.headers().clone(); - let body = response.into_body().collect().await.unwrap().to_bytes(); - (status, body, headers) - } - - async fn pair_and_cookie(state: Arc) -> String { - let response = request( - state, - Method::POST, - "/api/pair", - json!({ "code": "PAIR1234" }), - &[], - ) - .await; - response - .headers() - .get(SET_COOKIE) - .unwrap() - .to_str() - .unwrap() - .split(';') - .next() - .unwrap() - .to_owned() - } - - async fn create_project_for_test(state: &Arc, name: &str) -> String { - let root = std::env::temp_dir() - .join(format!("idea-server-project-{}", Uuid::new_v4())) - .to_string_lossy() - .into_owned(); - let output = state - .app - .create_project - .execute(CreateProjectInput { - name: name.to_owned(), - root, - remote: None, - default_profile_id: None, - }) - .await - .expect("test project is created"); - output.project.id.to_string() - } - - async fn create_background_task_for_test( - state: &Arc, - project_id: &str, - label: &str, - ) -> (TaskId, AgentId) { - let task_id = TaskId::from_uuid(Uuid::new_v4()); - let owner = AgentId::from_uuid(Uuid::new_v4()); - let project_id = ProjectId::from_uuid(Uuid::parse_str(project_id).unwrap()); - let task = BackgroundTask::new( - task_id, - project_id, - owner, - BackgroundTaskKind::Command { - label: label.to_owned(), - }, - BackgroundTaskWakePolicy::WakeOwner, - 1_000, - None, - ) - .expect("test background task is valid"); - state - .app - .background_task_store - .create(&task) - .await - .expect("test background task is stored"); - (task_id, owner) - } - - async fn create_raw_cli_agent_for_test( - state: &Arc, - name: &str, - ) -> (String, String) { - let project_id = create_project_for_test(state, name).await; - let project = resolve_project_readonly(&project_id, &state.app) - .await - .expect("test project resolves"); - let profile_id = ProfileId::from_uuid(Uuid::new_v4()); - let profile = AgentProfile::new( - profile_id, - format!("{name} shell profile"), - "/bin/sh", - vec![ - "-c".to_owned(), - "printf agent-ready; sleep 30".to_owned(), - "idea-test-sh".to_owned(), - ], - ContextInjection::env("IDEA_CONTEXT").expect("valid env injection"), - None, - "{agentRunDir}", - Some( - SessionStrategy::new(Some("--session-id".to_owned()), "--resume") - .expect("valid session strategy"), - ), - ) - .expect("valid raw CLI profile"); - state - .app - .save_profile - .execute(SaveProfileInput { profile }) - .await - .expect("test profile saved"); - let agent = state - .app - .create_agent - .execute(CreateAgentInput { - project, - name: format!("{name} agent"), - profile_id, - initial_content: Some("Test agent context".to_owned()), - }) - .await - .expect("test agent created") - .agent; - (project_id, agent.id.to_string()) - } - - fn ws_headers(origin: &str, cookie: Option<&str>) -> HeaderMap { - let mut headers = HeaderMap::new(); - headers.insert(ORIGIN, HeaderValue::from_str(origin).unwrap()); - headers.insert("upgrade", HeaderValue::from_static("websocket")); - headers.insert("connection", HeaderValue::from_static("Upgrade")); - headers.insert( - "sec-websocket-key", - HeaderValue::from_static("dGhlIHNhbXBsZSBub25jZQ=="), - ); - if let Some(cookie) = cookie { - headers.insert(COOKIE, HeaderValue::from_str(cookie).unwrap()); - } - headers - } - - fn masked_text_frame(text: &str) -> Vec { - let payload = text.as_bytes(); - let mask = [1_u8, 2, 3, 4]; - let mut out = vec![0x81]; - out.push(0x80 | payload.len() as u8); - out.extend_from_slice(&mask); - for (i, byte) in payload.iter().enumerate() { - out.push(byte ^ mask[i % 4]); - } - out - } - - async fn recv_server_frame(rx: &mut mpsc::Receiver) -> ServerFrame { - tokio::time::timeout(Duration::from_secs(2), rx.recv()) - .await - .expect("server frame received before timeout") - .expect("server frame channel is open") - } - - async fn recv_server_frame_where( - rx: &mut mpsc::Receiver, - mut predicate: impl FnMut(&ServerFrame) -> bool, - ) -> ServerFrame { - for _ in 0..16 { - let frame = recv_server_frame(rx).await; - if predicate(&frame) { - return frame; - } - } - panic!("matching server frame was not received"); - } - - fn drain_server_frames(rx: &mut mpsc::Receiver) { - while rx.try_recv().is_ok() {} - } - - fn ws_frame(id: &str, kind: &str, payload: Value) -> ClientFrame { - ClientFrame { - id: id.to_owned(), - kind: kind.to_owned(), - payload, - } - } - - fn attached_session_id(frame: &ServerFrame) -> String { - assert_eq!(frame.kind, "terminal.attached"); - frame.payload["session"]["sessionId"] - .as_str() - .expect("attached frame carries sessionId") - .to_owned() - } - - fn open_terminal_payload(command: &str, args: Vec<&str>) -> Value { - json!({ - "request": { - "cwd": std::env::temp_dir().to_string_lossy(), - "rows": 24, - "cols": 80, - "command": command, - "args": args, - } - }) - } - - fn agent_launch_payload( - project_id: &str, - agent_id: &str, - node_id: &str, - conversation_id: Option<&str>, - ) -> Value { - json!({ - "projectId": project_id, - "agentId": agent_id, - "nodeId": node_id, - "rows": 24, - "cols": 80, - "conversationId": conversation_id, - }) - } - - async fn close_ws_session(state: &Arc, session_id: &str) { - let (tx, mut rx) = mpsc::channel(8); - let owned = Arc::new(Mutex::new(Vec::new())); - handle_client_frame( - ws_frame( - "close-cleanup", - "terminal.close", - json!({ "sessionId": session_id }), - ), - state, - &tx, - &owned, - ) - .await; - let _ = recv_server_frame(&mut rx).await; - } - - async fn wait_for_scrollback( - state: &Arc, - session_id: &str, - needle: &[u8], - ) -> Vec { - let sid = parse_session_id(session_id).expect("test session id parses"); - let handle = PtyHandle { session_id: sid }; - for _ in 0..100 { - if let Ok(scrollback) = state.app.pty_port.scrollback(&handle) { - if scrollback - .windows(needle.len()) - .any(|window| window == needle) - { - return scrollback; - } - } - tokio::time::sleep(Duration::from_millis(20)).await; - } - panic!( - "scrollback did not contain {:?}", - String::from_utf8_lossy(needle) - ); - } - - #[test] - fn websocket_accept_matches_rfc_example() { - assert_eq!( - websocket_accept("dGhlIHNhbXBsZSBub25jZQ=="), - "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=" - ); - } - - #[test] - fn websocket_upgrade_requires_valid_cookie() { - let state = state(); - let headers = ws_headers("http://127.0.0.1:17373", None); - - let response = validate_ws_upgrade(&headers, &state).unwrap_err(); - - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - } - - #[test] - fn websocket_upgrade_rejects_invalid_cookie() { - let state = state(); - let headers = ws_headers( - "http://127.0.0.1:17373", - Some("idea_session=not-a-valid-session"), - ); - - let response = validate_ws_upgrade(&headers, &state).unwrap_err(); - - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - } - - #[test] - fn websocket_upgrade_requires_allowed_origin() { - let state = state(); - let token = state.create_session(); - let headers = ws_headers( - "https://evil.example", - Some(&format!("idea_session={token}")), - ); - - let response = validate_ws_upgrade(&headers, &state).unwrap_err(); - - assert_eq!(response.status(), StatusCode::FORBIDDEN); - } - - #[test] - fn websocket_upgrade_accepts_valid_cookie_and_origin() { - let state = state(); - let token = state.create_session(); - let headers = ws_headers( - "http://127.0.0.1:17373", - Some(&format!("idea_session={token}")), - ); - - let accept = validate_ws_upgrade(&headers, &state).unwrap(); - - assert_eq!(accept, "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="); - } - - #[test] - fn websocket_client_text_frame_decodes_masked_json() { - let raw = masked_text_frame(r#"{"kind":"ping"}"#); - - let frame = decode_client_ws_frame(&raw).unwrap(); - - assert_eq!(frame.opcode, WsOpcode::Text); - assert_eq!(frame.payload, br#"{"kind":"ping"}"#); - } - - #[test] - fn websocket_client_json_frame_round_trips_base64_bytes() { - let sid = domain::SessionId::new_random().to_string(); - let value = json!({ - "id": "req-1", - "kind": "input", - "payload": { - "sessionId": sid, - "bytesBase64": "AQID" - } - }); - - let frame: ClientFrame = serde_json::from_value(value.clone()).unwrap(); - let roundtrip = serde_json::to_value(&frame).unwrap(); - - assert_eq!(roundtrip, value); - assert_eq!(payload_bytes(&frame.payload).unwrap(), vec![1, 2, 3]); - } - - #[test] - fn websocket_server_attached_frame_uses_contract_shape() { - let sid = domain::SessionId::new_random().to_string(); - let frame = ServerFrame::attached( - "req-attach", - &TerminalSessionDto { - session_id: sid.clone(), - cwd: "/tmp".to_owned(), - rows: 24, - cols: 80, - assigned_conversation_id: None, - engine_session_id: None, - cell_kind: crate::dto::CellKind::Pty, - }, - vec![4, 5, 6], - 1, - true, - ); - let value = serde_json::to_value(frame).unwrap(); - - assert_eq!(value["kind"], "terminal.attached"); - assert_eq!(value["replyTo"], "req-attach"); - assert_eq!(value["payload"]["session"]["sessionId"], sid); - assert_eq!(value["payload"]["scrollback"][0]["seq"], 0); - assert_eq!(value["payload"]["scrollback"][0]["bytesBase64"], "BAUG"); - assert_eq!(value["payload"]["nextSeq"], 1); - assert_eq!(value["payload"]["gap"], true); - } - - #[tokio::test] - async fn websocket_app_ping_emits_pong() { - let state = state(); - let (tx, mut rx) = mpsc::channel(4); - let owned = Arc::new(Mutex::new(Vec::new())); - - handle_client_frame(ws_frame("ping-1", "ping", json!({})), &state, &tx, &owned).await; - let frame = recv_server_frame(&mut rx).await; - - assert_eq!(frame.kind, "pong"); - assert!(frame.payload.as_object().unwrap().is_empty()); - } - - #[test] - fn websocket_domain_event_frame_uses_contract_shape() { - let project_id = ProjectId::from_uuid(Uuid::new_v4()); - let frame = ServerFrame::domain_event(&DomainEvent::ProjectCreated { project_id }); - - assert_eq!(frame.kind, "event.domain"); - assert!(frame.reply_to.is_none()); - assert_eq!(frame.payload["type"], "projectCreated"); - assert_eq!(frame.payload["projectId"], project_id.to_string()); - } - - #[tokio::test] - async fn websocket_domain_event_relay_forwards_domain_events() { - let state = state(); - let project_id = ProjectId::from_uuid(Uuid::new_v4()); - let event_rx = state.app.event_bus.raw_receiver(); - let (tx, mut rx) = mpsc::channel(4); - let relay = spawn_ws_domain_event_relay(event_rx, tx); - - state - .app - .event_bus - .publish(DomainEvent::ProjectCreated { project_id }); - let frame = tokio::time::timeout(Duration::from_secs(1), rx.recv()) - .await - .expect("event frame is forwarded") - .expect("event frame channel is open"); - relay.abort(); - - assert_eq!(frame.kind, "event.domain"); - assert_eq!(frame.payload["type"], "projectCreated"); - assert_eq!(frame.payload["projectId"], project_id.to_string()); - } - - #[tokio::test] - async fn websocket_domain_event_relay_forwards_background_completion() { - let state = state(); - let project_id = ProjectId::from_uuid(Uuid::new_v4()); - let task_id = TaskId::from_uuid(Uuid::new_v4()); - let owner = AgentId::from_uuid(Uuid::new_v4()); - let event_rx = state.app.event_bus.raw_receiver(); - let (tx, mut rx) = mpsc::channel(4); - let relay = spawn_ws_domain_event_relay(event_rx, tx); - - state - .app - .event_bus - .publish(DomainEvent::BackgroundTaskCompleted { - project_id, - task_id, - owner_agent_id: owner, - }); - let frame = tokio::time::timeout(Duration::from_secs(1), rx.recv()) - .await - .expect("background completion event is forwarded") - .expect("event frame channel is open"); - relay.abort(); - - assert_eq!(frame.kind, "event.domain"); - assert_eq!(frame.payload["type"], "backgroundTaskChanged"); - assert_eq!(frame.payload["projectId"], project_id.to_string()); - assert_eq!(frame.payload["taskId"], task_id.to_string()); - assert_eq!(frame.payload["agentId"], owner.to_string()); - assert_eq!(frame.payload["state"], "completed"); - } - - #[tokio::test] - async fn websocket_domain_event_relay_excludes_pty_output() { - let state = state(); - let project_id = ProjectId::from_uuid(Uuid::new_v4()); - let event_rx = state.app.event_bus.raw_receiver(); - let (tx, mut rx) = mpsc::channel(4); - let relay = spawn_ws_domain_event_relay(event_rx, tx); - - state.app.event_bus.publish(DomainEvent::PtyOutput { - session_id: SessionId::new_random(), - bytes: b"hidden from global live bus".to_vec(), - }); - state - .app - .event_bus - .publish(DomainEvent::ProjectCreated { project_id }); - let frame = tokio::time::timeout(Duration::from_secs(1), rx.recv()) - .await - .expect("sentinel event is forwarded") - .expect("event frame channel is open"); - relay.abort(); - - assert_eq!(frame.kind, "event.domain"); - assert_eq!(frame.payload["type"], "projectCreated"); - assert_eq!(frame.payload["projectId"], project_id.to_string()); - assert!( - rx.try_recv().is_err(), - "PtyOutput must not be emitted on the global event.domain stream" - ); - } - - #[tokio::test] - async fn websocket_domain_event_relay_drops_when_client_queue_is_full() { - let state = state(); - let event_rx = state.app.event_bus.raw_receiver(); - let (tx, mut rx) = mpsc::channel(1); - assert!(tx.try_send(ServerFrame::pong()).is_ok()); - let relay = spawn_ws_domain_event_relay(event_rx, tx); - - state.app.event_bus.publish(DomainEvent::ProjectCreated { - project_id: ProjectId::from_uuid(Uuid::new_v4()), - }); - tokio::time::sleep(Duration::from_millis(20)).await; - let first = rx.try_recv().expect("pre-filled frame remains queued"); - relay.abort(); - - assert_eq!(first.kind, "pong"); - assert!( - rx.try_recv().is_err(), - "full client queue should drop the live event instead of buffering" - ); - } - - #[tokio::test] - async fn websocket_open_terminal_emits_attached_with_empty_scrollback() { - let state = state(); - let (tx, mut rx) = mpsc::channel(16); - let owned = Arc::new(Mutex::new(Vec::new())); - - handle_client_frame( - ws_frame( - "open-1", - "terminal.open", - open_terminal_payload("/bin/sh", vec!["-c", "sleep 30"]), - ), - &state, - &tx, - &owned, - ) - .await; - let frame = recv_server_frame(&mut rx).await; - let session_id = attached_session_id(&frame); - - assert_eq!(frame.reply_to.as_deref(), Some("open-1")); - assert_eq!(frame.payload["scrollback"].as_array().unwrap().len(), 0); - assert_eq!(frame.payload["nextSeq"], 0); - - close_ws_session(&state, &session_id).await; - } - - #[tokio::test] - async fn websocket_attach_terminal_replays_scrollback_in_attached_ack() { - let state = state(); - let (tx, mut rx) = mpsc::channel(32); - let owned = Arc::new(Mutex::new(Vec::new())); - - handle_client_frame( - ws_frame( - "open-replay", - "terminal.open", - open_terminal_payload("/bin/sh", vec!["-c", "printf replay-ready; sleep 30"]), - ), - &state, - &tx, - &owned, - ) - .await; - let opened = recv_server_frame(&mut rx).await; - let session_id = attached_session_id(&opened); - - let scrollback = wait_for_scrollback(&state, &session_id, b"replay-ready").await; - assert!(scrollback.len() <= 100 * 1024); - drain_server_frames(&mut rx); - - handle_client_frame( - ws_frame( - "attach-replay", - "terminal.attach", - json!({ - "sessionId": session_id, - "lastSeq": 0, - "rows": 30, - "cols": 100, - }), - ), - &state, - &tx, - &owned, - ) - .await; - let attached = recv_server_frame(&mut rx).await; - let replay = attached.payload["scrollback"].as_array().unwrap(); - - assert_eq!(attached.kind, "terminal.attached"); - assert_eq!(attached.reply_to.as_deref(), Some("attach-replay")); - assert_eq!(attached.payload["session"]["rows"], 30); - assert_eq!(attached.payload["session"]["cols"], 100); - assert_eq!(attached.payload["gap"], true); - assert_eq!(attached.payload["nextSeq"], 1); - assert_eq!( - base64_decode(replay[0]["bytesBase64"].as_str().unwrap()).unwrap(), - scrollback - ); - - close_ws_session( - &state, - attached.payload["session"]["sessionId"].as_str().unwrap(), - ) - .await; - } - - #[tokio::test] - async fn websocket_close_terminal_emits_exited_status_and_releases_session() { - let state = state(); - let (tx, mut rx) = mpsc::channel(16); - let owned = Arc::new(Mutex::new(Vec::new())); - - handle_client_frame( - ws_frame( - "open-close", - "terminal.open", - open_terminal_payload("/bin/sh", vec!["-c", "sleep 30"]), - ), - &state, - &tx, - &owned, - ) - .await; - let opened = recv_server_frame(&mut rx).await; - let session_id = attached_session_id(&opened); - - handle_client_frame( - ws_frame( - "close-1", - "terminal.close", - json!({ "sessionId": session_id }), - ), - &state, - &tx, - &owned, - ) - .await; - let status = recv_server_frame(&mut rx).await; - - assert_eq!(status.kind, "terminal.status"); - assert_eq!(status.payload["status"], "exited"); - assert_eq!(status.payload["sessionId"], session_id); - assert_eq!(state.ws_pty_bridge.active_sessions(), 0); - assert!(state.app.terminal_sessions.is_empty()); - } - - #[tokio::test] - async fn websocket_launch_agent_emits_attached_with_assigned_conversation_id() { - let state = state(); - let (project_id, agent_id) = create_raw_cli_agent_for_test(&state, "ws-launch").await; - let node_id = Uuid::new_v4().to_string(); - let (tx, mut rx) = mpsc::channel(32); - let owned = Arc::new(Mutex::new(Vec::new())); - - handle_client_frame( - ws_frame( - "agent-launch-1", - "agent.launch", - agent_launch_payload(&project_id, &agent_id, &node_id, None), - ), - &state, - &tx, - &owned, - ) - .await; - let attached = recv_server_frame(&mut rx).await; - let session_id = attached_session_id(&attached); - - assert_eq!(attached.kind, "terminal.attached"); - assert_eq!(attached.reply_to.as_deref(), Some("agent-launch-1")); - assert_eq!(attached.payload["scrollback"].as_array().unwrap().len(), 0); - assert!(attached.payload["assignedConversationId"] - .as_str() - .and_then(|raw| Uuid::parse_str(raw).ok()) - .is_some()); - - close_ws_session(&state, &session_id).await; - } - - #[tokio::test] - async fn websocket_launch_agent_structured_is_unsupported() { - let state = state(); - let (tx, mut rx) = mpsc::channel(4); - let owned = Arc::new(Mutex::new(Vec::new())); - let session_id = SessionId::new_random(); - let agent_id = AgentId::from_uuid(Uuid::new_v4()); - let node_id = NodeId::from_uuid(Uuid::new_v4()); - let size = PtySize::new(24, 80).unwrap(); - let mut session = TerminalSession::starting( - session_id, - node_id, - ProjectPath::new("/".to_owned()).unwrap(), - SessionKind::Agent { agent_id }, - size, - ); - session.status = SessionStatus::Running; - let frame = ws_frame("agent-structured", "agent.launch", json!({})); - let output = LaunchAgentOutput { - session, - assigned_conversation_id: Some("pair-conversation".to_owned()), - engine_session_id: Some("engine-session".to_owned()), - structured: Some(StructuredSessionDescriptor { - session_id, - agent_id, - node_id, - conversation_id: Some("engine-session".to_owned()), - }), - profile: None, - }; - - if let Err(err) = send_launch_agent_attached(&frame, &state, &tx, &owned, output).await { - tx.send(ServerFrame::error( - Some(frame.id), - payload_session_id(&frame.payload), - err.code, - err.message, - )) - .await - .unwrap(); - } - let error = recv_server_frame(&mut rx).await; - - assert_eq!(error.kind, "error"); - assert_eq!(error.reply_to.as_deref(), Some("agent-structured")); - assert_eq!(error.payload["code"], "UNSUPPORTED"); - assert_eq!( - error.payload["message"], - "structured agent sessions do not stream over the PTY websocket" - ); - assert_eq!(state.ws_pty_bridge.active_sessions(), 0); - } - - #[tokio::test] - async fn websocket_launch_agent_reattach_replays_scrollback_without_respawn() { - let state = state(); - let (project_id, agent_id) = create_raw_cli_agent_for_test(&state, "ws-reattach").await; - let node_id = Uuid::new_v4().to_string(); - let (tx, mut rx) = mpsc::channel(32); - let owned = Arc::new(Mutex::new(Vec::new())); - - handle_client_frame( - ws_frame( - "agent-launch-reattach", - "agent.launch", - agent_launch_payload(&project_id, &agent_id, &node_id, None), - ), - &state, - &tx, - &owned, - ) - .await; - let launched = recv_server_frame(&mut rx).await; - let session_id = attached_session_id(&launched); - let scrollback = wait_for_scrollback(&state, &session_id, b"agent-ready").await; - drain_server_frames(&mut rx); - - handle_client_frame( - ws_frame( - "agent-attach", - "terminal.attach", - json!({ "sessionId": session_id, "lastSeq": 0, "rows": 24, "cols": 80 }), - ), - &state, - &tx, - &owned, - ) - .await; - let attached = recv_server_frame_where(&mut rx, |frame| { - frame.kind == "terminal.attached" && frame.reply_to.as_deref() == Some("agent-attach") - }) - .await; - let replay = attached.payload["scrollback"].as_array().unwrap(); - - assert_eq!(attached.kind, "terminal.attached"); - assert_eq!(attached.payload["session"]["sessionId"], session_id); - assert_eq!( - base64_decode(replay[0]["bytesBase64"].as_str().unwrap()).unwrap(), - scrollback - ); - let aid = parse_agent_id(&agent_id).unwrap(); - assert_eq!( - state.app.terminal_sessions.sessions_for_agent(&aid).len(), - 1 - ); - - close_ws_session(&state, &session_id).await; - } - - #[tokio::test] - async fn websocket_launch_agent_same_cell_is_idempotent_singleton() { - let state = state(); - let (project_id, agent_id) = create_raw_cli_agent_for_test(&state, "ws-singleton").await; - let node_id = Uuid::new_v4().to_string(); - let (tx, mut rx) = mpsc::channel(32); - let owned = Arc::new(Mutex::new(Vec::new())); - - handle_client_frame( - ws_frame( - "agent-launch-first", - "agent.launch", - agent_launch_payload(&project_id, &agent_id, &node_id, None), - ), - &state, - &tx, - &owned, - ) - .await; - let first = recv_server_frame(&mut rx).await; - let first_session = attached_session_id(&first); - - handle_client_frame( - ws_frame( - "agent-launch-second", - "agent.launch", - agent_launch_payload( - &project_id, - &agent_id, - &node_id, - first.payload["assignedConversationId"].as_str(), - ), - ), - &state, - &tx, - &owned, - ) - .await; - let second = recv_server_frame_where(&mut rx, |frame| { - frame.kind == "terminal.attached" - && frame.reply_to.as_deref() == Some("agent-launch-second") - }) - .await; - - assert_eq!(second.kind, "terminal.attached"); - assert_eq!(attached_session_id(&second), first_session); - let aid = parse_agent_id(&agent_id).unwrap(); - assert_eq!( - state.app.terminal_sessions.sessions_for_agent(&aid).len(), - 1 - ); - - close_ws_session(&state, &first_session).await; - } - - #[tokio::test] - async fn websocket_launch_agent_different_cell_is_refused_by_singleton_guard() { - let state = state(); - let (project_id, agent_id) = - create_raw_cli_agent_for_test(&state, "ws-singleton-refuse").await; - let first_node = Uuid::new_v4().to_string(); - let second_node = Uuid::new_v4().to_string(); - let (tx, mut rx) = mpsc::channel(32); - let owned = Arc::new(Mutex::new(Vec::new())); - - handle_client_frame( - ws_frame( - "agent-launch-first-cell", - "agent.launch", - agent_launch_payload(&project_id, &agent_id, &first_node, None), - ), - &state, - &tx, - &owned, - ) - .await; - let first = recv_server_frame(&mut rx).await; - let session_id = attached_session_id(&first); - - handle_client_frame( - ws_frame( - "agent-launch-other-cell", - "agent.launch", - agent_launch_payload(&project_id, &agent_id, &second_node, None), - ), - &state, - &tx, - &owned, - ) - .await; - let error = recv_server_frame_where(&mut rx, |frame| { - frame.kind == "error" && frame.reply_to.as_deref() == Some("agent-launch-other-cell") - }) - .await; - - assert_eq!(error.kind, "error"); - assert_eq!(error.reply_to.as_deref(), Some("agent-launch-other-cell")); - assert_eq!(error.payload["code"], "AGENT_ALREADY_RUNNING"); - let aid = parse_agent_id(&agent_id).unwrap(); - assert_eq!( - state.app.terminal_sessions.sessions_for_agent(&aid).len(), - 1 - ); - - close_ws_session(&state, &session_id).await; - } - - #[test] - fn websocket_client_unmasked_frame_is_rejected() { - let raw = [0x81, 0x02, b'h', b'i']; - - let err = decode_client_ws_frame(&raw).unwrap_err(); - - assert!(matches!(err, WsFrameError::Protocol(_))); - } - - #[test] - fn websocket_payload_too_large_is_rejected() { - let len = (WS_MAX_PAYLOAD + 1) as u64; - let mut raw = vec![0x81, 0x80 | 127]; - raw.extend_from_slice(&len.to_be_bytes()); - raw.extend_from_slice(&[1, 2, 3, 4]); - - let err = decode_client_ws_frame(&raw).unwrap_err(); - - assert_eq!(err, WsFrameError::TooLarge); - } - - #[test] - fn websocket_server_frames_are_unmasked() { - let raw = encode_ws_frame(WsOpcode::Text, b"{}"); - - assert_eq!(raw[0], 0x81); - assert_eq!(raw[1] & 0x80, 0, "server frames must not be masked"); - } - - #[tokio::test] - async fn websocket_sink_full_reports_backpressure_without_blocking() { - let (tx, mut rx) = mpsc::channel(1); - let sid = domain::SessionId::new_random(); - let sink = WsPtySink::new(sid, tx, 0); - - assert!(sink.send(vec![1]).is_ok()); - assert_eq!(sink.send(vec![2]), Err(OutputSinkError::Full)); - let frame = rx.recv().await.unwrap(); - assert_eq!(frame.kind, "terminal.output"); - } - - #[tokio::test] - async fn websocket_output_bridge_replaces_attachment_without_double_delivery() { - let bridge = OutputBridge::::new(); - let sid = domain::SessionId::new_random(); - let (old_tx, mut old_rx) = mpsc::channel(4); - let (new_tx, mut new_rx) = mpsc::channel(4); - - let old_gen = bridge.register(sid, Arc::new(WsPtySink::new(sid, old_tx, 0))); - let _new_gen = bridge.register(sid, Arc::new(WsPtySink::new(sid, new_tx, 0))); - - assert!(bridge.send_output(&sid, vec![9])); - assert!(old_rx.try_recv().is_err()); - let frame = new_rx.recv().await.unwrap(); - assert_eq!(frame.kind, "terminal.output"); - assert_eq!(frame.payload["bytesBase64"], "CQ=="); - - bridge.unregister_if(&sid, old_gen); - assert_eq!(bridge.active_sessions(), 1); - } - - #[test] - fn public_bind_requires_explicit_remote_security() { - let config = ServerConfig { - listen: "0.0.0.0:17373".parse().unwrap(), - ..test_config() - }; - - assert!(config.validate().is_err()); - - let config = ServerConfig { - allow_remote: true, - public_origin: Some("https://idea.example.com".to_owned()), - trust_reverse_proxy: true, - ..config - }; - assert!(config.validate().is_ok()); - } - - #[test] - fn allow_remote_requires_https_origin_and_reverse_proxy() { - let base = ServerConfig { - listen: "0.0.0.0:17373".parse().unwrap(), - allow_remote: true, - ..test_config() - }; - - assert!(base.validate().is_err()); - - let with_http = ServerConfig { - public_origin: Some("http://idea.example.com".to_owned()), - trust_reverse_proxy: true, - ..base.clone() - }; - assert!(with_http.validate().is_err()); - - let without_proxy = ServerConfig { - public_origin: Some("https://idea.example.com".to_owned()), - trust_reverse_proxy: false, - ..base - }; - assert!(without_proxy.validate().is_err()); - } - - #[tokio::test] - async fn invoke_requires_valid_cookie() { - let state = state(); - - let response = request( - state, - Method::POST, - "/api/invoke", - json!({ "command": "health", "args": {} }), - &[], - ) - .await; - let (status, body, _) = response_json(response).await; - - assert_eq!(status, StatusCode::UNAUTHORIZED); - assert_eq!(body["code"], "UNAUTHORIZED"); - } - - #[tokio::test] - async fn invoke_rejects_invalid_session_cookie() { - let state = state(); - - let response = request( - state, - Method::POST, - "/api/invoke", - json!({ "command": "health", "args": {} }), - &[("cookie", "idea_session=not-a-valid-session")], - ) - .await; - let (status, body, _) = response_json(response).await; - - assert_eq!(status, StatusCode::UNAUTHORIZED); - assert_eq!(body["code"], "UNAUTHORIZED"); - } - - #[tokio::test] - async fn logout_revokes_session_for_http_and_websocket() { - let state = state(); - let cookie = pair_and_cookie(Arc::clone(&state)).await; - - let response = request( - Arc::clone(&state), - Method::POST, - "/api/logout", - json!({}), - &[("cookie", &cookie)], - ) - .await; - let (status, body, headers) = response_json(response).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(body, json!({ "revoked": true })); - assert!(headers - .get(SET_COOKIE) - .unwrap() - .to_str() - .unwrap() - .contains("Max-Age=0")); - - let response = request( - Arc::clone(&state), - Method::POST, - "/api/invoke", - json!({ "command": "health", "args": {} }), - &[("cookie", &cookie)], - ) - .await; - let (status, body, _) = response_json(response).await; - assert_eq!(status, StatusCode::UNAUTHORIZED); - assert_eq!(body["code"], "UNAUTHORIZED"); - - let headers = ws_headers("http://127.0.0.1:17373", Some(&cookie)); - let response = validate_ws_upgrade(&headers, &state).unwrap_err(); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - } - - #[tokio::test] - async fn security_logs_pairing_origin_ws_and_revoke_without_secrets() { - let logger = Arc::new(RecordingSecurityLogger::default()); - let state = Arc::new(ServerState::new_for_test_with_logger( - test_config(), - "PAIR1234", - logger.clone(), - )); - - let bad_origin = handle_request( - Request::builder() - .method(Method::POST) - .uri("/api/pair") - .header(ORIGIN, "https://evil.example") - .header(CONTENT_TYPE, "application/json") - .body(Full::new(Bytes::from_static(br#"{"code":"PAIR1234"}"#))) - .unwrap(), - Arc::clone(&state), - ) - .await; - assert_eq!(bad_origin.status(), StatusCode::FORBIDDEN); - - let wrong_code = request( - Arc::clone(&state), - Method::POST, - "/api/pair", - json!({ "code": "WRONG999" }), - &[], - ) - .await; - assert_eq!(wrong_code.status(), StatusCode::FORBIDDEN); - - let cookie = pair_and_cookie(Arc::clone(&state)).await; - let _ = request( - Arc::clone(&state), - Method::POST, - "/api/logout", - json!({}), - &[("cookie", &cookie)], - ) - .await; - - let headers = ws_headers( - "http://127.0.0.1:17373", - Some("idea_session=invalid-token-value"), - ); - let _ = validate_ws_upgrade(&headers, &state); - - let events = logger.events(); - assert!(events.iter().any(|event| matches!( - event, - SecurityLogEvent::OriginRejected { route, .. } if route == "/api/pair" - ))); - assert!(events.iter().any(|event| matches!( - event, - SecurityLogEvent::PairingFailed { - reason: "wrong_code", - .. - } - ))); - assert!(events - .iter() - .any(|event| matches!(event, SecurityLogEvent::PairingSucceeded { .. }))); - assert!(events - .iter() - .any(|event| matches!(event, SecurityLogEvent::SessionRevoked { .. }))); - assert!(events.iter().any(|event| matches!( - event, - SecurityLogEvent::WsUpgradeRejected { - reason: "invalid_session", - .. - } - ))); - - let lines = events.iter().map(security_log_line).collect::>(); - assert!( - !lines.iter().any(|line| line.contains("PAIR1234") - || line.contains("WRONG999") - || line.contains("invalid-token-value")), - "security logs must not leak pairing codes or session tokens" - ); - } - - #[tokio::test] - async fn pairing_rejects_wrong_code() { - let state = state(); - - let response = request( - state, - Method::POST, - "/api/pair", - json!({ "code": "WRONG999" }), - &[], - ) - .await; - let (status, body, headers) = response_json(response).await; - - assert_eq!(status, StatusCode::FORBIDDEN); - assert_eq!(body["code"], "FORBIDDEN"); - assert!( - !headers.contains_key(SET_COOKIE), - "wrong pairing code must not issue a session cookie" - ); - } - - #[tokio::test] - async fn pairing_sets_http_only_strict_cookie_for_loopback_dev() { - let state = state(); - - let response = request( - state, - Method::POST, - "/api/pair", - json!({ "code": "PAIR1234" }), - &[], - ) - .await; - let (status, body, headers) = response_json(response).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(body, json!({ "paired": true })); - let cookie = headers.get(SET_COOKIE).unwrap().to_str().unwrap(); - assert!(cookie.contains("idea_session=")); - assert!(cookie.contains("HttpOnly")); - assert!(cookie.contains("SameSite=Strict")); - assert!( - !cookie.contains("Secure"), - "loopback dev over HTTP deliberately avoids Secure so browsers store it" - ); - } - - #[tokio::test] - async fn pairing_sets_secure_cookie_for_remote_https_origin() { - let config = ServerConfig { - listen: "0.0.0.0:17373".parse().unwrap(), - public_origin: Some("https://idea.example.com".to_owned()), - allow_remote: true, - trust_reverse_proxy: true, - ..test_config() - }; - let state = Arc::new(ServerState::new_for_test(config, "PAIR1234")); - - let mut req = Request::builder() - .method(Method::POST) - .uri("/api/pair") - .header(ORIGIN, "https://idea.example.com") - .header(CONTENT_TYPE, "application/json") - .body(Full::new(Bytes::from_static(br#"{"code":"PAIR1234"}"#))) - .unwrap(); - *req.headers_mut().get_mut(CONTENT_TYPE).unwrap() = - HeaderValue::from_static("application/json"); - - let response = handle_request(req, state).await; - let (status, _, headers) = response_json(response).await; - - assert_eq!(status, StatusCode::OK); - let cookie = headers.get(SET_COOKIE).unwrap().to_str().unwrap(); - assert!(cookie.contains("Secure")); - assert!(cookie.contains("HttpOnly")); - assert!(cookie.contains("SameSite=Strict")); - } - - #[tokio::test] - async fn token_or_secret_in_query_string_is_refused() { - let state = state(); - - let response = request( - state, - Method::POST, - "/api/invoke?token=abc", - json!({ "command": "health", "args": {} }), - &[], - ) - .await; - let (status, body, _) = response_json(response).await; - - assert_eq!(status, StatusCode::BAD_REQUEST); - assert_eq!(body["code"], "INVALID"); - } - - #[tokio::test] - async fn serves_web_index_same_origin_with_csp() { - let state = state(); - - let response = request(state, Method::GET, "/", json!({}), &[]).await; - let (status, body, headers) = response_bytes(response).await; - - assert_eq!(status, StatusCode::OK); - assert!(std::str::from_utf8(&body).unwrap().contains("id=\"root\"")); - assert_eq!( - headers.get(CONTENT_TYPE).unwrap(), - "text/html; charset=utf-8" - ); - assert!(headers.contains_key("content-security-policy")); - assert_eq!(headers.get("x-content-type-options").unwrap(), "nosniff"); - } - - #[tokio::test] - async fn serves_static_assets_with_mime_types() { - let state = state(); - - let response = request(state, Method::GET, "/assets/app.js", json!({}), &[]).await; - let (status, body, headers) = response_bytes(response).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(std::str::from_utf8(&body).unwrap(), "console.log('idea');"); - assert_eq!( - headers.get(CONTENT_TYPE).unwrap(), - "text/javascript; charset=utf-8" - ); - } - - #[tokio::test] - async fn serves_spa_fallback_for_non_api_routes_without_extension() { - let state = state(); - - let response = request(state, Method::GET, "/projects/abc", json!({}), &[]).await; - let (status, body, headers) = response_bytes(response).await; - - assert_eq!(status, StatusCode::OK); - assert!(std::str::from_utf8(&body).unwrap().contains("id=\"root\"")); - assert!(headers.contains_key("content-security-policy")); - } - - #[tokio::test] - async fn missing_asset_and_api_routes_do_not_fallback_to_spa() { - let state = state(); - let cookie = pair_and_cookie(Arc::clone(&state)).await; - - let missing_asset = request( - Arc::clone(&state), - Method::GET, - "/assets/missing.js", - json!({}), - &[], - ) - .await; - let (asset_status, asset_body, _) = response_json(missing_asset).await; - assert_eq!(asset_status, StatusCode::NOT_FOUND); - assert_eq!(asset_body["code"], "NOT_FOUND"); - - let api_unknown = request( - state, - Method::GET, - "/api/unknown", - json!({}), - &[("cookie", &cookie)], - ) - .await; - let (api_status, api_body, _) = response_json(api_unknown).await; - assert_eq!(api_status, StatusCode::NOT_FOUND); - assert_eq!(api_body["code"], "NOT_FOUND"); - } - - #[test] - fn static_route_rejects_traversal_and_dotfiles() { - let root = PathBuf::from("/tmp/web"); - - assert!(static_route(&root, "/../secret").is_none()); - assert!(static_route(&root, "/%2e%2e/secret").is_none()); - assert!(static_route(&root, "/.env").is_none()); - assert_eq!( - static_route(&root, "/dashboard").unwrap(), - StaticRoute { - candidate: root.join("dashboard"), - spa_fallback: true - } - ); - assert_eq!( - static_route(&root, "/assets/app.js").unwrap(), - StaticRoute { - candidate: root.join("assets").join("app.js"), - spa_fallback: false - } - ); - } - - #[test] - fn explicit_web_root_requires_index_html() { - let missing = std::env::temp_dir().join(format!("idea-empty-web-{}", Uuid::new_v4())); - std::fs::create_dir_all(&missing).unwrap(); - - assert!(validate_web_root(missing, "--web-root").is_err()); - assert!(validate_web_root(create_web_root(), "--web-root").is_ok()); - } - - #[tokio::test] - async fn non_allowed_origin_is_refused() { - let state = state(); - let mut req = Request::builder() - .method(Method::POST) - .uri("/api/pair") - .header(ORIGIN, "https://evil.example") - .header(CONTENT_TYPE, "application/json") - .body(Full::new(Bytes::from_static(br#"{"code":"PAIR1234"}"#))) - .unwrap(); - *req.headers_mut().get_mut(CONTENT_TYPE).unwrap() = - HeaderValue::from_static("application/json"); - - let response = handle_request(req, state).await; - let (status, body, _) = response_json(response).await; - - assert_eq!(status, StatusCode::FORBIDDEN); - assert_eq!(body["code"], "FORBIDDEN"); - } - - #[tokio::test] - async fn unknown_allowlisted_command_returns_unknown_command() { - let state = state(); - let cookie = pair_and_cookie(Arc::clone(&state)).await; - - let response = request( - state, - Method::POST, - "/api/invoke", - json!({ "command": "debug_dump", "args": {} }), - &[("cookie", &cookie)], - ) - .await; - let (status, body, _) = response_json(response).await; - - assert_eq!(status, StatusCode::BAD_REQUEST); - assert_eq!(body["code"], "UNKNOWN_COMMAND"); - } - - #[tokio::test] - async fn authorized_health_invoke_returns_health_report() { - let state = state(); - let cookie = pair_and_cookie(Arc::clone(&state)).await; - - let response = request( - state, - Method::POST, - "/api/invoke", - json!({ "command": "health", "args": { "request": { "note": "hi" } } }), - &[("cookie", &cookie)], - ) - .await; - let (status, body, _) = response_json(response).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["alive"], true); - assert_eq!(body["note"], "hi"); - } - - #[tokio::test] - async fn authorized_list_projects_returns_tauri_project_list_contract() { - let state = state(); - let project_id = create_project_for_test(&state, "Web Read").await; - let cookie = pair_and_cookie(Arc::clone(&state)).await; - - let response = request( - state, - Method::POST, - "/api/invoke", - json!({ "command": "list_projects", "args": {} }), - &[("cookie", &cookie)], - ) - .await; - let (status, body, _) = response_json(response).await; - - assert_eq!(status, StatusCode::OK); - let projects = body - .as_array() - .expect("ProjectListDto is a transparent array"); - let project = projects - .iter() - .find(|project| project["id"] == project_id) - .expect("created project is listed"); - assert_eq!(project["name"], "Web Read"); - assert!(project["root"] - .as_str() - .is_some_and(|root| root.contains("idea-server-project-"))); - } - - #[tokio::test] - async fn authorized_open_project_returns_readonly_project_dto() { - let state = state(); - let project_id = create_project_for_test(&state, "Readonly Open").await; - let cookie = pair_and_cookie(Arc::clone(&state)).await; - - let response = request( - state, - Method::POST, - "/api/invoke", - json!({ "command": "open_project", "args": { "projectId": project_id } }), - &[("cookie", &cookie)], - ) - .await; - let (status, body, _) = response_json(response).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["id"], project_id); - assert_eq!(body["name"], "Readonly Open"); - assert!(body["root"].is_string()); - } - - #[tokio::test] - async fn authorized_get_project_work_state_returns_tauri_contract() { - let state = state(); - let project_id = create_project_for_test(&state, "Readonly Work").await; - let cookie = pair_and_cookie(Arc::clone(&state)).await; - - let response = request( - state, - Method::POST, - "/api/invoke", - json!({ "command": "get_project_work_state", "args": { "projectId": project_id } }), - &[("cookie", &cookie)], - ) - .await; - let (status, body, _) = response_json(response).await; - - assert_eq!(status, StatusCode::OK); - assert!(body["agents"].as_array().is_some()); - assert!(body["conversations"].as_array().is_some()); - } - - #[tokio::test] - async fn authorized_list_background_tasks_returns_tauri_contract() { - let state = state(); - let project_id = create_project_for_test(&state, "Background Snapshot").await; - let (task_id, owner) = - create_background_task_for_test(&state, &project_id, "web background").await; - let cookie = pair_and_cookie(Arc::clone(&state)).await; - - let response = request( - state, - Method::POST, - "/api/invoke", - json!({ - "command": "list_background_tasks", - "args": { "projectId": project_id, "agentId": owner.to_string() } - }), - &[("cookie", &cookie)], - ) - .await; - let (status, body, _) = response_json(response).await; - - assert_eq!(status, StatusCode::OK); - let tasks = body.as_array().expect("background task list is an array"); - assert_eq!(tasks.len(), 1); - assert_eq!(tasks[0]["taskId"], task_id.to_string()); - assert_eq!(tasks[0]["ownerAgentId"], owner.to_string()); - assert_eq!(tasks[0]["projectId"], project_id); - assert_eq!(tasks[0]["kind"], "command"); - assert_eq!(tasks[0]["state"], "queued"); - } - - #[tokio::test] - async fn background_actions_are_allowlisted_with_auth_gate() { - let state = state(); - let cookie = pair_and_cookie(Arc::clone(&state)).await; - let bad_task_id = "not-a-task-id"; - - for command in ["cancel_background_task", "retry_background_task"] { - let response = request( - Arc::clone(&state), - Method::POST, - "/api/invoke", - json!({ "command": command, "args": { "taskId": bad_task_id } }), - &[("cookie", &cookie)], - ) - .await; - let (status, body, _) = response_json(response).await; - - assert_eq!(status, StatusCode::BAD_REQUEST); - assert_eq!( - body["code"], "INVALID", - "{command} must be an explicit action, not UNKNOWN_COMMAND" - ); - } - } - - #[tokio::test] - async fn mutation_and_pty_commands_stay_out_of_readonly_allowlist() { - let state = state(); - let cookie = pair_and_cookie(Arc::clone(&state)).await; - - for command in ["create_project", "open_terminal", "launch_agent"] { - let response = request( - Arc::clone(&state), - Method::POST, - "/api/invoke", - json!({ "command": command, "args": {} }), - &[("cookie", &cookie)], - ) - .await; - let (status, body, _) = response_json(response).await; - - assert_eq!(status, StatusCode::BAD_REQUEST); - assert_eq!( - body["code"], "UNKNOWN_COMMAND", - "{command} must stay blocked" - ); - } - } - - #[tokio::test] - async fn invalid_project_id_is_mapped_to_error_dto() { - let state = state(); - let cookie = pair_and_cookie(Arc::clone(&state)).await; - - let response = request( - state, - Method::POST, - "/api/invoke", - json!({ "command": "open_project", "args": { "projectId": "nope" } }), - &[("cookie", &cookie)], - ) - .await; - let (status, body, _) = response_json(response).await; - - assert_eq!(status, StatusCode::BAD_REQUEST); - assert_eq!(body["code"], "INVALID"); - } - - #[test] - fn direct_project_id_parser_still_rejects_invalid_ids() { - assert!(parse_project_id("not-a-uuid").is_err()); - } + web_server::run_from_args(args) } diff --git a/crates/app-tauri/src/tickets.rs b/crates/app-tauri/src/tickets.rs index 37f9452..bab1524 100644 --- a/crates/app-tauri/src/tickets.rs +++ b/crates/app-tauri/src/tickets.rs @@ -1743,15 +1743,6 @@ fn required_str<'a>(arguments: &'a Value, key: &str) -> Result<&'a str, TicketTo .ok_or_else(|| TicketToolError::new("invalid", format!("missing {key}"))) } -impl ErrorDto { - fn invalid(message: impl Into) -> Self { - Self { - code: "INVALID".to_owned(), - message: message.into(), - } - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs new file mode 100644 index 0000000..87a09bf --- /dev/null +++ b/crates/backend/src/dto.rs @@ -0,0 +1,3495 @@ +//! Data Transfer Objects crossing the IPC boundary. +//! +//! Convention (frozen here for L1, see L1-ipc-bridge.md "points d'attention"): +//! **all IPC payloads are `camelCase`** via `#[serde(rename_all = "camelCase")]`. +//! Rust uses `snake_case` fields; serde renames them on the wire so the +//! TypeScript side sees idiomatic camelCase. This matches the persisted-domain +//! JSON convention already used in the domain (`agents.json` etc.). + +use serde::{Deserialize, Serialize}; + +use application::{ + AgentBackgroundTaskState, AgentTicketState, AppError, AttachLiveAgentOutput, + BackgroundTaskKindLabel, ConversationPreviewStatus, ConversationTurnWorkPreview, + ConversationWorkSummary, CreateProjectInput, CreateProjectOutput, GitGraphOutput, HealthInput, + HealthReport, LayoutKind, ListProjectsOutput, LiveSessionKind, LiveSessionSnapshot, + OpenProjectOutput, ProjectWorkState, StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, + TurnPage, TurnSource, TurnView, +}; +use domain::{AgentBusyState, PageCursor, PageDirection, Project, ProjectId, TurnRole}; + +/// Request DTO for the `health` command. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HealthRequestDto { + /// Optional note echoed back by the use case. + #[serde(default)] + pub note: Option, +} + +impl From for HealthInput { + fn from(dto: HealthRequestDto) -> Self { + Self { note: dto.note } + } +} + +/// Response DTO for the `health` command. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HealthResponseDto { + /// Application version. + pub version: String, + /// Liveness flag. + pub alive: bool, + /// Server time in epoch milliseconds. + pub time_millis: i64, + /// Correlation id for this call. + pub correlation_id: String, + /// Echoed note, if any. + pub note: Option, +} + +impl From for HealthResponseDto { + fn from(r: HealthReport) -> Self { + Self { + version: r.version, + alive: r.alive, + time_millis: r.time_millis, + correlation_id: r.correlation_id, + note: r.note, + } + } +} + +/// Error DTO returned to the frontend in the `Err` arm of every command. +/// +/// `code` is a stable machine-readable string (see [`AppError::code`]); the +/// frontend branches on it without parsing `message`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ErrorDto { + /// Stable error code, e.g. `NOT_FOUND`, `INVALID`. + pub code: String, + /// Human-readable message. + pub message: String, +} + +impl From for ErrorDto { + fn from(e: AppError) -> Self { + Self { + code: e.code().to_owned(), + message: e.to_string(), + } + } +} + +impl ErrorDto { + /// Builds a stable `INVALID` error. + #[must_use] + pub fn invalid(message: impl Into) -> Self { + Self { + code: "INVALID".to_owned(), + message: message.into(), + } + } +} + +// --------------------------------------------------------------------------- +// Projects (L2) +// --------------------------------------------------------------------------- + +/// A project as seen by the frontend (camelCase wire shape). +/// +/// `remote` is the domain [`domain::RemoteRef`], which already serialises +/// camelCase + tagged (`kind`), so we embed it directly. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectDto { + /// Stable project id (UUID string). + pub id: String, + /// Display name. + pub name: String, + /// Absolute project root. + pub root: String, + /// Where the project lives (`{ "kind": "local" }`, `ssh`, `wsl`). + pub remote: domain::RemoteRef, + /// Creation timestamp, epoch milliseconds. + pub created_at: i64, +} + +impl From for ProjectDto { + fn from(p: Project) -> Self { + Self { + id: p.id.to_string(), + name: p.name, + root: p.root.as_str().to_owned(), + remote: p.remote, + created_at: p.created_at, + } + } +} + +/// Request DTO for `create_project`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateProjectRequestDto { + /// Display name. + pub name: String, + /// Absolute project root. + pub root: String, + /// Optional remote reference; defaults to local when omitted. + #[serde(default)] + pub remote: Option, + /// Optional default profile id. + #[serde(default)] + pub default_profile_id: Option, +} + +impl From for CreateProjectInput { + fn from(dto: CreateProjectRequestDto) -> Self { + Self { + name: dto.name, + root: dto.root, + remote: dto.remote, + default_profile_id: dto.default_profile_id, + } + } +} + +impl From for ProjectDto { + fn from(out: CreateProjectOutput) -> Self { + out.project.into() + } +} + +impl From for ProjectDto { + fn from(out: OpenProjectOutput) -> Self { + out.project.into() + } +} + +/// Parses a project-id string (UUID) coming from the frontend. +/// +/// # Errors +/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. +pub fn parse_project_id(raw: &str) -> Result { + uuid::Uuid::parse_str(raw) + .map(ProjectId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid project id: {raw}"), + }) +} + +/// Response DTO for `list_projects`. +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct ProjectListDto(pub Vec); + +impl From for ProjectListDto { + fn from(out: ListProjectsOutput) -> Self { + Self(out.projects.into_iter().map(ProjectDto::from).collect()) + } +} + +// --------------------------------------------------------------------------- +// Terminals (L3) +// --------------------------------------------------------------------------- + +use application::{ + CloseTerminalInput, CloseTerminalOutput, OpenTerminalInput, OpenTerminalOutput, + ResizeTerminalInput, WriteToTerminalInput, +}; +use domain::SessionId; + +/// Request DTO for `open_terminal`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenTerminalRequestDto { + /// Working directory (typically the project root). + pub cwd: String, + /// Initial terminal height in rows. + pub rows: u16, + /// Initial terminal width in columns. + pub cols: u16, + /// Optional explicit command; defaults to the platform shell when omitted. + #[serde(default)] + pub command: Option, + /// Optional arguments for the command. + #[serde(default)] + pub args: Vec, +} + +impl From for OpenTerminalInput { + fn from(dto: OpenTerminalRequestDto) -> Self { + Self { + cwd: dto.cwd, + rows: dto.rows, + cols: dto.cols, + command: dto.command, + args: dto.args, + node_id: None, + } + } +} + +/// Response DTO for `open_terminal`: the freshly-opened session. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalSessionDto { + /// Stable session id (UUID string) — used for write/resize/close + the + /// output channel. + pub session_id: String, + /// Working directory the shell runs in. + pub cwd: String, + /// Current rows. + pub rows: u16, + /// Current cols. + pub cols: u16, + /// Conversation id **assigned** by this launch, when the agent's profile + /// supports session assignment and the hosting cell had none yet (T4b). The + /// front persists it on the leaf (`setCellConversation`) so the next open + /// resumes. `None` for a plain terminal, a resume, or a degraded launch. + #[serde(skip_serializing_if = "Option::is_none")] + pub assigned_conversation_id: Option, + /// **Id de session moteur** (resumable du provider courant) exposé par ce + /// lancement, **distinct** de l'id de paire `assignedConversationId` (ARCHITECTURE + /// §19.7, lot P8a). Le front le range dans le **cache** `engineSessionId` de la + /// cellule (jamais sur `conversationId`). Absent pour un terminal nu, une reprise, + /// ou quand le moteur n'expose encore aucun id. La source de vérité du resumable + /// est `providers.json` (lot P8b). + #[serde(skip_serializing_if = "Option::is_none")] + pub engine_session_id: Option, + /// How the frontend should render the cell hosting this session (§17.6): + /// [`CellKind::Chat`] for a structured AI session (an `AgentChatView` driven by + /// `agent_send`/`reattach_agent_chat`), [`CellKind::Pty`] for a raw terminal + /// (xterm). **Derived**, not a layout field: it follows the presence of a + /// structured session descriptor on the launch output — a single source of + /// truth. Plain terminals and the non-launch construction paths default to + /// [`CellKind::Pty`] (the historical, non-breaking shape: a PTY session DTO + /// always serialises with `cellKind: "pty"`). + pub cell_kind: CellKind, +} + +/// Whether a session's hosting cell renders as a structured chat view or a raw +/// terminal (ARCHITECTURE §17.6). Serialises as `"chat"` / `"pty"` on the wire; +/// the frontend `LayoutGrid` switches `AgentChatView` vs `TerminalView` on it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum CellKind { + /// A raw PTY terminal cell (xterm) — the historical default. + Pty, + /// A structured AI chat cell (`AgentChatView`), backed by an `AgentSession`. + Chat, +} + +impl From for TerminalSessionDto { + fn from(out: OpenTerminalOutput) -> Self { + let s = out.session; + Self { + session_id: s.id.to_string(), + cwd: s.cwd.as_str().to_owned(), + rows: s.pty_size.rows, + cols: s.pty_size.cols, + assigned_conversation_id: None, + engine_session_id: None, + cell_kind: CellKind::Pty, + } + } +} + +/// Response DTO for `reattach_terminal`: the retained scrollback of a still-live +/// session, repainted into the re-mounting xterm before the new output stream is +/// wired. Bytes are serialised as a number array, matching the PTY output channel. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReattachResultDto { + /// The session that was re-attached (echoed back for the frontend). + pub session_id: String, + /// The most-recent retained output bytes (scrollback ring buffer). + pub scrollback: Vec, +} + +/// Request DTO for `write_terminal`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WriteTerminalRequestDto { + /// Target session id. + pub session_id: String, + /// Bytes to write (xterm keystrokes). + pub data: Vec, +} + +impl WriteTerminalRequestDto { + /// Converts to the use-case input, parsing the session id. + /// + /// # Errors + /// [`ErrorDto`] with code `INVALID` if the id is malformed. + pub fn into_input(self) -> Result { + Ok(WriteToTerminalInput { + session_id: parse_session_id(&self.session_id)?, + data: self.data, + }) + } +} + +/// Request DTO for `resize_terminal`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResizeTerminalRequestDto { + /// Target session id. + pub session_id: String, + /// New rows. + pub rows: u16, + /// New cols. + pub cols: u16, +} + +impl ResizeTerminalRequestDto { + /// Converts to the use-case input, parsing the session id. + /// + /// # Errors + /// [`ErrorDto`] with code `INVALID` if the id is malformed. + pub fn into_input(self) -> Result { + Ok(ResizeTerminalInput { + session_id: parse_session_id(&self.session_id)?, + rows: self.rows, + cols: self.cols, + }) + } +} + +/// Response DTO for `close_terminal`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalClosedDto { + /// Exit code, if the process reported one. + pub code: Option, +} + +impl From for TerminalClosedDto { + fn from(out: CloseTerminalOutput) -> Self { + Self { code: out.code } + } +} + +/// Builds a [`CloseTerminalInput`] from a raw session-id string. +/// +/// # Errors +/// [`ErrorDto`] with code `INVALID` if the id is malformed. +pub fn parse_close_terminal(raw: &str) -> Result { + Ok(CloseTerminalInput { + session_id: parse_session_id(raw)?, + }) +} + +/// Parses a session-id string (UUID) coming from the frontend. +/// +/// # Errors +/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. +pub fn parse_session_id(raw: &str) -> Result { + uuid::Uuid::parse_str(raw) + .map(SessionId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid session id: {raw}"), + }) +} + +// --------------------------------------------------------------------------- +// Layout (L4 + #4 management + #3 per-cell agent) +// --------------------------------------------------------------------------- + +use application::{ + CreateLayoutOutput, DeleteLayoutOutput, LayoutInfo, LayoutOperation, ListLayoutsOutput, + LoadLayoutOutput, MutateLayoutOutput, SetActiveLayoutOutput, +}; +use domain::{AgentId, Direction, LayoutId, LayoutTree, NodeId}; + +/// Response DTO carrying a layout tree. +/// +/// [`LayoutTree`] already serialises camelCase + tagged (its enum uses +/// `#[serde(tag = "type", content = "node")]`), so we embed it directly; the +/// TypeScript mirror in `@/domain` matches this shape. +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct LayoutDto(pub LayoutTree); + +impl From for LayoutDto { + fn from(out: LoadLayoutOutput) -> Self { + Self(out.layout) + } +} + +impl From for LayoutDto { + fn from(out: MutateLayoutOutput) -> Self { + Self(out.layout) + } +} + +/// A layout operation as sent by the frontend (tagged on `type`, camelCase). +/// +/// Mirrors [`LayoutOperation`]; node/session ids cross the wire as UUID strings +/// and are parsed here. `direction` reuses the domain [`Direction`] (which +/// already serialises `"row"`/`"column"`). +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum LayoutOperationDto { + /// Split a leaf into a two-child split. + #[serde(rename_all = "camelCase")] + Split { + /// Leaf to split. + target: String, + /// Row (columns) or Column (rows). + direction: Direction, + /// Id for the new sibling leaf. + new_leaf: String, + /// Id for the wrapping split container. + container: String, + }, + /// Collapse a split container back to one child. + #[serde(rename_all = "camelCase")] + Merge { + /// Split container to collapse. + container: String, + /// Index of the child to keep. + keep_index: usize, + }, + /// Reassign a split's child weights. + #[serde(rename_all = "camelCase")] + Resize { + /// Split container to resize. + container: String, + /// New weights (one per child). + weights: Vec, + }, + /// Move a session from one leaf to another. + #[serde(rename_all = "camelCase")] + Move { + /// Source leaf. + from: String, + /// Target (empty) leaf. + to: String, + }, + /// Attach/detach a session to/from a leaf. + #[serde(rename_all = "camelCase")] + SetSession { + /// Hosting leaf. + target: String, + /// Session id, or `null` to clear. + #[serde(default)] + session: Option, + }, + /// Attach/detach an agent to/from a leaf (#3 per-cell agent). + #[serde(rename_all = "camelCase")] + SetCellAgent { + /// Hosting leaf. + target: String, + /// Agent id, or `null` to clear. + #[serde(default)] + agent: Option, + }, + /// Record/clear the persistent CLI conversation id on a leaf (T4b). + #[serde(rename_all = "camelCase")] + SetCellConversation { + /// Hosting leaf. + target: String, + /// Conversation id, or `null` to clear. + #[serde(default)] + conversation_id: Option, + }, +} + +impl LayoutOperationDto { + /// Converts to the use-case operation, parsing all ids. + /// + /// # Errors + /// [`ErrorDto`] with code `INVALID` if any id is malformed. + pub fn into_operation(self) -> Result { + Ok(match self { + Self::Split { + target, + direction, + new_leaf, + container, + } => LayoutOperation::Split { + target: parse_node_id(&target)?, + direction, + new_leaf: parse_node_id(&new_leaf)?, + container: parse_node_id(&container)?, + }, + Self::Merge { + container, + keep_index, + } => LayoutOperation::Merge { + container: parse_node_id(&container)?, + keep_index, + }, + Self::Resize { container, weights } => LayoutOperation::Resize { + container: parse_node_id(&container)?, + weights, + }, + Self::Move { from, to } => LayoutOperation::Move { + from: parse_node_id(&from)?, + to: parse_node_id(&to)?, + }, + Self::SetSession { target, session } => LayoutOperation::SetSession { + target: parse_node_id(&target)?, + session: session.as_deref().map(parse_session_id).transpose()?, + }, + Self::SetCellAgent { target, agent } => LayoutOperation::SetCellAgent { + target: parse_node_id(&target)?, + agent: agent.as_deref().map(parse_agent_id).transpose()?, + }, + Self::SetCellConversation { + target, + conversation_id, + } => LayoutOperation::SetCellConversation { + target: parse_node_id(&target)?, + conversation_id, + }, + }) + } +} + +/// Parses a node-id string (UUID) coming from the frontend. +/// +/// # Errors +/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. +pub fn parse_node_id(raw: &str) -> Result { + uuid::Uuid::parse_str(raw) + .map(NodeId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid node id: {raw}"), + }) +} + +/// Parses a layout-id string (UUID) coming from the frontend. +/// +/// # Errors +/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. +pub fn parse_layout_id(raw: &str) -> Result { + uuid::Uuid::parse_str(raw) + .map(LayoutId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid layout id: {raw}"), + }) +} + +// --------------------------------------------------------------------------- +// Layouts (#4) — management DTOs +// --------------------------------------------------------------------------- + +/// Lightweight layout descriptor (id + name + kind), for the tab bar. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LayoutInfoDto { + /// Stable layout id (UUID string). + pub id: String, + /// Display name. + pub name: String, + /// Layout kind: `"terminal"` or `"gitGraph"`. + pub kind: String, +} + +impl From for LayoutInfoDto { + fn from(info: LayoutInfo) -> Self { + let kind = match info.kind { + LayoutKind::Terminal => "terminal", + LayoutKind::GitGraph => "gitGraph", + } + .to_owned(); + Self { + id: info.id.to_string(), + name: info.name, + kind, + } + } +} + +/// Response DTO for `list_layouts`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ListLayoutsDto { + /// All named layouts (id + name), in order. + pub layouts: Vec, + /// The id of the currently active layout. + pub active_id: String, +} + +impl From for ListLayoutsDto { + fn from(out: ListLayoutsOutput) -> Self { + Self { + layouts: out.layouts.into_iter().map(LayoutInfoDto::from).collect(), + active_id: out.active_id.to_string(), + } + } +} + +/// Response DTO for `create_layout`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateLayoutResultDto { + /// The id minted for the new layout. + pub layout_id: String, +} + +impl From for CreateLayoutResultDto { + fn from(out: CreateLayoutOutput) -> Self { + Self { + layout_id: out.layout_id.to_string(), + } + } +} + +/// Response DTO for `delete_layout`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteLayoutResultDto { + /// The active layout after the deletion. + pub active_id: String, +} + +impl From for DeleteLayoutResultDto { + fn from(out: DeleteLayoutOutput) -> Self { + Self { + active_id: out.active_id.to_string(), + } + } +} + +/// Request DTO for `create_layout`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateLayoutRequestDto { + /// Owning project id. + pub project_id: String, + /// Display name for the new layout. + pub name: String, + /// Optional layout kind: `"terminal"` (default) or `"gitGraph"`. + #[serde(default)] + pub kind: Option, +} + +impl CreateLayoutRequestDto { + /// Parses the optional `kind` string into a [`LayoutKind`]. + /// + /// # Errors + /// [`ErrorDto`] with code `INVALID` if the value is not a known kind. + pub fn parse_kind(&self) -> Result { + match self.kind.as_deref() { + None | Some("terminal") => Ok(LayoutKind::Terminal), + Some("gitGraph") => Ok(LayoutKind::GitGraph), + Some(other) => Err(ErrorDto { + code: "INVALID".to_owned(), + message: format!("unknown layout kind: {other}"), + }), + } + } +} + +/// Request DTO for `rename_layout`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RenameLayoutRequestDto { + /// Owning project id. + pub project_id: String, + /// Layout to rename. + pub layout_id: String, + /// New display name. + pub name: String, +} + +/// Request DTO for `delete_layout`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteLayoutRequestDto { + /// Owning project id. + pub project_id: String, + /// Layout to delete. + pub layout_id: String, +} + +/// Response DTO for `set_active_layout`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SetActiveLayoutResultDto { + /// The layout actually activated — equals the requested id when valid, else + /// the unchanged current active id (self-healing fallback). Authoritative. + pub active_id: String, +} + +impl From for SetActiveLayoutResultDto { + fn from(out: SetActiveLayoutOutput) -> Self { + Self { + active_id: out.active_id.to_string(), + } + } +} + +/// Request DTO for `set_active_layout`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetActiveLayoutRequestDto { + /// Owning project id. + pub project_id: String, + /// Layout to make active. + pub layout_id: String, +} + +// --------------------------------------------------------------------------- +// Profiles & first-run (L5) +// --------------------------------------------------------------------------- + +use application::{ + CloneOpenCodeProfileFromSeedInput, CloneOpenCodeProfileFromSeedOutput, ConfigureProfilesInput, + ConfigureProfilesOutput, DeleteProfileInput, DetectProfilesInput, DetectProfilesOutput, + FirstRunStateOutput, ListProfilesOutput, ProfileAvailability, ReferenceProfilesOutput, + SaveProfileInput, SaveProfileOutput, +}; +use domain::profile::{AgentProfile, OpenCodeConfig}; +use domain::ProfileId; + +/// A profile crossing the wire. [`AgentProfile`] already serialises camelCase +/// (id, name, command, args, `contextInjection{strategy,…}`, detect, +/// `cwdTemplate`), so we embed it directly — the TS mirror matches this shape. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ProfileDto(pub AgentProfile); + +/// A list of profiles (camelCase array on the wire). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct ProfileListDto(pub Vec); + +impl From> for ProfileListDto { + fn from(v: Vec) -> Self { + Self(v.into_iter().map(ProfileDto).collect()) + } +} + +impl From for ProfileListDto { + fn from(out: ListProfilesOutput) -> Self { + out.profiles.into() + } +} + +impl From for ProfileListDto { + fn from(out: ReferenceProfilesOutput) -> Self { + out.profiles.into() + } +} + +impl From for ProfileDto { + fn from(out: SaveProfileOutput) -> Self { + Self(out.profile) + } +} + +impl From for ProfileDto { + fn from(out: CloneOpenCodeProfileFromSeedOutput) -> Self { + Self(out.profile) + } +} + +/// Request DTO for `detect_profiles`: the candidate profiles to probe. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DetectProfilesRequestDto { + /// Candidate profiles whose `detect` command should be run. + pub candidates: Vec, +} + +impl From for DetectProfilesInput { + fn from(dto: DetectProfilesRequestDto) -> Self { + Self { + candidates: dto.candidates, + } + } +} + +/// One availability result (`profile` + whether its CLI is installed). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProfileAvailabilityDto { + /// The probed profile. + pub profile: AgentProfile, + /// Whether the CLI was detected (exit code 0). + pub available: bool, +} + +impl From for ProfileAvailabilityDto { + fn from(a: ProfileAvailability) -> Self { + Self { + profile: a.profile, + available: a.available, + } + } +} + +/// Response DTO for `detect_profiles`. +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct DetectProfilesResponseDto(pub Vec); + +impl From for DetectProfilesResponseDto { + fn from(out: DetectProfilesOutput) -> Self { + Self(out.results.into_iter().map(Into::into).collect()) + } +} + +/// Request DTO for `save_profile`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveProfileRequestDto { + /// The profile to upsert. + pub profile: AgentProfile, +} + +impl From for SaveProfileInput { + fn from(dto: SaveProfileRequestDto) -> Self { + Self { + profile: dto.profile, + } + } +} + +/// Request DTO for `clone_opencode_profile_from_seed`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CloneOpenCodeProfileFromSeedRequestDto { + /// Optional display name for the new profile. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Optional OpenCode config override. When omitted, the seed config is copied. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opencode: Option, +} + +impl From for CloneOpenCodeProfileFromSeedInput { + fn from(dto: CloneOpenCodeProfileFromSeedRequestDto) -> Self { + Self { + name: dto.name, + opencode: dto.opencode, + } + } +} + +/// Request DTO for `configure_profiles` (closes the first run). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfigureProfilesRequestDto { + /// All profiles the user chose to keep. + pub profiles: Vec, +} + +impl From for ConfigureProfilesInput { + fn from(dto: ConfigureProfilesRequestDto) -> Self { + Self { + profiles: dto.profiles, + } + } +} + +impl From for ProfileListDto { + fn from(out: ConfigureProfilesOutput) -> Self { + out.profiles.into() + } +} + +/// Response DTO for `first_run_state`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FirstRunStateDto { + /// `true` when the first-run wizard should be shown. + pub is_first_run: bool, + /// Pre-filled reference catalogue to seed the wizard. + pub reference_profiles: Vec, +} + +impl From for FirstRunStateDto { + fn from(out: FirstRunStateOutput) -> Self { + Self { + is_first_run: out.is_first_run, + reference_profiles: out.reference_profiles, + } + } +} + +// --------------------------------------------------------------------------- +// Local model servers (B35) +// --------------------------------------------------------------------------- + +use application::{ListModelServersOutput, SaveModelServerInput, SaveModelServerOutput}; +use domain::model_server::{ + ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig, + LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelSource, +}; +use domain::{LocalModelServerId, StopPolicy}; + +/// Local model-server implementation on the IPC wire. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ModelServerKindDto { + /// llama.cpp `llama-server`. + LlamaCpp, +} + +impl From for ModelServerKindDto { + fn from(kind: LocalModelServerKind) -> Self { + match kind { + LocalModelServerKind::LlamaCpp => Self::LlamaCpp, + } + } +} + +impl From for LocalModelServerKind { + fn from(kind: ModelServerKindDto) -> Self { + match kind { + ModelServerKindDto::LlamaCpp => Self::LlamaCpp, + } + } +} + +/// Stop policy on the IPC wire. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum StopPolicyDto { + /// Keep the process alive. + KeepAlive, + /// Stop when no longer used. + StopWhenUnused, + /// Stop when the application exits. + StopOnAppExit, +} + +impl From for StopPolicyDto { + fn from(policy: StopPolicy) -> Self { + match policy { + StopPolicy::KeepAlive => Self::KeepAlive, + StopPolicy::StopWhenUnused => Self::StopWhenUnused, + StopPolicy::StopOnAppExit => Self::StopOnAppExit, + } + } +} + +impl From for StopPolicy { + fn from(policy: StopPolicyDto) -> Self { + match policy { + StopPolicyDto::KeepAlive => Self::KeepAlive, + StopPolicyDto::StopWhenUnused => Self::StopWhenUnused, + StopPolicyDto::StopOnAppExit => Self::StopOnAppExit, + } + } +} + +/// Model source on the IPC wire. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "type")] +pub enum ModelSourceDto { + /// Local `.gguf` path. + LocalPath { + /// Absolute local path. + path: String, + }, + /// Hugging Face `namespace/repo[:quant]` reference. + HuggingFace { + /// Repository reference. + repo: String, + }, +} + +impl From for ModelSourceDto { + fn from(source: ModelSource) -> Self { + match source { + ModelSource::LocalPath { path } => Self::LocalPath { path: path.0 }, + ModelSource::HuggingFace { repo } => Self::HuggingFace { repo: repo.0 }, + } + } +} + +impl ModelSourceDto { + fn into_domain(self) -> Result { + match self { + Self::LocalPath { path } => Ok(ModelSource::LocalPath { + path: ModelPath::new(path).map_err(invalid_domain_error)?, + }), + Self::HuggingFace { repo } => Ok(ModelSource::HuggingFace { + repo: HfModelRef::new(repo).map_err(invalid_domain_error)?, + }), + } + } +} + +/// A local model-server config crossing the wire. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelServerConfigDto { + /// Stable local server config id. + pub id: String, + /// Server implementation kind. + pub kind: ModelServerKindDto, + /// Display name. + pub name: String, + /// OpenAI-compatible base URL. + #[serde(rename = "baseURL")] + pub base_url: String, + /// TCP port. + pub port: u16, + /// Optional explicit model source. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_source: Option, + /// Legacy V1 input alias for `modelSource:{type:"localPath",path}`. + #[serde(default, skip_serializing)] + pub model_path: Option, + /// Served model name exposed to OpenCode. + pub served_model_name: String, + /// Optional explicit `llama-server` executable path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub binary_path: Option, + /// llama.cpp host passed to `--host`. + #[serde(default = "default_llamacpp_host")] + pub host: String, + /// llama.cpp GPU layers passed to `-ngl`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gpu_layers: Option, + /// llama.cpp context size passed to `-c`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_size: Option, + /// Whether to pass `--jinja`. + #[serde(default)] + pub jinja: bool, + /// Extra argv entries. + #[serde(default)] + pub args: Vec, + /// Whether IdeA should start the server lazily. + pub auto_start: bool, + /// Stop policy. + pub stop_policy: StopPolicyDto, + /// Optional readiness warmup deadline override in seconds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub warmup_deadline_secs: Option, +} + +impl ModelServerConfigDto { + /// Maps a domain config to the flat IPC DTO. + #[must_use] + pub fn from_domain(config: LocalModelServerConfig) -> Self { + Self { + id: config.id.to_string(), + kind: config.kind.into(), + name: config.name, + base_url: config.endpoint.base_url, + port: config.endpoint.port, + model_source: config.model.source.map(Into::into), + model_path: None, + served_model_name: config.model.served_name, + binary_path: config.binary.map(|binary| binary.as_str().to_owned()), + host: config.options.host, + gpu_layers: config.options.gpu_layers, + context_size: config.options.context_size, + jinja: config.options.jinja, + args: config.args, + auto_start: config.auto_start, + stop_policy: config.stop_policy.into(), + warmup_deadline_secs: config.warmup_deadline_secs, + } + } + + /// Maps the flat IPC DTO into the domain config, preserving internal model id + /// from the existing config when available. + /// + /// # Errors + /// [`ErrorDto`] with `INVALID` code if any domain invariant rejects the input. + pub fn into_domain( + self, + existing: Option<&LocalModelServerConfig>, + ) -> Result { + let server_id = parse_model_server_id(&self.id)?; + let endpoint = ModelServerEndpoint::new(self.base_url.clone(), self.port) + .map_err(invalid_domain_error)?; + let source = resolve_model_source(self.model_source, self.model_path)?; + let model_id = existing + .filter(|config| config.id == server_id) + .map(|config| config.model.id.clone()) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let label = non_empty_or_fallback(&self.served_model_name, &self.name); + let model = LocalModelRef::new(model_id, label, source, self.served_model_name.clone()) + .map_err(invalid_domain_error)?; + let binary = optional_non_empty(self.binary_path) + .map(ExecutablePath::new) + .transpose() + .map_err(invalid_domain_error)?; + let options = + LlamaCppOptions::new(self.host, self.gpu_layers, self.context_size, self.jinja) + .map_err(invalid_domain_error)?; + LocalModelServerConfig::new( + server_id, + self.kind.into(), + self.name, + endpoint, + model, + binary, + options, + self.args, + self.auto_start, + self.stop_policy.into(), + ) + .and_then(|config| config.with_warmup_deadline_secs(self.warmup_deadline_secs)) + .map_err(invalid_domain_error) + } +} + +/// Response DTO for `preview_model_server_command`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PreviewModelServerCommandDto { + /// Executable command. + pub command: String, + /// Arguments without shell parsing. + pub args: Vec, + /// Human-readable escaped command line. + pub display: String, +} + +/// List response for local model servers. +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct ModelServerConfigListDto(pub Vec); + +impl From for ModelServerConfigListDto { + fn from(out: ListModelServersOutput) -> Self { + Self( + out.servers + .into_iter() + .map(ModelServerConfigDto::from_domain) + .collect(), + ) + } +} + +/// Request DTO for `save_model_server`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveModelServerRequestDto { + /// Config to upsert. + pub config: ModelServerConfigDto, +} + +impl From for ModelServerConfigDto { + fn from(out: SaveModelServerOutput) -> Self { + Self::from_domain(out.config) + } +} + +/// Parses a local model-server id string. +/// +/// # Errors +/// [`ErrorDto`] with `INVALID` code if the string is not a UUID. +pub fn parse_model_server_id(raw: &str) -> Result { + uuid::Uuid::parse_str(raw) + .map(LocalModelServerId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid model server id: {raw}"), + }) +} + +/// Builds a save-model-server input after the caller has resolved the existing +/// config, if any. +/// +/// # Errors +/// [`ErrorDto`] if DTO-to-domain validation fails. +pub fn save_model_server_input( + request: SaveModelServerRequestDto, + existing: Option<&LocalModelServerConfig>, +) -> Result { + Ok(SaveModelServerInput { + config: request.config.into_domain(existing)?, + }) +} + +/// Converts a model-server DTO to domain using the same path as save. +/// +/// # Errors +/// [`ErrorDto`] if DTO-to-domain validation fails. +pub fn model_server_config_domain( + config: ModelServerConfigDto, + existing: Option<&LocalModelServerConfig>, +) -> Result { + config.into_domain(existing) +} + +fn resolve_model_source( + model_source: Option, + model_path: Option, +) -> Result, ErrorDto> { + let legacy_path = optional_non_empty(model_path); + match (model_source, legacy_path) { + (None, None) => Ok(None), + (None, Some(path)) => Ok(Some(ModelSource::LocalPath { + path: ModelPath::new(path).map_err(invalid_domain_error)?, + })), + (Some(source), None) => Ok(Some(source.into_domain()?)), + (Some(source), Some(path)) => { + let domain_source = source.into_domain()?; + match &domain_source { + ModelSource::LocalPath { path: source_path } if source_path.as_str() == path => { + Ok(Some(domain_source)) + } + _ => Err(ErrorDto { + code: "INVALID".to_owned(), + message: "modelPath and modelSource differ".to_owned(), + }), + } + } + } +} + +fn optional_non_empty(raw: Option) -> Option { + raw.map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) +} + +fn default_llamacpp_host() -> String { + "127.0.0.1".to_owned() +} + +fn non_empty_or_fallback(primary: &str, fallback: &str) -> String { + let primary = primary.trim(); + if primary.is_empty() { + fallback.trim().to_owned() + } else { + primary.to_owned() + } +} + +fn invalid_domain_error(error: impl std::fmt::Display) -> ErrorDto { + ErrorDto { + code: "INVALID".to_owned(), + message: error.to_string(), + } +} + +// --------------------------------------------------------------------------- +// Embedder profiles & engines (LOT C2 — §14.5.3) +// --------------------------------------------------------------------------- + +use application::{ + DismissChoice, DismissEmbedderSuggestionInput, EmbedderEnginesView, ListEmbedderProfilesOutput, + OnnxModelView, SaveEmbedderProfileInput, SaveEmbedderProfileOutput, +}; +use domain::profile::EmbedderProfile; + +/// An embedder profile crossing the wire. [`EmbedderProfile`] already serialises +/// camelCase (`id, name, strategy, model?, endpoint?, apiKeyEnv?, dimension`), so we +/// embed it directly — the TS mirror matches this shape. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(transparent)] +pub struct EmbedderProfileDto(pub EmbedderProfile); + +/// A list of embedder profiles (camelCase array on the wire). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct EmbedderProfileListDto(pub Vec); + +impl From> for EmbedderProfileListDto { + fn from(v: Vec) -> Self { + Self(v.into_iter().map(EmbedderProfileDto).collect()) + } +} + +impl From for EmbedderProfileListDto { + fn from(out: ListEmbedderProfilesOutput) -> Self { + out.profiles.into() + } +} + +impl From for EmbedderProfileDto { + fn from(out: SaveEmbedderProfileOutput) -> Self { + Self(out.profile) + } +} + +/// Request DTO for `save_embedder_profile`: the embedder profile to upsert. +/// +/// Carries the [`EmbedderProfile`] fields directly (the entity validates them in the +/// use case). Deserialised camelCase to match the persisted/domain shape. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveEmbedderProfileRequestDto { + /// The embedder profile to upsert. + pub profile: EmbedderProfile, +} + +impl From for SaveEmbedderProfileInput { + fn from(dto: SaveEmbedderProfileRequestDto) -> Self { + let EmbedderProfile { + id, + name, + strategy, + model, + endpoint, + api_key_env, + dimension, + } = dto.profile; + Self { + id, + name, + strategy, + model, + endpoint, + api_key_env, + dimension, + } + } +} + +/// One recommendable local ONNX model on the wire (camelCase). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OnnxModelInfoDto { + /// Stable model id accepted by a `localOnnx` profile's `model` field. + pub id: String, + /// Human-readable name for the UI. + pub display_name: String, + /// Length of the vectors this model produces. + pub dimension: usize, + /// Approximate download/disk size in megabytes. + pub approx_size_mb: u32, + /// Whether this is the recommended default model. + pub recommended: bool, +} + +impl From for OnnxModelInfoDto { + fn from(m: OnnxModelView) -> Self { + Self { + id: m.id, + display_name: m.display_name, + dimension: m.dimension, + approx_size_mb: m.approx_size_mb, + recommended: m.recommended, + } + } +} + +/// Response DTO for `describe_embedder_engines` (drives the "configure an embedder?" +/// UI). All-camelCase wire shape. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EmbedderEnginesDto { + /// The curated catalogue of recommendable local ONNX models. + pub recommended_onnx: Vec, + /// Whether an Ollama-style local embedding server was detected (best-effort). + pub ollama_detected: bool, + /// Ids of the recommended ONNX models already present in the local cache. + pub onnx_cached_models: Vec, + /// Whether the HTTP capability (`localServer`/`api`) is compiled into this binary. + pub vector_http_enabled: bool, + /// Whether the in-process ONNX capability (`localOnnx`) is compiled into this binary. + pub vector_onnx_enabled: bool, +} + +impl From for EmbedderEnginesDto { + fn from(v: EmbedderEnginesView) -> Self { + Self { + recommended_onnx: v.recommended_onnx.into_iter().map(Into::into).collect(), + ollama_detected: v.ollama_detected, + onnx_cached_models: v.onnx_cached_models, + vector_http_enabled: v.vector_http_enabled, + vector_onnx_enabled: v.vector_onnx_enabled, + } + } +} + +// --------------------------------------------------------------------------- +// Embedder suggestion (LOT C3 — §14.5.5) +// --------------------------------------------------------------------------- + +/// The user's response to the embedder suggestion, on the wire (camelCase: +/// `"later"` | `"never"`). +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DismissChoiceDto { + /// "Plus tard" — re-proposable next session. + Later, + /// "Ne plus demander" — never again. + Never, +} + +impl From for DismissChoice { + fn from(c: DismissChoiceDto) -> Self { + match c { + DismissChoiceDto::Later => Self::Later, + DismissChoiceDto::Never => Self::Never, + } + } +} + +/// Request DTO for `dismiss_embedder_suggestion`. The `project_id` is resolved to a +/// project root by the command; `choice` is the user's dismissal. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DismissEmbedderSuggestionRequestDto { + /// The project the suggestion concerned (UUID string). + pub project_id: String, + /// The user's choice. + pub choice: DismissChoiceDto, +} + +impl DismissEmbedderSuggestionRequestDto { + /// Builds the use-case input from a resolved project root + the DTO choice. + #[must_use] + pub fn into_input(self, project_root: domain::ProjectPath) -> DismissEmbedderSuggestionInput { + DismissEmbedderSuggestionInput { + project_root, + choice: self.choice.into(), + } + } +} + +/// Builds a [`DeleteProfileInput`] from a raw profile-id string. +/// +/// # Errors +/// [`ErrorDto`] with code `INVALID` if the id is malformed. +pub fn parse_delete_profile(raw: &str) -> Result { + Ok(DeleteProfileInput { + id: parse_profile_id(raw)?, + }) +} + +/// Parses a profile-id string (UUID) coming from the frontend. +/// +/// # Errors +/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. +pub fn parse_profile_id(raw: &str) -> Result { + uuid::Uuid::parse_str(raw) + .map(ProfileId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid profile id: {raw}"), + }) +} + +// --------------------------------------------------------------------------- +// Agents (L6) +// --------------------------------------------------------------------------- + +use application::{ + ChangeAgentProfileOutput, CreateAgentOutput, InspectConversationOutput, LaunchAgentOutput, + ListAgentsOutput, ReadAgentContextOutput, +}; +use domain::{Agent, EffectivePermissions, PermissionSet, ProjectPermissions, TerminalSession}; + +/// An agent crossing the wire. [`Agent`] already serialises camelCase +/// (`id`, `name`, `contextPath`, `profileId`, `origin` tagged, `synchronized`), +/// so we embed it directly — the TS mirror matches this shape. +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct AgentDto(pub Agent); + +/// A list of agents (camelCase array on the wire). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct AgentListDto(pub Vec); + +impl From for AgentListDto { + fn from(out: ListAgentsOutput) -> Self { + Self(out.agents.into_iter().map(AgentDto).collect()) + } +} + +impl From for AgentDto { + fn from(out: CreateAgentOutput) -> Self { + Self(out.agent) + } +} + +/// Request DTO for `create_agent`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateAgentRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Display name for the new agent. + pub name: String, + /// Runtime profile id. + pub profile_id: String, + /// Initial Markdown content (empty when absent). + #[serde(default)] + pub initial_content: Option, +} + +/// Response DTO for `read_agent_context`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReadAgentContextResponseDto { + /// The agent's Markdown context content. + pub content: String, +} + +impl From for ReadAgentContextResponseDto { + fn from(out: ReadAgentContextOutput) -> Self { + Self { + content: out.content.into_string(), + } + } +} + +/// Request DTO for `update_agent_context`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateAgentContextRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Id of the agent to update. + pub agent_id: String, + /// New Markdown content. + pub content: String, +} + +// --------------------------------------------------------------------------- +// Permissions (LP1) +// --------------------------------------------------------------------------- + +/// Full project permission document crossing the wire. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ProjectPermissionsDto(pub ProjectPermissions); + +/// Effective permissions crossing the wire. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(transparent)] +pub struct EffectivePermissionsDto(pub EffectivePermissions); + +/// Request DTO for updating project default permissions. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateProjectPermissionsRequestDto { + /// Id of the owning project. + pub project_id: String, + /// New project defaults. `null` removes defaults. + pub permissions: Option, +} + +/// Request DTO for updating one agent permission override. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateAgentPermissionsRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Target agent id. + pub agent_id: String, + /// New override. `null` removes the override. + pub permissions: Option, +} + +/// Request DTO for resolving one agent's effective permissions. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResolveAgentPermissionsRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Target agent id. + pub agent_id: String, +} + +/// Request DTO for `update_project_context`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateProjectContextRequestDto { + /// Id of the owning project. + pub project_id: String, + /// New project-level Markdown context, stored under `.ideai/CONTEXT.md`. + pub content: String, +} + +/// Request DTO for `launch_agent`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LaunchAgentRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Id of the agent to launch. + pub agent_id: String, + /// Initial terminal height in rows. + pub rows: u16, + /// Initial terminal width in columns. + pub cols: u16, + /// Persistent CLI conversation id currently recorded on the hosting cell, if + /// any. `Some` ⇒ the launch resumes it; absent/`None` ⇒ a fresh cell (the + /// launch may assign a new id when the profile supports it). + #[serde(default)] + pub conversation_id: Option, + /// The layout leaf (node) hosting this launch. Enforces the "one live session + /// per agent" invariant: a launch into a node different from where the agent + /// is already running is refused (`AGENT_ALREADY_RUNNING`); the same node is + /// idempotent. Absent ⇒ a fresh node is minted (and any already-live agent is + /// refused). + #[serde(default)] + pub node_id: Option, +} + +impl From for TerminalSessionDto { + fn from(out: LaunchAgentOutput) -> Self { + let assigned_conversation_id = out.assigned_conversation_id; + let engine_session_id = out.engine_session_id; + // §17.6: the cell kind is *derived* from the launch routing. A structured + // descriptor (`structured: Some(..)`) means LaunchAgent routed to an + // AgentSession ⇒ chat cell; its absence means the PTY/terminal path ⇒ + // terminal cell. Single source of truth, no layout migration. + let cell_kind = if out.structured.is_some() { + CellKind::Chat + } else { + CellKind::Pty + }; + let s = out.session; + Self { + session_id: s.id.to_string(), + cwd: s.cwd.as_str().to_owned(), + rows: s.pty_size.rows, + cols: s.pty_size.cols, + assigned_conversation_id, + engine_session_id, + cell_kind, + } + } +} + +/// Request DTO for `interrupt_agent` (cadrage C4 §4.2): the Interrompre path. The +/// frontend sends `{ request: { projectId, agentId } }`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InterruptAgentRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Id of the agent whose running turn to preempt. + pub agent_id: String, +} + +/// Request DTO for `delegation_delivered` (ARCHITECTURE §20.3): the frontend write- +/// portal acks that it **physically wrote** a delegation `ticket` into the agent's +/// native PTY. Best-effort observability — it never changes correlation (the `ask` is +/// still woken by `idea_reply`). The frontend sends `{ request: { projectId, agentId, +/// ticket } }`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeliveredDelegationRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Id of the agent whose terminal received the delegation. + pub agent_id: String, + /// Id of the delivered mailbox ticket. + pub ticket: String, +} + +/// Request DTO for `set_front_attached`: the write-portal of an agent cell reports +/// whether a **frontend terminal cell is mounted** for `agentId` (`true` on mount, +/// `false` on unmount). The mediator uses it to choose, at delivery time, between +/// publishing `DelegationReady` (a cell will write it) and writing the turn into the +/// PTY itself (headless/background-delegated agent with no cell). The frontend sends +/// `{ request: { agentId, attached } }`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FrontAttachedRequestDto { + /// Id of the agent whose terminal cell mounted/unmounted. + pub agent_id: String, + /// `true` when the cell's write-portal is now active, `false` on teardown. + pub attached: bool, +} + +/// Request DTO for `change_agent_profile` (§15.1): hot-swap an agent's runtime +/// profile, optionally relaunching its live session in place. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChangeAgentProfileRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Id of the agent whose runtime profile to change. + pub agent_id: String, + /// Id of the new runtime profile. + pub profile_id: String, + /// Terminal height in rows for a possible hot relaunch. + pub rows: u16, + /// Terminal width in columns for a possible hot relaunch. + pub cols: u16, +} + +/// Response DTO for `change_agent_profile`: the mutated agent plus the freshly +/// relaunched session when a live session was hot-swapped (absent otherwise). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChangeAgentProfileDto { + /// The agent now carrying the new profile. + pub agent: AgentDto, + /// The relaunched session, present only when a live session was swapped. + #[serde(skip_serializing_if = "Option::is_none")] + pub relaunched_session: Option, +} + +impl From for ChangeAgentProfileDto { + fn from(out: ChangeAgentProfileOutput) -> Self { + Self { + agent: AgentDto(out.agent), + relaunched_session: out.relaunched.map(TerminalSessionDto::from), + } + } +} + +impl From for TerminalSessionDto { + fn from(s: TerminalSession) -> Self { + Self { + session_id: s.id.to_string(), + cwd: s.cwd.as_str().to_owned(), + rows: s.pty_size.rows, + cols: s.pty_size.cols, + assigned_conversation_id: None, + engine_session_id: None, + cell_kind: CellKind::Pty, + } + } +} + +// --------------------------------------------------------------------------- +// Structured chat sessions (§17 — D4) +// --------------------------------------------------------------------------- + +/// One incremental chunk of a structured agent reply, streamed over the chat +/// session's adapter-owned channel. The serialised wire twin of a +/// [`domain::ports::ReplyEvent`]: the `agent_send` pump maps each turn event to +/// one of these and pushes it to the frontend `AgentChatView`. +/// +/// Tagged on `kind` (camelCase: `"textDelta"` | `"toolActivity"` | `"final"` | +/// `"error"`), so the front branches without positional parsing. `Final` is the +/// normal deterministic terminal chunk of a turn; `Error` is a visible terminal +/// fallback for an otherwise silent/empty turn. `Deserialize` is derived too so +/// tests (and a mock gateway) can round-trip it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum ReplyChunk { + /// An assistant text fragment (incremental chat rendering). + #[serde(rename_all = "camelCase")] + TextDelta { + /// The text fragment. + text: String, + }, + /// A human-readable tool-activity badge (best-effort observability). + #[serde(rename_all = "camelCase")] + ToolActivity { + /// The human-readable activity label. + label: String, + }, + /// The deterministic end-of-turn chunk carrying the aggregated final content. + #[serde(rename_all = "camelCase")] + Final { + /// The aggregated final content of the turn. + content: String, + }, + /// A visible terminal fallback when the model stream produced no usable answer. + #[serde(rename_all = "camelCase")] + Error { + /// Human-readable explanation to render in the chat cell. + message: String, + }, +} + +/// Response DTO for `reattach_agent_chat`: the retained **conversation +/// scrollback** of a still-live structured session, replayed into the +/// re-mounting `AgentChatView` before the new reply stream is wired (§17.6). The +/// typed twin of [`ReattachResultDto`] (PTY scrollback bytes) — here the +/// scrollback is the ordered list of [`ReplyChunk`]s already streamed. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReattachChatDto { + /// The session that was re-attached (echoed back for the frontend). + pub session_id: String, + /// The chunks already streamed for this conversation, in order. The frontend + /// replays them to rebuild the visible turns, then receives subsequent chunks + /// over the freshly-registered channel. + pub scrollback: Vec, +} + +/// Request DTO for `inspect_conversation` (T7). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InspectConversationRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Id of the agent whose conversation is inspected. + pub agent_id: String, + /// The conversation id recorded on the hosting cell. + pub conversation_id: String, +} + +/// Response DTO for `inspect_conversation` (T7): the best-effort enriched +/// details for a resume popup. Both fields are optional and **omitted from the +/// wire when `None`** (`skip_serializing_if`), so the TypeScript side sees an +/// absent key — not `null` — for a degraded inspection. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConversationDetailsDto { + /// A short, best-effort label for the conversation (last user message, + /// truncated). Absent when it could not be extracted. + #[serde(skip_serializing_if = "Option::is_none")] + pub last_topic: Option, + /// A best-effort cumulative token count. Absent when no usage info exists. + #[serde(skip_serializing_if = "Option::is_none")] + pub token_count: Option, +} + +impl From for ConversationDetailsDto { + fn from(out: InspectConversationOutput) -> Self { + Self { + last_topic: out.details.last_topic, + token_count: out.details.token_count, + } + } +} + +/// One currently-live agent and the cell hosting it, for `list_live_agents`. +/// +/// Lets the UI disable an agent already running in another cell (it cannot be +/// launched a second time — the "one live session per agent" invariant). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LiveAgentDto { + /// The live agent's id (UUID string). + pub agent_id: String, + /// The hosting layout leaf's node id (UUID string). + pub node_id: String, + /// The live PTY session id, used to reattach a newly-opened cell without + /// respawning the agent. + pub session_id: String, + /// Runtime family that owns the session (`pty`/`structured`). + pub kind: LiveWorkSessionKindDto, +} + +/// Response DTO for `list_live_agents` (transparent array on the wire). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct LiveAgentListDto(pub Vec); + +impl LiveAgentListDto { + /// Builds the wire list from the registry's live-session snapshots. + /// + /// De-duplicates by `agent_id`: the "one live session per agent" invariant + /// guarantees an agent is live in at most one registry (PTY **or** + /// structured), but the aggregated input could in theory carry the same agent + /// twice; the UI contract is a dup-free set (each agent appears once), so we + /// keep the first occurrence and drop any later one for the same agent. + #[must_use] + pub fn from_snapshots(pairs: Vec) -> Self { + let mut seen = std::collections::HashSet::new(); + Self( + pairs + .into_iter() + .filter(|snapshot| seen.insert(snapshot.agent_id)) + .map(|snapshot| LiveAgentDto { + agent_id: snapshot.agent_id.to_string(), + node_id: snapshot.node_id.to_string(), + session_id: snapshot.session_id.to_string(), + kind: snapshot.kind.into(), + }) + .collect(), + ) + } +} + +/// Runtime family of a live work-state session. +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum LiveWorkSessionKindDto { + /// Raw PTY-backed CLI session. + Pty, + /// Structured agent-session backend. + Structured, +} + +impl From for LiveWorkSessionKindDto { + fn from(kind: LiveSessionKind) -> Self { + match kind { + LiveSessionKind::Pty => Self::Pty, + LiveSessionKind::Structured => Self::Structured, + } + } +} + +/// Live session coordinates in the project work-state read model. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LiveWorkSessionDto { + /// The layout node currently hosting the session view. + pub node_id: String, + /// The live session id. + pub session_id: String, + /// Runtime family that owns the session. + pub kind: LiveWorkSessionKindDto, +} + +/// Derived processing status of a queued ticket. +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum TicketWorkStatusDto { + /// The agent's current busy turn is running this ticket. + InProgress, + /// Waiting behind the head / the agent is idle. + Queued, +} + +impl From for TicketWorkStatusDto { + fn from(status: TicketWorkStatus) -> Self { + match status { + TicketWorkStatus::InProgress => Self::InProgress, + TicketWorkStatus::Queued => Self::Queued, + } + } +} + +/// Origin of a queued ticket (human operator or a delegating agent). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum TicketWorkSourceDto { + /// The human operator. + Human, + /// Another agent delegating via `idea_ask_agent`. + #[serde(rename_all = "camelCase")] + Agent { + /// The delegating agent id. + agent_id: String, + }, +} + +impl From for TicketWorkSourceDto { + fn from(source: TicketWorkSource) -> Self { + match source { + TicketWorkSource::Human => Self::Human, + TicketWorkSource::Agent { agent_id } => Self::Agent { + agent_id: agent_id.to_string(), + }, + } + } +} + +/// One queued/in-progress delegation ticket for an agent. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentTicketStateDto { + /// Stable id of the queued ticket. + pub ticket_id: String, + /// Conversation thread this task enters. + pub conversation_id: String, + /// FIFO position at snapshot time (`0` = head). + pub position: u32, + /// Derived status (in-progress vs queued). + pub status: TicketWorkStatusDto, + /// Origin of the ticket. + pub source: TicketWorkSourceDto, + /// Display label of the requester. + pub requester_label: String, + /// Bounded excerpt of the task. + pub task_preview: String, + /// Character length of the original (un-truncated) task. + pub task_len: usize, +} + +impl From for AgentTicketStateDto { + fn from(ticket: AgentTicketState) -> Self { + Self { + ticket_id: ticket.ticket_id.to_string(), + conversation_id: ticket.conversation_id.to_string(), + position: ticket.position, + status: ticket.status.into(), + source: ticket.source.into(), + requester_label: ticket.requester_label, + task_preview: ticket.task_preview, + task_len: ticket.task_len, + } + } +} + +/// One background task owned by an agent in the Work panel read model. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentBackgroundTaskStateDto { + /// Stable task id (UUID string). + pub task_id: String, + /// Kind discriminant. + pub kind: String, + /// Lifecycle state. + pub state: String, + /// Process exit code, when the terminal result carries one. + #[serde(skip_serializing_if = "Option::is_none")] + pub exit_code: Option, + /// Human-readable summary / error / reason of the terminal result. + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + /// Bounded stdout tail. + #[serde(skip_serializing_if = "Option::is_none")] + pub stdout_tail: Option, + /// Bounded stderr tail. + #[serde(skip_serializing_if = "Option::is_none")] + pub stderr_tail: Option, + /// Creation timestamp, epoch milliseconds. + pub created_at_ms: u64, + /// Last update timestamp, epoch milliseconds. + pub updated_at_ms: u64, +} + +impl From for AgentBackgroundTaskStateDto { + fn from(task: AgentBackgroundTaskState) -> Self { + Self { + task_id: task.task_id.to_string(), + kind: background_kind_label_from_work_state(task.kind).to_owned(), + state: background_state_label(task.state).to_owned(), + exit_code: task.exit_code, + summary: task.summary, + stdout_tail: task.stdout_tail, + stderr_tail: task.stderr_tail, + created_at_ms: task.created_at_ms, + updated_at_ms: task.updated_at_ms, + } + } +} + +/// One manifest agent's current live/busy state. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentWorkStateDto { + /// Agent id. + pub agent_id: String, + /// Agent display name. + pub name: String, + /// Runtime profile id assigned to the agent. + pub profile_id: String, + /// Live session, if any. + pub live: Option, + /// Current mediated-input busy state. + pub busy: AgentBusyState, + /// Pending/in-progress delegation tickets, in FIFO order. + pub tickets: Vec, + /// Best-effort first-class background tasks owned by this agent. + pub background_tasks: Vec, +} + +/// How much of a [`ConversationWorkSummaryDto`] could be derived, best-effort. +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ConversationPreviewStatusDto { + /// A handoff was present and usable. + Ready, + /// No handoff yet; `recentTurns` may carry the log fallback. + Missing, + /// The handoff was unreadable but the log fallback was readable. + Partial, + /// Neither the handoff nor the log could be read. + Unavailable, +} + +impl From for ConversationPreviewStatusDto { + fn from(status: ConversationPreviewStatus) -> Self { + match status { + ConversationPreviewStatus::Ready => Self::Ready, + ConversationPreviewStatus::Missing => Self::Missing, + ConversationPreviewStatus::Partial => Self::Partial, + ConversationPreviewStatus::Unavailable => Self::Unavailable, + } + } +} + +/// One recent turn surfaced in a conversation summary's log fallback. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConversationTurnWorkPreviewDto { + /// Nature of the turn (`prompt`/`response`/`toolActivity`). + pub role: TurnRole, + /// Origin of the turn (human operator or a delegating agent). + pub source: TicketWorkSourceDto, + /// Timestamp (epoch milliseconds) of the turn. + pub at_ms: u64, + /// Bounded excerpt of the turn text. + pub text_preview: String, + /// Character length of the original (un-truncated) turn text. + pub text_len: usize, +} + +impl From for ConversationTurnWorkPreviewDto { + fn from(turn: ConversationTurnWorkPreview) -> Self { + Self { + role: turn.role, + source: turn.source.into(), + at_ms: turn.at_ms, + text_preview: turn.text_preview, + text_len: turn.text_len, + } + } +} + +/// Best-effort, read-only summary of one conversation visible through the tickets. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConversationWorkSummaryDto { + /// The conversation (pair) this summary describes. + pub conversation_id: String, + /// How much could be derived. + pub status: ConversationPreviewStatusDto, + /// Bounded excerpt of the handoff objective, when present. + pub objective_preview: Option, + /// Bounded excerpt of the handoff summary, when present. + pub summary_preview: Option, + /// Character length of the original (un-truncated) handoff summary (`0` when none). + pub summary_len: usize, + /// Cursor (last turn id) covered by the handoff summary, when present. + pub up_to: Option, + /// Bounded, recent turns from the log fallback. + pub recent_turns: Vec, +} + +impl From for ConversationWorkSummaryDto { + fn from(summary: ConversationWorkSummary) -> Self { + Self { + conversation_id: summary.conversation_id.to_string(), + status: summary.status.into(), + objective_preview: summary.objective_preview, + summary_preview: summary.summary_preview, + summary_len: summary.summary_len, + up_to: summary.up_to.map(|cursor| cursor.to_string()), + recent_turns: summary + .recent_turns + .into_iter() + .map(ConversationTurnWorkPreviewDto::from) + .collect(), + } + } +} + +/// Project-level read model for conversation/delegation UX. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectWorkStateDto { + /// Manifest agents in manifest order, enriched with live/busy state. + pub agents: Vec, + /// Best-effort summaries of the conversations referenced by the tickets, + /// joined frontend-side via `tickets[].conversationId`. + pub conversations: Vec, +} + +impl From for ProjectWorkStateDto { + fn from(state: ProjectWorkState) -> Self { + Self { + agents: state + .agents + .into_iter() + .map(|agent| AgentWorkStateDto { + agent_id: agent.agent_id.to_string(), + name: agent.name, + profile_id: agent.profile_id.to_string(), + live: agent.live.map(|live| LiveWorkSessionDto { + node_id: live.node_id.to_string(), + session_id: live.session_id.to_string(), + kind: live.kind.into(), + }), + busy: agent.busy, + tickets: agent + .tickets + .into_iter() + .map(AgentTicketStateDto::from) + .collect(), + background_tasks: agent + .background_tasks + .into_iter() + .map(AgentBackgroundTaskStateDto::from) + .collect(), + }) + .collect(), + conversations: state + .conversations + .into_iter() + .map(ConversationWorkSummaryDto::from) + .collect(), + } + } +} + +/// Request DTO for `attach_live_agent`: bind an already-running agent session to +/// a visible layout cell without spawning a new process. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AttachLiveAgentRequestDto { + /// Id of the owning project (validated for symmetry and future scoping). + pub project_id: String, + /// Id of the already-running agent. + pub agent_id: String, + /// Layout leaf that should display the live session. + pub node_id: String, +} + +/// Response DTO for `attach_live_agent`: the rebound live session's coordinates. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AttachLiveAgentResponseDto { + /// The rebound agent's id. + pub agent_id: String, + /// The node now hosting the session view. + pub node_id: String, + /// The (unchanged) live session id. + pub session_id: String, + /// Runtime family that owns the session (`pty`/`structured`). + pub kind: LiveWorkSessionKindDto, +} + +impl From for AttachLiveAgentResponseDto { + fn from(out: AttachLiveAgentOutput) -> Self { + Self { + agent_id: out.agent_id.to_string(), + node_id: out.node_id.to_string(), + session_id: out.session_id.to_string(), + kind: out.kind.into(), + } + } +} + +/// Request DTO for `stop_live_agent`: tear down an already-running agent's live +/// session by agent id (no spawn, agent entity preserved). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StopLiveAgentRequestDto { + /// Id of the owning project (validated for symmetry and future scoping). + pub project_id: String, + /// Id of the running agent to stop. + pub agent_id: String, +} + +/// Response DTO for `stop_live_agent`: the torn-down session's coordinates. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StopLiveAgentResponseDto { + /// The stopped agent's id. + pub agent_id: String, + /// The id of the session that was torn down. + pub session_id: String, + /// Runtime family that owned the session (`pty`/`structured`). + pub kind: LiveWorkSessionKindDto, +} + +impl From for StopLiveAgentResponseDto { + fn from(out: StopLiveAgentOutput) -> Self { + Self { + agent_id: out.agent_id.to_string(), + session_id: out.session_id.to_string(), + kind: out.kind.into(), + } + } +} + +/// Parses an agent-id string (UUID) coming from the frontend. +/// +/// # Errors +/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. +pub fn parse_agent_id(raw: &str) -> Result { + uuid::Uuid::parse_str(raw) + .map(AgentId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid agent id: {raw}"), + }) +} + +/// Parses a ticket-id string (UUID) coming from the frontend (`delegation_delivered`). +/// +/// # Errors +/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. +pub fn parse_ticket_id(raw: &str) -> Result { + uuid::Uuid::parse_str(raw) + .map(domain::TicketId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid ticket id: {raw}"), + }) +} + +// --------------------------------------------------------------------------- +// Resumable agents (§15.2 — Chantier B2) +// --------------------------------------------------------------------------- + +use application::{ListResumableAgentsOutput, ResumableAgent}; + +/// One resumable agent cell, as seen by the frontend (§15.2). +/// +/// Mirrors [`ResumableAgent`]: the agent's identity + its host cell, the CLI +/// conversation id to resume (absent ⇒ fresh relaunch), the `was_running` flag +/// frozen at close, and whether the agent's profile can resume a CLI +/// conversation. `conversationId` is **omitted from the wire when `None`** +/// (`skip_serializing_if`), so the TypeScript side sees an absent key — not +/// `null`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResumableAgentDto { + /// The resumable agent's id (UUID string). + pub agent_id: String, + /// The agent's display name (resolved from the manifest). + pub name: String, + /// The host layout leaf where the agent is relaunched/resumed (UUID string). + pub node_id: String, + /// Persistent CLI conversation id carried by the cell. Absent ⇒ fresh + /// relaunch (no history to resume). + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, + /// The `agent_was_running` flag frozen at the cell's close. + pub was_running: bool, + /// `true` when the agent's profile carries a usable resume strategy. + pub resume_supported: bool, +} + +impl From for ResumableAgentDto { + fn from(r: ResumableAgent) -> Self { + Self { + agent_id: r.agent_id.to_string(), + name: r.name, + node_id: r.node_id.to_string(), + conversation_id: r.conversation_id, + was_running: r.was_running, + resume_supported: r.resume_supported, + } + } +} + +/// Response DTO for `list_resumable_agents` (§15.2). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResumableAgentListDto { + /// The resumable agent cells, in layout-traversal order. + pub resumable: Vec, +} + +impl From for ResumableAgentListDto { + fn from(out: ListResumableAgentsOutput) -> Self { + Self { + resumable: out + .resumable + .into_iter() + .map(ResumableAgentDto::from) + .collect(), + } + } +} + +// --------------------------------------------------------------------------- +// Templates & sync (L7) +// --------------------------------------------------------------------------- + +use application::{ + AgentDrift, CreateAgentFromTemplateInput, CreateTemplateInput, CreateTemplateOutput, + DetectAgentDriftOutput, ListTemplatesOutput, SyncAgentWithTemplateOutput, UpdateTemplateInput, + UpdateTemplateOutput, +}; +use domain::{AgentTemplate, TemplateId}; + +/// A template crossing the wire. [`AgentTemplate`] already serialises camelCase +/// (`id`, `name`, `contentMd`, `version` as a number, `defaultProfileId`), +/// so we embed it directly — the TS mirror matches this shape. +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct TemplateDto(pub AgentTemplate); + +/// A list of templates (transparent array on the wire). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct TemplateListDto(pub Vec); + +impl From for TemplateListDto { + fn from(out: ListTemplatesOutput) -> Self { + Self(out.templates.into_iter().map(TemplateDto).collect()) + } +} + +impl From for TemplateDto { + fn from(out: CreateTemplateOutput) -> Self { + Self(out.template) + } +} + +impl From for TemplateDto { + fn from(out: UpdateTemplateOutput) -> Self { + Self(out.template) + } +} + +/// Request DTO for `create_template`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateTemplateRequestDto { + /// Display name. + pub name: String, + /// Initial Markdown content. + pub content: String, + /// Default runtime profile id for agents created from this template. + pub default_profile_id: String, +} + +impl CreateTemplateRequestDto { + /// Converts to the use-case input, parsing the profile id. + /// + /// # Errors + /// [`ErrorDto`] with code `INVALID` if the profile id is malformed. + pub fn into_input(self) -> Result { + Ok(CreateTemplateInput { + name: self.name, + content: self.content, + default_profile_id: parse_profile_id(&self.default_profile_id)?, + }) + } +} + +/// Request DTO for `update_template`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateTemplateRequestDto { + /// Id of the template to update. + pub template_id: String, + /// New Markdown content. + pub content: String, +} + +impl UpdateTemplateRequestDto { + /// Converts to the use-case input, parsing the template id. + /// + /// # Errors + /// [`ErrorDto`] with code `INVALID` if the template id is malformed. + pub fn into_input(self) -> Result { + Ok(UpdateTemplateInput { + template_id: parse_template_id(&self.template_id)?, + content: self.content, + }) + } +} + +/// Parses a template-id string (UUID) coming from the frontend. +/// +/// # Errors +/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. +pub fn parse_template_id(raw: &str) -> Result { + uuid::Uuid::parse_str(raw) + .map(TemplateId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid template id: {raw}"), + }) +} + +/// Request DTO for `create_agent_from_template`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateAgentFromTemplateRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Source template id. + pub template_id: String, + /// Optional agent name; defaults to the template's name when absent. + #[serde(default)] + pub name: Option, + /// Whether the agent tracks the template for future syncs. + pub synchronized: bool, +} + +impl CreateAgentFromTemplateRequestDto { + /// Converts to the use-case input, given the resolved project. + /// + /// # Errors + /// [`ErrorDto`] with code `INVALID` if the template id is malformed. + pub fn into_input( + self, + project: domain::Project, + ) -> Result { + Ok(CreateAgentFromTemplateInput { + project, + template_id: parse_template_id(&self.template_id)?, + name: self.name, + synchronized: self.synchronized, + }) + } +} + +/// One drifting agent, as seen by the frontend. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentDriftDto { + /// The drifting agent id (UUID string). + pub agent_id: String, + /// Version the agent is currently synced to. + pub from: u64, + /// Version available from the template. + pub to: u64, +} + +impl From for AgentDriftDto { + fn from(d: AgentDrift) -> Self { + Self { + agent_id: d.agent_id.to_string(), + from: d.from.get(), + to: d.to.get(), + } + } +} + +/// Response DTO for `detect_agent_drift` (transparent array on the wire). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct AgentDriftListDto(pub Vec); + +impl From for AgentDriftListDto { + fn from(out: DetectAgentDriftOutput) -> Self { + Self(out.drifts.into_iter().map(AgentDriftDto::from).collect()) + } +} + +/// Response DTO for `sync_agent_with_template`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncResultDto { + /// Whether a sync was actually applied. + pub synced: bool, + /// The version the agent is now at (`null` when no sync happened). + pub version: Option, +} + +impl From for SyncResultDto { + fn from(out: SyncAgentWithTemplateOutput) -> Self { + Self { + synced: out.synced, + version: out.version.map(|v| v.get()), + } + } +} + +/// Request DTO for `sync_agent_with_template`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncAgentWithTemplateRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Id of the agent to sync. + pub agent_id: String, +} + +// --------------------------------------------------------------------------- +// Git (L8) +// --------------------------------------------------------------------------- + +use application::{GitBranchesOutput, GitCommitOutput, GitLogOutput, GitStatusOutput}; +use domain::ports::{GitCommitInfo, GitFileStatus, GraphCommit}; + +/// One changed path returned by `git_status`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GitFileStatusDto { + /// Repo-relative path. + pub path: String, + /// Whether the change is staged. + pub staged: bool, +} + +impl From for GitFileStatusDto { + fn from(s: GitFileStatus) -> Self { + Self { + path: s.path, + staged: s.staged, + } + } +} + +/// Response DTO for `git_status` (transparent array on the wire). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct GitStatusListDto(pub Vec); + +impl From for GitStatusListDto { + fn from(out: GitStatusOutput) -> Self { + Self( + out.entries + .into_iter() + .map(GitFileStatusDto::from) + .collect(), + ) + } +} + +/// A single commit summary crossing the wire. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GitCommitDto { + /// Commit hash. + pub hash: String, + /// Commit message summary. + pub summary: String, +} + +impl From for GitCommitDto { + fn from(c: GitCommitInfo) -> Self { + Self { + hash: c.hash, + summary: c.summary, + } + } +} + +impl From for GitCommitDto { + fn from(out: GitCommitOutput) -> Self { + Self::from(out.commit) + } +} + +/// Response DTO for `git_log` (transparent array on the wire). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct GitCommitListDto(pub Vec); + +impl From for GitCommitListDto { + fn from(out: GitLogOutput) -> Self { + Self(out.commits.into_iter().map(GitCommitDto::from).collect()) + } +} + +/// Response DTO for `git_branches`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GitBranchesDto { + /// All local branches. + pub branches: Vec, + /// The current branch (`null` when detached or unborn). + pub current: Option, +} + +impl From for GitBranchesDto { + fn from(out: GitBranchesOutput) -> Self { + Self { + branches: out.branches, + current: out.current, + } + } +} + +/// A single commit enriched for graph display. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GraphCommitDto { + /// Full commit hash. + pub hash: String, + /// First line of the commit message. + pub summary: String, + /// Parent commit hashes. + pub parents: Vec, + /// Ref labels pointing at this commit (e.g. `"main"`, `"tag: v1.0"`). + pub refs: Vec, + /// Author name. + pub author: String, + /// Author timestamp in Unix seconds. + pub timestamp: i64, +} + +impl From for GraphCommitDto { + fn from(c: GraphCommit) -> Self { + Self { + hash: c.hash, + summary: c.summary, + parents: c.parents, + refs: c.refs, + author: c.author, + timestamp: c.timestamp, + } + } +} + +/// Response DTO for `git_graph` (transparent array on the wire). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct GraphCommitListDto(pub Vec); + +impl From for GraphCommitListDto { + fn from(out: GitGraphOutput) -> Self { + Self(out.commits.into_iter().map(GraphCommitDto::from).collect()) + } +} + +/// Request DTO for `git_stage` / `git_unstage`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitStageRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Repo-relative path to (un)stage. + pub path: String, +} + +/// Request DTO for `git_commit`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitCommitRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Commit message. + pub message: String, +} + +/// Request DTO for `git_checkout`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitCheckoutRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Branch to check out. + pub branch: String, +} + +// --------------------------------------------------------------------------- +// Windows (L10) +// --------------------------------------------------------------------------- + +use application::MoveTabToNewWindowOutput; +use domain::ids::TabId; + +/// Response DTO for `move_tab_to_new_window`: the id minted for the new window +/// (used as the new OS window's label). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MoveTabResultDto { + /// The new window id (UUID string). + pub new_window_id: String, +} + +impl From for MoveTabResultDto { + fn from(out: MoveTabToNewWindowOutput) -> Self { + Self { + new_window_id: out.new_window_id.to_string(), + } + } +} + +/// Parses a tab-id string (UUID) coming from the frontend. +/// +/// # Errors +/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. +pub fn parse_tab_id(raw: &str) -> Result { + uuid::Uuid::parse_str(raw) + .map(TabId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid tab id: {raw}"), + }) +} + +// --------------------------------------------------------------------------- +// Skills (L12) +// --------------------------------------------------------------------------- + +use application::{CreateSkillOutput, ListSkillsOutput, UpdateSkillOutput}; +use domain::{Skill, SkillId, SkillScope}; + +/// A skill crossing the wire. [`Skill`] already serialises camelCase +/// (`id`, `name`, `contentMd`, `scope` as `"global"`/`"project"`), so we embed +/// it directly — the TS mirror matches this shape. +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct SkillDto(pub Skill); + +/// A list of skills (transparent array on the wire). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct SkillListDto(pub Vec); + +impl From for SkillListDto { + fn from(out: ListSkillsOutput) -> Self { + Self(out.skills.into_iter().map(SkillDto).collect()) + } +} + +impl From for SkillDto { + fn from(out: CreateSkillOutput) -> Self { + Self(out.skill) + } +} + +impl From for SkillDto { + fn from(out: UpdateSkillOutput) -> Self { + Self(out.skill) + } +} + +/// Request DTO for `create_skill`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateSkillRequestDto { + /// Owning project (resolved to a root; ignored on disk for `Global`). + pub project_id: String, + /// Display name. + pub name: String, + /// Initial Markdown content. + pub content: String, + /// Scope the skill is created in. + pub scope: SkillScope, +} + +/// Request DTO for `update_skill`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateSkillRequestDto { + /// Owning project (resolved to a root; ignored on disk for `Global`). + pub project_id: String, + /// Id of the skill to update. + pub skill_id: String, + /// Scope the skill lives in. + pub scope: SkillScope, + /// New Markdown content. + pub content: String, +} + +/// Request DTO for `assign_skill_to_agent`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AssignSkillRequestDto { + /// Owning project. + pub project_id: String, + /// Agent receiving the skill. + pub agent_id: String, + /// Skill to assign. + pub skill_id: String, + /// Scope of the skill (recorded alongside the ref). + pub scope: SkillScope, +} + +/// Request DTO for `unassign_skill_from_agent`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UnassignSkillRequestDto { + /// Owning project. + pub project_id: String, + /// Agent losing the skill. + pub agent_id: String, + /// Skill to unassign. + pub skill_id: String, +} + +/// Parses a skill-id string (UUID) coming from the frontend. +/// +/// # Errors +/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. +pub fn parse_skill_id(raw: &str) -> Result { + uuid::Uuid::parse_str(raw) + .map(SkillId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid skill id: {raw}"), + }) +} + +// --------------------------------------------------------------------------- +// Memory (LOT A — §14.5.1) +// --------------------------------------------------------------------------- + +use application::{ + CreateMemoryOutput, GetMemoryOutput, ListMemoriesOutput, ReadMemoryIndexOutput, + RecallMemoryOutput, ResolveMemoryLinksOutput, UpdateMemoryOutput, +}; +use domain::{Memory, MemoryIndexEntry, MemorySlug, MemoryType}; + +/// A memory note crossing the wire. +/// +/// Built explicitly from [`Memory`] (its `body` is [`domain::MarkdownDoc`], not +/// directly a JSON string) so the TS mirror gets a flat camelCase shape. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MemoryDto { + /// The note's slug (its identity, also the file stem). + pub name: String, + /// One-line description (the index hook). + pub description: String, + /// The note's kind. + pub r#type: MemoryType, + /// Markdown body of the note. + pub content: String, +} + +impl From for MemoryDto { + fn from(memory: Memory) -> Self { + Self { + name: memory.frontmatter.name.as_str().to_owned(), + description: memory.frontmatter.description, + r#type: memory.frontmatter.r#type, + content: memory.body.into_string(), + } + } +} + +impl From for MemoryDto { + fn from(out: CreateMemoryOutput) -> Self { + Self::from(out.memory) + } +} + +impl From for MemoryDto { + fn from(out: UpdateMemoryOutput) -> Self { + Self::from(out.memory) + } +} + +impl From for MemoryDto { + fn from(out: GetMemoryOutput) -> Self { + Self::from(out.memory) + } +} + +/// A list of memory notes (transparent array on the wire). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct MemoryListDto(pub Vec); + +impl From for MemoryListDto { + fn from(out: ListMemoriesOutput) -> Self { + Self(out.memories.into_iter().map(MemoryDto::from).collect()) + } +} + +/// One row of the structured `MEMORY.md` index crossing the wire. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MemoryIndexEntryDto { + /// The note's slug. + pub slug: String, + /// The note's display title. + pub title: String, + /// The one-line hook (the frontmatter description). + pub hook: String, + /// The note's kind. + pub r#type: MemoryType, +} + +impl From for MemoryIndexEntryDto { + fn from(entry: MemoryIndexEntry) -> Self { + Self { + slug: entry.slug.as_str().to_owned(), + title: entry.title, + hook: entry.hook, + r#type: entry.r#type, + } + } +} + +/// The structured memory index (transparent array on the wire). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct MemoryIndexDto(pub Vec); + +impl From for MemoryIndexDto { + fn from(out: ReadMemoryIndexOutput) -> Self { + Self( + out.entries + .into_iter() + .map(MemoryIndexEntryDto::from) + .collect(), + ) + } +} + +impl From for MemoryIndexDto { + fn from(out: RecallMemoryOutput) -> Self { + Self( + out.entries + .into_iter() + .map(MemoryIndexEntryDto::from) + .collect(), + ) + } +} + +/// A note's resolved outgoing links — the target slugs (transparent array). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct MemoryLinksDto(pub Vec); + +impl From for MemoryLinksDto { + fn from(out: ResolveMemoryLinksOutput) -> Self { + Self( + out.links + .into_iter() + .map(|link| link.target.as_str().to_owned()) + .collect(), + ) + } +} + +/// Request DTO for `create_memory`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateMemoryRequestDto { + /// Owning project (resolved to a root). + pub project_id: String, + /// Raw slug for the new note. + pub name: String, + /// One-line description (the index hook). + pub description: String, + /// The note's kind. + pub r#type: MemoryType, + /// Markdown body of the note. + pub content: String, +} + +/// Request DTO for `update_memory`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateMemoryRequestDto { + /// Owning project (resolved to a root). + pub project_id: String, + /// Slug of the note to replace. + pub slug: String, + /// New description (the index hook). + pub description: String, + /// New kind. + pub r#type: MemoryType, + /// New Markdown body. + pub content: String, +} + +/// Request DTO for `recall_memory` (LOT B — §14.5.2). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RecallMemoryRequestDto { + /// Owning project (resolved to a root). + pub project_id: String, + /// The recall query (often the agent's current working context). + pub text: String, + /// Approximate token budget bounding the recalled entries (`0` ⇒ empty). + pub token_budget: usize, +} + +/// Parses a memory slug string coming from the frontend. +/// +/// # Errors +/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a valid +/// kebab-case slug. +pub fn parse_memory_slug(raw: &str) -> Result { + MemorySlug::new(raw).map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid memory slug: {raw}"), + }) +} + +// ── Conversation pagination (lot LS6) ──────────────────────────────────────── + +/// Request DTO for `read_conversation_page` — a human, paginated transcript read. +/// +/// `anchor` is a turn id to paginate around (omit to start from a thread end); +/// `direction` is `"forward"` (towards newer) or `"backward"` (towards older, +/// default — the human view opens on the latest page); `limit` is clamped by the +/// domain to `[1, 200]` (omit/`0` ⇒ default 50). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReadConversationPageRequestDto { + /// The owning project's id. + pub project_id: String, + /// The conversation (pair) id to read. + pub conversation_id: String, + /// Optional anchor turn id; omit to start from a thread end. + #[serde(default)] + pub anchor: Option, + /// Pagination direction (`"forward"` or `"backward"`); defaults to backward. + #[serde(default)] + pub direction: Option, + /// Requested page size (omit/`0` ⇒ default; clamped to `[1, 200]`). + #[serde(default)] + pub limit: Option, +} + +impl ReadConversationPageRequestDto { + /// Builds the domain [`PageCursor`] from the request (default direction backward, + /// an unparseable anchor degrades to `None` = a thread-end page). + #[must_use] + pub fn cursor(&self) -> PageCursor { + let direction = match self.direction.as_deref() { + Some(d) if d.eq_ignore_ascii_case("forward") => PageDirection::Forward, + _ => PageDirection::Backward, + }; + let anchor = self + .anchor + .as_deref() + .and_then(|raw| uuid::Uuid::parse_str(raw).ok()) + .map(domain::TurnId::from_uuid); + PageCursor { anchor, direction } + } +} + +/// Origin of a turn in the human transcript view (mirrors [`TurnSource`]). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum TurnSourceDto { + /// The human operator. + Human, + /// Another agent (delegation via `idea_ask_agent`). + #[serde(rename_all = "camelCase")] + Agent { + /// The originating agent id. + agent_id: String, + }, +} + +impl From for TurnSourceDto { + fn from(source: TurnSource) -> Self { + match source { + TurnSource::Human => Self::Human, + TurnSource::Agent { agent_id } => Self::Agent { + agent_id: agent_id.to_string(), + }, + } + } +} + +/// One turn in the human transcript — **full text, never truncated**. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TurnViewDto { + /// Stable turn id (also the pagination anchor). + pub id: String, + /// Timestamp (epoch milliseconds). + pub at_ms: u64, + /// Turn nature (prompt, response, tool activity). + pub role: TurnRole, + /// Origin (human or delegating agent). + pub source: TurnSourceDto, + /// The turn's **complete** text (not truncated). + pub text: String, + /// Character length of the text. + pub text_len: usize, +} + +impl From for TurnViewDto { + fn from(turn: TurnView) -> Self { + Self { + id: turn.id.to_string(), + at_ms: turn.at_ms, + role: turn.role, + source: turn.source.into(), + text: turn.text, + text_len: turn.text_len, + } + } +} + +/// A page of the human transcript, oldest-to-newest. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TurnPageDto { + /// The page's turns, oldest to newest. + pub turns: Vec, + /// Whether more turns exist beyond the page in the travel direction. + pub has_more: bool, + /// Last turn id of the page (anchor for the next request), or `None` if empty. + pub next_anchor: Option, +} + +impl From for TurnPageDto { + fn from(page: TurnPage) -> Self { + Self { + turns: page.turns.into_iter().map(TurnViewDto::from).collect(), + has_more: page.has_more, + next_anchor: page.next_anchor.map(|id| id.to_string()), + } + } +} + +#[cfg(test)] +mod ls6_pagination_tests { + //! LS6 — DTO de la lecture humaine paginée : parsing du curseur + mapping riche + //! `TurnPage → TurnPageDto` (texte complet non borné, source Human/Agent, `has_more`, + //! `next_anchor`). C'est le « round-trip d'une page » testable sans `State` Tauri. + + use super::*; + use application::{TurnPage, TurnSource, TurnView}; + + fn tid(n: u128) -> domain::TurnId { + domain::TurnId::from_uuid(uuid::Uuid::from_u128(n)) + } + + #[test] + fn request_cursor_defaults_to_backward_and_parses_anchor() { + // Pas de direction ⇒ backward (la vue humaine ouvre sur la page la plus récente). + let req = ReadConversationPageRequestDto { + project_id: "p".into(), + conversation_id: "c".into(), + anchor: None, + direction: None, + limit: None, + }; + let cur = req.cursor(); + assert_eq!(cur.direction, PageDirection::Backward); + assert_eq!(cur.anchor, None); + + // Forward (insensible à la casse) + ancre UUID valide. + let u = uuid::Uuid::from_u128(9); + let req = ReadConversationPageRequestDto { + project_id: "p".into(), + conversation_id: "c".into(), + anchor: Some(u.to_string()), + direction: Some("Forward".into()), + limit: Some(10), + }; + let cur = req.cursor(); + assert_eq!(cur.direction, PageDirection::Forward); + assert_eq!(cur.anchor, Some(domain::TurnId::from_uuid(u))); + + // Ancre non-UUID ⇒ dégrade en None (page depuis un bout du fil), jamais d'erreur. + let req = ReadConversationPageRequestDto { + project_id: "p".into(), + conversation_id: "c".into(), + anchor: Some("not-a-uuid".into()), + direction: Some("backward".into()), + limit: None, + }; + assert_eq!(req.cursor().anchor, None); + } + + #[test] + fn turn_page_dto_round_trips_full_text_and_maps_sources() { + let big = "Z".repeat(40_000); + let page = TurnPage { + turns: vec![ + TurnView { + id: tid(1), + at_ms: 1_700_000_000_000, + role: TurnRole::Prompt, + source: TurnSource::Human, + text: big.clone(), + text_len: big.chars().count(), + }, + TurnView { + id: tid(2), + at_ms: 1_700_000_000_001, + role: TurnRole::Response, + source: TurnSource::Agent { + agent_id: domain::AgentId::from_uuid(uuid::Uuid::from_u128(7)), + }, + text: "réponse".to_owned(), + text_len: 7, + }, + ], + has_more: true, + next_anchor: Some(tid(2)), + }; + + let dto = TurnPageDto::from(page); + // Texte complet préservé (non borné) + text_len. + assert_eq!(dto.turns[0].text.chars().count(), 40_000); + assert_eq!(dto.turns[0].text_len, 40_000); + // next_anchor stringifié, has_more porté. + assert!(dto.has_more); + assert_eq!( + dto.next_anchor.as_deref(), + Some(tid(2).to_string().as_str()) + ); + + // Round-trip JSON (la « page » telle qu'envoyée au front) : camelCase + source. + let json = serde_json::to_value(&dto).unwrap(); + assert_eq!(json["hasMore"], serde_json::json!(true)); + assert_eq!(json["nextAnchor"], serde_json::json!(tid(2).to_string())); + assert_eq!( + json["turns"][0]["source"]["kind"], + serde_json::json!("human") + ); + assert_eq!( + json["turns"][1]["source"]["kind"], + serde_json::json!("agent") + ); + assert_eq!( + json["turns"][1]["source"]["agentId"], + serde_json::json!(uuid::Uuid::from_u128(7).to_string()) + ); + // Le texte intégral survit au passage JSON. + assert_eq!( + json["turns"][0]["text"].as_str().unwrap().chars().count(), + 40_000 + ); + } + + #[test] + fn empty_turn_page_dto_has_no_next_anchor() { + let dto = TurnPageDto::from(TurnPage { + turns: Vec::new(), + has_more: false, + next_anchor: None, + }); + assert!(dto.turns.is_empty() && !dto.has_more && dto.next_anchor.is_none()); + } +} + +// --------------------------------------------------------------------------- +// Background tasks (B8 — first-class background command) +// --------------------------------------------------------------------------- + +use domain::{ + BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskState, TaskId, +}; + +/// One first-class background task as seen by the frontend (camelCase wire shape). +/// +/// Mirrors the persisted [`BackgroundTask`], flattening the terminal +/// [`BackgroundTaskResult`] into optional `exitCode` / `summary` / tails so the +/// UI has a single shape for every lifecycle state. Optional fields are omitted +/// from the wire when absent (`skip_serializing_if`). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BackgroundTaskDto { + /// Stable task id (UUID string). + pub task_id: String, + /// Owning agent id (UUID string). + pub owner_agent_id: String, + /// Owning project id (UUID string). + pub project_id: String, + /// Kind discriminant (`"command"`, `"headlessRendezvous"`, `"sessionResume"`, + /// `"maintenance"`). + pub kind: String, + /// Lifecycle state (`"queued"`, `"running"`, `"waiting"`, `"completed"`, + /// `"failed"`, `"cancelled"`, `"expired"`). + pub state: String, + /// Process exit code, when the terminal result carries one. + #[serde(skip_serializing_if = "Option::is_none")] + pub exit_code: Option, + /// Human-readable summary / error / reason of the terminal result. + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + /// Bounded stdout tail (merged output for PTY-backed commands). + #[serde(skip_serializing_if = "Option::is_none")] + pub stdout_tail: Option, + /// Bounded stderr tail (unset for PTY-backed commands, which merge streams). + #[serde(skip_serializing_if = "Option::is_none")] + pub stderr_tail: Option, + /// Creation timestamp, epoch milliseconds. + pub created_at_ms: u64, + /// Last update timestamp, epoch milliseconds. + pub updated_at_ms: u64, +} + +/// Kind discriminant string for a [`BackgroundTaskKind`]. +fn background_kind_label(kind: &BackgroundTaskKind) -> &'static str { + match kind { + BackgroundTaskKind::Command { .. } => "command", + BackgroundTaskKind::HeadlessRendezvous { .. } => "headlessRendezvous", + BackgroundTaskKind::SessionResume { .. } => "sessionResume", + BackgroundTaskKind::Maintenance { .. } => "maintenance", + } +} + +/// Kind discriminant string for a work-state [`BackgroundTaskKindLabel`]. +fn background_kind_label_from_work_state(kind: BackgroundTaskKindLabel) -> &'static str { + match kind { + BackgroundTaskKindLabel::Command => "command", + BackgroundTaskKindLabel::HeadlessRendezvous => "headlessRendezvous", + BackgroundTaskKindLabel::SessionResume => "sessionResume", + BackgroundTaskKindLabel::Maintenance => "maintenance", + } +} + +/// Lifecycle state string for a [`BackgroundTaskState`]. +fn background_state_label(state: BackgroundTaskState) -> &'static str { + match state { + BackgroundTaskState::Queued => "queued", + BackgroundTaskState::Running => "running", + BackgroundTaskState::Waiting => "waiting", + BackgroundTaskState::Completed => "completed", + BackgroundTaskState::Failed => "failed", + BackgroundTaskState::Cancelled => "cancelled", + BackgroundTaskState::Expired => "expired", + } +} + +impl From for BackgroundTaskDto { + fn from(task: BackgroundTask) -> Self { + let (exit_code, summary, stdout_tail, stderr_tail) = match &task.result { + Some(BackgroundTaskResult::Success { + exit_code, + summary, + stdout_tail, + stderr_tail, + .. + }) => ( + *exit_code, + Some(summary.clone()), + stdout_tail.clone(), + stderr_tail.clone(), + ), + Some(BackgroundTaskResult::Failure { + exit_code, + error, + stdout_tail, + stderr_tail, + .. + }) => ( + *exit_code, + Some(error.clone()), + stdout_tail.clone(), + stderr_tail.clone(), + ), + Some(BackgroundTaskResult::Cancelled { reason, .. }) + | Some(BackgroundTaskResult::Expired { reason, .. }) => { + (None, Some(reason.clone()), None, None) + } + None => (None, None, None, None), + }; + Self { + task_id: task.id.to_string(), + owner_agent_id: task.owner_agent_id.to_string(), + project_id: task.project_id.to_string(), + kind: background_kind_label(&task.kind).to_owned(), + state: background_state_label(task.state).to_owned(), + exit_code, + summary, + stdout_tail, + stderr_tail, + created_at_ms: task.created_at_ms, + updated_at_ms: task.updated_at_ms, + } + } +} + +/// Parses a task-id string (UUID) coming from the frontend. +/// +/// # Errors +/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. +pub fn parse_task_id(raw: &str) -> Result { + uuid::Uuid::parse_str(raw) + .map(TaskId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid task id: {raw}"), + }) +} + +/// Request DTO for `spawn_background_command`. +/// +/// Builds a command-backed [`BackgroundTask`]: the command line runs under the +/// project host's PTY and its completion is delivered to `ownerAgentId`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpawnBackgroundCommandRequestDto { + /// Owning project id (UUID string). + pub project_id: String, + /// Agent that owns completion delivery (UUID string). + pub owner_agent_id: String, + /// Human-facing label. + pub label: String, + /// Executable to run. + pub command: String, + /// Arguments. + #[serde(default)] + pub args: Vec, + /// Working directory (absolute path). + pub cwd: String, + /// Extra environment variables. + #[serde(default)] + pub env: Vec<(String, String)>, + /// When `true` (the default), wake the owner on completion; otherwise only + /// record it. + #[serde(default)] + pub record_only: bool, + /// Optional absolute deadline, epoch milliseconds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub deadline_ms: Option, +} diff --git a/crates/backend/src/events.rs b/crates/backend/src/events.rs new file mode 100644 index 0000000..ef1805c --- /dev/null +++ b/crates/backend/src/events.rs @@ -0,0 +1,1314 @@ +//! Transport-neutral DTOs for domain events. +//! +//! The event relay itself belongs to each driving adapter. This module owns only +//! the stable JSON wire shape shared by Tauri IPC and the headless web server. + +use serde::Serialize; + +use domain::conversation::ConversationParty; +use domain::events::{DomainEvent, OrchestrationSource}; +use domain::input::AgentLiveness; +use domain::model_server::ModelServerLifecycleStatus; +use domain::{IssueLinkKind, IssuePriority, IssueStatus}; + +/// Name of the Tauri event carrying relayed [`DomainEvent`]s. +pub const DOMAIN_EVENT: &str = "domain://event"; +/// Dedicated Tauri event for local model-server status changes. +pub const MODEL_SERVER_STATUS_CHANGED: &str = "model_server_status_changed"; +/// Dedicated Tauri event for launch failures. +pub const AGENT_LAUNCH_FAILED: &str = "agent_launch_failed"; + +/// Model-server lifecycle status on the Tauri wire. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "state", rename_all = "camelCase")] +pub enum ModelServerStatusDto { + /// No server is configured for this profile. + NotConfigured, + /// Readiness probing is in progress. + Probing, + /// IdeA is starting the process. + Starting, + /// Server is downloading/preparing a remote model. + #[serde(rename_all = "camelCase")] + Downloading { + /// Downloaded bytes when known. + downloaded_bytes: Option, + /// Total bytes when known. + total_bytes: Option, + /// Completion percentage when known. + percent: Option, + /// Remote model source. + source: Option, + }, + /// Server is ready. + #[serde(rename_all = "camelCase")] + Ready { + /// `true` when an existing server was reused. + reused: bool, + }, + /// Server preparation failed. + #[serde(rename_all = "camelCase")] + Failed { + /// Stable failure code. + code: String, + /// Human-readable message. + message: String, + }, +} + +impl From<&ModelServerLifecycleStatus> for ModelServerStatusDto { + fn from(status: &ModelServerLifecycleStatus) -> Self { + match status { + ModelServerLifecycleStatus::NotConfigured => Self::NotConfigured, + ModelServerLifecycleStatus::Probing => Self::Probing, + ModelServerLifecycleStatus::Starting => Self::Starting, + ModelServerLifecycleStatus::Downloading { + downloaded_bytes, + total_bytes, + percent, + source, + } => Self::Downloading { + downloaded_bytes: *downloaded_bytes, + total_bytes: *total_bytes, + percent: *percent, + source: source.clone(), + }, + ModelServerLifecycleStatus::Ready { reused } => Self::Ready { reused: *reused }, + ModelServerLifecycleStatus::Failed { code, message } => Self::Failed { + code: code.clone(), + message: message.clone(), + }, + } + } +} + +/// Payload for [`MODEL_SERVER_STATUS_CHANGED`]. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelServerStatusChangedDto { + /// Local model server id. + pub server_id: String, + /// Lifecycle status. + pub status: ModelServerStatusDto, +} + +/// Payload for [`AGENT_LAUNCH_FAILED`]. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentLaunchFailedDto { + /// Agent id. + pub agent_id: String, + /// Failure cause namespace. + pub cause: String, + /// Stable error code. + pub code: String, + /// Human-readable message. + pub message: String, +} + +/// Serialisable mirror of [`DomainEvent`] for the IPC wire (camelCase, tagged). +/// +/// `type` is the discriminant; payload fields are flattened per variant. This is +/// the single owner of the event wire format on the backend side. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum DomainEventDto { + /// A project was created. + #[serde(rename_all = "camelCase")] + ProjectCreated { + /// Project id (UUID string). + project_id: String, + }, + /// An agent was launched. + #[serde(rename_all = "camelCase")] + AgentLaunched { + /// Agent id. + agent_id: String, + /// Session id. + session_id: String, + }, + /// An agent launch failed before runtime session creation. + #[serde(rename_all = "camelCase")] + AgentLaunchFailed { + /// Agent id. + agent_id: String, + /// Failure cause namespace. + cause: String, + /// Stable error code. + code: String, + /// Human-readable message. + message: String, + }, + /// Local model-server status changed. + #[serde(rename_all = "camelCase")] + ModelServerStatusChanged { + /// Local model server id. + server_id: String, + /// Lifecycle status. + status: ModelServerStatusDto, + }, + /// An agent exited. + #[serde(rename_all = "camelCase")] + AgentExited { + /// Agent id. + agent_id: String, + /// Exit code. + code: i32, + }, + /// A ticket assistant chat was opened. + #[serde(rename_all = "camelCase")] + TicketAssistantOpened { + /// Bound ticket reference. + issue_ref: String, + /// Runtime profile id. + profile_id: String, + }, + /// A ticket assistant chat was closed. + #[serde(rename_all = "camelCase")] + TicketAssistantClosed { + /// Bound ticket reference. + issue_ref: String, + }, + /// An agent's busy/idle state changed (cadrage C4 §4.2). The frontend dims + /// "Envoyer" while `busy` is `true`. + #[serde(rename_all = "camelCase")] + AgentBusyChanged { + /// Agent id. + agent_id: String, + /// `true` when a turn is in flight, `false` when idle. + busy: bool, + }, + /// A delegation is ready to be injected into the agent's **native terminal** + /// (ARCHITECTURE §20). The frontend write-portal writes `text` + `submitSequence` + /// (default `"\r"`) after `submitDelayMs` (default ~60) once the human line is empty, + /// then acks via the `delegation_delivered` command. The backend no longer PTY-writes + /// the turn. + #[serde(rename_all = "camelCase")] + DelegationReady { + /// Target agent id (UUID string). + agent_id: String, + /// Mailbox ticket id (UUID string) to ack back via `delegation_delivered`. + ticket: String, + /// Task text to inject (written without a trailing newline by the portal). + text: String, + /// Profile's submit sequence; `null` ⇒ the front applies its default (`"\r"`). + #[serde(skip_serializing_if = "Option::is_none")] + submit_sequence: Option, + /// Profile's submit delay in ms; `null` ⇒ the front default (~60 ms). + #[serde(skip_serializing_if = "Option::is_none")] + submit_delay_ms: Option, + }, + /// A target agent produced a synchronous reply to an inter-agent `ask` (§17.4). + #[serde(rename_all = "camelCase")] + AgentReplied { + /// Target agent id. + agent_id: String, + /// Reply length in bytes (metric, not the payload). + reply_len: usize, + }, + /// Intermediate assistant announcement emitted live during an inter-agent turn. + #[serde(rename_all = "camelCase")] + AgentAnnouncement { + /// Project id. + project_id: String, + /// Requester party (`"user"` or requester agent id). + requester: String, + /// Target agent id. + target: String, + /// FIFO ticket id. + ticket_id: String, + /// Announcement text. + text: String, + /// Wall-clock timestamp in epoch milliseconds. + at_ms: u64, + }, + /// An agent's liveness (alive/stalled) changed (lot 2, readiness/heartbeat). The + /// frontend can badge a frozen agent; `"stalled"` while no proof of liveness arrived + /// for longer than the profile's `stallAfterMs`, back to `"alive"` on a late + /// battement or when the turn ends. + #[serde(rename_all = "camelCase")] + AgentLivenessChanged { + /// Agent id (UUID string). + agent_id: String, + /// New liveness, as a lowercase string (`"alive"` / `"stalled"`). + liveness: AgentLivenessDto, + }, + /// A background task lifecycle/delivery event occurred. + #[serde(rename_all = "camelCase")] + BackgroundTaskChanged { + /// Project id. + project_id: String, + /// Task id. + task_id: String, + /// Owner agent id. + agent_id: String, + /// Lightweight event/state label. + state: String, + }, + /// An agent inbox queue depth changed. + #[serde(rename_all = "camelCase")] + AgentInboxChanged { + /// Agent id. + agent_id: String, + /// Queue depth after the operation. + depth: usize, + /// Queue operation label. + action: String, + }, + /// An owner wake event occurred. + #[serde(rename_all = "camelCase")] + AgentWakeChanged { + /// Project id. + project_id: String, + /// Agent id. + agent_id: String, + /// Wake operation label. + action: String, + /// Failure reason, when any. + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option, + }, + /// An agent's runtime profile was changed (hot-swap of the AI engine). + #[serde(rename_all = "camelCase")] + AgentProfileChanged { + /// Agent id. + agent_id: String, + /// The new runtime profile id. + profile_id: String, + }, + /// A template was updated. + #[serde(rename_all = "camelCase")] + TemplateUpdated { + /// Template id. + template_id: String, + /// New version. + version: u64, + }, + /// A synchronized agent drifted from its template. + #[serde(rename_all = "camelCase")] + AgentDriftDetected { + /// Agent id. + agent_id: String, + /// Current version. + from: u64, + /// Available version. + to: u64, + }, + /// A synchronized agent was brought up to date. + #[serde(rename_all = "camelCase")] + AgentSynced { + /// Agent id. + agent_id: String, + /// Version synced to. + to: u64, + }, + /// A skill was assigned to (or unassigned from) an agent. + #[serde(rename_all = "camelCase")] + SkillAssigned { + /// Agent id. + agent_id: String, + /// Skill id. + skill_id: String, + /// `true` if assigned, `false` if unassigned. + assigned: bool, + }, + /// A tab's layout changed. + #[serde(rename_all = "camelCase")] + LayoutChanged { + /// Project id. + project_id: String, + }, + /// A remote connection was established. + #[serde(rename_all = "camelCase")] + RemoteConnected { + /// Project id. + project_id: String, + }, + /// Git state changed. + #[serde(rename_all = "camelCase")] + GitStateChanged { + /// Project id. + project_id: String, + }, + /// An issue-backed public ticket was created. + #[serde(rename_all = "camelCase")] + IssueCreated { + /// Issue id. + issue_id: String, + /// Public ticket reference (`#N`). + issue_ref: String, + }, + /// An issue-backed public ticket was updated. + #[serde(rename_all = "camelCase")] + IssueUpdated { + /// Public ticket reference (`#N`). + issue_ref: String, + /// New optimistic version. + version: u64, + }, + /// An issue-backed public ticket was deleted. + #[serde(rename_all = "camelCase")] + IssueDeleted { + /// Project id. + project_id: String, + /// Deleted public ticket reference (`#N`). + issue_ref: String, + /// Released sprint id, when any. + freed_sprint: Option, + }, + /// A public ticket status changed. + #[serde(rename_all = "camelCase")] + IssueStatusChanged { + /// Public ticket reference (`#N`). + issue_ref: String, + /// New status. + status: String, + /// New optimistic version. + version: u64, + }, + /// A public ticket priority changed. + #[serde(rename_all = "camelCase")] + IssuePriorityChanged { + /// Public ticket reference (`#N`). + issue_ref: String, + /// New priority. + priority: String, + /// New optimistic version. + version: u64, + }, + /// A public ticket carnet changed. + #[serde(rename_all = "camelCase")] + IssueCarnetUpdated { + /// Public ticket reference (`#N`). + issue_ref: String, + /// New optimistic version. + version: u64, + }, + /// A public ticket link was added. + #[serde(rename_all = "camelCase")] + IssueLinked { + /// Public source ticket reference (`#N`). + issue_ref: String, + /// Public target ticket reference (`#N`). + target: String, + /// Link kind. + kind: String, + /// New optimistic version. + version: u64, + }, + /// A public ticket link was removed. + #[serde(rename_all = "camelCase")] + IssueUnlinked { + /// Public source ticket reference (`#N`). + issue_ref: String, + /// Public target ticket reference (`#N`). + target: String, + /// Link kind. + kind: String, + /// New optimistic version. + version: u64, + }, + /// An agent was assigned to a public ticket. + #[serde(rename_all = "camelCase")] + IssueAgentAssigned { + /// Public ticket reference (`#N`). + issue_ref: String, + /// Assigned agent id. + agent_id: String, + /// New optimistic version. + version: u64, + }, + /// An agent was unassigned from a public ticket. + #[serde(rename_all = "camelCase")] + IssueAgentUnassigned { + /// Public ticket reference (`#N`). + issue_ref: String, + /// Unassigned agent id. + agent_id: String, + /// New optimistic version. + version: u64, + }, + /// A sprint was created. + #[serde(rename_all = "camelCase")] + SprintCreated { + /// Stable sprint id. + sprint_id: String, + /// Reorderable order. + order: u32, + }, + /// A sprint was renamed. + #[serde(rename_all = "camelCase")] + SprintRenamed { + /// Stable sprint id. + sprint_id: String, + /// New name. + name: String, + /// New optimistic version. + version: u64, + }, + /// A sprint was reordered. + #[serde(rename_all = "camelCase")] + SprintReordered { + /// Stable sprint id. + sprint_id: String, + /// New order. + order: u32, + /// New optimistic version. + version: u64, + }, + /// A sprint was deleted. + #[serde(rename_all = "camelCase")] + SprintDeleted { + /// Stable sprint id. + sprint_id: String, + }, + /// A ticket changed sprint membership. + #[serde(rename_all = "camelCase")] + IssueSprintChanged { + /// Public ticket reference (`#N`). + issue_ref: String, + /// Previous sprint id. + from: Option, + /// New sprint id. + to: Option, + /// New optimistic version. + version: u64, + }, + /// An orchestrator request was processed on behalf of a requester agent. + #[serde(rename_all = "camelCase")] + OrchestratorRequestProcessed { + /// Id of the requesting (orchestrator) agent. + requester_id: String, + /// The action that was processed. + action: String, + /// Whether IdeA handled it successfully. + ok: bool, + /// Which entry door the request arrived through (`"file"` watcher vs + /// `"mcp"` server). Serialised as a lowercase string so the frontend can + /// badge the source. + source: OrchestrationSourceDto, + }, + /// The project's orchestrator designation changed (T1). + #[serde(rename_all = "camelCase")] + OrchestratorChanged { + /// The project whose designation changed. + project_id: String, + /// The newly designated orchestrator agent, or `None` for the default + /// (the oldest agent orchestrates). + #[serde(skip_serializing_if = "Option::is_none")] + orchestrator: Option, + }, + /// A memory note was created or updated. + #[serde(rename_all = "camelCase")] + MemorySaved { + /// The saved note's slug. + slug: String, + }, + /// A memory note was deleted. + #[serde(rename_all = "camelCase")] + MemoryDeleted { + /// The deleted note's slug. + slug: String, + }, + /// The aggregated `MEMORY.md` index was rebuilt. + #[serde(rename_all = "camelCase")] + MemoryIndexRebuilt { + /// Project id. + project_id: String, + }, + /// A project's memory crossed the recall budget while no embedder is configured + /// (LOT C3 — §14.5.5): a one-time, dismissible "configure an embedder?" hint. + #[serde(rename_all = "camelCase")] + EmbedderSuggested { + /// Project id. + project_id: String, + /// Whether a local Ollama-style embedding server was detected. + ollama_detected: bool, + /// Ids of recommended ONNX models already present in the local cache. + onnx_cached: Vec, + /// Whether the HTTP capability is compiled in. + vector_http_enabled: bool, + /// Whether the in-process ONNX capability is compiled in. + vector_onnx_enabled: bool, + }, + /// An agent entered a **session/rate limit** (ARCHITECTURE §21). Low-frequency, + /// model-agnostic badge: carries only the neutral "limited, maybe resets at T" + /// fact. The frontend badges "limité jusqu'à HH:MM". + #[serde(rename_all = "camelCase")] + AgentRateLimited { + /// Agent id (UUID string). + agent_id: String, + /// Reset instant in **epoch-milliseconds**; `null` ⇒ unknown (no auto-resume). + #[serde(skip_serializing_if = "Option::is_none")] + resets_at_ms: Option, + }, + /// An **auto-resume** was armed for a rate-limited agent (ARCHITECTURE §21). The + /// frontend shows the countdown + the "Annuler la reprise" button (cancellable + /// window). + #[serde(rename_all = "camelCase")] + AgentResumeScheduled { + /// Agent id (UUID string). + agent_id: String, + /// Wake-up deadline in **epoch-milliseconds**. + fire_at_ms: i64, + }, + /// An agent's **auto-resume** was **cancelled** (ARCHITECTURE §21): the user + /// clicked "Annuler la reprise". The frontend removes the countdown. + #[serde(rename_all = "camelCase")] + AgentResumeCancelled { + /// Agent id (UUID string). + agent_id: String, + }, + /// An agent was effectively **resumed** after a limit (ARCHITECTURE §21): the + /// wake-up fired (or immediate resume). The frontend clears the "limited" state. + #[serde(rename_all = "camelCase")] + AgentResumed { + /// Agent id (UUID string). + agent_id: String, + }, + /// **Human net (level 3)**: a session limit is **suspected** without any reliable + /// reset time (ARCHITECTURE §21.1). IdeA never resumes blind: this asks the front + /// to **prompt the user** ("limit detected but time unknown — resume at?"). + #[serde(rename_all = "camelCase")] + AgentRateLimitSuspected { + /// Agent id (UUID string). + agent_id: String, + /// Reset instant in **epoch-milliseconds** if an estimate exists, else `null`. + #[serde(skip_serializing_if = "Option::is_none")] + resets_at_ms: Option, + }, + /// Raw PTY output (normally routed to a per-session channel, not here). + #[serde(rename_all = "camelCase")] + PtyOutput { + /// Session id. + session_id: String, + /// Output bytes. + bytes: Vec, + }, +} + +/// Wire mirror of [`OrchestrationSource`]: which entry door a processed +/// orchestration request arrived through. Serialised as a lowercase string +/// (`"file"` / `"mcp"`) the frontend badges on the event. +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum OrchestrationSourceDto { + /// A `.ideai/requests` JSON file (filesystem watcher). + File, + /// A `tools/call` on the MCP server. + Mcp, +} + +/// Wire mirror of [`AgentLiveness`]: the alive/stalled liveness of an agent (lot 2), +/// serialised as a lowercase string (`"alive"` / `"stalled"`) the frontend badges. +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum AgentLivenessDto { + /// The agent shows proof of liveness (or is idle). + Alive, + /// No proof of liveness past the profile's `stallAfterMs` threshold. + Stalled, +} + +impl From for AgentLivenessDto { + fn from(liveness: AgentLiveness) -> Self { + match liveness { + AgentLiveness::Alive => Self::Alive, + AgentLiveness::Stalled => Self::Stalled, + } + } +} + +impl From for OrchestrationSourceDto { + fn from(source: OrchestrationSource) -> Self { + match source { + OrchestrationSource::File => Self::File, + OrchestrationSource::Mcp => Self::Mcp, + } + } +} + +fn issue_status_wire(status: IssueStatus) -> &'static str { + match status { + IssueStatus::Open => "open", + IssueStatus::InProgress => "inProgress", + IssueStatus::Qa => "QA", + IssueStatus::Closed => "closed", + } +} + +fn issue_priority_wire(priority: IssuePriority) -> &'static str { + match priority { + IssuePriority::Low => "low", + IssuePriority::Medium => "medium", + IssuePriority::High => "high", + IssuePriority::Critical => "critical", + } +} + +fn issue_link_kind_wire(kind: IssueLinkKind) -> &'static str { + match kind { + IssueLinkKind::RelatesTo => "relatesTo", + IssueLinkKind::Blocks => "blocks", + IssueLinkKind::BlockedBy => "blockedBy", + IssueLinkKind::Duplicates => "duplicates", + IssueLinkKind::DependsOn => "dependsOn", + } +} + +fn conversation_party_wire(party: ConversationParty) -> String { + match party { + ConversationParty::User => "user".to_owned(), + ConversationParty::Agent { agent_id } => agent_id.to_string(), + } +} + +impl From<&DomainEvent> for DomainEventDto { + fn from(e: &DomainEvent) -> Self { + match e { + DomainEvent::ProjectCreated { project_id } => Self::ProjectCreated { + project_id: project_id.to_string(), + }, + DomainEvent::AgentLaunched { + agent_id, + session_id, + } => Self::AgentLaunched { + agent_id: agent_id.to_string(), + session_id: session_id.to_string(), + }, + DomainEvent::AgentLaunchFailed { + agent_id, + cause, + code, + message, + } => Self::AgentLaunchFailed { + agent_id: agent_id.to_string(), + cause: cause.clone(), + code: code.clone(), + message: message.clone(), + }, + DomainEvent::ModelServerStatusChanged { server_id, status } => { + Self::ModelServerStatusChanged { + server_id: server_id.to_string(), + status: status.into(), + } + } + DomainEvent::AgentExited { agent_id, code } => Self::AgentExited { + agent_id: agent_id.to_string(), + code: *code, + }, + DomainEvent::TicketAssistantOpened { + issue_ref, + profile_id, + } => Self::TicketAssistantOpened { + issue_ref: issue_ref.to_string(), + profile_id: profile_id.to_string(), + }, + DomainEvent::TicketAssistantClosed { issue_ref } => Self::TicketAssistantClosed { + issue_ref: issue_ref.to_string(), + }, + DomainEvent::AgentBusyChanged { agent_id, busy } => Self::AgentBusyChanged { + agent_id: agent_id.to_string(), + busy: *busy, + }, + DomainEvent::DelegationReady { + agent_id, + ticket, + text, + submit_sequence, + submit_delay_ms, + } => Self::DelegationReady { + agent_id: agent_id.to_string(), + ticket: ticket.to_string(), + text: text.clone(), + submit_sequence: submit_sequence.clone(), + submit_delay_ms: *submit_delay_ms, + }, + DomainEvent::AgentReplied { + agent_id, + reply_len, + } => Self::AgentReplied { + agent_id: agent_id.to_string(), + reply_len: *reply_len, + }, + DomainEvent::AgentAnnouncement { + project_id, + requester, + target, + ticket, + text, + at_ms, + } => Self::AgentAnnouncement { + project_id: project_id.to_string(), + requester: conversation_party_wire(*requester), + target: target.to_string(), + ticket_id: ticket.to_string(), + text: text.clone(), + at_ms: *at_ms, + }, + DomainEvent::AgentLivenessChanged { agent_id, liveness } => { + Self::AgentLivenessChanged { + agent_id: agent_id.to_string(), + liveness: (*liveness).into(), + } + } + DomainEvent::BackgroundTaskStarted { + project_id, + task_id, + owner_agent_id, + } => Self::BackgroundTaskChanged { + project_id: project_id.to_string(), + task_id: task_id.to_string(), + agent_id: owner_agent_id.to_string(), + state: "started".to_owned(), + }, + DomainEvent::BackgroundTaskStateChanged { + project_id, + task_id, + owner_agent_id, + state, + } => Self::BackgroundTaskChanged { + project_id: project_id.to_string(), + task_id: task_id.to_string(), + agent_id: owner_agent_id.to_string(), + state: format!("{state:?}"), + }, + DomainEvent::BackgroundTaskCompleted { + project_id, + task_id, + owner_agent_id, + } => Self::BackgroundTaskChanged { + project_id: project_id.to_string(), + task_id: task_id.to_string(), + agent_id: owner_agent_id.to_string(), + state: "completed".to_owned(), + }, + DomainEvent::BackgroundTaskFailed { + project_id, + task_id, + owner_agent_id, + } => Self::BackgroundTaskChanged { + project_id: project_id.to_string(), + task_id: task_id.to_string(), + agent_id: owner_agent_id.to_string(), + state: "failed".to_owned(), + }, + DomainEvent::BackgroundTaskCancelled { + project_id, + task_id, + owner_agent_id, + } => Self::BackgroundTaskChanged { + project_id: project_id.to_string(), + task_id: task_id.to_string(), + agent_id: owner_agent_id.to_string(), + state: "cancelled".to_owned(), + }, + DomainEvent::BackgroundTaskCompletionDeliveryPending { + project_id, + task_id, + owner_agent_id, + } => Self::BackgroundTaskChanged { + project_id: project_id.to_string(), + task_id: task_id.to_string(), + agent_id: owner_agent_id.to_string(), + state: "deliveryPending".to_owned(), + }, + DomainEvent::BackgroundTaskCompletionDelivered { + project_id, + task_id, + owner_agent_id, + } => Self::BackgroundTaskChanged { + project_id: project_id.to_string(), + task_id: task_id.to_string(), + agent_id: owner_agent_id.to_string(), + state: "delivered".to_owned(), + }, + DomainEvent::AgentInboxQueued { agent_id, depth } => Self::AgentInboxChanged { + agent_id: agent_id.to_string(), + depth: *depth, + action: "queued".to_owned(), + }, + DomainEvent::AgentInboxDrained { agent_id, depth } => Self::AgentInboxChanged { + agent_id: agent_id.to_string(), + depth: *depth, + action: "drained".to_owned(), + }, + DomainEvent::AgentWakeScheduled { + project_id, + agent_id, + } => Self::AgentWakeChanged { + project_id: project_id.to_string(), + agent_id: agent_id.to_string(), + action: "scheduled".to_owned(), + reason: None, + }, + DomainEvent::AgentWakeStarted { + project_id, + agent_id, + } => Self::AgentWakeChanged { + project_id: project_id.to_string(), + agent_id: agent_id.to_string(), + action: "started".to_owned(), + reason: None, + }, + DomainEvent::AgentWakeFailed { + project_id, + agent_id, + reason, + } => Self::AgentWakeChanged { + project_id: project_id.to_string(), + agent_id: agent_id.to_string(), + action: "failed".to_owned(), + reason: Some(reason.clone()), + }, + DomainEvent::AgentProfileChanged { + agent_id, + profile_id, + } => Self::AgentProfileChanged { + agent_id: agent_id.to_string(), + profile_id: profile_id.to_string(), + }, + DomainEvent::TemplateUpdated { + template_id, + version, + } => Self::TemplateUpdated { + template_id: template_id.to_string(), + version: version.get(), + }, + DomainEvent::AgentDriftDetected { agent_id, from, to } => Self::AgentDriftDetected { + agent_id: agent_id.to_string(), + from: from.get(), + to: to.get(), + }, + DomainEvent::AgentSynced { agent_id, to } => Self::AgentSynced { + agent_id: agent_id.to_string(), + to: to.get(), + }, + DomainEvent::SkillAssigned { + agent_id, + skill_id, + assigned, + } => Self::SkillAssigned { + agent_id: agent_id.to_string(), + skill_id: skill_id.to_string(), + assigned: *assigned, + }, + DomainEvent::LayoutChanged { project_id } => Self::LayoutChanged { + project_id: project_id.to_string(), + }, + DomainEvent::RemoteConnected { project_id } => Self::RemoteConnected { + project_id: project_id.to_string(), + }, + DomainEvent::GitStateChanged { project_id } => Self::GitStateChanged { + project_id: project_id.to_string(), + }, + DomainEvent::IssueCreated { + issue_id, + issue_ref, + } => Self::IssueCreated { + issue_id: issue_id.to_string(), + issue_ref: issue_ref.to_string(), + }, + DomainEvent::IssueUpdated { issue_ref, version } => Self::IssueUpdated { + issue_ref: issue_ref.to_string(), + version: version.get(), + }, + DomainEvent::IssueDeleted { + project_id, + issue_ref, + freed_sprint, + } => Self::IssueDeleted { + project_id: project_id.to_string(), + issue_ref: issue_ref.to_string(), + freed_sprint: freed_sprint.map(|sprint| sprint.to_string()), + }, + DomainEvent::IssueStatusChanged { + issue_ref, + status, + version, + } => Self::IssueStatusChanged { + issue_ref: issue_ref.to_string(), + status: issue_status_wire(*status).to_owned(), + version: version.get(), + }, + DomainEvent::IssuePriorityChanged { + issue_ref, + priority, + version, + } => Self::IssuePriorityChanged { + issue_ref: issue_ref.to_string(), + priority: issue_priority_wire(*priority).to_owned(), + version: version.get(), + }, + DomainEvent::IssueCarnetUpdated { issue_ref, version } => Self::IssueCarnetUpdated { + issue_ref: issue_ref.to_string(), + version: version.get(), + }, + DomainEvent::IssueLinked { + issue_ref, + target, + kind, + version, + } => Self::IssueLinked { + issue_ref: issue_ref.to_string(), + target: target.to_string(), + kind: issue_link_kind_wire(*kind).to_owned(), + version: version.get(), + }, + DomainEvent::IssueUnlinked { + issue_ref, + target, + kind, + version, + } => Self::IssueUnlinked { + issue_ref: issue_ref.to_string(), + target: target.to_string(), + kind: issue_link_kind_wire(*kind).to_owned(), + version: version.get(), + }, + DomainEvent::IssueAgentAssigned { + issue_ref, + agent_id, + version, + } => Self::IssueAgentAssigned { + issue_ref: issue_ref.to_string(), + agent_id: agent_id.to_string(), + version: version.get(), + }, + DomainEvent::IssueAgentUnassigned { + issue_ref, + agent_id, + version, + } => Self::IssueAgentUnassigned { + issue_ref: issue_ref.to_string(), + agent_id: agent_id.to_string(), + version: version.get(), + }, + DomainEvent::SprintCreated { sprint_id, order } => Self::SprintCreated { + sprint_id: sprint_id.to_string(), + order: order.get(), + }, + DomainEvent::SprintRenamed { + sprint_id, + name, + version, + } => Self::SprintRenamed { + sprint_id: sprint_id.to_string(), + name: name.clone(), + version: version.get(), + }, + DomainEvent::SprintReordered { + sprint_id, + order, + version, + } => Self::SprintReordered { + sprint_id: sprint_id.to_string(), + order: order.get(), + version: version.get(), + }, + DomainEvent::SprintDeleted { sprint_id } => Self::SprintDeleted { + sprint_id: sprint_id.to_string(), + }, + DomainEvent::IssueSprintChanged { + issue_ref, + from, + to, + version, + } => Self::IssueSprintChanged { + issue_ref: issue_ref.to_string(), + from: from.map(|id| id.to_string()), + to: to.map(|id| id.to_string()), + version: version.get(), + }, + DomainEvent::OrchestratorRequestProcessed { + requester_id, + action, + ok, + source, + } => Self::OrchestratorRequestProcessed { + requester_id: requester_id.clone(), + action: action.clone(), + ok: *ok, + source: (*source).into(), + }, + DomainEvent::OrchestratorChanged { + project_id, + orchestrator, + } => Self::OrchestratorChanged { + project_id: project_id.to_string(), + orchestrator: orchestrator.as_ref().map(|a| a.to_string()), + }, + DomainEvent::MemorySaved { slug } => Self::MemorySaved { + slug: slug.as_str().to_string(), + }, + DomainEvent::MemoryDeleted { slug } => Self::MemoryDeleted { + slug: slug.as_str().to_string(), + }, + DomainEvent::MemoryIndexRebuilt { project_id } => Self::MemoryIndexRebuilt { + project_id: project_id.to_string(), + }, + DomainEvent::EmbedderSuggested { + project_id, + ollama_detected, + onnx_cached, + vector_http_enabled, + vector_onnx_enabled, + } => Self::EmbedderSuggested { + project_id: project_id.to_string(), + ollama_detected: *ollama_detected, + onnx_cached: onnx_cached.clone(), + vector_http_enabled: *vector_http_enabled, + vector_onnx_enabled: *vector_onnx_enabled, + }, + DomainEvent::AgentRateLimited { + agent_id, + resets_at_ms, + } => Self::AgentRateLimited { + agent_id: agent_id.to_string(), + resets_at_ms: *resets_at_ms, + }, + DomainEvent::AgentResumeScheduled { + agent_id, + fire_at_ms, + } => Self::AgentResumeScheduled { + agent_id: agent_id.to_string(), + fire_at_ms: *fire_at_ms, + }, + DomainEvent::AgentResumeCancelled { agent_id } => Self::AgentResumeCancelled { + agent_id: agent_id.to_string(), + }, + DomainEvent::AgentResumed { agent_id } => Self::AgentResumed { + agent_id: agent_id.to_string(), + }, + DomainEvent::AgentRateLimitSuspected { + agent_id, + resets_at_ms, + } => Self::AgentRateLimitSuspected { + agent_id: agent_id.to_string(), + resets_at_ms: *resets_at_ms, + }, + DomainEvent::PtyOutput { session_id, bytes } => Self::PtyOutput { + session_id: session_id.to_string(), + bytes: bytes.clone(), + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use domain::ids::AgentId; + use domain::mailbox::TicketId; + use domain::{LocalModelServerId, ProjectId}; + use serde_json::json; + + fn agent(n: u128) -> AgentId { + AgentId::from_uuid(uuid::Uuid::from_u128(n)) + } + + fn server(n: u128) -> LocalModelServerId { + LocalModelServerId::from_uuid(uuid::Uuid::from_u128(n)) + } + + #[test] + fn model_server_status_changed_relays_ready_to_dto_and_wire() { + let dto = DomainEventDto::from(&DomainEvent::ModelServerStatusChanged { + server_id: server(35), + status: ModelServerLifecycleStatus::Ready { reused: true }, + }); + + let json = serde_json::to_value(&dto).expect("serialisable"); + assert_eq!(json["type"], "modelServerStatusChanged"); + assert_eq!(json["serverId"], server(35).to_string()); + assert_eq!(json["status"]["state"], "ready"); + assert_eq!(json["status"]["reused"], true); + } + + #[test] + fn model_server_status_changed_relays_downloading_to_dto_and_wire() { + let dto = DomainEventDto::from(&DomainEvent::ModelServerStatusChanged { + server_id: server(37), + status: ModelServerLifecycleStatus::Downloading { + downloaded_bytes: None, + total_bytes: None, + percent: None, + source: Some("Qwen/Qwen3-Coder-30B-A3B-Instruct-GGUF".to_owned()), + }, + }); + + let json = serde_json::to_value(&dto).expect("serialisable"); + assert_eq!(json["type"], "modelServerStatusChanged"); + assert_eq!(json["serverId"], server(37).to_string()); + assert_eq!(json["status"]["state"], "downloading"); + assert_eq!(json["status"]["downloadedBytes"], serde_json::Value::Null); + assert_eq!(json["status"]["totalBytes"], serde_json::Value::Null); + assert_eq!(json["status"]["percent"], serde_json::Value::Null); + assert_eq!( + json["status"]["source"], + "Qwen/Qwen3-Coder-30B-A3B-Instruct-GGUF" + ); + } + + #[test] + fn agent_launch_failed_relays_model_server_cause() { + let dto = DomainEventDto::from(&DomainEvent::AgentLaunchFailed { + agent_id: agent(36), + cause: "model_server".to_owned(), + code: "path_not_accessible".to_owned(), + message: "path not accessible: /models/missing.gguf".to_owned(), + }); + + let json = serde_json::to_value(&dto).expect("serialisable"); + assert_eq!(json["type"], "agentLaunchFailed"); + assert_eq!(json["agentId"], agent(36).to_string()); + assert_eq!(json["cause"], "model_server"); + assert_eq!(json["code"], "path_not_accessible"); + } + + /// Lot 2 : un `AgentLivenessChanged{Stalled}` du domaine se relaie en DTO + /// `Stalled` portant le même agent, et se sérialise en `"stalled"` (le mot que + /// le front badge). Garantit le câblage présentation de la détection de stall. + #[test] + fn liveness_changed_stalled_relays_to_dto_and_wire() { + let dto = DomainEventDto::from(&DomainEvent::AgentLivenessChanged { + agent_id: agent(7), + liveness: AgentLiveness::Stalled, + }); + let json = serde_json::to_value(&dto).expect("serialisable"); + assert_eq!(json["type"], "agentLivenessChanged"); + assert_eq!(json["agentId"], agent(7).to_string()); + assert_eq!(json["liveness"], "stalled"); + } + + /// La reprise `Stalled→Alive` se relaie en DTO `Alive` ⇒ wire `"alive"`. + #[test] + fn liveness_changed_alive_relays_to_dto_and_wire() { + let dto = DomainEventDto::from(&DomainEvent::AgentLivenessChanged { + agent_id: agent(3), + liveness: AgentLiveness::Alive, + }); + let json = serde_json::to_value(&dto).expect("serialisable"); + assert_eq!(json["type"], "agentLivenessChanged"); + assert_eq!(json["liveness"], "alive"); + } + + #[test] + fn agent_announcement_relays_to_camel_case_wire_with_requester_party() { + let project_id = ProjectId::from_uuid(uuid::Uuid::from_u128(1)); + let requester_agent = agent(2); + let target = agent(3); + let ticket = TicketId::from_uuid(uuid::Uuid::from_u128(4)); + + let human = DomainEventDto::from(&DomainEvent::AgentAnnouncement { + project_id, + requester: ConversationParty::User, + target, + ticket, + text: "statut humain".into(), + at_ms: 123_456, + }); + assert_eq!( + serde_json::to_value(&human).unwrap(), + json!({ + "type": "agentAnnouncement", + "projectId": project_id.to_string(), + "requester": "user", + "target": target.to_string(), + "ticketId": ticket.to_string(), + "text": "statut humain", + "atMs": 123456, + }) + ); + + let agent_requester = DomainEventDto::from(&DomainEvent::AgentAnnouncement { + project_id, + requester: ConversationParty::agent(requester_agent), + target, + ticket, + text: "statut agent".into(), + at_ms: 654_321, + }); + assert_eq!( + serde_json::to_value(&agent_requester).unwrap(), + json!({ + "type": "agentAnnouncement", + "projectId": project_id.to_string(), + "requester": requester_agent.to_string(), + "target": target.to_string(), + "ticketId": ticket.to_string(), + "text": "statut agent", + "atMs": 654321, + }) + ); + } + + /// LS6 : un `AgentRateLimited` du domaine se relaie en DTO portant le même agent + /// et l'heure de reset (époche-ms), et se sérialise en `"agentRateLimited"` avec + /// `resetsAtMs` — le fait neutre que le front badge « limité jusqu'à HH:MM ». + #[test] + fn rate_limited_relays_to_dto_and_wire() { + let dto = DomainEventDto::from(&DomainEvent::AgentRateLimited { + agent_id: agent(11), + resets_at_ms: Some(1_700_000_000_000), + }); + let json = serde_json::to_value(&dto).expect("serialisable"); + assert_eq!(json["type"], "agentRateLimited"); + assert_eq!(json["agentId"], agent(11).to_string()); + assert_eq!(json["resetsAtMs"], 1_700_000_000_000_i64); + } + + #[test] + fn issue_sprint_changed_relays_to_dto_and_wire() { + let from = domain::SprintId::from_uuid(uuid::Uuid::from_u128(41)); + let to = domain::SprintId::from_uuid(uuid::Uuid::from_u128(42)); + let dto = DomainEventDto::from(&DomainEvent::IssueSprintChanged { + issue_ref: domain::IssueRef::from(domain::IssueNumber::new(7).unwrap()), + from: Some(from), + to: Some(to), + version: domain::IssueVersion::new(3).unwrap(), + }); + let json = serde_json::to_value(&dto).expect("serialisable"); + assert_eq!(json["type"], "issueSprintChanged"); + assert_eq!(json["issueRef"], "#7"); + assert_eq!(json["from"], from.to_string()); + assert_eq!(json["to"], to.to_string()); + assert_eq!(json["version"], 3); + } + + #[test] + fn issue_deleted_relays_to_dto_and_wire() { + let project_id = domain::ProjectId::from_uuid(uuid::Uuid::from_u128(40)); + let sprint_id = domain::SprintId::from_uuid(uuid::Uuid::from_u128(42)); + let dto = DomainEventDto::from(&DomainEvent::IssueDeleted { + project_id, + issue_ref: domain::IssueRef::from(domain::IssueNumber::new(7).unwrap()), + freed_sprint: Some(sprint_id), + }); + let json = serde_json::to_value(&dto).expect("serialisable"); + assert_eq!(json["type"], "issueDeleted"); + assert_eq!(json["projectId"], project_id.to_string()); + assert_eq!(json["issueRef"], "#7"); + assert_eq!(json["freedSprint"], sprint_id.to_string()); + } + + #[test] + fn ticket_assistant_events_relay_to_dto_and_wire() { + let profile_id = domain::ProfileId::from_uuid(uuid::Uuid::from_u128(9)); + let opened = DomainEventDto::from(&DomainEvent::TicketAssistantOpened { + issue_ref: domain::IssueRef::from(domain::IssueNumber::new(7).unwrap()), + profile_id, + }); + let opened = serde_json::to_value(&opened).expect("serialisable"); + assert_eq!(opened["type"], "ticketAssistantOpened"); + assert_eq!(opened["issueRef"], "#7"); + assert_eq!(opened["profileId"], profile_id.to_string()); + + let closed = DomainEventDto::from(&DomainEvent::TicketAssistantClosed { + issue_ref: domain::IssueRef::from(domain::IssueNumber::new(7).unwrap()), + }); + let closed = serde_json::to_value(&closed).expect("serialisable"); + assert_eq!(closed["type"], "ticketAssistantClosed"); + assert_eq!(closed["issueRef"], "#7"); + } +} diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index b8b699d..e06c9a0 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -83,6 +83,8 @@ use infrastructure::{ VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, }; +pub mod dto; +pub mod events; pub mod mcp_endpoint; pub mod openai_tools; pub mod stream; diff --git a/crates/backend/src/mcp_endpoint.rs b/crates/backend/src/mcp_endpoint.rs index 06422ee..1e14984 100644 --- a/crates/backend/src/mcp_endpoint.rs +++ b/crates/backend/src/mcp_endpoint.rs @@ -159,7 +159,7 @@ pub fn mcp_endpoint(project_id: &ProjectId) -> McpEndpoint { /// `/tmp/.mount_*` qui disparaît au redémarrage ⇒ `ENOENT` au respawn du pont MCP. /// Hors AppImage `$APPIMAGE` est absent ⇒ on retombe sur `current_exe()`. `None` si /// aucun des deux n'est résolvable (ne devrait pas arriver) ⇒ déclaration minimale. -pub(crate) fn idea_exe_path() -> Option { +pub fn idea_exe_path() -> Option { std::env::var("APPIMAGE").ok().or_else(|| { std::env::current_exe() .ok() diff --git a/crates/web-server/Cargo.toml b/crates/web-server/Cargo.toml new file mode 100644 index 0000000..dc072d7 --- /dev/null +++ b/crates/web-server/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "web-server" +version = "0.3.0" +edition.workspace = true +license.workspace = true +rust-version.workspace = true +description = "IdeA headless HTTP/WebSocket driving adapter, reusable by desktop and server binaries." + +[lib] +name = "web_server" +path = "src/lib.rs" + +[[bin]] +name = "idea-serve" +path = "src/bin/idea-serve.rs" + +[dependencies] +backend = { workspace = true } +application = { workspace = true } +domain = { workspace = true } +tokio = { workspace = true, features = ["io-std", "rt", "net"] } +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +base64 = "0.22" +bytes = "1.11" +cookie = "0.18" +http = "1.4" +http-body-util = "0.1" + +[features] +vector-http = ["backend/vector-http"] +vector-onnx = ["backend/vector-onnx"] diff --git a/crates/web-server/src/bin/idea-serve.rs b/crates/web-server/src/bin/idea-serve.rs new file mode 100644 index 0000000..0adf2fc --- /dev/null +++ b/crates/web-server/src/bin/idea-serve.rs @@ -0,0 +1,5 @@ +use std::process::ExitCode; + +fn main() -> ExitCode { + web_server::run_from_args(std::env::args().skip(1).collect()) +} diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs new file mode 100644 index 0000000..cc6eca7 --- /dev/null +++ b/crates/web-server/src/lib.rs @@ -0,0 +1,4140 @@ +//! Secure HTTP driving adapter for `idea --serve`. +//! +//! The shared backend core stays unaware of HTTP, cookies, origins and +//! WebSocket framing; this module owns the secure web driving adapter. + +use std::collections::HashSet; +use std::env; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use backend::stream::{OutputBridge, OutputSink, OutputSinkError}; +use bytes::Bytes; +use cookie::{Cookie, SameSite}; +use domain::events::DomainEvent; +use domain::ports::BackgroundTaskPortError; +use http::header::{HeaderValue, CONTENT_TYPE, COOKIE, ORIGIN, SET_COOKIE}; +#[cfg(test)] +use http::Request; +use http::{HeaderMap, Method, Response, StatusCode, Uri}; +use http_body_util::{BodyExt, Full}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tokio::sync::{mpsc, oneshot}; +use tokio::task::JoinHandle; +use uuid::Uuid; + +use application::{ + CloseTerminalInput, GetProjectWorkStateInput, LaunchAgentInput, McpRuntime, OpenProjectInput, + ResizeTerminalInput, RotateConversationLogInput, WriteToTerminalInput, +}; +use domain::ports::PtyHandle; +use domain::{Project, SessionId}; + +use backend::dto::{ + parse_agent_id, parse_node_id, parse_project_id, parse_session_id, parse_task_id, + BackgroundTaskDto, ErrorDto, HealthRequestDto, HealthResponseDto, LaunchAgentRequestDto, + OpenTerminalRequestDto, ProjectDto, ProjectListDto, ProjectWorkStateDto, TerminalSessionDto, +}; +use backend::events::DomainEventDto; +type PtyChunk = Vec; +use backend::BackendCore; + +const DEFAULT_LISTEN: &str = "127.0.0.1:17373"; +const SESSION_COOKIE: &str = "idea_session"; +const WS_PATH: &str = "/api/ws"; +const WS_MAGIC: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; +const WS_MAX_PAYLOAD: usize = 64 * 1024; +const WS_OUTPUT_BUFFER: usize = 512; + +type ResponseBody = Full; + +/// Runs the `idea --serve` subcommand from already-split CLI arguments. +pub fn run_from_args(args: Vec) -> ExitCode { + let config = match ServerConfig::from_args(args).and_then(|config| { + config.validate()?; + Ok(config) + }) { + Ok(config) => config, + Err(err) => { + eprintln!("idea --serve: {err}"); + return ExitCode::from(2); + } + }; + + let state = Arc::new(ServerState::new(config.clone())); + eprintln!("IdeA pairing code: {}", state.pairing_code()); + eprintln!( + "idea --serve: app data dir = {}", + config.app_data_dir.display() + ); + eprintln!("IdeA server listening on {}", config.listen); + + match tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + { + Ok(runtime) => match runtime.block_on(run_server(config, state)) { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("idea --serve: {err}"); + ExitCode::from(1) + } + }, + Err(err) => { + eprintln!("idea --serve: failed to build runtime: {err}"); + ExitCode::from(1) + } + } +} + +/// Configuration shared by the standalone and embedded web-server entry points. +#[derive(Clone, Debug)] +pub struct ServerConfig { + /// Address to bind. + pub listen: SocketAddr, + /// Exact public origin allowed by CORS/origin checks. + pub public_origin: Option, + /// Whether non-loopback binds are allowed. + pub allow_remote: bool, + /// Whether a trusted HTTPS reverse proxy terminates remote access. + pub trust_reverse_proxy: bool, + /// IdeA application data directory. + pub app_data_dir: PathBuf, + /// Built frontend assets root. + pub web_root: PathBuf, +} + +impl ServerConfig { + /// Parses the web-server CLI flags. + pub fn from_args(args: Vec) -> Result { + let mut listen = DEFAULT_LISTEN + .parse::() + .expect("default listen address is valid"); + let mut public_origin = None; + let mut allow_remote = false; + let mut trust_reverse_proxy = false; + let mut app_data_dir = default_app_data_dir(); + let mut web_root = None; + + let mut it = args.into_iter(); + while let Some(arg) = it.next() { + match arg.as_str() { + "--listen" => { + let value = it + .next() + .ok_or_else(|| "--listen requires an address".to_owned())?; + listen = value + .parse() + .map_err(|_| format!("invalid --listen address: {value}"))?; + } + "--public-origin" => { + let value = it + .next() + .ok_or_else(|| "--public-origin requires an origin".to_owned())?; + public_origin = Some(value); + } + "--allow-remote" => allow_remote = true, + "--trust-reverse-proxy" => trust_reverse_proxy = true, + "--app-data-dir" => { + let value = it + .next() + .ok_or_else(|| "--app-data-dir requires a path".to_owned())?; + app_data_dir = PathBuf::from(value); + } + "--web-root" => { + let value = it + .next() + .ok_or_else(|| "--web-root requires a path".to_owned())?; + web_root = Some(PathBuf::from(value)); + } + "--help" | "-h" => return Err(Self::usage()), + other => return Err(format!("unknown --serve argument: {other}")), + } + } + let web_root = resolve_web_root(web_root)?; + + Ok(Self { + listen, + public_origin, + allow_remote, + trust_reverse_proxy, + app_data_dir, + web_root, + }) + } + + /// Validates security-sensitive configuration. + pub fn validate(&self) -> Result<(), String> { + if let Some(origin) = &self.public_origin { + validate_origin(origin)?; + } + + if !self.listen.ip().is_loopback() && !self.allow_remote { + return Err( + "refusing non-loopback bind without --allow-remote and HTTPS proxy config" + .to_owned(), + ); + } + + if self.allow_remote { + let Some(origin) = &self.public_origin else { + return Err("--allow-remote requires --public-origin https://...".to_owned()); + }; + if !origin.starts_with("https://") { + return Err("--allow-remote requires an HTTPS public origin".to_owned()); + } + if !self.trust_reverse_proxy { + return Err("--allow-remote requires --trust-reverse-proxy".to_owned()); + } + } + + Ok(()) + } + + fn secure_cookie(&self) -> bool { + self.allow_remote + || self + .public_origin + .as_deref() + .is_some_and(|o| o.starts_with("https://")) + } + + /// Human-readable CLI usage. + #[must_use] + pub fn usage() -> String { + "usage: idea-serve [--listen IP:PORT] [--app-data-dir PATH] [--web-root PATH] [--allow-remote --public-origin https://host --trust-reverse-proxy]".to_owned() + } +} + +/// Current lifecycle state of an embedded web server. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EmbeddedServerState { + /// The accept loop has been spawned. + Running, + /// A shutdown signal was sent. + Stopping, +} + +/// Handle returned by [`run_embedded`]. +pub struct EmbeddedServerHandle { + url: String, + pairing_code: String, + shutdown: Option>, + task: JoinHandle>, + state: EmbeddedServerState, +} + +impl EmbeddedServerHandle { + /// Effective base URL after binding. Includes the OS-assigned port when + /// `listen` used port `0`. + #[must_use] + pub fn url(&self) -> &str { + &self.url + } + + /// Pairing code accepted by `POST /api/pair`. + #[must_use] + pub fn pairing_code(&self) -> &str { + &self.pairing_code + } + + /// Current lifecycle state. + #[must_use] + pub fn state(&self) -> EmbeddedServerState { + self.state + } + + /// Requests shutdown and waits for the accept loop to finish. + pub async fn stop(mut self) -> Result<(), String> { + self.state = EmbeddedServerState::Stopping; + if let Some(shutdown) = self.shutdown.take() { + let _ = shutdown.send(()); + } + self.task + .await + .map_err(|err| format!("embedded web server task failed: {err}"))? + } +} + +/// Starts the web server as an embedded library component. +/// +/// This is the integration point for the desktop-hosted server planned after +/// the headless extraction. The returned handle owns shutdown. +pub async fn run_embedded(config: ServerConfig) -> Result { + config.validate()?; + let listener = TcpListener::bind(config.listen) + .await + .map_err(|err| format!("failed to bind {}: {err}", config.listen))?; + let local_addr = listener + .local_addr() + .map_err(|err| format!("failed to read listener address: {err}"))?; + let state = Arc::new(ServerState::new(config.clone())); + let pairing_code = state.pairing_code().to_owned(); + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let task = tokio::spawn(run_listener(listener, state, shutdown_rx)); + Ok(EmbeddedServerHandle { + url: format!("http://{local_addr}"), + pairing_code, + shutdown: Some(shutdown_tx), + task, + state: EmbeddedServerState::Running, + }) +} + +fn resolve_web_root(explicit: Option) -> Result { + if let Some(path) = explicit { + return validate_web_root(path, "--web-root"); + } + if let Some(path) = env::var_os("IDEA_WEB_ROOT").map(PathBuf::from) { + return validate_web_root(path, "IDEA_WEB_ROOT"); + } + + let mut candidates = Vec::new(); + if let Ok(exe) = env::current_exe() { + if let Some(dir) = exe.parent() { + candidates.push(dir.join("web")); + candidates.push(dir.join("frontend").join("dist")); + } + } + if let Ok(cwd) = env::current_dir() { + candidates.push(cwd.join("frontend").join("dist")); + } + + for candidate in candidates { + if candidate.join("index.html").is_file() { + return Ok(candidate); + } + } + + Err("web assets not found: build frontend/dist or pass --web-root PATH".to_owned()) +} + +fn validate_web_root(path: PathBuf, source: &str) -> Result { + if path.join("index.html").is_file() { + Ok(path) + } else { + Err(format!( + "{source} does not contain an index.html: {}", + path.display() + )) + } +} + +struct ServerState { + config: ServerConfig, + app: BackendCore, + pairing_code: String, + sessions: Mutex>, + ws_pty_bridge: Arc>, + security_logger: Arc, +} + +impl ServerState { + fn new(config: ServerConfig) -> Self { + Self { + app: BackendCore::build(config.app_data_dir.clone()), + config, + pairing_code: new_pairing_code(), + sessions: Mutex::new(HashSet::new()), + ws_pty_bridge: Arc::new(OutputBridge::new()), + security_logger: Arc::new(StderrSecurityLogger), + } + } + + #[cfg(test)] + fn new_for_test(config: ServerConfig, pairing_code: impl Into) -> Self { + Self::new_for_test_with_logger(config, pairing_code, Arc::new(NoopSecurityLogger)) + } + + #[cfg(test)] + fn new_for_test_with_logger( + config: ServerConfig, + pairing_code: impl Into, + security_logger: Arc, + ) -> Self { + Self { + app: BackendCore::build(config.app_data_dir.clone()), + config, + pairing_code: pairing_code.into(), + sessions: Mutex::new(HashSet::new()), + ws_pty_bridge: Arc::new(OutputBridge::new()), + security_logger, + } + } + + fn pairing_code(&self) -> &str { + &self.pairing_code + } + + fn create_session(&self) -> String { + let token = new_session_token(); + if let Ok(mut sessions) = self.sessions.lock() { + sessions.insert(token.clone()); + } + token + } + + fn has_session(&self, token: &str) -> bool { + self.sessions + .lock() + .map(|sessions| sessions.contains(token)) + .unwrap_or(false) + } + + fn revoke_session(&self, token: &str) -> bool { + self.sessions + .lock() + .map(|mut sessions| sessions.remove(token)) + .unwrap_or(false) + } + + fn log_security(&self, event: SecurityLogEvent) { + self.security_logger.log(event); + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum SecurityLogEvent { + PairingSucceeded { + origin: Option, + }, + PairingFailed { + origin: Option, + reason: &'static str, + }, + OriginRejected { + origin: Option, + route: String, + }, + WsUpgradeRejected { + origin: Option, + reason: &'static str, + }, + SessionRevoked { + origin: Option, + }, +} + +trait SecurityLogger: Send + Sync { + fn log(&self, event: SecurityLogEvent); +} + +struct StderrSecurityLogger; + +impl SecurityLogger for StderrSecurityLogger { + fn log(&self, event: SecurityLogEvent) { + eprintln!("idea --serve security: {}", security_log_line(&event)); + } +} + +#[cfg(test)] +struct NoopSecurityLogger; + +#[cfg(test)] +impl SecurityLogger for NoopSecurityLogger { + fn log(&self, _event: SecurityLogEvent) {} +} + +fn security_log_line(event: &SecurityLogEvent) -> String { + match event { + SecurityLogEvent::PairingSucceeded { origin } => { + format!("pairing succeeded origin={}", origin_label(origin)) + } + SecurityLogEvent::PairingFailed { origin, reason } => { + format!( + "pairing failed reason={reason} origin={}", + origin_label(origin) + ) + } + SecurityLogEvent::OriginRejected { origin, route } => { + format!( + "origin rejected route={} origin={}", + route, + origin_label(origin) + ) + } + SecurityLogEvent::WsUpgradeRejected { origin, reason } => { + format!( + "websocket upgrade rejected reason={reason} origin={}", + origin_label(origin) + ) + } + SecurityLogEvent::SessionRevoked { origin } => { + format!("session revoked origin={}", origin_label(origin)) + } + } +} + +fn origin_label(origin: &Option) -> &str { + origin.as_deref().unwrap_or("") +} + +async fn run_server(config: ServerConfig, state: Arc) -> Result<(), String> { + let listener = TcpListener::bind(config.listen) + .await + .map_err(|err| format!("failed to bind {}: {err}", config.listen))?; + let (_shutdown_tx, shutdown_rx) = oneshot::channel(); + run_listener(listener, state, shutdown_rx).await +} + +async fn run_listener( + listener: TcpListener, + state: Arc, + mut shutdown: oneshot::Receiver<()>, +) -> Result<(), String> { + loop { + let accepted = tokio::select! { + result = listener.accept() => result, + _ = &mut shutdown => return Ok(()), + }; + let (stream, _) = accepted.map_err(|err| format!("failed to accept connection: {err}"))?; + let state = Arc::clone(&state); + tokio::spawn(async move { + if let Err(err) = handle_tcp_connection(stream, state).await { + eprintln!("idea --serve: connection error: {err}"); + } + }); + } +} + +async fn handle_tcp_connection( + mut stream: tokio::net::TcpStream, + state: Arc, +) -> Result<(), String> { + let mut buffer = Vec::with_capacity(8192); + let mut chunk = [0_u8; 2048]; + let header_end = loop { + let read = stream + .read(&mut chunk) + .await + .map_err(|err| format!("failed to read request: {err}"))?; + if read == 0 { + return Ok(()); + } + buffer.extend_from_slice(&chunk[..read]); + if buffer.len() > 1024 * 1024 { + return Err("request too large".to_owned()); + } + if let Some(pos) = find_header_end(&buffer) { + break pos; + } + }; + + let (method, uri, headers, content_length) = parse_http_request_head(&buffer[..header_end])?; + if method == Method::GET && uri.path() == WS_PATH { + return handle_ws_upgrade(stream, headers, state).await; + } + + let body_start = header_end + 4; + while buffer.len() < body_start + content_length { + let read = stream + .read(&mut chunk) + .await + .map_err(|err| format!("failed to read request body: {err}"))?; + if read == 0 { + break; + } + buffer.extend_from_slice(&chunk[..read]); + if buffer.len() > 1024 * 1024 { + return Err("request too large".to_owned()); + } + } + if buffer.len() < body_start + content_length { + return Err("truncated request body".to_owned()); + } + + let response = dispatch_http( + method, + uri, + headers, + Bytes::copy_from_slice(&buffer[body_start..body_start + content_length]), + state, + ) + .await; + write_http_response(&mut stream, response).await +} + +#[cfg(test)] +async fn handle_request( + req: Request, + state: Arc, +) -> Response { + let (parts, body) = req.into_parts(); + let body = match body.collect().await { + Ok(body) => body.to_bytes(), + Err(err) => { + return error_response( + StatusCode::BAD_REQUEST, + "INVALID", + format!("invalid request body: {err}"), + None, + ); + } + }; + + dispatch_http( + parts.method, + parts.uri, + parts.headers, + body, + Arc::clone(&state), + ) + .await +} + +fn find_header_end(buffer: &[u8]) -> Option { + buffer.windows(4).position(|window| window == b"\r\n\r\n") +} + +fn parse_http_request_head(head: &[u8]) -> Result<(Method, Uri, HeaderMap, usize), String> { + let text = std::str::from_utf8(head).map_err(|_| "request head is not UTF-8".to_owned())?; + let mut lines = text.split("\r\n"); + let request_line = lines + .next() + .ok_or_else(|| "missing request line".to_owned())?; + let mut request_parts = request_line.split_whitespace(); + let method = request_parts + .next() + .ok_or_else(|| "missing method".to_owned())? + .parse::() + .map_err(|_| "invalid method".to_owned())?; + let uri = request_parts + .next() + .ok_or_else(|| "missing uri".to_owned())? + .parse::() + .map_err(|_| "invalid uri".to_owned())?; + let mut headers = HeaderMap::new(); + let mut content_length = 0; + + for line in lines { + if line.is_empty() { + continue; + } + let Some((name, value)) = line.split_once(':') else { + return Err("invalid header line".to_owned()); + }; + let name = http::header::HeaderName::from_bytes(name.trim().as_bytes()) + .map_err(|_| "invalid header name".to_owned())?; + let value = + HeaderValue::from_str(value.trim()).map_err(|_| "invalid header value".to_owned())?; + if name == http::header::CONTENT_LENGTH { + content_length = value + .to_str() + .ok() + .and_then(|raw| raw.parse::().ok()) + .ok_or_else(|| "invalid content-length".to_owned())?; + } + headers.insert(name, value); + } + + Ok((method, uri, headers, content_length)) +} + +async fn write_http_response( + stream: &mut tokio::net::TcpStream, + response: Response, +) -> Result<(), String> { + let status = response.status(); + let headers = response.headers().clone(); + let body = response + .into_body() + .collect() + .await + .map_err(|err| format!("failed to collect response: {err}"))? + .to_bytes(); + let reason = status.canonical_reason().unwrap_or("Unknown"); + let mut bytes = format!("HTTP/1.1 {} {}\r\n", status.as_u16(), reason).into_bytes(); + for (name, value) in &headers { + bytes.extend_from_slice(name.as_str().as_bytes()); + bytes.extend_from_slice(b": "); + bytes.extend_from_slice(value.as_bytes()); + bytes.extend_from_slice(b"\r\n"); + } + bytes.extend_from_slice(format!("content-length: {}\r\n", body.len()).as_bytes()); + bytes.extend_from_slice(b"connection: close\r\n\r\n"); + bytes.extend_from_slice(&body); + stream + .write_all(&bytes) + .await + .map_err(|err| format!("failed to write response: {err}")) +} + +async fn handle_ws_upgrade( + mut stream: tokio::net::TcpStream, + headers: HeaderMap, + state: Arc, +) -> Result<(), String> { + let accept = match validate_ws_upgrade(&headers, &state) { + Ok(accept) => accept, + Err(response) => return write_http_response(&mut stream, *response).await, + }; + + let response = format!( + "HTTP/1.1 101 Switching Protocols\r\n\ + upgrade: websocket\r\n\ + connection: Upgrade\r\n\ + sec-websocket-accept: {accept}\r\n\r\n" + ); + stream + .write_all(response.as_bytes()) + .await + .map_err(|err| format!("failed to write websocket upgrade: {err}"))?; + run_ws_connection(stream, state).await +} + +fn validate_ws_upgrade( + headers: &HeaderMap, + state: &ServerState, +) -> Result>> { + let origin = match validate_request_origin(headers, &state.config) { + Ok(origin) => origin, + Err(response) => { + state.log_security(SecurityLogEvent::OriginRejected { + origin: request_origin(headers), + route: WS_PATH.to_owned(), + }); + return Err(response); + } + }; + let Some(token) = session_cookie(headers) else { + state.log_security(SecurityLogEvent::WsUpgradeRejected { + origin: origin.clone(), + reason: "missing_session", + }); + return Err(Box::new(error_response( + StatusCode::UNAUTHORIZED, + "UNAUTHORIZED", + "missing session cookie", + origin.as_deref(), + ))); + }; + if !state.has_session(&token) { + state.log_security(SecurityLogEvent::WsUpgradeRejected { + origin: origin.clone(), + reason: "invalid_session", + }); + return Err(Box::new(error_response( + StatusCode::UNAUTHORIZED, + "UNAUTHORIZED", + "invalid session cookie", + origin.as_deref(), + ))); + } + if !header_contains_token(headers, "upgrade", "websocket") + || !header_contains_token(headers, "connection", "upgrade") + { + state.log_security(SecurityLogEvent::WsUpgradeRejected { + origin: origin.clone(), + reason: "invalid_upgrade", + }); + return Err(Box::new(error_response( + StatusCode::BAD_REQUEST, + "INVALID", + "missing websocket upgrade headers", + origin.as_deref(), + ))); + } + let Some(key) = headers + .get("sec-websocket-key") + .and_then(|value| value.to_str().ok()) + else { + state.log_security(SecurityLogEvent::WsUpgradeRejected { + origin: origin.clone(), + reason: "missing_key", + }); + return Err(Box::new(error_response( + StatusCode::BAD_REQUEST, + "INVALID", + "missing Sec-WebSocket-Key", + origin.as_deref(), + ))); + }; + Ok(websocket_accept(key)) +} + +fn header_contains_token(headers: &HeaderMap, name: &str, needle: &str) -> bool { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| { + value + .split(',') + .any(|token| token.trim().eq_ignore_ascii_case(needle)) + }) +} + +async fn run_ws_connection( + stream: tokio::net::TcpStream, + state: Arc, +) -> Result<(), String> { + let (mut reader, mut writer) = stream.into_split(); + let (tx, mut rx) = mpsc::channel::(WS_OUTPUT_BUFFER); + let owned = Arc::new(Mutex::new(Vec::<(SessionId, u64)>::new())); + let event_relay_task = + spawn_ws_domain_event_relay(state.app.event_bus.raw_receiver(), tx.clone()); + let writer_task = tokio::spawn(async move { + while let Some(frame) = rx.recv().await { + let text = serde_json::to_vec(&frame) + .map_err(|err| format!("failed to encode server frame: {err}"))?; + let bytes = encode_ws_frame(WsOpcode::Text, &text); + writer + .write_all(&bytes) + .await + .map_err(|err| format!("failed to write websocket frame: {err}"))?; + } + Ok::<(), String>(()) + }); + + loop { + let frame = match read_ws_frame(&mut reader).await { + Ok(frame) => frame, + Err(err) => { + let _ = tx + .send(ServerFrame::error( + None, + None, + "WS_PROTOCOL", + err.to_string(), + )) + .await; + break; + } + }; + match frame.opcode { + WsOpcode::Text => { + let parsed = serde_json::from_slice::(&frame.payload) + .map_err(|err| format!("invalid client frame JSON: {err}")); + match parsed { + Ok(frame) => { + handle_client_frame(frame, &state, &tx, &owned).await; + } + Err(err) => { + let _ = tx + .send(ServerFrame::error(None, None, "INVALID", err)) + .await; + } + } + } + WsOpcode::Ping => { + let _ = tx.send(ServerFrame::pong()).await; + } + WsOpcode::Close => break, + WsOpcode::Pong => {} + WsOpcode::Binary => { + let _ = tx + .send(ServerFrame::error( + None, + None, + "UNSUPPORTED", + "binary websocket frames are not supported", + )) + .await; + } + } + } + + if let Ok(owned) = owned.lock() { + for (session, gen) in owned.iter() { + state.ws_pty_bridge.unregister_if(session, *gen); + } + } + event_relay_task.abort(); + drop(tx); + writer_task + .await + .map_err(|err| format!("websocket writer task failed: {err}"))? +} + +fn spawn_ws_domain_event_relay( + mut rx: tokio::sync::broadcast::Receiver, + tx: mpsc::Sender, +) -> tokio::task::JoinHandle<()> { + use tokio::sync::broadcast::error::RecvError; + + tokio::spawn(async move { + loop { + match rx.recv().await { + Ok(event) => { + if matches!(event, DomainEvent::PtyOutput { .. }) { + continue; + } + match tx.try_send(ServerFrame::domain_event(&event)) { + Ok(()) | Err(mpsc::error::TrySendError::Full(_)) => {} + Err(mpsc::error::TrySendError::Closed(_)) => break, + } + } + Err(RecvError::Lagged(_)) => continue, + Err(RecvError::Closed) => break, + } + } + }) +} + +async fn handle_client_frame( + frame: ClientFrame, + state: &Arc, + tx: &mpsc::Sender, + owned: &Arc>>, +) { + let result = match frame.kind.as_str() { + "terminal.open" | "open_terminal" => ws_open_terminal(&frame, state, tx, owned).await, + "agent.launch" | "launch_agent" => ws_launch_agent(&frame, state, tx, owned).await, + "terminal.attach" | "attach_terminal" => ws_attach_terminal(&frame, state, tx, owned).await, + "terminal.input" | "input" => ws_input(&frame, state), + "terminal.resize" | "resize" => ws_resize(&frame, state), + "terminal.detach" | "detach" => ws_detach(&frame, state, owned), + "terminal.close" | "close" => match ws_close(&frame, state, owned).await { + Ok((session_id, exit_code)) => { + let _ = tx + .send(ServerFrame::status(session_id, "exited", exit_code)) + .await; + Ok(()) + } + Err(err) => Err(err), + }, + "ping" => { + let _ = tx.send(ServerFrame::pong()).await; + Ok(()) + } + _ => Err(ErrorDto { + code: "UNKNOWN_FRAME".to_owned(), + message: format!("unknown websocket frame kind: {}", frame.kind), + }), + }; + + if let Err(err) = result { + let _ = tx + .send(ServerFrame::error( + Some(frame.id), + payload_session_id(&frame.payload), + err.code, + err.message, + )) + .await; + } +} + +async fn ws_open_terminal( + frame: &ClientFrame, + state: &Arc, + tx: &mpsc::Sender, + owned: &Arc>>, +) -> Result<(), ErrorDto> { + let request_value = frame + .payload + .get("request") + .cloned() + .unwrap_or_else(|| frame.payload.clone()); + let request: OpenTerminalRequestDto = + serde_json::from_value(request_value).map_err(invalid_args_error)?; + let output = state + .app + .open_terminal + .execute(request.into()) + .await + .map_err(ErrorDto::from)?; + let dto = TerminalSessionDto::from(output); + let sid = parse_session_id(&dto.session_id)?; + let sink = WsPtySink::new(sid, tx.clone(), 0); + tx.send(ServerFrame::attached(&frame.id, &dto, Vec::new(), 0, false)) + .await + .map_err(|_| ErrorDto { + code: "WS_CLOSED".to_owned(), + message: "websocket output closed".to_owned(), + })?; + attach_sink_and_pump(state, sid, sink, owned) +} + +async fn ws_launch_agent( + frame: &ClientFrame, + state: &Arc, + tx: &mpsc::Sender, + owned: &Arc>>, +) -> Result<(), ErrorDto> { + let request_value = frame + .payload + .get("request") + .cloned() + .unwrap_or_else(|| frame.payload.clone()); + let request: LaunchAgentRequestDto = + serde_json::from_value(request_value).map_err(invalid_args_error)?; + let output = execute_launch_agent_for_ws(state, request).await?; + send_launch_agent_attached(frame, state, tx, owned, output).await +} + +async fn send_launch_agent_attached( + frame: &ClientFrame, + state: &Arc, + tx: &mpsc::Sender, + owned: &Arc>>, + output: application::LaunchAgentOutput, +) -> Result<(), ErrorDto> { + if output.structured.is_some() { + return Err(ErrorDto { + code: "UNSUPPORTED".to_owned(), + message: "structured agent sessions do not stream over the PTY websocket".to_owned(), + }); + } + let session_id = output.session.id; + let dto = TerminalSessionDto::from(output); + let sid = parse_session_id(&dto.session_id)?; + let sink = WsPtySink::new(sid, tx.clone(), 0); + tx.send(ServerFrame::attached(&frame.id, &dto, Vec::new(), 0, false)) + .await + .map_err(|_| ErrorDto { + code: "WS_CLOSED".to_owned(), + message: "websocket output closed".to_owned(), + })?; + attach_sink_and_pump(state, session_id, sink, owned) +} + +async fn execute_launch_agent_for_ws( + state: &Arc, + request: LaunchAgentRequestDto, +) -> Result { + let project = resolve_project_readonly(&request.project_id, &state.app).await?; + let agent_id = parse_agent_id(&request.agent_id)?; + let node_id = request.node_id.as_deref().map(parse_node_id).transpose()?; + let mcp_runtime = backend::mcp_endpoint::idea_exe_path().map(|exe| McpRuntime { + exe, + endpoint: backend::mcp_endpoint::mcp_endpoint(&project.id) + .as_cli_arg() + .to_owned(), + project_id: project.id.as_uuid().simple().to_string(), + requester: agent_id.to_string(), + }); + + state.app.reconcile_claude_run_dirs(&project).await; + + let resume_project = project.clone(); + let rotation_root = project.root.clone(); + let watch_root = project.root.clone(); + let output = state + .app + .launch_agent + .execute(LaunchAgentInput { + project, + agent_id, + rows: request.rows, + cols: request.cols, + node_id, + conversation_id: request.conversation_id.clone(), + mcp_runtime, + allow_structured_alongside_pty: false, + }) + .await + .map_err(ErrorDto::from)?; + + if let Ok(mut contexts) = state.app.resume_contexts.lock() { + contexts.insert( + agent_id, + backend::ResumeContext { + project: resume_project, + rows: request.rows, + cols: request.cols, + }, + ); + } + + if let Some(profile) = output.profile.as_ref() { + state.app.arm_turn_watch( + &watch_root, + agent_id, + profile, + output.assigned_conversation_id.clone(), + ); + } + + if let Some(conversation) = request + .conversation_id + .as_deref() + .and_then(|raw| uuid::Uuid::parse_str(raw).ok()) + .map(domain::ConversationId::from_uuid) + { + let rotate = Arc::clone(&state.app.rotate_conversation_log); + tokio::spawn(async move { + let _ = rotate + .execute(RotateConversationLogInput { + project_root: rotation_root, + conversation, + }) + .await; + }); + } + + Ok(output) +} + +async fn ws_attach_terminal( + frame: &ClientFrame, + state: &Arc, + tx: &mpsc::Sender, + owned: &Arc>>, +) -> Result<(), ErrorDto> { + let session_id = payload_string(&frame.payload, "sessionId")?; + let sid = parse_session_id(&session_id)?; + let handle = PtyHandle { session_id: sid }; + let scrollback = state + .app + .pty_port + .scrollback(&handle) + .map_err(|err| ErrorDto::from(application::AppError::from(err)))?; + let rows = payload_u16(&frame.payload, "rows").unwrap_or(24); + let cols = payload_u16(&frame.payload, "cols").unwrap_or(80); + let dto = TerminalSessionDto { + session_id, + cwd: String::new(), + rows, + cols, + assigned_conversation_id: None, + engine_session_id: None, + cell_kind: backend::dto::CellKind::Pty, + }; + let next_seq = u64::from(!scrollback.is_empty()); + tx.send(ServerFrame::attached( + &frame.id, + &dto, + scrollback.clone(), + next_seq, + frame.payload.get("lastSeq").is_some(), + )) + .await + .map_err(|_| ErrorDto { + code: "WS_CLOSED".to_owned(), + message: "websocket output closed".to_owned(), + })?; + let sink = WsPtySink::new(sid, tx.clone(), next_seq); + attach_sink_and_pump(state, sid, sink, owned) +} + +fn attach_sink_and_pump( + state: &Arc, + sid: SessionId, + sink: WsPtySink, + owned: &Arc>>, +) -> Result<(), ErrorDto> { + let gen = state.ws_pty_bridge.register(sid, Arc::new(sink)); + if let Ok(mut owned) = owned.lock() { + owned.push((sid, gen)); + } + let handle = PtyHandle { session_id: sid }; + let stream = state + .app + .pty_port + .subscribe_output(&handle) + .map_err(|err| ErrorDto::from(application::AppError::from(err)))?; + let bridge = Arc::clone(&state.ws_pty_bridge); + std::thread::spawn(move || { + for chunk in stream { + if !bridge.send_output(&sid, chunk) { + break; + } + } + bridge.unregister_if(&sid, gen); + }); + Ok(()) +} + +fn ws_input(frame: &ClientFrame, state: &Arc) -> Result<(), ErrorDto> { + let sid = parse_session_id(&payload_string(&frame.payload, "sessionId")?)?; + let bytes = payload_bytes(&frame.payload)?; + state + .app + .write_terminal + .execute(WriteToTerminalInput { + session_id: sid, + data: bytes, + }) + .map_err(ErrorDto::from) +} + +fn ws_resize(frame: &ClientFrame, state: &Arc) -> Result<(), ErrorDto> { + let sid = parse_session_id(&payload_string(&frame.payload, "sessionId")?)?; + let rows = payload_u16(&frame.payload, "rows").ok_or_else(|| ErrorDto { + code: "INVALID".to_owned(), + message: "resize requires rows".to_owned(), + })?; + let cols = payload_u16(&frame.payload, "cols").ok_or_else(|| ErrorDto { + code: "INVALID".to_owned(), + message: "resize requires cols".to_owned(), + })?; + state + .app + .resize_terminal + .execute(ResizeTerminalInput { + session_id: sid, + rows, + cols, + }) + .map_err(ErrorDto::from) +} + +fn ws_detach( + frame: &ClientFrame, + state: &Arc, + owned: &Arc>>, +) -> Result<(), ErrorDto> { + let sid = parse_session_id(&payload_string(&frame.payload, "sessionId")?)?; + if let Ok(mut owned) = owned.lock() { + if let Some(index) = owned.iter().rposition(|(session, _)| *session == sid) { + let (_, gen) = owned.remove(index); + state.ws_pty_bridge.unregister_if(&sid, gen); + } + } + Ok(()) +} + +async fn ws_close( + frame: &ClientFrame, + state: &Arc, + owned: &Arc>>, +) -> Result<(SessionId, Option), ErrorDto> { + let sid = parse_session_id(&payload_string(&frame.payload, "sessionId")?)?; + ws_detach(frame, state, owned)?; + let output = state + .app + .close_terminal + .execute(CloseTerminalInput { session_id: sid }) + .await + .map_err(ErrorDto::from)?; + Ok((sid, output.code)) +} + +async fn dispatch_http( + method: Method, + uri: Uri, + headers: HeaderMap, + body: Bytes, + state: Arc, +) -> Response { + if has_forbidden_query_secret(&uri) { + return error_response( + StatusCode::BAD_REQUEST, + "INVALID", + "secrets in URL query strings are forbidden", + None, + ); + } + + if !is_api_path(uri.path()) { + return serve_static(&state.config.web_root, method, uri.path()).await; + } + + let origin = match validate_request_origin(&headers, &state.config) { + Ok(origin) => origin, + Err(response) => { + state.log_security(SecurityLogEvent::OriginRejected { + origin: request_origin(&headers), + route: uri.path().to_owned(), + }); + return *response; + } + }; + + if method == Method::OPTIONS { + return cors_response(StatusCode::NO_CONTENT, origin.as_deref()); + } + + match (method, uri.path()) { + (Method::POST, "/api/pair") => pair(body, state, origin.as_deref()).await, + (Method::POST, "/api/logout") => { + let Some(token) = session_cookie(&headers) else { + return error_response( + StatusCode::UNAUTHORIZED, + "UNAUTHORIZED", + "missing session cookie", + origin.as_deref(), + ); + }; + if !state.revoke_session(&token) { + return error_response( + StatusCode::UNAUTHORIZED, + "UNAUTHORIZED", + "invalid session cookie", + origin.as_deref(), + ); + } + state.log_security(SecurityLogEvent::SessionRevoked { + origin: origin.clone(), + }); + logout_response(&state, origin.as_deref()) + } + (Method::POST, "/api/invoke") => { + let Some(token) = session_cookie(&headers) else { + return error_response( + StatusCode::UNAUTHORIZED, + "UNAUTHORIZED", + "missing session cookie", + origin.as_deref(), + ); + }; + if !state.has_session(&token) { + return error_response( + StatusCode::UNAUTHORIZED, + "UNAUTHORIZED", + "invalid session cookie", + origin.as_deref(), + ); + } + invoke(body, state, origin.as_deref()).await + } + (_, "/api/pair" | "/api/invoke" | "/api/logout") => error_response( + StatusCode::METHOD_NOT_ALLOWED, + "METHOD_NOT_ALLOWED", + "method not allowed", + origin.as_deref(), + ), + _ if is_api_path(uri.path()) => error_response( + StatusCode::NOT_FOUND, + "NOT_FOUND", + "route not found", + origin.as_deref(), + ), + _ => error_response(StatusCode::NOT_FOUND, "NOT_FOUND", "route not found", None), + } +} + +fn is_api_path(path: &str) -> bool { + path == "/api" || path.starts_with("/api/") +} + +async fn serve_static(web_root: &Path, method: Method, path: &str) -> Response { + if !matches!(method, Method::GET | Method::HEAD) { + return error_response( + StatusCode::METHOD_NOT_ALLOWED, + "METHOD_NOT_ALLOWED", + "method not allowed", + None, + ); + } + + let Some(route) = static_route(web_root, path) else { + return error_response(StatusCode::NOT_FOUND, "NOT_FOUND", "route not found", None); + }; + + let file = if route.candidate.is_file() { + route.candidate + } else if route.spa_fallback { + web_root.join("index.html") + } else { + return error_response(StatusCode::NOT_FOUND, "NOT_FOUND", "route not found", None); + }; + + let bytes = match tokio::fs::read(&file).await { + Ok(bytes) => bytes, + Err(_) => { + return error_response(StatusCode::NOT_FOUND, "NOT_FOUND", "route not found", None); + } + }; + static_file_response(&file, bytes, method == Method::HEAD) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct StaticRoute { + candidate: PathBuf, + spa_fallback: bool, +} + +fn static_route(web_root: &Path, request_path: &str) -> Option { + if !request_path.starts_with('/') || request_path.contains('\\') { + return None; + } + let lower = request_path.to_ascii_lowercase(); + if lower.contains("%2e") || lower.contains("%2f") || lower.contains("%5c") { + return None; + } + + let mut candidate = web_root.to_path_buf(); + let mut last = ""; + for segment in request_path + .split('/') + .filter(|segment| !segment.is_empty()) + { + if segment == "." || segment == ".." || segment.starts_with('.') || segment.contains('%') { + return None; + } + candidate.push(segment); + last = segment; + } + + if request_path == "/" { + return Some(StaticRoute { + candidate: web_root.join("index.html"), + spa_fallback: false, + }); + } + + Some(StaticRoute { + candidate, + spa_fallback: !last.contains('.'), + }) +} + +fn static_file_response(path: &Path, bytes: Vec, head_only: bool) -> Response { + let is_index = path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name == "index.html"); + let body = if head_only { Vec::new() } else { bytes }; + let mut response = Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, content_type(path)) + .header("x-content-type-options", "nosniff") + .body(Full::new(Bytes::from(body))) + .expect("static response is valid"); + if is_index { + response.headers_mut().insert( + "content-security-policy", + HeaderValue::from_static( + "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; base-uri 'self'; frame-ancestors 'none'", + ), + ); + } + response +} + +fn content_type(path: &Path) -> &'static str { + match path.extension().and_then(|ext| ext.to_str()).unwrap_or("") { + "html" => "text/html; charset=utf-8", + "js" | "mjs" => "text/javascript; charset=utf-8", + "css" => "text/css; charset=utf-8", + "json" => "application/json; charset=utf-8", + "svg" => "image/svg+xml", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "ico" => "image/x-icon", + "wasm" => "application/wasm", + "txt" => "text/plain; charset=utf-8", + _ => "application/octet-stream", + } +} + +#[derive(Deserialize)] +struct PairRequest { + code: String, +} + +async fn pair( + body: Bytes, + state: Arc, + origin: Option<&str>, +) -> Response { + let request = match serde_json::from_slice::(&body) { + Ok(request) => request, + Err(err) => { + return error_response( + StatusCode::BAD_REQUEST, + "INVALID", + format!("invalid pairing request: {err}"), + origin, + ); + } + }; + + if request.code != state.pairing_code() { + state.log_security(SecurityLogEvent::PairingFailed { + origin: origin.map(str::to_owned), + reason: "wrong_code", + }); + return error_response( + StatusCode::FORBIDDEN, + "FORBIDDEN", + "invalid pairing code", + origin, + ); + } + + let token = state.create_session(); + state.log_security(SecurityLogEvent::PairingSucceeded { + origin: origin.map(str::to_owned), + }); + let cookie = Cookie::build((SESSION_COOKIE, token)) + .path("/") + .http_only(true) + .secure(state.config.secure_cookie()) + .same_site(SameSite::Strict) + .build(); + let mut response = json_response(StatusCode::OK, &json!({ "paired": true }), origin); + response.headers_mut().insert( + SET_COOKIE, + HeaderValue::from_str(&cookie.to_string()).expect("session cookie is header-safe"), + ); + response +} + +fn logout_response(state: &ServerState, origin: Option<&str>) -> Response { + let mut response = json_response(StatusCode::OK, &json!({ "revoked": true }), origin); + let secure = if state.config.secure_cookie() { + "; Secure" + } else { + "" + }; + let cookie = format!("{SESSION_COOKIE}=; Path=/; HttpOnly{secure}; SameSite=Strict; Max-Age=0"); + response.headers_mut().insert( + SET_COOKIE, + HeaderValue::from_str(&cookie).expect("session clearing cookie is header-safe"), + ); + response +} + +#[derive(Deserialize)] +struct InvokeRequest { + command: String, + #[serde(default)] + args: Value, +} + +async fn invoke( + body: Bytes, + state: Arc, + origin: Option<&str>, +) -> Response { + let request = match serde_json::from_slice::(&body) { + Ok(request) => request, + Err(err) => { + return error_response( + StatusCode::BAD_REQUEST, + "INVALID", + format!("invalid invoke request: {err}"), + origin, + ); + } + }; + + let result = match request.command.as_str() { + "health" => invoke_health(&request.args, &state.app), + "list_projects" => invoke_list_projects(&state.app).await, + "open_project" => invoke_open_project(&request.args, &state.app).await, + "get_project_work_state" => invoke_get_project_work_state(&request.args, &state.app).await, + "list_background_tasks" => invoke_list_background_tasks(&request.args, &state.app).await, + "cancel_background_task" => invoke_cancel_background_task(&request.args, &state.app).await, + "retry_background_task" => invoke_retry_background_task(&request.args, &state.app).await, + _ => Err(ErrorDto { + code: "UNKNOWN_COMMAND".to_owned(), + message: format!("unknown command: {}", request.command), + }), + }; + + match result { + Ok(value) => json_response(StatusCode::OK, &value, origin), + Err(error) => error_dto_response(status_for_error(&error), error, origin), + } +} + +fn invoke_health(args: &Value, state: &BackendCore) -> Result { + let request = optional_request::(args)?; + let output = state + .health + .execute(request.unwrap_or_default().into()) + .map(HealthResponseDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_list_projects(state: &BackendCore) -> Result { + let output = state + .list_projects + .execute() + .await + .map(ProjectListDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_open_project(args: &Value, state: &BackendCore) -> Result { + let project_id = args + .get("projectId") + .and_then(Value::as_str) + .ok_or_else(|| ErrorDto { + code: "INVALID".to_owned(), + message: "open_project requires args.projectId".to_owned(), + })?; + let project = resolve_project_readonly(project_id, state).await?; + serde_json::to_value(ProjectDto::from(project)).map_err(serialization_error) +} + +async fn invoke_get_project_work_state( + args: &Value, + state: &BackendCore, +) -> Result { + let project_id = args + .get("projectId") + .and_then(Value::as_str) + .ok_or_else(|| ErrorDto { + code: "INVALID".to_owned(), + message: "get_project_work_state requires args.projectId".to_owned(), + })?; + let project = resolve_project_readonly(project_id, state).await?; + let output = state + .get_project_work_state + .execute(GetProjectWorkStateInput { project }) + .await + .map(ProjectWorkStateDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_list_background_tasks( + args: &Value, + state: &BackendCore, +) -> Result { + let project_id = args + .get("projectId") + .and_then(Value::as_str) + .ok_or_else(|| ErrorDto { + code: "INVALID".to_owned(), + message: "list_background_tasks requires args.projectId".to_owned(), + }) + .and_then(parse_project_id)?; + let agent_id = args + .get("agentId") + .and_then(Value::as_str) + .map(parse_agent_id) + .transpose()?; + + let mut by_id = std::collections::HashMap::::new(); + if let Some(agent_id) = agent_id { + for task in state + .background_task_store + .list_open_for_agent(agent_id) + .await + .map_err(background_error)? + { + by_id.insert(task.id, task); + } + } + for task in state + .background_task_store + .list_undelivered_completions() + .await + .map_err(background_error)? + { + by_id.entry(task.id).or_insert(task); + } + + let mut tasks = by_id + .into_values() + .filter(|task| task.project_id == project_id) + .filter(|task| agent_id.map_or(true, |agent_id| task.owner_agent_id == agent_id)) + .collect::>(); + tasks.sort_by_key(|task| (task.created_at_ms, task.id)); + let dto = tasks + .into_iter() + .map(BackgroundTaskDto::from) + .collect::>(); + serde_json::to_value(dto).map_err(serialization_error) +} + +async fn invoke_cancel_background_task( + args: &Value, + state: &BackendCore, +) -> Result { + let task_id = task_id_arg(args, "cancel_background_task")?; + verify_background_task_project(args, state, task_id).await?; + let output = state + .cancel_background_task + .execute(task_id) + .await + .map_err(ErrorDto::from)?; + serde_json::to_value(output.task.map(BackgroundTaskDto::from)).map_err(serialization_error) +} + +async fn invoke_retry_background_task( + args: &Value, + state: &BackendCore, +) -> Result { + let task_id = task_id_arg(args, "retry_background_task")?; + verify_background_task_project(args, state, task_id).await?; + let output = state + .retry_background_task + .execute(task_id) + .await + .map_err(ErrorDto::from)?; + serde_json::to_value(BackgroundTaskDto::from(output.task)).map_err(serialization_error) +} + +fn task_id_arg(args: &Value, command: &str) -> Result { + args.get("taskId") + .and_then(Value::as_str) + .ok_or_else(|| ErrorDto { + code: "INVALID".to_owned(), + message: format!("{command} requires args.taskId"), + }) + .and_then(parse_task_id) +} + +async fn verify_background_task_project( + args: &Value, + state: &BackendCore, + task_id: domain::TaskId, +) -> Result<(), ErrorDto> { + let Some(project_id) = args.get("projectId").and_then(Value::as_str) else { + return Ok(()); + }; + let project_id = parse_project_id(project_id)?; + let Some(task) = state + .background_task_store + .get(task_id) + .await + .map_err(background_error)? + else { + return Err(ErrorDto { + code: "NOT_FOUND".to_owned(), + message: format!("background task not found: {task_id}"), + }); + }; + if task.project_id != project_id { + return Err(ErrorDto { + code: "FORBIDDEN".to_owned(), + message: "background task does not belong to requested project".to_owned(), + }); + } + Ok(()) +} + +fn background_error(err: BackgroundTaskPortError) -> ErrorDto { + let code = match &err { + BackgroundTaskPortError::NotFound => "NOT_FOUND", + BackgroundTaskPortError::AlreadyExists | BackgroundTaskPortError::Invalid(_) => "INVALID", + BackgroundTaskPortError::Runner(_) => "PROCESS", + BackgroundTaskPortError::Store(_) => "STORE", + }; + ErrorDto { + code: code.to_owned(), + message: err.to_string(), + } +} + +async fn resolve_project_readonly( + project_id: &str, + state: &BackendCore, +) -> Result { + let id = parse_project_id(project_id)?; + state + .open_project + .execute(OpenProjectInput { project_id: id }) + .await + .map(|output| output.project) + .map_err(ErrorDto::from) +} + +fn optional_request(args: &Value) -> Result, ErrorDto> +where + T: for<'de> Deserialize<'de>, +{ + match args { + Value::Object(map) => match map.get("request") { + Some(value) => serde_json::from_value(value.clone()) + .map(Some) + .map_err(invalid_args_error), + None if map.is_empty() => Ok(None), + None => serde_json::from_value(args.clone()) + .map(Some) + .map_err(invalid_args_error), + }, + Value::Null => Ok(None), + _ => Err(ErrorDto { + code: "INVALID".to_owned(), + message: "args must be an object".to_owned(), + }), + } +} + +fn validate_request_origin( + headers: &HeaderMap, + config: &ServerConfig, +) -> Result, Box>> { + let Some(origin) = headers.get(ORIGIN).and_then(|value| value.to_str().ok()) else { + return Err(Box::new(error_response( + StatusCode::FORBIDDEN, + "FORBIDDEN", + "missing Origin header", + None, + ))); + }; + + if origin_allowed(origin, config) { + Ok(Some(origin.to_owned())) + } else { + Err(Box::new(error_response( + StatusCode::FORBIDDEN, + "FORBIDDEN", + "origin not allowed", + None, + ))) + } +} + +fn request_origin(headers: &HeaderMap) -> Option { + headers + .get(ORIGIN) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) +} + +fn origin_allowed(origin: &str, config: &ServerConfig) -> bool { + if let Some(public_origin) = &config.public_origin { + return origin == public_origin; + } + + if !config.listen.ip().is_loopback() { + return false; + } + + let port = config.listen.port(); + origin == format!("http://127.0.0.1:{port}") + || origin == format!("http://localhost:{port}") + || origin == format!("http://[::1]:{port}") +} + +fn has_forbidden_query_secret(uri: &Uri) -> bool { + uri.query().is_some_and(|query| { + query.split('&').any(|pair| { + let key = pair.split_once('=').map_or(pair, |(key, _)| key); + matches!( + key.to_ascii_lowercase().as_str(), + "token" | "secret" | "session" | "code" + ) + }) + }) +} + +fn session_cookie(headers: &HeaderMap) -> Option { + headers + .get(COOKIE) + .and_then(|value| value.to_str().ok()) + .and_then(|raw| { + raw.split(';').find_map(|part| { + let (name, value) = part.trim().split_once('=')?; + (name == SESSION_COOKIE).then(|| value.to_owned()) + }) + }) +} + +fn json_response( + status: StatusCode, + value: &Value, + origin: Option<&str>, +) -> Response { + let body = serde_json::to_vec(value).expect("JSON value serializes"); + let mut response = Response::new(Full::new(Bytes::from(body))); + *response.status_mut() = status; + response + .headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + add_cors_headers(&mut response, origin); + response +} + +fn error_dto_response( + status: StatusCode, + error: ErrorDto, + origin: Option<&str>, +) -> Response { + let value = serde_json::to_value(error).expect("ErrorDto serializes"); + json_response(status, &value, origin) +} + +fn error_response( + status: StatusCode, + code: impl Into, + message: impl Into, + origin: Option<&str>, +) -> Response { + error_dto_response( + status, + ErrorDto { + code: code.into(), + message: message.into(), + }, + origin, + ) +} + +fn cors_response(status: StatusCode, origin: Option<&str>) -> Response { + let mut response = Response::new(Full::new(Bytes::new())); + *response.status_mut() = status; + add_cors_headers(&mut response, origin); + response +} + +fn add_cors_headers(response: &mut Response, origin: Option<&str>) { + if let Some(origin) = origin { + if let Ok(origin) = HeaderValue::from_str(origin) { + response + .headers_mut() + .insert("access-control-allow-origin", origin); + response.headers_mut().insert( + "access-control-allow-credentials", + HeaderValue::from_static("true"), + ); + response.headers_mut().insert( + "access-control-allow-headers", + HeaderValue::from_static("content-type"), + ); + response.headers_mut().insert( + "access-control-allow-methods", + HeaderValue::from_static("POST, OPTIONS"), + ); + } + } +} + +fn status_for_error(error: &ErrorDto) -> StatusCode { + match error.code.as_str() { + "UNKNOWN_COMMAND" => StatusCode::BAD_REQUEST, + "INVALID" => StatusCode::BAD_REQUEST, + "NOT_FOUND" => StatusCode::NOT_FOUND, + "FORBIDDEN" => StatusCode::FORBIDDEN, + "UNAUTHORIZED" => StatusCode::UNAUTHORIZED, + _ => StatusCode::INTERNAL_SERVER_ERROR, + } +} + +fn validate_origin(origin: &str) -> Result<(), String> { + if origin == "*" || origin.contains('*') { + return Err("--public-origin must be exact, not a wildcard".to_owned()); + } + if origin.ends_with('/') { + return Err("--public-origin must not end with '/'".to_owned()); + } + if !(origin.starts_with("https://") || origin.starts_with("http://")) { + return Err("--public-origin must be an HTTP(S) origin".to_owned()); + } + Ok(()) +} + +fn default_app_data_dir() -> PathBuf { + if let Some(path) = env::var_os("IDEA_APP_DATA_DIR") { + return PathBuf::from(path); + } + if let Some(path) = env::var_os("XDG_DATA_HOME") { + return PathBuf::from(path).join("app.idea.ide"); + } + if let Some(home) = env::var_os("HOME") { + return PathBuf::from(home).join(".local/share/app.idea.ide"); + } + env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(".ideai/app-data") +} + +fn new_pairing_code() -> String { + Uuid::new_v4() + .simple() + .to_string() + .chars() + .take(8) + .collect::() + .to_ascii_uppercase() +} + +fn new_session_token() -> String { + format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()) +} + +fn invalid_args_error(err: serde_json::Error) -> ErrorDto { + ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid command args: {err}"), + } +} + +fn serialization_error(err: serde_json::Error) -> ErrorDto { + ErrorDto { + code: "SERIALIZATION".to_owned(), + message: format!("failed to serialize response: {err}"), + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct ClientFrame { + id: String, + kind: String, + #[serde(default)] + payload: Value, +} + +#[derive(Debug, Clone, Serialize)] +struct ServerFrame { + kind: String, + #[serde(skip_serializing_if = "Option::is_none", rename = "replyTo")] + reply_to: Option, + payload: Value, +} + +impl ServerFrame { + fn attached( + request_id: &str, + session: &TerminalSessionDto, + scrollback: Vec, + next_seq: u64, + gap: bool, + ) -> Self { + let scrollback = if scrollback.is_empty() { + Vec::new() + } else { + vec![json!({ + "seq": 0, + "bytesBase64": base64_encode(&scrollback), + })] + }; + Self { + kind: "terminal.attached".to_owned(), + reply_to: Some(request_id.to_owned()), + payload: json!({ + "session": { + "sessionId": session.session_id, + "rows": session.rows, + "cols": session.cols, + }, + "scrollback": scrollback, + "nextSeq": next_seq, + "status": "running", + "gap": gap, + "assignedConversationId": session.assigned_conversation_id, + }), + } + } + + fn output(session_id: SessionId, seq: u64, bytes: Vec) -> Self { + Self { + kind: "terminal.output".to_owned(), + reply_to: None, + payload: json!({ + "sessionId": session_id.to_string(), + "seq": seq, + "bytesBase64": base64_encode(&bytes), + }), + } + } + + fn status(session_id: SessionId, status: &str, exit_code: Option) -> Self { + Self { + kind: "terminal.status".to_owned(), + reply_to: None, + payload: json!({ + "sessionId": session_id.to_string(), + "status": status, + "exitCode": exit_code, + }), + } + } + + fn domain_event(event: &DomainEvent) -> Self { + Self { + kind: "event.domain".to_owned(), + reply_to: None, + payload: serde_json::to_value(DomainEventDto::from(event)) + .expect("DomainEventDto serializes"), + } + } + + fn error( + request_id: Option, + session_id: Option, + code: impl Into, + message: impl Into, + ) -> Self { + Self { + kind: "error".to_owned(), + reply_to: request_id, + payload: json!({ + "sessionId": session_id, + "code": code.into(), + "message": message.into(), + }), + } + } + + fn pong() -> Self { + Self { + kind: "pong".to_owned(), + reply_to: None, + payload: json!({}), + } + } +} + +struct WsPtySink { + session_id: SessionId, + tx: mpsc::Sender, + seq: AtomicU64, +} + +impl WsPtySink { + fn new(session_id: SessionId, tx: mpsc::Sender, next_seq: u64) -> Self { + Self { + session_id, + tx, + seq: AtomicU64::new(next_seq), + } + } +} + +impl OutputSink for WsPtySink { + fn send(&self, item: PtyChunk) -> Result<(), OutputSinkError> { + let seq = self.seq.fetch_add(1, Ordering::Relaxed); + self.tx + .try_send(ServerFrame::output(self.session_id, seq, item)) + .map_err(|err| match err { + mpsc::error::TrySendError::Full(_) => OutputSinkError::Full, + mpsc::error::TrySendError::Closed(_) => OutputSinkError::Closed, + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WsOpcode { + Text, + Binary, + Close, + Ping, + Pong, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct WsFrame { + opcode: WsOpcode, + payload: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum WsFrameError { + Incomplete, + Protocol(String), + TooLarge, +} + +impl std::fmt::Display for WsFrameError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Incomplete => f.write_str("incomplete websocket frame"), + Self::Protocol(message) => write!(f, "websocket protocol error: {message}"), + Self::TooLarge => f.write_str("websocket frame payload too large"), + } + } +} + +async fn read_ws_frame(reader: &mut R) -> Result +where + R: AsyncRead + Unpin, +{ + let mut header = [0_u8; 2]; + reader + .read_exact(&mut header) + .await + .map_err(|_| WsFrameError::Incomplete)?; + let len_code = header[1] & 0x7f; + let masked = header[1] & 0x80 != 0; + let mut rest = Vec::new(); + let extra_len = match len_code { + 126 => 2, + 127 => 8, + _ => 0, + }; + rest.resize(extra_len + usize::from(masked) * 4, 0); + if !rest.is_empty() { + reader + .read_exact(&mut rest) + .await + .map_err(|_| WsFrameError::Incomplete)?; + } + let mut raw = Vec::with_capacity(2 + rest.len()); + raw.extend_from_slice(&header); + raw.extend_from_slice(&rest); + let payload_len = decoded_payload_len(&raw)?; + if payload_len > WS_MAX_PAYLOAD { + return Err(WsFrameError::TooLarge); + } + let mut payload = vec![0_u8; payload_len]; + reader + .read_exact(&mut payload) + .await + .map_err(|_| WsFrameError::Incomplete)?; + raw.extend_from_slice(&payload); + decode_client_ws_frame(&raw) +} + +fn decode_client_ws_frame(raw: &[u8]) -> Result { + if raw.len() < 2 { + return Err(WsFrameError::Incomplete); + } + let fin = raw[0] & 0x80 != 0; + if !fin { + return Err(WsFrameError::Protocol( + "fragmented frames are not supported".to_owned(), + )); + } + let opcode = match raw[0] & 0x0f { + 0x1 => WsOpcode::Text, + 0x2 => WsOpcode::Binary, + 0x8 => WsOpcode::Close, + 0x9 => WsOpcode::Ping, + 0xA => WsOpcode::Pong, + other => { + return Err(WsFrameError::Protocol(format!( + "unsupported opcode {other}" + ))) + } + }; + let masked = raw[1] & 0x80 != 0; + if !masked { + return Err(WsFrameError::Protocol( + "client frames must be masked".to_owned(), + )); + } + let len_code = raw[1] & 0x7f; + let mut offset = 2; + let len = match len_code { + 126 => { + if raw.len() < offset + 2 { + return Err(WsFrameError::Incomplete); + } + let len = u16::from_be_bytes([raw[offset], raw[offset + 1]]) as usize; + offset += 2; + len + } + 127 => { + if raw.len() < offset + 8 { + return Err(WsFrameError::Incomplete); + } + let len = u64::from_be_bytes(raw[offset..offset + 8].try_into().expect("8 bytes")); + offset += 8; + usize::try_from(len).map_err(|_| WsFrameError::TooLarge)? + } + len => usize::from(len), + }; + if len > WS_MAX_PAYLOAD { + return Err(WsFrameError::TooLarge); + } + if raw.len() < offset + 4 + len { + return Err(WsFrameError::Incomplete); + } + let mask = &raw[offset..offset + 4]; + offset += 4; + let mut payload = raw[offset..offset + len].to_vec(); + for (i, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[i % 4]; + } + Ok(WsFrame { opcode, payload }) +} + +fn decoded_payload_len(raw_header: &[u8]) -> Result { + let len_code = raw_header[1] & 0x7f; + let offset = 2; + let len = match len_code { + 126 => { + if raw_header.len() < offset + 2 { + return Err(WsFrameError::Incomplete); + } + u16::from_be_bytes([raw_header[offset], raw_header[offset + 1]]) as usize + } + 127 => { + if raw_header.len() < offset + 8 { + return Err(WsFrameError::Incomplete); + } + let len = + u64::from_be_bytes(raw_header[offset..offset + 8].try_into().expect("8 bytes")); + usize::try_from(len).map_err(|_| WsFrameError::TooLarge)? + } + len => usize::from(len), + }; + Ok(len) +} + +fn encode_ws_frame(opcode: WsOpcode, payload: &[u8]) -> Vec { + let opcode = match opcode { + WsOpcode::Text => 0x1, + WsOpcode::Binary => 0x2, + WsOpcode::Close => 0x8, + WsOpcode::Ping => 0x9, + WsOpcode::Pong => 0xA, + }; + let mut out = vec![0x80 | opcode]; + match payload.len() { + 0..=125 => out.push(payload.len() as u8), + 126..=65535 => { + out.push(126); + out.extend_from_slice(&(payload.len() as u16).to_be_bytes()); + } + len => { + out.push(127); + out.extend_from_slice(&(len as u64).to_be_bytes()); + } + } + out.extend_from_slice(payload); + out +} + +fn websocket_accept(key: &str) -> String { + let mut input = Vec::with_capacity(key.len() + WS_MAGIC.len()); + input.extend_from_slice(key.as_bytes()); + input.extend_from_slice(WS_MAGIC.as_bytes()); + base64_encode(&sha1_digest(&input)) +} + +fn base64_encode(bytes: &[u8]) -> String { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD.encode(bytes) +} + +fn base64_decode(raw: &str) -> Result, ErrorDto> { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD + .decode(raw) + .map_err(|err| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid base64 bytes: {err}"), + }) +} + +fn sha1_digest(input: &[u8]) -> [u8; 20] { + let mut h0: u32 = 0x67452301; + let mut h1: u32 = 0xEFCDAB89; + let mut h2: u32 = 0x98BADCFE; + let mut h3: u32 = 0x10325476; + let mut h4: u32 = 0xC3D2E1F0; + + let bit_len = (input.len() as u64) * 8; + let mut msg = input.to_vec(); + msg.push(0x80); + while (msg.len() % 64) != 56 { + msg.push(0); + } + msg.extend_from_slice(&bit_len.to_be_bytes()); + + for chunk in msg.chunks(64) { + let mut w = [0_u32; 80]; + for (i, word) in w.iter_mut().take(16).enumerate() { + let j = i * 4; + *word = u32::from_be_bytes([chunk[j], chunk[j + 1], chunk[j + 2], chunk[j + 3]]); + } + for i in 16..80 { + w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1); + } + let mut a = h0; + let mut b = h1; + let mut c = h2; + let mut d = h3; + let mut e = h4; + for (i, word) in w.iter().enumerate() { + let (f, k) = match i { + 0..=19 => ((b & c) | ((!b) & d), 0x5A827999), + 20..=39 => (b ^ c ^ d, 0x6ED9EBA1), + 40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1BBCDC), + _ => (b ^ c ^ d, 0xCA62C1D6), + }; + let temp = a + .rotate_left(5) + .wrapping_add(f) + .wrapping_add(e) + .wrapping_add(k) + .wrapping_add(*word); + e = d; + d = c; + c = b.rotate_left(30); + b = a; + a = temp; + } + h0 = h0.wrapping_add(a); + h1 = h1.wrapping_add(b); + h2 = h2.wrapping_add(c); + h3 = h3.wrapping_add(d); + h4 = h4.wrapping_add(e); + } + + let mut out = [0_u8; 20]; + out[0..4].copy_from_slice(&h0.to_be_bytes()); + out[4..8].copy_from_slice(&h1.to_be_bytes()); + out[8..12].copy_from_slice(&h2.to_be_bytes()); + out[12..16].copy_from_slice(&h3.to_be_bytes()); + out[16..20].copy_from_slice(&h4.to_be_bytes()); + out +} + +fn payload_string(payload: &Value, key: &str) -> Result { + payload + .get(key) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .ok_or_else(|| ErrorDto { + code: "INVALID".to_owned(), + message: format!("payload requires {key}"), + }) +} + +fn payload_u16(payload: &Value, key: &str) -> Option { + payload + .get(key) + .and_then(Value::as_u64) + .and_then(|value| u16::try_from(value).ok()) +} + +fn payload_bytes(payload: &Value) -> Result, ErrorDto> { + if let Some(raw) = payload.get("bytesBase64").and_then(Value::as_str) { + base64_decode(raw) + } else if let Some(raw) = payload.get("bytes").and_then(Value::as_str) { + base64_decode(raw) + } else { + Err(ErrorDto { + code: "INVALID".to_owned(), + message: "payload requires bytesBase64".to_owned(), + }) + } +} + +fn payload_session_id(payload: &Value) -> Option { + payload + .get("sessionId") + .and_then(Value::as_str) + .map(ToOwned::to_owned) +} + +#[cfg(test)] +mod tests { + use super::*; + use application::{ + CreateAgentInput, CreateProjectInput, LaunchAgentOutput, SaveProfileInput, + StructuredSessionDescriptor, + }; + use domain::ports::EventBus; + use domain::{ + AgentId, AgentProfile, BackgroundTask, BackgroundTaskKind, BackgroundTaskWakePolicy, + ContextInjection, NodeId, ProfileId, ProjectId, ProjectPath, PtySize, SessionKind, + SessionStatus, SessionStrategy, TaskId, TerminalSession, + }; + use http::header::HeaderName; + use std::time::Duration; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + struct EnvVarGuard { + saved: Vec<(&'static str, Option)>, + } + + impl EnvVarGuard { + fn new(keys: &[&'static str]) -> Self { + Self { + saved: keys + .iter() + .map(|key| (*key, std::env::var_os(key))) + .collect(), + } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + for (key, value) in self.saved.iter().rev() { + if let Some(value) = value { + std::env::set_var(key, value); + } else { + std::env::remove_var(key); + } + } + } + } + + #[derive(Default)] + struct RecordingSecurityLogger { + events: Mutex>, + } + + impl RecordingSecurityLogger { + fn events(&self) -> Vec { + self.events.lock().unwrap().clone() + } + } + + impl SecurityLogger for RecordingSecurityLogger { + fn log(&self, event: SecurityLogEvent) { + self.events.lock().unwrap().push(event); + } + } + + fn create_web_root() -> PathBuf { + let root = std::env::temp_dir().join(format!("idea-web-root-{}", Uuid::new_v4())); + std::fs::create_dir_all(root.join("assets")).unwrap(); + std::fs::write( + root.join("index.html"), + r#"
"#, + ) + .unwrap(); + std::fs::write(root.join("assets").join("app.js"), "console.log('idea');").unwrap(); + std::fs::write(root.join("assets").join("style.css"), "body{margin:0}").unwrap(); + root + } + + fn test_config() -> ServerConfig { + ServerConfig { + listen: "127.0.0.1:17373".parse().unwrap(), + public_origin: None, + allow_remote: false, + trust_reverse_proxy: false, + app_data_dir: std::env::temp_dir().join(format!("idea-server-test-{}", Uuid::new_v4())), + web_root: create_web_root(), + } + } + + #[test] + fn default_app_data_dir_uses_tauri_identifier_with_env_precedence() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvVarGuard::new(&["IDEA_APP_DATA_DIR", "XDG_DATA_HOME", "HOME"]); + let root = std::env::temp_dir().join(format!("idea-app-data-env-{}", Uuid::new_v4())); + let home = root.join("home"); + let xdg = root.join("xdg"); + let override_dir = root.join("override"); + + std::env::remove_var("IDEA_APP_DATA_DIR"); + std::env::remove_var("XDG_DATA_HOME"); + std::env::set_var("HOME", &home); + assert_eq!( + default_app_data_dir(), + home.join(".local/share/app.idea.ide") + ); + + std::env::set_var("XDG_DATA_HOME", &xdg); + assert_eq!(default_app_data_dir(), xdg.join("app.idea.ide")); + + std::env::set_var("IDEA_APP_DATA_DIR", &override_dir); + assert_eq!(default_app_data_dir(), override_dir); + } + + fn state() -> Arc { + Arc::new(ServerState::new_for_test(test_config(), "PAIR1234")) + } + + async fn request( + state: Arc, + method: Method, + uri: &str, + body: Value, + extra_headers: &[(&str, &str)], + ) -> Response { + let mut builder = Request::builder() + .method(method) + .uri(uri) + .header(ORIGIN, "http://127.0.0.1:17373") + .header(CONTENT_TYPE, "application/json"); + for (name, value) in extra_headers { + builder = builder.header(HeaderName::from_bytes(name.as_bytes()).unwrap(), *value); + } + handle_request( + builder + .body(Full::new(Bytes::from(serde_json::to_vec(&body).unwrap()))) + .unwrap(), + state, + ) + .await + } + + async fn response_json(response: Response) -> (StatusCode, Value, HeaderMap) { + let status = response.status(); + let headers = response.headers().clone(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let value = if body.is_empty() { + Value::Null + } else { + serde_json::from_slice(&body).unwrap() + }; + (status, value, headers) + } + + async fn response_bytes(response: Response) -> (StatusCode, Bytes, HeaderMap) { + let status = response.status(); + let headers = response.headers().clone(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + (status, body, headers) + } + + async fn pair_and_cookie(state: Arc) -> String { + let response = request( + state, + Method::POST, + "/api/pair", + json!({ "code": "PAIR1234" }), + &[], + ) + .await; + response + .headers() + .get(SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_owned() + } + + async fn create_project_for_test(state: &Arc, name: &str) -> String { + let root = std::env::temp_dir() + .join(format!("idea-server-project-{}", Uuid::new_v4())) + .to_string_lossy() + .into_owned(); + let output = state + .app + .create_project + .execute(CreateProjectInput { + name: name.to_owned(), + root, + remote: None, + default_profile_id: None, + }) + .await + .expect("test project is created"); + output.project.id.to_string() + } + + async fn create_background_task_for_test( + state: &Arc, + project_id: &str, + label: &str, + ) -> (TaskId, AgentId) { + let task_id = TaskId::from_uuid(Uuid::new_v4()); + let owner = AgentId::from_uuid(Uuid::new_v4()); + let project_id = ProjectId::from_uuid(Uuid::parse_str(project_id).unwrap()); + let task = BackgroundTask::new( + task_id, + project_id, + owner, + BackgroundTaskKind::Command { + label: label.to_owned(), + }, + BackgroundTaskWakePolicy::WakeOwner, + 1_000, + None, + ) + .expect("test background task is valid"); + state + .app + .background_task_store + .create(&task) + .await + .expect("test background task is stored"); + (task_id, owner) + } + + async fn create_raw_cli_agent_for_test( + state: &Arc, + name: &str, + ) -> (String, String) { + let project_id = create_project_for_test(state, name).await; + let project = resolve_project_readonly(&project_id, &state.app) + .await + .expect("test project resolves"); + let profile_id = ProfileId::from_uuid(Uuid::new_v4()); + let profile = AgentProfile::new( + profile_id, + format!("{name} shell profile"), + "/bin/sh", + vec![ + "-c".to_owned(), + "printf agent-ready; sleep 30".to_owned(), + "idea-test-sh".to_owned(), + ], + ContextInjection::env("IDEA_CONTEXT").expect("valid env injection"), + None, + "{agentRunDir}", + Some( + SessionStrategy::new(Some("--session-id".to_owned()), "--resume") + .expect("valid session strategy"), + ), + ) + .expect("valid raw CLI profile"); + state + .app + .save_profile + .execute(SaveProfileInput { profile }) + .await + .expect("test profile saved"); + let agent = state + .app + .create_agent + .execute(CreateAgentInput { + project, + name: format!("{name} agent"), + profile_id, + initial_content: Some("Test agent context".to_owned()), + }) + .await + .expect("test agent created") + .agent; + (project_id, agent.id.to_string()) + } + + fn ws_headers(origin: &str, cookie: Option<&str>) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(ORIGIN, HeaderValue::from_str(origin).unwrap()); + headers.insert("upgrade", HeaderValue::from_static("websocket")); + headers.insert("connection", HeaderValue::from_static("Upgrade")); + headers.insert( + "sec-websocket-key", + HeaderValue::from_static("dGhlIHNhbXBsZSBub25jZQ=="), + ); + if let Some(cookie) = cookie { + headers.insert(COOKIE, HeaderValue::from_str(cookie).unwrap()); + } + headers + } + + fn masked_text_frame(text: &str) -> Vec { + let payload = text.as_bytes(); + let mask = [1_u8, 2, 3, 4]; + let mut out = vec![0x81]; + out.push(0x80 | payload.len() as u8); + out.extend_from_slice(&mask); + for (i, byte) in payload.iter().enumerate() { + out.push(byte ^ mask[i % 4]); + } + out + } + + async fn recv_server_frame(rx: &mut mpsc::Receiver) -> ServerFrame { + tokio::time::timeout(Duration::from_secs(2), rx.recv()) + .await + .expect("server frame received before timeout") + .expect("server frame channel is open") + } + + async fn recv_server_frame_where( + rx: &mut mpsc::Receiver, + mut predicate: impl FnMut(&ServerFrame) -> bool, + ) -> ServerFrame { + for _ in 0..16 { + let frame = recv_server_frame(rx).await; + if predicate(&frame) { + return frame; + } + } + panic!("matching server frame was not received"); + } + + fn drain_server_frames(rx: &mut mpsc::Receiver) { + while rx.try_recv().is_ok() {} + } + + fn ws_frame(id: &str, kind: &str, payload: Value) -> ClientFrame { + ClientFrame { + id: id.to_owned(), + kind: kind.to_owned(), + payload, + } + } + + fn attached_session_id(frame: &ServerFrame) -> String { + assert_eq!(frame.kind, "terminal.attached"); + frame.payload["session"]["sessionId"] + .as_str() + .expect("attached frame carries sessionId") + .to_owned() + } + + fn open_terminal_payload(command: &str, args: Vec<&str>) -> Value { + json!({ + "request": { + "cwd": std::env::temp_dir().to_string_lossy(), + "rows": 24, + "cols": 80, + "command": command, + "args": args, + } + }) + } + + fn agent_launch_payload( + project_id: &str, + agent_id: &str, + node_id: &str, + conversation_id: Option<&str>, + ) -> Value { + json!({ + "projectId": project_id, + "agentId": agent_id, + "nodeId": node_id, + "rows": 24, + "cols": 80, + "conversationId": conversation_id, + }) + } + + async fn close_ws_session(state: &Arc, session_id: &str) { + let (tx, mut rx) = mpsc::channel(8); + let owned = Arc::new(Mutex::new(Vec::new())); + handle_client_frame( + ws_frame( + "close-cleanup", + "terminal.close", + json!({ "sessionId": session_id }), + ), + state, + &tx, + &owned, + ) + .await; + let _ = recv_server_frame(&mut rx).await; + } + + async fn wait_for_scrollback( + state: &Arc, + session_id: &str, + needle: &[u8], + ) -> Vec { + let sid = parse_session_id(session_id).expect("test session id parses"); + let handle = PtyHandle { session_id: sid }; + for _ in 0..100 { + if let Ok(scrollback) = state.app.pty_port.scrollback(&handle) { + if scrollback + .windows(needle.len()) + .any(|window| window == needle) + { + return scrollback; + } + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + panic!( + "scrollback did not contain {:?}", + String::from_utf8_lossy(needle) + ); + } + + #[test] + fn websocket_accept_matches_rfc_example() { + assert_eq!( + websocket_accept("dGhlIHNhbXBsZSBub25jZQ=="), + "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=" + ); + } + + #[test] + fn websocket_upgrade_requires_valid_cookie() { + let state = state(); + let headers = ws_headers("http://127.0.0.1:17373", None); + + let response = validate_ws_upgrade(&headers, &state).unwrap_err(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[test] + fn websocket_upgrade_rejects_invalid_cookie() { + let state = state(); + let headers = ws_headers( + "http://127.0.0.1:17373", + Some("idea_session=not-a-valid-session"), + ); + + let response = validate_ws_upgrade(&headers, &state).unwrap_err(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[test] + fn websocket_upgrade_requires_allowed_origin() { + let state = state(); + let token = state.create_session(); + let headers = ws_headers( + "https://evil.example", + Some(&format!("idea_session={token}")), + ); + + let response = validate_ws_upgrade(&headers, &state).unwrap_err(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[test] + fn websocket_upgrade_accepts_valid_cookie_and_origin() { + let state = state(); + let token = state.create_session(); + let headers = ws_headers( + "http://127.0.0.1:17373", + Some(&format!("idea_session={token}")), + ); + + let accept = validate_ws_upgrade(&headers, &state).unwrap(); + + assert_eq!(accept, "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="); + } + + #[test] + fn websocket_client_text_frame_decodes_masked_json() { + let raw = masked_text_frame(r#"{"kind":"ping"}"#); + + let frame = decode_client_ws_frame(&raw).unwrap(); + + assert_eq!(frame.opcode, WsOpcode::Text); + assert_eq!(frame.payload, br#"{"kind":"ping"}"#); + } + + #[test] + fn websocket_client_json_frame_round_trips_base64_bytes() { + let sid = domain::SessionId::new_random().to_string(); + let value = json!({ + "id": "req-1", + "kind": "input", + "payload": { + "sessionId": sid, + "bytesBase64": "AQID" + } + }); + + let frame: ClientFrame = serde_json::from_value(value.clone()).unwrap(); + let roundtrip = serde_json::to_value(&frame).unwrap(); + + assert_eq!(roundtrip, value); + assert_eq!(payload_bytes(&frame.payload).unwrap(), vec![1, 2, 3]); + } + + #[test] + fn websocket_server_attached_frame_uses_contract_shape() { + let sid = domain::SessionId::new_random().to_string(); + let frame = ServerFrame::attached( + "req-attach", + &TerminalSessionDto { + session_id: sid.clone(), + cwd: "/tmp".to_owned(), + rows: 24, + cols: 80, + assigned_conversation_id: None, + engine_session_id: None, + cell_kind: backend::dto::CellKind::Pty, + }, + vec![4, 5, 6], + 1, + true, + ); + let value = serde_json::to_value(frame).unwrap(); + + assert_eq!(value["kind"], "terminal.attached"); + assert_eq!(value["replyTo"], "req-attach"); + assert_eq!(value["payload"]["session"]["sessionId"], sid); + assert_eq!(value["payload"]["scrollback"][0]["seq"], 0); + assert_eq!(value["payload"]["scrollback"][0]["bytesBase64"], "BAUG"); + assert_eq!(value["payload"]["nextSeq"], 1); + assert_eq!(value["payload"]["gap"], true); + } + + #[tokio::test] + async fn websocket_app_ping_emits_pong() { + let state = state(); + let (tx, mut rx) = mpsc::channel(4); + let owned = Arc::new(Mutex::new(Vec::new())); + + handle_client_frame(ws_frame("ping-1", "ping", json!({})), &state, &tx, &owned).await; + let frame = recv_server_frame(&mut rx).await; + + assert_eq!(frame.kind, "pong"); + assert!(frame.payload.as_object().unwrap().is_empty()); + } + + #[test] + fn websocket_domain_event_frame_uses_contract_shape() { + let project_id = ProjectId::from_uuid(Uuid::new_v4()); + let frame = ServerFrame::domain_event(&DomainEvent::ProjectCreated { project_id }); + + assert_eq!(frame.kind, "event.domain"); + assert!(frame.reply_to.is_none()); + assert_eq!(frame.payload["type"], "projectCreated"); + assert_eq!(frame.payload["projectId"], project_id.to_string()); + } + + #[tokio::test] + async fn websocket_domain_event_relay_forwards_domain_events() { + let state = state(); + let project_id = ProjectId::from_uuid(Uuid::new_v4()); + let event_rx = state.app.event_bus.raw_receiver(); + let (tx, mut rx) = mpsc::channel(4); + let relay = spawn_ws_domain_event_relay(event_rx, tx); + + state + .app + .event_bus + .publish(DomainEvent::ProjectCreated { project_id }); + let frame = tokio::time::timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("event frame is forwarded") + .expect("event frame channel is open"); + relay.abort(); + + assert_eq!(frame.kind, "event.domain"); + assert_eq!(frame.payload["type"], "projectCreated"); + assert_eq!(frame.payload["projectId"], project_id.to_string()); + } + + #[tokio::test] + async fn websocket_domain_event_relay_forwards_background_completion() { + let state = state(); + let project_id = ProjectId::from_uuid(Uuid::new_v4()); + let task_id = TaskId::from_uuid(Uuid::new_v4()); + let owner = AgentId::from_uuid(Uuid::new_v4()); + let event_rx = state.app.event_bus.raw_receiver(); + let (tx, mut rx) = mpsc::channel(4); + let relay = spawn_ws_domain_event_relay(event_rx, tx); + + state + .app + .event_bus + .publish(DomainEvent::BackgroundTaskCompleted { + project_id, + task_id, + owner_agent_id: owner, + }); + let frame = tokio::time::timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("background completion event is forwarded") + .expect("event frame channel is open"); + relay.abort(); + + assert_eq!(frame.kind, "event.domain"); + assert_eq!(frame.payload["type"], "backgroundTaskChanged"); + assert_eq!(frame.payload["projectId"], project_id.to_string()); + assert_eq!(frame.payload["taskId"], task_id.to_string()); + assert_eq!(frame.payload["agentId"], owner.to_string()); + assert_eq!(frame.payload["state"], "completed"); + } + + #[tokio::test] + async fn websocket_domain_event_relay_excludes_pty_output() { + let state = state(); + let project_id = ProjectId::from_uuid(Uuid::new_v4()); + let event_rx = state.app.event_bus.raw_receiver(); + let (tx, mut rx) = mpsc::channel(4); + let relay = spawn_ws_domain_event_relay(event_rx, tx); + + state.app.event_bus.publish(DomainEvent::PtyOutput { + session_id: SessionId::new_random(), + bytes: b"hidden from global live bus".to_vec(), + }); + state + .app + .event_bus + .publish(DomainEvent::ProjectCreated { project_id }); + let frame = tokio::time::timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("sentinel event is forwarded") + .expect("event frame channel is open"); + relay.abort(); + + assert_eq!(frame.kind, "event.domain"); + assert_eq!(frame.payload["type"], "projectCreated"); + assert_eq!(frame.payload["projectId"], project_id.to_string()); + assert!( + rx.try_recv().is_err(), + "PtyOutput must not be emitted on the global event.domain stream" + ); + } + + #[tokio::test] + async fn websocket_domain_event_relay_drops_when_client_queue_is_full() { + let state = state(); + let event_rx = state.app.event_bus.raw_receiver(); + let (tx, mut rx) = mpsc::channel(1); + assert!(tx.try_send(ServerFrame::pong()).is_ok()); + let relay = spawn_ws_domain_event_relay(event_rx, tx); + + state.app.event_bus.publish(DomainEvent::ProjectCreated { + project_id: ProjectId::from_uuid(Uuid::new_v4()), + }); + tokio::time::sleep(Duration::from_millis(20)).await; + let first = rx.try_recv().expect("pre-filled frame remains queued"); + relay.abort(); + + assert_eq!(first.kind, "pong"); + assert!( + rx.try_recv().is_err(), + "full client queue should drop the live event instead of buffering" + ); + } + + #[tokio::test] + async fn websocket_open_terminal_emits_attached_with_empty_scrollback() { + let state = state(); + let (tx, mut rx) = mpsc::channel(16); + let owned = Arc::new(Mutex::new(Vec::new())); + + handle_client_frame( + ws_frame( + "open-1", + "terminal.open", + open_terminal_payload("/bin/sh", vec!["-c", "sleep 30"]), + ), + &state, + &tx, + &owned, + ) + .await; + let frame = recv_server_frame(&mut rx).await; + let session_id = attached_session_id(&frame); + + assert_eq!(frame.reply_to.as_deref(), Some("open-1")); + assert_eq!(frame.payload["scrollback"].as_array().unwrap().len(), 0); + assert_eq!(frame.payload["nextSeq"], 0); + + close_ws_session(&state, &session_id).await; + } + + #[tokio::test] + async fn websocket_attach_terminal_replays_scrollback_in_attached_ack() { + let state = state(); + let (tx, mut rx) = mpsc::channel(32); + let owned = Arc::new(Mutex::new(Vec::new())); + + handle_client_frame( + ws_frame( + "open-replay", + "terminal.open", + open_terminal_payload("/bin/sh", vec!["-c", "printf replay-ready; sleep 30"]), + ), + &state, + &tx, + &owned, + ) + .await; + let opened = recv_server_frame(&mut rx).await; + let session_id = attached_session_id(&opened); + + let scrollback = wait_for_scrollback(&state, &session_id, b"replay-ready").await; + assert!(scrollback.len() <= 100 * 1024); + drain_server_frames(&mut rx); + + handle_client_frame( + ws_frame( + "attach-replay", + "terminal.attach", + json!({ + "sessionId": session_id, + "lastSeq": 0, + "rows": 30, + "cols": 100, + }), + ), + &state, + &tx, + &owned, + ) + .await; + let attached = recv_server_frame(&mut rx).await; + let replay = attached.payload["scrollback"].as_array().unwrap(); + + assert_eq!(attached.kind, "terminal.attached"); + assert_eq!(attached.reply_to.as_deref(), Some("attach-replay")); + assert_eq!(attached.payload["session"]["rows"], 30); + assert_eq!(attached.payload["session"]["cols"], 100); + assert_eq!(attached.payload["gap"], true); + assert_eq!(attached.payload["nextSeq"], 1); + assert_eq!( + base64_decode(replay[0]["bytesBase64"].as_str().unwrap()).unwrap(), + scrollback + ); + + close_ws_session( + &state, + attached.payload["session"]["sessionId"].as_str().unwrap(), + ) + .await; + } + + #[tokio::test] + async fn websocket_close_terminal_emits_exited_status_and_releases_session() { + let state = state(); + let (tx, mut rx) = mpsc::channel(16); + let owned = Arc::new(Mutex::new(Vec::new())); + + handle_client_frame( + ws_frame( + "open-close", + "terminal.open", + open_terminal_payload("/bin/sh", vec!["-c", "sleep 30"]), + ), + &state, + &tx, + &owned, + ) + .await; + let opened = recv_server_frame(&mut rx).await; + let session_id = attached_session_id(&opened); + + handle_client_frame( + ws_frame( + "close-1", + "terminal.close", + json!({ "sessionId": session_id }), + ), + &state, + &tx, + &owned, + ) + .await; + let status = recv_server_frame(&mut rx).await; + + assert_eq!(status.kind, "terminal.status"); + assert_eq!(status.payload["status"], "exited"); + assert_eq!(status.payload["sessionId"], session_id); + assert_eq!(state.ws_pty_bridge.active_sessions(), 0); + assert!(state.app.terminal_sessions.is_empty()); + } + + #[tokio::test] + async fn websocket_launch_agent_emits_attached_with_assigned_conversation_id() { + let state = state(); + let (project_id, agent_id) = create_raw_cli_agent_for_test(&state, "ws-launch").await; + let node_id = Uuid::new_v4().to_string(); + let (tx, mut rx) = mpsc::channel(32); + let owned = Arc::new(Mutex::new(Vec::new())); + + handle_client_frame( + ws_frame( + "agent-launch-1", + "agent.launch", + agent_launch_payload(&project_id, &agent_id, &node_id, None), + ), + &state, + &tx, + &owned, + ) + .await; + let attached = recv_server_frame(&mut rx).await; + let session_id = attached_session_id(&attached); + + assert_eq!(attached.kind, "terminal.attached"); + assert_eq!(attached.reply_to.as_deref(), Some("agent-launch-1")); + assert_eq!(attached.payload["scrollback"].as_array().unwrap().len(), 0); + assert!(attached.payload["assignedConversationId"] + .as_str() + .and_then(|raw| Uuid::parse_str(raw).ok()) + .is_some()); + + close_ws_session(&state, &session_id).await; + } + + #[tokio::test] + async fn websocket_launch_agent_structured_is_unsupported() { + let state = state(); + let (tx, mut rx) = mpsc::channel(4); + let owned = Arc::new(Mutex::new(Vec::new())); + let session_id = SessionId::new_random(); + let agent_id = AgentId::from_uuid(Uuid::new_v4()); + let node_id = NodeId::from_uuid(Uuid::new_v4()); + let size = PtySize::new(24, 80).unwrap(); + let mut session = TerminalSession::starting( + session_id, + node_id, + ProjectPath::new("/".to_owned()).unwrap(), + SessionKind::Agent { agent_id }, + size, + ); + session.status = SessionStatus::Running; + let frame = ws_frame("agent-structured", "agent.launch", json!({})); + let output = LaunchAgentOutput { + session, + assigned_conversation_id: Some("pair-conversation".to_owned()), + engine_session_id: Some("engine-session".to_owned()), + structured: Some(StructuredSessionDescriptor { + session_id, + agent_id, + node_id, + conversation_id: Some("engine-session".to_owned()), + }), + profile: None, + }; + + if let Err(err) = send_launch_agent_attached(&frame, &state, &tx, &owned, output).await { + tx.send(ServerFrame::error( + Some(frame.id), + payload_session_id(&frame.payload), + err.code, + err.message, + )) + .await + .unwrap(); + } + let error = recv_server_frame(&mut rx).await; + + assert_eq!(error.kind, "error"); + assert_eq!(error.reply_to.as_deref(), Some("agent-structured")); + assert_eq!(error.payload["code"], "UNSUPPORTED"); + assert_eq!( + error.payload["message"], + "structured agent sessions do not stream over the PTY websocket" + ); + assert_eq!(state.ws_pty_bridge.active_sessions(), 0); + } + + #[tokio::test] + async fn websocket_launch_agent_reattach_replays_scrollback_without_respawn() { + let state = state(); + let (project_id, agent_id) = create_raw_cli_agent_for_test(&state, "ws-reattach").await; + let node_id = Uuid::new_v4().to_string(); + let (tx, mut rx) = mpsc::channel(32); + let owned = Arc::new(Mutex::new(Vec::new())); + + handle_client_frame( + ws_frame( + "agent-launch-reattach", + "agent.launch", + agent_launch_payload(&project_id, &agent_id, &node_id, None), + ), + &state, + &tx, + &owned, + ) + .await; + let launched = recv_server_frame(&mut rx).await; + let session_id = attached_session_id(&launched); + let scrollback = wait_for_scrollback(&state, &session_id, b"agent-ready").await; + drain_server_frames(&mut rx); + + handle_client_frame( + ws_frame( + "agent-attach", + "terminal.attach", + json!({ "sessionId": session_id, "lastSeq": 0, "rows": 24, "cols": 80 }), + ), + &state, + &tx, + &owned, + ) + .await; + let attached = recv_server_frame_where(&mut rx, |frame| { + frame.kind == "terminal.attached" && frame.reply_to.as_deref() == Some("agent-attach") + }) + .await; + let replay = attached.payload["scrollback"].as_array().unwrap(); + + assert_eq!(attached.kind, "terminal.attached"); + assert_eq!(attached.payload["session"]["sessionId"], session_id); + assert_eq!( + base64_decode(replay[0]["bytesBase64"].as_str().unwrap()).unwrap(), + scrollback + ); + let aid = parse_agent_id(&agent_id).unwrap(); + assert_eq!( + state.app.terminal_sessions.sessions_for_agent(&aid).len(), + 1 + ); + + close_ws_session(&state, &session_id).await; + } + + #[tokio::test] + async fn websocket_launch_agent_same_cell_is_idempotent_singleton() { + let state = state(); + let (project_id, agent_id) = create_raw_cli_agent_for_test(&state, "ws-singleton").await; + let node_id = Uuid::new_v4().to_string(); + let (tx, mut rx) = mpsc::channel(32); + let owned = Arc::new(Mutex::new(Vec::new())); + + handle_client_frame( + ws_frame( + "agent-launch-first", + "agent.launch", + agent_launch_payload(&project_id, &agent_id, &node_id, None), + ), + &state, + &tx, + &owned, + ) + .await; + let first = recv_server_frame(&mut rx).await; + let first_session = attached_session_id(&first); + + handle_client_frame( + ws_frame( + "agent-launch-second", + "agent.launch", + agent_launch_payload( + &project_id, + &agent_id, + &node_id, + first.payload["assignedConversationId"].as_str(), + ), + ), + &state, + &tx, + &owned, + ) + .await; + let second = recv_server_frame_where(&mut rx, |frame| { + frame.kind == "terminal.attached" + && frame.reply_to.as_deref() == Some("agent-launch-second") + }) + .await; + + assert_eq!(second.kind, "terminal.attached"); + assert_eq!(attached_session_id(&second), first_session); + let aid = parse_agent_id(&agent_id).unwrap(); + assert_eq!( + state.app.terminal_sessions.sessions_for_agent(&aid).len(), + 1 + ); + + close_ws_session(&state, &first_session).await; + } + + #[tokio::test] + async fn websocket_launch_agent_different_cell_is_refused_by_singleton_guard() { + let state = state(); + let (project_id, agent_id) = + create_raw_cli_agent_for_test(&state, "ws-singleton-refuse").await; + let first_node = Uuid::new_v4().to_string(); + let second_node = Uuid::new_v4().to_string(); + let (tx, mut rx) = mpsc::channel(32); + let owned = Arc::new(Mutex::new(Vec::new())); + + handle_client_frame( + ws_frame( + "agent-launch-first-cell", + "agent.launch", + agent_launch_payload(&project_id, &agent_id, &first_node, None), + ), + &state, + &tx, + &owned, + ) + .await; + let first = recv_server_frame(&mut rx).await; + let session_id = attached_session_id(&first); + + handle_client_frame( + ws_frame( + "agent-launch-other-cell", + "agent.launch", + agent_launch_payload(&project_id, &agent_id, &second_node, None), + ), + &state, + &tx, + &owned, + ) + .await; + let error = recv_server_frame_where(&mut rx, |frame| { + frame.kind == "error" && frame.reply_to.as_deref() == Some("agent-launch-other-cell") + }) + .await; + + assert_eq!(error.kind, "error"); + assert_eq!(error.reply_to.as_deref(), Some("agent-launch-other-cell")); + assert_eq!(error.payload["code"], "AGENT_ALREADY_RUNNING"); + let aid = parse_agent_id(&agent_id).unwrap(); + assert_eq!( + state.app.terminal_sessions.sessions_for_agent(&aid).len(), + 1 + ); + + close_ws_session(&state, &session_id).await; + } + + #[test] + fn websocket_client_unmasked_frame_is_rejected() { + let raw = [0x81, 0x02, b'h', b'i']; + + let err = decode_client_ws_frame(&raw).unwrap_err(); + + assert!(matches!(err, WsFrameError::Protocol(_))); + } + + #[test] + fn websocket_payload_too_large_is_rejected() { + let len = (WS_MAX_PAYLOAD + 1) as u64; + let mut raw = vec![0x81, 0x80 | 127]; + raw.extend_from_slice(&len.to_be_bytes()); + raw.extend_from_slice(&[1, 2, 3, 4]); + + let err = decode_client_ws_frame(&raw).unwrap_err(); + + assert_eq!(err, WsFrameError::TooLarge); + } + + #[test] + fn websocket_server_frames_are_unmasked() { + let raw = encode_ws_frame(WsOpcode::Text, b"{}"); + + assert_eq!(raw[0], 0x81); + assert_eq!(raw[1] & 0x80, 0, "server frames must not be masked"); + } + + #[tokio::test] + async fn websocket_sink_full_reports_backpressure_without_blocking() { + let (tx, mut rx) = mpsc::channel(1); + let sid = domain::SessionId::new_random(); + let sink = WsPtySink::new(sid, tx, 0); + + assert!(sink.send(vec![1]).is_ok()); + assert_eq!(sink.send(vec![2]), Err(OutputSinkError::Full)); + let frame = rx.recv().await.unwrap(); + assert_eq!(frame.kind, "terminal.output"); + } + + #[tokio::test] + async fn websocket_output_bridge_replaces_attachment_without_double_delivery() { + let bridge = OutputBridge::::new(); + let sid = domain::SessionId::new_random(); + let (old_tx, mut old_rx) = mpsc::channel(4); + let (new_tx, mut new_rx) = mpsc::channel(4); + + let old_gen = bridge.register(sid, Arc::new(WsPtySink::new(sid, old_tx, 0))); + let _new_gen = bridge.register(sid, Arc::new(WsPtySink::new(sid, new_tx, 0))); + + assert!(bridge.send_output(&sid, vec![9])); + assert!(old_rx.try_recv().is_err()); + let frame = new_rx.recv().await.unwrap(); + assert_eq!(frame.kind, "terminal.output"); + assert_eq!(frame.payload["bytesBase64"], "CQ=="); + + bridge.unregister_if(&sid, old_gen); + assert_eq!(bridge.active_sessions(), 1); + } + + #[test] + fn public_bind_requires_explicit_remote_security() { + let config = ServerConfig { + listen: "0.0.0.0:17373".parse().unwrap(), + ..test_config() + }; + + assert!(config.validate().is_err()); + + let config = ServerConfig { + allow_remote: true, + public_origin: Some("https://idea.example.com".to_owned()), + trust_reverse_proxy: true, + ..config + }; + assert!(config.validate().is_ok()); + } + + #[test] + fn allow_remote_requires_https_origin_and_reverse_proxy() { + let base = ServerConfig { + listen: "0.0.0.0:17373".parse().unwrap(), + allow_remote: true, + ..test_config() + }; + + assert!(base.validate().is_err()); + + let with_http = ServerConfig { + public_origin: Some("http://idea.example.com".to_owned()), + trust_reverse_proxy: true, + ..base.clone() + }; + assert!(with_http.validate().is_err()); + + let without_proxy = ServerConfig { + public_origin: Some("https://idea.example.com".to_owned()), + trust_reverse_proxy: false, + ..base + }; + assert!(without_proxy.validate().is_err()); + } + + #[tokio::test] + async fn invoke_requires_valid_cookie() { + let state = state(); + + let response = request( + state, + Method::POST, + "/api/invoke", + json!({ "command": "health", "args": {} }), + &[], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(body["code"], "UNAUTHORIZED"); + } + + #[tokio::test] + async fn invoke_rejects_invalid_session_cookie() { + let state = state(); + + let response = request( + state, + Method::POST, + "/api/invoke", + json!({ "command": "health", "args": {} }), + &[("cookie", "idea_session=not-a-valid-session")], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(body["code"], "UNAUTHORIZED"); + } + + #[tokio::test] + async fn logout_revokes_session_for_http_and_websocket() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let response = request( + Arc::clone(&state), + Method::POST, + "/api/logout", + json!({}), + &[("cookie", &cookie)], + ) + .await; + let (status, body, headers) = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body, json!({ "revoked": true })); + assert!(headers + .get(SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .contains("Max-Age=0")); + + let response = request( + Arc::clone(&state), + Method::POST, + "/api/invoke", + json!({ "command": "health", "args": {} }), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(body["code"], "UNAUTHORIZED"); + + let headers = ws_headers("http://127.0.0.1:17373", Some(&cookie)); + let response = validate_ws_upgrade(&headers, &state).unwrap_err(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn security_logs_pairing_origin_ws_and_revoke_without_secrets() { + let logger = Arc::new(RecordingSecurityLogger::default()); + let state = Arc::new(ServerState::new_for_test_with_logger( + test_config(), + "PAIR1234", + logger.clone(), + )); + + let bad_origin = handle_request( + Request::builder() + .method(Method::POST) + .uri("/api/pair") + .header(ORIGIN, "https://evil.example") + .header(CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from_static(br#"{"code":"PAIR1234"}"#))) + .unwrap(), + Arc::clone(&state), + ) + .await; + assert_eq!(bad_origin.status(), StatusCode::FORBIDDEN); + + let wrong_code = request( + Arc::clone(&state), + Method::POST, + "/api/pair", + json!({ "code": "WRONG999" }), + &[], + ) + .await; + assert_eq!(wrong_code.status(), StatusCode::FORBIDDEN); + + let cookie = pair_and_cookie(Arc::clone(&state)).await; + let _ = request( + Arc::clone(&state), + Method::POST, + "/api/logout", + json!({}), + &[("cookie", &cookie)], + ) + .await; + + let headers = ws_headers( + "http://127.0.0.1:17373", + Some("idea_session=invalid-token-value"), + ); + let _ = validate_ws_upgrade(&headers, &state); + + let events = logger.events(); + assert!(events.iter().any(|event| matches!( + event, + SecurityLogEvent::OriginRejected { route, .. } if route == "/api/pair" + ))); + assert!(events.iter().any(|event| matches!( + event, + SecurityLogEvent::PairingFailed { + reason: "wrong_code", + .. + } + ))); + assert!(events + .iter() + .any(|event| matches!(event, SecurityLogEvent::PairingSucceeded { .. }))); + assert!(events + .iter() + .any(|event| matches!(event, SecurityLogEvent::SessionRevoked { .. }))); + assert!(events.iter().any(|event| matches!( + event, + SecurityLogEvent::WsUpgradeRejected { + reason: "invalid_session", + .. + } + ))); + + let lines = events.iter().map(security_log_line).collect::>(); + assert!( + !lines.iter().any(|line| line.contains("PAIR1234") + || line.contains("WRONG999") + || line.contains("invalid-token-value")), + "security logs must not leak pairing codes or session tokens" + ); + } + + #[tokio::test] + async fn pairing_rejects_wrong_code() { + let state = state(); + + let response = request( + state, + Method::POST, + "/api/pair", + json!({ "code": "WRONG999" }), + &[], + ) + .await; + let (status, body, headers) = response_json(response).await; + + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(body["code"], "FORBIDDEN"); + assert!( + !headers.contains_key(SET_COOKIE), + "wrong pairing code must not issue a session cookie" + ); + } + + #[tokio::test] + async fn pairing_sets_http_only_strict_cookie_for_loopback_dev() { + let state = state(); + + let response = request( + state, + Method::POST, + "/api/pair", + json!({ "code": "PAIR1234" }), + &[], + ) + .await; + let (status, body, headers) = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body, json!({ "paired": true })); + let cookie = headers.get(SET_COOKIE).unwrap().to_str().unwrap(); + assert!(cookie.contains("idea_session=")); + assert!(cookie.contains("HttpOnly")); + assert!(cookie.contains("SameSite=Strict")); + assert!( + !cookie.contains("Secure"), + "loopback dev over HTTP deliberately avoids Secure so browsers store it" + ); + } + + #[tokio::test] + async fn pairing_sets_secure_cookie_for_remote_https_origin() { + let config = ServerConfig { + listen: "0.0.0.0:17373".parse().unwrap(), + public_origin: Some("https://idea.example.com".to_owned()), + allow_remote: true, + trust_reverse_proxy: true, + ..test_config() + }; + let state = Arc::new(ServerState::new_for_test(config, "PAIR1234")); + + let mut req = Request::builder() + .method(Method::POST) + .uri("/api/pair") + .header(ORIGIN, "https://idea.example.com") + .header(CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from_static(br#"{"code":"PAIR1234"}"#))) + .unwrap(); + *req.headers_mut().get_mut(CONTENT_TYPE).unwrap() = + HeaderValue::from_static("application/json"); + + let response = handle_request(req, state).await; + let (status, _, headers) = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + let cookie = headers.get(SET_COOKIE).unwrap().to_str().unwrap(); + assert!(cookie.contains("Secure")); + assert!(cookie.contains("HttpOnly")); + assert!(cookie.contains("SameSite=Strict")); + } + + #[tokio::test] + async fn token_or_secret_in_query_string_is_refused() { + let state = state(); + + let response = request( + state, + Method::POST, + "/api/invoke?token=abc", + json!({ "command": "health", "args": {} }), + &[], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["code"], "INVALID"); + } + + #[tokio::test] + async fn serves_web_index_same_origin_with_csp() { + let state = state(); + + let response = request(state, Method::GET, "/", json!({}), &[]).await; + let (status, body, headers) = response_bytes(response).await; + + assert_eq!(status, StatusCode::OK); + assert!(std::str::from_utf8(&body).unwrap().contains("id=\"root\"")); + assert_eq!( + headers.get(CONTENT_TYPE).unwrap(), + "text/html; charset=utf-8" + ); + assert!(headers.contains_key("content-security-policy")); + assert_eq!(headers.get("x-content-type-options").unwrap(), "nosniff"); + } + + #[tokio::test] + async fn serves_static_assets_with_mime_types() { + let state = state(); + + let response = request(state, Method::GET, "/assets/app.js", json!({}), &[]).await; + let (status, body, headers) = response_bytes(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(std::str::from_utf8(&body).unwrap(), "console.log('idea');"); + assert_eq!( + headers.get(CONTENT_TYPE).unwrap(), + "text/javascript; charset=utf-8" + ); + } + + #[tokio::test] + async fn serves_spa_fallback_for_non_api_routes_without_extension() { + let state = state(); + + let response = request(state, Method::GET, "/projects/abc", json!({}), &[]).await; + let (status, body, headers) = response_bytes(response).await; + + assert_eq!(status, StatusCode::OK); + assert!(std::str::from_utf8(&body).unwrap().contains("id=\"root\"")); + assert!(headers.contains_key("content-security-policy")); + } + + #[tokio::test] + async fn missing_asset_and_api_routes_do_not_fallback_to_spa() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let missing_asset = request( + Arc::clone(&state), + Method::GET, + "/assets/missing.js", + json!({}), + &[], + ) + .await; + let (asset_status, asset_body, _) = response_json(missing_asset).await; + assert_eq!(asset_status, StatusCode::NOT_FOUND); + assert_eq!(asset_body["code"], "NOT_FOUND"); + + let api_unknown = request( + state, + Method::GET, + "/api/unknown", + json!({}), + &[("cookie", &cookie)], + ) + .await; + let (api_status, api_body, _) = response_json(api_unknown).await; + assert_eq!(api_status, StatusCode::NOT_FOUND); + assert_eq!(api_body["code"], "NOT_FOUND"); + } + + #[test] + fn static_route_rejects_traversal_and_dotfiles() { + let root = PathBuf::from("/tmp/web"); + + assert!(static_route(&root, "/../secret").is_none()); + assert!(static_route(&root, "/%2e%2e/secret").is_none()); + assert!(static_route(&root, "/.env").is_none()); + assert_eq!( + static_route(&root, "/dashboard").unwrap(), + StaticRoute { + candidate: root.join("dashboard"), + spa_fallback: true + } + ); + assert_eq!( + static_route(&root, "/assets/app.js").unwrap(), + StaticRoute { + candidate: root.join("assets").join("app.js"), + spa_fallback: false + } + ); + } + + #[test] + fn explicit_web_root_requires_index_html() { + let missing = std::env::temp_dir().join(format!("idea-empty-web-{}", Uuid::new_v4())); + std::fs::create_dir_all(&missing).unwrap(); + + assert!(validate_web_root(missing, "--web-root").is_err()); + assert!(validate_web_root(create_web_root(), "--web-root").is_ok()); + } + + #[tokio::test] + async fn non_allowed_origin_is_refused() { + let state = state(); + let mut req = Request::builder() + .method(Method::POST) + .uri("/api/pair") + .header(ORIGIN, "https://evil.example") + .header(CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from_static(br#"{"code":"PAIR1234"}"#))) + .unwrap(); + *req.headers_mut().get_mut(CONTENT_TYPE).unwrap() = + HeaderValue::from_static("application/json"); + + let response = handle_request(req, state).await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(body["code"], "FORBIDDEN"); + } + + #[tokio::test] + async fn unknown_allowlisted_command_returns_unknown_command() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let response = request( + state, + Method::POST, + "/api/invoke", + json!({ "command": "debug_dump", "args": {} }), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["code"], "UNKNOWN_COMMAND"); + } + + #[tokio::test] + async fn authorized_health_invoke_returns_health_report() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let response = request( + state, + Method::POST, + "/api/invoke", + json!({ "command": "health", "args": { "request": { "note": "hi" } } }), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["alive"], true); + assert_eq!(body["note"], "hi"); + } + + #[tokio::test] + async fn authorized_list_projects_returns_tauri_project_list_contract() { + let state = state(); + let project_id = create_project_for_test(&state, "Web Read").await; + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let response = request( + state, + Method::POST, + "/api/invoke", + json!({ "command": "list_projects", "args": {} }), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + let projects = body + .as_array() + .expect("ProjectListDto is a transparent array"); + let project = projects + .iter() + .find(|project| project["id"] == project_id) + .expect("created project is listed"); + assert_eq!(project["name"], "Web Read"); + assert!(project["root"] + .as_str() + .is_some_and(|root| root.contains("idea-server-project-"))); + } + + #[tokio::test] + async fn authorized_open_project_returns_readonly_project_dto() { + let state = state(); + let project_id = create_project_for_test(&state, "Readonly Open").await; + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let response = request( + state, + Method::POST, + "/api/invoke", + json!({ "command": "open_project", "args": { "projectId": project_id } }), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["id"], project_id); + assert_eq!(body["name"], "Readonly Open"); + assert!(body["root"].is_string()); + } + + #[tokio::test] + async fn authorized_get_project_work_state_returns_tauri_contract() { + let state = state(); + let project_id = create_project_for_test(&state, "Readonly Work").await; + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let response = request( + state, + Method::POST, + "/api/invoke", + json!({ "command": "get_project_work_state", "args": { "projectId": project_id } }), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert!(body["agents"].as_array().is_some()); + assert!(body["conversations"].as_array().is_some()); + } + + #[tokio::test] + async fn authorized_list_background_tasks_returns_tauri_contract() { + let state = state(); + let project_id = create_project_for_test(&state, "Background Snapshot").await; + let (task_id, owner) = + create_background_task_for_test(&state, &project_id, "web background").await; + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let response = request( + state, + Method::POST, + "/api/invoke", + json!({ + "command": "list_background_tasks", + "args": { "projectId": project_id, "agentId": owner.to_string() } + }), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + let tasks = body.as_array().expect("background task list is an array"); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0]["taskId"], task_id.to_string()); + assert_eq!(tasks[0]["ownerAgentId"], owner.to_string()); + assert_eq!(tasks[0]["projectId"], project_id); + assert_eq!(tasks[0]["kind"], "command"); + assert_eq!(tasks[0]["state"], "queued"); + } + + #[tokio::test] + async fn background_actions_are_allowlisted_with_auth_gate() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + let bad_task_id = "not-a-task-id"; + + for command in ["cancel_background_task", "retry_background_task"] { + let response = request( + Arc::clone(&state), + Method::POST, + "/api/invoke", + json!({ "command": command, "args": { "taskId": bad_task_id } }), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + body["code"], "INVALID", + "{command} must be an explicit action, not UNKNOWN_COMMAND" + ); + } + } + + #[tokio::test] + async fn mutation_and_pty_commands_stay_out_of_readonly_allowlist() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + for command in ["create_project", "open_terminal", "launch_agent"] { + let response = request( + Arc::clone(&state), + Method::POST, + "/api/invoke", + json!({ "command": command, "args": {} }), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + body["code"], "UNKNOWN_COMMAND", + "{command} must stay blocked" + ); + } + } + + #[tokio::test] + async fn invalid_project_id_is_mapped_to_error_dto() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let response = request( + state, + Method::POST, + "/api/invoke", + json!({ "command": "open_project", "args": { "projectId": "nope" } }), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["code"], "INVALID"); + } + + #[test] + fn direct_project_id_parser_still_rejects_invalid_ids() { + assert!(parse_project_id("not-a-uuid").is_err()); + } +}