feat(workspace): open existing investigations with isolated Qwen context
This commit is contained in:
parent
73086405da
commit
b2fc42eb24
25 changed files with 2323 additions and 314 deletions
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
77
docs/architecture/EXISTING_INVESTIGATION_LIBRARY.md
Normal file
77
docs/architecture/EXISTING_INVESTIGATION_LIBRARY.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
143
prototypes/web-graph/public/app.js
vendored
143
prototypes/web-graph/public/app.js
vendored
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
338
prototypes/web-graph/tests/test_existing_library.py
Normal file
338
prototypes/web-graph/tests/test_existing_library.py
Normal 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()
|
||||
297
prototypes/web-graph/tests/test_existing_workspace_lifecycle.py
Normal file
297
prototypes/web-graph/tests/test_existing_workspace_lifecycle.py
Normal 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()
|
||||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue