fix(dashboard,worker): vérité produit P0 — dashboard+worker+VWB export
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m46s
tests / Tests unitaires (sans GPU) (push) Failing after 2m0s
tests / Tests sécurité (critique) (push) Has been skipped

War-room clôture DGX 2026-06-18 (recadrage Dom : graphe/apprentissage/mémoire/dashboard = surface produit P0).
Le dashboard et le statut worker affichaient des états faux ; corrige pour refléter la vérité du produit.

- dashboard FAISS: distingue index brut / metadata HMAC invalide / runtime / absent (plus de faux "inactif")
- dashboard process-mining: 503 explicite missing_dependency (plus de message trompeur)
- dashboard /api/workflows + system/status: lecture DB VWB v3 canonique (total réel = 24, plus de 0)
- worker /processing/status: véridique (lit _worker_health.json) + statut "idle/armé (lazy)" distinct de "dégradé (échec)"
- VWB export: N steps -> N actions/edges (dernière action n'est plus perdue)
- tests: dashboard routes, worker status truthfulness, export VWB

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-06-18 17:50:12 +02:00
parent 6d5ef51c60
commit ec1fb81054
8 changed files with 626 additions and 56 deletions

View File

@@ -119,8 +119,10 @@ def test_export_vwb_workflow_with_pause_step():
]
core = convert_vwb_to_core_workflow(workflow_data, steps_data)
assert core["learning_state"] == "COACHING"
assert len(core["nodes"]) == 3
assert len(core["edges"]) == 2
assert len(core["nodes"]) == 4
assert len(core["edges"]) == 3
assert core["edges"][-1]["action"]["type"] == "mouse_click"
assert core["nodes"][-1]["is_end"] is True
# L'edge sortant du node de pause doit avoir le bon type + message
pause_edges = [

View File

@@ -1289,3 +1289,158 @@ class TestAPIEndpoints:
assert len(workflows) == 1
assert workflows[0]["workflow_id"] == "wf_api_001"
assert workflows[0]["nodes"] == 2
class TestWorkerStatusTruthfulness:
"""Truthfulness du statut worker exposé par _get_worker_queue_status.
Distingue VEILLE (armé, lazy : worker neuf qui n'a jamais traité de
session, composants chargés à la 1re session) de DÉGRADÉ (init tentée
et en échec). Un worker en veille ne doit JAMAIS être étiqueté 'degraded'.
"""
# Même contrainte que TestAPIEndpoints : api_stream fail-closed à l'import
# si RPA_API_TOKEN absent.
_TEST_API_TOKEN = "test_token_for_worker_status_0123456789abcdef"
@pytest.fixture(autouse=True)
def _ensure_api_token(self, monkeypatch):
monkeypatch.setenv("RPA_API_TOKEN", self._TEST_API_TOKEN)
@pytest.fixture
def status_env(self, tmp_path, monkeypatch):
"""Isole les fichiers worker (health/queue/lock) sur tmp_path."""
from agent_v0.server_v1 import api_stream
health_file = tmp_path / "_worker_health.json"
queue_file = tmp_path / "_worker_queue.txt"
lock_file = tmp_path / "_replay_active.lock"
monkeypatch.setattr(api_stream, "WORKER_HEALTH_FILE", health_file)
monkeypatch.setattr(api_stream, "WORKER_QUEUE_FILE", queue_file)
monkeypatch.setattr(api_stream, "REPLAY_LOCK_FILE", lock_file)
return api_stream, health_file
@staticmethod
def _write_health(health_file, **overrides):
"""Écrit un health file frais (mtime récent => non stale)."""
payload = {
"pid": 1234,
"started_at": "2026-06-18T10:00:00",
"last_cycle": "2026-06-18T10:00:30",
"current_session": None,
"queue_length": 0,
"components": {
"screen_analyzer": False,
"clip_embedder": False,
"faiss_manager": False,
"state_embedding_builder": False,
},
"stats": {
"sessions_processed": 0,
"sessions_failed": 0,
"sessions_skipped": 0,
"total_screenshots_analyzed": 0,
},
"status": "healthy",
}
payload.update(overrides)
health_file.write_text(json.dumps(payload), encoding="utf-8")
def test_fresh_worker_is_idle_not_degraded(self, status_env):
"""Worker neuf : healthy, 0 session, tous composants false
=> statut 'idle' (en veille / armé), PAS 'degraded'."""
api_stream, health_file = status_env
self._write_health(health_file) # défaut = état neuf
status = api_stream._get_worker_queue_status()
assert status["running"] is True
assert status["status"] == "idle", status
assert status["armed"] is True
assert status["components_ready"] is False
# processing_ready reste False tant que les composants ne sont pas chargés
assert status["processing_ready"] is False
assert "veille" in status["status_hint"].lower()
def test_worker_init_failed_is_degraded(self, status_env):
"""Init tentée et en échec : run_worker force status='degraded'
(VLM + ScreenAnalyzer absent) => on conserve 'degraded'."""
api_stream, health_file = status_env
self._write_health(
health_file,
status="degraded", # forcé par run_worker._write_health
components={
"screen_analyzer": False,
"clip_embedder": True,
"faiss_manager": True,
"state_embedding_builder": False,
},
stats={
"sessions_processed": 0,
"sessions_failed": 1, # une session a tenté l'init et échoué
"sessions_skipped": 0,
"total_screenshots_analyzed": 0,
},
)
status = api_stream._get_worker_queue_status()
assert status["running"] is True
assert status["status"] == "degraded", status
assert status["armed"] is False
assert status["processing_ready"] is False
assert "dégradé" in status["status_hint"].lower()
def test_worker_partial_components_after_attempt_is_degraded(self, status_env):
"""Composants partiels après tentative de traitement (sessions_failed>0),
sans status forcé par le worker => 'degraded' (pas 'idle')."""
api_stream, health_file = status_env
self._write_health(
health_file,
status="healthy",
components={
"screen_analyzer": True,
"clip_embedder": True,
"faiss_manager": False, # un composant manquant
"state_embedding_builder": True,
},
stats={
"sessions_processed": 0,
"sessions_failed": 2,
"sessions_skipped": 0,
"total_screenshots_analyzed": 0,
},
)
status = api_stream._get_worker_queue_status()
assert status["status"] == "degraded", status
assert status["armed"] is False
def test_worker_ready_after_processing_is_healthy(self, status_env):
"""Worker ayant traité au moins une session, tous composants chargés
=> 'healthy' et processing_ready=True."""
api_stream, health_file = status_env
self._write_health(
health_file,
status="healthy",
components={
"screen_analyzer": True,
"clip_embedder": True,
"faiss_manager": True,
"state_embedding_builder": True,
},
stats={
"sessions_processed": 3,
"sessions_failed": 0,
"sessions_skipped": 0,
"total_screenshots_analyzed": 42,
},
)
status = api_stream._get_worker_queue_status()
assert status["status"] == "healthy", status
assert status["armed"] is False
assert status["components_ready"] is True
assert status["processing_ready"] is True

View File

@@ -296,9 +296,11 @@ def test_export_workflow_with_t2a_chain():
]
core = convert_vwb_to_core_workflow(workflow_data, steps_data)
edge_types = [e["action"]["type"] for e in core["edges"]]
assert len(core["edges"]) == len(steps_data)
assert "extract_text" in edge_types
assert "t2a_decision" in edge_types
assert "pause_for_human" in edge_types
assert edge_types[-1] == "mouse_click"
# Vérifier que le templating est bien transporté
t2a_edge = next(e for e in core["edges"] if e["action"]["type"] == "t2a_decision")
assert t2a_edge["action"]["parameters"]["input_template"] == "{{dpi}}"

