3 Commits

Author SHA1 Message Date
Dom
83be93e121 chore(qw): cleanup post-review (préfixes BUS, événements monitor, import io)
Some checks failed
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
- safety_checks_provider : tous les logger.warning d'échec LLM préfixés
  [BUS] lea:safety_checks_llm_failed avec une raison spécifique
  (exception, http_status, timeout, network, json_decode).
- monitor_router : émission [BUS] lea:monitor_invalid_index si l'index
  explicite passé dans l'action est hors limites de monitors_geometry,
  et [BUS] lea:monitor_unavailable si focus actif demandé mais introuvable.
  Ces deux events permettent au bus de tracer chaque fallback de la cascade
  de routage QW1.
- safety_checks_provider : import io supprimé (inutilisé).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:08:22 +02:00
Dom
f5c33477f0 fix(qw4): câblage polling frontend → streaming pour PauseDialog
Avant ce fix, le frontend VWB ne savait pas qu'un replay Agent V1 (Windows)
était en pause supervisée : le seul polling (App.tsx) interrogeait
/execute/status (exécution locale Linux) et n'avait jamais l'info
safety_checks / pause_message du replay distant.

Côté backend (dag_execute.py) :
- ajout du proxy GET /api/v3/replay/state/<replay_id> qui forward vers
  /api/v1/traces/stream/replay/<id> avec Bearer token.

Côté frontend :
- ExecutionControls : nouvelle prop onWindowsReplayStarted, appelée avec
  le replay_id retourné par /api/v3/execute-windows.
- App.tsx : nouveau state streamingReplayId + useEffect qui poll
  /api/v3/replay/state/<id> toutes les secondes et fusionne status,
  pause_message, pause_reason, safety_checks dans appState.execution.
  Le PauseDialog existant s'affiche donc automatiquement quand
  status = paused_need_help.

Le polling s'arrête quand le replay est completed/error/cancelled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:06:20 +02:00
Dom
b1a3aa16f1 fix(qw1): enrichir heartbeat Windows avec monitor_index + monitors_geometry
Avant ce fix, le _heartbeat_loop côté Agent V1 deploy Windows
n'enrichissait pas son payload, donc QW1 multi-écran ne s'activait sur Windows
que via les events window_capture (déclenchés par les clics), pas en continu.

La source agent_v0/agent_v1/main.py portait déjà l'enrichissement (commit 2d71e2a24)
mais le snapshot deploy/windows_client/agent_v1/main.py n'avait pas été synchronisé.

Désormais chaque heartbeat porte monitor_index + monitors_geometry, le serveur
peut donc résoudre l'écran cible en permanence, même sans clic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:02:11 +02:00
6 changed files with 157 additions and 11 deletions

View File

@@ -319,7 +319,22 @@ class AgentV1:
if img_hash != self._last_heartbeat_hash: if img_hash != self._last_heartbeat_hash:
self._last_heartbeat_hash = img_hash self._last_heartbeat_hash = img_hash
self.streamer.push_image(full_path, f"heartbeat_{int(time.time())}") self.streamer.push_image(full_path, f"heartbeat_{int(time.time())}")
self.streamer.push_event({"type": "heartbeat", "image": full_path, "timestamp": time.time(), "machine_id": self.machine_id}) heartbeat_event = {
"type": "heartbeat",
"image": full_path,
"timestamp": time.time(),
"machine_id": self.machine_id,
}
# QW1 — enrichissement multi-écrans (monitor_index + monitors_geometry)
# Additif, fallback gracieux : sans cet enrichissement, le serveur
# ne reçoit l'info qu'au moment des clics, donc QW1 ne s'active
# pas en continu sur poste Windows multi-écrans.
try:
from .vision.capturer import _enrich_with_monitor_info
_enrich_with_monitor_info(heartbeat_event)
except Exception as e:
logger.debug("QW1 enrichissement heartbeat échoué: %s", e)
self.streamer.push_event(heartbeat_event)
except Exception as e: except Exception as e:
logger.error(f"Heartbeat error: {e}") logger.error(f"Heartbeat error: {e}")
time.sleep(5) time.sleep(5)

View File

@@ -9,9 +9,12 @@ Stratégie en cascade :
Émet sur le bus lea:* l'event monitor_routed avec la source de la décision. Émet sur le bus lea:* l'event monitor_routed avec la source de la décision.
""" """
import logging
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
@dataclass @dataclass
class MonitorTarget: class MonitorTarget:
@@ -76,6 +79,10 @@ def resolve_target_monitor(
if m is not None: if m is not None:
return _to_target(m, source="action") return _to_target(m, source="action")
# Index invalide → on tombe sur le fallback focus # Index invalide → on tombe sur le fallback focus
logger.warning(
"[BUS] lea:monitor_invalid_index requested=%d available_idx=%s",
int(explicit_idx), [g.get("idx") for g in geometry],
)
# 2. Fallback focus actif # 2. Fallback focus actif
focused_idx = session_state.get("last_focused_monitor") focused_idx = session_state.get("last_focused_monitor")
@@ -83,6 +90,10 @@ def resolve_target_monitor(
m = _find_monitor(geometry, int(focused_idx)) m = _find_monitor(geometry, int(focused_idx))
if m is not None: if m is not None:
return _to_target(m, source="focus") return _to_target(m, source="focus")
logger.warning(
"[BUS] lea:monitor_unavailable focused_idx=%d available_idx=%s",
int(focused_idx), [g.get("idx") for g in geometry],
)
# 3. Fallback composite (backward compat — comportement actuel mss.monitors[0]) # 3. Fallback composite (backward compat — comportement actuel mss.monitors[0])
return _COMPOSITE_FALLBACK return _COMPOSITE_FALLBACK

View File

@@ -11,7 +11,6 @@ le replay continue avec uniquement les déclaratifs (fallback safe).
""" """
import base64 import base64
import io
import json import json
import logging import logging
import os import os
@@ -77,7 +76,7 @@ def build_pause_payload(
existing_labels=[c["label"] for c in checks], existing_labels=[c["label"] for c in checks],
) )
except Exception as e: except Exception as e:
logger.warning("safety_checks LLM exception (%s) — fallback safe", e) logger.warning("[BUS] lea:safety_checks_llm_failed reason=exception detail=%s", e)
additional = [] additional = []
for a in additional: for a in additional:
@@ -159,21 +158,21 @@ Réponds UNIQUEMENT en JSON :
timeout=timeout_s, timeout=timeout_s,
) )
if response.status_code != 200: if response.status_code != 200:
logger.warning("safety_checks LLM HTTP %s", response.status_code) logger.warning("[BUS] lea:safety_checks_llm_failed reason=http_status detail=%s", response.status_code)
return [] return []
text = response.json().get("response", "").strip() text = response.json().get("response", "").strip()
except requests.Timeout: except requests.Timeout:
logger.warning("safety_checks LLM timeout (%ss)", timeout_s) logger.warning("[BUS] lea:safety_checks_llm_failed reason=timeout detail=%ss", timeout_s)
return [] return []
except Exception as e: except Exception as e:
logger.warning("safety_checks LLM erreur réseau: %s", e) logger.warning("[BUS] lea:safety_checks_llm_failed reason=network detail=%s", e)
return [] return []
# format=json garantit normalement du JSON valide # format=json garantit normalement du JSON valide
try: try:
parsed = json.loads(text) parsed = json.loads(text)
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
logger.warning("safety_checks LLM JSON invalide (%s) — fallback safe", e) logger.warning("[BUS] lea:safety_checks_llm_failed reason=json_decode detail=%s", e)
return [] return []
additional = parsed.get("additional_checks") or [] additional = parsed.get("additional_checks") or []

View File

@@ -1155,3 +1155,34 @@ def replay_resume_proxy():
'detail': f'Streaming server non disponible ({streaming_url})'}), 502 'detail': f'Streaming server non disponible ({streaming_url})'}), 502
except req.RequestException as e: except req.RequestException as e:
return jsonify({'error': 'streaming_unreachable', 'detail': str(e)}), 502 return jsonify({'error': 'streaming_unreachable', 'detail': str(e)}), 502
# ---------------------------------------------------------------------------
# QW4 — Proxy GET /api/v3/replay/state/<replay_id> → streaming /replay/{id}
# Forward Bearer token vers le serveur streaming.
# Permet à App.tsx de récupérer le state du replay actif (Agent V1 Windows)
# pour afficher PauseDialog quand status = paused_need_help avec safety_checks.
# ---------------------------------------------------------------------------
@api_v3_bp.route('/replay/state/<replay_id>', methods=['GET'])
def replay_state_proxy(replay_id):
"""Proxy QW4 vers le serveur streaming pour récupérer le state replay actif."""
import requests as req
streaming_url = os.environ.get('RPA_STREAMING_URL', 'http://localhost:5005')
token = os.environ.get('RPA_API_TOKEN', '')
headers = {}
if token:
headers['Authorization'] = f'Bearer {token}'
try:
resp = req.get(
f'{streaming_url}/api/v1/traces/stream/replay/{replay_id}',
headers=headers,
timeout=5,
)
return resp.content, resp.status_code, {'Content-Type': 'application/json'}
except req.ConnectionError:
return jsonify({'error': 'streaming_unreachable',
'detail': f'Streaming server non disponible ({streaming_url})'}), 502
except req.RequestException as e:
return jsonify({'error': 'streaming_unreachable', 'detail': str(e)}), 502

View File

