feat(workflows): M4 — nested sub-workflows (start_child, parent-park, depth ceiling)
A `workflow`-kind step now starts a nested sub-workflow run instead of a function. The orchestrator's step processing is restructured into prepare → dispatch (`Prep`): after the shared context/`when`/input resolution, a function step runs through the executor as before, while a workflow step: - checks the nesting depth ceiling (`PICLOUD_WORKFLOW_MAX_DEPTH`; the child would run at parent depth + 1) — over-deep → the step fails, not infinite; - resolves the child workflow by name in the run's app scope; - `start_child_and_park`: in one token-gated tx, seeds a child run (depth + 1, `parent_run_id`/`parent_step_id` linkage, correlated under the same `root_execution_id`) and PARKS the parent step (`running`, claim_token cleared, `child_run_id` set). A parked step is never re-claimed (only `ready` steps are) nor reclaimed (only *leased* running steps). The token-gated park runs before the child insert, so a stale claim writes nothing (no orphan). Each tick first runs `resume_finished_children`: a parent step parked on a now-terminal child is resolved (child output → parent step output, or child error → parent step failed) and the parent run advanced — idempotent via a `status='running'` gate. The child's own steps are claimed by the same global scan, so nesting is just more runs. A failed sub-workflow honors the parent step's `on_error`. Shared plumbing extracted for reuse: `advance_run_tx` (out of `complete_step_and_advance`) and `seed_run_tx` (out of `start_run`). Apply-time soft-warn (`workflow_nesting_warnings`, wired into `plan_warnings`): a workflow that nests into itself — directly or via a mutual cycle within the bundle — is flagged at plan (bounded by the depth ceiling, but almost always a bug). Not an error. Tests: nested output-flows-to-parent + depth-ceiling-fails-the-run (DB-gated, end-to-end), self/mutual-cycle warning (pure unit). fmt + clippy -D warnings clean, 449 manager-core lib tests, 13 orchestrator DB tests, 26 CLI journeys (workflows/apply/plan/prune) green, binary boots. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3616,6 +3616,7 @@ impl ApplyService {
|
||||
warnings.extend(unreachable_endpoint_warnings(bundle));
|
||||
}
|
||||
warnings.extend(self.dangling_suppress_warnings(owner, bundle).await?);
|
||||
warnings.extend(workflow_nesting_warnings(bundle));
|
||||
Ok(warnings)
|
||||
}
|
||||
|
||||
@@ -5280,6 +5281,64 @@ fn disabled_target_warnings(bundle: &Bundle) -> Vec<String> {
|
||||
out
|
||||
}
|
||||
|
||||
/// M4 soft-warn: a `workflow`-kind step that (directly or transitively, within
|
||||
/// this bundle) nests back into its own workflow forms a cycle. This is not an
|
||||
/// error — the runtime depth ceiling (`PICLOUD_WORKFLOW_MAX_DEPTH`) bounds it —
|
||||
/// but it's almost always a mistake, so we surface it at plan time. Only edges
|
||||
/// to workflows present in the same bundle are considered (a reference to an
|
||||
/// external workflow can't be resolved here).
|
||||
fn workflow_nesting_warnings(bundle: &Bundle) -> Vec<String> {
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
if bundle.workflows.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let names: HashSet<String> = bundle
|
||||
.workflows
|
||||
.iter()
|
||||
.map(|w| w.name.to_lowercase())
|
||||
.collect();
|
||||
// from → the set of in-bundle workflows it nests into.
|
||||
let mut edges: HashMap<String, BTreeSet<String>> = HashMap::new();
|
||||
for w in &bundle.workflows {
|
||||
let from = w.name.to_lowercase();
|
||||
for s in &w.definition.steps {
|
||||
if let Some(t) = s.workflow.as_deref() {
|
||||
let t = t.to_lowercase();
|
||||
if names.contains(&t) {
|
||||
edges.entry(from.clone()).or_default().insert(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for w in &bundle.workflows {
|
||||
let start = w.name.to_lowercase();
|
||||
// Reachability from `start`: if we can get back to `start`, it's cyclic.
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
let mut stack: Vec<String> = edges.get(&start).into_iter().flatten().cloned().collect();
|
||||
let mut cyclic = false;
|
||||
while let Some(n) = stack.pop() {
|
||||
if n == start {
|
||||
cyclic = true;
|
||||
break;
|
||||
}
|
||||
if seen.insert(n.clone()) {
|
||||
if let Some(next) = edges.get(&n) {
|
||||
stack.extend(next.iter().cloned());
|
||||
}
|
||||
}
|
||||
}
|
||||
if cyclic {
|
||||
out.push(format!(
|
||||
"workflow `{}` can nest into itself (a sub-workflow cycle) — runs are \
|
||||
bounded by PICLOUD_WORKFLOW_MAX_DEPTH, not infinite, but this is likely a bug",
|
||||
w.name
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Per-kind structural validation for one desired trigger — kept at parity
|
||||
/// with the interactive trigger API so `apply` can't write a trigger the
|
||||
/// dashboard would have rejected. Pure (no live state), so it's unit-tested
|
||||
@@ -6695,4 +6754,47 @@ mod tests {
|
||||
]));
|
||||
assert!(validate_workflow_definition("w", &bad_tmpl).is_err());
|
||||
}
|
||||
|
||||
fn bundle_workflow(name: &str, steps: serde_json::Value) -> BundleWorkflow {
|
||||
BundleWorkflow {
|
||||
name: name.into(),
|
||||
definition: wf_def(steps),
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_nesting_warning_flags_self_and_mutual_cycles() {
|
||||
// Direct self-nest.
|
||||
let mut b = empty_bundle();
|
||||
b.workflows = vec![bundle_workflow(
|
||||
"loop",
|
||||
serde_json::json!([{ "name": "s", "workflow": "loop" }]),
|
||||
)];
|
||||
let w = workflow_nesting_warnings(&b);
|
||||
assert_eq!(w.len(), 1);
|
||||
assert!(w[0].contains("loop") && w[0].contains("cycle"));
|
||||
|
||||
// Mutual cycle w1 -> w2 -> w1.
|
||||
let mut b = empty_bundle();
|
||||
b.workflows = vec![
|
||||
bundle_workflow("w1", serde_json::json!([{ "name": "s", "workflow": "w2" }])),
|
||||
bundle_workflow("w2", serde_json::json!([{ "name": "s", "workflow": "w1" }])),
|
||||
];
|
||||
assert_eq!(workflow_nesting_warnings(&b).len(), 2);
|
||||
|
||||
// Acyclic nesting (parent -> child, child is a leaf) → no warning.
|
||||
let mut b = empty_bundle();
|
||||
b.workflows = vec![
|
||||
bundle_workflow(
|
||||
"parent",
|
||||
serde_json::json!([{ "name": "s", "workflow": "child" }]),
|
||||
),
|
||||
bundle_workflow(
|
||||
"child",
|
||||
serde_json::json!([{ "name": "s", "function": "f" }]),
|
||||
),
|
||||
];
|
||||
assert!(workflow_nesting_warnings(&b).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user