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