mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-20 16:02:28 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c41565b6b8 | ||
|
|
d1b8d5427b | ||
|
|
fc5e479424 | ||
|
|
da18a69808 | ||
|
|
1b6e2556b7 | ||
|
|
470b8aa5f1 | ||
|
|
5713760b7a | ||
|
|
f5c9ff709b | ||
|
|
f0bb270723 | ||
|
|
4c16877366 | ||
|
|
4342c18541 | ||
|
|
01e6414ddb | ||
|
|
2d18a68099 |
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE v2_job_status\n SET flow_status = JSONB_SET(flow_status, ARRAY['cleanup_module', 'flow_jobs_to_clean'], COALESCE(flow_status->'cleanup_module'->'flow_jobs_to_clean', '[]'::jsonb) || $1)\n WHERE id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Jsonb",
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "007fa93171b244490b94464938b9f95aca4e91bccde6da93cb151799b3398049"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO job_delete_schedule (job_id, workspace_id, delete_at) VALUES ($1, $2, now() + make_interval(secs => $3::double precision)) ON CONFLICT (job_id) DO NOTHING",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Varchar",
|
||||
"Float8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "035e29e775bfc5b236100135e1d94a4baf2b617b86f0c3c74ba9a00b859993f6"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO script\n (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels)\n\n SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, content, created_by, schema, is_template, extra_perms, NULL, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels\n\n FROM script WHERE hash = $2 AND workspace_id = $3;\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "0ca770234f3e38be3fb1c280d82e9c06440168806fe605e38808bdcb400d4034"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE v2_job_status\n SET flow_status = JSONB_SET(flow_status, ARRAY['cleanup_module', 'flow_jobs_to_schedule_clean'], COALESCE(flow_status->'cleanup_module'->'flow_jobs_to_schedule_clean', '[]'::jsonb) || $1)\n WHERE id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Jsonb",
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "21331baf02c3c798bcc215443dad16eb66acac5f40c20529b595154f4e6fd754"
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO script (\n workspace_id, hash, path, parent_hashes, summary, description, content,\n created_by, created_at, archived, schema, deleted, is_template,\n extra_perms, lock, lock_error_logs, language, kind, tag, draft_only,\n envs, concurrent_limit, concurrency_time_window_s, cache_ttl,\n dedicated_worker, ws_error_handler_muted, priority, timeout,\n delete_after_use, restart_unless_cancelled, concurrency_key,\n visible_to_runner_only, auto_kind, codebase, has_preprocessor,\n on_behalf_of_email, assets, modules\n )\n SELECT\n $1, hash, path, parent_hashes, summary, description, content,\n created_by, created_at, archived, schema, deleted, is_template,\n extra_perms, lock, lock_error_logs, language, kind, tag, draft_only,\n envs, concurrent_limit, concurrency_time_window_s, cache_ttl,\n dedicated_worker, ws_error_handler_muted, priority, timeout,\n delete_after_use, restart_unless_cancelled, concurrency_key,\n visible_to_runner_only, auto_kind, codebase, has_preprocessor,\n on_behalf_of_email, assets, modules\n FROM script\n WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2c256552a430877c42224055aeb81df33d88ff295483cb28369eda42ce58afec"
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT value FROM resource WHERE path = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "value",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "63c16a4277983aaed0aed54972923919cee3cc444725ac6b7906922554bae800"
|
||||
}
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO resource\n (workspace_id, path, value, description, resource_type, created_by, edited_at, ws_specific)\n VALUES ($1, $2, $3, $4, $5, $6, now(), $7) ON CONFLICT (workspace_id, path)\n DO UPDATE SET value = EXCLUDED.value, description = EXCLUDED.description, resource_type = EXCLUDED.resource_type, edited_at = now(), ws_specific = EXCLUDED.ws_specific",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Jsonb",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "7929fa087a28949906ffb3f508d8b88c922d3442418701edd6b97e5fa50bd739"
|
||||
}
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT value, description, resource_type\n FROM resource\n WHERE workspace_id = $1 AND path = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "value",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "description",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "resource_type",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "819c233915383e89af1bcf1a56c5f67c4e1fc217f216f609e36a9944a7807b33"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE job_logs SET logs = '##DELETED##' WHERE job_id = ANY($1)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"UuidArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "954e832d2587506a4bb0cb3a4fb45658026de7da4680eca1ea6b08f4b5e33800"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id FROM v2_job WHERE root_job = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "9c5f9fc1c2fdf35e98c78180a319f2cfdfe09b4eef3375a6170454d3d52e8dbc"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE v2_job_status\n SET flow_status = JSONB_SET(flow_status, ARRAY['cleanup_module', 'flow_jobs_to_schedule_clean'], COALESCE(flow_status->'cleanup_module'->'flow_jobs_to_schedule_clean', '[]'::jsonb) || $1)\n WHERE id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Jsonb",
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a25200e046d6e15bf5a5f81d6cc9622bdb7ad8009fa6e67606296c1a8a1a92b4"
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO script\n (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels)\n\n SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, content, created_by, schema, is_template, extra_perms, NULL, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels\n\n FROM script WHERE hash = $2 AND workspace_id = $3;\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a969194571dd3f12e628ce0f01b0ddc09bbcf4506eff2290664c295d16fec4ae"
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE v2_job_status\n SET flow_status = JSONB_SET(flow_status, ARRAY['cleanup_module', 'flow_jobs_to_clean'], COALESCE(flow_status->'cleanup_module'->'flow_jobs_to_clean', '[]'::jsonb) || $1)\n WHERE id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Jsonb",
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b01160fe44d69834ac08bbf60feacb3e3caa02a04b084da44cdcb9103794b39e"
|
||||
}
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO script (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle, modules, labels) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40)",
|
||||
"query": "INSERT INTO script (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle, modules, labels) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -77,6 +77,7 @@
|
||||
"Bool",
|
||||
"Bool",
|
||||
"Int4",
|
||||
"Int4",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Varchar",
|
||||
@@ -95,5 +96,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "790d79ec7abe6ebe1092afd9de4c5fc383272d2057ac5b47a7425f095f4e8788"
|
||||
"hash": "dafc503a5f3adc5c7db7c11096775cacfffd2d3173dfdddcf37589cba356791e"
|
||||
}
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO resource\n (workspace_id, path, value, description, resource_type, created_by, edited_at, labels)\n VALUES ($1, $2, $3, $4, $5, $6, now(), $7) ON CONFLICT (workspace_id, path)\n DO UPDATE SET value = EXCLUDED.value, description = EXCLUDED.description, resource_type = EXCLUDED.resource_type, edited_at = now(), labels = EXCLUDED.labels",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Jsonb",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "deac41298e8b0d0870e314fef0813c24dd55d63bda78a0a5f35ed6f22bea6bef"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM job_delete_schedule\n WHERE job_id IN (\n SELECT job_id FROM job_delete_schedule\n WHERE delete_at <= now()\n ORDER BY delete_at\n LIMIT $1\n FOR UPDATE SKIP LOCKED\n )\n RETURNING job_id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "job_id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e1886af775f70f7ad3949e35d2e274818884b2ce38226d5d8f9b8774dda6d5dc"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO script (\n workspace_id, hash, path, parent_hashes, summary, description, content,\n created_by, created_at, archived, schema, deleted, is_template,\n extra_perms, lock, lock_error_logs, language, kind, tag, draft_only,\n envs, concurrent_limit, concurrency_time_window_s, cache_ttl,\n dedicated_worker, ws_error_handler_muted, priority, timeout,\n delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key,\n visible_to_runner_only, auto_kind, codebase, has_preprocessor,\n on_behalf_of_email, assets, modules\n )\n SELECT\n $1, hash, path, parent_hashes, summary, description, content,\n created_by, created_at, archived, schema, deleted, is_template,\n extra_perms, lock, lock_error_logs, language, kind, tag, draft_only,\n envs, concurrent_limit, concurrency_time_window_s, cache_ttl,\n dedicated_worker, ws_error_handler_muted, priority, timeout,\n delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key,\n visible_to_runner_only, auto_kind, codebase, has_preprocessor,\n on_behalf_of_email, assets, modules\n FROM script\n WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f0fcc355334f4d715b366e1b3be88b2b5e316536efae31da09217176b59db030"
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
86158dde674238fd94f925bdcd5155759e823ed6
|
||||
a30e828a8742b8b644a25a603f0ee380a374607f
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS job_delete_schedule;
|
||||
ALTER TABLE script DROP COLUMN IF EXISTS delete_after_secs;
|
||||
@@ -0,0 +1,9 @@
|
||||
ALTER TABLE script ADD COLUMN delete_after_secs INTEGER;
|
||||
|
||||
CREATE TABLE job_delete_schedule (
|
||||
job_id UUID PRIMARY KEY,
|
||||
workspace_id VARCHAR(50) NOT NULL,
|
||||
delete_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_job_delete_schedule_delete_at ON job_delete_schedule (delete_at);
|
||||
+4
-4
@@ -754,7 +754,7 @@ async fn windmill_main() -> anyhow::Result<()> {
|
||||
.and_then(|x| x.parse().ok())
|
||||
.unwrap_or(IpAddr::from(default_bind_addr));
|
||||
|
||||
let (conn, first_suffix, agent_config) = if mode == Mode::Agent {
|
||||
let (conn, first_suffix, agent_config) = if matches!(mode, Mode::Agent | Mode::AgentBatch) {
|
||||
let agent_config = match AgentConfig::from_env() {
|
||||
Ok(config) => config,
|
||||
Err(e) => {
|
||||
@@ -829,7 +829,7 @@ async fn windmill_main() -> anyhow::Result<()> {
|
||||
|
||||
let _guard = windmill_common::tracing_init::initialize_tracing(&hostname, &mode, &environment);
|
||||
|
||||
let is_agent = mode == Mode::Agent;
|
||||
let is_agent = matches!(mode, Mode::Agent | Mode::AgentBatch);
|
||||
|
||||
let mut migration_handle: Option<JoinHandle<()>> = None;
|
||||
#[cfg(feature = "parquet")]
|
||||
@@ -953,7 +953,7 @@ async fn windmill_main() -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
let conn = if mode == Mode::Agent {
|
||||
let conn = if matches!(mode, Mode::Agent | Mode::AgentBatch) {
|
||||
conn
|
||||
} else {
|
||||
// Drop the initial connection pool before creating the main one.
|
||||
@@ -1289,7 +1289,7 @@ Windmill Community Edition {GIT_VERSION}
|
||||
)
|
||||
},
|
||||
worker_name: worker_name_with_suffix(
|
||||
mode == Mode::Agent,
|
||||
matches!(mode, Mode::Agent | Mode::AgentBatch),
|
||||
WORKER_GROUP.as_str(),
|
||||
&suffix,
|
||||
),
|
||||
|
||||
@@ -1205,6 +1205,92 @@ pub async fn delete_expired_items(db: &DB) -> () {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
async fn cleanup_scheduled_job_deletions(db: &Pool<Postgres>) {
|
||||
const BATCH_SIZE: i64 = 1000;
|
||||
const MAX_BATCHES: i32 = 10;
|
||||
|
||||
let mut total_deleted = 0u64;
|
||||
for batch_num in 0..MAX_BATCHES {
|
||||
let mut tx = match db.begin().await {
|
||||
Ok(tx) => tx,
|
||||
Err(e) => {
|
||||
tracing::error!("Error starting transaction for scheduled job deletion: {e:?}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let rows = match sqlx::query_scalar!(
|
||||
"DELETE FROM job_delete_schedule
|
||||
WHERE job_id IN (
|
||||
SELECT job_id FROM job_delete_schedule
|
||||
WHERE delete_at <= now()
|
||||
ORDER BY delete_at
|
||||
LIMIT $1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
RETURNING job_id",
|
||||
BATCH_SIZE,
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await
|
||||
{
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
tracing::error!("Error in scheduled job deletion batch {batch_num}: {e:?}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if rows.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
let job_ids = rows;
|
||||
let count = job_ids.len() as u64;
|
||||
|
||||
let cleanup_result: Result<(), sqlx::Error> = async {
|
||||
sqlx::query!(
|
||||
"UPDATE v2_job SET args = '{}'::jsonb WHERE id = ANY($1)",
|
||||
&job_ids,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
"UPDATE v2_job_completed SET result = '{}'::jsonb WHERE id = ANY($1)",
|
||||
&job_ids,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
"UPDATE job_logs SET logs = '##DELETED##' WHERE job_id = ANY($1)",
|
||||
&job_ids,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(e) = cleanup_result {
|
||||
// Roll back so schedule rows survive for retry on next cycle
|
||||
tracing::error!("Error cleaning job data in batch {batch_num}, rolling back: {e:?}");
|
||||
break;
|
||||
}
|
||||
|
||||
if let Err(e) = tx.commit().await {
|
||||
tracing::error!("Error committing scheduled job deletion batch {batch_num}: {e:?}");
|
||||
break;
|
||||
}
|
||||
|
||||
total_deleted += count;
|
||||
}
|
||||
|
||||
if total_deleted > 0 {
|
||||
tracing::info!("Scheduled job deletion: cleaned {total_deleted} jobs");
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check_expiring_tokens(db: &DB) {
|
||||
// Find tokens expiring within 7 days that still have a pending notification row.
|
||||
// The notification table stores token_hash (not plaintext) so the join works
|
||||
@@ -2328,6 +2414,15 @@ pub async fn monitor_db(
|
||||
}
|
||||
};
|
||||
|
||||
let cleanup_scheduled_job_deletions_f = async {
|
||||
#[cfg(feature = "enterprise")]
|
||||
if server_mode && !initial_load {
|
||||
if let Some(db) = conn.as_sql() {
|
||||
cleanup_scheduled_job_deletions(&db).await;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
join!(
|
||||
expired_items_f,
|
||||
zombie_jobs_f,
|
||||
@@ -2351,6 +2446,7 @@ pub async fn monitor_db(
|
||||
cleanup_notify_events_f,
|
||||
check_expiring_tokens_f,
|
||||
manage_audit_partitions_f,
|
||||
cleanup_scheduled_job_deletions_f,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_common::jobs::{resolve_delete_after_secs, schedule_job_deletion};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests for resolve_delete_after_secs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_resolve_no_deletion() {
|
||||
assert_eq!(resolve_delete_after_secs(None, None), None);
|
||||
assert_eq!(resolve_delete_after_secs(Some(false), None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_immediate_backward_compat() {
|
||||
// delete_after_use=true with no secs → immediate (0)
|
||||
assert_eq!(resolve_delete_after_secs(Some(true), None), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_explicit_secs() {
|
||||
assert_eq!(resolve_delete_after_secs(None, Some(0)), Some(0));
|
||||
assert_eq!(resolve_delete_after_secs(None, Some(3600)), Some(3600));
|
||||
assert_eq!(resolve_delete_after_secs(Some(true), Some(60)), Some(60));
|
||||
assert_eq!(resolve_delete_after_secs(Some(false), Some(120)), Some(120));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_rejects_negative() {
|
||||
assert_eq!(resolve_delete_after_secs(None, Some(-1)), None);
|
||||
assert_eq!(resolve_delete_after_secs(Some(true), Some(-100)), None);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration: schedule_job_deletion inserts into job_delete_schedule
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_schedule_job_deletion_inserts_row(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
let job_id = uuid::Uuid::new_v4();
|
||||
|
||||
schedule_job_deletion(&db, job_id, "test-workspace", 3600).await?;
|
||||
|
||||
let row = sqlx::query_as::<_, (uuid::Uuid, String)>(
|
||||
"SELECT job_id, workspace_id FROM job_delete_schedule WHERE job_id = $1",
|
||||
)
|
||||
.bind(job_id)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
|
||||
assert_eq!(row.0, job_id);
|
||||
assert_eq!(row.1, "test-workspace");
|
||||
|
||||
// Verify delete_at is approximately now + 3600s
|
||||
let delete_at: chrono::DateTime<chrono::Utc> =
|
||||
sqlx::query_scalar("SELECT delete_at FROM job_delete_schedule WHERE job_id = $1")
|
||||
.bind(job_id)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
|
||||
let expected_min = chrono::Utc::now() + chrono::Duration::seconds(3500);
|
||||
let expected_max = chrono::Utc::now() + chrono::Duration::seconds(3700);
|
||||
assert!(
|
||||
delete_at > expected_min && delete_at < expected_max,
|
||||
"delete_at should be ~1 hour from now, got {delete_at}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_schedule_job_deletion_is_idempotent(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
let job_id = uuid::Uuid::new_v4();
|
||||
|
||||
schedule_job_deletion(&db, job_id, "test-workspace", 60).await?;
|
||||
// Second call should not error (ON CONFLICT DO NOTHING)
|
||||
schedule_job_deletion(&db, job_id, "test-workspace", 120).await?;
|
||||
|
||||
let count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM job_delete_schedule WHERE job_id = $1")
|
||||
.bind(job_id)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(count, 1, "should have exactly one row (idempotent)");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -35,7 +35,7 @@ mod dependency_map {
|
||||
schema: std::collections::HashMap::new(),
|
||||
ws_error_handler_muted: Some(false),
|
||||
priority: None,
|
||||
delete_after_use: None,
|
||||
delete_after_secs: None,
|
||||
timeout: None,
|
||||
restart_unless_cancelled: None,
|
||||
deployment_message: None,
|
||||
|
||||
+734
-537
File diff suppressed because it is too large
Load Diff
@@ -33,7 +33,7 @@ mod relock_skip {
|
||||
schema: std::collections::HashMap::new(),
|
||||
ws_error_handler_muted: Some(false),
|
||||
priority: None,
|
||||
delete_after_use: None,
|
||||
delete_after_secs: None,
|
||||
timeout: None,
|
||||
restart_unless_cancelled: None,
|
||||
deployment_message: None,
|
||||
|
||||
@@ -206,6 +206,7 @@ async fn test_deno_flow(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
timeout: None,
|
||||
priority: None,
|
||||
delete_after_use: None,
|
||||
delete_after_secs: None,
|
||||
continue_on_error: None,
|
||||
skip_if: None,
|
||||
apply_preprocessor: None,
|
||||
@@ -253,6 +254,7 @@ async fn test_deno_flow(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
timeout: None,
|
||||
priority: None,
|
||||
delete_after_use: None,
|
||||
delete_after_secs: None,
|
||||
continue_on_error: None,
|
||||
skip_if: None,
|
||||
apply_preprocessor: None,
|
||||
@@ -274,6 +276,7 @@ async fn test_deno_flow(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
timeout: None,
|
||||
priority: None,
|
||||
delete_after_use: None,
|
||||
delete_after_secs: None,
|
||||
continue_on_error: None,
|
||||
skip_if: None,
|
||||
apply_preprocessor: None,
|
||||
@@ -389,6 +392,7 @@ async fn test_deno_flow_same_worker(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
timeout: None,
|
||||
priority: None,
|
||||
delete_after_use: None,
|
||||
delete_after_secs: None,
|
||||
continue_on_error: None,
|
||||
skip_if: None,
|
||||
apply_preprocessor: None,
|
||||
@@ -445,6 +449,7 @@ async fn test_deno_flow_same_worker(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
timeout: None,
|
||||
priority: None,
|
||||
delete_after_use: None,
|
||||
delete_after_secs: None,
|
||||
continue_on_error: None,
|
||||
skip_if: None,
|
||||
apply_preprocessor: None,
|
||||
@@ -487,6 +492,7 @@ async fn test_deno_flow_same_worker(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
timeout: None,
|
||||
priority: None,
|
||||
delete_after_use: None,
|
||||
delete_after_secs: None,
|
||||
continue_on_error: None,
|
||||
skip_if: None,
|
||||
apply_preprocessor: None,
|
||||
@@ -508,6 +514,7 @@ async fn test_deno_flow_same_worker(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
timeout: None,
|
||||
priority: None,
|
||||
delete_after_use: None,
|
||||
delete_after_secs: None,
|
||||
continue_on_error: None,
|
||||
skip_if: None,
|
||||
apply_preprocessor: None,
|
||||
@@ -556,6 +563,7 @@ async fn test_deno_flow_same_worker(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
timeout: None,
|
||||
priority: None,
|
||||
delete_after_use: None,
|
||||
delete_after_secs: None,
|
||||
continue_on_error: None,
|
||||
skip_if: None,
|
||||
apply_preprocessor: None,
|
||||
|
||||
@@ -364,7 +364,7 @@ pub mod types {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dedicated_worker: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub delete_after_use: Option<bool>,
|
||||
pub delete_after_secs: Option<i32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub deployment_message: Option<String>,
|
||||
pub description: String,
|
||||
@@ -540,7 +540,7 @@ pub mod types {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub continue_on_error: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub delete_after_use: Option<bool>,
|
||||
pub delete_after_secs: Option<i32>,
|
||||
pub id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mock: Option<serde_json::Value>,
|
||||
|
||||
@@ -1856,6 +1856,7 @@ mod tests {
|
||||
timeout: None,
|
||||
priority: None,
|
||||
delete_after_use: None,
|
||||
delete_after_secs: None,
|
||||
continue_on_error: None,
|
||||
skip_if: None,
|
||||
apply_preprocessor: None,
|
||||
@@ -1890,6 +1891,7 @@ mod tests {
|
||||
timeout: None,
|
||||
priority: None,
|
||||
delete_after_use: None,
|
||||
delete_after_secs: None,
|
||||
continue_on_error: None,
|
||||
skip_if: None,
|
||||
apply_preprocessor: None,
|
||||
@@ -1924,6 +1926,7 @@ mod tests {
|
||||
timeout: None,
|
||||
priority: None,
|
||||
delete_after_use: None,
|
||||
delete_after_secs: None,
|
||||
continue_on_error: None,
|
||||
skip_if: None,
|
||||
apply_preprocessor: None,
|
||||
@@ -1957,6 +1960,7 @@ mod tests {
|
||||
timeout: None,
|
||||
priority: None,
|
||||
delete_after_use: None,
|
||||
delete_after_secs: None,
|
||||
continue_on_error: None,
|
||||
skip_if: None,
|
||||
apply_preprocessor: None,
|
||||
@@ -1972,6 +1976,8 @@ mod tests {
|
||||
early_return: None,
|
||||
chat_input_enabled: None,
|
||||
flow_env: None,
|
||||
delete_after_use: None,
|
||||
delete_after_secs: None,
|
||||
concurrency_settings: ConcurrencySettings::default(),
|
||||
debouncing_settings: DebouncingSettings::default(),
|
||||
};
|
||||
|
||||
@@ -476,6 +476,25 @@ pub async fn delete_job_metadata_after_use(db: &DB, job_uuid: Uuid) -> Result<()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub use windmill_common::jobs::resolve_delete_after_secs;
|
||||
pub use windmill_common::jobs::schedule_job_deletion;
|
||||
|
||||
/// Handle deletion or scheduling for a completed job.
|
||||
pub async fn handle_delete_after_completion(
|
||||
db: &DB,
|
||||
job_uuid: Uuid,
|
||||
w_id: &str,
|
||||
delete_after_use: Option<bool>,
|
||||
delete_after_secs: Option<i32>,
|
||||
) -> Result<(), Error> {
|
||||
match resolve_delete_after_secs(delete_after_use, delete_after_secs) {
|
||||
Some(0) => delete_job_metadata_after_use(db, job_uuid).await?,
|
||||
Some(secs) => schedule_job_deletion(db, job_uuid, w_id, secs).await?,
|
||||
None => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queue limit check
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -799,7 +818,7 @@ pub async fn push_script_job_by_path_into_queue<'c>(
|
||||
trigger: Option<TriggerMetadata>,
|
||||
) -> error::Result<(
|
||||
Uuid,
|
||||
Option<bool>,
|
||||
Option<i32>,
|
||||
Option<sqlx::Transaction<'c, sqlx::Postgres>>,
|
||||
)> {
|
||||
#[cfg(feature = "enterprise")]
|
||||
@@ -809,14 +828,16 @@ pub async fn push_script_job_by_path_into_queue<'c>(
|
||||
check_scopes(&authed, || format!("jobs:run:scripts:{script_path}"))?;
|
||||
|
||||
let userdb_authed = UserDbWithAuthed { db: user_db.clone(), authed: &authed.to_authed_ref() };
|
||||
let (job_payload, tag, delete_after_use, timeout, on_behalf_of) = script_path_to_payload(
|
||||
script_path,
|
||||
Some(userdb_authed),
|
||||
db.clone(),
|
||||
&w_id,
|
||||
run_query.skip_preprocessor,
|
||||
)
|
||||
.await?;
|
||||
let (job_payload, tag, delete_after_use, delete_after_secs, timeout, on_behalf_of) =
|
||||
script_path_to_payload(
|
||||
script_path,
|
||||
Some(userdb_authed),
|
||||
db.clone(),
|
||||
&w_id,
|
||||
run_query.skip_preprocessor,
|
||||
)
|
||||
.await?;
|
||||
let resolved_delete_secs = resolve_delete_after_secs(delete_after_use, delete_after_secs);
|
||||
let scheduled_for = run_query.get_scheduled_for(&db).await?;
|
||||
|
||||
let tag = run_query.tag.clone().or(tag);
|
||||
@@ -886,9 +907,9 @@ pub async fn push_script_job_by_path_into_queue<'c>(
|
||||
|
||||
// If we were given a transaction, return it; otherwise commit it
|
||||
if return_tx {
|
||||
Ok((uuid, delete_after_use, Some(tx)))
|
||||
Ok((uuid, resolved_delete_secs, Some(tx)))
|
||||
} else {
|
||||
tx.commit().await?;
|
||||
Ok((uuid, delete_after_use, None))
|
||||
Ok((uuid, resolved_delete_secs, None))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,8 @@ pub struct ScriptWDraft<SR> {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub delete_after_use: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub delete_after_secs: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub timeout: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub visible_to_runner_only: Option<bool>,
|
||||
@@ -178,6 +180,7 @@ impl ScriptWDraft<ScriptRunnableSettingsHandle> {
|
||||
priority: self.priority,
|
||||
restart_unless_cancelled: self.restart_unless_cancelled,
|
||||
delete_after_use: self.delete_after_use,
|
||||
delete_after_secs: self.delete_after_secs,
|
||||
timeout: self.timeout,
|
||||
visible_to_runner_only: self.visible_to_runner_only,
|
||||
auto_kind: self.auto_kind,
|
||||
@@ -943,8 +946,8 @@ async fn create_script_internal<'c>(
|
||||
content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, \
|
||||
draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, \
|
||||
dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \
|
||||
delete_after_use, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle, modules, labels) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40)",
|
||||
delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle, modules, labels) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41)",
|
||||
&w_id,
|
||||
&hash.0,
|
||||
ns.path,
|
||||
@@ -970,12 +973,13 @@ async fn create_script_internal<'c>(
|
||||
ns.priority,
|
||||
ns.restart_unless_cancelled,
|
||||
ns.delete_after_use,
|
||||
ns.delete_after_secs,
|
||||
ns.timeout,
|
||||
guarded_concurrency_key,
|
||||
ns.visible_to_runner_only,
|
||||
auto_kind.as_deref(),
|
||||
codebase,
|
||||
has_preprocessor.filter(|x: &bool| *x), // should be Some(true) or None
|
||||
has_preprocessor.filter(|x: &bool| *x),
|
||||
windmill_common::resolve_on_behalf_of_email(
|
||||
ns.on_behalf_of_email.as_deref(),
|
||||
ns.preserve_on_behalf_of.unwrap_or(false),
|
||||
@@ -1438,7 +1442,7 @@ async fn get_script_by_path_w_draft(
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let script_o = sqlx::query_as::<_, ScriptWDraft<ScriptRunnableSettingsHandle>>(
|
||||
"SELECT hash, script.path, summary, description, content, language, kind, tag, schema, draft_only, envs, runnable_settings_handle, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, ws_error_handler_muted, draft.value as draft, dedicated_worker, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, auto_kind, has_preprocessor, on_behalf_of_email, assets, modules, debounce_key, debounce_delay_s, labels FROM script LEFT JOIN draft ON
|
||||
"SELECT hash, script.path, summary, description, content, language, kind, tag, schema, draft_only, envs, runnable_settings_handle, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, ws_error_handler_muted, draft.value as draft, dedicated_worker, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, has_preprocessor, on_behalf_of_email, assets, modules, debounce_key, debounce_delay_s, labels FROM script LEFT JOIN draft ON
|
||||
script.path = draft.path AND script.workspace_id = draft.workspace_id AND draft.typ = 'script'
|
||||
WHERE script.path = $1 AND script.workspace_id = $2
|
||||
ORDER BY script.created_at DESC LIMIT 1",
|
||||
|
||||
@@ -3723,7 +3723,7 @@ async fn clone_scripts(
|
||||
extra_perms, lock, lock_error_logs, language, kind, tag, draft_only,
|
||||
envs, concurrent_limit, concurrency_time_window_s, cache_ttl,
|
||||
dedicated_worker, ws_error_handler_muted, priority, timeout,
|
||||
delete_after_use, restart_unless_cancelled, concurrency_key,
|
||||
delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key,
|
||||
visible_to_runner_only, auto_kind, codebase, has_preprocessor,
|
||||
on_behalf_of_email, assets, modules
|
||||
)
|
||||
@@ -3733,7 +3733,7 @@ async fn clone_scripts(
|
||||
extra_perms, lock, lock_error_logs, language, kind, tag, draft_only,
|
||||
envs, concurrent_limit, concurrency_time_window_s, cache_ttl,
|
||||
dedicated_worker, ws_error_handler_muted, priority, timeout,
|
||||
delete_after_use, restart_unless_cancelled, concurrency_key,
|
||||
delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key,
|
||||
visible_to_runner_only, auto_kind, codebase, has_preprocessor,
|
||||
on_behalf_of_email, assets, modules
|
||||
FROM script
|
||||
|
||||
@@ -20165,8 +20165,9 @@ components:
|
||||
type: boolean
|
||||
timeout:
|
||||
type: integer
|
||||
delete_after_use:
|
||||
type: boolean
|
||||
delete_after_secs:
|
||||
type: integer
|
||||
description: If set, delete the job's args, result and logs after this many seconds following job completion
|
||||
visible_to_runner_only:
|
||||
type: boolean
|
||||
auto_kind:
|
||||
@@ -20257,8 +20258,9 @@ components:
|
||||
type: boolean
|
||||
timeout:
|
||||
type: integer
|
||||
delete_after_use:
|
||||
type: boolean
|
||||
delete_after_secs:
|
||||
type: integer
|
||||
description: If set, delete the job's args, result and logs after this many seconds following job completion
|
||||
deployment_message:
|
||||
type: string
|
||||
concurrency_key:
|
||||
|
||||
@@ -4281,49 +4281,51 @@ pub async fn run_workflow_as_code(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let (job_payload, tag, _delete_after_use, timeout, on_behalf_of) = match job.job_kind {
|
||||
JobKind::Preview => (
|
||||
JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content: raw_code.unwrap_or_default(),
|
||||
path: job.script_path,
|
||||
language: job.language.unwrap_or_else(|| ScriptLang::Deno),
|
||||
lock: raw_lock,
|
||||
concurrency_settings: concurrency_settings
|
||||
.maybe_fallback(
|
||||
windmill_queue::custom_concurrency_key(&db, &job.id)
|
||||
.await
|
||||
.map_err(to_anyhow)?,
|
||||
job.concurrent_limit,
|
||||
job.concurrency_time_window_s,
|
||||
)
|
||||
.into(),
|
||||
cache_ttl: job.cache_ttl,
|
||||
cache_ignore_s3_path: job.cache_ignore_s3_path,
|
||||
dedicated_worker: None,
|
||||
// TODO(debouncing): enable for this mode
|
||||
debouncing_settings: DebouncingSettings::default(),
|
||||
modules: None,
|
||||
}),
|
||||
Some(job.tag.clone()),
|
||||
None,
|
||||
run_query.timeout,
|
||||
None,
|
||||
),
|
||||
JobKind::Script => {
|
||||
let userdb_authed =
|
||||
UserDbWithAuthed { db: user_db.clone(), authed: &authed.to_authed_ref() };
|
||||
script_path_to_payload(
|
||||
job.script_path(),
|
||||
Some(userdb_authed),
|
||||
db.clone(),
|
||||
&w_id,
|
||||
run_query.skip_preprocessor,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
_ => return Err(anyhow::anyhow!("Not supported").into()),
|
||||
};
|
||||
let (job_payload, tag, _delete_after_use, _delete_after_secs, timeout, on_behalf_of) =
|
||||
match job.job_kind {
|
||||
JobKind::Preview => (
|
||||
JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content: raw_code.unwrap_or_default(),
|
||||
path: job.script_path,
|
||||
language: job.language.unwrap_or_else(|| ScriptLang::Deno),
|
||||
lock: raw_lock,
|
||||
concurrency_settings: concurrency_settings
|
||||
.maybe_fallback(
|
||||
windmill_queue::custom_concurrency_key(&db, &job.id)
|
||||
.await
|
||||
.map_err(to_anyhow)?,
|
||||
job.concurrent_limit,
|
||||
job.concurrency_time_window_s,
|
||||
)
|
||||
.into(),
|
||||
cache_ttl: job.cache_ttl,
|
||||
cache_ignore_s3_path: job.cache_ignore_s3_path,
|
||||
dedicated_worker: None,
|
||||
// TODO(debouncing): enable for this mode
|
||||
debouncing_settings: DebouncingSettings::default(),
|
||||
modules: None,
|
||||
}),
|
||||
Some(job.tag.clone()),
|
||||
None,
|
||||
None,
|
||||
run_query.timeout,
|
||||
None,
|
||||
),
|
||||
JobKind::Script => {
|
||||
let userdb_authed =
|
||||
UserDbWithAuthed { db: user_db.clone(), authed: &authed.to_authed_ref() };
|
||||
script_path_to_payload(
|
||||
job.script_path(),
|
||||
Some(userdb_authed),
|
||||
db.clone(),
|
||||
&w_id,
|
||||
run_query.skip_preprocessor,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
_ => return Err(anyhow::anyhow!("Not supported").into()),
|
||||
};
|
||||
|
||||
if *CLOUD_HOSTED {
|
||||
tracing::info!("workflow_as_code_tracing id {i} ");
|
||||
@@ -4554,14 +4556,15 @@ pub async fn run_wait_result_job_by_path_get(
|
||||
|
||||
let user_db_with_authed =
|
||||
UserDbWithAuthed { db: user_db.clone(), authed: &authed.to_authed_ref() };
|
||||
let (job_payload, tag, delete_after_use, timeout, on_behalf_authed) = script_path_to_payload(
|
||||
script_path,
|
||||
Some(user_db_with_authed),
|
||||
db.clone(),
|
||||
&w_id,
|
||||
run_query.skip_preprocessor,
|
||||
)
|
||||
.await?;
|
||||
let (job_payload, tag, delete_after_use, delete_after_secs, timeout, on_behalf_authed) =
|
||||
script_path_to_payload(
|
||||
script_path,
|
||||
Some(user_db_with_authed),
|
||||
db.clone(),
|
||||
&w_id,
|
||||
run_query.skip_preprocessor,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let tag = run_query.tag.clone().or(tag);
|
||||
check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?;
|
||||
@@ -4617,9 +4620,7 @@ pub async fn run_wait_result_job_by_path_get(
|
||||
tx.commit().await?;
|
||||
|
||||
let wait_result = run_wait_result(&db, uuid, &w_id, None, &authed.username).await;
|
||||
if delete_after_use.unwrap_or(false) {
|
||||
delete_job_metadata_after_use(&db, uuid).await?;
|
||||
}
|
||||
handle_delete_after_completion(&db, uuid, &w_id, delete_after_use, delete_after_secs).await?;
|
||||
return wait_result;
|
||||
}
|
||||
|
||||
@@ -4699,14 +4700,15 @@ pub async fn run_wait_result_script_by_path_internal(
|
||||
check_queue_too_long(&db, QUEUE_LIMIT_WAIT_RESULT.or(run_query.queue_limit)).await?;
|
||||
|
||||
let db_authed = UserDbWithAuthed { db: user_db.clone(), authed: &authed.to_authed_ref() };
|
||||
let (job_payload, tag, delete_after_use, timeout, on_behalf_of) = script_path_to_payload(
|
||||
script_path.to_path(),
|
||||
Some(db_authed),
|
||||
db.clone(),
|
||||
&w_id,
|
||||
run_query.skip_preprocessor,
|
||||
)
|
||||
.await?;
|
||||
let (job_payload, tag, delete_after_use, delete_after_secs, timeout, on_behalf_of) =
|
||||
script_path_to_payload(
|
||||
script_path.to_path(),
|
||||
Some(db_authed),
|
||||
db.clone(),
|
||||
&w_id,
|
||||
run_query.skip_preprocessor,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let tag = run_query.tag.clone().or(tag);
|
||||
check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?;
|
||||
@@ -4762,9 +4764,7 @@ pub async fn run_wait_result_script_by_path_internal(
|
||||
tx.commit().await?;
|
||||
|
||||
let wait_result = run_wait_result(&db, uuid, &w_id, None, &authed.username).await;
|
||||
if delete_after_use.unwrap_or(false) {
|
||||
delete_job_metadata_after_use(&db, uuid).await?;
|
||||
}
|
||||
handle_delete_after_completion(&db, uuid, &w_id, delete_after_use, delete_after_secs).await?;
|
||||
return wait_result;
|
||||
}
|
||||
|
||||
@@ -4802,6 +4802,7 @@ pub async fn run_wait_result_script_by_hash(
|
||||
dedicated_worker,
|
||||
priority,
|
||||
delete_after_use,
|
||||
delete_after_secs,
|
||||
timeout,
|
||||
has_preprocessor,
|
||||
on_behalf_of_email,
|
||||
@@ -4888,9 +4889,7 @@ pub async fn run_wait_result_script_by_hash(
|
||||
tx.commit().await?;
|
||||
|
||||
let wait_result = run_wait_result(&db, uuid, &w_id, None, &authed.username).await;
|
||||
if delete_after_use.unwrap_or(false) {
|
||||
delete_job_metadata_after_use(&db, uuid).await?;
|
||||
}
|
||||
handle_delete_after_completion(&db, uuid, &w_id, delete_after_use, delete_after_secs).await?;
|
||||
return wait_result;
|
||||
}
|
||||
|
||||
@@ -5060,7 +5059,7 @@ pub async fn stream_job(
|
||||
(uuid, None)
|
||||
}
|
||||
RunnableId::ScriptId(ScriptId::ScriptHash(script_hash)) => {
|
||||
let (uuid, _) = run_job_by_hash_inner(
|
||||
let (uuid, _, _) = run_job_by_hash_inner(
|
||||
authed.clone(),
|
||||
db.clone(),
|
||||
user_db,
|
||||
@@ -6543,7 +6542,7 @@ pub async fn run_job_by_hash(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let (uuid, _) = run_job_by_hash_inner(
|
||||
let (uuid, _, _) = run_job_by_hash_inner(
|
||||
authed,
|
||||
db,
|
||||
user_db,
|
||||
@@ -6567,7 +6566,7 @@ pub async fn run_job_by_hash_inner(
|
||||
run_query: RunJobQuery,
|
||||
args: PushArgsOwned,
|
||||
trigger: Option<TriggerMetadata>,
|
||||
) -> error::Result<(Uuid, Option<bool>)> {
|
||||
) -> error::Result<(Uuid, Option<bool>, Option<i32>)> {
|
||||
#[cfg(feature = "enterprise")]
|
||||
check_license_key_valid().await?;
|
||||
|
||||
@@ -6588,6 +6587,7 @@ pub async fn run_job_by_hash_inner(
|
||||
on_behalf_of_email,
|
||||
created_by,
|
||||
delete_after_use,
|
||||
delete_after_secs,
|
||||
labels,
|
||||
..
|
||||
} = get_script_info_for_hash(Some(userdb_authed), &db, &w_id, hash)
|
||||
@@ -6668,7 +6668,7 @@ pub async fn run_job_by_hash_inner(
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok((uuid, delete_after_use))
|
||||
Ok((uuid, delete_after_use, delete_after_secs))
|
||||
}
|
||||
|
||||
async fn get_log_file(Path((_w_id, file_p)): Path<(String, String)>) -> error::Result<Response> {
|
||||
|
||||
@@ -84,7 +84,7 @@ struct ScriptMetadata {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub timeout: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub delete_after_use: Option<bool>,
|
||||
pub delete_after_secs: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub restart_unless_cancelled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -508,7 +508,7 @@ pub(crate) async fn tarball_workspace(
|
||||
priority: script.priority,
|
||||
tag: script.tag,
|
||||
timeout: script.timeout,
|
||||
delete_after_use: script.delete_after_use,
|
||||
delete_after_secs: script.delete_after_secs,
|
||||
restart_unless_cancelled: script.restart_unless_cancelled,
|
||||
visible_to_runner_only: script.visible_to_runner_only,
|
||||
auto_kind: script.auto_kind,
|
||||
|
||||
@@ -43,6 +43,39 @@ pub fn get_has_preprocessor_from_content_and_lang(
|
||||
Ok(has_preprocessor)
|
||||
}
|
||||
|
||||
pub async fn schedule_job_deletion(
|
||||
db: &DB,
|
||||
job_id: uuid::Uuid,
|
||||
w_id: &str,
|
||||
delete_after_secs: i32,
|
||||
) -> crate::error::Result<()> {
|
||||
sqlx::query!(
|
||||
"INSERT INTO job_delete_schedule (job_id, workspace_id, delete_at) \
|
||||
VALUES ($1, $2, now() + make_interval(secs => $3::double precision)) \
|
||||
ON CONFLICT (job_id) DO NOTHING",
|
||||
job_id,
|
||||
w_id,
|
||||
delete_after_secs as f64,
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve effective delete behavior from delete_after_use (bool) and delete_after_secs.
|
||||
/// Returns Some(secs) if deletion should happen, None otherwise.
|
||||
pub fn resolve_delete_after_secs(
|
||||
delete_after_use: Option<bool>,
|
||||
delete_after_secs: Option<i32>,
|
||||
) -> Option<i32> {
|
||||
match (delete_after_use, delete_after_secs) {
|
||||
(_, Some(secs)) if secs >= 0 => Some(secs),
|
||||
(_, Some(_)) => None, // reject negative values
|
||||
(Some(true), None) => Some(0), // backward compat: immediate
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn script_path_to_payload<'e>(
|
||||
script_path: &str,
|
||||
db_authed: Option<UserDbWithAuthed<'e, AuthedRef<'e>>>,
|
||||
@@ -54,88 +87,94 @@ pub async fn script_path_to_payload<'e>(
|
||||
Option<Tag>,
|
||||
Option<bool>,
|
||||
Option<i32>,
|
||||
Option<i32>,
|
||||
Option<OnBehalfOf>,
|
||||
)> {
|
||||
let (job_payload, tag, delete_after_use, script_timeout, on_behalf_of) = if script_path
|
||||
.starts_with("hub/")
|
||||
{
|
||||
let hub_script =
|
||||
get_full_hub_script_by_path(StripPath(script_path.to_string()), &HTTP_CLIENT, None)
|
||||
.await?;
|
||||
let (job_payload, tag, delete_after_use, delete_after_secs, script_timeout, on_behalf_of) =
|
||||
if script_path.starts_with("hub/") {
|
||||
let hub_script =
|
||||
get_full_hub_script_by_path(StripPath(script_path.to_string()), &HTTP_CLIENT, None)
|
||||
.await?;
|
||||
|
||||
let has_preprocessor =
|
||||
get_has_preprocessor_from_content_and_lang(&hub_script.content, &hub_script.language)?;
|
||||
let has_preprocessor = get_has_preprocessor_from_content_and_lang(
|
||||
&hub_script.content,
|
||||
&hub_script.language,
|
||||
)?;
|
||||
|
||||
(
|
||||
JobPayload::ScriptHub {
|
||||
path: script_path.to_owned(),
|
||||
apply_preprocessor: has_preprocessor && !skip_preprocessor.unwrap_or(false),
|
||||
},
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
} else {
|
||||
let ScriptHashInfo {
|
||||
hash,
|
||||
tag,
|
||||
runnable_settings:
|
||||
super::scripts::ScriptRunnableSettingsInline {
|
||||
concurrency_settings,
|
||||
debouncing_settings,
|
||||
(
|
||||
JobPayload::ScriptHub {
|
||||
path: script_path.to_owned(),
|
||||
apply_preprocessor: has_preprocessor && !skip_preprocessor.unwrap_or(false),
|
||||
},
|
||||
cache_ttl,
|
||||
cache_ignore_s3_path,
|
||||
language,
|
||||
dedicated_worker,
|
||||
priority,
|
||||
delete_after_use,
|
||||
timeout,
|
||||
has_preprocessor,
|
||||
on_behalf_of_email,
|
||||
created_by,
|
||||
labels,
|
||||
..
|
||||
} = get_latest_deployed_hash_for_path(db_authed, db.clone(), w_id, script_path)
|
||||
.await?
|
||||
.prefetch_cached(&db)
|
||||
.await?;
|
||||
|
||||
let on_behalf_of = if let Some(email) = on_behalf_of_email {
|
||||
Some(OnBehalfOf {
|
||||
email,
|
||||
permissioned_as: username_to_permissioned_as(created_by.as_str()),
|
||||
})
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
(
|
||||
JobPayload::ScriptHash {
|
||||
hash: ScriptHash(hash),
|
||||
path: script_path.to_owned(),
|
||||
let ScriptHashInfo {
|
||||
hash,
|
||||
tag,
|
||||
runnable_settings:
|
||||
super::scripts::ScriptRunnableSettingsInline {
|
||||
concurrency_settings,
|
||||
debouncing_settings,
|
||||
},
|
||||
cache_ttl,
|
||||
cache_ignore_s3_path,
|
||||
language,
|
||||
dedicated_worker,
|
||||
priority,
|
||||
apply_preprocessor: !skip_preprocessor.unwrap_or(false)
|
||||
&& has_preprocessor.unwrap_or(false),
|
||||
debouncing_settings,
|
||||
concurrency_settings,
|
||||
delete_after_use,
|
||||
delete_after_secs,
|
||||
timeout,
|
||||
has_preprocessor,
|
||||
on_behalf_of_email,
|
||||
created_by,
|
||||
labels,
|
||||
},
|
||||
tag,
|
||||
delete_after_use,
|
||||
timeout,
|
||||
on_behalf_of,
|
||||
)
|
||||
};
|
||||
..
|
||||
} = get_latest_deployed_hash_for_path(db_authed, db.clone(), w_id, script_path)
|
||||
.await?
|
||||
.prefetch_cached(&db)
|
||||
.await?;
|
||||
|
||||
let on_behalf_of = if let Some(email) = on_behalf_of_email {
|
||||
Some(OnBehalfOf {
|
||||
email,
|
||||
permissioned_as: username_to_permissioned_as(created_by.as_str()),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
(
|
||||
JobPayload::ScriptHash {
|
||||
hash: ScriptHash(hash),
|
||||
path: script_path.to_owned(),
|
||||
cache_ttl,
|
||||
cache_ignore_s3_path,
|
||||
language,
|
||||
dedicated_worker,
|
||||
priority,
|
||||
apply_preprocessor: !skip_preprocessor.unwrap_or(false)
|
||||
&& has_preprocessor.unwrap_or(false),
|
||||
debouncing_settings,
|
||||
concurrency_settings,
|
||||
labels,
|
||||
},
|
||||
tag,
|
||||
delete_after_use,
|
||||
delete_after_secs,
|
||||
timeout,
|
||||
on_behalf_of,
|
||||
)
|
||||
};
|
||||
Ok((
|
||||
job_payload,
|
||||
tag,
|
||||
delete_after_use,
|
||||
delete_after_secs,
|
||||
script_timeout,
|
||||
on_behalf_of,
|
||||
))
|
||||
@@ -146,7 +185,7 @@ pub async fn get_payload_tag_from_prefixed_path(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
) -> Result<(JobPayload, Option<String>, Option<OnBehalfOf>), Error> {
|
||||
let (payload, tag, _, _, on_behalf_of) = if path.starts_with("script/") {
|
||||
let (payload, tag, _, _, _, on_behalf_of) = if path.starts_with("script/") {
|
||||
script_path_to_payload(
|
||||
path.strip_prefix("script/").unwrap(),
|
||||
None,
|
||||
@@ -170,6 +209,7 @@ pub async fn get_payload_tag_from_prefixed_path(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
} else {
|
||||
let FlowVersionInfo { dedicated_worker, tag, version, labels, .. } =
|
||||
@@ -186,6 +226,7 @@ pub async fn get_payload_tag_from_prefixed_path(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -1037,6 +1037,7 @@ pub struct ScriptHashInfo<SR> {
|
||||
pub dedicated_worker: Option<bool>,
|
||||
pub priority: Option<i16>,
|
||||
pub delete_after_use: Option<bool>,
|
||||
pub delete_after_secs: Option<i32>,
|
||||
pub timeout: Option<i32>,
|
||||
pub has_preprocessor: Option<bool>,
|
||||
pub on_behalf_of_email: Option<String>,
|
||||
@@ -1067,6 +1068,7 @@ impl ScriptHashInfo<ScriptRunnableSettingsHandle> {
|
||||
dedicated_worker: self.dedicated_worker,
|
||||
priority: self.priority,
|
||||
delete_after_use: self.delete_after_use,
|
||||
delete_after_secs: self.delete_after_secs,
|
||||
timeout: self.timeout,
|
||||
has_preprocessor: self.has_preprocessor,
|
||||
on_behalf_of_email: self.on_behalf_of_email,
|
||||
@@ -1236,6 +1238,7 @@ async fn get_script_info_for_hash_inner<'e, E: sqlx::PgExecutor<'e>>(
|
||||
dedicated_worker,
|
||||
priority,
|
||||
delete_after_use,
|
||||
delete_after_secs,
|
||||
timeout,
|
||||
has_preprocessor,
|
||||
on_behalf_of_email,
|
||||
|
||||
@@ -106,6 +106,7 @@ pub async fn prefetch_cached_script(
|
||||
cache_ignore_s3_path: script.cache_ignore_s3_path,
|
||||
timeout: script.timeout,
|
||||
delete_after_use: script.delete_after_use,
|
||||
delete_after_secs: script.delete_after_secs,
|
||||
restart_unless_cancelled: script.restart_unless_cancelled,
|
||||
visible_to_runner_only: script.visible_to_runner_only,
|
||||
auto_kind: script.auto_kind,
|
||||
@@ -346,6 +347,7 @@ pub async fn fetch_script_for_update<'a>(
|
||||
cache_ignore_s3_path,
|
||||
timeout,
|
||||
delete_after_use,
|
||||
delete_after_secs,
|
||||
restart_unless_cancelled,
|
||||
visible_to_runner_only,
|
||||
auto_kind,
|
||||
@@ -420,6 +422,7 @@ pub async fn clone_script<'c>(
|
||||
priority: s.priority,
|
||||
timeout: s.timeout,
|
||||
delete_after_use: s.delete_after_use,
|
||||
delete_after_secs: s.delete_after_secs,
|
||||
restart_unless_cancelled: s.restart_unless_cancelled,
|
||||
deployment_message,
|
||||
visible_to_runner_only: s.visible_to_runner_only,
|
||||
@@ -449,14 +452,14 @@ pub async fn clone_script<'c>(
|
||||
created_by, schema, is_template, extra_perms, lock, language, kind, tag, \
|
||||
draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, \
|
||||
dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \
|
||||
delete_after_use, timeout, concurrency_key, visible_to_runner_only, auto_kind, \
|
||||
delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, \
|
||||
codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels)
|
||||
|
||||
SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, \
|
||||
content, created_by, schema, is_template, extra_perms, NULL, language, kind, tag, \
|
||||
draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, \
|
||||
dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \
|
||||
delete_after_use, timeout, concurrency_key, visible_to_runner_only, auto_kind, \
|
||||
delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, \
|
||||
codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels
|
||||
|
||||
FROM script WHERE hash = $2 AND workspace_id = $3;
|
||||
|
||||
@@ -122,6 +122,21 @@ lazy_static::lazy_static! {
|
||||
}
|
||||
#[cfg(feature = "enterprise")]
|
||||
Mode::Agent
|
||||
} else if &x == "agent-batch" {
|
||||
println!("Binary is in 'agent-batch' mode with BASE_INTERNAL_URL={}", std::env::var("BASE_INTERNAL_URL").unwrap_or_default());
|
||||
if std::env::var("BASE_INTERNAL_URL").is_err() {
|
||||
panic!("BASE_INTERNAL_URL is required in agent-batch mode")
|
||||
}
|
||||
if std::env::var("AGENT_TOKEN").is_err() {
|
||||
println!("AGENT_TOKEN is not passed. This is required for the agent to work and contains the JWT to authenticate with the server.")
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
{
|
||||
panic!("Agent-batch mode is only available in the EE, ignoring...");
|
||||
}
|
||||
#[cfg(feature = "enterprise")]
|
||||
Mode::AgentBatch
|
||||
} else if &x == "indexer" {
|
||||
tracing::info!("Binary is in 'indexer' mode");
|
||||
#[cfg(not(feature = "tantivy"))]
|
||||
@@ -474,6 +489,7 @@ pub fn map_string_to_number(s: &str, max_number: u64) -> u64 {
|
||||
pub enum Mode {
|
||||
Worker,
|
||||
Agent,
|
||||
AgentBatch,
|
||||
Server,
|
||||
Standalone,
|
||||
Indexer,
|
||||
@@ -485,6 +501,7 @@ impl std::fmt::Display for Mode {
|
||||
match self {
|
||||
Mode::Worker => write!(f, "worker"),
|
||||
Mode::Agent => write!(f, "agent"),
|
||||
Mode::AgentBatch => write!(f, "agent-batch"),
|
||||
Mode::Server => write!(f, "server"),
|
||||
Mode::Standalone => write!(f, "standalone"),
|
||||
Mode::Indexer => write!(f, "indexer"),
|
||||
|
||||
@@ -237,6 +237,12 @@ lazy_static::lazy_static! {
|
||||
|
||||
pub static ref WORKER_PULL_QUERIES: Arc<RwLock<Vec<String>>> = Arc::new(RwLock::new(vec![]));
|
||||
pub static ref WORKER_SUSPENDED_PULL_QUERY: Arc<RwLock<String>> = Arc::new(RwLock::new("".to_string()));
|
||||
pub static ref WORKER_BATCH_PULL_QUERIES: Arc<RwLock<Vec<String>>> = Arc::new(RwLock::new(vec![]));
|
||||
|
||||
pub static ref BATCH_PULL_SIZE: i32 = std::env::var("BATCH_PULL_SIZE")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
|
||||
pub static ref SMTP_CONFIG: Arc<RwLock<Option<Smtp>>> = Arc::new(RwLock::new(None));
|
||||
@@ -522,16 +528,84 @@ pub fn make_pull_query(tags: &[String]) -> String {
|
||||
|
||||
pub async fn store_pull_query(wc: &WorkerConfig) {
|
||||
let mut queries = vec![];
|
||||
let mut batch_queries = vec![];
|
||||
for tags in wc.priority_tags_sorted.iter() {
|
||||
if tags.tags.len() == 0 {
|
||||
tracing::error!("Empty tags in priority tags, skipping");
|
||||
continue;
|
||||
}
|
||||
let query = make_pull_query(&tags.tags);
|
||||
queries.push(query);
|
||||
queries.push(make_pull_query(&tags.tags));
|
||||
batch_queries.push(make_batch_pull_query(&tags.tags));
|
||||
}
|
||||
let mut l = WORKER_PULL_QUERIES.write().await;
|
||||
*l = queries;
|
||||
drop(l);
|
||||
let mut l = WORKER_BATCH_PULL_QUERIES.write().await;
|
||||
*l = batch_queries;
|
||||
}
|
||||
|
||||
/// Build a batch pull query that claims up to $2 jobs at once.
|
||||
/// Uses $1 for worker_name and $2 for batch_size (i32).
|
||||
pub fn make_batch_pull_query(tags: &[String]) -> String {
|
||||
format_batch_pull_query(format!(
|
||||
"SELECT id
|
||||
FROM v2_job_queue
|
||||
WHERE running = false
|
||||
AND tag IN ({}) AND scheduled_for <= now()
|
||||
AND id NOT IN (SELECT id FROM v2_job WHERE same_worker = true)
|
||||
ORDER BY priority DESC NULLS LAST, scheduled_for
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT $2",
|
||||
tags.iter().map(|x| format!("'{x}'")).join(", ")
|
||||
))
|
||||
}
|
||||
|
||||
fn format_batch_pull_query(peek: String) -> String {
|
||||
format!(
|
||||
"WITH peek AS (
|
||||
{}
|
||||
), q AS NOT MATERIALIZED (
|
||||
UPDATE v2_job_queue SET
|
||||
running = true,
|
||||
started_at = coalesce(started_at, now()),
|
||||
suspend_until = null,
|
||||
worker = $1
|
||||
WHERE id IN (SELECT id FROM peek)
|
||||
RETURNING
|
||||
id, started_at, scheduled_for,
|
||||
canceled_by, canceled_reason, worker, cache_ignore_s3_path, runnable_settings_handle
|
||||
), r AS NOT MATERIALIZED (
|
||||
UPDATE v2_job_runtime SET
|
||||
ping = now()
|
||||
WHERE id IN (SELECT id FROM peek)
|
||||
), j AS NOT MATERIALIZED (
|
||||
SELECT
|
||||
id, workspace_id, parent_job, created_by, created_at, runnable_id,
|
||||
runnable_path, args, kind, trigger, trigger_kind,
|
||||
permissioned_as, permissioned_as_email, script_lang,
|
||||
flow_innermost_root_job, root_job, flow_step_id,
|
||||
same_worker, pre_run_error, visible_to_owner, tag, concurrent_limit,
|
||||
concurrency_time_window_s, timeout, cache_ttl, priority, raw_code, raw_lock,
|
||||
raw_flow, script_entrypoint_override, preprocessed
|
||||
FROM v2_job
|
||||
WHERE id IN (SELECT id FROM peek)
|
||||
) SELECT j.id, j.workspace_id, j.parent_job, j.created_by, q.started_at, q.scheduled_for,
|
||||
j.runnable_id, j.runnable_path, j.args, q.canceled_by,
|
||||
q.canceled_reason, j.kind, j.trigger, j.trigger_kind, j.permissioned_as,
|
||||
f.flow_status, j.script_lang,
|
||||
j.same_worker, j.pre_run_error, j.visible_to_owner,
|
||||
j.tag, j.concurrent_limit, j.concurrency_time_window_s, j.flow_innermost_root_job, j.root_job,
|
||||
j.timeout, j.flow_step_id, j.cache_ttl, q.cache_ignore_s3_path, q.runnable_settings_handle, j.priority, j.raw_code, j.raw_lock, j.raw_flow,
|
||||
j.script_entrypoint_override, j.preprocessed, COALESCE(pj.runnable_path, j.args->>'_FLOW_PATH') as parent_runnable_path,
|
||||
COALESCE(p.email, j.permissioned_as_email) as permissioned_as_email, p.username as permissioned_as_username, p.is_admin as permissioned_as_is_admin,
|
||||
p.is_operator as permissioned_as_is_operator, p.groups as permissioned_as_groups, p.folders as permissioned_as_folders, p.end_user_email as permissioned_as_end_user_email
|
||||
FROM q
|
||||
INNER JOIN j ON q.id = j.id
|
||||
LEFT JOIN v2_job_status f ON f.id = j.id
|
||||
LEFT JOIN job_perms p ON p.job_id = j.id
|
||||
LEFT JOIN v2_job pj ON j.parent_job = pj.id",
|
||||
peek
|
||||
)
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
|
||||
@@ -443,6 +443,92 @@ pub async fn append_logs(
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull up to `batch_size` jobs at once using the given batch pull query.
|
||||
/// The query must use $1 for worker_name and $2 for batch_size (i32).
|
||||
pub async fn batch_pull(
|
||||
db: &Pool<Postgres>,
|
||||
worker_name: &str,
|
||||
batch_query: &str,
|
||||
batch_size: i32,
|
||||
) -> windmill_common::error::Result<Vec<PulledJob>> {
|
||||
let jobs = sqlx::query_as::<_, PulledJob>(batch_query)
|
||||
.bind(worker_name)
|
||||
.bind(batch_size)
|
||||
.fetch_all(db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
windmill_common::error::Error::InternalErr(format!("batch pull error: {e:#}"))
|
||||
})?;
|
||||
Ok(jobs)
|
||||
}
|
||||
|
||||
/// Batch-commit multiple completed jobs in a single transaction.
|
||||
/// Only for simple jobs (no flows, no schedules, no concurrency limits).
|
||||
/// Returns (job_id, duration_ms) for each committed job.
|
||||
pub async fn batch_commit_completed_jobs(
|
||||
db: &Pool<Postgres>,
|
||||
jobs: &[(Uuid, bool, &serde_json::value::RawValue, i32, Option<i64>)], // (id, success, result, mem_peak, duration)
|
||||
) -> windmill_common::error::Result<Vec<(Uuid, i64)>> {
|
||||
if jobs.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let mut tx = db.begin().await.map_err(|e| {
|
||||
windmill_common::error::Error::InternalErr(format!("batch commit begin: {e:#}"))
|
||||
})?;
|
||||
|
||||
let mut results = Vec::with_capacity(jobs.len());
|
||||
|
||||
for &(job_id, success, result, mem_peak, duration) in jobs {
|
||||
let status = if success { "success" } else { "failure" };
|
||||
let duration_ms: Option<i64> = sqlx::query_scalar(
|
||||
"INSERT INTO v2_job_completed AS cj
|
||||
(workspace_id, id, started_at, duration_ms, result,
|
||||
flow_status, workflow_as_code_status,
|
||||
memory_peak, status, worker)
|
||||
SELECT q.workspace_id, q.id, started_at,
|
||||
COALESCE($3::bigint, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE(started_at, now()))))*1000),
|
||||
$2::jsonb, flow_status, workflow_as_code_status,
|
||||
$4, $5::job_status, q.worker
|
||||
FROM v2_job_queue q LEFT JOIN v2_job_status USING (id)
|
||||
WHERE q.id = $1
|
||||
ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, result = $2::jsonb
|
||||
RETURNING duration_ms",
|
||||
)
|
||||
.bind(job_id)
|
||||
.bind(result.get())
|
||||
.bind(duration)
|
||||
.bind(if mem_peak > 0 { Some(mem_peak) } else { None })
|
||||
.bind(status)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
windmill_common::error::Error::InternalErr(format!(
|
||||
"batch commit insert job {job_id}: {e:#}"
|
||||
))
|
||||
})?;
|
||||
|
||||
if let Some(dur) = duration_ms {
|
||||
sqlx::query!("DELETE FROM v2_job_queue WHERE id = $1", job_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
windmill_common::error::Error::InternalErr(format!(
|
||||
"batch commit delete job {job_id}: {e:#}"
|
||||
))
|
||||
})?;
|
||||
results.push((job_id, dur));
|
||||
} else {
|
||||
tracing::warn!("batch commit: job {job_id} not found in queue, skipping");
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|e| windmill_common::error::Error::InternalErr(format!("batch commit: {e:#}")))?;
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
pub const PERIODIC_SCRIPT_TAG: &str = "periodic_bash_script";
|
||||
pub const INIT_SCRIPT_TAG: &str = "init_script";
|
||||
pub const INIT_SCRIPT_PATH_PREFIX: &str = "init_script_";
|
||||
@@ -3638,8 +3724,11 @@ pub fn resolve_debounce_key<'b>(
|
||||
.join(":"),
|
||||
));
|
||||
|
||||
tracing::debug!("Original debounce key (len={}): {}", original_debounce_key.len(), original_debounce_key);
|
||||
|
||||
tracing::debug!(
|
||||
"Original debounce key (len={}): {}",
|
||||
original_debounce_key.len(),
|
||||
original_debounce_key
|
||||
);
|
||||
|
||||
// If debounce_key is not too long (< 255 chars), keep it as is, otherwise hash it.
|
||||
// On cloud, we prepend "{workspace_id}:" so we must reserve space for that prefix
|
||||
@@ -5217,6 +5306,8 @@ async fn push_inner<'c, 'd>(
|
||||
preprocessor_module: None,
|
||||
chat_input_enabled: None,
|
||||
flow_env: None,
|
||||
delete_after_use: None,
|
||||
delete_after_secs: None,
|
||||
};
|
||||
// this is a new flow being pushed, flow_status is set to flow_value:
|
||||
let flow_status: FlowStatus = FlowStatus::new(&flow_value);
|
||||
|
||||
@@ -253,8 +253,8 @@ var $Script = {
|
||||
timeout: {
|
||||
type: "integer",
|
||||
},
|
||||
delete_after_use: {
|
||||
type: "boolean",
|
||||
delete_after_secs: {
|
||||
type: "integer",
|
||||
},
|
||||
visible_to_runner_only: {
|
||||
type: "boolean",
|
||||
@@ -369,8 +369,8 @@ var $NewScript = {
|
||||
timeout: {
|
||||
type: "integer",
|
||||
},
|
||||
delete_after_use: {
|
||||
type: "boolean",
|
||||
delete_after_secs: {
|
||||
type: "integer",
|
||||
},
|
||||
deployment_message: {
|
||||
type: "string",
|
||||
@@ -2830,8 +2830,8 @@ var $FlowModule = {
|
||||
timeout: {
|
||||
type: "number",
|
||||
},
|
||||
delete_after_use: {
|
||||
type: "boolean",
|
||||
delete_after_secs: {
|
||||
type: "integer",
|
||||
},
|
||||
summary: {
|
||||
type: "string",
|
||||
|
||||
@@ -753,7 +753,7 @@ pub async fn assert_lockfile(
|
||||
schema: std::collections::HashMap::new(),
|
||||
ws_error_handler_muted: Some(false),
|
||||
priority: None,
|
||||
delete_after_use: None,
|
||||
delete_after_secs: None,
|
||||
timeout: None,
|
||||
restart_unless_cancelled: None,
|
||||
deployment_message: None,
|
||||
@@ -851,7 +851,7 @@ pub async fn run_deployed_relative_imports(
|
||||
schema: std::collections::HashMap::new(),
|
||||
ws_error_handler_muted: Some(false),
|
||||
priority: None,
|
||||
delete_after_use: None,
|
||||
delete_after_secs: None,
|
||||
timeout: None,
|
||||
restart_unless_cancelled: None,
|
||||
deployment_message: None,
|
||||
|
||||
@@ -31,7 +31,7 @@ use windmill_api_jobs::{
|
||||
execution::{
|
||||
check_tag_available_for_workspace, delete_job_metadata_after_use,
|
||||
push_flow_job_by_path_into_queue, push_script_job_by_path_into_queue, result_to_response,
|
||||
run_wait_result_internal,
|
||||
run_wait_result_internal, schedule_job_deletion,
|
||||
},
|
||||
types::RunJobQuery,
|
||||
};
|
||||
@@ -522,7 +522,7 @@ pub async fn trigger_runnable_inner<'c>(
|
||||
suspended_mode: Option<bool>,
|
||||
) -> Result<(
|
||||
Uuid,
|
||||
Option<bool>,
|
||||
Option<i32>,
|
||||
Option<String>,
|
||||
Option<sqlx::Transaction<'c, sqlx::Postgres>>,
|
||||
)> {
|
||||
@@ -536,7 +536,7 @@ pub async fn trigger_runnable_inner<'c>(
|
||||
});
|
||||
|
||||
let user_db = user_db.unwrap_or_else(|| UserDB::new(db.clone()));
|
||||
let (uuid, delete_after_use, early_return, tx_out) = if is_flow {
|
||||
let (uuid, resolved_delete_secs, early_return, tx_out) = if is_flow {
|
||||
let run_query = RunJobQuery { job_id, suspended_mode, ..Default::default() };
|
||||
let path = StripPath(runnable_path.to_string());
|
||||
let (uuid, early_return, tx_out) = push_flow_job_by_path_into_queue(
|
||||
@@ -553,7 +553,7 @@ pub async fn trigger_runnable_inner<'c>(
|
||||
.await?;
|
||||
(uuid, None, early_return, tx_out)
|
||||
} else {
|
||||
let (uuid, delete_after_use, tx_out) = trigger_script_internal(
|
||||
let (uuid, resolved_delete_secs, tx_out) = trigger_script_internal(
|
||||
db,
|
||||
tx_o,
|
||||
user_db,
|
||||
@@ -570,10 +570,10 @@ pub async fn trigger_runnable_inner<'c>(
|
||||
suspended_mode,
|
||||
)
|
||||
.await?;
|
||||
(uuid, delete_after_use, None, tx_out)
|
||||
(uuid, resolved_delete_secs, None, tx_out)
|
||||
};
|
||||
|
||||
Ok((uuid, delete_after_use, early_return, tx_out))
|
||||
Ok((uuid, resolved_delete_secs, early_return, tx_out))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -631,7 +631,7 @@ pub async fn trigger_runnable_and_wait_for_result(
|
||||
trigger: TriggerMetadata,
|
||||
) -> Result<axum::response::Response> {
|
||||
let username = authed.username.clone();
|
||||
let (uuid, delete_after_use, early_return, _) = trigger_runnable_inner(
|
||||
let (uuid, resolved_delete_secs, early_return, _) = trigger_runnable_inner(
|
||||
db,
|
||||
None,
|
||||
user_db,
|
||||
@@ -652,8 +652,10 @@ pub async fn trigger_runnable_and_wait_for_result(
|
||||
let (result, success) =
|
||||
run_wait_result_internal(db, uuid, &workspace_id, early_return, &username).await?;
|
||||
|
||||
if delete_after_use.unwrap_or(false) {
|
||||
delete_job_metadata_after_use(&db, uuid).await?;
|
||||
match resolved_delete_secs {
|
||||
Some(0) => delete_job_metadata_after_use(&db, uuid).await?,
|
||||
Some(secs) => schedule_job_deletion(&db, uuid, &workspace_id, secs).await?,
|
||||
None => {}
|
||||
}
|
||||
|
||||
result_to_response(result, success)
|
||||
@@ -675,7 +677,7 @@ pub async fn trigger_runnable_and_wait_for_raw_result(
|
||||
trigger: TriggerMetadata,
|
||||
) -> Result<(Box<RawValue>, bool)> {
|
||||
let username = authed.username.clone();
|
||||
let (uuid, delete_after_use, early_return, _) = trigger_runnable_inner(
|
||||
let (uuid, resolved_delete_secs, early_return, _) = trigger_runnable_inner(
|
||||
db,
|
||||
None,
|
||||
user_db,
|
||||
@@ -705,8 +707,10 @@ pub async fn trigger_runnable_and_wait_for_raw_result(
|
||||
)
|
||||
})?;
|
||||
|
||||
if delete_after_use.unwrap_or(false) {
|
||||
delete_job_metadata_after_use(&db, uuid).await?;
|
||||
match resolved_delete_secs {
|
||||
Some(0) => delete_job_metadata_after_use(&db, uuid).await?,
|
||||
Some(secs) => schedule_job_deletion(&db, uuid, &workspace_id, secs).await?,
|
||||
None => {}
|
||||
}
|
||||
|
||||
Ok((result, success))
|
||||
@@ -770,13 +774,13 @@ async fn trigger_script_internal<'c>(
|
||||
suspended_mode: Option<bool>,
|
||||
) -> Result<(
|
||||
Uuid,
|
||||
Option<bool>,
|
||||
Option<i32>,
|
||||
Option<sqlx::Transaction<'c, sqlx::Postgres>>,
|
||||
)> {
|
||||
if retry.is_none() && error_handler_path.is_none() {
|
||||
let run_query = RunJobQuery { job_id, suspended_mode, ..Default::default() };
|
||||
let path = StripPath(script_path.to_string());
|
||||
let (uuid, delete_after_use, tx_out) = push_script_job_by_path_into_queue(
|
||||
let (uuid, resolved_delete_secs, tx_out) = push_script_job_by_path_into_queue(
|
||||
authed,
|
||||
db.clone(),
|
||||
tx_o,
|
||||
@@ -788,9 +792,9 @@ async fn trigger_script_internal<'c>(
|
||||
Some(trigger),
|
||||
)
|
||||
.await?;
|
||||
Ok((uuid, delete_after_use, tx_out))
|
||||
Ok((uuid, resolved_delete_secs, tx_out))
|
||||
} else {
|
||||
let (uuid, delete_after_use, tx_out) = trigger_script_with_retry_and_error_handler(
|
||||
let (uuid, resolved_delete_secs, tx_out) = trigger_script_with_retry_and_error_handler(
|
||||
db,
|
||||
tx_o,
|
||||
user_db,
|
||||
@@ -807,7 +811,7 @@ async fn trigger_script_internal<'c>(
|
||||
suspended_mode,
|
||||
)
|
||||
.await?;
|
||||
Ok((uuid, delete_after_use, tx_out))
|
||||
Ok((uuid, resolved_delete_secs, tx_out))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -828,7 +832,7 @@ async fn trigger_script_with_retry_and_error_handler<'c>(
|
||||
suspended_mode: Option<bool>,
|
||||
) -> Result<(
|
||||
Uuid,
|
||||
Option<bool>,
|
||||
Option<i32>,
|
||||
Option<sqlx::Transaction<'c, sqlx::Postgres>>,
|
||||
)> {
|
||||
#[cfg(feature = "enterprise")]
|
||||
@@ -840,7 +844,7 @@ async fn trigger_script_with_retry_and_error_handler<'c>(
|
||||
let error_handler_path = error_handler_path.map(|p| p.to_string());
|
||||
let error_handler_args = error_handler_args.map(|args| args.0.clone());
|
||||
|
||||
let (job_payload, tag, delete_after_use, timeout, on_behalf_of) = {
|
||||
let (job_payload, tag, delete_after_use, delete_after_secs, timeout, on_behalf_of) = {
|
||||
let db_authed = UserDbWithAuthed { db: user_db.clone(), authed: &authed.to_authed_ref() };
|
||||
script_path_to_payload(
|
||||
script_path,
|
||||
@@ -851,6 +855,10 @@ async fn trigger_script_with_retry_and_error_handler<'c>(
|
||||
)
|
||||
.await?
|
||||
};
|
||||
let resolved_delete_secs = windmill_api_jobs::execution::resolve_delete_after_secs(
|
||||
delete_after_use,
|
||||
delete_after_secs,
|
||||
);
|
||||
|
||||
check_tag_available_for_workspace(&db, &workspace_id, &tag, &authed).await?;
|
||||
|
||||
@@ -951,9 +959,9 @@ async fn trigger_script_with_retry_and_error_handler<'c>(
|
||||
|
||||
// If we were given a transaction, return it; otherwise commit it
|
||||
if return_tx {
|
||||
Ok((uuid, delete_after_use, Some(tx)))
|
||||
Ok((uuid, resolved_delete_secs, Some(tx)))
|
||||
} else {
|
||||
tx.commit().await?;
|
||||
Ok((uuid, delete_after_use, None))
|
||||
Ok((uuid, resolved_delete_secs, None))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,15 @@ pub struct FlowCleanupModule {
|
||||
#[serde(default)]
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub flow_jobs_to_clean: Vec<Uuid>,
|
||||
#[serde(default)]
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub flow_jobs_to_schedule_clean: Vec<FlowJobScheduledClean>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct FlowJobScheduledClean {
|
||||
pub id: Uuid,
|
||||
pub delete_after_secs: i32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
@@ -513,7 +522,10 @@ impl FlowStatus {
|
||||
} else {
|
||||
None
|
||||
},
|
||||
cleanup_module: FlowCleanupModule { flow_jobs_to_clean: vec![] },
|
||||
cleanup_module: FlowCleanupModule {
|
||||
flow_jobs_to_clean: vec![],
|
||||
flow_jobs_to_schedule_clean: vec![],
|
||||
},
|
||||
retry: RetryStatus { fail_count: 0, failed_jobs: vec![] },
|
||||
restarted_from: None,
|
||||
user_states: HashMap::new(),
|
||||
|
||||
@@ -184,6 +184,10 @@ pub struct FlowValue {
|
||||
pub chat_input_enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub flow_env: Option<HashMap<String, Box<RawValue>>>,
|
||||
#[serde(skip_serializing, default)]
|
||||
pub delete_after_use: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub delete_after_secs: Option<i32>,
|
||||
}
|
||||
|
||||
impl FlowValue {
|
||||
@@ -441,9 +445,11 @@ pub struct FlowModule {
|
||||
pub timeout: Option<InputTransform>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub priority: Option<i16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(skip_serializing, default)]
|
||||
pub delete_after_use: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub delete_after_secs: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub continue_on_error: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub skip_if: Option<SkipIf>,
|
||||
@@ -1122,6 +1128,7 @@ pub fn add_virtual_items_if_necessary(modules: &mut Vec<FlowModule>) {
|
||||
timeout: None,
|
||||
priority: None,
|
||||
delete_after_use: None,
|
||||
delete_after_secs: None,
|
||||
continue_on_error: None,
|
||||
skip_if: None,
|
||||
apply_preprocessor: None,
|
||||
|
||||
@@ -357,9 +357,11 @@ pub struct Script<SR> {
|
||||
pub cache_ignore_s3_path: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub timeout: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(skip_serializing, default)]
|
||||
pub delete_after_use: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub delete_after_secs: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub restart_unless_cancelled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub visible_to_runner_only: Option<bool>,
|
||||
@@ -505,7 +507,9 @@ pub struct NewScript {
|
||||
pub ws_error_handler_muted: Option<bool>,
|
||||
pub priority: Option<i16>,
|
||||
pub timeout: Option<i32>,
|
||||
#[serde(skip_serializing, default)]
|
||||
pub delete_after_use: Option<bool>,
|
||||
pub delete_after_secs: Option<i32>,
|
||||
pub restart_unless_cancelled: Option<bool>,
|
||||
pub deployment_message: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
||||
@@ -37,6 +37,15 @@ pub async fn pull_job(
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn batch_pull_jobs(
|
||||
client: &HttpClient,
|
||||
batch_size: i32,
|
||||
) -> anyhow::Result<Vec<JobAndPerms>> {
|
||||
client
|
||||
.post("/api/agent_workers/batch_pull", None, &batch_size)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn send_result(client: &HttpClient, jc: JobCompleted) -> anyhow::Result<String> {
|
||||
client
|
||||
.post(
|
||||
|
||||
@@ -391,6 +391,7 @@ async fn execute_windmill_tool(
|
||||
tool_module,
|
||||
tag,
|
||||
tool_module.delete_after_use.unwrap_or(false),
|
||||
None,
|
||||
)
|
||||
}
|
||||
FlowModuleValue::FlowScript { id, language, concurrency_settings, tag, .. } => {
|
||||
@@ -407,6 +408,7 @@ async fn execute_windmill_tool(
|
||||
},
|
||||
tag: tag.clone(),
|
||||
delete_after_use: tool_module.delete_after_use.unwrap_or(false),
|
||||
delete_after_secs: None,
|
||||
timeout: None,
|
||||
on_behalf_of: None,
|
||||
}
|
||||
@@ -429,6 +431,7 @@ async fn execute_windmill_tool(
|
||||
payload: JobPayload::AIAgent { path },
|
||||
tag: None,
|
||||
delete_after_use: tool_module.delete_after_use.unwrap_or(false),
|
||||
delete_after_secs: None,
|
||||
timeout: None,
|
||||
on_behalf_of: None,
|
||||
}
|
||||
|
||||
@@ -2590,7 +2590,7 @@ pub async fn handle_wac_v2_output(
|
||||
}
|
||||
"script" => {
|
||||
// Resolve script path to job payload (handles hash, lang, etc.)
|
||||
let (payload, _, _, _, _) = script_path_to_payload(
|
||||
let (payload, _, _, _, _, _) = script_path_to_payload(
|
||||
&step.script,
|
||||
None, // no authed db for background workers
|
||||
db.clone(),
|
||||
|
||||
@@ -218,6 +218,22 @@ enum JobCompletedRx {
|
||||
WakeUp,
|
||||
}
|
||||
|
||||
fn is_batchable(jc: &JobCompleted) -> bool {
|
||||
jc.success
|
||||
&& !jc.job.is_flow_step()
|
||||
&& jc.job.flow_step_id.as_deref() != Some("preprocessor")
|
||||
&& jc.preprocessed_args.is_none()
|
||||
&& jc.job.tag.as_str() != INIT_SCRIPT_TAG
|
||||
&& !matches!(
|
||||
jc.job.kind,
|
||||
JobKind::Dependencies | JobKind::FlowDependencies
|
||||
)
|
||||
&& jc.canceled_by.is_none()
|
||||
&& jc.cached_res_path.is_none()
|
||||
&& jc.job.concurrent_limit.is_none()
|
||||
&& jc.job.schedule_path().is_none()
|
||||
}
|
||||
|
||||
pub fn start_background_processor(
|
||||
job_completed_rx: JobCompletedReceiver,
|
||||
job_completed_sender: JobCompletedSender,
|
||||
@@ -264,6 +280,8 @@ pub fn start_background_processor(
|
||||
}
|
||||
});
|
||||
|
||||
let mut batch_result_buffer: Vec<windmill_queue::JobCompleted> = Vec::new();
|
||||
|
||||
//if we have been killed, we want to drain the queue of jobs
|
||||
while let Some(sr) = {
|
||||
if has_been_killed {
|
||||
@@ -303,61 +321,153 @@ pub fn start_background_processor(
|
||||
result: SendResultPayload::JobCompleted(jc),
|
||||
time,
|
||||
}) => {
|
||||
let is_init_script_and_failure =
|
||||
!jc.success && jc.job.tag.as_str() == INIT_SCRIPT_TAG;
|
||||
let is_dependency_job = matches!(
|
||||
jc.job.kind,
|
||||
JobKind::Dependencies | JobKind::FlowDependencies
|
||||
);
|
||||
#[cfg(feature = "benchmark")]
|
||||
let bench_job_id = jc.job.id;
|
||||
#[cfg(feature = "benchmark")]
|
||||
let is_top_level_job = jc.job.parent_job.is_none();
|
||||
let batch_mode = *windmill_common::worker::BATCH_PULL_SIZE > 0
|
||||
|| windmill_common::utils::MODE_AND_ADDONS.mode
|
||||
== windmill_common::utils::Mode::AgentBatch;
|
||||
|
||||
process_jc(
|
||||
jc,
|
||||
&worker_name,
|
||||
&base_internal_url,
|
||||
&db,
|
||||
&worker_dir,
|
||||
Some(&same_worker_tx),
|
||||
&job_completed_sender,
|
||||
&stats_map,
|
||||
&killpill_rx,
|
||||
#[cfg(feature = "benchmark")]
|
||||
&mut bench,
|
||||
#[cfg(feature = "benchmark")]
|
||||
&mut infos,
|
||||
)
|
||||
.warn_after_seconds(10)
|
||||
.await;
|
||||
if batch_mode && is_batchable(&jc) {
|
||||
// Accumulate for batch commit
|
||||
batch_result_buffer.push(jc);
|
||||
|
||||
if is_init_script_and_failure {
|
||||
tracing::error!("init script errored, exiting");
|
||||
killpill_tx.send();
|
||||
break;
|
||||
}
|
||||
if is_dependency_job && is_dedicated_worker {
|
||||
tracing::error!("Dedicated worker executed a dependency job, a new script has been deployed. Exiting expecting to be restarted.");
|
||||
sqlx::query!(
|
||||
"UPDATE config SET config = config WHERE name = $1",
|
||||
format!("worker__{}", *WORKER_GROUP)
|
||||
)
|
||||
.execute(&db)
|
||||
.await
|
||||
.expect("update config to trigger restart of all dedicated workers at that config");
|
||||
killpill_tx.send();
|
||||
}
|
||||
add_time!(bench, "job completed processed");
|
||||
|
||||
#[cfg(feature = "benchmark")]
|
||||
{
|
||||
if infos.add_iter(bench, bench_job_id, is_top_level_job) {
|
||||
infos.shared_iters.fetch_add(1, Ordering::Relaxed);
|
||||
// Drain any additional ready results
|
||||
while let Ok(SendResult {
|
||||
result: SendResultPayload::JobCompleted(jc2),
|
||||
..
|
||||
}) = bounded_rx.try_recv()
|
||||
{
|
||||
if is_batchable(&jc2) {
|
||||
batch_result_buffer.push(jc2);
|
||||
} else {
|
||||
process_jc(
|
||||
jc2,
|
||||
&worker_name,
|
||||
&base_internal_url,
|
||||
&db,
|
||||
&worker_dir,
|
||||
Some(&same_worker_tx),
|
||||
&job_completed_sender,
|
||||
&stats_map,
|
||||
&killpill_rx,
|
||||
#[cfg(feature = "benchmark")]
|
||||
&mut bench,
|
||||
#[cfg(feature = "benchmark")]
|
||||
&mut infos,
|
||||
)
|
||||
.warn_after_seconds(10)
|
||||
.await;
|
||||
}
|
||||
if batch_result_buffer.len() >= 50 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Flush batch
|
||||
if !batch_result_buffer.is_empty() {
|
||||
let batch_items: Vec<_> = batch_result_buffer
|
||||
.iter()
|
||||
.map(|jc| {
|
||||
(
|
||||
jc.job.id,
|
||||
jc.success,
|
||||
jc.result.as_ref() as &serde_json::value::RawValue,
|
||||
jc.mem_peak,
|
||||
jc.duration,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
match windmill_queue::batch_commit_completed_jobs(&db, &batch_items)
|
||||
.await
|
||||
{
|
||||
Ok(committed) => {
|
||||
tracing::debug!("batch committed {} jobs", committed.len());
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"batch commit failed, falling back to individual: {e:#}"
|
||||
);
|
||||
for jc in batch_result_buffer.drain(..) {
|
||||
process_jc(
|
||||
jc,
|
||||
&worker_name,
|
||||
&base_internal_url,
|
||||
&db,
|
||||
&worker_dir,
|
||||
Some(&same_worker_tx),
|
||||
&job_completed_sender,
|
||||
&stats_map,
|
||||
&killpill_rx,
|
||||
#[cfg(feature = "benchmark")]
|
||||
&mut bench,
|
||||
#[cfg(feature = "benchmark")]
|
||||
&mut infos,
|
||||
)
|
||||
.warn_after_seconds(10)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
batch_result_buffer.clear();
|
||||
}
|
||||
|
||||
last_processing_duration
|
||||
.store(time.elapsed().as_secs() as u16, Ordering::SeqCst);
|
||||
} else {
|
||||
let is_init_script_and_failure =
|
||||
!jc.success && jc.job.tag.as_str() == INIT_SCRIPT_TAG;
|
||||
let is_dependency_job = matches!(
|
||||
jc.job.kind,
|
||||
JobKind::Dependencies | JobKind::FlowDependencies
|
||||
);
|
||||
#[cfg(feature = "benchmark")]
|
||||
let bench_job_id = jc.job.id;
|
||||
#[cfg(feature = "benchmark")]
|
||||
let is_top_level_job = jc.job.parent_job.is_none();
|
||||
|
||||
process_jc(
|
||||
jc,
|
||||
&worker_name,
|
||||
&base_internal_url,
|
||||
&db,
|
||||
&worker_dir,
|
||||
Some(&same_worker_tx),
|
||||
&job_completed_sender,
|
||||
&stats_map,
|
||||
&killpill_rx,
|
||||
#[cfg(feature = "benchmark")]
|
||||
&mut bench,
|
||||
#[cfg(feature = "benchmark")]
|
||||
&mut infos,
|
||||
)
|
||||
.warn_after_seconds(10)
|
||||
.await;
|
||||
|
||||
if is_init_script_and_failure {
|
||||
tracing::error!("init script errored, exiting");
|
||||
killpill_tx.send();
|
||||
break;
|
||||
}
|
||||
if is_dependency_job && is_dedicated_worker {
|
||||
tracing::error!("Dedicated worker executed a dependency job, a new script has been deployed. Exiting expecting to be restarted.");
|
||||
sqlx::query!(
|
||||
"UPDATE config SET config = config WHERE name = $1",
|
||||
format!("worker__{}", *WORKER_GROUP)
|
||||
)
|
||||
.execute(&db)
|
||||
.await
|
||||
.expect("update config to trigger restart of all dedicated workers at that config");
|
||||
killpill_tx.send();
|
||||
}
|
||||
add_time!(bench, "job completed processed");
|
||||
|
||||
#[cfg(feature = "benchmark")]
|
||||
{
|
||||
if infos.add_iter(bench, bench_job_id, is_top_level_job) {
|
||||
infos.shared_iters.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
last_processing_duration
|
||||
.store(time.elapsed().as_secs() as u16, Ordering::SeqCst);
|
||||
}
|
||||
last_processing_duration
|
||||
.store(time.elapsed().as_secs() as u16, Ordering::SeqCst);
|
||||
}
|
||||
JobCompletedRx::JobCompleted(SendResult {
|
||||
result:
|
||||
|
||||
@@ -2075,6 +2075,23 @@ pub async fn run_worker(
|
||||
let mut last_suspend_first = Instant::now();
|
||||
let mut killed_but_draining_same_worker_jobs = false;
|
||||
|
||||
let is_agent_batch =
|
||||
windmill_common::utils::MODE_AND_ADDONS.mode == windmill_common::utils::Mode::AgentBatch;
|
||||
let batch_pull_size = {
|
||||
let s = *windmill_common::worker::BATCH_PULL_SIZE;
|
||||
if s > 0 {
|
||||
s
|
||||
} else if is_agent_batch {
|
||||
100
|
||||
} else {
|
||||
0
|
||||
}
|
||||
};
|
||||
let mut batch_pull_buffer: std::collections::VecDeque<windmill_queue::PulledJob> =
|
||||
std::collections::VecDeque::new();
|
||||
let mut agent_batch_buffer: std::collections::VecDeque<windmill_queue::JobAndPerms> =
|
||||
std::collections::VecDeque::new();
|
||||
|
||||
let mut killpill_rx2 = killpill_rx.resubscribe();
|
||||
|
||||
loop {
|
||||
@@ -2318,6 +2335,53 @@ pub async fn run_worker(
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
continue;
|
||||
}
|
||||
} else if batch_pull_size > 0 && !batch_pull_buffer.is_empty() {
|
||||
// Serve from batch pull buffer
|
||||
Ok(batch_pull_buffer
|
||||
.pop_front()
|
||||
.map(|job| NextJob::Sql { flow_runners: None, job }))
|
||||
} else if batch_pull_size > 0 && matches!(&conn, Connection::Sql(_)) {
|
||||
// Batch pull: try to refill buffer from DB
|
||||
let db = conn.as_sql().unwrap();
|
||||
let queries = windmill_common::worker::WORKER_BATCH_PULL_QUERIES
|
||||
.read()
|
||||
.await;
|
||||
if queries.is_empty() {
|
||||
drop(queries);
|
||||
// Queries not populated yet, fall through to normal pull below
|
||||
None
|
||||
} else {
|
||||
let mut pulled = Vec::new();
|
||||
for query in queries.iter() {
|
||||
match windmill_queue::batch_pull(db, &worker_name, query, batch_pull_size)
|
||||
.await
|
||||
{
|
||||
Ok(jobs) if !jobs.is_empty() => {
|
||||
pulled = jobs;
|
||||
break;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
tracing::error!(worker = %worker_name, "batch pull error: {e:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(queries);
|
||||
if pulled.is_empty() {
|
||||
Some(Ok(None))
|
||||
} else {
|
||||
let mut iter = pulled.into_iter();
|
||||
let first = iter.next();
|
||||
for job in iter {
|
||||
batch_pull_buffer.push_back(job);
|
||||
}
|
||||
Some(Ok(first.map(|job| NextJob::Sql { flow_runners: None, job })))
|
||||
}
|
||||
}
|
||||
.unwrap_or_else(|| {
|
||||
// Fall through: queries not ready yet
|
||||
Ok(None)
|
||||
})
|
||||
} else {
|
||||
match &conn {
|
||||
Connection::Sql(db) => {
|
||||
@@ -2453,10 +2517,36 @@ pub async fn run_worker(
|
||||
}
|
||||
}
|
||||
|
||||
Connection::Http(client) => crate::agent_workers::pull_job(&client, None, None)
|
||||
.await
|
||||
.map_err(|e| error::Error::InternalErr(e.to_string()))
|
||||
.map(|x| x.map(|y| NextJob::Http(y))),
|
||||
Connection::Http(client) => {
|
||||
if batch_pull_size > 0 {
|
||||
// Agent-batch mode: serve from buffer, refill via batch pull
|
||||
if let Some(job) = agent_batch_buffer.pop_front() {
|
||||
Ok(Some(NextJob::Http(job)))
|
||||
} else {
|
||||
match crate::agent_workers::batch_pull_jobs(
|
||||
&client,
|
||||
batch_pull_size,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(mut jobs) if !jobs.is_empty() => {
|
||||
let first = jobs.remove(0);
|
||||
for job in jobs {
|
||||
agent_batch_buffer.push_back(job);
|
||||
}
|
||||
Ok(Some(NextJob::Http(first)))
|
||||
}
|
||||
Ok(_) => Ok(None),
|
||||
Err(e) => Err(error::Error::InternalErr(e.to_string())),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
crate::agent_workers::pull_job(&client, None, None)
|
||||
.await
|
||||
.map_err(|e| error::Error::InternalErr(e.to_string()))
|
||||
.map(|x| x.map(|y| NextJob::Http(y)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1651,13 +1651,33 @@ pub async fn update_flow_status_after_job_completion_internal(
|
||||
.warn_after_seconds(3)
|
||||
.await?;
|
||||
}
|
||||
if !_cleanup_module.flow_jobs_to_schedule_clean.is_empty() {
|
||||
let entries_json = serde_json::to_value(
|
||||
&_cleanup_module.flow_jobs_to_schedule_clean,
|
||||
)
|
||||
.map_err(|e| {
|
||||
error::Error::internal_err(format!(
|
||||
"Unable to serialize scheduled clean entries: {e:#}"
|
||||
))
|
||||
})?;
|
||||
sqlx::query!(
|
||||
"UPDATE v2_job_status
|
||||
SET flow_status = JSONB_SET(flow_status, ARRAY['cleanup_module', 'flow_jobs_to_schedule_clean'], COALESCE(flow_status->'cleanup_module'->'flow_jobs_to_schedule_clean', '[]'::jsonb) || $1)
|
||||
WHERE id = $2",
|
||||
entries_json,
|
||||
parent_job
|
||||
)
|
||||
.execute(db)
|
||||
.warn_after_seconds(3)
|
||||
.await?;
|
||||
}
|
||||
} else {
|
||||
// run the cleanup step only when the root job is complete
|
||||
if !_cleanup_module.flow_jobs_to_clean.is_empty() {
|
||||
tracing::debug!(
|
||||
"Cleaning up jobs arguments, result and logs as they were marked as delete_after_use {:?}",
|
||||
_cleanup_module.flow_jobs_to_clean
|
||||
);
|
||||
"Cleaning up jobs arguments, result and logs as they were marked as delete_after_use {:?}",
|
||||
_cleanup_module.flow_jobs_to_clean
|
||||
);
|
||||
sqlx::query!(
|
||||
"UPDATE v2_job SET args = '{}'::jsonb WHERE id = ANY($1)",
|
||||
&_cleanup_module.flow_jobs_to_clean,
|
||||
@@ -1677,6 +1697,92 @@ pub async fn update_flow_status_after_job_completion_internal(
|
||||
Error::internal_err(format!("error while cleaning up completed job: {e:#}"))
|
||||
})?;
|
||||
}
|
||||
|
||||
// Process scheduled deletions — insert into job_delete_schedule
|
||||
if !_cleanup_module.flow_jobs_to_schedule_clean.is_empty() {
|
||||
let w_id = &flow_job.workspace_id;
|
||||
for entry in &_cleanup_module.flow_jobs_to_schedule_clean {
|
||||
windmill_common::jobs::schedule_job_deletion(
|
||||
db,
|
||||
entry.id,
|
||||
w_id,
|
||||
entry.delete_after_secs,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Error::internal_err(format!(
|
||||
"error scheduling job deletion for {}: {e:#}",
|
||||
entry.id
|
||||
))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
// Flow-level delete_after_secs: apply to ALL child jobs + the parent flow job
|
||||
let flow_value = flow_data.value();
|
||||
let flow_delete_secs = windmill_common::jobs::resolve_delete_after_secs(
|
||||
flow_value.delete_after_use,
|
||||
flow_value.delete_after_secs,
|
||||
);
|
||||
if let Some(secs) = flow_delete_secs {
|
||||
let w_id = &flow_job.workspace_id;
|
||||
let mut all_ids: Vec<Uuid> =
|
||||
sqlx::query_scalar!("SELECT id FROM v2_job WHERE root_job = $1", flow_job.id)
|
||||
.fetch_all(db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Error::internal_err(format!(
|
||||
"error fetching child jobs for flow-level deletion: {e:#}"
|
||||
))
|
||||
})?;
|
||||
all_ids.push(flow_job.id);
|
||||
|
||||
if secs == 0 {
|
||||
sqlx::query!(
|
||||
"UPDATE v2_job SET args = '{}'::jsonb WHERE id = ANY($1)",
|
||||
&all_ids,
|
||||
)
|
||||
.execute(db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Error::internal_err(format!(
|
||||
"error while cleaning up completed job args: {e:#}"
|
||||
))
|
||||
})?;
|
||||
sqlx::query!(
|
||||
"UPDATE v2_job_completed SET result = '{}'::jsonb WHERE id = ANY($1)",
|
||||
&all_ids,
|
||||
)
|
||||
.execute(db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Error::internal_err(format!(
|
||||
"error while cleaning up completed job results: {e:#}"
|
||||
))
|
||||
})?;
|
||||
sqlx::query!(
|
||||
"UPDATE job_logs SET logs = '##DELETED##' WHERE job_id = ANY($1)",
|
||||
&all_ids,
|
||||
)
|
||||
.execute(db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Error::internal_err(format!(
|
||||
"error while cleaning up completed job logs: {e:#}"
|
||||
))
|
||||
})?;
|
||||
} else {
|
||||
for id in &all_ids {
|
||||
windmill_common::jobs::schedule_job_deletion(db, *id, w_id, secs)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Error::internal_err(format!(
|
||||
"error scheduling job deletion for {id}: {e:#}"
|
||||
))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if flow_job.is_canceled() {
|
||||
@@ -3753,21 +3859,54 @@ async fn push_next_flow_job(
|
||||
}
|
||||
}
|
||||
|
||||
if payload_tag.delete_after_use {
|
||||
let uuid_singleton_json = serde_json::to_value(&[uuid]).map_err(|e| {
|
||||
error::Error::internal_err(format!("Unable to serialize uuid: {e:#}"))
|
||||
})?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE v2_job_status
|
||||
SET flow_status = JSONB_SET(flow_status, ARRAY['cleanup_module', 'flow_jobs_to_clean'], COALESCE(flow_status->'cleanup_module'->'flow_jobs_to_clean', '[]'::jsonb) || $1)
|
||||
WHERE id = $2",
|
||||
uuid_singleton_json,
|
||||
flow_innermost_root_job.unwrap_or(flow_job.id)
|
||||
)
|
||||
.execute(&mut *inner_tx)
|
||||
.warn_after_seconds(3)
|
||||
.await?;
|
||||
{
|
||||
use windmill_common::jobs::resolve_delete_after_secs;
|
||||
let resolved = resolve_delete_after_secs(
|
||||
payload_tag.delete_after_use.then_some(true),
|
||||
payload_tag.delete_after_secs,
|
||||
);
|
||||
let root_id = flow_innermost_root_job.unwrap_or(flow_job.id);
|
||||
match resolved {
|
||||
Some(0) => {
|
||||
// Immediate deletion: track in flow_jobs_to_clean (existing behavior)
|
||||
let uuid_singleton_json = serde_json::to_value(&[uuid]).map_err(|e| {
|
||||
error::Error::internal_err(format!("Unable to serialize uuid: {e:#}"))
|
||||
})?;
|
||||
sqlx::query!(
|
||||
"UPDATE v2_job_status
|
||||
SET flow_status = JSONB_SET(flow_status, ARRAY['cleanup_module', 'flow_jobs_to_clean'], COALESCE(flow_status->'cleanup_module'->'flow_jobs_to_clean', '[]'::jsonb) || $1)
|
||||
WHERE id = $2",
|
||||
uuid_singleton_json,
|
||||
root_id
|
||||
)
|
||||
.execute(&mut *inner_tx)
|
||||
.warn_after_seconds(3)
|
||||
.await?;
|
||||
}
|
||||
Some(secs) => {
|
||||
// Scheduled deletion: track in flow_jobs_to_schedule_clean
|
||||
let entry = windmill_types::flow_status::FlowJobScheduledClean {
|
||||
id: uuid,
|
||||
delete_after_secs: secs,
|
||||
};
|
||||
let entry_json = serde_json::to_value(&[entry]).map_err(|e| {
|
||||
error::Error::internal_err(format!(
|
||||
"Unable to serialize scheduled clean entry: {e:#}"
|
||||
))
|
||||
})?;
|
||||
sqlx::query!(
|
||||
"UPDATE v2_job_status
|
||||
SET flow_status = JSONB_SET(flow_status, ARRAY['cleanup_module', 'flow_jobs_to_schedule_clean'], COALESCE(flow_status->'cleanup_module'->'flow_jobs_to_schedule_clean', '[]'::jsonb) || $1)
|
||||
WHERE id = $2",
|
||||
entry_json,
|
||||
root_id
|
||||
)
|
||||
.execute(&mut *inner_tx)
|
||||
.warn_after_seconds(3)
|
||||
.await?;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
tx = inner_tx;
|
||||
@@ -4162,6 +4301,7 @@ pub struct JobPayloadWithTag {
|
||||
pub payload: JobPayload,
|
||||
pub tag: Option<String>,
|
||||
pub delete_after_use: bool,
|
||||
pub delete_after_secs: Option<i32>,
|
||||
pub timeout: Option<i32>,
|
||||
pub on_behalf_of: Option<OnBehalfOf>,
|
||||
}
|
||||
@@ -4263,6 +4403,7 @@ async fn compute_next_flow_transform(
|
||||
payload: JobPayload::Identity,
|
||||
tag: None,
|
||||
delete_after_use: false,
|
||||
delete_after_secs: None,
|
||||
timeout: None,
|
||||
on_behalf_of: None,
|
||||
}),
|
||||
@@ -4275,6 +4416,7 @@ async fn compute_next_flow_transform(
|
||||
payload,
|
||||
tag: None,
|
||||
delete_after_use: false,
|
||||
delete_after_secs: None,
|
||||
timeout: None,
|
||||
on_behalf_of: None,
|
||||
}),
|
||||
@@ -4282,6 +4424,7 @@ async fn compute_next_flow_transform(
|
||||
))
|
||||
};
|
||||
let delete_after_use = module.delete_after_use.unwrap_or(false);
|
||||
let delete_after_secs = module.delete_after_secs;
|
||||
|
||||
tracing::debug!(id = %flow_job.id, "computing next flow transform for {:?}", &module.value);
|
||||
if is_skipped {
|
||||
@@ -4291,8 +4434,14 @@ async fn compute_next_flow_transform(
|
||||
match module.get_value()? {
|
||||
FlowModuleValue::Identity => trivial_next_job(JobPayload::Identity),
|
||||
FlowModuleValue::Flow { path, .. } => {
|
||||
let payload =
|
||||
flow_to_payload(path, delete_after_use, &flow_job.workspace_id, db).await?;
|
||||
let payload = flow_to_payload(
|
||||
path,
|
||||
delete_after_use,
|
||||
delete_after_secs,
|
||||
&flow_job.workspace_id,
|
||||
db,
|
||||
)
|
||||
.await?;
|
||||
Ok(NextFlowTransform::Continue(
|
||||
ContinuePayload::SingleJob(payload),
|
||||
NextStatus::NextStep,
|
||||
@@ -4306,6 +4455,7 @@ async fn compute_next_flow_transform(
|
||||
payload,
|
||||
tag: None,
|
||||
delete_after_use,
|
||||
delete_after_secs,
|
||||
timeout: None,
|
||||
on_behalf_of: None,
|
||||
}),
|
||||
@@ -4348,6 +4498,7 @@ async fn compute_next_flow_transform(
|
||||
module,
|
||||
tag,
|
||||
delete_after_use,
|
||||
delete_after_secs,
|
||||
);
|
||||
Ok(NextFlowTransform::Continue(
|
||||
ContinuePayload::SingleJob(payload),
|
||||
@@ -4375,6 +4526,7 @@ async fn compute_next_flow_transform(
|
||||
},
|
||||
tag: tag.clone(),
|
||||
delete_after_use,
|
||||
delete_after_secs,
|
||||
timeout: None,
|
||||
on_behalf_of: None,
|
||||
};
|
||||
@@ -4518,6 +4670,7 @@ async fn compute_next_flow_transform(
|
||||
payload,
|
||||
tag: None,
|
||||
delete_after_use,
|
||||
delete_after_secs,
|
||||
timeout: None,
|
||||
on_behalf_of: None,
|
||||
})
|
||||
@@ -4615,6 +4768,7 @@ async fn compute_next_flow_transform(
|
||||
payload,
|
||||
tag: None,
|
||||
delete_after_use,
|
||||
delete_after_secs,
|
||||
timeout: None,
|
||||
on_behalf_of: None,
|
||||
}),
|
||||
@@ -4650,6 +4804,7 @@ async fn compute_next_flow_transform(
|
||||
payload,
|
||||
tag: None,
|
||||
delete_after_use,
|
||||
delete_after_secs,
|
||||
timeout: None,
|
||||
on_behalf_of: None,
|
||||
})
|
||||
@@ -4728,6 +4883,7 @@ async fn compute_next_flow_transform(
|
||||
payload,
|
||||
tag: None,
|
||||
delete_after_use,
|
||||
delete_after_secs,
|
||||
timeout: None,
|
||||
on_behalf_of: None,
|
||||
}),
|
||||
@@ -4792,6 +4948,7 @@ async fn next_loop_iteration(
|
||||
payload,
|
||||
tag: None,
|
||||
delete_after_use,
|
||||
delete_after_secs: None,
|
||||
timeout: None,
|
||||
on_behalf_of: None,
|
||||
}),
|
||||
@@ -4986,9 +5143,17 @@ async fn payload_from_simple_module(
|
||||
inner_path: String,
|
||||
) -> Result<JobPayloadWithTag, Error> {
|
||||
let delete_after_use = module.delete_after_use.unwrap_or(false);
|
||||
let delete_after_secs = module.delete_after_secs;
|
||||
Ok(match value {
|
||||
FlowModuleValue::Flow { path, .. } => {
|
||||
flow_to_payload(path, delete_after_use, &flow_job.workspace_id, db).await?
|
||||
flow_to_payload(
|
||||
path,
|
||||
delete_after_use,
|
||||
delete_after_secs,
|
||||
&flow_job.workspace_id,
|
||||
db,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
FlowModuleValue::Script { path: script_path, hash: script_hash, tag_override, .. } => {
|
||||
script_to_payload(
|
||||
@@ -5019,6 +5184,7 @@ async fn payload_from_simple_module(
|
||||
module,
|
||||
tag,
|
||||
delete_after_use,
|
||||
delete_after_secs,
|
||||
),
|
||||
FlowModuleValue::FlowScript {
|
||||
id, // flow_node(id).
|
||||
@@ -5038,6 +5204,7 @@ async fn payload_from_simple_module(
|
||||
},
|
||||
tag,
|
||||
delete_after_use,
|
||||
delete_after_secs,
|
||||
timeout: None, // timeout evaluation handled at higher level
|
||||
on_behalf_of: None,
|
||||
},
|
||||
@@ -5054,6 +5221,7 @@ pub fn raw_script_to_payload(
|
||||
module: &FlowModule,
|
||||
tag: Option<String>,
|
||||
delete_after_use: bool,
|
||||
delete_after_secs: Option<i32>,
|
||||
) -> JobPayloadWithTag {
|
||||
JobPayloadWithTag {
|
||||
payload: JobPayload::Code(RawCode {
|
||||
@@ -5072,6 +5240,7 @@ pub fn raw_script_to_payload(
|
||||
}),
|
||||
tag,
|
||||
delete_after_use,
|
||||
delete_after_secs,
|
||||
timeout: None, // timeout evaluation handled at higher level
|
||||
on_behalf_of: None,
|
||||
}
|
||||
@@ -5080,6 +5249,7 @@ pub fn raw_script_to_payload(
|
||||
async fn flow_to_payload(
|
||||
path: String,
|
||||
delete_after_use: bool,
|
||||
delete_after_secs: Option<i32>,
|
||||
w_id: &str,
|
||||
db: &DB,
|
||||
) -> Result<JobPayloadWithTag, Error> {
|
||||
@@ -5097,7 +5267,14 @@ async fn flow_to_payload(
|
||||
version,
|
||||
labels: None,
|
||||
};
|
||||
Ok(JobPayloadWithTag { payload, tag, delete_after_use, timeout: None, on_behalf_of })
|
||||
Ok(JobPayloadWithTag {
|
||||
payload,
|
||||
tag,
|
||||
delete_after_use,
|
||||
delete_after_secs,
|
||||
timeout: None,
|
||||
on_behalf_of,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn script_to_payload(
|
||||
@@ -5114,75 +5291,80 @@ pub async fn script_to_payload(
|
||||
} else {
|
||||
tag_override
|
||||
};
|
||||
let (payload, tag, delete_after_use, script_timeout, on_behalf_of) = if script_hash.is_none() {
|
||||
let (jp, tag, delete_after_use, script_timeout, on_behalf_of) = script_path_to_payload(
|
||||
&script_path,
|
||||
None,
|
||||
db.clone(),
|
||||
&flow_job.workspace_id,
|
||||
Some(true),
|
||||
)
|
||||
.await?;
|
||||
(
|
||||
jp,
|
||||
tag_override.to_owned().or(tag),
|
||||
delete_after_use,
|
||||
script_timeout,
|
||||
on_behalf_of,
|
||||
)
|
||||
} else {
|
||||
let hash = script_hash.unwrap();
|
||||
|
||||
let ScriptHashInfo {
|
||||
tag,
|
||||
cache_ttl,
|
||||
language,
|
||||
dedicated_worker,
|
||||
priority,
|
||||
delete_after_use,
|
||||
timeout,
|
||||
on_behalf_of_email,
|
||||
created_by,
|
||||
runnable_settings:
|
||||
ScriptRunnableSettingsInline { concurrency_settings, debouncing_settings },
|
||||
..
|
||||
} = get_script_info_for_hash(None, db, &flow_job.workspace_id, hash.0)
|
||||
.await?
|
||||
.prefetch_cached(&db)
|
||||
.await?;
|
||||
|
||||
let on_behalf_of = if let Some(email) = on_behalf_of_email {
|
||||
Some(OnBehalfOf { email, permissioned_as: username_to_permissioned_as(&created_by) })
|
||||
let (payload, tag, delete_after_use, delete_after_secs, script_timeout, on_behalf_of) =
|
||||
if script_hash.is_none() {
|
||||
let (jp, tag, delete_after_use, delete_after_secs, script_timeout, on_behalf_of) =
|
||||
script_path_to_payload(
|
||||
&script_path,
|
||||
None,
|
||||
db.clone(),
|
||||
&flow_job.workspace_id,
|
||||
Some(true),
|
||||
)
|
||||
.await?;
|
||||
(
|
||||
jp,
|
||||
tag_override.to_owned().or(tag),
|
||||
delete_after_use,
|
||||
delete_after_secs,
|
||||
script_timeout,
|
||||
on_behalf_of,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(
|
||||
// We only apply the preprocessor if it's explicitly set to true in the module,
|
||||
// which can only happen if the the flow is a SingleStepFlow triggered by a trigger with retries or error handling.
|
||||
// In that case, apply_preprocessor is still only set to true if the script has a preprocesor.
|
||||
// We only check for script hash because SingleStepFlow triggers specifies the script hash
|
||||
JobPayload::ScriptHash {
|
||||
hash,
|
||||
path: script_path,
|
||||
concurrency_settings,
|
||||
debouncing_settings,
|
||||
cache_ttl: module.cache_ttl.map(|x| x as i32).ok_or(cache_ttl).ok(),
|
||||
cache_ignore_s3_path: module.cache_ignore_s3_path,
|
||||
let hash = script_hash.unwrap();
|
||||
|
||||
let ScriptHashInfo {
|
||||
tag,
|
||||
cache_ttl,
|
||||
language,
|
||||
dedicated_worker,
|
||||
priority,
|
||||
apply_preprocessor: apply_preprocessor.unwrap_or(false),
|
||||
labels: None,
|
||||
},
|
||||
tag_override.to_owned().or(tag),
|
||||
delete_after_use,
|
||||
timeout,
|
||||
on_behalf_of,
|
||||
)
|
||||
};
|
||||
// the module value overrides the value set at the script level. Defaults to false if both are unset.
|
||||
let final_delete_after_user =
|
||||
delete_after_use,
|
||||
delete_after_secs,
|
||||
timeout,
|
||||
on_behalf_of_email,
|
||||
created_by,
|
||||
runnable_settings:
|
||||
ScriptRunnableSettingsInline { concurrency_settings, debouncing_settings },
|
||||
..
|
||||
} = get_script_info_for_hash(None, db, &flow_job.workspace_id, hash.0)
|
||||
.await?
|
||||
.prefetch_cached(&db)
|
||||
.await?;
|
||||
|
||||
let on_behalf_of = if let Some(email) = on_behalf_of_email {
|
||||
Some(OnBehalfOf {
|
||||
email,
|
||||
permissioned_as: username_to_permissioned_as(&created_by),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(
|
||||
JobPayload::ScriptHash {
|
||||
hash,
|
||||
path: script_path,
|
||||
concurrency_settings,
|
||||
debouncing_settings,
|
||||
cache_ttl: module.cache_ttl.map(|x| x as i32).ok_or(cache_ttl).ok(),
|
||||
cache_ignore_s3_path: module.cache_ignore_s3_path,
|
||||
language,
|
||||
dedicated_worker,
|
||||
priority,
|
||||
apply_preprocessor: apply_preprocessor.unwrap_or(false),
|
||||
labels: None,
|
||||
},
|
||||
tag_override.to_owned().or(tag),
|
||||
delete_after_use,
|
||||
delete_after_secs,
|
||||
timeout,
|
||||
on_behalf_of,
|
||||
)
|
||||
};
|
||||
// Module-level delete_after_secs takes precedence over script-level, then fall back to booleans
|
||||
let final_delete_after_use =
|
||||
module.delete_after_use.unwrap_or(false) || delete_after_use.unwrap_or(false);
|
||||
let final_delete_after_secs = module.delete_after_secs.or(delete_after_secs);
|
||||
|
||||
let flow_step_timeout = if module.timeout.is_some() {
|
||||
None
|
||||
@@ -5192,7 +5374,8 @@ pub async fn script_to_payload(
|
||||
Ok(JobPayloadWithTag {
|
||||
payload,
|
||||
tag,
|
||||
delete_after_use: final_delete_after_user,
|
||||
delete_after_use: final_delete_after_use,
|
||||
delete_after_secs: final_delete_after_secs,
|
||||
timeout: flow_step_timeout,
|
||||
on_behalf_of,
|
||||
})
|
||||
|
||||
@@ -3,19 +3,18 @@ import { colors } from "@cliffy/ansi/colors";
|
||||
import { Input } from "@cliffy/prompt/input";
|
||||
import * as log from "../../core/log.ts";
|
||||
import { setClient } from "../../core/client.ts";
|
||||
import { allWorkspaces, list, removeWorkspace } from "./workspace.ts";
|
||||
import { allWorkspaces, list, removeWorkspace } from "./workspace.ts";
|
||||
import * as wmill from "../../../gen/services.gen.ts";
|
||||
import { getCurrentGitBranch, getOriginalBranchForWorkspaceForks, isGitRepository } from "../../utils/git.ts";
|
||||
import { WM_FORK_PREFIX } from "../../core/constants.ts";
|
||||
import { tryResolveBranchWorkspace } from "../../core/context.ts";
|
||||
|
||||
// NOTE: This import will work after regenerating the API client
|
||||
// Run ./gen_wm_client.sh to regenerate after backend changes
|
||||
// import * as wmill from "../../../gen/services.gen.ts";
|
||||
|
||||
async function createWorkspaceFork(
|
||||
opts: GlobalOptions & {
|
||||
createWorkspaceName: string | undefined;
|
||||
color: string | undefined;
|
||||
datatableBehavior: string | undefined;
|
||||
yes: boolean | undefined;
|
||||
},
|
||||
workspaceName: string | undefined,
|
||||
workspaceId: string | undefined = undefined,
|
||||
@@ -104,21 +103,139 @@ async function createWorkspaceFork(
|
||||
throw new Error(`This forked workspace '${workspaceId}' (${workspaceName}) already exists. Choose a different id`);
|
||||
}
|
||||
|
||||
// --- Datatable cloning (matches ForkDatatableSection.svelte) ---
|
||||
interface ForkedDatatableInfo {
|
||||
name: string;
|
||||
new_dbname: string;
|
||||
}
|
||||
const forkedDatatables: ForkedDatatableInfo[] = [];
|
||||
|
||||
let datatables: Awaited<ReturnType<typeof wmill.listDataTables>> = [];
|
||||
try {
|
||||
datatables = await wmill.listDataTables({
|
||||
workspace: workspace.workspaceId,
|
||||
});
|
||||
} catch (e) {
|
||||
log.info(
|
||||
colors.yellow(
|
||||
`Note: Could not list datatables: ${(e as Error).message}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (datatables && datatables.length > 0) {
|
||||
const behavior = opts.datatableBehavior ?? (opts.yes ? "skip" : undefined);
|
||||
|
||||
if (behavior !== "skip") {
|
||||
log.info(`\nFound ${datatables.length} datatable(s):`);
|
||||
|
||||
for (const dt of datatables) {
|
||||
let dtBehavior: string;
|
||||
|
||||
if (behavior === "schema_only" || behavior === "schema_and_data") {
|
||||
dtBehavior = behavior;
|
||||
} else {
|
||||
// Interactive prompt
|
||||
const { Select } = await import("@cliffy/prompt/select");
|
||||
dtBehavior = await Select.prompt({
|
||||
message: `Datatable "${dt.name}" (${dt.resource_type}):`,
|
||||
options: [
|
||||
{ name: "Keep original (no cloning)", value: "keep_original" },
|
||||
{ name: "Clone schema only", value: "schema_only" },
|
||||
{ name: "Clone schema and data", value: "schema_and_data" },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (dtBehavior === "keep_original") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const newDbName = `${trueWorkspaceId.replace(/-/g, "_")}__${dt.name}`;
|
||||
|
||||
try {
|
||||
log.info(
|
||||
colors.blue(` Creating database "${newDbName}" for datatable "${dt.name}"...`)
|
||||
);
|
||||
|
||||
await wmill.createPgDatabase({
|
||||
workspace: workspace.workspaceId,
|
||||
requestBody: {
|
||||
source: `datatable://${dt.name}`,
|
||||
target_dbname: newDbName,
|
||||
},
|
||||
});
|
||||
|
||||
log.info(
|
||||
colors.blue(
|
||||
` Importing ${dtBehavior === "schema_only" ? "schema" : "schema + data"}...`
|
||||
)
|
||||
);
|
||||
|
||||
await wmill.importPgDatabase({
|
||||
workspace: workspace.workspaceId,
|
||||
requestBody: {
|
||||
source: `datatable://${dt.name}`,
|
||||
target: `datatable://${dt.name}`,
|
||||
target_dbname_override: newDbName,
|
||||
fork_behavior: dtBehavior as "schema_only" | "schema_and_data",
|
||||
},
|
||||
});
|
||||
|
||||
log.info(colors.green(` ✓ Datatable "${dt.name}" cloned.`));
|
||||
forkedDatatables.push({ name: dt.name, new_dbname: newDbName });
|
||||
} catch (e) {
|
||||
log.info(
|
||||
colors.yellow(
|
||||
` ✗ Failed to clone datatable "${dt.name}": ${(e as Error).message}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Create git branch for fork (matches UI: createWorkspaceForkGitBranch) ---
|
||||
const forkColor = opts.color;
|
||||
try {
|
||||
const gitSyncJobIds = await wmill.createWorkspaceForkGitBranch({
|
||||
workspace: workspace.workspaceId,
|
||||
requestBody: {
|
||||
id: trueWorkspaceId,
|
||||
name: opts.createWorkspaceName ?? trueWorkspaceId,
|
||||
color: forkColor,
|
||||
},
|
||||
});
|
||||
if (gitSyncJobIds && gitSyncJobIds.length > 0) {
|
||||
log.info(
|
||||
colors.blue(
|
||||
`Git sync branch creation triggered (${gitSyncJobIds.length} job(s)). These will complete asynchronously.`
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
log.error(
|
||||
colors.red(
|
||||
`Failed to create git branch for fork: ${(e as Error).message}`
|
||||
)
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
|
||||
// --- Create the fork workspace ---
|
||||
try {
|
||||
// TODO: Update to createWorkspaceFork after regenerating client from new OpenAPI spec
|
||||
const result = await wmill.createWorkspaceFork({
|
||||
workspace: workspace.workspaceId,
|
||||
requestBody: {
|
||||
id: trueWorkspaceId,
|
||||
name: opts.createWorkspaceName ?? trueWorkspaceId,
|
||||
color: undefined,
|
||||
color: forkColor,
|
||||
forked_datatables: forkedDatatables,
|
||||
},
|
||||
});
|
||||
|
||||
log.info(colors.green(`✅ ${result}`));
|
||||
|
||||
} catch (error) {
|
||||
// If workspace creation fails, we should clean up the git branch
|
||||
log.error(
|
||||
colors.red(`Failed to create forked workspace: ${(error as Error).message}`),
|
||||
);
|
||||
@@ -135,8 +252,8 @@ async function createWorkspaceFork(
|
||||
When doing operations on the forked workspace, it will use the remote setup in gitBranches for the branch it was forked from.
|
||||
|
||||
To merge changes back to the parent workspace, you can:
|
||||
- Use the CLI: ` + colors.white(`git checkout ${newBranchName} && wmill workspace merge`) + `
|
||||
- Use the Merge UI from the forked workspace home page
|
||||
- Deploy individual items via the Deploy to staging/prod UI
|
||||
- Use git: ` + colors.white(`git checkout ${clonedBranchName} && git merge ${newBranchName} && wmill sync push`) + `
|
||||
See: https://www.windmill.dev/docs/advanced/workspace_forks`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
import { GlobalOptions } from "../../types.ts";
|
||||
import { colors } from "@cliffy/ansi/colors";
|
||||
import { Table } from "@cliffy/table";
|
||||
import * as log from "../../core/log.ts";
|
||||
import { setClient } from "../../core/client.ts";
|
||||
import { tryResolveBranchWorkspace } from "../../core/context.ts";
|
||||
import * as wmill from "../../../gen/services.gen.ts";
|
||||
import {
|
||||
deployItem,
|
||||
deleteItemInWorkspace,
|
||||
getOnBehalfOf,
|
||||
type DeployKind,
|
||||
type DeployProvider,
|
||||
} from "../../../windmill-utils-internal/src/deploy.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider adapter — wraps CLI's standalone API functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const provider: DeployProvider = {
|
||||
existsFlowByPath: wmill.existsFlowByPath,
|
||||
existsScriptByPath: wmill.existsScriptByPath,
|
||||
existsApp: wmill.existsApp,
|
||||
existsVariable: wmill.existsVariable,
|
||||
existsResource: wmill.existsResource,
|
||||
existsResourceType: wmill.existsResourceType,
|
||||
existsFolder: wmill.existsFolder,
|
||||
getFlowByPath: wmill.getFlowByPath,
|
||||
createFlow: wmill.createFlow,
|
||||
updateFlow: wmill.updateFlow,
|
||||
archiveFlowByPath: wmill.archiveFlowByPath,
|
||||
getScriptByPath: wmill.getScriptByPath,
|
||||
createScript: wmill.createScript,
|
||||
archiveScriptByPath: wmill.archiveScriptByPath,
|
||||
getAppByPath: wmill.getAppByPath,
|
||||
createApp: wmill.createApp,
|
||||
updateApp: wmill.updateApp,
|
||||
createAppRaw: wmill.createAppRaw,
|
||||
updateAppRaw: wmill.updateAppRaw,
|
||||
getPublicSecretOfLatestVersionOfApp:
|
||||
wmill.getPublicSecretOfLatestVersionOfApp,
|
||||
getRawAppData: wmill.getRawAppData,
|
||||
deleteApp: wmill.deleteApp,
|
||||
getVariable: wmill.getVariable,
|
||||
createVariable: wmill.createVariable,
|
||||
updateVariable: wmill.updateVariable,
|
||||
deleteVariable: wmill.deleteVariable,
|
||||
getResource: wmill.getResource,
|
||||
createResource: wmill.createResource,
|
||||
updateResource: wmill.updateResource,
|
||||
deleteResource: wmill.deleteResource,
|
||||
getResourceType: wmill.getResourceType,
|
||||
createResourceType: wmill.createResourceType,
|
||||
updateResourceType: wmill.updateResourceType,
|
||||
deleteResourceType: wmill.deleteResourceType,
|
||||
getFolder: wmill.getFolder,
|
||||
createFolder: wmill.createFolder,
|
||||
updateFolder: wmill.updateFolder,
|
||||
deleteFolder: wmill.deleteFolder,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main merge command
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function mergeWorkspaces(
|
||||
opts: GlobalOptions & {
|
||||
direction?: string;
|
||||
all?: boolean;
|
||||
skipConflicts?: boolean;
|
||||
include?: string;
|
||||
exclude?: string;
|
||||
preserveOnBehalfOf?: boolean;
|
||||
yes?: boolean;
|
||||
}
|
||||
): Promise<void> {
|
||||
// 1. Resolve fork workspace
|
||||
const workspace = await tryResolveBranchWorkspace(opts);
|
||||
if (!workspace) {
|
||||
throw new Error(
|
||||
"Could not resolve workspace from branch name. Make sure you are in a git repo with gitBranches configured."
|
||||
);
|
||||
}
|
||||
|
||||
const token = workspace.token;
|
||||
if (!token) {
|
||||
throw new Error("Not logged in. Please run 'wmill workspace add' first.");
|
||||
}
|
||||
|
||||
const remote = workspace.remote;
|
||||
setClient(
|
||||
token,
|
||||
remote.endsWith("/") ? remote.substring(0, remote.length - 1) : remote
|
||||
);
|
||||
|
||||
const forkWorkspaceId = workspace.workspaceId;
|
||||
|
||||
// 2. Find parent workspace
|
||||
const userWorkspaces = await wmill.listUserWorkspaces();
|
||||
const forkEntry = userWorkspaces.workspaces?.find(
|
||||
(w) => w.id === forkWorkspaceId
|
||||
);
|
||||
|
||||
if (!forkEntry?.parent_workspace_id) {
|
||||
throw new Error(
|
||||
`Workspace '${forkWorkspaceId}' is not a fork (no parent_workspace_id). ` +
|
||||
`You can only merge from a forked workspace.`
|
||||
);
|
||||
}
|
||||
|
||||
const parentWorkspaceId = forkEntry.parent_workspace_id;
|
||||
log.info(
|
||||
`Fork: ${colors.bold(forkWorkspaceId)} → Parent: ${colors.bold(parentWorkspaceId)}`
|
||||
);
|
||||
|
||||
// 3. Compare workspaces
|
||||
log.info("Comparing workspaces...");
|
||||
const comparison = await wmill.compareWorkspaces({
|
||||
workspace: parentWorkspaceId,
|
||||
targetWorkspaceId: forkWorkspaceId,
|
||||
});
|
||||
|
||||
if (comparison.skipped_comparison) {
|
||||
log.info(
|
||||
colors.yellow(
|
||||
"This fork was created before change tracking was available. " +
|
||||
"Use the UI or git-based merge instead."
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = comparison.summary;
|
||||
if (summary.total_diffs === 0) {
|
||||
log.info(colors.green("Everything is up to date. No differences found."));
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Display summary
|
||||
log.info("");
|
||||
log.info(colors.bold("Comparison Summary:"));
|
||||
|
||||
const summaryRows: string[][] = [];
|
||||
if (summary.scripts_changed > 0)
|
||||
summaryRows.push(["Scripts", String(summary.scripts_changed)]);
|
||||
if (summary.flows_changed > 0)
|
||||
summaryRows.push(["Flows", String(summary.flows_changed)]);
|
||||
if (summary.apps_changed > 0)
|
||||
summaryRows.push(["Apps", String(summary.apps_changed)]);
|
||||
if (summary.resources_changed > 0)
|
||||
summaryRows.push(["Resources", String(summary.resources_changed)]);
|
||||
if (summary.variables_changed > 0)
|
||||
summaryRows.push(["Variables", String(summary.variables_changed)]);
|
||||
if (summary.resource_types_changed > 0)
|
||||
summaryRows.push(["Resource Types", String(summary.resource_types_changed)]);
|
||||
if (summary.folders_changed > 0)
|
||||
summaryRows.push(["Folders", String(summary.folders_changed)]);
|
||||
summaryRows.push(["Total", String(summary.total_diffs)]);
|
||||
if (summary.conflicts > 0)
|
||||
summaryRows.push([
|
||||
colors.red("Conflicts"),
|
||||
colors.red(String(summary.conflicts)),
|
||||
]);
|
||||
|
||||
new Table()
|
||||
.header(["Type", "Changed"])
|
||||
.padding(2)
|
||||
.border(true)
|
||||
.body(summaryRows)
|
||||
.render();
|
||||
|
||||
// 5. Display diffs table
|
||||
const diffs = comparison.diffs.filter((d) => d.has_changes !== false);
|
||||
if (diffs.length === 0) {
|
||||
log.info(colors.green("No effective changes to deploy."));
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("");
|
||||
log.info(colors.bold("Changed items:"));
|
||||
|
||||
new Table()
|
||||
.header(["#", "Kind", "Path", "Ahead", "Behind", "Conflict"])
|
||||
.padding(1)
|
||||
.border(true)
|
||||
.body(
|
||||
diffs.map((d, i) => {
|
||||
const isConflict = d.ahead > 0 && d.behind > 0;
|
||||
return [
|
||||
String(i + 1),
|
||||
d.kind,
|
||||
d.path,
|
||||
d.ahead > 0 ? colors.green(String(d.ahead)) : "0",
|
||||
d.behind > 0 ? colors.yellow(String(d.behind)) : "0",
|
||||
isConflict ? colors.red("YES") : "",
|
||||
];
|
||||
})
|
||||
)
|
||||
.render();
|
||||
|
||||
// 6. Determine direction
|
||||
let direction: "to-parent" | "to-fork";
|
||||
if (opts.direction === "to-parent" || opts.direction === "to-fork") {
|
||||
direction = opts.direction;
|
||||
} else if (opts.direction) {
|
||||
throw new Error(
|
||||
`Invalid direction '${opts.direction}'. Use 'to-parent' or 'to-fork'.`
|
||||
);
|
||||
} else if (opts.yes) {
|
||||
direction = "to-parent";
|
||||
} else {
|
||||
const { Select } = await import("@cliffy/prompt/select");
|
||||
direction = (await Select.prompt({
|
||||
message: "Deploy direction:",
|
||||
options: [
|
||||
{
|
||||
name: `Deploy to parent (${parentWorkspaceId}) ← fork changes`,
|
||||
value: "to-parent",
|
||||
},
|
||||
{
|
||||
name: `Update fork (${forkWorkspaceId}) ← parent changes`,
|
||||
value: "to-fork",
|
||||
},
|
||||
],
|
||||
})) as "to-parent" | "to-fork";
|
||||
}
|
||||
|
||||
log.info(
|
||||
`\nDirection: ${colors.bold(direction === "to-parent" ? `Fork → Parent (${parentWorkspaceId})` : `Parent → Fork (${forkWorkspaceId})`)}`
|
||||
);
|
||||
|
||||
// 7. Filter selectable diffs based on direction
|
||||
const selectableDiffs = diffs.filter((d) => {
|
||||
if (direction === "to-parent") {
|
||||
return d.ahead > 0;
|
||||
} else {
|
||||
return d.behind > 0;
|
||||
}
|
||||
});
|
||||
|
||||
if (selectableDiffs.length === 0) {
|
||||
log.info(
|
||||
colors.yellow(`No items to deploy in the '${direction}' direction.`)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 8. Select items
|
||||
let selectedDiffs = selectableDiffs;
|
||||
|
||||
if (opts.all) {
|
||||
selectedDiffs = selectableDiffs;
|
||||
} else if (opts.skipConflicts) {
|
||||
selectedDiffs = selectableDiffs.filter(
|
||||
(d) => !(d.ahead > 0 && d.behind > 0)
|
||||
);
|
||||
} else if (opts.yes && !opts.include && !opts.exclude) {
|
||||
if (direction === "to-fork") {
|
||||
selectedDiffs = selectableDiffs.filter(
|
||||
(d) => !(d.ahead > 0 && d.behind > 0)
|
||||
);
|
||||
}
|
||||
} else if (!opts.yes) {
|
||||
const { Checkbox } = await import("@cliffy/prompt/checkbox");
|
||||
const defaultForToFork = direction === "to-fork";
|
||||
const selectedValues = await Checkbox.prompt({
|
||||
message: `Select items to deploy (${selectableDiffs.length} available):`,
|
||||
options: selectableDiffs.map((d) => {
|
||||
const isConflict = d.ahead > 0 && d.behind > 0;
|
||||
const label = `${d.kind}:${d.path}${isConflict ? colors.red(" [CONFLICT]") : ""}`;
|
||||
return {
|
||||
name: label,
|
||||
value: `${d.kind}:${d.path}`,
|
||||
checked: defaultForToFork ? !isConflict : true,
|
||||
};
|
||||
}),
|
||||
});
|
||||
selectedDiffs = selectableDiffs.filter((d) =>
|
||||
selectedValues.includes(`${d.kind}:${d.path}`)
|
||||
);
|
||||
}
|
||||
|
||||
// Apply --include filter
|
||||
if (opts.include) {
|
||||
const includeSet = new Set(opts.include.split(",").map((s) => s.trim()));
|
||||
selectedDiffs = selectedDiffs.filter((d) =>
|
||||
includeSet.has(`${d.kind}:${d.path}`)
|
||||
);
|
||||
}
|
||||
|
||||
// Apply --exclude filter
|
||||
if (opts.exclude) {
|
||||
const excludeSet = new Set(opts.exclude.split(",").map((s) => s.trim()));
|
||||
selectedDiffs = selectedDiffs.filter(
|
||||
(d) => !excludeSet.has(`${d.kind}:${d.path}`)
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedDiffs.length === 0) {
|
||||
log.info(colors.yellow("No items selected for deployment."));
|
||||
return;
|
||||
}
|
||||
|
||||
// Warn about conflicts
|
||||
const conflicts = selectedDiffs.filter(
|
||||
(d) => d.ahead > 0 && d.behind > 0
|
||||
);
|
||||
if (conflicts.length > 0) {
|
||||
log.info(
|
||||
colors.yellow(
|
||||
`\n⚠ ${conflicts.length} conflicting item(s) will be deployed (source will overwrite target):`
|
||||
)
|
||||
);
|
||||
for (const c of conflicts) {
|
||||
log.info(colors.yellow(` - ${c.kind}:${c.path}`));
|
||||
}
|
||||
if (!opts.yes) {
|
||||
const { Confirm } = await import("@cliffy/prompt/confirm");
|
||||
const proceed = await Confirm.prompt(
|
||||
"Proceed with deploying conflicting items?"
|
||||
);
|
||||
if (!proceed) {
|
||||
log.info("Aborted.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info(
|
||||
`\nDeploying ${colors.bold(String(selectedDiffs.length))} item(s)...`
|
||||
);
|
||||
|
||||
// 9. Sort: folders first
|
||||
const sorted = [...selectedDiffs].sort((a, b) => {
|
||||
const aFolder = (a.kind as string) === "folder" ? 0 : 1;
|
||||
const bFolder = (b.kind as string) === "folder" ? 0 : 1;
|
||||
return aFolder - bFolder;
|
||||
});
|
||||
|
||||
// Determine workspaceFrom and workspaceTo based on direction
|
||||
const workspaceFrom =
|
||||
direction === "to-parent" ? forkWorkspaceId : parentWorkspaceId;
|
||||
const workspaceTo =
|
||||
direction === "to-parent" ? parentWorkspaceId : forkWorkspaceId;
|
||||
|
||||
// 10. Deploy
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const diff of sorted) {
|
||||
const label = `${diff.kind}:${diff.path}`;
|
||||
|
||||
// Check if the item was deleted in the source workspace
|
||||
const itemDeletedInSource =
|
||||
direction === "to-parent"
|
||||
? diff.exists_in_fork === false
|
||||
: diff.exists_in_source === false;
|
||||
|
||||
let result;
|
||||
if (itemDeletedInSource) {
|
||||
log.info(colors.yellow(` ⌫ ${label} (removing from target)`));
|
||||
result = await deleteItemInWorkspace(
|
||||
provider,
|
||||
diff.kind as DeployKind,
|
||||
diff.path,
|
||||
workspaceTo
|
||||
);
|
||||
} else {
|
||||
let onBehalfOf: string | undefined;
|
||||
if (opts.preserveOnBehalfOf) {
|
||||
onBehalfOf = await getOnBehalfOf(
|
||||
provider,
|
||||
diff.kind as DeployKind,
|
||||
diff.path,
|
||||
workspaceFrom
|
||||
);
|
||||
}
|
||||
|
||||
result = await deployItem(
|
||||
provider,
|
||||
diff.kind as DeployKind,
|
||||
diff.path,
|
||||
workspaceFrom,
|
||||
workspaceTo,
|
||||
onBehalfOf
|
||||
);
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
log.info(colors.green(` ✓ ${label}`));
|
||||
successCount++;
|
||||
} else {
|
||||
log.info(colors.red(` ✗ ${label}: ${result.error}`));
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 11. Reset diff tally (only if items were successfully deployed)
|
||||
if (successCount > 0) {
|
||||
try {
|
||||
await wmill.resetDiffTally({
|
||||
workspace: parentWorkspaceId,
|
||||
forkWorkspaceId: forkWorkspaceId,
|
||||
});
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
|
||||
// 12. Summary
|
||||
log.info("");
|
||||
if (failCount === 0) {
|
||||
log.info(
|
||||
colors.green(
|
||||
`✅ Successfully deployed ${successCount} item(s) from ${workspaceFrom} to ${workspaceTo}.`
|
||||
)
|
||||
);
|
||||
} else {
|
||||
log.info(
|
||||
colors.yellow(
|
||||
`Deployed ${successCount} item(s), ${colors.red(String(failCount) + " failed")} from ${workspaceFrom} to ${workspaceTo}.`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { mergeWorkspaces };
|
||||
@@ -15,6 +15,7 @@ import * as log from "../../core/log.ts";
|
||||
import { setClient } from "../../core/client.ts";
|
||||
import { requireLogin } from "../../core/auth.ts";
|
||||
import { createWorkspaceFork, deleteWorkspaceFork } from "./fork.ts";
|
||||
import { mergeWorkspaces } from "./merge.ts";
|
||||
|
||||
import * as wmill from "../../../gen/services.gen.ts";
|
||||
|
||||
@@ -651,11 +652,27 @@ const command = new Command()
|
||||
"--create-workspace-name <workspace_name:string>",
|
||||
"Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id."
|
||||
)
|
||||
.option("--color <color:string>", "Workspace color (hex code, e.g. #ff0000)")
|
||||
.option(
|
||||
"--datatable-behavior <behavior:string>",
|
||||
"How to handle datatables: skip, schema_only, or schema_and_data (default: interactive prompt)"
|
||||
)
|
||||
.option("-y --yes", "Skip interactive prompts (defaults datatable behavior to 'skip')")
|
||||
.action(createWorkspaceFork as any)
|
||||
.command("delete-fork")
|
||||
.description("Delete a forked workspace and git branch")
|
||||
.arguments("<fork_name:string>")
|
||||
.option("-y --yes", "Skip confirmation prompt")
|
||||
.action(deleteWorkspaceFork as any);
|
||||
.action(deleteWorkspaceFork as any)
|
||||
.command("merge")
|
||||
.description("Compare and deploy changes between a fork and its parent workspace")
|
||||
.option("--direction <direction:string>", "Deploy direction: to-parent or to-fork")
|
||||
.option("--all", "Deploy all changed items including conflicts")
|
||||
.option("--skip-conflicts", "Skip items modified in both workspaces")
|
||||
.option("--include <items:string>", "Comma-separated kind:path items to include (e.g. script:f/test/main,flow:f/my/flow)")
|
||||
.option("--exclude <items:string>", "Comma-separated kind:path items to exclude")
|
||||
.option("--preserve-on-behalf-of", "Preserve original on_behalf_of/permissioned_as values")
|
||||
.option("-y --yes", "Non-interactive mode (deploy without prompts)")
|
||||
.action(mergeWorkspaces as any);
|
||||
|
||||
export default command;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,535 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { withTestBackend, type TestBackend } from "./test_backend.ts";
|
||||
|
||||
// =============================================================================
|
||||
// FORK & MERGE INTEGRATION TESTS
|
||||
//
|
||||
// Tests the full fork/merge cycle using a single test backend instance.
|
||||
// All sub-tests share the same backend to avoid workspace-limit issues
|
||||
// (CE limits to 2 non-admins workspaces).
|
||||
//
|
||||
// workspace_diff tracking is an EE feature (populated by git sync).
|
||||
// We manually insert rows into workspace_diff to simulate what git sync
|
||||
// does, matching the pattern in backend/.../workspace_comparison.rs.
|
||||
// =============================================================================
|
||||
|
||||
const FORK_ID = "wm-fork-merge-test";
|
||||
|
||||
async function api(
|
||||
backend: TestBackend,
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<Response> {
|
||||
return backend.apiRequest!(path, options);
|
||||
}
|
||||
|
||||
async function runSQL(backend: TestBackend, query: string): Promise<void> {
|
||||
const dbUrl =
|
||||
process.env["DATABASE_URL"] ||
|
||||
"postgres://postgres:changeme@localhost:5432";
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"psql",
|
||||
`${dbUrl}/postgres?sslmode=disable`,
|
||||
"-t",
|
||||
"-c",
|
||||
`SELECT datname FROM pg_database WHERE datname LIKE 'windmill_test_%' ORDER BY datname DESC LIMIT 1`,
|
||||
],
|
||||
{ stdout: "pipe", stderr: "pipe" }
|
||||
);
|
||||
const dbName = (await new Response(proc.stdout).text()).trim();
|
||||
await proc.exited;
|
||||
if (!dbName) throw new Error("Could not find test database");
|
||||
|
||||
const sqlProc = Bun.spawn(
|
||||
["psql", `${dbUrl}/${dbName}?sslmode=disable`, "-c", query],
|
||||
{ stdout: "pipe", stderr: "pipe" }
|
||||
);
|
||||
await sqlProc.exited;
|
||||
}
|
||||
|
||||
async function populateWorkspaceDiff(
|
||||
backend: TestBackend,
|
||||
parentWs: string,
|
||||
forkWs: string,
|
||||
diffs: Array<{ path: string; kind: string; ahead: number; behind: number }>
|
||||
): Promise<void> {
|
||||
const values = diffs
|
||||
.map(
|
||||
(d) =>
|
||||
`('${parentWs}', '${forkWs}', '${d.path}', '${d.kind}', ${d.ahead}, ${d.behind})`
|
||||
)
|
||||
.join(",\n");
|
||||
await runSQL(
|
||||
backend,
|
||||
`INSERT INTO workspace_diff (source_workspace_id, fork_workspace_id, path, kind, ahead, behind)
|
||||
VALUES ${values}
|
||||
ON CONFLICT (source_workspace_id, fork_workspace_id, path, kind)
|
||||
DO UPDATE SET ahead = EXCLUDED.ahead, behind = EXCLUDED.behind, has_changes = NULL`
|
||||
);
|
||||
}
|
||||
|
||||
async function removeFromSkipTally(backend: TestBackend, workspaceId: string) {
|
||||
await runSQL(backend, `DELETE FROM skip_workspace_diff_tally WHERE workspace_id = '${workspaceId}'`);
|
||||
}
|
||||
|
||||
async function deleteFork(backend: TestBackend, forkId: string) {
|
||||
try {
|
||||
await api(backend, `/api/w/${forkId}/workspaces/delete`, { method: "POST" });
|
||||
} catch {}
|
||||
// Force-clean via SQL to ensure workspace slot is freed (CE limits to 2)
|
||||
const esc = forkId.replace(/'/g, "''");
|
||||
await runSQL(backend, `
|
||||
SET session_replication_role = replica;
|
||||
DO $$ DECLARE r RECORD; BEGIN
|
||||
FOR r IN SELECT c.table_name FROM information_schema.columns c
|
||||
JOIN information_schema.tables t ON c.table_name = t.table_name AND c.table_schema = t.table_schema
|
||||
WHERE c.column_name = 'workspace_id' AND c.table_schema = 'public' AND t.table_type = 'BASE TABLE'
|
||||
GROUP BY c.table_name
|
||||
LOOP EXECUTE format('DELETE FROM %I WHERE workspace_id = ''${esc}''', r.table_name);
|
||||
END LOOP;
|
||||
END $$;
|
||||
DELETE FROM workspace WHERE id = '${esc}';
|
||||
DELETE FROM workspace_diff WHERE fork_workspace_id = '${esc}';
|
||||
DELETE FROM skip_workspace_diff_tally WHERE workspace_id = '${esc}';
|
||||
SET session_replication_role = DEFAULT;
|
||||
`);
|
||||
}
|
||||
|
||||
async function createTestItems(backend: TestBackend, workspace: string) {
|
||||
await api(backend, `/api/w/${workspace}/folders/create`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: "merge_test" }),
|
||||
});
|
||||
await api(backend, `/api/w/${workspace}/scripts/create`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
path: "f/merge_test/script_a",
|
||||
content: "export function main() { return 'parent v1'; }",
|
||||
language: "bun",
|
||||
summary: "Script A",
|
||||
schema: { type: "object", properties: {}, required: [] },
|
||||
}),
|
||||
});
|
||||
await api(backend, `/api/w/${workspace}/variables/create`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "f/merge_test/var_a", value: "parent_value", is_secret: false, description: "Variable A" }),
|
||||
});
|
||||
await api(backend, `/api/w/${workspace}/resources/type/create`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: "merge_test_type", schema: { type: "object" }, description: "Test type" }),
|
||||
});
|
||||
await api(backend, `/api/w/${workspace}/resources/create`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "f/merge_test/resource_a", resource_type: "merge_test_type", value: { key: "parent" }, description: "Resource A" }),
|
||||
});
|
||||
}
|
||||
|
||||
async function createFork(backend: TestBackend, parentWs: string, forkId: string, color?: string) {
|
||||
const r = await api(backend, `/api/w/${parentWs}/workspaces/create_fork`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id: forkId, name: "Test Fork", color, forked_datatables: [] }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const err = await r.text();
|
||||
throw new Error(`Fork creation failed: ${r.status} ${err}`);
|
||||
}
|
||||
await removeFromSkipTally(backend, forkId);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// All fork/merge sub-tests run inside a single withTestBackend to share
|
||||
// the backend instance and avoid CE workspace limits.
|
||||
// =====================================================================
|
||||
test(
|
||||
"Fork/Merge: full cycle integration tests",
|
||||
async () => {
|
||||
await withTestBackend(async (backend, _tempDir) => {
|
||||
const parentWs = backend.workspace;
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Sub-test 1: Deploy changes from fork to parent
|
||||
// ---------------------------------------------------------------
|
||||
console.log("\n--- Sub-test 1: fork→parent deploy ---");
|
||||
await deleteFork(backend, FORK_ID);
|
||||
await createTestItems(backend, parentWs);
|
||||
await createFork(backend, parentWs, FORK_ID, "#ff5500");
|
||||
|
||||
// Make changes in fork
|
||||
const forkScript = await (await api(backend, `/api/w/${FORK_ID}/scripts/get/p/f/merge_test/script_a`)).json();
|
||||
await api(backend, `/api/w/${FORK_ID}/scripts/create`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...forkScript, content: "export function main() { return 'fork v2 - modified!'; }", summary: "Script A (fork)", parent_hash: forkScript.hash }),
|
||||
});
|
||||
await api(backend, `/api/w/${FORK_ID}/variables/create`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "f/merge_test/new_fork_var", value: "fork_only", is_secret: false, description: "New from fork" }),
|
||||
});
|
||||
await api(backend, `/api/w/${FORK_ID}/resources/update/f/merge_test/resource_a`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "f/merge_test/resource_a", value: { key: "fork_modified" }, description: "Resource A (fork)" }),
|
||||
});
|
||||
|
||||
// Populate workspace_diff
|
||||
await populateWorkspaceDiff(backend, parentWs, FORK_ID, [
|
||||
{ path: "f/merge_test/script_a", kind: "script", ahead: 1, behind: 0 },
|
||||
{ path: "f/merge_test/new_fork_var", kind: "variable", ahead: 1, behind: 0 },
|
||||
{ path: "f/merge_test/resource_a", kind: "resource", ahead: 1, behind: 0 },
|
||||
]);
|
||||
|
||||
// Compare
|
||||
const comp1 = await (await api(backend, `/api/w/${parentWs}/workspaces/compare/${FORK_ID}`)).json();
|
||||
expect(comp1.skipped_comparison).toBe(false);
|
||||
expect(comp1.summary.total_diffs).toBeGreaterThanOrEqual(3);
|
||||
expect(comp1.summary.conflicts).toBe(0);
|
||||
|
||||
// Deploy fork→parent
|
||||
for (const diff of comp1.diffs.filter((d: any) => d.ahead > 0)) {
|
||||
const { kind, path } = diff;
|
||||
if (kind === "script") {
|
||||
const s = await (await api(backend, `/api/w/${FORK_ID}/scripts/get/p/${path}`)).json();
|
||||
let parentHash;
|
||||
try { parentHash = (await (await api(backend, `/api/w/${parentWs}/scripts/get/p/${path}`)).json()).hash; } catch {}
|
||||
expect((await api(backend, `/api/w/${parentWs}/scripts/create`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...s, lock: s.lock, parent_hash: parentHash }),
|
||||
})).ok).toBe(true);
|
||||
} else if (kind === "variable") {
|
||||
const v = await (await api(backend, `/api/w/${FORK_ID}/variables/get/${path}?decrypt_secret=true`)).json();
|
||||
const exists = await (await api(backend, `/api/w/${parentWs}/variables/exists/${path}`)).json();
|
||||
if (exists) {
|
||||
await api(backend, `/api/w/${parentWs}/variables/update/${path}?already_encrypted=false`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path, value: v.value ?? "", is_secret: v.is_secret, description: v.description ?? "" }),
|
||||
});
|
||||
} else {
|
||||
await api(backend, `/api/w/${parentWs}/variables/create`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path, value: v.value ?? "", is_secret: v.is_secret, description: v.description ?? "" }),
|
||||
});
|
||||
}
|
||||
} else if (kind === "resource") {
|
||||
const res = await (await api(backend, `/api/w/${FORK_ID}/resources/get/${path}`)).json();
|
||||
const exists = await (await api(backend, `/api/w/${parentWs}/resources/exists/${path}`)).json();
|
||||
if (exists) {
|
||||
await api(backend, `/api/w/${parentWs}/resources/update/${path}`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path, value: res.value, description: res.description ?? "" }),
|
||||
});
|
||||
} else {
|
||||
await api(backend, `/api/w/${parentWs}/resources/create`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path, value: res.value, resource_type: res.resource_type, description: res.description ?? "" }),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify
|
||||
expect((await (await api(backend, `/api/w/${parentWs}/scripts/get/p/f/merge_test/script_a`)).json()).content).toContain("fork v2");
|
||||
expect((await api(backend, `/api/w/${parentWs}/variables/get/f/merge_test/new_fork_var`)).ok).toBe(true);
|
||||
expect(JSON.stringify((await (await api(backend, `/api/w/${parentWs}/resources/get/f/merge_test/resource_a`)).json()).value)).toContain("fork_modified");
|
||||
console.log(" ✓ Sub-test 1 passed: fork→parent deploy");
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Sub-test 2: Deploy parent→fork direction
|
||||
// ---------------------------------------------------------------
|
||||
console.log("\n--- Sub-test 2: parent→fork deploy ---");
|
||||
|
||||
// Create new script in parent
|
||||
await api(backend, `/api/w/${parentWs}/scripts/create`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "f/merge_test/script_b", content: "export function main() { return 'parent only'; }", language: "bun", summary: "Script B", schema: { type: "object", properties: {}, required: [] } }),
|
||||
});
|
||||
|
||||
await populateWorkspaceDiff(backend, parentWs, FORK_ID, [
|
||||
{ path: "f/merge_test/script_b", kind: "script", ahead: 0, behind: 1 },
|
||||
]);
|
||||
|
||||
const comp2 = await (await api(backend, `/api/w/${parentWs}/workspaces/compare/${FORK_ID}`)).json();
|
||||
const behindDiffs = comp2.diffs.filter((d: any) => d.behind > 0);
|
||||
expect(behindDiffs.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Deploy parent→fork
|
||||
for (const diff of behindDiffs) {
|
||||
if (diff.kind === "script") {
|
||||
const s = await (await api(backend, `/api/w/${parentWs}/scripts/get/p/${diff.path}`)).json();
|
||||
let forkHash;
|
||||
try { forkHash = (await (await api(backend, `/api/w/${FORK_ID}/scripts/get/p/${diff.path}`)).json()).hash; } catch {}
|
||||
await api(backend, `/api/w/${FORK_ID}/scripts/create`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...s, lock: s.lock, parent_hash: forkHash }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
expect((await api(backend, `/api/w/${FORK_ID}/scripts/get/p/f/merge_test/script_b`)).ok).toBe(true);
|
||||
console.log(" ✓ Sub-test 2 passed: parent→fork deploy");
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Sub-test 3: Conflict detection
|
||||
// ---------------------------------------------------------------
|
||||
console.log("\n--- Sub-test 3: conflict detection ---");
|
||||
|
||||
// Create actual divergence: modify script_a differently in both workspaces
|
||||
const parentScriptA = await (await api(backend, `/api/w/${parentWs}/scripts/get/p/f/merge_test/script_a`)).json();
|
||||
await api(backend, `/api/w/${parentWs}/scripts/create`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...parentScriptA, content: "export function main() { return 'parent conflict version'; }", parent_hash: parentScriptA.hash }),
|
||||
});
|
||||
|
||||
const forkScriptA = await (await api(backend, `/api/w/${FORK_ID}/scripts/get/p/f/merge_test/script_a`)).json();
|
||||
await api(backend, `/api/w/${FORK_ID}/scripts/create`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...forkScriptA, content: "export function main() { return 'fork conflict version'; }", parent_hash: forkScriptA.hash }),
|
||||
});
|
||||
|
||||
// Now populate the conflict diff
|
||||
await populateWorkspaceDiff(backend, parentWs, FORK_ID, [
|
||||
{ path: "f/merge_test/script_a", kind: "script", ahead: 2, behind: 1 },
|
||||
]);
|
||||
|
||||
const comp3 = await (await api(backend, `/api/w/${parentWs}/workspaces/compare/${FORK_ID}`)).json();
|
||||
expect(comp3.summary.conflicts).toBeGreaterThanOrEqual(1);
|
||||
const conflict = comp3.diffs.find((d: any) => d.path === "f/merge_test/script_a" && d.ahead > 0 && d.behind > 0);
|
||||
expect(conflict).toBeDefined();
|
||||
console.log(" ✓ Sub-test 3 passed: conflict detected");
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Sub-test 4: Fork has correct parent_workspace_id
|
||||
// ---------------------------------------------------------------
|
||||
console.log("\n--- Sub-test 4: parent_workspace_id ---");
|
||||
// Check parent_workspace_id via direct SQL (no REST endpoint exposes this directly)
|
||||
const dbUrl = process.env["DATABASE_URL"] || "postgres://postgres:changeme@localhost:5432";
|
||||
const dbProc = Bun.spawn(
|
||||
["psql", `${dbUrl}/postgres?sslmode=disable`, "-t", "-c",
|
||||
`SELECT datname FROM pg_database WHERE datname LIKE 'windmill_test_%' ORDER BY datname DESC LIMIT 1`],
|
||||
{ stdout: "pipe", stderr: "pipe" }
|
||||
);
|
||||
const testDb = (await new Response(dbProc.stdout).text()).trim();
|
||||
await dbProc.exited;
|
||||
const parentProc = Bun.spawn(
|
||||
["psql", `${dbUrl}/${testDb}?sslmode=disable`, "-t", "-c",
|
||||
`SELECT parent_workspace_id FROM workspace WHERE id = '${FORK_ID}'`],
|
||||
{ stdout: "pipe", stderr: "pipe" }
|
||||
);
|
||||
const parentId = (await new Response(parentProc.stdout).text()).trim();
|
||||
await parentProc.exited;
|
||||
expect(parentId).toBe(parentWs);
|
||||
console.log(" ✓ Sub-test 4 passed: parent_workspace_id correct");
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Sub-test 5: resetDiffTally cleans unchanged items
|
||||
// ---------------------------------------------------------------
|
||||
console.log("\n--- Sub-test 5: resetDiffTally ---");
|
||||
|
||||
// Add a diff for an item that hasn't actually changed (var_a was deployed already)
|
||||
await populateWorkspaceDiff(backend, parentWs, FORK_ID, [
|
||||
{ path: "f/merge_test/var_a", kind: "variable", ahead: 1, behind: 0 },
|
||||
]);
|
||||
|
||||
const resetResp = await api(backend, `/api/w/${parentWs}/workspaces/reset_diff_tally/${FORK_ID}`, { method: "POST" });
|
||||
expect(resetResp.ok).toBe(true);
|
||||
|
||||
const comp5 = await (await api(backend, `/api/w/${parentWs}/workspaces/compare/${FORK_ID}`)).json();
|
||||
const varDiff = comp5.diffs.find((d: any) => d.path === "f/merge_test/var_a" && d.kind === "variable");
|
||||
// var_a was already deployed (same value in both), so it should be cleaned up
|
||||
expect(varDiff).toBeUndefined();
|
||||
console.log(" ✓ Sub-test 5 passed: resetDiffTally cleaned unchanged items");
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Sub-test 6: All item types in one merge
|
||||
// ---------------------------------------------------------------
|
||||
console.log("\n--- Sub-test 6: all item types (script, variable, resource, resource_type, flow, app) ---");
|
||||
await deleteFork(backend, FORK_ID);
|
||||
|
||||
// Create resource type + resource in parent
|
||||
await api(backend, `/api/w/${parentWs}/resources/type/create`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: "merge_test_type", schema: { type: "object" }, description: "Type" }),
|
||||
});
|
||||
await api(backend, `/api/w/${parentWs}/resources/create`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "f/merge_test/res_t6", resource_type: "merge_test_type", value: { key: "parent" }, description: "R" }),
|
||||
});
|
||||
// Create flow in parent
|
||||
await api(backend, `/api/w/${parentWs}/flows/create`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
path: "f/merge_test/flow_t6", summary: "Flow", schema: { type: "object", properties: {}, required: [] },
|
||||
value: { modules: [{ id: "a", value: { type: "rawscript", content: "export function main() { return 1; }", language: "bun", input_transforms: {} } }], failure_module: null, same_worker: false },
|
||||
}),
|
||||
});
|
||||
// Create app in parent
|
||||
await api(backend, `/api/w/${parentWs}/apps/create`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
path: "f/merge_test/app_t6", summary: "App",
|
||||
value: { type: "rawscript", content: { v: 1 } },
|
||||
policy: { on_behalf_of: "", on_behalf_of_email: "", extra_perms: {}, execution_mode: "publisher" },
|
||||
}),
|
||||
});
|
||||
|
||||
await createFork(backend, parentWs, FORK_ID);
|
||||
|
||||
// Modify all in fork
|
||||
const forkRes = await (await api(backend, `/api/w/${FORK_ID}/resources/get/f/merge_test/res_t6`)).json();
|
||||
await api(backend, `/api/w/${FORK_ID}/resources/update/f/merge_test/res_t6`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: forkRes.path, value: { key: "fork" }, description: "Fork" }),
|
||||
});
|
||||
|
||||
const forkFlow = await (await api(backend, `/api/w/${FORK_ID}/flows/get/f/merge_test/flow_t6`)).json();
|
||||
forkFlow.value.modules[0].value.content = "export function main() { return 2; }";
|
||||
await api(backend, `/api/w/${FORK_ID}/flows/update/f/merge_test/flow_t6`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(forkFlow),
|
||||
});
|
||||
|
||||
const forkApp = await (await api(backend, `/api/w/${FORK_ID}/apps/get/p/f/merge_test/app_t6`)).json();
|
||||
await api(backend, `/api/w/${FORK_ID}/apps/update/f/merge_test/app_t6`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...forkApp, summary: "Fork App" }),
|
||||
});
|
||||
|
||||
await populateWorkspaceDiff(backend, parentWs, FORK_ID, [
|
||||
{ path: "f/merge_test/res_t6", kind: "resource", ahead: 1, behind: 0 },
|
||||
{ path: "f/merge_test/flow_t6", kind: "flow", ahead: 1, behind: 0 },
|
||||
{ path: "f/merge_test/app_t6", kind: "app", ahead: 1, behind: 0 },
|
||||
]);
|
||||
|
||||
const comp6 = await (await api(backend, `/api/w/${parentWs}/workspaces/compare/${FORK_ID}`)).json();
|
||||
const ahead6 = comp6.diffs.filter((d: any) => d.ahead > 0);
|
||||
expect(ahead6.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// Deploy all
|
||||
for (const d of ahead6) {
|
||||
if (d.kind === "resource") {
|
||||
const r = await (await api(backend, `/api/w/${FORK_ID}/resources/get/${d.path}`)).json();
|
||||
await api(backend, `/api/w/${parentWs}/resources/update/${d.path}`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: d.path, value: r.value, description: r.description ?? "" }),
|
||||
});
|
||||
} else if (d.kind === "flow") {
|
||||
const f = await (await api(backend, `/api/w/${FORK_ID}/flows/get/${d.path}`)).json();
|
||||
for (const m of f.value?.modules ?? []) { if (m.value?.hash) m.value.hash = undefined; }
|
||||
await api(backend, `/api/w/${parentWs}/flows/update/${d.path}`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(f),
|
||||
});
|
||||
} else if (d.kind === "app") {
|
||||
const a = await (await api(backend, `/api/w/${FORK_ID}/apps/get/p/${d.path}`)).json();
|
||||
await api(backend, `/api/w/${parentWs}/apps/update/${d.path}`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(a),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Verify
|
||||
const pRes = await (await api(backend, `/api/w/${parentWs}/resources/get/f/merge_test/res_t6`)).json();
|
||||
expect(JSON.stringify(pRes.value)).toContain("fork");
|
||||
const pFlow = await (await api(backend, `/api/w/${parentWs}/flows/get/f/merge_test/flow_t6`)).json();
|
||||
expect(pFlow.value.modules[0].value.content).toContain("return 2");
|
||||
const pApp = await (await api(backend, `/api/w/${parentWs}/apps/get/p/f/merge_test/app_t6`)).json();
|
||||
expect(pApp.summary).toBe("Fork App");
|
||||
console.log(" ✓ Sub-test 6 passed: all item types merged");
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Sub-test 7: Secret variables preserved across fork/merge
|
||||
// ---------------------------------------------------------------
|
||||
console.log("\n--- Sub-test 7: secret variable ---");
|
||||
await api(backend, `/api/w/${FORK_ID}/variables/create`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "f/merge_test/secret_fork", value: "s3cret!", is_secret: true, description: "Secret" }),
|
||||
});
|
||||
await populateWorkspaceDiff(backend, parentWs, FORK_ID, [
|
||||
{ path: "f/merge_test/secret_fork", kind: "variable", ahead: 1, behind: 0 },
|
||||
]);
|
||||
const comp7 = await (await api(backend, `/api/w/${parentWs}/workspaces/compare/${FORK_ID}`)).json();
|
||||
const secDiff = comp7.diffs.find((d: any) => d.path === "f/merge_test/secret_fork");
|
||||
expect(secDiff).toBeDefined();
|
||||
|
||||
const secVar = await (await api(backend, `/api/w/${FORK_ID}/variables/get/f/merge_test/secret_fork?decrypt_secret=true`)).json();
|
||||
await api(backend, `/api/w/${parentWs}/variables/create`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: secVar.path, value: secVar.value ?? "", is_secret: secVar.is_secret, description: secVar.description ?? "" }),
|
||||
});
|
||||
const pSec = await (await api(backend, `/api/w/${parentWs}/variables/get/f/merge_test/secret_fork?decrypt_secret=true`)).json();
|
||||
expect(pSec.value).toBe("s3cret!");
|
||||
expect(pSec.is_secret).toBe(true);
|
||||
console.log(" ✓ Sub-test 7 passed: secret variable preserved");
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Sub-test 8: Special characters in variable values
|
||||
// ---------------------------------------------------------------
|
||||
console.log("\n--- Sub-test 8: special characters ---");
|
||||
await api(backend, `/api/w/${FORK_ID}/variables/create`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "f/merge_test/special", value: "hello\nworld\t\"quotes\" 'single' \\back 日本語 🎉", is_secret: false, description: "" }),
|
||||
});
|
||||
await populateWorkspaceDiff(backend, parentWs, FORK_ID, [
|
||||
{ path: "f/merge_test/special", kind: "variable", ahead: 1, behind: 0 },
|
||||
]);
|
||||
const forkSpecial = await (await api(backend, `/api/w/${FORK_ID}/variables/get/f/merge_test/special?decrypt_secret=true`)).json();
|
||||
await api(backend, `/api/w/${parentWs}/variables/create`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: forkSpecial.path, value: forkSpecial.value ?? "", is_secret: false, description: "" }),
|
||||
});
|
||||
const pSpecial = await (await api(backend, `/api/w/${parentWs}/variables/get/f/merge_test/special?decrypt_secret=true`)).json();
|
||||
expect(pSpecial.value).toBe(forkSpecial.value);
|
||||
console.log(" ✓ Sub-test 8 passed: special characters preserved");
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Sub-test 9: Partial deploy — only deployed items cleaned by resetDiffTally
|
||||
// ---------------------------------------------------------------
|
||||
console.log("\n--- Sub-test 9: partial deploy + resetDiffTally ---");
|
||||
await api(backend, `/api/w/${FORK_ID}/variables/create`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "f/merge_test/partial_a", value: "a", is_secret: false, description: "A" }),
|
||||
});
|
||||
await api(backend, `/api/w/${FORK_ID}/variables/create`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "f/merge_test/partial_b", value: "b", is_secret: false, description: "B" }),
|
||||
});
|
||||
await populateWorkspaceDiff(backend, parentWs, FORK_ID, [
|
||||
{ path: "f/merge_test/partial_a", kind: "variable", ahead: 1, behind: 0 },
|
||||
{ path: "f/merge_test/partial_b", kind: "variable", ahead: 1, behind: 0 },
|
||||
]);
|
||||
|
||||
// Deploy only partial_a
|
||||
const va = await (await api(backend, `/api/w/${FORK_ID}/variables/get/f/merge_test/partial_a?decrypt_secret=true`)).json();
|
||||
await api(backend, `/api/w/${parentWs}/variables/create`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: va.path, value: va.value ?? "", is_secret: false, description: "" }),
|
||||
});
|
||||
|
||||
await api(backend, `/api/w/${parentWs}/workspaces/reset_diff_tally/${FORK_ID}`, { method: "POST" });
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
const comp9 = await (await api(backend, `/api/w/${parentWs}/workspaces/compare/${FORK_ID}`)).json();
|
||||
const bAfter = comp9.diffs.find((d: any) => d.path === "f/merge_test/partial_b");
|
||||
// partial_b was NOT deployed, so it must still appear in diffs
|
||||
expect(bAfter).toBeDefined();
|
||||
expect(bAfter?.ahead).toBeGreaterThan(0);
|
||||
console.log(" ✓ Sub-test 9 passed: partial deploy + resetDiffTally");
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Cleanup
|
||||
// ---------------------------------------------------------------
|
||||
await deleteFork(backend, FORK_ID);
|
||||
console.log("\n✅ All sub-tests passed!");
|
||||
});
|
||||
},
|
||||
300_000
|
||||
);
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "windmill-utils-internal",
|
||||
"version": "1.3.7",
|
||||
"version": "1.3.8",
|
||||
"description": "Internal utility functions for Windmill",
|
||||
"main": "dist/cjs/index.js",
|
||||
"module": "dist/esm/index.js",
|
||||
|
||||
@@ -0,0 +1,624 @@
|
||||
/**
|
||||
* Shared deploy logic for workspace fork/merge operations.
|
||||
*
|
||||
* Used by both the CLI (`wmill workspace merge`) and the frontend
|
||||
* (`CompareWorkspaces.svelte`). The caller provides a {@link DeployProvider}
|
||||
* that wraps the concrete API client (class-based for the frontend,
|
||||
* standalone functions for the CLI).
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type DeployKind =
|
||||
| "script"
|
||||
| "flow"
|
||||
| "app"
|
||||
| "raw_app"
|
||||
| "resource"
|
||||
| "variable"
|
||||
| "resource_type"
|
||||
| "folder";
|
||||
|
||||
export interface DeployResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstraction over the generated API client.
|
||||
* Both the frontend (class-based services) and the CLI (standalone functions)
|
||||
* can satisfy this interface with a thin adapter.
|
||||
*/
|
||||
export interface DeployProvider {
|
||||
// Existence checks
|
||||
existsFlowByPath(p: { workspace: string; path: string }): Promise<boolean>;
|
||||
existsScriptByPath(p: { workspace: string; path: string }): Promise<boolean>;
|
||||
existsApp(p: { workspace: string; path: string }): Promise<boolean>;
|
||||
existsVariable(p: { workspace: string; path: string }): Promise<boolean>;
|
||||
existsResource(p: { workspace: string; path: string }): Promise<boolean>;
|
||||
existsResourceType(p: { workspace: string; path: string }): Promise<boolean>;
|
||||
existsFolder(p: { workspace: string; name: string }): Promise<boolean>;
|
||||
// Flows
|
||||
getFlowByPath(p: { workspace: string; path: string }): Promise<any>;
|
||||
createFlow(p: { workspace: string; requestBody: any }): Promise<any>;
|
||||
updateFlow(p: {
|
||||
workspace: string;
|
||||
path: string;
|
||||
requestBody: any;
|
||||
}): Promise<any>;
|
||||
archiveFlowByPath(p: {
|
||||
workspace: string;
|
||||
path: string;
|
||||
requestBody: any;
|
||||
}): Promise<any>;
|
||||
// Scripts
|
||||
getScriptByPath(p: { workspace: string; path: string }): Promise<any>;
|
||||
createScript(p: { workspace: string; requestBody: any }): Promise<any>;
|
||||
archiveScriptByPath(p: {
|
||||
workspace: string;
|
||||
path: string;
|
||||
}): Promise<any>;
|
||||
// Apps
|
||||
getAppByPath(p: { workspace: string; path: string }): Promise<any>;
|
||||
createApp(p: { workspace: string; requestBody: any }): Promise<any>;
|
||||
updateApp(p: {
|
||||
workspace: string;
|
||||
path: string;
|
||||
requestBody: any;
|
||||
}): Promise<any>;
|
||||
createAppRaw(p: { workspace: string; formData: any }): Promise<any>;
|
||||
updateAppRaw(p: {
|
||||
workspace: string;
|
||||
path: string;
|
||||
formData: any;
|
||||
}): Promise<any>;
|
||||
getPublicSecretOfLatestVersionOfApp(p: {
|
||||
workspace: string;
|
||||
path: string;
|
||||
}): Promise<any>;
|
||||
getRawAppData(p: {
|
||||
secretWithExtension: string;
|
||||
workspace: string;
|
||||
}): Promise<any>;
|
||||
deleteApp(p: { workspace: string; path: string }): Promise<any>;
|
||||
// Variables
|
||||
getVariable(p: {
|
||||
workspace: string;
|
||||
path: string;
|
||||
decryptSecret?: boolean;
|
||||
}): Promise<any>;
|
||||
createVariable(p: { workspace: string; requestBody: any }): Promise<any>;
|
||||
updateVariable(p: {
|
||||
workspace: string;
|
||||
path: string;
|
||||
requestBody: any;
|
||||
alreadyEncrypted?: boolean;
|
||||
}): Promise<any>;
|
||||
deleteVariable(p: { workspace: string; path: string }): Promise<any>;
|
||||
// Resources
|
||||
getResource(p: { workspace: string; path: string }): Promise<any>;
|
||||
createResource(p: { workspace: string; requestBody: any }): Promise<any>;
|
||||
updateResource(p: {
|
||||
workspace: string;
|
||||
path: string;
|
||||
requestBody: any;
|
||||
}): Promise<any>;
|
||||
deleteResource(p: { workspace: string; path: string }): Promise<any>;
|
||||
// Resource types
|
||||
getResourceType(p: { workspace: string; path: string }): Promise<any>;
|
||||
createResourceType(p: { workspace: string; requestBody: any }): Promise<any>;
|
||||
updateResourceType(p: {
|
||||
workspace: string;
|
||||
path: string;
|
||||
requestBody: any;
|
||||
}): Promise<any>;
|
||||
deleteResourceType(p: { workspace: string; path: string }): Promise<any>;
|
||||
// Folders
|
||||
getFolder(p: { workspace: string; name: string }): Promise<any>;
|
||||
createFolder(p: { workspace: string; requestBody: any }): Promise<any>;
|
||||
updateFolder(p: {
|
||||
workspace: string;
|
||||
name: string;
|
||||
requestBody: any;
|
||||
}): Promise<any>;
|
||||
deleteFolder(p: { workspace: string; name: string }): Promise<any>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Folder diff paths carry the `f/` prefix; folder API endpoints expect just the name. */
|
||||
export function folderName(path: string): string {
|
||||
return path.replace(/^f\//, "");
|
||||
}
|
||||
|
||||
function getSubModules(flowModule: any): any[][] {
|
||||
const type = flowModule?.value?.type;
|
||||
if (type === "forloopflow" || type === "whileloopflow") {
|
||||
return [flowModule.value.modules ?? []];
|
||||
} else if (type === "branchall") {
|
||||
return (flowModule.value.branches ?? []).map(
|
||||
(branch: any) => branch.modules ?? []
|
||||
);
|
||||
} else if (type === "branchone") {
|
||||
return [
|
||||
...(flowModule.value.branches ?? []).map((b: any) => b.modules ?? []),
|
||||
flowModule.value.default ?? [],
|
||||
];
|
||||
} else if (type === "aiagent") {
|
||||
if (flowModule.value.tools) {
|
||||
return [
|
||||
flowModule.value.tools
|
||||
.filter(
|
||||
(t: any) =>
|
||||
t.value?.type === "script" || t.value?.type === "flow"
|
||||
)
|
||||
.map((t: any) => ({
|
||||
id: t.id,
|
||||
value: t.value,
|
||||
summary: t.summary,
|
||||
})),
|
||||
];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function getAllSubmodules(flowModule: any): any[] {
|
||||
return getSubModules(flowModule)
|
||||
.map((modules) => modules.flatMap((m: any) => [m, ...getAllSubmodules(m)]))
|
||||
.flat();
|
||||
}
|
||||
|
||||
/** Recursively collect all modules from a flow definition, including the failure module. */
|
||||
export function getAllModules(
|
||||
flowModules: any[],
|
||||
failureModule?: any
|
||||
): any[] {
|
||||
return [
|
||||
...flowModules,
|
||||
...flowModules.flatMap((x) => getAllSubmodules(x)),
|
||||
...(failureModule ? [failureModule] : []),
|
||||
];
|
||||
}
|
||||
|
||||
function toError(e: unknown): string {
|
||||
const err = e as { body?: string; message?: string };
|
||||
return err.body || err.message || String(e);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// checkItemExists
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function checkItemExists(
|
||||
provider: DeployProvider,
|
||||
kind: DeployKind,
|
||||
path: string,
|
||||
workspace: string
|
||||
): Promise<boolean> {
|
||||
if (kind === "flow") {
|
||||
return provider.existsFlowByPath({ workspace, path });
|
||||
} else if (kind === "script") {
|
||||
return provider.existsScriptByPath({ workspace, path });
|
||||
} else if (kind === "app" || kind === "raw_app") {
|
||||
return provider.existsApp({ workspace, path });
|
||||
} else if (kind === "variable") {
|
||||
return provider.existsVariable({ workspace, path });
|
||||
} else if (kind === "resource") {
|
||||
return provider.existsResource({ workspace, path });
|
||||
} else if (kind === "resource_type") {
|
||||
return provider.existsResourceType({ workspace, path });
|
||||
} else if (kind === "folder") {
|
||||
return provider.existsFolder({ workspace, name: folderName(path) });
|
||||
}
|
||||
throw new Error(`Unknown kind: ${kind}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// deployItem
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function deployItem(
|
||||
provider: DeployProvider,
|
||||
kind: DeployKind,
|
||||
path: string,
|
||||
workspaceFrom: string,
|
||||
workspaceTo: string,
|
||||
onBehalfOf?: string
|
||||
): Promise<DeployResult> {
|
||||
const preserveOnBehalfOf = onBehalfOf !== undefined;
|
||||
|
||||
try {
|
||||
const alreadyExists = await checkItemExists(
|
||||
provider,
|
||||
kind,
|
||||
path,
|
||||
workspaceTo
|
||||
);
|
||||
|
||||
if (kind === "flow") {
|
||||
const flow = await provider.getFlowByPath({
|
||||
workspace: workspaceFrom,
|
||||
path,
|
||||
});
|
||||
// Clear inline script hashes so the target workspace resolves by path
|
||||
getAllModules(
|
||||
flow.value?.modules ?? [],
|
||||
flow.value?.failure_module
|
||||
).forEach((x: any) => {
|
||||
if (x.value?.type === "script" && x.value.hash != undefined) {
|
||||
x.value.hash = undefined;
|
||||
}
|
||||
});
|
||||
if (alreadyExists) {
|
||||
await provider.updateFlow({
|
||||
workspace: workspaceTo,
|
||||
path,
|
||||
requestBody: {
|
||||
...flow,
|
||||
preserve_on_behalf_of: preserveOnBehalfOf,
|
||||
on_behalf_of_email: onBehalfOf,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await provider.createFlow({
|
||||
workspace: workspaceTo,
|
||||
requestBody: {
|
||||
...flow,
|
||||
preserve_on_behalf_of: preserveOnBehalfOf,
|
||||
on_behalf_of_email: onBehalfOf,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (kind === "script") {
|
||||
const script = await provider.getScriptByPath({
|
||||
workspace: workspaceFrom,
|
||||
path,
|
||||
});
|
||||
let parentHash: string | undefined;
|
||||
if (alreadyExists) {
|
||||
const existing = await provider.getScriptByPath({
|
||||
workspace: workspaceTo,
|
||||
path,
|
||||
});
|
||||
parentHash = existing.hash;
|
||||
}
|
||||
await provider.createScript({
|
||||
workspace: workspaceTo,
|
||||
requestBody: {
|
||||
...script,
|
||||
lock: script.lock,
|
||||
parent_hash: parentHash,
|
||||
preserve_on_behalf_of: preserveOnBehalfOf,
|
||||
on_behalf_of_email: onBehalfOf,
|
||||
},
|
||||
});
|
||||
} else if (kind === "app" || kind === "raw_app") {
|
||||
const app = await provider.getAppByPath({
|
||||
workspace: workspaceFrom,
|
||||
path,
|
||||
});
|
||||
if (alreadyExists) {
|
||||
if (app.raw_app) {
|
||||
const secret = await provider.getPublicSecretOfLatestVersionOfApp({
|
||||
workspace: workspaceFrom,
|
||||
path: app.path,
|
||||
});
|
||||
const js = await provider.getRawAppData({
|
||||
secretWithExtension: `${secret}.js`,
|
||||
workspace: workspaceFrom,
|
||||
});
|
||||
const css = await provider.getRawAppData({
|
||||
secretWithExtension: `${secret}.css`,
|
||||
workspace: workspaceFrom,
|
||||
});
|
||||
await provider.updateAppRaw({
|
||||
workspace: workspaceTo,
|
||||
path,
|
||||
formData: {
|
||||
app: { ...app, preserve_on_behalf_of: preserveOnBehalfOf },
|
||||
css,
|
||||
js,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await provider.updateApp({
|
||||
workspace: workspaceTo,
|
||||
path,
|
||||
requestBody: {
|
||||
...app,
|
||||
preserve_on_behalf_of: preserveOnBehalfOf,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (app.raw_app) {
|
||||
const secret = await provider.getPublicSecretOfLatestVersionOfApp({
|
||||
workspace: workspaceFrom,
|
||||
path: app.path,
|
||||
});
|
||||
const js = await provider.getRawAppData({
|
||||
secretWithExtension: `${secret}.js`,
|
||||
workspace: workspaceFrom,
|
||||
});
|
||||
const css = await provider.getRawAppData({
|
||||
secretWithExtension: `${secret}.css`,
|
||||
workspace: workspaceFrom,
|
||||
});
|
||||
await provider.createAppRaw({
|
||||
workspace: workspaceTo,
|
||||
formData: {
|
||||
app: { ...app, preserve_on_behalf_of: preserveOnBehalfOf },
|
||||
css,
|
||||
js,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await provider.createApp({
|
||||
workspace: workspaceTo,
|
||||
requestBody: {
|
||||
...app,
|
||||
preserve_on_behalf_of: preserveOnBehalfOf,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (kind === "variable") {
|
||||
const variable = await provider.getVariable({
|
||||
workspace: workspaceFrom,
|
||||
path,
|
||||
decryptSecret: true,
|
||||
});
|
||||
if (alreadyExists) {
|
||||
await provider.updateVariable({
|
||||
workspace: workspaceTo,
|
||||
path,
|
||||
requestBody: {
|
||||
path,
|
||||
value: variable.value ?? "",
|
||||
is_secret: variable.is_secret,
|
||||
description: variable.description ?? "",
|
||||
},
|
||||
alreadyEncrypted: false,
|
||||
});
|
||||
} else {
|
||||
await provider.createVariable({
|
||||
workspace: workspaceTo,
|
||||
requestBody: {
|
||||
path,
|
||||
value: variable.value ?? "",
|
||||
is_secret: variable.is_secret,
|
||||
description: variable.description ?? "",
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (kind === "resource") {
|
||||
const resource = await provider.getResource({
|
||||
workspace: workspaceFrom,
|
||||
path,
|
||||
});
|
||||
if (alreadyExists) {
|
||||
await provider.updateResource({
|
||||
workspace: workspaceTo,
|
||||
path,
|
||||
requestBody: {
|
||||
path,
|
||||
value: resource.value ?? "",
|
||||
description: resource.description ?? "",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await provider.createResource({
|
||||
workspace: workspaceTo,
|
||||
requestBody: {
|
||||
path,
|
||||
value: resource.value ?? "",
|
||||
resource_type: resource.resource_type,
|
||||
description: resource.description ?? "",
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (kind === "resource_type") {
|
||||
const rt = await provider.getResourceType({
|
||||
workspace: workspaceFrom,
|
||||
path,
|
||||
});
|
||||
if (alreadyExists) {
|
||||
await provider.updateResourceType({
|
||||
workspace: workspaceTo,
|
||||
path,
|
||||
requestBody: {
|
||||
schema: rt.schema,
|
||||
description: rt.description ?? "",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await provider.createResourceType({
|
||||
workspace: workspaceTo,
|
||||
requestBody: {
|
||||
name: rt.name,
|
||||
schema: rt.schema,
|
||||
description: rt.description ?? "",
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (kind === "folder") {
|
||||
const name = folderName(path);
|
||||
const folder = await provider.getFolder({
|
||||
workspace: workspaceFrom,
|
||||
name,
|
||||
});
|
||||
if (alreadyExists) {
|
||||
await provider.updateFolder({
|
||||
workspace: workspaceTo,
|
||||
name,
|
||||
requestBody: {
|
||||
owners: folder.owners,
|
||||
extra_perms: folder.extra_perms,
|
||||
summary: folder.summary ?? undefined,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await provider.createFolder({
|
||||
workspace: workspaceTo,
|
||||
requestBody: {
|
||||
name,
|
||||
owners: folder.owners,
|
||||
extra_perms: folder.extra_perms,
|
||||
summary: folder.summary ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Unknown kind: ${kind}`);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, error: toError(e) };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// deleteItemInWorkspace
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Delete/archive an item in a workspace.
|
||||
* Scripts and flows are archived (reversible). Other types are deleted.
|
||||
*/
|
||||
export async function deleteItemInWorkspace(
|
||||
provider: DeployProvider,
|
||||
kind: DeployKind,
|
||||
path: string,
|
||||
workspace: string
|
||||
): Promise<DeployResult> {
|
||||
try {
|
||||
if (kind === "script") {
|
||||
await provider.archiveScriptByPath({ workspace, path });
|
||||
} else if (kind === "flow") {
|
||||
await provider.archiveFlowByPath({
|
||||
workspace,
|
||||
path,
|
||||
requestBody: { archived: true },
|
||||
});
|
||||
} else if (kind === "app" || kind === "raw_app") {
|
||||
await provider.deleteApp({ workspace, path });
|
||||
} else if (kind === "variable") {
|
||||
await provider.deleteVariable({ workspace, path });
|
||||
} else if (kind === "resource") {
|
||||
await provider.deleteResource({ workspace, path });
|
||||
} else if (kind === "resource_type") {
|
||||
await provider.deleteResourceType({ workspace, path });
|
||||
} else if (kind === "folder") {
|
||||
await provider.deleteFolder({ workspace, name: folderName(path) });
|
||||
} else {
|
||||
throw new Error(`Deletion not supported for kind: ${kind}`);
|
||||
}
|
||||
return { success: true };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, error: toError(e) };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getOnBehalfOf
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the value of an item for diff comparison.
|
||||
* Returns a normalized representation suitable for JSON comparison.
|
||||
*/
|
||||
export async function getItemValue(
|
||||
provider: DeployProvider,
|
||||
kind: DeployKind,
|
||||
path: string,
|
||||
workspace: string
|
||||
): Promise<unknown> {
|
||||
try {
|
||||
if (kind === "flow") {
|
||||
const flow = await provider.getFlowByPath({ workspace, path });
|
||||
getAllModules(flow.value?.modules ?? [], flow.value?.failure_module).forEach(
|
||||
(x: any) => {
|
||||
if (x.value?.type === "script" && x.value.hash != undefined) {
|
||||
x.value.hash = undefined;
|
||||
}
|
||||
}
|
||||
);
|
||||
return {
|
||||
summary: flow.summary,
|
||||
description: flow.description,
|
||||
value: flow.value,
|
||||
};
|
||||
} else if (kind === "script") {
|
||||
const script = await provider.getScriptByPath({ workspace, path });
|
||||
return {
|
||||
content: script.content,
|
||||
lock: script.lock,
|
||||
schema: script.schema,
|
||||
summary: script.summary,
|
||||
language: script.language,
|
||||
};
|
||||
} else if (kind === "app" || kind === "raw_app") {
|
||||
return await provider.getAppByPath({ workspace, path });
|
||||
} else if (kind === "variable") {
|
||||
const variable = await provider.getVariable({
|
||||
workspace,
|
||||
path,
|
||||
decryptSecret: true,
|
||||
});
|
||||
return variable.value;
|
||||
} else if (kind === "resource") {
|
||||
const resource = await provider.getResource({ workspace, path });
|
||||
return resource.value;
|
||||
} else if (kind === "resource_type") {
|
||||
const rt = await provider.getResourceType({ workspace, path });
|
||||
return rt.schema;
|
||||
} else if (kind === "folder") {
|
||||
const folder = await provider.getFolder({
|
||||
workspace,
|
||||
name: folderName(path),
|
||||
});
|
||||
return {
|
||||
name: folder.name,
|
||||
owners: folder.owners,
|
||||
extra_perms: folder.extra_perms,
|
||||
summary: folder.summary,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Item may not exist
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the on_behalf_of value for a deployable item.
|
||||
* Returns an email for flows/scripts/apps, or undefined if not applicable.
|
||||
*/
|
||||
export async function getOnBehalfOf(
|
||||
provider: DeployProvider,
|
||||
kind: DeployKind,
|
||||
path: string,
|
||||
workspace: string
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
if (kind === "flow") {
|
||||
const flow = await provider.getFlowByPath({ workspace, path });
|
||||
return flow.on_behalf_of_email;
|
||||
} else if (kind === "script") {
|
||||
const script = await provider.getScriptByPath({ workspace, path });
|
||||
return script.on_behalf_of_email;
|
||||
} else if (kind === "app" || kind === "raw_app") {
|
||||
const app = await provider.getAppByPath({ workspace, path });
|
||||
return app.policy?.on_behalf_of_email;
|
||||
}
|
||||
} catch {
|
||||
// Item may not exist
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -12,4 +12,5 @@ export * from "./inline-scripts";
|
||||
export * from "./path-utils";
|
||||
export * from "./parse";
|
||||
export * from "./config";
|
||||
export * from "./deploy";
|
||||
export { SEP, DELIMITER } from "./constants";
|
||||
Generated
+6
-52
@@ -90,7 +90,7 @@
|
||||
"windmill-parser-wasm-wac": "1.668.6",
|
||||
"windmill-parser-wasm-yaml": "1.593.0",
|
||||
"windmill-sql-datatype-parser-wasm": "1.512.0",
|
||||
"windmill-utils-internal": "^1.3.4",
|
||||
"windmill-utils-internal": "^1.3.8",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-readline": "^1.1.2",
|
||||
"y-monaco": "^0.1.4",
|
||||
@@ -844,7 +844,6 @@
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz",
|
||||
"integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -856,7 +855,6 @@
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz",
|
||||
"integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -867,7 +865,6 @@
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
|
||||
"integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -1357,7 +1354,6 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
|
||||
"integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -1514,7 +1510,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1531,7 +1526,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1548,7 +1542,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1565,7 +1558,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1582,7 +1574,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1599,7 +1590,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1616,7 +1606,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1633,7 +1622,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1650,7 +1638,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1667,7 +1654,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1684,7 +1670,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1701,7 +1686,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1718,7 +1702,6 @@
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -1735,7 +1718,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1752,7 +1734,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2058,7 +2039,6 @@
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -6867,7 +6847,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==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
@@ -7366,7 +7346,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7387,7 +7366,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7408,7 +7386,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7429,7 +7406,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7450,7 +7426,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7471,7 +7446,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7492,7 +7466,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7513,7 +7486,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7534,7 +7506,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7555,7 +7526,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7576,7 +7546,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -12152,21 +12121,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -12897,7 +12851,7 @@
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -13723,9 +13677,9 @@
|
||||
"integrity": "sha512-uHNL8F72/Tf96xF3hOHnPDjkEyqXw7fNjcPJiUhth9sTQkcwUIoJMOdwm8/cs+j9kKVRJ4tgNYMHEBLylazp6g=="
|
||||
},
|
||||
"node_modules/windmill-utils-internal": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/windmill-utils-internal/-/windmill-utils-internal-1.3.4.tgz",
|
||||
"integrity": "sha512-XVypDKIZ6P4fwIjZwvuvq1m+j0rtAA7BDp1rI2F7hQ+VBKZUHsLskP+jgstXs+kN1LqGGsQJj4ecMYDImpIZ6A==",
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/windmill-utils-internal/-/windmill-utils-internal-1.3.8.tgz",
|
||||
"integrity": "sha512-FtVEvAI2PIqPTEpowTjo5c5JkYe09Scu9zcwzJutOWMEh4aDdzOejaG7EZTac0pk+dK4JB46+nbl82hhLsL8Mw==",
|
||||
"license": "Apache 2.0"
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
|
||||
@@ -163,7 +163,7 @@
|
||||
"windmill-parser-wasm-wac": "1.668.6",
|
||||
"windmill-parser-wasm-yaml": "1.593.0",
|
||||
"windmill-sql-datatype-parser-wasm": "1.512.0",
|
||||
"windmill-utils-internal": "^1.3.4",
|
||||
"windmill-utils-internal": "^1.3.8",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-readline": "^1.1.2",
|
||||
"y-monaco": "^0.1.4",
|
||||
@@ -592,4 +592,4 @@
|
||||
"@rollup/rollup-linux-x64-gnu": "^4.35.0",
|
||||
"fsevents": "^2.3.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,13 @@
|
||||
import { userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
|
||||
import type { Kind } from '$lib/utils_deployable'
|
||||
import { deployItem, getItemValue, getOnBehalfOf } from '$lib/utils_workspace_deploy'
|
||||
import {
|
||||
deployItem,
|
||||
deleteItemInWorkspace,
|
||||
getItemValue,
|
||||
getOnBehalfOf,
|
||||
type DeployResult
|
||||
} from '$lib/utils_workspace_deploy'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
import OnBehalfOfSelector, {
|
||||
needsOnBehalfOfSelection,
|
||||
@@ -307,13 +313,27 @@
|
||||
) {
|
||||
deploymentStatus[statusPath] = { status: 'loading' }
|
||||
|
||||
const result = await deployItem({
|
||||
kind,
|
||||
path,
|
||||
workspaceFrom,
|
||||
workspaceTo: workspaceToDeployTo,
|
||||
onBehalfOf: getOnBehalfOfForDeploy(statusPath, kind)
|
||||
})
|
||||
// Check if the item was deleted in the source workspace.
|
||||
// If so, archive/delete it in the target workspace instead of copying.
|
||||
const diff = comparison?.diffs.find((d) => getItemKey(d) === statusPath)
|
||||
const itemDeletedInSource = diff
|
||||
? mergeIntoParent
|
||||
? diff.exists_in_fork === false
|
||||
: diff.exists_in_source === false
|
||||
: false
|
||||
|
||||
let result: DeployResult
|
||||
if (itemDeletedInSource) {
|
||||
result = await deleteItemInWorkspace(kind, path, workspaceToDeployTo)
|
||||
} else {
|
||||
result = await deployItem({
|
||||
kind,
|
||||
path,
|
||||
workspaceFrom,
|
||||
workspaceTo: workspaceToDeployTo,
|
||||
onBehalfOf: getOnBehalfOfForDeploy(statusPath, kind)
|
||||
})
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
deploymentStatus[statusPath] = { status: 'deployed' }
|
||||
|
||||
@@ -598,7 +598,6 @@
|
||||
ws_error_handler_muted: script.ws_error_handler_muted,
|
||||
priority: script.priority,
|
||||
restart_unless_cancelled: script.restart_unless_cancelled,
|
||||
delete_after_use: script.delete_after_use,
|
||||
timeout: script.timeout,
|
||||
concurrency_key: emptyString(script.concurrency_key) ? undefined : script.concurrency_key,
|
||||
visible_to_runner_only: script.visible_to_runner_only,
|
||||
@@ -757,7 +756,6 @@
|
||||
ws_error_handler_muted: script.ws_error_handler_muted,
|
||||
priority: script.priority,
|
||||
restart_unless_cancelled: script.restart_unless_cancelled,
|
||||
delete_after_use: script.delete_after_use,
|
||||
timeout: script.timeout,
|
||||
concurrency_key: emptyString(script.concurrency_key)
|
||||
? undefined
|
||||
@@ -1644,7 +1642,7 @@
|
||||
>
|
||||
{/snippet}
|
||||
</Section>
|
||||
<Section label="Delete after use">
|
||||
<Section label="Delete after completion">
|
||||
{#snippet header()}
|
||||
<Tooltip
|
||||
documentationLink="https://www.windmill.dev/docs/script_editor/settings#delete-after-use"
|
||||
@@ -1655,7 +1653,8 @@
|
||||
<br />
|
||||
<br />
|
||||
The logs, arguments and results of the job will be completely deleted from Windmill
|
||||
once it is complete and the result has been returned.
|
||||
after the specified delay once it is complete and the result has been returned.
|
||||
Set to 0 for immediate deletion.
|
||||
<br />
|
||||
<br />
|
||||
The deletion is irreversible.
|
||||
@@ -1670,18 +1669,24 @@
|
||||
<Toggle
|
||||
disabled={!$enterpriseLicense}
|
||||
size="sm"
|
||||
checked={Boolean(script.delete_after_use)}
|
||||
checked={script.delete_after_secs != null}
|
||||
on:change={() => {
|
||||
if (script.delete_after_use) {
|
||||
script.delete_after_use = undefined
|
||||
if (script.delete_after_secs != null) {
|
||||
script.delete_after_secs = undefined
|
||||
} else {
|
||||
script.delete_after_use = true
|
||||
script.delete_after_secs = 0
|
||||
}
|
||||
}}
|
||||
options={{
|
||||
right: 'Delete logs, arguments and results after use'
|
||||
right: 'Delete logs, arguments and results after completion'
|
||||
}}
|
||||
/>
|
||||
{#if script.delete_after_secs != null}
|
||||
<SecondsInput
|
||||
bind:seconds={script.delete_after_secs}
|
||||
disabled={!$enterpriseLicense}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</Section>
|
||||
{#if !isCloudHosted()}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import { SecondsInput } from '$lib/components/common'
|
||||
|
||||
import Section from '$lib/components/Section.svelte'
|
||||
|
||||
@@ -11,18 +12,21 @@
|
||||
}
|
||||
|
||||
let { flowModule = $bindable(), disabled = false }: Props = $props()
|
||||
|
||||
let enabled = $derived(flowModule.delete_after_secs != null)
|
||||
</script>
|
||||
|
||||
<Section label="Delete after use">
|
||||
<Section label="Delete after completion">
|
||||
{#snippet header()}
|
||||
<Tooltip>
|
||||
The logs, arguments and results of this flow step will be completely deleted from Windmill
|
||||
once the flow is complete. They might be temporarily visible in UI while the flow is running.
|
||||
after the specified delay once the flow is complete. They might be temporarily visible in UI
|
||||
while the flow is running.
|
||||
<br />
|
||||
This also applies to a flow step that has failed: the error will not be accessible.
|
||||
<br />
|
||||
<br />
|
||||
The deletion is irreversible.
|
||||
The deletion is irreversible. Set to 0 for immediate deletion.
|
||||
{#if disabled}
|
||||
<br />
|
||||
<br />
|
||||
@@ -34,16 +38,21 @@
|
||||
<Toggle
|
||||
{disabled}
|
||||
size="sm"
|
||||
checked={Boolean(flowModule.delete_after_use)}
|
||||
checked={enabled}
|
||||
on:change={() => {
|
||||
if (flowModule.delete_after_use) {
|
||||
flowModule.delete_after_use = undefined
|
||||
if (enabled) {
|
||||
flowModule.delete_after_secs = undefined
|
||||
} else {
|
||||
flowModule.delete_after_use = true
|
||||
flowModule.delete_after_secs = 0
|
||||
}
|
||||
}}
|
||||
options={{
|
||||
right: 'Delete logs, arguments and results after the flow is complete'
|
||||
}}
|
||||
/>
|
||||
{#if enabled}
|
||||
<div class="mt-2">
|
||||
<SecondsInput bind:seconds={flowModule.delete_after_secs} {disabled} size="sm" />
|
||||
</div>
|
||||
{/if}
|
||||
</Section>
|
||||
|
||||
@@ -125,6 +125,7 @@
|
||||
}}
|
||||
/>
|
||||
</Label>
|
||||
<!-- prettier-ignore -->
|
||||
<LabelsInput bind:labels={(flowStore.val as any).labels} class="-mt-4" />
|
||||
|
||||
{#if !noEditor}
|
||||
@@ -589,6 +590,34 @@
|
||||
{/snippet}
|
||||
</Toggle>
|
||||
|
||||
<Toggle
|
||||
textClass="font-medium"
|
||||
size="xs"
|
||||
disabled={!$enterpriseLicense}
|
||||
checked={flowStore.val.value.delete_after_secs != null}
|
||||
on:change={() => {
|
||||
if (flowStore.val.value.delete_after_secs != null) {
|
||||
flowStore.val.value.delete_after_secs = undefined
|
||||
} else {
|
||||
flowStore.val.value.delete_after_secs = 0
|
||||
}
|
||||
}}
|
||||
options={{
|
||||
right: 'Delete all step results after completion',
|
||||
rightTooltip: `When enabled, the logs, arguments and results of all flow steps will be deleted after the specified delay once the flow completes. Set to 0 for immediate deletion. The deletion is irreversible. ${!$enterpriseLicense ? 'This is a feature only available on enterprise edition.' : ''}`
|
||||
}}
|
||||
eeOnly={true}
|
||||
/>
|
||||
{#if flowStore.val.value.delete_after_secs != null}
|
||||
<div class="ml-6 mt-1">
|
||||
<SecondsInput
|
||||
bind:seconds={flowStore.val.value.delete_after_secs}
|
||||
disabled={!$enterpriseLicense}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<Toggle
|
||||
textClass="font-medium"
|
||||
|
||||
@@ -124,8 +124,7 @@
|
||||
class={twMerge(
|
||||
'text-xs bg-surface border-[1px] border-gray-300 dark:border-gray-500 focus:outline-none',
|
||||
'hover:bg-surface-hover focus:ring-4 focus:ring-surface-selected font-medium rounded-sm w-[40px] gap-1 h-[20px]',
|
||||
'flex items-center justify-center',
|
||||
flowJobsSuccess?.[selected] == false ? 'text-red-400' : 'text-secondary'
|
||||
'flex items-center justify-center text-secondary'
|
||||
)}
|
||||
meltElement={trigger}
|
||||
>
|
||||
@@ -143,34 +142,41 @@
|
||||
<div class="max-h-[300px]">
|
||||
{#key items}
|
||||
{#if items.length > 0}
|
||||
<VirtualList height={300} width="100%" itemCount={items.length} itemSize={24}>
|
||||
{#snippet header()}{/snippet}
|
||||
{#snippet footer()}{/snippet}
|
||||
{#snippet item({ index: idx, style })}
|
||||
<div {style}>
|
||||
<MenuItem
|
||||
class={twMerge(
|
||||
'text-primary text-xs w-full text-left py-1 pl-2 hover:bg-surface-hover whitespace-nowrap flex flex-row gap-2 items-center',
|
||||
items[idx].success == false ? 'text-red-400' : '',
|
||||
'data-[highlighted]:bg-surface-hover',
|
||||
items[idx].index == selected ? 'bg-surface-selected' : ''
|
||||
)}
|
||||
onClick={() => {
|
||||
onSelectedIteration?.({
|
||||
moduleId: moduleId,
|
||||
index: items[idx].index,
|
||||
id: items[idx].id,
|
||||
manuallySet: true
|
||||
})
|
||||
menu?.close()
|
||||
}}
|
||||
item={childrenItem}
|
||||
>
|
||||
#{items[idx].index + 1}
|
||||
</MenuItem>
|
||||
</div>
|
||||
{/snippet}
|
||||
</VirtualList>
|
||||
<VirtualList height={300} width="100%" itemCount={items.length} itemSize={24}>
|
||||
{#snippet header()}{/snippet}
|
||||
{#snippet footer()}{/snippet}
|
||||
{#snippet item({ index: idx, style })}
|
||||
<div {style}>
|
||||
<MenuItem
|
||||
class={twMerge(
|
||||
'text-primary text-xs w-full text-left py-1 pl-2 hover:bg-surface-hover whitespace-nowrap flex flex-row gap-2 items-center',
|
||||
'data-[highlighted]:bg-surface-hover',
|
||||
items[idx].index == selected ? 'bg-surface-selected' : ''
|
||||
)}
|
||||
onClick={() => {
|
||||
onSelectedIteration?.({
|
||||
moduleId: moduleId,
|
||||
index: items[idx].index,
|
||||
id: items[idx].id,
|
||||
manuallySet: true
|
||||
})
|
||||
menu?.close()
|
||||
}}
|
||||
item={childrenItem}
|
||||
>
|
||||
<span
|
||||
class="inline-block w-2 h-2 rounded-full shrink-0 {items[idx].success ===
|
||||
true
|
||||
? 'bg-green-500'
|
||||
: items[idx].success === false
|
||||
? 'bg-red-500'
|
||||
: 'bg-yellow-400'}"
|
||||
></span>
|
||||
#{items[idx].index + 1}
|
||||
</MenuItem>
|
||||
</div>
|
||||
{/snippet}
|
||||
</VirtualList>
|
||||
{:else}
|
||||
<div class="text-xs text-tertiary py-2 px-2">No iterations</div>
|
||||
{/if}
|
||||
|
||||
@@ -68,6 +68,13 @@ export function usePreparedAssetSqlQueries(
|
||||
|
||||
type QueryEntry = [string, InferAssetsSqlQueryDetails]
|
||||
|
||||
// DuckDB prepare replaces $N params with NULL. Some functions (read_parquet, read_csv, etc.)
|
||||
// reject NULL arguments, which is expected — the query will work at execution time with real args.
|
||||
function isNullParamSubstitutionError(error?: string): boolean {
|
||||
if (!error) return false
|
||||
return error.includes('cannot take NULL') || error.includes('Could not choose a best candidate')
|
||||
}
|
||||
|
||||
function mapPrepareResults(
|
||||
res: { error?: string; columns?: { name: string; type: string }[] }[],
|
||||
chunk: QueryEntry[]
|
||||
@@ -83,7 +90,9 @@ function mapPrepareResults(
|
||||
r.columns.map(({ name, type: t }) => [name, sqlDataTypeToJsTypeHeuristic(t)])
|
||||
)
|
||||
}
|
||||
: { error: r.error ?? "Couldn't prepare query " }
|
||||
: isNullParamSubstitutionError(r.error)
|
||||
? { columns: {} }
|
||||
: { error: r.error ?? "Couldn't prepare query " }
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
ScriptService,
|
||||
VariableService
|
||||
} from '$lib/gen'
|
||||
import { getAllModules } from './components/flows/flowExplorer'
|
||||
import {
|
||||
existsTrigger,
|
||||
getTriggersDeployData,
|
||||
@@ -18,11 +17,70 @@ import {
|
||||
} from '$lib/utils_deployable'
|
||||
import type { TriggerKind } from './components/triggers'
|
||||
|
||||
/** Folder diff paths carry the `f/` prefix (e.g. `f/test`), but folder API endpoints expect just the name. */
|
||||
function folderName(path: string): string {
|
||||
return path.replace(/^f\//, '')
|
||||
import {
|
||||
deployItem as sharedDeployItem,
|
||||
deleteItemInWorkspace as sharedDeleteItem,
|
||||
checkItemExists as sharedCheckItemExists,
|
||||
getOnBehalfOf as sharedGetOnBehalfOf,
|
||||
getItemValue as sharedGetItemValue,
|
||||
type DeployProvider,
|
||||
type DeployKind,
|
||||
type DeployResult
|
||||
} from 'windmill-utils-internal'
|
||||
|
||||
export type { DeployResult }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider adapter — wraps frontend's class-based services
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeProvider(): DeployProvider {
|
||||
return {
|
||||
existsFlowByPath: (p) => FlowService.existsFlowByPath(p),
|
||||
existsScriptByPath: (p) => ScriptService.existsScriptByPath(p),
|
||||
existsApp: (p) => AppService.existsApp(p),
|
||||
existsVariable: (p) => VariableService.existsVariable(p),
|
||||
existsResource: (p) => ResourceService.existsResource(p),
|
||||
existsResourceType: (p) => ResourceService.existsResourceType(p),
|
||||
existsFolder: (p) => FolderService.existsFolder(p),
|
||||
getFlowByPath: (p) => FlowService.getFlowByPath(p),
|
||||
createFlow: (p) => FlowService.createFlow(p),
|
||||
updateFlow: (p) => FlowService.updateFlow(p),
|
||||
archiveFlowByPath: (p) => FlowService.archiveFlowByPath(p),
|
||||
getScriptByPath: (p) => ScriptService.getScriptByPath(p),
|
||||
createScript: (p) => ScriptService.createScript(p),
|
||||
archiveScriptByPath: (p) => ScriptService.archiveScriptByPath(p),
|
||||
getAppByPath: (p) => AppService.getAppByPath(p),
|
||||
createApp: (p) => AppService.createApp(p),
|
||||
updateApp: (p) => AppService.updateApp(p),
|
||||
createAppRaw: (p) => AppService.createAppRaw(p),
|
||||
updateAppRaw: (p) => AppService.updateAppRaw(p),
|
||||
getPublicSecretOfLatestVersionOfApp: (p) => AppService.getPublicSecretOfLatestVersionOfApp(p),
|
||||
getRawAppData: (p) => AppService.getRawAppData(p),
|
||||
deleteApp: (p) => AppService.deleteApp(p),
|
||||
getVariable: (p) => VariableService.getVariable(p),
|
||||
createVariable: (p) => VariableService.createVariable(p),
|
||||
updateVariable: (p) => VariableService.updateVariable(p),
|
||||
deleteVariable: (p) => VariableService.deleteVariable(p),
|
||||
getResource: (p) => ResourceService.getResource(p),
|
||||
createResource: (p) => ResourceService.createResource(p),
|
||||
updateResource: (p) => ResourceService.updateResource(p),
|
||||
deleteResource: (p) => ResourceService.deleteResource(p),
|
||||
getResourceType: (p) => ResourceService.getResourceType(p),
|
||||
createResourceType: (p) => ResourceService.createResourceType(p),
|
||||
updateResourceType: (p) => ResourceService.updateResourceType(p),
|
||||
deleteResourceType: (p) => ResourceService.deleteResourceType(p),
|
||||
getFolder: (p) => FolderService.getFolder(p),
|
||||
createFolder: (p) => FolderService.createFolder(p),
|
||||
updateFolder: (p) => FolderService.updateFolder(p),
|
||||
deleteFolder: (p) => FolderService.deleteFolder(p)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API — thin wrappers that add trigger handling (frontend-specific)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DeployItemParams {
|
||||
kind: Kind
|
||||
path: string
|
||||
@@ -40,282 +98,58 @@ export interface DeployItemParams {
|
||||
onBehalfOf?: string
|
||||
}
|
||||
|
||||
export interface DeployResult {
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploy an item from one workspace to another.
|
||||
* Handles all item kinds: flow, script, app, variable, resource, resource_type, folder, trigger.
|
||||
*/
|
||||
export async function deployItem(params: DeployItemParams): Promise<DeployResult> {
|
||||
const { kind, path, workspaceFrom, workspaceTo, additionalInformation, onBehalfOf } = params
|
||||
// When onBehalfOf is set, we preserve the on_behalf_of setting with the specified value
|
||||
const preserveOnBehalfOf = onBehalfOf !== undefined
|
||||
|
||||
try {
|
||||
const alreadyExists = await checkItemExists(kind, path, workspaceTo, additionalInformation)
|
||||
|
||||
if (kind === 'flow') {
|
||||
const flow = await FlowService.getFlowByPath({
|
||||
workspace: workspaceFrom,
|
||||
path: path
|
||||
})
|
||||
getAllModules(flow.value.modules).forEach((x) => {
|
||||
if (x.value.type === 'script' && x.value.hash != undefined) {
|
||||
x.value.hash = undefined
|
||||
}
|
||||
})
|
||||
// Triggers are frontend-specific (not in the shared module)
|
||||
if (kind === 'trigger') {
|
||||
if (!additionalInformation?.triggers) {
|
||||
return { success: false, error: 'Missing triggers kind' }
|
||||
}
|
||||
try {
|
||||
const alreadyExists = await checkItemExists(kind, path, workspaceTo, additionalInformation)
|
||||
const { data, createFn, updateFn } = await getTriggersDeployData(
|
||||
additionalInformation.triggers.kind,
|
||||
path,
|
||||
workspaceFrom,
|
||||
onBehalfOf
|
||||
)
|
||||
if (alreadyExists) {
|
||||
await FlowService.updateFlow({
|
||||
workspace: workspaceTo,
|
||||
path: path,
|
||||
requestBody: {
|
||||
...flow,
|
||||
preserve_on_behalf_of: preserveOnBehalfOf,
|
||||
on_behalf_of_email: onBehalfOf
|
||||
}
|
||||
})
|
||||
await updateFn({ path, workspace: workspaceTo, requestBody: data } as any)
|
||||
} else {
|
||||
await FlowService.createFlow({
|
||||
workspace: workspaceTo,
|
||||
requestBody: {
|
||||
...flow,
|
||||
preserve_on_behalf_of: preserveOnBehalfOf,
|
||||
on_behalf_of_email: onBehalfOf
|
||||
}
|
||||
})
|
||||
await createFn({ workspace: workspaceTo, requestBody: data } as any)
|
||||
}
|
||||
} else if (kind === 'script') {
|
||||
const script = await ScriptService.getScriptByPath({
|
||||
workspace: workspaceFrom,
|
||||
path: path
|
||||
})
|
||||
await ScriptService.createScript({
|
||||
workspace: workspaceTo,
|
||||
requestBody: {
|
||||
...script,
|
||||
lock: script.lock,
|
||||
parent_hash: alreadyExists
|
||||
? (
|
||||
await ScriptService.getScriptByPath({
|
||||
workspace: workspaceTo,
|
||||
path: path
|
||||
})
|
||||
).hash
|
||||
: undefined,
|
||||
preserve_on_behalf_of: preserveOnBehalfOf,
|
||||
on_behalf_of_email: onBehalfOf
|
||||
}
|
||||
})
|
||||
} else if (kind === 'app' || kind === 'raw_app') {
|
||||
const app = await AppService.getAppByPath({
|
||||
workspace: workspaceFrom,
|
||||
path: path
|
||||
})
|
||||
if (alreadyExists) {
|
||||
if (app.raw_app) {
|
||||
const secret = await AppService.getPublicSecretOfLatestVersionOfApp({
|
||||
workspace: workspaceFrom,
|
||||
path: app.path
|
||||
})
|
||||
const js = await AppService.getRawAppData({
|
||||
secretWithExtension: `${secret}.js`,
|
||||
workspace: workspaceFrom
|
||||
})
|
||||
const css = await AppService.getRawAppData({
|
||||
secretWithExtension: `${secret}.css`,
|
||||
workspace: workspaceFrom
|
||||
})
|
||||
await AppService.updateAppRaw({
|
||||
workspace: workspaceTo,
|
||||
path: path,
|
||||
formData: {
|
||||
app: { ...app, preserve_on_behalf_of: preserveOnBehalfOf },
|
||||
css,
|
||||
js
|
||||
}
|
||||
})
|
||||
} else {
|
||||
await AppService.updateApp({
|
||||
workspace: workspaceTo,
|
||||
path: path,
|
||||
requestBody: {
|
||||
...app,
|
||||
preserve_on_behalf_of: preserveOnBehalfOf
|
||||
}
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if (app.raw_app) {
|
||||
const secret = await AppService.getPublicSecretOfLatestVersionOfApp({
|
||||
workspace: workspaceFrom,
|
||||
path: app.path
|
||||
})
|
||||
const js = await AppService.getRawAppData({
|
||||
secretWithExtension: `${secret}.js`,
|
||||
workspace: workspaceFrom
|
||||
})
|
||||
const css = await AppService.getRawAppData({
|
||||
secretWithExtension: `${secret}.css`,
|
||||
workspace: workspaceFrom
|
||||
})
|
||||
await AppService.createAppRaw({
|
||||
workspace: workspaceTo,
|
||||
formData: {
|
||||
app: { ...app, preserve_on_behalf_of: preserveOnBehalfOf },
|
||||
css,
|
||||
js
|
||||
}
|
||||
})
|
||||
} else {
|
||||
await AppService.createApp({
|
||||
workspace: workspaceTo,
|
||||
requestBody: {
|
||||
...app,
|
||||
preserve_on_behalf_of: preserveOnBehalfOf
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (kind === 'variable') {
|
||||
const variable = await VariableService.getVariable({
|
||||
workspace: workspaceFrom,
|
||||
path: path,
|
||||
decryptSecret: true
|
||||
})
|
||||
if (alreadyExists) {
|
||||
await VariableService.updateVariable({
|
||||
workspace: workspaceTo,
|
||||
path: path,
|
||||
requestBody: {
|
||||
path: path,
|
||||
value: variable.value ?? '',
|
||||
is_secret: variable.is_secret,
|
||||
description: variable.description ?? ''
|
||||
},
|
||||
alreadyEncrypted: false
|
||||
})
|
||||
} else {
|
||||
await VariableService.createVariable({
|
||||
workspace: workspaceTo,
|
||||
requestBody: {
|
||||
path: path,
|
||||
value: variable.value ?? '',
|
||||
is_secret: variable.is_secret,
|
||||
description: variable.description ?? ''
|
||||
}
|
||||
})
|
||||
}
|
||||
} else if (kind === 'resource') {
|
||||
const resource = await ResourceService.getResource({
|
||||
workspace: workspaceFrom,
|
||||
path: path
|
||||
})
|
||||
if (alreadyExists) {
|
||||
await ResourceService.updateResource({
|
||||
workspace: workspaceTo,
|
||||
path: path,
|
||||
requestBody: {
|
||||
path: path,
|
||||
value: resource.value ?? '',
|
||||
description: resource.description ?? ''
|
||||
}
|
||||
})
|
||||
} else {
|
||||
await ResourceService.createResource({
|
||||
workspace: workspaceTo,
|
||||
requestBody: {
|
||||
path: path,
|
||||
value: resource.value ?? '',
|
||||
resource_type: resource.resource_type,
|
||||
description: resource.description ?? ''
|
||||
}
|
||||
})
|
||||
}
|
||||
} else if (kind === 'resource_type') {
|
||||
const resource = await ResourceService.getResourceType({
|
||||
workspace: workspaceFrom,
|
||||
path: path
|
||||
})
|
||||
if (alreadyExists) {
|
||||
await ResourceService.updateResourceType({
|
||||
workspace: workspaceTo,
|
||||
path: path,
|
||||
requestBody: {
|
||||
schema: resource.schema,
|
||||
description: resource.description ?? ''
|
||||
}
|
||||
})
|
||||
} else {
|
||||
await ResourceService.createResourceType({
|
||||
workspace: workspaceTo,
|
||||
requestBody: {
|
||||
description: resource.description ?? '',
|
||||
schema: resource.schema,
|
||||
name: resource.name
|
||||
}
|
||||
})
|
||||
}
|
||||
} else if (kind === 'folder') {
|
||||
const name = folderName(path)
|
||||
const folder = await FolderService.getFolder({
|
||||
workspace: workspaceFrom,
|
||||
name
|
||||
})
|
||||
if (alreadyExists) {
|
||||
await FolderService.updateFolder({
|
||||
workspace: workspaceTo,
|
||||
name,
|
||||
requestBody: {
|
||||
owners: folder.owners,
|
||||
extra_perms: folder.extra_perms as any,
|
||||
summary: folder.summary ?? undefined
|
||||
}
|
||||
})
|
||||
} else {
|
||||
await FolderService.createFolder({
|
||||
workspace: workspaceTo,
|
||||
requestBody: {
|
||||
name,
|
||||
owners: folder.owners,
|
||||
extra_perms: folder.extra_perms as any,
|
||||
summary: folder.summary ?? undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
} else if (kind === 'trigger') {
|
||||
if (additionalInformation?.triggers) {
|
||||
const { data, createFn, updateFn } = await getTriggersDeployData(
|
||||
additionalInformation.triggers.kind,
|
||||
path,
|
||||
workspaceFrom,
|
||||
onBehalfOf
|
||||
)
|
||||
if (alreadyExists) {
|
||||
await updateFn({
|
||||
path,
|
||||
workspace: workspaceTo,
|
||||
requestBody: data
|
||||
} as any)
|
||||
} else {
|
||||
await createFn({
|
||||
workspace: workspaceTo,
|
||||
requestBody: data
|
||||
} as any)
|
||||
}
|
||||
} else {
|
||||
throw new Error('Missing triggers kind')
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Unknown kind ${kind}`)
|
||||
return { success: true }
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.body || e.message || String(e) }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.body || e.message }
|
||||
}
|
||||
|
||||
return sharedDeployItem(
|
||||
makeProvider(),
|
||||
kind as DeployKind,
|
||||
path,
|
||||
workspaceFrom,
|
||||
workspaceTo,
|
||||
onBehalfOf
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete/archive an item in a workspace.
|
||||
* Used when deploying a deletion from one workspace to another.
|
||||
* Scripts and flows are archived (reversible). Other types are deleted.
|
||||
*/
|
||||
export async function deleteItemInWorkspace(
|
||||
kind: Kind,
|
||||
path: string,
|
||||
workspace: string
|
||||
): Promise<DeployResult> {
|
||||
return sharedDeleteItem(makeProvider(), kind as DeployKind, path, workspace)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -327,46 +161,9 @@ export async function checkItemExists(
|
||||
workspace: string,
|
||||
additionalInformation?: AdditionalInformation
|
||||
): Promise<boolean> {
|
||||
if (kind === 'flow') {
|
||||
return await FlowService.existsFlowByPath({
|
||||
workspace: workspace,
|
||||
path: path
|
||||
})
|
||||
} else if (kind === 'script') {
|
||||
return await ScriptService.existsScriptByPath({
|
||||
workspace: workspace,
|
||||
path: path
|
||||
})
|
||||
} else if (kind === 'app' || kind === 'raw_app') {
|
||||
return await AppService.existsApp({
|
||||
workspace: workspace,
|
||||
path: path
|
||||
})
|
||||
} else if (kind === 'variable') {
|
||||
return await VariableService.existsVariable({
|
||||
workspace: workspace,
|
||||
path: path
|
||||
})
|
||||
} else if (kind === 'resource') {
|
||||
return await ResourceService.existsResource({
|
||||
workspace: workspace,
|
||||
path: path
|
||||
})
|
||||
} else if (kind === 'schedule') {
|
||||
return await ScheduleService.existsSchedule({
|
||||
workspace: workspace,
|
||||
path: path
|
||||
})
|
||||
} else if (kind === 'resource_type') {
|
||||
return await ResourceService.existsResourceType({
|
||||
workspace: workspace,
|
||||
path: path
|
||||
})
|
||||
} else if (kind === 'folder') {
|
||||
return await FolderService.existsFolder({
|
||||
workspace: workspace,
|
||||
name: folderName(path)
|
||||
})
|
||||
// Triggers and schedules are frontend-specific
|
||||
if (kind === 'schedule') {
|
||||
return ScheduleService.existsSchedule({ workspace, path })
|
||||
} else if (kind === 'trigger') {
|
||||
const triggersKind: TriggerKind[] = [
|
||||
'kafka',
|
||||
@@ -383,20 +180,15 @@ export async function checkItemExists(
|
||||
additionalInformation?.triggers &&
|
||||
triggersKind.includes(additionalInformation.triggers.kind)
|
||||
) {
|
||||
return await existsTrigger(
|
||||
{ workspace: workspace, path },
|
||||
additionalInformation.triggers.kind
|
||||
)
|
||||
return existsTrigger({ workspace, path }, additionalInformation.triggers.kind)
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unexpected triggers kind, expected one of: '${triggersKind.join(', ')}' got: ${
|
||||
additionalInformation?.triggers?.kind
|
||||
}`
|
||||
`Unexpected triggers kind, expected one of: '${triggersKind.join(', ')}' got: ${additionalInformation?.triggers?.kind}`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Unknown kind ${kind}`)
|
||||
}
|
||||
|
||||
return sharedCheckItemExists(makeProvider(), kind as DeployKind, path, workspace)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -408,86 +200,23 @@ export async function getItemValue(
|
||||
workspace: string,
|
||||
additionalInformation?: AdditionalInformation
|
||||
): Promise<unknown> {
|
||||
try {
|
||||
if (kind === 'flow') {
|
||||
const flow = await FlowService.getFlowByPath({
|
||||
workspace: workspace,
|
||||
path: path
|
||||
})
|
||||
getAllModules(flow.value.modules).forEach((x) => {
|
||||
if (x.value.type === 'script' && x.value.hash != undefined) {
|
||||
x.value.hash = undefined
|
||||
}
|
||||
})
|
||||
return { summary: flow.summary, description: flow.description, value: flow.value }
|
||||
} else if (kind === 'script') {
|
||||
const script = await ScriptService.getScriptByPath({
|
||||
workspace: workspace,
|
||||
path: path
|
||||
})
|
||||
return {
|
||||
content: script.content,
|
||||
lock: script.lock,
|
||||
schema: script.schema,
|
||||
summary: script.summary,
|
||||
language: script.language
|
||||
}
|
||||
} else if (kind === 'app' || kind === 'raw_app') {
|
||||
const app = await AppService.getAppByPath({
|
||||
workspace: workspace,
|
||||
path: path
|
||||
})
|
||||
return app
|
||||
} else if (kind === 'variable') {
|
||||
const variable = await VariableService.getVariable({
|
||||
workspace: workspace,
|
||||
path: path,
|
||||
decryptSecret: true
|
||||
})
|
||||
return variable.value
|
||||
} else if (kind === 'resource') {
|
||||
const resource = await ResourceService.getResource({
|
||||
workspace: workspace,
|
||||
path: path
|
||||
})
|
||||
return resource.value
|
||||
} else if (kind === 'resource_type') {
|
||||
const resource = await ResourceService.getResourceType({
|
||||
workspace: workspace,
|
||||
path: path
|
||||
})
|
||||
return resource.schema
|
||||
} else if (kind === 'folder') {
|
||||
const folder = await FolderService.getFolder({
|
||||
workspace: workspace,
|
||||
name: folderName(path)
|
||||
})
|
||||
return {
|
||||
name: folder.name,
|
||||
owners: folder.owners,
|
||||
extra_perms: folder.extra_perms,
|
||||
summary: folder.summary
|
||||
}
|
||||
} else if (kind === 'trigger') {
|
||||
if (additionalInformation?.triggers) {
|
||||
// Triggers are frontend-specific
|
||||
if (kind === 'trigger') {
|
||||
if (additionalInformation?.triggers) {
|
||||
try {
|
||||
return await getTriggerValue(additionalInformation.triggers.kind, path, workspace)
|
||||
} else {
|
||||
throw new Error(`Missing trigger information`)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Unknown kind ${kind}`)
|
||||
}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
|
||||
return sharedGetItemValue(makeProvider(), kind as DeployKind, path, workspace)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the on_behalf_of value for a deployable item.
|
||||
*
|
||||
* Return type varies by item kind:
|
||||
* - For flows/scripts/apps: returns on_behalf_of_email (an email address)
|
||||
* - For triggers/schedules: returns permissioned_as (u/username or g/group format)
|
||||
*/
|
||||
export async function getOnBehalfOf(
|
||||
kind: Kind,
|
||||
@@ -495,21 +224,14 @@ export async function getOnBehalfOf(
|
||||
workspace: string,
|
||||
additionalInformation?: AdditionalInformation
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
if (kind === 'flow') {
|
||||
const flow = await FlowService.getFlowByPath({ workspace, path })
|
||||
return flow.on_behalf_of_email
|
||||
} else if (kind === 'script') {
|
||||
const script = await ScriptService.getScriptByPath({ workspace, path })
|
||||
return script.on_behalf_of_email
|
||||
} else if (kind === 'app' || kind === 'raw_app') {
|
||||
const app = await AppService.getAppByPath({ workspace, path })
|
||||
return app.policy.on_behalf_of_email
|
||||
} else if (kind === 'trigger' && additionalInformation?.triggers) {
|
||||
// Triggers are frontend-specific
|
||||
if (kind === 'trigger' && additionalInformation?.triggers) {
|
||||
try {
|
||||
return await getTriggerPermissionedAs(additionalInformation.triggers.kind, path, workspace)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
} catch {
|
||||
// Item may not exist in the workspace
|
||||
}
|
||||
return undefined
|
||||
|
||||
return sharedGetOnBehalfOf(makeProvider(), kind as DeployKind, path, workspace)
|
||||
}
|
||||
|
||||
@@ -94,6 +94,9 @@ components:
|
||||
description: Cache duration in seconds for flow results
|
||||
cache_ignore_s3_path:
|
||||
type: boolean
|
||||
delete_after_secs:
|
||||
type: integer
|
||||
description: If set, delete the flow job's args, result and logs after this many seconds following job completion
|
||||
flow_env:
|
||||
type: object
|
||||
description: "Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource)."
|
||||
@@ -303,9 +306,9 @@ components:
|
||||
timeout:
|
||||
description: Maximum execution time in seconds (static value or expression)
|
||||
$ref: '#/components/schemas/InputTransform'
|
||||
delete_after_use:
|
||||
type: boolean
|
||||
description: If true, this step's result is deleted after use to save memory
|
||||
delete_after_secs:
|
||||
type: integer
|
||||
description: If set, delete the step's args, result and logs after this many seconds following job completion
|
||||
summary:
|
||||
type: string
|
||||
description: Short description of what this step does
|
||||
|
||||
@@ -581,6 +581,17 @@ workspace related commands
|
||||
- `--branch, --env <branch:string>` - Specify branch/environment (defaults to current)
|
||||
- `workspace fork [workspace_name:string] [workspace_id:string]` - Create a forked workspace
|
||||
- `--create-workspace-name <workspace_name:string>` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id.
|
||||
- `--color <color:string>` - Workspace color (hex code, e.g. #ff0000)
|
||||
- `--datatable-behavior <behavior:string>` - How to handle datatables: skip, schema_only, or schema_and_data (default: interactive prompt)
|
||||
- `-y --yes` - Skip interactive prompts (defaults datatable behavior to 'skip')
|
||||
- `workspace delete-fork <fork_name:string>` - Delete a forked workspace and git branch
|
||||
- `-y --yes` - Skip confirmation prompt
|
||||
- `workspace merge` - Compare and deploy changes between a fork and its parent workspace
|
||||
- `--direction <direction:string>` - Deploy direction: to-parent or to-fork
|
||||
- `--all` - Deploy all changed items including conflicts
|
||||
- `--skip-conflicts` - Skip items modified in both workspaces
|
||||
- `--include <items:string>` - Comma-separated kind:path items to include (e.g. script:f/test/main,flow:f/my/flow)
|
||||
- `--exclude <items:string>` - Comma-separated kind:path items to exclude
|
||||
- `--preserve-on-behalf-of` - Preserve original on_behalf_of/permissioned_as values
|
||||
- `-y --yes` - Non-interactive mode (deploy without prompts)
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -586,6 +586,17 @@ workspace related commands
|
||||
- `--branch, --env <branch:string>` - Specify branch/environment (defaults to current)
|
||||
- `workspace fork [workspace_name:string] [workspace_id:string]` - Create a forked workspace
|
||||
- `--create-workspace-name <workspace_name:string>` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id.
|
||||
- `--color <color:string>` - Workspace color (hex code, e.g. #ff0000)
|
||||
- `--datatable-behavior <behavior:string>` - How to handle datatables: skip, schema_only, or schema_and_data (default: interactive prompt)
|
||||
- `-y --yes` - Skip interactive prompts (defaults datatable behavior to 'skip')
|
||||
- `workspace delete-fork <fork_name:string>` - Delete a forked workspace and git branch
|
||||
- `-y --yes` - Skip confirmation prompt
|
||||
- `workspace merge` - Compare and deploy changes between a fork and its parent workspace
|
||||
- `--direction <direction:string>` - Deploy direction: to-parent or to-fork
|
||||
- `--all` - Deploy all changed items including conflicts
|
||||
- `--skip-conflicts` - Skip items modified in both workspaces
|
||||
- `--include <items:string>` - Comma-separated kind:path items to include (e.g. script:f/test/main,flow:f/my/flow)
|
||||
- `--exclude <items:string>` - Comma-separated kind:path items to exclude
|
||||
- `--preserve-on-behalf-of` - Preserve original on_behalf_of/permissioned_as values
|
||||
- `-y --yes` - Non-interactive mode (deploy without prompts)
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user