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| {
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
104pub 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
116pub 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
154pub 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 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
258pub 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
381pub 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 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 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}