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

@@ -2266,6 +2266,15 @@ pub struct WorkflowDto {
pub name: String,
pub enabled: bool,
pub steps: usize,
/// The full stored definition — present so `pic pull` can rebuild the
/// `[[workflows]]` manifest block. `#[serde(default)]` tolerates an older
/// server that predates the field (the definition is then empty).
#[serde(default = "empty_workflow_definition")]
pub definition: picloud_shared::WorkflowDefinition,
}
fn empty_workflow_definition() -> picloud_shared::WorkflowDefinition {
picloud_shared::WorkflowDefinition { steps: Vec::new() }
}
#[derive(Debug, Deserialize)]

View File

@@ -18,8 +18,9 @@ use crate::client::{Client, VarOwnerArg};
use crate::config;
use crate::manifest::{
CronTriggerSpec, DocsTriggerSpec, FilesTriggerSpec, KvTriggerSpec, Manifest, ManifestApp,
ManifestRoute, ManifestScript, ManifestSecrets, ManifestTriggers, PubsubTriggerSpec,
QueueTriggerSpec, MANIFEST_FILE,
ManifestRoute, ManifestScript, ManifestSecrets, ManifestTriggers, ManifestWorkflow,
ManifestWorkflowRetry, ManifestWorkflowStep, PubsubTriggerSpec, QueueTriggerSpec,
MANIFEST_FILE,
};
use crate::output::{KvBlock, OutputMode};
@@ -45,6 +46,7 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
let app = client.apps_get(app_ident).await?;
let scripts = client.scripts_list_by_app(app_ident).await?;
let triggers = client.triggers_list(app_ident).await?.triggers;
let workflows = client.workflows_list(app_ident).await?;
let secrets = client
.secrets_list(VarOwnerArg::App(app_ident), None)
.await?
@@ -251,7 +253,7 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
},
vars: manifest_vars,
suppress: crate::manifest::ManifestSuppress::default(),
workflows: Vec::new(),
workflows: workflows.iter().map(wire_workflow_to_manifest).collect(),
};
std::fs::write(&manifest_path, manifest.to_toml()?)
@@ -264,7 +266,8 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
.field("scripts", manifest.scripts.len().to_string())
.field("routes", manifest.routes.len().to_string())
.field("triggers", trigger_count(&manifest.triggers).to_string())
.field("secrets", manifest.secrets.names.len().to_string());
.field("secrets", manifest.secrets.names.len().to_string())
.field("workflows", manifest.workflows.len().to_string());
block.print(mode);
Ok(())
}
@@ -273,6 +276,70 @@ fn trigger_count(t: &ManifestTriggers) -> usize {
t.kv.len() + t.docs.len() + t.files.len() + t.cron.len() + t.pubsub.len() + t.queue.len()
}
/// Rebuild a `[[workflows]]` manifest block from the server's stored definition
/// (the inverse of `plan::workflow_to_wire`). Without this a `pic pull` would
/// drop the app's workflows, and a subsequent `pic apply --prune` would delete
/// them from the server.
fn wire_workflow_to_manifest(w: &crate::client::WorkflowDto) -> ManifestWorkflow {
ManifestWorkflow {
name: w.name.clone(),
enabled: w.enabled,
steps: w
.definition
.steps
.iter()
.map(wire_step_to_manifest)
.collect(),
}
}
fn wire_step_to_manifest(s: &picloud_shared::WorkflowStepDef) -> ManifestWorkflowStep {
ManifestWorkflowStep {
name: s.name.clone(),
function: s.function.clone(),
workflow: s.workflow.clone(),
// JSON step input → TOML. A whole-null input is the "absent" encoding;
// anything else round-trips through serde. A value TOML can't represent
// (e.g. a nested null) is dropped with a warning rather than aborting
// the whole pull.
input: if s.input.is_null() {
None
} else {
match toml::Value::try_from(&s.input) {
Ok(tv) => Some(tv),
Err(e) => {
eprintln!(
"warning: workflow step `{}` input not representable in TOML ({e}); \
omitting it from the manifest",
s.name
);
None
}
}
},
depends_on: s.depends_on.clone(),
when: s.when.clone(),
retry: s.retry.as_ref().map(|r| ManifestWorkflowRetry {
max_attempts: r.max_attempts,
// Exponential is the default → omit; likewise the default base_ms.
backoff: match r.backoff {
picloud_shared::WorkflowBackoff::Exponential => None,
other => Some(other),
},
base_ms: if r.base_ms == 500 {
None
} else {
Some(r.base_ms)
},
}),
// Fail is the default → omit; Continue is explicit.
on_error: match s.on_error {
picloud_shared::OnError::Fail => None,
picloud_shared::OnError::Continue => Some(picloud_shared::OnError::Continue),
},
}
}
/// True if `name` is safe to use as a single path component in `scripts/`.
/// Rejects empty/over-long names, path separators, `.`/`..`, leading dots,
/// and any deceptive display character — a server-returned name is otherwise

View File

@@ -651,9 +651,11 @@ pub struct ManifestWorkflowStep {
pub when: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retry: Option<ManifestWorkflowRetry>,
/// `fail` (default) or `continue`.
/// `fail` (default) or `continue`. Typed against the shared enum so a typo
/// (`on_error = "retry"`) fails at TOML parse with an "unknown variant"
/// message pointing at this step, rather than an opaque server-side error.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_error: Option<String>,
pub on_error: Option<picloud_shared::OnError>,
}
/// Per-step retry policy in the manifest (mirrors the server `WorkflowRetry`).
@@ -661,9 +663,10 @@ pub struct ManifestWorkflowStep {
#[serde(deny_unknown_fields)]
pub struct ManifestWorkflowRetry {
pub max_attempts: u32,
/// `constant` | `linear` | `exponential` (default).
/// `constant` | `linear` | `exponential` (default). Typed against the shared
/// enum so a bad value fails at TOML parse, not opaquely on the server.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backoff: Option<String>,
pub backoff: Option<picloud_shared::WorkflowBackoff>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_ms: Option<u32>,
}