fix(workflows): close review gaps — pull round-trip, dup-names, reclaim budget
Adversarial review of the v1.2 Workflows track surfaced two HIGH footguns and several correctness/hardening gaps. Fixes: HIGH - pull round-trip: `pic pull` dropped `[[workflows]]`, so a later `apply --prune` silently deleted them. The list endpoint now returns the full `definition`; pull rebuilds the manifest block (inverse of workflow_to_wire). - case-colliding names: two workflows differing only by case collided in the reconcile diff (keyed by lower(name)), silently dropping one. Rejected up front in validate_bundle_for. MEDIUM - reclaim retry budget: a crashed attempt (no outcome) consumed the retry budget. reclaim_stale_steps now decrements `attempt` (floored) and clears `next_attempt_at`, so a crash no longer counts as a failed try. - on_error/backoff: typed the manifest fields against the shared enums so a bad value fails at TOML parse with a clear message, not an opaque 500. - dedupe depends_on: a repeated dependency inflated the Kahn in-degree past the single decrement, reporting a spurious cycle for a valid DAG. Count distinct. - canceled child: a canceled sub-workflow resolved the parent step with a message naming the cause instead of a generic "failed"; documented that a run-level cancel op is not yet supported. LOW - list_run_steps is now app-scoped at the query (JOIN workflow_runs) rather than relying on caller pre-verification. - partial failure is surfaced: a run that succeeds with on_error=continue failures records them in the run's `error` field. - admin-started runs log the root_execution_id against the principal. - documented the deliberate when(missing→false) vs template(missing→fail) asymmetry; corrected the claim's atomicity comment. Tests: unit (dedupe-deps), DB-gated reclaim-budget assertion, and two new CLI journeys (duplicate-name rejection, pull→plan clean round-trip). fmt + clippy -D warnings clean; 450 lib + 14 orchestrator DB + 5 workflow journeys pass; schema snapshot unchanged (no migration). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1157,6 +1157,19 @@ impl ApplyService {
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
// Duplicate workflow names collide in the reconcile diff (keyed by
|
||||
// `lower(name)`), silently dropping all but one. Reject them up front —
|
||||
// case-insensitively, matching the diff key and the DB's partial-unique
|
||||
// index on `(app_id, lower(name))`.
|
||||
let mut seen_workflows: HashSet<String> = HashSet::new();
|
||||
for w in &bundle.workflows {
|
||||
if !seen_workflows.insert(w.name.to_lowercase()) {
|
||||
return Err(ApplyError::Invalid(format!(
|
||||
"duplicate workflow name `{}` (names are case-insensitive)",
|
||||
w.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
for w in &bundle.workflows {
|
||||
validate_workflow_definition(&w.name, &w.definition).map_err(ApplyError::Invalid)?;
|
||||
}
|
||||
@@ -4233,12 +4246,14 @@ fn validate_workflow_definition(
|
||||
)
|
||||
})?;
|
||||
}
|
||||
// DAG acyclicity via Kahn topological sort.
|
||||
// DAG acyclicity via Kahn topological sort. Count DISTINCT deps: a step that
|
||||
// lists the same dependency twice inflates its in-degree past what the
|
||||
// single-decrement in the drain loop below can undo, which would otherwise
|
||||
// report a spurious cycle for a valid DAG.
|
||||
let mut indeg: Map<&str, usize> = def.steps.iter().map(|s| (s.name.as_str(), 0)).collect();
|
||||
for s in &def.steps {
|
||||
for _dep in &s.depends_on {
|
||||
*indeg.get_mut(s.name.as_str()).unwrap() += 1;
|
||||
}
|
||||
let distinct: BTreeSet<&str> = s.depends_on.iter().map(String::as_str).collect();
|
||||
*indeg.get_mut(s.name.as_str()).unwrap() += distinct.len();
|
||||
}
|
||||
let mut queue: Vec<&str> = indeg
|
||||
.iter()
|
||||
@@ -6730,6 +6745,17 @@ mod tests {
|
||||
.contains("depends on itself"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_validation_tolerates_duplicate_depends_on() {
|
||||
// A step listing the same dep twice is a valid DAG — the topo sort must
|
||||
// not report a spurious cycle from the double-counted in-degree.
|
||||
let dup_dep = wf_def(serde_json::json!([
|
||||
{ "name": "a", "function": "fa" },
|
||||
{ "name": "b", "function": "fb", "depends_on": ["a", "a"] },
|
||||
]));
|
||||
assert!(validate_workflow_definition("w", &dup_dep).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_validation_detects_cycles() {
|
||||
let cyclic = wf_def(serde_json::json!([
|
||||
|
||||
Reference in New Issue
Block a user