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:
MechaCat02
2026-07-12 17:30:16 +02:00
parent 41a21c0551
commit 74d5874384
4 changed files with 674 additions and 109 deletions

View File

@@ -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());
}
}