feat(workspace): open existing investigations with isolated Qwen context

This commit is contained in:
fy59 2026-09-29 18:46:04 +02:00
parent 73086405da
commit b2fc42eb24
25 changed files with 2323 additions and 314 deletions

View file

@ -30,7 +30,9 @@ 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
charge sa liste, peut [découvrir et enregistrer un dossier existant](docs/architecture/EXISTING_INVESTIGATION_LIBRARY.md)
sous `LABFY_LIBRARY` sans copie, puis ouvre l'enquête. « Fermer l’enquête » vide
le contexte actif avant une autre ouverture. 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).

View file

@ -8,6 +8,11 @@ Le contrôle navigateur local sur espace exclusivement synthétique est décrit
dans [WEB_WORKSPACE_CONTROL.md](architecture/WEB_WORKSPACE_CONTROL.md). Cette
surface mutationnelle J6 est distincte des démonstrations read-only J2–J5.
La [bibliothèque d'enquêtes existantes](architecture/EXISTING_INVESTIGATION_LIBRARY.md)
est `CURRENT local` sur `SPECIMEN` : discovery explicite limitée aux enfants
directs, registre v2 rétrocompatible, fermeture et changement d'enquête avec
contexte Agent isolé. Elle n'ajoute aucun accès filesystem ou SQLite au modèle.
La [recherche OSINT assistée V1](architecture/OSINT_ASSISTED_RESEARCH.md) est
`CURRENT` dans le worktree uniquement pour son parcours de laboratoire : le
JobStore V4 est additif à V3, les grants sont explicites et aucun fournisseur

View file

@ -23,6 +23,13 @@
> du prompt Agent avec Qwen réel (`investigation.search`, final `COMPLETED`).
> Aucune enquête réelle n'a été ouverte pendant cette validation locale.
> Ouverture d'enquêtes existantes : `CURRENT local` sur deux dossiers
> `SPECIMEN` après validation A → fermeture → B avec Qwen réel. Le registre
> v2 conserve les entrées managed et existing ; la discovery reste explicite
> et confinée à `LABFY_LIBRARY`. Le sélecteur de chemin arbitraire, la
> migration SQLite automatique et plusieurs enquêtes actives restent `TARGET`
> ou hors périmètre. Voir [bibliothèque existante](architecture/EXISTING_INVESTIGATION_LIBRARY.md).
## 1. Ordre et portes
Labfy évolue sans réécriture générale. Les contrats du cœur précèdent les

View file

@ -0,0 +1,77 @@
# Enquêtes existantes dans la bibliothèque locale — CURRENT local
Ce parcours a été validé uniquement avec deux enquêtes `SPECIMEN` temporaires.
Il ne donne ni accès à un sélecteur de chemin arbitraire, ni autorisation de
traiter une enquête réelle pendant le développement.
## Parcours humain
Le démarrage `--lazy-library` ne lit pas la bibliothèque. Depuis le poste Web,
« Charger les enquêtes » lit le registre, puis « Rechercher les dossiers
existants » déclenche une discovery distincte. Celle-ci examine uniquement les
enfants directs de `LABFY_LIBRARY`, sans descendre dans les sous-dossiers.
L'utilisateur enregistre un candidat, l'ouvre, puis peut choisir « Fermer
l’enquête » avant d'en ouvrir une autre. « Accueil » ne ferme pas l'enquête.
Une seule enquête est active par instance.
Les routes `POST /api/v1/library/discover-existing`,
`POST /api/v1/library/register-existing` et `POST /api/v1/library/close`
appliquent les contrôles de session, Host, Origin, CSRF, type et taille de corps
du serveur Web. Le navigateur ne transmet qu'un `candidate_id` opaque, une
génération et une clé d'idempotence ; il ne peut fournir un chemin serveur.
La réponse de discovery expose un nom d'affichage, un titre, un identifiant
d'enquête éventuel, un état et une raison bornée, jamais un chemin absolu.
## Registre et validation
`labfy.web_library.registry.v2` conserve des entrées `MANAGED` sous
`workspaces/<uuid>` et des entrées `EXISTING` référencées par un nom d'enfant
direct. La référence reste privée et relative à la bibliothèque. Le lecteur
accepte aussi `registry.v1` ; la conversion en v2 n'a lieu qu'à la première
mutation humaine explicite. L'écriture utilise un temporaire privé, `fsync`,
remplacement atomique et `fsync` du répertoire. Des identifiants, références ou
clés d'idempotence dupliqués sont refusés. Une entrée enregistrée devenue
absente ou invalide apparaît `MISSING` ou `INVALID` sans bloquer les autres.
La discovery est en lecture seule. Elle ignore les fichiers, dossiers cachés,
éléments internes et sous-dossiers imbriqués ; elle borne sa réponse à 256
candidats. Elle refuse les liens symboliques du dossier, des parents de
métadonnées, de la base, du JobStore et du manifeste. Le bridge C
`validate-workspace-json` vérifie en lecture seule le schéma runtime courant,
l'identité, le titre et le JobStore de la même enquête. Il ne publie aucun
snapshot et ne migre aucune base.
Les états des candidats sont `READY_TO_REGISTER`, `ALREADY_REGISTERED`,
`NEEDS_METADATA_MIGRATION`, `UNSUPPORTED_LEGACY_LAYOUT` et `INVALID`.
Un dossier prêt est inscrit par une mise à jour du registre uniquement : ni
copie, ni déplacement, ni renommage, ni modification de fichiers métier.
Lorsqu'une base et un JobStore sont déjà compatibles mais que seul
`workspace.json` manque, une action humaine séparée peut créer ce manifeste
après prévisualisation. La base reste inchangée. Un manifeste présent mais
invalide n'est jamais écrasé. Aucune migration SQLite n'est effectuée.
L'inscription revalide le candidat et sa signature au moment de la mutation.
Si le dossier a changé, l'utilisateur doit relancer la discovery. Une même
intention et une même clé se rejouent sans second enregistrement ; une clé liée
à une autre intention est refusée. L'ouverture revalide le registre, le
mapping de stockage, le manifeste et les données via le bridge C avant de
publier l'identité active. Un échec d'export ne laisse pas de demi-ouverture.
## Fermeture et contexte Qwen
La fermeture annule les turns Agent non terminaux du workspace et attend de
façon bornée les appels déjà en cours avant d'effacer l'identité active. Elle
terminalise la mission active. Les propositions et demandes historiques gardent
leur provenance, mais ne deviennent pas actives dans l'enquête suivante.
L'interface abandonne ses requêtes en cours et réinitialise graphe, sélection,
panneaux Agent, mission, propositions, toolbox et code change.
Sans enquête active, le démarrage d'un turn est refusé et l'interface affiche
« Ouvrez une enquête pour utiliser Qwen. ». Après A → fermeture → B,
`InvestigationContext` est reconstruit depuis B. Les références objet et les
continuations de A ne sont pas acceptées sous B. Qwen utilise les capabilities
existantes ; aucun accès direct au filesystem ou à SQLite ne lui est ajouté.
Un sélecteur de chemin extérieur à `LABFY_LIBRARY`, la discovery récursive,
une migration automatique de base et plusieurs contextes actifs simultanés
restent hors de ce contrat.

View file

@ -14,6 +14,13 @@ compteurs, types, sélection et activité récente bornée. Il ne reçoit ni SQL
ni chemins, ni corpus de preuves. Les références sont réévaluées dans le
workspace actif.
Le [parcours d'enquête existante](EXISTING_INVESTIGATION_LIBRARY.md) ferme
explicitement A avant l'ouverture de B. Les turns non terminaux de A sont
annulés avant le changement, et un nouveau turn reçoit un
`InvestigationContext` reconstruit depuis B seulement. Sans workspace actif,
le backend refuse de démarrer Qwen ; les continuations, références et demandes
de tooling de A ne sont pas admises dans B.
Les corrélations sont des candidats déterministes d'égalité normalisée (email,
domaine, téléphone, username, URL, IP, IBAN/BIC, hash, identifiant structuré,
provenance ou observation). Elles gardent les sources et `confidence_kind`; elles

View file

@ -1,7 +1,7 @@
# Poste de travail Web contrôlé — J6 local
> État : `CURRENT` pour le poste Web local contrôlé et sa bibliothèque
> explicitement choisie. Runtime métier : V20 inchangé. JobStore : V3 inchangé.
> explicitement choisie. Runtime métier : V20 inchangé. JobStore : V4 inchangé.
> V21 reste indépendante.
## Parcours
@ -17,8 +17,8 @@ L'instance écoute sur `127.0.0.1:8081` dans cet exemple. La navigation racine
établit automatiquement une session locale HttpOnly et ouvre directement la
bibliothèque, sans formulaire ni code de session. La bibliothèque permet de
créer une enquête locale par le bridge C ou d'ouvrir une enquête enregistrée.
Une seule enquête peut être active par instance ; une
autre ouverture exige le redémarrage du poste local. L'utilisateur sélectionne
Une seule enquête peut être active par instance ; une autre ouverture exige sa
fermeture explicite, puis l'ouverture suivante. L'utilisateur sélectionne
ensuite l’EML ou l’image dans le graphe et choisit « Analyser les en-têtes » ou
« Examiner les métadonnées ». L’intention est persistée avant la réponse HTTP
`202`. Le worker C exécute le service J3/J4 correspondant, puis C republie
@ -46,19 +46,24 @@ 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
« Rechercher les dossiers existants » lance ensuite la discovery explicite
des enfants directs. L'enregistrement et l'ouverture exigent chacun 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
un registre atomique borné à 256 enquêtes. Chaque enquête est un enfant direct
identifié par UUID ; le bridge C existant est le seul créateur de son runtime
V20 et de son JobStore V3. La création est idempotente pour une même clé et le
même titre ; réemployer la clé avec un autre titre est refusé. L'ouverture exige
un registre atomique borné à 256 enquêtes. Les entrées `MANAGED` vivent sous
`workspaces/<uuid>` ; les entrées `EXISTING` sont des enfants directs référencés
sans chemin absolu exposé. Le bridge C existant reste le seul créateur du runtime
V20 et du JobStore lors d'une création managed. La création est idempotente
pour une même clé et le même titre ; réemployer la clé avec un autre titre est
refusé. L'ouverture exige
la génération courante du registre afin de ne pas utiliser une projection
périmée.
périmée. La discovery, l'adoption metadata only et l'isolation après fermeture
sont détaillées dans [le contrat de bibliothèque](EXISTING_INVESTIGATION_LIBRARY.md).
L'instance place ses verrous, identité et code d'amorçage dans
`$XDG_RUNTIME_DIR/labfy-investigation-web/`, ou dans un repli privé par UID

View file

@ -10,9 +10,13 @@
À l'ouverture, le lanceur `tools/labfy` démarre ou rejoint l’instance loopback
possédée, ouvre le navigateur et établit une session HttpOnly locale sans code
manuel. La bibliothèque affiche les enquêtes locales enregistrées et
permet d'en créer une avec un titre explicite. Une enquête ouverte reste la
permet d'en créer une avec un titre explicite. Elle peut aussi découvrir les
dossiers existants sous `LABFY_LIBRARY` après « Rechercher les dossiers
existants », puis les enregistrer sans copie. Une enquête ouverte reste la
seule enquête active de cette instance ; le poste n'en restaure pas une autre
automatiquement. Le titre et la génération de la bibliothèque identifient le
automatiquement. « Fermer l’enquête » permet ensuite d'en ouvrir une autre ;
« Accueil » ne ferme pas l'enquête. Le titre et la génération de la bibliothèque
identifient le
contexte affiché, afin qu'une réponse tardive d'une enquête précédente ne le
repeuple pas.
@ -138,7 +142,11 @@ 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.
l'enquête souhaitée ou lance la discovery des enfants directs et enregistre un
dossier détecté. La fiche présente nom, titre, état et raison sans chemin
absolu ; une adoption de métadonnées demande une confirmation explicite.
Le lancement du service seul ne lit pas leurs données. Sans enquête ouverte,
le prompt indique « Ouvrez une enquête pour utiliser Qwen. ».
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`.

View file

@ -232,6 +232,36 @@ class AgentRuntime:
self._set_state(turn, "CANCELLED", "Annulation demandée")
return self._snapshot(turn)
def cancel_scope(self, scope, timeout=2.0):
"""Cancel every non-terminal turn in one workspace scope, then wait bounded."""
if (not isinstance(scope, str) or not scope or
not isinstance(timeout, (int, float)) or isinstance(timeout, bool) or
timeout < 0):
raise AgentRuntimeError("Annulation de scope invalide")
deadline = time.monotonic() + timeout
with self._changed:
scoped = [turn for (turn_scope, _turn_id), turn in self._turns.items()
if turn_scope == scope]
for turn in scoped:
if turn["state"] in self.TERMINAL_STATES:
continue
turn["cancel_requested"] = True
if turn["state"] == "QUEUED" or turn["state"] in self.WAITING_STATES:
self._set_state(turn, "CANCELLED", "Workspace fermé")
# WHY: a model request or gateway call already owned by this runtime
# cannot be interrupted safely. Closing waits until its worker observes
# cancel_requested, which prevents a later tool call or cross-workspace use.
while any(turn["state"] not in self.TERMINAL_STATES for turn in scoped):
remaining = deadline - time.monotonic()
if remaining <= 0:
raise AgentRuntimeError(
"Annulation Agent encore en cours",
code="agent_runtime_cancel_timeout", status=409,
)
self._changed.wait(remaining)
return [self._snapshot(turn) for turn in scoped]
def status(self, scope, turn_id):
with self._lock:
return self._snapshot(self._get_turn(scope, turn_id))

View file

@ -150,6 +150,7 @@ let evidenceOpenSequence = 0;
let openedEvidenceId = null;
let libraryGeneration = null;
let pendingCreateIntent = null;
const pendingRegisterIntents = new Map();
let workspaceContext = null;
let workspaceAbortController = null;
let workspaceReadTail = Promise.resolve();
@ -168,6 +169,7 @@ function invalidateWorkspaceContext() {
workspaceAbortController?.abort();
workspaceAbortController = null;
workspaceContext = null;
workspaceReadTail = Promise.resolve();
eventSource?.close();
eventSource = null;
for (const timer of refreshTimers) clearInterval(timer);
@ -191,6 +193,24 @@ function invalidateWorkspaceContext() {
byId("agent-proposal-list")?.replaceChildren();
byId("agent-toolbox-list")?.replaceChildren();
byId("agent-code-changes-list")?.replaceChildren();
agentCalling = false;
byId("agent-send").disabled = true;
byId("agent-resume").disabled = true;
byId("agent-cancel").disabled = true;
byId("agent-status").textContent = "Ouvrez une enquête pour utiliser Qwen.";
byId("agent-runtime-detail").textContent = "Aucun contexte d’enquête actif";
byId("agent-mission-state").textContent = "Aucune mission active.";
byId("agent-mission-goal").textContent = "—";
byId("agent-mission-scope").textContent = "—";
byId("agent-mission-risk").textContent = "—";
byId("agent-mission-profile").textContent = "—";
byId("agent-mission-contacts").textContent = "—";
byId("agent-mission-tools").textContent = "—";
byId("agent-mission-elapsed").textContent = "—";
byId("agent-mission-start").disabled = true;
byId("agent-mission-cancel").disabled = true;
byId("agent-toolbox-state").textContent = "Aucun workspace actif.";
byId("agent-code-changes-state").textContent = "Aucun workspace actif.";
}
function beginWorkspaceContext(workspaceId, generation) {
@ -2640,7 +2660,7 @@ function renderLibrary(value) {
const active = value.active_workspace_id === entry.workspace_id;
const anotherActive = value.active_workspace_id !== null && !active;
state.textContent = active ? "Ouverte dans cette session" : anotherActive
? "Disponible après redémarrage du poste local"
? "Fermez d’abord l’enquête courante"
: entry.state === "READY" ? "Prête à ouvrir" : `État : ${entry.state}`;
const open = document.createElement("button");
open.type = "button";
@ -2656,12 +2676,90 @@ function renderLibrary(value) {
`${value.entries.length} enquête(s) · génération ${value.generation}`;
}
function renderExistingCandidates(value) {
if (value.contract !== "labfy.web_library.discovery.v1" ||
!Array.isArray(value.candidates) || !Number.isInteger(value.generation))
throw new Error("Contrat de découverte inattendu");
libraryGeneration = value.generation;
pendingRegisterIntents.clear();
const list = byId("existing-list");
list.replaceChildren();
for (const candidate of value.candidates) {
const item = document.createElement("li");
item.className = "workspace-card existing-candidate";
const heading = document.createElement("h3");
heading.textContent = candidate.display_name;
const title = document.createElement("p");
title.textContent = candidate.title ? `Titre détecté : ${candidate.title}` :
"Titre détecté : indisponible";
const state = document.createElement("p");
state.className = "candidate-state";
state.textContent = candidate.state;
const reason = document.createElement("p");
reason.textContent = candidate.reason;
item.append(heading, title, state, reason);
if (["READY_TO_REGISTER", "NEEDS_METADATA_MIGRATION"].includes(candidate.state)) {
const register = document.createElement("button");
register.type = "button";
register.textContent = candidate.state === "NEEDS_METADATA_MIGRATION"
? "Préparer les métadonnées" : "Enregistrer";
register.dataset.candidateId = candidate.candidate_id;
register.addEventListener("click", () => void registerExisting(candidate, register));
item.append(register);
}
list.append(item);
}
byId("library-discovery-status").textContent = value.candidates.length === 0
? "Aucun dossier existant détecté dans la bibliothèque configurée."
: `${value.candidates.length} dossier(s) examiné(s).`;
}
async function discoverExisting() {
const value = await libraryRequest("/api/v1/library/discover-existing", {
method: "POST",
headers: { "Content-Type": "application/json", "X-Labfy-CSRF": csrfToken },
body: "{}",
});
renderExistingCandidates(value);
}
async function registerExisting(candidate, button) {
if (candidate.state === "NEEDS_METADATA_MIGRATION" && !window.confirm(
"Préparer les métadonnées Labfy ?\n\n" +
"La base est compatible. Le manifeste sera créé atomiquement et la base restera inchangée.",
)) return;
button.disabled = true;
let intent = pendingRegisterIntents.get(candidate.candidate_id);
if (!intent) {
intent = newUuid();
pendingRegisterIntents.set(candidate.candidate_id, intent);
}
try {
const registered = await libraryRequest("/api/v1/library/register-existing", {
method: "POST",
headers: { "Content-Type": "application/json", "X-Labfy-CSRF": csrfToken },
body: JSON.stringify({ candidate_id: candidate.candidate_id,
expected_generation: libraryGeneration, idempotency_key: intent,
human_confirmed: true }),
});
if (registered.contract !== "labfy.web_library.workspace.v1")
throw new Error("Contrat d’enregistrement inattendu");
libraryGeneration = registered.generation;
await refreshLibrary();
await discoverExisting();
} catch (error) {
byId("library-discovery-status").textContent = error.message;
button.disabled = false;
}
}
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;
byId("existing-discovery").hidden = false;
return value;
}
@ -2674,6 +2772,7 @@ async function showLibrary({ focus = false, load = true } = {}) {
byId("library-home").hidden = false;
byId("workspace-create").hidden = !load;
byId("library-list-section").hidden = !load;
byId("existing-discovery").hidden = !load;
byId("library-load").hidden = load;
byId("library-title").textContent = "Bibliothèque d’enquêtes";
byId("library-connection").textContent = load
@ -2713,6 +2812,8 @@ async function activateWorkspace(opened) {
byId("library-home").hidden = true;
byId("workbench-shell").hidden = false;
byId("home-button").hidden = false;
byId("close-workspace").hidden = false;
byId("close-workspace").disabled = false;
byId("investigation-title").textContent = opened.title;
byId("mode-badge").textContent = "Espace local · contrôles locaux";
byId("open-import").disabled = false;
@ -2763,6 +2864,7 @@ async function activateLegacyWorkspace(session) {
byId("library-home").hidden = true;
byId("workbench-shell").hidden = false;
byId("home-button").hidden = true;
byId("close-workspace").hidden = true;
byId("investigation-title").textContent = session?.title ?? "Enquête locale";
byId("open-import").disabled = !operationalMode || !workspaceReady;
reportDraftKey = `labfy-report-draft:${workspaceId}:${generation}`;
@ -2844,6 +2946,31 @@ async function openWorkspace(workspaceId, button) {
}
}
async function closeWorkspace(button) {
if (!workspaceContext) return;
button.disabled = true;
byId("connection").textContent = "Fermeture…";
try {
const closed = await libraryRequest("/api/v1/library/close", {
method: "POST",
headers: { "Content-Type": "application/json", "X-Labfy-CSRF": csrfToken },
body: JSON.stringify({ workspace_id: workspaceContext.workspaceId,
expected_generation: libraryGeneration, idempotency_key: newUuid(),
human_confirmed: true }),
});
if (closed.contract !== "labfy.web_library.close.v1" ||
closed.active_workspace_id !== null)
throw new Error("Contrat de fermeture inattendu");
libraryGeneration = closed.generation;
invalidateWorkspaceContext();
resetWorkspacePresentation();
await showLibrary({ focus: true, load: true });
} catch (error) {
byId("connection").textContent = error.message;
button.disabled = false;
}
}
function configureApplication() {
configureControls();
configureResizableLayout();
@ -2890,6 +3017,8 @@ function configureApplication() {
}
});
byId("home-button").addEventListener("click", () => void showLibrary({ focus: true }));
byId("close-workspace").addEventListener("click", (event) =>
void closeWorkspace(event.currentTarget));
byId("library-load").addEventListener("click", async (event) => {
const button = event.currentTarget;
button.disabled = true;
@ -2902,6 +3031,18 @@ function configureApplication() {
button.disabled = false;
}
});
byId("library-discover").addEventListener("click", async (event) => {
const button = event.currentTarget;
button.disabled = true;
byId("library-discovery-status").textContent = "Recherche…";
try {
await discoverExisting();
} catch (error) {
byId("library-discovery-status").textContent = error.message;
} finally {
button.disabled = false;
}
});
byId("workspace-create-form").addEventListener("submit", async (event) => {
event.preventDefault();
const button = event.currentTarget.querySelector("button");

View file

@ -24,10 +24,16 @@
<p id="workspace-create-status" role="alert"></p>
</section>
<section id="library-list-section" aria-labelledby="workspace-list-title">
<h2 id="workspace-list-title">Enquêtes disponibles</h2>
<h2 id="workspace-list-title">Enquêtes enregistrées</h2>
<p id="library-empty" class="empty-state" hidden>Aucune enquête. Créez votre premier espace local pour commencer.</p>
<ul id="workspace-list" class="workspace-list" aria-live="polite"></ul>
</section>
<section id="existing-discovery" class="library-discovery" aria-labelledby="existing-title" hidden>
<div class="library-section-heading"><h2 id="existing-title">Dossiers existants détectés</h2>
<button id="library-discover" type="button">Rechercher les dossiers existants</button></div>
<p id="library-discovery-status" role="status">Aucune recherche effectuée.</p>
<ul id="existing-list" class="workspace-list" aria-live="polite"></ul>
</section>
</main>
<div id="workbench-shell" hidden>
<dialog id="import-dialog" aria-labelledby="import-title">
@ -71,6 +77,8 @@
<div class="graph-tools">
<button id="home-button">Accueil</button
>
<button id="close-workspace">Fermer l’enquête</button
>
<button id="open-import">Ajouter des preuves</button
>
<button id="back" disabled>← Retour</button

View file

@ -113,6 +113,21 @@ textarea:focus-visible,
border: 1px solid var(--border);
border-radius: 0.75rem;
}
.library-discovery {
margin-top: 2rem;
}
.library-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.library-section-heading h2 {
margin-bottom: 0.5rem;
}
#library-discovery-status {
color: var(--muted);
}
.library-create h2,
.library-create p {
margin-top: 0;
@ -151,6 +166,10 @@ textarea:focus-visible,
.workspace-card button {
width: 100%;
}
.workspace-card .candidate-state {
color: var(--text);
font-weight: 700;
}
.topbar {
height: var(--top);
display: grid;

View file

@ -0,0 +1,385 @@
#!/usr/bin/env python3
"""Smoke manuel Qwen A/B sur deux enquêtes existantes SPECIMEN temporaires."""
from __future__ import annotations
import argparse
import hashlib
import json
import subprocess
import sys
import tempfile
import threading
import time
import uuid
from http.client import HTTPConnection
from pathlib import Path
PROTOTYPE = Path(__file__).resolve().parents[1]
REPOSITORY = Path(__file__).resolve().parents[3]
BRIDGE = REPOSITORY / "tools/local-jobs"
sys.path.insert(0, str(PROTOTYPE))
from web_library import WebLibrary # noqa: E402
from workspace_server import Handler, WorkspaceServer # noqa: E402
TERMINAL = {"COMPLETED", "FAILED", "CANCELLED", "MODEL_UNAVAILABLE",
"MODEL_PROTOCOL_ERROR", "BUDGET_EXHAUSTED"}
class SmokeError(RuntimeError):
"""Erreur bornée du smoke SPECIMEN."""
class QuietHandler(Handler):
def log_message(self, _format, *_args):
pass
def arguments():
parser = argparse.ArgumentParser()
parser.add_argument("--model-path", type=Path, required=True)
parser.add_argument("--model-sha256", required=True)
parser.add_argument("--model-id", default="Huihui-Qwen3.5-9B-Q4_K_M.gguf")
parser.add_argument("--model-timeout", type=float, default=90.0)
parser.add_argument("--turn-timeout", type=float, default=600.0)
return parser.parse_args()
def verify_model(path, expected):
forbidden = (Path.home() / "Documents" / "Investigations").absolute()
candidate = path.expanduser().absolute()
if candidate.is_relative_to(forbidden) or candidate.is_symlink():
raise SmokeError("Chemin modèle interdit")
resolved = candidate.resolve(strict=True)
if resolved.is_relative_to(forbidden) or not resolved.is_file():
raise SmokeError("Fichier modèle invalide")
digest = hashlib.sha256()
with resolved.open("rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(block)
if digest.hexdigest() != expected.lower():
raise SmokeError("SHA-256 modèle inattendu")
return resolved
def bridge_create(workspace, title):
result = subprocess.run(
[str(BRIDGE), "create-workspace", "--workspace", str(workspace),
"--title", title], cwd=REPOSITORY, capture_output=True, text=True,
timeout=30, check=False,
)
if result.returncode:
raise SmokeError("Création du workspace SPECIMEN refusée")
def sha256(path):
digest = hashlib.sha256()
with path.open("rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
class Client:
def __init__(self, server):
self.server = server
self.cookie = None
self.csrf = None
def request(self, method, path, value=None, *, mutation=False):
if mutation:
time.sleep(0.04)
connection = HTTPConnection("127.0.0.1", self.server.server_port, timeout=95)
headers = {"Host": self.server.authority}
if self.cookie:
headers["Cookie"] = self.cookie
if mutation:
headers.update(Origin=self.server.origin, **{"X-Labfy-CSRF": self.csrf})
body = None
if value is not None:
headers["Content-Type"] = "application/json"
body = json.dumps(value, ensure_ascii=False,
separators=(",", ":")).encode("utf-8")
connection.request(method, path, body=body, headers=headers)
response = connection.getresponse()
data = response.read()
result = response.status, dict(response.getheaders()), (
json.loads(data) if data else None)
connection.close()
return result
def expect(self, method, path, statuses, value=None, *, mutation=False):
status, headers, body = self.request(method, path, value, mutation=mutation)
if status not in statuses:
message = body.get("message") if isinstance(body, dict) else None
raise SmokeError(f"HTTP {status} inattendu pour {path}: {message}")
return headers, body
def open_session(self):
status, headers, _body = self.request("GET", "/")
if status != 303 or "Set-Cookie" not in headers:
raise SmokeError("Session locale indisponible")
self.cookie = headers["Set-Cookie"].split(";", 1)[0]
_headers, session = self.expect("GET", "/api/v1/session", {200})
self.csrf = session["csrf"]
def wait_turn(client, turn_id, timeout):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
_headers, turn = client.expect(
"GET", f"/api/v1/agent-runtime/turns/{turn_id}", {200})
if turn.get("state") in TERMINAL:
return turn
time.sleep(0.2)
raise SmokeError("Timeout global du turn Qwen")
def gateway_search_output(server, turn_id):
scope = server.agent_scope()
events = server.agent_gateway.events(scope, 0).get("events", [])
event = next((item for item in reversed(events)
if item.get("turn_id") == turn_id and
item.get("kind") == "agent.tool.completed" and
item.get("payload", {}).get("tool_id") == "investigation.search"), None)
if event is None:
raise SmokeError("Résultat gateway search absent")
result = server.agent_gateway.result(scope, event["payload"]["result_id"])
if result.get("state") != "COMPLETED" or not isinstance(result.get("output"), dict):
raise SmokeError("Résultat gateway search non COMPLETED")
return result["output"]
def run_turn(server, client, marker, timeout):
objective = (f"Analyse l’enquête {marker}. Appelle investigation.get_overview, "
f"puis investigation.search avec query {marker}, puis donne un "
"bilan factuel court. Attends chaque résultat avant de continuer.")
_headers, started = client.expect(
"POST", "/api/v1/agent-runtime/turns", {202},
{"objective": objective, "idempotency_key": str(uuid.uuid4())}, mutation=True)
turn_id = started.get("turn_id")
if not isinstance(turn_id, str):
raise SmokeError("Identifiant de turn absent")
final = wait_turn(client, turn_id, timeout)
_headers, events = client.expect(
"GET", "/api/v1/agent-runtime/events?cursor=0", {200})
tools = [event.get("payload", {}).get("tool_id")
for event in events.get("events", [])
if event.get("turn_id") == turn_id and
event.get("kind") == "agent.runtime.tool_requested"]
if final.get("state") != "COMPLETED" or not str(final.get("final", "")).strip():
raise SmokeError(f"Turn Qwen non terminé: {final.get('state')}")
if "investigation.search" not in tools:
raise SmokeError("investigation.search absent du turn réel")
search = gateway_search_output(server, turn_id)
if not any(marker in json.dumps(item, ensure_ascii=False)
for item in search.get("matches", [])):
raise SmokeError("Marqueur actif absent du résultat search réel")
if marker not in final["final"]:
raise SmokeError("Marqueur du contexte actif absent du bilan Qwen")
return turn_id, final, tools, search
def candidate(discovery, name):
value = next((item for item in discovery["candidates"]
if item["display_name"] == name), None)
if value is None or value["state"] != "READY_TO_REGISTER":
raise SmokeError(f"Candidat {name} non prêt")
return value
def main():
args = arguments()
results = {
"lazy_start_no_scan": False, "discovery": False, "register_a": False,
"no_copy_a": False, "open_a": False, "qwen_a_context": False,
"qwen_a_search": False, "b_hidden_from_a": False, "close_a": False,
"qwen_disabled_closed": False, "old_a_resume_rejected": False,
"register_b": False, "open_b": False, "qwen_b_context": False,
"qwen_b_search": False, "a_hidden_from_b": False, "cleanup": False,
}
security = {"absolute_path_exposed": False, "model_filesystem_access": False,
"model_sqlite_access": False, "symlink_followed": False,
"cross_workspace_leak": False,
"automatic_real_library_scan": False,
"real_investigation_access": False}
server = None
thread = None
turn_a = None
turn_b = None
tools_a = []
tools_b = []
gateway_marker_a = False
gateway_marker_b = False
registered_workspace_a = None
registered_workspace_b = None
context_workspace_a = None
context_workspace_b = None
try:
if not 0.1 <= args.model_timeout <= 90 or not 30 <= args.turn_timeout <= 600:
raise SmokeError("Timeouts hors contrat")
model = verify_model(args.model_path, args.model_sha256)
with tempfile.TemporaryDirectory(
prefix="labfy-existing-Qwen-SPECIMEN-") as directory:
root = Path(directory).resolve()
library_root = root / "SPECIMEN_LIBRARY"
library_root.mkdir(mode=0o700)
workspace_a = library_root / "Existing-A"
workspace_b = library_root / "Existing-B"
bridge_create(workspace_a, "ALPHA-SPECIMEN-ONLY")
bridge_create(workspace_b, "BRAVO-SPECIMEN-ONLY")
hash_a = sha256(workspace_a / "Enquete.sqlite")
config = root / "agent.json"
config.write_text(json.dumps({"model": {"model_path": str(model),
"model_id": args.model_id, "context": 4096, "parallel": 1,
"reasoning": "off"}}, separators=(",", ":")), encoding="utf-8")
config.chmod(0o600)
inactive = root / "inactive"
inactive.mkdir(mode=0o700)
library = WebLibrary(library_root, BRIDGE, lazy=True)
results["lazy_start_no_scan"] = not (library_root / "registry.json").exists()
server = WorkspaceServer(
("127.0.0.1", 0), QuietHandler, workspace=inactive, bridge=BRIDGE,
library=library, agent_autostart=True, agent_config_path=config,
agent_timeout=args.model_timeout,
)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
client = Client(server)
client.open_session()
model_status = server.agent_runtime_status()
if model_status.get("available") is not True:
raise SmokeError(
"Qwen temporaire non READY: " + str(model_status.get("reason"))[:180])
_headers, discovery = client.expect(
"POST", "/api/v1/library/discover-existing", {200}, {}, mutation=True)
encoded = json.dumps(discovery, ensure_ascii=False)
security["absolute_path_exposed"] = str(root) in encoded
a = candidate(discovery, "Existing-A")
b = candidate(discovery, "Existing-B")
results["discovery"] = not (library_root / "registry.json").exists()
_headers, registered_a = client.expect(
"POST", "/api/v1/library/register-existing", {201}, {
"candidate_id": a["candidate_id"],
"expected_generation": discovery["generation"],
"idempotency_key": str(uuid.uuid4()), "human_confirmed": True,
}, mutation=True)
results["register_a"] = registered_a["state"] == "READY"
registered_workspace_a = registered_a["workspace_id"]
results["no_copy_a"] = (sha256(workspace_a / "Enquete.sqlite") == hash_a and
not (library_root / "workspaces" / registered_a["workspace_id"]).exists())
_headers, opened_a = client.expect(
"POST", "/api/v1/library/open", {200}, {
"workspace_id": registered_a["workspace_id"],
"expected_generation": registered_a["generation"],
}, mutation=True)
results["open_a"] = server.active_workspace_id == registered_a["workspace_id"]
context_a = server._agent_investigation_context(server.agent_scope())
context_workspace_a = context_a["workspace_id"]
results["qwen_a_context"] = (context_a["workspace_id"] == registered_a["workspace_id"]
and context_a["title"] == "ALPHA-SPECIMEN-ONLY")
turn_a, final_a, tools_a, search_a = run_turn(
server, client, "ALPHA-SPECIMEN-ONLY", args.turn_timeout)
results["qwen_a_search"] = ("investigation.search" in tools_a and
any("ALPHA-SPECIMEN-ONLY" in json.dumps(item, ensure_ascii=False)
for item in search_a["matches"]))
gateway_marker_a = results["qwen_a_search"]
hidden_b = server._agent_search({"query": "BRAVO-SPECIMEN-ONLY"},
"smoke-a-hidden")
results["b_hidden_from_a"] = (not hidden_b["matches"] and
"BRAVO-SPECIMEN-ONLY" not in final_a["final"])
_headers, closed = client.expect(
"POST", "/api/v1/library/close", {200}, {
"workspace_id": registered_a["workspace_id"],
"expected_generation": opened_a["generation"],
"idempotency_key": str(uuid.uuid4()), "human_confirmed": True,
}, mutation=True)
results["close_a"] = closed["active_workspace_id"] is None
status, _headers, _body = client.request(
"POST", "/api/v1/agent-runtime/turns", {
"objective": "SPECIMEN fermé", "idempotency_key": str(uuid.uuid4())},
mutation=True)
results["qwen_disabled_closed"] = status == 409
status, _headers, _body = client.request(
"POST", f"/api/v1/agent-runtime/turns/{turn_a}/resume", {}, mutation=True)
results["old_a_resume_rejected"] = status in {403, 404, 409}
_headers, discovery_b = client.expect(
"POST", "/api/v1/library/discover-existing", {200}, {}, mutation=True)
b = candidate(discovery_b, "Existing-B")
_headers, registered_b = client.expect(
"POST", "/api/v1/library/register-existing", {201}, {
"candidate_id": b["candidate_id"],
"expected_generation": discovery_b["generation"],
"idempotency_key": str(uuid.uuid4()), "human_confirmed": True,
}, mutation=True)
results["register_b"] = registered_b["state"] == "READY"
registered_workspace_b = registered_b["workspace_id"]
_headers, _opened_b = client.expect(
"POST", "/api/v1/library/open", {200}, {
"workspace_id": registered_b["workspace_id"],
"expected_generation": registered_b["generation"],
}, mutation=True)
results["open_b"] = server.active_workspace_id == registered_b["workspace_id"]
context_b = server._agent_investigation_context(server.agent_scope())
context_workspace_b = context_b["workspace_id"]
results["qwen_b_context"] = (context_b["workspace_id"] == registered_b["workspace_id"]
and context_b["title"] == "BRAVO-SPECIMEN-ONLY")
turn_b, final_b, tools_b, search_b = run_turn(
server, client, "BRAVO-SPECIMEN-ONLY", args.turn_timeout)
results["qwen_b_search"] = ("investigation.search" in tools_b and
any("BRAVO-SPECIMEN-ONLY" in json.dumps(item, ensure_ascii=False)
for item in search_b["matches"]))
gateway_marker_b = results["qwen_b_search"]
hidden_a = server._agent_search({"query": "ALPHA-SPECIMEN-ONLY"},
"smoke-b-hidden")
results["a_hidden_from_b"] = (not hidden_a["matches"] and
"ALPHA-SPECIMEN-ONLY" not in final_b["final"])
security["cross_workspace_leak"] = not (
results["b_hidden_from_a"] and results["a_hidden_from_b"])
terminal_a, terminal_b = final_a["state"], final_b["state"]
except (SmokeError, OSError, ValueError, subprocess.TimeoutExpired) as error:
print(f"ERROR={type(error).__name__}:{str(error)[:300]}")
terminal_a = terminal_b = "UNKNOWN"
finally:
if server is not None:
try:
server.shutdown()
server.server_close()
except (OSError, RuntimeError):
pass
if thread is not None:
thread.join(timeout=5)
results["cleanup"] = bool(
server is None or server.local_model_supervisor is None or
server.local_model_supervisor.process is None)
print("REAL_EXISTING_INVESTIGATION_QWEN_SMOKE")
print(f"model_sha256={args.model_sha256.lower()}")
print(f"turn_a_id={turn_a}")
print(f"turn_a_tools={','.join(str(item) for item in tools_a)}")
print(f"turn_a_gateway_search_marker={'true' if gateway_marker_a else 'false'}")
print(f"registered_workspace_a={registered_workspace_a}")
print(f"context_workspace_a={context_workspace_a}")
print(f"turn_b_id={turn_b}")
print(f"turn_b_tools={','.join(str(item) for item in tools_b)}")
print(f"turn_b_gateway_search_marker={'true' if gateway_marker_b else 'false'}")
print(f"registered_workspace_b={registered_workspace_b}")
print(f"context_workspace_b={context_workspace_b}")
for name, passed in results.items():
print(f"{name}={'PASS' if passed else 'FAIL'}")
print(f"terminal_a={terminal_a}")
print(f"terminal_b={terminal_b}")
print("SECURITY")
for name, exposed in security.items():
print(f"{name}={'true' if exposed else 'false'}")
return 0 if all(results.values()) and terminal_a == terminal_b == "COMPLETED" and not any(
security.values()) else 1
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -32,6 +32,9 @@ export const scenarios = Object.freeze([
// CONTRACT: le service persistant n'inspecte la bibliothèque qu'après le
// clic explicite de l'utilisateur, puis ouvre un workspace SPECIMEN choisi.
"test_lazy_library_browser.mjs",
// CONTRACT: two direct-child SPECIMEN workspaces prove explicit discovery,
// registration without relocation, close/reset and A→B UI isolation.
"test_existing_investigation_library_browser.mjs",
// CONTRACT: ce parcours Firefox exerce le vrai formulaire et une expiration
// de harness isolée ; il démontre l'arrêt du polling et la reconnexion sans
// rejouer une mutation dont l'admission n'est pas connue.

View file

@ -313,6 +313,39 @@ class AgentRuntimeTest(unittest.TestCase):
final = runtime.wait_for_state("scope-a", started["turn_id"], "CANCELLED")
self.assertIsNone(final["final"])
def test_cancel_scope_waits_for_owned_running_inference(self):
entered = threading.Event()
release = threading.Event()
cancelled = threading.Event()
class BlockingModel:
def complete(self, _messages):
entered.set()
release.wait(2)
return action("tool_call", tool_id="investigation.search",
arguments={"query": "SPECIMEN"})
requests = []
runtime = AgentRuntime(BlockingModel(), requests.append,
{"investigation.search"})
self.addCleanup(runtime.close)
started = self.start(runtime, key="cancel-scope")
self.assertTrue(entered.wait(1))
def close_scope():
runtime.cancel_scope("scope-a", timeout=1.5)
cancelled.set()
thread = threading.Thread(target=close_scope)
thread.start()
self.assertFalse(cancelled.wait(0.05))
release.set()
thread.join(2)
self.assertTrue(cancelled.is_set())
final = runtime.status("scope-a", started["turn_id"])
self.assertEqual(final["state"], "CANCELLED")
self.assertEqual(requests, [])
def test_events_are_bounded_and_scope_filtered(self):
runtime = self.make_runtime([action("final", text="ok")], max_events=2)
started = self.start(runtime)

View file

@ -40,7 +40,21 @@ try {
const page=await browser.newPage(); await page.setViewport({width:1280,height:900}); await openAutomaticSession(page,origin);
await page.waitForFunction(() => document.querySelector("#agent-status")?.textContent.includes("Agent de démonstration déterministe"));
const hostile='<img src=x onerror="window.__agentInjected=true"> recherche SPECIMEN';
await page.type("#agent-prompt",hostile); await page.click("#agent-send");
await page.type("#agent-prompt",hostile);
await page.click("#agent-send");
await page.waitForSelector(".agent-card.error");
assert.equal(await page.evaluate(() => window.__agentInjected),undefined);
assert.equal(await page.$eval("#agent-prompt", (input) => input.value), hostile);
assert.equal(await page.$eval(".agent-conversation", (node) => node.querySelector("img, script")), null);
assert.match(await page.$eval(".agent-card.error", (node) => node.textContent), /Sélection de recherche invalide/);
await page.$eval("#agent-prompt", (input) => { input.value = ""; });
const objective = await page.evaluate(async () => {
const response = await fetch("/api/v1/snapshot", { cache: "no-store" });
const value = await response.json();
return value.nodes.find((node) => typeof node.label === "string" && node.label)?.label;
});
assert.equal(typeof objective, "string");
await page.type("#agent-prompt",objective); await page.click("#agent-send");
await page.waitForFunction(() => document.querySelector(".agent-conversation")?.textContent.includes("PLAN"));
await page.waitForFunction(() => document.querySelector(".agent-conversation")?.textContent.includes("investigation.search · COMPLETED"));
await page.waitForFunction(() => document.querySelector(".agent-conversation")?.textContent.includes("AUTHORIZATION_REQUIRED"));

View file

@ -0,0 +1,140 @@
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import { once } from "node:events";
import { mkdtemp, mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import puppeteer from "puppeteer-core";
import { openAutomaticSession } from "./browser_automatic_session.mjs";
const prototype = new URL("..", import.meta.url);
const repository = new URL("../../..", import.meta.url);
const root = await mkdtemp(join(tmpdir(), "labfy-existing-browser-SPECIMEN-"));
const library = join(root, "library-SPECIMEN");
const runtime = join(root, "runtime");
const state = join(root, "state");
const profile = join(root, "firefox-profile");
await mkdir(library, { mode: 0o700 });
await mkdir(runtime, { mode: 0o700 });
await mkdir(state, { mode: 0o700 });
const bridge = new URL("../../../tools/local-jobs", import.meta.url).pathname;
for (const [name, title] of [
["Existing-A", "ALPHA-SPECIMEN-ONLY <script>window.hostile = true</script>"],
["Existing-B", "BRAVO-SPECIMEN-ONLY"],
]) {
const created = spawnSync(bridge, ["create-workspace", "--workspace",
join(library, name), "--title", title], {
cwd: repository, encoding: "utf8",
});
assert.equal(created.status, 0, created.stderr);
}
let server;
let browser;
try {
server = spawn("python3", ["web_app.py", "serve", "--library", library,
"--lazy-library", "--port", "0"], {
cwd: prototype,
env: { ...process.env, XDG_RUNTIME_DIR: runtime, XDG_STATE_HOME: state,
PYTHONUNBUFFERED: "1" },
stdio: ["ignore", "pipe", "pipe"],
});
const origin = await new Promise((resolve, reject) => {
let output = "";
const timer = setTimeout(() => reject(new Error("démarrage expiré")), 15000);
server.stdout.on("data", (chunk) => {
output += chunk.toString();
const match = output.match(/Labfy Web : (http:\/\/127\.0\.0\.1:\d+)\//);
if (match) { clearTimeout(timer); resolve(match[1]); }
});
server.once("exit", (code) => reject(new Error(`serveur arrêté : ${code}`)));
});
browser = await puppeteer.launch({ browser: "firefox", executablePath: "/usr/bin/firefox",
headless: true, userDataDir: profile, args: ["--no-remote"] });
const page = await browser.newPage();
await page.setViewport({ width: 1360, height: 980 });
await openAutomaticSession(page, origin, "existing library SPECIMEN");
await page.click("#library-load");
await page.waitForSelector("#existing-discovery:not([hidden])");
await page.click("#library-discover");
await page.waitForFunction(() =>
document.querySelectorAll(".existing-candidate").length === 2);
assert.equal(await page.evaluate(() => window.hostile), undefined);
assert.equal(await page.$eval(".existing-candidate h3", (node) => node.querySelector("script")),
null);
const clickCandidate = async (name) => {
await page.evaluate((wanted) => {
const card = [...document.querySelectorAll(".existing-candidate")]
.find((item) => item.querySelector("h3")?.textContent === wanted);
const button = card?.querySelector("button[data-candidate-id]");
if (!button) throw new Error(`candidat ${wanted} non enregistrable`);
button.click();
}, name);
};
const clickWorkspace = async (marker) => {
await page.evaluate((wanted) => {
const card = [...document.querySelectorAll(".workspace-card")]
.find((item) => item.querySelector("h3")?.textContent.includes(wanted));
const button = card?.querySelector("button[data-workspace-id]");
if (!button) throw new Error(`workspace ${wanted} absent`);
button.click();
}, marker);
};
await clickCandidate("Existing-A");
await page.waitForFunction(() => [...document.querySelectorAll("#workspace-list h3")]
.some((node) => node.textContent.includes("ALPHA-SPECIMEN-ONLY")));
await clickWorkspace("ALPHA-SPECIMEN-ONLY");
await page.waitForSelector("#workbench-shell:not([hidden])");
await page.waitForFunction(() => document.querySelector("#agent-send")?.disabled === false);
const workspaceA = await page.evaluate(() => window.__LABFY_TEST__.getState().workspaceId);
assert.ok(workspaceA);
await page.evaluate(() => {
const card = document.createElement("li");
card.textContent = "ALPHA-SPECIMEN-ONLY stale Agent card";
document.querySelector(".agent-conversation").append(card);
document.querySelector("#agent-proposal-list").append(card.cloneNode(true));
document.querySelector("#agent-toolbox-list").append(card.cloneNode(true));
document.querySelector("#agent-code-changes-list").append(card.cloneNode(true));
});
await page.click("#close-workspace");
await page.waitForSelector("#library-home:not([hidden])");
assert.equal(await page.evaluate(() => window.__LABFY_TEST__.getState().workspaceId), null);
assert.equal(await page.$eval("#agent-send", (button) => button.disabled), true);
assert.equal(await page.$eval("#agent-status", (node) => node.textContent),
"Ouvrez une enquête pour utiliser Qwen.");
await page.click("#library-discover");
await page.waitForFunction(() => [...document.querySelectorAll(".existing-candidate h3")]
.some((node) => node.textContent === "Existing-B"));
await clickCandidate("Existing-B");
await page.waitForFunction(() => [...document.querySelectorAll("#workspace-list h3")]
.some((node) => node.textContent === "BRAVO-SPECIMEN-ONLY"));
await clickWorkspace("BRAVO-SPECIMEN-ONLY");
await page.waitForSelector("#workbench-shell:not([hidden])");
await page.waitForFunction(() => window.__LABFY_TEST__.getState().workspaceId !== null);
const workspaceB = await page.evaluate(() => window.__LABFY_TEST__.getState().workspaceId);
assert.notEqual(workspaceB, workspaceA);
assert.equal(await page.$eval("#investigation-title", (node) => node.textContent),
"BRAVO-SPECIMEN-ONLY");
for (const selector of [".agent-conversation", "#agent-proposal-list",
"#agent-toolbox-list", "#agent-code-changes-list", ".activity-stream"]) {
assert.equal((await page.$eval(selector, (node) => node.textContent))
.includes("ALPHA-SPECIMEN-ONLY"), false, selector);
}
const activeState = await page.evaluate(() => window.__LABFY_TEST__.getState());
assert.deepEqual(activeState.visibleNodeIds, []);
assert.equal(activeState.selectedNodeId, null);
console.log("PASS Firefox bibliothèque existante : register/open/close A→B isolé SPECIMEN");
} finally {
if (browser) await browser.close();
if (server?.exitCode === null) {
server.kill("SIGTERM");
await once(server, "exit");
}
await rm(root, { recursive: true, force: true });
}

View file

@ -0,0 +1,338 @@
"""Contrats SPECIMEN de découverte et d’adoption d’enquêtes existantes."""
import hashlib
import json
import subprocess
import sys
import tempfile
import unittest
import uuid
from pathlib import Path
from unittest import mock
WEB = Path(__file__).resolve().parents[1]
ROOT = WEB.parents[1]
sys.path.insert(0, str(WEB))
from web_library import LibraryError, WebLibrary, REGISTRY_CONTRACT
class ExistingLibraryTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix='labfy-existing-SPECIMEN-')
self.root = Path(self.temp.name) / 'library'
self.root.mkdir(mode=0o700)
self.bridge = ROOT / 'tools/local-jobs'
def tearDown(self):
self.temp.cleanup()
def workspace(self, name, title):
path = self.root / name
path.mkdir()
result = subprocess.run([str(self.bridge), 'create-workspace', '--workspace', str(path), '--title', title], cwd=ROOT, text=True, capture_output=True)
self.assertEqual(result.returncode, 0, result.stderr)
return path
@staticmethod
def digest(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def test_discovery_is_direct_read_only_opaque_and_bounded(self):
valid = self.workspace('Existing-A', 'SPECIMEN Existing A')
(self.root / 'ordinary.txt').write_text('SPECIMEN', encoding='utf-8')
nested = self.root / 'container'
nested.mkdir()
(nested / 'Nested').mkdir()
(self.root / '.hidden').mkdir()
(self.root / 'link').symlink_to(valid, target_is_directory=True)
library = WebLibrary(self.root, self.bridge, lazy=True)
before = {p.relative_to(self.root).as_posix() for p in self.root.rglob('*')}
result = library.discover_existing()
after = {p.relative_to(self.root).as_posix() for p in self.root.rglob('*')}
self.assertEqual(before, after)
self.assertNotIn('Nested', [c['display_name'] for c in result['candidates']])
self.assertNotIn('.hidden', [c['display_name'] for c in result['candidates']])
candidate = next((c for c in result['candidates'] if c['display_name'] == 'Existing-A'))
self.assertEqual(candidate['state'], 'READY_TO_REGISTER')
self.assertNotIn(str(self.root), json.dumps(result))
self.assertEqual(next((c for c in result['candidates'] if c['display_name'] == 'link'))['state'], 'INVALID')
def test_register_existing_no_copy_idempotence_and_open(self):
path = self.workspace('Existing-B', 'SPECIMEN Existing B')
database = path / 'Enquete.sqlite'
before = self.digest(database)
library = WebLibrary(self.root, self.bridge)
discovery = library.discover_existing()
candidate = next((c for c in discovery['candidates'] if c['display_name'] == 'Existing-B'))
result = library.register_existing(candidate['candidate_id'], discovery['generation'], 'register-b', True)
self.assertFalse(result['replayed'])
self.assertEqual(before, self.digest(database))
self.assertFalse((library.workspaces / result['workspace_id']).exists())
replay = library.register_existing(
candidate['candidate_id'],
discovery['generation'],
'register-b',
True,
)
self.assertTrue(replay['replayed'])
with self.assertRaisesRegex(LibraryError, 'autre intention'):
library.register_existing(
candidate['candidate_id'],
result['generation'],
'register-b',
True,
)
opened, entry, generation = library.open(result['workspace_id'], result['generation'])
self.assertEqual(opened, path)
self.assertEqual(entry['storage_kind'], 'EXISTING')
self.assertEqual(generation, 1)
registry = json.loads(library.registry_path.read_text(encoding='utf-8'))
self.assertEqual(registry['contract'], REGISTRY_CONTRACT)
self.assertEqual(registry['entries'][0]['storage_ref'], 'Existing-B')
def test_metadata_only_adoption_preserves_database(self):
path = self.workspace('Existing-Metadata', 'SPECIMEN Metadata')
manifest = path / '.labfy/runtime/workspace.json'
manifest.unlink()
before = self.digest(path / 'Enquete.sqlite')
library = WebLibrary(self.root, self.bridge)
discovery = library.discover_existing()
candidate = discovery['candidates'][0]
self.assertEqual(candidate['state'], 'NEEDS_METADATA_MIGRATION')
result = library.register_existing(candidate['candidate_id'], 0, 'metadata-key', True)
self.assertEqual(before, self.digest(path / 'Enquete.sqlite'))
self.assertTrue(manifest.is_file())
self.assertEqual(json.loads(manifest.read_text())['investigation_id'], result['workspace_id'])
def test_metadata_adoption_resumes_after_manifest_publication(self):
path = self.workspace('Existing-Metadata-Crash', 'SPECIMEN Metadata Crash')
manifest = path / '.labfy/runtime/workspace.json'
manifest.unlink()
database = path / 'Enquete.sqlite'
before = self.digest(database)
library = WebLibrary(self.root, self.bridge)
discovery = library.discover_existing()
candidate = discovery['candidates'][0]
write_registry = library._write_registry
with mock.patch.object(
library,
'_write_registry',
side_effect=OSError('crash après manifeste SPECIMEN')):
with self.assertRaisesRegex(OSError, 'après manifeste'):
library.register_existing(
candidate['candidate_id'],
discovery['generation'],
'metadata-crash-key',
True,
)
self.assertTrue(manifest.is_file())
self.assertEqual(self.digest(database), before)
with mock.patch.object(library, '_write_registry', wraps=write_registry):
resumed = library.register_existing(
candidate['candidate_id'],
discovery['generation'],
'metadata-crash-key',
True,
)
self.assertFalse(resumed['replayed'])
self.assertEqual(resumed['generation'], 1)
self.assertEqual(self.digest(database), before)
self.assertFalse((library.workspaces / resumed['workspace_id']).exists())
def test_metadata_adoption_requires_compatible_jobstore(self):
path = self.workspace('Existing-No-Jobs', 'SPECIMEN no jobs')
(path / '.labfy/runtime/workspace.json').unlink()
(path / '.labfy/runtime/jobs.sqlite').unlink()
candidate = WebLibrary(self.root, self.bridge).discover_existing()['candidates'][0]
self.assertEqual(candidate['state'], 'INVALID')
self.assertIn('JobStore', candidate['reason'])
def test_metadata_parent_symlinks_are_refused(self):
for name, component in (('Existing-Labfy-Link', '.labfy'), ('Existing-Runtime-Link', '.labfy/runtime')):
with self.subTest(component=component):
path = self.workspace(name, f'SPECIMEN {name}')
target = self.root / f'target-{name}'
target.mkdir()
selected = path / component
if selected.name == 'runtime':
selected.rename(path / '.labfy/runtime-real')
else:
selected.rename(path / '.labfy-real')
selected.symlink_to(target, target_is_directory=True)
candidates = WebLibrary(self.root, self.bridge).discover_existing()['candidates']
states = {item['display_name']: item['state'] for item in candidates}
self.assertEqual(states['Existing-Labfy-Link'], 'INVALID')
self.assertEqual(states['Existing-Runtime-Link'], 'INVALID')
def test_candidate_change_and_confirmation_are_rejected(self):
path = self.workspace('Existing-C', 'SPECIMEN Existing C')
library = WebLibrary(self.root, self.bridge)
discovery = library.discover_existing()
candidate = discovery['candidates'][0]
with self.assertRaisesRegex(LibraryError, 'Confirmation'):
library.register_existing(candidate['candidate_id'], 0, 'key-c', False)
(path / '.labfy/runtime/workspace.json').write_text('{}', encoding='utf-8')
with self.assertRaisesRegex(LibraryError, 'CANDIDATE_CHANGED'):
library.register_existing(candidate['candidate_id'], 0, 'key-c', True)
def test_dangling_registry_intent_and_manifest_symlinks_are_invalid(self):
library = WebLibrary(self.root, self.bridge)
library.registry_path.symlink_to(self.root / 'missing-registry')
with self.assertRaisesRegex(LibraryError, 'Registre.*invalide'):
library.snapshot()
library.registry_path.unlink()
library.creation_intent_path.symlink_to(self.root / 'missing-intent')
with self.assertRaisesRegex(LibraryError, 'Intention.*invalide'):
library.create('SPECIMEN dangling', 'dangling-key')
library.creation_intent_path.unlink()
path = self.workspace('Existing-Dangling', 'SPECIMEN dangling manifest')
manifest = path / '.labfy/runtime/workspace.json'
manifest.unlink()
manifest.symlink_to(path / 'missing-manifest')
candidate = library.discover_existing()['candidates'][0]
self.assertEqual(candidate['state'], 'INVALID')
self.assertEqual(candidate['reason'], "Manifeste d'enquête invalide")
def test_candidate_reason_never_exposes_exception_path(self):
path = self.workspace('Existing-Private', 'SPECIMEN private')
library = WebLibrary(self.root, self.bridge)
private = '/private/SPECIMEN-secret/path'
with mock.patch.object(library, '_validate_workspace', side_effect=OSError(private)):
result = library.discover_existing()
encoded = json.dumps(result)
self.assertNotIn(private, encoded)
self.assertEqual(result['candidates'][0]['reason'], 'Dossier invalide ou incompatible')
def test_validator_output_is_bounded(self):
bridge = self.root / 'noisy-validator'
bridge.write_text("#!/usr/bin/env python3\nimport sys\nsys.stdout.write('x' * 70000)\n", encoding='utf-8')
bridge.chmod(0o700)
library = WebLibrary(self.root, bridge)
with self.assertRaisesRegex(LibraryError, 'trop volumineuse'):
library._run_validator(self.root)
def test_registered_symlink_is_invalid_without_breaking_snapshot(self):
library = WebLibrary(self.root, self.bridge)
created = library.create('SPECIMEN managed symlink', 'managed-link')
workspace = library.workspaces / created['workspace_id']
target = self.root / 'managed-real'
workspace.rename(target)
workspace.symlink_to(target, target_is_directory=True)
snapshot = library.snapshot()
self.assertEqual(snapshot['entries'][0]['state'], 'INVALID')
def test_v1_read_missing_entry_and_v2_migration_on_mutation(self):
library = WebLibrary(self.root, self.bridge)
workspace_id = str(uuid.uuid4())
entry = {'workspace_id': workspace_id, 'title': 'SPECIMEN absent', 'idempotency_key': 'old-key', 'request_hash': 'a' * 64}
library.registry_path.write_text(json.dumps({'contract': 'labfy.web_library.registry.v1', 'generation': 4, 'entries': [entry]}), encoding='utf-8')
snapshot = library.snapshot()
self.assertEqual(snapshot['entries'][0]['state'], 'MISSING')
self.assertEqual(json.loads(library.registry_path.read_text())['contract'], 'labfy.web_library.registry.v1')
created = library.create('SPECIMEN managed', 'new-key')
self.assertEqual(created['generation'], 5)
self.assertEqual(json.loads(library.registry_path.read_text())['contract'], REGISTRY_CONTRACT)
def test_v2_registry_is_read_without_rewrite(self):
library = WebLibrary(self.root, self.bridge)
identifier = str(uuid.uuid4())
entry = {
'workspace_id': identifier,
'title': 'SPECIMEN v2 absent',
'storage_kind': 'EXISTING',
'storage_ref': 'Existing-V2',
'idempotency_key': 'v2-key',
'request_hash': '2' * 64,
}
registry = {
'contract': REGISTRY_CONTRACT,
'generation': 7,
'entries': [entry],
}
library.registry_path.write_text(json.dumps(registry), encoding='utf-8')
before = library.registry_path.read_bytes()
snapshot = library.snapshot()
self.assertEqual(snapshot['generation'], 7)
self.assertEqual(snapshot['entries'][0]['state'], 'MISSING')
self.assertEqual(library.registry_path.read_bytes(), before)
def test_duplicate_workspace_id_is_rejected(self):
library = WebLibrary(self.root, self.bridge)
identifier = str(uuid.uuid4())
entries = [
self._existing_entry(identifier, 'Existing-One', 'key-one'),
self._existing_entry(identifier, 'Existing-Two', 'key-two'),
]
self._write_registry(library, entries)
with self.assertRaisesRegex(LibraryError, 'dupliquée'):
library.snapshot()
def test_duplicate_storage_ref_is_rejected(self):
library = WebLibrary(self.root, self.bridge)
entries = [
self._existing_entry(str(uuid.uuid4()), 'Existing-Same', 'key-one'),
self._existing_entry(str(uuid.uuid4()), 'Existing-Same', 'key-two'),
]
self._write_registry(library, entries)
with self.assertRaisesRegex(LibraryError, 'dupliquée'):
library.snapshot()
def test_malformed_and_symlink_registry_are_rejected(self):
library = WebLibrary(self.root, self.bridge)
library.registry_path.write_text('{malformed', encoding='utf-8')
with self.assertRaisesRegex(LibraryError, 'illisible'):
library.snapshot()
library.registry_path.unlink()
target = self.root / 'registry-target.json'
target.write_text(json.dumps({'contract': REGISTRY_CONTRACT}), encoding='utf-8')
library.registry_path.symlink_to(target)
with self.assertRaisesRegex(LibraryError, 'invalide'):
library.snapshot()
def test_control_long_names_and_candidate_cap(self):
control = self.root / 'control\nname'
control.mkdir()
long_name = self.root / ('L' * 201)
long_name.mkdir()
library = WebLibrary(self.root, self.bridge)
candidates = library.discover_existing()['candidates']
states = {item['display_name']: item['state'] for item in candidates}
self.assertEqual(states['control\nname'], 'INVALID')
self.assertIn('L' * 200, states)
self.assertEqual(states['L' * 200], 'INVALID')
for index in range(260):
(self.root / f'cap-{index:03d}').mkdir()
capped = library.discover_existing()['candidates']
self.assertEqual(len(capped), 256)
def test_crash_after_temp_write_keeps_registry_restartable(self):
library = WebLibrary(self.root, self.bridge)
library.registry_path.write_text(json.dumps({'contract': REGISTRY_CONTRACT, 'generation': 0, 'entries': []}), encoding='utf-8')
before = library.registry_path.read_bytes()
with mock.patch.object(Path, 'replace', side_effect=OSError('crash SPECIMEN')):
with self.assertRaisesRegex(OSError, 'crash'):
library.create('SPECIMEN crash', 'crash-key')
self.assertEqual(library.registry_path.read_bytes(), before)
self.assertEqual(WebLibrary(self.root, self.bridge).snapshot()['generation'], 0)
@staticmethod
def _existing_entry(identifier, storage_ref, key):
return {
'workspace_id': identifier,
'title': f'SPECIMEN {storage_ref}',
'storage_kind': 'EXISTING',
'storage_ref': storage_ref,
'idempotency_key': key,
'request_hash': hashlib.sha256(key.encode()).hexdigest(),
}
@staticmethod
def _write_registry(library, entries):
value = {
'contract': REGISTRY_CONTRACT,
'generation': 1,
'entries': entries,
}
library.registry_path.write_text(json.dumps(value), encoding='utf-8')
if __name__ == '__main__':
unittest.main()

View file

@ -0,0 +1,297 @@
import json
import sys
import tempfile
import threading
import time
import unittest
import uuid
from http.client import HTTPConnection
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from workspace_server import Handler, WorkspaceServer
class FakeLibrary:
lazy = True
def __init__(self, workspace):
self.workspace = workspace
self.generation = 4
self.registered = False
def discover_existing(self):
return {
"contract": "labfy.web_library.discovery.v1",
"generation": self.generation,
"candidates": [{
"candidate_id": "opaque-SPECIMEN",
"display_name": "Existing-SPECIMEN",
"workspace_id": "81000000-0000-4000-8000-000000000001",
"title": "Titre <script>SPECIMEN</script>",
"state": "READY_TO_REGISTER",
"reason": "Workspace compatible",
}],
}
def register_existing(self, candidate_id, expected_generation, key, confirmed):
if (candidate_id != "opaque-SPECIMEN" or expected_generation != self.generation
or confirmed is not True):
raise ValueError("Enregistrement SPECIMEN refusé")
uuid.UUID(key)
self.registered = True
self.generation += 1
return {
"contract": "labfy.web_library.workspace.v1",
"workspace_id": "81000000-0000-4000-8000-000000000001",
"title": "Titre <script>SPECIMEN</script>",
"state": "READY", "generation": self.generation, "replayed": False,
}
def open(self, workspace_id, expected_generation):
if workspace_id not in {"81000000-0000-4000-8000-000000000001",
"82000000-0000-4000-8000-000000000002"}:
raise ValueError("Enquête inconnue")
if expected_generation != self.generation:
raise ValueError("Projection de bibliothèque périmée")
return self.workspace, {"title": "Titre SPECIMEN"}, self.generation
class ExistingWorkspaceLifecycleHttpTest(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory(
prefix="labfy-existing-lifecycle-SPECIMEN-")
root = Path(self.temporary.name)
self.inactive = root / "inactive"
self.inactive.mkdir()
self.existing = root / "Existing-SPECIMEN"
(self.existing / ".labfy/runtime").mkdir(parents=True)
(self.existing / "Enquete.sqlite").write_bytes(b"SPECIMEN")
(self.existing / ".labfy/runtime/workspace.json").write_text(json.dumps({
"contract": "labfy.local_workspace.v1",
"investigation_id": "81000000-0000-4000-8000-000000000001",
"title": "Titre SPECIMEN",
}), encoding="utf-8")
self.existing_b = root / "Existing-B-SPECIMEN"
(self.existing_b / ".labfy/runtime").mkdir(parents=True)
(self.existing_b / "Enquete.sqlite").write_bytes(b"SPECIMEN-B")
(self.existing_b / ".labfy/runtime/workspace.json").write_text(json.dumps({
"contract": "labfy.local_workspace.v1",
"investigation_id": "82000000-0000-4000-8000-000000000002",
"title": "Titre B SPECIMEN",
}), encoding="utf-8")
self.library = FakeLibrary(self.existing)
self.server = WorkspaceServer(
("127.0.0.1", 0), Handler, workspace=self.inactive,
bridge=root / "unused-bridge", library=self.library,
)
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
status, headers, _ = self.request("GET", "/")
self.assertEqual(status, 303)
self.cookie = headers["Set-Cookie"].split(";", 1)[0]
status, _, body = self.request("GET", "/api/v1/session", cookie=self.cookie)
self.assertEqual(status, 200)
self.csrf = json.loads(body)["csrf"]
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join()
self.temporary.cleanup()
def request(self, method, path, value=None, *, cookie=None, origin=None, csrf=None):
connection = HTTPConnection("127.0.0.1", self.server.server_port, timeout=10)
headers = {"Host": self.server.authority}
if cookie:
headers["Cookie"] = cookie
if origin:
headers["Origin"] = origin
if csrf:
headers["X-Labfy-CSRF"] = csrf
body = None
if value is not None:
headers["Content-Type"] = "application/json"
body = json.dumps(value)
connection.request(method, path, body=body, headers=headers)
response = connection.getresponse()
result = response.status, dict(response.getheaders()), response.read()
connection.close()
return result
def mutate(self, path, value):
time.sleep(0.04)
return self.request("POST", path, value, cookie=self.cookie,
origin=self.server.origin, csrf=self.csrf)
def test_discover_and_register_routes_use_human_mutation_guards(self):
self.assertEqual(self.request("POST", "/api/v1/library/discover-existing", {})[0],
403)
status, _, body = self.mutate("/api/v1/library/discover-existing", {})
self.assertEqual(status, 200, body)
discovery = json.loads(body)
self.assertEqual(discovery["candidates"][0]["candidate_id"],
"opaque-SPECIMEN")
self.assertNotIn(str(Path(self.temporary.name)), body.decode())
status, _, body = self.mutate("/api/v1/library/register-existing", {
"candidate_id": "opaque-SPECIMEN",
"expected_generation": discovery["generation"],
"idempotency_key": str(uuid.uuid4()),
"human_confirmed": True,
})
self.assertEqual(status, 201, body)
self.assertTrue(self.library.registered)
def test_open_failure_is_atomic_and_close_sets_inactive_state(self):
with mock.patch.object(self.server, "bridge_call",
side_effect=ValueError("export SPECIMEN refusé")):
with self.assertRaisesRegex(ValueError, "export SPECIMEN refusé"):
self.server.open_library_workspace(
"81000000-0000-4000-8000-000000000001", 4)
self.assertIsNone(self.server.active_workspace_id)
self.assertEqual(self.server.workspace, self.inactive.resolve())
self.server.workspace = self.existing.resolve()
self.server.active_workspace_id = "81000000-0000-4000-8000-000000000001"
self.server.library_generation = 4
self.server.agent_missions[self.server.active_workspace_id] = {
"state": "ACTIVE", "mission": mock.Mock(), "started_monotonic": 0,
}
key = str(uuid.uuid4())
value = self.server.close_library_workspace(
self.server.active_workspace_id, 4, key, True)
self.assertEqual(value["state"], "CLOSED")
self.assertIsNone(self.server.active_workspace_id)
self.assertEqual(self.server.workspace, self.inactive.resolve())
self.assertEqual(
self.server.agent_missions["81000000-0000-4000-8000-000000000001"]["state"],
"CANCELLED",
)
replay = self.server.close_library_workspace(
"81000000-0000-4000-8000-000000000001", 4, key, True)
self.assertTrue(replay["replayed"])
self.library.workspace = self.existing_b
with mock.patch.object(self.server, "bridge_call", return_value=""):
opened = self.server.open_library_workspace(
"82000000-0000-4000-8000-000000000002", 4)
self.assertEqual(opened["workspace_id"],
"82000000-0000-4000-8000-000000000002")
self.assertEqual(self.server.active_workspace_id,
"82000000-0000-4000-8000-000000000002")
self.assertEqual(self.server.workspace, self.existing_b.resolve())
def test_agent_runtime_start_is_rejected_without_active_workspace(self):
status, _, body = self.mutate("/api/v1/agent-runtime/turns", {
"objective": "Analyser SPECIMEN", "idempotency_key": "start-SPECIMEN",
})
self.assertEqual(status, 409, body)
self.assertIn("Aucun workspace actif", body.decode())
def test_agent_search_has_no_unrelated_first_node_fallback(self):
self.server.workspace = self.existing.resolve()
self.server.active_workspace_id = "81000000-0000-4000-8000-000000000001"
graph = {"nodes": [{"id": "entity:SPECIMEN-A", "label": "Alice SPECIMEN"}]}
with mock.patch.object(self.server, "_agent_graph", return_value=graph):
absent = self.server._agent_search({"query": "BRAVO-SPECIMEN-ONLY"}, "k1")
active = self.server._agent_search({"query": "Titre SPECIMEN"}, "k2")
self.assertEqual(absent, {"matches": [], "object_refs": []})
self.assertEqual(active["matches"], [{
"object_kind": "investigation",
"workspace_id": "81000000-0000-4000-8000-000000000001",
"label": "Titre SPECIMEN",
}])
self.assertEqual(active["object_refs"], [])
def test_close_route_rejects_none_and_wrong_then_replays_success(self):
request = {"workspace_id": "81000000-0000-4000-8000-000000000001",
"expected_generation": 4,
"idempotency_key": str(uuid.uuid4()),
"human_confirmed": True}
self.assertEqual(self.mutate("/api/v1/library/close", request)[0], 409)
self.server.workspace = self.existing.resolve()
self.server.active_workspace_id = request["workspace_id"]
self.server.library_generation = 4
wrong = {**request, "workspace_id": "82000000-0000-4000-8000-000000000002",
"idempotency_key": str(uuid.uuid4())}
self.assertEqual(self.mutate("/api/v1/library/close", wrong)[0], 403)
status, _, body = self.mutate("/api/v1/library/close", request)
self.assertEqual(status, 200, body)
self.assertFalse(json.loads(body)["replayed"])
status, _, body = self.mutate("/api/v1/library/close", request)
self.assertEqual(status, 200, body)
self.assertTrue(json.loads(body)["replayed"])
def test_open_b_rejects_all_old_a_mission_and_tooling_references(self):
workspace_a = "81000000-0000-4000-8000-000000000001"
workspace_b = "82000000-0000-4000-8000-000000000002"
mission_id = str(uuid.uuid4())
proposal_id = str(uuid.uuid4())
provision_id = str(uuid.uuid4())
change_id = str(uuid.uuid4())
mission = mock.Mock()
mission.snapshot.return_value = {"mission_id": mission_id}
self.server.workspace = self.existing.resolve()
self.server.active_workspace_id = workspace_a
self.server.library_generation = 4
self.server.agent_missions[workspace_a] = {
"state": "ACTIVE", "mission": mission, "started_monotonic": 0,
}
self.server.agent_proposals[workspace_a] = {
proposal_id: {"proposal_id": proposal_id, "mission_id": mission_id,
"proposal": {}, "decision": None},
}
provisioning = mock.Mock()
provisioning.get.return_value = {
"request_id": provision_id, "workspace_id": workspace_a,
"turn_id": str(uuid.uuid4()),
}
self.server._tool_provisioning = provisioning
self.server.tooling_enabled = True
self.server.code_change_turns[change_id] = {
"workspace_id": workspace_a, "turn_id": str(uuid.uuid4()),
}
self.server.close_library_workspace(
workspace_a, 4, str(uuid.uuid4()), True)
self.library.workspace = self.existing_b
with mock.patch.object(self.server, "bridge_call", return_value=""):
self.server.open_library_workspace(workspace_b, 4)
# INVARIANT: changing the active workspace invalidates every durable
# continuation from A before its underlying service can mutate state.
status, _, body = self.mutate("/api/v1/agent-mission/rescope", {
"workspace_id": workspace_a, "mission_id": mission_id,
"scoped_refs": [], "pivots": [], "human_confirmed": True,
"idempotency_key": str(uuid.uuid4()),
})
self.assertEqual(status, 403, body)
status, _, body = self.mutate(
f"/api/v1/agent-proposals/{proposal_id}/approve", {
"workspace_id": workspace_a, "decision_id": str(uuid.uuid4()),
"reason": "Décision SPECIMEN", "decided_by": "human",
"decided_at": "2026-01-01T00:00:00Z",
"idempotency_key": str(uuid.uuid4()),
})
self.assertEqual(status, 403, body)
decision = {
"decision_id": str(uuid.uuid4()),
"idempotency_key": str(uuid.uuid4()),
"actor": "human", "human_confirmed": True,
"reason": "Décision SPECIMEN",
}
status, _, body = self.mutate(
f"/api/v1/tool-provisioning/{provision_id}/approve", decision)
self.assertEqual(status, 403, body)
status, _, body = self.mutate(
f"/api/v1/code-changes/{change_id}/approve-prepare",
{**decision, "decision_id": str(uuid.uuid4()),
"idempotency_key": str(uuid.uuid4())})
self.assertEqual(status, 403, body)
provisioning.approve_provision.assert_not_called()
if __name__ == "__main__":
unittest.main()

View file

@ -75,19 +75,23 @@ try {
await assertNoHorizontalOverflow(page);
await screenshot(page, "1440-network");
const objective = await page.evaluate(async () => {
const response = await fetch("/api/v1/snapshot", { cache: "no-store" });
const value = await response.json();
return value.nodes.find((node) => typeof node.label === "string" && node.label)?.label;
});
assert.equal(typeof objective, "string");
await page.focus("#agent-prompt");
await page.type("#agent-prompt", "Prépare une piste SPECIMEN sans lancer d’action.");
await page.type("#agent-prompt", objective);
await page.keyboard.down("Control");
await page.keyboard.press("Enter");
await page.keyboard.up("Control");
assert.equal(await page.$eval("#agent-prompt", (element) => element.value),
"Prépare une piste SPECIMEN sans lancer d’action.");
await page.waitForFunction(() => document.querySelector(".agent-conversation")?.textContent
.includes("Prépare une piste SPECIMEN"));
assert.match(
await page.$eval(".agent-conversation", (element) => element.textContent),
/Prépare une piste SPECIMEN/,
);
objective);
await page.waitForFunction((expected) =>
document.querySelector(".agent-conversation")?.textContent.includes(expected), {}, objective);
assert.ok((await page.$eval(".agent-conversation", (element) => element.textContent))
.includes(objective));
await page.type("#agent-prompt", "Brouillon SPECIMEN à conserver pendant le défilement.");
await page.$eval(".agent-scroll", (element) => { element.scrollTop = element.scrollHeight; });
assert.deepEqual(
@ -95,13 +99,13 @@ try {
value: element.value,
visible: element.getBoundingClientRect().bottom > 0,
})),
{ value: "Prépare une piste SPECIMEN sans lancer d’action.Brouillon SPECIMEN à conserver pendant le défilement.", visible: true },
{ value: `${objective}Brouillon SPECIMEN à conserver pendant le défilement.`, visible: true },
);
await page.click('[data-pane="agent-panel"]');
await page.click('[data-pane="agent-panel"]');
assert.equal(
await page.$eval("#agent-prompt", (element) => element.value),
"Prépare une piste SPECIMEN sans lancer d’action.Brouillon SPECIMEN à conserver pendant le défilement.",
`${objective}Brouillon SPECIMEN à conserver pendant le défilement.`,
);
await screenshot(page, "1440-agent");

View file

@ -202,8 +202,8 @@ class LibraryTest(SyntheticEnvironment):
moved = self.root / "moved-workspace"
workspace.rename(moved)
workspace.symlink_to(moved, target_is_directory=True)
with self.assertRaisesRegex(LibraryError, "invalide"):
self.library.snapshot()
snapshot = self.library.snapshot()
self.assertEqual(snapshot["entries"][0]["state"], "INVALID")
def test_create_resumes_same_workspace_after_bridge_interruption(self):
run = subprocess.run

View file

@ -311,7 +311,10 @@ try {
await stopServer(server);
server = startServer(library, runtime, state, assignedPort);
const restartedForB = await assertServerReady(server, session.origin);
await resumeAutomaticSessionAfterOwnedRestart(page, restartedForB, "B");
// WHY: a unique root URL prevents Firefox from restoring the document of the
// previous owned instance before GET / has established the new HttpOnly cookie.
await resumeAutomaticSessionAfterOwnedRestart(
page, `${restartedForB}/?owned_restart=B`, "B");
assert.equal(await page.$eval("#workspace-list", (item) => item.children.length), 1);
assert.equal(await page.$eval(".workspace-card h3", (item) => item.textContent), workspaceATitle);
assert.match(await page.$eval(".workspace-card p", (item) => item.textContent), /Prête à ouvrir/);
@ -347,7 +350,8 @@ try {
await stopServer(server);
server = startServer(library, runtime, state, assignedPort);
const restartedForA = await assertServerReady(server, session.origin);
await resumeAutomaticSessionAfterOwnedRestart(page, restartedForA, "C");
await resumeAutomaticSessionAfterOwnedRestart(
page, `${restartedForA}/?owned_restart=C`, "C");
assert.equal(await page.$eval("#workspace-list", (item) => item.children.length), 2);
await openWorkspace(page, workspaceAId);
assert.equal(await page.$eval("#investigation-title", (item) => item.textContent),

View file

@ -148,14 +148,35 @@ class WorkspaceServerTest(unittest.TestCase):
result = json.loads(body)
self.assertEqual(result["state"], "COMPLETED")
self.assertNotEqual(result["call_id"], result["result_id"])
self.assertTrue(result["object_refs"])
self.assertEqual(result["output"]["matches"], [])
self.assertEqual(result["object_refs"], [])
graph = json.loads((self.workspace / "core-snapshot.json").read_text())
node = next(item for item in graph["nodes"] if item.get("label"))
positive = {**request,
"turn_id": "83000000-0000-4000-8000-000000000032",
"input": {"query": node["label"]},
"idempotency_key": "83000000-0000-4000-8000-000000000033"}
time.sleep(0.04)
status, _, body = self.mutate("/api/v1/agent-tools/calls", positive)
self.assertEqual(status, 201, body)
positive_call = json.loads(body)
status, _, body = self.request_raw(
"GET", f'/api/v1/agent-tools/results/{positive_call["result_id"]}',
cookie=self.cookie)
self.assertEqual(status, 200, body)
positive_result = json.loads(body)
self.assertEqual(positive_result["state"], "COMPLETED")
self.assertIn({"object_id": node["id"]}, positive_result["object_refs"])
self.assertTrue(any(match.get("object_id") == node["id"]
for match in positive_result["output"]["matches"]))
status, _, body = self.request_raw(
"GET", "/api/v1/agent-tools/events?cursor=0", cookie=self.cookie)
self.assertEqual(status, 200, body)
self.assertTrue(json.loads(body)["events"])
time.sleep(0.04)
status, _, body = self.mutate("/api/v1/agent-tools/turns", {
"objective": "Analyser la piste SPECIMEN sans auto-autorisation"})
"objective": node["label"]})
self.assertEqual(status, 201, body)
turn = json.loads(body)
self.assertEqual(turn["state"], "AUTHORIZATION_REQUIRED")

View file

@ -1,314 +1,580 @@
#!/usr/bin/env python3
"""Bibliothèque locale privée d'espaces Labfy explicitement enregistrés."""
from __future__ import annotations
import fcntl
import hashlib
import heapq
import json
import os
import selectors
import secrets
import subprocess
import threading
import time
import uuid
from pathlib import Path
MAX_ENTRIES = 256
REGISTRY_CONTRACT = "labfy.web_library.registry.v1"
CREATION_INTENT_CONTRACT = "labfy.web_library.creation_intent.v1"
MAX_CANDIDATES = 256
REGISTRY_CONTRACT_V1 = 'labfy.web_library.registry.v1'
REGISTRY_CONTRACT = 'labfy.web_library.registry.v2'
CREATION_INTENT_CONTRACT = 'labfy.web_library.creation_intent.v1'
WORKSPACE_CONTRACT = 'labfy.local_workspace.v1'
VALIDATION_CONTRACT = 'labfy.local_workspace.validation.v1'
class LibraryError(ValueError):
"""Refus d'une opération de bibliothèque sans effet implicite sur le disque."""
class WebLibrary:
def __init__(self, root: Path, bridge: Path, *, lazy=False):
if not isinstance(lazy, bool):
raise TypeError("Mode de bibliothèque invalide")
self.root = Path(root).absolute()
self.bridge = Path(bridge).resolve()
self.lazy = lazy
self._prepare_lock = threading.Lock()
self._ready = False
self.workspaces = self.root / "workspaces"
self.registry_path = self.root / "registry.json"
self.creation_intent_path = self.root / "creation-intent.json"
self.lock_path = self.root / "registry.lock"
raise TypeError('Mode de bibliothèque invalide')
self.root, self.bridge, self.lazy = (Path(root).absolute(), Path(bridge).resolve(), lazy)
self.workspaces, self.registry_path = (self.root / 'workspaces', self.root / 'registry.json')
self.creation_intent_path, self.lock_path = (self.root / 'creation-intent.json', self.root / 'registry.lock')
self._prepare_lock, self._candidate_lock = (threading.Lock(), threading.Lock())
self._ready, self._candidates = (False, {})
if not lazy:
self._ensure_ready()
def _ensure_ready(self):
# INVARIANT: lazy construction performs no stat, mkdir or registry read
# under the selected library. Only an explicit list/create/open call
# initializes it, once, before the existing locked operations run.
# INVARIANT: lazy construction performs no filesystem inspection.
with self._prepare_lock:
if self._ready:
return
self._prepare_private_root()
for part in reversed((self.root, *self.root.parents)):
if part.exists() and part.is_symlink():
raise LibraryError('Lien symbolique interdit dans la bibliothèque')
if self.root.exists():
self._assert_dir(self.root)
else:
self.root.mkdir(mode=0o700, parents=True)
os.chmod(self.root, 0o700)
self.workspaces.mkdir(mode=0o700, exist_ok=True)
self._assert_plain_directory(self.workspaces)
self._assert_dir(self.workspaces)
self._ready = True
def _prepare_private_root(self):
for component in reversed((self.root, *self.root.parents)):
if component.exists() and component.is_symlink():
raise LibraryError(
f"Lien symbolique interdit dans la bibliothèque : {component}")
if self.root.exists():
self._assert_plain_directory(self.root)
else:
self.root.mkdir(mode=0o700, parents=True)
os.chmod(self.root, 0o700)
@staticmethod
def _assert_plain_directory(path: Path):
# INVARIANT: aucun composant géré ne peut rediriger la bibliothèque.
def _assert_dir(path):
if path.is_symlink() or not path.is_dir():
raise LibraryError(f"Répertoire de bibliothèque invalide : {path}")
raise LibraryError('Répertoire de bibliothèque invalide')
def _locked(self):
self._ensure_ready()
descriptor = os.open(self.lock_path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
os.fchmod(descriptor, 0o600)
fcntl.flock(descriptor, fcntl.LOCK_EX)
return descriptor
def _empty_registry(self):
return {"contract": REGISTRY_CONTRACT, "generation": 0, "entries": []}
def _read_registry(self):
if not self.registry_path.exists():
return self._empty_registry()
if self.registry_path.is_symlink() or not self.registry_path.is_file():
raise LibraryError("Registre de bibliothèque invalide")
try:
value = json.loads(self.registry_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise LibraryError("Registre de bibliothèque illisible") from error
if (not isinstance(value, dict) or value.get("contract") != REGISTRY_CONTRACT or
not isinstance(value.get("generation"), int) or
not isinstance(value.get("entries"), list) or
len(value["entries"]) > MAX_ENTRIES):
raise LibraryError("Registre de bibliothèque mal formé")
seen = set()
for entry in value["entries"]:
if not self._valid_entry(entry) or entry["workspace_id"] in seen:
raise LibraryError("Entrée de bibliothèque invalide")
seen.add(entry["workspace_id"])
self._workspace_path(entry["workspace_id"], require_exists=True)
return value
fd = os.open(self.lock_path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
os.fchmod(fd, 0o600)
fcntl.flock(fd, fcntl.LOCK_EX)
return fd
@staticmethod
def _valid_entry(entry):
if not isinstance(entry, dict) or set(entry) != {
"workspace_id", "title", "idempotency_key", "request_hash"}:
return False
def _valid_uuid(value):
try:
uuid.UUID(entry["workspace_id"])
return isinstance(value, str) and str(uuid.UUID(value)) == value
except (ValueError, TypeError, AttributeError):
return False
return (isinstance(entry["title"], str) and 0 < len(entry["title"]) <= 200 and
isinstance(entry["idempotency_key"], str) and
0 < len(entry["idempotency_key"]) <= 128 and
isinstance(entry["request_hash"], str) and
len(entry["request_hash"]) == 64)
def _write_registry(self, value):
self._write_json(self.registry_path, value, "registry")
@staticmethod
def _valid_title(value):
return isinstance(value, str) and 0 < len(value) <= 200 and (value == value.strip()) and (not any((ord(c) < 32 for c in value)))
def _write_json(self, destination, value, temporary_prefix):
temporary = self.root / f".{temporary_prefix}-{uuid.uuid4().hex}.tmp"
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
0o600)
try:
data = (json.dumps(value, ensure_ascii=False, sort_keys=True,
separators=(",", ":")) + "\n").encode()
os.write(descriptor, data)
os.fsync(descriptor)
finally:
os.close(descriptor)
temporary.replace(destination)
self._sync_root()
@staticmethod
def _valid_key(value):
return isinstance(value, str) and 0 < len(value) <= 128 and (not any((ord(c) < 32 for c in value)))
def _sync_root(self):
directory = os.open(self.root, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory)
finally:
os.close(directory)
@staticmethod
def _valid_generation(value):
return isinstance(value, int) and (not isinstance(value, bool)) and (value >= 0)
def _read_creation_intent(self):
if not self.creation_intent_path.exists():
@staticmethod
def _valid_child(value):
return isinstance(value, str) and 0 < len(value) <= 200 and (Path(value).name == value) and (not value.startswith('.')) and (not any((ord(c) < 32 for c in value)))
def _normalize_entry(self, raw, contract):
base = {'workspace_id', 'title', 'idempotency_key', 'request_hash'}
expected = base if contract == REGISTRY_CONTRACT_V1 else base | {'storage_kind', 'storage_ref'}
if not isinstance(raw, dict) or set(raw) != expected:
return None
if (self.creation_intent_path.is_symlink() or
not self.creation_intent_path.is_file()):
raise LibraryError("Intention de création invalide")
item = dict(raw)
if contract == REGISTRY_CONTRACT_V1:
item.update(storage_kind='MANAGED', storage_ref=f"workspaces/{raw.get('workspace_id', '')}")
if not self._valid_uuid(item.get('workspace_id')) or not self._valid_title(item.get('title')) or (not self._valid_key(item.get('idempotency_key'))) or (not isinstance(item.get('request_hash'), str)) or (len(item['request_hash']) != 64):
return None
if item.get('storage_kind') == 'MANAGED':
return item if item.get('storage_ref') == f"workspaces/{item['workspace_id']}" else None
if item.get('storage_kind') == 'EXISTING' and self._valid_child(item.get('storage_ref')):
return item
return None
def _read_registry(self):
if self.registry_path.is_symlink() or not self.registry_path.is_file():
if not self.registry_path.exists() and not self.registry_path.is_symlink():
return {'contract': REGISTRY_CONTRACT, 'generation': 0, 'entries': []}
raise LibraryError('Registre de bibliothèque invalide')
try:
value = json.loads(self.creation_intent_path.read_text(encoding="utf-8"))
value = json.loads(self.registry_path.read_text(encoding='utf-8'))
except (OSError, UnicodeError, json.JSONDecodeError) as error:
raise LibraryError('Registre de bibliothèque illisible') from error
contract = value.get('contract') if isinstance(value, dict) else None
if contract not in {REGISTRY_CONTRACT_V1, REGISTRY_CONTRACT} or not self._valid_generation(value.get('generation')) or (not isinstance(value.get('entries'), list)) or (len(value['entries']) > MAX_ENTRIES):
raise LibraryError('Registre de bibliothèque mal formé')
entries = []
ids = set()
refs = set()
keys = set()
for raw in value['entries']:
item = self._normalize_entry(raw, contract)
if item is None or item['workspace_id'] in ids or item['storage_ref'] in refs or (item['idempotency_key'] in keys):
raise LibraryError('Entrée de bibliothèque invalide ou dupliquée')
ids.add(item['workspace_id'])
refs.add(item['storage_ref'])
keys.add(item['idempotency_key'])
entries.append(item)
return {'contract': contract, 'generation': value['generation'], 'entries': entries}
def _write_json(self, destination, value, prefix):
temp = destination.parent / f'.{prefix}-{uuid.uuid4().hex}.tmp'
# INVARIANT: registry/manifest publication is temp + fsync + replace +
# parent fsync, so a crash exposes either the old or the new JSON.
fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
try:
data = (json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(',', ':')) + '\n').encode()
offset = 0
while offset < len(data):
offset += os.write(fd, data[offset:])
os.fsync(fd)
except Exception:
try:
temp.unlink()
except FileNotFoundError:
pass
raise
finally:
os.close(fd)
temp.replace(destination)
self._sync_dir(destination.parent)
@staticmethod
def _sync_dir(path):
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
try:
os.fsync(fd)
finally:
os.close(fd)
def _write_registry(self, registry):
self._write_json(self.registry_path, {'contract': REGISTRY_CONTRACT, 'generation': registry['generation'], 'entries': registry['entries']}, 'registry')
def _read_intent(self):
if self.creation_intent_path.is_symlink() or not self.creation_intent_path.is_file():
if (not self.creation_intent_path.exists() and
not self.creation_intent_path.is_symlink()):
return None
raise LibraryError('Intention de création invalide')
try:
value = json.loads(self.creation_intent_path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError) as error:
raise LibraryError("Intention de création illisible") from error
if (not isinstance(value, dict) or
value.get("contract") != CREATION_INTENT_CONTRACT or
not self._valid_entry({key: item for key, item in value.items()
if key != "contract"}) or
set(value) != {"contract", "workspace_id", "title",
"idempotency_key", "request_hash"}):
raise LibraryError("Intention de création mal formée")
return {key: item for key, item in value.items() if key != "contract"}
raise LibraryError('Intention de création illisible') from error
raw = {k: v for k, v in value.items() if k != 'contract'}
if value.get('contract') != CREATION_INTENT_CONTRACT or self._normalize_entry(raw, REGISTRY_CONTRACT_V1) is None:
raise LibraryError('Intention de création mal formée')
return raw
def _write_creation_intent(self, entry):
self._write_json(self.creation_intent_path,
{"contract": CREATION_INTENT_CONTRACT, **entry},
"creation-intent")
def _clear_creation_intent(self):
def _clear_intent(self):
try:
self.creation_intent_path.unlink()
except FileNotFoundError:
return
self._sync_root()
self._sync_dir(self.root)
def _entry_path(self, item, require=False):
path = self.workspaces / item['workspace_id'] if item['storage_kind'] == 'MANAGED' else self.root / item['storage_ref']
parent = self.workspaces.resolve() if item['storage_kind'] == 'MANAGED' else self.root.resolve()
if require:
self._assert_dir(path)
if path.resolve().parent != parent:
raise LibraryError('Espace hors bibliothèque')
return path
def _workspace_path(self, workspace_id, *, require_exists):
try:
normalized = str(uuid.UUID(workspace_id))
except (ValueError, TypeError, AttributeError) as error:
raise LibraryError("Identifiant d'enquête invalide") from error
if normalized != workspace_id:
raise LibraryError("Identifiant d'enquête non canonique")
candidate = self.workspaces / normalized
if require_exists:
self._assert_plain_directory(candidate)
# CONTRACT: un espace enregistré est un enfant direct, jamais un chemin exploré.
if candidate.resolve().parent != self.workspaces.resolve():
raise LibraryError("Espace hors bibliothèque")
return candidate
if not self._valid_uuid(workspace_id):
raise LibraryError("Identifiant d'enquête invalide")
return self._entry_path({'workspace_id': workspace_id, 'storage_kind': 'MANAGED'}, require_exists)
@staticmethod
def _public_entry(entry):
return {"workspace_id": entry["workspace_id"], "title": entry["title"],
"state": "READY"}
def _bridge_error(result, fallback):
return result.stderr.strip().splitlines()[-1].removeprefix('local-jobs: ')[:300] if result.stderr.strip() else fallback
def snapshot(self, active_workspace_id=None):
descriptor = self._locked()
def _validate_workspace(self, path, require_manifest=True):
# WHY: Python checks confinement and delegates database/jobstore
# compatibility to the strictly read-only C bridge.
database = path / 'Enquete.sqlite'
if database.is_symlink() or not database.is_file():
raise LibraryError("Base d'enquête absente ou invalide")
labfy = path / '.labfy'
runtime = labfy / 'runtime'
jobs = runtime / 'jobs.sqlite'
for directory in (labfy, runtime):
if directory.is_symlink() or not directory.is_dir():
raise LibraryError('Répertoire de métadonnées invalide')
if jobs.is_symlink() or not jobs.is_file():
raise LibraryError('JobStore compatible absent ou invalide')
before = self._stat(database)
result = self._run_validator(path)
if result.returncode:
raise LibraryError(self._bridge_error(result, "Base d'enquête incompatible"))
try:
registry = self._read_registry()
finally:
os.close(descriptor)
entries = sorted((self._public_entry(item) for item in registry["entries"]),
key=lambda item: (item["title"].casefold(), item["workspace_id"]))
return {"contract": "labfy.web_library.v1", "entries": entries,
"active_workspace_id": active_workspace_id,
"generation": registry["generation"]}
core = json.loads(result.stdout)
except json.JSONDecodeError as error:
raise LibraryError('Validation C illisible') from error
if core.get('contract') != VALIDATION_CONTRACT or not self._valid_uuid(core.get('workspace_id')) or (not self._valid_title(core.get('title'))):
raise LibraryError('Validation C invalide')
if before != self._stat(database):
raise LibraryError('CANDIDATE_CHANGED : relancez la recherche')
manifest = path / '.labfy/runtime/workspace.json'
if manifest.is_symlink():
raise LibraryError("Manifeste d'enquête invalide")
if not manifest.exists():
if require_manifest:
raise LibraryError("Métadonnées d'enquête absentes")
return {**core, 'manifest': False}
if not manifest.is_file():
raise LibraryError("Manifeste d'enquête invalide")
try:
data = json.loads(manifest.read_text(encoding='utf-8'))
except (OSError, UnicodeError, json.JSONDecodeError) as error:
raise LibraryError("Manifeste d'enquête invalide") from error
if set(data) != {'contract', 'version', 'investigation_id', 'title', 'mode', 'database'} or data.get('contract') != WORKSPACE_CONTRACT or data.get('version') != 1 or (data.get('database') != 'Enquete.sqlite') or (data.get('mode') != 'local_experimental') or (data.get('investigation_id') != core['workspace_id']) or (data.get('title') != core['title']):
raise LibraryError("Manifeste d'enquête incohérent")
return {**core, 'manifest': True}
def create(self, title, idempotency_key):
if (not isinstance(title, str) or not title.strip() or len(title) > 200 or
any(ord(character) < 32 for character in title)):
raise TypeError("Titre d'enquête invalide")
if (not isinstance(idempotency_key, str) or not idempotency_key or
len(idempotency_key) > 128):
raise TypeError("Clé d'idempotence invalide")
title = title.strip()
request_hash = hashlib.sha256(title.encode("utf-8")).hexdigest()
descriptor = self._locked()
def _run_validator(self, path):
arguments = [str(self.bridge), 'validate-workspace-json',
'--workspace', str(path)]
process = subprocess.Popen(
arguments,
cwd=self.bridge.parents[1],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
streams = selectors.DefaultSelector()
streams.register(process.stdout, selectors.EVENT_READ, 'stdout')
streams.register(process.stderr, selectors.EVENT_READ, 'stderr')
output = {'stdout': bytearray(), 'stderr': bytearray()}
deadline = time.monotonic() + 30
try:
registry = self._read_registry()
intent = self._read_creation_intent()
if intent is not None:
published = next((item for item in registry["entries"]
if item["workspace_id"] == intent["workspace_id"]), None)
if published is not None:
if published != intent:
raise LibraryError("Intention de création incohérente avec le registre")
self._clear_creation_intent()
intent = None
replay = next((item for item in registry["entries"]
if item["idempotency_key"] == idempotency_key), None)
if replay is not None:
if replay["request_hash"] != request_hash:
raise LibraryError("Clé d'idempotence déjà liée à un autre titre")
return self._response(replay, registry["generation"], True)
if len(registry["entries"]) >= MAX_ENTRIES:
raise LibraryError("Bibliothèque limitée à 256 enquêtes")
if intent is None:
entry = {"workspace_id": str(uuid.uuid4()), "title": title,
"idempotency_key": idempotency_key,
"request_hash": request_hash}
self._write_creation_intent(entry)
workspace = self._workspace_path(entry["workspace_id"],
require_exists=False)
workspace.mkdir(mode=0o700)
workspace_ready = False
else:
if (intent["idempotency_key"] != idempotency_key or
intent["request_hash"] != request_hash):
raise LibraryError(
"Une création interrompue doit être reprise avec sa clé d'idempotence")
entry = intent
workspace = self._workspace_path(entry["workspace_id"],
require_exists=False)
if not workspace.exists():
workspace.mkdir(mode=0o700)
workspace_ready = False
else:
self._workspace_path(entry["workspace_id"], require_exists=True)
try:
self._validate_created_workspace(workspace)
workspace_ready = True
except LibraryError:
workspace_ready = False
if not workspace_ready:
# CONTRACT: le bridge C existant reste l'unique créateur de l'enquête.
result = subprocess.run(
[str(self.bridge), "create-workspace", "--workspace", str(workspace),
"--title", title], cwd=self.bridge.parents[1], text=True,
capture_output=True, timeout=30, check=False)
if result.returncode != 0:
message = (result.stderr.strip().splitlines()[-1]
if result.stderr.strip() else "Création C refusée")
raise LibraryError(message.removeprefix("local-jobs: "))
self._validate_created_workspace(workspace)
registry["entries"].append(entry)
registry["generation"] += 1
self._write_registry(registry)
self._clear_creation_intent()
return self._response(entry, registry["generation"], False)
while streams.get_map():
remaining = deadline - time.monotonic()
if remaining <= 0:
raise LibraryError('Validation C hors délai')
ready = streams.select(remaining)
if not ready:
raise LibraryError('Validation C hors délai')
for key, _events in ready:
chunk = os.read(key.fileobj.fileno(), 4096)
if not chunk:
streams.unregister(key.fileobj)
continue
target = output[key.data]
if len(target) + len(chunk) > 64 * 1024:
raise LibraryError('Sortie de validation C trop volumineuse')
target.extend(chunk)
returncode = process.wait(timeout=max(0.1, deadline - time.monotonic()))
except Exception:
process.kill()
process.wait()
raise
finally:
os.close(descriptor)
streams.close()
process.stdout.close()
process.stderr.close()
return subprocess.CompletedProcess(
arguments,
returncode,
output['stdout'].decode('utf-8', errors='replace'),
output['stderr'].decode('utf-8', errors='replace'),
)
@staticmethod
def _validate_created_workspace(workspace):
database = workspace / "Enquete.sqlite"
manifest = workspace / ".labfy" / "runtime" / "workspace.json"
if (database.is_symlink() or not database.is_file() or manifest.is_symlink() or
not manifest.is_file()):
def _stat(path):
value = path.stat(follow_symlinks=False)
return (value.st_dev, value.st_ino, value.st_size, value.st_mtime_ns, value.st_ctime_ns)
def _signature(self, path):
manifest = path / '.labfy/runtime/workspace.json'
return (self._stat(path), self._stat(path / 'Enquete.sqlite'), self._stat(manifest) if manifest.exists() else None)
@staticmethod
def _validate_created_workspace(path):
database = path / 'Enquete.sqlite'
manifest = path / '.labfy/runtime/workspace.json'
if database.is_symlink() or not database.is_file() or manifest.is_symlink() or (not manifest.is_file()):
raise LibraryError("Le bridge C n'a pas publié un espace valide")
try:
value = json.loads(manifest.read_text(encoding="utf-8"))
data = json.loads(manifest.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError) as error:
raise LibraryError("Manifeste d'enquête invalide") from error
if value.get("contract") != "labfy.local_workspace.v1":
if data.get('contract') != WORKSPACE_CONTRACT:
raise LibraryError("Contrat de manifeste d'enquête inattendu")
@staticmethod
def _response(entry, generation, replayed):
return {"contract": "labfy.web_library.workspace.v1",
**WebLibrary._public_entry(entry), "generation": generation,
"replayed": replayed}
def _validate_managed(self, path):
self._validate_created_workspace(path)
data = json.loads((path / '.labfy/runtime/workspace.json').read_text(encoding='utf-8'))
if not self._valid_uuid(data.get('investigation_id')) or not self._valid_title(data.get('title')):
raise LibraryError("Manifeste d'enquête invalide")
return {'workspace_id': data['investigation_id'], 'title': data['title']}
def open(self, workspace_id, expected_generation):
if not isinstance(expected_generation, int) or isinstance(expected_generation, bool):
raise TypeError("Génération attendue invalide")
descriptor = self._locked()
def _state(self, item):
path = self._entry_path(item)
if path.is_symlink():
return 'INVALID'
if not path.exists():
return 'MISSING'
try:
self._entry_path(item, True)
info = self._validate_managed(path) if item['storage_kind'] == 'MANAGED' else self._validate_workspace(path)
if item['storage_kind'] == 'EXISTING' and (info['workspace_id'] != item['workspace_id'] or info['title'] != item['title']):
raise LibraryError('incohérent')
return 'READY'
except (LibraryError, OSError, subprocess.TimeoutExpired):
return 'INVALID'
def snapshot(self, active_workspace_id=None):
fd = self._locked()
try:
registry = self._read_registry()
if expected_generation != registry["generation"]:
raise LibraryError("Projection de bibliothèque périmée")
entry = next((item for item in registry["entries"]
if item["workspace_id"] == workspace_id), None)
if entry is None:
raise LibraryError("Enquête absente de la bibliothèque")
workspace = self._workspace_path(workspace_id, require_exists=True)
self._validate_created_workspace(workspace)
return workspace, entry, registry["generation"]
finally:
os.close(descriptor)
os.close(fd)
entries = [{'workspace_id': e['workspace_id'], 'title': e['title'], 'storage_kind': e['storage_kind'], 'state': self._state(e)} for e in registry['entries']]
entries.sort(key=lambda e: (e['title'].casefold(), e['workspace_id']))
return {'contract': 'labfy.web_library.v1', 'entries': entries, 'active_workspace_id': active_workspace_id, 'generation': registry['generation']}
@staticmethod
def _response(item, generation, replayed):
return {'contract': 'labfy.web_library.workspace.v1', 'workspace_id': item['workspace_id'], 'title': item['title'], 'state': 'READY', 'generation': generation, 'replayed': replayed}
def create(self, title, idempotency_key):
if not self._valid_title(title):
raise TypeError("Titre d'enquête invalide")
if not self._valid_key(idempotency_key):
raise TypeError("Clé d'idempotence invalide")
request_hash = hashlib.sha256(title.encode()).hexdigest()
fd = self._locked()
try:
registry = self._read_registry()
intent = self._read_intent()
replay = next((e for e in registry['entries'] if e['idempotency_key'] == idempotency_key), None)
if replay:
if replay['request_hash'] != request_hash or replay['storage_kind'] != 'MANAGED':
raise LibraryError("Clé d'idempotence déjà liée à un autre titre")
if intent and intent['workspace_id'] == replay['workspace_id']:
self._clear_intent()
return self._response(replay, registry['generation'], True)
if len(registry['entries']) >= MAX_ENTRIES:
raise LibraryError('Bibliothèque limitée à 256 enquêtes')
if intent is None:
intent = {'workspace_id': str(uuid.uuid4()), 'title': title, 'idempotency_key': idempotency_key, 'request_hash': request_hash}
self._write_json(self.creation_intent_path, {'contract': CREATION_INTENT_CONTRACT, **intent}, 'creation-intent')
elif intent['idempotency_key'] != idempotency_key or intent['request_hash'] != request_hash:
raise LibraryError("Une création interrompue doit être reprise avec sa clé d'idempotence")
path = self._workspace_path(intent['workspace_id'], require_exists=False)
ready = False
if path.exists():
self._workspace_path(intent['workspace_id'], require_exists=True)
try:
self._validate_created_workspace(path)
ready = True
except LibraryError:
pass
else:
path.mkdir(mode=448)
if not ready:
result = subprocess.run([str(self.bridge), 'create-workspace', '--workspace', str(path), '--title', title], cwd=self.bridge.parents[1], text=True, capture_output=True, timeout=30, check=False)
if result.returncode:
raise LibraryError(self._bridge_error(result, 'Création C refusée'))
self._validate_created_workspace(path)
item = {**intent, 'storage_kind': 'MANAGED', 'storage_ref': f"workspaces/{intent['workspace_id']}"}
registry['entries'].append(item)
registry['generation'] += 1
self._write_registry(registry)
self._clear_intent()
return self._response(item, registry['generation'], False)
finally:
os.close(fd)
@staticmethod
def _candidate(candidate_id, name, workspace_id, title, state, reason):
return {'candidate_id': candidate_id, 'display_name': name[:200], 'workspace_id': workspace_id, 'title': title, 'state': state, 'reason': reason}
def _inspect(self, path, candidate_id):
if path.is_symlink() or not path.is_dir() or path.resolve().parent != self.root.resolve():
return (self._candidate(candidate_id, path.name, None, None, 'INVALID', 'Lien symbolique ou dossier hors bibliothèque'), None)
specimen = path / '.labfy/runtime/specimen.json'
if specimen.exists() or specimen.is_symlink():
return (self._candidate(candidate_id, path.name, None, None, 'UNSUPPORTED_LEGACY_LAYOUT', 'Disposition SPECIMEN historique non enregistrable'), None)
try:
info = self._validate_workspace(path, False)
signature = self._signature(path)
except (LibraryError, OSError, subprocess.TimeoutExpired) as error:
reason = self._safe_candidate_reason(error)
return (self._candidate(candidate_id, path.name, None, None,
'INVALID', reason), None)
state = 'READY_TO_REGISTER' if info['manifest'] else 'NEEDS_METADATA_MIGRATION'
reason = 'Dossier valide prêt à enregistrer' if info['manifest'] else 'Base compatible ; métadonnées à créer, base inchangée'
return (self._candidate(candidate_id, path.name, info['workspace_id'], info['title'], state, reason), signature)
@staticmethod
def _safe_candidate_reason(error):
allowed = {
"Base d'enquête absente ou invalide",
'Répertoire de métadonnées invalide',
'JobStore compatible absent ou invalide',
"Manifeste d'enquête invalide",
"Manifeste d'enquête incohérent",
'Validation C invalide',
'Validation C illisible',
'Validation C hors délai',
'Sortie de validation C trop volumineuse',
}
message = str(error)
return message if message in allowed else 'Dossier invalide ou incompatible'
def discover_existing(self):
# CONTRACT: discovery creates no directory, lock file or metadata.
if not self.root.exists():
return {'contract': 'labfy.web_library.discovery.v1', 'generation': 0, 'candidates': []}
self._assert_dir(self.root)
registry = self._read_registry()
registered = {e['storage_ref']: e for e in registry['entries'] if e['storage_kind'] == 'EXISTING'}
def discoverable_names():
# INVARIANT: nsmallest retains at most MAX_CANDIDATES names even
# when the library root contains many direct children.
with os.scandir(self.root) as iterator:
for entry in iterator:
if entry.name in {'workspaces', 'registry.json', 'registry.lock', 'creation-intent.json'} or entry.name.startswith('.'):
continue
if entry.is_dir(follow_symlinks=False) or entry.is_symlink():
yield entry.name
cache = {}
public = []
names = heapq.nsmallest(MAX_CANDIDATES, discoverable_names(), key=str.casefold)
for name in names:
cid = secrets.token_urlsafe(24)
if not self._valid_child(name):
result, signature = (self._candidate(cid, name, None, None, 'INVALID', 'Nom de dossier invalide'), None)
else:
result, signature = self._inspect(self.root / name, cid)
if name in registered and result['workspace_id'] == registered[name]['workspace_id']:
result.update(state='ALREADY_REGISTERED', reason='Enquête déjà enregistrée')
cache[cid] = {'name': name, 'signature': signature, 'state': result['state'], 'workspace_id': result['workspace_id'], 'title': result['title']}
public.append(result)
with self._candidate_lock:
self._candidates = cache
return {'contract': 'labfy.web_library.discovery.v1', 'generation': registry['generation'], 'candidates': public}
def _write_manifest(self, path, workspace_id, title):
for directory in (path / '.labfy', path / '.labfy/runtime'):
if directory.exists():
self._assert_dir(directory)
else:
directory.mkdir(mode=0o700)
self._sync_dir(directory.parent)
manifest = path / '.labfy/runtime/workspace.json'
if manifest.exists() or manifest.is_symlink():
raise LibraryError('Manifeste existant non remplaçable')
self._write_json(manifest, {'contract': WORKSPACE_CONTRACT, 'version': 1, 'investigation_id': workspace_id, 'title': title, 'mode': 'local_experimental', 'database': 'Enquete.sqlite'}, 'workspace')
def register_existing(self, candidate_id, expected_generation, idempotency_key, human_confirmed):
if not isinstance(candidate_id, str) or not candidate_id or len(candidate_id) > 128:
raise TypeError('Identifiant de candidat invalide')
if not self._valid_generation(expected_generation):
raise TypeError('Génération attendue invalide')
if not self._valid_key(idempotency_key):
raise TypeError("Clé d'idempotence invalide")
if human_confirmed is not True:
raise LibraryError('Confirmation humaine requise')
with self._candidate_lock:
cached = dict(self._candidates.get(candidate_id, {}))
if not cached:
raise LibraryError('Candidat inconnu ; relancez la recherche')
fd = self._locked()
try:
registry = self._read_registry()
intention = {
'candidate_id': candidate_id,
'name': cached['name'],
'workspace_id': cached['workspace_id'],
'title': cached['title'],
'expected_generation': expected_generation,
'human_confirmed': True,
}
request_hash = hashlib.sha256(
json.dumps(
intention,
sort_keys=True,
separators=(',', ':'),
).encode()
).hexdigest()
replay = next((e for e in registry['entries'] if e['idempotency_key'] == idempotency_key), None)
if replay:
if replay['request_hash'] != request_hash:
raise LibraryError("Clé d'idempotence déjà liée à une autre intention")
return self._response(replay, registry['generation'], True)
if expected_generation != registry['generation']:
raise LibraryError('Projection de bibliothèque périmée')
if cached['state'] not in {'READY_TO_REGISTER', 'NEEDS_METADATA_MIGRATION'}:
raise LibraryError('Candidat non enregistrable')
path = self.root / cached['name']
current, signature = self._inspect(path, candidate_id)
identity_matches = (
current['workspace_id'] == cached['workspace_id'] and
current['title'] == cached['title']
)
metadata_recovery = (
cached['state'] == 'NEEDS_METADATA_MIGRATION' and
current['state'] == 'READY_TO_REGISTER' and
identity_matches and
signature is not None and
cached['signature'] is not None and
signature[1] == cached['signature'][1]
)
unchanged_candidate = (
signature == cached['signature'] and
current['state'] == cached['state'] and
identity_matches
)
if not unchanged_candidate and not metadata_recovery:
raise LibraryError('CANDIDATE_CHANGED : relancez la recherche')
if any((e['workspace_id'] == cached['workspace_id'] or e['storage_ref'] == cached['name'] for e in registry['entries'])):
raise LibraryError('Enquête ou dossier déjà enregistré')
if len(registry['entries']) >= MAX_ENTRIES:
raise LibraryError('Bibliothèque limitée à 256 enquêtes')
if cached['state'] == 'NEEDS_METADATA_MIGRATION' and not metadata_recovery:
self._write_manifest(path, cached['workspace_id'], cached['title'])
self._validate_workspace(path)
item = {'workspace_id': cached['workspace_id'], 'title': cached['title'], 'storage_kind': 'EXISTING', 'storage_ref': cached['name'], 'idempotency_key': idempotency_key, 'request_hash': request_hash}
registry['entries'].append(item)
registry['generation'] += 1
self._write_registry(registry)
return self._response(item, registry['generation'], False)
finally:
os.close(fd)
def open(self, workspace_id, expected_generation):
if not self._valid_generation(expected_generation):
raise TypeError('Génération attendue invalide')
fd = self._locked()
try:
registry = self._read_registry()
if expected_generation != registry['generation']:
raise LibraryError('Projection de bibliothèque périmée')
item = next((e for e in registry['entries'] if e['workspace_id'] == workspace_id), None)
if item is None:
raise LibraryError('Enquête absente de la bibliothèque')
path = self._entry_path(item, True)
info = self._validate_managed(path) if item['storage_kind'] == 'MANAGED' else self._validate_workspace(path)
if item['storage_kind'] == 'EXISTING' and (info['workspace_id'] != item['workspace_id'] or info['title'] != item['title']):
raise LibraryError('Enquête enregistrée invalide')
return (path, item, registry['generation'])
finally:
os.close(fd)

View file

@ -86,6 +86,7 @@ class WorkspaceServer(ThreadingHTTPServer):
raise ValueError("Origine locale publique invalide")
super().__init__(address, handler)
self.workspace = workspace.resolve()
self.inactive_workspace = self.workspace
self.bridge = bridge.resolve()
self.authority = f"127.0.0.1:{self.server_port}"
self.origin = f"http://{self.authority}"
@ -120,6 +121,7 @@ class WorkspaceServer(ThreadingHTTPServer):
self.worker_supervisor = None
self.worker_supervisor_stop = threading.Event()
self.lock = threading.Lock()
self.workspace_lifecycle_lock = threading.RLock()
self.command_rate_lock = threading.Lock()
self.last_command = 0.0
self.report_tasks = {}
@ -134,6 +136,7 @@ class WorkspaceServer(ThreadingHTTPServer):
self.agent_missions = {}
self.agent_proposals = {}
self.agent_operation_replays = {}
self.library_close_replays = {}
# CONTRACT: les magasins rootless sont ouverts uniquement sur demande.
# Le démarrage lazy du service ne lit aucune enquête de la library.
self._tool_provisioning = tool_provisioning
@ -435,14 +438,28 @@ class WorkspaceServer(ThreadingHTTPServer):
context["tool_docs"] = docs
result = self._ensure_developer_agent().propose(context, idempotency_key=key)
if result.get("change_id"):
self.code_change_turns[result["change_id"]] = call_context["turn_id"]
self.code_change_turns[result["change_id"]] = {
"workspace_id": workspace_id,
"turn_id": call_context["turn_id"],
}
return ({**result, "runtime_state": "CODE_CHANGE_APPROVAL_REQUIRED"}
if result.get("state") == "WAITING_DEV_APPROVAL" else result)
def _agent_code_change_get(self, arguments, _key, _call_context):
binding = self.code_change_turns.get(arguments["change_id"])
if binding is None or binding["workspace_id"] != self.agent_workspace_id():
raise PermissionError("Changement de code d'un autre workspace")
return self._code_change_model_summary(
self.code_changes.get(arguments["change_id"]))
def code_change_turn(self, change_id):
binding = self.code_change_turns.get(change_id)
if binding is None and self.library is None:
return ""
if binding is None or binding["workspace_id"] != self.agent_workspace_id():
raise PermissionError("Changement de code d'un autre workspace")
return binding["turn_id"]
@staticmethod
def _code_change_model_summary(record):
# CONTRACT: Qwen receives durable references and bounded test states,
@ -499,10 +516,14 @@ class WorkspaceServer(ThreadingHTTPServer):
def agent_workspace_id(self):
"""Return the opaque identity of the currently opened workspace."""
if self.library is not None:
if self.active_workspace_id is None:
raise AgentMissionError("Aucun workspace actif")
return self.active_workspace_id
context = self.context()
if context is None:
raise AgentMissionError("Aucun workspace actif")
return self.active_workspace_id or context["investigation_id"]
return context["investigation_id"]
def _agent_ref_owned(self, workspace_id, ref):
if workspace_id != self.agent_workspace_id():
@ -991,9 +1012,11 @@ class WorkspaceServer(ThreadingHTTPServer):
def _agent_investigation_context(self, _scope):
"""Return only the bounded projection allowed into a model turn."""
workspace_id = self.agent_workspace_id()
graph = self._agent_graph()
context = self.context() or {}
workspace_id = self.active_workspace_id or context.get("investigation_id", "EMPTY")
context = self.context()
if context is None:
raise AgentMissionError("Aucun workspace actif")
nodes = graph.get("nodes", [])
known = {item.get("id") for item in nodes if isinstance(item, dict)}
@ -1065,6 +1088,7 @@ class WorkspaceServer(ThreadingHTTPServer):
if record["state"] not in acceptable:
raise AgentRuntimeError("Décision toolbox encore en attente", status=409)
elif pending_tool == "code.change.propose":
self.code_change_turn(change_id)
record = self.code_changes.get(change_id)
if record["state"] not in {"WAITING_APPLY_APPROVAL", "DEV_REJECTED",
"APPLIED_LOCAL", "APPLY_REJECTED", "FAILED"}:
@ -1112,6 +1136,22 @@ class WorkspaceServer(ThreadingHTTPServer):
self.library_generation = value["generation"]
return value
def discover_existing_library_workspaces(self):
if self.library is None:
raise ValueError("Mode bibliothèque inactif")
value = self.library.discover_existing()
self.library_generation = value["generation"]
return value
def register_existing_library_workspace(self, candidate_id, expected_generation,
idempotency_key, human_confirmed):
if self.library is None:
raise ValueError("Mode bibliothèque inactif")
value = self.library.register_existing(
candidate_id, expected_generation, idempotency_key, human_confirmed)
self.library_generation = value["generation"]
return value
def create_library_workspace(self, title, idempotency_key):
if self.library is None:
raise ValueError("Mode bibliothèque inactif")
@ -1122,43 +1162,90 @@ class WorkspaceServer(ThreadingHTTPServer):
def open_library_workspace(self, workspace_id, expected_generation):
if self.library is None:
raise ValueError("Mode bibliothèque inactif")
with self.lock:
with self.workspace_lifecycle_lock:
if (self.active_workspace_id is not None and
self.active_workspace_id != workspace_id):
raise ValueError("Une autre enquête est déjà active")
workspace, entry, generation = self.library.open(
workspace_id, expected_generation)
if self.active_workspace_id is None:
previous = self.workspace
# INVARIANT: validation and export target the candidate directly;
# no concurrent request can observe a half-open self.workspace.
self.bridge_call(["export"], workspace=workspace)
self.context(workspace=workspace)
self.workspace = workspace
try:
# CONTRACT: l'ouverture valide aussi le pipeline C existant ;
# le serveur Web ne reconstruit aucune projection lui-même.
self.bridge_call(["export"])
self.context()
except Exception:
self.workspace = previous
raise
self.active_workspace_id = workspace_id
self.library_generation = generation
return {"contract": "labfy.web_library.open.v1",
"workspace_id": workspace_id, "title": entry["title"],
"state": "READY", "generation": generation}
def close_library_workspace(self, workspace_id, expected_generation,
idempotency_key, human_confirmed):
if self.library is None:
raise ValueError("Mode bibliothèque inactif")
try:
key = str(uuid.UUID(idempotency_key))
except (ValueError, TypeError, AttributeError) as error:
raise TypeError("Clé d'idempotence de fermeture invalide") from error
intent = {"workspace_id": workspace_id,
"expected_generation": expected_generation,
"human_confirmed": human_confirmed}
digest = self._agent_operation_digest(intent)
with self.workspace_lifecycle_lock:
replay = self.library_close_replays.get(key)
if replay is not None:
if replay[0] != digest:
raise ValueError("Clé d'idempotence déjà utilisée pour une autre fermeture")
return {**replay[1], "replayed": True}
if human_confirmed is not True:
raise PermissionError("Confirmation humaine requise")
if self.active_workspace_id is None:
raise ValueError("Aucune enquête active")
if workspace_id != self.active_workspace_id:
raise PermissionError("Workspace différent du workspace actif")
if expected_generation != self.library_generation:
raise ValueError("Projection de bibliothèque périmée")
scope = self.agent_scope()
if self.agent_runtime is not None:
self.agent_runtime.cancel_scope(scope, timeout=5.0)
with self.agent_operation_lock:
mission = self.agent_missions.get(workspace_id)
if mission is not None and mission["state"] == "ACTIVE":
mission["state"] = "CANCELLED"
# INVARIANT: only after every owned turn is terminal may the active
# workspace identity and filesystem root become inactive together.
self.active_workspace_id = None
self.workspace = self.inactive_workspace
response = {
"contract": "labfy.web_library.close.v1",
"workspace_id": workspace_id,
"state": "CLOSED",
"active_workspace_id": None,
"generation": self.library_generation,
"replayed": False,
}
self.library_close_replays[key] = (digest, response)
return response
def upload_mutex(self, upload_id):
"""Return the process-local owner lock for one persisted upload."""
with self.upload_lock:
return self.upload_mutexes.setdefault(upload_id, threading.Lock())
@property
def context_path(self):
generic = self.workspace / ".labfy" / "runtime" / "workspace.json"
specimen = self.workspace / ".labfy" / "runtime" / "specimen.json"
def _context_path(self, workspace):
generic = workspace / ".labfy" / "runtime" / "workspace.json"
specimen = workspace / ".labfy" / "runtime" / "specimen.json"
return generic if generic.is_file() else specimen
def context(self):
context_path = self.context_path
database = self.workspace / "Enquete.sqlite"
@property
def context_path(self):
return self._context_path(self.workspace)
def context(self, workspace=None):
workspace = self.workspace if workspace is None else Path(workspace).resolve()
context_path = self._context_path(workspace)
database = workspace / "Enquete.sqlite"
if not context_path.is_file():
if database.exists():
raise ValueError("Espace incomplet : base présente sans manifeste")
@ -1173,9 +1260,7 @@ class WorkspaceServer(ThreadingHTTPServer):
return value
def agent_scope(self):
context = self.context()
identity = context.get("investigation_id") if context else "EMPTY"
return f"{self.config_id}:{self.active_workspace_id or identity}"
return f"{self.config_id}:{self.agent_workspace_id()}"
def _agent_read_export(self, name, contracts):
path = self.workspace / name
@ -1219,17 +1304,24 @@ class WorkspaceServer(ThreadingHTTPServer):
node for node in nodes
if query in json.dumps(node, ensure_ascii=False).casefold()
]
if not matched:
matched = nodes[:1]
context = self.context()
investigation_matches = []
if context is not None and query in context.get("title", "").casefold():
investigation_matches.append({
"object_kind": "investigation",
"workspace_id": self.agent_workspace_id(),
"label": context["title"],
})
return {
"matches": [
"matches": investigation_matches + [
{
"object_id": node["id"],
"label": node.get("label", node["id"]),
}
for node in matched[:8]
for node in matched[:8 - len(investigation_matches)]
],
"object_refs": [self._agent_ref(node) for node in matched[:8]],
"object_refs": [self._agent_ref(node)
for node in matched[:8 - len(investigation_matches)]],
}
def _agent_get_node(self, arguments, _key):
@ -1337,9 +1429,10 @@ class WorkspaceServer(ThreadingHTTPServer):
self.bridge_call(["export"], timeout=15)
return json.loads(output)
def bridge_call(self, arguments, timeout=8):
def bridge_call(self, arguments, timeout=8, workspace=None):
workspace = self.workspace if workspace is None else Path(workspace).resolve()
command = [str(self.bridge), arguments[0], "--workspace",
str(self.workspace), *arguments[1:]]
str(workspace), *arguments[1:]]
environment = os.environ.copy()
if self.research_fixture_authority is not None:
environment["LABFY_RESEARCH_FIXTURE_AUTHORITY"] = \
@ -1611,6 +1704,37 @@ class Handler(BaseHTTPRequestHandler):
raise TypeError("Demande d'ouverture de bibliothèque invalide")
return value
def _library_register_body(self):
value = self._json_body()
if (not isinstance(value, dict) or set(value) != {
"candidate_id", "expected_generation", "idempotency_key",
"human_confirmed"} or
not isinstance(value["candidate_id"], str) or
not 0 < len(value["candidate_id"]) <= 256 or
not isinstance(value["expected_generation"], int) or
isinstance(value["expected_generation"], bool) or
not isinstance(value["idempotency_key"], str) or
value["human_confirmed"] is not True):
raise TypeError("Demande d'enregistrement invalide")
try:
uuid.UUID(value["idempotency_key"])
except ValueError as error:
raise TypeError("Clé d'idempotence invalide") from error
return value
def _library_close_body(self):
value = self._json_body()
if (not isinstance(value, dict) or set(value) != {
"workspace_id", "expected_generation", "idempotency_key",
"human_confirmed"} or
not isinstance(value["workspace_id"], str) or
not isinstance(value["expected_generation"], int) or
isinstance(value["expected_generation"], bool) or
not isinstance(value["idempotency_key"], str) or
not isinstance(value["human_confirmed"], bool)):
raise TypeError("Demande de fermeture invalide")
return value
def _agent_operation_body(self, operation):
value = self._json_body()
shapes = {
@ -1912,8 +2036,15 @@ class Handler(BaseHTTPRequestHandler):
self._error(HTTPStatus.NOT_FOUND, "route_unknown", "Route inconnue")
return
try:
workspace_id = self.server.agent_workspace_id()
changes = self.server.code_changes.list_changes()
if self.server.library is not None:
changes = [item for item in changes
if self.server.code_change_turns.get(
item.get("change_id"), {}).get("workspace_id") ==
workspace_id]
self._json(HTTPStatus.OK, {"contract": "labfy.code_change_list.v1",
"changes": self.server.code_changes.list_changes()})
"changes": changes})
except (CodeChangeError, OSError, ValueError) as error:
self._error(HTTPStatus.CONFLICT, "code_changes_unavailable", str(error))
elif path.startswith("/api/v1/code-changes/") and path.endswith("/diff"):
@ -1925,6 +2056,7 @@ class Handler(BaseHTTPRequestHandler):
self._error(HTTPStatus.NOT_FOUND, "route_unknown", "Route inconnue")
return
try:
self.server.code_change_turn(parts[4])
difference = self.server.code_changes.get_diff(parts[4])
self._json(HTTPStatus.OK, {"contract": "labfy.code_change_diff.v1",
**difference})
@ -2100,6 +2232,7 @@ class Handler(BaseHTTPRequestHandler):
raise TypeError("Route de décision de code invalide")
value = self._human_decision_body()
service = self.server.code_changes
turn_id = self.server.code_change_turn(parts[4])
common = {"actor": "human", "decision_id": value["decision_id"],
"idempotency_key": value["idempotency_key"]}
if parts[5] == "approve-prepare":
@ -2107,27 +2240,23 @@ class Handler(BaseHTTPRequestHandler):
response = service.approve_prepare(parts[4], **common)
def finish_development():
agent.continue_development(parts[4])
self.server._resume_after_tooling(
self.server.code_change_turns.get(parts[4], ""))
self.server._resume_after_tooling(turn_id)
self.server._start_tooling_task("labfy-developer-agent",
finish_development)
elif parts[5] == "reject-prepare":
response = service.reject_prepare(parts[4], reason=value["reason"], **common)
self.server._resume_after_tooling(
self.server.code_change_turns.get(parts[4], ""))
self.server._resume_after_tooling(turn_id)
elif parts[5] == "approve-apply":
def finish_apply():
service.approve_apply(parts[4], **common)
self.server._resume_after_tooling(
self.server.code_change_turns.get(parts[4], ""))
self.server._resume_after_tooling(turn_id)
self.server._start_tooling_task("labfy-code-apply",
finish_apply)
response = {"contract": "labfy.code_change_apply_task.v1",
"change_id": parts[4], "state": "ACCEPTED"}
elif parts[5] == "reject-apply":
response = service.reject_apply(parts[4], reason=value["reason"], **common)
self.server._resume_after_tooling(
self.server.code_change_turns.get(parts[4], ""))
self.server._resume_after_tooling(turn_id)
else:
raise TypeError("Action de code inconnue")
self._json(HTTPStatus.ACCEPTED, response)
@ -2189,6 +2318,7 @@ class Handler(BaseHTTPRequestHandler):
self._json(HTTPStatus.OK, response)
return
if path == "/api/v1/agent-runtime/turns":
self.server.agent_workspace_id()
if self.server.agent_runtime is None:
raise AgentRuntimeError("Modèle local indisponible", status=503)
response = self.server.agent_runtime.start_turn(
@ -2201,6 +2331,7 @@ class Handler(BaseHTTPRequestHandler):
parts = path.split("/")
if len(parts) != 7:
raise ValueError("Route de reprise runtime invalide")
self.server.agent_workspace_id()
self._body(set())
self._json(HTTPStatus.ACCEPTED,
self.server.resume_agent_runtime(parts[5]))
@ -2210,6 +2341,7 @@ class Handler(BaseHTTPRequestHandler):
parts = path.split("/")
if len(parts) != 7:
raise ValueError("Route d’annulation runtime invalide")
self.server.agent_workspace_id()
if self.server.agent_runtime is None:
raise AgentRuntimeError("Modèle local indisponible", status=503)
self._body(set())
@ -2237,11 +2369,30 @@ class Handler(BaseHTTPRequestHandler):
self._json(HTTPStatus.OK if response["replayed"] else HTTPStatus.CREATED,
response)
return
if path == "/api/v1/library/discover-existing":
self._body(set())
self._json(HTTPStatus.OK,
self.server.discover_existing_library_workspaces())
return
if path == "/api/v1/library/register-existing":
value = self._library_register_body()
response = self.server.register_existing_library_workspace(
value["candidate_id"], value["expected_generation"],
value["idempotency_key"], value["human_confirmed"])
self._json(HTTPStatus.OK if response["replayed"]
else HTTPStatus.CREATED, response)
return
if path == "/api/v1/library/open":
value = self._library_open_body()
self._json(HTTPStatus.OK, self.server.open_library_workspace(
value["workspace_id"], value["expected_generation"]))
return
if path == "/api/v1/library/close":
value = self._library_close_body()
self._json(HTTPStatus.OK, self.server.close_library_workspace(
value["workspace_id"], value["expected_generation"],
value["idempotency_key"], value["human_confirmed"]))
return
if path == "/api/v1/workspace":
if self.server.library is not None:
raise ValueError("Utiliser la création explicite de la bibliothèque")

View file

@ -148,6 +148,48 @@ static char *workspace_path(const char *root, const char *leaf) {
return g_build_filename(root, leaf, NULL);
}
static gboolean validate_workspace_json(const char *root, GError **error) {
char *database_path = workspace_path(root, "Enquete.sqlite");
/* WHY: discovery is privacy-sensitive and read-only. This API deliberately
* bypasses manifest/export code so validation cannot migrate the database or
* publish derived snapshots as a side effect. */
Database *database = database_open_read_only(database_path, error);
InvestigationRecord *record = database != NULL
? investigation_dao_load(database) : NULL;
const char *identifier = record != NULL
? investigation_record_get_id(record) : NULL;
const char *title = record != NULL
? investigation_record_get_name(record) : NULL;
gboolean ok = identifier != NULL && g_uuid_string_is_valid(identifier) &&
title != NULL && title[0] != '\0';
char *jobs_path = workspace_path(root, ".labfy/runtime/jobs.sqlite");
LocalJobStore *store = ok
? local_job_store_open(jobs_path, identifier, TRUE, error) : NULL;
ok = ok && store != NULL;
if (!ok && database != NULL && error != NULL && *error == NULL)
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA,
"La base ne fournit pas une enquête valide.");
if (ok) {
JsonBuilder *builder = json_builder_new();
json_builder_begin_object(builder);
json_builder_set_member_name(builder, "contract");
json_builder_add_string_value(builder, "labfy.local_workspace.validation.v1");
json_builder_set_member_name(builder, "workspace_id");
json_builder_add_string_value(builder, identifier);
json_builder_set_member_name(builder, "title");
json_builder_add_string_value(builder, title);
json_builder_end_object(builder);
ok = print_json_builder(builder, error);
g_object_unref(builder);
}
investigation_record_free(record);
local_job_store_close(store);
database_close(database);
g_free(jobs_path);
g_free(database_path);
return ok;
}
static char *now_iso(void) {
GDateTime *now = g_date_time_new_now_utc();
char *value = g_date_time_format(now, "%Y-%m-%dT%H:%M:%SZ");
@ -2215,6 +2257,8 @@ int main(int argc, char **argv) {
gboolean ok = FALSE;
if (strcmp(argv[1], "init-specimen") == 0)
ok = init_specimen(workspace, &error);
else if (strcmp(argv[1], "validate-workspace-json") == 0)
ok = validate_workspace_json(workspace, &error);
else if (strcmp(argv[1], "create-workspace") == 0) {
const char *title = NULL;
for (int i = 4; i + 1 < argc; i += 2)