This commit is contained in:
Ruben Fiszel
2026-05-27 08:44:39 +00:00
40 changed files with 1447 additions and 1983 deletions
+31 -5
View File
@@ -98,6 +98,21 @@ jobs:
vcpkg.exe install openssl:x64-windows-static
vcpkg.exe integrate install
- name: Free disk space (post-vcpkg)
shell: pwsh
run: |
# vcpkg leaves multi-GB of buildtrees/downloads after installing openssl;
# we only need the installed/ dir for linking.
$vcpkgRoot = $env:VCPKG_INSTALLATION_ROOT
foreach ($sub in @("buildtrees", "downloads", "packages")) {
$path = Join-Path $vcpkgRoot $sub
if (Test-Path $path) {
Write-Host "Removing $path"
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $path
}
}
Get-PSDrive C | Select-Object Used,Free | Format-Table -AutoSize
- name: Get runtime paths
id: runtime-paths
shell: pwsh
@@ -119,6 +134,10 @@ jobs:
cargo build --release -p windmill_duckdb_ffi_internal
New-Item -ItemType Directory -Path ..\target\debug -Force
Copy-Item target\release\windmill_duckdb_ffi_internal.dll ..\target\debug\
# duckdb is bundled (~2GB of build artifacts); the DLL is the only
# thing we need from this excluded-crate target dir.
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue target
Get-PSDrive C | Select-Object Used,Free | Format-Table -AutoSize
- name: Print runtime versions and env
shell: pwsh
@@ -136,6 +155,10 @@ jobs:
echo "USERPROFILE=$env:USERPROFILE"
echo "HOME=$env:HOME"
- name: Disk space before cargo test
shell: pwsh
run: Get-PSDrive C | Select-Object Used,Free | Format-Table -AutoSize
- name: cargo test
working-directory: backend
timeout-minutes: 60
@@ -144,13 +167,16 @@ jobs:
RUST_LOG: "off"
RUST_LOG_STYLE: never
CARGO_NET_GIT_FETCH_WITH_CLI: true
CARGO_BUILD_JOBS: 12
# 16-vcpu runners with disabled PDB still hit LNK1180 ("insufficient
# disk space") at link time with 12 parallel link jobs: each test
# binary link spikes several hundred MB of transient I/O. Capping at
# 8 trades ~25% wall time for headroom on the ~75GB runner disk.
CARGO_BUILD_JOBS: 8
# backend/Cargo.toml sets split-debuginfo = "unpacked", which on
# windows-msvc is coerced to "packed": every test-binary link spawns
# the mspdbsrv.exe PDB type server and writes a large .pdb. With 12
# parallel link jobs this races the type-server cap (LNK1318 "LIMIT
# (12)") and exhausts the runner disk (LNK1180). CI needs no debug
# info, so disable PDB generation for the dev/test profiles here.
# the mspdbsrv.exe PDB type server and writes a large .pdb. CI needs
# no debug info, so disable PDB generation for the dev/test profiles
# here (avoids both LNK1318 type-server limit and PDB disk usage).
CARGO_PROFILE_DEV_SPLIT_DEBUGINFO: "off"
CARGO_PROFILE_TEST_SPLIT_DEBUGINFO: "off"
# Tests' poll-time stack frames (deep nested async fn chains in
+32
View File
@@ -1,5 +1,37 @@
# Changelog
## [1.711.0](https://github.com/windmill-labs/windmill/compare/v1.710.1...v1.711.0) (2026-05-26)
### Features
* **cli:** add object-storage commands and flow test-step ([#9326](https://github.com/windmill-labs/windmill/issues/9326)) ([36f574f](https://github.com/windmill-labs/windmill/commit/36f574ff951198a4d40ee068a27d74c41ce32154))
### Bug Fixes
* **cli:** handle __flow suffix when deriving the flow's Windmill path ([#9333](https://github.com/windmill-labs/windmill/issues/9333)) ([6f77034](https://github.com/windmill-labs/windmill/commit/6f770346fb330997a836c39fba347df4c088a83c))
* **queue:** duration-weighted workspace fairness signal ([#9329](https://github.com/windmill-labs/windmill/issues/9329)) ([42d2121](https://github.com/windmill-labs/windmill/commit/42d2121af925de50f549ecb72ffb5132f5c41079))
## [1.710.1](https://github.com/windmill-labs/windmill/compare/v1.710.0...v1.710.1) (2026-05-26)
### Bug Fixes
* improve workspace fairness ([896add0](https://github.com/windmill-labs/windmill/commit/896add0350f4de31f5674d6be0907a582c5ec17e))
## [1.710.0](https://github.com/windmill-labs/windmill/compare/v1.709.0...v1.710.0) (2026-05-26)
### Features
* **queue:** stochastic admission + EE availability of workspace fairness algorithm ([#9321](https://github.com/windmill-labs/windmill/issues/9321)) ([8bf7fd2](https://github.com/windmill-labs/windmill/commit/8bf7fd2c921c48861b71731a085b18ea8f72fb68))
### Bug Fixes
* **websocket-trigger:** honor HTTPS_PROXY/HTTP_PROXY/NO_PROXY ([#9324](https://github.com/windmill-labs/windmill/issues/9324)) ([6f36316](https://github.com/windmill-labs/windmill/commit/6f363163df9cd15f5af7d56cf34a01b70d236830))
## [1.709.0](https://github.com/windmill-labs/windmill/compare/v1.708.0...v1.709.0) (2026-05-25)
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM background_task_state\n WHERE name LIKE $1\n AND updated_at < NOW() - INTERVAL '7 days'",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "8597cd40f80e69edbf1bc7d7402baca32e33e871be454acb5175c11361fe1b0a"
}
+154 -154
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.709.0"
version = "1.711.0"
authors.workspace = true
edition.workspace = true
@@ -87,7 +87,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
[workspace.package]
version = "1.709.0"
version = "1.711.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
2de9dc793360764dc81b9593d72cb50347656a52
327d23f7438968a21bac9fd42e7f6f027c61477c
+24 -24
View File
@@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.709.0"
version = "1.711.0"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6263,7 +6263,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.709.0"
version = "1.711.0"
dependencies = [
"proc-macro2",
"quote",
@@ -6275,7 +6275,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.709.0"
version = "1.711.0"
dependencies = [
"convert_case",
"serde",
@@ -6284,7 +6284,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.709.0"
version = "1.711.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6296,7 +6296,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.709.0"
version = "1.711.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6308,7 +6308,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.709.0"
version = "1.711.0"
dependencies = [
"anyhow",
"gosyn",
@@ -6320,7 +6320,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.709.0"
version = "1.711.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6332,7 +6332,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.709.0"
version = "1.711.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6344,7 +6344,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.709.0"
version = "1.711.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -6355,7 +6355,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.709.0"
version = "1.711.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6366,7 +6366,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.709.0"
version = "1.711.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6378,7 +6378,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.709.0"
version = "1.711.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6389,7 +6389,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.709.0"
version = "1.711.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -6411,7 +6411,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.709.0"
version = "1.711.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6423,7 +6423,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.709.0"
version = "1.711.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6437,7 +6437,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.709.0"
version = "1.711.0"
dependencies = [
"anyhow",
"convert_case",
@@ -6454,7 +6454,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.709.0"
version = "1.711.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6467,7 +6467,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.709.0"
version = "1.711.0"
dependencies = [
"anyhow",
"serde",
@@ -6479,7 +6479,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.709.0"
version = "1.711.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6497,7 +6497,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.709.0"
version = "1.711.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6513,7 +6513,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.709.0"
version = "1.711.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6529,7 +6529,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.709.0"
version = "1.711.0"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6561,7 +6561,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.709.0"
version = "1.711.0"
dependencies = [
"anyhow",
"serde",
@@ -6572,7 +6572,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.709.0"
version = "1.711.0"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.709.0"
version = "1.711.0"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+21
View File
@@ -2709,6 +2709,26 @@ pub async fn monitor_db(
}
};
// run every hour (120 iterations * 30s = 3600s)
let cleanup_stale_server_heartbeats_f = async {
if server_mode && iteration.is_some() && iteration.as_ref().unwrap().should_run(120) {
if let Some(db) = conn.as_sql() {
match windmill_api::cleanup_stale_server_heartbeats(db).await {
Ok(count) if count > 0 => {
tracing::info!(
"Deleted {} stale server_heartbeat background_task_state rows",
count
);
}
Err(e) => {
tracing::error!("Error cleaning up stale server_heartbeat rows: {:?}", e);
}
_ => {}
}
}
}
};
// run every hour (120 iterations * 30s = 3600s)
let manage_audit_partitions_f = async {
if server_mode && iteration.is_some() && iteration.as_ref().unwrap().should_run(120) {
@@ -2767,6 +2787,7 @@ pub async fn monitor_db(
native_triggers_sync_f,
cleanup_notify_events_f,
check_expiring_tokens_f,
cleanup_stale_server_heartbeats_f,
manage_audit_partitions_f,
export_audit_logs_to_object_store_f,
cleanup_scheduled_job_deletions_f,
+221 -7
View File
@@ -79,15 +79,28 @@ async fn create_workspace(db: &Pool<Postgres>, id: &str) {
.unwrap();
}
/// Insert `n` completed jobs for `workspace_id`, each ending `secs_ago`
/// seconds in the past with a 1-second wall-clock duration. The fairness
/// algorithm weights contributions by `duration_ms` (clamped to the window),
/// so each job contributes ~1 worker-second when fully inside the window.
async fn insert_completed(db: &Pool<Postgres>, workspace_id: &str, n: usize, secs_ago: i32) {
for _ in 0..n {
let id: Uuid = sqlx::query_scalar(
"INSERT INTO v2_job (id, workspace_id, kind)
VALUES (gen_random_uuid(), $1, 'script'::job_kind) RETURNING id",
)
.bind(workspace_id)
.fetch_one(db)
.await
.unwrap();
sqlx::query(
"INSERT INTO v2_job_completed (id, workspace_id, duration_ms, status,
started_at, completed_at)
VALUES (gen_random_uuid(), $1, 1, 'success'::job_status,
NOW() - make_interval(secs => $2::int),
NOW() - make_interval(secs => $2::int))",
VALUES ($1, $2, 1000, 'success'::job_status,
NOW() - make_interval(secs => ($3::int + 1)),
NOW() - make_interval(secs => $3::int))",
)
.bind(id)
.bind(workspace_id)
.bind(secs_ago)
.execute(db)
@@ -106,12 +119,98 @@ async fn insert_queued(
let mut ids = Vec::with_capacity(n);
for _ in 0..n {
let id: Uuid = sqlx::query_scalar(
"INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, tag)
VALUES (gen_random_uuid(), $1, NOW(), $2, $3) RETURNING id",
"INSERT INTO v2_job (id, workspace_id, kind, tag)
VALUES (gen_random_uuid(), $1, 'script'::job_kind, $2) RETURNING id",
)
.bind(workspace_id)
.bind(tag)
.fetch_one(db)
.await
.unwrap();
// Running jobs need a `started_at` for the fairness algorithm to
// compute a positive elapsed-time contribution. Backdate by 1s so
// each running row contributes ~1 worker-second by the time the
// refresh runs, matching the `insert_completed` scale.
sqlx::query(
"INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, tag, started_at)
VALUES ($1, $2, NOW(), $3, $4,
CASE WHEN $3 THEN NOW() - interval '1 second' ELSE NULL END)",
)
.bind(id)
.bind(workspace_id)
.bind(running)
.bind(tag)
.execute(db)
.await
.unwrap();
if running {
// The fairness algorithm bounds the running contribution by the
// per-job `v2_job_runtime.ping`. Insert a fresh ping so each
// running row accrues real-time worker-seconds.
sqlx::query(
"INSERT INTO v2_job_runtime (id, ping) VALUES ($1, NOW())
ON CONFLICT (id) DO UPDATE SET ping = NOW()",
)
.bind(id)
.execute(db)
.await
.unwrap();
insert_live_worker_ping(db, workspace_id, id).await;
}
ids.push(id);
}
ids
}
/// Insert a live `worker_ping` row claiming the given job. Each insert uses
/// a fresh randomly-named worker so callers can stack multiple pings without
/// PK collisions on `worker`.
async fn insert_live_worker_ping(db: &Pool<Postgres>, workspace_id: &str, job_id: Uuid) {
let worker_name = format!("test-worker-{}", Uuid::new_v4());
sqlx::query(
"INSERT INTO worker_ping (worker, worker_instance, ping_at, ip, current_job_id, current_job_workspace_id)
VALUES ($1, 'test', NOW(), '127.0.0.1', $2, $3)",
)
.bind(&worker_name)
.bind(job_id)
.bind(workspace_id)
.execute(db)
.await
.unwrap();
}
/// Insert a "zombie" running row: a row in `v2_job_queue` with `running=true`
/// but **no** live `worker_ping` claiming it (no paired worker, or the worker
/// has stopped pinging). The fairness algorithm must NOT count these — they
/// don't consume any worker slot.
async fn insert_zombie_running(db: &Pool<Postgres>, workspace_id: &str, n: usize) -> Vec<Uuid> {
let mut ids = Vec::with_capacity(n);
for _ in 0..n {
let id: Uuid = sqlx::query_scalar(
"INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, tag, started_at)
VALUES (gen_random_uuid(), $1, NOW() - interval '1 hour', true, 'deno',
NOW() - interval '1 hour') RETURNING id",
)
.bind(workspace_id)
.fetch_one(db)
.await
.unwrap();
ids.push(id);
}
ids
}
/// Insert a concurrency-suspended row: `running=true` AND `suspend > 0`. These
/// rows are not being processed by any worker (the flow is paused), so the
/// algorithm must not count them as slot occupancy.
async fn insert_suspended_running(db: &Pool<Postgres>, workspace_id: &str, n: usize) -> Vec<Uuid> {
let mut ids = Vec::with_capacity(n);
for _ in 0..n {
let id: Uuid = sqlx::query_scalar(
"INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, suspend, tag)
VALUES (gen_random_uuid(), $1, NOW(), true, 1, 'deno') RETURNING id",
)
.bind(workspace_id)
.fetch_one(db)
.await
.unwrap();
@@ -334,6 +433,76 @@ async fn fairness_catches_slot_hoggers(db: Pool<Postgres>) {
assert_eq!(overloaded_set(), vec!["hogger".to_string()]);
}
/// Regression: a workspace with a large backlog of `running = true` rows that
/// have **no live worker** claiming them (worker died, ping went stale, etc.)
/// must not be counted as "active". A previous version of the algorithm
/// counted `v2_job_queue.running = true` directly and was perpetually pinned
/// on the workspace with the most zombie rows, masking every other workspace.
#[sqlx::test(fixtures("base"))]
#[serial]
#[ignore = "flaky in CI"]
async fn fairness_ignores_zombie_running_rows(db: Pool<Postgres>) {
reset_fairness_state();
create_workspace(&db, "stuck_backlog").await;
create_workspace(&db, "real_noisy").await;
create_workspace(&db, "victim").await;
// 100 zombie running rows for `stuck_backlog`. No paired worker_ping ⇒
// no live worker is processing them. Old algorithm: 100 units of fake
// activity. New algorithm: 0 units.
insert_zombie_running(&db, "stuck_backlog", 100).await;
// `real_noisy` is genuinely flooding the cluster.
insert_completed(&db, "real_noisy", 60, 2).await;
insert_completed(&db, "victim", 5, 3).await;
refresh_overloaded(&db).await.expect("refresh ok");
let set = overloaded_set();
assert!(
!set.contains(&"stuck_backlog".to_string()),
"zombie running rows must not flag a workspace as overloaded; got {set:?}"
);
assert_eq!(
set,
vec!["real_noisy".to_string()],
"the actually noisy workspace must surface even when another workspace \
has a large backlog of zombie running rows; got {set:?}"
);
}
/// Regression: concurrency-suspended rows (`running = true AND suspend > 0`)
/// are not consuming worker slots — the flow is paused at a suspend step —
/// and must not contribute to the activity share.
#[sqlx::test(fixtures("base"))]
#[serial]
#[ignore = "flaky in CI"]
async fn fairness_ignores_concurrency_suspended_rows(db: Pool<Postgres>) {
reset_fairness_state();
create_workspace(&db, "concurrency_capped").await;
create_workspace(&db, "real_noisy").await;
create_workspace(&db, "victim").await;
// 100 concurrency-suspended rows. Each has `running = true` (the legacy
// signal) but `suspend > 0` (not actually on a worker).
insert_suspended_running(&db, "concurrency_capped", 100).await;
insert_completed(&db, "real_noisy", 60, 2).await;
insert_completed(&db, "victim", 5, 3).await;
refresh_overloaded(&db).await.expect("refresh ok");
let set = overloaded_set();
assert!(
!set.contains(&"concurrency_capped".to_string()),
"concurrency-suspended rows must not flag a workspace as overloaded; got {set:?}"
);
assert_eq!(
set,
vec!["real_noisy".to_string()],
"noisy workspace must still surface despite another workspace's large \
suspended backlog; got {set:?}"
);
}
// ---------------------------------------------------------------------------
// Simulation test
// ---------------------------------------------------------------------------
@@ -449,7 +618,21 @@ async fn mock_worker(
stats: Arc<Mutex<Stats>>,
completed_counter: Arc<AtomicU64>,
) {
let _ = worker_id;
let worker_name = format!("mock-worker-{worker_id}");
// Each mock worker maintains its own `worker_ping` row, the way a real
// worker would: `current_job_*` set on pick-up, cleared on completion.
// The fairness algorithm now reads slot occupancy from `worker_ping` (so
// that concurrency-suspended rows and zombies with no live ping do not
// inflate the denominator), so the simulation must keep this in sync.
sqlx::query(
"INSERT INTO worker_ping (worker, worker_instance, ping_at) VALUES ($1, 'sim', NOW())
ON CONFLICT (worker) DO UPDATE SET ping_at = NOW(),
current_job_id = NULL, current_job_workspace_id = NULL",
)
.bind(&worker_name)
.execute(&db)
.await
.unwrap();
let standard_sql = "WITH picked AS (
SELECT id FROM v2_job_queue
WHERE running = false AND scheduled_for <= now()
@@ -516,6 +699,20 @@ async fn mock_worker(
match row {
Some((id, ws, dur_ms, created_at)) => {
// Claim the slot on this worker's ping so the fairness
// algorithm counts this workspace's slot occupancy.
sqlx::query(
"UPDATE worker_ping SET ping_at = NOW(),
current_job_id = $1, current_job_workspace_id = $2
WHERE worker = $3",
)
.bind(id)
.bind(&ws)
.bind(&worker_name)
.execute(&db)
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(dur_ms as u64)).await;
// Move to completed atomically: insert + delete in one query.
@@ -534,6 +731,17 @@ async fn mock_worker(
.await
.unwrap();
// Release the slot.
sqlx::query(
"UPDATE worker_ping SET ping_at = NOW(),
current_job_id = NULL, current_job_workspace_id = NULL
WHERE worker = $1",
)
.bind(&worker_name)
.execute(&db)
.await
.unwrap();
let latency_ms = (completed_at - created_at).num_milliseconds().max(0) as u64;
{
let mut s = stats.lock().await;
@@ -543,7 +751,13 @@ async fn mock_worker(
}
None => {
// Empty queue (or every queued workspace is capped). Back off
// briefly so we don't hammer the DB.
// briefly so we don't hammer the DB. Refresh the heartbeat so
// this worker's ping doesn't go stale during long idle gaps.
sqlx::query("UPDATE worker_ping SET ping_at = NOW() WHERE worker = $1")
.bind(&worker_name)
.execute(&db)
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(2)).await;
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.709.0
version: 1.711.0
title: Windmill API
contact:
+23
View File
@@ -1261,3 +1261,26 @@ pub async fn check_any_server_started(db: &DB, not_before: chrono::DateTime<chro
.await
.unwrap_or(false)
}
/// Delete `server_heartbeat:*` rows that have not been refreshed in a long
/// time. Each server startup generates a fresh random `INSTANCE_NAME` and
/// inserts a new row keyed by `server_heartbeat:{instance}`; because that
/// row is only written once (on startup) and never updated thereafter, the
/// table grows by one row per server restart and is otherwise never pruned.
///
/// The row is only consulted by `check_any_server_started`, which itself
/// filters on `updated_at > not_before` (the moment a restart was initiated),
/// so rows older than the cutoff cannot influence any restart decision and
/// are safe to delete.
pub async fn cleanup_stale_server_heartbeats(db: &DB) -> anyhow::Result<u64> {
let prefix = format!("{SERVER_HEARTBEAT_TASK}:");
let res = sqlx::query!(
"DELETE FROM background_task_state
WHERE name LIKE $1
AND updated_at < NOW() - INTERVAL '7 days'",
format!("{prefix}%"),
)
.execute(db)
.await?;
Ok(res.rows_affected())
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.709.0";
export const VERSION = "v1.711.0";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+201 -6
View File
@@ -4,7 +4,7 @@ import { Command } from "@cliffy/command";
import { Confirm } from "@cliffy/prompt/confirm";
import { Table } from "@cliffy/table";
import * as log from "../../core/log.ts";
import { sep as SEP } from "node:path";
import { dirname, sep as SEP } from "node:path";
import { stringify as yamlStringify } from "yaml";
import { yamlParseFile } from "../../utils/yaml.ts";
import { readTextFile, validateRequiredArgs } from "../../utils/utils.ts";
@@ -542,6 +542,7 @@ async function preview(
data?: string;
silent: boolean;
remote?: boolean;
step?: string;
} & SyncOptions,
flowPath: string
) {
@@ -562,7 +563,10 @@ async function preview(
if (!isFlowDir) {
// Check if it's a flow.yaml file
if (flowPath.endsWith("flow.yaml") || flowPath.endsWith("flow.json")) {
flowPath = flowPath.substring(0, flowPath.lastIndexOf(SEP));
// Use dirname so a bare "flow.yaml" (no parent dir) becomes "."
// instead of "" — the latter, after appending SEP below, becomes "/"
// and silently reads from filesystem root.
flowPath = dirname(flowPath);
} else {
throw new Error(
"Flow path must be a .flow/__flow directory or a flow.yaml file"
@@ -636,18 +640,35 @@ async function preview(
const input = opts.data ? await resolve(opts.data) : {};
log.debug(`Flow value: ${JSON.stringify(localFlow.value, null, 2)}`);
// Single-step mode: run only the named module's runnable.
// The full-flow prep above (inline-script replacement, local PathScript
// substitution, tempScriptRefs build) is exactly what the single step needs
// too — PathScript modules have already been rewritten to inline rawscript
// when `useLocalPathScripts` is set, and tempScriptRefs covers relative
// imports in inline scripts.
// Compute the flow's windmill path (e.g. "f/cli_smoke/myrelflow"). Used as
// the anchor for relative-import resolution: inline scripts in this flow are
// treated as living at "<flow_wm_path>/<step_id>", so "./util" resolves to
// "<flow_wm_path_parent>/util" — matching the keys in temp_script_refs.
const flowWmPath = stripFlowSuffix(flowPath).replaceAll(SEP, "/");
if (opts.step) {
await previewStep(opts.step, localFlow, flowWmPath, workspace, input, tempScriptRefs, opts.silent);
return;
}
if (!opts.silent) {
log.info(colors.yellow(`Running flow preview for ${flowPath}...`));
}
log.debug(`Flow value: ${JSON.stringify(localFlow.value, null, 2)}`);
// Run the flow preview — start the job, then poll for completion
const jobId = await wmill.runFlowPreview({
workspace: workspace.workspaceId,
requestBody: {
value: localFlow.value,
path: flowPath.substring(0, flowPath.indexOf(".flow")).replaceAll(SEP, "/"),
path: flowWmPath,
args: input,
temp_script_refs: tempScriptRefs,
},
@@ -674,6 +695,176 @@ async function preview(
}
}
async function previewStep(
stepId: string,
localFlow: FlowFile,
flowWmPath: string,
workspace: { workspaceId: string },
baseArgs: Record<string, unknown>,
tempScriptRefs: Record<string, string> | undefined,
silent: boolean,
) {
const module = findStepInFlowValue(localFlow.value, stepId);
if (!module) {
const available = collectStepIds(localFlow.value).join(", ") || "(none)";
throw new Error(`Step '${stepId}' not found in flow. Available steps: ${available}`);
}
// The preprocessor module receives args via _ENTRYPOINT_OVERRIDE so the
// runner picks the preprocessor entrypoint (matches frontend behavior in
// copilot/chat/flow/core.ts).
const args =
stepId === "preprocessor"
? { _ENTRYPOINT_OVERRIDE: "preprocessor", ...baseArgs }
: baseArgs;
const moduleValue = module.value;
let jobId: string;
if (moduleValue?.type === "rawscript") {
log.info(colors.yellow(`Previewing step '${stepId}' (rawscript, ${moduleValue.language})...`));
jobId = await wmill.runScriptPreview({
workspace: workspace.workspaceId,
requestBody: {
content: moduleValue.content ?? "",
language: moduleValue.language,
// Anchor relative imports to "<flow_wm_path>/<step_id>" so
// temp_script_refs (keyed by Windmill paths) resolve correctly.
// Without `path`, the worker defaults to "tmp/main" and "../foo"
// resolves to "tmp/foo", missing every entry in temp_script_refs.
path: `${flowWmPath}/${stepId}`,
flow_path: flowWmPath,
args,
temp_script_refs: tempScriptRefs,
},
});
} else if (moduleValue?.type === "script") {
// Falls through here only when the deployed PathScript is what we want —
// either --remote was passed, or no local file exists for this path.
log.info(colors.yellow(`Previewing step '${stepId}' (script ${moduleValue.path})...`));
const script = moduleValue.hash
? await wmill.getScriptByHash({
workspace: workspace.workspaceId,
hash: moduleValue.hash,
})
: await wmill.getScriptByPath({
workspace: workspace.workspaceId,
path: moduleValue.path,
});
jobId = await wmill.runScriptPreview({
workspace: workspace.workspaceId,
requestBody: {
content: script.content,
language: script.language as any,
// Anchor to the script's own deployed path so its relative imports
// resolve against the workspace tree (or temp_script_refs).
path: moduleValue.path,
flow_path: flowWmPath,
args,
temp_script_refs: tempScriptRefs,
},
});
} else if (moduleValue?.type === "flow") {
log.info(colors.yellow(`Previewing step '${stepId}' (flow ${moduleValue.path})...`));
jobId = await wmill.runFlowByPath({
workspace: workspace.workspaceId,
path: moduleValue.path,
requestBody: args,
});
} else {
throw new Error(
`Cannot preview step of type '${moduleValue?.type ?? "unknown"}'. Supported types: rawscript, script, flow.`
);
}
const { result, success } = await pollForJobResult(workspace.workspaceId, jobId);
if (!success) {
if (silent) {
console.log(JSON.stringify(result));
} else {
log.info(colors.red.bold(`Step '${stepId}' failed:`));
log.info(JSON.stringify(result, null, 2));
}
process.exitCode = 1;
return;
}
if (silent) {
console.log(JSON.stringify(result));
} else {
log.info(colors.bold.underline.green(`Step '${stepId}' completed`));
log.info(JSON.stringify(result, null, 2));
}
}
// Strip the `.flow`/`__flow` directory suffix to recover the flow's logical
// Windmill path. Workspaces with nonDottedPaths use `__flow`; the default
// uses `.flow`. A previous version used `indexOf(".flow")` which returned -1
// (and thus `substring(0, -1) === ""`) for `__flow` folders and for the
// `dirname("flow.yaml") === "."` fallback — producing an empty path that
// broke relative-import resolution downstream.
function stripFlowSuffix(flowPath: string): string {
const stripped = flowPath.endsWith(SEP) ? flowPath.slice(0, -SEP.length) : flowPath;
if (stripped.endsWith(".flow")) return stripped.slice(0, -".flow".length);
if (stripped.endsWith("__flow")) return stripped.slice(0, -"__flow".length);
return stripped;
}
function findStepInFlowValue(flowValue: any, stepId: string): any | undefined {
if (!flowValue) return undefined;
if (flowValue.failure_module?.id === stepId) return flowValue.failure_module;
if (flowValue.preprocessor_module?.id === stepId) return flowValue.preprocessor_module;
return findStepInModules(flowValue.modules ?? [], stepId);
}
function findStepInModules(modules: any[], stepId: string): any | undefined {
for (const m of modules) {
if (m?.id === stepId) return m;
const v = m?.value;
if (!v) continue;
if (v.type === "forloopflow" || v.type === "whileloopflow") {
const found = findStepInModules(v.modules ?? [], stepId);
if (found) return found;
} else if (v.type === "branchone") {
for (const b of v.branches ?? []) {
const found = findStepInModules(b.modules ?? [], stepId);
if (found) return found;
}
const found = findStepInModules(v.default ?? [], stepId);
if (found) return found;
} else if (v.type === "branchall") {
for (const b of v.branches ?? []) {
const found = findStepInModules(b.modules ?? [], stepId);
if (found) return found;
}
}
}
return undefined;
}
function collectStepIds(flowValue: any): string[] {
const ids: string[] = [];
const walkModules = (modules: any[]) => {
for (const m of modules) {
if (m?.id) ids.push(m.id);
const v = m?.value;
if (!v) continue;
if (v.type === "forloopflow" || v.type === "whileloopflow") {
walkModules(v.modules ?? []);
} else if (v.type === "branchone") {
for (const b of v.branches ?? []) walkModules(b.modules ?? []);
walkModules(v.default ?? []);
} else if (v.type === "branchall") {
for (const b of v.branches ?? []) walkModules(b.modules ?? []);
}
}
};
if (flowValue?.preprocessor_module?.id) ids.push(flowValue.preprocessor_module.id);
if (flowValue?.failure_module?.id) ids.push(flowValue.failure_module.id);
walkModules(flowValue?.modules ?? []);
return ids;
}
export async function generateLocks(
opts: GlobalOptions & {
yes?: boolean;
@@ -890,7 +1081,7 @@ const command = new Command()
.action(run as any)
.command(
"preview",
"preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default."
"preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default. Pass --step <id> to run only one module in isolation (resolves nested steps inside branchone/branchall/forloopflow/whileloopflow plus the special preprocessor/failure modules; supported step types: rawscript, script, flow)."
)
.arguments("<flow_path:string>")
.option(
@@ -905,6 +1096,10 @@ const command = new Command()
"--remote",
"Use deployed workspace scripts for PathScript steps instead of local files."
)
.option(
"--step <step_id:string>",
"Run only the named step instead of the whole flow. Honors --data as the step's args and --remote / local-PathScript resolution the same way the full-flow preview does."
)
.action(preview as any)
.command(
"generate-locks",
@@ -0,0 +1,344 @@
import { Buffer } from "node:buffer";
import { readFile, writeFile } from "node:fs/promises";
import { basename } from "node:path";
import { GlobalOptions } from "../../types.ts";
import { requireLogin } from "../../core/auth.ts";
import { resolveWorkspace } from "../../core/context.ts";
import { Command } from "@cliffy/command";
import { Confirm } from "@cliffy/prompt/confirm";
import { Table } from "@cliffy/table";
import { colors } from "@cliffy/ansi/colors";
import * as log from "../../core/log.ts";
import * as wmill from "../../../gen/services.gen.ts";
import { formatTimestamp } from "../../utils/utils.ts";
function formatBytes(n: number | undefined): string {
if (n == null) return "-";
if (n < 1024) return `${n}B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}K`;
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)}M`;
return `${(n / (1024 * 1024 * 1024)).toFixed(2)}G`;
}
async function listStorages(
opts: GlobalOptions & { json?: boolean }
) {
if (opts.json) log.setSilent(true);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
const names = await wmill.getSecondaryStorageNames({
workspace: workspace.workspaceId,
includeDefault: true,
});
if (opts.json) {
console.log(JSON.stringify(names));
return;
}
if (names.length === 0) {
log.info("No object storage configured for this workspace.");
return;
}
for (const name of names) {
console.log(name === "_default_" ? `${name} ${colors.dim("(default)")}` : name);
}
}
async function listFiles(
opts: GlobalOptions & {
json?: boolean;
maxKeys?: number;
marker?: string;
storage?: string;
},
prefix?: string
) {
if (opts.json) log.setSilent(true);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
const result = await wmill.listStoredFiles({
workspace: workspace.workspaceId,
maxKeys: opts.maxKeys ?? 100,
marker: opts.marker,
prefix,
storage: opts.storage,
});
if (opts.json) {
console.log(JSON.stringify(result));
return;
}
const files = result.windmill_large_files ?? [];
if (files.length === 0) {
log.info("No files found.");
return;
}
new Table()
.header(["Key"])
.padding(2)
.border(true)
.body(files.map((f) => [f.s3]))
.render();
if (result.next_marker) {
log.info(`\nMore results available. Use --marker '${result.next_marker}' to paginate.`);
}
}
async function upload(
opts: GlobalOptions & {
storage?: string;
contentType?: string;
contentDisposition?: string;
},
localPath: string,
fileKey: string
) {
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
const buf = await readFile(localPath);
// Wrap Node Buffer in a Blob for the SDK request body.
const blob = new Blob([buf], { type: opts.contentType ?? "application/octet-stream" });
await wmill.fileUpload({
workspace: workspace.workspaceId,
fileKey,
storage: opts.storage,
contentType: opts.contentType,
contentDisposition: opts.contentDisposition,
requestBody: blob,
});
log.info(colors.green(`Uploaded ${localPath} -> ${fileKey}`));
}
async function download(
opts: GlobalOptions & { storage?: string; stdout?: boolean },
fileKey: string,
outputPath?: string
) {
if (opts.stdout) log.setSilent(true);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
// The generated request layer (cli/gen/core/request.ts:getResponseBody)
// routes by Content-Type: binary types → Blob, text/* → string, JSON → object.
// The generated return type is `Blob | File`, which is wrong for non-binary
// responses, so widen to unknown before normalizing.
const body: unknown = await wmill.fileDownload({
workspace: workspace.workspaceId,
fileKey,
storage: opts.storage,
});
let buf: Buffer;
if (typeof body === "string") {
buf = Buffer.from(body, "utf-8");
} else if (body instanceof Blob) {
buf = Buffer.from(await body.arrayBuffer());
} else if (body instanceof ArrayBuffer) {
buf = Buffer.from(body);
} else if (body == null) {
buf = Buffer.alloc(0);
} else {
buf = Buffer.from(JSON.stringify(body), "utf-8");
}
if (opts.stdout) {
process.stdout.write(buf);
return;
}
const dest = outputPath ?? basename(fileKey);
await writeFile(dest, buf);
log.info(colors.green(`Downloaded ${fileKey} -> ${dest}`));
}
async function del(
opts: GlobalOptions & { storage?: string; yes?: boolean },
fileKey: string
) {
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
if (!opts.yes) {
const confirmed = await Confirm.prompt({
message: `Delete '${fileKey}' from object storage${opts.storage ? ` (storage: ${opts.storage})` : ""}?`,
default: false,
});
if (!confirmed) {
log.info("Aborted.");
return;
}
}
await wmill.deleteS3File({
workspace: workspace.workspaceId,
fileKey,
storage: opts.storage,
});
log.info(colors.green(`Deleted ${fileKey}`));
}
async function move(
opts: GlobalOptions & { storage?: string },
srcFileKey: string,
destFileKey: string
) {
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
await wmill.moveS3File({
workspace: workspace.workspaceId,
srcFileKey,
destFileKey,
storage: opts.storage,
});
log.info(colors.green(`Moved ${srcFileKey} -> ${destFileKey}`));
}
async function info(
opts: GlobalOptions & { json?: boolean; storage?: string },
fileKey: string
) {
if (opts.json) log.setSilent(true);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
const metadata = await wmill.loadFileMetadata({
workspace: workspace.workspaceId,
fileKey,
storage: opts.storage,
});
if (opts.json) {
console.log(JSON.stringify(metadata));
return;
}
console.log(colors.bold("Key:") + " " + fileKey);
console.log(colors.bold("Size:") + " " + formatBytes(metadata.size_in_bytes));
console.log(colors.bold("Mime:") + " " + (metadata.mime_type ?? "-"));
console.log(
colors.bold("Last Modified:") + " " +
(metadata.last_modified ? formatTimestamp(metadata.last_modified) : "-")
);
if (metadata.expires) {
console.log(colors.bold("Expires:") + " " + formatTimestamp(metadata.expires));
}
if (metadata.version_id) {
console.log(colors.bold("Version Id:") + " " + metadata.version_id);
}
}
async function preview(
opts: GlobalOptions & {
storage?: string;
bytesFrom?: number;
bytesLength?: number;
csvSeparator?: string;
csvHeader?: boolean;
mime?: string;
},
fileKey: string
) {
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
// Backend requires both byte fields; mirror the frontend's defaults
// (frontend/src/lib/components/S3FilePickerInner.svelte) for an interactive
// peek so the user gets useful output without passing flags.
const result = await wmill.loadFilePreview({
workspace: workspace.workspaceId,
fileKey,
storage: opts.storage,
fileMimeType: opts.mime,
readBytesFrom: opts.bytesFrom ?? 0,
readBytesLength: opts.bytesLength ?? 128 * 1024,
csvSeparator: opts.csvSeparator,
csvHasHeader: opts.csvHeader,
});
if (result.msg) {
log.info(colors.yellow(result.msg));
}
if (result.content != null) {
process.stdout.write(result.content);
if (!result.content.endsWith("\n")) process.stdout.write("\n");
}
}
const command = new Command()
.alias("s3")
.description("Object storage (S3) related commands. Operates on the workspace's default object storage; use --storage to target a configured secondary storage.")
.action(listStorages as any)
.command(
"list",
"List configured object storages for the workspace (default + secondary)."
)
.option("--json", "Output as JSON (for piping to jq)")
.action(listStorages as any)
.command(
"files",
"List files in an object storage. Optionally filter by prefix."
)
.alias("ls")
.arguments("[prefix:string]")
.option("--json", "Output as JSON (for piping to jq)")
.option("--max-keys <maxKeys:number>", "Page size (default 100)")
.option("--marker <marker:string>", "Pagination marker from a previous response")
.option("--storage <storage:string>", "Secondary storage name (omit for the workspace default)")
.action(listFiles as any)
.command(
"upload",
"Upload a local file to object storage at the given file key."
)
.arguments("<local_path:string> <file_key:string>")
.option("--storage <storage:string>", "Secondary storage name")
.option("--content-type <contentType:string>", "Content-Type header to set on the object")
.option("--content-disposition <contentDisposition:string>", "Content-Disposition header to set on the object")
.action(upload as any)
.command(
"download",
"Download an object to a local file (or stdout). Default output path is the basename of the file key in the current directory."
)
.arguments("<file_key:string> [output_path:string]")
.option("--storage <storage:string>", "Secondary storage name")
.option("--stdout", "Write file contents to stdout instead of a file")
.action(download as any)
.command(
"delete",
"Delete an object from object storage. Prompts for confirmation unless --yes is set."
)
.arguments("<file_key:string>")
.option("--storage <storage:string>", "Secondary storage name")
.option("--yes", "Skip the confirmation prompt")
.action(del as any)
.command(
"move",
"Move an object within the same storage (rename or relocate by key)."
)
.arguments("<src_file_key:string> <dest_file_key:string>")
.option("--storage <storage:string>", "Secondary storage name")
.action(move as any)
.command(
"info",
"Show metadata (size, mime, last-modified) for an object."
)
.arguments("<file_key:string>")
.option("--json", "Output as JSON (for piping to jq)")
.option("--storage <storage:string>", "Secondary storage name")
.action(info as any)
.command(
"preview",
"Preview the contents of an object (text/CSV). Use --bytes-from / --bytes-length to peek at a slice of binary files."
)
.arguments("<file_key:string>")
.option("--storage <storage:string>", "Secondary storage name")
.option("--mime <mime:string>", "Override the detected mime type (e.g. text/csv)")
.option("--bytes-from <bytesFrom:number>", "Start offset in bytes")
.option("--bytes-length <bytesLength:number>", "Number of bytes to read")
.option("--csv-separator <csvSeparator:string>", "CSV column separator (default ,)")
.option("--csv-header", "Treat the first CSV row as a header")
.action(preview as any);
export default command;
+65 -2
View File
@@ -5167,7 +5167,7 @@ Once the flow has real content, **offer** to open the visual preview as a one-se
After writing, tell the user which command fits what they want to do:
- \`wmill flow preview <flow_path>\` — **default when iterating on a local flow.** Runs the local \`flow.yaml\` against local inline scripts without deploying. Add \`--remote\` to use deployed workspace scripts for PathScript steps instead of local files.
- \`wmill flow preview <flow_path>\` — **default when iterating on a local flow.** Runs the local \`flow.yaml\` against local inline scripts without deploying. Add \`--remote\` to use deployed workspace scripts for PathScript steps instead of local files. Add \`--step <step_id>\` to run only one module in isolation (see "Single-step vs whole-flow preview" below).
- \`wmill flow run <path>\` — runs the flow **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits.
- \`wmill generate-metadata\` — regenerate stale \`.lock\` and \`.script.yaml\` files. By default it scans **scripts, flows, and apps** across the workspace; pass \`--skip-flows --skip-apps\` (or run from a subdirectory) to limit the scope when you only care about the flow you edited.
- \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test".
@@ -5184,6 +5184,12 @@ Only use \`sync push\` when:
- The user explicitly asks to deploy, publish, push, or ship.
- The preview has already validated the change and the user wants it in the workspace.
### Single-step vs whole-flow preview
Use \`flow preview <flow_path> --step <step_id>\` when the user is iterating on one module and the flow's upstream steps aren't part of what they're trying to validate. It runs only that step's runnable (rawscript: the inline script; script: the PathScript, locally if available; flow: the subflow by path) and is much faster than running the whole flow when previous steps are slow or expensive. The step id is resolved by walking nested branchone/branchall/forloopflow/whileloopflow modules and includes the special \`preprocessor\` and \`failure\` modules.
Use \`flow preview <flow_path>\` (no \`--step\`) when steps depend on each other's outputs, when the user is validating the overall control flow, or when \`--step\` doesn't apply (branchone, branchall, forloopflow, whileloopflow, identity, and AI agent steps cannot themselves be tested in isolation — for branchone/branchall/forloopflow/whileloopflow, the *contained* steps can, by passing the inner step's id).
### After writing offer to run, don't wait passively
This is about **programmatic execution** (\`wmill flow preview -d '<args>'\`), which actually runs the flow and has side effects. Visual preview (the \`preview\` skill) is offered separately — see "Visual preview" below.
@@ -6838,10 +6844,11 @@ flow related commands
- \`flow run <path:string>\` - run a flow by path.
- \`-d --data <data:string>\` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-.
- \`-s --silent\` - Do not ouput anything other then the final output. Useful for scripting.
- \`flow preview <flow_path:string>\` - preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default.
- \`flow preview <flow_path:string>\` - preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default. Pass --step <id> to run only one module in isolation (resolves nested steps inside branchone/branchall/forloopflow/whileloopflow plus the special preprocessor/failure modules; supported step types: rawscript, script, flow).
- \`-d --data <data:string>\` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-.
- \`-s --silent\` - Do not output anything other then the final output. Useful for scripting.
- \`--remote\` - Use deployed workspace scripts for PathScript steps instead of local files.
- \`--step <step_id:string>\` - Run only the named step instead of the whole flow. Honors --data as the step's args and --remote / local-PathScript resolution the same way the full-flow preview does.
- \`flow new <flow_path:string>\` - create a new empty flow
- \`--summary <summary:string>\` - flow summary
- \`--description <description:string>\` - flow description
@@ -7057,6 +7064,42 @@ Validate Windmill flow, schedule, and trigger YAML files in a directory
- \`--locks-required\` - Fail if scripts or flow inline scripts that need locks have no locks
- \`-w, --watch\` - Watch for file changes and re-lint automatically
### object-storage
**Alias:** \`s3\`
**Subcommands:**
- \`object-storage list\` - List configured object storages for the workspace (default + secondary).
- \`--json\` - Output as JSON (for piping to jq)
- \`object-storage files [prefix:string]\` - List files in an object storage. Optionally filter by prefix.
- \`--json\` - Output as JSON (for piping to jq)
- \`--max-keys <maxKeys:number>\` - Page size (default 100)
- \`--marker <marker:string>\` - Pagination marker from a previous response
- \`--storage <storage:string>\` - Secondary storage name (omit for the workspace default)
- \`object-storage upload <local_path:string> <file_key:string>\` - Upload a local file to object storage at the given file key.
- \`--storage <storage:string>\` - Secondary storage name
- \`--content-type <contentType:string>\` - Content-Type header to set on the object
- \`--content-disposition <contentDisposition:string>\` - Content-Disposition header to set on the object
- \`object-storage download <file_key:string> [output_path:string]\` - Download an object to a local file (or stdout). Default output path is the basename of the file key in the current directory.
- \`--storage <storage:string>\` - Secondary storage name
- \`--stdout\` - Write file contents to stdout instead of a file
- \`object-storage delete <file_key:string>\` - Delete an object from object storage. Prompts for confirmation unless --yes is set.
- \`--storage <storage:string>\` - Secondary storage name
- \`--yes\` - Skip the confirmation prompt
- \`object-storage move <src_file_key:string> <dest_file_key:string>\` - Move an object within the same storage (rename or relocate by key).
- \`--storage <storage:string>\` - Secondary storage name
- \`object-storage info <file_key:string>\` - Show metadata (size, mime, last-modified) for an object.
- \`--json\` - Output as JSON (for piping to jq)
- \`--storage <storage:string>\` - Secondary storage name
- \`object-storage preview <file_key:string>\` - Preview the contents of an object (text/CSV). Use --bytes-from / --bytes-length to peek at a slice of binary files.
- \`--storage <storage:string>\` - Secondary storage name
- \`--mime <mime:string>\` - Override the detected mime type (e.g. text/csv)
- \`--bytes-from <bytesFrom:number>\` - Start offset in bytes
- \`--bytes-length <bytesLength:number>\` - Number of bytes to read
- \`--csv-separator <csvSeparator:string>\` - CSV column separator (default ,)
- \`--csv-header\` - Treat the first CSV row as a header
### protection-rules
**Subcommands:**
@@ -7393,6 +7436,26 @@ workspace related commands
- \`--team-name <team_name:string>\` - Slack team name
- \`workspace disconnect-slack\`
# Object Storage CLI
\`wmill object-storage\` (alias \`wmill s3\`) exposes the workspace's object storage (S3-compatible: AWS S3, MinIO, GCS, R2, Azure Blob) over the per-workspace \`/job_helpers/*\` endpoints.
## Key concepts (not obvious from per-command --help)
- **\`file_key\` is the path inside the bucket** (e.g. \`reports/2026-05/orders.csv\`), not a Windmill path. Do NOT pass \`u/...\` or \`f/...\` here — those are Windmill paths to scripts/flows/resources, unrelated to objects in the bucket.
- **Scope is the active workspace.** Object storage is configured per-workspace (default storage + optional secondary storages). Switching workspaces switches which bucket the commands target.
- **\`--storage <name>\` targets a secondary storage** configured on the workspace. Omit it to use the workspace's default object storage. Use \`wmill object-storage list\` to discover configured storages.
- **\`preview\` vs \`download\`**: \`preview\` returns a peek (CSV first rows, text content, or a byte slice via \`--bytes-from\`/\`--bytes-length\`) without writing to disk. Use \`download\` when you want the full file on disk.
## Choosing a subcommand
- Look at what's there: \`wmill object-storage files [prefix]\` (alias \`ls\`) — paginated, use \`--marker\` to continue.
- Inspect one file: \`wmill object-storage info <file_key>\` for size/mime/last-modified, \`wmill object-storage preview <file_key>\` for content peek.
- Move data in: \`wmill object-storage upload <local_path> <file_key>\` — set \`--content-type\` if the receiver cares (e.g. \`text/csv\`).
- Move data out: \`wmill object-storage download <file_key> [output_path]\`\`--stdout\` to pipe.
- Reorganize: \`wmill object-storage move <src> <dest>\` (same storage), \`wmill object-storage delete <file_key>\` (interactive confirm unless \`--yes\`).
`,
"preview": `---
name: preview
+4 -1
View File
@@ -52,6 +52,7 @@ import docs from "./commands/docs/docs.ts";
import config from "./commands/config/config.ts";
import datatable from "./commands/datatable/datatable.ts";
import ducklake from "./commands/ducklake/ducklake.ts";
import objectStorage from "./commands/object-storage/object-storage.ts";
import { fetchVersion } from "./core/context.ts";
export {
@@ -77,6 +78,7 @@ export {
config,
datatable,
ducklake,
objectStorage,
hubPull,
pull,
push,
@@ -87,7 +89,7 @@ export {
token,
};
export const VERSION = "1.709.0";
export const VERSION = "1.711.0";
// Re-exported from constants.ts to maintain backwards compatibility
export { WM_FORK_PREFIX } from "./core/constants.ts";
@@ -210,6 +212,7 @@ const command = new Command()
.command("config", config)
.command("datatable", datatable)
.command("ducklake", ducklake)
.command("object-storage", objectStorage)
.command("version --version", "Show version information")
.action(async (opts: any) => {
console.log("CLI version: " + VERSION);
+31 -337
View File
@@ -1,354 +1,48 @@
# App Mode AI Chat Review
## Purpose
This note only tracks the highest-value next steps for making app-mode AI chat
safer and more efficient.
This document reviews the current app-mode AI chat design with a focus on:
## Recommended Next Steps
- keeping prompts and context as small as possible;
- requiring user confirmation for important actions;
- making datatable integration smooth and safe for users.
1. Add confirmation for dangerous app tools.
## Short verdict
Require explicit user confirmation before file writes, file deletes, backend
runnable writes, backend runnable deletes, and datatable SQL execution. Show a
useful diff or exact SQL before applying the action.
The app-mode AI chat has a solid foundation: mode-specific helpers, explicit `@` context, app snapshots/revert, datatable whitelisting, and generic confirmation UI already exist.
2. Enforce datatable SQL safety in code.
However, it is not yet optimal for minimal context and user-safe automation:
Do not rely on prompt instructions for SQL safety. Classify statements before
execution, block DDL unless table creation is allowed, and require
confirmation for DDL, DML, and row-returning reads that would expose data back
to the model.
1. **Context is still too large by default**, especially the app system prompt, broad file-discovery guidance, full datatable schemas, and persistent `@` context. (`get_files()` has since been replaced by metadata-only `list_files()`.)
2. **Important app/datatable actions are not consistently confirmed**. The confirmation infrastructure exists, but app tools mostly bypass it.
3. **Datatables UX is promising but has rough edges**: stale cached table context, weak SQL safety, policy persistence issues, and too-heavy full-schema fetching.
3. Keep default context demand-driven.
## Relevant files
Prefer selected context and targeted reads before broad discovery. Keep file
listings metadata-only, avoid sending full datatable schemas by default, and
keep SDK/reference material out of the base prompt unless it is requested or
needed for the task.
### AI chat orchestration
4. Improve app context lifecycle.
- `frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts`
- `frontend/src/lib/components/copilot/chat/chatLoop.ts`
- `frontend/src/lib/components/copilot/chat/shared.ts`
- `frontend/src/lib/components/copilot/chat/AIChat.svelte`
- `frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte`
- `frontend/src/lib/components/copilot/chat/AIChatInput.svelte`
- `frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte`
Treat `@` context as per-message by default, with an explicit pinning affordance
for context that should persist. Lazy-load file and runnable contents, and add
a visible approximate context-size indicator so users can spot prompt bloat.
### App mode
5. Refresh datatable context after mutations.
- `frontend/src/lib/components/copilot/chat/app/core.ts`
- `frontend/src/lib/components/copilot/chat/AppAvailableContextList.svelte`
- `frontend/src/lib/components/copilot/chat/ContextElementBadge.svelte`
- `frontend/src/lib/components/copilot/chat/DatatableCreationPolicy.svelte`
Refresh table metadata after data-panel changes and after AI-created tables so
follow-up tool calls and user-visible context do not use stale schema data.
### Raw app editor and datatables
6. Persist table creation policy explicitly.
- `frontend/src/lib/components/raw_apps/RawAppEditor.svelte`
- `frontend/src/lib/components/raw_apps/RawAppDataTableList.svelte`
- `frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte`
- `frontend/src/lib/components/raw_apps/DefaultDatabaseSelector.svelte`
- `frontend/src/lib/components/raw_apps/dataTableRefUtils.ts`
- `frontend/src/lib/components/raw_apps/datatableUtils.svelte.ts`
- `frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte`
- `frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte`
Store whether AI table creation is enabled as an explicit app setting instead
of inferring it from the presence of datatable configuration.
### Backend datatable APIs
7. Add focused eval coverage for these behaviors.
- `backend/windmill-api-workspaces/src/workspaces.rs`
- `list_datatables`
- `list_datatable_schemas`
- `get_datatable_schema`
- `edit_datatable_config`
### System prompts
- `system_prompts/README.md`
- `system_prompts/auto-generated/index.ts`
- `system_prompts/auto-generated/sdks/datatable-typescript.md`
- `system_prompts/auto-generated/sdks/datatable-python.md`
## How app mode works today
In the raw app editor, `RawAppEditor.svelte` initializes app-mode AI chat on mount:
- calls `aiChatManager.saveAndClear()`;
- calls `aiChatManager.changeMode(AIMode.APP)`;
- registers app helpers through `aiChatManager.setAppHelpers(...)`.
Those app helpers expose operations for:
- frontend files;
- backend runnables;
- current selected editor context;
- linting;
- app snapshots and revert;
- datatable schema loading;
- SQL execution;
- app table whitelisting.
When app mode is active, `AIChatManager.changeMode(AIMode.APP)` sets:
- system prompt: `prepareAppSystemMessage(...)`;
- tools: `getAppTools()`;
- helpers: `appAiChatHelpers`.
When the user sends a message, `prepareAppUserMessage(...)` builds the user prompt from:
- current frontend/backend file selection, unless excluded;
- inspector-selected DOM element;
- editor code selection;
- additional `@`-mentioned context;
- the user instructions.
`runChatLoop(...)` then sends the system message, history, user message, and tool definitions to the selected model. Tool calls go through `processToolCall(...)`, which supports confirmation only when a tool opts into `requiresConfirmation`.
## Current app tools
### Read and discovery tools
These are generally safe without confirmation:
- `list_files`
- `get_frontend_file`
- `get_backend_runnable`
- `get_selected_context`
- `lint`
- `search_workspace`
- `get_runnable_details`
- `search_hub_scripts`
- `list_datatables`
- `get_datatable_table_schema`
### Mutating tools
These currently execute directly in app mode:
- `set_frontend_file`
- `patch_file`
- `delete_frontend_file`
- `set_backend_runnable`
- `delete_backend_runnable`
- `exec_datatable_sql`
This is the biggest mismatch with the requirement that every important action should be confirmed by the user.
## System prompt assessment
The app system prompt is useful but heavier than ideal.
### Strengths
- Clearly explains raw app structure.
- Explains the frontend/backend runnable split.
- Encourages `patch_file` for small edits.
- Pushes datatables for persisted app storage.
- Explains that datatable DDL should go through `exec_datatable_sql`.
- Includes table creation policy context.
### Concerns
1. It always includes broad app-building instructions, even for small localized edits.
2. The previous prompt included the datatable SDK reference for both TypeScript and Python every time. This has since been removed; concise examples remain in the prompt.
3. The previous prompt told the model to start with `get_files()`, which encouraged loading all files even when selected context was sufficient. This is now improved by `list_files()`, but the prompt still needs to stay demand-driven.
4. It relies heavily on prompt instructions for datatable safety instead of enforcing safety in tools.
5. Custom workspace/user prompts are appended as `USER GIVEN INSTRUCTIONS`, which is flexible but can further increase context.
### Recommendation
The base app prompt should be shorter and more demand-driven:
- Keep file discovery demand-driven: use selected and explicitly provided context first; call `list_files()` only when a broader metadata overview is needed.
- Keep full SDK details out of the default prompt; concise examples are usually enough. Add an on-demand SDK reference only if it does not cause unnecessary extra tool turns.
- Keep only minimal datatable rules in the base prompt:
- use datatables for persistence;
- call `list_datatables()` before schema work;
- DDL must use `exec_datatable_sql`;
- non-read SQL requires confirmation.
## Additional context assessment
The `@` context system is a good UX foundation.
App mode exposes categories for:
- frontend files;
- backend runnables;
- datatables.
Selecting a datatable context includes its columns and also calls `addTableToWhitelist(...)`, adding the table to the app data panel.
### Strengths
- Context is explicit and user-controllable.
- Datatable table selection is naturally integrated into the chat input.
- Selected app file/runnable chips are visible and can be excluded.
- Inspector and code-selection context are compact and useful.
### Concerns
1. `@` context persists across messages until manually removed, which can silently bloat follow-up prompts.
2. Available app context currently includes file contents/runnable configs in memory before selection.
3. Each selected context item is truncated, but there is no overall context budget indicator.
4. Current file/runnable selection is included by default unless excluded, which is convenient but not minimal.
### Recommendation
- Make app `@` context per-message by default.
- Add an explicit “pin” option for context that should persist across messages.
- Lazy-load file/runnable content when selected or when a message is sent.
- Show an approximate context-size/token budget indicator.
- Prefer sending path/name and selected code first; fetch full files only when necessary.
## Confirmation assessment
The generic confirmation mechanism already exists:
- `processToolCall(...)` checks `tool.requiresConfirmation`.
- `ToolExecutionDisplay.svelte` renders Run/Cancel controls.
- Script test runs, flow test runs, and mutating API calls already use confirmation.
App mode should use the same infrastructure for important actions.
### Suggested confirmation policy
#### No confirmation required
- `list_files`, as a metadata-only response;
- `get_frontend_file`;
- `get_backend_runnable`;
- `get_selected_context`;
- `list_datatables`, as table-name metadata only;
- `get_datatable_table_schema`, as a targeted schema read;
- `lint`;
- search tools.
#### Confirmation required
- `set_frontend_file`;
- `patch_file`;
- `delete_frontend_file`;
- `set_backend_runnable`;
- `delete_backend_runnable`;
- `exec_datatable_sql` for any DDL or DML;
- `exec_datatable_sql` for `SELECT` if it returns real row data that will be sent back to the model.
### Recommended UX
For files/runnables:
- Prefer batched proposed edits.
- Show a diff.
- Let the user click “Apply changes”.
- Run lint after applying.
For SQL:
- Show the exact SQL.
- Classify the query as:
- schema read;
- data read;
- insert/update/delete;
- DDL.
- Require confirmation before data reads and all mutations.
- For table creation, require both:
- table creation policy enabled;
- explicit confirmation of the `CREATE TABLE` SQL.
## Datatables integration assessment
The datatable integration is directionally good and already has several strong user-facing pieces.
### Current strengths
The new app setup lets the user choose:
- default datatable;
- schema mode: none, new, existing;
- whether AI can create tables;
- pre-whitelisted existing tables.
The raw app data panel lets users:
- add datatable table references;
- inspect tables through the DB manager drawer;
- configure the default datatable/schema for new tables.
The AI chat integration lets users:
- mention datatable tables through `@` context;
- add mentioned tables to the app whitelist;
- list datatable/schema/table names with `list_datatables()`;
- retrieve one table's columns with `get_datatable_table_schema()`;
- create tables through `exec_datatable_sql(..., new_table)`.
### Concerns
1. **`exec_datatable_sql` is too powerful without confirmation.**
It can run `SELECT`, `INSERT`, `UPDATE`, `DELETE`, `CREATE`, `DROP`, `ALTER`, etc.
2. **Table creation policy is not fully enforced in code.**
The tool blocks `new_table` when policy is disabled, but it does not block DDL if the model omits `new_table`.
3. **Table creation disabled state may not persist cleanly.**
`RawAppData` stores `datatable` and `schema`, but not an explicit `enabled` value. `RawAppEditor` infers enabled from `data.datatable !== undefined`, which can re-enable table creation after reopening.
4. **Datatable context cache can become stale.**
`AIChatManager.refreshDatatables()` runs when app helpers are set, but may not refresh immediately after data panel changes or after AI creates a new table.
5. **Full schema loading can still be too expensive internally.**
`list_datatables()` and `get_datatable_table_schema()` reduce what is sent to the model, but they still currently rely on app helpers that fetch full schema data before filtering.
6. **Auto-whitelisting from `@table` is convenient but silent.**
It mutates app data without an obvious confirmation or undo affordance.
### Recommended datatable tool design
Instead of one broad schema tool and one unrestricted SQL tool, prefer smaller tools:
- `list_datatables()`
- `list_datatable_tables(datatable, schema?, search?)` (optional backend/API optimization if table lists need server-side filtering)
- `get_datatable_table_schema(datatable, schema, table)`
- `preview_datatable_rows(datatable, schema, table, limit)` with confirmation
- `execute_datatable_sql(datatable, sql)` with query classification and confirmation
- `create_datatable_table(datatable, schema, table, columns)` as a structured safe path for table creation
## Priority recommendations
1. **Add confirmation to dangerous app tools**
- file/runnable writes;
- file/runnable deletes;
- datatable SQL;
- especially DDL/DML.
2. **Enforce SQL safety in code, not only in prompts**
- block DDL unless `new_table` is provided and policy allows it;
- confirm all non-`SELECT` statements;
- consider confirming `SELECT` row reads too.
3. **Reduce default prompt/tool context**
- keep `list_files()` metadata-only and demand-driven;
- use selected context first;
- keep full SDK references out of the default prompt;
- keep datatable tools split into smaller schema/table lookups.
4. **Refresh datatable context reliably**
- refresh after data panel changes;
- refresh after `exec_datatable_sql(..., new_table)`;
- remove debug logging from datatable refresh.
5. **Persist table creation policy explicitly**
- store a boolean such as `tableCreationEnabled` in raw app data;
- do not infer enabled solely from `data.datatable`.
6. **Improve `@` context lifecycle**
- make app `@` context per-message by default;
- add pinning for persistent context;
- lazy-load file/runnable contents;
- show approximate context size.
## Overall opinion
The current architecture is good and extensible, but it should become more demand-driven and safer before being considered efficient and user-safe.
The highest-impact changes are:
- add confirmation for app mutations and datatable SQL;
- enforce datatable SQL policy programmatically;
- reduce the app system prompt and avoid automatic broad context loading;
- split datatable schema access into smaller, targeted tools.
Cover confirmation requirements, datatable SQL policy enforcement, selected
context minimization, and stale-schema refresh behavior with targeted app-mode
evals or lower-level tests where practical.
-245
View File
@@ -1,245 +0,0 @@
# App Mode AI Chat Token Baseline
This baseline was collected before optimizing app-mode context/prompt/datatable behavior.
> Note: The historical commands/results below include `app-token-selected-large-frontend-context` and `app-token-selected-large-backend-context`. Those cases were removed from the active eval suite because `runtime.appContext.selected` only verified that the file/runnable existed and did not serialize a selected file/runnable hint to the model. Future selected-file/runnable coverage should be reintroduced through the app context manager path.
## Command
Secrets were loaded from `~/windmill/ai_evals/.env` without printing them.
```bash
cd ai_evals
set -a
source ~/windmill/ai_evals/.env
set +a
bun run cli -- run app \
app-token-baseline-large-app-small-edit \
app-token-selected-large-frontend-context \
app-token-selected-large-backend-context \
app-token-many-datatable-context \
app-token-large-datatable-discovery \
--model haiku \
--runs 1 \
--output results/app-token-baseline-current-max8.json
```
## Environment
- Mode: `app`
- Model under test: `anthropic:claude-haiku-4-5-20251001`
- Transport: `direct`
- Judge model: `claude-sonnet-4-6`
- Runs per case: `1`
- Token-heavy app cases use `runtime.maxTurns: 8`
## Results
Pass rate: **100% (5/5)**
| Case | Prompt tokens | Completion tokens | Total tokens | Tool calls | Tools used |
|---|---:|---:|---:|---:|---|
| `app-token-baseline-large-app-small-edit` | 73,682 | 519 | 74,201 | 4 | `get_files`, `get_frontend_file`, `patch_file` |
| `app-token-selected-large-frontend-context` | 36,305 | 348 | 36,653 | 2 | `get_frontend_file`, `patch_file` |
| `app-token-selected-large-backend-context` | 95,232 | 19,633 | 114,865 | 4 | `set_backend_runnable`, `get_backend_runnable` |
| `app-token-many-datatable-context` | 35,204 | 404 | 35,608 | 2 | `get_files`, `patch_file` |
| `app-token-large-datatable-discovery` | 114,964 | 4,047 | 119,011 | 7 | `get_files`, `get_datatables`, `set_backend_runnable`, `set_frontend_file`, `patch_file`, `lint` |
Aggregate token usage:
```json
{
"totalTokenUsage": {
"prompt": 355387,
"completion": 24951,
"total": 380338
},
"averageTokenUsagePerAttempt": {
"prompt": 71077.4,
"completion": 4990.2,
"total": 76067.6
}
}
```
## Interpretation
The highest-token cases are:
1. `app-token-large-datatable-discovery` — full datatable discovery with `get_datatables()` and app edits reached **119,011** total tokens.
2. `app-token-selected-large-backend-context` — selected large backend runnable plus a rewrite-style tool call reached **114,865** total tokens.
3. `app-token-baseline-large-app-small-edit` — a trivial heading edit still reached **74,201** total tokens, largely due broad file discovery.
These cases should be rerun after prompt/context/tool changes to compare total and prompt-token reductions.
## Follow-up: metadata-only `list_files`
The contentful `get_files` app-mode tool was replaced with `list_files` to make broad app discovery cheaper and less sticky in chat history.
Changes:
- Renamed the overview tool from `get_files` to `list_files`.
- Changed the overview response from truncated source/config contents to metadata only.
- `list_files` returns:
- frontend files: `path`, character `size`, and file `kind`;
- backend runnables: `key`, `name`, `type`, and lightweight optional metadata such as `path`, `language`, `contentSize`, and `staticInputKeys`.
- Updated app-mode prompt guidance so the model no longer starts every task with broad file discovery.
- Kept targeted content tools as the path for inspection:
- `get_frontend_file(path)` for frontend source;
- `get_backend_runnable(key)` for runnable configuration/source.
The same five cases were rerun with:
```bash
cd ai_evals
set -a
source ~/windmill/ai_evals/.env
set +a
bun run cli -- run app \
app-token-baseline-large-app-small-edit \
app-token-selected-large-frontend-context \
app-token-selected-large-backend-context \
app-token-many-datatable-context \
app-token-large-datatable-discovery \
--model haiku \
--runs 1 \
--output results/app-token-after-list-files.json
```
Pass rate: **100% (5/5)**
| Case | Prompt tokens | Completion tokens | Total tokens | Tool calls | Tools used |
|---|---:|---:|---:|---:|---|
| `app-token-baseline-large-app-small-edit` | 41,020 | 422 | 41,442 | 3 | `list_files`, `get_frontend_file`, `patch_file` |
| `app-token-selected-large-frontend-context` | 41,020 | 422 | 41,442 | 3 | `list_files`, `get_frontend_file`, `patch_file` |
| `app-token-selected-large-backend-context` | 53,511 | 9,714 | 63,225 | 3 | `list_files`, `get_backend_runnable`, `set_backend_runnable` |
| `app-token-many-datatable-context` | 46,990 | 475 | 47,465 | 3 | `list_files`, `get_frontend_file`, `patch_file` |
| `app-token-large-datatable-discovery` | 131,607 | 5,084 | 136,691 | 8 | `get_datatables`, `list_files`, `set_backend_runnable`, `set_frontend_file`, `patch_file`, `lint` |
Aggregate token usage:
```json
{
"totalTokenUsage": {
"prompt": 314148,
"completion": 16117,
"total": 330265
},
"averageTokenUsagePerAttempt": {
"prompt": 62829.6,
"completion": 3223.4,
"total": 66053
}
}
```
Comparison against the post-rebase / PR #8922 run (`results/app-token-after-origin-main-pr8922.json`):
| Case | PR #8922 total | `list_files` total | Delta | Delta % | Prompt delta |
|---|---:|---:|---:|---:|---:|
| `app-token-baseline-large-app-small-edit` | 74,061 | 41,442 | -32,619 | -44.0% | -32,522 |
| `app-token-selected-large-frontend-context` | 74,061 | 41,442 | -32,619 | -44.0% | -32,522 |
| `app-token-selected-large-backend-context` | 71,050 | 63,225 | -7,825 | -11.0% | -7,787 |
| `app-token-many-datatable-context` | 35,497 | 47,465 | +11,968 | +33.7% | +11,886 |
| `app-token-large-datatable-discovery` | 97,128 | 136,691 | +39,563 | +40.7% | +38,295 |
Aggregate comparison against the post-rebase / PR #8922 run:
| Metric | PR #8922 | `list_files` | Delta | Delta % |
|---|---:|---:|---:|---:|
| Prompt tokens | 336,798 | 314,148 | -22,650 | -6.7% |
| Completion tokens | 14,999 | 16,117 | +1,118 | +7.5% |
| Total tokens | 351,797 | 330,265 | -21,532 | -6.1% |
Compared to the original baseline above, the `list_files` run is **-50,073 total tokens** (**-13.2% total**).
Interpretation:
- The small edit and selected-frontend cases improved substantially because broad discovery no longer injects truncated contents for the whole app.
- The selected-backend case also improved, despite still needing targeted runnable inspection.
- The datatable-context cases can require an extra `get_frontend_file` after `list_files`, so the small datatable edit regressed in this single-run sample.
- The large datatable case remains dominated by datatable/schema prompt bloat and model variability; moving datatable SDK/reference and schema discovery behind smaller on-demand tools is still the next likely high-impact optimization.
## Follow-up: targeted datatable tools and shorter datatable prompt
The next pass reduced default datatable context by making datatable discovery metadata-first and removing the full datatable SDK reference from the system prompt.
Changes:
- Replaced the broad schema discovery tool with `list_datatables()` for datatable/schema/table names only.
- Added `get_datatable_table_schema(datatable_name, schema_name, table_name)` for targeted column lookup when column names/types are actually needed.
- Removed the full TypeScript + Python datatable SDK reference from the default app system prompt.
- Kept concise TypeScript and Python datatable examples in the prompt, which were enough for the benchmark cases.
- Strengthened prompt/tool guidance so table-list dashboards use `list_datatables()` directly and avoid schema/SDK lookups unless needed.
The same five cases were rerun with:
```bash
cd ai_evals
set -a
source ~/windmill/ai_evals/.env
set +a
bun run cli -- run app \
app-token-baseline-large-app-small-edit \
app-token-selected-large-frontend-context \
app-token-selected-large-backend-context \
app-token-many-datatable-context \
app-token-large-datatable-discovery \
--model haiku \
--runs 1 \
--output results/app-token-after-datatable-tools-v3.json
```
Pass rate: **100% (5/5)**
| Case | Prompt tokens | Completion tokens | Total tokens | Tool calls | Tools used |
|---|---:|---:|---:|---:|---|
| `app-token-baseline-large-app-small-edit` | 37,516 | 425 | 37,941 | 3 | `list_files`, `get_frontend_file`, `patch_file` |
| `app-token-selected-large-frontend-context` | 37,516 | 358 | 37,874 | 3 | `list_files`, `get_frontend_file`, `patch_file` |
| `app-token-selected-large-backend-context` | 49,995 | 9,708 | 59,703 | 3 | `list_files`, `get_backend_runnable`, `set_backend_runnable` |
| `app-token-many-datatable-context` | 43,493 | 536 | 44,029 | 3 | `list_files`, `get_frontend_file`, `patch_file` |
| `app-token-large-datatable-discovery` | 24,193 | 2,043 | 26,236 | 4 | `list_datatables`, `list_files`, `get_frontend_file`, `set_frontend_file` |
Aggregate token usage:
```json
{
"totalTokenUsage": {
"prompt": 192713,
"completion": 13070,
"total": 205783
},
"averageTokenUsagePerAttempt": {
"prompt": 38542.6,
"completion": 2614,
"total": 41156.6
}
}
```
Comparison against the metadata-only `list_files` run (`results/app-token-after-list-files.json`):
| Case | `list_files` total | Datatable-tools total | Delta | Delta % | Prompt delta |
|---|---:|---:|---:|---:|---:|
| `app-token-baseline-large-app-small-edit` | 41,442 | 37,941 | -3,501 | -8.4% | -3,504 |
| `app-token-selected-large-frontend-context` | 41,442 | 37,874 | -3,568 | -8.6% | -3,504 |
| `app-token-selected-large-backend-context` | 63,225 | 59,703 | -3,522 | -5.6% | -3,516 |
| `app-token-many-datatable-context` | 47,465 | 44,029 | -3,436 | -7.2% | -3,497 |
| `app-token-large-datatable-discovery` | 136,691 | 26,236 | -110,455 | -80.8% | -107,414 |
Aggregate comparison:
| Metric | `list_files` | Datatable tools | Delta | Delta % |
|---|---:|---:|---:|---:|
| Prompt tokens | 314,148 | 192,713 | -121,435 | -38.7% |
| Completion tokens | 16,117 | 13,070 | -3,047 | -18.9% |
| Total tokens | 330,265 | 205,783 | -124,482 | -37.7% |
Compared to the post-rebase / PR #8922 run, the datatable-tools run is **-146,014 total tokens** (**-41.5% total**). Compared to the original baseline above, it is **-174,555 total tokens** (**-45.9% total**).
Interpretation:
- Removing the full datatable SDK reference from the default prompt saved about 3.5k prompt tokens in every case.
- The large datatable discovery case improved dramatically because the model used `list_datatables()` table-name metadata instead of loading full schemas.
- The small datatable-context edit is still higher than the post-rebase / PR #8922 run because selected file identifiers are not yet injected, so the model still discovers and reads `/index.tsx` before patching.
- A future context-manager-backed selected file/runnable flow should add cheap selected identifiers when that UX is ready, so selected-file tasks can skip `list_files()` without reintroducing implicit source-content bloat.
-33
View File
@@ -1,33 +0,0 @@
# Failing Tests
This file tracks benchmark cases that still fail or need follow-up validation.
## Flow
- `flow-test6-ai-agent-tools`
Latest failing run: `ai_evals/results/2026-04-09T11-25-24.107Z__flow`
Issues:
final output does not include the actions or tool-result details the prompt asks for
`open_support_ticket` contains a syntax bug
- `flow-test7-simple-modification`
Latest failing run: `ai_evals/results/2026-04-09T11-25-24.107Z__flow`
Issues:
`validate_data` was added, but the failure behavior still does not match the requested contract
`save_results` throws instead of returning a graceful structured result
- `flow-test11-preprocessor-and-failure-handler`
Latest failing run: `ai_evals/results/2026-04-09T11-25-24.107Z__flow`
Issues:
the model creates regular `preprocessor` and `failure` modules
it does not use Windmill's special top-level `preprocessor_module` and `failure_module`
## Needs Reconfirmation
- `flow-test4-order-processing-loop`
Full-suite failing run: `ai_evals/results/2026-04-09T11-25-24.107Z__flow`
Follow-up passing run after prompt improvement: `ai_evals/results/2026-04-09T13-29-15.877Z__flow`
Note:
this case failed on invalid `branchone` downstream result access
it passed after adding explicit branch-output guidance to the flow prompt
rerun the full flow suite to confirm the fix holds in the broader benchmark
File diff suppressed because it is too large Load Diff
-140
View File
@@ -1,140 +0,0 @@
# System Prompt Testing Status
This document describes the benchmark tool that exists today. It is the current
truth for `ai_evals/`.
The longer planning document in
[system-prompt-testing-plan.md](/home/farhad/windmill__worktrees/prompt-testing-plan/docs/system-prompt-testing-plan.md)
still contains useful background, but parts of its workflow are now historical
because the old variants/history system was removed.
## Current Tool
There is one repo-level benchmark CLI under `ai_evals/` with three commands:
- `bun run cli -- models`
- `bun run cli -- cases [mode]`
- `bun run cli -- run <mode> [caseIds...]`
Supported modes:
- `cli`
- `flow`
- `script`
- `app`
Public `run` options:
- `--runs <n>`
- `--output <path>`
- `--model <alias>`
- `--verbose`
- `--record`
There is no variant workflow and no compare command in the current tool.
Tracked history is intentionally minimal: `run --record` appends one compact
summary line to `ai_evals/history/<mode>.jsonl`. This is only allowed for
full-suite runs, not selected case ids. History lines include average token
usage when the benchmark mode reports it, plus average judge score and per-case
duration/judge/token usage summaries.
## How It Works
Each attempt runs:
1. the current production prompts, tools, and guidance from this checkout
2. deterministic validation
3. LLM judging
Results are written locally under `ai_evals/results/` as:
- a summary JSON file
- a sibling artifacts directory containing the generated flow/script/app/workspace
If `--record` is used, the CLI also appends a compact JSONL summary line to the
tracked file for that mode under `ai_evals/history/`.
## Current Architecture
- `ai_evals/cases/`: one YAML manifest per mode
- `ai_evals/fixtures/`: initial and expected fixtures
- `ai_evals/core/`: shared case loading, model resolution, validation, judging, and result writing
- `ai_evals/history/`: optional tracked pass-rate history written by `run --record`, one JSONL file per mode
- `ai_evals/modes/`: one runner per mode
Execution model:
- `flow`, `script`, and `app` reuse the production frontend chat loop and production tool definitions through the frontend Vitest bridge
- `cli` creates a temp workspace, writes the current checkout guidance into it, and runs the Anthropic agent SDK against that workspace
## Case Model
Each case is intentionally small:
- `prompt`
- optional `initial`
- optional `expected`
- optional `validate`
- optional `cliExpect`
`validate` is mainly used for stronger deterministic checks where exact fixture
matching would be too strict, especially for `flow` creation cases.
`cliExpect` is used by CLI-mode cases to assert agent behavior deterministically,
including:
- required or forbidden skills
- skills invoked before the first file mutation
- ordered `wmill` command proposals in the assistant response
- forbidden attempted `wmill` executions
- read-only guidance cases where the workspace must stay unchanged
Examples of current deterministic checks:
- schema contains one of several accepted input shapes
- `results.*` references resolve
- required code/input characteristics exist in some module
- expected workspace files are created in `cli` mode
- expected CLI skills and proposed `wmill` commands are observed in `cli` mode
## Model Selection
Model aliases are resolved through a shared registry in `ai_evals/core/models.ts`.
Current aliases:
- `haiku`
- `sonnet`
- `opus`
- `4o`
Notes:
- the `models` command also shows accepted alias spellings such as `gpt-4o` and `claude-opus-4.6`
- frontend modes can use Anthropic and OpenAI-backed aliases
- `cli` mode is Anthropic-only because it runs through the Anthropic agent SDK
- the judge model is separate and currently defaults to `claude-sonnet-4-6`
## What Is Working Well
- one simple local benchmark CLI
- real production execution paths instead of synthetic prompt variants
- local result and artifact persistence by default
- live frontend progress output
- reusable flow/script/app/cli runners under one tool
- deterministic validation can now catch real runtime-invalid flow wiring
## What Still Needs Work
- broader case coverage across all four modes
- stronger deterministic validators for more cases, especially app/script semantics
- clearer per-case validation metadata as the corpus grows
- CI automation for smoke and nightly runs
## Recommended Next Focus
The next high-value work is:
1. add more realistic benchmark cases
2. keep simplifying deterministic validators so they check correctness, not one exact implementation
3. add CI only after the local benchmark signal is trustworthy
+50 -4
View File
@@ -1,12 +1,12 @@
{
"name": "@windmill-labs/components",
"version": "1.709.0",
"version": "1.711.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@windmill-labs/components",
"version": "1.709.0",
"version": "1.711.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
@@ -846,6 +846,7 @@
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -857,6 +858,7 @@
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -867,6 +869,7 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1356,6 +1359,7 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1504,6 +1508,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1520,6 +1525,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1536,6 +1542,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1552,6 +1559,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1568,6 +1576,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1584,6 +1593,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1600,6 +1610,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1616,6 +1627,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1632,6 +1644,7 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1648,6 +1661,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1664,6 +1678,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1680,6 +1695,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1696,6 +1712,7 @@
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1714,6 +1731,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1730,6 +1748,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2035,6 +2054,7 @@
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -6810,7 +6830,7 @@
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"devOptional": true,
"dev": true,
"license": "MIT",
"bin": {
"jiti": "bin/jiti.js"
@@ -7309,6 +7329,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7329,6 +7350,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7349,6 +7371,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7369,6 +7392,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7389,6 +7413,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7409,6 +7434,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7429,6 +7455,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7449,6 +7476,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7469,6 +7497,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7489,6 +7518,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7509,6 +7539,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -12077,6 +12108,21 @@
}
}
},
"node_modules/svelte-check/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/svelte-eslint-parser": {
"version": "0.43.0",
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz",
@@ -12807,7 +12853,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill-labs/components",
"version": "1.709.0",
"version": "1.711.0",
"scripts": {
"dev": "vite dev",
"dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev",
+3 -3
View File
@@ -428,7 +428,7 @@
inputBaseClass,
inputBorderClass({ error: !!error }),
inputSizeClasses[size],
'relative flex gap-0 pb-0 mb-1 flex-col flex-wrap sm:flex-row sm:items-center',
'relative flex gap-0 pb-0 mb-1 flex-wrap flex-row items-center',
disabled && '!bg-surface-disabled cursor-not-allowed border-none'
)}
>
@@ -486,7 +486,7 @@
/>
</label>
{:else if meta.ownerKind === 'folder'}
<label class="block grow w-42">
<label class="block grow">
<FolderPicker
bind:folderName={meta.owner}
{initialPath}
@@ -500,7 +500,7 @@
{/if}
</div>
<div class="text-sm text-secondary">/</div>
<label class="block grow min-w-32 mr-3">
<label class="block grow mr-3">
<!-- svelte-ignore a11y_autofocus -->
<PathNameAutocomplete
bind:this={inputP}
@@ -1,5 +1,6 @@
<script lang="ts">
import { buildWsUrl } from '$lib/wsUrl'
import { paneMinPercent } from '$lib/utils/splitpaneSizing'
import { processSecretArgs } from './secretArgUtils'
import type { Schema, SupportedLanguage } from '$lib/common'
import {
@@ -1432,9 +1433,7 @@
const splitAxisExtent = $derived(
previewLayout === 'bottom' ? splitContainerHeight : splitContainerWidth
)
const testPaneMinPercent = $derived(
splitAxisExtent > 0 ? Math.min(80, (testPaneMinPx / splitAxisExtent) * 100) : 0
)
const testPaneMinPercent = $derived(paneMinPercent(splitAxisExtent, testPaneMinPx))
// Raw user-controlled test size (what the splitter wrote, or what the
// toggle set). The size we actually pass to <Pane> is clamped to the
+1 -1
View File
@@ -4,7 +4,7 @@ verify_ssl = true
name = "pypi"
[packages]
wmill = ">=1.709.0"
wmill = ">=1.711.0"
sendgrid = "*"
mysql-connector-python = "*"
pymongo = "*"
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: '3.0.3'
info:
version: 1.709.0
version: 1.711.0
title: OpenFlow Spec
contact:
name: Ruben Fiszel
@@ -12,7 +12,7 @@
RootModule = 'WindmillClient.psm1'
# Version number of this module.
ModuleVersion = '1.709.0'
ModuleVersion = '1.711.0'
# Supported PSEditions
# CompatiblePSEditions = @()
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "wmill"
version = "1.709.0"
version = "1.711.0"
description = "A client library for accessing Windmill server wrapping the Windmill client API"
license = "Apache-2.0"
homepage = "https://windmill.dev"
@@ -148,10 +148,11 @@ flow related commands
- `flow run <path:string>` - run a flow by path.
- `-d --data <data:string>` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-.
- `-s --silent` - Do not ouput anything other then the final output. Useful for scripting.
- `flow preview <flow_path:string>` - preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default.
- `flow preview <flow_path:string>` - preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default. Pass --step <id> to run only one module in isolation (resolves nested steps inside branchone/branchall/forloopflow/whileloopflow plus the special preprocessor/failure modules; supported step types: rawscript, script, flow).
- `-d --data <data:string>` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-.
- `-s --silent` - Do not output anything other then the final output. Useful for scripting.
- `--remote` - Use deployed workspace scripts for PathScript steps instead of local files.
- `--step <step_id:string>` - Run only the named step instead of the whole flow. Honors --data as the step's args and --remote / local-PathScript resolution the same way the full-flow preview does.
- `flow new <flow_path:string>` - create a new empty flow
- `--summary <summary:string>` - flow summary
- `--description <description:string>` - flow description
@@ -367,6 +368,42 @@ Validate Windmill flow, schedule, and trigger YAML files in a directory
- `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks
- `-w, --watch` - Watch for file changes and re-lint automatically
### object-storage
**Alias:** `s3`
**Subcommands:**
- `object-storage list` - List configured object storages for the workspace (default + secondary).
- `--json` - Output as JSON (for piping to jq)
- `object-storage files [prefix:string]` - List files in an object storage. Optionally filter by prefix.
- `--json` - Output as JSON (for piping to jq)
- `--max-keys <maxKeys:number>` - Page size (default 100)
- `--marker <marker:string>` - Pagination marker from a previous response
- `--storage <storage:string>` - Secondary storage name (omit for the workspace default)
- `object-storage upload <local_path:string> <file_key:string>` - Upload a local file to object storage at the given file key.
- `--storage <storage:string>` - Secondary storage name
- `--content-type <contentType:string>` - Content-Type header to set on the object
- `--content-disposition <contentDisposition:string>` - Content-Disposition header to set on the object
- `object-storage download <file_key:string> [output_path:string]` - Download an object to a local file (or stdout). Default output path is the basename of the file key in the current directory.
- `--storage <storage:string>` - Secondary storage name
- `--stdout` - Write file contents to stdout instead of a file
- `object-storage delete <file_key:string>` - Delete an object from object storage. Prompts for confirmation unless --yes is set.
- `--storage <storage:string>` - Secondary storage name
- `--yes` - Skip the confirmation prompt
- `object-storage move <src_file_key:string> <dest_file_key:string>` - Move an object within the same storage (rename or relocate by key).
- `--storage <storage:string>` - Secondary storage name
- `object-storage info <file_key:string>` - Show metadata (size, mime, last-modified) for an object.
- `--json` - Output as JSON (for piping to jq)
- `--storage <storage:string>` - Secondary storage name
- `object-storage preview <file_key:string>` - Preview the contents of an object (text/CSV). Use --bytes-from / --bytes-length to peek at a slice of binary files.
- `--storage <storage:string>` - Secondary storage name
- `--mime <mime:string>` - Override the detected mime type (e.g. text/csv)
- `--bytes-from <bytesFrom:number>` - Start offset in bytes
- `--bytes-length <bytesLength:number>` - Number of bytes to read
- `--csv-separator <csvSeparator:string>` - CSV column separator (default ,)
- `--csv-header` - Treat the first CSV row as a header
### protection-rules
**Subcommands:**
@@ -703,3 +740,23 @@ workspace related commands
- `--team-name <team_name:string>` - Slack team name
- `workspace disconnect-slack`
# Object Storage CLI
`wmill object-storage` (alias `wmill s3`) exposes the workspace's object storage (S3-compatible: AWS S3, MinIO, GCS, R2, Azure Blob) over the per-workspace `/job_helpers/*` endpoints.
## Key concepts (not obvious from per-command --help)
- **`file_key` is the path inside the bucket** (e.g. `reports/2026-05/orders.csv`), not a Windmill path. Do NOT pass `u/...` or `f/...` here — those are Windmill paths to scripts/flows/resources, unrelated to objects in the bucket.
- **Scope is the active workspace.** Object storage is configured per-workspace (default storage + optional secondary storages). Switching workspaces switches which bucket the commands target.
- **`--storage <name>` targets a secondary storage** configured on the workspace. Omit it to use the workspace's default object storage. Use `wmill object-storage list` to discover configured storages.
- **`preview` vs `download`**: `preview` returns a peek (CSV first rows, text content, or a byte slice via `--bytes-from`/`--bytes-length`) without writing to disk. Use `download` when you want the full file on disk.
## Choosing a subcommand
- Look at what's there: `wmill object-storage files [prefix]` (alias `ls`) — paginated, use `--marker` to continue.
- Inspect one file: `wmill object-storage info <file_key>` for size/mime/last-modified, `wmill object-storage preview <file_key>` for content peek.
- Move data in: `wmill object-storage upload <local_path> <file_key>` — set `--content-type` if the receiver cares (e.g. `text/csv`).
- Move data out: `wmill object-storage download <file_key> [output_path]``--stdout` to pipe.
- Reorganize: `wmill object-storage move <src> <dest>` (same storage), `wmill object-storage delete <file_key>` (interactive confirm unless `--yes`).
+58 -1
View File
@@ -2698,10 +2698,11 @@ flow related commands
- \`flow run <path:string>\` - run a flow by path.
- \`-d --data <data:string>\` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-.
- \`-s --silent\` - Do not ouput anything other then the final output. Useful for scripting.
- \`flow preview <flow_path:string>\` - preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default.
- \`flow preview <flow_path:string>\` - preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default. Pass --step <id> to run only one module in isolation (resolves nested steps inside branchone/branchall/forloopflow/whileloopflow plus the special preprocessor/failure modules; supported step types: rawscript, script, flow).
- \`-d --data <data:string>\` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-.
- \`-s --silent\` - Do not output anything other then the final output. Useful for scripting.
- \`--remote\` - Use deployed workspace scripts for PathScript steps instead of local files.
- \`--step <step_id:string>\` - Run only the named step instead of the whole flow. Honors --data as the step's args and --remote / local-PathScript resolution the same way the full-flow preview does.
- \`flow new <flow_path:string>\` - create a new empty flow
- \`--summary <summary:string>\` - flow summary
- \`--description <description:string>\` - flow description
@@ -2917,6 +2918,42 @@ Validate Windmill flow, schedule, and trigger YAML files in a directory
- \`--locks-required\` - Fail if scripts or flow inline scripts that need locks have no locks
- \`-w, --watch\` - Watch for file changes and re-lint automatically
### object-storage
**Alias:** \`s3\`
**Subcommands:**
- \`object-storage list\` - List configured object storages for the workspace (default + secondary).
- \`--json\` - Output as JSON (for piping to jq)
- \`object-storage files [prefix:string]\` - List files in an object storage. Optionally filter by prefix.
- \`--json\` - Output as JSON (for piping to jq)
- \`--max-keys <maxKeys:number>\` - Page size (default 100)
- \`--marker <marker:string>\` - Pagination marker from a previous response
- \`--storage <storage:string>\` - Secondary storage name (omit for the workspace default)
- \`object-storage upload <local_path:string> <file_key:string>\` - Upload a local file to object storage at the given file key.
- \`--storage <storage:string>\` - Secondary storage name
- \`--content-type <contentType:string>\` - Content-Type header to set on the object
- \`--content-disposition <contentDisposition:string>\` - Content-Disposition header to set on the object
- \`object-storage download <file_key:string> [output_path:string]\` - Download an object to a local file (or stdout). Default output path is the basename of the file key in the current directory.
- \`--storage <storage:string>\` - Secondary storage name
- \`--stdout\` - Write file contents to stdout instead of a file
- \`object-storage delete <file_key:string>\` - Delete an object from object storage. Prompts for confirmation unless --yes is set.
- \`--storage <storage:string>\` - Secondary storage name
- \`--yes\` - Skip the confirmation prompt
- \`object-storage move <src_file_key:string> <dest_file_key:string>\` - Move an object within the same storage (rename or relocate by key).
- \`--storage <storage:string>\` - Secondary storage name
- \`object-storage info <file_key:string>\` - Show metadata (size, mime, last-modified) for an object.
- \`--json\` - Output as JSON (for piping to jq)
- \`--storage <storage:string>\` - Secondary storage name
- \`object-storage preview <file_key:string>\` - Preview the contents of an object (text/CSV). Use --bytes-from / --bytes-length to peek at a slice of binary files.
- \`--storage <storage:string>\` - Secondary storage name
- \`--mime <mime:string>\` - Override the detected mime type (e.g. text/csv)
- \`--bytes-from <bytesFrom:number>\` - Start offset in bytes
- \`--bytes-length <bytesLength:number>\` - Number of bytes to read
- \`--csv-separator <csvSeparator:string>\` - CSV column separator (default ,)
- \`--csv-header\` - Treat the first CSV row as a header
### protection-rules
**Subcommands:**
@@ -3253,6 +3290,26 @@ workspace related commands
- \`--team-name <team_name:string>\` - Slack team name
- \`workspace disconnect-slack\`
# Object Storage CLI
\`wmill object-storage\` (alias \`wmill s3\`) exposes the workspace's object storage (S3-compatible: AWS S3, MinIO, GCS, R2, Azure Blob) over the per-workspace \`/job_helpers/*\` endpoints.
## Key concepts (not obvious from per-command --help)
- **\`file_key\` is the path inside the bucket** (e.g. \`reports/2026-05/orders.csv\`), not a Windmill path. Do NOT pass \`u/...\` or \`f/...\` here — those are Windmill paths to scripts/flows/resources, unrelated to objects in the bucket.
- **Scope is the active workspace.** Object storage is configured per-workspace (default storage + optional secondary storages). Switching workspaces switches which bucket the commands target.
- **\`--storage <name>\` targets a secondary storage** configured on the workspace. Omit it to use the workspace's default object storage. Use \`wmill object-storage list\` to discover configured storages.
- **\`preview\` vs \`download\`**: \`preview\` returns a peek (CSV first rows, text content, or a byte slice via \`--bytes-from\`/\`--bytes-length\`) without writing to disk. Use \`download\` when you want the full file on disk.
## Choosing a subcommand
- Look at what's there: \`wmill object-storage files [prefix]\` (alias \`ls\`) — paginated, use \`--marker\` to continue.
- Inspect one file: \`wmill object-storage info <file_key>\` for size/mime/last-modified, \`wmill object-storage preview <file_key>\` for content peek.
- Move data in: \`wmill object-storage upload <local_path> <file_key>\` — set \`--content-type\` if the receiver cares (e.g. \`text/csv\`).
- Move data out: \`wmill object-storage download <file_key> [output_path]\`\`--stdout\` to pipe.
- Reorganize: \`wmill object-storage move <src> <dest>\` (same storage), \`wmill object-storage delete <file_key>\` (interactive confirm unless \`--yes\`).
`;
export const LANG_BASH = `# Bash
@@ -153,10 +153,11 @@ flow related commands
- `flow run <path:string>` - run a flow by path.
- `-d --data <data:string>` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-.
- `-s --silent` - Do not ouput anything other then the final output. Useful for scripting.
- `flow preview <flow_path:string>` - preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default.
- `flow preview <flow_path:string>` - preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default. Pass --step <id> to run only one module in isolation (resolves nested steps inside branchone/branchall/forloopflow/whileloopflow plus the special preprocessor/failure modules; supported step types: rawscript, script, flow).
- `-d --data <data:string>` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-.
- `-s --silent` - Do not output anything other then the final output. Useful for scripting.
- `--remote` - Use deployed workspace scripts for PathScript steps instead of local files.
- `--step <step_id:string>` - Run only the named step instead of the whole flow. Honors --data as the step's args and --remote / local-PathScript resolution the same way the full-flow preview does.
- `flow new <flow_path:string>` - create a new empty flow
- `--summary <summary:string>` - flow summary
- `--description <description:string>` - flow description
@@ -372,6 +373,42 @@ Validate Windmill flow, schedule, and trigger YAML files in a directory
- `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks
- `-w, --watch` - Watch for file changes and re-lint automatically
### object-storage
**Alias:** `s3`
**Subcommands:**
- `object-storage list` - List configured object storages for the workspace (default + secondary).
- `--json` - Output as JSON (for piping to jq)
- `object-storage files [prefix:string]` - List files in an object storage. Optionally filter by prefix.
- `--json` - Output as JSON (for piping to jq)
- `--max-keys <maxKeys:number>` - Page size (default 100)
- `--marker <marker:string>` - Pagination marker from a previous response
- `--storage <storage:string>` - Secondary storage name (omit for the workspace default)
- `object-storage upload <local_path:string> <file_key:string>` - Upload a local file to object storage at the given file key.
- `--storage <storage:string>` - Secondary storage name
- `--content-type <contentType:string>` - Content-Type header to set on the object
- `--content-disposition <contentDisposition:string>` - Content-Disposition header to set on the object
- `object-storage download <file_key:string> [output_path:string]` - Download an object to a local file (or stdout). Default output path is the basename of the file key in the current directory.
- `--storage <storage:string>` - Secondary storage name
- `--stdout` - Write file contents to stdout instead of a file
- `object-storage delete <file_key:string>` - Delete an object from object storage. Prompts for confirmation unless --yes is set.
- `--storage <storage:string>` - Secondary storage name
- `--yes` - Skip the confirmation prompt
- `object-storage move <src_file_key:string> <dest_file_key:string>` - Move an object within the same storage (rename or relocate by key).
- `--storage <storage:string>` - Secondary storage name
- `object-storage info <file_key:string>` - Show metadata (size, mime, last-modified) for an object.
- `--json` - Output as JSON (for piping to jq)
- `--storage <storage:string>` - Secondary storage name
- `object-storage preview <file_key:string>` - Preview the contents of an object (text/CSV). Use --bytes-from / --bytes-length to peek at a slice of binary files.
- `--storage <storage:string>` - Secondary storage name
- `--mime <mime:string>` - Override the detected mime type (e.g. text/csv)
- `--bytes-from <bytesFrom:number>` - Start offset in bytes
- `--bytes-length <bytesLength:number>` - Number of bytes to read
- `--csv-separator <csvSeparator:string>` - CSV column separator (default ,)
- `--csv-header` - Treat the first CSV row as a header
### protection-rules
**Subcommands:**
@@ -708,3 +745,23 @@ workspace related commands
- `--team-name <team_name:string>` - Slack team name
- `workspace disconnect-slack`
# Object Storage CLI
`wmill object-storage` (alias `wmill s3`) exposes the workspace's object storage (S3-compatible: AWS S3, MinIO, GCS, R2, Azure Blob) over the per-workspace `/job_helpers/*` endpoints.
## Key concepts (not obvious from per-command --help)
- **`file_key` is the path inside the bucket** (e.g. `reports/2026-05/orders.csv`), not a Windmill path. Do NOT pass `u/...` or `f/...` here — those are Windmill paths to scripts/flows/resources, unrelated to objects in the bucket.
- **Scope is the active workspace.** Object storage is configured per-workspace (default storage + optional secondary storages). Switching workspaces switches which bucket the commands target.
- **`--storage <name>` targets a secondary storage** configured on the workspace. Omit it to use the workspace's default object storage. Use `wmill object-storage list` to discover configured storages.
- **`preview` vs `download`**: `preview` returns a peek (CSV first rows, text content, or a byte slice via `--bytes-from`/`--bytes-length`) without writing to disk. Use `download` when you want the full file on disk.
## Choosing a subcommand
- Look at what's there: `wmill object-storage files [prefix]` (alias `ls`) — paginated, use `--marker` to continue.
- Inspect one file: `wmill object-storage info <file_key>` for size/mime/last-modified, `wmill object-storage preview <file_key>` for content peek.
- Move data in: `wmill object-storage upload <local_path> <file_key>` — set `--content-type` if the receiver cares (e.g. `text/csv`).
- Move data out: `wmill object-storage download <file_key> [output_path]``--stdout` to pipe.
- Reorganize: `wmill object-storage move <src> <dest>` (same storage), `wmill object-storage delete <file_key>` (interactive confirm unless `--yes`).
@@ -46,7 +46,7 @@ Once the flow has real content, **offer** to open the visual preview as a one-se
After writing, tell the user which command fits what they want to do:
- `wmill flow preview <flow_path>`**default when iterating on a local flow.** Runs the local `flow.yaml` against local inline scripts without deploying. Add `--remote` to use deployed workspace scripts for PathScript steps instead of local files.
- `wmill flow preview <flow_path>`**default when iterating on a local flow.** Runs the local `flow.yaml` against local inline scripts without deploying. Add `--remote` to use deployed workspace scripts for PathScript steps instead of local files. Add `--step <step_id>` to run only one module in isolation (see "Single-step vs whole-flow preview" below).
- `wmill flow run <path>` — runs the flow **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits.
- `wmill generate-metadata` — regenerate stale `.lock` and `.script.yaml` files. By default it scans **scripts, flows, and apps** across the workspace; pass `--skip-flows --skip-apps` (or run from a subdirectory) to limit the scope when you only care about the flow you edited.
- `wmill sync push` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test".
@@ -63,6 +63,12 @@ Only use `sync push` when:
- The user explicitly asks to deploy, publish, push, or ship.
- The preview has already validated the change and the user wants it in the workspace.
### Single-step vs whole-flow preview
Use `flow preview <flow_path> --step <step_id>` when the user is iterating on one module and the flow's upstream steps aren't part of what they're trying to validate. It runs only that step's runnable (rawscript: the inline script; script: the PathScript, locally if available; flow: the subflow by path) and is much faster than running the whole flow when previous steps are slow or expensive. The step id is resolved by walking nested branchone/branchall/forloopflow/whileloopflow modules and includes the special `preprocessor` and `failure` modules.
Use `flow preview <flow_path>` (no `--step`) when steps depend on each other's outputs, when the user is validating the overall control flow, or when `--step` doesn't apply (branchone, branchall, forloopflow, whileloopflow, identity, and AI agent steps cannot themselves be tested in isolation — for branchone/branchall/forloopflow/whileloopflow, the *contained* steps can, by passing the inner step's id).
### After writing — offer to run, don't wait passively
This is about **programmatic execution** (`wmill flow preview -d '<args>'`), which actually runs the flow and has side effects. Visual preview (the `preview` skill) is offered separately — see "Visual preview" below.
+7 -1
View File
@@ -41,7 +41,7 @@ Once the flow has real content, **offer** to open the visual preview as a one-se
After writing, tell the user which command fits what they want to do:
- `wmill flow preview <flow_path>`**default when iterating on a local flow.** Runs the local `flow.yaml` against local inline scripts without deploying. Add `--remote` to use deployed workspace scripts for PathScript steps instead of local files.
- `wmill flow preview <flow_path>`**default when iterating on a local flow.** Runs the local `flow.yaml` against local inline scripts without deploying. Add `--remote` to use deployed workspace scripts for PathScript steps instead of local files. Add `--step <step_id>` to run only one module in isolation (see "Single-step vs whole-flow preview" below).
- `wmill flow run <path>` — runs the flow **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits.
- `wmill generate-metadata` — regenerate stale `.lock` and `.script.yaml` files. By default it scans **scripts, flows, and apps** across the workspace; pass `--skip-flows --skip-apps` (or run from a subdirectory) to limit the scope when you only care about the flow you edited.
- `wmill sync push` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test".
@@ -58,6 +58,12 @@ Only use `sync push` when:
- The user explicitly asks to deploy, publish, push, or ship.
- The preview has already validated the change and the user wants it in the workspace.
### Single-step vs whole-flow preview
Use `flow preview <flow_path> --step <step_id>` when the user is iterating on one module and the flow's upstream steps aren't part of what they're trying to validate. It runs only that step's runnable (rawscript: the inline script; script: the PathScript, locally if available; flow: the subflow by path) and is much faster than running the whole flow when previous steps are slow or expensive. The step id is resolved by walking nested branchone/branchall/forloopflow/whileloopflow modules and includes the special `preprocessor` and `failure` modules.
Use `flow preview <flow_path>` (no `--step`) when steps depend on each other's outputs, when the user is validating the overall control flow, or when `--step` doesn't apply (branchone, branchall, forloopflow, whileloopflow, identity, and AI agent steps cannot themselves be tested in isolation — for branchone/branchall/forloopflow/whileloopflow, the *contained* steps can, by passing the inner step's id).
### After writing — offer to run, don't wait passively
This is about **programmatic execution** (`wmill flow preview -d '<args>'`), which actually runs the flow and has side effects. Visual preview (the `preview` skill) is offered separately — see "Visual preview" below.
+18
View File
@@ -0,0 +1,18 @@
# Object Storage CLI
`wmill object-storage` (alias `wmill s3`) exposes the workspace's object storage (S3-compatible: AWS S3, MinIO, GCS, R2, Azure Blob) over the per-workspace `/job_helpers/*` endpoints.
## Key concepts (not obvious from per-command --help)
- **`file_key` is the path inside the bucket** (e.g. `reports/2026-05/orders.csv`), not a Windmill path. Do NOT pass `u/...` or `f/...` here — those are Windmill paths to scripts/flows/resources, unrelated to objects in the bucket.
- **Scope is the active workspace.** Object storage is configured per-workspace (default storage + optional secondary storages). Switching workspaces switches which bucket the commands target.
- **`--storage <name>` targets a secondary storage** configured on the workspace. Omit it to use the workspace's default object storage. Use `wmill object-storage list` to discover configured storages.
- **`preview` vs `download`**: `preview` returns a peek (CSV first rows, text content, or a byte slice via `--bytes-from`/`--bytes-length`) without writing to disk. Use `download` when you want the full file on disk.
## Choosing a subcommand
- Look at what's there: `wmill object-storage files [prefix]` (alias `ls`) — paginated, use `--marker` to continue.
- Inspect one file: `wmill object-storage info <file_key>` for size/mime/last-modified, `wmill object-storage preview <file_key>` for content peek.
- Move data in: `wmill object-storage upload <local_path> <file_key>` — set `--content-type` if the receiver cares (e.g. `text/csv`).
- Move data out: `wmill object-storage download <file_key> [output_path]``--stdout` to pipe.
- Reorganize: `wmill object-storage move <src> <dest>` (same storage), `wmill object-storage delete <file_key>` (interactive confirm unless `--yes`).
+7
View File
@@ -2312,6 +2312,13 @@ def main():
print("Extracting CLI commands...")
cli_data = extract_cli_commands()
cli_commands = generate_cli_commands_markdown(cli_data)
# Append hand-written CLI guidance covering bits that aren't obvious from
# the auto-generated per-command --help (file_key semantics, --storage,
# workspace scope). The cli-commands skill is the entry point agents read
# to learn about `wmill`, so non-obvious usage notes belong here.
object_storage_cli = read_markdown_file(base_dir / "object-storage-cli.md")
if object_storage_cli:
cli_commands = f"{cli_commands}\n\n{object_storage_cli}"
OUTPUT_CLI_DIR.mkdir(parents=True, exist_ok=True)
(OUTPUT_CLI_DIR / "cli-commands.md").write_text(cli_commands)
print(f" Found {len(cli_data['commands'])} commands, {len(cli_data['global_options'])} global options")
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill/windmill",
"version": "1.709.0",
"version": "1.711.0",
"exports": "./src/index.ts",
"publish": {
"exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "windmill-client",
"description": "Windmill SDK client for browsers and Node.js",
"version": "1.709.0",
"version": "1.711.0",
"author": "Ruben Fiszel",
"license": "Apache 2.0",
"sideEffects": false,
+1 -1
View File
@@ -1 +1 @@
1.709.0
1.711.0