feat(workflows): M3 — conditional when + input templating at run time

Wire the two pure M1 modules into the orchestrator's execute path:

- Before running a step, build a `RunContext` from the run input + every prior
  succeeded step's output (`load_run_step_outputs`, read fresh at execution
  time so a step sees all upstream results).
- `workflow_expr::eval` the step's `when` condition (if any): false → the step
  is `skipped` — never executed — and counts as satisfied-but-empty for its
  dependents (a new `StepOutcome::Skipped`, written in `complete_step_and_advance`
  then advanced; `compute_advance` already treats skipped as satisfying deps).
- `workflow_template::resolve` the step's `input` against the context: an
  exact single `{{ ref }}` preserves the referenced JSON type, an embedded ref
  interpolates as text; a missing reference is a hard step failure (a definition
  bug surfaced, not hidden), never a silent null.

`when` and templates were already parse-validated at apply time (M1); this is
the runtime half.

Tests (DB-gated, end-to-end via a scripted fake executor):
- `when_false_skips_step` — b skipped, never executed, omitted from run output
- `step_output_flows_into_downstream_input` — a's `{n:7}` feeds b's input,
  type-preserved for a bare ref and interpolated in a larger string
- `missing_input_ref_fails_the_step` — an unresolved ref fails b → run fails

Verified: cargo fmt, clippy -D warnings clean, 448 manager-core lib tests,
11 workflow_orchestrator DB tests (3 new).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-12 17:18:01 +02:00
parent a55c2f112e
commit 41a21c0551
3 changed files with 306 additions and 5 deletions

View File

@@ -48,9 +48,12 @@ use picloud_shared::{ExecutionId, ExecutionLogSink, ExecutionSource, RequestId};
use crate::dispatcher::compute_backoff;
use crate::repo::ScriptRepository;
use crate::trigger_config::BackoffShape;
use crate::workflow_expr;
use crate::workflow_repo::{
claim_ready_step, complete_step_and_advance, reclaim_stale_steps, ClaimedStep, StepOutcome,
claim_ready_step, complete_step_and_advance, load_run_step_outputs, reclaim_stale_steps,
ClaimedStep, StepOutcome,
};
use crate::workflow_template::{self, RunContext};
/// Max steps claimed (and thus gate permits held) per orchestrator tick. Keeps
/// the workflow worker from monopolizing the gate it shares with HTTP + the
@@ -238,6 +241,31 @@ impl WorkflowOrchestrator {
);
};
// M3: build the run context (run input + prior succeeded outputs) once;
// it backs both the `when` condition and the input template.
let outputs = match load_run_step_outputs(&self.pool, claimed.run_id).await {
Ok(o) => o,
Err(e) => {
return self.fail(Some(step), claimed, format!("loading step context: {e}"));
}
};
let ctx = RunContext {
input: claimed.run_input.clone(),
steps: outputs,
};
// M3: a false `when` condition skips the step — never executed, and
// satisfied-but-empty for its dependents (`compute_advance`).
if let Some(cond) = &step.when {
match workflow_expr::eval(cond, &ctx) {
Ok(true) => {}
Ok(false) => return StepOutcome::Skipped,
Err(e) => {
return self.fail(Some(step), claimed, format!("evaluating `when`: {e}"));
}
}
}
let fn_name = match step.target() {
Some(StepTarget::Function(f)) => f.to_string(),
Some(StepTarget::Workflow(_)) => {
@@ -285,13 +313,19 @@ impl WorkflowOrchestrator {
}
};
// M2 input: pass the step's static `input` if present, else the run
// input. M3 replaces this with `{{ … }}` template resolution against
// prior step outputs.
// M3: resolve `{{ … }}` references in the step input against the
// context. A null input passes the run input through unchanged; a
// missing reference is a hard step failure (a definition bug we
// surface, not hide).
let body = if step.input.is_null() {
claimed.run_input.clone()
} else {
step.input.clone()
match workflow_template::resolve(&step.input, &ctx) {
Ok(v) => v,
Err(e) => {
return self.fail(Some(step), claimed, format!("resolving input: {e}"));
}
}
};
let execution_id = ExecutionId::new();

View File

@@ -330,6 +330,9 @@ pub enum StepOutcome {
error: String,
retry_delay: chrono::Duration,
},
/// The step's `when` condition evaluated false — it is `skipped` (never
/// executed) and counts as satisfied-but-empty for its dependents (M3).
Skipped,
}
/// What `complete_step_and_advance` did — surfaced mostly for tests/logging.
@@ -662,6 +665,22 @@ pub async fn complete_step_and_advance(
return Ok(AdvanceResult::Stale);
}
}
StepOutcome::Skipped => {
let res = sqlx::query(
"UPDATE workflow_run_steps \
SET status = 'skipped', output = NULL, error = NULL, \
claim_token = NULL, claimed_at = NULL, updated_at = NOW() \
WHERE id = $1 AND claim_token = $2",
)
.bind(claimed.step_id.into_inner())
.bind(claimed.claim_token)
.execute(&mut *tx)
.await?;
if res.rows_affected() == 0 {
tx.rollback().await?;
return Ok(AdvanceResult::Stale);
}
}
}
// 2. Advance — lock the run row so concurrent advances of the same run
@@ -884,6 +903,27 @@ pub async fn list_run_steps(
rows.into_iter().map(TryInto::try_into).collect()
}
/// The `{ step_name: output }` map of a run's **succeeded** steps — the
/// accumulated context M3 resolves a step's `when` + `input` templates against
/// (see `workflow_template::RunContext`). Read fresh at execution time so a
/// step sees every prior step's output.
pub async fn load_run_step_outputs(
pool: &PgPool,
run_id: WorkflowRunId,
) -> Result<BTreeMap<String, serde_json::Value>, WorkflowRepoError> {
let rows: Vec<(String, Option<serde_json::Value>)> = sqlx::query_as(
"SELECT step_name, output FROM workflow_run_steps \
WHERE run_id = $1 AND status = 'succeeded'",
)
.bind(run_id.into_inner())
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.filter_map(|(name, out)| out.map(|v| (name, v)))
.collect())
}
#[cfg(test)]
mod tests {
use super::*;