Skip to main content

browser_automation_cli/
workflow_local.rs

1//! One-shot workflow journal (PRD ยง5H): DAG + SQLite, no live Page/@eN across processes.
2
3use std::collections::BTreeMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use petgraph::algo::{is_cyclic_directed, toposort};
8use petgraph::graph::DiGraph;
9use rusqlite::{params, Connection};
10use serde::{Deserialize, Serialize};
11use serde_json::{json, Value};
12use uuid::Uuid;
13
14use crate::error::{CliError, ErrorKind};
15use crate::xdg;
16
17/// Workflow step in a manifest (no live browser handles).
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct WorkflowStep {
20    /// Stable step id.
21    pub id: String,
22    /// CLI command name (e.g. goto, scrape, run).
23    pub cmd: String,
24    /// Optional argv/object for the step.
25    #[serde(default)]
26    pub args: Value,
27    /// Dependencies (step ids that must complete first).
28    #[serde(default)]
29    pub depends_on: Vec<String>,
30}
31
32/// Workflow manifest file shape.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct WorkflowManifest {
35    /// Optional name.
36    #[serde(default)]
37    pub name: Option<String>,
38    /// Correlation id for envelopes.
39    #[serde(default)]
40    pub correlation_id: Option<String>,
41    /// Steps forming a DAG.
42    pub steps: Vec<WorkflowStep>,
43}
44
45/// Open or create journal DB under XDG state.
46pub fn journal_path(name: Option<&str>) -> Result<PathBuf, CliError> {
47    let dir = xdg::workflow_dir()?;
48    xdg::ensure_dir(&dir)?;
49    let file = name.unwrap_or("default");
50    let safe: String = file
51        .chars()
52        .map(|c| {
53            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
54                c
55            } else {
56                '_'
57            }
58        })
59        .collect();
60    Ok(dir.join(format!("{safe}.sqlite")))
61}
62
63fn open_db(path: &Path) -> Result<Connection, CliError> {
64    let conn = Connection::open(path).map_err(|e| {
65        CliError::new(
66            ErrorKind::Io,
67            format!("open workflow journal {}: {e}", path.display()),
68        )
69    })?;
70    conn.execute_batch(
71        r#"
72        CREATE TABLE IF NOT EXISTS meta (
73            key TEXT PRIMARY KEY,
74            value TEXT NOT NULL
75        );
76        CREATE TABLE IF NOT EXISTS steps (
77            step_id TEXT PRIMARY KEY,
78            cmd TEXT NOT NULL,
79            status TEXT NOT NULL,
80            depends_on TEXT NOT NULL DEFAULT '[]',
81            result_json TEXT,
82            error TEXT,
83            updated_at TEXT NOT NULL
84        );
85        CREATE TABLE IF NOT EXISTS runs (
86            run_id TEXT PRIMARY KEY,
87            correlation_id TEXT,
88            status TEXT NOT NULL,
89            started_at TEXT NOT NULL,
90            finished_at TEXT
91        );
92        "#,
93    )
94    .map_err(|e| CliError::new(ErrorKind::Software, format!("workflow schema: {e}")))?;
95    Ok(conn)
96}
97
98fn now_rfc3339() -> String {
99    time::OffsetDateTime::now_utc()
100        .format(&time::format_description::well_known::Rfc3339)
101        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".into())
102}
103
104/// Load manifest from JSON path.
105pub fn load_manifest(path: &Path) -> Result<WorkflowManifest, CliError> {
106    let raw = fs::read_to_string(path).map_err(|e| {
107        CliError::new(
108            ErrorKind::Io,
109            format!("read workflow manifest {}: {e}", path.display()),
110        )
111    })?;
112    serde_json::from_str(&raw)
113        .map_err(|e| CliError::new(ErrorKind::Data, format!("invalid workflow manifest: {e}")))
114}
115
116/// Validate DAG with petgraph; return topological order of step ids.
117pub fn validate_dag(steps: &[WorkflowStep]) -> Result<Vec<String>, CliError> {
118    let mut g: DiGraph<String, ()> = DiGraph::new();
119    let mut idx: BTreeMap<String, petgraph::graph::NodeIndex> = BTreeMap::new();
120    for s in steps {
121        if idx.contains_key(&s.id) {
122            return Err(CliError::new(
123                ErrorKind::Data,
124                format!("duplicate workflow step id: {}", s.id),
125            ));
126        }
127        let n = g.add_node(s.id.clone());
128        idx.insert(s.id.clone(), n);
129    }
130    for s in steps {
131        let to = idx[&s.id];
132        for dep in &s.depends_on {
133            let from = idx.get(dep).ok_or_else(|| {
134                CliError::new(
135                    ErrorKind::Data,
136                    format!("step {} depends on unknown id {dep}", s.id),
137                )
138            })?;
139            g.add_edge(*from, to, ());
140        }
141    }
142    if is_cyclic_directed(&g) {
143        return Err(CliError::with_suggestion(
144            ErrorKind::Data,
145            "workflow DAG has a cycle",
146            "Remove circular depends_on edges",
147        ));
148    }
149    let order = toposort(&g, None)
150        .map_err(|_| CliError::new(ErrorKind::Data, "workflow toposort failed (cycle?)"))?;
151    Ok(order.into_iter().map(|i| g[i].clone()).collect())
152}
153
154/// Run workflow one-shot: validate DAG, execute steps that are CLI-data commands,
155/// journal state. Browser multi-step with @eN still requires nested `run` scripts.
156pub fn workflow_run(manifest_path: &Path, journal: Option<&Path>) -> Result<Value, CliError> {
157    let manifest = load_manifest(manifest_path)?;
158    let order = validate_dag(&manifest.steps)?;
159    let jpath = match journal {
160        Some(p) => p.to_path_buf(),
161        None => journal_path(manifest.name.as_deref())?,
162    };
163    let conn = open_db(&jpath)?;
164    let run_id = Uuid::new_v4().to_string();
165    let correlation = manifest
166        .correlation_id
167        .clone()
168        .unwrap_or_else(|| run_id.clone());
169    let started = now_rfc3339();
170    conn.execute(
171        "INSERT INTO runs (run_id, correlation_id, status, started_at) VALUES (?1, ?2, 'running', ?3)",
172        params![run_id, correlation, started],
173    )
174    .map_err(|e| CliError::new(ErrorKind::Software, format!("insert run: {e}")))?;
175
176    let mut by_id: BTreeMap<String, WorkflowStep> = BTreeMap::new();
177    for s in &manifest.steps {
178        by_id.insert(s.id.clone(), s.clone());
179        let deps = serde_json::to_string(&s.depends_on).unwrap_or_else(|_| "[]".into());
180        conn.execute(
181            "INSERT OR REPLACE INTO steps (step_id, cmd, status, depends_on, updated_at) VALUES (?1, ?2, 'pending', ?3, ?4)",
182            params![s.id, s.cmd, deps, now_rfc3339()],
183        )
184        .map_err(|e| CliError::new(ErrorKind::Software, format!("insert step: {e}")))?;
185    }
186
187    let mut results = Vec::new();
188    let mut failed: Option<String> = None;
189    for sid in &order {
190        let step = &by_id[sid];
191        // Fail-fast if dependency failed (tracked only in this run).
192        if let Some(ref f) = failed {
193            conn.execute(
194                "UPDATE steps SET status='skipped', error=?2, updated_at=?3 WHERE step_id=?1",
195                params![sid, format!("skipped after failure of {f}"), now_rfc3339()],
196            )
197            .ok();
198            results.push(json!({
199                "id": sid,
200                "cmd": step.cmd,
201                "ok": false,
202                "skipped": true,
203            }));
204            continue;
205        }
206
207        match execute_offline_step(step) {
208            Ok(data) => {
209                let body = serde_json::to_string(&data).unwrap_or_else(|_| "{}".into());
210                conn.execute(
211                    "UPDATE steps SET status='ok', result_json=?2, error=NULL, updated_at=?3 WHERE step_id=?1",
212                    params![sid, body, now_rfc3339()],
213                )
214                .map_err(|e| CliError::new(ErrorKind::Software, format!("update step: {e}")))?;
215                results.push(json!({
216                    "id": sid,
217                    "cmd": step.cmd,
218                    "ok": true,
219                    "data": data,
220                }));
221            }
222            Err(e) => {
223                let msg = e.to_string();
224                conn.execute(
225                    "UPDATE steps SET status='error', error=?2, updated_at=?3 WHERE step_id=?1",
226                    params![sid, msg, now_rfc3339()],
227                )
228                .ok();
229                results.push(json!({
230                    "id": sid,
231                    "cmd": step.cmd,
232                    "ok": false,
233                    "error": msg,
234                }));
235                failed = Some(sid.clone());
236            }
237        }
238    }
239
240    let status = if failed.is_some() { "failed" } else { "ok" };
241    conn.execute(
242        "UPDATE runs SET status=?2, finished_at=?3 WHERE run_id=?1",
243        params![run_id, status, now_rfc3339()],
244    )
245    .ok();
246
247    Ok(json!({
248        "run_id": run_id,
249        "correlation_id": correlation,
250        "status": status,
251        "journal": jpath.display().to_string(),
252        "order": order,
253        "steps": results,
254        "note": "offline/data steps executed in-process; browser @eN multi-step remains in `run --script`",
255    }))
256}
257
258/// Resume: skip steps already `ok` in journal; re-execute pending/error only.
259pub fn workflow_resume(manifest_path: &Path, journal: Option<&Path>) -> Result<Value, CliError> {
260    let manifest = load_manifest(manifest_path)?;
261    let order = validate_dag(&manifest.steps)?;
262    let jpath = match journal {
263        Some(p) => p.to_path_buf(),
264        None => journal_path(manifest.name.as_deref())?,
265    };
266    if !jpath.exists() {
267        return Err(CliError::with_suggestion(
268            ErrorKind::NoInput,
269            format!("journal not found: {}", jpath.display()),
270            "Run `workflow run` first or pass --journal",
271        ));
272    }
273    let conn = open_db(&jpath)?;
274    let mut done: BTreeMap<String, String> = BTreeMap::new();
275    {
276        let mut stmt = conn
277            .prepare("SELECT step_id, status FROM steps")
278            .map_err(|e| CliError::new(ErrorKind::Software, format!("resume prepare: {e}")))?;
279        let rows = stmt
280            .query_map([], |row| {
281                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
282            })
283            .map_err(|e| CliError::new(ErrorKind::Software, format!("resume query: {e}")))?;
284        for r in rows {
285            let (id, st) =
286                r.map_err(|e| CliError::new(ErrorKind::Software, format!("row: {e}")))?;
287            done.insert(id, st);
288        }
289    }
290    let run_id = Uuid::new_v4().to_string();
291    let correlation = manifest
292        .correlation_id
293        .clone()
294        .unwrap_or_else(|| run_id.clone());
295    conn.execute(
296        "INSERT INTO runs (run_id, correlation_id, status, started_at) VALUES (?1, ?2, 'running', ?3)",
297        params![run_id, correlation, now_rfc3339()],
298    )
299    .map_err(|e| CliError::new(ErrorKind::Software, format!("insert resume run: {e}")))?;
300
301    let mut by_id: BTreeMap<String, WorkflowStep> = BTreeMap::new();
302    for s in &manifest.steps {
303        by_id.insert(s.id.clone(), s.clone());
304    }
305
306    let mut results = Vec::new();
307    let mut failed: Option<String> = None;
308    for sid in &order {
309        let step = &by_id[sid];
310        if done.get(sid).map(|s| s.as_str()) == Some("ok") {
311            results.push(json!({
312                "id": sid,
313                "cmd": step.cmd,
314                "ok": true,
315                "skipped": true,
316                "reason": "already_ok",
317            }));
318            continue;
319        }
320        if let Some(ref f) = failed {
321            results.push(json!({
322                "id": sid,
323                "cmd": step.cmd,
324                "ok": false,
325                "skipped": true,
326                "reason": format!("after_failure:{f}"),
327            }));
328            continue;
329        }
330        match execute_offline_step(step) {
331            Ok(data) => {
332                let body = serde_json::to_string(&data).unwrap_or_else(|_| "{}".into());
333                conn.execute(
334                    "UPDATE steps SET status='ok', result_json=?2, error=NULL, updated_at=?3 WHERE step_id=?1",
335                    params![sid, body, now_rfc3339()],
336                )
337                .ok();
338                results.push(json!({
339                    "id": sid,
340                    "cmd": step.cmd,
341                    "ok": true,
342                    "data": data,
343                    "resumed": true,
344                }));
345            }
346            Err(e) => {
347                let msg = e.to_string();
348                conn.execute(
349                    "UPDATE steps SET status='error', error=?2, updated_at=?3 WHERE step_id=?1",
350                    params![sid, msg, now_rfc3339()],
351                )
352                .ok();
353                results.push(json!({
354                    "id": sid,
355                    "cmd": step.cmd,
356                    "ok": false,
357                    "error": msg,
358                    "resumed": true,
359                }));
360                failed = Some(sid.clone());
361            }
362        }
363    }
364    let status = if failed.is_some() { "failed" } else { "ok" };
365    conn.execute(
366        "UPDATE runs SET status=?2, finished_at=?3 WHERE run_id=?1",
367        params![run_id, status, now_rfc3339()],
368    )
369    .ok();
370    Ok(json!({
371        "run_id": run_id,
372        "correlation_id": correlation,
373        "status": status,
374        "journal": jpath.display().to_string(),
375        "order": order,
376        "steps": results,
377        "resume": true,
378    }))
379}
380
381/// Status of journal steps.
382pub fn workflow_status(journal: Option<&Path>, name: Option<&str>) -> Result<Value, CliError> {
383    let jpath = match journal {
384        Some(p) => p.to_path_buf(),
385        None => journal_path(name)?,
386    };
387    if !jpath.exists() {
388        return Ok(json!({
389            "journal": jpath.display().to_string(),
390            "exists": false,
391            "steps": [],
392        }));
393    }
394    let conn = open_db(&jpath)?;
395    let mut stmt = conn
396        .prepare("SELECT step_id, cmd, status, error, updated_at FROM steps ORDER BY step_id")
397        .map_err(|e| CliError::new(ErrorKind::Software, format!("status prepare: {e}")))?;
398    let rows = stmt
399        .query_map([], |row| {
400            Ok(json!({
401                "step_id": row.get::<_, String>(0)?,
402                "cmd": row.get::<_, String>(1)?,
403                "status": row.get::<_, String>(2)?,
404                "error": row.get::<_, Option<String>>(3)?,
405                "updated_at": row.get::<_, String>(4)?,
406            }))
407        })
408        .map_err(|e| CliError::new(ErrorKind::Software, format!("status query: {e}")))?;
409    let mut steps = Vec::new();
410    for r in rows {
411        steps.push(r.map_err(|e| CliError::new(ErrorKind::Software, format!("row: {e}")))?);
412    }
413    Ok(json!({
414        "journal": jpath.display().to_string(),
415        "exists": true,
416        "count": steps.len(),
417        "steps": steps,
418    }))
419}
420
421fn execute_offline_step(step: &WorkflowStep) -> Result<Value, CliError> {
422    match step.cmd.as_str() {
423        "noop" | "echo" => Ok(json!({
424            "cmd": step.cmd,
425            "args": step.args,
426            "ok": true,
427        })),
428        "parse" => {
429            let path = step
430                .args
431                .get("path")
432                .and_then(|v| v.as_str())
433                .ok_or_else(|| CliError::new(ErrorKind::Usage, "parse step needs args.path"))?;
434            crate::scrape_local::parse_file(Path::new(path))
435        }
436        "scrape" => {
437            // Offline workflow cannot launch browser without lifecycle; require engine=http.
438            let url = step
439                .args
440                .get("url")
441                .and_then(|v| v.as_str())
442                .ok_or_else(|| CliError::new(ErrorKind::Usage, "scrape step needs args.url"))?;
443            let fmt = step
444                .args
445                .get("format")
446                .and_then(|v| v.as_str())
447                .unwrap_or("text");
448            let opts = crate::scrape_local::ScrapeOpts {
449                format: crate::scrape_local::ScrapeFormat::parse(fmt)?,
450                engine: "http".into(),
451                only_main_content: step
452                    .args
453                    .get("only_main_content")
454                    .and_then(|v| v.as_bool())
455                    .unwrap_or(false),
456                ..Default::default()
457            };
458            // Block on async HTTP scrape in a small runtime.
459            let robots = crate::robots::RobotsPolicy::Honor;
460            let rt = tokio::runtime::Builder::new_current_thread()
461                .enable_all()
462                .build()
463                .map_err(|e| CliError::new(ErrorKind::Software, format!("runtime: {e}")))?;
464            rt.block_on(crate::scrape_local::scrape_http(url, robots, &opts))
465        }
466        "batch-scrape" | "batch_scrape" => {
467            let path = step
468                .args
469                .get("urls_file")
470                .or_else(|| step.args.get("urls-file"))
471                .and_then(|v| v.as_str())
472                .ok_or_else(|| {
473                    CliError::new(ErrorKind::Usage, "batch-scrape needs args.urls_file")
474                })?;
475            let urls = crate::scrape_local::read_urls_file(Path::new(path))?;
476            let opts = crate::scrape_local::ScrapeOpts {
477                format: crate::scrape_local::ScrapeFormat::Text,
478                engine: "http".into(),
479                ..Default::default()
480            };
481            let rt = tokio::runtime::Builder::new_current_thread()
482                .enable_all()
483                .build()
484                .map_err(|e| CliError::new(ErrorKind::Software, format!("runtime: {e}")))?;
485            rt.block_on(crate::scrape_local::batch_scrape_http(
486                &urls,
487                crate::robots::RobotsPolicy::Honor,
488                &opts,
489                2,
490            ))
491        }
492        other => Err(CliError::with_suggestion(
493            ErrorKind::Usage,
494            format!("workflow offline step unsupported cmd: {other}"),
495            "Supported offline: noop, echo, parse, scrape (http), batch-scrape; use run --script for browser refs",
496        )),
497    }
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503
504    #[test]
505    fn dag_topo() {
506        let steps = vec![
507            WorkflowStep {
508                id: "a".into(),
509                cmd: "noop".into(),
510                args: json!({}),
511                depends_on: vec![],
512            },
513            WorkflowStep {
514                id: "b".into(),
515                cmd: "noop".into(),
516                args: json!({}),
517                depends_on: vec!["a".into()],
518            },
519        ];
520        let order = validate_dag(&steps).unwrap();
521        assert_eq!(order, vec!["a".to_string(), "b".to_string()]);
522    }
523
524    #[test]
525    fn dag_cycle_detected() {
526        let steps = vec![
527            WorkflowStep {
528                id: "a".into(),
529                cmd: "noop".into(),
530                args: json!({}),
531                depends_on: vec!["b".into()],
532            },
533            WorkflowStep {
534                id: "b".into(),
535                cmd: "noop".into(),
536                args: json!({}),
537                depends_on: vec!["a".into()],
538            },
539        ];
540        assert!(validate_dag(&steps).is_err());
541    }
542}