1use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6
7use crate::shared::artifacts::Artifact;
8use crate::shared::error::{Error, ErrorCode};
9use crate::shared::ids::{RequestId, TabId};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct FetchResult {
15 pub request_id: RequestId,
16 pub request_url: String,
17 pub final_url: String,
18 pub status: u16,
19 #[serde(skip_serializing_if = "Option::is_none")]
20 pub page_kind: Option<PageKind>,
21 #[serde(skip_serializing_if = "Option::is_none")]
22 pub next_action: Option<NextAction>,
23 #[serde(skip_serializing_if = "Option::is_none")]
24 pub tab_id: Option<TabId>,
25 pub trace: Trace,
26 #[serde(skip_serializing_if = "Vec::is_empty", default)]
27 pub warnings: Vec<Warning>,
28 #[serde(skip_serializing_if = "Option::is_none")]
29 pub body_file: Option<PathBuf>,
30 #[serde(skip_serializing_if = "Option::is_none")]
31 pub rendered_html_file: Option<PathBuf>,
32 #[serde(skip_serializing_if = "Option::is_none")]
33 pub text_file: Option<PathBuf>,
34 #[serde(skip_serializing_if = "Option::is_none")]
35 pub content_file: Option<PathBuf>,
36 #[serde(skip_serializing_if = "Option::is_none")]
37 pub content_json_file: Option<PathBuf>,
38 #[serde(skip_serializing_if = "Option::is_none")]
39 pub screenshot_file: Option<PathBuf>,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub network_file: Option<PathBuf>,
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub console_file: Option<PathBuf>,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub observation_file: Option<PathBuf>,
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub storage_file: Option<PathBuf>,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub download_file: Option<PathBuf>,
50 #[serde(skip_serializing_if = "Option::is_none")]
51 pub download_bytes: Option<u64>,
52 #[serde(skip_serializing_if = "Option::is_none")]
53 pub download_filename: Option<String>,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 pub download_url: Option<String>,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub download_state: Option<String>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, Default)]
61pub struct Trace {
62 pub render_decision: RenderDecision,
63 #[serde(default)]
69 pub render_mode: TraceRenderMode,
70 #[serde(default)]
74 pub render_used: bool,
75 #[serde(skip_serializing_if = "Option::is_none")]
76 pub escalation_reason: Option<String>,
77 pub main_request_observed: bool,
78 pub duration_ms: u64,
79 pub timeout_ms: u64,
80 pub current_stage: String,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub navigation_duration_ms: Option<u64>,
83 #[serde(skip_serializing_if = "Option::is_none")]
86 pub wait_mode: Option<String>,
87 #[serde(skip_serializing_if = "Option::is_none")]
89 pub wait_satisfied_by: Option<String>,
90 #[serde(skip_serializing_if = "Option::is_none")]
94 pub network_quiet: Option<bool>,
95 #[serde(skip_serializing_if = "Option::is_none")]
96 pub dom_stable: Option<bool>,
97 #[serde(skip_serializing_if = "Option::is_none")]
98 pub text_stable: Option<bool>,
99 #[serde(skip_serializing_if = "Option::is_none")]
101 pub capture_reason: Option<String>,
102 #[serde(skip_serializing_if = "Option::is_none")]
107 pub cookie_jar_file: Option<std::path::PathBuf>,
108 #[serde(skip_serializing_if = "Option::is_none")]
111 pub cookie_jar_warning: Option<String>,
112 #[serde(skip_serializing_if = "Vec::is_empty", default)]
115 pub sensitive_capture: Vec<String>,
116 pub stages: Vec<TraceStage>,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct TraceStage {
121 pub name: String,
122 pub status: TraceStageStatus,
123 pub duration_ms: u64,
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(rename_all = "snake_case")]
128pub enum TraceStageStatus {
129 Ok,
130 Error,
131 Timeout,
132 Started,
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
139#[serde(rename_all = "snake_case")]
140pub enum TraceRenderMode {
141 None,
142 #[default]
143 Auto,
144 Always,
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
148#[serde(rename_all = "snake_case")]
149pub enum RenderDecision {
150 #[default]
151 HttpOnly,
152 Browser,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct Warning {
157 pub artifact: Artifact,
158 pub code: crate::shared::error::ErrorCode,
159 pub detail: String,
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(rename_all = "snake_case")]
166pub enum PageKind {
167 BotWallDetected,
168 SecurityChallengeDetected,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172pub struct NextAction {
173 pub kind: NextActionKind,
174 pub recommended_command: String,
175 pub target_content_verified: bool,
176 #[serde(skip_serializing_if = "Option::is_none")]
180 pub takeover_url: Option<String>,
181 #[serde(skip_serializing_if = "Option::is_none")]
182 pub takeover_url_expires_at_rfc3339: Option<String>,
183 #[serde(skip_serializing_if = "Option::is_none")]
184 pub takeover_url_ttl_s: Option<u64>,
185 #[serde(skip_serializing_if = "Option::is_none")]
186 pub takeover_url_scope: Option<String>,
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
190#[serde(rename_all = "snake_case")]
191pub enum NextActionKind {
192 HumanTakeover,
193}
194
195impl NextAction {
196 fn human_takeover(url: &str) -> Self {
197 Self {
198 kind: NextActionKind::HumanTakeover,
199 recommended_command: format!("afhttp fetch {} --takeover", shell_quote(url)),
200 target_content_verified: false,
201 takeover_url: None,
202 takeover_url_expires_at_rfc3339: None,
203 takeover_url_ttl_s: None,
204 takeover_url_scope: None,
205 }
206 }
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct FetchError {
213 pub error_code: ErrorCode,
214 #[serde(rename = "error")]
215 pub detail: String,
216 pub retryable: bool,
217 pub trace: Trace,
218}
219
220impl FetchError {
221 #[must_use]
222 pub fn new(error: Error, trace: Trace) -> Self {
223 Self {
224 error_code: error.error_code,
225 detail: error.detail,
226 retryable: error.retryable,
227 trace,
228 }
229 }
230
231 #[must_use]
232 pub fn into_error(self) -> Error {
233 Error {
234 error_code: self.error_code,
235 detail: self.detail,
236 retryable: self.retryable,
237 }
238 }
239
240 #[must_use]
241 pub fn as_error(&self) -> Error {
242 Error {
243 error_code: self.error_code,
244 detail: self.detail.clone(),
245 retryable: self.retryable,
246 }
247 }
248}
249
250pub struct EscalationReason;
261
262impl EscalationReason {
263 pub const EMPTY_HTML_SHELL: &'static str = "empty_html_shell";
265
266 #[must_use]
268 pub fn http_status(status: u16) -> String {
269 format!("http_status_{status}")
270 }
271
272 #[must_use]
274 pub fn http_failed(error_code: &str) -> String {
275 format!("http_failed_{error_code}")
276 }
277}
278
279impl FetchResult {
280 pub(crate) fn new(request_id: RequestId, url: String, trace: Trace) -> Self {
285 Self {
286 request_id,
287 final_url: url.clone(),
288 request_url: url,
289 status: 0,
290 page_kind: None,
291 next_action: None,
292 tab_id: None,
293 trace,
294 warnings: Vec::new(),
295 body_file: None,
296 rendered_html_file: None,
297 text_file: None,
298 content_file: None,
299 content_json_file: None,
300 screenshot_file: None,
301 network_file: None,
302 console_file: None,
303 observation_file: None,
304 storage_file: None,
305 download_file: None,
306 download_bytes: None,
307 download_filename: None,
308 download_url: None,
309 download_state: None,
310 }
311 }
312
313 pub fn set_artifact_file(&mut self, artifact: Artifact, path: PathBuf) {
316 match artifact {
317 Artifact::Body => self.body_file = Some(path),
318 Artifact::RenderedHtml => self.rendered_html_file = Some(path),
319 Artifact::Text => self.text_file = Some(path),
320 Artifact::Content => self.content_file = Some(path),
321 Artifact::ContentJson => self.content_json_file = Some(path),
322 Artifact::Screenshot => self.screenshot_file = Some(path),
323 Artifact::Network => self.network_file = Some(path),
324 Artifact::Console => self.console_file = Some(path),
325 Artifact::Observation => self.observation_file = Some(path),
326 Artifact::Storage => self.storage_file = Some(path),
327 }
328 }
329
330 pub(crate) fn set_page_kind(&mut self, kind: PageKind) {
331 self.page_kind = Some(kind);
332 if matches!(
333 kind,
334 PageKind::BotWallDetected | PageKind::SecurityChallengeDetected
335 ) {
336 self.next_action = Some(NextAction::human_takeover(&self.request_url));
337 }
338 }
339
340 pub fn attach_takeover(&mut self, takeover_url: String) {
345 self.attach_takeover_with_context(takeover_url, None, None, None, None, None);
346 }
347
348 pub fn attach_takeover_with_context(
351 &mut self,
352 takeover_url: String,
353 expires_at_rfc3339: Option<String>,
354 ttl_s: Option<u64>,
355 scope: Option<String>,
356 endpoint: Option<&str>,
357 profile: Option<&str>,
358 ) {
359 let url = self.request_url.clone();
360 let tab = self.tab_id.as_ref().map(|t| t.as_str().to_string());
361 if let Some(next) = self.next_action.as_mut() {
362 next.takeover_url = Some(takeover_url);
363 next.takeover_url_expires_at_rfc3339 = expires_at_rfc3339;
364 next.takeover_url_ttl_s = ttl_s;
365 next.takeover_url_scope = scope;
366 if let Some(tab) = tab {
367 let mut command = format!(
368 "afhttp fetch {} --takeover --tab {}",
369 shell_quote(&url),
370 shell_quote(&tab),
371 );
372 if let Some(endpoint) = endpoint {
373 command.push_str(" --endpoint-url ");
374 command.push_str(&shell_quote(endpoint));
375 }
376 if let Some(profile) = profile {
377 command.push_str(" --profile ");
378 command.push_str(&shell_quote(profile));
379 }
380 next.recommended_command = command;
381 }
382 }
383 }
384
385 #[must_use]
386 pub fn artifact_file(&self, artifact: Artifact) -> Option<&PathBuf> {
387 match artifact {
388 Artifact::Body => self.body_file.as_ref(),
389 Artifact::RenderedHtml => self.rendered_html_file.as_ref(),
390 Artifact::Text => self.text_file.as_ref(),
391 Artifact::Content => self.content_file.as_ref(),
392 Artifact::ContentJson => self.content_json_file.as_ref(),
393 Artifact::Screenshot => self.screenshot_file.as_ref(),
394 Artifact::Network => self.network_file.as_ref(),
395 Artifact::Console => self.console_file.as_ref(),
396 Artifact::Observation => self.observation_file.as_ref(),
397 Artifact::Storage => self.storage_file.as_ref(),
398 }
399 }
400}
401
402fn shell_quote(value: &str) -> String {
403 if !value.is_empty()
404 && value.bytes().all(|b| {
405 b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b'/' | b':' | b',' | b'=')
406 })
407 {
408 return value.to_string();
409 }
410 format!("'{}'", value.replace('\'', "'\\''"))
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416
417 #[test]
418 fn fetch_success_json_is_flat_golden() {
419 let mut result = FetchResult::new(
420 RequestId("req-1".into()),
421 "https://example.com/".into(),
422 Trace {
423 render_decision: RenderDecision::Browser,
424 render_mode: TraceRenderMode::Always,
425 render_used: true,
426 escalation_reason: None,
427 main_request_observed: true,
428 duration_ms: 12,
429 timeout_ms: 30000,
430 current_stage: "complete".into(),
431 navigation_duration_ms: Some(8),
432 wait_mode: Some("load".into()),
433 wait_satisfied_by: Some("load".into()),
434 network_quiet: None,
435 dom_stable: None,
436 text_stable: None,
437 capture_reason: Some("wait_satisfied".into()),
438 cookie_jar_file: None,
439 cookie_jar_warning: None,
440 sensitive_capture: Vec::new(),
441 stages: vec![
442 TraceStage {
443 name: "navigate".into(),
444 status: TraceStageStatus::Ok,
445 duration_ms: 8,
446 },
447 TraceStage {
448 name: "capture_text".into(),
449 status: TraceStageStatus::Ok,
450 duration_ms: 4,
451 },
452 ],
453 },
454 );
455 result.status = 200;
456 for (artifact, file) in [
457 (Artifact::Body, "body.html"),
458 (Artifact::RenderedHtml, "rendered.html"),
459 (Artifact::Text, "text.txt"),
460 (Artifact::Screenshot, "page.png"),
461 (Artifact::Network, "network.json"),
462 (Artifact::Console, "console.json"),
463 (Artifact::Observation, "observation.json"),
464 (Artifact::Storage, "storage.json"),
465 ] {
466 result.set_artifact_file(artifact, PathBuf::from("/tmp/afhttp-out/req-1").join(file));
467 }
468 let mut buf = Vec::new();
469 crate::shared::afdata::emit(&mut buf, "fetch", &result).unwrap();
470 let json: serde_json::Value = serde_json::from_slice(&buf).unwrap();
471 agent_first_data::validate_protocol_event(&json, true).unwrap();
472 assert!(
473 json["result"].get("artifacts").is_none(),
474 "artifacts map must be gone"
475 );
476 let canonical = serde_json::to_string(&json).unwrap();
477 let expected = include_str!("../../../tests/golden/fetch-success.json").trim();
478 assert_eq!(canonical, expected);
479 }
480
481 #[test]
482 fn bot_wall_page_kind_adds_human_takeover_next_action() {
483 let mut result = FetchResult::new(
484 RequestId("req-1".into()),
485 "https://example.com/a b".into(),
486 Trace::default(),
487 );
488 result.set_page_kind(PageKind::BotWallDetected);
489 let next = result.next_action.expect("next_action");
490 assert_eq!(next.kind, NextActionKind::HumanTakeover);
491 assert!(!next.target_content_verified);
492 assert_eq!(
493 next.recommended_command,
494 "afhttp fetch 'https://example.com/a b' --takeover"
495 );
496 }
497
498 #[test]
499 fn takeover_next_action_can_include_connection_and_profile_context() {
500 let mut result = FetchResult::new(
501 RequestId("req-1".into()),
502 "https://example.com/login".into(),
503 Trace::default(),
504 );
505 result.tab_id = Some(TabId::new("page 7"));
506 result.set_page_kind(PageKind::BotWallDetected);
507 result.attach_takeover_with_context(
508 "http://127.0.0.1:9222/takeover/panel?handoff=h".into(),
509 Some("2026-06-11T00:00:00Z".into()),
510 Some(900),
511 Some("takeover".into()),
512 Some("ws://127.0.0.1:9222"),
513 Some("work profile"),
514 );
515
516 let next = result.next_action.expect("next_action");
517 assert_eq!(
518 next.recommended_command,
519 "afhttp fetch https://example.com/login --takeover --tab 'page 7' --endpoint-url ws://127.0.0.1:9222 --profile 'work profile'"
520 );
521 assert_eq!(
522 next.takeover_url.as_deref(),
523 Some("http://127.0.0.1:9222/takeover/panel?handoff=h")
524 );
525 assert_eq!(next.takeover_url_ttl_s, Some(900));
526 assert_eq!(next.takeover_url_scope.as_deref(), Some("takeover"));
527 }
528}