@@ -62,6 +62,13 @@ function App() {
const [healingCandidates, setHealingCandidates] = useState<any[]>([]); const [healingCandidates, setHealingCandidates] = useState<any[]>([]);
const [healingStepInfo, setHealingStepInfo] = useState<any>(null); const [healingStepInfo, setHealingStepInfo] = useState<any>(null);
// QW4 — Replay streaming Windows en cours (Agent V1 distant).
// Quand un replay distant est lancé via ExecutionControls "→ Windows",
// ExecutionControls appelle setStreamingReplayId(replay_id) et un useEffect
// poll /api/v3/replay/state/<id> pour fusionner safety_checks + pause_*
// dans appState.execution → PauseDialog s'affiche.
const [streamingReplayId, setStreamingReplayId] = useState<string | null>(null);
// Charger l'état initial // Charger l'état initial
const loadState = useCallback(async () => { const loadState = useCallback(async () => {
try { try {
@@ -123,6 +130,62 @@ function App() {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [isExecutionRunning, loadState]); }, [isExecutionRunning, loadState]);
// QW4 — Polling state replay streaming (Agent V1 Windows distant)
// Tourne dès qu'un replay distant a été lancé. Récupère safety_checks,
// pause_message, pause_reason et les fusionne dans appState.execution
// pour que PauseDialog s'affiche quand status = paused_need_help.
useEffect(() => {
if (!streamingReplayId) return;
let stopped = false;
const pollReplay = async () => {
try {
const resp = await fetch(`/api/v3/replay/state/${streamingReplayId}`);
if (!resp.ok) return;
const state = await resp.json();
if (stopped) return;
// Fusionner dans appState.execution sans écraser le reste.
setAppState(prev => {
if (!prev) return prev;
const prevExec = prev.execution || {
id: streamingReplayId,
workflow_id: prev.session?.active_workflow_id || '',
status: 'pending',
progress: 0,
current_step_index: 0,
completed_steps: 0,
failed_steps: 0,
total_steps: 0,
};
return {
...prev,
execution: {
...prevExec,
status: state.status || prevExec.status,
pause_message: state.pause_message || state.message,
pause_reason: state.pause_reason,
safety_checks: state.safety_checks || [],
replay_id: streamingReplayId,
},
};
});
// Stopper le polling si le replay est terminé / annulé.
if (state.status && ['completed', 'error', 'cancelled'].includes(state.status)) {
setStreamingReplayId(null);
}
} catch (err) {
// ignore (le serveur streaming peut être momentanément indispo)
}
};
// Tick immédiat puis toutes les 1s.
pollReplay();
const interval = setInterval(pollReplay, 1000);
return () => { stopped = true; clearInterval(interval); };
}, [streamingReplayId]);
// Convertir les étapes en nœuds React Flow // Convertir les étapes en nœuds React Flow
// Les edges ne sont générées automatiquement que lors du premier chargement // Les edges ne sont générées automatiquement que lors du premier chargement
// d'un workflow. Ensuite, les connexions manuelles de l'utilisateur sont préservées. // d'un workflow. Ensuite, les connexions manuelles de l'utilisateur sont préservées.
@@ -452,6 +515,7 @@ function App() {
execution={appState?.execution || null} execution={appState?.execution || null}
onStart={handleStartExecution} onStart={handleStartExecution}
onStop={handleStopExecution} onStop={handleStopExecution}
onWindowsReplayStarted={(replayId) => setStreamingReplayId(replayId)}
/> />
<ConfidenceDashboard <ConfidenceDashboard
isExecutionRunning={isExecutionRunning} isExecutionRunning={isExecutionRunning}

View File

@@ -9,9 +9,12 @@ interface Props {
execution: Execution | null; execution: Execution | null;
onStart: () => void; onStart: () => void;
onStop: () => void; onStop: () => void;
// QW4 — Notifie App.tsx quand un replay streaming Windows est lancé,
// pour qu'il poll /api/v3/replay/state/<id> et affiche PauseDialog au besoin.
onWindowsReplayStarted?: (replayId: string) => void;
} }
export default function ExecutionControls({ execution, onStart, onStop }: Props) { export default function ExecutionControls({ execution, onStart, onStop, onWindowsReplayStarted }: Props) {
const isRunning = execution?.status === 'running' || execution?.status === 'paused'; const isRunning = execution?.status === 'running' || execution?.status === 'paused';
const [windowsStatus, setWindowsStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle'); const [windowsStatus, setWindowsStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
@@ -56,6 +59,11 @@ export default function ExecutionControls({ execution, onStart, onStop }: Props)
const result = await resp.json(); const result = await resp.json();
if (result.replay_id) { if (result.replay_id) {
setWindowsStatus('sent'); setWindowsStatus('sent');
// QW4 — propage le replay_id à App.tsx pour activer le polling
// /api/v3/replay/state/<id> (PauseDialog si paused_need_help).
if (onWindowsReplayStarted) {
try { onWindowsReplayStarted(result.replay_id); } catch {}
}
alert('Replay lancé ! Réduisez cette fenêtre maintenant.\nLes actions commenceront dans 5 secondes.'); alert('Replay lancé ! Réduisez cette fenêtre maintenant.\nLes actions commenceront dans 5 secondes.');
setTimeout(() => setWindowsStatus('idle'), 5000); setTimeout(() => setWindowsStatus('idle'), 5000);
} else { } else {
@@ -75,9 +83,27 @@ export default function ExecutionControls({ execution, onStart, onStop }: Props)
{!isRunning ? ( {!isRunning ? (
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}> <div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
{userOS === 'linux' ? ( {userOS === 'linux' ? (
<button className="btn-start" onClick={onStart} title="Exécuter sur cet écran"> <>
Exécuter <button className="btn-start" onClick={onStart} title="Exécuter sur cet écran (Linux local)">
</button> Exécuter
</button>
<button
className="btn-start"
onClick={handleExecuteWindows}
disabled={windowsStatus === 'sending'}
style={{
background: windowsStatus === 'sent' ? '#22c55e' : windowsStatus === 'error' ? '#ef4444' : '#0078d4',
fontSize: '12px',
opacity: windowsStatus === 'sending' ? 0.6 : 1,
}}
title="Exécuter sur le PC Windows (Agent V1)"
>
{windowsStatus === 'sending' ? 'Envoi...' :
windowsStatus === 'sent' ? 'Lancé !' :
windowsStatus === 'error' ? 'Erreur' :
'→ Windows'}
</button>
</>
) : ( ) : (
<button <button
className="btn-start" className="btn-start"