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:
@@ -18,7 +18,7 @@ use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::get;
|
||||
use axum::{Extension, Router};
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{AppId, Principal, WorkflowRunId};
|
||||
use picloud_shared::{AppId, Principal, WorkflowDefinition, WorkflowRunId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use uuid::Uuid;
|
||||
@@ -58,7 +58,12 @@ pub fn workflows_router(state: WorkflowsState) -> Router {
|
||||
struct WorkflowDto {
|
||||
name: String,
|
||||
enabled: bool,
|
||||
/// Step count (kept for the dashboard's summary column).
|
||||
steps: usize,
|
||||
/// The full stored definition, so `pic pull` can round-trip the
|
||||
/// `[[workflows]]` manifest block (without it, a pull-then-apply silently
|
||||
/// deletes every workflow).
|
||||
definition: WorkflowDefinition,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -147,6 +152,7 @@ async fn list_workflows(
|
||||
name: w.name,
|
||||
enabled: w.enabled,
|
||||
steps: w.definition.steps.len(),
|
||||
definition: w.definition,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
@@ -171,11 +177,16 @@ async fn start_run_handler(
|
||||
)));
|
||||
}
|
||||
let input = body.map_or(Value::Null, |Json(b)| b.input);
|
||||
// An operator-initiated run has no parent execution to correlate to, so it
|
||||
// roots a fresh execution tree. Log the id against the principal so the
|
||||
// run's step logs remain traceable to who launched it (the SDK path threads
|
||||
// `cx.root_execution_id` instead).
|
||||
let root_execution_id = Uuid::new_v4();
|
||||
let new = NewWorkflowRun {
|
||||
workflow_id: wf.id,
|
||||
app_id,
|
||||
input,
|
||||
root_execution_id: Uuid::new_v4(),
|
||||
root_execution_id,
|
||||
workflow_depth: 0,
|
||||
parent_run_id: None,
|
||||
parent_step_id: None,
|
||||
@@ -183,6 +194,11 @@ async fn start_run_handler(
|
||||
let run_id = start_run(&s.pool, new, &wf.definition)
|
||||
.await
|
||||
.map_err(|e| WorkflowsApiError::Backend(e.to_string()))?;
|
||||
tracing::info!(
|
||||
%app_id, workflow = %name, run_id = %run_id.into_inner(), %root_execution_id,
|
||||
user_id = %principal.user_id.into_inner(),
|
||||
"admin started a workflow run"
|
||||
);
|
||||
// Read it back for the response (status = pending until the orchestrator ticks).
|
||||
let run = get_run(&s.pool, app_id, run_id)
|
||||
.await
|
||||
@@ -219,7 +235,7 @@ async fn get_run_detail(
|
||||
.await
|
||||
.map_err(|e| WorkflowsApiError::Backend(e.to_string()))?
|
||||
.ok_or_else(|| WorkflowsApiError::NotFound(format!("run {run_id}")))?;
|
||||
let steps = list_run_steps(&s.pool, run.id)
|
||||
let steps = list_run_steps(&s.pool, app_id, run.id)
|
||||
.await
|
||||
.map_err(|e| WorkflowsApiError::Backend(e.to_string()))?;
|
||||
// Load the definition for the per-step `depends_on` DAG edges (they live on
|
||||
|
||||
Reference in New Issue
Block a user