feat: agent Rust Phase 1 — POC headless fonctionnel
1527 lignes Rust, compile sans warnings, testé sur Linux. - Capture d'écran (xcap) + JPEG base64 + hash dedup - Heartbeat toutes les 5s vers streaming server - Poll replay + exécution actions (clic, frappe, combos) - Serveur HTTP port 5006 (capture, health, file-action) - Compatible avec le streaming server Python existant Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
245
agent_rust/src/network.rs
Normal file
245
agent_rust/src/network.rs
Normal file
@@ -0,0 +1,245 @@
|
||||
//! Client HTTP pour la communication avec le serveur streaming.
|
||||
//!
|
||||
//! Gère l'envoi des heartbeats (screenshots périodiques),
|
||||
//! le polling des actions replay, et le rapport des résultats.
|
||||
//! Compatible avec l'API de agent_v0/server_v1/api_stream.py (port 5005).
|
||||
|
||||
use crate::config::Config;
|
||||
use reqwest::blocking::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Action de replay reçue du serveur.
|
||||
///
|
||||
/// Format identique à celui du Python executor (agent_v1/core/executor.py).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Action {
|
||||
/// Identifiant unique de l'action
|
||||
#[serde(default)]
|
||||
pub action_id: String,
|
||||
|
||||
/// Type d'action : "click", "type", "key_combo", "scroll", "wait"
|
||||
#[serde(rename = "type")]
|
||||
pub action_type: String,
|
||||
|
||||
/// Coordonnée X normalisée (0.0 à 1.0)
|
||||
#[serde(default)]
|
||||
pub x_pct: f64,
|
||||
|
||||
/// Coordonnée Y normalisée (0.0 à 1.0)
|
||||
#[serde(default)]
|
||||
pub y_pct: f64,
|
||||
|
||||
/// Texte à taper (pour action "type")
|
||||
#[serde(default)]
|
||||
pub text: String,
|
||||
|
||||
/// Liste de touches (pour action "key_combo")
|
||||
#[serde(default)]
|
||||
pub keys: Vec<String>,
|
||||
|
||||
/// Bouton de souris : "left", "right", "double"
|
||||
#[serde(default = "default_button")]
|
||||
pub button: String,
|
||||
|
||||
/// Durée d'attente en ms (pour action "wait")
|
||||
#[serde(default = "default_duration")]
|
||||
pub duration_ms: u64,
|
||||
|
||||
/// Delta de scroll (pour action "scroll")
|
||||
#[serde(default)]
|
||||
pub delta: i32,
|
||||
|
||||
/// Mode visuel (résolution par le serveur)
|
||||
#[serde(default)]
|
||||
pub visual_mode: bool,
|
||||
|
||||
/// Spécification de la cible visuelle
|
||||
#[serde(default)]
|
||||
pub target_spec: serde_json::Value,
|
||||
}
|
||||
|
||||
fn default_button() -> String {
|
||||
"left".to_string()
|
||||
}
|
||||
|
||||
fn default_duration() -> u64 {
|
||||
500
|
||||
}
|
||||
|
||||
/// Résultat d'exécution d'une action.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ActionResult {
|
||||
pub action_id: String,
|
||||
pub success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub screenshot: Option<String>,
|
||||
}
|
||||
|
||||
impl ActionResult {
|
||||
/// Crée un résultat d'erreur.
|
||||
pub fn error(action_id: &str, msg: &str) -> Self {
|
||||
ActionResult {
|
||||
action_id: action_id.to_string(),
|
||||
success: false,
|
||||
error: Some(msg.to_string()),
|
||||
screenshot: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée un résultat de succès.
|
||||
pub fn ok(action_id: &str) -> Self {
|
||||
ActionResult {
|
||||
action_id: action_id.to_string(),
|
||||
success: true,
|
||||
error: None,
|
||||
screenshot: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Envoie un heartbeat (screenshot) au serveur streaming.
|
||||
///
|
||||
/// POST /traces/stream/image avec le screenshot en multipart.
|
||||
/// Retourne true si l'envoi a réussi.
|
||||
pub fn send_heartbeat(
|
||||
client: &Client,
|
||||
config: &Config,
|
||||
jpeg_bytes: &[u8],
|
||||
session_id: &str,
|
||||
) -> bool {
|
||||
let url = format!("{}/image", config.streaming_url());
|
||||
let shot_id = format!("heartbeat_{}", chrono::Utc::now().timestamp());
|
||||
|
||||
let part = reqwest::blocking::multipart::Part::bytes(jpeg_bytes.to_vec())
|
||||
.file_name("screenshot.jpg")
|
||||
.mime_str("image/jpeg")
|
||||
.unwrap_or_else(|_| {
|
||||
reqwest::blocking::multipart::Part::bytes(jpeg_bytes.to_vec())
|
||||
.file_name("screenshot.jpg")
|
||||
});
|
||||
|
||||
let form = reqwest::blocking::multipart::Form::new().part("file", part);
|
||||
|
||||
match client
|
||||
.post(&url)
|
||||
.query(&[
|
||||
("session_id", session_id),
|
||||
("shot_id", &shot_id),
|
||||
("machine_id", &config.machine_id),
|
||||
])
|
||||
.multipart(form)
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.send()
|
||||
{
|
||||
Ok(resp) => {
|
||||
if resp.status().is_success() {
|
||||
true
|
||||
} else {
|
||||
eprintln!(
|
||||
"[HEARTBEAT] Envoi echoue : HTTP {}",
|
||||
resp.status()
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Log discret pour ne pas spammer la console
|
||||
eprintln!("[HEARTBEAT] Erreur reseau : {}", e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Réponse du serveur pour GET /replay/next
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ReplayNextResponse {
|
||||
action: Option<Action>,
|
||||
}
|
||||
|
||||
/// Poll le serveur pour récupérer la prochaine action de replay.
|
||||
///
|
||||
/// GET /traces/stream/replay/next?session_id=...&machine_id=...
|
||||
/// Retourne None si pas d'action en attente ou si le serveur est indisponible.
|
||||
pub fn poll_next_action(client: &Client, config: &Config) -> Option<Action> {
|
||||
let url = format!("{}/replay/next", config.streaming_url());
|
||||
let session_id = config.agent_session_id();
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.query(&[
|
||||
("session_id", session_id.as_str()),
|
||||
("machine_id", config.machine_id.as_str()),
|
||||
])
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.send()
|
||||
.ok()?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let data: ReplayNextResponse = resp.json().ok()?;
|
||||
data.action
|
||||
}
|
||||
|
||||
/// Rapporte le résultat d'une action au serveur.
|
||||
///
|
||||
/// POST /traces/stream/replay/result avec le résultat en JSON.
|
||||
pub fn report_result(client: &Client, config: &Config, result: &ActionResult) -> bool {
|
||||
let url = format!("{}/replay/result", config.streaming_url());
|
||||
let session_id = config.agent_session_id();
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Report<'a> {
|
||||
session_id: &'a str,
|
||||
action_id: &'a str,
|
||||
success: bool,
|
||||
error: &'a Option<String>,
|
||||
screenshot: &'a Option<String>,
|
||||
}
|
||||
|
||||
let report = Report {
|
||||
session_id: &session_id,
|
||||
action_id: &result.action_id,
|
||||
success: result.success,
|
||||
error: &result.error,
|
||||
screenshot: &result.screenshot,
|
||||
};
|
||||
|
||||
match client
|
||||
.post(&url)
|
||||
.json(&report)
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.send()
|
||||
{
|
||||
Ok(resp) => {
|
||||
if resp.status().is_success() {
|
||||
if let Ok(data) = resp.json::<serde_json::Value>() {
|
||||
let status = data.get("replay_status")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("?");
|
||||
let remaining = data.get("remaining_actions")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(-1);
|
||||
println!(
|
||||
" [RESULT] Rapporte : status={}, restant={}",
|
||||
status, remaining
|
||||
);
|
||||
}
|
||||
true
|
||||
} else {
|
||||
eprintln!(
|
||||
" [RESULT] Rapport echoue : HTTP {}",
|
||||
resp.status()
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(" [RESULT] Erreur reseau : {}", e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user