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:
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -154,6 +154,118 @@ fn workflow_with_a_cycle_is_rejected() {
|
||||
assert!(err.contains("cycle"), "expected a cycle error, got:\n{err}");
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn workflow_duplicate_name_is_rejected() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("wfdup");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let dir = manifest_dir();
|
||||
seed_scripts(&dir);
|
||||
// Two workflows whose names collide case-insensitively — the reconcile keys
|
||||
// by lower(name), so this must be rejected before it silently drops one.
|
||||
let dup = format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"WF Dup\"\n\n{SCRIPTS_BLOCK}\
|
||||
[[workflows]]\nname = \"Pipe\"\n\n\
|
||||
[[workflows.steps]]\nname = \"a\"\nfunction = \"validate\"\n\n\
|
||||
[[workflows]]\nname = \"pipe\"\n\n\
|
||||
[[workflows.steps]]\nname = \"a\"\nfunction = \"charge\"\n"
|
||||
);
|
||||
let path = dir.path().join("picloud.toml");
|
||||
fs::write(&path, &dup).unwrap();
|
||||
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&path)
|
||||
.output()
|
||||
.expect("apply");
|
||||
assert!(!out.status.success(), "duplicate names should be rejected");
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
err.contains("duplicate workflow"),
|
||||
"expected a duplicate-workflow error, got:\n{err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A workflow survives a `pull` → `plan` round-trip with no phantom drift.
|
||||
/// Regression for the bug where `pic pull` dropped `[[workflows]]`, so a
|
||||
/// subsequent `apply --prune` would silently delete them.
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn workflow_pull_round_trips_clean() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("wfpull");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let dir = manifest_dir();
|
||||
seed_scripts(&dir);
|
||||
// Exercise the full step shape the pull mapper must round-trip: input
|
||||
// template, when, depends_on, retry (with defaults omitted), on_error.
|
||||
let manifest = format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"WF Pull\"\n\n{SCRIPTS_BLOCK}\
|
||||
[[workflows]]\nname = \"pipeline\"\n\n\
|
||||
[[workflows.steps]]\nname = \"v\"\nfunction = \"validate\"\n\
|
||||
input = {{ order = \"{{{{ input.order }}}}\" }}\n\n\
|
||||
[[workflows.steps]]\nname = \"c\"\nfunction = \"charge\"\n\
|
||||
depends_on = [\"v\"]\nwhen = \"steps.v.output.ok == true\"\n\
|
||||
on_error = \"continue\"\n[workflows.steps.retry]\nmax_attempts = 3\n"
|
||||
);
|
||||
let path = dir.path().join("picloud.toml");
|
||||
fs::write(&path, &manifest).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&path)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Pull the live state into a fresh dir, then plan it back — the workflow
|
||||
// must round-trip to a clean no-op (no create / update / delete).
|
||||
let out = TempDir::new().expect("tempdir");
|
||||
common::pic_as(&env)
|
||||
.args(["pull", &slug, "--dir"])
|
||||
.arg(out.path())
|
||||
.assert()
|
||||
.success();
|
||||
let pulled = out.path().join("picloud.toml");
|
||||
let toml = fs::read_to_string(&pulled).unwrap();
|
||||
assert!(
|
||||
toml.contains("[[workflows]]") && toml.contains("pipeline"),
|
||||
"pulled manifest is missing the workflow:\n{toml}"
|
||||
);
|
||||
|
||||
let plan = common::pic_as(&env)
|
||||
.args(["plan", "--file"])
|
||||
.arg(&pulled)
|
||||
.output()
|
||||
.expect("plan");
|
||||
let stdout = String::from_utf8_lossy(&plan.stdout);
|
||||
assert!(
|
||||
plan.status.success(),
|
||||
"plan failed: {}\n{stdout}",
|
||||
String::from_utf8_lossy(&plan.stderr)
|
||||
);
|
||||
// The workflow row must be a no-op; nothing created/updated/deleted.
|
||||
assert!(
|
||||
!stdout.contains("create") && !stdout.contains("update") && !stdout.contains("delete"),
|
||||
"pull→plan should diff clean for workflows, got:\n{stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
/// M5 end-to-end: `pic workflows run` starts a run, the durable orchestrator
|
||||
/// (running inside the fixture's picloud) executes both steps, and
|
||||
/// `run-status` shows it succeeded.
|
||||
|
||||
Reference in New Issue
Block a user