fix(workflows): close re-review gaps in the pull round-trip + partial-failure UI
A re-review of eaf5ace surfaced three follow-ups, all in the fixes themselves:
- pull vs an old server: a returned empty `definition` can only mean a server
predating the definition-in-list field (a real zero-step workflow is rejected
at apply). Emitting a stepless `[[workflows]]` block would just fail the next
apply, so `wire_workflow_to_manifest` now warns and skips it instead.
- base_ms/backoff defaults: the pull mapper keyed its default-omission off a
hardcoded `500` literal. `default_base_ms` is now `pub` + re-exported, and the
mapper omits whatever equals `default_base_ms()` / `WorkflowBackoff::default()`
— no drift if a default ever changes.
- dashboard: a run that succeeds with an `on_error = continue` step failure now
carries a partial-failure note in `run.error` (from the compute_advance
change); the run-detail view rendered it as a red failure, contradicting the
green "succeeded" badge. It now renders as a `.notice` (warning) for a
succeeded run, `.error` only for a failed one.
Verified: fmt + clippy -D warnings clean; 450 lib tests; 5 workflow CLI
journeys (incl. the pull→plan round-trip); dashboard npm run check clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -253,7 +253,10 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
|||||||
},
|
},
|
||||||
vars: manifest_vars,
|
vars: manifest_vars,
|
||||||
suppress: crate::manifest::ManifestSuppress::default(),
|
suppress: crate::manifest::ManifestSuppress::default(),
|
||||||
workflows: workflows.iter().map(wire_workflow_to_manifest).collect(),
|
workflows: workflows
|
||||||
|
.iter()
|
||||||
|
.filter_map(wire_workflow_to_manifest)
|
||||||
|
.collect(),
|
||||||
};
|
};
|
||||||
|
|
||||||
std::fs::write(&manifest_path, manifest.to_toml()?)
|
std::fs::write(&manifest_path, manifest.to_toml()?)
|
||||||
@@ -280,8 +283,21 @@ fn trigger_count(t: &ManifestTriggers) -> usize {
|
|||||||
/// (the inverse of `plan::workflow_to_wire`). Without this a `pic pull` would
|
/// (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
|
/// drop the app's workflows, and a subsequent `pic apply --prune` would delete
|
||||||
/// them from the server.
|
/// them from the server.
|
||||||
fn wire_workflow_to_manifest(w: &crate::client::WorkflowDto) -> ManifestWorkflow {
|
///
|
||||||
ManifestWorkflow {
|
/// Returns `None` (with a warning) when the definition came back empty. A
|
||||||
|
/// zero-step workflow is rejected at apply, so an empty `definition` can only
|
||||||
|
/// mean the server predates the definition-in-list field (version skew) —
|
||||||
|
/// emitting a stepless block would just fail the next apply, so skip it.
|
||||||
|
fn wire_workflow_to_manifest(w: &crate::client::WorkflowDto) -> Option<ManifestWorkflow> {
|
||||||
|
if w.definition.steps.is_empty() {
|
||||||
|
eprintln!(
|
||||||
|
"warning: skipping workflow `{}` — the server did not return its \
|
||||||
|
definition (upgrade the server to round-trip workflows via pull)",
|
||||||
|
w.name
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(ManifestWorkflow {
|
||||||
name: w.name.clone(),
|
name: w.name.clone(),
|
||||||
enabled: w.enabled,
|
enabled: w.enabled,
|
||||||
steps: w
|
steps: w
|
||||||
@@ -290,7 +306,7 @@ fn wire_workflow_to_manifest(w: &crate::client::WorkflowDto) -> ManifestWorkflow
|
|||||||
.iter()
|
.iter()
|
||||||
.map(wire_step_to_manifest)
|
.map(wire_step_to_manifest)
|
||||||
.collect(),
|
.collect(),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn wire_step_to_manifest(s: &picloud_shared::WorkflowStepDef) -> ManifestWorkflowStep {
|
fn wire_step_to_manifest(s: &picloud_shared::WorkflowStepDef) -> ManifestWorkflowStep {
|
||||||
@@ -321,16 +337,10 @@ fn wire_step_to_manifest(s: &picloud_shared::WorkflowStepDef) -> ManifestWorkflo
|
|||||||
when: s.when.clone(),
|
when: s.when.clone(),
|
||||||
retry: s.retry.as_ref().map(|r| ManifestWorkflowRetry {
|
retry: s.retry.as_ref().map(|r| ManifestWorkflowRetry {
|
||||||
max_attempts: r.max_attempts,
|
max_attempts: r.max_attempts,
|
||||||
// Exponential is the default → omit; likewise the default base_ms.
|
// Omit whatever equals the server default (so the round-trip is a
|
||||||
backoff: match r.backoff {
|
// no-op), keyed off the shared defaults rather than local literals.
|
||||||
picloud_shared::WorkflowBackoff::Exponential => None,
|
backoff: (r.backoff != picloud_shared::WorkflowBackoff::default()).then_some(r.backoff),
|
||||||
other => Some(other),
|
base_ms: (r.base_ms != picloud_shared::default_base_ms()).then_some(r.base_ms),
|
||||||
},
|
|
||||||
base_ms: if r.base_ms == 500 {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(r.base_ms)
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
// Fail is the default → omit; Continue is explicit.
|
// Fail is the default → omit; Continue is explicit.
|
||||||
on_error: match s.on_error {
|
on_error: match s.on_error {
|
||||||
|
|||||||
@@ -121,6 +121,6 @@ pub use validator::{ScriptValidator, ValidatedScript, ValidationError};
|
|||||||
pub use vars::{NoopVarsService, VarsError, VarsService};
|
pub use vars::{NoopVarsService, VarsError, VarsService};
|
||||||
pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION};
|
pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION};
|
||||||
pub use workflow::{
|
pub use workflow::{
|
||||||
NoopWorkflowService, OnError, RunStatus, StepStatus, WorkflowBackoff, WorkflowDefinition,
|
default_base_ms, NoopWorkflowService, OnError, RunStatus, StepStatus, WorkflowBackoff,
|
||||||
WorkflowError, WorkflowRetry, WorkflowService, WorkflowStepDef,
|
WorkflowDefinition, WorkflowError, WorkflowRetry, WorkflowService, WorkflowStepDef,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -158,7 +158,11 @@ pub enum WorkflowBackoff {
|
|||||||
Exponential,
|
Exponential,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_base_ms() -> u32 {
|
/// Default per-step retry base delay (ms). Public so the CLI can invert the
|
||||||
|
/// pull round-trip (omit `base_ms` when it equals this) without duplicating the
|
||||||
|
/// literal.
|
||||||
|
#[must_use]
|
||||||
|
pub fn default_base_ms() -> u32 {
|
||||||
500
|
500
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -230,7 +230,12 @@
|
|||||||
<span class="badge st-{detail.run.status}">{detail.run.status}</span>
|
<span class="badge st-{detail.run.status}">{detail.run.status}</span>
|
||||||
</h3>
|
</h3>
|
||||||
{#if detail.run.error}
|
{#if detail.run.error}
|
||||||
<p class="error">{detail.run.error}</p>
|
<!-- A succeeded run can still carry an `error` note when a step failed
|
||||||
|
under `on_error = continue` — surface it as a warning, not a
|
||||||
|
red failure, so the green badge and message don't contradict. -->
|
||||||
|
<p class={detail.run.status === 'succeeded' ? 'notice' : 'error'}>
|
||||||
|
{detail.run.error}
|
||||||
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<h4>DAG</h4>
|
<h4>DAG</h4>
|
||||||
@@ -341,6 +346,9 @@
|
|||||||
.error {
|
.error {
|
||||||
color: var(--color-danger);
|
color: var(--color-danger);
|
||||||
}
|
}
|
||||||
|
.notice {
|
||||||
|
color: var(--color-warning, var(--text-muted));
|
||||||
|
}
|
||||||
.muted {
|
.muted {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user