use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
pub type Extra = Map<String, Value>;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct AgentStatus {
pub status: String,
pub version: Option<String>,
pub active_runs: Option<i64>,
pub encrypted: Option<bool>,
pub due_monitors: Option<i64>,
pub last_tick_at: Option<String>,
pub warm_browser: Option<bool>,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Health {
pub status: String,
pub version: Option<String>,
pub cipher_present: Option<bool>,
pub db_ok: Option<bool>,
pub keyring_ok: Option<bool>,
pub active_runs: Option<i64>,
pub warm_browser: Option<bool>,
pub scheduler: Option<Value>,
pub cloud_link: Option<Value>,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Workflow {
pub id: i64,
pub name: String,
pub description: Option<String>,
pub workflow_type: Option<String>,
pub entry_url: Option<String>,
pub steps: Option<Value>,
pub form_data: Option<Value>,
pub functions: Option<Value>,
pub is_active: Option<i64>,
pub is_verified: Option<i64>,
pub timeout_ms: Option<i64>,
pub retry_count: Option<i64>,
pub headless: Option<i64>,
pub schedule_enabled: Option<i64>,
pub schedule_interval_ms: Option<i64>,
pub schedule_kind: Option<String>,
pub schedule_time: Option<String>,
pub schedule_days: Option<String>,
pub schedule_tz: Option<String>,
pub last_scheduled_at: Option<String>,
pub next_scheduled_at: Option<String>,
pub default_persona_id: Option<i64>,
pub http_capable: Option<i64>,
pub usage_count: Option<i64>,
pub total_run_count: Option<i64>,
pub total_failure_count: Option<i64>,
pub consecutive_failures: Option<i64>,
pub last_run_at: Option<String>,
pub last_run_status: Option<String>,
pub last_run_duration_ms: Option<i64>,
pub last_failure_at: Option<String>,
pub last_failure_error: Option<String>,
pub cloud_callable: Option<i64>,
pub execution_target: Option<String>,
pub marketplace_slug: Option<String>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub has_credentials: Option<bool>,
pub credential_keys: Option<Vec<String>>,
pub placeholders: Option<Vec<Value>>,
pub has_login: Option<bool>,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct RunStarted {
pub run_id: i64,
pub status: String,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RunCompleted {
pub run_id: i64,
pub status: String,
#[serde(default)]
pub done: bool,
#[serde(default)]
pub data: Option<Value>,
#[serde(default)]
pub error: Option<String>,
#[serde(default)]
pub duration_ms: Option<i64>,
#[serde(default)]
pub status_url: Option<String>,
#[serde(default)]
pub events_url: Option<String>,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct RunFeedItem {
pub id: String,
pub run_type: Option<String>,
pub entity_id: Option<i64>,
pub entity_name: Option<String>,
pub status: String,
pub started_at: Option<String>,
pub finished_at: Option<String>,
pub duration_ms: Option<i64>,
pub trigger_source: Option<String>,
pub error: Option<String>,
pub detail_url_hint: Option<String>,
pub data_url_hint: Option<String>,
pub rows_extracted: Option<i64>,
pub change_detected: Option<bool>,
pub engine: Option<String>,
#[serde(flatten)]
pub extra: Extra,
}
impl RunFeedItem {
pub fn row_id(&self) -> Option<i64> {
self.id
.rsplit_once('-')
.and_then(|(_, tail)| tail.parse().ok())
.or_else(|| self.id.parse().ok())
}
pub fn is_running(&self) -> bool {
self.status == "running"
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct RunResults {
pub run_id: i64,
pub status: String,
pub result: Value,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct RunData {
pub run_id: i64,
pub status: String,
pub data: Value,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct CancelOutcome {
pub run_id: Option<i64>,
pub id: Option<i64>,
pub status: String,
pub run_status: Option<String>,
#[serde(flatten)]
pub extra: Extra,
}
impl CancelOutcome {
pub fn cancel_requested(&self) -> bool {
self.status == "cancel_requested"
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum RunEvent {
Started { run_id: i64, total_steps: u64 },
Step {
run_id: i64,
index: u64,
step_type: String,
status: String,
},
Progress {
run_id: i64,
completed: u64,
total: u64,
},
Finished { run_id: i64, status: String },
Error { run_id: i64, message: String },
Unknown(Value),
}
#[derive(Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
enum TaggedRunEvent {
Started {
run_id: i64,
total_steps: u64,
},
Step {
run_id: i64,
index: u64,
step_type: String,
status: String,
},
Progress {
run_id: i64,
completed: u64,
total: u64,
},
Finished {
run_id: i64,
status: String,
},
Error {
run_id: i64,
message: String,
},
}
impl From<TaggedRunEvent> for RunEvent {
fn from(ev: TaggedRunEvent) -> Self {
match ev {
TaggedRunEvent::Started {
run_id,
total_steps,
} => RunEvent::Started {
run_id,
total_steps,
},
TaggedRunEvent::Step {
run_id,
index,
step_type,
status,
} => RunEvent::Step {
run_id,
index,
step_type,
status,
},
TaggedRunEvent::Progress {
run_id,
completed,
total,
} => RunEvent::Progress {
run_id,
completed,
total,
},
TaggedRunEvent::Finished { run_id, status } => RunEvent::Finished { run_id, status },
TaggedRunEvent::Error { run_id, message } => RunEvent::Error { run_id, message },
}
}
}
impl<'de> Deserialize<'de> for RunEvent {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Value::deserialize(deserializer)?;
Ok(
match serde_json::from_value::<TaggedRunEvent>(value.clone()) {
Ok(known) => known.into(),
Err(_) => RunEvent::Unknown(value),
},
)
}
}
impl RunEvent {
pub fn parse(data: &str) -> RunEvent {
match serde_json::from_str::<RunEvent>(data) {
Ok(ev) => ev,
Err(_) => RunEvent::Unknown(Value::String(data.to_string())),
}
}
pub fn run_id(&self) -> Option<i64> {
match self {
RunEvent::Started { run_id, .. }
| RunEvent::Step { run_id, .. }
| RunEvent::Progress { run_id, .. }
| RunEvent::Finished { run_id, .. }
| RunEvent::Error { run_id, .. } => Some(*run_id),
RunEvent::Unknown(v) => v.get("run_id").and_then(Value::as_i64),
}
}
pub fn is_terminal(&self) -> bool {
matches!(self, RunEvent::Finished { .. } | RunEvent::Error { .. })
}
}
#[derive(Debug, Clone)]
pub struct RunOutcome {
pub run: RunFeedItem,
pub results: Option<RunResults>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Monitor {
pub id: i64,
pub url: Option<String>,
pub name: Option<String>,
pub check_type: Option<String>,
pub enabled: Option<i64>,
pub check_period_ms: Option<i64>,
pub requires_playwright: Option<i64>,
pub state: Option<String>,
pub last_checked_at: Option<String>,
pub status_code: Option<i64>,
pub is_up: Option<bool>,
pub last_change_at: Option<String>,
pub state_updated_at: Option<String>,
pub changes_count: Option<i64>,
pub selector_count: Option<i64>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct MonitorHistory {
pub monitor_id: i64,
pub limit: Option<i64>,
pub offset: Option<i64>,
pub has_more: Option<bool>,
pub changes: Vec<Value>,
pub uptime_checks: Vec<Value>,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Selector {
pub id: i64,
pub target_id: Option<i64>,
pub name: Option<String>,
pub selector: Option<String>,
pub description: Option<String>,
pub enabled: Option<i64>,
pub content_type: Option<String>,
pub ignore_regex: Option<String>,
pub priority: Option<i64>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Extractor {
pub id: i64,
pub target_selector_id: Option<i64>,
pub name: Option<String>,
pub output_name: Option<String>,
pub enabled: Option<i64>,
pub extract_type: Option<String>,
pub config: Option<Value>,
pub is_array: Option<i64>,
pub default_value: Option<String>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Automation {
pub id: i64,
pub name: String,
pub enabled: Option<i64>,
pub event_type: Option<String>,
pub conditions: Option<Value>,
pub actions: Option<Value>,
pub blocks: Option<Value>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Persona {
pub id: i64,
pub name: Option<String>,
pub description: Option<String>,
pub target_domain: Option<String>,
pub login_username: Option<String>,
pub has_password: Option<bool>,
pub twofa_method: Option<String>,
pub has_totp_seed: Option<bool>,
pub email_otp_mode: Option<String>,
pub has_fingerprint: Option<bool>,
pub has_proxy: Option<bool>,
pub is_active: Option<bool>,
pub validation_status: Option<String>,
pub has_warm_session: Option<bool>,
pub session_expires_at: Option<String>,
pub last_login_at: Option<String>,
pub last_used_at: Option<String>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub linked_workflows: Option<Vec<Value>>,
pub linked_secrets: Option<Value>,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct SecretMeta {
pub id: Option<i64>,
pub key: String,
pub name: Option<String>,
pub description: Option<String>,
pub category: Option<String>,
pub is_credential: Option<bool>,
pub is_card: Option<bool>,
pub username: Option<String>,
pub card_last4: Option<String>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub last_used_at: Option<String>,
pub use_count: Option<i64>,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct VaultStatus {
pub enabled: bool,
pub locked: bool,
pub idle_timeout_secs: Option<i64>,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct StoredFile {
pub id: String,
pub object: Option<String>,
pub filename: Option<String>,
pub content_type: Option<String>,
pub bytes: Option<i64>,
pub created_at: Option<i64>,
pub status: Option<String>,
pub source: Option<String>,
pub purpose: Option<String>,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ApiKey {
pub id: i64,
pub name: Option<String>,
pub prefix: Option<String>,
pub scopes: Option<String>,
pub enabled: Option<i64>,
pub last_used_at: Option<String>,
pub created_at: Option<String>,
pub revoked_at: Option<String>,
pub key: Option<String>,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct WsTicket {
pub ticket: String,
pub expires_in_secs: u64,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct CrawlJob {
pub id: i64,
pub name: String,
pub seed_url: String,
pub include_paths: Vec<String>,
pub exclude_paths: Vec<String>,
pub max_depth: i64,
pub same_domain: i64,
pub allow_subdomains: i64,
pub extract_mode: String,
pub extract_schema: Option<Value>,
pub persona_id: Option<i64>,
pub respect_robots: i64,
pub delay_ms: i64,
pub max_concurrent: i64,
pub page_budget: i64,
pub workflow_id: Option<i64>,
pub data_workflow_id: Option<i64>,
pub concierge_session_id: Option<i64>,
pub status: String,
pub pages_discovered: i64,
pub pages_done: i64,
pub pages_failed: i64,
pub pages_skipped: i64,
pub workers_active: i64,
pub current_depth: i64,
pub error: Option<String>,
pub cancel_requested: i64,
pub brand: String,
pub is_terminal: bool,
pub created_at: String,
pub updated_at: Option<String>,
pub started_at: Option<String>,
pub completed_at: Option<String>,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct CrawlStartParams {
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extract_mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extract_schema: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub persona_id: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_paths: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exclude_paths: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_depth: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub page_budget: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_concurrent: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delay_ms: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub respect_robots: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub same_domain: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub allow_subdomains: Option<bool>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct CrawlList {
pub crawls: Vec<CrawlJob>,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct CrawlCancel {
#[serde(flatten)]
pub job: CrawlJob,
pub cancel_requested_now: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Dataset {
pub id: i64,
pub name: String,
pub source_type: String,
pub run_count: i64,
pub last_updated: Option<String>,
pub origin: Option<String>,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct DatasetList {
pub datasets: Vec<Dataset>,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct DatasetMeta {
pub id: i64,
pub name: String,
pub source_type: String,
pub columns: Value,
pub facets: Value,
pub row_count: i64,
pub run_count: i64,
pub truncated: bool,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct DatasetRef {
pub id: i64,
pub name: Option<String>,
pub source_type: String,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct DatasetSearchHit {
pub dataset: DatasetRef,
pub run_id: Option<i64>,
pub run_at: Option<String>,
pub fields: Value,
pub highlight: Value,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DatasetFormat {
Json,
Csv,
Markdown,
Html,
}
impl DatasetFormat {
pub fn as_str(self) -> &'static str {
match self {
DatasetFormat::Json => "json",
DatasetFormat::Csv => "csv",
DatasetFormat::Markdown => "markdown",
DatasetFormat::Html => "html",
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct DatasetSearchResult {
pub query: String,
pub terms: Vec<String>,
pub results: Vec<DatasetSearchHit>,
pub total: i64,
pub truncated: bool,
pub scanned_runs: i64,
#[serde(flatten)]
pub extra: Extra,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn run_feed_item_row_id_parses_composite() {
let item: RunFeedItem =
serde_json::from_value(json!({"id": "workflow-3", "status": "success"})).unwrap();
assert_eq!(item.row_id(), Some(3));
let item: RunFeedItem =
serde_json::from_value(json!({"id": "check-142", "status": "running"})).unwrap();
assert_eq!(item.row_id(), Some(142));
assert!(item.is_running());
let bare: RunFeedItem =
serde_json::from_value(json!({"id": "7", "status": "success"})).unwrap();
assert_eq!(bare.row_id(), Some(7));
}
#[test]
fn run_event_parses_known_and_unknown() {
let ev = RunEvent::parse(r#"{"event":"started","run_id":9,"total_steps":4}"#);
assert_eq!(
ev,
RunEvent::Started {
run_id: 9,
total_steps: 4
}
);
assert!(!ev.is_terminal());
let ev = RunEvent::parse(
r#"{"event":"step","run_id":9,"index":1,"step_type":"click","status":"succeeded"}"#,
);
assert_eq!(ev.run_id(), Some(9));
let ev = RunEvent::parse(r#"{"event":"finished","run_id":9,"status":"success"}"#);
assert!(ev.is_terminal());
let ev = RunEvent::parse(r#"{"event":"error","run_id":9,"message":"navigation failed"}"#);
assert!(ev.is_terminal());
let ev = RunEvent::parse(r#"{"event":"warp","run_id":9,"factor":5}"#);
assert!(matches!(ev, RunEvent::Unknown(_)));
assert_eq!(ev.run_id(), Some(9));
assert!(!ev.is_terminal());
let ev = RunEvent::parse("not json");
assert_eq!(ev, RunEvent::Unknown(Value::String("not json".into())));
}
#[test]
fn crawl_cancel_flattens_job_and_splits_cancel_flag() {
let c: CrawlCancel = serde_json::from_value(json!({
"id": 5, "name": "Dragnet: example.com", "seed_url": "https://example.com",
"include_paths": ["^/docs"], "exclude_paths": [], "status": "stopping",
"brand": "Dragnet", "is_terminal": false, "workflow_id": 77,
"data_workflow_id": 77, "cancel_requested_now": true, "some_future": 1
}))
.unwrap();
assert!(c.cancel_requested_now);
assert_eq!(c.job.id, 5);
assert_eq!(c.job.status, "stopping");
assert_eq!(c.job.brand, "Dragnet");
assert_eq!(c.job.data_workflow_id, Some(77));
assert_eq!(c.job.include_paths, vec!["^/docs".to_string()]);
assert_eq!(c.job.extra["some_future"], 1);
assert!(!c.job.extra.contains_key("cancel_requested_now"));
}
#[test]
fn crawl_start_params_omit_unset_fields() {
let body = serde_json::to_value(CrawlStartParams {
url: "https://example.com".into(),
max_depth: Some(2),
respect_robots: Some(true),
..Default::default()
})
.unwrap();
assert_eq!(body["url"], "https://example.com");
assert_eq!(body["max_depth"], 2);
assert_eq!(body["respect_robots"], true);
assert!(body.get("name").is_none());
assert!(body.get("persona_id").is_none());
assert!(body.get("page_budget").is_none());
assert!(body.get("include_paths").is_none());
}
#[test]
fn workflow_unknown_fields_land_in_extra() {
let wf: Workflow = serde_json::from_value(json!({
"id": 5, "name": "scrape", "steps": [], "some_future_field": {"x": 1}
}))
.unwrap();
assert_eq!(wf.id, 5);
assert_eq!(wf.extra["some_future_field"]["x"], 1);
}
}