mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-12 16:02:56 +00:00
Merge remote-tracking branch 'origin/main' into home-tight-tags-runs-triggers
# Conflicts: # frontend/src/lib/components/common/table/AgentRow.svelte # frontend/src/lib/components/home/ItemsList.svelte # frontend/src/lib/components/home/TreeView.svelte # frontend/src/lib/utils/describeCron.test.ts # frontend/src/lib/utils/describeCron.ts # frontend/src/routes/(root)/(logged)/+page.svelte
This commit is contained in:
@@ -16,7 +16,7 @@ on:
|
||||
default: "global cli"
|
||||
models:
|
||||
description: "Space-separated model aliases (bun run cli -- models); cli skips those it has no agent for"
|
||||
default: "sonnet-5.5 opus-5.5 gpt-6-sol gpt-6-astra deepseek-v4-pro"
|
||||
default: "sonnet-5.5 opus-5.5 gpt-6.1-sol gpt-6-astra deepseek-v4-pro"
|
||||
runs:
|
||||
description: "Runs per case"
|
||||
default: "3"
|
||||
@@ -28,7 +28,7 @@ concurrency:
|
||||
group: ai-evals-scheduled-${{ github.ref }}
|
||||
|
||||
env:
|
||||
MODELS: ${{ inputs.models || 'sonnet-5.5 opus-5.5 gpt-6-sol gpt-6-astra deepseek-v4-pro' }}
|
||||
MODELS: ${{ inputs.models || 'sonnet-5.5 opus-5.5 gpt-6.1-sol gpt-6-astra deepseek-v4-pro' }}
|
||||
RUNS: ${{ inputs.runs || '3' }}
|
||||
REASONING: ${{ inputs.reasoning }}
|
||||
# Routes on the cloud instance are prefixed with their workspace.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "1.827.0"
|
||||
".": "1.828.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
## [1.828.0](https://github.com/windmill-labs/windmill/compare/v1.827.0...v1.828.0) (2026-10-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add gpt-6.1-sol to the chat, agent steps and weekly ai evals ([#11584](https://github.com/windmill-labs/windmill/issues/11584)) ([8f7e0c7](https://github.com/windmill-labs/windmill/commit/8f7e0c72848b56fa97f6f0141d930c7d15044864))
|
||||
* add wmill user list and document post-suspend approvers context ([#11600](https://github.com/windmill-labs/windmill/issues/11600)) ([1c2667c](https://github.com/windmill-labs/windmill/commit/1c2667cd5530925893cf69fd622938b6ffa55d1d))
|
||||
* ask before minting the frontend sdk token in the raw app editor ([#11604](https://github.com/windmill-labs/windmill/issues/11604)) ([6e948b0](https://github.com/windmill-labs/windmill/commit/6e948b07d13b85e9251c3a200bf149f167649bc4))
|
||||
* improve data table roles and grants UX ([#11562](https://github.com/windmill-labs/windmill/issues/11562)) ([d7d18f0](https://github.com/windmill-labs/windmill/commit/d7d18f00af4e2df8fcb1362c9439d2250d0ce4b6))
|
||||
* one page header across routes, and one sidebar for operators and developers ([#11310](https://github.com/windmill-labs/windmill/issues/11310)) ([41162b2](https://github.com/windmill-labs/windmill/commit/41162b270c70ddac6a256bde9acf31a629990e24))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add retry to runScriptByPathAsync and runScriptByHashAsync ([#11485](https://github.com/windmill-labs/windmill/issues/11485)) ([3f3b1c2](https://github.com/windmill-labs/windmill/commit/3f3b1c2c308ddaba377db45308711e7c660d8775))
|
||||
* describe exponential retry as multiplier × seconds^attempt ([#11546](https://github.com/windmill-labs/windmill/issues/11546)) ([a2e51cd](https://github.com/windmill-labs/windmill/commit/a2e51cdbaafb7d71b8e8bbd818e40c472098f9ba))
|
||||
* keep capturing job logs after a non-UTF-8 output line ([#11595](https://github.com/windmill-labs/windmill/issues/11595)) ([0e7d974](https://github.com/windmill-labs/windmill/commit/0e7d974ce9b468353ecb6618dace710704690687))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* load public raw apps without the loading screen ([#11608](https://github.com/windmill-labs/windmill/issues/11608)) ([c09764f](https://github.com/windmill-labs/windmill/commit/c09764fa6516badddbdbb520af32e5d1541cf996))
|
||||
|
||||
## [1.827.0](https://github.com/windmill-labs/windmill/compare/v1.826.0...v1.827.0) (2026-10-07)
|
||||
|
||||
|
||||
|
||||
@@ -148,6 +148,15 @@ export const EVAL_MODELS: EvalModelSpec[] = [
|
||||
model: "gpt-6-astra",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gpt-6.1-sol",
|
||||
label: "GPT-6.1 Sol",
|
||||
aliases: ["gpt-6.1-sol"],
|
||||
frontend: {
|
||||
provider: "openai",
|
||||
model: "gpt-6.1-sol",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gpt-6-sol",
|
||||
label: "GPT-6 Sol",
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH prev AS (SELECT started_at FROM v2_job_queue WHERE id = $1)\n UPDATE v2_job_queue q SET running = false, started_at = null,\n extras = CASE WHEN q.started_at IS NULL OR q.extras ? 'wac_first_started_at' THEN q.extras\n ELSE jsonb_set(COALESCE(q.extras, '{}'::jsonb), '{wac_first_started_at}', to_jsonb(q.started_at))\n END\n FROM prev WHERE q.id = $1\n RETURNING (extract(epoch FROM now() - prev.started_at) * 1000)::bigint",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "int8",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "1333436e3d0466eccdaf99fb70aadad56b50619036fab3f2c1bdbbf280a80217"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT wp.draining FROM v2_job_queue q JOIN worker_ping wp ON wp.worker = q.worker\n WHERE q.id = $1 AND q.workspace_id = $2 AND q.running AND q.started_at IS NOT NULL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "draining",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "1e3407ad2ec46bd965f2aeede0d3cd4a3f9226a5e0c6b15c2580ddd1dba6199b"
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH deleted AS (\n DELETE FROM v2_job_queue WHERE id = $1\n RETURNING id, workspace_id, started_at, worker, canceled_by, canceled_reason,\n (extras->>'wac_first_started_at')::timestamptz AS first_started_at,\n COALESCE($9::bigint, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE(started_at, now()))))*1000)::bigint AS run_ms\n ), completed AS (\n INSERT INTO v2_job_completed AS cj\n ( workspace_id\n , id\n , started_at\n , duration_ms\n , extras\n , result\n , result_columns\n , canceled_by\n , canceled_reason\n , flow_status\n , workflow_as_code_status\n , memory_peak\n , status\n , worker\n )\n SELECT d.workspace_id, d.id, COALESCE(d.first_started_at, d.started_at),\n CASE WHEN d.first_started_at IS NULL THEN d.run_ms\n ELSE EXTRACT('epoch' FROM (now() - d.first_started_at))*1000 END,\n CASE WHEN d.first_started_at IS NOT NULL\n THEN jsonb_build_object('wac_last_segment_ms', d.run_ms) END,\n $3::text::jsonb, $10,\n CASE WHEN $4::BOOL THEN $5 ELSE d.canceled_by END,\n CASE WHEN $4::BOOL THEN $6 WHEN d.canceled_by IS NOT NULL THEN d.canceled_reason END,\n s.flow_status, s.workflow_as_code_status, $8,\n CASE WHEN $4::BOOL OR d.canceled_by IS NOT NULL THEN 'canceled'::job_status\n WHEN $7::BOOL THEN 'skipped'::job_status\n WHEN $2::BOOL THEN 'success'::job_status\n ELSE 'failure'::job_status END,\n d.worker\n FROM deleted d LEFT JOIN v2_job_status s ON s.id = d.id\n ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, result = $3::text::jsonb,\n canceled_by = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL\n THEN EXCLUDED.canceled_by ELSE cj.canceled_by END,\n canceled_reason = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL\n THEN EXCLUDED.canceled_reason ELSE cj.canceled_reason END\n RETURNING cj.started_at, cj.duration_ms,\n COALESCE((cj.extras->>'wac_last_segment_ms')::bigint, cj.duration_ms) AS last_run_ms\n ), parent_ping AS (\n UPDATE v2_job_runtime r SET ping = now()\n FROM v2_job_queue q\n WHERE r.id = $11 AND q.id = r.id AND q.workspace_id = $12\n AND EXISTS (SELECT 1 FROM completed)\n )\n SELECT started_at AS \"started_at?\", duration_ms AS \"duration_ms!\",\n last_run_ms AS \"last_run_ms!\" FROM completed",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "started_at?",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "duration_ms!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "last_run_ms!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Bool",
|
||||
"Text",
|
||||
"Bool",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Bool",
|
||||
"Int4",
|
||||
"Int8",
|
||||
"TextArray",
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "71c9e0c0f1d9eba0f24c8e410ca2f2a64dc559e3228d3b763f68b1ad3491ae6e"
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT wp.draining FROM v2_job j\n JOIN v2_job_queue q ON q.id = j.id\n JOIN worker_ping wp ON wp.worker = q.worker\n WHERE j.id = $1 AND j.workspace_id = $2 AND q.running AND q.started_at IS NOT NULL\n AND ($3::text[] IS NULL OR j.tag = ANY($3))",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "draining",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "7963eff5f7109874b94c0940eaef7d4b418a9e82be56dfe7dd65d160e0dcfd13"
|
||||
}
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH deleted AS (\n DELETE FROM v2_job_queue WHERE id = $1\n RETURNING id, workspace_id, started_at, worker, canceled_by, canceled_reason\n ), completed AS (\n INSERT INTO v2_job_completed AS cj\n ( workspace_id\n , id\n , started_at\n , duration_ms\n , result\n , result_columns\n , canceled_by\n , canceled_reason\n , flow_status\n , workflow_as_code_status\n , memory_peak\n , status\n , worker\n )\n SELECT d.workspace_id, d.id, d.started_at,\n COALESCE($9::bigint, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE(d.started_at, now()))))*1000),\n $3::text::jsonb, $10,\n CASE WHEN $4::BOOL THEN $5 ELSE d.canceled_by END,\n CASE WHEN $4::BOOL THEN $6 WHEN d.canceled_by IS NOT NULL THEN d.canceled_reason END,\n s.flow_status, s.workflow_as_code_status, $8,\n CASE WHEN $4::BOOL OR d.canceled_by IS NOT NULL THEN 'canceled'::job_status\n WHEN $7::BOOL THEN 'skipped'::job_status\n WHEN $2::BOOL THEN 'success'::job_status\n ELSE 'failure'::job_status END,\n d.worker\n FROM deleted d LEFT JOIN v2_job_status s ON s.id = d.id\n ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, result = $3::text::jsonb,\n canceled_by = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL\n THEN EXCLUDED.canceled_by ELSE cj.canceled_by END,\n canceled_reason = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL\n THEN EXCLUDED.canceled_reason ELSE cj.canceled_reason END\n RETURNING duration_ms\n )\n SELECT duration_ms AS \"duration_ms!\" FROM completed",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "duration_ms!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Bool",
|
||||
"Text",
|
||||
"Bool",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Bool",
|
||||
"Int4",
|
||||
"Int8",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "9212902d4eb2834ab3b429968a7a85c53c49e4ef0f42c146324d5c71fbd1e21d"
|
||||
}
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH deleted AS (\n DELETE FROM v2_job_queue WHERE id = $1\n RETURNING id, workspace_id, started_at, worker, canceled_by, canceled_reason\n ), completed AS (\n INSERT INTO v2_job_completed AS cj\n ( workspace_id\n , id\n , started_at\n , duration_ms\n , result\n , result_columns\n , canceled_by\n , canceled_reason\n , flow_status\n , workflow_as_code_status\n , memory_peak\n , status\n , worker\n )\n SELECT d.workspace_id, d.id, d.started_at,\n COALESCE($9::bigint, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE(d.started_at, now()))))*1000),\n $3::text::jsonb, $10,\n CASE WHEN $4::BOOL THEN $5 ELSE d.canceled_by END,\n CASE WHEN $4::BOOL THEN $6 WHEN d.canceled_by IS NOT NULL THEN d.canceled_reason END,\n s.flow_status, s.workflow_as_code_status, $8,\n CASE WHEN $4::BOOL OR d.canceled_by IS NOT NULL THEN 'canceled'::job_status\n WHEN $7::BOOL THEN 'skipped'::job_status\n WHEN $2::BOOL THEN 'success'::job_status\n ELSE 'failure'::job_status END,\n d.worker\n FROM deleted d LEFT JOIN v2_job_status s ON s.id = d.id\n ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, result = $3::text::jsonb,\n canceled_by = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL\n THEN EXCLUDED.canceled_by ELSE cj.canceled_by END,\n canceled_reason = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL\n THEN EXCLUDED.canceled_reason ELSE cj.canceled_reason END\n RETURNING duration_ms\n ), parent_ping AS (\n UPDATE v2_job_runtime r SET ping = now()\n FROM v2_job_queue q\n WHERE r.id = $11 AND q.id = r.id AND q.workspace_id = $12\n AND EXISTS (SELECT 1 FROM completed)\n )\n SELECT duration_ms AS \"duration_ms!\" FROM completed",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "duration_ms!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Bool",
|
||||
"Text",
|
||||
"Bool",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Bool",
|
||||
"Int4",
|
||||
"Int8",
|
||||
"TextArray",
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "975158ae72ec12f3f933fba1d9623bf1c925feebe746299cd1f0eda3f8b949d5"
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE v2_job_queue\n SET suspend = $3, suspend_until = now() + make_interval(secs => $4), started_at = null,\n extras = CASE WHEN started_at IS NULL OR extras ? 'wac_first_started_at' THEN extras\n ELSE jsonb_set(COALESCE(extras, '{}'::jsonb), '{wac_first_started_at}', to_jsonb(started_at))\n END\n WHERE id = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text",
|
||||
"Int4",
|
||||
"Float8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "9e7a5e1ebba42217092971dd340cd9182a4812b14735e502958eaafeb4c6b33d"
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT usr.email, usage.executions\n FROM usr, LATERAL (\n SELECT COALESCE(SUM(c.duration_ms + 1000)/1000 , 0)::BIGINT executions\n FROM v2_job_completed c JOIN v2_job j USING (id)\n WHERE j.workspace_id = $1\n AND j.kind NOT IN ('flow', 'flowpreview', 'flownode', 'singlestepflow')\n AND j.permissioned_as_email = usr.email\n AND now() - '1 week'::interval < j.created_at\n ) usage\n WHERE workspace_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "executions",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "a2f6ca89faaa8d8739f67cd1aae571de793b377dd6058065f51b12c974e5b665"
|
||||
}
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, dedicated_workers, wm_version, vcpus, memory, job_isolation, native_mode) VALUES ($1, $2, COALESCE($3, 'NO IP'), $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT (worker)\n DO UPDATE set ping_at = now(), worker_instance = EXCLUDED.worker_instance, ip = COALESCE($3, worker_ping.ip), custom_tags = EXCLUDED.custom_tags, worker_group = EXCLUDED.worker_group, dedicated_worker = EXCLUDED.dedicated_worker, dedicated_workers = EXCLUDED.dedicated_workers, wm_version = EXCLUDED.wm_version, vcpus = COALESCE(EXCLUDED.vcpus, worker_ping.vcpus), memory = COALESCE(EXCLUDED.memory, worker_ping.memory), job_isolation = EXCLUDED.job_isolation, native_mode = EXCLUDED.native_mode, current_job_id = NULL, current_job_workspace_id = NULL\n RETURNING jobs_executed",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "jobs_executed",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"TextArray",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"TextArray",
|
||||
"Varchar",
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Text",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c748617e060bc41b5922df4b433f20b6971b993e0671b6ccb45db5ef028550bc"
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH deleted AS (\n DELETE FROM v2_job_queue WHERE id = $1\n RETURNING id, workspace_id, started_at, worker, canceled_by, canceled_reason,\n (extras->>'wac_first_started_at')::timestamptz AS first_started_at,\n COALESCE($9::bigint, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE(started_at, now()))))*1000)::bigint AS run_ms\n ), completed AS (\n INSERT INTO v2_job_completed AS cj\n ( workspace_id\n , id\n , started_at\n , duration_ms\n , extras\n , result\n , result_columns\n , canceled_by\n , canceled_reason\n , flow_status\n , workflow_as_code_status\n , memory_peak\n , status\n , worker\n )\n SELECT d.workspace_id, d.id, COALESCE(d.first_started_at, d.started_at),\n CASE WHEN d.first_started_at IS NULL THEN d.run_ms\n ELSE EXTRACT('epoch' FROM (now() - d.first_started_at))*1000 END,\n CASE WHEN d.first_started_at IS NOT NULL\n THEN jsonb_build_object('wac_last_segment_ms', d.run_ms) END,\n $3::text::jsonb, $10,\n CASE WHEN $4::BOOL THEN $5 ELSE d.canceled_by END,\n CASE WHEN $4::BOOL THEN $6 WHEN d.canceled_by IS NOT NULL THEN d.canceled_reason END,\n s.flow_status, s.workflow_as_code_status, $8,\n CASE WHEN $4::BOOL OR d.canceled_by IS NOT NULL THEN 'canceled'::job_status\n WHEN $7::BOOL THEN 'skipped'::job_status\n WHEN $2::BOOL THEN 'success'::job_status\n ELSE 'failure'::job_status END,\n d.worker\n FROM deleted d LEFT JOIN v2_job_status s ON s.id = d.id\n ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, result = $3::text::jsonb,\n canceled_by = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL\n THEN EXCLUDED.canceled_by ELSE cj.canceled_by END,\n canceled_reason = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL\n THEN EXCLUDED.canceled_reason ELSE cj.canceled_reason END\n RETURNING cj.started_at, cj.duration_ms,\n COALESCE((cj.extras->>'wac_last_segment_ms')::bigint, cj.duration_ms) AS last_run_ms\n )\n SELECT started_at AS \"started_at?\", duration_ms AS \"duration_ms!\",\n last_run_ms AS \"last_run_ms!\" FROM completed",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "started_at?",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "duration_ms!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "last_run_ms!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Bool",
|
||||
"Text",
|
||||
"Bool",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Bool",
|
||||
"Int4",
|
||||
"Int8",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "ce39fd9650500b16f7247c8173a40c7e5d2d8a51aea2d36f2bc8c6f86d53a9b8"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT q.id FROM v2_job_queue q\n JOIN native_retry_attempt n ON n.job_id = q.id\n JOIN v2_job r ON r.id = q.id\n JOIN v2_job a ON a.id = r.parent_job\n WHERE q.workspace_id = $2 AND a.parent_job = ANY($1)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"UuidArray",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "de1adae1306fb9d877ec6f2d52235b3f57d4fa4c27861b51da6bfe8c74abd429"
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH prev AS (SELECT started_at FROM v2_job_queue WHERE id = $1)\n UPDATE v2_job_queue q SET running = false, started_at = null\n FROM prev WHERE q.id = $1\n RETURNING (extract(epoch FROM now() - prev.started_at) * 1000)::bigint",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "int8",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "e12f852fa196d16862151ab490b05e6f5c25e23b7c41ce1c0e5a83bf7b118bfd"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE worker_ping SET draining = true WHERE worker = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e4b9c0e8a46dbfe29bace8d8b12123a98ec175f9d3dc72e40a1cb40e14dec919"
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, dedicated_workers, wm_version, vcpus, memory, job_isolation, native_mode, draining) VALUES ($1, $2, COALESCE($3, 'NO IP'), $4, $5, $6, $7, $8, $9, $10, $11, $12, COALESCE($13, false)) ON CONFLICT (worker)\n DO UPDATE set ping_at = now(), worker_instance = EXCLUDED.worker_instance, ip = COALESCE($3, worker_ping.ip), custom_tags = EXCLUDED.custom_tags, worker_group = EXCLUDED.worker_group, dedicated_worker = EXCLUDED.dedicated_worker, dedicated_workers = EXCLUDED.dedicated_workers, wm_version = EXCLUDED.wm_version, vcpus = COALESCE(EXCLUDED.vcpus, worker_ping.vcpus), memory = COALESCE(EXCLUDED.memory, worker_ping.memory), job_isolation = EXCLUDED.job_isolation, native_mode = EXCLUDED.native_mode, current_job_id = NULL, current_job_workspace_id = NULL, draining = COALESCE($13, worker_ping.draining)\n RETURNING jobs_executed",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "jobs_executed",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"TextArray",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"TextArray",
|
||||
"Varchar",
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Text",
|
||||
"Bool",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e7a8c0ee68b8626eb45a7eacdd364360a5d5c783d6ca0a3c4123ea126ed0e60b"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT usr.email, usage.executions\n FROM usr, LATERAL (\n SELECT COALESCE(SUM(COALESCE((c.extras->>'wac_last_segment_ms')::bigint, c.duration_ms) + 1000)/1000 , 0)::BIGINT executions\n FROM v2_job_completed c JOIN v2_job j USING (id)\n WHERE j.workspace_id = $1\n AND j.kind NOT IN ('flow', 'flowpreview', 'flownode', 'singlestepflow')\n AND j.permissioned_as_email = usr.email\n AND now() - '1 week'::interval < j.created_at\n ) usage\n WHERE workspace_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "executions",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "e8779f1cddc475554dbbd2e3122db8a21a2eaacf971bf046cef1ee14cf04e323"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO job_resolution (job_id, workspace_id, resolved_by, note, automatic)\n SELECT c.id, c.workspace_id, NULL, NULL, true\n FROM v2_job_completed c\n JOIN v2_job j ON j.id = c.id\n WHERE c.status = 'failure'\n AND c.workspace_id = $2\n AND j.flow_step_id IS NULL\n AND j.runnable_id IS NOT DISTINCT FROM $3\n AND EXISTS (\n SELECT 1 FROM v2_job_completed sc\n JOIN v2_job sj ON sj.id = sc.id\n JOIN native_retry_attempt nra ON nra.job_id = sc.id\n WHERE sc.status = 'success'\n AND sc.workspace_id = $2\n AND sj.parent_job = $1\n AND sj.runnable_id IS NOT DISTINCT FROM $3\n )\n AND (\n (j.parent_job = $1\n AND EXISTS (SELECT 1 FROM native_retry_attempt WHERE job_id = c.id))\n OR (c.id = $1 AND j.parent_job IS NULL\n AND NOT EXISTS (SELECT 1 FROM native_retry_attempt WHERE job_id = c.id))\n )\n ON CONFLICT (job_id) DO NOTHING",
|
||||
"query": "INSERT INTO job_resolution (job_id, workspace_id, resolved_by, note, automatic)\n SELECT c.id, c.workspace_id, NULL, NULL, true\n FROM v2_job_completed c\n JOIN v2_job j ON j.id = c.id\n WHERE c.status = 'failure'\n AND c.workspace_id = $2\n AND j.flow_step_id IS NULL\n AND j.runnable_id IS NOT DISTINCT FROM $3\n AND EXISTS (\n SELECT 1 FROM v2_job_completed sc\n JOIN v2_job sj ON sj.id = sc.id\n JOIN native_retry_attempt nra ON nra.job_id = sc.id\n WHERE sc.status = 'success'\n AND sc.workspace_id = $2\n AND sj.parent_job = $1\n AND sj.runnable_id IS NOT DISTINCT FROM $3\n )\n AND (\n (j.parent_job = $1\n AND EXISTS (SELECT 1 FROM native_retry_attempt WHERE job_id = c.id))\n OR (c.id = $1\n AND NOT EXISTS (SELECT 1 FROM native_retry_attempt WHERE job_id = c.id))\n )\n ON CONFLICT (job_id) DO NOTHING",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -12,5 +12,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e34cb7cd6ff9d80e77a51ae47c3cfb9b0772d5d3d239273fc5cea322cae4e258"
|
||||
"hash": "eecea4a5ba0de73129bde6ed0c229690c322bc376b1bfc13ed719126f72cabb9"
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE v2_job_queue\n SET suspend = $3, suspend_until = now() + make_interval(secs => $4), started_at = null\n WHERE id = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text",
|
||||
"Int4",
|
||||
"Float8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f0498c9bddc0d4d8948167f3ed849bf8b3523d8dde1ef6b8f1950423830cc6ed"
|
||||
}
|
||||
+8
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT worker, worker_instance, EXTRACT(EPOCH FROM (now() - ping_at))::integer as last_ping, started_at, ip, jobs_executed,\n CASE WHEN $4 IS TRUE THEN current_job_id ELSE NULL END as last_job_id, CASE WHEN $4 IS TRUE THEN current_job_workspace_id ELSE NULL END as last_job_workspace_id,\n custom_tags, worker_group, wm_version, occupancy_rate, occupancy_rate_15s, occupancy_rate_5m, occupancy_rate_30m, memory, vcpus, memory_usage, wm_memory_usage, job_isolation, native_mode\n FROM worker_ping\n WHERE ($1::integer IS NULL AND ping_at > now() - interval '5 minute') OR (ping_at > now() - ($1 || ' seconds')::interval)\n ORDER BY ping_at desc LIMIT $2 OFFSET $3",
|
||||
"query": "SELECT worker, worker_instance, EXTRACT(EPOCH FROM (now() - ping_at))::integer as last_ping, started_at, ip, jobs_executed,\n CASE WHEN $4 IS TRUE THEN current_job_id ELSE NULL END as last_job_id, CASE WHEN $4 IS TRUE THEN current_job_workspace_id ELSE NULL END as last_job_workspace_id,\n custom_tags, worker_group, wm_version, occupancy_rate, occupancy_rate_15s, occupancy_rate_5m, occupancy_rate_30m, memory, vcpus, memory_usage, wm_memory_usage, job_isolation, native_mode, draining\n FROM worker_ping\n WHERE ($1::integer IS NULL AND ping_at > now() - interval '5 minute') OR (ping_at > now() - ($1 || ' seconds')::interval)\n ORDER BY ping_at desc LIMIT $2 OFFSET $3",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -107,6 +107,11 @@
|
||||
"ordinal": 20,
|
||||
"name": "native_mode",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 21,
|
||||
"name": "draining",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -138,8 +143,9 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "68bca8f839e47705b11d312ee874eceaa3d1d24d9053ad4aea94b9f8465585ca"
|
||||
"hash": "fe22c4609d8b2472c0f83fedb92eceec2b6f1ae1a32fcf375161d04419404d9c"
|
||||
}
|
||||
Generated
+89
-89
@@ -6685,9 +6685,9 @@ checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02"
|
||||
|
||||
[[package]]
|
||||
name = "inventory"
|
||||
version = "0.3.24"
|
||||
version = "0.3.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b"
|
||||
checksum = "6928282826c822ad91bf1c9a1cb90a30ba1c26770749929b4656cd6be829cd7c"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
@@ -9617,7 +9617,7 @@ version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||
dependencies = [
|
||||
"toml_edit 0.25.15+spec-1.1.0",
|
||||
"toml_edit 0.25.16+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -13622,9 +13622,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
|
||||
checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
@@ -13644,21 +13644,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.25.15+spec-1.1.0"
|
||||
version = "0.25.16+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614"
|
||||
checksum = "88f38f3af8bf71841c67a990f4e2b52143b2d0a1a994c72513ff4c14977fc722"
|
||||
dependencies = [
|
||||
"indexmap 2.14.2",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_datetime 1.1.2+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
version = "1.1.4+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
|
||||
checksum = "bed810ee007d8e7f9cf20b4774e7198142fa27186e0f805871da7f3c39eaf333"
|
||||
dependencies = [
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
@@ -14829,7 +14829,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -14918,7 +14918,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-ai"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
@@ -14952,7 +14952,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-alerting"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14965,7 +14965,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -15105,7 +15105,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-agent-workers"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15128,7 +15128,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-assets"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15145,7 +15145,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-auth"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15171,7 +15171,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-client"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
@@ -15181,7 +15181,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-configs"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15198,7 +15198,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-debug"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"base64 0.22.1",
|
||||
@@ -15220,7 +15220,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-embeddings"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15243,7 +15243,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flow-conversations"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15259,7 +15259,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flows"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15281,7 +15281,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-groups"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15303,7 +15303,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-inputs"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15317,7 +15317,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-integration-tests"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -15353,7 +15353,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-jobs"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15379,7 +15379,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-npm-proxy"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15407,7 +15407,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-openapi"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15429,7 +15429,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-schedule"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15449,7 +15449,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-scripts"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15491,7 +15491,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-settings"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15520,7 +15520,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-sse"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"serde",
|
||||
@@ -15532,7 +15532,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-users"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"axum 0.8.9",
|
||||
@@ -15556,7 +15556,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workers"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15570,7 +15570,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workspaces"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15609,7 +15609,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-audit"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"lazy_static",
|
||||
@@ -15624,7 +15624,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-autoscaling"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15643,7 +15643,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"aho-corasick",
|
||||
@@ -15757,7 +15757,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-dep-map"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"futures",
|
||||
@@ -15777,7 +15777,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-git-sync"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde",
|
||||
@@ -15794,7 +15794,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-indexer"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"astral-tokio-tar",
|
||||
@@ -15821,7 +15821,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-jseval"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -15838,7 +15838,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
@@ -15854,7 +15854,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-mcp"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15875,7 +15875,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-native-triggers"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15906,7 +15906,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-oauth"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -15931,7 +15931,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-object-store"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -15967,7 +15967,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-operator"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -15985,7 +15985,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"convert_case 0.6.0",
|
||||
"serde",
|
||||
@@ -15994,7 +15994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16006,7 +16006,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -16018,7 +16018,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -16030,7 +16030,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16042,7 +16042,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -16054,7 +16054,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -16065,7 +16065,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -16076,7 +16076,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -16088,7 +16088,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -16099,7 +16099,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16121,7 +16121,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -16133,7 +16133,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16147,7 +16147,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case 0.6.0",
|
||||
@@ -16164,7 +16164,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16177,7 +16177,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -16189,7 +16189,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16207,7 +16207,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -16223,7 +16223,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -16239,7 +16239,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16253,7 +16253,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-queue"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16292,7 +16292,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-runtime-nativets"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"const_format",
|
||||
@@ -16332,7 +16332,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-sql-datatype-parser-wasm"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"wasm-bindgen",
|
||||
@@ -16343,7 +16343,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-store"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16378,7 +16378,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-test-utils"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16402,7 +16402,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16435,7 +16435,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-amqp"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16462,7 +16462,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-azure"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16495,7 +16495,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-email"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16515,7 +16515,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-gcp"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16549,7 +16549,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-http"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16586,7 +16586,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-kafka"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16609,7 +16609,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-mqtt"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16633,7 +16633,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-nats"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -16657,7 +16657,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-postgres"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16692,7 +16692,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-sqs"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16720,7 +16720,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-websocket"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16745,7 +16745,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.13.2",
|
||||
@@ -16764,7 +16764,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-once-cell",
|
||||
@@ -16883,7 +16883,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker-volumes"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -88,7 +88,7 @@ members = [
|
||||
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
1600d49ad5513af8b54ad65688d3d1c7edc977d9
|
||||
017710bdd0a10de857f0f40800dde238cdb1ad24
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE worker_ping DROP COLUMN IF EXISTS draining;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE worker_ping ADD COLUMN IF NOT EXISTS draining BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
+24
-24
@@ -6216,7 +6216,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"anyhow",
|
||||
@@ -6302,7 +6302,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6314,7 +6314,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"serde",
|
||||
@@ -6323,7 +6323,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6335,7 +6335,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6347,7 +6347,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -6359,7 +6359,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6371,7 +6371,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6383,7 +6383,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -6394,7 +6394,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6405,7 +6405,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6417,7 +6417,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6428,7 +6428,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -6450,7 +6450,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6462,7 +6462,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6476,7 +6476,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case",
|
||||
@@ -6493,7 +6493,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6506,7 +6506,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -6518,7 +6518,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6536,7 +6536,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -6552,7 +6552,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6568,7 +6568,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wasm"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"getrandom 0.2.17",
|
||||
@@ -6600,7 +6600,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6614,7 +6614,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
|
||||
@@ -12,7 +12,7 @@ resolver = "2"
|
||||
members = ["."]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.827.0"
|
||||
version = "1.828.0"
|
||||
edition = "2021"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
|
||||
|
||||
@@ -219,7 +219,7 @@ websocket_trigger: path(char), url(char), script_path(char), is_flow(bool), work
|
||||
windmill_migrations: name(text), created_at(ts)
|
||||
worker_group_job_stats: hour(bigint), worker_group(text), script_lang(char), workspace_id(char), job_count(int), total_duration_ms(bigint)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
worker_ping: worker(char), worker_instance(char), ping_at(ts), started_at(ts), ip(char), jobs_executed(int), custom_tags(text[]), worker_group(char), dedicated_worker(char), wm_version(char), current_job_id(uuid), current_job_workspace_id(char), vcpus(bigint), memory(bigint), occupancy_rate(float), memory_usage(bigint), wm_memory_usage(bigint), occupancy_rate_15s(float), occupancy_rate_5m(float), occupancy_rate_30m(float), job_isolation(text), dedicated_workers(text[])
|
||||
worker_ping: worker(char), worker_instance(char), ping_at(ts), started_at(ts), ip(char), jobs_executed(int), custom_tags(text[]), worker_group(char), dedicated_worker(char), wm_version(char), current_job_id(uuid), current_job_workspace_id(char), vcpus(bigint), memory(bigint), occupancy_rate(float), memory_usage(bigint), wm_memory_usage(bigint), occupancy_rate_15s(float), occupancy_rate_5m(float), occupancy_rate_30m(float), job_isolation(text), dedicated_workers(text[]), draining(bool)
|
||||
workspace: id(char), name(char), owner(char), deleted(bool), premium(bool), parent_workspace_id(char), is_dev_workspace(bool)
|
||||
FK: (parent_workspace_id) -> workspace(id)
|
||||
workspace_dependencies: id(bigint), name(char), content(text), language(script_lang), description(text), archived(bool), workspace_id(char), created_at(ts)
|
||||
|
||||
@@ -411,3 +411,70 @@ async fn test_error_handler_not_triggered_on_success(db: Pool<Postgres>) -> anyh
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A hub script run at the top level (a schedule, a trigger, the API) is a `script_hub` job,
|
||||
/// and its failure must reach the workspace error handler like a workspace script's.
|
||||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_error_handler_triggered_on_hub_script_failure(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
use windmill_common::jobs::JobPayload;
|
||||
|
||||
initialize_tracing().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
|
||||
let version = "990000002";
|
||||
let hub_dir = &*windmill_common::worker::HUB_CACHE_DIR;
|
||||
tokio::fs::create_dir_all(hub_dir).await?;
|
||||
tokio::fs::write(
|
||||
format!("{hub_dir}/{version}"),
|
||||
r#"{"content":"exit 1","lockfile":null,"language":"bash","schema":{},"summary":null}"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// The workspace handler settings are cached per workspace id across the tests of this
|
||||
// binary, so this reuses the handler path every other test here configures.
|
||||
sqlx::query(
|
||||
"INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock)
|
||||
VALUES ('test-workspace', 1111111112, 'f/test/error_handler', 'echo handled', 'bash', 'script', 'test-user', '{}', '', '', '')",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
r#"UPDATE workspace_settings SET error_handler = '{"path": "script/f/test/error_handler"}'::jsonb
|
||||
WHERE workspace_id = 'test-workspace'"#,
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let completed_job = RunJob::from(JobPayload::ScriptHub {
|
||||
path: format!("hub/{version}/test/fail"),
|
||||
apply_preprocessor: false,
|
||||
})
|
||||
.run_until_complete(&db, false, server.addr.port())
|
||||
.await;
|
||||
assert!(!completed_job.success, "Hub script should have failed");
|
||||
|
||||
let mut handler_parent = None;
|
||||
for _ in 0..50 {
|
||||
handler_parent = sqlx::query_scalar::<_, Option<uuid::Uuid>>(
|
||||
"SELECT parent_job FROM v2_job
|
||||
WHERE workspace_id = 'test-workspace' AND permissioned_as_email = 'error_handler@windmill.dev'",
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
if handler_parent.is_some() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
assert_eq!(
|
||||
handler_parent,
|
||||
Some(Some(completed_job.id)),
|
||||
"Workspace error handler should handle the failed hub script"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+26
-1
@@ -176,6 +176,10 @@ async fn test_all_metrics_e2e() {
|
||||
otel_set_worker_uptime("w-uptime", 3600.0);
|
||||
otel_set_health_status_phase("healthy");
|
||||
otel_set_health_db_unresponsive(true);
|
||||
otel_set_worker_memory("w-res", "gpu", Some(512), Some(128), Some(1024));
|
||||
otel_add_worker_cpu("w-res", "gpu", 1.5, 40, 7, 0.35);
|
||||
otel_set_worker_occupancy_rate("w-res", "gpu", "5m", 0.25);
|
||||
otel_record_job_memory_peak("python3", 100 * 1024 * 1024);
|
||||
|
||||
// ── Histograms ──────────────────────────────────────────────────
|
||||
|
||||
@@ -189,7 +193,7 @@ async fn test_all_metrics_e2e() {
|
||||
let metrics = flush_and_get_metrics(&state);
|
||||
let names = metric_names(&metrics);
|
||||
|
||||
// ── Verify all 21 metric names are present ──────────────────────
|
||||
// ── Verify all metric names are present ─────────────────────────
|
||||
|
||||
let expected = [
|
||||
"windmill.queue.push_count",
|
||||
@@ -213,6 +217,15 @@ async fn test_all_metrics_e2e() {
|
||||
"windmill.worker.uptime",
|
||||
"windmill.health.status",
|
||||
"windmill.health.db_unresponsive",
|
||||
"windmill.worker.memory_usage",
|
||||
"windmill.worker.windmill_memory_usage",
|
||||
"windmill.worker.memory_limit",
|
||||
"windmill.worker.cpu_usage",
|
||||
"windmill.worker.cpu_periods",
|
||||
"windmill.worker.cpu_throttled_periods",
|
||||
"windmill.worker.cpu_throttled_time",
|
||||
"windmill.worker.occupancy_rate",
|
||||
"windmill.worker.job_memory_peak",
|
||||
];
|
||||
for name in expected {
|
||||
assert!(
|
||||
@@ -290,6 +303,18 @@ async fn test_all_metrics_e2e() {
|
||||
.expect("worker.busy data point with worker=worker-test-1 not found");
|
||||
assert_eq!(dp.1, 1);
|
||||
|
||||
let m = find_metric(&metrics, "windmill.worker.memory_limit").unwrap();
|
||||
let values = gauge_i64_values(m);
|
||||
let dp = values
|
||||
.iter()
|
||||
.find(|(attrs, _)| {
|
||||
attrs
|
||||
.iter()
|
||||
.any(|kv| kv.key.as_str() == "worker_group" && kv.value.as_str() == "gpu")
|
||||
})
|
||||
.expect("worker.memory_limit data point with worker_group=gpu not found");
|
||||
assert_eq!(dp.1, 1024);
|
||||
|
||||
let m = find_metric(&metrics, "windmill.db.pool.active").unwrap();
|
||||
assert_eq!(gauge_i64_values(m)[0].1, 5);
|
||||
let m = find_metric(&metrics, "windmill.db.pool.idle").unwrap();
|
||||
|
||||
@@ -239,3 +239,70 @@ export async function main(path: string, email: string, job_id: string, is_flow:
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A hub script run at the top level (a schedule, a trigger, the API) is a `script_hub` job,
|
||||
/// and its success must reach the workspace success handler like a workspace script's.
|
||||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_success_handler_triggered_on_hub_script_success(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
use windmill_common::jobs::JobPayload;
|
||||
|
||||
initialize_tracing().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
|
||||
let version = "990000003";
|
||||
let hub_dir = &*windmill_common::worker::HUB_CACHE_DIR;
|
||||
tokio::fs::create_dir_all(hub_dir).await?;
|
||||
tokio::fs::write(
|
||||
format!("{hub_dir}/{version}"),
|
||||
r#"{"content":"echo ok","lockfile":null,"language":"bash","schema":{},"summary":null}"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// The workspace handler settings are cached per workspace id across the tests of this
|
||||
// binary, so this reuses the handler path every other test here configures.
|
||||
sqlx::query(
|
||||
"INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock)
|
||||
VALUES ('test-workspace', 1234567891, 'f/test/success_handler', 'echo handled', 'bash', 'script', 'test-user', '{}', '', '', '')",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
r#"UPDATE workspace_settings SET success_handler = '{"path": "script/f/test/success_handler"}'::jsonb
|
||||
WHERE workspace_id = 'test-workspace'"#,
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let completed_job = RunJob::from(JobPayload::ScriptHub {
|
||||
path: format!("hub/{version}/test/ok"),
|
||||
apply_preprocessor: false,
|
||||
})
|
||||
.run_until_complete(&db, false, server.addr.port())
|
||||
.await;
|
||||
assert!(completed_job.success, "Hub script should have succeeded");
|
||||
|
||||
let mut handler_parent = None;
|
||||
for _ in 0..50 {
|
||||
handler_parent = sqlx::query_scalar::<_, Option<uuid::Uuid>>(
|
||||
"SELECT parent_job FROM v2_job
|
||||
WHERE workspace_id = 'test-workspace' AND permissioned_as_email = 'success_handler@windmill.dev'",
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
if handler_parent.is_some() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
assert_eq!(
|
||||
handler_parent,
|
||||
Some(Some(completed_job.id)),
|
||||
"Workspace success handler should handle the successful hub script"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -109,3 +109,91 @@ async fn wac_suspend_stands_down_for_a_cancel(db: Pool<Postgres>) -> anyhow::Res
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Each park clears `started_at`, so the completion would otherwise store only the last
|
||||
/// segment: a workflow that waited an hour on its tasks would read as a sub-second job that
|
||||
/// started an hour late. The completed row spans the workflow from its first start, and
|
||||
/// keeps the last segment aside: that is what is metered and what worker-time readers sum.
|
||||
#[sqlx::test]
|
||||
async fn wac_completion_spans_the_workflow_and_keeps_the_last_segment(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
let job_id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO v2_job (id, workspace_id, created_by, created_at, permissioned_as, \
|
||||
permissioned_as_email, kind, script_lang, runnable_path, tag, visible_to_owner) \
|
||||
VALUES ($1, 'test-workspace', 'test-user', now() - interval '2 hours', 'u/test-user', \
|
||||
'test@windmill.dev', 'script', 'bun', 'u/test-user/wac', 'bun', true)",
|
||||
)
|
||||
.bind(job_id)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, tag, started_at) \
|
||||
VALUES ($1, 'test-workspace', now(), true, 'bun', now() - interval '1 hour')",
|
||||
)
|
||||
.bind(job_id)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// Two parks, each followed by the pull re-stamping `started_at`: the second park must
|
||||
// not replace the first start with its own segment's.
|
||||
for resumed_ago in ["10 minutes", "3 seconds"] {
|
||||
let mut tx = db.begin().await?;
|
||||
suspend_wac_parent(&mut tx, &job_id, "test-workspace", 1, 3600.0).await?;
|
||||
tx.commit().await?;
|
||||
sqlx::query(
|
||||
"UPDATE v2_job_queue SET suspend = 0, started_at = now() - $2::text::interval \
|
||||
WHERE id = $1",
|
||||
)
|
||||
.bind(job_id)
|
||||
.bind(resumed_ago)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let job = windmill_queue::get_mini_completed_job(&job_id, "test-workspace", &db)
|
||||
.await?
|
||||
.unwrap();
|
||||
let result = serde_json::value::to_raw_value(&serde_json::json!("done"))?;
|
||||
let (_, span) = windmill_queue::add_completed_job(
|
||||
&db,
|
||||
&job,
|
||||
true,
|
||||
false,
|
||||
sqlx::types::Json(&result),
|
||||
None,
|
||||
0,
|
||||
None,
|
||||
false,
|
||||
Some(3000),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let (started_ago_s, duration_ms, last_segment_ms): (f64, i64, Option<i64>) = sqlx::query_as(
|
||||
"SELECT extract(epoch FROM now() - started_at)::float8, duration_ms, \
|
||||
(extras->>'wac_last_segment_ms')::bigint \
|
||||
FROM v2_job_completed WHERE id = $1",
|
||||
)
|
||||
.bind(job_id)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert!(
|
||||
(started_ago_s - 3600.0).abs() < 60.0,
|
||||
"started_at must be the first start, got {started_ago_s}s ago"
|
||||
);
|
||||
assert!(
|
||||
(duration_ms - 3_600_000).abs() < 60_000,
|
||||
"duration_ms must be the wall time since the first start, got {duration_ms}"
|
||||
);
|
||||
assert_eq!(last_segment_ms, Some(3000));
|
||||
assert_eq!(
|
||||
span.duration_ms, duration_ms,
|
||||
"a flow records the step with the span the row holds"
|
||||
);
|
||||
assert!(span.outlasts_run(job.started_at));
|
||||
assert_eq!(span.last_run_ms, 3000, "only the last segment is left to meter");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2,10 +2,19 @@ use sqlx::{Pool, Postgres};
|
||||
use uuid::Uuid;
|
||||
use windmill_common::{
|
||||
external_ip::UNKNOWN_IP,
|
||||
worker::{insert_ping_query, update_worker_ping_main_loop_query},
|
||||
worker::{insert_ping_query, set_worker_draining_query, update_worker_ping_main_loop_query},
|
||||
};
|
||||
|
||||
async fn insert_ping(db: &Pool<Postgres>, worker: &str, ip: Option<&str>) -> anyhow::Result<()> {
|
||||
register(db, worker, ip, None).await
|
||||
}
|
||||
|
||||
async fn register(
|
||||
db: &Pool<Postgres>,
|
||||
worker: &str,
|
||||
ip: Option<&str>,
|
||||
draining: Option<bool>,
|
||||
) -> anyhow::Result<()> {
|
||||
insert_ping_query(
|
||||
"test-instance",
|
||||
worker,
|
||||
@@ -19,12 +28,33 @@ async fn insert_ping(db: &Pool<Postgres>, worker: &str, ip: Option<&str>) -> any
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
draining,
|
||||
db,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A worker re-registers mid-life once its external IP resolves. That registration must not
|
||||
/// clear a draining flag the same process already published; only a worker starting up under
|
||||
/// a reclaimed name resets it.
|
||||
#[sqlx::test]
|
||||
async fn only_a_starting_worker_resets_draining(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let draining = || async {
|
||||
sqlx::query_scalar::<_, bool>("SELECT draining FROM worker_ping WHERE worker = 'wk-drain'")
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
};
|
||||
register(&db, "wk-drain", None, Some(false)).await?;
|
||||
set_worker_draining_query("wk-drain", &db).await?;
|
||||
register(&db, "wk-drain", Some("1.2.3.4"), None).await?;
|
||||
assert!(draining().await?);
|
||||
|
||||
register(&db, "wk-drain", None, Some(false)).await?;
|
||||
assert!(!draining().await?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The external IP resolves in the background, so the initial ping often has none yet. That must
|
||||
/// not blank the address a previous process wrote to the row this one reclaims — worker names are
|
||||
/// stable across restarts under EXIT_AFTER_N_JOBS.
|
||||
|
||||
@@ -62,9 +62,13 @@ const REASONING_RULES: &[ReasoningRule] = &[
|
||||
can_disable: false,
|
||||
completions_tools_need_off: false,
|
||||
},
|
||||
// Live-verified: astra takes low..max only, where sol and luna also take `none`.
|
||||
// Live-verified: gpt-6-astra and gpt-6.1-sol take low..max only, where gpt-6-sol and
|
||||
// gpt-6-luna also take `none`.
|
||||
ReasoningRule {
|
||||
matches: |m| base_id(m).starts_with("gpt-6-astra"),
|
||||
matches: |m| {
|
||||
let base = base_id(m);
|
||||
base.starts_with("gpt-6-astra") || base.starts_with("gpt-6.1-sol")
|
||||
},
|
||||
can_disable: false,
|
||||
completions_tools_need_off: true,
|
||||
},
|
||||
|
||||
@@ -996,6 +996,7 @@ pub fn is_own_job_runtime_route(route_path: &str, http_method: &str, job_id: uui
|
||||
["job_metrics", "set_progress", ..] => (http_method == "POST", 2),
|
||||
["job_metrics", "get_progress", ..] => (http_method == "GET", 2),
|
||||
["jobs" | "jobs_u", "resume_urls" | "wac_approval_urls", ..] => (http_method == "GET", 2),
|
||||
["jobs", "worker_is_draining", ..] => (http_method == "GET", 2),
|
||||
["jobs", "wac", "inline_checkpoint", ..] => (http_method == "POST", 3),
|
||||
["jobs", "run", "workflow_as_code", ..] => (http_method == "POST", 3),
|
||||
_ => return false,
|
||||
|
||||
@@ -417,6 +417,89 @@ async fn concurrent_role_creations_both_survive(db: Pool<Postgres>) -> anyhow::R
|
||||
outcome
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
||||
async fn an_existing_role_is_taken_over_only_when_asked_and_never_with_members(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let suffix: String = uuid::Uuid::new_v4().simple().to_string()[..8].to_string();
|
||||
let plain = format!("wmtest_plain_{suffix}");
|
||||
let group = format!("wmtest_group_{suffix}");
|
||||
let member = format!("wmtest_member_{suffix}");
|
||||
for sql in [
|
||||
// An expired password and no connection slot: kept as is, the login Windmill sets is dead.
|
||||
format!(
|
||||
"CREATE ROLE \"{plain}\" LOGIN PASSWORD 'theirs' VALID UNTIL '2000-01-01' \
|
||||
CONNECTION LIMIT 0"
|
||||
),
|
||||
format!("CREATE ROLE \"{group}\" NOLOGIN"),
|
||||
format!("CREATE ROLE \"{member}\" LOGIN"),
|
||||
format!("GRANT \"{group}\" TO \"{member}\""),
|
||||
] {
|
||||
sqlx::query(&sql).execute(&db).await?;
|
||||
}
|
||||
|
||||
let create = |name: String, take_over: bool| async move {
|
||||
let resp = authed(
|
||||
client().post(format!(
|
||||
"http://localhost:{port}/api/settings/datatable_roles"
|
||||
)),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&json!({ "name": name, "take_over": take_over }))
|
||||
.send()
|
||||
.await?;
|
||||
Ok::<_, anyhow::Error>((resp.status().as_u16(), resp.text().await?))
|
||||
};
|
||||
let outcome = async {
|
||||
let (status, body) = create(plain.clone(), false).await?;
|
||||
assert_eq!(
|
||||
status, 409,
|
||||
"an existing role is not taken without asking: {body}"
|
||||
);
|
||||
|
||||
// The group's member would inherit every grant the data table role gets.
|
||||
let (status, body) = create(group.clone(), true).await?;
|
||||
assert_eq!(status, 400, "{body}");
|
||||
assert!(
|
||||
body.contains(&member),
|
||||
"the refusal names the member: {body}"
|
||||
);
|
||||
|
||||
let (status, body) = create(plain.clone(), true).await?;
|
||||
assert_eq!(status, 200, "{body}");
|
||||
let catalog = windmill_common::datatable_roles::read_role_catalog(
|
||||
&db,
|
||||
windmill_common::datatable_roles::DatatableRoleCluster::Instance,
|
||||
)
|
||||
.await?;
|
||||
let taken = catalog
|
||||
.values()
|
||||
.find(|r| r.name == plain)
|
||||
.ok_or_else(|| anyhow::anyhow!("{plain} is not in the catalog: {catalog:?}"))?;
|
||||
let options = (*db.connect_options())
|
||||
.clone()
|
||||
.username(&plain)
|
||||
.password(taken.pwd.as_deref().unwrap_or_default());
|
||||
let mut login = <sqlx::PgConnection as sqlx::Connection>::connect_with(&options).await?;
|
||||
sqlx::query("SELECT 1").execute(&mut login).await?;
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
// Roles are cluster-wide and outlive this test's database, whatever happened above.
|
||||
for name in [&member, &group, &plain] {
|
||||
let _ = sqlx::query(&format!("DROP ROLE IF EXISTS \"{name}\""))
|
||||
.execute(&db)
|
||||
.await;
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
||||
async fn a_role_delete_that_fails_part_way_leaves_the_role_disabled(
|
||||
|
||||
@@ -702,3 +702,51 @@ async fn test_list_filtered_job_uuids_resolved_excludes_queue(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_worker_is_draining_follows_the_job_worker(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let job = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, kind, tag, args)
|
||||
VALUES ($1, 'test-workspace', 'test-user', 'u/test-user', 'script', 'deno', '{}'::jsonb)",
|
||||
)
|
||||
.bind(job)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag, running, started_at, worker)
|
||||
VALUES ($1, 'test-workspace', now(), 'deno', true, now(), 'wk-drain-test')",
|
||||
)
|
||||
.bind(job)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO worker_ping (worker, worker_instance, ip, worker_group, wm_version)
|
||||
VALUES ('wk-drain-test', 'host', 'NO IP', 'default', 'v0')",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let url = format!("http://localhost:{port}/api/w/test-workspace/jobs/worker_is_draining/{job}");
|
||||
let draining = || async {
|
||||
let resp = authed(client().get(&url)).send().await?;
|
||||
anyhow::Ok(resp.json::<bool>().await?)
|
||||
};
|
||||
assert!(!draining().await?);
|
||||
|
||||
windmill_common::worker::set_worker_draining_query("wk-drain-test", &db).await?;
|
||||
assert!(draining().await?);
|
||||
|
||||
// A parked job keeps its last worker on the queue row without running on it.
|
||||
sqlx::query("UPDATE v2_job_queue SET started_at = NULL WHERE id = $1")
|
||||
.bind(job)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
assert!(!draining().await?);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ use windmill_common::{
|
||||
flow_conversations::{
|
||||
add_message_to_conversation_tx, message_attachments, MessageExtras, MessageType,
|
||||
},
|
||||
flows::Retry,
|
||||
get_latest_flow_version_info_for_path,
|
||||
jobs::{
|
||||
check_tag_available_for_workspace_internal, format_result, script_path_to_payload,
|
||||
@@ -126,6 +127,71 @@ pub async fn drop_unclaimable_run_lineage(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies the run's `retry` policy by handing `push` a one-step-flow request, which it
|
||||
/// materializes as a native retryable `Script`.
|
||||
pub fn with_run_retry(
|
||||
run_query: &RunJobQuery,
|
||||
payload: JobPayload,
|
||||
args: &PushArgs<'_>,
|
||||
tag: &Option<String>,
|
||||
) -> error::Result<JobPayload> {
|
||||
let Some(retry) = run_query.retry.as_deref() else {
|
||||
return Ok(payload);
|
||||
};
|
||||
// Retry attempts are always pushed visible to the owner, which would leak a private run.
|
||||
if run_query.invisible_to_owner.unwrap_or(false) {
|
||||
return Err(Error::BadRequest(
|
||||
"retry cannot be combined with invisible_to_owner".to_string(),
|
||||
));
|
||||
}
|
||||
let retry: Retry = serde_json::from_str(retry)
|
||||
.map_err(|e| Error::BadRequest(format!("invalid retry policy: {e}")))?;
|
||||
let JobPayload::ScriptHash {
|
||||
hash,
|
||||
path,
|
||||
cache_ttl,
|
||||
cache_ignore_s3_path,
|
||||
language,
|
||||
priority,
|
||||
apply_preprocessor,
|
||||
debouncing_settings,
|
||||
concurrency_settings,
|
||||
..
|
||||
} = payload
|
||||
else {
|
||||
return Err(Error::BadRequest(
|
||||
"retry is only supported for workspace scripts".to_string(),
|
||||
));
|
||||
};
|
||||
// A failed attempt keeps its raw args and the retry re-push skips the preprocessor, so
|
||||
// every retry would call `main` with un-preprocessed input.
|
||||
if apply_preprocessor {
|
||||
return Err(Error::BadRequest(
|
||||
"retry is not supported for a script with a preprocessor unless skip_preprocessor is set"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
Ok(JobPayload::SingleStepFlow {
|
||||
path,
|
||||
hash: Some(hash),
|
||||
flow_version: None,
|
||||
language: Some(language),
|
||||
args: HashMap::from(args),
|
||||
retry: Some(retry),
|
||||
error_handler_path: None,
|
||||
error_handler_args: None,
|
||||
skip_handler: None,
|
||||
cache_ttl,
|
||||
cache_ignore_s3_path,
|
||||
priority,
|
||||
tag_override: tag.clone(),
|
||||
trigger_path: None,
|
||||
apply_preprocessor,
|
||||
concurrency_settings,
|
||||
debouncing_settings,
|
||||
})
|
||||
}
|
||||
|
||||
/// The jobs of `referenced` that `authed` cannot claim as its own run lineage: anything but the
|
||||
/// token's own job and that job's `parent_job`, `root_job` and `flow_innermost_root_job`, or for
|
||||
/// a workspace admin anything outside the workspace. A restricted job token never gets the admin
|
||||
@@ -1151,6 +1217,7 @@ pub async fn push_script_job_by_path_into_queue<'c>(
|
||||
let tag = run_query.tag.clone().or(tag);
|
||||
let push_args = PushArgs { args: &args.args, extra: args.extra };
|
||||
check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?;
|
||||
let job_payload = with_run_retry(&run_query, job_payload, &push_args, &tag)?;
|
||||
|
||||
let return_tx = tx_o.is_some();
|
||||
|
||||
|
||||
@@ -52,6 +52,8 @@ pub struct RunJobQuery {
|
||||
pub trigger_external_id: Option<String>,
|
||||
pub service_name: Option<String>,
|
||||
pub suspended_mode: Option<bool>,
|
||||
/// A JSON-encoded `Retry` policy for a script run; see `with_run_retry`.
|
||||
pub retry: Option<String>,
|
||||
}
|
||||
|
||||
impl RunJobQuery {
|
||||
|
||||
@@ -58,10 +58,10 @@ use windmill_common::{
|
||||
error::{self, pg_error_message, JsonResult, Result},
|
||||
get_database_url,
|
||||
global_settings::{
|
||||
ACCENT_COLOR_SETTING, AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING,
|
||||
AUTOMATE_USERNAME_CREATION_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING, CUSTOM_TAGS_SETTING,
|
||||
DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_HUB_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS,
|
||||
EXTERNAL_INSTANCE_PG_SETTING,
|
||||
ACCENT_COLOR_SETTING, AI_CONFIG_SETTING, API_BASE_URL_SETTING,
|
||||
APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING,
|
||||
CRITICAL_ALERT_MUTE_UI_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING,
|
||||
DISABLE_HUB_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS, EXTERNAL_INSTANCE_PG_SETTING,
|
||||
GITHUB_APP_WEBHOOK_BASE_URL_SETTING, HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING,
|
||||
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING,
|
||||
INSTANCE_BANNER_SETTING, MAX_RETENTION_OVERRIDE_WORKSPACES,
|
||||
@@ -1238,26 +1238,22 @@ async fn run_setting_pre_write_hook(
|
||||
}
|
||||
}
|
||||
}
|
||||
GITHUB_APP_WEBHOOK_BASE_URL_SETTING => {
|
||||
// A bad value here yields a webhook GitHub can never deliver to, and the
|
||||
GITHUB_APP_WEBHOOK_BASE_URL_SETTING | API_BASE_URL_SETTING => {
|
||||
// A bad webhook base yields a webhook GitHub can never deliver to, and the
|
||||
// failure only shows up much later as "falling back to polling" on a
|
||||
// repository — so reject it at the boundary instead.
|
||||
// repository; a bad API base puts an unreachable URL in front of every user.
|
||||
// So reject both at the boundary instead.
|
||||
match value {
|
||||
// Clearing (delete row) is handled by the caller; allow it through.
|
||||
serde_json::Value::Null => {}
|
||||
serde_json::Value::String(s) if s.trim().is_empty() => {}
|
||||
serde_json::Value::String(s) => {
|
||||
windmill_common::global_settings::validate_webhook_base_url(s).map_err(
|
||||
|e| {
|
||||
error::Error::BadRequest(format!(
|
||||
"{GITHUB_APP_WEBHOOK_BASE_URL_SETTING}: {e}"
|
||||
))
|
||||
},
|
||||
)?;
|
||||
windmill_common::global_settings::validate_webhook_base_url(s)
|
||||
.map_err(|e| error::Error::BadRequest(format!("{key}: {e}")))?;
|
||||
}
|
||||
_ => {
|
||||
return Err(error::Error::BadRequest(format!(
|
||||
"{GITHUB_APP_WEBHOOK_BASE_URL_SETTING} must be a URL string"
|
||||
"{key} must be a URL string"
|
||||
)));
|
||||
}
|
||||
}
|
||||
@@ -1394,8 +1390,12 @@ async fn set_instance_config(
|
||||
|
||||
// Applied and audited one setting at a time: the writes are not transactional, so a
|
||||
// batch that fails midway must not leave the settings it did write unrecorded.
|
||||
let instance_config::SettingsDiff { upserts, deletes, mut previous_values, unchanged_count } =
|
||||
settings_diff;
|
||||
let instance_config::SettingsDiff {
|
||||
upserts,
|
||||
deletes,
|
||||
mut previous_values,
|
||||
unchanged_count,
|
||||
} = settings_diff;
|
||||
let steps = upserts
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, Some(v)))
|
||||
|
||||
@@ -490,7 +490,7 @@ async fn list_user_usage(
|
||||
"
|
||||
SELECT usr.email, usage.executions
|
||||
FROM usr, LATERAL (
|
||||
SELECT COALESCE(SUM(c.duration_ms + 1000)/1000 , 0)::BIGINT executions
|
||||
SELECT COALESCE(SUM(COALESCE((c.extras->>'wac_last_segment_ms')::bigint, c.duration_ms) + 1000)/1000 , 0)::BIGINT executions
|
||||
FROM v2_job_completed c JOIN v2_job j USING (id)
|
||||
WHERE j.workspace_id = $1
|
||||
AND j.kind NOT IN ('flow', 'flowpreview', 'flownode', 'singlestepflow')
|
||||
|
||||
@@ -86,6 +86,7 @@ struct WorkerPing {
|
||||
job_isolation: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
native_mode: Option<bool>,
|
||||
draining: bool,
|
||||
}
|
||||
|
||||
// #[derive(Serialize, Deserialize)]
|
||||
@@ -118,7 +119,7 @@ async fn list_worker_pings(
|
||||
WorkerPing,
|
||||
"SELECT worker, worker_instance, EXTRACT(EPOCH FROM (now() - ping_at))::integer as last_ping, started_at, ip, jobs_executed,
|
||||
CASE WHEN $4 IS TRUE THEN current_job_id ELSE NULL END as last_job_id, CASE WHEN $4 IS TRUE THEN current_job_workspace_id ELSE NULL END as last_job_workspace_id,
|
||||
custom_tags, worker_group, wm_version, occupancy_rate, occupancy_rate_15s, occupancy_rate_5m, occupancy_rate_30m, memory, vcpus, memory_usage, wm_memory_usage, job_isolation, native_mode
|
||||
custom_tags, worker_group, wm_version, occupancy_rate, occupancy_rate_15s, occupancy_rate_5m, occupancy_rate_30m, memory, vcpus, memory_usage, wm_memory_usage, job_isolation, native_mode, draining
|
||||
FROM worker_ping
|
||||
WHERE ($1::integer IS NULL AND ping_at > now() - interval '5 minute') OR (ping_at > now() - ($1 || ' seconds')::interval)
|
||||
ORDER BY ping_at desc LIMIT $2 OFFSET $3",
|
||||
|
||||
@@ -683,6 +683,9 @@ pub(crate) struct CatalogFacts {
|
||||
pub(crate) former_owner: Option<FormerOwnerDefaults>,
|
||||
/// For a revoke: each grant it takes back.
|
||||
pub(crate) revoked_grants: Vec<RevokedGrant>,
|
||||
/// For a grant on what a schema holds: the role cannot reach the schema yet, so the grant
|
||||
/// would give it nothing it can use.
|
||||
pub(crate) lacks_schema_usage: bool,
|
||||
}
|
||||
|
||||
/// A grant a revoke takes back, as the catalog records it: what `source` gave on `object` (the
|
||||
@@ -1526,7 +1529,24 @@ async fn build_plan(
|
||||
)));
|
||||
}
|
||||
}
|
||||
let facts = CatalogFacts { other_pg_roles, existing_objects, former_owner, revoked_grants };
|
||||
let lacks_schema_usage = match (&change, target) {
|
||||
(AclChange::Grant { scope, .. }, AclTarget::Schema { schema })
|
||||
if !matches!(scope, GrantScope::Target) =>
|
||||
{
|
||||
!has_schema_usage(client, schema, &pg_role).await?
|
||||
}
|
||||
(AclChange::Grant { .. }, AclTarget::Table { schema, .. }) => {
|
||||
!has_schema_usage(client, schema, &pg_role).await?
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let facts = CatalogFacts {
|
||||
other_pg_roles,
|
||||
existing_objects,
|
||||
former_owner,
|
||||
revoked_grants,
|
||||
lacks_schema_usage,
|
||||
};
|
||||
let mut plan =
|
||||
crate::datatable_acl_oss::plan_statements(target, &change, dbname, &pg_role, &facts)?;
|
||||
if matches!(change, AclChange::SetOwner { .. }) {
|
||||
@@ -1540,6 +1560,27 @@ async fn build_plan(
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
/// Whether `pg_role` may look into `schema`, directly or through a role it inherits from.
|
||||
async fn has_schema_usage(
|
||||
client: &tokio_postgres::Client,
|
||||
schema: &str,
|
||||
pg_role: &str,
|
||||
) -> Result<bool> {
|
||||
Ok(client
|
||||
.query_one(
|
||||
"SELECT has_schema_privilege($1::name, $2::text, 'USAGE')",
|
||||
&[&pg_role, &schema],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Error::internal_err(format!(
|
||||
"Failed to read the role's privileges on schema {schema}: {}",
|
||||
pg_error_message(&e)
|
||||
))
|
||||
})?
|
||||
.get(0))
|
||||
}
|
||||
|
||||
/// Postgres only hands an object to a role that could have created it: a table to one with
|
||||
/// `CREATE` on its schema, a schema to one with `CREATE` on the database.
|
||||
async fn missing_owner_privilege(
|
||||
|
||||
@@ -34993,7 +34993,7 @@
|
||||
},
|
||||
"exponential": {
|
||||
"type": "object",
|
||||
"description": "Retry with exponential backoff (delay doubles each time)",
|
||||
"description": "Retry with exponential backoff: the n-th retry waits multiplier × seconds^n",
|
||||
"properties": {
|
||||
"attempts": {
|
||||
"type": "integer",
|
||||
@@ -35006,7 +35006,7 @@
|
||||
"seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Initial delay in seconds"
|
||||
"description": "Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too"
|
||||
},
|
||||
"random_factor": {
|
||||
"type": "integer",
|
||||
@@ -47930,7 +47930,7 @@
|
||||
},
|
||||
"exponential": {
|
||||
"type": "object",
|
||||
"description": "Retry with exponential backoff (delay doubles each time)",
|
||||
"description": "Retry with exponential backoff: the n-th retry waits multiplier × seconds^n",
|
||||
"properties": {
|
||||
"attempts": {
|
||||
"type": "integer",
|
||||
@@ -47943,7 +47943,7 @@
|
||||
"seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Initial delay in seconds"
|
||||
"description": "Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too"
|
||||
},
|
||||
"random_factor": {
|
||||
"type": "integer",
|
||||
|
||||
@@ -11725,9 +11725,7 @@ paths:
|
||||
description: Seconds to wait between retries
|
||||
exponential:
|
||||
type: object
|
||||
description: >-
|
||||
Retry with exponential backoff (delay
|
||||
doubles each time)
|
||||
description: 'Retry with exponential backoff: the n-th retry waits multiplier × seconds^n'
|
||||
properties:
|
||||
attempts:
|
||||
type: integer
|
||||
@@ -11738,7 +11736,7 @@ paths:
|
||||
seconds:
|
||||
type: integer
|
||||
minimum: 1
|
||||
description: Initial delay in seconds
|
||||
description: Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too
|
||||
random_factor:
|
||||
type: integer
|
||||
minimum: 0
|
||||
@@ -23272,7 +23270,7 @@ paths:
|
||||
description: Seconds to wait between retries
|
||||
exponential:
|
||||
type: object
|
||||
description: Retry with exponential backoff (delay doubles each time)
|
||||
description: 'Retry with exponential backoff: the n-th retry waits multiplier × seconds^n'
|
||||
properties:
|
||||
attempts:
|
||||
type: integer
|
||||
@@ -23283,7 +23281,7 @@ paths:
|
||||
seconds:
|
||||
type: integer
|
||||
minimum: 1
|
||||
description: Initial delay in seconds
|
||||
description: Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too
|
||||
random_factor:
|
||||
type: integer
|
||||
minimum: 0
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.827.0
|
||||
version: 1.828.0
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -1765,6 +1765,12 @@ paths:
|
||||
type: string
|
||||
cluster:
|
||||
$ref: "#/components/schemas/DatatableRoleCluster"
|
||||
take_over:
|
||||
type: boolean
|
||||
description: >
|
||||
manage a Postgres role of that name that already exists on the cluster,
|
||||
replacing its password, instead of refusing it. A role with cluster-wide
|
||||
attributes or memberships is refused either way
|
||||
responses:
|
||||
"200":
|
||||
description: the created role
|
||||
@@ -1772,6 +1778,8 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InstanceDatatableRole"
|
||||
"409":
|
||||
description: a Postgres role of that name already exists on the cluster and take_over was not set
|
||||
|
||||
/settings/datatable_roles/{id}:
|
||||
post:
|
||||
@@ -1911,7 +1919,7 @@ paths:
|
||||
|
||||
/settings/instance_ui:
|
||||
get:
|
||||
summary: get the instance-wide announcement banner and accent color
|
||||
summary: get the instance-wide announcement banner, accent color and API base url
|
||||
description: |
|
||||
Readable by any authenticated user; every page load reads it. Each value
|
||||
is the raw stored setting, or null when unset.
|
||||
@@ -1920,7 +1928,7 @@ paths:
|
||||
- setting
|
||||
responses:
|
||||
"200":
|
||||
description: the instance_banner and accent_color settings
|
||||
description: the instance_banner, accent_color and api_base_url settings
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -1928,6 +1936,7 @@ paths:
|
||||
properties:
|
||||
instance_banner: {}
|
||||
accent_color: {}
|
||||
api_base_url: {}
|
||||
|
||||
/settings_u/ruff_config:
|
||||
get:
|
||||
@@ -12004,6 +12013,7 @@ paths:
|
||||
- $ref: "#/components/parameters/WorkerTag"
|
||||
- $ref: "#/components/parameters/CacheTtl"
|
||||
- $ref: "#/components/parameters/NewJobId"
|
||||
- $ref: "#/components/parameters/RunRetry"
|
||||
- name: invisible_to_owner
|
||||
description: make the run invisible to the the script owner (default false)
|
||||
in: query
|
||||
@@ -16053,6 +16063,7 @@ paths:
|
||||
- $ref: "#/components/parameters/WorkerTag"
|
||||
- $ref: "#/components/parameters/CacheTtl"
|
||||
- $ref: "#/components/parameters/NewJobId"
|
||||
- $ref: "#/components/parameters/RunRetry"
|
||||
- $ref: "#/components/parameters/IncludeHeader"
|
||||
- name: invisible_to_owner
|
||||
description: make the run invisible to the the script owner (default false)
|
||||
@@ -17357,6 +17368,27 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/jobs/worker_is_draining/{id}:
|
||||
get:
|
||||
summary: whether the worker running this job is draining
|
||||
description: |
|
||||
True once the worker running the job has received its shutdown signal. The worker
|
||||
still waits for the job to finish, so a long-running job can poll this to exit early.
|
||||
False when the job is not currently running on a worker.
|
||||
operationId: workerIsDraining
|
||||
tags:
|
||||
- job
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/JobId"
|
||||
responses:
|
||||
"200":
|
||||
description: whether the worker running this job is draining
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: boolean
|
||||
|
||||
/w/{workspace}/jobs_u/get_logs/{id}:
|
||||
get:
|
||||
summary: get job logs
|
||||
@@ -27867,6 +27899,12 @@ components:
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
RunRetry:
|
||||
name: retry
|
||||
description: JSON-encoded retry policy (same shape as a schedule's retry) re-running the job when it fails. The returned id is the first attempt's; each retry is a job of its own, recorded as a child of the first attempt. Not supported for hub scripts, with invisible_to_owner, or for a script whose preprocessor would run.
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
NewJobId:
|
||||
name: job_id
|
||||
description:
|
||||
@@ -34627,6 +34665,11 @@ components:
|
||||
type: boolean
|
||||
cluster:
|
||||
$ref: "#/components/schemas/DatatableRoleCluster"
|
||||
in_use:
|
||||
type: boolean
|
||||
description: >-
|
||||
Whether any data table, in any workspace, lets someone connect as this role. Only set
|
||||
in a data table's available_roles.
|
||||
|
||||
DatatableRoleTenants:
|
||||
type: object
|
||||
@@ -35982,6 +36025,9 @@ components:
|
||||
type: string
|
||||
native_mode:
|
||||
type: boolean
|
||||
draining:
|
||||
type: boolean
|
||||
description: the worker received its shutdown signal and exits once its current job is done
|
||||
required:
|
||||
- worker
|
||||
- worker_instance
|
||||
|
||||
@@ -979,7 +979,14 @@ async fn get_raw_app_data(
|
||||
// the opaque, sandboxed wrapper (incl. under a cross-origin-isolated
|
||||
// / COEP `require-corp` embedder).
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
.header("Cross-Origin-Resource-Policy", "cross-origin");
|
||||
.header("Cross-Origin-Resource-Policy", "cross-origin")
|
||||
// The secret names one app version, whose bundle is written once at deploy,
|
||||
// so the URL's content never changes. `private`: the URL is a capability, so
|
||||
// shared caches must not keep serving it after the app is deleted.
|
||||
.header(
|
||||
http::header::CACHE_CONTROL,
|
||||
"private, max-age=31536000, immutable",
|
||||
);
|
||||
Ok(res.body(body).unwrap())
|
||||
} else {
|
||||
return Err(Error::NotFound("File not found".to_string()));
|
||||
|
||||
@@ -378,6 +378,10 @@ pub fn workspaced_service() -> Router {
|
||||
"/wac_approval_urls/{job_id}/{step_key}",
|
||||
get(get_wac_approval_urls).layer(cors.clone()),
|
||||
)
|
||||
.route(
|
||||
"/worker_is_draining/{id}",
|
||||
get(worker_is_draining).layer(cors.clone()),
|
||||
)
|
||||
.route(
|
||||
"/result_by_id/{job_id}/{node_id}",
|
||||
get(get_result_by_id).layer(cors.clone()),
|
||||
@@ -613,6 +617,53 @@ async fn get_root_job(
|
||||
Ok(Json(res))
|
||||
}
|
||||
|
||||
/// Whether the worker running this job has received its shutdown signal. A draining worker
|
||||
/// never interrupts its job, so a long-running script polls this to exit on its own terms.
|
||||
async fn worker_is_draining(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, id)): Path<(String, Uuid)>,
|
||||
) -> windmill_common::error::JsonResult<bool> {
|
||||
// A job always may ask about itself. Anyone else must be able to see the job: the
|
||||
// caller's row policies on v2_job decide, narrowed by the token's tag filter.
|
||||
let draining = if authed.job_id == Some(id) {
|
||||
sqlx::query_scalar!(
|
||||
"SELECT wp.draining FROM v2_job_queue q JOIN worker_ping wp ON wp.worker = q.worker
|
||||
WHERE q.id = $1 AND q.workspace_id = $2 AND q.running AND q.started_at IS NOT NULL",
|
||||
id,
|
||||
w_id
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
} else {
|
||||
let tags = get_scope_tags(&authed)
|
||||
.map(|tags| tags.into_iter().map(str::to_string).collect::<Vec<_>>());
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let draining = sqlx::query_scalar!(
|
||||
"SELECT wp.draining FROM v2_job j
|
||||
JOIN v2_job_queue q ON q.id = j.id
|
||||
JOIN worker_ping wp ON wp.worker = q.worker
|
||||
WHERE j.id = $1 AND j.workspace_id = $2 AND q.running AND q.started_at IS NOT NULL
|
||||
AND ($3::text[] IS NULL OR j.tag = ANY($3))",
|
||||
id,
|
||||
w_id,
|
||||
tags.as_deref()
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
draining
|
||||
}
|
||||
.unwrap_or(false);
|
||||
windmill_common::feature_usage::log_feature_usage(
|
||||
"worker_draining",
|
||||
"check",
|
||||
if draining { "draining" } else { "not_draining" },
|
||||
);
|
||||
Ok(Json(draining))
|
||||
}
|
||||
|
||||
async fn compute_root_job_for_flow(db: &DB, w_id: &str, job_id: Uuid) -> error::Result<String> {
|
||||
let root_job = sqlx::query_scalar!(
|
||||
r#"SELECT COALESCE(root_job, flow_innermost_root_job, parent_job, id) as "root_job!" FROM v2_job WHERE id = $1 AND workspace_id = $2"#,
|
||||
@@ -8541,6 +8592,11 @@ pub async fn stream_job(
|
||||
run_query: RunJobQuery,
|
||||
is_get: bool,
|
||||
) -> error::Result<Response> {
|
||||
if run_query.retry.is_some() {
|
||||
return Err(error::Error::BadRequest(
|
||||
"retry is only supported when running a script asynchronously".to_string(),
|
||||
));
|
||||
}
|
||||
let args = if is_get {
|
||||
let payload_as_args = run_query.payload_as_args()?;
|
||||
|
||||
@@ -10483,26 +10539,29 @@ pub async fn run_job_by_hash_inner(
|
||||
)
|
||||
};
|
||||
|
||||
let job_payload = JobPayload::ScriptHash {
|
||||
hash: ScriptHash(hash),
|
||||
path: path,
|
||||
concurrency_settings,
|
||||
debouncing_settings,
|
||||
cache_ttl,
|
||||
cache_ignore_s3_path,
|
||||
language,
|
||||
dedicated_worker,
|
||||
priority,
|
||||
apply_preprocessor: !run_query.skip_preprocessor.unwrap_or(false)
|
||||
&& has_preprocessor.unwrap_or(false),
|
||||
labels,
|
||||
job_token_scopes,
|
||||
};
|
||||
let job_payload = with_run_retry(&run_query, job_payload, &push_args, &tag)?;
|
||||
|
||||
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?;
|
||||
let (uuid, tx) = push(
|
||||
&db,
|
||||
tx,
|
||||
&w_id,
|
||||
JobPayload::ScriptHash {
|
||||
hash: ScriptHash(hash),
|
||||
path: path,
|
||||
concurrency_settings,
|
||||
debouncing_settings,
|
||||
cache_ttl,
|
||||
cache_ignore_s3_path,
|
||||
language,
|
||||
dedicated_worker,
|
||||
priority,
|
||||
apply_preprocessor: !run_query.skip_preprocessor.unwrap_or(false)
|
||||
&& has_preprocessor.unwrap_or(false),
|
||||
labels,
|
||||
job_token_scopes,
|
||||
},
|
||||
job_payload,
|
||||
push_args,
|
||||
authed.display_username(),
|
||||
email,
|
||||
|
||||
@@ -2024,7 +2024,7 @@ You should get the schema of the script or flow before creating the schedule to
|
||||
},
|
||||
"exponential": {
|
||||
"type": "object",
|
||||
"description": "Retry with exponential backoff (delay doubles each time)",
|
||||
"description": "Retry with exponential backoff: the n-th retry waits multiplier × seconds^n",
|
||||
"properties": {
|
||||
"attempts": {
|
||||
"type": "integer",
|
||||
@@ -2037,7 +2037,7 @@ You should get the schema of the script or flow before creating the schedule to
|
||||
"seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Initial delay in seconds"
|
||||
"description": "Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too"
|
||||
},
|
||||
"random_factor": {
|
||||
"type": "integer",
|
||||
@@ -2231,7 +2231,7 @@ You should get the schema of the script or flow before updating the schedule to
|
||||
},
|
||||
"exponential": {
|
||||
"type": "object",
|
||||
"description": "Retry with exponential backoff (delay doubles each time)",
|
||||
"description": "Retry with exponential backoff: the n-th retry waits multiplier × seconds^n",
|
||||
"properties": {
|
||||
"attempts": {
|
||||
"type": "integer",
|
||||
@@ -2244,7 +2244,7 @@ You should get the schema of the script or flow before updating the schedule to
|
||||
"seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Initial delay in seconds"
|
||||
"description": "Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too"
|
||||
},
|
||||
"random_factor": {
|
||||
"type": "integer",
|
||||
|
||||
@@ -331,6 +331,12 @@ pub async fn converge_connect_grants_with(
|
||||
/// `CREATE ROLE <name> LOGIN PASSWORD ...; GRANT <name> TO custom_instance_user` on `cluster`. No
|
||||
/// privileges beyond that — an admin grants them through SQL or the ACL editor.
|
||||
///
|
||||
/// A role of that name that already exists is refused with a 409, unless `take_over`: then its
|
||||
/// password is replaced and it is managed from here on like one created here. A role holding
|
||||
/// cluster-wide privileges, or inheriting another role's, is refused either way, since every tenant
|
||||
/// would get them. Returns whether an existing role was taken over: undoing a failed take-over
|
||||
/// must not drop a role that was there before.
|
||||
///
|
||||
/// On Windmill's own cluster the DDL runs on `tx`, so it commits with the catalog row. The external
|
||||
/// cluster is another server: the role is created there before `tx` commits, and callers MUST drop
|
||||
/// it again ([`drop_datatable_role`]) if `tx` then fails to commit.
|
||||
@@ -343,8 +349,10 @@ pub async fn create_datatable_role(
|
||||
cluster: DatatableRoleCluster,
|
||||
name: &str,
|
||||
password: &str,
|
||||
) -> Result<()> {
|
||||
crate::datatable_roles_oss::create_datatable_role(db, tx, cluster, name, password).await
|
||||
take_over: bool,
|
||||
) -> Result<bool> {
|
||||
crate::datatable_roles_oss::create_datatable_role(db, tx, cluster, name, password, take_over)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Authorization: alters a cluster-wide Postgres login. Callers MUST restrict this to superadmin
|
||||
|
||||
@@ -125,7 +125,8 @@ mod ce {
|
||||
_cluster: DatatableRoleCluster,
|
||||
_name: &str,
|
||||
_password: &str,
|
||||
) -> Result<()> {
|
||||
_take_over: bool,
|
||||
) -> Result<bool> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
|
||||
@@ -181,6 +181,12 @@ pub const GITHUB_ENTERPRISE_APP_SETTING: &str = "github_enterprise_app";
|
||||
/// `base_url` when unset; set it when the browser-facing URL is not reachable
|
||||
/// from GitHub and a separate ingress fronts the API for inbound webhooks.
|
||||
pub const GITHUB_APP_WEBHOOK_BASE_URL_SETTING: &str = "github_app_webhook_base_url";
|
||||
/// Base URL the UI shows for the endpoints external clients call (webhooks, HTTP
|
||||
/// routes, push trigger endpoints, the CLI), instead of the origin the UI is browsed on.
|
||||
/// For deployments that serve the API on a separate domain or behind a gateway.
|
||||
/// EE only. Readable by any authenticated user, like [`INSTANCE_BANNER_SETTING`]:
|
||||
/// every user is shown URLs built from it.
|
||||
pub const API_BASE_URL_SETTING: &str = "api_base_url";
|
||||
/// Instance-wide announcement rendered above every page of the app (maintenance
|
||||
/// windows, incidents). Readable by any authenticated user, unlike most settings:
|
||||
/// the banner exists to be shown to everyone, so it must never hold anything the
|
||||
@@ -313,12 +319,17 @@ pub fn validate_accent_color(value: &serde_json::Value) -> Result<(), String> {
|
||||
pub struct InstanceUi {
|
||||
pub instance_banner: Option<serde_json::Value>,
|
||||
pub accent_color: Option<serde_json::Value>,
|
||||
pub api_base_url: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
pub async fn get_instance_ui(db: &Pool<Postgres>) -> error::Result<InstanceUi> {
|
||||
let rows = sqlx::query!(
|
||||
"SELECT name, value FROM global_settings WHERE name = ANY($1)",
|
||||
&[INSTANCE_BANNER_SETTING, ACCENT_COLOR_SETTING] as &[&str]
|
||||
&[
|
||||
INSTANCE_BANNER_SETTING,
|
||||
ACCENT_COLOR_SETTING,
|
||||
API_BASE_URL_SETTING
|
||||
] as &[&str]
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
@@ -327,17 +338,22 @@ pub async fn get_instance_ui(db: &Pool<Postgres>) -> error::Result<InstanceUi> {
|
||||
match row.name.as_str() {
|
||||
INSTANCE_BANNER_SETTING => ui.instance_banner = Some(row.value),
|
||||
ACCENT_COLOR_SETTING => ui.accent_color = Some(row.value),
|
||||
API_BASE_URL_SETTING => ui.api_base_url = Some(row.value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(ui)
|
||||
}
|
||||
|
||||
/// Validate a [`GITHUB_APP_WEBHOOK_BASE_URL_SETTING`] value.
|
||||
const SHELL_ACTIVE_CHARS: [char; 16] = [
|
||||
'$', '`', '\'', '"', '\\', ';', '&', '|', '<', '>', '(', ')', '{', '}', '*', '!',
|
||||
];
|
||||
|
||||
/// Validate a [`GITHUB_APP_WEBHOOK_BASE_URL_SETTING`] or [`API_BASE_URL_SETTING`] value.
|
||||
///
|
||||
/// The receiver path is appended to it verbatim, so anything that doesn't
|
||||
/// concatenate into a URL GitHub can POST to must be rejected at write time
|
||||
/// rather than silently producing an unreachable hook: a wrong scheme
|
||||
/// A path is appended to it verbatim, so anything that doesn't concatenate into a
|
||||
/// URL an external caller can reach must be rejected at write time rather than
|
||||
/// silently producing an unreachable hook or endpoint: a wrong scheme
|
||||
/// (`httpss://`), a missing host, embedded whitespace, or a query/fragment
|
||||
/// (appending a path after `?`/`#` keeps it inside the query/fragment).
|
||||
///
|
||||
@@ -353,7 +369,7 @@ pub fn validate_webhook_base_url(value: &str) -> Result<(), String> {
|
||||
url::Url::parse(value).map_err(|e| format!("must be an absolute http(s) URL: {e}"))?;
|
||||
if !url.username().is_empty() || url.password().is_some() {
|
||||
return Err(
|
||||
"must not embed a username or password: the receiver URL is stored in workspace settings, where it is readable by workspace admins".to_string(),
|
||||
"must not embed a username or password: the URL is readable by users who are not instance admins".to_string(),
|
||||
);
|
||||
}
|
||||
if !matches!(url.scheme(), "http" | "https") {
|
||||
@@ -366,13 +382,21 @@ pub fn validate_webhook_base_url(value: &str) -> Result<(), String> {
|
||||
}
|
||||
if url.query().is_some() || url.fragment().is_some() {
|
||||
return Err(
|
||||
"must not include a query string or fragment, since the webhook path is appended to it"
|
||||
"must not include a query string or fragment, since a path is appended to it"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if value.chars().any(char::is_whitespace) {
|
||||
return Err("must not contain whitespace".to_string());
|
||||
}
|
||||
// The UI pastes the URL into shell commands users copy, where double quotes
|
||||
// do not stop `$(...)` or backticks from running.
|
||||
if value.contains(SHELL_ACTIVE_CHARS) {
|
||||
return Err(
|
||||
"must not contain shell metacharacters, since it is shown inside commands users copy"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
// Matches the sibling `base_url` / `hub_base_url` convention. The receiver
|
||||
// builder trims it anyway, so this is about keeping the stored value canonical
|
||||
// rather than about reachability.
|
||||
@@ -1101,6 +1125,9 @@ mod tests {
|
||||
"https://hooks.example.com#",
|
||||
"https://user:password@hooks.example.com",
|
||||
"https://user@hooks.example.com",
|
||||
"https://hooks.example.com/$(uname)",
|
||||
"https://hooks.example.com/`uname`",
|
||||
"https://hooks.example.com/a;b",
|
||||
];
|
||||
for v in accept {
|
||||
assert!(
|
||||
|
||||
@@ -281,6 +281,8 @@ pub struct GlobalSettings {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub github_app_webhook_base_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub api_base_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email_domain: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hub_base_url: Option<String>,
|
||||
@@ -1375,8 +1377,8 @@ pub fn diff_worker_configs(
|
||||
}
|
||||
|
||||
/// Declaratively replace the global settings, rejecting a
|
||||
/// `github_app_webhook_base_url` or `http_route_default_allowed_origins` the
|
||||
/// API would reject.
|
||||
/// `github_app_webhook_base_url`, `api_base_url` or
|
||||
/// `http_route_default_allowed_origins` the API would reject.
|
||||
///
|
||||
/// Every declarative writer (the `sync-config` CLI, the Kubernetes operator's
|
||||
/// ConfigMap sync) MUST go through this rather than calling
|
||||
@@ -1399,29 +1401,35 @@ pub async fn sync_global_settings_declarative(
|
||||
current: &BTreeMap<String, serde_json::Value>,
|
||||
desired: &BTreeMap<String, serde_json::Value>,
|
||||
) -> anyhow::Result<()> {
|
||||
let webhook_key = crate::global_settings::GITHUB_APP_WEBHOOK_BASE_URL_SETTING;
|
||||
// Non-string shapes are rejected rather than ignored: `as_str()` alone would let a
|
||||
// bool/number/object through as if the key were absent, and the diff below would
|
||||
// then persist it — where the HTTP path answers "must be a URL string".
|
||||
match desired.get(webhook_key) {
|
||||
None | Some(serde_json::Value::Null) => {}
|
||||
Some(serde_json::Value::String(s)) if s.trim().is_empty() => {}
|
||||
Some(serde_json::Value::String(s)) => crate::global_settings::validate_webhook_base_url(s)
|
||||
.map_err(|e| anyhow::anyhow!("{webhook_key}: {e}"))?,
|
||||
// Names the JSON kind rather than printing it: this is the last message on
|
||||
// this path that could report submitted content, and an object or array could
|
||||
// carry a secret into `sync-config` output and operator logs.
|
||||
Some(other) => {
|
||||
let kind = match other {
|
||||
serde_json::Value::Bool(_) => "a boolean",
|
||||
serde_json::Value::Number(_) => "a number",
|
||||
serde_json::Value::Array(_) => "an array",
|
||||
serde_json::Value::Object(_) => "an object",
|
||||
_ => "a non-string value",
|
||||
};
|
||||
return Err(anyhow::anyhow!(
|
||||
"{webhook_key} must be a URL string, got {kind}"
|
||||
));
|
||||
for url_key in [
|
||||
crate::global_settings::GITHUB_APP_WEBHOOK_BASE_URL_SETTING,
|
||||
crate::global_settings::API_BASE_URL_SETTING,
|
||||
] {
|
||||
// Non-string shapes are rejected rather than ignored: `as_str()` alone would let a
|
||||
// bool/number/object through as if the key were absent, and the diff below would
|
||||
// then persist it — where the HTTP path answers "must be a URL string".
|
||||
match desired.get(url_key) {
|
||||
None | Some(serde_json::Value::Null) => {}
|
||||
Some(serde_json::Value::String(s)) if s.trim().is_empty() => {}
|
||||
Some(serde_json::Value::String(s)) => {
|
||||
crate::global_settings::validate_webhook_base_url(s)
|
||||
.map_err(|e| anyhow::anyhow!("{url_key}: {e}"))?
|
||||
}
|
||||
// Names the JSON kind rather than printing it: this is the last message on
|
||||
// this path that could report submitted content, and an object or array could
|
||||
// carry a secret into `sync-config` output and operator logs.
|
||||
Some(other) => {
|
||||
let kind = match other {
|
||||
serde_json::Value::Bool(_) => "a boolean",
|
||||
serde_json::Value::Number(_) => "a number",
|
||||
serde_json::Value::Array(_) => "an array",
|
||||
serde_json::Value::Object(_) => "an object",
|
||||
_ => "a non-string value",
|
||||
};
|
||||
return Err(anyhow::anyhow!(
|
||||
"{url_key} must be a URL string, got {kind}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -132,3 +132,36 @@ pub fn otel_set_health_status_phase(_phase: &str) {}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub fn otel_set_health_db_unresponsive(_unresponsive: bool) {}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub fn otel_set_worker_memory(
|
||||
_worker: &str,
|
||||
_worker_group: &str,
|
||||
_usage: Option<i64>,
|
||||
_windmill_usage: Option<i64>,
|
||||
_limit: Option<i64>,
|
||||
) {
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub fn otel_add_worker_cpu(
|
||||
_worker: &str,
|
||||
_worker_group: &str,
|
||||
_usage_secs: f64,
|
||||
_periods: u64,
|
||||
_throttled_periods: u64,
|
||||
_throttled_secs: f64,
|
||||
) {
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub fn otel_set_worker_occupancy_rate(
|
||||
_worker: &str,
|
||||
_worker_group: &str,
|
||||
_window: &'static str,
|
||||
_rate: f64,
|
||||
) {
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub fn otel_record_job_memory_peak(_tag: &str, _bytes: u64) {}
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::utils::WarnAfterExt;
|
||||
use crate::worker::Connection;
|
||||
use crate::{worker::WORKER_GROUP, BASE_URL, DB};
|
||||
use chrono::{SecondsFormat, Utc};
|
||||
use magic_crypt::{MagicCrypt256, MagicCryptError, MagicCryptTrait};
|
||||
use magic_crypt::{MagicCrypt256, MagicCryptTrait};
|
||||
use quick_cache::sync::Cache;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -375,13 +375,15 @@ pub fn encrypt(mc: &MagicCrypt256, value: &str) -> String {
|
||||
mc.encrypt_str_to_base64(value)
|
||||
}
|
||||
|
||||
const DECRYPT_FAILED: &str =
|
||||
"Could not decrypt value. The value may have been encrypted with a different key.";
|
||||
|
||||
/// Every failure returns the same message so responses carry no signal about why decryption
|
||||
/// failed (padding, base64, UTF-8); the cause is only logged.
|
||||
pub fn decrypt(mc: &MagicCrypt256, value: String) -> error::Result<String> {
|
||||
mc.decrypt_base64_to_string(value).map_err(|e| match e {
|
||||
MagicCryptError::DecryptError(_) => error::Error::internal_err(
|
||||
"Could not decrypt value. The value may have been encrypted with a different key."
|
||||
.to_string(),
|
||||
),
|
||||
_ => error::Error::internal_err(e.to_string()),
|
||||
mc.decrypt_base64_to_string(value).map_err(|e| {
|
||||
tracing::warn!("decryption failed: {e}");
|
||||
error::Error::internal_err(DECRYPT_FAILED.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -746,7 +748,31 @@ pub async fn get_variable_or_self_as<T: Authable + Sync>(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{can_bind_as_prologue_const, escape_js_single_quoted, is_valid_js_identifier};
|
||||
use super::{
|
||||
can_bind_as_prologue_const, decrypt, escape_js_single_quoted, is_valid_js_identifier,
|
||||
DECRYPT_FAILED,
|
||||
};
|
||||
use crate::error::Error;
|
||||
use magic_crypt::MagicCryptTrait;
|
||||
|
||||
#[test]
|
||||
fn decrypt_failures_all_return_the_same_message() {
|
||||
let mc = magic_crypt::new_magic_crypt!("workspace-key", 256);
|
||||
let other = magic_crypt::new_magic_crypt!("another-key", 256);
|
||||
let failures = [
|
||||
"not base64 at all!".to_string(),
|
||||
mc.encrypt_bytes_to_base64(&[0xff, 0xfe, 0xfd]),
|
||||
other.encrypt_str_to_base64("secret"),
|
||||
];
|
||||
for value in failures {
|
||||
match decrypt(&mc, value.clone()) {
|
||||
Err(Error::InternalErrLoc { message, .. }) => {
|
||||
assert_eq!(message, DECRYPT_FAILED, "for {value:?}")
|
||||
}
|
||||
other => panic!("expected the uniform decrypt error for {value:?}, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_valid_js_identifier_gates_prologue_injection() {
|
||||
|
||||
@@ -1783,6 +1783,73 @@ pub fn get_cpu_period() -> Option<i64> {
|
||||
Some(100000)
|
||||
}
|
||||
|
||||
/// Bucket bounds in bytes, 16 MiB to 32 GiB, shared by the Prometheus and OTel job
|
||||
/// peak-memory histograms so the two exports stay comparable.
|
||||
pub const JOB_MEMORY_PEAK_BUCKETS: [f64; 12] = {
|
||||
let mut buckets = [0.0; 12];
|
||||
let mut i = 0;
|
||||
while i < 12 {
|
||||
buckets[i] = (16u64 << (20 + i)) as f64;
|
||||
i += 1;
|
||||
}
|
||||
buckets
|
||||
};
|
||||
|
||||
/// Cumulative CPU accounting of the worker's cgroup. The throttling fields stay at 0
|
||||
/// when the cgroup has no CPU controller, which is also what an unthrottled one reports.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct CpuStat {
|
||||
pub usage_usec: u64,
|
||||
pub nr_periods: u64,
|
||||
pub nr_throttled: u64,
|
||||
pub throttled_usec: u64,
|
||||
}
|
||||
|
||||
fn cpu_stat_field(stat: &str, key: &str) -> Option<u64> {
|
||||
stat.lines().find_map(|line| {
|
||||
let (k, v) = line.split_once(' ')?;
|
||||
(k == key).then(|| v.trim().parse().ok()).flatten()
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_cgroup_v2_cpu_stat(stat: &str) -> Option<CpuStat> {
|
||||
Some(CpuStat {
|
||||
usage_usec: cpu_stat_field(stat, "usage_usec")?,
|
||||
nr_periods: cpu_stat_field(stat, "nr_periods").unwrap_or(0),
|
||||
nr_throttled: cpu_stat_field(stat, "nr_throttled").unwrap_or(0),
|
||||
throttled_usec: cpu_stat_field(stat, "throttled_usec").unwrap_or(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// cgroup v1 splits the same figures over two controllers and counts time in nanoseconds.
|
||||
fn parse_cgroup_v1_cpu_stat(cpuacct_usage: &str, stat: &str) -> Option<CpuStat> {
|
||||
Some(CpuStat {
|
||||
usage_usec: cpuacct_usage.trim().parse::<u64>().ok()? / 1000,
|
||||
nr_periods: cpu_stat_field(stat, "nr_periods").unwrap_or(0),
|
||||
nr_throttled: cpu_stat_field(stat, "nr_throttled").unwrap_or(0),
|
||||
throttled_usec: cpu_stat_field(stat, "throttled_time").unwrap_or(0) / 1000,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn get_cpu_stat() -> Option<CpuStat> {
|
||||
const V1_USAGE: &str = "/sys/fs/cgroup/cpuacct/cpuacct.usage";
|
||||
if Path::new(V1_USAGE).exists() {
|
||||
parse_cgroup_v1_cpu_stat(
|
||||
&std::fs::read_to_string(V1_USAGE).ok()?,
|
||||
&std::fs::read_to_string("/sys/fs/cgroup/cpu/cpu.stat").unwrap_or_default(),
|
||||
)
|
||||
} else {
|
||||
let cgroup_path = get_cgroupv2_path()?;
|
||||
parse_cgroup_v2_cpu_stat(&std::fs::read_to_string(format!("{cgroup_path}/cpu.stat")).ok()?)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn get_cpu_stat() -> Option<CpuStat> {
|
||||
None
|
||||
}
|
||||
|
||||
/// CPUs the process is allowed to run on, ignoring any bandwidth quota — the count
|
||||
/// Go's `NumCPU` reports. `available_parallelism` cannot stand in for it: that folds
|
||||
/// the quota in, so a fraction of a CPU makes it report a single-core machine.
|
||||
@@ -1952,6 +2019,7 @@ pub enum PingType {
|
||||
MainLoop,
|
||||
Job,
|
||||
InitScript,
|
||||
Draining,
|
||||
}
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Ping {
|
||||
@@ -1974,6 +2042,8 @@ pub struct Ping {
|
||||
pub occupancy_rate_30m: Option<f32>,
|
||||
pub job_isolation: Option<String>,
|
||||
pub native_mode: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub draining: Option<bool>,
|
||||
pub ping_type: PingType,
|
||||
}
|
||||
pub async fn update_ping_http(
|
||||
@@ -2026,6 +2096,7 @@ pub async fn update_ping_http(
|
||||
insert_ping.memory,
|
||||
insert_ping.job_isolation,
|
||||
insert_ping.native_mode.unwrap_or(false),
|
||||
insert_ping.draining,
|
||||
db,
|
||||
)
|
||||
.await?;
|
||||
@@ -2054,10 +2125,21 @@ pub async fn update_ping_http(
|
||||
)
|
||||
.await?
|
||||
}
|
||||
PingType::Draining => set_worker_draining_query(worker_name, db).await?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_worker_draining_query(worker_name: &str, db: &DB) -> anyhow::Result<()> {
|
||||
sqlx::query!(
|
||||
"UPDATE worker_ping SET draining = true WHERE worker = $1",
|
||||
worker_name
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct JobCancelled {
|
||||
pub canceled_by: String,
|
||||
@@ -2156,6 +2238,9 @@ pub async fn fetch_raw_script_from_app_query(
|
||||
/// `started_at` and `jobs_executed` are the only two columns carried over, being the
|
||||
/// continuity itself — plus `ip` for as long as `ip` is `None`, which means the external IP
|
||||
/// lookup has not resolved yet and the predecessor's address is still the best guess.
|
||||
///
|
||||
/// `draining` is `Some(false)` from a worker starting up and `None` from one re-registering
|
||||
/// mid-life, which must leave alone a flag its own shutdown may have set in the meantime.
|
||||
pub async fn insert_ping_query(
|
||||
worker_instance: &str,
|
||||
worker_name: &str,
|
||||
@@ -2169,6 +2254,7 @@ pub async fn insert_ping_query(
|
||||
memory: Option<i64>,
|
||||
job_isolation: Option<String>,
|
||||
native_mode: bool,
|
||||
draining: Option<bool>,
|
||||
db: &DB,
|
||||
) -> anyhow::Result<i32> {
|
||||
// A NULL `ip` means the external IP lookup is still in flight; a later ping fills it in, and
|
||||
@@ -2176,8 +2262,8 @@ pub async fn insert_ping_query(
|
||||
// lookup that has failed reports `external_ip::UNRETRIEVABLE_IP`, which does overwrite it. The
|
||||
// literal below must stay equal to `external_ip::UNKNOWN_IP`.
|
||||
let previous_jobs_executed = sqlx::query_scalar!(
|
||||
"INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, dedicated_workers, wm_version, vcpus, memory, job_isolation, native_mode) VALUES ($1, $2, COALESCE($3, 'NO IP'), $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT (worker)
|
||||
DO UPDATE set ping_at = now(), worker_instance = EXCLUDED.worker_instance, ip = COALESCE($3, worker_ping.ip), custom_tags = EXCLUDED.custom_tags, worker_group = EXCLUDED.worker_group, dedicated_worker = EXCLUDED.dedicated_worker, dedicated_workers = EXCLUDED.dedicated_workers, wm_version = EXCLUDED.wm_version, vcpus = COALESCE(EXCLUDED.vcpus, worker_ping.vcpus), memory = COALESCE(EXCLUDED.memory, worker_ping.memory), job_isolation = EXCLUDED.job_isolation, native_mode = EXCLUDED.native_mode, current_job_id = NULL, current_job_workspace_id = NULL
|
||||
"INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, dedicated_workers, wm_version, vcpus, memory, job_isolation, native_mode, draining) VALUES ($1, $2, COALESCE($3, 'NO IP'), $4, $5, $6, $7, $8, $9, $10, $11, $12, COALESCE($13, false)) ON CONFLICT (worker)
|
||||
DO UPDATE set ping_at = now(), worker_instance = EXCLUDED.worker_instance, ip = COALESCE($3, worker_ping.ip), custom_tags = EXCLUDED.custom_tags, worker_group = EXCLUDED.worker_group, dedicated_worker = EXCLUDED.dedicated_worker, dedicated_workers = EXCLUDED.dedicated_workers, wm_version = EXCLUDED.wm_version, vcpus = COALESCE(EXCLUDED.vcpus, worker_ping.vcpus), memory = COALESCE(EXCLUDED.memory, worker_ping.memory), job_isolation = EXCLUDED.job_isolation, native_mode = EXCLUDED.native_mode, current_job_id = NULL, current_job_workspace_id = NULL, draining = COALESCE($13, worker_ping.draining)
|
||||
RETURNING jobs_executed",
|
||||
worker_instance,
|
||||
worker_name,
|
||||
@@ -2191,6 +2277,7 @@ pub async fn insert_ping_query(
|
||||
memory,
|
||||
job_isolation.as_deref(),
|
||||
native_mode,
|
||||
draining,
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
@@ -2899,6 +2986,32 @@ mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn cgroup_cpu_stat_is_normalized_to_microseconds() {
|
||||
let v2 = "usage_usec 2500000\nuser_usec 2000000\nsystem_usec 500000\n\
|
||||
nr_periods 40\nnr_throttled 7\nthrottled_usec 350000\nnr_bursts 0\n";
|
||||
let expected = CpuStat {
|
||||
usage_usec: 2_500_000,
|
||||
nr_periods: 40,
|
||||
nr_throttled: 7,
|
||||
throttled_usec: 350_000,
|
||||
};
|
||||
assert_eq!(parse_cgroup_v2_cpu_stat(v2), Some(expected));
|
||||
|
||||
let v1_stat = "nr_periods 40\nnr_throttled 7\nthrottled_time 350000000\n";
|
||||
assert_eq!(
|
||||
parse_cgroup_v1_cpu_stat("2500000000\n", v1_stat),
|
||||
Some(expected)
|
||||
);
|
||||
|
||||
// No CPU controller: usage is still reported, throttling reads as zero.
|
||||
assert_eq!(
|
||||
parse_cgroup_v2_cpu_stat("usage_usec 12\nuser_usec 8\nsystem_usec 4\n"),
|
||||
Some(CpuStat { usage_usec: 12, ..Default::default() })
|
||||
);
|
||||
assert_eq!(parse_cgroup_v2_cpu_stat("nr_periods 40\n"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn datatable_role_is_read_from_the_leading_comment_block() {
|
||||
let role = |code| SqlAnnotations::datatable_role(code);
|
||||
|
||||
@@ -359,6 +359,22 @@ ORDER BY depth, id
|
||||
.filter_map(|r| r.id.clone())
|
||||
.collect_vec();
|
||||
|
||||
// A native retry is the child of its first attempt, which has completed by the time the
|
||||
// retry is queued, so the walk above stops before reaching it.
|
||||
let tree = [&[job.id][..], &jobs_to_cancel[..]].concat();
|
||||
let pending_retries = sqlx::query_scalar!(
|
||||
"SELECT q.id FROM v2_job_queue q
|
||||
JOIN native_retry_attempt n ON n.job_id = q.id
|
||||
JOIN v2_job r ON r.id = q.id
|
||||
JOIN v2_job a ON a.id = r.parent_job
|
||||
WHERE q.workspace_id = $2 AND a.parent_job = ANY($1)",
|
||||
tree.as_slice(),
|
||||
w_id
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
jobs_to_cancel.extend(pending_retries);
|
||||
|
||||
jobs_to_cancel.reverse();
|
||||
if !jobs_to_cancel.is_empty() {
|
||||
tracing::info!("Found {} child jobs to cancel", jobs_to_cancel.len());
|
||||
@@ -1205,7 +1221,7 @@ pub async fn add_completed_job_pre_shaped_failure<T: Serialize + Send + Sync + V
|
||||
worker_name: &str,
|
||||
flow_is_done: bool,
|
||||
duration: Option<i64>,
|
||||
) -> Result<(), Error> {
|
||||
) -> Result<CompletedSpan, Error> {
|
||||
record_failure_metrics(completed_job, worker_name).await;
|
||||
|
||||
tracing::error!(
|
||||
@@ -1213,7 +1229,7 @@ pub async fn add_completed_job_pre_shaped_failure<T: Serialize + Send + Sync + V
|
||||
completed_job.id,
|
||||
completed_job.workspace_id,
|
||||
);
|
||||
let _ = add_completed_job(
|
||||
let (_, span) = add_completed_job(
|
||||
db,
|
||||
completed_job,
|
||||
false,
|
||||
@@ -1228,7 +1244,7 @@ pub async fn add_completed_job_pre_shaped_failure<T: Serialize + Send + Sync + V
|
||||
)
|
||||
.warn_after_seconds(10)
|
||||
.await?;
|
||||
Ok(())
|
||||
Ok(span)
|
||||
}
|
||||
|
||||
pub async fn add_completed_job_error(
|
||||
@@ -1241,6 +1257,31 @@ pub async fn add_completed_job_error(
|
||||
flow_is_done: bool,
|
||||
duration: Option<i64>,
|
||||
) -> Result<WrappedError, Error> {
|
||||
add_completed_job_error_with_span(
|
||||
db,
|
||||
completed_job,
|
||||
mem_peak,
|
||||
canceled_by,
|
||||
e,
|
||||
worker_name,
|
||||
flow_is_done,
|
||||
duration,
|
||||
)
|
||||
.await
|
||||
.map(|(wrapped, _)| wrapped)
|
||||
}
|
||||
|
||||
/// `add_completed_job_error`, also handing back what the completed row holds.
|
||||
pub async fn add_completed_job_error_with_span(
|
||||
db: &Pool<Postgres>,
|
||||
completed_job: &MiniCompletedJob,
|
||||
mem_peak: i32,
|
||||
canceled_by: Option<CanceledBy>,
|
||||
e: serde_json::Value,
|
||||
worker_name: &str,
|
||||
flow_is_done: bool,
|
||||
duration: Option<i64>,
|
||||
) -> Result<(WrappedError, CompletedSpan), Error> {
|
||||
record_failure_metrics(completed_job, worker_name).await;
|
||||
|
||||
let result = WrappedError { error: e };
|
||||
@@ -1250,7 +1291,7 @@ pub async fn add_completed_job_error(
|
||||
completed_job.workspace_id,
|
||||
serde_json::to_string(&result).unwrap_or_else(|_| "".to_string())
|
||||
);
|
||||
let _ = add_completed_job(
|
||||
let (_, span) = add_completed_job(
|
||||
db,
|
||||
&completed_job,
|
||||
false,
|
||||
@@ -1265,7 +1306,7 @@ pub async fn add_completed_job_error(
|
||||
)
|
||||
.warn_after_seconds(10)
|
||||
.await?;
|
||||
Ok(result)
|
||||
Ok((result, span))
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
@@ -1304,7 +1345,7 @@ pub async fn add_completed_job<T: Serialize + Send + Sync + ValidableJson>(
|
||||
flow_is_done: bool,
|
||||
duration: Option<i64>,
|
||||
from_cache: bool,
|
||||
) -> Result<(Uuid, i64), Error> {
|
||||
) -> Result<(Uuid, CompletedSpan), Error> {
|
||||
// tracing::error!("Start");
|
||||
// let start = tokio::time::Instant::now();
|
||||
|
||||
@@ -1338,7 +1379,7 @@ pub async fn add_completed_job<T: Serialize + Send + Sync + ValidableJson>(
|
||||
};
|
||||
|
||||
let result_columns = result_columns.as_ref();
|
||||
let (opt_uuid, duration, _skip_downstream_error_handlers, wac_parent_ready) = (|| {
|
||||
let (opt_uuid, span, _skip_downstream_error_handlers, wac_parent_ready) = (|| {
|
||||
commit_completed_job(
|
||||
db,
|
||||
completed_job,
|
||||
@@ -1379,7 +1420,7 @@ pub async fn add_completed_job<T: Serialize + Send + Sync + ValidableJson>(
|
||||
|
||||
// if scheduling next job failed, return the job_id early to ensure the job get retried after a timeout
|
||||
if let Some(job_id) = opt_uuid {
|
||||
return Ok((job_id, duration));
|
||||
return Ok((job_id, span));
|
||||
}
|
||||
|
||||
// Auto-resolve a retry chain that ultimately worked, from whichever of the two
|
||||
@@ -1391,7 +1432,18 @@ pub async fn add_completed_job<T: Serialize + Send + Sync + ValidableJson>(
|
||||
.then(|| completed_job.parent_job)
|
||||
.flatten()
|
||||
} else if !success && !skipped && retry_pending {
|
||||
Some(completed_job.parent_job.unwrap_or(completed_job.id))
|
||||
let is_retry_attempt = sqlx::query_scalar!(
|
||||
"SELECT attempt FROM native_retry_attempt WHERE job_id = $1",
|
||||
completed_job.id,
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::error!("reading the retry marker of {}: {e:#}", completed_job.id);
|
||||
None
|
||||
})
|
||||
.is_some();
|
||||
Some(native_retry_root(completed_job, is_retry_attempt))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -1409,7 +1461,7 @@ pub async fn add_completed_job<T: Serialize + Send + Sync + ValidableJson>(
|
||||
}
|
||||
|
||||
#[cfg(feature = "cloud")]
|
||||
apply_completed_job_cloud_usage(db, completed_job, duration);
|
||||
apply_completed_job_cloud_usage(db, completed_job, span.last_run_ms);
|
||||
|
||||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||||
crate::jobs_ee::apply_completed_job_error_handlers(
|
||||
@@ -1426,7 +1478,7 @@ pub async fn add_completed_job<T: Serialize + Send + Sync + ValidableJson>(
|
||||
|
||||
// tracing::error!("4 {:?}", start.elapsed());
|
||||
|
||||
Ok((completed_job.id, duration))
|
||||
Ok((completed_job.id, span))
|
||||
}
|
||||
|
||||
async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
|
||||
@@ -1444,7 +1496,7 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
|
||||
// True when a native script retry was enqueued for this failed attempt, i.e.
|
||||
// this is not the terminal attempt — schedule completion handlers must wait.
|
||||
retry_pending: bool,
|
||||
) -> windmill_common::error::Result<(Option<Uuid>, i64, bool, bool)> {
|
||||
) -> windmill_common::error::Result<(Option<Uuid>, CompletedSpan, bool, bool)> {
|
||||
// let start = std::time::Instant::now();
|
||||
|
||||
let job_id = completed_job.id;
|
||||
@@ -1511,11 +1563,11 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
|
||||
&& monitor_parent.is_none()
|
||||
&& !is_scheduled
|
||||
{
|
||||
let Some(duration) = completion.execute(&mut *db.acquire().await?).await? else {
|
||||
let Some(completed) = completion.execute(&mut *db.acquire().await?).await? else {
|
||||
return Err(not_in_queue_error(db, job_id).await);
|
||||
};
|
||||
log_completed_job(completed_job, duration, success);
|
||||
return Ok((None, duration, false, false));
|
||||
log_completed_job(completed_job, completed.duration_ms, success);
|
||||
return Ok((None, completed, false, false));
|
||||
}
|
||||
|
||||
let mut tx = db.begin().warn_after_seconds(10).await?;
|
||||
@@ -1523,7 +1575,8 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
|
||||
// The parent's rows are locked ahead of the child's own queue row (see
|
||||
// `record_child_completion` for the order this must keep), so the duration it stamps is read
|
||||
// before the completion: the one the completed row will hold. `now()` is fixed for the
|
||||
// transaction, and a completed row already there keeps its own duration.
|
||||
// transaction, and a completed row already there keeps its own duration. For a child that
|
||||
// itself parked it is the last run's, matching the last start its timeline entry carries.
|
||||
let mut wac_parent_ready = false;
|
||||
if let Some(parent_job) = wac_parent {
|
||||
let Some(duration) = sqlx::query_scalar!(
|
||||
@@ -1550,7 +1603,7 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
|
||||
.await?;
|
||||
}
|
||||
|
||||
let Some(duration) = completion.execute(&mut *tx).await? else {
|
||||
let Some(completed) = completion.execute(&mut *tx).await? else {
|
||||
return Err(not_in_queue_error(&mut *tx, job_id).await);
|
||||
};
|
||||
|
||||
@@ -1760,16 +1813,35 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
|
||||
|
||||
tx.commit().warn_after_seconds(10).await?;
|
||||
|
||||
log_completed_job(completed_job, duration, success);
|
||||
log_completed_job(completed_job, completed.duration_ms, success);
|
||||
// tracing::info!("completed job: {:?}", start.elapsed().as_micros());
|
||||
Ok((
|
||||
None,
|
||||
duration,
|
||||
completed,
|
||||
_skip_downstream_error_handlers,
|
||||
wac_parent_ready,
|
||||
))
|
||||
}
|
||||
|
||||
/// What a job's completion wrote. `started_at` and `duration_ms` are the completed row's.
|
||||
/// `last_run_ms` is the time the job's last run held a worker, which is what gets metered;
|
||||
/// it differs from `duration_ms` only for a job that parked.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct CompletedSpan {
|
||||
pub started_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub duration_ms: i64,
|
||||
pub last_run_ms: i64,
|
||||
}
|
||||
|
||||
impl CompletedSpan {
|
||||
/// Whether the row spans more than the run that started at `last_started_at`, i.e. the
|
||||
/// job parked and resumed. A flow records such a step with the row's span, so the step
|
||||
/// reads like the job does.
|
||||
pub fn outlasts_run(&self, last_started_at: Option<chrono::DateTime<chrono::Utc>>) -> bool {
|
||||
matches!((self.started_at, last_started_at), (Some(first), Some(last)) if first < last)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a job's completion writes, for `Completion::execute`.
|
||||
struct Completion<'a> {
|
||||
completed_job: &'a MiniCompletedJob,
|
||||
@@ -1786,6 +1858,11 @@ impl Completion<'_> {
|
||||
/// Moves the job from the queue to the completed jobs and refreshes a flow step's parent
|
||||
/// ping, as one statement. Returns `None` when the job was no longer in the queue.
|
||||
///
|
||||
/// For a job that parked (a Workflow-as-Code parent, see `suspend_wac_parent`) the row's
|
||||
/// `started_at` and `duration_ms` span the whole workflow from its first start, and the
|
||||
/// last run goes to `extras.wac_last_segment_ms` for readers that sum worker time from
|
||||
/// completed rows.
|
||||
///
|
||||
/// The completion takes its cancellation from the queue row it deletes, not only from
|
||||
/// `canceled_by`: that is what the worker last read, and the delete waits for a cancel still
|
||||
/// being written, so the deleted row is the final word on whether the job was canceled.
|
||||
@@ -1793,7 +1870,7 @@ impl Completion<'_> {
|
||||
/// It locks the queue row before the completed row's key. Any other writer completing a job
|
||||
/// (the monitor's zombie fallback, debounce) must take them in the same order, or the two
|
||||
/// deadlock.
|
||||
async fn execute(&self, conn: &mut sqlx::PgConnection) -> error::Result<Option<i64>> {
|
||||
async fn execute(&self, conn: &mut sqlx::PgConnection) -> error::Result<Option<CompletedSpan>> {
|
||||
let Completion {
|
||||
completed_job,
|
||||
success,
|
||||
@@ -1824,16 +1901,20 @@ impl Completion<'_> {
|
||||
.then_some(completed_job.parent_job)
|
||||
.flatten()
|
||||
else {
|
||||
return sqlx::query_scalar!(
|
||||
return sqlx::query_as!(
|
||||
CompletedSpan,
|
||||
"WITH deleted AS (
|
||||
DELETE FROM v2_job_queue WHERE id = $1
|
||||
RETURNING id, workspace_id, started_at, worker, canceled_by, canceled_reason
|
||||
RETURNING id, workspace_id, started_at, worker, canceled_by, canceled_reason,
|
||||
(extras->>'wac_first_started_at')::timestamptz AS first_started_at,
|
||||
COALESCE($9::bigint, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE(started_at, now()))))*1000)::bigint AS run_ms
|
||||
), completed AS (
|
||||
INSERT INTO v2_job_completed AS cj
|
||||
( workspace_id
|
||||
, id
|
||||
, started_at
|
||||
, duration_ms
|
||||
, extras
|
||||
, result
|
||||
, result_columns
|
||||
, canceled_by
|
||||
@@ -1844,8 +1925,11 @@ impl Completion<'_> {
|
||||
, status
|
||||
, worker
|
||||
)
|
||||
SELECT d.workspace_id, d.id, d.started_at,
|
||||
COALESCE($9::bigint, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE(d.started_at, now()))))*1000),
|
||||
SELECT d.workspace_id, d.id, COALESCE(d.first_started_at, d.started_at),
|
||||
CASE WHEN d.first_started_at IS NULL THEN d.run_ms
|
||||
ELSE EXTRACT('epoch' FROM (now() - d.first_started_at))*1000 END,
|
||||
CASE WHEN d.first_started_at IS NOT NULL
|
||||
THEN jsonb_build_object('wac_last_segment_ms', d.run_ms) END,
|
||||
$3::text::jsonb, $10,
|
||||
CASE WHEN $4::BOOL THEN $5 ELSE d.canceled_by END,
|
||||
CASE WHEN $4::BOOL THEN $6 WHEN d.canceled_by IS NOT NULL THEN d.canceled_reason END,
|
||||
@@ -1861,9 +1945,11 @@ impl Completion<'_> {
|
||||
THEN EXCLUDED.canceled_by ELSE cj.canceled_by END,
|
||||
canceled_reason = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL
|
||||
THEN EXCLUDED.canceled_reason ELSE cj.canceled_reason END
|
||||
RETURNING duration_ms
|
||||
RETURNING cj.started_at, cj.duration_ms,
|
||||
COALESCE((cj.extras->>'wac_last_segment_ms')::bigint, cj.duration_ms) AS last_run_ms
|
||||
)
|
||||
SELECT duration_ms AS \"duration_ms!\" FROM completed",
|
||||
SELECT started_at AS \"started_at?\", duration_ms AS \"duration_ms!\",
|
||||
last_run_ms AS \"last_run_ms!\" FROM completed",
|
||||
/* $1 */ completed_job.id,
|
||||
/* $2 */ success,
|
||||
/* $3 */ result,
|
||||
@@ -1883,16 +1969,20 @@ impl Completion<'_> {
|
||||
// A canceled flow is pinged too: it is completed by its next transition like any other
|
||||
// flow, and the zombie flow monitor needs the ping to finish the cancel if that
|
||||
// transition is lost.
|
||||
sqlx::query_scalar!(
|
||||
sqlx::query_as!(
|
||||
CompletedSpan,
|
||||
"WITH deleted AS (
|
||||
DELETE FROM v2_job_queue WHERE id = $1
|
||||
RETURNING id, workspace_id, started_at, worker, canceled_by, canceled_reason
|
||||
RETURNING id, workspace_id, started_at, worker, canceled_by, canceled_reason,
|
||||
(extras->>'wac_first_started_at')::timestamptz AS first_started_at,
|
||||
COALESCE($9::bigint, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE(started_at, now()))))*1000)::bigint AS run_ms
|
||||
), completed AS (
|
||||
INSERT INTO v2_job_completed AS cj
|
||||
( workspace_id
|
||||
, id
|
||||
, started_at
|
||||
, duration_ms
|
||||
, extras
|
||||
, result
|
||||
, result_columns
|
||||
, canceled_by
|
||||
@@ -1903,8 +1993,11 @@ impl Completion<'_> {
|
||||
, status
|
||||
, worker
|
||||
)
|
||||
SELECT d.workspace_id, d.id, d.started_at,
|
||||
COALESCE($9::bigint, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE(d.started_at, now()))))*1000),
|
||||
SELECT d.workspace_id, d.id, COALESCE(d.first_started_at, d.started_at),
|
||||
CASE WHEN d.first_started_at IS NULL THEN d.run_ms
|
||||
ELSE EXTRACT('epoch' FROM (now() - d.first_started_at))*1000 END,
|
||||
CASE WHEN d.first_started_at IS NOT NULL
|
||||
THEN jsonb_build_object('wac_last_segment_ms', d.run_ms) END,
|
||||
$3::text::jsonb, $10,
|
||||
CASE WHEN $4::BOOL THEN $5 ELSE d.canceled_by END,
|
||||
CASE WHEN $4::BOOL THEN $6 WHEN d.canceled_by IS NOT NULL THEN d.canceled_reason END,
|
||||
@@ -1920,14 +2013,16 @@ impl Completion<'_> {
|
||||
THEN EXCLUDED.canceled_by ELSE cj.canceled_by END,
|
||||
canceled_reason = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL
|
||||
THEN EXCLUDED.canceled_reason ELSE cj.canceled_reason END
|
||||
RETURNING duration_ms
|
||||
RETURNING cj.started_at, cj.duration_ms,
|
||||
COALESCE((cj.extras->>'wac_last_segment_ms')::bigint, cj.duration_ms) AS last_run_ms
|
||||
), parent_ping AS (
|
||||
UPDATE v2_job_runtime r SET ping = now()
|
||||
FROM v2_job_queue q
|
||||
WHERE r.id = $11 AND q.id = r.id AND q.workspace_id = $12
|
||||
AND EXISTS (SELECT 1 FROM completed)
|
||||
)
|
||||
SELECT duration_ms AS \"duration_ms!\" FROM completed",
|
||||
SELECT started_at AS \"started_at?\", duration_ms AS \"duration_ms!\",
|
||||
last_run_ms AS \"last_run_ms!\" FROM completed",
|
||||
/* $1 */ completed_job.id,
|
||||
/* $2 */ success,
|
||||
/* $3 */ result,
|
||||
@@ -1994,7 +2089,7 @@ async fn check_result_size<T: ValidableJson>(
|
||||
db: &Pool<Postgres>,
|
||||
queued_job: &MiniCompletedJob,
|
||||
result: Json<&T>,
|
||||
) -> Option<Result<(Option<Uuid>, i64, bool, bool), Error>> {
|
||||
) -> Option<Result<(Option<Uuid>, CompletedSpan, bool, bool), Error>> {
|
||||
let result_size = result.size() / 1024 / 1024;
|
||||
if result_size > 2 {
|
||||
if result_size > *MAX_RESULT_SIZE_MB {
|
||||
@@ -2194,18 +2289,17 @@ async fn restart_job_if_perpetual_inner(
|
||||
/// resolution too; `ON CONFLICT DO NOTHING` keeps a human's note intact.
|
||||
///
|
||||
/// Membership of the chain must be *proven* per row, never inferred from `parent_job`
|
||||
/// alone: `root` is `job.parent_job.unwrap_or(job.id)`, so for a job launched with an
|
||||
/// explicit `parent_job` (WAC inline children, SDK-launched children) `root` is the
|
||||
/// *calling* job, and its other failed children are unrelated. Resolving those would hide
|
||||
/// exactly the failures this feature exists to surface, so each row must be either
|
||||
/// alone: the success-side caller passes the succeeding job's `parent_job`, which for a
|
||||
/// job launched with an explicit parent (WAC inline children, SDK-launched children) is
|
||||
/// the *calling* job, and its other failed children are unrelated. Resolving those would
|
||||
/// hide exactly the failures this feature exists to surface, so each row must be either
|
||||
/// - a job carrying a `native_retry_attempt` marker under `root` (provably an attempt), or
|
||||
/// - `root` itself as the original attempt, which is only provable when `root` is
|
||||
/// parentless and unmarked.
|
||||
/// - `root` itself as the original attempt: unmarked, and proven a chain root by the
|
||||
/// marked success under it, since only a chain root is ever a marked job's parent
|
||||
/// (see [`native_retry_root`]).
|
||||
///
|
||||
/// Both arms also require the same `runnable_id` as the succeeding attempt, since every
|
||||
/// attempt in a chain runs the same runnable. The deliberate cost is a miss, not an
|
||||
/// over-reach: when the original attempt had a parent it is an unmarked sibling
|
||||
/// indistinguishable from any other child of the caller, so it stays red.
|
||||
/// attempt in a chain runs the same runnable.
|
||||
///
|
||||
/// Nothing is trusted of the caller (this writes through a privileged pool): the gate is
|
||||
/// "some marked attempt under `root` running `runnable_id` has succeeded", evaluated in
|
||||
@@ -2249,7 +2343,7 @@ pub async fn resolve_retry_chain_if_succeeded(
|
||||
AND (
|
||||
(j.parent_job = $1
|
||||
AND EXISTS (SELECT 1 FROM native_retry_attempt WHERE job_id = c.id))
|
||||
OR (c.id = $1 AND j.parent_job IS NULL
|
||||
OR (c.id = $1
|
||||
AND NOT EXISTS (SELECT 1 FROM native_retry_attempt WHERE job_id = c.id))
|
||||
)
|
||||
ON CONFLICT (job_id) DO NOTHING",
|
||||
@@ -2303,6 +2397,18 @@ async fn eval_retry_if(
|
||||
false
|
||||
}
|
||||
|
||||
/// The first attempt of `job`'s native retry chain, which every later attempt is pushed
|
||||
/// with as `parent_job` and whose id seeds theirs. A first attempt may have a parent of its
|
||||
/// own (a script dispatched from another job), so only a marked attempt's parent is the root:
|
||||
/// rooting at that outer parent would merge the chains of its retried children.
|
||||
fn native_retry_root(job: &MiniCompletedJob, is_retry_attempt: bool) -> Uuid {
|
||||
if is_retry_attempt {
|
||||
job.parent_job.unwrap_or(job.id)
|
||||
} else {
|
||||
job.id
|
||||
}
|
||||
}
|
||||
|
||||
/// Native script retry. When a failed `Script` or `Script_Hub` job carries a retry
|
||||
/// policy (via `runnable_settings_handle`) and has attempts left, enqueue a fresh
|
||||
/// attempt of the same script after the policy's backoff delay — instead of having
|
||||
@@ -2346,6 +2452,18 @@ pub async fn maybe_enqueue_native_script_retry(
|
||||
if !policy.has_attempts() {
|
||||
return Ok(false);
|
||||
}
|
||||
// Cancelling a job that has a parent (every retry attempt does) only flags its queue row,
|
||||
// and the worker that pulls it completes it without a `canceled_by`: read the flag here.
|
||||
let soft_canceled = sqlx::query_scalar!(
|
||||
"SELECT canceled_by IS NOT NULL AS \"canceled!\" FROM v2_job_queue WHERE id = $1",
|
||||
job.id,
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
if soft_canceled {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Attempt counter for this job: its `native_retry_attempt` marker, or 0 for
|
||||
// the first (un-marked) attempt. The marker is persistent (unlike the queue
|
||||
@@ -2358,7 +2476,7 @@ pub async fn maybe_enqueue_native_script_retry(
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.unwrap_or(0) as u32;
|
||||
let root = job.parent_job.unwrap_or(job.id);
|
||||
let root = native_retry_root(job, prev_attempts > 0);
|
||||
// Scheduled chains keep the schedule trigger so the terminal attempt drives
|
||||
// the schedule completion handlers (on_failure/on_success); `parent_job`
|
||||
// keeps every retry out of the per-occurrence handler counting queries.
|
||||
|
||||
@@ -561,4 +561,123 @@ mod native_retry {
|
||||
.unwrap();
|
||||
assert_eq!(status, "failure", "resolving must not change job status");
|
||||
}
|
||||
|
||||
// A script dispatched from another job keeps that job as its parent, so its chain is
|
||||
// rooted at the first attempt itself: sibling dispatches must not share retry ids, and
|
||||
// a parented first attempt is still resolved once its retry succeeds.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base", "schedule_push"))]
|
||||
async fn parented_first_attempts_root_their_own_chains(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
let retry = Retry {
|
||||
constant: ConstantDelay { attempts: 1, seconds: 0 },
|
||||
exponential: Default::default(),
|
||||
retry_if: None,
|
||||
};
|
||||
let handle = insert_rs(
|
||||
RunnableSettings {
|
||||
debouncing_settings: None,
|
||||
concurrency_settings: None,
|
||||
retry_settings: RetrySettings::from(&retry).insert_cached(&db).await?,
|
||||
},
|
||||
&db,
|
||||
)
|
||||
.await?;
|
||||
let dispatcher = Uuid::new_v4();
|
||||
let (a, b) = (Uuid::new_v4(), Uuid::new_v4());
|
||||
for first in [a, b] {
|
||||
let attempt = mini(first, Some(dispatcher), handle);
|
||||
assert!(maybe_enqueue_native_script_retry(&db, &attempt, &None, &no_result).await?);
|
||||
}
|
||||
assert!(
|
||||
retry_by_attempt(&db, a, 1).await.is_some(),
|
||||
"a's retry hangs off a"
|
||||
);
|
||||
assert!(
|
||||
retry_by_attempt(&db, b, 1).await.is_some(),
|
||||
"b's retry is not mistaken for a's"
|
||||
);
|
||||
|
||||
let (c, c_retry, c_sibling) = (Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4());
|
||||
seed_job(&db, c, Some(dispatcher), false, "failure").await;
|
||||
seed_job(&db, c_sibling, Some(dispatcher), false, "failure").await;
|
||||
seed_job(&db, c_retry, Some(c), true, "success").await;
|
||||
windmill_queue::jobs::resolve_retry_chain_if_succeeded(&db, c, WS, None).await?;
|
||||
windmill_queue::jobs::resolve_retry_chain_if_succeeded(&db, dispatcher, WS, None).await?;
|
||||
let resolved: Vec<Uuid> = sqlx::query_scalar("SELECT job_id FROM job_resolution")
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resolved,
|
||||
vec![c],
|
||||
"only the parented first attempt resolves, not its sibling"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Cancelling the job that dispatched a script reaches that script's pending retry, which
|
||||
// hangs off a first attempt that has already completed.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base", "schedule_push"))]
|
||||
async fn cancelling_the_caller_cancels_a_pending_retry(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
let retry = Retry {
|
||||
constant: ConstantDelay { attempts: 2, seconds: 60 },
|
||||
exponential: Default::default(),
|
||||
retry_if: None,
|
||||
};
|
||||
let handle = insert_rs(
|
||||
RunnableSettings {
|
||||
debouncing_settings: None,
|
||||
concurrency_settings: None,
|
||||
retry_settings: RetrySettings::from(&retry).insert_cached(&db).await?,
|
||||
},
|
||||
&db,
|
||||
)
|
||||
.await?;
|
||||
let (caller, first) = (Uuid::new_v4(), Uuid::new_v4());
|
||||
sqlx::query(
|
||||
"INSERT INTO v2_job (id, workspace_id, kind, runnable_path) VALUES ($1, $2, 'script', $3)",
|
||||
)
|
||||
.bind(caller)
|
||||
.bind(WS)
|
||||
.bind(SCRIPT)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running) VALUES ($1, $2, now(), true)",
|
||||
)
|
||||
.bind(caller)
|
||||
.bind(WS)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
seed_job(&db, first, Some(caller), false, "failure").await;
|
||||
let attempt = mini(first, Some(caller), handle);
|
||||
assert!(maybe_enqueue_native_script_retry(&db, &attempt, &None, &no_result).await?);
|
||||
let (retry_id, ..) = retry_by_attempt(&db, first, 1)
|
||||
.await
|
||||
.expect("retry is queued");
|
||||
|
||||
let tx = db.begin().await?;
|
||||
let (tx, _) =
|
||||
windmill_queue::jobs::cancel_job("test-user", None, caller, WS, tx, &db, false, false)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
let canceled_by: Option<String> =
|
||||
sqlx::query_scalar("SELECT canceled_by FROM v2_job_queue WHERE id = $1")
|
||||
.bind(retry_id)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(canceled_by.as_deref(), Some("test-user"));
|
||||
|
||||
// The worker that pulls the canceled retry completes it without a `canceled_by`.
|
||||
let (_, _, _, _, retry_handle) = retry_by_attempt(&db, first, 1).await.unwrap();
|
||||
let canceled_retry = mini(retry_id, Some(first), retry_handle);
|
||||
assert!(
|
||||
!maybe_enqueue_native_script_retry(&db, &canceled_retry, &None, &no_result).await?,
|
||||
"a canceled retry does not enqueue the next attempt"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,11 +54,45 @@ pub struct VolumeMount {
|
||||
pub target: String,
|
||||
}
|
||||
|
||||
/// A file's size and last change time. Sync-back trusts a pulled file's stored hash only while
|
||||
/// its stamp is the one taken at hand-off: size alone misses a rewrite of the same length. A
|
||||
/// write within the filesystem's timestamp granularity of the hand-off is not seen.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct FileStamp {
|
||||
size: u64,
|
||||
changed: Option<(i64, i64)>,
|
||||
}
|
||||
|
||||
impl FileStamp {
|
||||
/// Whether the file is as it was at `earlier`. An unreadable change time never matches.
|
||||
pub fn unchanged_since(&self, earlier: &FileStamp) -> bool {
|
||||
self.changed.is_some() && self == earlier
|
||||
}
|
||||
|
||||
pub fn of(meta: &std::fs::Metadata) -> Self {
|
||||
// ctime rather than mtime: a tool that restores mtime (cp -p, tar, rsync -t) cannot set it.
|
||||
#[cfg(unix)]
|
||||
let changed = {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
Some((meta.ctime(), meta.ctime_nsec()))
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let changed = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| (d.as_secs() as i64, d.subsec_nanos() as i64));
|
||||
Self { size: meta.len(), changed }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct VolumeState {
|
||||
pub mount: VolumeMount,
|
||||
pub local_dir: PathBuf,
|
||||
pub manifest: HashMap<String, FileEntry>,
|
||||
pub symlinks: HashMap<String, String>,
|
||||
/// Stamp of each pulled file as the job received it, keyed like `manifest`.
|
||||
pub stamps: HashMap<String, FileStamp>,
|
||||
}
|
||||
|
||||
pub struct DownloadStats {
|
||||
|
||||
@@ -3731,7 +3731,10 @@ pub async fn handle_wac_v2_output(
|
||||
// status row, the order every child completion takes.
|
||||
let segment_ms = sqlx::query_scalar!(
|
||||
"WITH prev AS (SELECT started_at FROM v2_job_queue WHERE id = $1)
|
||||
UPDATE v2_job_queue q SET running = false, started_at = null
|
||||
UPDATE v2_job_queue q SET running = false, started_at = null,
|
||||
extras = CASE WHEN q.started_at IS NULL OR q.extras ? 'wac_first_started_at' THEN q.extras
|
||||
ELSE jsonb_set(COALESCE(q.extras, '{}'::jsonb), '{wac_first_started_at}', to_jsonb(q.started_at))
|
||||
END
|
||||
FROM prev WHERE q.id = $1
|
||||
RETURNING (extract(epoch FROM now() - prev.started_at) * 1000)::bigint",
|
||||
job.id,
|
||||
|
||||
@@ -643,6 +643,7 @@ pub async fn update_worker_ping_for_failed_init_script(
|
||||
wm_memory_usage: None,
|
||||
job_isolation: None,
|
||||
native_mode: None,
|
||||
draining: None,
|
||||
ping_type: PingType::InitScript,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1049,6 +1049,7 @@ where
|
||||
let wm_memory_usage = get_windmill_memory_usage();
|
||||
tracing::info!("job {job_id} on {worker_name} in {w_id} worker memory snapshot {}kB/{}kB", memory_usage.unwrap_or_default()/1024, wm_memory_usage.unwrap_or_default()/1024);
|
||||
let occupancy = occupancy_metrics.as_mut().map(|x| x.update_occupancy_metrics());
|
||||
crate::resource_metrics::record_worker_resources(worker_name, memory_usage, wm_memory_usage, occupancy.as_ref());
|
||||
if job_id != Uuid::nil() {
|
||||
if let Err(err) = update_worker_ping_from_job(&conn, &job_id, w_id, worker_name, memory_usage, wm_memory_usage, occupancy).await {
|
||||
tracing::error!("Unable to update worker ping for job {} in workspace {}. Error was: {:?}", job_id, w_id, err);
|
||||
@@ -1140,8 +1141,8 @@ fn child_joined_output_stream(
|
||||
.take()
|
||||
.expect("child did not have a handle to stdout");
|
||||
|
||||
let stdout = BufReader::new(stdout).lines();
|
||||
let stderr = BufReader::new(stderr).lines();
|
||||
let stdout = BufReader::new(stdout).split(b'\n');
|
||||
let stderr = BufReader::new(stderr).split(b'\n');
|
||||
stream::select(
|
||||
lines_to_stream(stderr, true, job_id.clone(), w_id.clone())
|
||||
.map(|l| l.map(|line| OutputLine { stderr: true, line })),
|
||||
@@ -1151,18 +1152,32 @@ fn child_joined_output_stream(
|
||||
}
|
||||
|
||||
pub fn lines_to_stream<R: tokio::io::AsyncBufRead + Unpin>(
|
||||
mut lines: tokio::io::Lines<R>,
|
||||
mut lines: tokio::io::Split<R>,
|
||||
stderr: bool,
|
||||
job_id: Uuid,
|
||||
w_id: String,
|
||||
) -> impl futures::Stream<Item = io::Result<String>> {
|
||||
stream::poll_fn(move |cx| {
|
||||
std::pin::Pin::new(&mut lines)
|
||||
.poll_next_line(cx)
|
||||
.map(|result| process_streaming_log_lines(result, stderr, &job_id, &w_id))
|
||||
.poll_next_segment(cx)
|
||||
.map(|result| {
|
||||
let result = result.map(|segment| segment.map(decode_output_line));
|
||||
process_streaming_log_lines(result, stderr, &job_id, &w_id)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Child output is not guaranteed to be UTF-8 (e.g. pwsh on Windows writes in the
|
||||
/// console code page), so decode lossily: a strict decode errors out and ends log
|
||||
/// capture for the rest of the job. Strips a trailing `\r` so CRLF output doesn't keep it.
|
||||
pub(crate) fn decode_output_line(mut segment: Vec<u8>) -> String {
|
||||
if segment.last() == Some(&b'\r') {
|
||||
segment.pop();
|
||||
}
|
||||
String::from_utf8(segment)
|
||||
.unwrap_or_else(|err| String::from_utf8_lossy(err.as_bytes()).into_owned())
|
||||
}
|
||||
|
||||
pub fn process_status(
|
||||
program: &str,
|
||||
status: ExitStatus,
|
||||
@@ -1209,4 +1224,33 @@ mod tests {
|
||||
let left = JobDeadline(Some(Instant::now() + Duration::from_secs(120)));
|
||||
assert!(matches!(left.remaining_secs(), Some(s) if (118..=120).contains(&s)));
|
||||
}
|
||||
|
||||
// "Prüfe" in CP850/1252 is not UTF-8. A strict decode errors on that line and
|
||||
// would stop log capture, dropping every line after it.
|
||||
#[tokio::test]
|
||||
async fn a_non_utf8_line_is_decoded_lossily_and_later_lines_still_arrive() {
|
||||
let output: &[u8] =
|
||||
b"start\r\nPr\x81fe Benutzer\nPr\xfcfe\nGr\xc3\xbc\xc3\x9fe\r\nafter\n\nlast";
|
||||
let lines: Vec<String> = lines_to_stream(
|
||||
BufReader::new(output).split(b'\n'),
|
||||
false,
|
||||
Uuid::nil(),
|
||||
"w".into(),
|
||||
)
|
||||
.map(|line| line.expect("reading from a byte slice cannot fail"))
|
||||
.collect()
|
||||
.await;
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec![
|
||||
"start",
|
||||
"Pr\u{FFFD}fe Benutzer",
|
||||
"Pr\u{FFFD}fe",
|
||||
"Grüße",
|
||||
"after",
|
||||
"",
|
||||
"last",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ mod prepare_deps;
|
||||
mod python_executor;
|
||||
#[cfg(feature = "python")]
|
||||
mod python_versions;
|
||||
mod resource_metrics;
|
||||
pub mod result_processor;
|
||||
#[cfg(feature = "rust")]
|
||||
mod rust_executor;
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
use std::sync::{atomic::Ordering, Mutex, OnceLock};
|
||||
|
||||
use windmill_common::{
|
||||
otel_oss::{
|
||||
otel_add_worker_cpu, otel_record_job_memory_peak, otel_set_worker_memory,
|
||||
otel_set_worker_occupancy_rate,
|
||||
},
|
||||
worker::{get_cpu_stat, get_memory, CpuStat, WORKER_GROUP},
|
||||
OTEL_METRICS_ENABLED,
|
||||
};
|
||||
|
||||
use crate::common::OccupancyResult;
|
||||
|
||||
#[cfg(feature = "prometheus")]
|
||||
struct PromMetrics {
|
||||
memory_usage: prometheus::IntGaugeVec,
|
||||
windmill_memory_usage: prometheus::IntGaugeVec,
|
||||
memory_limit: prometheus::IntGaugeVec,
|
||||
cpu_usage: prometheus::CounterVec,
|
||||
cpu_periods: prometheus::IntCounterVec,
|
||||
cpu_throttled_periods: prometheus::IntCounterVec,
|
||||
cpu_throttled_seconds: prometheus::CounterVec,
|
||||
occupancy_rate: prometheus::GaugeVec,
|
||||
job_memory_peak: prometheus::HistogramVec,
|
||||
}
|
||||
|
||||
#[cfg(feature = "prometheus")]
|
||||
lazy_static::lazy_static! {
|
||||
static ref PROM_METRICS: Option<PromMetrics> =
|
||||
if windmill_common::METRICS_ENABLED.load(Ordering::Relaxed) {
|
||||
const WORKER: &[&str] = &["name", "worker_group"];
|
||||
Some(PromMetrics {
|
||||
memory_usage: prometheus::register_int_gauge_vec!(
|
||||
"worker_memory_usage_bytes",
|
||||
"Memory used by the worker's container, page cache excluded",
|
||||
WORKER
|
||||
)
|
||||
.unwrap(),
|
||||
windmill_memory_usage: prometheus::register_int_gauge_vec!(
|
||||
"worker_windmill_memory_usage_bytes",
|
||||
"Resident memory of the worker process itself",
|
||||
WORKER
|
||||
)
|
||||
.unwrap(),
|
||||
memory_limit: prometheus::register_int_gauge_vec!(
|
||||
"worker_memory_limit_bytes",
|
||||
"Memory limit of the worker's container",
|
||||
WORKER
|
||||
)
|
||||
.unwrap(),
|
||||
cpu_usage: prometheus::register_counter_vec!(
|
||||
"worker_cpu_usage_seconds_total",
|
||||
"CPU time consumed by the worker's container",
|
||||
WORKER
|
||||
)
|
||||
.unwrap(),
|
||||
cpu_periods: prometheus::register_int_counter_vec!(
|
||||
"worker_cpu_periods_total",
|
||||
"CPU enforcement periods elapsed for the worker's container",
|
||||
WORKER
|
||||
)
|
||||
.unwrap(),
|
||||
cpu_throttled_periods: prometheus::register_int_counter_vec!(
|
||||
"worker_cpu_throttled_periods_total",
|
||||
"CPU enforcement periods in which the worker's container was throttled",
|
||||
WORKER
|
||||
)
|
||||
.unwrap(),
|
||||
cpu_throttled_seconds: prometheus::register_counter_vec!(
|
||||
"worker_cpu_throttled_seconds_total",
|
||||
"Time the worker's container spent throttled by its CPU limit",
|
||||
WORKER
|
||||
)
|
||||
.unwrap(),
|
||||
occupancy_rate: prometheus::register_gauge_vec!(
|
||||
"worker_occupancy_rate",
|
||||
"Share of time the worker spent executing jobs, over the window",
|
||||
&["name", "worker_group", "window"]
|
||||
)
|
||||
.unwrap(),
|
||||
job_memory_peak: prometheus::register_histogram_vec!(
|
||||
"worker_job_memory_peak_bytes",
|
||||
"Peak memory of a job's main process",
|
||||
&["tag"],
|
||||
windmill_common::worker::JOB_MEMORY_PEAK_BUCKETS.to_vec()
|
||||
)
|
||||
.unwrap(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
|
||||
fn metrics_enabled() -> bool {
|
||||
#[cfg(feature = "prometheus")]
|
||||
if PROM_METRICS.is_some() {
|
||||
return true;
|
||||
}
|
||||
OTEL_METRICS_ENABLED.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
// Memory and CPU are read from the cgroup, which every worker of a process shares
|
||||
// (native mode runs several). Only the first worker to report exports them, so that
|
||||
// summing the series over workers does not count one container several times.
|
||||
static CONTAINER_SERIES_OWNER: OnceLock<String> = OnceLock::new();
|
||||
static MEMORY_LIMIT: Mutex<Option<Option<i64>>> = Mutex::new(None);
|
||||
|
||||
/// Replaces the exported memory limit, for the ping that re-reads the cgroup limits.
|
||||
pub(crate) fn set_memory_limit(limit: Option<i64>) {
|
||||
*MEMORY_LIMIT.lock().unwrap() = Some(limit);
|
||||
}
|
||||
static LAST_CPU_STAT: Mutex<CpuStat> =
|
||||
Mutex::new(CpuStat { usage_usec: 0, nr_periods: 0, nr_throttled: 0, throttled_usec: 0 });
|
||||
|
||||
/// Exports the worker's resource readings. Meant for the ping ticks, which have already
|
||||
/// read memory usage and occupancy; the only extra work is the cgroup `cpu.stat` read.
|
||||
pub(crate) fn record_worker_resources(
|
||||
worker_name: &str,
|
||||
memory_usage: Option<i64>,
|
||||
wm_memory_usage: Option<i64>,
|
||||
occupancy: Option<&OccupancyResult>,
|
||||
) {
|
||||
if !metrics_enabled() {
|
||||
return;
|
||||
}
|
||||
let worker_group = WORKER_GROUP.as_str();
|
||||
|
||||
if let Some(occupancy) = occupancy {
|
||||
let windows = [
|
||||
("total", Some(occupancy.occupancy_rate)),
|
||||
("15s", occupancy.occupancy_rate_15s),
|
||||
("5m", occupancy.occupancy_rate_5m),
|
||||
("30m", occupancy.occupancy_rate_30m),
|
||||
];
|
||||
for (window, rate) in windows {
|
||||
let Some(rate) = rate.filter(|r| r.is_finite()) else {
|
||||
continue;
|
||||
};
|
||||
#[cfg(feature = "prometheus")]
|
||||
if let Some(m) = PROM_METRICS.as_ref() {
|
||||
m.occupancy_rate
|
||||
.with_label_values(&[worker_name, worker_group, window])
|
||||
.set(rate as f64);
|
||||
}
|
||||
otel_set_worker_occupancy_rate(worker_name, worker_group, window, rate as f64);
|
||||
}
|
||||
}
|
||||
|
||||
if CONTAINER_SERIES_OWNER.get_or_init(|| worker_name.to_string()) != worker_name {
|
||||
return;
|
||||
}
|
||||
|
||||
let memory_limit = *MEMORY_LIMIT.lock().unwrap().get_or_insert_with(get_memory);
|
||||
#[cfg(feature = "prometheus")]
|
||||
if let Some(m) = PROM_METRICS.as_ref() {
|
||||
let labels = &[worker_name, worker_group];
|
||||
if let Some(v) = memory_usage {
|
||||
m.memory_usage.with_label_values(labels).set(v);
|
||||
}
|
||||
if let Some(v) = wm_memory_usage {
|
||||
m.windmill_memory_usage.with_label_values(labels).set(v);
|
||||
}
|
||||
if let Some(v) = memory_limit {
|
||||
m.memory_limit.with_label_values(labels).set(v);
|
||||
}
|
||||
}
|
||||
otel_set_worker_memory(
|
||||
worker_name,
|
||||
worker_group,
|
||||
memory_usage,
|
||||
wm_memory_usage,
|
||||
memory_limit,
|
||||
);
|
||||
|
||||
let Some(stat) = get_cpu_stat() else {
|
||||
return;
|
||||
};
|
||||
let delta = {
|
||||
let mut last = LAST_CPU_STAT.lock().unwrap();
|
||||
// A reading below the previous one means the cgroup changed under the process:
|
||||
// restart from it rather than exporting a negative increase.
|
||||
let delta = if stat.usage_usec < last.usage_usec {
|
||||
CpuStat::default()
|
||||
} else {
|
||||
CpuStat {
|
||||
usage_usec: stat.usage_usec - last.usage_usec,
|
||||
nr_periods: stat.nr_periods.saturating_sub(last.nr_periods),
|
||||
nr_throttled: stat.nr_throttled.saturating_sub(last.nr_throttled),
|
||||
throttled_usec: stat.throttled_usec.saturating_sub(last.throttled_usec),
|
||||
}
|
||||
};
|
||||
*last = stat;
|
||||
delta
|
||||
};
|
||||
let usage_secs = delta.usage_usec as f64 / 1e6;
|
||||
let throttled_secs = delta.throttled_usec as f64 / 1e6;
|
||||
#[cfg(feature = "prometheus")]
|
||||
if let Some(m) = PROM_METRICS.as_ref() {
|
||||
let labels = &[worker_name, worker_group];
|
||||
m.cpu_usage.with_label_values(labels).inc_by(usage_secs);
|
||||
m.cpu_periods
|
||||
.with_label_values(labels)
|
||||
.inc_by(delta.nr_periods);
|
||||
m.cpu_throttled_periods
|
||||
.with_label_values(labels)
|
||||
.inc_by(delta.nr_throttled);
|
||||
m.cpu_throttled_seconds
|
||||
.with_label_values(labels)
|
||||
.inc_by(throttled_secs);
|
||||
}
|
||||
otel_add_worker_cpu(
|
||||
worker_name,
|
||||
worker_group,
|
||||
usage_secs,
|
||||
delta.nr_periods,
|
||||
delta.nr_throttled,
|
||||
throttled_secs,
|
||||
);
|
||||
}
|
||||
|
||||
/// `mem_peak_kb` is the job's `mem_peak`: non-positive when the executor had no process
|
||||
/// to sample or the reading failed, and those are not observations.
|
||||
pub(crate) fn record_job_memory_peak(tag: &str, mem_peak_kb: i32) {
|
||||
if mem_peak_kb <= 0 {
|
||||
return;
|
||||
}
|
||||
let bytes = mem_peak_kb as u64 * 1024;
|
||||
#[cfg(feature = "prometheus")]
|
||||
if let Some(m) = PROM_METRICS.as_ref() {
|
||||
m.job_memory_peak
|
||||
.with_label_values(&[tag])
|
||||
.observe(bytes as f64);
|
||||
}
|
||||
otel_record_job_memory_peak(tag, bytes);
|
||||
}
|
||||
@@ -1776,7 +1776,7 @@ pub async fn process_completed_job(
|
||||
|
||||
add_time!(bench, "pre add_completed_job");
|
||||
|
||||
let (_, duration) = add_completed_job(
|
||||
let (_, span) = add_completed_job(
|
||||
db,
|
||||
&job,
|
||||
true,
|
||||
@@ -1835,7 +1835,10 @@ pub async fn process_completed_job(
|
||||
true,
|
||||
canceled_by,
|
||||
result,
|
||||
started_at.map(|x| FlowJobDuration { started_at: x, duration_ms: duration }),
|
||||
span.started_at.or(started_at).map(|x| FlowJobDuration {
|
||||
started_at: x,
|
||||
duration_ms: span.duration_ms,
|
||||
}),
|
||||
StepFailureKind::Normal,
|
||||
&same_worker_tx.expect(SAME_WORKER_REQUIREMENTS).to_owned(),
|
||||
&worker_dir,
|
||||
@@ -1867,9 +1870,10 @@ pub async fn process_completed_job(
|
||||
// `wm_failure` field) so a real runtime failure whose raw
|
||||
// result happens to contain a `wm_failure` field still goes
|
||||
// through the standard `WrappedError { error: ... }` wrap path.
|
||||
let downstream_result: Arc<Box<RawValue>> = if is_pre_shaped_wm_failure_result(result.get())
|
||||
{
|
||||
windmill_queue::add_completed_job_pre_shaped_failure(
|
||||
let (downstream_result, span): (Arc<Box<RawValue>>, _) = if is_pre_shaped_wm_failure_result(
|
||||
result.get(),
|
||||
) {
|
||||
let span = windmill_queue::add_completed_job_pre_shaped_failure(
|
||||
db,
|
||||
&job,
|
||||
mem_peak.to_owned(),
|
||||
@@ -1880,9 +1884,9 @@ pub async fn process_completed_job(
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
result.clone()
|
||||
(result.clone(), span)
|
||||
} else {
|
||||
let wrapped = add_completed_job_error(
|
||||
let (wrapped, span) = windmill_queue::add_completed_job_error_with_span(
|
||||
db,
|
||||
&job,
|
||||
mem_peak.to_owned(),
|
||||
@@ -1895,7 +1899,10 @@ pub async fn process_completed_job(
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Arc::new(serde_json::value::to_raw_value(&wrapped).unwrap())
|
||||
(
|
||||
Arc::new(serde_json::value::to_raw_value(&wrapped).unwrap()),
|
||||
span,
|
||||
)
|
||||
};
|
||||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||||
if job.kind == JobKind::DeploymentCallback {
|
||||
@@ -1927,12 +1934,19 @@ pub async fn process_completed_job(
|
||||
false,
|
||||
canceled_by,
|
||||
downstream_result,
|
||||
duration.and_then(|d| {
|
||||
job.started_at.map(|started_at| FlowJobDuration {
|
||||
started_at: started_at,
|
||||
duration_ms: d,
|
||||
if span.outlasts_run(job.started_at) {
|
||||
span.started_at.map(|started_at| FlowJobDuration {
|
||||
started_at,
|
||||
duration_ms: span.duration_ms,
|
||||
})
|
||||
}),
|
||||
} else {
|
||||
duration.and_then(|d| {
|
||||
job.started_at.map(|started_at| FlowJobDuration {
|
||||
started_at: started_at,
|
||||
duration_ms: d,
|
||||
})
|
||||
})
|
||||
},
|
||||
StepFailureKind::Normal,
|
||||
&same_worker_tx.expect(SAME_WORKER_REQUIREMENTS).to_owned(),
|
||||
&worker_dir,
|
||||
|
||||
@@ -113,6 +113,11 @@ pub enum WacPark {
|
||||
/// handler — falls back to `now() - started_at`. Left pointing at the first segment,
|
||||
/// that fallback reports the whole sleep or approval wait as execution time.
|
||||
///
|
||||
/// The first park keeps that first start in `extras.wac_first_started_at`, so the
|
||||
/// completed row can still say when the workflow began and how long it took overall
|
||||
/// (`Completion::execute`). The queue row's `started_at` stays per segment. The inline
|
||||
/// checkpoint requeue clears `started_at` on its own and keeps the first start the same way.
|
||||
///
|
||||
/// Call it before any write to the parent's `v2_job_status` row in the same
|
||||
/// transaction: a child's completion locks the queue row and then the status row
|
||||
/// (`record_child_completion`), and taking them the other way round here can
|
||||
@@ -156,7 +161,10 @@ pub async fn suspend_wac_parent(
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE v2_job_queue
|
||||
SET suspend = $3, suspend_until = now() + make_interval(secs => $4), started_at = null
|
||||
SET suspend = $3, suspend_until = now() + make_interval(secs => $4), started_at = null,
|
||||
extras = CASE WHEN started_at IS NULL OR extras ? 'wac_first_started_at' THEN extras
|
||||
ELSE jsonb_set(COALESCE(extras, '{}'::jsonb), '{wac_first_started_at}', to_jsonb(started_at))
|
||||
END
|
||||
WHERE id = $1 AND workspace_id = $2",
|
||||
job_id,
|
||||
w_id,
|
||||
|
||||
@@ -155,7 +155,7 @@ use crate::{
|
||||
handle_app_dependency_job, handle_dependency_job, handle_flow_dependency_job,
|
||||
tally_unfinished_dependency_deploy,
|
||||
},
|
||||
worker_utils::{insert_ping, queue_vacuum, update_worker_ping_full},
|
||||
worker_utils::{insert_ping, mark_worker_draining, queue_vacuum, update_worker_ping_full},
|
||||
};
|
||||
|
||||
#[cfg(feature = "rust")]
|
||||
@@ -2713,7 +2713,7 @@ pub async fn run_worker(
|
||||
let mut last_ping = Instant::now() - Duration::from_secs(NUM_SECS_PING + 1);
|
||||
|
||||
let mut reported_ip = cached_ip();
|
||||
let previous_jobs_executed = insert_ping(hostname, &worker_name, reported_ip, conn)
|
||||
let previous_jobs_executed = insert_ping(hostname, &worker_name, reported_ip, true, conn)
|
||||
.await
|
||||
.expect("initial ping could be sent");
|
||||
|
||||
@@ -3160,6 +3160,31 @@ pub async fn run_worker(
|
||||
|
||||
let mut killpill_rx2 = killpill_rx.resubscribe();
|
||||
|
||||
// The loop below only sees the killpill once the running job is done, which is too late
|
||||
// for that job to learn its worker is going away.
|
||||
{
|
||||
let mut killpill_rx = killpill_rx.resubscribe();
|
||||
let conn = conn.clone();
|
||||
let worker_name = worker_name.clone();
|
||||
tokio::spawn(async move {
|
||||
if matches!(
|
||||
killpill_rx.recv().await,
|
||||
Err(broadcast::error::RecvError::Closed)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
for attempt in 1..=3 {
|
||||
match mark_worker_draining(&conn, &worker_name).await {
|
||||
Ok(()) => return,
|
||||
Err(e) => {
|
||||
tracing::warn!(worker = %worker_name, "failed to mark worker as draining (attempt {attempt}/3): {e:#}");
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loop {
|
||||
let last_processing_duration_secs = last_processing_duration.load(Ordering::SeqCst);
|
||||
if last_processing_duration_secs > 5 {
|
||||
@@ -3235,7 +3260,7 @@ pub async fn run_worker(
|
||||
// which costs at most the last job's id here: no job of this worker is in flight at this
|
||||
// point in the loop, and the next one refills them.
|
||||
if ip_just_resolved && conn.as_sql().is_none() {
|
||||
if let Err(e) = insert_ping(hostname, &worker_name, ip, &conn).await {
|
||||
if let Err(e) = insert_ping(hostname, &worker_name, ip, false, &conn).await {
|
||||
tracing::warn!(
|
||||
worker = %worker_name, hostname = %hostname,
|
||||
"failed to re-register with the resolved external IP: {e}"
|
||||
@@ -5105,6 +5130,7 @@ pub async fn handle_queued_job(
|
||||
// WAC v2 job suspended while waiting for child jobs — don't complete it
|
||||
return Ok(JobOutcome::Completed);
|
||||
}
|
||||
crate::resource_metrics::record_job_memory_peak(&cjob.tag, mem_peak);
|
||||
process_result(
|
||||
cjob,
|
||||
result.map(|x| Arc::new(x)),
|
||||
|
||||
@@ -2097,7 +2097,7 @@ pub async fn update_flow_status_after_job_completion_internal(
|
||||
)
|
||||
.await?;
|
||||
let duration = if success {
|
||||
let (_, duration) = add_completed_job(
|
||||
let (_, span) = add_completed_job(
|
||||
db,
|
||||
&cflow_job,
|
||||
true,
|
||||
@@ -2111,9 +2111,9 @@ pub async fn update_flow_status_after_job_completion_internal(
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
duration
|
||||
span.duration_ms
|
||||
} else {
|
||||
let (_, duration) = add_completed_job(
|
||||
let (_, span) = add_completed_job(
|
||||
db,
|
||||
&cflow_job,
|
||||
false,
|
||||
@@ -2131,7 +2131,7 @@ pub async fn update_flow_status_after_job_completion_internal(
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
duration
|
||||
span.duration_ms
|
||||
};
|
||||
flow_job_duration = flow_job
|
||||
.started_at
|
||||
|
||||
@@ -7,9 +7,9 @@ use windmill_common::{
|
||||
external_ip::UNKNOWN_IP,
|
||||
worker::{
|
||||
get_memory, get_vcpus, get_windmill_memory_usage, get_worker_memory_usage,
|
||||
insert_ping_query, update_job_ping_query, update_worker_ping_from_job_query,
|
||||
update_worker_ping_main_loop_query, Connection, Ping, PingType, NATIVE_MODE_RESOLVED,
|
||||
WORKER_CONFIG, WORKER_GROUP,
|
||||
insert_ping_query, set_worker_draining_query, update_job_ping_query,
|
||||
update_worker_ping_from_job_query, update_worker_ping_main_loop_query, Connection, Ping,
|
||||
PingType, NATIVE_MODE_RESOLVED, WORKER_CONFIG, WORKER_GROUP,
|
||||
},
|
||||
KillpillSender, DB,
|
||||
};
|
||||
@@ -39,17 +39,26 @@ pub(crate) async fn update_worker_ping_full(
|
||||
let wm_memory_usage = get_windmill_memory_usage();
|
||||
|
||||
let (vcpus, memory) = if read_cgroups {
|
||||
(get_vcpus(), get_memory())
|
||||
let memory = get_memory();
|
||||
crate::resource_metrics::set_memory_limit(memory);
|
||||
(get_vcpus(), memory)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
let occupancy = occupancy_metrics.update_occupancy_metrics();
|
||||
crate::resource_metrics::record_worker_resources(
|
||||
worker_name,
|
||||
memory_usage,
|
||||
wm_memory_usage,
|
||||
Some(&occupancy),
|
||||
);
|
||||
let OccupancyResult {
|
||||
occupancy_rate,
|
||||
occupancy_rate_15s,
|
||||
occupancy_rate_5m,
|
||||
occupancy_rate_30m,
|
||||
} = occupancy_metrics.update_occupancy_metrics();
|
||||
} = occupancy;
|
||||
|
||||
let ping_start = std::time::Instant::now();
|
||||
if let Err(e) = (|| {
|
||||
@@ -164,6 +173,7 @@ async fn update_worker_ping_full_inner(
|
||||
wm_memory_usage: get_windmill_memory_usage(),
|
||||
job_isolation: None,
|
||||
native_mode: Some(native_mode),
|
||||
draining: None,
|
||||
ping_type: PingType::MainLoop,
|
||||
},
|
||||
)
|
||||
@@ -179,6 +189,7 @@ pub async fn insert_ping(
|
||||
worker_instance: &str,
|
||||
worker_name: &str,
|
||||
ip: Option<&str>,
|
||||
reset_draining: bool,
|
||||
db: &Connection,
|
||||
) -> anyhow::Result<i32> {
|
||||
let (tags, dw, dws, native_mode) = {
|
||||
@@ -224,6 +235,7 @@ pub async fn insert_ping(
|
||||
memory,
|
||||
job_isolation,
|
||||
native_mode,
|
||||
reset_draining.then_some(false),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
@@ -256,6 +268,7 @@ pub async fn insert_ping(
|
||||
wm_memory_usage: get_windmill_memory_usage(),
|
||||
job_isolation,
|
||||
native_mode: Some(native_mode),
|
||||
draining: reset_draining.then_some(false),
|
||||
ping_type: PingType::Initial,
|
||||
},
|
||||
)
|
||||
@@ -314,6 +327,7 @@ pub async fn update_worker_ping_from_job(
|
||||
&Ping {
|
||||
last_job_executed: Some(job_id.clone()),
|
||||
last_job_workspace_id: Some(w_id.to_string()),
|
||||
draining: None,
|
||||
ping_type: PingType::Job,
|
||||
worker_instance: None,
|
||||
ip: None,
|
||||
@@ -342,6 +356,45 @@ pub async fn update_worker_ping_from_job(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Publishes on the worker's ping row that it has received its killpill, so the job it is
|
||||
/// still running can find out and exit early.
|
||||
pub async fn mark_worker_draining(conn: &Connection, worker_name: &str) -> anyhow::Result<()> {
|
||||
match conn {
|
||||
Connection::Sql(db) => set_worker_draining_query(worker_name, db).await,
|
||||
Connection::Http(client) => {
|
||||
client
|
||||
.post::<Ping, ()>(
|
||||
UPDATE_PING_URL,
|
||||
None,
|
||||
&Ping {
|
||||
last_job_executed: None,
|
||||
last_job_workspace_id: None,
|
||||
draining: None,
|
||||
ping_type: PingType::Draining,
|
||||
worker_instance: None,
|
||||
ip: None,
|
||||
tags: None,
|
||||
dw: None,
|
||||
dws: None,
|
||||
version: None,
|
||||
vcpus: None,
|
||||
memory: None,
|
||||
memory_usage: None,
|
||||
wm_memory_usage: None,
|
||||
jobs_executed: None,
|
||||
occupancy_rate: None,
|
||||
occupancy_rate_15s: None,
|
||||
occupancy_rate_5m: None,
|
||||
occupancy_rate_30m: None,
|
||||
job_isolation: None,
|
||||
native_mode: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ping_job_status(
|
||||
conn: &Connection,
|
||||
job_id: &Uuid,
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
|
||||
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
|
||||
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
|
||||
|
||||
export const VERSION = "v1.827.0";
|
||||
export const VERSION = "v1.828.0";
|
||||
|
||||
export async function login(email: string, password: string): Promise<string> {
|
||||
return await windmill.UserService.login({
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "windmill-chat",
|
||||
"version": "1.827.0",
|
||||
"version": "1.828.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "windmill-chat",
|
||||
"version": "1.827.0",
|
||||
"version": "1.828.0",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@ai-sdk/react": "^4.0.102",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "windmill-chat",
|
||||
"description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app",
|
||||
"version": "1.827.0",
|
||||
"version": "1.828.0",
|
||||
"author": "Ruben Fiszel",
|
||||
"license": "Apache-2.0",
|
||||
"homepage": "https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
|
||||
import { requireLogin } from "../../core/auth.ts";
|
||||
import { resolveWorkspace } from "../../core/context.ts";
|
||||
import { mergeConfigWithConfigFile } from "../../core/conf.ts";
|
||||
import {
|
||||
GlobalOptions,
|
||||
isSuperset,
|
||||
@@ -69,6 +71,32 @@ async function list(opts: GlobalOptions) {
|
||||
.render();
|
||||
}
|
||||
|
||||
async function listWorkspaceUsers(opts: GlobalOptions & { json?: boolean }) {
|
||||
if (opts.json) log.setSilent(true);
|
||||
opts = await mergeConfigWithConfigFile(opts);
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
await requireLogin(opts);
|
||||
|
||||
const users = await wmill.listUsers({ workspace: workspace.workspaceId });
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(users));
|
||||
} else {
|
||||
new Table()
|
||||
.header(["username", "email", "role"])
|
||||
.padding(2)
|
||||
.border(true)
|
||||
.body(
|
||||
users.map((u) => [
|
||||
u.username,
|
||||
u.email,
|
||||
u.is_admin ? "admin" : u.operator ? "operator" : "developer",
|
||||
])
|
||||
)
|
||||
.render();
|
||||
}
|
||||
}
|
||||
|
||||
function rdString() {
|
||||
return Math.random().toString(36).slice(2, 7);
|
||||
}
|
||||
@@ -518,6 +546,9 @@ export async function pushInstanceGroups(
|
||||
const command = new Command()
|
||||
.description("user related commands")
|
||||
.action(list as any)
|
||||
.command("list", "List the users of the workspace")
|
||||
.option("--json", "Output as JSON (for piping to jq)")
|
||||
.action(listWorkspaceUsers as any)
|
||||
.command("add", "Create a user")
|
||||
.arguments("<email:string> [password:string]")
|
||||
.option("--superadmin", "Specify to make the new user superadmin.")
|
||||
|
||||
@@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork";
|
||||
// (e.g. utils.ts) can read it without importing main.ts and creating a circular
|
||||
// dependency (main → workspace → utils → main) that triggers a TDZ.
|
||||
// Re-exported from main.ts for backwards compatibility.
|
||||
export const VERSION = "1.827.0";
|
||||
export const VERSION = "1.828.0";
|
||||
|
||||
Generated
+156
-42
File diff suppressed because one or more lines are too long
@@ -69,7 +69,7 @@ makes the client own the whole cascade so the backend dispatcher never double-fi
|
||||
- `frontend/src/lib/components/assets/AssetGraph/cascadeRun.ts` — reusable run primitives
|
||||
(`makeLaunch`, `makeWaitJobTerminal`, `runDownstreamCascade`, `runBoundedCascade`) wrapping
|
||||
`cascadeOrchestrator` + `graphTraversal`.
|
||||
- `frontend/src/lib/components/assets/AssetGraph/PipelineDevView.svelte` + `frontend/src/routes/pipeline_dev/+page.svelte`.
|
||||
- `frontend/src/lib/components/assets/AssetGraph/PipelineDevView.svelte` + `frontend/src/routes/(styled)/pipeline_dev/+page.svelte`.
|
||||
|
||||
**Modified**
|
||||
- `cli/src/commands/pipeline/pipeline.ts` — `--local` on `show`/`run`; `show` split into graph
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.827.0",
|
||||
"version": "1.828.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.827.0",
|
||||
"version": "1.828.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.827.0",
|
||||
"version": "1.828.0",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev",
|
||||
|
||||
@@ -12,6 +12,17 @@
|
||||
fill: #3b82f6;
|
||||
}
|
||||
|
||||
/* Public app pages render their shell before app.css loads, if at all. */
|
||||
html[data-wm-public-app] #svelte-global-loader {
|
||||
display: none;
|
||||
}
|
||||
html[data-wm-public-app] body {
|
||||
margin: 0;
|
||||
}
|
||||
html[data-wm-public-app='dark'] body {
|
||||
background-color: #3b4252;
|
||||
}
|
||||
|
||||
/* Background color for dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
#mainbg {
|
||||
@@ -123,6 +134,64 @@
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
<script>
|
||||
// Public app pages (`/public/<ws>/<secret>`, `/a/<custom path>`): skip the loading
|
||||
// screen, and start the API calls the page makes first so they overlap the module
|
||||
// download instead of following it. `publicAppApi.ts` takes these responses by URL,
|
||||
// so each URL here must be exactly what it requests. Only the cookie credential is
|
||||
// prefetched: a share link carrying a JWT, or the opaque viewer frame (`wm_embed`),
|
||||
// sends its own and fetches normally. The bundle of an unsandboxed raw app is
|
||||
// requested as soon as its secret is known, for the iframe to find in the HTTP cache.
|
||||
;(function () {
|
||||
var path = location.pathname
|
||||
var pub = /^\/public\/([^/]+)\/([^/]+)\/?$/.exec(path)
|
||||
var custom = /^\/a\/(.+?)\/?$/.exec(path)
|
||||
if (!pub && !custom && !/^\/public\//.test(path)) return
|
||||
// Dark as PublicApp decides it, painted before any stylesheet so that a dark app
|
||||
// does not load over a white page.
|
||||
var dark = false
|
||||
try {
|
||||
var mode = localStorage.getItem('dark-mode')
|
||||
dark = mode ? mode === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches
|
||||
} catch (_) {}
|
||||
document.documentElement.setAttribute('data-wm-public-app', dark ? 'dark' : '')
|
||||
if (new URLSearchParams(location.search).get('wm_embed') === '1') return
|
||||
var prefetched = (window.__wmPublicAppPrefetch = {})
|
||||
function get(url) {
|
||||
var res = fetch(url)
|
||||
res.catch(function () {})
|
||||
prefetched[url] = res
|
||||
return res
|
||||
}
|
||||
function warmBundle(appRes, workspace) {
|
||||
appRes
|
||||
.then(function (r) {
|
||||
return r.ok ? r.clone().json() : null
|
||||
})
|
||||
.then(function (app) {
|
||||
if (!app || !app.raw_app || !app.bundle_secret || (app.policy || {}).sandbox) return
|
||||
var ws = workspace || app.workspace_id
|
||||
var v = '/api/w/' + ws + '/apps_u/get_data/v/' + app.bundle_secret
|
||||
fetch(v + '.css').catch(function () {})
|
||||
fetch(v + '.js').catch(function () {})
|
||||
})
|
||||
.catch(function () {})
|
||||
}
|
||||
if (pub) {
|
||||
var w = '/api/w/' + pub[1]
|
||||
get(w + '/apps_u/embed_token/' + pub[2])
|
||||
warmBundle(get(w + '/apps_u/public_app/' + pub[2]), pub[1])
|
||||
get(w + '/apps_u/guest_entry/' + pub[2])
|
||||
get(w + '/users/whoami')
|
||||
} else if (custom && custom[1].indexOf('.') === -1) {
|
||||
// A `.` marks a trailing JWT segment, which no custom path contains.
|
||||
get('/api/apps_u/embed_token_by_custom_path/' + custom[1])
|
||||
warmBundle(get('/api/apps_u/public_app_by_custom_path/' + custom[1]))
|
||||
get('/api/apps_u/guest_entry_by_custom_path/' + custom[1])
|
||||
}
|
||||
get('/api/ee_license')
|
||||
})()
|
||||
</script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-code="viewport" class="outline-none focus:outline-none">
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { fromStore } from 'svelte/store'
|
||||
import { base } from '$lib/base'
|
||||
import { instanceUi } from '$lib/instanceUi'
|
||||
|
||||
const ui = fromStore(instanceUi)
|
||||
|
||||
/**
|
||||
* Base of every URL shown for an external client to call (webhooks, HTTP routes, push
|
||||
* trigger endpoints, the CLI remote): the `api_base_url` instance setting, else the
|
||||
* origin being browsed. Append `/api/...` to it.
|
||||
*
|
||||
* Not for URLs the editor itself calls or opens (captures, previews, OAuth popups, links
|
||||
* to pages): those must stay on the browsing origin, where the session cookie lives.
|
||||
*
|
||||
* Reactive: the setting lands after page load, so read it inside a `$derived` or the
|
||||
* template rather than caching the result.
|
||||
*/
|
||||
export function apiBaseUrl(): string {
|
||||
const setting = ui.current?.api_base_url
|
||||
const configured = typeof setting === 'string' ? setting.trim() : ''
|
||||
return configured || `${window.location.origin}${base}`
|
||||
}
|
||||
@@ -142,7 +142,13 @@
|
||||
let hintTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const kindLabel = $derived(
|
||||
itemKind === 'flow' ? 'flow' : itemKind === 'app' || itemKind === 'raw_app' ? 'app' : 'script'
|
||||
itemKind === 'flow'
|
||||
? 'flow'
|
||||
: itemKind === 'app' || itemKind === 'raw_app'
|
||||
? 'app'
|
||||
: itemKind === 'data_pipeline'
|
||||
? 'pipeline'
|
||||
: 'script'
|
||||
)
|
||||
|
||||
// Each false → true transition fires a hint (others > loaded). Seeded to
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<!--
|
||||
@component
|
||||
The inside of one breadcrumb part: an optional leading icon and the label.
|
||||
|
||||
Every part of the header's breadcrumb renders this, whether its dropdown is a melt menu or a
|
||||
popover, so they share one look. Separators are the host's job — a part must not contain the
|
||||
slash before it, or hovering the part would highlight the separator too.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
let { label, icon }: { label: string; icon?: Snippet } = $props()
|
||||
</script>
|
||||
|
||||
{@render icon?.()}
|
||||
<span class="truncate">{label}</span>
|
||||
@@ -6,7 +6,7 @@ state — switching popovers relies on melt-ui's `closeOnOtherPopoverOpen` to
|
||||
close siblings.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { ChevronRight } from 'lucide-svelte'
|
||||
import BreadcrumbItemContent from './BreadcrumbItemContent.svelte'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import WorkspaceItemDrillPicker, {
|
||||
type Scope
|
||||
@@ -15,7 +15,8 @@ close siblings.
|
||||
|
||||
interface Props {
|
||||
label: string
|
||||
withChevron?: boolean
|
||||
/** Leading glyph, e.g. the folder or user a path scope names. */
|
||||
icon?: import('svelte').Snippet
|
||||
extraClass?: string
|
||||
disabled?: boolean
|
||||
/** Where the picker lands when this segment is clicked. */
|
||||
@@ -33,7 +34,7 @@ close siblings.
|
||||
|
||||
let {
|
||||
label,
|
||||
withChevron = false,
|
||||
icon,
|
||||
extraClass = '',
|
||||
disabled = false,
|
||||
initialScope,
|
||||
@@ -71,8 +72,7 @@ close siblings.
|
||||
{disabled}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
{#if withChevron}<ChevronRight size={10} class="shrink-0" /><span class="truncate">{label}</span
|
||||
>{:else}{label}{/if}
|
||||
<BreadcrumbItemContent {label} {icon} />
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<WorkspaceItemDrillPicker
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
containOverflow?: boolean
|
||||
/** The Windmill version and update notice in the header. */
|
||||
showVersion?: boolean
|
||||
/** Set on the pages that render inside the app shell, under the page header: there the
|
||||
* window is not the card's box, and taking the whole of it pushes 44px past the bottom. */
|
||||
inPage?: boolean
|
||||
children?: import('svelte').Snippet
|
||||
}
|
||||
|
||||
@@ -28,6 +31,7 @@
|
||||
loading = false,
|
||||
containOverflow = false,
|
||||
showVersion = true,
|
||||
inPage = false,
|
||||
children
|
||||
}: Props = $props()
|
||||
|
||||
@@ -37,7 +41,9 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex justify-center h-screen p-4 relative bg-surface-secondary {containOverflow
|
||||
class="flex justify-center {inPage
|
||||
? 'h-full'
|
||||
: 'h-screen'} p-4 relative bg-surface-secondary {containOverflow
|
||||
? 'overflow-hidden'
|
||||
: 'overflow-auto'}"
|
||||
class:items-center={centerVertically}
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
import ClipboardPanel from './details/ClipboardPanel.svelte'
|
||||
import Section from './Section.svelte'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
import { apiBaseUrl } from '$lib/apiBaseUrl.svelte'
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
|
||||
let url = $derived(`${window.location.protocol}//${window.location.hostname}/`)
|
||||
let url = $derived(`${apiBaseUrl()}/`)
|
||||
</script>
|
||||
|
||||
<Section label="CLI quick setup">
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDownRight,
|
||||
ArrowDown,
|
||||
ArrowRight,
|
||||
ArrowUpRight,
|
||||
ArrowUp,
|
||||
Building,
|
||||
CircleCheck,
|
||||
CircleX,
|
||||
@@ -16,7 +16,7 @@
|
||||
RefreshCw,
|
||||
UserPlus
|
||||
} from 'lucide-svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import { onDestroy, untrack } from 'svelte'
|
||||
import type { CiTestResult } from '$lib/gen'
|
||||
import { Alert, Badge } from './common'
|
||||
import {
|
||||
@@ -97,6 +97,8 @@
|
||||
onRetry?: () => void
|
||||
onSelectTarget?: (target: string) => void
|
||||
comparison: WorkspaceComparison | undefined
|
||||
/** The comparison is still in flight, so an empty list says nothing yet. */
|
||||
comparisonLoading?: boolean
|
||||
/** Set when the comparison request failed (e.g. not an admin of the target). */
|
||||
comparisonError?: string
|
||||
/** Initial merge direction; lets the page restore the chosen direction when
|
||||
@@ -144,6 +146,7 @@
|
||||
onRetry,
|
||||
onSelectTarget,
|
||||
comparison,
|
||||
comparisonLoading = false,
|
||||
comparisonError,
|
||||
initialMergeIntoParent = true,
|
||||
deployCount = 0,
|
||||
@@ -486,6 +489,12 @@
|
||||
// Summary cache: stores summaries from both workspaces
|
||||
type SummaryCache = Record<string, { current?: string; parent?: string; loading?: boolean }>
|
||||
let summaryCache = $state<SummaryCache>({})
|
||||
// What has already been asked for. Deliberately not `$state`: the fetchers below run inside an
|
||||
// effect, and a guard that reads reactive state makes that effect depend on what it writes —
|
||||
// which is a loop. A plain Set is also what tells "asked, and the answer was nothing" from
|
||||
// "never asked", which a value map cannot when the value for both is `undefined`.
|
||||
const summariesRequested = new Set<string>()
|
||||
const onBehalfOfRequested = new Set<string>()
|
||||
|
||||
// On-behalf-of tracking for flows and scripts
|
||||
// Source workspace on_behalf_of emails (keyed by workspace/kind:path)
|
||||
@@ -499,51 +508,101 @@
|
||||
return `${diff.kind}:${diff.path}`
|
||||
}
|
||||
|
||||
/** The two things a row needs from an item — the name it shows and who it runs as — are fields
|
||||
* of the same object, so it is read once per item per workspace and awaited by both loops.
|
||||
*
|
||||
* `SHARED_READ_KINDS` is the kinds that carry both. A folder has a summary and no identity; a
|
||||
* schedule or a trigger has an identity read from its own endpoint, which `getOnBehalfOf`
|
||||
* dispatches. */
|
||||
const SHARED_READ_KINDS = ['script', 'flow', 'app', 'raw_app']
|
||||
type ItemRead = { summary?: string; onBehalfOf?: string }
|
||||
const itemReads = new Map<string, Promise<ItemRead>>()
|
||||
|
||||
function readItem(kind: string, path: string, workspace: string): Promise<ItemRead> {
|
||||
const key = `${workspace}/${kind}:${path}`
|
||||
let pending = itemReads.get(key)
|
||||
if (!pending) {
|
||||
// A failure is not an answer, so it is not kept: held, it would read as "this item has
|
||||
// no identity", which is what decides whether the deploy offers to preserve one — and
|
||||
// an item deployed without it silently becomes the deployer's. Each caller swallows its
|
||||
// own, as both did before they shared a read.
|
||||
pending = loadItem(kind, path, workspace).catch((error) => {
|
||||
itemReads.delete(key)
|
||||
throw error
|
||||
})
|
||||
itemReads.set(key, pending)
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
async function loadItem(kind: string, path: string, workspace: string): Promise<ItemRead> {
|
||||
if (kind === 'script') {
|
||||
const script = await ScriptService.getScriptByPath({ workspace, path })
|
||||
return { summary: script.summary, onBehalfOf: script.on_behalf_of_email }
|
||||
} else if (kind === 'flow') {
|
||||
const flow = await FlowService.getFlowByPath({ workspace, path })
|
||||
return { summary: flow.summary, onBehalfOf: flow.on_behalf_of_email }
|
||||
} else if (kind === 'app' || kind === 'raw_app') {
|
||||
const app = await AppService.getAppByPath({ workspace, path })
|
||||
return { summary: app.summary, onBehalfOf: app.policy?.on_behalf_of_email }
|
||||
} else if (kind === 'folder') {
|
||||
const folder = await FolderService.getFolder({ workspace, name: path.replace(/^f\//, '') })
|
||||
return { summary: folder.summary }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
async function fetchSummary(
|
||||
kind: string,
|
||||
path: string,
|
||||
workspace: string
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
if (kind === 'script') {
|
||||
const script = await ScriptService.getScriptByPath({ workspace, path })
|
||||
return script.summary
|
||||
} else if (kind === 'flow') {
|
||||
const flow = await FlowService.getFlowByPath({ workspace, path })
|
||||
return flow.summary
|
||||
} else if (kind === 'app' || kind === 'raw_app') {
|
||||
const app = await AppService.getAppByPath({ workspace, path })
|
||||
return app.summary
|
||||
} else if (kind === 'folder') {
|
||||
const folder = await FolderService.getFolder({ workspace, name: path.replace(/^f\//, '') })
|
||||
return folder.summary
|
||||
}
|
||||
return (await readItem(kind, path, workspace)).summary
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch summary for ${kind}:${path}`, error)
|
||||
return undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Both loops below run one item at a time and outlive the mount that started them: the page
|
||||
// remounts this component when either side of the pair changes, and whatever was in flight then
|
||||
// still resolves. It must not be stored — the summary key is kind and path, which the other
|
||||
// pair spells the same way — and the loop must not keep asking for a pair nobody is looking at.
|
||||
let alive = true
|
||||
onDestroy(() => {
|
||||
alive = false
|
||||
})
|
||||
|
||||
async function fetchSummaries(diffs: WorkspaceItemDiff[]) {
|
||||
// Only fetch summaries for scripts, flows, and apps
|
||||
const itemsToFetch = diffs.filter((diff) =>
|
||||
['script', 'flow', 'app', 'raw_app', 'folder'].includes(diff.kind)
|
||||
)
|
||||
// Read once, so every request in this pass names the pair the pass was started for. Named
|
||||
// the way the diff names the two sides: `exists_in_fork` is this workspace, `exists_in_source`
|
||||
// the one it is compared against.
|
||||
const forkWs = currentWorkspaceId
|
||||
const sourceWs = parentWorkspaceId
|
||||
|
||||
for (const diff of itemsToFetch) {
|
||||
if (!alive) return
|
||||
const key = getItemKey(diff)
|
||||
|
||||
// Skip if already cached or loading
|
||||
if (summaryCache[key]) continue
|
||||
// Asked for already — including one still in flight, and one whose answer was nothing.
|
||||
if (summariesRequested.has(key)) continue
|
||||
summariesRequested.add(key)
|
||||
|
||||
// Mark as loading
|
||||
summaryCache[key] = { loading: true }
|
||||
|
||||
// Fetch from both workspaces in parallel
|
||||
// Only the sides that have the item. An item that is only ahead does not exist in the
|
||||
// other workspace, so asking it there is a 404 by construction — a guaranteed-failing
|
||||
// request per one-sided row, and a console error for each.
|
||||
const [currentSummary, parentSummary] = await Promise.all([
|
||||
fetchSummary(diff.kind, diff.path, currentWorkspaceId),
|
||||
fetchSummary(diff.kind, diff.path, parentWorkspaceId)
|
||||
diff.exists_in_fork ? fetchSummary(diff.kind, diff.path, forkWs) : undefined,
|
||||
diff.exists_in_source ? fetchSummary(diff.kind, diff.path, sourceWs) : undefined
|
||||
])
|
||||
if (!alive) return
|
||||
|
||||
summaryCache[key] = {
|
||||
current: currentSummary,
|
||||
@@ -561,18 +620,32 @@
|
||||
(d) =>
|
||||
['flow', 'script', 'app', 'raw_app'].includes(d.kind) || isTriggerOrScheduleKind(d.kind)
|
||||
)
|
||||
const pair = [
|
||||
{ workspace: currentWorkspaceId, side: 'fork' as const },
|
||||
{ workspace: parentWorkspaceId, side: 'source' as const }
|
||||
]
|
||||
for (const diff of itemsWithOnBehalfOf) {
|
||||
for (const workspace of [currentWorkspaceId, parentWorkspaceId]) {
|
||||
for (const { workspace, side } of pair) {
|
||||
if (!alive) return
|
||||
const workspacedKey = getWorkspacedKey(workspace, getItemKey(diff))
|
||||
if (onBehalfOfInfo[workspacedKey] !== undefined) continue
|
||||
// Marked before the request, not after it answers: a guard keyed on the value it
|
||||
// stores (`undefined` either way) would ask again on every pass, forever.
|
||||
if (onBehalfOfRequested.has(workspacedKey)) continue
|
||||
onBehalfOfRequested.add(workspacedKey)
|
||||
// The side that does not have the item has nothing to say about who it runs as, and
|
||||
// asking produces a 404 rather than an answer.
|
||||
if (!(side === 'fork' ? diff.exists_in_fork : diff.exists_in_source)) continue
|
||||
|
||||
try {
|
||||
onBehalfOfInfo[workspacedKey] = await getOnBehalfOf(
|
||||
diff.kind as Kind,
|
||||
diff.path,
|
||||
workspace
|
||||
)
|
||||
// The same read the summary loop does, for the kinds that answer both from one
|
||||
// object; the rest keep their own endpoint.
|
||||
const email = SHARED_READ_KINDS.includes(diff.kind)
|
||||
? (await readItem(diff.kind, diff.path, workspace)).onBehalfOf
|
||||
: await getOnBehalfOf(diff.kind as Kind, diff.path, workspace)
|
||||
if (!alive) return
|
||||
onBehalfOfInfo[workspacedKey] = email
|
||||
} catch {
|
||||
if (!alive) return
|
||||
onBehalfOfInfo[workspacedKey] = undefined
|
||||
}
|
||||
}
|
||||
@@ -1004,11 +1077,16 @@
|
||||
)
|
||||
)
|
||||
|
||||
// Fetch summaries and on_behalf_of_email when comparison data loads
|
||||
// Fetch summaries and on_behalf_of_email when comparison data loads. `untrack`: both fetchers
|
||||
// write the state they also read, and without this the effect depends on their writes and
|
||||
// re-runs on each one.
|
||||
$effect(() => {
|
||||
if (comparison?.diffs) {
|
||||
fetchSummaries(comparison.diffs)
|
||||
fetchOnBehalfOfInfo(comparison.diffs)
|
||||
const diffs = comparison?.diffs
|
||||
if (diffs) {
|
||||
untrack(() => {
|
||||
fetchSummaries(diffs)
|
||||
fetchOnBehalfOfInfo(diffs)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1229,6 +1307,7 @@
|
||||
onSelectAll={selectAll}
|
||||
onDeselectAll={deselectAll}
|
||||
emptyMessage={emptyDeployMessage}
|
||||
loading={comparisonLoading}
|
||||
>
|
||||
{#snippet header()}
|
||||
<div class="flex items-center justify-between bg-surface-tertiary">
|
||||
@@ -1650,13 +1729,13 @@
|
||||
{#if isConflict || existsInBothWorkspaces}
|
||||
{#if diff.ahead > 0}
|
||||
<Badge color="green" size="xs">
|
||||
<ArrowUpRight class="w-3 h-3 inline" />
|
||||
<ArrowUp class="w-3 h-3 inline" />
|
||||
{diff.ahead} ahead
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if diff.behind > 0}
|
||||
<Badge color="blue" size="xs">
|
||||
<ArrowDownRight class="w-3 h-3 inline" />
|
||||
<ArrowDown class="w-3 h-3 inline" />
|
||||
{diff.behind} behind
|
||||
</Badge>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<!--
|
||||
@component
|
||||
Re-exposes contexts from one component tree inside another.
|
||||
|
||||
A snippet handed to the page header renders under the header, not under the page that wrote it, so
|
||||
any component it creates looks its contexts up in the header's tree and finds nothing. The page
|
||||
passes the entries its buttons need and this sets them again around the render.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { setContext, type Snippet } from 'svelte'
|
||||
|
||||
let { contexts, children }: { contexts?: Map<any, any>; children: Snippet } = $props()
|
||||
|
||||
// setContext only takes effect during init, so this instance is keyed on `contexts` by callers.
|
||||
if (contexts) for (const [key, value] of contexts) setContext(key, value)
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
@@ -15,7 +15,7 @@
|
||||
contentClasses="p-4"
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<Button variant="accent" size="xs" nonCaptureEvent={true} startIcon={{ icon: Clock }}>
|
||||
<Button variant="default" unifiedSize="sm" nonCaptureEvent={true} startIcon={{ icon: Clock }}>
|
||||
Cron builder
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
@@ -375,7 +375,8 @@
|
||||
}
|
||||
if (!isInstanceDatatable(datatable))
|
||||
return {
|
||||
reason: 'Only data tables stored in the instance database have roles and grants.',
|
||||
reason:
|
||||
"Roles and grants are not supported on data tables backed by a Postgres resource. Store the data table on Windmill's database or on an external Postgres instance (set up by a superadmin in instance settings) to use them.",
|
||||
ee: false
|
||||
}
|
||||
if (needsAdmin && !canManageDatatable)
|
||||
@@ -458,6 +459,11 @@
|
||||
// default to open — actually be folded; a plain "expanded" set could never
|
||||
// close them, since the default would keep winning.
|
||||
// What the permissions drawer is open on, if anything: a schema, or a table.
|
||||
let aclEditor = $state<PgAclEditor | undefined>(undefined)
|
||||
/** The open access editor lists the data table's roles, which the roles drawer can change. */
|
||||
export function refreshAcl() {
|
||||
aclEditor?.refresh()
|
||||
}
|
||||
let aclDrawer = $state<{ datatable: string | undefined; target: AclTarget } | undefined>(
|
||||
undefined
|
||||
)
|
||||
@@ -1466,7 +1472,7 @@
|
||||
<Portal>
|
||||
<Drawer open={!!aclDrawer} size="900px" on:close={() => (aclDrawer = undefined)}>
|
||||
<DrawerContent
|
||||
title="Access — {aclLabel(aclDrawer?.target)}"
|
||||
title="Access: {aclLabel(aclDrawer?.target)}"
|
||||
on:close={() => (aclDrawer = undefined)}
|
||||
tooltip="Who owns this, and what each role may do with it."
|
||||
>
|
||||
@@ -1474,7 +1480,18 @@
|
||||
{@const dt = aclDrawer.datatable ?? currentDatatable}
|
||||
{#if dt}
|
||||
{#key `${dt}~${JSON.stringify(aclDrawer.target)}`}
|
||||
<PgAclEditor {workspace} datatable={dt} target={aclDrawer.target} />
|
||||
<PgAclEditor
|
||||
bind:this={aclEditor}
|
||||
{workspace}
|
||||
datatable={dt}
|
||||
target={aclDrawer.target}
|
||||
manageRoles={onDatatableAction
|
||||
? {
|
||||
open: () => onDatatableAction?.(dt, 'roles'),
|
||||
blocker: accessBlocker(dt, true)?.reason
|
||||
}
|
||||
: undefined}
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -311,6 +311,7 @@
|
||||
function refreshRoles() {
|
||||
datatables.refetch()
|
||||
usableRoles.refetch()
|
||||
dbManagerContent?.dbManager()?.refreshAcl()
|
||||
}
|
||||
|
||||
async function handleExportSchema(explicitSource?: string) {
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
selectInputClass?: string
|
||||
class?: string
|
||||
onClear?: () => void
|
||||
/** Size the input to its content, like Path's folder picker. */
|
||||
useContentEditable?: boolean
|
||||
RightIcon?: any
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -27,7 +30,9 @@
|
||||
placeholder = undefined,
|
||||
selectInputClass = '',
|
||||
class: className = '',
|
||||
onClear = undefined
|
||||
onClear = undefined,
|
||||
useContentEditable = false,
|
||||
RightIcon = undefined
|
||||
}: Props = $props()
|
||||
|
||||
let datatables = usePromise(() =>
|
||||
@@ -47,6 +52,8 @@
|
||||
{placeholder}
|
||||
inputClass={selectInputClass}
|
||||
{onClear}
|
||||
{useContentEditable}
|
||||
{RightIcon}
|
||||
/>
|
||||
{#if showSchemaExplorer && value && assetCanBeExplored({ kind: 'datatable', path: value })}
|
||||
<ExploreAssetButton class="mt-1 w-fit" asset={{ kind: 'datatable', path: value }} />
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<!--
|
||||
@component
|
||||
The badge naming a dev workspace's environment, wherever one is shown.
|
||||
|
||||
A fork that has a colour wears it: the badge is filled with that hue at the lightest shade white
|
||||
still reads on (`forkAccentStyle`), so a yellow workspace says "dev" in dark yellow rather than in
|
||||
a blue that belongs to no workspace in particular. A fork with no colour keeps whatever its host
|
||||
gave it — `fallbackColor` and `fallbackClass`, which is what every dev workspace looked like
|
||||
before a colour could be picked.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Badge } from '$lib/components/common'
|
||||
import type { BadgeColor } from '$lib/components/common/badge/model'
|
||||
import { devBadgeText } from '$lib/utils/devWorkspaceLabel'
|
||||
import { forkAccentStyle } from '$lib/utils/forkColor'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
interface Props {
|
||||
/** The workspace's `dev_workspace_label`; spelled the way `devBadgeText` spells it. Nullable
|
||||
* because that is what the workspace rows carry. */
|
||||
label?: string | null
|
||||
/** The fork's own colour. Without one — or with an unparsable one — the badge falls back. */
|
||||
color?: string | null
|
||||
/** The badge's colour when the fork has none of its own. */
|
||||
fallbackColor?: BadgeColor
|
||||
/** Extra classes for that same case, for a host that tuned the uncoloured badge. */
|
||||
fallbackClass?: string
|
||||
/** Size and spacing, which the row around the badge decides either way. */
|
||||
class?: string
|
||||
}
|
||||
|
||||
let {
|
||||
label,
|
||||
color,
|
||||
fallbackColor = 'dark-blue',
|
||||
fallbackClass,
|
||||
class: className
|
||||
}: Props = $props()
|
||||
|
||||
// Set on the badge itself rather than read from an ancestor: a host that paints a coloured chip
|
||||
// around it already defines these, and defining them again costs nothing and makes the badge
|
||||
// work the same in a row that has no chip.
|
||||
const accent = $derived(forkAccentStyle(color))
|
||||
</script>
|
||||
|
||||
<Badge
|
||||
color={fallbackColor}
|
||||
small
|
||||
style={accent}
|
||||
class={twMerge(
|
||||
accent
|
||||
? 'bg-[color:var(--fork-accent-badge)] dark:bg-[color:var(--fork-accent-badge)] text-white dark:text-white'
|
||||
: fallbackClass,
|
||||
className
|
||||
)}>{devBadgeText(label)}</Badge
|
||||
>
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { workspaceStore, userWorkspaces, usersWorkspaceStore, superadmin } from '$lib/stores'
|
||||
import { WorkspaceService, type ProtectionRuleset } from '$lib/gen'
|
||||
import { Badge, Button } from '$lib/components/common'
|
||||
import DevWorkspaceBadge from '$lib/components/DevWorkspaceBadge.svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import Select from '$lib/components/select/Select.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
@@ -17,7 +18,6 @@
|
||||
import { getUserExt } from '$lib/user'
|
||||
import {
|
||||
DEV_WORKSPACE_LABELS,
|
||||
devBadgeText,
|
||||
devLabelKey,
|
||||
devLabelNoun,
|
||||
type DevWorkspaceLabelKey
|
||||
@@ -554,7 +554,11 @@
|
||||
<b>{parentId}</b>. Promote changes from the home page banner or the Compare & Deploy page.
|
||||
</p>
|
||||
<div class="text-2xs text-secondary">
|
||||
Environment: <Badge color="indigo" small>{devBadgeText(currentLabel)}</Badge>
|
||||
Environment: <DevWorkspaceBadge
|
||||
label={currentLabel}
|
||||
color={currentWs?.color}
|
||||
fallbackColor="indigo"
|
||||
/>
|
||||
<span class="ml-1">
|
||||
Set when the workspace is created or attached. Git sync deploys to the
|
||||
<span class="font-mono">{currentLabel}</span> branch.
|
||||
|
||||
@@ -107,9 +107,19 @@
|
||||
<DropdownSubmenuItem {item} {builders} {meltItem} close={close ?? (() => {})} />
|
||||
{:else if item.disabled && item.tooltip}
|
||||
<!-- Wrapper carries the native `title`; the disabled button's `pointer-events-none`
|
||||
lets the hover reach it so the user learns why the item is disabled. -->
|
||||
<div title={item.tooltip} class="w-full">
|
||||
{@render menuItem(item)}
|
||||
lets the hover reach it so the user learns why the item is disabled. The ⓘ sits
|
||||
outside the disabled button, the only place it can receive a hover. -->
|
||||
<div class="relative w-full">
|
||||
<div title={item.tooltip} class="w-full pr-6">
|
||||
{@render menuItem(item)}
|
||||
</div>
|
||||
<div class="absolute right-3 top-1/2 -translate-y-1/2 flex">
|
||||
<Tooltip>
|
||||
{#snippet text()}
|
||||
{item.tooltip}
|
||||
{/snippet}
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{@render menuItem(item)}
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
selectInputClass?: string
|
||||
class?: string
|
||||
onClear?: () => void
|
||||
/** Size the input to its content, like Path's folder picker. */
|
||||
useContentEditable?: boolean
|
||||
RightIcon?: any
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -27,7 +30,9 @@
|
||||
placeholder = undefined,
|
||||
selectInputClass = '',
|
||||
class: className = '',
|
||||
onClear = undefined
|
||||
onClear = undefined,
|
||||
useContentEditable = false,
|
||||
RightIcon = undefined
|
||||
}: Props = $props()
|
||||
|
||||
let ducklakes = usePromise(() =>
|
||||
@@ -45,6 +50,8 @@
|
||||
{placeholder}
|
||||
inputClass={selectInputClass}
|
||||
{onClear}
|
||||
{useContentEditable}
|
||||
{RightIcon}
|
||||
/>
|
||||
{#if showSchemaExplorer && value && assetCanBeExplored({ kind: 'ducklake', path: value })}
|
||||
<ExploreAssetButton class="mt-1 w-fit" asset={{ kind: 'ducklake', path: value }} />
|
||||
|
||||
@@ -1523,6 +1523,9 @@
|
||||
} catch (e) {
|
||||
console.log('error initializing services', e)
|
||||
}
|
||||
// Unmounted while the services loaded: the container is detached, and
|
||||
// creating the editor on it throws.
|
||||
if (destroyed || !divEl?.isConnected) return
|
||||
|
||||
// vscode.languages.registerDefinitionProvider('*', {
|
||||
// provideDefinition(document, position, token) {
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
| 'powershell'
|
||||
| 'bunnative'
|
||||
| 'claudesandbox'
|
||||
| 'pisandbox'
|
||||
| 'wac_python'
|
||||
| 'wac_typescript'
|
||||
| 'ci_test_bun'
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Pencil } from 'lucide-svelte'
|
||||
import { Alert, Button } from '$lib/components/common'
|
||||
import { ChevronRight } from 'lucide-svelte'
|
||||
import EditableInput from '$lib/components/common/EditableInput.svelte'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import Path from '$lib/components/Path.svelte'
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
import PathEditPopover from '$lib/components/PathEditPopover.svelte'
|
||||
import {
|
||||
dirKey,
|
||||
KIND_LABEL_LOWER,
|
||||
@@ -14,17 +11,7 @@
|
||||
type WorkspaceItemKind
|
||||
} from '$lib/components/workspacePicker'
|
||||
import BreadcrumbSegment from '$lib/components/BreadcrumbSegment.svelte'
|
||||
import { isOwner } from '$lib/utils'
|
||||
import { userStore } from '$lib/stores'
|
||||
import {
|
||||
useOperatingUser,
|
||||
useOperatingWorkspace
|
||||
} from '$lib/components/operatingWorkspace.svelte'
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
const operatingUser = useOperatingUser()
|
||||
const actingUser = $derived(operatingUser.current)
|
||||
|
||||
import { splitItemPath } from '$lib/components/breadcrumbPath'
|
||||
interface Props {
|
||||
summary?: string
|
||||
path?: string
|
||||
@@ -75,55 +62,17 @@
|
||||
workspaceId
|
||||
}: Props = $props()
|
||||
|
||||
let pathPopoverOpen = $state(false)
|
||||
/** Snapshot of `path` taken when the pen popover opens; cleared on close.
|
||||
* While set, the breadcrumb derives from it instead of `path` so the pen
|
||||
* anchor doesn't reflow as the user types in the popover (which would drag
|
||||
* the popover with it via floating-ui's auto-update). */
|
||||
/** Held by the pen popover while it is open, so the trail below — and the pen anchored to its
|
||||
* end — does not reflow as the user types. */
|
||||
let snapshotPath = $state<string | undefined>(undefined)
|
||||
|
||||
function setPathPopoverOpen(open: boolean) {
|
||||
pathPopoverOpen = open
|
||||
// Only snapshot when there's a path to freeze. New flows / scripts open
|
||||
// the popover with `path === ''` and rely on `Path.reset()` to seed a
|
||||
// path; if we snapshot the empty value the breadcrumb stays blank for
|
||||
// the whole popover lifetime. Leaving `snapshotPath` undefined lets
|
||||
// `displayPath` track the live seeded path instead.
|
||||
snapshotPath = open && path ? path : undefined
|
||||
}
|
||||
|
||||
// Path segments. e.g. "f/demo/weather_report" → scope "f/demo", slug "weather_report".
|
||||
// `snapshotPath` keeps the breadcrumb frozen while the pen popover is open
|
||||
// so the trigger doesn't drift mid-edit. Outside of that, the breadcrumb
|
||||
// follows the live `path` — coherent with the picker because we inject a
|
||||
// virtual current-item entry at that same live path (see `currentItem`).
|
||||
let displayPath = $derived(snapshotPath ?? path ?? '')
|
||||
/** Breakdown for breadcrumb rendering. Top-level dirs (scopes) come first
|
||||
* — `f/<folder>` or `u/<user>` — then any number of subfolders, then the
|
||||
* item name. Each dir entry's `fullPath` is the cumulative path up to
|
||||
* that level, used to key the picker's `initialOpen` / `initialHighlight`. */
|
||||
let segments = $derived.by(() => {
|
||||
const parts = displayPath.split('/')
|
||||
if (parts.length < 3) return null
|
||||
const scope = parts.slice(0, 2).join('/')
|
||||
const slug = parts.slice(2)
|
||||
const dirs: { name: string; fullPath: string }[] = []
|
||||
// Top-level scope is a dir.
|
||||
dirs.push({ name: scope, fullPath: scope })
|
||||
// Intermediate slug parts (everything except the last one).
|
||||
let acc = scope
|
||||
for (let i = 0; i < slug.length - 1; i++) {
|
||||
acc = `${acc}/${slug[i]}`
|
||||
dirs.push({ name: slug[i], fullPath: acc })
|
||||
}
|
||||
const leaf = { name: slug[slug.length - 1], fullPath: displayPath }
|
||||
return { dirs, leaf }
|
||||
})
|
||||
|
||||
// Treat an empty path as ownable so the pen popover lets a user pick the
|
||||
// path for a brand-new item. `Path.reset()` then synthesizes a default
|
||||
// under their own user/folder scope.
|
||||
let own = $derived(!path || isOwner(path, actingUser, $operatingWorkspace))
|
||||
let segments = $derived(splitItemPath(displayPath) ?? null)
|
||||
|
||||
// Virtual entry for the picker: surfaces the currently-edited item at its
|
||||
// live path (which may differ from `savedPath` mid-rename, so the picker
|
||||
@@ -145,7 +94,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="inline-block max-w-full align-top group px-2 py-0.5 leading-tight">
|
||||
<div class="max-w-full group px-2 py-0.5 leading-tight inline-block align-top">
|
||||
<!-- Path row -->
|
||||
{#if !hidePath}
|
||||
<div class="flex items-center max-w-full text-2xs text-secondary font-mono">
|
||||
@@ -162,9 +111,9 @@
|
||||
{#if segments}
|
||||
{#each segments.dirs as dir, i (dir.fullPath)}
|
||||
{@const dKey = dirKey('all', dir.fullPath)}
|
||||
<ChevronRight size={10} class="shrink-0" />
|
||||
<BreadcrumbSegment
|
||||
label={dir.name}
|
||||
withChevron
|
||||
extraClass={i === 0 ? 'gap-0.5 min-w-0 max-w-[40%]' : 'gap-0.5 min-w-0'}
|
||||
initialScope={i === 0
|
||||
? { kind: 'all' }
|
||||
@@ -175,11 +124,13 @@
|
||||
onPick={handlePickerSelect}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if segments?.leaf}
|
||||
{@const leafKey = leafKeyFor(kind, segments.leaf.fullPath)}
|
||||
{@const leafParent = segments.dirs[segments.dirs.length - 1]?.fullPath}
|
||||
<ChevronRight size={10} class="shrink-0" />
|
||||
<BreadcrumbSegment
|
||||
label={segments.leaf.name}
|
||||
withChevron
|
||||
extraClass="gap-0.5 min-w-0"
|
||||
initialScope={leafParent ? { kind: 'all', dir: leafParent } : { kind: 'all' }}
|
||||
initialHighlight={leafKey}
|
||||
@@ -191,65 +142,21 @@
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<!-- Pen → path-edit popover. Skipped entirely when path editing is
|
||||
disabled so the user doesn't see an inert button. -->
|
||||
<!-- Skipped entirely when path editing is disabled so the user doesn't see an inert
|
||||
button. -->
|
||||
{#if pathEditable}
|
||||
<Popover
|
||||
placement="bottom-start"
|
||||
contentClasses="p-4"
|
||||
usePointerDownOutside
|
||||
excludeSelectors=".drawer"
|
||||
disableFocusTrap
|
||||
closeOnOtherPopoverOpen
|
||||
bind:isOpen={() => pathPopoverOpen, setPathPopoverOpen}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
iconOnly
|
||||
startIcon={{ icon: Pencil }}
|
||||
title="Edit path"
|
||||
aria-label="Edit path"
|
||||
btnClasses={penVisibility === 'hover' && !pathPopoverOpen
|
||||
? 'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100'
|
||||
: ''}
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<div class="flex flex-col gap-6 w-[480px]">
|
||||
{#if own}
|
||||
<Path
|
||||
autofocus
|
||||
bind:path
|
||||
initialPath={snapshotPath ?? path ?? ''}
|
||||
namePlaceholder={kind}
|
||||
{kind}
|
||||
size="sm"
|
||||
drawerOffset={4000}
|
||||
workspaceOverride={workspaceId}
|
||||
/>
|
||||
{#if savedPath && path && path !== savedPath}
|
||||
<Alert
|
||||
type="info"
|
||||
size="xs"
|
||||
title="Deploy the {kind} to make the path change effective."
|
||||
/>
|
||||
{/if}
|
||||
{#if onBehalfOfEmail}
|
||||
<Alert type="info" title="Run on behalf of" size="xs">
|
||||
This flow will be redeployed on behalf of you ({$userStore?.email}) instead of {onBehalfOfEmail}
|
||||
</Alert>
|
||||
{/if}
|
||||
{:else}
|
||||
<Label label="Path">
|
||||
<span class="text-xs font-mono text-secondary">{path}</span>
|
||||
<p class="text-2xs text-tertiary mt-1">Only the owner can change the path</p>
|
||||
</Label>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
<!-- Path only: this bar carries its own summary field below, and `hidePath` would take
|
||||
the pen with it, leaving a condensed pane no way to name the item. -->
|
||||
<PathEditPopover
|
||||
bind:path
|
||||
bind:snapshotPath
|
||||
summaryEditable={false}
|
||||
{savedPath}
|
||||
{kind}
|
||||
{workspaceId}
|
||||
{onBehalfOfEmail}
|
||||
{penVisibility}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user