1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct WorkflowStep {
20 pub id: String,
22 pub cmd: String,
24 #[serde(default)]
26 pub args: Value,
27 #[serde(default)]
29 pub depends_on: Vec<String>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct WorkflowManifest {
35 #[serde(default)]
37 pub name: Option<String>,
38 #[serde(default)]
40 pub correlation_id: Option<String>,
41 pub steps: Vec<WorkflowStep>,
43}
44
45pub 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
95pub 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
108pub 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
147pub 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 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
251pub 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
371pub 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 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 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}