1use std::cell::RefCell;
15use std::collections::{BTreeMap, BTreeSet};
16use std::path::Path;
17
18use serde_json::Value;
19
20use crate::config::Config;
21use crate::dispatch::{Chosen, Overrides};
22use crate::route::{Approval, Policy};
23use crate::store::{Result, Store, WorkflowUnit};
24use crate::todo;
25use crate::workflow::{
26 Action, Document, FieldType, From, Node, Op, Out, Sink, To, Type, Via, Workflow,
27};
28
29struct Ctx<'a> {
32 store: &'a Store,
33 cfg: &'a Config,
34 doc: &'a Document,
35 w: &'a Workflow,
36 instance: i64,
37 home: &'a Path,
39 over: &'a Overrides,
41 params: BTreeMap<String, Value>,
44 policy: Option<Active>,
46 refused: RefCell<Vec<String>>,
48 deferred: RefCell<BTreeSet<(String, String)>>,
50 bases: RefCell<BTreeMap<String, std::result::Result<String, String>>>,
53 local: bool,
56 landed: RefCell<Vec<String>>,
58}
59
60pub struct Active {
63 revision: i64,
64 shadow: bool,
65 policy: Policy,
66}
67
68pub fn active_policy(store: &Store) -> Result<Option<Active>> {
70 Ok(match store.active_route()? {
71 None => None,
72 Some(rev) => Some(Active {
73 revision: rev.revision,
74 shadow: rev.shadow,
75 policy: rev.policy()?,
76 }),
77 })
78}
79
80pub fn unrouted(store: &Store, w: &Workflow) -> Result<Vec<String>> {
84 let Some(active) = active_policy(store)?.filter(|a| !a.shadow) else {
85 return Ok(Vec::new());
86 };
87 Ok(w.nodes
88 .iter()
89 .filter(|n| !free(n))
90 .filter(|n| {
91 !active
92 .policy
93 .names_node(&n.name, matches!(n.op, Op::Edit(_)))
94 })
95 .map(|n| {
96 format!(
97 "node `{}`: no route in policy revision {} names it, so it would be refused; \
98 add {{\"match\": {{\"node\": \"*/{}\"}}, ...}}",
99 n.name,
100 active.revision,
101 n.name.rsplit('/').next().unwrap_or(&n.name)
102 )
103 })
104 .collect())
105}
106
107#[derive(Debug)]
110pub struct Capped(pub String);
111
112impl std::fmt::Display for Capped {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 f.write_str(&self.0)
115 }
116}
117
118impl std::error::Error for Capped {}
119
120#[derive(Debug, Clone, Default)]
123struct Known {
124 verdicts: BTreeMap<(String, String), String>,
125 runs: BTreeMap<String, i64>,
126}
127
128impl Known {
129 fn load(store: &Store, instance: i64) -> Result<Known> {
130 let verdicts = store
131 .workflow_verdicts(instance)?
132 .into_iter()
133 .map(|(u, c, v)| ((u, c), v))
134 .collect();
135 let runs = store
137 .runs()?
138 .into_iter()
139 .filter(|r| r.workflow_instance == Some(instance))
140 .filter_map(|r| Some((r.unit?, r.id)))
141 .collect();
142 Ok(Known { verdicts, runs })
143 }
144}
145
146pub struct Invocation<'a> {
149 pub home: &'a Path,
150 pub args: &'a Value,
151 pub approve: Option<&'a str>,
152 pub unattended: bool,
155}
156
157#[derive(Debug, Clone, PartialEq)]
159pub struct Planned {
160 pub node: String,
161 pub op: String,
162 pub units: Vec<String>,
164 pub agent: Option<String>,
166 pub model: Option<String>,
167 pub cost: f64,
169}
170
171impl Planned {
172 pub fn free(&self) -> bool {
173 self.agent.is_none()
174 }
175}
176
177#[derive(Debug, Clone, PartialEq, Default)]
179pub struct Plan {
180 pub steps: Vec<Planned>,
181 basis: String,
184}
185
186impl Plan {
187 pub fn is_empty(&self) -> bool {
188 self.steps.is_empty()
189 }
190
191 pub fn cost(&self) -> f64 {
192 self.steps.iter().map(|p| p.cost).sum()
193 }
194
195 pub fn id(&self) -> String {
200 let mut text = self.basis.clone();
201 for p in &self.steps {
202 text.push_str(&format!(
203 "{}\u{0}{}\u{0}{}\u{0}{}\n",
204 p.node,
205 p.units.join(","),
206 p.agent.as_deref().unwrap_or(""),
207 p.model.as_deref().unwrap_or("")
208 ));
209 }
210 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
212 for b in text.bytes() {
213 h ^= u64::from(b);
214 h = h.wrapping_mul(0x0100_0000_01b3);
215 }
216 format!("{h:016x}")[..12].to_string()
217 }
218}
219
220#[derive(Debug, Default)]
222pub struct Outcome {
223 pub plan: Plan,
226 pub waiting: Vec<(String, usize)>,
229 pub refused: Vec<String>,
231 pub ran: bool,
233 pub landed: Vec<String>,
235}
236
237pub fn root_units(
238 store: &Store,
239 doc: &Document,
240 workflow: &str,
241 targets: &[String],
242) -> Result<Vec<WorkflowUnit>> {
243 use crate::dispatch::Target;
244 let w = doc
245 .workflow(workflow)
246 .ok_or_else(|| format!("unknown workflow `{workflow}`"))?;
247 let projects = store.projects()?;
248 let tasks = store.tasks()?;
249 let mut data: Vec<(String, Option<String>, Value)> = Vec::new();
250 for spec in targets {
251 let (name, what) = crate::dispatch::target(spec)?;
252 let row = projects
253 .iter()
254 .find(|p| p.name == name)
255 .ok_or_else(|| format!("no project `{name}`; `pma scan` first"))?;
256 match &what {
257 Target::Project => {
258 data.push(("project".into(), Some(row.name.clone()), project_data(row)))
259 }
260 Target::Line(line) => {
261 let t = tasks
262 .iter()
263 .find(|t| t.project == name && t.line == *line)
264 .ok_or_else(|| format!("{spec} is not an open item at the last scan"))?;
265 data.push(("item".into(), Some(row.name.clone()), item_data(t)));
266 }
267 Target::Priority(want) => {
268 let found: Vec<&crate::store::TaskRow> = tasks
269 .iter()
270 .filter(|t| t.project == name && t.priority == *want)
271 .collect();
272 if found.is_empty() {
273 return Err(
274 format!("{name}: no open {} items at the last scan", want.name()).into(),
275 );
276 }
277 for t in found {
278 data.push(("item".into(), Some(row.name.clone()), item_data(t)));
279 }
280 }
281 Target::Signal(kind) => {
282 let detail = match kind.as_str() {
283 "ci" => match &row.ci {
284 crate::scan::Ci::Failing(workflows) => workflows.join(" "),
285 _ => {
286 return Err(
287 format!("{name}: CI was not failing at the last scan").into()
288 );
289 }
290 },
291 _ => match row.deps.unwrap_or_default() {
292 0 => {
293 return Err(format!(
294 "{name}: no outdated dependencies at the last `pma scan --deps`"
295 )
296 .into());
297 }
298 _ => row.deps_detail.clone(),
299 },
300 };
301 data.push((
302 "signal".into(),
303 Some(row.name.clone()),
304 serde_json::json!({"kind": kind, "detail": detail}),
305 ));
306 }
307 Target::Quadrant(_) => {
310 return Err(format!(
311 "`{spec}` holds items and signals together, so it is not an argument; \
312 name a heading, a line, or the project"
313 )
314 .into());
315 }
316 }
317 }
318 if data.is_empty() {
319 return Err("the target names nothing".into());
320 }
321 if let Some((ty, _, _)) = data.iter().find(|(ty, _, _)| *ty != w.input) {
322 return Err(format!(
323 "`{workflow}` reads `{}`, and the target yields `{ty}`",
324 w.input
325 )
326 .into());
327 }
328 Ok(data
329 .into_iter()
330 .enumerate()
331 .map(|(i, (ty, project, data))| {
332 let id = format!("u{}", i + 1);
333 WorkflowUnit {
334 id: id.clone(),
335 ty,
336 node: "@input".into(),
337 parent: None,
338 root: id,
339 depth: 0,
340 lap: 0,
341 project,
342 data: data.to_string(),
343 }
344 })
345 .collect())
346}
347
348fn project_data(row: &crate::store::ProjectRow) -> Value {
349 serde_json::json!({
350 "name": row.name,
351 "tier": row.tier.map(i64::from).unwrap_or_default().to_string(),
352 "repo": row.path.to_string_lossy(),
353 "owner": row.slug.clone().unwrap_or_default(),
354 "default_branch": "",
355 "ci": "",
356 "deps": row.deps.unwrap_or_default().to_string(),
357 "tags": "",
358 })
359}
360
361fn item_data(t: &crate::store::TaskRow) -> Value {
362 serde_json::json!({
363 "key": t.key,
364 "text": t.text,
365 "priority": t.priority.name(),
366 "tags": t.tags.join(" "),
367 "due": t.due.clone().unwrap_or_default(),
368 "gh": t.gh.map(|n| n.to_string()).unwrap_or_default(),
369 "group": t.group.clone().unwrap_or_default(),
370 "description": "",
371 "done": "false",
372 })
373}
374
375pub fn entry_moves(w: &Workflow, unit: &WorkflowUnit) -> Vec<(usize, bool, Option<String>)> {
378 let data: Value = serde_json::from_str(&unit.data).unwrap_or(Value::Null);
379 let system = system_fields(unit, &Known::default());
380 w.edges
381 .iter()
382 .enumerate()
383 .filter(|(_, e)| e.from == From::Input)
384 .map(|(i, e)| match &e.when {
385 None => (i, true, None),
386 Some(guard) => match crate::workflow::holds(guard, &data, &system) {
387 Ok(()) => (i, true, None),
388 Err(why) => (i, false, Some(why)),
389 },
390 })
391 .collect()
392}
393
394pub fn enter(store: &Store, instance: i64, w: &Workflow, unit: &WorkflowUnit) -> Result<()> {
396 for (edge, taken, why) in entry_moves(w, unit) {
397 store.add_workflow_move(instance, &unit.id, edge, taken, why.as_deref())?;
398 }
399 Ok(())
400}
401
402fn frontier(
407 w: &Workflow,
408 units: &[WorkflowUnit],
409 moves: &[(String, usize, bool)],
410 deferred: &BTreeSet<(String, String)>,
411) -> Vec<(String, Vec<WorkflowUnit>)> {
412 let waiting = waiting(w, units, moves);
413 let mut finished: BTreeSet<String> = BTreeSet::new();
414 let mut ready = Vec::new();
415 for name in crate::workflow::topological(w) {
416 if !w.predecessors(&name).iter().all(|p| finished.contains(*p)) {
417 continue;
418 }
419 let held = waiting.get(&name).cloned().unwrap_or_default();
420 if held.is_empty() {
421 finished.insert(name);
422 continue;
423 }
424 let here: Vec<WorkflowUnit> = held
425 .into_iter()
426 .filter(|u| !deferred.contains(&(name.clone(), u.id.clone())))
427 .collect();
428 if !here.is_empty() {
429 ready.push((name, here));
430 }
431 }
432 ready
433}
434
435fn holding(
438 w: &Workflow,
439 units: &[WorkflowUnit],
440 moves: &[(String, usize, bool)],
441) -> Vec<(String, usize)> {
442 let waiting = waiting(w, units, moves);
443 crate::workflow::topological(w)
444 .into_iter()
445 .filter_map(|n| waiting.get(&n).map(|u| (n.clone(), u.len())))
446 .filter(|(_, n)| *n > 0)
447 .collect()
448}
449
450fn batches(node: &Node, units: &[WorkflowUnit]) -> Vec<Vec<WorkflowUnit>> {
453 match &node.op {
454 Op::Reduce(r) => {
455 let mut groups: Vec<(String, Vec<WorkflowUnit>)> = Vec::new();
456 for unit in units {
457 let key = group_key(r, unit);
458 match groups.iter_mut().find(|(k, _)| *k == key) {
459 Some((_, bag)) => bag.push(unit.clone()),
460 None => groups.push((key, vec![unit.clone()])),
461 }
462 }
463 groups.into_iter().map(|(_, bag)| bag).collect()
464 }
465 _ => units.iter().map(|u| vec![u.clone()]).collect(),
466 }
467}
468
469fn shown_worker(
472 store: &Store,
473 cfg: &Config,
474 over: &Overrides,
475 policy: &Option<Active>,
476 node: &Node,
477 unit: &WorkflowUnit,
478) -> Result<(String, Option<String>)> {
479 if matches!(node.op, Op::Edit(_)) && policy.as_ref().is_some_and(|p| !p.shadow) {
480 let chosen = crate::dispatch::choose_worker(store, cfg, over, None)?;
481 let flagged = over.agent.is_some() || over.model.is_some() || over.preset.is_some();
482 return Ok(match flagged {
483 true => (chosen.agent, chosen.model),
484 false => ("by route".into(), None),
485 });
486 }
487 let tier = cfg.tier_or_default(tier_of(store, unit)?);
488 Ok(match resolve(store, cfg, over, policy, node, unit, tier)? {
489 Ok(r) => (r.chosen.agent, r.chosen.model),
490 Err(_) => ("no route".into(), None),
491 })
492}
493
494fn tier_of(store: &Store, unit: &WorkflowUnit) -> Result<Option<u8>> {
495 Ok(match &unit.project {
496 Some(p) => store.project(p)?.and_then(|r| r.tier),
497 None => None,
498 })
499}
500
501struct Resolved {
506 chosen: Chosen,
507 route: Option<(i64, Option<String>, Option<Approval>)>,
510}
511
512fn resolve(
513 store: &Store,
514 cfg: &Config,
515 over: &Overrides,
516 policy: &Option<Active>,
517 node: &Node,
518 unit: &WorkflowUnit,
519 tier: Option<u8>,
520) -> Result<std::result::Result<Resolved, String>> {
521 let mut applied = None;
522 let mut route = None;
523 if let Some(active) = policy
524 && active.shadow
525 {
526 let r = active.policy.route_read(&node.name, unit.lap, tier);
527 route = Some((active.revision, r.map(|r| r.name.clone()), None));
528 } else if let Some(active) = policy {
529 let Some(r) = active.policy.route_read(&node.name, unit.lap, tier) else {
530 return Ok(Err(format!(
531 "node `{}` matches no route in policy revision {}; add a route naming it",
532 node.name, active.revision
533 )));
534 };
535 route = Some((active.revision, Some(r.name.clone()), Some(r.approval)));
536 applied = Some((r.agent.clone(), r.model.clone()));
537 }
538 let chosen = crate::dispatch::choose_worker(store, cfg, over, applied)?;
539 Ok(Ok(Resolved { chosen, route }))
540}
541
542fn plan_of(
545 store: &Store,
546 cfg: &Config,
547 over: &Overrides,
548 policy: &Option<Active>,
549 w: &Workflow,
550 args: &Value,
551 ready: &[(String, Vec<WorkflowUnit>)],
552) -> Result<Plan> {
553 let mut room = match cfg.agent_budget > 0.0 {
554 true => (cfg.batch_budget / cfg.agent_budget).floor().max(0.0) as usize,
555 false => usize::MAX,
556 };
557 let mut steps = Vec::new();
558 let mut basis = format!(
559 "{}\u{0}{args}\u{0}{:?}\u{0}{}\n",
560 w.name,
561 policy.as_ref().map(|p| (p.revision, p.shadow)),
562 cfg.agent_budget
563 );
564 for (name, units) in ready {
565 let Some(node) = w.node(name) else { continue };
566 if free(node) {
567 continue;
568 }
569 if room == 0 {
570 if steps.is_empty() {
571 return Err(format!(
572 "batch_budget of ${:.2} admits no run at agent_budget of ${:.2}",
573 cfg.batch_budget, cfg.agent_budget
574 )
575 .into());
576 }
577 break;
578 }
579 let all = batches(node, units);
580 let take = all.len().min(room);
581 room -= take;
582 let taken: Vec<WorkflowUnit> = all[..take].concat();
583 basis.push_str(&format!("{node:?}\n"));
584 let mut shown: Option<(String, Option<String>)> = None;
585 for u in &taken {
586 let (agent, model) = shown_worker(store, cfg, over, policy, node, u)?;
587 basis.push_str(&format!(
588 "{}\u{0}{}\u{0}{}\u{0}{}\u{0}{agent}\u{0}{}\n",
589 u.id,
590 u.ty,
591 u.project.as_deref().unwrap_or(""),
592 u.data,
593 model.as_deref().unwrap_or("")
594 ));
595 shown = match shown {
597 None => Some((agent, model)),
598 Some(s) if s == (agent.clone(), model.clone()) => Some(s),
599 Some(_) => Some(("several".into(), None)),
600 };
601 }
602 let (agent, model) = shown.expect("a batch holds a unit");
603 steps.push(Planned {
604 node: name.clone(),
605 op: node.op.name().to_string(),
606 units: taken.iter().map(|u| u.id.clone()).collect(),
607 agent: Some(agent),
608 model,
609 cost: take as f64 * cfg.agent_budget,
610 });
611 }
612 Ok(Plan { steps, basis })
613}
614
615pub fn plan_over(
619 store: &Store,
620 cfg: &Config,
621 over: &Overrides,
622 w: &Workflow,
623 args: &Value,
624 units: &[WorkflowUnit],
625 moves: &[(String, usize, bool)],
626) -> Result<(Vec<Planned>, Plan)> {
627 let policy = active_policy(store)?;
628 let ready = frontier(w, units, moves, &BTreeSet::new());
629 let rules = ready
630 .iter()
631 .filter_map(|(name, units)| {
632 let node = w.node(name)?;
633 free(node).then(|| Planned {
634 node: name.clone(),
635 op: node.op.name().to_string(),
636 units: units.iter().map(|u| u.id.clone()).collect(),
637 agent: None,
638 model: None,
639 cost: 0.0,
640 })
641 })
642 .collect();
643 let plan = plan_of(store, cfg, over, &policy, w, args, &ready)?;
644 Ok((rules, plan))
645}
646
647pub fn plan(
649 store: &Store,
650 cfg: &Config,
651 over: &Overrides,
652 w: &Workflow,
653 args: &Value,
654 instance: i64,
655) -> Result<(Vec<Planned>, Plan)> {
656 let units = store.workflow_units(instance)?;
657 let moves = store.workflow_moves(instance)?;
658 plan_over(store, cfg, over, w, args, &units, &moves)
659}
660
661pub fn describe(steps: &[Planned], lead: &str) -> String {
663 if steps.is_empty() {
664 return "nothing is runnable\n".to_string();
665 }
666 let rows: Vec<Vec<String>> = steps
667 .iter()
668 .map(|p| {
669 vec![
670 format!("{lead}{}", p.node),
671 p.op.clone(),
672 format!("{} unit(s)", p.units.len()),
673 match (&p.agent, &p.model) {
674 (None, _) => "a rule, free".to_string(),
675 (Some(a), None) => a.clone(),
676 (Some(a), Some(m)) => format!("{a}/{m}"),
677 },
678 match p.free() {
679 true => "$0.00".to_string(),
680 false => format!("<= ${:.2}", p.cost),
681 },
682 ]
683 })
684 .collect();
685 crate::report::table(&rows, "")
686}
687
688fn free(node: &Node) -> bool {
690 match &node.op {
691 Op::Check { .. } | Op::Emit(_) => true,
692 Op::Map(m) => m.via == Via::Rule,
693 Op::Reduce(r) => r.via == Via::Rule,
694 Op::Edit(_) | Op::Call(_) => false,
695 }
696}
697
698fn waiting(
702 w: &Workflow,
703 units: &[WorkflowUnit],
704 moves: &[(String, usize, bool)],
705) -> BTreeMap<String, Vec<WorkflowUnit>> {
706 let mut out: BTreeMap<String, Vec<WorkflowUnit>> = BTreeMap::new();
707 for (unit, edge, taken) in moves {
708 if !taken {
709 continue;
710 }
711 let Some(e) = w.edges.get(*edge) else {
712 continue;
713 };
714 let To::Node(target) = &e.to else { continue };
715 let Some(u) = units.iter().find(|u| u.id == *unit) else {
716 continue;
717 };
718 if handled(w, target, &u.id, moves) {
719 continue;
720 }
721 let bag = out.entry(target.clone()).or_default();
722 if !bag.iter().any(|other| other.id == u.id) {
723 bag.push(u.clone());
724 }
725 }
726 out
727}
728
729fn route_unit(
733 store: &Store,
734 instance: i64,
735 w: &Workflow,
736 from: &str,
737 unit: &WorkflowUnit,
738 known: &Known,
739) -> Result<()> {
740 let data: Value = serde_json::from_str(&unit.data).unwrap_or(Value::Null);
741 let system = system_fields(unit, known);
742 let mut matched = false;
743 let mut defaults = Vec::new();
744 for (i, e) in w.edges.iter().enumerate() {
745 if e.from != From::Node(from.to_string()) {
746 continue;
747 }
748 if e.default {
749 defaults.push(i);
750 continue;
751 }
752 match &e.when {
753 None => {
754 store.add_workflow_move(instance, &unit.id, i, true, None)?;
755 matched = true;
756 }
757 Some(guard) => match crate::workflow::holds(guard, &data, &system) {
758 Ok(()) => {
759 store.add_workflow_move(instance, &unit.id, i, true, None)?;
760 matched = true;
761 }
762 Err(why) => store.add_workflow_move(instance, &unit.id, i, false, Some(&why))?,
763 },
764 }
765 }
766 if !matched {
767 for i in defaults {
768 store.add_workflow_move(instance, &unit.id, i, true, Some("no guard accepted it"))?;
769 matched = true;
770 }
771 }
772 if !matched {
773 store.add_workflow_move(
775 instance,
776 &unit.id,
777 settled_at(w, from),
778 false,
779 Some("settled"),
780 )?;
781 }
782 Ok(())
783}
784
785fn route_failed(ctx: &Ctx<'_>, node: &Node, unit: &WorkflowUnit, why: &str) -> Result<()> {
790 let Ctx {
791 store, w, instance, ..
792 } = *ctx;
793 store.add_workflow_verdict(instance, &unit.id, &node.name, "failed", Some(why))?;
794 ctx.refused
795 .borrow_mut()
796 .push(format!("{}: {}: {why}", node.name, unit.id));
797 let carries = w.output_of(node) == unit.ty;
798 let mut taken = false;
799 for (i, e) in w.edges.iter().enumerate() {
800 if e.from != From::Node(node.name.clone()) {
801 continue;
802 }
803 if e.default && carries {
804 store.add_workflow_move(instance, &unit.id, i, true, Some("the node failed"))?;
805 taken = true;
806 } else {
807 let reason = format!("the node failed: {why}");
808 store.add_workflow_move(instance, &unit.id, i, false, Some(&reason))?;
809 }
810 }
811 if !taken {
812 let reason = format!("failed: {why}");
813 store.add_workflow_move(
814 instance,
815 &unit.id,
816 settled_at(w, &node.name),
817 false,
818 Some(&reason),
819 )?;
820 }
821 Ok(())
822}
823
824fn system_fields(unit: &WorkflowUnit, known: &Known) -> BTreeMap<String, Value> {
827 let mut out = BTreeMap::new();
828 out.insert("id".into(), Value::from(unit.id.clone()));
829 out.insert("type".into(), Value::from(unit.ty.clone()));
830 out.insert("node".into(), Value::from(unit.node.clone()));
831 out.insert("root".into(), Value::from(unit.root.clone()));
832 out.insert("depth".into(), Value::from(unit.depth));
833 out.insert("lap".into(), Value::from(unit.lap));
834 if let Some(parent) = &unit.parent {
835 out.insert("parent".into(), Value::from(parent.clone()));
836 }
837 if let Some(project) = &unit.project {
838 out.insert("project".into(), Value::from(project.clone()));
839 }
840 if let Some(run) = known.runs.get(&unit.id) {
841 out.insert("run".into(), Value::from(run.to_string()));
842 }
843 for ((u, check), verdict) in &known.verdicts {
844 if *u == unit.id {
845 out.insert(check.clone(), Value::from(verdict.clone()));
846 }
847 }
848 out
849}
850
851fn settled_at(w: &Workflow, node: &str) -> usize {
857 let index = w.nodes.iter().position(|n| n.name == node).unwrap_or(0);
858 w.edges.len() + index
859}
860
861fn handled(w: &Workflow, node: &str, unit: &str, moves: &[(String, usize, bool)]) -> bool {
864 let settled = settled_at(w, node);
865 moves.iter().any(|(u, edge, _)| {
866 u == unit
867 && (*edge == settled
868 || w.edges
869 .get(*edge)
870 .is_some_and(|e| e.from == From::Node(node.to_string())))
871 })
872}
873
874fn next_id(units: &[WorkflowUnit], node: &str, n: usize) -> String {
875 format!("{node}-{}", units.len() + n + 1)
876}
877
878fn mint(
884 ctx: &Ctx<'_>,
885 node: &Node,
886 unit: WorkflowUnit,
887 held: &mut i64,
888 known: &Known,
889) -> Result<()> {
890 if *held >= ctx.w.caps.max_units {
891 return Err(capped(
892 &node.name,
893 format!(
894 "instance {} holds {held} units, its `caps.max_units`",
895 ctx.instance
896 ),
897 ));
898 }
899 *held += 1;
900 ctx.store.add_workflow_unit(ctx.instance, &unit)?;
901 route_unit(ctx.store, ctx.instance, ctx.w, &node.name, &unit, known)
902}
903
904fn capped(node: &str, why: String) -> Box<dyn std::error::Error> {
906 Box::new(Capped(format!(
907 "node `{node}`: {why}. Nothing further was written; resume with a higher \
908 cap, `--cap max_units=<n>` or `--cap max_edits=<n>`"
909 )))
910}
911
912fn width(w: &Workflow, node: &Node) -> Option<i64> {
915 match &node.op {
916 Op::Map(m) if m.out == Out::Grows => {
917 Some(m.max_units.as_ref().map_or(0, |c| c.ceiling(&w.params)))
918 }
919 _ => None,
920 }
921}
922
923pub fn advance(
928 store: &Store,
929 cfg: &Config,
930 over: &Overrides,
931 doc: &Document,
932 w: &Workflow,
933 instance: i64,
934 given: &Invocation<'_>,
935) -> Result<Outcome> {
936 let result = advance_inner(store, cfg, over, doc, w, instance, given);
937 if let Err(e) = &result
938 && e.downcast_ref::<Capped>().is_some()
939 {
940 store.note_instance_outcome(instance, "capped")?;
941 }
942 result
943}
944
945fn advance_inner(
946 store: &Store,
947 cfg: &Config,
948 over: &Overrides,
949 doc: &Document,
950 w: &Workflow,
951 instance: i64,
952 given: &Invocation<'_>,
953) -> Result<Outcome> {
954 let ctx = Ctx {
955 store,
956 cfg,
957 doc,
958 w,
959 instance,
960 home: given.home,
961 over,
962 params: w.arguments(given.args),
963 policy: active_policy(store)?,
964 refused: RefCell::new(Vec::new()),
965 deferred: RefCell::new(BTreeSet::new()),
966 bases: RefCell::new(BTreeMap::new()),
967 local: given.args.get("@local").and_then(Value::as_bool) == Some(true),
968 landed: RefCell::new(Vec::new()),
969 };
970 let mut approve = given.approve.map(String::from);
971 let mut ran = false;
972 let ceiling = (w.caps.max_units.max(1) as usize + 1) * (w.nodes.len() + 1);
975 for _ in 0..ceiling {
976 let units = store.workflow_units(instance)?;
977 let moves = store.workflow_moves(instance)?;
978 let known = Known::load(store, instance)?;
979 let ready = frontier(w, &units, &moves, &ctx.deferred.borrow());
980
981 if let Some((name, waiting)) = ready.iter().find(|(n, _)| w.node(n).is_some_and(free)) {
984 let node = w.node(name).expect("found above");
985 run_free(&ctx, node, waiting, &known)?;
986 continue;
987 }
988 let plan = plan_of(store, cfg, over, &ctx.policy, w, given.args, &ready)?;
989 let id = plan.id();
990 let unattended = (given.unattended && !plan.is_empty()).then(|| {
991 println!(
992 "{}plan {id}: approved unattended",
993 describe(&plan.steps, "next: ")
994 );
995 id.clone()
996 });
997 match approve.take().or(unattended) {
998 Some(given) if !plan.is_empty() && given == id => {
999 for step in &plan.steps {
1000 let node = w.node(&step.node).expect("planned from the graph");
1001 let waiting = &ready
1002 .iter()
1003 .find(|(n, _)| *n == step.node)
1004 .expect("planned from the frontier")
1005 .1;
1006 let units: Vec<WorkflowUnit> = waiting
1007 .iter()
1008 .filter(|u| step.units.contains(&u.id))
1009 .cloned()
1010 .collect();
1011 match node.op {
1012 Op::Edit(_) => run_edit(&ctx, node, &units, &known)?,
1013 _ => run_agent(&ctx, node, &units, &known)?,
1014 }
1015 }
1016 ran = true;
1017 continue;
1018 }
1019 Some(given) if plan.is_empty() => {
1020 return Err(format!("plan {given} is not ready: no agent node is").into());
1021 }
1022 Some(given) => {
1023 return Err(format!(
1024 "plan {given} is not the plan for this pass, which is {id}:\n{}\
1025 Nothing was spent.",
1026 describe(&plan.steps, "")
1027 )
1028 .into());
1029 }
1030 None => {
1031 return Ok(Outcome {
1032 plan,
1033 waiting: holding(w, &units, &moves),
1034 refused: ctx.refused.take(),
1035 ran,
1036 landed: ctx.landed.take(),
1037 });
1038 }
1039 }
1040 }
1041 Err(format!(
1042 "workflow `{}` ran {ceiling} nodes without settling, which is a bug in the pass",
1043 w.name
1044 )
1045 .into())
1046}
1047
1048fn run_free(ctx: &Ctx<'_>, node: &Node, waiting: &[WorkflowUnit], known: &Known) -> Result<()> {
1052 let Ctx {
1053 store,
1054 doc,
1055 w,
1056 instance,
1057 ..
1058 } = *ctx;
1059 let mut known = known.clone();
1060 match &node.op {
1061 Op::Map(m) => {
1062 let rule = m.rule.as_deref().unwrap_or_default();
1063 let all = store.workflow_units(instance)?;
1064 let mut held = all.len() as i64;
1065 let mut minted = 0;
1066 let mut seen = unique_seen(ctx, node, &m.emits)?;
1067 for unit in waiting {
1068 let produced = match rule_map(store, doc, rule, &m.emits, m.out, unit) {
1069 Ok(p) => p,
1070 Err(why) => {
1071 store.atomically(|| route_failed(ctx, node, unit, &why.to_string()))?;
1072 continue;
1073 }
1074 };
1075 if let Some(width) = width(w, node)
1076 && produced.len() as i64 > width
1077 {
1078 return Err(capped(
1079 &node.name,
1080 format!(
1081 "`{}` yielded {} units from `{}`, over its `max_units` of {width}",
1082 rule,
1083 produced.len(),
1084 unit.id
1085 ),
1086 ));
1087 }
1088 store.atomically(|| {
1089 for data in produced {
1090 if let Some(why) = duplicate(ctx, &m.emits, &data, unit, &mut seen)? {
1091 ctx.refused
1092 .borrow_mut()
1093 .push(format!("{}: {}: dropped: {why}", node.name, unit.id));
1094 continue;
1095 }
1096 let id = next_id(&all, &node.name, minted);
1097 minted += 1;
1098 mint(
1099 ctx,
1100 node,
1101 WorkflowUnit {
1102 id,
1103 ty: m.emits.clone(),
1104 node: node.name.clone(),
1105 parent: Some(unit.id.clone()),
1106 root: unit.root.clone(),
1107 depth: unit.depth + 1,
1108 lap: unit.lap,
1109 project: unit.project.clone(),
1110 data: data.to_string(),
1111 },
1112 &mut held,
1113 &known,
1114 )?;
1115 }
1116 store.add_workflow_move(
1118 instance,
1119 &unit.id,
1120 settled_at(w, &node.name),
1121 false,
1122 Some("expanded"),
1123 )
1124 })?;
1125 }
1126 }
1127 Op::Reduce(r) => {
1128 let rule = r.rule.as_deref().unwrap_or("dedupe");
1129 let all = store.workflow_units(instance)?;
1130 let mut held = all.len() as i64;
1131 let mut minted = 0;
1132 for bag in batches(node, waiting) {
1135 let bag: Vec<&WorkflowUnit> = bag.iter().collect();
1136 let (kept, dropped) = rule_reduce(rule, &bag)?;
1137 for unit in dropped {
1138 store.add_workflow_move(
1139 instance,
1140 &unit.id,
1141 settled_at(w, &node.name),
1142 false,
1143 Some(match rule {
1144 "dedupe" => "dropped as a duplicate",
1145 _ => "dropped by the group's limit",
1146 }),
1147 )?;
1148 }
1149 for unit in kept {
1150 let id = next_id(&all, &node.name, minted);
1151 minted += 1;
1152 store.atomically(|| {
1153 mint(
1154 ctx,
1155 node,
1156 WorkflowUnit {
1157 id,
1158 ty: r.emits.clone(),
1159 node: node.name.clone(),
1160 parent: Some(unit.id.clone()),
1161 root: unit.root.clone(),
1162 depth: unit.depth,
1163 lap: unit.lap,
1164 project: unit.project.clone(),
1165 data: unit.data.clone(),
1166 },
1167 &mut held,
1168 &known,
1169 )?;
1170 store.add_workflow_move(
1174 instance,
1175 &unit.id,
1176 settled_at(w, &node.name),
1177 false,
1178 Some("reduced"),
1179 )
1180 })?;
1181 }
1182 }
1183 }
1184 Op::Check { rule } => {
1185 for unit in waiting {
1186 let (verdict, detail) = match run_check(ctx, rule, unit)? {
1187 Checked::NotReady(_) => {
1188 ctx.deferred
1189 .borrow_mut()
1190 .insert((node.name.clone(), unit.id.clone()));
1191 continue;
1192 }
1193 Checked::Verdict(v, d) => (v, d),
1194 };
1195 known
1196 .verdicts
1197 .insert((unit.id.clone(), rule.clone()), verdict.clone());
1198 store.atomically(|| {
1199 store.add_workflow_verdict(
1200 instance,
1201 &unit.id,
1202 rule,
1203 &verdict,
1204 detail.as_deref(),
1205 )?;
1206 route_unit(store, instance, w, &node.name, unit, &known)?;
1207 mark_handled(store, instance, w, &node.name, &unit.id)
1208 })?;
1209 }
1210 }
1211 Op::Emit(e) => {
1212 for unit in waiting {
1213 let data: Value = serde_json::from_str(&unit.data).unwrap_or(Value::Null);
1214 store.atomically(|| match emit(ctx, e, unit, &data, &known)? {
1218 Ok(()) => {
1219 route_unit(store, instance, w, &node.name, unit, &known)?;
1220 mark_handled(store, instance, w, &node.name, &unit.id)
1221 }
1222 Err(why) => route_failed(ctx, node, unit, &why),
1223 })?;
1224 }
1225 }
1226 Op::Edit(_) | Op::Call(_) => {
1227 return Err(format!("node `{}` is not decided by a rule", node.name).into());
1228 }
1229 }
1230 Ok(())
1231}
1232
1233fn mark_handled(store: &Store, instance: i64, w: &Workflow, node: &str, unit: &str) -> Result<()> {
1236 store.add_workflow_move(instance, unit, settled_at(w, node), false, Some("handled"))
1237}
1238
1239fn rule_map(
1242 store: &Store,
1243 doc: &Document,
1244 rule: &str,
1245 emits: &str,
1246 out: Out,
1247 unit: &WorkflowUnit,
1248) -> Result<Vec<Value>> {
1249 let data: Value = serde_json::from_str(&unit.data).unwrap_or(Value::Null);
1250 let project = unit.project.clone().unwrap_or_default();
1251 if let Some(ty) = rule.strip_prefix("as:") {
1252 let target = doc
1254 .resolve(ty)
1255 .ok_or_else(|| format!("rule `{rule}`: unknown type `{ty}`"))?;
1256 let mut kept = serde_json::Map::new();
1257 for field in target.fields.keys() {
1258 if let Some(v) = data.get(field) {
1259 kept.insert(field.clone(), v.clone());
1260 }
1261 }
1262 return Ok(vec![Value::Object(kept)]);
1263 }
1264 if let Some(condition) = rule.strip_prefix("where:") {
1265 let (field, want) = condition
1266 .split_once('=')
1267 .ok_or_else(|| format!("rule `{rule}`: expected field=value"))?;
1268 let holds = data.get(field).and_then(Value::as_str) == Some(want);
1269 return Ok(if holds { vec![data] } else { Vec::new() });
1270 }
1271 if let Some(glob) = rule.strip_prefix("path-present:") {
1272 return Ok(path_holds(store, &project, glob, true)?
1273 .then_some(data)
1274 .into_iter()
1275 .collect());
1276 }
1277 if let Some(glob) = rule.strip_prefix("path-absent:") {
1278 return Ok(path_holds(store, &project, glob, false)?
1279 .then_some(data)
1280 .into_iter()
1281 .collect());
1282 }
1283 if out != Out::Grows {
1284 return Err(format!("rule `{rule}` produces many units; it needs `0..n`").into());
1285 }
1286 let row = || {
1287 store
1288 .project(&project)?
1289 .ok_or_else(|| format!("no project `{project}`; `pma scan` first").into())
1290 as Result<crate::store::ProjectRow>
1291 };
1292 match rule {
1293 "todo-items" => Ok(store
1294 .tasks()?
1295 .iter()
1296 .filter(|t| t.project == project)
1297 .map(item_data)
1298 .collect()),
1299 "open-runs" => Ok(store
1302 .runs()?
1303 .iter()
1304 .filter(|r| r.project == project && !r.state.is_final())
1305 .map(|r| {
1306 serde_json::json!({
1307 "id": r.id.to_string(),
1308 "task": r.text,
1309 "state": r.state.name(),
1310 "branch": r.branch,
1311 "pr": r.outcome.clone().unwrap_or_default(),
1312 "verify": r.verify_ok.map(|v| v.to_string()).unwrap_or_default(),
1313 "cost": r.cost_usd.map(|c| format!("{c:.2}")).unwrap_or_default(),
1314 })
1315 })
1316 .collect()),
1317 "outdated-deps" => {
1318 let row = row()?;
1319 Ok(row
1320 .deps_detail
1321 .lines()
1322 .filter(|l| !l.trim().is_empty())
1323 .map(|l| serde_json::json!({"kind": "deps", "detail": l}))
1324 .collect())
1325 }
1326 "open-issues" => {
1329 let row = row()?;
1330 let mut cmd = std::process::Command::new("gh");
1331 cmd.args(["issue", "list", "--state", "open", "--limit", "100"])
1332 .args(["--json", "number,title,body,labels"])
1333 .current_dir(&row.path);
1334 let listed = crate::scan::call(cmd, crate::scan::CALL_TIMEOUT)
1335 .map_err(|e| format!("gh issue list: {e}"))?;
1336 let listed: Value =
1337 serde_json::from_str(&listed).map_err(|e| format!("gh issue list: {e}"))?;
1338 let ty = doc
1339 .resolve(emits)
1340 .ok_or_else(|| format!("unknown type `{emits}`"))?;
1341 listed
1342 .as_array()
1343 .cloned()
1344 .unwrap_or_default()
1345 .into_iter()
1346 .map(|v| {
1347 let issue = serde_json::json!({
1348 "gh": v.get("number").map(|n| n.to_string()).unwrap_or_default(),
1349 "text": v.get("title").and_then(Value::as_str).unwrap_or_default(),
1350 "description": v.get("body").and_then(Value::as_str).unwrap_or_default(),
1351 "tags": v
1352 .get("labels")
1353 .and_then(Value::as_array)
1354 .map(|l| {
1355 l.iter()
1356 .filter_map(|x| x.get("name").and_then(Value::as_str))
1357 .collect::<Vec<_>>()
1358 .join(" ")
1359 })
1360 .unwrap_or_default(),
1361 });
1362 let projected: serde_json::Map<String, Value> = ty
1365 .fields
1366 .keys()
1367 .filter_map(|f| Some((f.clone(), issue.get(f)?.clone())))
1368 .filter(|(_, v)| v.as_str() != Some(""))
1369 .collect();
1370 let projected = Value::Object(projected);
1371 if !crate::workflow::BUILTIN_TYPES.contains(&emits) {
1372 ty.check(&projected)
1373 .map_err(|e| format!("issue {}: {e}", issue["gh"]))?;
1374 }
1375 Ok(projected)
1376 })
1377 .collect()
1378 }
1379 other => Err(format!("rule `{other}` is not built yet").into()),
1380 }
1381}
1382
1383fn unique_seen(ctx: &Ctx<'_>, node: &Node, emits: &str) -> Result<BTreeSet<String>> {
1386 let Some(ty) = ctx.doc.resolve(emits) else {
1387 return Ok(BTreeSet::new());
1388 };
1389 let mut seen = BTreeSet::new();
1390 for u in ctx.store.workflow_units(ctx.instance)? {
1391 if u.node != node.name {
1392 continue;
1393 }
1394 let data: Value = serde_json::from_str(&u.data).unwrap_or(Value::Null);
1395 for key in unique_keys(&ty, &data) {
1396 seen.insert(key);
1397 }
1398 }
1399 Ok(seen)
1400}
1401
1402fn unique_keys(ty: &Type, data: &Value) -> Vec<String> {
1403 ty.fields
1404 .iter()
1405 .filter(|(_, f)| matches!(f.ty, FieldType::Line { unique: true, .. }))
1406 .filter_map(|(name, _)| {
1407 let v = data.get(name)?.as_str()?;
1408 Some(format!("{name}\u{0}{}", todo::normal_text(v)))
1409 })
1410 .collect()
1411}
1412
1413fn duplicate(
1416 ctx: &Ctx<'_>,
1417 emits: &str,
1418 data: &Value,
1419 source: &WorkflowUnit,
1420 seen: &mut BTreeSet<String>,
1421) -> Result<Option<String>> {
1422 let Some(ty) = ctx.doc.resolve(emits) else {
1423 return Ok(None);
1424 };
1425 let keys = unique_keys(&ty, data);
1426 if keys.is_empty() {
1427 return Ok(None);
1428 }
1429 let open: Vec<String> = match &source.project {
1430 Some(p) => ctx
1431 .store
1432 .tasks()?
1433 .into_iter()
1434 .filter(|t| &t.project == p)
1435 .map(|t| todo::normal_text(&t.text))
1436 .collect(),
1437 None => Vec::new(),
1438 };
1439 for key in &keys {
1440 let (field, value) = key.split_once('\u{0}').expect("built above");
1441 if open.iter().any(|t| t == value) {
1442 return Ok(Some(format!("`{field}` repeats an open item: {value}")));
1443 }
1444 if seen.contains(key) {
1445 return Ok(Some(format!(
1446 "`{field}` repeats a unit already in the bag: {value}"
1447 )));
1448 }
1449 }
1450 seen.extend(keys);
1451 Ok(None)
1452}
1453
1454enum Checked {
1457 Verdict(String, Option<String>),
1458 NotReady(String),
1459}
1460
1461fn verdict(v: &str, detail: Option<String>) -> Checked {
1462 Checked::Verdict(v.to_string(), detail)
1463}
1464
1465fn run_check(ctx: &Ctx<'_>, rule: &str, unit: &WorkflowUnit) -> Result<Checked> {
1468 let store = ctx.store;
1469 match rule {
1470 "lint-todo" => {
1471 let Some(project) = &unit.project else {
1472 return Ok(verdict("unknown", Some("the unit names no project".into())));
1473 };
1474 let Some(row) = store.project(project)? else {
1475 return Ok(verdict("unknown", Some(format!("no project `{project}`"))));
1476 };
1477 let Ok(text) = std::fs::read_to_string(row.path.join("TODO.md")) else {
1478 return Ok(verdict("unknown", Some("no TODO.md".into())));
1479 };
1480 Ok(match todo::parse(&text).has_errors() {
1481 true => verdict("failed", Some("TODO.md has lint errors".into())),
1482 false => verdict("passed", None),
1483 })
1484 }
1485 "nonempty" => Ok(verdict("unknown", Some("`nonempty` is not built".into()))),
1486 other => match run_of(ctx, unit)? {
1489 None => Ok(verdict(
1490 "unknown",
1491 Some(format!("`{other}` reads a run, and this unit has none")),
1492 )),
1493 Some(run) => Ok(verdict_of(&run, other)),
1494 },
1495 }
1496}
1497
1498fn run_of(ctx: &Ctx<'_>, unit: &WorkflowUnit) -> Result<Option<crate::store::Run>> {
1502 let runs = ctx.store.runs()?;
1503 if let Some(run) = runs
1504 .iter()
1505 .rev()
1506 .find(|r| r.workflow_instance == Some(ctx.instance) && r.unit.as_deref() == Some(&unit.id))
1507 {
1508 return Ok(Some(run.clone()));
1509 }
1510 if unit.ty != "run" {
1511 return Ok(None);
1512 }
1513 let data: Value = serde_json::from_str(&unit.data).unwrap_or(Value::Null);
1514 let id = data
1515 .get("id")
1516 .and_then(Value::as_str)
1517 .and_then(|s| s.parse::<i64>().ok());
1518 Ok(id.and_then(|id| runs.into_iter().find(|r| r.id == id)))
1519}
1520
1521fn verdict_of(run: &crate::store::Run, rule: &str) -> Checked {
1526 use crate::store::RunState;
1527 let ok = |b: bool| verdict(if b { "passed" } else { "failed" }, None);
1528 let pr = run
1529 .outcome
1530 .as_deref()
1531 .filter(|o| o.contains("/pull/"))
1532 .map(str::to_string);
1533 let unpublished = || match run.state {
1534 RunState::Rejected => verdict("failed", Some("the run was rejected".into())),
1535 RunState::Failed => verdict("failed", Some("the run failed".into())),
1536 _ => Checked::NotReady("the run is not published yet".into()),
1537 };
1538 match rule {
1539 "verify" => match (&run.verify, run.verify_ok) {
1540 (None, _) => verdict(
1541 "unknown",
1542 Some(format!("`{}` states no verify command", run.project)),
1543 ),
1544 (Some(_), None) => verdict(
1545 "unknown",
1546 Some(
1547 run.error
1548 .clone()
1549 .unwrap_or_else(|| "verify did not run".into()),
1550 ),
1551 ),
1552 (Some(_), Some(true)) => ok(true),
1553 (Some(_), Some(false)) => verdict("failed", Some("verify failed".into())),
1554 },
1555 "scope-clean" => {
1556 let reasons = crate::accept::review_reasons(run);
1557 let scope: Vec<&String> = reasons
1558 .iter()
1559 .filter(|r| r.contains("outside") || r.contains("scope"))
1560 .collect();
1561 match (&run.changed_paths, scope.as_slice()) {
1562 (None, _) => verdict(
1563 "unknown",
1564 Some(
1565 run.scope_error
1566 .clone()
1567 .unwrap_or_else(|| "the changed paths are not known".into()),
1568 ),
1569 ),
1570 (Some(_), []) => ok(true),
1571 (Some(_), why) => verdict(
1572 "failed",
1573 Some(
1574 why.iter()
1575 .map(|s| s.as_str())
1576 .collect::<Vec<_>>()
1577 .join("; "),
1578 ),
1579 ),
1580 }
1581 }
1582 "pr-merged" => match (&pr, run.state) {
1583 (_, RunState::Merged) => ok(true),
1585 (_, RunState::Closed) => verdict(
1586 "failed",
1587 Some("the pull request was closed without merging".into()),
1588 ),
1589 (None, RunState::Pushed) => ok(true),
1592 (None, _) => unpublished(),
1593 (Some(url), _) => match gh_json(&["pr", "view", url, "--json", "state"]) {
1594 Err(e) => verdict("unknown", Some(e)),
1595 Ok(v) => match v.get("state").and_then(Value::as_str) {
1596 Some("MERGED") => ok(true),
1597 Some("CLOSED") => verdict(
1598 "failed",
1599 Some("the pull request was closed without merging".into()),
1600 ),
1601 Some(other) => Checked::NotReady(format!("the pull request is {other}")),
1602 None => verdict("unknown", Some("gh reported no state".into())),
1603 },
1604 },
1605 },
1606 "ci-green" => match (&pr, run.state) {
1607 (None, RunState::Pushed) => verdict(
1608 "unknown",
1609 Some("pushed without a pull request, so no checks are read".into()),
1610 ),
1611 (None, _) => unpublished(),
1612 (Some(url), _) => match gh_json(&["pr", "view", url, "--json", "statusCheckRollup"]) {
1613 Err(e) => verdict("unknown", Some(e)),
1614 Ok(v) => {
1615 let checks = v
1616 .get("statusCheckRollup")
1617 .and_then(Value::as_array)
1618 .cloned()
1619 .unwrap_or_default();
1620 let decisive = |c: &Value| -> Option<bool> {
1621 let state = c
1622 .get("conclusion")
1623 .filter(|s| s.as_str().is_some_and(|s| !s.is_empty()))
1624 .or_else(|| c.get("state"))
1625 .and_then(Value::as_str)
1626 .unwrap_or_default()
1627 .to_ascii_uppercase();
1628 match state.as_str() {
1629 "SUCCESS" | "NEUTRAL" | "SKIPPED" => Some(true),
1630 "FAILURE" | "TIMED_OUT" | "STARTUP_FAILURE" | "ERROR" | "CANCELLED"
1631 | "ACTION_REQUIRED" => Some(false),
1632 _ => None,
1633 }
1634 };
1635 let verdicts: Vec<Option<bool>> = checks.iter().map(decisive).collect();
1636 if verdicts.contains(&Some(false)) {
1637 verdict("failed", Some("a check failed".into()))
1638 } else if verdicts.iter().any(Option::is_none) {
1639 Checked::NotReady("a check has not finished".into())
1640 } else if verdicts.is_empty() {
1641 verdict("unknown", Some("no checks are reported".into()))
1642 } else {
1643 ok(true)
1644 }
1645 }
1646 },
1647 },
1648 other => verdict("unknown", Some(format!("check `{other}` is not built yet"))),
1649 }
1650}
1651
1652fn gh_json(args: &[&str]) -> std::result::Result<Value, String> {
1654 let mut cmd = std::process::Command::new("gh");
1655 cmd.args(args);
1656 let out = crate::scan::call(cmd, crate::scan::CALL_TIMEOUT).map_err(|e| e.to_string())?;
1657 serde_json::from_str(&out).map_err(|e| format!("gh: {e}"))
1658}
1659
1660fn emit(
1664 ctx: &Ctx<'_>,
1665 e: &crate::workflow::EmitNode,
1666 unit: &WorkflowUnit,
1667 data: &Value,
1668 known: &Known,
1669) -> Result<std::result::Result<(), String>> {
1670 let store = ctx.store;
1671 let system = system_fields(unit, known);
1672 let fill = |template: &str| -> String { fill(template, data, &system, &ctx.params, &[]) };
1673 let mapped = |field: &str| e.map.get(field).map(|t| fill(t));
1674 match e.sink {
1675 Sink::Note => {
1676 let text = mapped("text").unwrap_or_default();
1677 if text.trim().is_empty() {
1678 return Ok(Err("the note's text is empty".into()));
1679 }
1680 store.add_note(&text, crate::dates::now())?;
1681 Ok(Ok(()))
1682 }
1683 Sink::Todo => {
1684 let Some(project) = &unit.project else {
1685 return Ok(Err("a todo sink needs a unit that names a project".into()));
1686 };
1687 let Some(row) = store.project(project)? else {
1688 return Ok(Err(format!("no project `{project}`")));
1689 };
1690 let file = row.path.join("TODO.md");
1691 let text = match std::fs::read_to_string(&file) {
1692 Ok(t) => t,
1693 Err(e) => return Ok(Err(format!("{}: {e}", file.display()))),
1694 };
1695 let parsed = todo::parse(&text);
1696 if parsed.has_errors() {
1697 return Ok(Err(format!(
1698 "{}: lint errors, so an item cannot be identified; `pma lint` first",
1699 file.display()
1700 )));
1701 }
1702 let item = mapped("text").unwrap_or_default();
1703 if item.trim().is_empty() || item.contains('\n') {
1704 return Ok(Err(format!("an item is one non-empty line, not `{item}`")));
1705 }
1706 let key = todo::normal_text(&item);
1707 let updated = match e.action {
1708 Action::Add => {
1709 let Some(priority) =
1710 mapped("priority").and_then(|p| todo::Priority::parse(&p.to_lowercase()))
1711 else {
1712 return Ok(Err(
1713 "the priority is not critical, high, medium or low".into()
1714 ));
1715 };
1716 if parsed
1719 .items
1720 .iter()
1721 .any(|i| !i.done && todo::normal_text(&i.text) == key)
1722 {
1723 return Ok(Ok(()));
1724 }
1725 let description = mapped("description");
1726 let line = format!("{item} ^{}", todo::new_id(&text));
1729 match todo::insert(&text, priority, &line, description.as_deref()) {
1730 Some(t) => t,
1731 None => {
1732 return Ok(Err(format!(
1733 "{} has no `## {}` section",
1734 file.display(),
1735 capitalised(priority.name())
1736 )));
1737 }
1738 }
1739 }
1740 Action::Tick => match todo::mark_done(&text, &key, &item) {
1741 Some(t) => t,
1742 None => return Ok(Err(format!("no open item `{item}`"))),
1743 },
1744 Action::Remove => match todo::remove_done(&text, &key, &item) {
1745 Some(t) => t,
1746 None => return Ok(Err(format!("no finished item `{item}`"))),
1747 },
1748 };
1749 todo::save(&file, &updated).map_err(|e| format!("{}: {e}", file.display()))?;
1750 Ok(Ok(()))
1751 }
1752 Sink::Doc => Ok(Err("the `doc` sink is not built".into())),
1753 }
1754}
1755
1756fn capitalised(s: &str) -> String {
1757 let mut c = s.chars();
1758 c.next()
1759 .map(|f| f.to_uppercase().chain(c).collect())
1760 .unwrap_or_default()
1761}
1762
1763fn rule_reduce<'a>(
1767 rule: &str,
1768 group: &[&'a WorkflowUnit],
1769) -> Result<(Vec<&'a WorkflowUnit>, Vec<&'a WorkflowUnit>)> {
1770 let mut ordered: Vec<&WorkflowUnit> = group.to_vec();
1771 if rule == "rank" {
1772 let key = |u: &WorkflowUnit| -> usize {
1773 let data: Value = serde_json::from_str(&u.data).unwrap_or(Value::Null);
1774 data.get("priority")
1775 .and_then(Value::as_str)
1776 .and_then(todo::Priority::parse)
1777 .map_or(usize::MAX, |p| p as usize)
1778 };
1779 ordered.sort_by_key(|u| key(u));
1780 }
1781 let keep = match rule {
1782 "dedupe" => 1,
1783 "rank" => ordered.len(),
1784 other => other
1785 .strip_prefix("limit:")
1786 .and_then(|n| n.parse::<usize>().ok())
1787 .ok_or_else(|| format!("rule `{other}` is not built yet"))?,
1788 };
1789 let dropped = ordered.split_off(keep.min(ordered.len()));
1790 Ok((ordered, dropped))
1791}
1792
1793fn path_holds(store: &Store, project: &str, glob: &str, want: bool) -> Result<bool> {
1795 let row = store
1796 .project(project)?
1797 .ok_or_else(|| format!("no project `{project}`"))?;
1798 let found = walk(&row.path, &row.path, glob);
1799 Ok(found == want)
1800}
1801
1802fn walk(root: &Path, dir: &Path, glob: &str) -> bool {
1803 let Ok(entries) = std::fs::read_dir(dir) else {
1804 return false;
1805 };
1806 for entry in entries.flatten() {
1807 let path = entry.path();
1808 let relative = path.strip_prefix(root).unwrap_or(&path).to_string_lossy();
1809 if crate::scan::glob_match(glob, &relative) {
1810 return true;
1811 }
1812 if path.is_dir() && !path.ends_with(".git") && walk(root, &path, glob) {
1813 return true;
1814 }
1815 }
1816 false
1817}
1818
1819fn run_agent(ctx: &Ctx<'_>, node: &Node, units: &[WorkflowUnit], known: &Known) -> Result<()> {
1830 let mut staged = Vec::new();
1831 for batch in batches(node, units) {
1832 match stage(ctx, node, &batch, known)? {
1833 Ok(job) => staged.push(job),
1834 Err(why) => {
1835 for unit in &batch {
1836 ctx.store
1837 .atomically(|| route_failed(ctx, node, unit, &why))?;
1838 }
1839 }
1840 }
1841 }
1842 if staged.is_empty() {
1843 return Ok(());
1844 }
1845 let queue = std::sync::Mutex::new(std::collections::VecDeque::from(staged));
1846 let (tx, rx) = std::sync::mpsc::channel::<(Staged, Ran)>();
1847 let mut held = ctx.store.workflow_units(ctx.instance)?.len() as i64;
1848 let mut failure = None;
1849 let cfg = ctx.cfg;
1850 std::thread::scope(|scope| {
1851 for _ in 0..(cfg.max_parallel as usize).max(1) {
1852 let tx = tx.clone();
1853 let queue = &queue;
1854 scope.spawn(move || {
1855 loop {
1856 let Some(job) = queue.lock().unwrap().pop_front() else {
1857 break;
1858 };
1859 let ran = work(cfg, &job);
1860 let _ = tx.send((job, ran));
1861 }
1862 });
1863 }
1864 drop(tx);
1865 for (job, ran) in rx {
1866 if let Err(e) = record(ctx, node, job, ran, &mut held, known)
1867 && failure.is_none()
1868 {
1869 failure = Some(e);
1870 }
1871 }
1872 });
1873 match failure {
1874 Some(e) => Err(e),
1875 None => Ok(()),
1876 }
1877}
1878
1879struct Staged {
1882 run: crate::store::Run,
1883 worker: crate::worker::Worker,
1884 chosen: Chosen,
1885 batch: Vec<WorkflowUnit>,
1886 repo: std::path::PathBuf,
1887 tree: std::path::PathBuf,
1888 out: std::path::PathBuf,
1889 doc: Option<std::path::PathBuf>,
1890 artifacts: std::path::PathBuf,
1892 log: std::path::PathBuf,
1893 agent_env: std::path::PathBuf,
1896}
1897
1898struct Ran {
1901 seconds: i64,
1902 cost_usd: Option<f64>,
1903 summary: String,
1904 produced: std::result::Result<Vec<Value>, String>,
1905}
1906
1907fn read_base(ctx: &Ctx<'_>, repo: &Path, project: &str) -> std::result::Result<String, String> {
1912 if !ctx.w.effects.repo || ctx.local {
1913 return Ok("HEAD".into());
1914 }
1915 if let Some(found) = ctx.bases.borrow().get(project) {
1916 return found.clone();
1917 }
1918 let found = (|| {
1919 crate::dispatch::clone_unchanged(ctx.store, repo).map_err(|e| e.to_string())?;
1920 crate::dispatch::git(repo, &["fetch", "--quiet", "origin"]).map_err(|e| e.to_string())?;
1921 let branch = crate::scan::default_branch(repo)
1922 .ok_or_else(|| crate::scan::DEFAULT_BRANCH_UNKNOWN.to_string())?;
1923 crate::dispatch::git(
1924 repo,
1925 &["rev-parse", &format!("refs/remotes/origin/{branch}")],
1926 )
1927 .map(|s| s.trim().to_string())
1928 .map_err(|e| e.to_string())
1929 })();
1930 ctx.bases
1931 .borrow_mut()
1932 .insert(project.to_string(), found.clone());
1933 found
1934}
1935
1936fn contract(doc: &Document, w: &Workflow, node: &Node, input: &Path, out: &Path) -> Option<String> {
1941 let (emits, shape, writes) = match &node.op {
1942 Op::Map(m) => (m.emits.as_str(), Some(m.out), m.writes.as_slice()),
1943 Op::Reduce(r) => (r.emits.as_str(), None, &[][..]),
1944 _ => return None,
1945 };
1946 let ty = doc.resolve(emits)?;
1947 let (input, out) = (input.display(), out.display());
1948 let mut text = String::from("Output, which pma checks before it accepts anything:\n");
1949 text.push_str(&match shape {
1950 Some(Out::Grows) => format!(
1951 "- Put in {out} a JSON array of new objects, at most {}. Put [] there if there are none.\n",
1952 width(w, node).unwrap_or(0)
1953 ),
1954 Some(Out::Same) => format!(
1955 "- Put in {out} a JSON array holding every object of {input}, each with its `@id`.\n"
1956 ),
1957 Some(Out::Shrinks) => format!(
1958 "- Put in {out} a JSON array holding the objects of {input} you keep, each with its \
1959 `@id`. Leave out the ones you drop; put [] there to drop all.\n"
1960 ),
1961 None => format!(
1962 "- Put in {out} a JSON array of at most one object, with `@from` listing the `@id` of \
1963 every object of {input} it comes from.\n"
1964 ),
1965 });
1966 let fields: Vec<(&String, &crate::workflow::Field)> = match shape {
1967 Some(Out::Same) | Some(Out::Shrinks) => {
1970 if writes.is_empty() {
1971 text.push_str("- Change no field.\n");
1972 return Some(text);
1973 }
1974 text.push_str("- Set only these fields; any other change is refused:\n");
1975 ty.fields
1976 .iter()
1977 .filter(|(n, _)| writes.contains(n))
1978 .collect()
1979 }
1980 _ => {
1981 text.push_str("- Each object has these fields and no others:\n");
1982 ty.fields.iter().collect()
1983 }
1984 };
1985 for (name, field) in fields {
1986 let required = match field.required {
1987 true => ", required",
1988 false => "",
1989 };
1990 text.push_str(&format!(
1991 " - `{name}`{required}: {}\n",
1992 field.ty.describe()
1993 ));
1994 }
1995 Some(text)
1996}
1997
1998fn stage(
2002 ctx: &Ctx<'_>,
2003 node: &Node,
2004 batch: &[WorkflowUnit],
2005 known: &Known,
2006) -> Result<std::result::Result<Staged, String>> {
2007 let lead = batch.first().expect("a batch holds at least one unit");
2008 let Some(project) = lead.project.clone() else {
2009 return Ok(Err(format!("unit `{}` names no project", lead.id)));
2010 };
2011 let Some(row) = ctx.store.project(&project)? else {
2012 return Ok(Err(format!("no project `{project}`; `pma scan` first")));
2013 };
2014 let resolved = match resolve(
2015 ctx.store,
2016 ctx.cfg,
2017 ctx.over,
2018 &ctx.policy,
2019 node,
2020 lead,
2021 ctx.cfg.tier_or_default(row.tier),
2022 )? {
2023 Ok(r) => r,
2024 Err(why) => return Ok(Err(why)),
2025 };
2026 let Some(worker) = ctx
2027 .store
2028 .agents()?
2029 .into_iter()
2030 .find(|w| w.name == resolved.chosen.agent)
2031 else {
2032 return Ok(Err(format!(
2033 "unknown agent `{}`; see `pma agent`",
2034 resolved.chosen.agent
2035 )));
2036 };
2037 let base = match read_base(ctx, &row.path, &project) {
2038 Ok(b) => b,
2039 Err(why) => return Ok(Err(format!("{project}: {why}"))),
2040 };
2041
2042 let dir = artifacts(ctx, node)?;
2043 let tree = ctx
2048 .home
2049 .join("workflow-trees")
2050 .join(ctx.instance.to_string())
2051 .join(node.name.replace('/', "-"))
2052 .join(dir.file_name().expect("a numbered directory"));
2053 let _ = std::fs::remove_dir_all(&tree);
2054 if let Some(parent) = tree.parent() {
2055 std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
2056 }
2057 crate::dispatch::clone_unchanged(ctx.store, &row.path)?;
2058 crate::dispatch::git(
2059 &row.path,
2060 &[
2061 "worktree",
2062 "add",
2063 "--detach",
2064 "--quiet",
2065 &tree.to_string_lossy(),
2066 &base,
2067 ],
2068 )?;
2069
2070 let git_snapshot = crate::dispatch::git_snapshot(&crate::store::Run {
2077 repo: row.path.clone(),
2078 worktree: tree.clone(),
2079 ..crate::store::Run::default()
2080 })?;
2081 let io = tree.join(".pma");
2082 std::fs::create_dir_all(&io).map_err(|e| format!("{}: {e}", io.display()))?;
2083 let input: Vec<Value> = batch.iter().map(|u| exported(u, known)).collect();
2084 let in_file = io.join("in.json");
2085 let out_file = io.join("out.json");
2086 let doc_file = node_doc(ctx, node).map(|name| io.join(name));
2087 write(&in_file, &serde_json::to_string_pretty(&input)?)?;
2088
2089 let data: Value = serde_json::from_str(&lead.data).unwrap_or(Value::Null);
2090 let task = task_of(node).ok_or_else(|| format!("node `{}` states no task", node.name))?;
2091 let prompt = fill(
2092 task,
2093 &data,
2094 &system_fields(lead, known),
2095 &ctx.params,
2096 &[
2097 ("in", in_file.to_string_lossy().into_owned()),
2098 ("out", out_file.to_string_lossy().into_owned()),
2099 (
2100 "doc",
2101 doc_file
2102 .as_ref()
2103 .map(|p| p.to_string_lossy().into_owned())
2104 .unwrap_or_default(),
2105 ),
2106 ],
2107 );
2108 let prompt = match contract(ctx.doc, ctx.w, node, &in_file, &out_file) {
2109 Some(c) => format!("{prompt}\n\n{c}"),
2110 None => prompt,
2111 };
2112
2113 let chosen = resolved.chosen;
2114 let (route_revision, route, approval) = match resolved.route {
2115 Some((rev, name, approval)) => (Some(rev), name, approval),
2116 None => (None, None, None),
2117 };
2118 let mut run = crate::store::Run {
2119 project: project.clone(),
2120 task_key: format!("workflow:{}:{}", ctx.instance, lead.root),
2121 text: node.name.clone(),
2122 agent: chosen.agent.clone(),
2123 model: chosen.model.clone(),
2124 repo: row.path.clone(),
2125 branch: String::new(),
2126 worktree: tree.clone(),
2127 git_snapshot: Some(git_snapshot),
2128 base: String::new(),
2129 prompt,
2130 state: crate::store::RunState::Running,
2131 agent_budget: Some(ctx.cfg.agent_budget),
2132 timeout_minutes: Some(ctx.cfg.timeout),
2133 tier: ctx.cfg.tier_or_default(row.tier),
2134 route_revision,
2135 route,
2136 approval,
2137 workflow_instance: Some(ctx.instance),
2138 node: Some(node.name.clone()),
2139 unit: Some(lead.id.clone()),
2140 lap: lead.lap,
2141 preset: chosen.preset.clone(),
2142 extra_args: chosen.args.clone(),
2143 ..crate::store::Run::default()
2144 };
2145 ctx.store.insert_run(&mut run)?;
2146 let log = ctx
2147 .home
2148 .join("runs")
2149 .join(run.id.to_string())
2150 .join("agent-1.log");
2151 if let Some(parent) = log.parent() {
2152 std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
2153 }
2154 let agent_env = ctx.home.join("agent-env");
2155 Ok(Ok(Staged {
2156 run,
2157 worker,
2158 chosen,
2159 batch: batch.to_vec(),
2160 repo: row.path,
2161 tree,
2162 out: out_file,
2163 doc: doc_file,
2164 artifacts: dir,
2165 log,
2166 agent_env,
2167 }))
2168}
2169
2170fn work(cfg: &Config, job: &Staged) -> Ran {
2173 let timeout = std::time::Duration::from_secs(cfg.timeout as u64 * 60);
2174 let mut cmd = job.worker.build(
2175 &job.run.prompt,
2176 &job.tree,
2177 job.chosen.model.as_deref(),
2178 cfg.agent_budget,
2179 &job.chosen.args,
2180 timeout.saturating_sub(crate::dispatch::INNER_GRACE),
2181 );
2182 cmd.current_dir(&job.tree);
2183 let finished = match crate::agent::restrict(&mut cmd, &job.agent_env)
2184 .and_then(|()| crate::agent::run_limited(cmd, None, &job.log, timeout))
2185 {
2186 Ok(f) => f,
2187 Err(e) => {
2188 return Ran {
2189 seconds: 0,
2190 cost_usd: None,
2191 summary: String::new(),
2192 produced: Err(format!("{}: {e}", job.worker.command)),
2193 };
2194 }
2195 };
2196 let report = job.worker.parse.report(
2197 &std::fs::read_to_string(&job.log).unwrap_or_default(),
2198 finished.success == Some(true),
2199 );
2200 let changed = crate::dispatch::unchanged_git(&job.run).map_err(|e| e.to_string());
2201 let produced = match finished.success {
2202 _ if changed.is_err() => changed.map(|()| Vec::new()),
2203 None => Err(format!("timed out after {} minutes", cfg.timeout)),
2204 Some(_) if !report.ok => Err(format!("the agent failed; log: {}", job.log.display())),
2205 _ => read_out(&job.out).and_then(|units| match &job.doc {
2206 Some(f) => doc_written(f).map(|()| units),
2207 None => Ok(units),
2208 }),
2209 };
2210 Ran {
2211 seconds: finished.seconds,
2212 cost_usd: report.cost_usd,
2213 summary: report.summary,
2214 produced,
2215 }
2216}
2217
2218fn record(
2226 ctx: &Ctx<'_>,
2227 node: &Node,
2228 job: Staged,
2229 ran: Ran,
2230 held: &mut i64,
2231 known: &Known,
2232) -> Result<()> {
2233 let Staged {
2234 mut run,
2235 batch,
2236 repo,
2237 tree,
2238 artifacts,
2239 ..
2240 } = job;
2241 run.seconds = Some(ran.seconds);
2242 run.cost_usd = ran.cost_usd;
2243 run.summary = Some(ran.summary);
2244 run.state = match &ran.produced {
2245 Ok(_) => crate::store::RunState::Done,
2246 Err(_) => crate::store::RunState::Failed,
2247 };
2248 run.error = ran.produced.as_ref().err().cloned();
2249 run.ready_at = Some(crate::dates::now());
2250 ctx.store.update_run(&run)?;
2251 keep_files(&tree.join(".pma"), &artifacts);
2254 let _ = crate::dispatch::remove_worktree(&repo, &tree, "");
2255 let before = *held;
2256 let recorded = ctx.store.atomically(|| match ran.produced {
2257 Ok(units) => accept(ctx, node, &batch, units, held, known),
2258 Err(why) => {
2259 for unit in &batch {
2260 route_failed(ctx, node, unit, &why)?;
2261 }
2262 Ok(())
2263 }
2264 });
2265 if recorded.is_err() {
2266 *held = before;
2267 }
2268 recorded
2269}
2270
2271fn group_key(r: &crate::workflow::ReduceNode, unit: &WorkflowUnit) -> String {
2272 let data: Value = serde_json::from_str(&unit.data).unwrap_or(Value::Null);
2273 r.group_by
2274 .iter()
2275 .map(|f| todo::normal_text(data.get(f).and_then(Value::as_str).unwrap_or_default()))
2276 .collect::<Vec<_>>()
2277 .join("\u{0}")
2278}
2279
2280fn keep_files(io: &Path, artifacts: &Path) {
2283 let Ok(entries) = std::fs::read_dir(io) else {
2284 return;
2285 };
2286 for entry in entries.flatten() {
2287 let path = entry.path();
2288 if path.is_file() {
2289 let _ = std::fs::copy(&path, artifacts.join(entry.file_name()));
2290 }
2291 }
2292}
2293
2294fn read_out(file: &Path) -> std::result::Result<Vec<Value>, String> {
2298 let text = std::fs::read_to_string(file)
2299 .map_err(|e| format!("the agent wrote no {}: {e}", file.display()))?;
2300 let v: Value =
2301 serde_json::from_str(&text).map_err(|e| format!("{}: not JSON: {e}", file.display()))?;
2302 match v {
2303 Value::Array(units) => Ok(units),
2304 Value::Object(_) => Ok(vec![v]),
2305 _ => Err(format!("{}: expected a list of units", file.display())),
2306 }
2307}
2308
2309fn doc_written(file: &Path) -> std::result::Result<(), String> {
2311 const MAX: u64 = 1 << 20;
2312 match std::fs::metadata(file) {
2313 Err(_) => Err(format!("{}: the document was not written", file.display())),
2314 Ok(m) if !m.is_file() => Err(format!("{}: not a regular file", file.display())),
2315 Ok(m) if m.len() == 0 => Err(format!("{}: the document is empty", file.display())),
2316 Ok(m) if m.len() > MAX => Err(format!("{}: over 1 MiB", file.display())),
2317 Ok(_) => Ok(()),
2318 }
2319}
2320
2321fn accept(
2330 ctx: &Ctx<'_>,
2331 node: &Node,
2332 batch: &[WorkflowUnit],
2333 produced: Vec<Value>,
2334 held: &mut i64,
2335 known: &Known,
2336) -> Result<()> {
2337 let (emits, out, writes) = match &node.op {
2338 Op::Map(m) => (m.emits.clone(), Some(m.out), m.writes.clone()),
2339 Op::Reduce(r) => (r.emits.clone(), None, Vec::new()),
2340 _ => return Err(format!("node `{}` is not decided by a model", node.name).into()),
2341 };
2342 let ty = ctx
2343 .doc
2344 .resolve(&emits)
2345 .ok_or_else(|| format!("node `{}`: unknown type `{emits}`", node.name))?;
2346
2347 let mut reasons = Vec::new();
2348 if let (Some(Out::Grows), Some(width)) = (out, width(ctx.w, node))
2349 && produced.len() as i64 > width
2350 {
2351 reasons.push(format!(
2352 "{} units returned, over `max_units` of {width}",
2353 produced.len()
2354 ));
2355 }
2356 if out == Some(Out::Shrinks) && produced.len() > batch.len() {
2357 reasons.push("a filter returned more units than it was given".into());
2358 }
2359 if out == Some(Out::Same) && produced.len() != batch.len() {
2360 reasons.push(format!(
2361 "an annotation returned {} units for {}",
2362 produced.len(),
2363 batch.len()
2364 ));
2365 }
2366 if out.is_none() && produced.len() > 1 {
2367 reasons.push(format!(
2368 "a reduce writes at most one unit per group, and returned {}",
2369 produced.len()
2370 ));
2371 }
2372
2373 let mut minted: Vec<(&WorkflowUnit, Value)> = Vec::new();
2375 let mut returned_ids = BTreeSet::new();
2376 for (i, v) in produced.iter().enumerate() {
2377 let n = i + 1;
2378 let (source, data) = match out {
2379 Some(Out::Same) | Some(Out::Shrinks) => {
2380 let Some(source) = kept_id(v).and_then(|id| batch.iter().find(|u| u.id == id))
2381 else {
2382 reasons.push(format!("unit {n} names no `@id` it was given"));
2383 continue;
2384 };
2385 if !returned_ids.insert(source.id.clone()) {
2386 reasons.push(format!("unit {n} repeats `@id` {}", source.id));
2387 continue;
2388 }
2389 if let Err(e) = unchanged(source, v, &writes) {
2390 reasons.push(format!("unit {n}: {e}"));
2391 continue;
2392 }
2393 (source, merged(source, v, &writes))
2394 }
2395 Some(Out::Grows) => (&batch[0], stripped(v)),
2396 None => {
2397 let from: Vec<&str> = v
2398 .get("@from")
2399 .and_then(Value::as_array)
2400 .map(|a| a.iter().filter_map(Value::as_str).collect())
2401 .unwrap_or_default();
2402 let sources: Vec<&WorkflowUnit> = from
2403 .iter()
2404 .filter_map(|id| batch.iter().find(|u| u.id == *id))
2405 .collect();
2406 if from.is_empty() || sources.len() != from.len() {
2407 reasons.push(format!(
2408 "unit {n}: `@from` must list the ids of the units it came from"
2409 ));
2410 continue;
2411 }
2412 (sources[0], stripped(v))
2413 }
2414 };
2415 if let Err(e) = ty.check(&data) {
2416 reasons.push(format!("unit {n}: {e}"));
2417 continue;
2418 }
2419 minted.push((source, data));
2420 }
2421 if !reasons.is_empty() {
2422 let why = reasons.join("; ");
2423 for unit in batch {
2424 route_failed(ctx, node, unit, &why)?;
2425 }
2426 return Ok(());
2427 }
2428
2429 let all = ctx.store.workflow_units(ctx.instance)?;
2430 let mut seen = unique_seen(ctx, node, &emits)?;
2431 let mut n = 0;
2432 for (source, data) in minted {
2433 if let Some(why) = duplicate(ctx, &emits, &data, source, &mut seen)? {
2434 ctx.refused
2435 .borrow_mut()
2436 .push(format!("{}: {}: dropped: {why}", node.name, source.id));
2437 continue;
2438 }
2439 mint(
2440 ctx,
2441 node,
2442 WorkflowUnit {
2443 id: next_id(&all, &node.name, n),
2444 ty: emits.clone(),
2445 node: node.name.clone(),
2446 parent: Some(source.id.clone()),
2447 root: source.root.clone(),
2448 depth: match out {
2449 Some(Out::Grows) => source.depth + 1,
2450 _ => source.depth,
2451 },
2452 lap: source.lap,
2453 project: source.project.clone(),
2454 data: data.to_string(),
2455 },
2456 held,
2457 known,
2458 )?;
2459 n += 1;
2460 }
2461 for unit in batch {
2464 ctx.store.add_workflow_move(
2465 ctx.instance,
2466 &unit.id,
2467 settled_at(ctx.w, &node.name),
2468 false,
2469 Some("handed to the node's output"),
2470 )?;
2471 }
2472 Ok(())
2473}
2474
2475fn kept_id(v: &Value) -> Option<String> {
2476 v.get("@id").and_then(Value::as_str).map(String::from)
2477}
2478
2479fn stripped(v: &Value) -> Value {
2481 let mut data = v.clone();
2482 if let Value::Object(map) = &mut data {
2483 map.retain(|k, _| !k.starts_with('@'));
2484 }
2485 data
2486}
2487
2488fn merged(source: &WorkflowUnit, returned: &Value, writes: &[String]) -> Value {
2491 let mut data = match serde_json::from_str::<Value>(&source.data) {
2492 Ok(Value::Object(m)) => m,
2493 _ => serde_json::Map::new(),
2494 };
2495 for field in writes {
2496 match returned.get(field) {
2497 Some(v) if !v.is_null() => {
2498 data.insert(field.clone(), v.clone());
2499 }
2500 _ => {
2501 data.remove(field);
2502 }
2503 }
2504 }
2505 Value::Object(data)
2506}
2507
2508fn unchanged(
2512 source: &WorkflowUnit,
2513 returned: &Value,
2514 writes: &[String],
2515) -> std::result::Result<(), String> {
2516 let before: Value = serde_json::from_str(&source.data).unwrap_or(Value::Null);
2517 let Some(before) = before.as_object() else {
2518 return Ok(());
2519 };
2520 for (field, was) in before {
2521 if writes.iter().any(|w| w == field) {
2522 continue;
2523 }
2524 match returned.get(field) {
2525 Some(now) if now == was => {}
2526 None => {}
2527 Some(_) => {
2528 return Err(format!(
2529 "`{field}` changed, and the node may write only {}",
2530 match writes.is_empty() {
2531 true => "nothing".to_string(),
2532 false => writes.join(", "),
2533 }
2534 ));
2535 }
2536 }
2537 }
2538 Ok(())
2539}
2540
2541fn exported(unit: &WorkflowUnit, known: &Known) -> Value {
2544 let mut map = match serde_json::from_str::<Value>(&unit.data) {
2545 Ok(Value::Object(m)) => m,
2546 _ => serde_json::Map::new(),
2547 };
2548 for (k, v) in system_fields(unit, known) {
2549 map.insert(format!("@{k}"), v);
2550 }
2551 Value::Object(map)
2552}
2553
2554fn task_of(node: &Node) -> Option<&str> {
2555 match &node.op {
2556 Op::Map(m) => m.task.as_deref(),
2557 Op::Reduce(r) => r.task.as_deref(),
2558 Op::Edit(e) => Some(&e.task),
2559 _ => None,
2560 }
2561}
2562
2563fn node_doc(ctx: &Ctx<'_>, node: &Node) -> Option<String> {
2564 let Op::Map(m) = &node.op else { return None };
2565 let name = m.doc.as_ref()?;
2566 let filled = fill(name, &Value::Null, &BTreeMap::new(), &ctx.params, &[]);
2567 Some(
2569 filled
2570 .rsplit(['/', '\\'])
2571 .next()
2572 .filter(|s| !s.is_empty() && *s != "." && *s != "..")
2573 .unwrap_or("doc.md")
2574 .to_string(),
2575 )
2576}
2577
2578fn artifacts(ctx: &Ctx<'_>, node: &Node) -> Result<std::path::PathBuf> {
2581 let base = ctx
2582 .home
2583 .join("artifacts")
2584 .join(ctx.instance.to_string())
2585 .join(node.name.replace('/', "-"));
2586 let dir = (1..)
2587 .map(|n| base.join(n.to_string()))
2588 .find(|p| !p.exists())
2589 .expect("an unused number exists");
2590 std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
2591 Ok(dir)
2592}
2593
2594fn write(file: &Path, text: &str) -> Result<()> {
2595 std::fs::write(file, text).map_err(|e| format!("{}: {e}", file.display()).into())
2596}
2597
2598fn fill(
2603 template: &str,
2604 data: &Value,
2605 system: &BTreeMap<String, Value>,
2606 params: &BTreeMap<String, Value>,
2607 files: &[(&str, String)],
2608) -> String {
2609 let text = |v: &Value| -> String {
2610 match v {
2611 Value::String(s) => s.clone(),
2612 Value::Array(items) if items.iter().all(Value::is_string) => items
2613 .iter()
2614 .filter_map(Value::as_str)
2615 .collect::<Vec<_>>()
2616 .join("\n"),
2617 other => other.to_string(),
2618 }
2619 };
2620 let mut out = template.to_string();
2621 for (name, value) in files {
2622 out = out.replace(&format!("{{{name}}}"), value);
2623 }
2624 for (name, value) in params {
2625 out = out.replace(&format!("{{${name}}}"), &text(value));
2626 }
2627 for (name, value) in system {
2628 out = out.replace(&format!("{{@{name}}}"), &text(value));
2629 }
2630 if let Value::Object(map) = data {
2631 for (name, value) in map {
2632 out = out.replace(&format!("{{{name}}}"), &text(value));
2633 }
2634 }
2635 out
2636}
2637
2638fn run_edit(ctx: &Ctx<'_>, node: &Node, units: &[WorkflowUnit], known: &Known) -> Result<()> {
2647 let Op::Edit(e) = &node.op else {
2648 return Err(format!("node `{}` is not an edit", node.name).into());
2649 };
2650 let mut known = known.clone();
2651 let mut edits = edits_made(ctx)?;
2652 let all = ctx.store.workflow_units(ctx.instance)?;
2653 let size = match ctx.local {
2656 true => ctx.cfg.max_parallel.max(1) as usize,
2657 false => units.len().max(1),
2658 };
2659 for chunk in units.chunks(size) {
2660 let mut queued: Vec<crate::store::Run> = Vec::new();
2661 let mut driving: BTreeMap<i64, WorkflowUnit> = BTreeMap::new();
2662 for unit in chunk {
2663 if edits >= ctx.w.caps.max_edits {
2664 return Err(capped(
2665 &node.name,
2666 format!(
2667 "instance {} has made {edits} edits, its `caps.max_edits`",
2668 ctx.instance
2669 ),
2670 ));
2671 }
2672 let Some(project) = unit.project.clone() else {
2673 ctx.store
2674 .atomically(|| route_failed(ctx, node, unit, "the unit names no project"))?;
2675 continue;
2676 };
2677 let Some(row) = ctx.store.project(&project)? else {
2678 let why = format!("no project `{project}`; `pma scan` first");
2679 ctx.store
2680 .atomically(|| route_failed(ctx, node, unit, &why))?;
2681 continue;
2682 };
2683 let key = format!("workflow:{}:{}", ctx.instance, lineage(&all, unit));
2685 let spent = ctx.store.consumed_attempts(&project, &key)?;
2686 if spent >= crate::dispatch::ATTEMPT_LIMIT {
2687 let why = format!("{spent} attempts on this unit's lineage were used");
2688 ctx.store
2689 .atomically(|| route_failed(ctx, node, unit, &why))?;
2690 continue;
2691 }
2692 let data: Value = serde_json::from_str(&unit.data).unwrap_or(Value::Null);
2693 let task = fill(
2694 &e.task,
2695 &data,
2696 &system_fields(unit, &known),
2697 &ctx.params,
2698 &[],
2699 );
2700 let pick = crate::dispatch::Pick {
2701 workflow: Some(crate::dispatch::UnitRef {
2702 instance: ctx.instance,
2703 node: node.name.clone(),
2704 unit: unit.id.clone(),
2705 lap: unit.lap,
2706 }),
2707 project: project.clone(),
2708 repo: row.path.clone(),
2709 key,
2710 text: summary(&task),
2711 gh: None,
2712 tier: row.tier,
2713 class: None,
2714 details: Some(task),
2715 quadrant: None,
2716 local: ctx.local,
2717 };
2718 match crate::dispatch::prepare(ctx.store, ctx.home, ctx.cfg, ctx.over, &pick)? {
2719 crate::dispatch::Prepared::Refused(why) => {
2720 ctx.store
2721 .atomically(|| route_failed(ctx, node, unit, &why))?;
2722 }
2723 crate::dispatch::Prepared::Queued(run) => {
2724 edits += 1;
2725 driving.insert(run.id, unit.clone());
2726 queued.push(*run);
2727 }
2728 }
2729 }
2730 if queued.is_empty() {
2731 continue;
2732 }
2733 let done =
2734 crate::dispatch::execute(ctx.store, ctx.home, ctx.cfg, ctx.over, queued, |_| {})?;
2735 for mut run in done {
2736 let Some(unit) = driving.get(&run.id) else {
2737 continue;
2738 };
2739 if run
2743 .error
2744 .as_deref()
2745 .is_some_and(|e| e.starts_with(crate::dispatch::NOT_STARTED))
2746 {
2747 let _ = crate::dispatch::remove_worktree(&run.repo, &run.worktree, &run.branch);
2748 continue;
2749 }
2750 known.runs.insert(unit.id.clone(), run.id);
2751 if ctx.local {
2752 let line = match crate::publish::land_local(ctx.store, ctx.home, &mut run) {
2753 Ok((_, said)) => format!("#{} {}: {said}", run.id, run.text),
2754 Err(e) => format!("#{} {}: left in `pma review`: {e}", run.id, run.text),
2755 };
2756 ctx.landed.borrow_mut().push(line);
2757 }
2758 ctx.store.atomically(|| {
2759 if let Some(rule) = &e.check {
2762 let (verdict, detail) = match verdict_of(&run, rule) {
2763 Checked::Verdict(v, d) => (v, d),
2764 Checked::NotReady(why) => ("unknown".to_string(), Some(why)),
2765 };
2766 ctx.store.add_workflow_verdict(
2767 ctx.instance,
2768 &unit.id,
2769 rule,
2770 &verdict,
2771 detail.as_deref(),
2772 )?;
2773 known
2774 .verdicts
2775 .insert((unit.id.clone(), rule.clone()), verdict);
2776 }
2777 route_unit(ctx.store, ctx.instance, ctx.w, &node.name, unit, &known)?;
2778 mark_handled(ctx.store, ctx.instance, ctx.w, &node.name, &unit.id)
2779 })?;
2780 }
2781 }
2782 Ok(())
2783}
2784
2785fn lineage<'a>(all: &'a [WorkflowUnit], unit: &'a WorkflowUnit) -> &'a str {
2790 let mut u = unit;
2791 while u.lap > 0 {
2792 match u
2793 .parent
2794 .as_deref()
2795 .and_then(|p| all.iter().find(|x| x.id == p))
2796 {
2797 Some(parent) => u = parent,
2798 None => break,
2799 }
2800 }
2801 &u.id
2802}
2803
2804fn edits_made(ctx: &Ctx<'_>) -> Result<i64> {
2808 Ok(ctx
2809 .store
2810 .runs()?
2811 .iter()
2812 .filter(|r| r.workflow_instance == Some(ctx.instance))
2813 .filter(|r| {
2814 !r.error
2815 .as_deref()
2816 .is_some_and(|e| e.starts_with(crate::dispatch::NOT_STARTED))
2817 })
2818 .filter(|r| {
2819 r.node
2820 .as_deref()
2821 .and_then(|n| ctx.w.node(n))
2822 .is_some_and(|n| matches!(n.op, Op::Edit(_)))
2823 })
2824 .count() as i64)
2825}
2826
2827fn summary(task: &str) -> String {
2830 let line = task.lines().find(|l| !l.trim().is_empty()).unwrap_or(task);
2831 line.chars()
2832 .take(120)
2833 .collect::<String>()
2834 .trim()
2835 .to_string()
2836}
2837
2838#[cfg(test)]
2839mod tests {
2840 use super::*;
2841
2842 const DOC: &str = r#"{
2843 "types": {"finding": {"fields": {
2844 "severity": {"type": "enum", "required": true, "values": ["high", "low"]},
2845 "title": {"type": "line", "required": true, "max": 120},
2846 "reason": {"type": "lines", "max": 30}
2847 }}},
2848 "workflow": [{"name": "w", "in": "project", "out": "finding", "effects": [],
2849 "caps": {"max_units": 20, "max_edits": 0},
2850 "nodes": [
2851 {"name": "review", "op": "map", "out": "0..n", "in": "project", "emits": "finding",
2852 "max_units": 7, "task": "Review {name} into {out}."},
2853 {"name": "confirm", "op": "map", "out": "0..1", "in": "finding", "emits": "finding",
2854 "writes": ["reason"], "task": "Confirm {in} into {out}."}],
2855 "edges": [{"from": "@input", "to": "review"}, {"from": "review", "to": "confirm"},
2856 {"from": "confirm", "to": "@output"}]}]}"#;
2857
2858 #[test]
2861 fn a_step_is_told_the_output_it_is_held_to() {
2862 let doc = Document::parse(DOC).unwrap();
2863 let w = doc.flatten("w").unwrap();
2864 let (input, out) = (Path::new("/t/in.json"), Path::new("/t/out.json"));
2865 let review = contract(&doc, &w, w.node("review").unwrap(), input, out).unwrap();
2866 assert!(
2867 review.contains("Put in /t/out.json a JSON array of new objects, at most 7."),
2868 "{review}"
2869 );
2870 assert!(
2871 review.contains("- `severity`, required: one of high, low\n"),
2872 "{review}"
2873 );
2874 assert!(
2875 review.contains("- `title`, required: one line of text, at most 120 characters\n"),
2876 "{review}"
2877 );
2878 assert!(
2879 review.contains("- `reason`: a JSON array of at most 30 strings, one line each\n"),
2880 "{review}"
2881 );
2882
2883 let confirm = contract(&doc, &w, w.node("confirm").unwrap(), input, out).unwrap();
2884 assert!(
2885 confirm.contains("the objects of /t/in.json you keep, each with its `@id`"),
2886 "{confirm}"
2887 );
2888 assert!(confirm.contains("Set only these fields"), "{confirm}");
2889 assert!(
2890 confirm.contains("`reason`") && !confirm.contains("`title`"),
2891 "only what it may set: {confirm}"
2892 );
2893 }
2894}