Files
Ruben Fiszel f00fcb2d1b fix: show scheduled singlestepflow runs in flow history sidebar (#10312)
* fix: show scheduled singlestepflow runs in flow input history

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

* fix: only match flow-wrapped singlestepflow rows in flow history

singlestepflow wraps either a script or a flow; a script and flow may share
a runnable_path, so filter flow history to flow-wrapped rows via the wrapped
module type. Extends the regression test to cover the same-path collision.

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

* fix: surface scheduled singlestepflow runs in script history too

Scheduled scripts with a dynamic-skip handler or native retry also run as
singlestepflow. Include that kind for ScriptPath history, filtered to
script-wrapped rows so a same-path flow run does not leak in. ScriptHash is
untouched (these wrappers carry no runnable_id). Test covers both directions.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 18:12:09 +02:00

168 lines
5.9 KiB
Rust

use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
builder.header("Authorization", "Bearer SECRET_TOKEN")
}
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
assert!(
(200..300).contains(&status),
"{endpoint} returned {status}: {body}",
);
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_inputs_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace/inputs");
// GET /history with fake runnable → 200 empty array
let resp = authed(client().get(format!(
"{base}/history?runnable_id=u/test-user/test&runnable_type=ScriptPath"
)))
.send()
.await?;
let status = resp.status().as_u16();
let body = resp.text().await?;
assert_2xx(status, &body, "GET /inputs/history");
// GET /list with fake runnable → 200 empty array
let resp = authed(client().get(format!(
"{base}/list?runnable_id=u/test-user/test&runnable_type=ScriptPath"
)))
.send()
.await?;
let status = resp.status().as_u16();
let body = resp.text().await?;
assert_2xx(status, &body, "GET /inputs/list");
// POST /create → 200, returns UUID
let resp = authed(client().post(format!(
"{base}/create?runnable_id=u/test-user/test&runnable_type=ScriptPath"
)))
.json(&json!({"name": "test_input", "args": {}}))
.send()
.await?;
let status = resp.status().as_u16();
let body = resp.text().await?;
assert_2xx(status, &body, "POST /inputs/create");
let input_id: String = serde_json::from_str(&body)?;
// GET /{id}/args → 200
let resp = authed(client().get(format!("{base}/{input_id}/args")))
.send()
.await?;
let status = resp.status().as_u16();
let body = resp.text().await?;
assert_2xx(status, &body, "GET /inputs/{id}/args");
// POST /delete/{id} → 200
let resp = authed(client().post(format!("{base}/delete/{input_id}")))
.send()
.await?;
let status = resp.status().as_u16();
let body = resp.text().await?;
assert_2xx(status, &body, "POST /inputs/delete/{id}");
Ok(())
}
// A scheduled runnable with a dynamic-skip handler (or a scheduled script with native
// retry) runs as a `singlestepflow`, not `flow`/`script` (see windmill-queue schedule.rs).
// Both a flow's and a script's history must surface their own singlestepflow runs, but a
// script and flow may share a path, so each side must match only the wrapped kind that
// belongs to it — the other must not leak in.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_input_history_singlestepflow_flow_vs_script(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
// Both rows share runnable_path 'f/test/scheduled'.
let flow_job = insert_singlestepflow(&db, "flow").await?;
let script_job = insert_singlestepflow(&db, "script").await?;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace/inputs");
let history_ids = |runnable_type: &'static str| {
let base = base.clone();
async move {
let resp = authed(client().get(format!(
"{base}/history?runnable_id=f/test/scheduled&runnable_type={runnable_type}"
)))
.send()
.await?;
let status = resp.status().as_u16();
let body = resp.text().await?;
assert_2xx(status, &body, "GET /inputs/history");
let inputs: Vec<serde_json::Value> = serde_json::from_str(&body)?;
anyhow::Ok(
inputs
.iter()
.filter_map(|i| i.get("id").and_then(|v| v.as_str()).map(String::from))
.collect::<std::collections::HashSet<_>>(),
)
}
};
let flow_hist = history_ids("FlowPath").await?;
assert!(
flow_hist.contains(&flow_job.to_string()),
"flow-wrapped singlestepflow missing from flow history: {flow_hist:?}",
);
assert!(
!flow_hist.contains(&script_job.to_string()),
"script-wrapped singlestepflow leaked into flow history: {flow_hist:?}",
);
let script_hist = history_ids("ScriptPath").await?;
assert!(
script_hist.contains(&script_job.to_string()),
"script-wrapped singlestepflow missing from script history: {script_hist:?}",
);
assert!(
!script_hist.contains(&flow_job.to_string()),
"flow-wrapped singlestepflow leaked into script history: {script_hist:?}",
);
Ok(())
}
// Insert a completed root singlestepflow at path f/test/scheduled wrapping `wrapped_type`
// ('flow' or 'script') as its single module — mirrors the schedule.rs wrapper shape.
async fn insert_singlestepflow(
db: &Pool<Postgres>,
wrapped_type: &str,
) -> anyhow::Result<uuid::Uuid> {
let id = uuid::Uuid::new_v4();
let raw_flow = json!({ "modules": [{ "id": "a", "value": { "type": wrapped_type } }] });
sqlx::query(
"INSERT INTO v2_job (id, workspace_id, tag, created_by, permissioned_as, \
permissioned_as_email, kind, runnable_path, raw_flow, same_worker, visible_to_owner) \
VALUES ($1, 'test-workspace', 'flow', 'test-user', 'u/test-user', \
'test@windmill.dev', 'singlestepflow', 'f/test/scheduled', $2, false, true)",
)
.bind(id)
.bind(sqlx::types::Json(&raw_flow))
.execute(db)
.await?;
sqlx::query(
"INSERT INTO v2_job_completed (id, workspace_id, duration_ms, deleted, status) \
VALUES ($1, 'test-workspace', 1, false, 'success')",
)
.bind(id)
.execute(db)
.await?;
Ok(id)
}