Files
windmill/backend/tests/restart_at_step_api.rs
hugocasa c95642863e feat: support restart from steps inside BranchOne, ForLoop, Subflow (#8955)
* feat: support restart from steps inside BranchOne, ForLoop, Subflow

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: preserve original job kind in nested restart, support expanded subflow steps

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: read selected iteration from graph state for nested ForLoop restart

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: iteration selectors per ForLoop in restart popup, more nested restart tests

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: extract useNestedRestartState composable

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: cover deployed-subflow + FlowDependencies path in nested restart

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: update sqlx prepare cache

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: detect BranchOne/ForLoop ancestors inside expanded subflows for nested restart

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: hide restart button for non-restartable steps (parallel containers, untaken branches)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address review feedback on nested restart PR

- preview FlowRestartButton: hide nested case (chain UUIDs aren't resolvable in
  preview path; users can use the run page for nested restart instead)
- branchOneAncestorMatchesOriginal: be permissive when status isn't reachable
  (don't hide the button for BranchOnes nested deeper than top-level)
- worker_flow.rs: apply nested_restart_payload swap on the is_simple ForLoop
  fast path too, so simple iterations don't bypass restart spawn interception
- FlowStatusViewer: reset expandedSubflows cache on jobId change; drop
  $bindable({}) banned pattern for the new prop
- API resolver: validate the leaf step exists before returning (fail-fast)
- doc fix: branch_or_iteration_n is 0-based, not 1-based
- selectedJobStepIsTopLevel reset on early-return in composable
- comment iterationCounts collision caveat
- new HTTP-level integration tests covering the API endpoint contract:
  happy path (top-level + nested), unknown step, out-of-range iteration,
  parallel-loop rejection

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* revert: remove unreachable nested-restart swap on is_simple ForLoop fast path

The swap is unreachable in valid flows: `is_simple_modules` requires the body
to be a single `script` / `rawscript` / `flowscript` (per `FlowModule::is_simple`),
none of which spawn flow-kind children. Any nested-restart chain targeting a
leaf inside such an iteration is rejected by the API at leaf validation. Even
if a chain reached the worker via `JobPayload::RawFlow.restarted_from`, the
resulting `RestartedFlow` would fail to push (script kind isn't a flow kind).

Replaced the swap with an explanatory comment so the next reader knows why
the symmetry with the non-simple path was deliberately not added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: handle undefined expandedSubflows + tighten branchOne match check

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 20:00:03 +00:00

256 lines
8.0 KiB
Rust

//! HTTP-level integration tests for the `restart_flow_at_step` API endpoint.
//!
//! The other restart tests in `job_payload.rs` exercise the worker by hand-
//! constructing `RestartedFrom` chains and calling `push()` directly. These
//! tests instead drive the actual HTTP endpoint to lock in the API contract,
//! including the validation branches in `resolve_nested_restart` (step lookup,
//! parallel rejection, etc.).
#![cfg(feature = "deno_core")]
#![cfg(feature = "enterprise")]
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_common::flows::FlowValue;
use windmill_common::jobs::JobPayload;
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
builder.header("Authorization", format!("Bearer {}", token))
}
const SUPER_TOKEN: &str = "SECRET_TOKEN";
/// Happy path: HTTP nested restart targeting a step inside iteration 1 of a
/// top-level sequential ForLoop. Verifies the API endpoint accepts a
/// `nested_path`, walks the original execution to resolve UUIDs, and the
/// resulting job runs successfully.
#[sqlx::test(fixtures("base", "hello"))]
async fn test_api_restart_at_step_nested_happy(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let first_run = RunJob::from(JobPayload::Flow {
path: "f/system/hello_with_nodes_flow".to_string(),
dedicated_worker: None,
apply_preprocessor: true,
version: 1443253234253454,
labels: None,
})
.run_until_complete(&db, false, port)
.await;
let resp = authed(
client().post(format!(
"http://localhost:{port}/api/w/test-workspace/jobs/restart/f/{}",
first_run.id
)),
SUPER_TOKEN,
)
.json(&json!({
"step_id": "a",
"branch_or_iteration_n": 1,
"nested_path": [{ "step_id": "c" }],
}))
.send()
.await?;
assert_eq!(
resp.status(),
201,
"expected 201, got {}: {}",
resp.status(),
resp.text().await.unwrap_or_default()
);
let new_job_id = resp.text().await?;
assert!(!new_job_id.is_empty(), "expected job UUID in response body");
Ok(())
}
/// Happy path: top-level (non-nested) restart via HTTP. Same surface as
/// `test_restarted_flow_payload` but driven through the actual REST endpoint.
#[sqlx::test(fixtures("base", "hello"))]
async fn test_api_restart_at_step_top_level_happy(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let first_run = RunJob::from(JobPayload::Flow {
path: "f/system/hello_with_nodes_flow".to_string(),
dedicated_worker: None,
apply_preprocessor: true,
version: 1443253234253454,
labels: None,
})
.run_until_complete(&db, false, port)
.await;
let resp = authed(
client().post(format!(
"http://localhost:{port}/api/w/test-workspace/jobs/restart/f/{}",
first_run.id
)),
SUPER_TOKEN,
)
.json(&json!({ "step_id": "a", "branch_or_iteration_n": 0 }))
.send()
.await?;
assert_eq!(resp.status(), 201);
Ok(())
}
/// Rejection: nested step doesn't exist in the original run. The API should
/// reject with a 4xx (or surface a clear backend error) rather than silently
/// queue an unrunnable job.
#[sqlx::test(fixtures("base", "hello"))]
async fn test_api_restart_at_step_rejects_unknown_step(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let first_run = RunJob::from(JobPayload::Flow {
path: "f/system/hello_with_nodes_flow".to_string(),
dedicated_worker: None,
apply_preprocessor: true,
version: 1443253234253454,
labels: None,
})
.run_until_complete(&db, false, port)
.await;
let resp = authed(
client().post(format!(
"http://localhost:{port}/api/w/test-workspace/jobs/restart/f/{}",
first_run.id
)),
SUPER_TOKEN,
)
.json(&json!({
"step_id": "a",
"branch_or_iteration_n": 1,
"nested_path": [{ "step_id": "does_not_exist" }],
}))
.send()
.await?;
assert!(
!resp.status().is_success(),
"expected error, got {}",
resp.status()
);
Ok(())
}
/// Rejection: nested restart targets an iteration past the actual count.
/// Original ran 3 iterations (index 0..2); requesting `branch_or_iteration_n=5`
/// must error.
#[sqlx::test(fixtures("base", "hello"))]
async fn test_api_restart_at_step_rejects_out_of_range_iteration(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let first_run = RunJob::from(JobPayload::Flow {
path: "f/system/hello_with_nodes_flow".to_string(),
dedicated_worker: None,
apply_preprocessor: true,
version: 1443253234253454,
labels: None,
})
.run_until_complete(&db, false, port)
.await;
let resp = authed(
client().post(format!(
"http://localhost:{port}/api/w/test-workspace/jobs/restart/f/{}",
first_run.id
)),
SUPER_TOKEN,
)
.json(&json!({
"step_id": "a",
"branch_or_iteration_n": 5,
"nested_path": [{ "step_id": "c" }],
}))
.send()
.await?;
assert!(
!resp.status().is_success(),
"expected error for out-of-range iteration, got {}: {}",
resp.status(),
resp.text().await.unwrap_or_default()
);
Ok(())
}
/// Rejection: parallel ForLoop ancestor on the nested path. The resolver
/// rejects this category outright — `branch_or_iteration_n` only makes sense
/// for sequential containers because each iteration runs as a separate
/// numbered child.
#[sqlx::test(fixtures("base", "hello"))]
async fn test_api_restart_at_step_rejects_parallel_loop(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
// Run a parallel ForLoop with an inner script. We use a `RawFlow` with an
// explicit `path` so the API endpoint accepts it as a valid completed job
// (the handler requires `runnable_path`).
let parallel_flow: FlowValue = serde_json::from_value(json!({
"modules": [{
"id": "loop",
"value": {
"type": "forloopflow",
"iterator": { "type": "javascript", "expr": "[1, 2]" },
"skip_failures": false,
"parallel": true,
"modules": [{
"id": "inner",
"value": {
"type": "rawscript",
"language": "deno",
"input_transforms": {},
"content": "export function main() { return 'ok' }"
}
}]
}
}],
}))
.unwrap();
let first_run = RunJob::from(JobPayload::RawFlow {
value: parallel_flow,
path: Some("u/admin/parallel_test".to_string()),
restarted_from: None,
})
.run_until_complete(&db, false, port)
.await;
let resp = authed(
client().post(format!(
"http://localhost:{port}/api/w/test-workspace/jobs/restart/f/{}",
first_run.id
)),
SUPER_TOKEN,
)
.json(&json!({
"step_id": "loop",
"branch_or_iteration_n": 0,
"nested_path": [{ "step_id": "inner" }],
}))
.send()
.await?;
assert!(
!resp.status().is_success(),
"expected rejection of parallel-loop nested restart, got {}: {}",
resp.status(),
resp.text().await.unwrap_or_default()
);
Ok(())
}