View File

@@ -114,6 +114,37 @@ class TestDashboardRoutes:
'timeout': 30,
}]
def test_streaming_status_snapshot_aggregates_existing_endpoints(self, monkeypatch):
"""Le proxy legacy /status agrège les endpoints streaming reels."""
calls = []
def fake_fetch(endpoint, query_string=''):
calls.append((endpoint, query_string))
if endpoint == 'stats':
return {'active_sessions': 1, 'total_events': 7}
if endpoint == 'sessions':
return {'sessions': [{'session_id': 's1'}]}
if endpoint == 'processing/status':
return {'status': 'degraded', 'components_ready': False}
if endpoint == 'replays':
return {'replays': [{'replay_id': 'r1', 'active': True}]}
raise AssertionError(endpoint)
monkeypatch.setattr(dashboard_app, '_fetch_streaming_json', fake_fetch)
snapshot = dashboard_app._streaming_status_snapshot()
assert snapshot['active_sessions'] == 1
assert snapshot['sessions'] == [{'session_id': 's1'}]
assert snapshot['processing']['status'] == 'degraded'
assert snapshot['replay']['replay_id'] == 'r1'
assert calls == [
('stats', ''),
('sessions', ''),
('processing/status', ''),
('replays', ''),
]
def test_dashboard_submit_competence_verdict(self, client, monkeypatch):
"""Le dashboard journalise un verdict sans write-back YAML."""
import core.competences.verdicts as verdicts_module
@@ -212,6 +243,58 @@ class TestDashboardRoutes:
data = resp.get_json()
assert 'workflows' in data
def test_workflows_list_reads_vwb_db(self, client, monkeypatch, tmp_path):
"""Régression red-gate : /api/workflows reflète la base VWB v3, pas 0.
Avant correctif l'endpoint globait un store JSON vide et renvoyait
toujours total:0. On construit une DB VWB minimale (schéma canonique
workflows + steps) et on vérifie que l'endpoint expose le compte réel.
"""
import sqlite3
from pathlib import Path
db_path = tmp_path / "instance" / "workflows.db"
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path))
conn.execute(
"CREATE TABLE workflows (id VARCHAR(64) PRIMARY KEY, name VARCHAR(255), "
"description TEXT, created_at DATETIME, updated_at DATETIME, "
"is_active BOOLEAN, source VARCHAR(64), review_status VARCHAR(32))"
)
conn.execute(
"CREATE TABLE steps (id VARCHAR(64) PRIMARY KEY, workflow_id VARCHAR(64), "
"action_type VARCHAR(64))"
)
conn.execute(
"INSERT INTO workflows VALUES (?,?,?,?,?,?,?,?)",
("wf_aiva", "Urgence_aiva_demo", "demo", "2026-06-01", "2026-06-18",
1, "manual", ""),
)
conn.execute(
"INSERT INTO workflows VALUES (?,?,?,?,?,?,?,?)",
("wf_learned", "Learned_flow", "", "2026-06-02", "2026-06-17",
1, "learned_import", "pending"),
)
# 3 steps pour wf_aiva → nodes_count attendu = 3
for i in range(3):
conn.execute(
"INSERT INTO steps VALUES (?,?,?)", (f"s{i}", "wf_aiva", "click")
)
conn.commit()
conn.close()
monkeypatch.setattr(dashboard_app, "VWB_DB_PATH", Path(db_path))
resp = client.get('/api/workflows')
assert resp.status_code == 200
data = resp.get_json()
assert data['total'] == 2, f"attendu 2 workflows, obtenu {data['total']}"
names = {w['name'] for w in data['workflows']}
assert 'Urgence_aiva_demo' in names
aiva = next(w for w in data['workflows'] if w['name'] == 'Urgence_aiva_demo')
assert aiva['nodes_count'] == 3
assert aiva['source'] == 'manual'
def test_sessions_list(self, client):
"""L'API sessions retourne la liste."""
resp = client.get('/api/agent/sessions')