feat: journée 17 avril — tests E2E validés, dashboard fleet+audit, VWB bridge, cleaner C2
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 14s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 14s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Pipeline E2E complet validé : Capture VM → streaming → serveur → cleaner → replay → audit trail Mode apprentissage supervisé fonctionne (Léa échoue → humain → reprise) Dashboard : - Cleanup 14→10 onglets (RCE supprimée) - Fleet : enregistrer/révoquer agents, tokens, ZIP pré-configuré téléchargeable - Audit trail MVP (/audit) : filtres, tableau, export CSV, conformité AI Act/RGPD - Formulaire Fleet simplifié (nom + email, machine_id auto) VWB bridge Léa→VWB : - Compound décomposés en N steps (saisie + raccourci visibles) - Layout serpentin 3 colonnes (plus colonne verticale) - Badge OS 🪟/🐧, filtre OS retiré (admin Linux voit Windows) - Fix import SQLite readonly Cleaner intelligent : - Descriptions lisibles (UIA/C2) + détection doublons - Logique C2 : UIElement identifié = jamais parasite - Patterns parasites resserrés - Message Léa : "Je n'y arrive pas, montrez-moi comment faire" Config agent (INC-1 à INC-7) : - SERVER_URL + SERVER_BASE unifiés - RPA_OLLAMA_HOST séparé - allow_redirects=False sur POST - Middleware réécriture URL serveur CI Gitea : fix token + Flask-SocketIO + ruff propre Fleet endpoints : /agents/enroll|uninstall|fleet + agent_registry SQLite Backup : script quotidien workflows.db + audit Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -37,7 +37,8 @@ CORE_ACTION_TO_VWB = {
|
||||
"mouse_click": "click_anchor",
|
||||
"text_input": "type_text",
|
||||
"key_press": "keyboard_shortcut",
|
||||
"compound": "click_anchor", # Sera décomposé en sous-étapes
|
||||
"key_combo": "keyboard_shortcut",
|
||||
"compound": "click_anchor", # Décomposé en N étapes séparées par le bridge
|
||||
"wait": "wait_for_anchor",
|
||||
"scroll": "scroll_to_anchor",
|
||||
"unknown": "click_anchor",
|
||||
@@ -133,76 +134,103 @@ def convert_learned_to_vwb_steps(
|
||||
if to_node and to_node not in visited:
|
||||
queue.append(to_node)
|
||||
|
||||
# Convertir chaque edge en Step VWB
|
||||
# Convertir chaque edge en Step(s) VWB
|
||||
# Les actions compound sont décomposées en N steps séparés
|
||||
steps = []
|
||||
for idx, edge in enumerate(ordered_edges):
|
||||
for edge in ordered_edges:
|
||||
action = edge.get("action", {})
|
||||
action_type = action.get("type", "unknown")
|
||||
action_params = action.get("parameters", {})
|
||||
target = action.get("target", {})
|
||||
|
||||
# Déterminer le type VWB
|
||||
vwb_action_type = CORE_ACTION_TO_VWB.get(action_type, "click_anchor")
|
||||
|
||||
# Construire les paramètres VWB
|
||||
vwb_params = {}
|
||||
|
||||
if action_type == "mouse_click":
|
||||
# Extraire la position en pourcentage si disponible
|
||||
by_position = target.get("by_position")
|
||||
if by_position:
|
||||
vwb_params["x_pct"] = by_position[0] if isinstance(by_position, list) else 0
|
||||
vwb_params["y_pct"] = by_position[1] if isinstance(by_position, list) else 0
|
||||
button = action_params.get("button", "left")
|
||||
if button == "double":
|
||||
vwb_action_type = "double_click_anchor"
|
||||
elif button == "right":
|
||||
vwb_action_type = "right_click_anchor"
|
||||
|
||||
elif action_type == "text_input":
|
||||
vwb_params["text"] = action_params.get("text", "")
|
||||
|
||||
elif action_type == "key_press":
|
||||
keys = action_params.get("keys", [])
|
||||
if not keys and action_params.get("key"):
|
||||
keys = [action_params["key"]]
|
||||
vwb_params["keys"] = keys
|
||||
|
||||
elif action_type == "compound":
|
||||
# Stocker les sous-étapes dans les paramètres pour référence
|
||||
vwb_params["compound_steps"] = action_params.get("steps", [])
|
||||
warnings.append(
|
||||
f"Étape {idx + 1} : action compound décomposée — vérifier manuellement"
|
||||
)
|
||||
|
||||
# Ajouter des infos de ciblage pour la review humaine
|
||||
if target.get("by_role"):
|
||||
vwb_params["target_role"] = target["by_role"]
|
||||
if target.get("by_text"):
|
||||
vwb_params["target_text"] = target["by_text"]
|
||||
|
||||
# Construire le label
|
||||
from_node = edge.get("from_node", "")
|
||||
to_node = edge.get("to_node") or edge.get("target_node", "")
|
||||
from_name = nodes_by_id.get(from_node, {}).get("name", from_node)
|
||||
to_name = nodes_by_id.get(to_node, {}).get("name", to_node)
|
||||
label = _build_step_label(vwb_action_type, vwb_params, from_name, to_name)
|
||||
|
||||
step = {
|
||||
"action_type": vwb_action_type,
|
||||
"order": idx,
|
||||
"position_x": 400,
|
||||
"position_y": 80 + idx * 120,
|
||||
"parameters": vwb_params,
|
||||
"label": label,
|
||||
# Métadonnées d'origine pour traçabilité
|
||||
"metadata": {
|
||||
"core_edge_id": edge.get("edge_id", ""),
|
||||
"core_from_node": from_node,
|
||||
"core_to_node": to_node,
|
||||
},
|
||||
edge_meta = {
|
||||
"core_edge_id": edge.get("edge_id", ""),
|
||||
"core_from_node": from_node,
|
||||
"core_to_node": to_node,
|
||||
}
|
||||
steps.append(step)
|
||||
|
||||
if action_type == "compound":
|
||||
# --- Décomposer les compound en N étapes VWB séparées ---
|
||||
sub_steps = action_params.get("steps", [])
|
||||
if not sub_steps:
|
||||
warnings.append(
|
||||
f"Action compound sans sous-étapes (edge {edge.get('edge_id', '?')})"
|
||||
)
|
||||
continue
|
||||
|
||||
for sub_idx, sub in enumerate(sub_steps):
|
||||
sub_type = sub.get("type", "unknown")
|
||||
sub_vwb_type, sub_params = _convert_compound_substep(
|
||||
sub_type, sub, target
|
||||
)
|
||||
label = _build_step_label(sub_vwb_type, sub_params, from_name, to_name)
|
||||
steps.append({
|
||||
"action_type": sub_vwb_type,
|
||||
"order": len(steps),
|
||||
"position_x": 0, # sera recalculé par _compute_layout
|
||||
"position_y": 0,
|
||||
"parameters": sub_params,
|
||||
"label": label,
|
||||
"metadata": {
|
||||
**edge_meta,
|
||||
"compound_sub_index": sub_idx,
|
||||
"compound_total": len(sub_steps),
|
||||
},
|
||||
})
|
||||
|
||||
warnings.append(
|
||||
f"Compound décomposé en {len(sub_steps)} étapes VWB séparées "
|
||||
f"(edge {edge.get('edge_id', '?')})"
|
||||
)
|
||||
else:
|
||||
# --- Action simple (non-compound) ---
|
||||
vwb_action_type = CORE_ACTION_TO_VWB.get(action_type, "click_anchor")
|
||||
vwb_params = {}
|
||||
|
||||
if action_type == "mouse_click":
|
||||
by_position = target.get("by_position")
|
||||
if by_position:
|
||||
vwb_params["x_pct"] = by_position[0] if isinstance(by_position, list) else 0
|
||||
vwb_params["y_pct"] = by_position[1] if isinstance(by_position, list) else 0
|
||||
button = action_params.get("button", "left")
|
||||
if button == "double":
|
||||
vwb_action_type = "double_click_anchor"
|
||||
elif button == "right":
|
||||
vwb_action_type = "right_click_anchor"
|
||||
|
||||
elif action_type == "text_input":
|
||||
vwb_params["text"] = action_params.get("text", "")
|
||||
|
||||
elif action_type in ("key_press", "key_combo"):
|
||||
vwb_action_type = "keyboard_shortcut"
|
||||
keys = action_params.get("keys", [])
|
||||
if not keys and action_params.get("key"):
|
||||
keys = [action_params["key"]]
|
||||
vwb_params["keys"] = keys
|
||||
|
||||
# Ajouter des infos de ciblage pour la review humaine
|
||||
if target.get("by_role"):
|
||||
vwb_params["target_role"] = target["by_role"]
|
||||
if target.get("by_text"):
|
||||
vwb_params["target_text"] = target["by_text"]
|
||||
|
||||
label = _build_step_label(vwb_action_type, vwb_params, from_name, to_name)
|
||||
steps.append({
|
||||
"action_type": vwb_action_type,
|
||||
"order": len(steps),
|
||||
"position_x": 0,
|
||||
"position_y": 0,
|
||||
"parameters": vwb_params,
|
||||
"label": label,
|
||||
"metadata": edge_meta,
|
||||
})
|
||||
|
||||
# Appliquer le layout serpentin à tous les steps
|
||||
_compute_layout(steps)
|
||||
|
||||
if not steps and nodes:
|
||||
# Pas d'edges mais des nodes → créer des étapes basiques depuis les nodes
|
||||
@@ -212,18 +240,91 @@ def convert_learned_to_vwb_steps(
|
||||
steps.append({
|
||||
"action_type": "click_anchor",
|
||||
"order": idx,
|
||||
"position_x": 400,
|
||||
"position_y": 80 + idx * 120,
|
||||
"position_x": 0,
|
||||
"position_y": 0,
|
||||
"parameters": {
|
||||
"window_title": (node.get("template", {}).get("window", {}) or {}).get("title_pattern", ""),
|
||||
},
|
||||
"label": f"Écran : {node_name}",
|
||||
"metadata": {"core_node_id": node.get("node_id", "")},
|
||||
})
|
||||
_compute_layout(steps)
|
||||
|
||||
return workflow_meta, steps, warnings
|
||||
|
||||
|
||||
def _convert_compound_substep(
|
||||
sub_type: str, sub: Dict[str, Any], parent_target: Dict[str, Any]
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
"""
|
||||
Convertit une sous-étape compound en (vwb_action_type, vwb_params).
|
||||
|
||||
Gère les types : mouse_click, text_input, key_combo, key_press.
|
||||
"""
|
||||
vwb_params: Dict[str, Any] = {}
|
||||
|
||||
if sub_type == "mouse_click":
|
||||
vwb_type = "click_anchor"
|
||||
pos = sub.get("pos")
|
||||
if pos and isinstance(pos, (list, tuple)) and len(pos) >= 2:
|
||||
vwb_params["x_pct"] = pos[0]
|
||||
vwb_params["y_pct"] = pos[1]
|
||||
button = sub.get("button", "left")
|
||||
if button == "double":
|
||||
vwb_type = "double_click_anchor"
|
||||
elif button == "right":
|
||||
vwb_type = "right_click_anchor"
|
||||
# Hériter les infos de ciblage du parent
|
||||
if parent_target.get("by_role"):
|
||||
vwb_params["target_role"] = parent_target["by_role"]
|
||||
if parent_target.get("by_text"):
|
||||
vwb_params["target_text"] = parent_target["by_text"]
|
||||
|
||||
elif sub_type == "text_input":
|
||||
vwb_type = "type_text"
|
||||
vwb_params["text"] = sub.get("text", "")
|
||||
|
||||
elif sub_type in ("key_combo", "key_press"):
|
||||
vwb_type = "keyboard_shortcut"
|
||||
keys = sub.get("keys", [])
|
||||
if not keys and sub.get("key"):
|
||||
keys = [sub["key"]]
|
||||
vwb_params["keys"] = keys
|
||||
|
||||
else:
|
||||
# Type inconnu — fallback sur click_anchor
|
||||
vwb_type = CORE_ACTION_TO_VWB.get(sub_type, "click_anchor")
|
||||
|
||||
return vwb_type, vwb_params
|
||||
|
||||
|
||||
def _compute_layout(
|
||||
steps: List[Dict[str, Any]],
|
||||
cols: int = 3,
|
||||
cell_w: int = 280,
|
||||
cell_h: int = 140,
|
||||
margin_x: int = 60,
|
||||
margin_y: int = 40,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Disposition en grille serpentin (zigzag) pour lisibilité humaine.
|
||||
|
||||
Lignes paires : gauche → droite
|
||||
Lignes impaires : droite → gauche
|
||||
|
||||
Modifie les steps en place et les retourne.
|
||||
"""
|
||||
for idx, step in enumerate(steps):
|
||||
row = idx // cols
|
||||
col = idx % cols
|
||||
# Serpentin : lignes impaires inversées
|
||||
if row % 2 == 1:
|
||||
col = cols - 1 - col
|
||||
step["position_x"] = margin_x + col * (cell_w + margin_x)
|
||||
step["position_y"] = margin_y + row * (cell_h + margin_y)
|
||||
return steps
|
||||
|
||||
|
||||
def _build_step_label(
|
||||
action_type: str, params: Dict[str, Any], from_name: str, to_name: str
|
||||
) -> str:
|
||||
|
||||
@@ -53,13 +53,15 @@ export default function WorkflowSelector({
|
||||
}
|
||||
}, [editingId]);
|
||||
|
||||
// Charger les workflows appris quand le dropdown s'ouvre (filtrés par OS)
|
||||
// Charger les workflows appris quand le dropdown s'ouvre (tous OS)
|
||||
// Note : pas de filtre OS — un admin Linux doit pouvoir voir et importer
|
||||
// les workflows captés sur Windows (et inversement à terme avec CLIP OS)
|
||||
const loadLearnedWorkflows = useCallback(async () => {
|
||||
setLearnedLoading(true);
|
||||
try {
|
||||
const data = await api.getLearnedWorkflows(undefined, userOS);
|
||||
const data = await api.getLearnedWorkflows();
|
||||
// Ne garder que ceux qui ne sont pas encore importés
|
||||
setLearnedWorkflows(data.workflows.filter(w => !w.already_imported));
|
||||
setLearnedWorkflows(data.workflows.filter((w: LearnedWorkflow) => !w.already_imported));
|
||||
} catch {
|
||||
// Silencieux : le streaming server n'est peut-être pas lancé
|
||||
setLearnedWorkflows([]);
|
||||
@@ -80,11 +82,10 @@ export default function WorkflowSelector({
|
||||
(wf.tags || []).some(tag => tag.toLowerCase().includes(search.toLowerCase()))
|
||||
);
|
||||
|
||||
// Filtrer les workflows appris (par recherche + OS)
|
||||
// Filtrer les workflows appris (par recherche uniquement, pas par OS)
|
||||
const filteredLearned = learnedWorkflows.filter(wf =>
|
||||
(wf.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
wf.workflow_id.toLowerCase().includes(search.toLowerCase())) &&
|
||||
(wf.machine_id || '').toLowerCase().includes(userOS)
|
||||
wf.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
wf.workflow_id.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
// Workflows récents (les 8 premiers)
|
||||
@@ -257,9 +258,12 @@ export default function WorkflowSelector({
|
||||
<span className="learned-badge" title={`Machine: ${wf.machine_id}`}>
|
||||
appris
|
||||
</span>
|
||||
<span className="os-badge" title={`Capturé sur ${(wf.machine_id || '').includes('windows') ? 'Windows' : (wf.machine_id || '').includes('linux') ? 'Linux' : 'Inconnu'}`}>
|
||||
{(wf.machine_id || '').includes('windows') ? '🪟' : (wf.machine_id || '').includes('linux') ? '🐧' : '❓'}
|
||||
</span>
|
||||
</span>
|
||||
<span className="item-meta">
|
||||
{wf.nodes} noeuds, {wf.edges} transitions
|
||||
{wf.nodes} nœuds, {wf.edges} transitions
|
||||
</span>
|
||||
<button
|
||||
className="import-btn"
|
||||
|
||||
Reference in New Issue
Block a user