From f72a192d4dd3f25f6fe1757d52b117abbfe0994d Mon Sep 17 00:00:00 2001 From: fy59 Date: Mon, 28 Sep 2026 17:27:01 +0200 Subject: [PATCH] feat(web): deploy local investigation workbench with Qwen --- README.md | 7 ++ docs/ROADMAP.md | 6 + docs/architecture/OPERATIONAL_AGENT.md | 7 ++ docs/architecture/WEB_WORKSPACE_CONTROL.md | 9 ++ docs/ui/WEB_WORKBENCH.md | 42 +++++++ packaging/nginx/invest.labfy.conf | 16 +++ .../systemd/labfy-investigation-web.service | 22 ++++ prototypes/web-graph/public/app.js | 87 +++++++++---- prototypes/web-graph/public/index.html | 3 +- .../web-graph/tests/run_browser_suite.mjs | 3 + .../web-graph/tests/test_automatic_session.py | 34 ++++++ .../tests/test_lazy_library_browser.mjs | 80 ++++++++++++ .../tests/test_local_model_agent_browser.mjs | 2 +- .../tests/test_operational_agent_browser.mjs | 2 +- prototypes/web-graph/tests/test_web_app.py | 44 +++++++ prototypes/web-graph/web_app.py | 19 ++- prototypes/web-graph/web_library.py | 26 +++- prototypes/web-graph/workspace_server.py | 30 ++++- scripts/apply-local-web-root.py | 115 ++++++++++++++++++ scripts/labfy-investigation-web | 19 +++ 20 files changed, 532 insertions(+), 41 deletions(-) create mode 100644 packaging/nginx/invest.labfy.conf create mode 100644 packaging/systemd/labfy-investigation-web.service create mode 100644 prototypes/web-graph/tests/test_lazy_library_browser.mjs create mode 100644 scripts/apply-local-web-root.py create mode 100755 scripts/labfy-investigation-web diff --git a/README.md b/README.md index 0d62bd3..7c9d446 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,13 @@ contextuel, les tâches, le planner J8 et les rapports J9. Cette disponibilité locale ne change ni le statut de production, ni l’interdiction d’utiliser une enquête réelle pendant les validations de développement. +Le déploiement personnel local de `http://invest.labfy` repose sur une unité +systemd utilisateur et un proxy nginx limités à `127.0.0.1`. Sur cette machine, +le service démarre sans inspecter la bibliothèque configurée ; l'utilisateur +charge sa liste, puis ouvre une enquête par deux actions explicites. Labfy +démarre le Qwen local configuré dans XDG pour le prompt Agent existant. Les étapes +d’installation et de retrait sont dans le [guide du poste Web](docs/ui/WEB_WORKBENCH.md). + Le parcours local expérimental d’import et de revue est détaillé dans [le contrat d’espace local](docs/architecture/LOCAL_WORKSPACE_IMPORT.md) : l’aperçu EML/PNG/JPEG provient du cœur C après contrôle d’intégrité ; les diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0335a40..ef2cf2f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -10,6 +10,12 @@ > L'exécution intrusive reste `LAB-ONLY TARGET`; Tool Auto-Provisioning, > Resource Governor et Persistent Agent Memory restent `TARGET`. +> Déploiement personnel `invest.labfy` : `CURRENT local` sur cette machine +> après validation `SPECIMEN` du proxy nginx, de la résolution hostname, +> du service systemd utilisateur, du démarrage différé de la bibliothèque et +> du prompt Agent avec Qwen réel (`investigation.search`, final `COMPLETED`). +> Aucune enquête réelle n'a été ouverte pendant cette validation locale. + ## 1. Ordre et portes Labfy évolue sans réécriture générale. Les contrats du cœur précèdent les diff --git a/docs/architecture/OPERATIONAL_AGENT.md b/docs/architecture/OPERATIONAL_AGENT.md index 5b660ae..033d7bb 100644 --- a/docs/architecture/OPERATIONAL_AGENT.md +++ b/docs/architecture/OPERATIONAL_AGENT.md @@ -42,6 +42,13 @@ puis ciblé après délai, ne concerne que son PID/groupe de processus possédé Le chemin de modèle reste hors du dépôt. Sans configuration, binaire ou healthcheck valides, l'agent est indisponible sans bascule cloud ou fake. +Le service local utilise `--agent-mode local-model --agent-autostart +--agent-timeout 90`. Le superviseur démarre son propre serveur modèle sur un +port loopback éphémère ; le navigateur appelle seulement Labfy. Le champ +« Objectif utilisateur » existant lance le turn Qwen et ses appels d'outils +passent par `AgentRuntime` et les capabilities. L'interface distingue Qwen prêt, +occupé et indisponible. Le choix manuel d'outil reste dans « Mode expert/debug ». + ## Limites actuelles La fondation Sandbox/Tool Registry et l'egress privacy sont décrites dans leurs diff --git a/docs/architecture/WEB_WORKSPACE_CONTROL.md b/docs/architecture/WEB_WORKSPACE_CONTROL.md index 3693737..d736c79 100644 --- a/docs/architecture/WEB_WORKSPACE_CONTROL.md +++ b/docs/architecture/WEB_WORKSPACE_CONTROL.md @@ -41,6 +41,15 @@ le code éphémère. Ne pas consigner ce code dans des scripts, URLs, journaux o documents. Le mode `--workspace` reste le parcours ciblé existant ; il ne crée pas une bibliothèque implicite. +Le service personnel `invest.labfy` emploie `--lazy-library`. Dans ce mode, +le démarrage conserve seulement le chemin configuré : il ne crée ni ne lit la +bibliothèque et n'ouvre aucune enquête. La première page établit la session +automatique sans appeler l'API de liste. L'action visible « Charger les +enquêtes » déclenche seule l'initialisation et la lecture du registre ; +l'ouverture d'une enquête exige ensuite un clic distinct. Le workspace +inactif du serveur réside dans son espace XDG privé, hors de la bibliothèque. +Le parcours `make web` sans cette option conserve son chargement initial. + ## Bibliothèque, état XDG et limites La bibliothèque est privée (`0700`), refuse les liens symboliques et conserve diff --git a/docs/ui/WEB_WORKBENCH.md b/docs/ui/WEB_WORKBENCH.md index 29d3533..b8c92e7 100644 --- a/docs/ui/WEB_WORKBENCH.md +++ b/docs/ui/WEB_WORKBENCH.md @@ -113,3 +113,45 @@ Le panneau Agent indique explicitement le parcours déterministe, un modèle loc configuré ou son indisponibilité. Les tours de modèle sont asynchrones, bornés et leurs cartes ne rendent jamais de HTML, de prompt complet ou de raisonnement privé. Le contrat de ce runtime est [LOCAL_MODEL_AGENT_RUNTIME.md](../architecture/LOCAL_MODEL_AGENT_RUNTIME.md). + +## Déploiement personnel `invest.labfy` + +Les templates [systemd](../../packaging/systemd/labfy-investigation-web.service) +et [nginx](../../packaging/nginx/invest.labfy.conf) prévoient un poste publié uniquement +sur la boucle locale. Le wrapper [labfy-investigation-web](../../scripts/labfy-investigation-web) +exécute `web_app.py serve --library --lazy-library` en avant-plan sur le port dédié `8091`, +avec session automatique et Qwen local démarré par Labfy. Le chemin de la +bibliothèque et celui du modèle restent dans les fichiers XDG privés, jamais +dans Git. L'unité est installée dans `~/.config/systemd/user/` et le wrapper +est lié depuis `~/.local/bin/`. `web.env` contient `LABFY_LIBRARY` (chemin +absolu explicitement choisi) et `LABFY_WEB_PORT=8091`, avec mode `0600`. +`agent.json` suit le contrat de configuration de `LocalModelSupervisor`. +À l'arrivée, la page n'inspecte pas la bibliothèque et n'ouvre aucune enquête. +L'utilisateur choisit « Charger les enquêtes », puis ouvre explicitement +l'enquête souhaitée. Le lancement du service seul ne lit pas leurs données. + +Le bloc nginx doit être placé dans `http {}` de `/etc/nginx/nginx.conf`, puis +`127.0.0.1 invest.labfy` doit être ajouté une fois à `/etc/hosts`. +Exécuter `nginx -t` avant de recharger nginx. Le backend vérifie exactement +`Host: invest.labfy` et `Origin: http://invest.labfy` pour ce mode ; il garde +son autorité loopback propre, le CSRF et le cookie HttpOnly/SameSite=Strict. +Le proxy préserve Host et Origin, désactive le buffering pour l'activité et +borne la lecture à 180 s. Il ne rend jamais le port modèle au navigateur. +Une fois la validation `SPECIMEN` terminée, l'opérateur peut appliquer les deux +changements root par `sudo python3 scripts/apply-local-web-root.py` depuis la +racine du dépôt. Le script refuse les conflits, crée des sauvegardes distinctes +dans `/etc`, valide la configuration réelle par `nginx -t`, puis recharge nginx. +Un second passage ne duplique rien. + +Pour les validations, configurer temporairement `LABFY_LIBRARY` vers une +bibliothèque `SPECIMEN`, puis lancer `systemctl --user start +labfy-investigation-web.service`. Après validation, arrêter l'unité et +remettre le chemin personnel sans l'ouvrir. Le service peut alors être activé +et démarré : son mode différé attend une action explicite dans l'interface +avant de consulter la bibliothèque. Les validations utilisent toujours une +bibliothèque `SPECIMEN` distincte. +Le retrait est : `systemctl --user disable --now +labfy-investigation-web.service`, supprimer uniquement l'unité et le lien +utilisateur installés, retirer le bloc `invest.labfy` et sa ligne hosts, puis +valider `nginx -t` avant rechargement. Ne toucher ni au bloc Trainlog ni à son +service. diff --git a/packaging/nginx/invest.labfy.conf b/packaging/nginx/invest.labfy.conf new file mode 100644 index 0000000..123f9cf --- /dev/null +++ b/packaging/nginx/invest.labfy.conf @@ -0,0 +1,16 @@ +# CONTRACT: this server is included inside nginx's http block. Backend and +# public hostname stay on loopback; Host and Origin reach Labfy unchanged. +server { + listen 127.0.0.1:80; + server_name invest.labfy; + + location / { + proxy_pass http://127.0.0.1:8091; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 180s; + } +} diff --git a/packaging/systemd/labfy-investigation-web.service b/packaging/systemd/labfy-investigation-web.service new file mode 100644 index 0000000..407caa9 --- /dev/null +++ b/packaging/systemd/labfy-investigation-web.service @@ -0,0 +1,22 @@ +[Unit] +Description=Labfy Investigation Web local service +After=default.target + +[Service] +Type=simple +EnvironmentFile=%h/.config/labfy-investigation/web.env +ExecStart=%h/.local/bin/labfy-investigation-web +Restart=on-failure +RestartSec=10 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectControlGroups=true +ProtectKernelModules=true +ProtectKernelTunables=true +RestrictSUIDSGID=true +LockPersonality=true +SystemCallArchitectures=native + +[Install] +WantedBy=default.target diff --git a/prototypes/web-graph/public/app.js b/prototypes/web-graph/public/app.js index c890530..6da0704 100644 --- a/prototypes/web-graph/public/app.js +++ b/prototypes/web-graph/public/app.js @@ -9,6 +9,17 @@ import { GraphRenderer, presentationKind } from "./graph-renderer.js"; const byId = (id) => document.getElementById(id); const svg = byId("graph"); +function newUuid() { + // CONTRACT: le poste local peut être servi en HTTP sous un nom dédié. Firefox + // y expose getRandomValues, mais réserve randomUUID aux contextes sécurisés. + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + function prepareSnapshot(value) { if (value?.contract === "labfy.web_graph.error.v1") { return { @@ -416,7 +427,7 @@ async function startAgentMission() { max_tool_calls: 16, }, human_confirmed: true, - idempotency_key: crypto.randomUUID(), + idempotency_key: newUuid(), }); renderAgentMission(value); appendAgentActivity({ @@ -433,7 +444,7 @@ async function cancelAgentMission() { workspace_id: workspaceContext.workspaceId, mission_id: missionId, human_confirmed: true, - idempotency_key: crypto.randomUUID(), + idempotency_key: newUuid(), }); renderAgentMission(value); appendAgentActivity({ @@ -461,11 +472,11 @@ async function decideAgentProposal(record, action) { `/api/v1/agent-proposals/${encodeURIComponent(record.proposal_id)}/${action}`, { workspace_id: workspaceContext.workspaceId, - decision_id: crypto.randomUUID(), + decision_id: newUuid(), reason: "Décision explicite depuis l’interface locale", decided_by: "opérateur local", decided_at: new Date().toISOString(), - idempotency_key: crypto.randomUUID(), + idempotency_key: newUuid(), }, ); await refreshAgentProposals(); @@ -542,9 +553,9 @@ async function loadAgentRuntime() { if (value.mode === "DETERMINISTIC_DEMO") return false; agentMode = "LOCAL"; if (value.mode === "LOCAL_MODEL" && value.available === true) { - byId("agent-status").textContent = `Agent local · ${value.model ?? "modèle configuré"}`; + byId("agent-status").textContent = `Qwen prêt · ${value.model ?? "modèle configuré"}`; byId("agent-send").disabled = false; - byId("agent-system-qwen").textContent = value.model ?? "disponible"; + byId("agent-system-qwen").textContent = "prêt"; } else { byId("agent-status").textContent = `Modèle local indisponible · ${shortAgentReason(value.reason)}`; @@ -754,6 +765,10 @@ async function refreshAgentEvents() { clearInterval(agentEventTimer); agentEventTimer = null; } + if (turn?.terminal && agentMode === "LOCAL") { + byId("agent-system-qwen").textContent = + agentTurnState === "MODEL_UNAVAILABLE" ? "indisponible" : "prêt"; + } byId("activity-filter").dispatchEvent(new Event("change")); } finally { agentEventsRefreshing = false; @@ -784,8 +799,9 @@ async function callAgentTool() { const path = agentMode === "LOCAL" ? "/api/v1/agent-runtime/turns" : "/api/v1/agent-tools/turns"; const body = agentMode === "LOCAL" - ? { objective, idempotency_key: crypto.randomUUID() } : { objective }; + ? { objective, idempotency_key: newUuid() } : { objective }; const admitted = await postCommand(path, body); + if (agentMode === "LOCAL") byId("agent-system-qwen").textContent = "occupé"; activeAgentTurnId = admitted.turn_id; agentTurnState = admitted.state ?? "QUEUED"; agentEventCursor = 0; @@ -829,7 +845,7 @@ async function evidenceJson(path) { function reviewEnvelope(observation, reason) { return { - operation_id: crypto.randomUUID(), + operation_id: newUuid(), expected_revision: String(observation.revision), author: "opérateur local", reason, @@ -986,7 +1002,7 @@ function configureImport() { // CONTRACT: la sélection reste multiple, mais le client respecte la // concurrence serveur au lieu de transformer le troisième fichier en // erreur artificielle. La limite reste contrôlée côté serveur. - const selectionId=crypto.randomUUID(); + const selectionId=newUuid(); for(const file of files) await receiveFile(file,selectionId); event.target.value=""; }); @@ -994,7 +1010,7 @@ function configureImport() { byId("import-confirm").disabled=true; for(const [id,prepared] of [...preparedUploads]){ try{await postCommand(`/api/v1/uploads/${id}/confirm`,{ - idempotency_key:crypto.randomUUID(),source:byId("import-source").value || "Non déclarée", + idempotency_key:newUuid(),source:byId("import-source").value || "Non déclarée", description:byId("import-description").value || "Sans commentaire"}); preparedUploads.delete(id);byId("import-status").textContent=`${prepared.name} importé avec l’UUID ${prepared.evidence_id}.`; }catch(error){byId("import-status").textContent=error.message;} @@ -1358,7 +1374,7 @@ async function prepareResearch() { selection_ids: [node.id], question: byId("research-question").value.trim(), exclusions, - idempotency_key: crypto.randomUUID(), + idempotency_key: newUuid(), }); byId("research-note").textContent = "Plan préparé par le cœur C. Choisissez chaque action avant le lancement final."; @@ -1382,7 +1398,7 @@ async function launchResearch() { selected_action_ids: actionIds, decisions, exclusions, - idempotency_key: crypto.randomUUID(), + idempotency_key: newUuid(), }); const grantId = grant.grants[0]?.grant_id; if (!grantId) throw new Error("Autorisation durable absente."); @@ -1390,7 +1406,7 @@ async function launchResearch() { grant_id: grantId, input_revision: grant.input_revision, action_ids: actionIds, - idempotency_key: crypto.randomUUID(), + idempotency_key: newUuid(), }); // CONTRACT: seul le flux C existant persiste grant et campagne. Le turn ne // reprend qu'après ce succès ; l'agent ne possède aucune voie d'autorisation. @@ -1839,7 +1855,7 @@ async function generateReport() { if (!reportPreview) return; const admission = await postCommand("/api/v1/reports", { preview_revision: reportPreview.revision, - idempotency_key: crypto.randomUUID(), + idempotency_key: newUuid(), }); byId("report-status").textContent = "Génération locale en cours…"; for (let i = 0; i < 100; i++) { @@ -1897,7 +1913,7 @@ async function runCapability(capability) { if (button) button.disabled = true; const storageKey = `labfy-intent:${workspaceContext.workspaceId}:` + `${workspaceContext.generation}:${node.object_id}:${capability.id}`; - const key = sessionStorage.getItem(storageKey) ?? crypto.randomUUID(); + const key = sessionStorage.getItem(storageKey) ?? newUuid(); sessionStorage.setItem(storageKey, key); try { await postCommand("/api/v1/jobs", { @@ -2442,24 +2458,31 @@ function renderLibrary(value) { async function refreshLibrary() { const value = await libraryRequest("/api/v1/library", { cache: "no-store" }); renderLibrary(value); + byId("library-load").hidden = true; + byId("workspace-create").hidden = false; + byId("library-list-section").hidden = false; return value; } -async function showLibrary({ focus = false } = {}) { +async function showLibrary({ focus = false, load = true } = {}) { invalidateWorkspaceContext(); for (const dialog of document.querySelectorAll("dialog[open]")) dialog.close(); preparedUploads.clear(); workspaceReady = false; byId("workbench-shell").hidden = true; byId("library-home").hidden = false; - byId("workspace-create").hidden = false; - byId("library-list-section").hidden = false; + byId("workspace-create").hidden = !load; + byId("library-list-section").hidden = !load; + byId("library-load").hidden = load; byId("library-title").textContent = "Bibliothèque d’enquêtes"; - byId("library-connection").textContent = "Actualisation…"; - try { - await refreshLibrary(); - } catch (error) { - byId("library-connection").textContent = error.message; + byId("library-connection").textContent = load + ? "Actualisation…" : "Bibliothèque non chargée"; + if (load) { + try { + await refreshLibrary(); + } catch (error) { + byId("library-connection").textContent = error.message; + } } if (focus) byId("library-title").focus(); } @@ -2654,6 +2677,18 @@ function configureApplication() { } }); byId("home-button").addEventListener("click", () => void showLibrary({ focus: true })); + byId("library-load").addEventListener("click", async (event) => { + const button = event.currentTarget; + button.disabled = true; + byId("library-connection").textContent = "Actualisation…"; + try { + await refreshLibrary(); + } catch (error) { + byId("library-connection").textContent = error.message; + } finally { + button.disabled = false; + } + }); byId("workspace-create-form").addEventListener("submit", async (event) => { event.preventDefault(); const button = event.currentTarget.querySelector("button"); @@ -2662,7 +2697,7 @@ function configureApplication() { try { const title = byId("workspace-title").value.trim(); if (pendingCreateIntent?.title !== title) - pendingCreateIntent = { title, idempotencyKey: crypto.randomUUID() }; + pendingCreateIntent = { title, idempotencyKey: newUuid() }; const path = libraryMode ? "/api/v1/library/workspaces" : "/api/v1/workspace"; const body = libraryMode ? { title, idempotency_key: pendingCreateIntent.idempotencyKey } @@ -2731,7 +2766,7 @@ function configureApplication() { ...byId("planner-list").querySelectorAll("input:checked"), ].map((input) => input.value); if (!ids.length || !plannerValue) return; - const key = crypto.randomUUID(); + const key = newUuid(); try { await postCommand("/api/v1/plans", { recommendation_ids: ids, @@ -2782,7 +2817,7 @@ async function start() { configureApplication(); // WHY: seule l'application bibliothèque ouvre toujours sur l'accueil ; les // serveurs historiques et de démonstration conservent leur contrat direct. - if (libraryMode) await showLibrary(); + if (libraryMode) await showLibrary({ load: session?.library_load_required !== true }); else if (session?.workspace_state === "EMPTY") showLegacyCreation(); else await activateLegacyWorkspace(session); } catch (error) { diff --git a/prototypes/web-graph/public/index.html b/prototypes/web-graph/public/index.html index c369260..f0cfbd7 100644 --- a/prototypes/web-graph/public/index.html +++ b/prototypes/web-graph/public/index.html @@ -12,6 +12,7 @@
Poste local

Bibliothèque d’enquêtes

Chargement… +

Nouvelle enquête

Crée un espace local vide. Aucun contenu d’exemple n’est ajouté.

@@ -92,7 +93,7 @@