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:
MechaCat02
2026-07-12 18:56:44 +02:00
parent bd9c3fa4f0
commit eaf5ace30f
10 changed files with 331 additions and 37 deletions

View File

@@ -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!([