Skip to main content

pma_core/
pass.rs

1//! One pass over a workflow instance: write the argument bag, derive what is
2//! ready, run what a rule decides, and run an agent only under a plan the
3//! developer approved by its id.
4//!
5//! A pass holds no state of its own. What has run is the units a node wrote and
6//! the moves recorded against them, so the frontier is re-derived on every
7//! invocation and a killed pass resumes by recomputing rather than by trusting
8//! a cursor.
9//!
10//! A node is ready once every node before it has finished, so a node always
11//! sees its whole bag: a `reduce` joins every branch, and a plan names every
12//! unit it will spend on. Design: `docs/dev/workflows.md`.
13
14use 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
29/// What a pass reads while it runs one node: the store it records into, the
30/// settings, the graph it is walking, and what it has learnt this pass.
31struct Ctx<'a> {
32    store: &'a Store,
33    cfg: &'a Config,
34    doc: &'a Document,
35    w: &'a Workflow,
36    instance: i64,
37    /// Where artifacts, logs and the scratch trees a node works in live.
38    home: &'a Path,
39    /// The flags this pass was given, which override any route.
40    over: &'a Overrides,
41    /// Every parameter's value for this instance: the declared default,
42    /// replaced by the argument the run was given. What `{$name}` reads.
43    params: BTreeMap<String, Value>,
44    /// The routing policy in effect, if any.
45    policy: Option<Active>,
46    /// Units a node refused this pass, each with its reason, for the report.
47    refused: RefCell<Vec<String>>,
48    /// Units a check found not ready this pass. They wait for the next one.
49    deferred: RefCell<BTreeSet<(String, String)>>,
50    /// The fetched head of each project's default branch, which the read
51    /// nodes of a workflow that edits work from.
52    bases: RefCell<BTreeMap<String, std::result::Result<String, String>>>,
53    /// The instance works from each clone's `HEAD` and writes a verified edit
54    /// into the clone, unstaged. Recorded on the instance as `@local`.
55    local: bool,
56    /// What happened to each edit written to, or discarded from, a clone.
57    landed: RefCell<Vec<String>>,
58}
59
60/// A policy revision in effect: its number, whether it is in shadow, and the
61/// policy itself.
62pub struct Active {
63    revision: i64,
64    shadow: bool,
65    policy: Policy,
66}
67
68/// The routing policy in effect, if any.
69pub 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
80/// A warning per agent node that the policy in effect would refuse, because
81/// no route names it. Under an applied policy, a node is never sent to the
82/// settings; a policy in shadow refuses nothing.
83pub 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/// A cap an instance hit. `advance` records it on the instance outside any
108/// transaction a node was writing in, so the record survives the rollback.
109#[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/// What a guard and a prompt may read about a unit beyond its fields: the
121/// verdicts written against it, and the run an `edit` recorded for it.
122#[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        // Oldest first, so a later run for the same unit replaces an earlier.
136        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
146/// What one invocation of a pass was told: where to work, the arguments the
147/// instance was given, and the plan the developer approved, if any.
148pub struct Invocation<'a> {
149    pub home: &'a Path,
150    pub args: &'a Value,
151    pub approve: Option<&'a str>,
152    /// Approve every plan as it is reached, until the instance finishes or
153    /// waits. `batch_budget` still sizes each plan, and the caps still hold.
154    pub unattended: bool,
155}
156
157/// What one node of a plan would spend on.
158#[derive(Debug, Clone, PartialEq)]
159pub struct Planned {
160    pub node: String,
161    pub op: String,
162    /// The units it would run over, by id.
163    pub units: Vec<String>,
164    /// `None` for a node a rule decides, which costs nothing.
165    pub agent: Option<String>,
166    pub model: Option<String>,
167    /// Upper bound on what running it would spend.
168    pub cost: f64,
169}
170
171impl Planned {
172    pub fn free(&self) -> bool {
173        self.agent.is_none()
174    }
175}
176
177/// The agent runs a pass would make, which is what an approval names.
178#[derive(Debug, Clone, PartialEq, Default)]
179pub struct Plan {
180    pub steps: Vec<Planned>,
181    /// What the steps do not show but a run depends on: the arguments, each
182    /// node's definition, each unit's data and worker, the policy, the budget.
183    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    /// A short hash of what the plan runs: each node, its units, its worker
196    /// and its model, and the basis. Approving by id means a pass runs what
197    /// was printed and nothing else; a frontier, a target or an argument that
198    /// changed gives another id. Unit ids alone repeat across instances.
199    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        // FNV-1a: stable across builds and machines, unlike `DefaultHasher`.
211        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/// What a pass did and where it stopped.
221#[derive(Debug, Default)]
222pub struct Outcome {
223    /// The agent runs the next pass would make, priced. Empty when no agent
224    /// node is ready.
225    pub plan: Plan,
226    /// Nodes holding units that cannot move yet, with how many: a check
227    /// waiting on the world, and every node after it.
228    pub waiting: Vec<(String, usize)>,
229    /// Units a node refused this pass, each with its reason.
230    pub refused: Vec<String>,
231    /// Whether an approved plan ran.
232    pub ran: bool,
233    /// Each edit of a local instance: applied to its clone, or discarded.
234    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            // A quadrant holds items and signals at once, so it names no one
308            // type and cannot be an argument (section 4.3).
309            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
375/// The moves a root unit makes leaving `@input`, as `(edge, taken, why not)`.
376/// Derived, so a dry run can price a bag it never wrote.
377pub 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
394/// Admits a root unit into the graph: one move per edge leaving `@input`.
395pub 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
402/// Nodes that may run now, in dependency order, each with the units waiting
403/// at it. A node is ready once every node before it has finished: it holds
404/// nothing and everything before it has finished. A node therefore sees its
405/// whole bag, which a `reduce` joining several branches depends on.
406fn 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
435/// Nodes holding units, with how many. What a pass reports when it stops with
436/// work that cannot move yet.
437fn 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
450/// The units one agent run takes: one per unit, or one per group for a
451/// `reduce`, which is what the worst case prices.
452fn 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
469/// The worker a plan shows for a node. An `edit` is routed at dispatch, where
470/// its class and complexity are known, so under a policy it shows the route.
471fn 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
501/// A worker and a model for one run of a node that edits nothing, and the
502/// route that chose them. The flags win, then the route, then the settings,
503/// exactly as a dispatch resolves. Under a policy, a node no route serves is
504/// refused rather than sent to the settings.
505struct Resolved {
506    chosen: Chosen,
507    /// The revision, the route it chose and that route's approval. Under
508    /// shadow only the first two, and the route may be absent.
509    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
542/// The agent runs the ready nodes would make, in dependency order, as many as
543/// `batch_budget` admits at `agent_budget` each. What `--approve` names.
544fn 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            // Units in projects of other tiers can resolve apart.
596            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
615/// The ready nodes of a bag, priced, whether or not the store holds it. What
616/// a dry run reads. A node a rule decides is listed as free, and the agent
617/// nodes form the plan a first pass would print.
618pub 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
647/// The frontier of a stored instance, priced, without running anything.
648pub 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
661/// One line per node, priced. What the developer approves.
662pub 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
688/// Whether a rule decides this node, which is what makes it free.
689fn 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
698/// Units waiting at each node: those an incoming edge admitted and this node
699/// has not handled. A node whose units have all been handled is done, which
700/// is how a second pass advances nothing.
701fn 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
729/// The moves a unit makes when it leaves `node`, and the guard that refused it
730/// where it goes nowhere. Recorded whatever happens, so a unit that stopped is
731/// answerable for.
732fn 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        // Settled here. The refusals above say why.
774        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
785/// Where a unit goes when its node did not run cleanly for it: a default edge
786/// alone, and only one that carries the unit's own type, else nowhere. An
787/// unguarded edge is for units the node produced; taking it here would pass
788/// on an unconfirmed claim, or a unit of the wrong type (section 10).
789fn 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
824/// The `@` fields a guard may read: the unit's own, every verdict written
825/// against it, and `@run` once an `edit` has recorded one.
826fn 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
851/// Where a unit's "it went nowhere from here" move is recorded. Moves are
852/// keyed by edge, and a unit that leaves a node by no edge has no edge to key
853/// on, so each node gets one index past the edge list. Per node rather than per
854/// unit, because a fork admits one unit into two nodes and both must be
855/// answerable for it.
856fn 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
861/// Whether this node has already handled the unit: a move on one of its own
862/// outgoing edges, or its settled marker.
863fn 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
878/// Writes a unit a node produced, once the instance cap admits it, and routes
879/// it. `held` is the instance's unit count, which this advances.
880///
881/// The caps are what `activate` weighed against the budget, so the runtime has
882/// to hold them or the approved figure bounds nothing.
883fn 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
904/// Stops the pass at a cap, naming the node and what it exceeded.
905fn 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
912/// What one input unit may yield at a `0..n` map: the declared width, read at
913/// its parameter's maximum, which is the figure the worst case used.
914fn 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
923/// Runs every ready node a rule decides, then either reports the plan for the
924/// agent nodes that are ready or, when `approve` names that plan, runs it.
925/// An approval runs one plan: nodes that become ready after it are priced for
926/// the next invocation, never run under an approval that did not name them.
927pub 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    // One iteration per node run. A graph's worst case bounds the units, so a
973    // pass that exceeds this is a bug rather than a long job.
974    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        // Free nodes first, every time: a rule costs nothing and needs no
982        // approval.
983        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
1048/// A node a rule decides. It reads what is already recorded, writes units or a
1049/// verdict or a file `pma` owns, and costs nothing. A unit the rule cannot
1050/// take is refused on its own; the pass goes on.
1051fn 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                    // The unit itself stops here: its children carry on.
1117                    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            // A reduce decides per group, so the groups are formed first and
1133            // the rule says which of each group's units carry on.
1134            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                        // The input stops here and its replacement carries
1171                        // on. Without this the node would be offered the same
1172                        // unit on the next turn of the frontier.
1173                        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                // The file and the moves are written together: a refusal
1215                // writes nothing to the file, and a unit already written is
1216                // found there on a resumed pass rather than written twice.
1217                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
1233/// A node that passes a unit on rather than replacing it still has to record
1234/// that it handled it, or the frontier would offer the same unit again.
1235fn 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
1239/// The rules a `map` may name. Each reads what `pma` already knows. An error
1240/// is the reason the unit is refused.
1241fn 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        // A projection by field name: keep what the target type declares.
1253        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        // A run that is final no longer holds its task or its
1300        // worktree, so it is not work a workflow can act on.
1301        "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        // `gh`'s fields under the names the built-in `item` uses, projected
1327        // onto the type the node emits and checked against it.
1328        "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                    // An empty value, such as an issue with no labels, is an
1363                    // absent field rather than a `line` of no characters.
1364                    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
1383/// The values of every `unique: "normalised"` field already in the node's
1384/// bag, keyed by field, so a repeat is dropped rather than minted.
1385fn 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
1413/// Why a produced unit repeats one already in the bag or an open item of its
1414/// project, by a `unique: "normalised"` field. `None` when it does not.
1415fn 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
1454/// A check's result for one unit: a verdict, or not ready yet, which leaves
1455/// the unit waiting at the check for a later pass.
1456enum 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
1465/// A check writes `passed`, `failed` or `unknown`. An unknown is a result: a
1466/// check that could not run is not evidence either way.
1467fn 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        // The rest read a run. A unit that has none has no evidence either
1487        // way, which is what `unknown` says.
1488        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
1498/// The run behind a unit: the latest an `edit` recorded against it, so a lap
1499/// reads its own attempt, or the run a `run` unit names by its `id`. This is
1500/// what `@run` names.
1501fn 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
1521/// A check about a run. `ci-green` and `pr-merged` wait while the run is on
1522/// its way: not yet published, or a pull request still open or still
1523/// checking. They read the pull request by the URL `pma pr` recorded, since the
1524/// worktree is gone by then.
1525fn 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            // Settled already: no need to ask GitHub again.
1584            (_, RunState::Merged) => ok(true),
1585            (_, RunState::Closed) => verdict(
1586                "failed",
1587                Some("the pull request was closed without merging".into()),
1588            ),
1589            // Pushed to the default branch: the change landed with no pull
1590            // request to merge.
1591            (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
1652/// `gh`, bounded like a scan's calls.
1653fn 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
1660/// Writes a unit where `pma` owns the file. A `todo` edit is uncommitted in the
1661/// user's clone, as `pma sync` writes one: `scripts/commit_todo.py` commits it.
1662/// The inner error refuses this unit alone.
1663fn 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                    // Already open: a resumed pass finds its own write, and a
1717                    // second copy would be a duplicate the linter refuses.
1718                    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                    // `pma` writes the whole line, so it gives the item an id
1727                    // at once: rewording it later keeps its age.
1728                    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
1763/// The rules a `reduce` may name, applied to one group: which units carry on,
1764/// and which stop here. Ordering is the project's priority order, which is why
1765/// `rank` is refused on a type that declares no `priority`.
1766fn 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
1793/// Whether the project's working tree holds a path matching `glob`.
1794fn 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
1819/// A node a model decides, over the units a plan named. One run per unit for a
1820/// `map`, one per group for a `reduce`.
1821///
1822/// The model reads `in.json` and writes `out.json` under the instance's
1823/// artifact directory, and it works in a detached worktree rather than in the
1824/// user's clone.
1825///
1826/// Runs go `max_parallel` at a time. The store is a single connection, so it
1827/// stays on this thread: a worker is handed everything it needs as files and
1828/// paths, and what comes back is recorded here, in the order it arrives.
1829fn 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
1879/// One agent run, staged: everything the store and the repository had to say
1880/// about it, so a worker thread needs nothing but the filesystem.
1881struct 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    /// Where the run's files are kept once its tree is removed.
1891    artifacts: std::path::PathBuf,
1892    log: std::path::PathBuf,
1893    /// A directory outside the worktree for `agent::restrict`. Inside the
1894    /// tree the agent could write credentials or hooks into it.
1895    agent_env: std::path::PathBuf,
1896}
1897
1898/// What a worker thread produced: no store, no repository, just the run's own
1899/// numbers and the units it wrote.
1900struct Ran {
1901    seconds: i64,
1902    cost_usd: Option<f64>,
1903    summary: String,
1904    produced: std::result::Result<Vec<Value>, String>,
1905}
1906
1907/// The commit a read node works from. In a workflow that edits, the head of
1908/// the fetched default branch, which is where its `edit` nodes start, so a
1909/// finding names code the fix will see. Otherwise the clone's `HEAD`, which
1910/// lets a review read local commits. Fetched once per project per pass.
1911fn 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
1936/// The output a step accepts, stated from the type it declares and the op
1937/// it applies, and appended to its prompt. `accept` checks exactly this, so
1938/// an agent is told the limits it will be held to rather than a prompt
1939/// author restating them by hand.
1940fn 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        // A kept or annotated object comes back as it was given, but for the
1968        // fields the step may set.
1969        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
1998/// Everything a run needs before a thread can take it: the worker the route
1999/// chose, the files it reads and writes, the worktree it works in, and the
2000/// `runs` row that records it. The inner error refuses the batch.
2001fn 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    // A read-only node still gets a tree of its own. An agent that writes to
2044    // the clone would put a change past every review this tool has. The trees
2045    // are added from this thread: two `git worktree add` in one repository
2046    // contend for its index lock.
2047    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    // The files a run reads and writes sit inside its tree, where any worker
2071    // may write: an agent allowed to edit its working directory is not always
2072    // allowed to write outside it. `record` copies them to `dir` before the
2073    // tree goes, so each run's files outlive it.
2074    // Before the agent, as dispatch takes one: a setting it adds to the
2075    // clone would otherwise become the baseline of the next run there.
2076    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
2170/// The agent itself. No store and no `&Ctx`: this is the half that runs on a
2171/// worker thread.
2172fn 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
2218/// What a finished run leaves behind: the row, the units it wrote, and the
2219/// worktree it no longer needs.
2220///
2221/// The row is written first, so its cost is recorded whatever follows. The
2222/// units and the moves that settle the batch are one transaction: a pass
2223/// killed part way leaves the batch waiting, not half minted, and a cap hit
2224/// part way rolls back what the batch had minted.
2225fn 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    // The tree was the agent's scratch space, and a node that is not an
2252    // `edit` publishes nothing from it.
2253    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
2280/// Copies what a run read and wrote out of its tree before the tree goes.
2281/// A file that will not copy is left: the run's row already says what it did.
2282fn 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
2294/// `out.json` as a list of units, or why it could not be read. A run that
2295/// wrote no file failed: an agent that found nothing writes `[]`, and one that
2296/// could not write at all must not read as one that found nothing.
2297fn 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
2309/// A prose document the node also writes, checked as section 10 states.
2310fn 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
2321/// What a model returned, admitted into the graph only where it matches the
2322/// declared type and the node's own contract (section 10).
2323///
2324/// An annotation or a filter returns the units it was given. What is minted is
2325/// the unit it was given with only the `writes` fields taken from the model,
2326/// so a model can neither rewrite nor drop a field it was not asked for (W17).
2327/// A `reduce` names the units each output came from in `@from`, and writes at
2328/// most one unit per group.
2329fn 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    // Each output with the unit it descends from and the data to mint.
2374    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    // The input stops here whatever it produced: its children carry on, and a
2462    // filter's dropped units are answerable for by the moves recorded.
2463    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
2479/// A returned unit without the `@` fields, which are `pma`'s.
2480fn 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
2488/// The unit a node was given, with the fields in `writes` taken from what the
2489/// model returned.
2490fn 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
2508/// Whether a returned unit changed only what the node declared it may write.
2509/// A field left out is not a change: the unit is rebuilt from what it was
2510/// given.
2511fn 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
2541/// The unit as an agent reads it: its declared fields, plus the `@` fields of
2542/// section 4, so a prompt and a returned unit can name the same identity.
2543fn 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    // A document name reaches the filesystem, so it stays a bare file name.
2568    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
2578/// The next numbered directory for this node, so each run keeps its own files
2579/// rather than overwriting the last.
2580fn 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
2598/// The three placeholder namespaces of W5, plus the file names a run works
2599/// with. Each is checked against its declaration where the document is read,
2600/// so anything left unreplaced here is a field the unit does not carry. A list
2601/// is written one entry per line.
2602fn 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
2638/// An `edit` node: the only op that changes a repository. It goes through
2639/// `dispatch::prepare` and the phase 1 gates like any other dispatch, and the
2640/// routing policy sees its node and lap, so a workflow chooses order, prompts
2641/// and parameters, never authority (W14). The runs it leaves are reviewed and
2642/// published the way every other run is.
2643///
2644/// Every unit is prepared before any is run, because `dispatch::execute`
2645/// spreads runs over `max_parallel`.
2646fn 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    // A local edit starts from the clone as the edits before it left it, so
2654    // only `max_parallel` of them share a base.
2655    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            // One counter per lineage, whatever node or lap spends it (W23).
2684            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            // A run the batch budget refused never reached the agent, so its unit
2740            // keeps its place in the frontier and the next plan names it again.
2741            // Its worktree goes, or the branch would be in the way.
2742            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                // The node's own check writes its verdict against the unit, under
2760                // the rule's name, so a guard reads `@verify` rather than the run.
2761                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
2785/// The unit an edit's attempts count against: itself, or for a unit a lap
2786/// minted, the unit the lap started from. Not the root: findings that one
2787/// project yielded are separate tasks, and one exhausted lineage would refuse
2788/// them all.
2789fn 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
2804/// Edits this instance has made, which is what `caps.max_edits` bounds.
2805/// Counted from the runs recorded, because a pass holds no state. A run the
2806/// batch budget never started made no edit.
2807fn 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
2827/// A one-line title for the branch and the run list. The prompt itself is the
2828/// run's details.
2829fn 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    /// The contract states what `accept` checks: the shape by op, and each
2859    /// field's limits, from the declared type.
2860    #[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}