Merge branch 'oidc-discovery-signing-alg' into oidc-external-signer

# Conflicts:
#	backend/ee-repo-ref.txt
This commit is contained in:
Ruben Fiszel
2026-10-09 11:46:47 +02:00
251 changed files with 13239 additions and 3326 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
+1 -1
View File
@@ -1 +1 @@
2f901d28bb1923ac91a04f71d3fdcd8abf28c6db
f94aa566e18aacab3b012d6909b3e0d6d6996a9a
@@ -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;
+1 -1
View File
@@ -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)
+67
View File
@@ -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
View File
@@ -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();
+67
View File
@@ -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(())
}
+88
View File
@@ -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(())
}
+31 -1
View File
@@ -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.
+1
View File
@@ -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,
@@ -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(())
}
+17 -17
View File
@@ -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)))
+1 -1
View File
@@ -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')
+2 -1
View File
@@ -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",
+27 -2
View File
@@ -1919,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.
@@ -1928,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:
@@ -1936,6 +1936,7 @@ paths:
properties:
instance_banner: {}
accent_color: {}
api_base_url: {}
/settings_u/ruff_config:
get:
@@ -17382,6 +17383,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
@@ -35961,6 +35983,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
+51
View File
@@ -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"#,
+34 -7
View File
@@ -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!(
+33 -25
View File
@@ -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>,
@@ -1379,8 +1381,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
@@ -1403,29 +1405,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}"
));
}
}
}
+33
View File
@@ -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) {}
+34 -8
View File
@@ -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() {
+115 -2
View File
@@ -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);
+100 -32
View File
@@ -1221,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!(
@@ -1229,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,
@@ -1244,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(
@@ -1257,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 };
@@ -1266,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,
@@ -1281,7 +1306,7 @@ pub async fn add_completed_job_error(
)
.warn_after_seconds(10)
.await?;
Ok(result)
Ok((result, span))
}
lazy_static::lazy_static! {
@@ -1320,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();
@@ -1354,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,
@@ -1395,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
@@ -1436,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(
@@ -1453,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>(
@@ -1471,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;
@@ -1538,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?;
@@ -1550,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!(
@@ -1577,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);
};
@@ -1787,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,
@@ -1813,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.
@@ -1820,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,
@@ -1851,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
@@ -1871,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,
@@ -1888,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,
@@ -1910,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
@@ -1930,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,
@@ -1947,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,
@@ -2021,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 {
@@ -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 {
+4 -1
View File
@@ -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,
+1
View File
@@ -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);
+1
View File
@@ -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);
}
+27 -13
View File
@@ -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,
+9 -1
View File
@@ -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,
+29 -3
View File
@@ -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)),
+4 -4
View File
@@ -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
+58 -5
View File
@@ -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,
+76 -2
View File
@@ -839,6 +839,14 @@ async setProgress(percent: number, jobId?: any): Promise<void>
*/
async getProgress(jobId?: any): Promise<number | null>
/**
* Whether the worker running this job has received its shutdown signal.
* A draining worker waits for its current job to finish before exiting, so a
* long-running script can poll this and return early to let the worker be replaced.
* @param jobId? Job to check, defaults to the current job
*/
async workerIsDraining(jobId?: string): Promise<boolean>
/**
* Set a flow user state
* @param key key of the state
@@ -1674,6 +1682,14 @@ async setProgress(percent: number, jobId?: any): Promise<void>
*/
async getProgress(jobId?: any): Promise<number | null>
/**
* Whether the worker running this job has received its shutdown signal.
* A draining worker waits for its current job to finish before exiting, so a
* long-running script can poll this and return early to let the worker be replaced.
* @param jobId? Job to check, defaults to the current job
*/
async workerIsDraining(jobId?: string): Promise<boolean>
/**
* Set a flow user state
* @param key key of the state
@@ -2615,6 +2631,14 @@ async setProgress(percent: number, jobId?: any): Promise<void>
*/
async getProgress(jobId?: any): Promise<number | null>
/**
* Whether the worker running this job has received its shutdown signal.
* A draining worker waits for its current job to finish before exiting, so a
* long-running script can poll this and return early to let the worker be replaced.
* @param jobId? Job to check, defaults to the current job
*/
async workerIsDraining(jobId?: string): Promise<boolean>
/**
* Set a flow user state
* @param key key of the state
@@ -4620,6 +4644,18 @@ def set_progress(value: int, job_id: Optional[str] = None)
# Progress value (0-100) or None if not set
def get_progress(job_id: Optional[str] = None) -> Any
# Whether the worker running the job has received its shutdown signal.
#
# A draining worker waits for its current job to finish before exiting. A long-running
# script can poll this and return early so the worker can be replaced.
#
# Args:
# job_id: Job ID (defaults to current WM_JOB_ID)
#
# Returns:
# True if the worker running the job is draining
def worker_is_draining(job_id: Optional[str] = None) -> bool
# Set the user state of a flow at a given key
def set_flow_user_state(key: str, value: Any) -> None
@@ -7879,10 +7915,48 @@ A **data pipeline** is NOT a flow. A flow is one runnable that orchestrates step
A pipeline node that produces a table should almost always be a **\`duckdb\`** node that materializes its output into a **DuckLake** table with \`-- materialize ducklake://<name>/<table>\` (in a DuckDB node the annotation uses SQL \`--\` comment syntax; write the body as a bare \`SELECT\` and let the runtime do the write). DuckLake is the default lakehouse store for pipelines and is the shape the pipeline editor is built around, so prefer it unless the work specifically calls for something else:
- \`postgresql\` / data tables — only for row-level, OLTP-style mutations against an existing Postgres data table (frequent single-row upserts/updates, transactional reads that an app queries live).
- \`bun\` / \`python3\` — only for non-tabular work that doesn't map to SQL: calling an external API, wrangling files, arbitrary glue. When such a node still produces tabular data for downstream steps, land it in DuckLake (write it with the wmill SDK / ducklake helpers) rather than inventing a parallel store.
- \`bun\` / \`python3\` — only for work that doesn't map to SQL: calling an external API, wrangling files, arbitrary glue. When such a node produces tabular data for downstream steps, write it **straight into DuckLake** (or a data table) from the same script — see "Ingesting from Python / TypeScript" below — rather than inventing a parallel store.
Do not spread a pipeline across postgres, S3, and DuckLake when one DuckLake lake would do; a consistent DuckLake lakehouse is the goal.
## Ingesting from Python / TypeScript
A node that fetches data in code (an API, a SaaS export, a scraper) writes the rows **directly** into their DuckLake table with \`wmill.ducklake()\`, or into a data table with \`wmill.datatable()\`. DuckLake already stores every table as parquet files in the workspace object storage, so **never stage the raw payload first** — no intermediate JSON / CSV / parquet file in S3 and no second "load the raw file" node. One ingestion node, one table.
Pass the rows as a query argument (a list of dicts / array of objects is sent as JSON) and let SQL unnest them; declare the column types so the table gets a real schema:
\`\`\`python
# pipeline
# on schedule
import wmill
def main():
rows = fetch_charges() # list[dict]
lake = wmill.ducklake()
lake.query("""
CREATE OR REPLACE TABLE stripe_charges AS
SELECT unnest(from_json($rows, '[{"id":"VARCHAR","amount":"BIGINT","created":"BIGINT"}]'), recursive := true)
""", rows=rows).fetch()
\`\`\`
\`\`\`ts
// pipeline
// on schedule
import * as wmill from "windmill-client"
export async function main() {
const rows = await fetchCharges()
const lake = wmill.ducklake()
await lake\`CREATE OR REPLACE TABLE stripe_charges AS
SELECT unnest(from_json(\${rows}, '[{"id":"VARCHAR","amount":"BIGINT","created":"BIGINT"}]'), recursive := true)\`.fetch()
}
\`\`\`
- Use \`INSERT INTO <table> SELECT ...\` instead of \`CREATE OR REPLACE\` to append (an incremental pull), with a one-time \`CREATE TABLE IF NOT EXISTS\` first.
- Assign the client to a variable (\`lake = wmill.ducklake()\`) and keep the SQL a **string literal** (Python) or a template without \`sql.raw\` (TS): that is what lets the graph record the node's write to \`ducklake://main/<table>\`. A chained \`wmill.ducklake().query(...)\` or SQL built in a variable records no output, so the downstream edge never forms.
- \`wmill.ducklake("<name>")\` targets a non-default catalog; \`wmill.datatable("<name>")\` writes a Postgres data table the same way (Python \`db.query(sql, *args)\` with \`$1\` placeholders, TS the same template form) — use it only when the data belongs in Postgres (see above).
- The downstream node triggers on the table itself: \`-- on ducklake://main/stripe_charges\`.
## Storage prerequisites
A DuckLake pipeline only runs once the workspace has **object storage** (S3 / Azure Blob / GCS) **and a DuckLake catalog** configured — DuckLake tables and \`s3://\` assets can't be materialized or read without it. Check which DuckLake catalogs the workspace has before you build.
@@ -7926,7 +8000,7 @@ The key must be a **string literal** — the graph parser is static and cannot f
## Materialize (the managed output)
> **A MANAGED \`// materialize\` is DuckDB-only**, and its target must be a DuckLake table (\`ducklake://<name>/<table>\`). Deploy **rejects** a \`ducklake://\` \`// materialize\` on any other language (\`python3\`, \`bun\`, \`postgresql\`). For a non-DuckDB node writing the lake, do **not** use \`// materialize\` — write the output via the SDK (\`wmill.writeS3File(...)\`, ducklake helpers, …) and let it be inferred. Use \`duckdb\` when a node should materialize a DuckLake table.
> **A MANAGED \`// materialize\` is DuckDB-only**, and its target must be a DuckLake table (\`ducklake://<name>/<table>\`). Deploy **rejects** a \`ducklake://\` \`// materialize\` on any other language (\`python3\`, \`bun\`, \`postgresql\`). For a non-DuckDB node writing the lake, do **not** use \`// materialize\` — write the table itself with \`wmill.ducklake()\` (see "Ingesting from Python / TypeScript") and let the output be inferred. Use \`duckdb\` when a node should materialize a DuckLake table.
>
> The one target ANY language may declare (except a dbt script, whose writes come from its manifest) is a **warehouse relation**: \`// materialize manual dbt://<warehouse>/<schema>/<name>\`, where \`<warehouse>\` is a warehouse the workspace configures under Settings → dbt. \`manual\` is the only mode it has — nothing generates warehouse DDL, so the node issues its own write (a postgresql \`CREATE TABLE\` / \`INSERT\`, an SDK load, …) and the annotation records the outcome. Use it on an ingestion node whose output a dbt project reads as a \`source\`: the declared relation and the dbt model land on ONE graph node, and a downstream \`// on dbt://<warehouse>/<schema>/<name>\` fires when the ingestion node completes.
+22
View File
@@ -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
@@ -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">
@@ -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}
@@ -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 }} />
@@ -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'
@@ -69,6 +69,7 @@
retainLinkedToolsScope
} from './flows/linkedAgentToolsStore.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import { AGENT_STEP_ID, agentStepLabel } from './flows/conversations/agentEditorChat'
const operatingWorkspace = useOperatingWorkspace()
@@ -1238,6 +1239,8 @@
id: string
type: FlowModuleValue['type']
suspend?: boolean
/** Shown in place of the id. */
label?: string
}
function allModulesForTimeline(
@@ -1250,7 +1253,8 @@
({
id: x.id,
type: x.value.type,
suspend: x.suspend != undefined
suspend: x.suspend != undefined,
label: x.id === AGENT_STEP_ID ? agentStepLabel(job?.script_path) : undefined
}) as FlowModuleForTimeline,
{
skipToolNodes: true
@@ -179,12 +179,12 @@
/>
</div>
{/if}
{#each flowModules as { id: k, type: typ, suspend: isSuspend } (k)}
{#each flowModules as { id: k, type: typ, suspend: isSuspend, label } (k)}
{@const subItems = items?.[k]?.filter((x) => x.created_at && x.started_at)}
<div class="relative px-3 py-1.5">
<div class="flex items-center justify-between mb-0.5">
<div class="text-xs font-medium"
>{k.startsWith('subflow:') ? k.substring(8) : k}
>{label ?? (k.startsWith('subflow:') ? k.substring(8) : k)}
{#if localModuleStates[k]?.selectedForloop && (typ == 'forloopflow' || typ == 'whileloopflow')}
<span class="text-xs font-mono font-medium inline-flex items-center -my-2">
<button onclick={(e) => e.stopPropagation()}>
+2 -1
View File
@@ -15,6 +15,7 @@
import { isWindmillTooBigObject } from './job_args'
import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile'
import { appendViewToken } from '$lib/viewToken'
import { apiBaseUrl } from '$lib/apiBaseUrl.svelte'
interface Props {
id?: string | undefined
@@ -246,7 +247,7 @@ ${Object.entries(displayArgs)
If using the wmill client in your code, set the following env variables:
<pre
><code
>BASE_URL="{window.location.origin}"
>BASE_URL="{apiBaseUrl()}"
WM_TOKEN="{'<TOKEN>'}"</code
></pre
>
@@ -396,6 +396,15 @@ not this component) — so the lead can run the whole line.
onPick={() => {}}
/>
{:else if section}
{#if section.parent}
<!-- Gives width back with the workspace part, before the section's own name does. -->
<span
class={twMerge(SEGMENT, LEAD, 'shrink-[999] hover:bg-transparent hover:text-secondary')}
>
<span class="truncate">{section.parent}</span>
</span>
{@render slash()}
{/if}
<!-- A section's own widget lays out as a row: its title, and whatever belongs with it. -->
<span
class={twMerge(
@@ -32,6 +32,7 @@ The row's height matches the sidebar's own header row, so the two read as one ba
const item = $derived(content?.item)
const section = $derived(content?.section)
const hasSummary = $derived(!!item && !!(item.summaryContent || item.summary))
// Below this the trail holds a tighter cap on the path it draws. The names in it truncate on
// their own at any width (see NavBreadcrumb), so this is not a breakpoint the look changes at —
@@ -135,8 +136,18 @@ The row's height matches the sidebar's own header row, so the two read as one ba
where the user is.
An embed (`navHidden`) keeps the page's own name but drops the workspace part around it:
the trail would offer to navigate the host's workspace, while the name is what says which
page the controls beside it belong to. -->
<div class="flex shrink-[0.3] min-w-[5rem]">
page the controls beside it belong to.
The 0.3 only holds against a summary or an actions box that shrinks too: alone, a factor
sum below 1 makes flex hand out only that share of the overflow, and the bar overflows
instead. -->
<div
class={twMerge(
'flex shrink-[0.3] min-w-[5rem]',
!hasSummary &&
(actions.length === 0 || (phone && !content?.actionsFlexible && !content?.actionsFill)) &&
'shrink'
)}
>
<!-- Bridged like the actions below: what a page hangs off its own name renders here, out of
the tree that named it, and the rename editor in there asks that tree who the acting user
is before it offers to rename anything. -->
@@ -161,7 +172,7 @@ The row's height matches the sidebar's own header row, so the two read as one ba
</svelte:boundary>
</div>
{#if item && (item.summaryContent || item.summary)}
{#if item && hasSummary}
<!-- No kind icon: the page below is the item, and saying "this is a flow" above a flow
editor tells the reader what they can already see. -->
<div class={twMerge('flex items-center gap-1 min-w-0', phone && 'shrink')}>
@@ -596,6 +596,7 @@
| 'powershell'
| 'bunnative'
| 'claudesandbox'
| 'pisandbox'
| 'wac_python'
| 'wac_typescript'
| 'ci_test_bun'
@@ -1543,10 +1544,12 @@
(lang == script.language &&
template != 'bunnative' &&
template != 'docker' &&
template != 'claudesandbox') ||
template != 'claudesandbox' &&
template != 'pisandbox') ||
(template == 'bunnative' && lang == 'bunnative') ||
(template == 'docker' && lang == 'docker') ||
(template == 'claudesandbox' && lang == 'bun')}
((template == 'claudesandbox' || template == 'pisandbox') &&
lang == 'bun')}
<Popover
disablePopup={!enterpriseLangs.includes(lang) || !!$enterpriseLicense}
>
@@ -1597,6 +1600,21 @@
>
Claude Sandbox
</Button>
<Button
unifiedSize="xs"
variant="default"
startIcon={{
icon: LanguageIcon,
props: { lang: 'pisandbox', width: 16, height: 16 }
} as ButtonType.Icon}
onclick={() => {
template = 'pisandbox'
script.language = 'bun'
initContent('bun', script.kind, template)
}}
>
Pi Sandbox
</Button>
<span title="Workflow-as-Code">
<Button
size="xs2"
+35 -37
View File
@@ -150,6 +150,7 @@
| 'powershell'
| 'bunnative'
| 'claudesandbox'
| 'pisandbox'
| 'wac_python'
| 'wac_typescript'
| 'ci_test_bun'
@@ -2100,43 +2101,15 @@
</div>
{/if}
{#if customUi?.previewPanel?.hideArgs}
<!-- Compact preview layout used by the pipeline editor:
no args column (the script is known to take no
inputs), LogPanel takes the full width, with a
small Test/Cancel button at the top-left of the
preview band. The earlier `-translate-y-1/2`
"float onto the editor" version was clipped in
Firefox (transform + overflow: visible interaction
on the parent splitpane Pane), so the button is
now positioned inside the panel — `top-1` keeps
it visually pinned to the top edge without
relying on cross-browser overflow behaviour. -->
<div class="relative h-full pt-9 flex flex-col">
{#if testJob?.id && testJob.type === 'CompletedJob' && opWs}
<!-- Right-side affordances when we're displaying a *completed*
job (either the user just ran a test, or the on-mount
last-run loader populated the panel). The job-id link
opens the full run page in a new tab; the dispatch
button mounts a popover that shows what downstream
jobs this run triggered (rendered only when the run
actually dispatched anything). -->
<div class="absolute top-1 right-2 z-10 flex items-center gap-2">
<a
class="text-3xs text-blue-600 hover:underline font-mono"
href={`${base}/run/${testJob.id}?workspace=${opWs}`}
target="_blank"
rel="noopener noreferrer"
title="Open this run"
>
{testJob.id.slice(0, 8)}… ↗
</a>
<DispatchEventsButton
workspace={testJob.workspace_id ?? opWs}
jobId={testJob.id}
/>
</div>
{/if}
<div class="absolute top-1 left-2 z-10">
<!-- Compact preview layout used by the pipeline editor: no args
column (most pipeline scripts take no inputs) and a full-width
LogPanel. The controls sit in normal flow rather than floating
onto the editor band, which Firefox clipped (transform +
overflow: visible on the parent splitpane Pane). -->
<div class="h-full flex flex-col">
<!-- Test sits at the top right, the last run's link under it. -->
<div class="shrink-0 flex flex-col items-end gap-1 px-2 pt-2 pb-1">
<div>
{#if testIsLoading}
{@render cancelTestButton('sm', 'shadow-md')}
{:else if (customUi?.previewPanel?.downstreamSubscribers ?? 0) > 0 || customUi?.previewPanel?.onBoundedRun}
@@ -2270,6 +2243,31 @@
{@render runTestButton('sm', 'shadow-md')}
{/if}
</div>
{#if testJob?.id && testJob.type === 'CompletedJob' && opWs}
<!-- Right-side affordances when we're displaying a *completed*
job (either the user just ran a test, or the on-mount
last-run loader populated the panel). The job-id link
opens the full run page in a new tab; the dispatch
button mounts a popover that shows what downstream
jobs this run triggered (rendered only when the run
actually dispatched anything). -->
<div class="flex items-center gap-2">
<a
class="text-3xs text-blue-600 hover:underline font-mono"
href={`${base}/run/${testJob.id}?workspace=${opWs}`}
target="_blank"
rel="noopener noreferrer"
title="Open this run"
>
{testJob.id.slice(0, 8)}… ↗
</a>
<DispatchEventsButton
workspace={testJob.workspace_id ?? opWs}
jobId={testJob.id}
/>
</div>
{/if}
</div>
{#if customUi?.previewPanel?.argsAboveLogs && schema?.properties && Object.keys(schema.properties).length > 0}
<div
class="shrink-0 overflow-auto max-h-[45%] border-b border-gray-200 dark:border-gray-700 px-4 py-3"
@@ -31,6 +31,13 @@
/** What is being renamed. An agent is a resource, so the path field is scoped to one and
* the write goes through the agent branch of the rename manager. */
kind?: 'flow' | 'script' | 'agent'
/** Saves instead of the API, for an item that exists only locally (a draft).
* Returns an error message to keep the popover open. */
saveOverride?: (next: {
path: string
summary: string
labels: string[] | undefined
}) => string | undefined
/** Header variant: no path line (the host shows it) and a lighter summary. */
compact?: boolean
/** Rendered inside the trigger in place of the summary, for a host that hangs a second copy
@@ -51,6 +58,7 @@
editable = false,
onSaved,
kind = 'flow',
saveOverride = undefined,
compact = false,
label,
triggerClass = 'block min-w-0 max-w-full px-1 py-0.5 rounded text-left cursor-pointer hover:bg-surface-hover transition-colors',
@@ -67,7 +75,9 @@
let popoverOpen = $state(false)
// Resolved before the popover opens, not on opening it: the path field only exists for an owner,
// and `openFocus` looks for it in the same flush the popover renders in.
const own = $derived(isOwner(path ?? '', actingUser, $operatingWorkspace))
const own = $derived(
saveOverride !== undefined || isOwner(path ?? '', actingUser, $operatingWorkspace)
)
let onBehalfOfEmail = $state<string | undefined>(undefined)
let summaryInput: ReturnType<typeof TextInput> | undefined = $state()
let labelsDirty = $state(false)
@@ -101,6 +111,17 @@
async function save(close: () => void) {
const initialPath = path ?? ''
const newPath = own ? editPath : initialPath
if (saveOverride) {
const error = saveOverride({ path: newPath, summary: editSummary, labels })
if (error) {
sendUserToast(error, true)
return
}
labelsDirty = false
close()
onSaved?.(newPath)
return
}
try {
await updateItemPathAndSummary({
@@ -372,7 +372,7 @@
</div>
{/if}
<Tabs bind:selected={usersSubTab} class="mb-4">
<Tabs bind:selected={usersSubTab} class="mb-4" wrapperClass="shrink-0">
<Tab value="users" label="Users" />
{#if extJwtTokens.length > 0}
<Tab value="ext_jwt" label="External JWTs" />
@@ -55,6 +55,7 @@
import TagList from './TagList.svelte'
import DedicatedWorkersSelector from './DedicatedWorkersSelector.svelte'
import { computeHashedTag } from './dedicated_worker'
import { apiBaseUrl } from '$lib/apiBaseUrl.svelte'
import ObjectStoreConfigSettings, {
type ObjectStoreConfig
} from './ObjectStoreConfigSettings.svelte'
@@ -1238,7 +1239,7 @@
<p class="text-xs text-tertiary">Requires a superadmin token.</p>
<pre
class="mt-1 p-2 bg-surface-secondary rounded text-xs overflow-x-auto whitespace-pre-wrap break-all"
>curl -X POST '{window.location.origin}/api/configs/update/worker__{name}' \
>curl -X POST '{apiBaseUrl()}/api/configs/update/worker__{name}' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer &lt;token&gt;' \
-d '{JSON.stringify(cleanWorkerGroupConfig(nconfig ?? {}), null, 2)}'</pre
@@ -0,0 +1,150 @@
<!--
@component
The menu that adds a pipeline script started by a data source (a schedule, a
webhook, a queue…): the add node's, and the canvas's right-click one.
-->
<script lang="ts">
import type { Snippet } from 'svelte'
import PipelineInsertMenu, {
type PipelineInsertOptions,
type PipelineInsertPick
} from './PipelineInsertMenu.svelte'
import {
Clock,
Webhook,
Mail,
Zap,
Radio,
MessageSquare,
Database,
Send,
CloudCog,
Upload
} from 'lucide-svelte'
import type { ScriptLang } from '$lib/gen'
import type { NativeTriggerKind } from './types'
import { PIPELINE_LANGUAGES } from './pipelineLanguages'
import type { PipelineOutputKind } from './pipelineTemplates'
interface Props {
onAddPipelineScript: (
language: ScriptLang,
path: string,
source: { kind: NativeTriggerKind; path: string | undefined },
outputKind: PipelineOutputKind,
aiPrompt?: string,
options?: PipelineInsertOptions
) => void
pathPrefix: string
defaultPathSuffix: string
trigger: Snippet<[{ open: boolean }]>
/** Opens the menu each time it changes. */
openSignal?: number
}
let {
onAddPipelineScript,
pathPrefix,
defaultPathSuffix,
trigger,
openSignal
}: Props = $props()
function handlePick(pick: PipelineInsertPick) {
if (!pick.language || !pick.path) return
// Native trigger annotation is marker-only — the binding lives on the
// trigger row's own `script_path`, which is created separately.
onAddPipelineScript(
pick.language as ScriptLang,
pick.path,
{ kind: pick.kindId as NativeTriggerKind, path: undefined },
(pick.outputKind ?? 'none') as PipelineOutputKind,
pick.aiPrompt,
{ schedule: pick.schedule, outputAsset: pick.outputAsset }
)
}
</script>
<PipelineInsertMenu
kinds={[
{
id: 'data_upload',
label: 'On data upload',
description: 'UI-first: run by uploading a file via the S3 picker',
icon: Upload
},
{
id: 'schedule',
label: 'On schedule',
description: 'Triggered by a schedule you create',
icon: Clock
},
{
id: 'webhook',
label: 'On webhook',
description: 'Triggered by an HTTP webhook',
icon: Webhook
},
{
id: 'email',
configuredAfterCreate: true,
label: 'On email',
description: 'Triggered by incoming email',
icon: Mail
},
{
id: 'kafka',
configuredAfterCreate: true,
label: 'On Kafka',
description: 'Triggered by a Kafka message',
icon: Zap
},
{
id: 'mqtt',
configuredAfterCreate: true,
label: 'On MQTT',
description: 'Triggered by an MQTT message',
icon: Radio
},
{
id: 'amqp',
configuredAfterCreate: true,
label: 'On AMQP',
description: 'Triggered by an AMQP (RabbitMQ) message',
icon: Radio
},
{
id: 'nats',
configuredAfterCreate: true,
label: 'On NATS',
description: 'Triggered by a NATS message',
icon: MessageSquare
},
{
id: 'postgres',
configuredAfterCreate: true,
label: 'On Postgres',
description: 'Triggered by a Postgres event',
icon: Database
},
{
id: 'sqs',
configuredAfterCreate: true,
label: 'On SQS',
description: 'Triggered by an SQS message',
icon: Send
},
{
id: 'gcp',
configuredAfterCreate: true,
label: 'On GCP Pub/Sub',
description: 'Triggered by a Pub/Sub message',
icon: CloudCog
}
]}
languages={PIPELINE_LANGUAGES as any}
{pathPrefix}
{defaultPathSuffix}
onPick={handlePick}
{openSignal}
{trigger}
/>
@@ -0,0 +1,73 @@
<!--
@component
The menu adding a pipeline script downstream of an asset: opened from the asset's
"+", or where that "+" was dropped on the blank canvas.
-->
<script lang="ts" module>
import type { ScriptLang } from '$lib/gen'
import type { AssetKind } from '$lib/components/assets/lib'
import type { PipelineInsertOptions } from './PipelineInsertMenu.svelte'
import type { PipelineOutputKind } from './pipelineTemplates'
export type AddScriptForAsset = (
asset: { kind: AssetKind; path: string },
language: ScriptLang,
scriptPath: string,
outputKind: PipelineOutputKind,
aiPrompt?: string,
options?: PipelineInsertOptions
) => void
</script>
<script lang="ts">
import { Code2 } from 'lucide-svelte'
import type { Snippet } from 'svelte'
import PipelineInsertMenu, { type PipelineInsertPick } from './PipelineInsertMenu.svelte'
import { PIPELINE_LANGUAGES } from './pipelineLanguages'
let {
asset,
onAddScript,
pathPrefix = '',
defaultPathSuffix = '',
openSignal,
trigger
}: {
asset: { kind: AssetKind; path: string }
onAddScript: AddScriptForAsset
pathPrefix?: string
defaultPathSuffix?: string
openSignal?: number
trigger: Snippet<[{ open: boolean }]>
} = $props()
function handlePick(pick: PipelineInsertPick) {
if (pick.kindId === 'pipeline_script' && pick.language && pick.path) {
onAddScript(
asset,
pick.language as ScriptLang,
pick.path,
(pick.outputKind ?? 'none') as PipelineOutputKind,
pick.aiPrompt,
{ outputAsset: pick.outputAsset }
)
}
}
</script>
<PipelineInsertMenu
kinds={[
{
id: 'pipeline_script',
label: 'Add downstream pipeline script',
description: 'Triggered when this asset changes',
icon: Code2
}
]}
languages={PIPELINE_LANGUAGES as any}
{pathPrefix}
{defaultPathSuffix}
{openSignal}
onPick={handlePick}
{trigger}
/>
@@ -0,0 +1,23 @@
<!--
@component
The "+" on an asset's bottom edge: a circle at rest, widened into a labelled pill
while its menu is open or while it is being dragged. The label animates its
max-width (width can't transition to auto) and never wraps.
-->
<script lang="ts">
import { Plus } from 'lucide-svelte'
let { expanded }: { expanded: boolean } = $props()
</script>
<span
class="group/add h-6 min-w-6 rounded-full flex items-center justify-center bg-surface border border-gray-400 dark:border-gray-600 text-secondary hover:text-primary hover:border-gray-500 shadow-sm leading-none overflow-hidden transition-[padding] duration-200 ease-out data-[open=true]:pl-1"
data-open={expanded}
>
<Plus size={16} strokeWidth={2.5} class="shrink-0" />
<span
class="max-w-0 opacity-0 whitespace-nowrap text-3xs font-normal transition-[max-width,opacity,margin] duration-200 ease-out group-data-[open=true]/add:max-w-32 group-data-[open=true]/add:opacity-100 group-data-[open=true]/add:ml-1 group-data-[open=true]/add:mr-2"
>
Add downstream step
</span>
</span>
@@ -1,145 +1,36 @@
<script lang="ts">
import PipelineInsertMenu, { type PipelineInsertPick } from './PipelineInsertMenu.svelte'
import {
Plus,
Clock,
Webhook,
Mail,
Zap,
Radio,
MessageSquare,
Database,
Send,
CloudCog,
Upload
} from 'lucide-svelte'
import type { ScriptLang } from '$lib/gen'
import type { NativeTriggerKind } from './types'
import { PIPELINE_LANGUAGES } from './pipelineLanguages'
import type { PipelineOutputKind } from './pipelineTemplates'
// Each left-column kind is just "pipeline script triggered by <trigger
// source>". id === the SCRIPT_TRIGGER_KIND value, so the handler can
// dispatch on it uniformly. Asset-triggered scripts are not in this
// menu; those live under the per-asset + inside the graph.
type KindId = NativeTriggerKind
import AddDataSourceMenu from './AddDataSourceMenu.svelte'
import { Plus } from 'lucide-svelte'
import { NODE } from '$lib/components/graph/util'
interface Props {
data: {
onAddPipelineScript: (
language: ScriptLang,
path: string,
source: { kind: NativeTriggerKind; path: string | undefined },
outputKind: PipelineOutputKind,
aiPrompt?: string
) => void
pathPrefix: string
defaultPathSuffix: string
}
data: Pick<
import('svelte').ComponentProps<typeof AddDataSourceMenu>,
'onAddPipelineScript' | 'pathPrefix' | 'defaultPathSuffix'
>
}
let { data }: Props = $props()
function handlePick(pick: PipelineInsertPick) {
if (!pick.language || !pick.path) return
const kindId = pick.kindId as KindId
const outputKind = (pick.outputKind ?? 'none') as PipelineOutputKind
// Native trigger annotation is marker-only — the binding lives on
// the trigger row's own `script_path`, which the user creates
// separately. Seed with `path: undefined`.
data.onAddPipelineScript(
pick.language as ScriptLang,
pick.path,
{ kind: kindId, path: undefined },
outputKind,
pick.aiPrompt
)
}
</script>
<PipelineInsertMenu
kinds={[
{
id: 'data_upload',
label: 'On data upload',
description: 'UI-first: run by uploading a file via the S3 picker',
icon: Upload
},
{
id: 'schedule',
label: 'On schedule',
description: 'Triggered by a schedule you create',
icon: Clock
},
{
id: 'webhook',
label: 'On webhook',
description: 'Triggered by an HTTP webhook',
icon: Webhook
},
{
id: 'email',
label: 'On email',
description: 'Triggered by incoming email',
icon: Mail
},
{
id: 'kafka',
label: 'On Kafka',
description: 'Triggered by a Kafka message',
icon: Zap
},
{
id: 'mqtt',
label: 'On MQTT',
description: 'Triggered by an MQTT message',
icon: Radio
},
{
id: 'amqp',
label: 'On AMQP',
description: 'Triggered by an AMQP (RabbitMQ) message',
icon: Radio
},
{
id: 'nats',
label: 'On NATS',
description: 'Triggered by a NATS message',
icon: MessageSquare
},
{
id: 'postgres',
label: 'On Postgres',
description: 'Triggered by a Postgres event',
icon: Database
},
{
id: 'sqs',
label: 'On SQS',
description: 'Triggered by an SQS message',
icon: Send
},
{
id: 'gcp',
label: 'On GCP Pub/Sub',
description: 'Triggered by a Pub/Sub message',
icon: CloudCog
}
]}
languages={PIPELINE_LANGUAGES as any}
pathPrefix={data.pathPrefix}
defaultPathSuffix={data.defaultPathSuffix}
onPick={handlePick}
>
{#snippet trigger()}
<!-- Quiet insert affordance, mirroring the flow editor's inline +
buttons (bg-surface + gray border + secondary text) — a filled
accent circle outweighed every real node on the canvas. -->
<button
type="button"
class="w-8 h-8 rounded-full flex items-center justify-center bg-surface border border-gray-400 dark:border-gray-600 text-secondary shadow-sm hover:bg-surface-hover transition-colors cursor-pointer"
title="Add to pipeline"
>
<Plus size={16} />
</button>
{/snippet}
</PipelineInsertMenu>
<!-- The layout gives this node a full node-width slot; centering the pill in
it lines it up with the nodes below whatever its label's width. -->
<div class="flex justify-center" style="width: {NODE.width}px;">
<AddDataSourceMenu
onAddPipelineScript={data.onAddPipelineScript}
pathPrefix={data.pathPrefix}
defaultPathSuffix={data.defaultPathSuffix}
>
{#snippet trigger()}
<!-- Quiet insert affordance, mirroring the flow editor's inline +
buttons (bg-surface + gray border + secondary text) — a filled
accent pill would outweigh every real node on the canvas. -->
<button
type="button"
class="h-8 px-3 rounded-full flex items-center gap-1.5 whitespace-nowrap bg-surface border border-gray-400 dark:border-gray-600 text-xs font-normal text-secondary shadow-sm hover:bg-surface-hover transition-colors cursor-pointer"
>
<Plus size={16} class="shrink-0" />
Add data source
</button>
{/snippet}
</AddDataSourceMenu>
</div>
File diff suppressed because it is too large Load Diff
@@ -20,10 +20,17 @@
SquareFunction,
Trash2,
X,
Pencil
Pencil,
ChevronDown,
Zap
} from 'lucide-svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import { tick } from 'svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import {
upstreamScriptItems,
upstreamTriggerItems,
type UpstreamScript,
type UpstreamTriggerAction
} from './upstreamScriptItems'
import { inferArgs } from '$lib/infer'
import { emptySchema, sendUserToast } from '$lib/utils'
import type { Schema } from '$lib/common'
@@ -41,6 +48,7 @@
import { extractDraftMacros } from './resolveGraph'
import { assetColumnNodes, type ColumnLineageGraph } from './columnLineageGraph'
import SummaryPathDisplay from '$lib/components/SummaryPathDisplay.svelte'
import Badge from '$lib/components/common/badge/Badge.svelte'
import S3FilePreview from '$lib/components/S3FilePreview.svelte'
import DataTablePreview from './DataTablePreview.svelte'
import DucklakeAssetPanel from './DucklakeAssetPanel.svelte'
@@ -117,7 +125,9 @@
// (`onDraftPersist`). `onDraftPersist` stays the authoritative commit on
// navigate-away; this is the continuous feed that keeps the draft's
// server copy current while the user is still typing.
onContentChange?: (scriptPath: string | undefined, content: string) => void
// `base`: the deployed copy being edited, so the parent can autosave unsaved
// edits to it before they are promoted to a draft. Unset for a draft.
onContentChange?: (scriptPath: string | undefined, content: string, base?: Script) => void
// Fires when the user navigates away from a draft (selects another
// node, closes the pane, etc.) — the parent must persist the new
// content + write outputs into its drafts Map. Without this,
@@ -133,7 +143,8 @@
writes: { kind: AssetWithAltAccessType['kind']; path: string }[]
// Body-inferred reads, so the parent's draft keeps its input
// lineage while inactive (no live inference runs for it).
reads: { kind: AssetWithAltAccessType['kind']; path: string }[]
// Undefined when nothing was inferred: keep what the draft has.
reads?: { kind: AssetWithAltAccessType['kind']; path: string }[]
// Full edited script — set when the source is a persisted
// script (needed to seed a brand-new draft entry).
script?: Script
@@ -156,6 +167,12 @@
path: string
unsaved?: boolean
}>
/** The selected asset's producing scripts, for its "Edit script" action,
* which calls `onEditScript` with the one picked. */
producerScripts?: UpstreamScript[]
onEditScript?: (path: string) => void
/** The selected asset's upstream triggers that can be opened from here. */
producerTriggers?: UpstreamTriggerAction[]
// Pipeline-wide column-lineage graph (built by the parent page from the
// resolved graph). Drives the transitive column-lineage trace shown for a
// selected materialized asset.
@@ -215,15 +232,18 @@
// the edges. Counter rather than boolean so back-to-back runs
// re-fire the effect even if no other prop changed.
requestRunSignal?: number
// Folder-scoped non-editable prefix shown next to the suffix
// editor when the user renames a draft (e.g. `f/<folder>/`). The
// new path = pathPrefix + suffix.
pathPrefix?: string
// Called when the user renames a draft — the parent reseats the
// path key in its drafts map and updates activeDraftPath. Returns
// true on success; false on collision/validation failure so the
// popover can keep itself open and surface the error inline.
onDraftPathChange?: (oldPath: string, newPath: string) => boolean | string
onDraftMetaChange?: (
path: string,
meta: { summary: string; labels: string[] | undefined }
) => void
// The pipeline's folder prefix (`f/<folder>/`). A draft renamed outside it
// would be deployed out of the pipeline, so the rename is refused.
pathPrefix?: string
// Bumped by the parent (e.g. from the runnable-node action menu) to
// auto-open the archive/delete confirmation modal for the currently
// loaded persisted script. No-ops while the pane is showing a draft
@@ -280,6 +300,19 @@
// can persist a data-upload entry's staged input (drives node readiness).
// `isValid` is the full-schema validity, not just "file present".
onRunFormArgsChange?: (path: string, args: Record<string, any>, isValid: boolean) => void
// Show only the open script's inputs and runs (a data-upload click), not
// its editor. `onRunInputs` runs it with them, drafts included, and
// `onShowScript` leaves this view for the script.
inputsOnly?: boolean
/** `live` is the pane's buffer when it differs from what is stored: always
* for a draft, and for a deployed script edited before switching to the
* inputs view. A run with it previews that body. */
onRunInputs?: (
path: string,
args: Record<string, any>,
live?: { content: string; language: ScriptLang }
) => Promise<string | undefined>
onShowScript?: () => void
}
let {
selection,
@@ -301,6 +334,9 @@
onScriptRenamed,
onScriptRemoved,
selectionProducers = [],
producerScripts = [],
onEditScript,
producerTriggers = [],
selectionColumnGraph,
selectionColumnLoading = false,
selectionColumnTruncated = false,
@@ -315,8 +351,9 @@
activeRunnable,
onTestStateChange,
requestRunSignal,
pathPrefix = '',
onDraftPathChange,
onDraftMetaChange,
pathPrefix = '',
requestRemoveSignal,
downstreamSubscribers = 0,
onStartBoundedRun,
@@ -328,10 +365,15 @@
onRunByPath,
onRunCascadeByPath,
runFormInitialArgs,
onRunFormArgsChange
onRunFormArgsChange,
inputsOnly = false,
onRunInputs,
onShowScript
}: Props = $props()
let readOnly = $derived(mode !== 'edit')
// Someone who cannot switch the pipeline to edit mode only gets to read it.
let editScriptLabel = $derived(readOnly && !onRequestEdit ? 'Open script' : 'Edit script')
// Root element of the pane — used to scope the S3-input lookup for the
// data_upload focus effect.
@@ -408,7 +450,7 @@
// PipelineScriptView renders instead of the test panel.
let viewReadyForTarget = $derived.by(
() =>
readOnly &&
(readOnly || inputsOnly) &&
runTargetPath !== undefined &&
script?.path === runTargetPath &&
(isDraft || (!scriptRes.loading && scriptRes.current?.path === runTargetPath))
@@ -484,6 +526,12 @@
// alongside `liveBodyAssets` and forwarded so the live graph can show
// inferred column lineage on the edited script before it deploys.
let liveColumnLineage = $state<ColumnLineage[] | undefined>(undefined)
// The script whose editor last bound `liveBodyAssets` (undefined there means it
// inferred nothing). They stay bound after that editor unmounts, so a script
// that never had one (the inputs-only view) must not report or persist them as
// its lineage. Set by an effect declared after the save-on-switch one, whose
// cleanup still needs the previous owner when the selection moves.
let liveAssetsOwner: string | undefined = undefined
// Bumped when the runs panel reports a watched job has reached a
// terminal state. Drives S3FilePreview's refreshKey so the preview
@@ -549,7 +597,9 @@
let args = $state<Record<string, any>>({})
let argsSeedPath: string | undefined = undefined
$effect.pre(() => {
const p = script?.path
// Re-seeded on leaving the inputs-only view too, so the file picked there
// shows in the editor's test form.
const p = script?.path === undefined ? undefined : `${script.path}|${inputsOnly}`
if (p === argsSeedPath) return
argsSeedPath = p
args = runFormInitialArgs ? structuredClone($state.snapshot(runFormInitialArgs)) : {}
@@ -559,7 +609,8 @@
// required field (not just the S3 file) is satisfied.
let runFormIsValid = $state(true)
$effect(() => {
if (readOnly || !script) return
// Inputs-only mounts no ScriptEditor: PipelineScriptView reports its own args.
if (readOnly || inputsOnly || !script) return
onRunFormArgsChange?.(script.path, $state.snapshot(args), runFormIsValid)
})
@@ -596,18 +647,25 @@
b: Array<{ kind: string; path: string }>
) => a.length === b.length && a.every((x, i) => x.kind === b[i]?.kind && x.path === b[i]?.path)
return () => {
const writes = (liveBodyAssets ?? [])
// No editor inferred this script (the inputs-only view): keep its stored
// lineage rather than persisting it as empty.
const inferred = liveAssetsOwner === captured.path
const writes = !inferred
? (writesAtRegister ?? [])
: (liveBodyAssets ?? [])
.filter((a) => {
const t = a.access_type ?? a.alt_access_type
return t === 'w' || t === 'rw'
})
.map((a) => ({ kind: a.kind, path: a.path }))
const reads = (liveBodyAssets ?? [])
.filter((a) => {
const t = a.access_type ?? a.alt_access_type
return t === 'r' || t === 'rw'
})
.map((a) => ({ kind: a.kind, path: a.path }))
const reads = !inferred
? readsAtRegister
: (liveBodyAssets ?? [])
.filter((a) => {
const t = a.access_type ?? a.alt_access_type
return t === 'r' || t === 'rw'
})
.map((a) => ({ kind: a.kind, path: a.path }))
// Draft runs: emit only when content or lineage changed in THIS clone.
// An unconditional emit ping-pongs forever when the entry is rewritten
// externally while the pane stays mounted (rename rekey, AI edit):
@@ -617,6 +675,7 @@
(captured.content ?? '') === contentAtRegister &&
refsEq(writesAtRegister ?? [], writes) &&
readsAtRegister != undefined &&
reads != undefined &&
refsEq(readsAtRegister, reads)
)
return
@@ -651,12 +710,42 @@
}
})
$effect(() => {
if (script && !readOnly && !inputsOnly) liveAssetsOwner = script.path
})
let saving = $state(false)
// What this pane last deployed, so the persist-back cleanup can tell "the
// buffer equals the new deployed head" from real unsaved edits (plain
// variable: only read inside the untracked cleanup).
let deployedFromPane: { path: string; content: string } | undefined = undefined
let isDraft = $derived(draftScript != undefined)
function liveBody(): { content: string; language: ScriptLang } | undefined {
if (!script) return undefined
const live = { content: script.content ?? '', language: script.language as ScriptLang }
if (isDraft) return live
const orig = scriptRes.current
return orig?.path === script.path && (orig.content ?? '') !== live.content ? live : undefined
}
// A draft's stored schema is empty until an editor infers it, and the inputs
// view mounts none: infer from the body here so its fields (the file picker)
// show up.
const inputsSchema = resource(
[() => (inputsOnly && isDraft ? script?.content : undefined), () => script?.language],
async ([content, language]) => {
if (content === undefined || !language) return undefined
const schema = emptySchema()
try {
await inferArgs(language, content, schema as Schema)
} catch {
return undefined
}
return schema
}
)
let inputsScript = $derived(
script && inputsSchema.current ? { ...script, schema: inputsSchema.current } : undefined
)
// Single trash-bin button opens one modal that exposes both Archive
// (always available) and Delete permanently (admin-only). Archive is
@@ -763,12 +852,17 @@
onAnnotationsChange?.(script?.path, liveAnnotations)
})
$effect(() => {
if (readOnly) return
if (readOnly || (inputsOnly && liveAssetsOwner !== script?.path)) return
onAssetsChange?.(script?.path, liveBodyAssets ?? [], liveColumnLineage)
})
$effect(() => {
if (readOnly) return
onContentChange?.(script?.path, script?.content ?? '')
const base = draftScript ? undefined : scriptRes.current
onContentChange?.(
script?.path,
script?.content ?? '',
base && base.path === script?.path ? base : undefined
)
})
async function save() {
@@ -910,49 +1004,6 @@
JSON.stringify(script.schema ?? null) === JSON.stringify(orig.schema ?? null)
)
})
// Suffix editor for the draft-path popover. Seeded from the current
// path each time the popover opens so the user starts with what they
// see, not stale state from an earlier rename.
let draftPathSuffix = $state('')
let draftPathError = $state<string | undefined>(undefined)
let draftPathInput: HTMLInputElement | undefined = $state(undefined)
function suffixOf(fullPath: string): string {
return fullPath.startsWith(pathPrefix) ? fullPath.slice(pathPrefix.length) : fullPath
}
async function openDraftPathEditor() {
draftPathSuffix = script ? suffixOf(script.path) : ''
draftPathError = undefined
await tick()
draftPathInput?.focus()
draftPathInput?.select()
}
function confirmDraftPath(close: () => void) {
if (!script) return
const suffix = draftPathSuffix.trim()
if (!suffix) {
draftPathError = 'Path cannot be empty'
return
}
const newPath = pathPrefix + suffix
if (newPath === script.path) {
close()
return
}
const result = onDraftPathChange?.(script.path, newPath)
if (result === true || result === undefined) {
script.path = newPath
draftPathError = undefined
close()
} else if (typeof result === 'string') {
draftPathError = result
} else {
draftPathError = 'Path already in use'
}
}
</script>
<div class="flex flex-col h-full bg-surface" bind:this={paneEl}>
@@ -964,80 +1015,34 @@
{@const draftScriptPath = script.path}
<Code2 size={16} class="shrink-0 text-tertiary" />
{#if onDraftPathChange && !readOnly}
<!-- Inline rename popover for drafts. The persisted-script
branch uses SummaryPathDisplay which round-trips through
updateItemPathAndSummary; drafts have no server row yet,
so we just rekey the parent's drafts map locally. -->
<Popover
placement="bottom-start"
contentClasses="p-3"
usePointerDownOutside
on:openChange={(e) => {
if (e.detail) openDraftPathEditor()
<!-- Same summary/path popover as a deployed script, saved into the
drafts map instead of through the API: a draft has no row yet. -->
<SummaryPathDisplay
summary={script.summary ?? ''}
path={draftScriptPath}
labels={script.labels ?? []}
kind="script"
onSaved={() => {}}
saveOverride={({ path, summary, labels }) => {
if (pathPrefix && (!path.startsWith(pathPrefix) || path === pathPrefix)) {
return `A pipeline script stays in ${pathPrefix}: pick a name inside that folder`
}
// Before the rename, which carries the draft entry over as it is.
onDraftMetaChange?.(draftScriptPath, { summary, labels })
if (script) {
script.summary = summary
script.labels = labels
}
if (path === draftScriptPath) return undefined
const result = onDraftPathChange(draftScriptPath, path)
if (result !== true && result !== undefined) {
return typeof result === 'string' ? result : 'Path already in use'
}
if (script) script.path = path
return undefined
}}
>
{#snippet trigger()}
<button
type="button"
class="flex flex-col min-w-0 text-left px-2 py-1 rounded-md hover:bg-surface-hover transition-colors group"
title="Edit draft path"
>
<span
class="text-3xs uppercase tracking-wide text-tertiary flex items-center gap-1"
>
Draft pipeline script
<Pencil size={9} class="opacity-0 group-hover:opacity-60 transition-opacity" />
</span>
<span class="text-xs font-mono truncate" title={draftScriptPath}
>{draftScriptPath}</span
>
</button>
{/snippet}
{#snippet content({ close })}
<div class="flex flex-col gap-2 w-[420px]">
<span class="text-2xs font-normal text-secondary">Path</span>
<div
class="flex items-stretch border rounded-md bg-surface overflow-hidden focus-within:ring-2 focus-within:ring-blue-400"
>
{#if pathPrefix}
<span
class="flex items-center px-2 bg-surface-secondary text-tertiary text-sm font-mono border-r select-none"
>
{pathPrefix}
</span>
{/if}
<input
bind:this={draftPathInput}
bind:value={draftPathSuffix}
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
confirmDraftPath(close)
} else if (e.key === 'Escape') {
e.preventDefault()
close()
}
}}
class="flex-1 min-w-0 px-2 py-1.5 text-sm font-mono bg-transparent focus:outline-none"
placeholder="my_script"
/>
</div>
{#if draftPathError}
<span class="text-2xs text-red-500">{draftPathError}</span>
{/if}
<div class="flex justify-end">
<Button
variant="accent"
unifiedSize="sm"
disabled={!draftPathSuffix.trim()}
onClick={() => confirmDraftPath(close)}
>
Rename
</Button>
</div>
</div>
{/snippet}
</Popover>
/>
<Badge color="gray">Draft</Badge>
{:else}
<div class="flex flex-col min-w-0">
<span class="text-3xs uppercase tracking-wide text-tertiary">
@@ -1097,6 +1102,66 @@
{/if}
</div>
<div class="flex items-center gap-1 shrink-0">
{#if selection?.kind === 'asset' && onEditScript && producerScripts.length === 1}
<Button
variant="default"
unifiedSize="sm"
startIcon={{ icon: Pencil }}
onclick={() => onEditScript(producerScripts[0].path)}
title={`Open ${producerScripts[0].path}, which builds this asset`}
>
{editScriptLabel}
</Button>
{:else if selection?.kind === 'asset' && onEditScript && producerScripts.length > 1}
<DropdownV2
items={upstreamScriptItems(producerScripts, onEditScript)}
placement="bottom-end"
enableFlyTransition
>
{#snippet buttonReplacement()}
<Button
nonCaptureEvent
variant="default"
unifiedSize="sm"
startIcon={{ icon: Pencil }}
endIcon={{ icon: ChevronDown }}
title={`${producerScripts.length} scripts build this asset`}
>
{editScriptLabel}
</Button>
{/snippet}
</DropdownV2>
{/if}
{#if selection?.kind === 'asset' && producerTriggers.length === 1}
<Button
variant="default"
unifiedSize="sm"
startIcon={{ icon: Zap }}
onclick={() => producerTriggers[0].onOpen()}
title={producerTriggers[0].detail}
>
{producerTriggers[0].label}
</Button>
{:else if selection?.kind === 'asset' && producerTriggers.length > 1}
<DropdownV2
items={upstreamTriggerItems(producerTriggers)}
placement="bottom-end"
enableFlyTransition
>
{#snippet buttonReplacement()}
<Button
nonCaptureEvent
variant="default"
unifiedSize="sm"
startIcon={{ icon: Zap }}
endIcon={{ icon: ChevronDown }}
title={`${producerTriggers.length} triggers start the scripts that build this asset`}
>
Triggers
</Button>
{/snippet}
</DropdownV2>
{/if}
{#if readOnly && onRequestEdit}
<Button
variant="accent"
@@ -1118,21 +1183,10 @@
title="Discard draft"
/>
{/if}
<!-- Action order, left → right: trash, external link, save, close.
Save sits closest to Close so the primary commit action is
anchored at the right edge of the bar; trash lives at the
far left so destructive ops are visually separated from
navigation/commit. Mirrors the draft Discard placement. -->
{#if !readOnly && !isDraft && isScriptView && script?.hash}
<Button
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: Trash2 }}
onclick={() => (removeOpen = true)}
iconOnly
title="Archive or delete"
/>
{/if}
<!-- Action order, left → right: external link, save, close. Save sits
closest to Close so the primary commit action is anchored at the
right edge of the bar. Removing a script goes through its node's
menu. -->
{#if !readOnly && !isDraft && selection?.kind === 'runnable'}
<Button
variant="subtle"
@@ -1338,14 +1392,31 @@
{/if}
</div>
{:else if !isDraft && scriptRes.loading && !script}
<div class="absolute inset-0 flex items-center justify-center gap-2 text-tertiary">
<Loader2 size={16} class="animate-spin" />
<span class="text-xs">Loading script…</span>
</div>
<!-- Blank while the script loads: the fetch is short, and a spinner only flashes. -->
{:else if !isDraft && scriptRes.error}
<div class="p-3 text-xs text-red-500">
Failed to load: {scriptRes.error.message}
</div>
{:else if script && inputsOnly}
{#key script.path}
<PipelineScriptView
{isDraft}
canRun
runsDrafts
inputsOnly
{onShowScript}
onRun={onRunInputs && ((path, args) => onRunInputs(path, args, liveBody()))}
script={inputsScript ?? script}
{runsRefreshKey}
{runsPendingJobId}
initialArgs={runFormInitialArgs}
onArgsChange={onRunFormArgsChange}
onRunCompleted={() => {
previewRefreshKey += 1
onRunCompleted?.()
}}
/>
{/key}
{:else if script && readOnly}
<!-- Read-only modes: no Monaco/ScriptEditor (operators are
backend-blocked from previews anyway) — highlighted source,
@@ -1,6 +1,7 @@
<script lang="ts">
import { BaseEdge, getBezierPath, type EdgeProps } from '@xyflow/svelte'
import { NODE } from '$lib/components/graph/util'
import { PIPELINE_NODE_HEIGHT } from './assetGraphLayout'
import { FlaskConical, Columns3, SquareFunction, BellOff } from 'lucide-svelte'
import type { ColumnLineage, DataTest } from './parsePipelineAnnotations'
@@ -103,33 +104,42 @@
// straight under the nodes in between — the child-and-grandchild-of-the-
// same-node case, where the tidy-tree layout puts all three on one
// column. Detour those through the gutter beside the column.
const SKIP_DY = NODE.gap.vertical * 2 + NODE.height
const SKIP_DY = NODE.gap.vertical * 2 + PIPELINE_NODE_HEIGHT
const NEAR_VERTICAL_DX = NODE.width / 4
// Lane offset: half a node plus a margin that stays inside the
// inter-column gutter (gap.horizontal / 2).
const LANE = NODE.width / 2 + NODE.gap.horizontal / 2
const CORNER = 14
// Rounded-orthogonal detour: drop out of the source, swing into the
// gutter lane at `midX`, run it down past the intermediate rows, swing
// back into the target.
// Detour through the gutter lane at `midX`, drawn with the same curves as
// every other edge: curve out of the source into the lane within the gap
// below it, run down the lane past the rows in between, curve into the
// target within the gap above it.
function gutterPath(midX: number): string {
const yTop = sourceY + 24
const yBot = targetY - 24
const outDir = midX > sourceX ? 1 : -1
const inDir = targetX > midX ? 1 : -1
return [
`M ${sourceX} ${sourceY}`,
`L ${sourceX} ${yTop - CORNER}`,
`Q ${sourceX} ${yTop} ${sourceX + outDir * CORNER} ${yTop}`,
`L ${midX - outDir * CORNER} ${yTop}`,
`Q ${midX} ${yTop} ${midX} ${yTop + CORNER}`,
`L ${midX} ${yBot - CORNER}`,
`Q ${midX} ${yBot} ${midX + inDir * CORNER} ${yBot}`,
`L ${targetX - inDir * CORNER} ${yBot}`,
`Q ${targetX} ${yBot} ${targetX} ${yBot + CORNER}`,
`L ${targetX} ${targetY}`
].join(' ')
const bend = NODE.gap.vertical
const yTop = sourceY + bend
// Clamp only downward edges: an upward (feedback) edge must still run up the
// lane, or its last curve cuts across the node the detour was clearing.
const yBot = targetY > sourceY ? Math.max(yTop, targetY - bend) : targetY - bend
const [out] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX: midX,
targetY: yTop,
targetPosition,
curvature: 0.25
})
const [into] = getBezierPath({
sourceX: midX,
sourceY: yBot,
sourcePosition,
targetX,
targetY,
targetPosition,
curvature: 0.25
})
// Continue from the lane rather than lifting the pen.
return `${out} L${midX},${yBot} ${into.replace(/^M/, 'L')}`
}
// Long edges bend out of the source within the layer gap and descend in
@@ -178,7 +188,7 @@
path={edgePath}
{markerEnd}
{style}
interactionWidth={0}
interactionWidth={12}
label={undefined}
labelStyle={undefined}
/>
@@ -2,9 +2,14 @@
import { Handle, Position } from '@xyflow/svelte'
import { twMerge } from 'tailwind-merge'
import AssetGenericIcon from '$lib/components/icons/AssetGenericIcon.svelte'
import { formatShortAssetPath, type AssetKind } from '$lib/components/assets/lib'
import { NODE } from '$lib/components/graph/util'
import PipelineInsertMenu, { type PipelineInsertPick } from './PipelineInsertMenu.svelte'
import { formatAssetKind, formatShortAssetPath, type AssetKind } from '$lib/components/assets/lib'
import PipelineNodeCard from './PipelineNodeCard.svelte'
import NodeActionsMenu from './NodeActionsMenu.svelte'
import NodeFixButton, { type NodeFix } from './NodeFixButton.svelte'
import NodeOnDot from './NodeOnDot.svelte'
import { PIPELINE_NODE_HEADER } from './assetGraphLayout'
import AddDownstreamMenu from './AddDownstreamMenu.svelte'
import AddDownstreamPill from './AddDownstreamPill.svelte'
import {
ArrowUpRight,
Code2,
@@ -12,19 +17,28 @@
History,
Play,
Loader2,
Plus,
ShieldCheck,
ShieldAlert,
CheckCircle2,
ChevronDown,
Pencil,
CircleSlash,
Trash2,
Upload,
XCircle
} from 'lucide-svelte'
import { Button } from '$lib/components/common'
import type { ScriptLang } from '$lib/gen'
import { enterpriseLicense } from '$lib/stores'
import { sendUserToast } from '$lib/utils'
import { PIPELINE_LANGUAGES } from './pipelineLanguages'
import { sendUserToast, type Item } from '$lib/utils'
import type { PipelineOutputKind } from './pipelineTemplates'
import type { DbtAssetProvenance } from './types'
import DbtIcon from '$lib/components/icons/DbtIcon.svelte'
import LanguageIcon from '$lib/components/common/languageIcons/LanguageIcon.svelte'
import RunStateChip from './RunStateChip.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { upstreamScriptItems } from './upstreamScriptItems'
import type { RunnableRunState } from './activeRunnables.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
@@ -33,6 +47,49 @@
// `content` / `language` so the page-level run handler can dispatch to
// `runScriptPreview` instead of `runScriptByPath` (which 404s for
// non-deployed scripts).
/** What starts a producing script, as its chip shows it. */
export type TriggerChipData = {
label: string
/** The native trigger kind; a data upload renders as a call to action. */
kind?: string
/** No chip: the node's incoming edges already show what starts it. */
hidden?: boolean
missing?: boolean
draft?: boolean
/** Opens the trigger's editor; unset for what has none (manual, asset change). */
onOpen?: () => void
selected?: boolean
}
export type AssetUpstreamChips =
| { none: true }
| {
multiple: true
/** The producing scripts; one when only its triggers are ambiguous. */
scripts: Array<{
runnableId: string
path: string
summary?: string
language?: ScriptLang
unsaved?: boolean
onOpen: () => void
}>
/** The scripts' own triggers, each named with its script. */
triggers: Array<TriggerChipData & { script: string }>
selected?: boolean
}
| {
runnableId: string
path: string
summary?: string
language?: ScriptLang
unsaved?: boolean
runState?: RunnableRunState
trigger: TriggerChipData
onOpen: () => void
selected?: boolean
}
export type AssetProducer = {
kind: 'script' | 'flow'
path: string
@@ -51,6 +108,8 @@
// "current view of <dim>" marker so it reads as a derived node, not an
// unrelated table.
derived_from?: string
// Why the node is misconfigured, when it is: renders it red.
error?: string
// dbt provenance when this warehouse table is a dbt node: which model
// it is, how dbt materializes it, its tags and its generic tests.
dbt?: DbtAssetProvenance
@@ -65,10 +124,14 @@
language: ScriptLang,
scriptPath: string,
outputKind: PipelineOutputKind,
aiPrompt?: string
aiPrompt?: string,
options?: import('./PipelineInsertMenu.svelte').PipelineInsertOptions
) => void
pathPrefix?: string
defaultPathSuffix?: string
/** Starts dragging the "+" onto a script (subscribing it to this asset)
* or onto the blank canvas (opening the add menu there). */
onStartFeedDrag?: (e: PointerEvent) => void
// Producer scripts/flows that write to this asset, supplied by
// the canvas from the graph's write/rw edges. Drives the on-hover
// "Run" button. Includes unsaved/draft producers so the button
@@ -99,6 +162,41 @@
// instead of only settling once the job ends.
runStatus?: 'running' | 'materialized' | 'failed'
runRowCount?: number | null
/** Set in the assets-only view, where the producing script and its
* trigger are folded into the asset: `none` for an asset nothing in the
* pipeline writes, `multiple` when the producer or its trigger is
* ambiguous. */
upstream?: AssetUpstreamChips
/** What is wrong with the node, and how to fix it: replaces the "+". */
fix?: NodeFix
/** Assets-only: errors of the scripts and triggers folded into this node. */
foldedFixes?: NodeFix[]
/** Assets-only: starts dragging the top dot onto an asset, subscribing the
* scripts that build this one to it. */
onStartOnDrag?: (e: PointerEvent) => void
/** The assets-only node for the scripts that build no asset. */
noAsset?: boolean
/** The No-asset node's script, by its file name. */
noAssetName?: string
/** Assets-only: the node is only its script's banner. */
bannerOnly?: boolean
/** Assets-only: a script folded into the node has an error. */
scriptError?: boolean
/** No script in the workspace writes it; a flow's write fires no asset trigger. */
neverWritten?: boolean
/** Card width in the assets-only view, sized to its text. */
width?: number
/** Assets-only view: the producing script and trigger no other asset
* shares, which the menu offers to delete one by one or together. */
upstreamDelete?: import('./assetsOnlyView').AssetUpstreamDelete
/** When no combined delete is possible: the folded scripts, each deletable
* on its own. */
scriptDeletes?: Array<{ path: string; unsaved: boolean }>
triggerDeletes?: Array<NonNullable<import('./assetsOnlyView').AssetUpstreamDelete['trigger']>>
onDeleteUpstream?: (target: import('./assetsOnlyView').AssetUpstreamDelete) => void
deleteVerb?: 'Delete' | 'Archive'
/** Why nothing can be deleted, when the producer is shared. */
deleteBlocked?: string
}
// SvelteFlow injects this on the node component when the user clicks
// the node. Combined with our own `hovered` state to drive the
@@ -140,19 +238,94 @@
}
}
function handlePick(pick: PipelineInsertPick) {
if (pick.kindId === 'pipeline_script' && pick.language && pick.path) {
data.onAddScript?.(
{ kind: data.asset_kind, path: data.path },
pick.language as ScriptLang,
pick.path,
(pick.outputKind ?? 'none') as PipelineOutputKind,
pick.aiPrompt
)
}
}
let showAdd = $derived(data.onAddScript != undefined)
// A red asset needs fixing where its error comes from, not a downstream step;
// and a step downstream of what nothing writes would never run on its writes.
let showAdd = $derived(data.onAddScript != undefined && !data.error && !data.neverWritten)
let menuItems: Item[] = $derived(
data.onDeleteUpstream && data.upstreamDelete
? deleteItems(data.upstreamDelete, data.onDeleteUpstream)
: data.onDeleteUpstream && (data.scriptDeletes?.length || data.triggerDeletes?.length)
? [
...(data.scriptDeletes ?? []).map((script) => ({ script })),
...(data.triggerDeletes ?? []).map((trigger) => ({ trigger }))
].flatMap((t) => deleteItems(t, data.onDeleteUpstream!))
: data.deleteBlocked
? [
{
displayName: `Can't delete: ${data.deleteBlocked}`,
icon: Trash2,
disabled: true
}
]
: []
)
const lastSegment = (p: string) => p.split('/').pop() ?? p
// A script is archived rather than deleted when the user may not delete it;
// a trigger is always deleted.
function deleteItems(
t: import('./assetsOnlyView').AssetUpstreamDelete,
run: (target: import('./assetsOnlyView').AssetUpstreamDelete) => void
): Item[] {
const items: Item[] = []
if (t.script) {
const script = t.script
items.push({
displayName: `${data.deleteVerb ?? 'Delete'} script ${lastSegment(script.path)}`,
icon: Trash2,
type: 'delete' as const,
action: () => run({ script })
})
}
if (t.trigger) {
const trigger = t.trigger
items.push({
displayName: `Delete ${trigger.kind} trigger ${lastSegment(trigger.path)}`,
icon: Trash2,
type: 'delete' as const,
action: () => run({ trigger })
})
}
if (t.script && t.trigger) {
items.push({
displayName: `${data.deleteVerb === 'Archive' ? 'Remove' : 'Delete'} all 2 items`,
icon: Trash2,
type: 'delete' as const,
action: () => run(t)
})
}
return items
}
let upstream = $derived(data.upstream)
let HEADER_SELECTED = $derived(
data.scriptError
? 'bg-red-200 dark:bg-red-600 hover:bg-red-200 dark:hover:bg-red-600'
: 'bg-surface-accent-selected hover:bg-surface-accent-selected text-accent'
)
const CHIP_SELECTED =
'bg-surface-accent-selected border-border-selected hover:bg-surface-accent-selected text-accent'
let producer = $derived(upstream && 'path' in upstream ? upstream : undefined)
let coProducers = $derived(upstream && 'scripts' in upstream ? upstream.scripts : [])
let hasHeader = $derived(!!producer || coProducers.length > 0)
// The corner controls sit on the card's corner, below the header tab:
// -top-2 plus PIPELINE_NODE_HEADER (22px).
const CARD_CORNER_TOP = 'top-[14px]'
let coSelected = $derived(!!(upstream && 'scripts' in upstream && upstream.selected))
let coProducerItems = $derived(
upstreamScriptItems(coProducers, (path) => coProducers.find((s) => s.path === path)?.onOpen())
)
let scriptMenuOpen = $state(false)
let coTriggers = $derived(upstream && 'triggers' in upstream ? upstream.triggers : [])
let coTriggerItems: Item[] = $derived(
coTriggers.map((t) => ({
displayName: `${t.label}${t.draft ? ' · draft' : ''}`,
description: coProducers.length > 1 ? t.script : undefined,
disabled: !t.onOpen,
action: () => t.onOpen?.()
}))
)
let triggerMenuOpen = $state(false)
// dbt badge. `materialized` is dbt's own word rather than the Windmill
// strategy because `view` and `ephemeral` have no strategy, and showing the
@@ -220,6 +393,185 @@
)
</script>
{#snippet scriptHeader()}
<!-- The script building this asset, folded into it: a strip across the top
naming it, which opens it. Several scripts open a menu to pick one. -->
{@const one = producer ?? (coProducers.length === 1 ? coProducers[0] : undefined)}
{@const headerClass = twMerge(
'flex items-center gap-1.5 w-full min-w-0 px-2 text-3xs font-normal leading-none text-secondary hover:bg-surface-hover hover:text-primary',
data.scriptError &&
'bg-red-100 dark:bg-red-700 text-red-700 dark:text-red-200 hover:bg-red-200 dark:hover:bg-red-600 hover:text-red-800'
)}
{#if one}
{@const isSelected = producer ? !!producer.selected : coSelected}
<button
type="button"
class={twMerge(headerClass, isSelected && HEADER_SELECTED)}
title={`Open ${one.summary ? `${one.summary} (${one.path})` : one.path}${one.unsaved ? ' · draft' : ''}`}
onpointerdown={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
onclick={(e) => {
e.stopPropagation()
one.onOpen()
}}
>
{#if one.language}
<LanguageIcon lang={one.language} width={11} height={11} />
{:else}
<Code2 size={11} class="shrink-0" />
{/if}
<span class="truncate">{one.summary || one.path}</span>
{#if one.unsaved}<span class="shrink-0 text-tertiary">draft</span>{/if}
{#if producer?.runState}
<RunStateChip runState={producer.runState} class="ml-auto h-4 px-1 rounded" />
{/if}
<Pencil size={10} class={twMerge('shrink-0 opacity-70', !producer?.runState && 'ml-auto')} />
</button>
{:else if coProducers.length > 1}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="w-full min-w-0 flex"
onpointerdown={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<DropdownV2
items={coProducerItems}
placement="bottom-start"
bind:open={scriptMenuOpen}
fixedHeight={false}
usePointerDownOutside
enableFlyTransition
class="w-full"
>
{#snippet buttonReplacement()}
<span
class={twMerge(headerClass, 'h-full', (coSelected || scriptMenuOpen) && HEADER_SELECTED)}
title={`Built by ${coProducers.length} scripts: pick one to open`}
>
{#each coProducers.slice(0, 2) as sc (sc.path)}
{#if sc.language}
<LanguageIcon lang={sc.language} width={11} height={11} />
{:else}
<Code2 size={11} class="shrink-0" />
{/if}
{/each}
<span class="truncate">{coProducers.length} scripts</span>
<ChevronDown size={11} class="shrink-0 ml-auto" />
</span>
{/snippet}
</DropdownV2>
</div>
{/if}
{/snippet}
{#snippet upstreamRow()}
{#if data.foldedFixes?.length}
<!-- A folded script or trigger is misconfigured: its Fix, one at a time. -->
<NodeFixButton
chip
fix={data.foldedFixes[0]}
label={`${data.foldedFixes.length} error${data.foldedFixes.length === 1 ? '' : 's'} · Fix`}
/>
{:else if upstream && coTriggers.length > 1 && !coTriggers.some((t) => t.onOpen)}
<!-- Several triggers, none editable here (View mode): a plain label. -->
<span
class="truncate rounded-md border px-1.5 py-1 text-3xs leading-none font-normal border-gray-300 dark:border-gray-600 text-secondary"
>Multiple triggers</span
>
{:else if upstream && coTriggers.length > 1}
<!-- Several triggers start what builds this asset: one chip, a menu to pick
the one to edit. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="min-w-0 flex"
onpointerdown={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<DropdownV2
items={coTriggerItems}
placement="bottom-start"
bind:open={triggerMenuOpen}
fixedHeight={false}
usePointerDownOutside
enableFlyTransition
>
{#snippet buttonReplacement()}
<span
class={twMerge(
'flex items-center gap-1 min-w-0 rounded-md border px-1.5 py-1 text-3xs leading-none font-normal border-gray-300 dark:border-gray-600 text-secondary bg-surface hover:bg-surface-hover cursor-pointer',
(triggerMenuOpen || coTriggers.some((t) => t.selected)) && CHIP_SELECTED
)}
title="Started by several triggers: pick one to edit"
>
<span class="truncate">Multiple triggers</span>
<ChevronDown size={9} class="shrink-0 opacity-70" />
</span>
{/snippet}
</DropdownV2>
</div>
{:else if upstream}
{@const trigger = producer?.trigger ?? coTriggers[0]}
{@const triggerScript = producer?.path ?? coTriggers[0]?.script}
{@const chipClass = twMerge(
'truncate rounded-md border px-1.5 py-1 text-3xs leading-none font-normal',
trigger?.missing
? 'border-red-300 dark:border-red-600 text-red-700 dark:text-red-300'
: 'border-gray-300 dark:border-gray-600 text-secondary',
trigger?.draft && 'border-dashed',
trigger?.onOpen && 'bg-surface hover:bg-surface-hover cursor-pointer',
trigger?.selected && CHIP_SELECTED
)}
{#if trigger?.kind === 'data_upload' && trigger.onOpen}
<!-- The one trigger that needs the user: nothing runs until a file is
provided, so it reads as the node's main action rather than a chip. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
class="flex min-w-0"
onpointerdown={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<Button
variant="accent"
unifiedSize="sm"
startIcon={{ icon: Upload }}
title={`Upload the data ${triggerScript} runs on`}
onClick={(e) => {
e?.stopPropagation()
trigger.onOpen?.()
}}
>
Upload data
</Button>
</span>
{:else if trigger?.onOpen}
<button
type="button"
class={twMerge(chipClass, 'flex items-center gap-1 min-w-0')}
title={trigger.missing
? `No ${trigger.label} trigger targets ${triggerScript} yet: click to create one`
: `Edit the ${trigger.label} trigger`}
onpointerdown={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
onclick={(e) => {
e.stopPropagation()
trigger.onOpen?.()
}}
>
<span class="truncate">{trigger.label}{trigger.draft ? ' · draft' : ''}</span>
{#if !trigger.missing}<Pencil size={9} class="shrink-0 opacity-70" />{/if}
</button>
{:else}
<span class={chipClass}>
{#if 'none' in upstream}
External source
{:else if trigger}
{trigger.label}{trigger.draft ? ' · draft' : ''}
{/if}
</span>
{/if}
{/if}
{/snippet}
<!-- onmouseenter/leave on the wrapper (not the inner card) so the run
button — which floats outside the card — keeps the hover state alive
when the cursor moves between the card and the button. -->
@@ -236,123 +588,169 @@
<div class="wm-recompute-flash pointer-events-none absolute inset-0 z-10 rounded-md"></div>
{/key}
{/if}
<!-- Mirrors the flow editor's asset pill: quiet surface + gray border at
rest, accent reserved for the selected state. -->
<div
class={twMerge(
'flex items-center rounded-md drop-shadow-sm overflow-hidden border',
'bg-surface-secondary border-gray-400 dark:border-gray-600 hover:border-gray-500 dark:hover:border-gray-500 transition-colors',
selected && 'bg-surface-accent-selected border-border-selected'
<NodeActionsMenu
items={menuItems}
hover={hovered}
kebabClass={twMerge(
showGuardBadge && '-right-8',
hasHeader && !data.bannerOnly && CARD_CORNER_TOP
)}
style="width: {NODE.width}px; min-height: {NODE.height}px;"
title={data.path}
>
<!-- Data identity carries the accent (luminance blue), pairing with
the blue write edges that produce these assets — scripts stay
neutral, so script vs data reads at a glance. -->
<AssetGenericIcon
assetKind={data.asset_kind}
class={`shrink-0 ml-2 mr-2 ${selected ? 'text-accent' : 'text-blue-600 dark:text-blue-400'}`}
size="14px"
/>
{#if data.runStatus}
<!-- The run in view, per relation: a spinner while its producer is
building it, then its outcome. Left of the name so the eye finds
the moving nodes first on a wide graph. -->
<span
class="shrink-0 mr-1 {data.runStatus === 'failed'
? 'text-red-600 dark:text-red-400'
: data.runStatus === 'materialized'
? 'text-green-600 dark:text-green-400'
: 'text-blue-600 dark:text-blue-400'}"
title={data.runStatus}
{#if data.bannerOnly}
<!-- A script that builds nothing and runs only on writes to what it reads:
the incoming edges say how it starts, so its banner is the whole node. -->
<div
class={twMerge(
'flex items-stretch min-w-0 rounded-md overflow-hidden bg-surface border border-gray-200 dark:border-gray-700 drop-shadow-sm',
(producer ? producer.selected : coSelected) &&
'border-border-selected dark:border-border-selected'
)}
style="width: {data.width ?? 160}px; height: {PIPELINE_NODE_HEADER}px;"
>
{#if data.runStatus === 'running'}
<Loader2 size={11} class="animate-spin" />
{:else if data.runStatus === 'failed'}
<XCircle size={11} />
{@render scriptHeader()}
</div>
{:else}
<PipelineNodeCard
kindLabel={data.noAsset ? 'No asset' : formatAssetKind(asset)}
title={data.noAsset ? (data.noAssetName ?? '') : formatShortAssetPath(asset)}
tooltip={data.noAsset
? 'This script builds no asset'
: data.error
? `${data.path}: ${data.error}`
: data.path}
{selected}
tone={data.error ? 'error' : undefined}
subtitle={data.foldedFixes?.length ||
(upstream &&
!producer?.trigger?.hidden &&
!('triggers' in upstream && upstream.triggers.length === 0))
? upstreamRow
: undefined}
header={hasHeader ? scriptHeader : undefined}
headerTone={data.scriptError ? 'error' : undefined}
headerSelected={producer ? !!producer.selected : coSelected}
width={data.width}
surface={upstream ? 'primary' : 'secondary'}
>
{#snippet icon()}
<!-- Data identity carries the accent (luminance blue), pairing with
the blue write edges that produce these assets — scripts stay
neutral, so script vs data reads at a glance. -->
{#if data.noAsset}
<CircleSlash size={14} class="text-tertiary" />
{:else}
<CheckCircle2 size={11} />
<AssetGenericIcon
assetKind={data.asset_kind}
class={data.error
? 'text-red-600 dark:text-red-200'
: selected
? 'text-accent'
: 'text-blue-600 dark:text-blue-400'}
size="14px"
/>
{/if}
</span>
<!-- Rows the run wrote. Recorded per relation already, and the cheapest
answer to "did this model actually produce anything" — a model that
built green but emitted 0 rows is the failure that looks like a
success. Only once settled: mid-build the number is not yet real. -->
{#if data.runStatus === 'materialized' && data.runRowCount != undefined}
<span
class="shrink-0 mr-1 text-3xs tabular-nums text-tertiary"
title="{data.runRowCount} rows written by this run"
>
{Intl.NumberFormat().format(data.runRowCount)}
</span>
{/if}
{/if}
<span class="flex-1 min-w-0 pr-1 py-0.5 text-2xs font-mono text-emphasis truncate">
{formatShortAssetPath(asset)}
</span>
<!-- Fork data-environment chip: in a fork every asset shares its parent's
name, so the env it resolves to must read at a glance. Labeled + tinted
(amber "parent" = deferred read of the parent's current table via a
view; emerald "fork" = the fork's own materialized copy) rather than a
bare icon, which was too easy to miss. The title carries the detail. -->
{#if data.fork_materialization === 'deferred'}
<span
class="shrink-0 mr-1.5 flex items-center gap-0.5 rounded px-1 py-px text-3xs font-semibold uppercase tracking-wide bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300 border border-amber-300 dark:border-amber-700"
title="Deferred to parent workspace: reads the parent's current data. Materialize it in this fork to iterate on it."
>
<ArrowUpRight size={10} />
parent
</span>
{:else if data.fork_materialization === 'fork'}
<span
class="shrink-0 mr-1.5 flex items-center gap-0.5 rounded px-1 py-px text-3xs font-semibold uppercase tracking-wide bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300 border border-emerald-300 dark:border-emerald-700"
title="Materialized in this fork: reads and writes use the fork's isolated copy."
>
<GitFork size={10} />
fork
</span>
{/if}
<!-- dbt chip: names the materialization dbt declares, plus a test count
when the model carries generic tests. Orange keeps it visually
separate from the fork/SCD2 chips, which describe Windmill state.
Where the project node is on the graph it also stands in for the edge
to it: hovering lights that node up, clicking selects it. Where it is
not — the run page, and a pipeline holding a dbt project — there is
nothing to point at, so the chip renders inert. -->
{#if data.dbt}
<button
type="button"
class="shrink-0 mr-1.5 flex items-center gap-0.5 rounded px-1 py-px text-3xs font-semibold tracking-wide bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300 border border-orange-300 dark:border-orange-700 {data.onDbtSelect
? 'hover:brightness-95 cursor-pointer'
: 'cursor-default'}"
title={dbtTitle}
onmouseenter={() => data.onDbtHover?.(true)}
onmouseleave={() => data.onDbtHover?.(false)}
onclick={(e) => {
e.stopPropagation()
data.onDbtSelect?.()
}}
>
<DbtIcon width={9} height={9} />
{dbtLabel}
{#if data.dbt.data_tests?.length}
<span class="opacity-70">&middot; {data.dbt.data_tests.length}T</span>
{/snippet}
{#snippet leading()}
{#if data.runStatus}
<!-- The run in view, per relation: a spinner while its producer is
building it, then its outcome. Left of the name so the eye finds
the moving nodes first on a wide graph. -->
<span
class="shrink-0 mr-1 {data.runStatus === 'failed'
? 'text-red-600 dark:text-red-400'
: data.runStatus === 'materialized'
? 'text-green-600 dark:text-green-400'
: 'text-blue-600 dark:text-blue-400'}"
title={data.runStatus}
>
{#if data.runStatus === 'running'}
<Loader2 size={11} class="animate-spin" />
{:else if data.runStatus === 'failed'}
<XCircle size={11} />
{:else}
<CheckCircle2 size={11} />
{/if}
</span>
<!-- Rows the run wrote. Recorded per relation already, and the cheapest
answer to "did this model actually produce anything" — a model that
built green but emitted 0 rows is the failure that looks like a
success. Only once settled: mid-build the number is not yet real. -->
{#if data.runStatus === 'materialized' && data.runRowCount != undefined}
<span
class="shrink-0 mr-1 text-3xs tabular-nums text-tertiary"
title="{data.runRowCount} rows written by this run"
>
{Intl.NumberFormat().format(data.runRowCount)}
</span>
{/if}
{/if}
</button>
{/snippet}
{#snippet trailing()}
<!-- Fork data-environment chip: in a fork every asset shares its parent's
name, so the env it resolves to must read at a glance. Labeled + tinted
(amber "parent" = deferred read of the parent's current table via a
view; emerald "fork" = the fork's own materialized copy) rather than a
bare icon, which was too easy to miss. The title carries the detail. -->
{#if data.fork_materialization === 'deferred'}
<span
class="shrink-0 mr-1.5 flex items-center gap-0.5 rounded px-1 py-px text-3xs font-semibold uppercase tracking-wide bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300 border border-amber-300 dark:border-amber-700"
title="Deferred to parent workspace: reads the parent's current data. Materialize it in this fork to iterate on it."
>
<ArrowUpRight size={10} />
parent
</span>
{:else if data.fork_materialization === 'fork'}
<span
class="shrink-0 mr-1.5 flex items-center gap-0.5 rounded px-1 py-px text-3xs font-semibold uppercase tracking-wide bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300 border border-emerald-300 dark:border-emerald-700"
title="Materialized in this fork: reads and writes use the fork's isolated copy."
>
<GitFork size={10} />
fork
</span>
{/if}
<!-- dbt chip: names the materialization dbt declares, plus a test count
when the model carries generic tests. Orange keeps it visually
separate from the fork/SCD2 chips, which describe Windmill state.
Where the project node is on the graph it also stands in for the edge
to it: hovering lights that node up, clicking selects it. Where it is
not — the run page, and a pipeline holding a dbt project — there is
nothing to point at, so the chip renders inert. -->
{#if data.dbt}
<button
type="button"
class="shrink-0 mr-1.5 flex items-center gap-0.5 rounded px-1 py-px text-3xs font-semibold tracking-wide bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300 border border-orange-300 dark:border-orange-700 {data.onDbtSelect
? 'hover:brightness-95 cursor-pointer'
: 'cursor-default'}"
title={dbtTitle}
onmouseenter={() => data.onDbtHover?.(true)}
onmouseleave={() => data.onDbtHover?.(false)}
onclick={(e) => {
e.stopPropagation()
data.onDbtSelect?.()
}}
>
<DbtIcon width={9} height={9} />
{dbtLabel}
{#if data.dbt.data_tests?.length}
<span class="opacity-70">&middot; {data.dbt.data_tests.length}T</span>
{/if}
</button>
{/if}
<!-- SCD2 companion marker: this node is the `<dim>_current` "latest row
per key" view its producer maintains alongside the base dimension.
Icon-only (the pill already truncates); the title names the base. -->
{#if data.derived_from}
<span
class="shrink-0 mr-1.5 text-violet-600 dark:text-violet-400"
title={`SCD2 current view of ${data.derived_from}: latest row per key, maintained by the same producer as the base dimension.`}
>
<History size={12} />
</span>
{/if}
{/snippet}
</PipelineNodeCard>
{/if}
<!-- SCD2 companion marker: this node is the `<dim>_current` "latest row
per key" view its producer maintains alongside the base dimension.
Icon-only (the pill already truncates); the title names the base. -->
{#if data.derived_from}
<span
class="shrink-0 mr-1.5 text-violet-600 dark:text-violet-400"
title={`SCD2 current view of ${data.derived_from}: latest row per key, maintained by the same producer as the base dimension.`}
>
<History size={12} />
</span>
{/if}
</div>
</NodeActionsMenu>
{#if showGuardBadge}
<!-- Data-test outcome badge. Floats off the TOP-RIGHT corner (opposite the
left-edge run button and the bottom + inserter) so it never collides
@@ -361,6 +759,7 @@
<div
class={twMerge(
'absolute -top-2 -right-2 z-10 rounded-full w-5 h-5 grid place-items-center border shadow-sm',
hasHeader && CARD_CORNER_TOP,
guardClass
)}
title={guardTitle}
@@ -376,7 +775,13 @@
cards. Drafts are runnable too via runScriptPreview, so no
greyed-out state — the page-supplied callback handles the
dispatch. -->
<div class="absolute -left-3 top-1/2 -translate-y-1/2 z-10">
<!-- Centred on the card, below the header tab when there is one. -->
<div
class={twMerge(
'absolute -left-3 top-1/2 -translate-y-1/2 z-10',
hasHeader && !data.bannerOnly && 'top-[calc(50%+11px)]'
)}
>
<button
type="button"
onclick={runProducers}
@@ -398,53 +803,47 @@
</button>
</div>
{/if}
{#if showAdd}
<!-- Always-visible + below the asset for downstream pipeline-script
creation. Half-overlapping the bottom edge so it visually attaches
to the node like the flow editor's between-step inserter. -->
{#if data.onStartOnDrag}
<NodeOnDot onStart={data.onStartOnDrag} visible={hovered} />
{/if}
{#if data.fix}
<!-- A red node's way out replaces its "+": adding downstream of a broken
node is not what it needs. -->
<div class="absolute left-1/2 -bottom-3 -translate-x-1/2 z-10">
<PipelineInsertMenu
kinds={[
{
id: 'pipeline_script',
label: 'Add downstream pipeline script',
description: 'Triggered when this asset changes',
icon: Code2
}
]}
languages={PIPELINE_LANGUAGES as any}
pathPrefix={data.pathPrefix ?? ''}
defaultPathSuffix={data.defaultPathSuffix ?? ''}
onPick={handlePick}
<NodeFixButton fix={data.fix} />
</div>
{:else if showAdd}
<!-- Always-visible + on the asset's bottom edge for downstream pipeline-
script creation, half-overlapping it like the flow editor's
between-step inserter. -->
<div class="absolute left-1/2 -bottom-3 -translate-x-1/2 z-10">
<AddDownstreamMenu
asset={{ kind: data.asset_kind, path: data.path }}
onAddScript={data.onAddScript!}
pathPrefix={data.pathPrefix}
defaultPathSuffix={data.defaultPathSuffix}
>
{#snippet trigger()}
<!--
Sizing notes for the round + button:
- w-6/h-6 (24px) chosen so that with border-2 (2px each
side) the inner area is exactly 20px — divisible by
the 16px icon to leave a 2px gap on every side. At
20px / 12px before, the gap was 4px-each but the
odd rounding interacted badly with svelte-flow's
fractional zoom transforms and the icon drifted
half a pixel off-center on certain zoom levels.
- `grid place-items-center` instead of flex centering:
flex's baseline alignment introduces a sub-pixel
nudge on small elements that grid avoids.
- `leading-none` strips the default line-height
contribution that the SVG inherits via the parent's
text rendering, otherwise the icon is shifted
downward by ~0.5px at fractional zooms.
-->
{#snippet trigger({ open })}
<!-- A click opens the menu; a drag goes to the canvas, which
tells a real drag from a click by how far the pointer moves. -->
<button
type="button"
class="nopan nodrag block"
onclick={(e) => e.stopPropagation()}
class="bg-surface border border-gray-400 dark:border-gray-600 text-secondary hover:bg-surface-hover rounded-full w-6 h-6 grid place-items-center shadow-sm leading-none"
title="Add downstream pipeline script"
onpointerdown={(e) => {
if (e.button !== 0) return
e.preventDefault()
data.onStartFeedDrag?.(e)
}}
aria-label="Add downstream step"
title={data.onStartFeedDrag
? 'Click to add a downstream step, or drag onto a script to run it after each write'
: undefined}
>
<Plus size={16} strokeWidth={2.5} />
<AddDownstreamPill expanded={open} />
</button>
{/snippet}
</PipelineInsertMenu>
</AddDownstreamMenu>
</div>
{/if}
</div>
@@ -11,7 +11,8 @@
// can't be read), so a selectable version always exists in the table.
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { Table2, History, Columns3 } from 'lucide-svelte'
import { Table2, History, Columns3, Database } from 'lucide-svelte'
import DBManagerContent from '$lib/components/DBManagerContent.svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { resource } from 'runed'
import { fetchDucklakeSnapshots } from '$lib/components/dbOps'
@@ -31,7 +32,7 @@
}
let { path, workspace, schemaCanEvolve = true }: Props = $props()
let tab = $state<'partitions' | 'history' | 'schema'>('partitions')
let tab = $state<'data' | 'partitions' | 'history' | 'schema'>('data')
// The user's explicit snapshot pick (undefined until they click a row).
let selectedVersion = $state<number | undefined>(undefined)
@@ -72,24 +73,28 @@
</script>
<div class="flex flex-col h-full">
{#if !qualifiedTable}
<!-- Catalog-level ducklake node (no table segment, e.g. `ducklake://main`):
snapshot history / time-travel are per-table, so only the partition
grid applies here. -->
<PartitionStatusGrid {path} {workspace} />
{:else}
<div class="flex items-center gap-2 px-3 py-2 border-b shrink-0">
<ToggleButtonGroup selected={tab} on:selected={(e) => (tab = e.detail)}>
{#snippet children({ item })}
<ToggleButton size="sm" value="partitions" label="Partitions" icon={Table2} {item} />
<div class="flex items-center gap-2 px-3 py-2 border-b shrink-0">
<ToggleButtonGroup selected={tab} on:selected={(e) => (tab = e.detail)}>
{#snippet children({ item })}
<ToggleButton size="sm" value="data" label="Data" icon={Database} {item} />
<ToggleButton size="sm" value="partitions" label="Partitions" icon={Table2} {item} />
<!-- Snapshot history and time-travel are per table: a catalog-level node
(`ducklake://main`, no table segment) has only its data and partitions. -->
{#if qualifiedTable}
<ToggleButton size="sm" value="schema" label="Schema" icon={Columns3} {item} />
<ToggleButton size="sm" value="history" label="History" icon={History} {item} />
{/snippet}
</ToggleButtonGroup>
</div>
{/if}
{/snippet}
</ToggleButtonGroup>
</div>
<div class="flex-1 min-h-0">
{#if tab === 'partitions'}
<div class="flex-1 min-h-0">
{#if tab === 'data' && parsed}
<!-- Keyed on the asset: the manager opens its table once, on mount. -->
{#key path}
<DBManagerContent input={parsed} {workspace} />
{/key}
{:else if tab === 'partitions' || !qualifiedTable}
<PartitionStatusGrid {path} {workspace} />
{:else if tab === 'schema'}
<SchemaHistoryPanel {path} {workspace} canEvolve={schemaCanEvolve} />
@@ -117,7 +122,6 @@
</Pane>
</Splitpanes>
</div>
{/if}
</div>
{/if}
{/if}
</div>
</div>
@@ -25,6 +25,8 @@
if (!nodesInitialized.current) return
if (!nodes.some((n) => n.type !== 'add')) return
fittedFor = fitKey
void fitView({ padding: 0.1, maxZoom: 1 })
// A re-fit over nodes that keep their ids finds them already measured, one
// render before their new positions reach the flow.
requestAnimationFrame(() => void fitView({ padding: 0.1, maxZoom: 1 }))
})
</script>
@@ -0,0 +1,68 @@
<!--
@component
A pipeline node's actions, reachable two ways: the kebab revealed on hover at the
node's top-right corner, and a right-click menu over the node carrying the same
items. Place it inside the node's `relative` wrapper, around the node's card.
-->
<script lang="ts">
import type { Snippet } from 'svelte'
import { twMerge } from 'tailwind-merge'
import { EllipsisVertical } from 'lucide-svelte'
import { stopPropagation, preventDefault } from 'svelte/legacy'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import ContextMenu from '$lib/components/common/contextmenu/ContextMenu.svelte'
import { contextMenuItemsFromMenu } from '$lib/components/common/contextmenu/fromMenuItems'
import type { Item } from '$lib/utils'
interface Props {
items: Item[]
/** The pointer is over the node: shows the kebab. */
hover: boolean
/** Moves the kebab, for a node with its own badge on that corner. */
kebabClass?: string
children: Snippet
}
let { items, hover, kebabClass, children }: Props = $props()
let menuOpen = $state(false)
let contextItems = $derived(contextMenuItemsFromMenu(items))
</script>
{#if items.length > 0}
<ContextMenu items={contextItems}>
{@render children()}
</ContextMenu>
<!-- Rendered only on hover or while open, so the canvas stays clean at rest.
`pointerdown` is stopped so svelte-flow doesn't select the node when the
user reaches for the menu. -->
<div
class={twMerge('absolute -top-2 -right-2 h-7 p-1 min-w-7', kebabClass)}
style="will-change: transform;"
>
<DropdownV2
{items}
placement="bottom-end"
bind:open={menuOpen}
fixedHeight={false}
usePointerDownOutside
enableFlyTransition
>
{#snippet buttonReplacement()}
<button
class={twMerge(
'center-center p-1 text-secondary shadow-sm bg-surface duration-0 hover:bg-surface-tertiary',
hover || menuOpen ? 'block' : '!hidden',
'shadow-md rounded-md'
)}
onpointerdown={stopPropagation(preventDefault(() => {}))}
title="Actions"
>
<EllipsisVertical size={12} />
</button>
{/snippet}
</DropdownV2>
</div>
{:else}
{@render children()}
{/if}
@@ -0,0 +1,124 @@
<!--
@component
The "Fix" pill on the bottom edge of a misconfigured (red) pipeline node: what is
wrong, and the actions that fix it. Styled like the node's "+" pill; the node's
red fill already says something is wrong.
-->
<script lang="ts" module>
export type NodeFixAction = {
label: string
/** The side effect, spelled out: what changes, where. */
detail?: string
/** `anchor`: where the Fix pill sits, for an action opening a menu there. */
run: (anchor: { x: number; y: number }) => void
}
/** Prose, with the entities it names as links that select them. */
export type FixExplainer = Array<string | { text: string; onClick: () => void }>
export type NodeFix = { explainer: FixExplainer; actions: NodeFixAction[] }
/** A fix as its supplier declares it: an action, or opening the schedule
* wizard (which the canvas owns). */
export type NodeFixSpec = {
explainer: FixExplainer
actions: Array<{
label: string
detail?: string
run?: () => void
/** Opens the schedule wizard for this script; its confirm calls `onSchedule`. */
scheduleFor?: {
script: string
onSchedule: (s: import('./PipelineInsertMenu.svelte').PipelineInsertSchedule) => void
}
}>
}
</script>
<script lang="ts">
import Popover from '$lib/components/meltComponents/Popover.svelte'
import { Button } from '$lib/components/common'
import { Wrench } from 'lucide-svelte'
let {
fix,
label = 'Fix',
chip = false
}: {
fix: NodeFix
label?: string
/** A small chip in a node's text, rather than the pill on its edge. */
chip?: boolean
} = $props()
let open = $state(false)
let anchorEl: HTMLElement | undefined = $state()
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={anchorEl}
onpointerdown={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
onclick={(e) => e.stopPropagation()}
>
<Popover
bind:isOpen={open}
placement="bottom"
enableFlyTransition
usePointerDownOutside
contentClasses="p-3 w-96"
>
{#snippet trigger()}
{#if chip}
<span
class="flex items-center gap-1 rounded-md border px-1.5 py-1 text-3xs leading-none font-normal whitespace-nowrap bg-surface border-red-300 dark:border-red-600 text-red-700 dark:text-red-300 hover:bg-surface-hover"
>
<Wrench size={10} class="shrink-0" />
{label}
</span>
{:else}
<span
class="h-6 px-2 rounded-full flex items-center gap-1 bg-surface border border-gray-400 dark:border-gray-600 text-secondary hover:text-primary hover:border-gray-500 shadow-sm text-2xs font-normal leading-none whitespace-nowrap"
>
<Wrench size={12} class="shrink-0" />
{label}
</span>
{/if}
{/snippet}
{#snippet content()}
<div class="flex flex-col gap-3">
<p class="text-xs text-primary">
{#each fix.explainer as part}
{#if typeof part === 'string'}{part}{:else}<button
type="button"
class="text-accent hover:underline"
onclick={() => {
open = false
part.onClick()
}}>{part.text}</button
>{/if}
{/each}
</p>
<div class="flex flex-col gap-2">
{#each fix.actions as action, i (action.label)}
<div class="flex flex-col gap-0.5">
<Button
variant={i === 0 ? 'accent' : 'default'}
unifiedSize="sm"
onclick={() => {
const r = anchorEl?.getBoundingClientRect()
open = false
action.run({ x: r ? r.left + r.width / 2 : 0, y: r ? r.bottom : 0 })
}}
>
{action.label}
</Button>
{#if action.detail}
<span class="text-2xs text-secondary">{action.detail}</span>
{/if}
</div>
{/each}
</div>
</div>
{/snippet}
</Popover>
</div>
@@ -0,0 +1,23 @@
<!--
@component
The dot on a pipeline node's top edge: dragged onto an asset, it makes the node's
script run after each write to that asset.
-->
<script lang="ts">
let { onStart, visible }: { onStart: (e: PointerEvent) => void; visible: boolean } = $props()
</script>
<button
type="button"
class="absolute -top-1.5 left-1/2 -translate-x-1/2 z-10 w-3 h-3 rounded-full bg-surface border-2 border-blue-500 cursor-crosshair transition-opacity {visible
? 'opacity-100'
: 'opacity-0'}"
title="Drag onto an asset to run after each write to it"
aria-label="Drag onto an asset to run after each write to it"
onpointerdown={(e) => {
e.stopPropagation()
e.preventDefault()
onStart(e)
}}
onclick={(e) => e.stopPropagation()}
></button>
@@ -2,6 +2,7 @@
import { useSvelteFlow, type Node } from '@xyflow/svelte'
import { untrack } from 'svelte'
import { NODE } from '$lib/components/graph/util'
import { PIPELINE_NODE_HEIGHT } from './assetGraphLayout'
// Smoothly pans the viewport so a freshly-created node lands in the middle,
// keeping the current zoom. Lives inside <SvelteFlow> so useSvelteFlow() has
@@ -27,8 +28,8 @@
untrack(() => {
const node = ns.find((n) => n.id === id)
if (!node) return // not laid out yet — re-runs when `nodes` updates
const cx = node.position.x + NODE.width / 2
const cy = node.position.y + NODE.height / 2
const cx = node.position.x + (node.measured?.width ?? NODE.width) / 2
const cy = node.position.y + PIPELINE_NODE_HEIGHT / 2
const key = `${id}:${cx}:${cy}`
if (key === lastKey) return
lastKey = key
@@ -416,7 +416,7 @@
onmouseenter={() => onHoverRun?.([e.path])}
onmouseleave={() => onHoverRun?.(undefined)}
class={twMerge(
'w-full flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-surface-hover text-xs text-left',
'w-full flex items-center gap-2 px-2 py-1.5 rounded-sm font-normal hover:bg-surface-hover text-xs text-left',
isMember && 'pl-7',
expanded && 'bg-surface-selected'
)}
@@ -445,7 +445,7 @@
{:else}
<Code2 size={12} class="shrink-0 text-tertiary" />
{/if}
<span class="flex-1 min-w-0 truncate font-mono text-2xs" title={e.path}>
<span class="flex-1 min-w-0 truncate text-xs" title={e.path}>
{e.path}
</span>
{#if showSource}
@@ -533,7 +533,7 @@
onclick={() => toggleGroup(g.key)}
onmouseenter={() => onHoverRun?.(g.members.map((m) => m.path))}
onmouseleave={() => onHoverRun?.(undefined)}
class="w-full flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-surface-hover text-xs text-left"
class="w-full flex items-center gap-2 px-2 py-1.5 rounded-sm font-normal hover:bg-surface-hover text-xs text-left"
title={collapsed ? 'Expand cascade runs' : 'Collapse cascade runs'}
>
<span class="shrink-0 text-tertiary">
@@ -549,7 +549,7 @@
{/if}
</span>
<Workflow size={12} class="shrink-0 text-indigo-500" />
<span class="flex-1 min-w-0 truncate font-mono text-2xs" title={groupTitle(g)}>
<span class="flex-1 min-w-0 truncate text-xs" title={groupTitle(g)}>
{groupTitle(g)}
</span>
<span
@@ -1,40 +0,0 @@
<script lang="ts">
import { base } from '$lib/base'
import Alert from '$lib/components/common/alert/Alert.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import DbtIcon from '$lib/components/icons/DbtIcon.svelte'
import { BookOpen } from 'lucide-svelte'
const DBT_DOCS = 'https://www.windmill.dev/docs/getting_started/scripts_quickstart/dbt'
</script>
<Alert type="info" title="Prefer using dbt? Windmill also runs dbt" size="xs">
<div class="flex flex-col gap-2">
<p>
A pipeline is Windmill's own abstraction; an existing dbt project stays a dbt project, and
runs here unchanged as its own kind of script, with dbt still owning its models, refs and
tests. All it needs is a warehouse configured under
<a class="underline" href="{base}/workspace_settings?tab=dbt">Settings → dbt</a>. Its models
show up in the asset graph as <span class="font-mono">dbt://</span> nodes, visible next to whatever
pipelines you do build, so you can adopt pipelines later, or never.
</p>
<div class="flex flex-row flex-wrap items-center gap-2">
<!-- The dbt mark goes in as a child rather than `startIcon`: that slot
sizes its icon with lucide's `size`, which DbtIcon has no prop for. -->
<Button variant="default" unifiedSize="sm" href="{base}/scripts/add?lang=dbt">
<DbtIcon width={14} height={14} />
New dbt script
</Button>
<Button
variant="subtle"
unifiedSize="sm"
href={DBT_DOCS}
target="_blank"
rel="noreferrer"
startIcon={{ icon: BookOpen }}
>
dbt documentation
</Button>
</div>
</div>
</Alert>
@@ -0,0 +1,49 @@
<script lang="ts">
import { AlertTriangle } from 'lucide-svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
// Which drafts failed to deploy and why, anchored next to the deploy button so
// the reasons are one click away without leaving the editor. Deployed drafts
// leave the map; what is listed is still unresolved.
let {
errors,
open = $bindable()
}: {
errors: ReadonlyMap<string, string>
open?: boolean
} = $props()
</script>
<Popover
placement="bottom-end"
contentClasses="p-3 max-w-[480px]"
usePointerDownOutside
enableFlyTransition
bind:isOpen={open}
>
{#snippet trigger()}
<button
type="button"
class="flex items-center gap-1.5 px-2 py-1 rounded-md text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/30 hover:bg-red-100 dark:hover:bg-red-900/50 transition-colors text-xs font-medium"
title="View save errors"
>
<AlertTriangle size={14} />
<span>{errors.size} failed</span>
</button>
{/snippet}
{#snippet content()}
<div class="flex flex-col gap-2">
<span class="text-xs font-semibold text-emphasis">Save errors</span>
<div class="flex flex-col gap-2 max-h-72 overflow-y-auto">
{#each [...errors.entries()] as [path, message] (path)}
<div class="flex flex-col gap-0.5 border-l-2 border-red-400 pl-2">
<span class="text-2xs font-mono text-emphasis">{path}</span>
<span class="text-2xs text-red-600 dark:text-red-400 break-words">
{message}
</span>
</div>
{/each}
</div>
</div>
{/snippet}
</Popover>
@@ -0,0 +1,47 @@
<script lang="ts">
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import type { PipelineTriggerDraft } from './types'
// Triggers start running once deployed, so deploying a pipeline's trigger
// drafts asks first, listing what will start.
let {
open,
triggerDrafts,
onConfirmed,
onCanceled
}: {
open: boolean
triggerDrafts: ReadonlyMap<string, PipelineTriggerDraft>
onConfirmed: () => void
onCanceled: () => void
} = $props()
</script>
<ConfirmationModal
{open}
title="Deploy triggers?"
confirmationText="Deploy"
type="info"
{onConfirmed}
{onCanceled}
>
<div class="flex flex-col gap-2 text-xs text-secondary">
<p>
{triggerDrafts.size === 1
? 'Saving the pipeline also creates this trigger. It starts running its script once deployed.'
: `Saving the pipeline also creates these ${triggerDrafts.size} triggers. They start running their scripts once deployed.`}
</p>
<ul class="flex flex-col gap-1">
{#each [...triggerDrafts] as [key, d] (key)}
<li class="flex flex-col">
<span class="font-mono text-2xs text-emphasis truncate">{d.config.path}</span>
<span class="text-2xs text-hint truncate">
{d.kind}{#if d.kind === 'schedule'}
· <span class="font-mono">{d.config.schedule}</span> ({d.config.timezone}){/if}
→ {d.config.script_path}
</span>
</li>
{/each}
</ul>
</div>
</ConfirmationModal>
@@ -18,11 +18,18 @@
mode?: 'view' | 'edit'
// Called right before navigating — lets a wrapping modal close itself.
onOpen?: () => void
// Only the folder picker, for hosts that don't list pipelines.
hideExisting?: boolean
}
let { currentFolder = undefined, mode = 'view', onOpen = undefined }: Props = $props()
let {
currentFolder = undefined,
mode = 'view',
onOpen = undefined,
hideExisting = false
}: Props = $props()
let pipelines = resource(
() => $workspaceStore,
() => (hideExisting ? undefined : $workspaceStore),
async (ws) => {
if (!ws) return [] as ListPipelineFoldersResponse
return await AssetService.listPipelineFolders({ workspace: ws })
@@ -49,50 +56,52 @@
</script>
<div class="flex flex-col gap-6 w-full min-w-0">
<section class="flex flex-col gap-2">
<h3 class="text-xs font-semibold text-secondary uppercase tracking-wide">
Existing pipelines
</h3>
{#if pipelines.loading && !pipelines.current}
<div class="text-tertiary text-sm flex items-center gap-2">
<Loader2 size={14} class="animate-spin" />
Loading…
</div>
{:else if pipelines.error}
<div class="text-red-500 text-sm">Failed: {pipelines.error.message}</div>
{:else if visiblePipelines.length === 0}
<div class="text-tertiary text-sm">
{currentFolder
? 'No other pipelines in this workspace.'
: 'No pipelines yet. A pipeline is any folder whose scripts carry pipeline annotations.'}
</div>
{:else}
<div class="flex flex-col border rounded-md overflow-hidden max-h-64 overflow-y-auto">
{#each visiblePipelines as p (p.folder)}
<button
type="button"
class="flex items-center justify-between px-3 py-2 border-b last:border-b-0 bg-surface hover:bg-surface-hover transition-colors text-left"
onclick={() => openExistingPipeline(p.folder)}
>
<span class="font-mono text-sm">f/{p.folder}</span>
<span class="text-2xs text-tertiary">
{p.script_count}
{p.script_count === 1 ? 'script' : 'scripts'}
</span>
</button>
{/each}
</div>
{/if}
</section>
{#if !hideExisting}
<section class="flex flex-col gap-2">
<h3 class="text-sm font-semibold text-emphasis">Existing pipelines</h3>
{#if pipelines.loading && !pipelines.current}
<div class="text-tertiary text-sm flex items-center gap-2">
<Loader2 size={14} class="animate-spin" />
Loading…
</div>
{:else if pipelines.error}
<div class="text-red-500 text-sm">Failed: {pipelines.error.message}</div>
{:else if visiblePipelines.length === 0}
<div class="text-tertiary text-sm">
{currentFolder
? 'No other pipelines in this workspace.'
: 'No pipelines yet. A pipeline is any folder whose scripts carry pipeline annotations.'}
</div>
{:else}
<div class="flex flex-col border rounded-md overflow-hidden max-h-64 overflow-y-auto">
{#each visiblePipelines as p (p.folder)}
<button
type="button"
class="flex items-center justify-between px-3 py-2 border-b last:border-b-0 bg-surface hover:bg-surface-hover transition-colors text-left"
onclick={() => openExistingPipeline(p.folder)}
>
<span class="font-mono text-sm">f/{p.folder}</span>
<span class="text-2xs text-tertiary">
{p.script_count}
{p.script_count === 1 ? 'script' : 'scripts'}
</span>
</button>
{/each}
</div>
{/if}
</section>
{/if}
{#if !$userStore?.operator}
<!-- Operators only view existing pipelines — opening an arbitrary
folder is a build-a-new-pipeline affordance. -->
<section class="flex flex-col gap-2">
<h3 class="text-xs font-semibold text-secondary uppercase tracking-wide">
Pick or create a folder
</h3>
<div class="flex items-center gap-2">
<section class="flex flex-col gap-1">
<h3 class="text-sm font-semibold text-emphasis">Pick or create a folder</h3>
<p class="text-xs text-secondary">
Each folder holds one pipeline, made of its pipeline scripts. Pick a folder to open its
pipeline, or create one to start a new pipeline.
</p>
<div class="flex items-center gap-2 mt-1">
<div class="flex-1 min-w-0">
<FolderPicker bind:folderName={pickedFolder} />
</div>
@@ -1,6 +1,15 @@
<script lang="ts">
import { untrack, type Snippet } from 'svelte'
import { PIPELINE_DRAFT_KIND, pipelineBundlePath } from '$lib/pipelinePaths'
import { MousePointerClick } from 'lucide-svelte'
import Disposable from '$lib/components/common/drawer/Disposable.svelte'
import Portal from '$lib/components/Portal.svelte'
import { getOverlayHost } from '$lib/components/common/overlayHost.svelte'
import { useFlowPanelMode } from '$lib/components/flows/flowPanelMode.svelte'
import {
PIPELINE_DRAFT_KIND,
pipelineBundlePath,
pipelineLocalMirrorKey
} from '$lib/pipelinePaths'
import { Loader2 } from 'lucide-svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { DraftService } from '$lib/gen'
@@ -11,6 +20,11 @@
import type { ColumnLineageGraph } from './columnLineageGraph'
import HideButton from '$lib/components/apps/editor/settingsPanel/HideButton.svelte'
import AssetGraphCanvas from './AssetGraphCanvas.svelte'
import { TRIGGER_NODE_STYLE } from './TriggerNode.svelte'
import { describeSchedule } from '$lib/utils/describeCron'
import type { UpstreamTriggerAction } from './upstreamScriptItems'
import type { AssetUpstreamDelete } from './assetsOnlyView'
import type { NodeFixSpec } from './NodeFixButton.svelte'
import AssetGraphDetailsPane from './AssetGraphDetailsPane.svelte'
import PipelineEventLog from './PipelineEventLog.svelte'
import type {
@@ -18,8 +32,10 @@
AssetGraphSelection,
NativeTriggerKind,
PipelineMode,
PipelineTriggerDraft,
DbtAssetProvenance
} from './types'
import { isDraftableTriggerKind, triggerDraftKey } from './pipelineTriggerDrafts'
import type { AssetKind, Script, ScriptLang } from '$lib/gen'
import type { RunnableRunState, PipelineEvent } from './activeRunnables.svelte'
import type { PipelineOutputKind } from './pipelineTemplates'
@@ -41,6 +57,7 @@
defaultPathSuffix = 'new_pipeline_script',
panelHidden = false,
onTogglePanelHidden,
modalPanel = false,
prefetchingAssets = false,
hoveredPaths = [],
selectedRunPaths = [],
@@ -65,6 +82,11 @@
onAddScriptForAsset,
onAddPipelineScript,
onRunnableMenuRemove,
onDeleteAssetUpstream,
nodeFixes,
assetDeleteVerb,
onEdgeAction,
onAddAssetTrigger,
onRunProducer,
idlePane,
onRequestEdit,
@@ -73,6 +95,9 @@
onRunCascadeByPath,
runFormInitialArgs,
onRunFormArgsChange,
inputsOnly = false,
onRunInputs,
onShowScript,
readyDataUploadPaths,
resolveLocalScript,
localScriptsVersion,
@@ -126,6 +151,9 @@
pathPrefix: string
defaultPathSuffix?: string
panelHidden?: boolean
/** Let the details pane open as a modal over a full-width graph when the editor
* is too narrow to dock it — the flow editor's step panel rule. */
modalPanel?: boolean
onTogglePanelHidden?: () => void
prefetchingAssets?: boolean
hoveredPaths?: string[]
@@ -153,16 +181,29 @@
language: ScriptLang,
scriptPath: string,
outputKind: PipelineOutputKind,
aiPrompt?: string
aiPrompt?: string,
options?: import('./PipelineInsertMenu.svelte').PipelineInsertOptions
) => void
onAddPipelineScript?: (
language: ScriptLang,
path: string,
source: { kind: NativeTriggerKind; path: string | undefined },
outputKind: PipelineOutputKind,
aiPrompt?: string
aiPrompt?: string,
options?: import('./PipelineInsertMenu.svelte').PipelineInsertOptions
) => void
onRunnableMenuRemove?: (...args: any[]) => void
onDeleteAssetUpstream?: (target: AssetUpstreamDelete) => void
nodeFixes?: ReadonlyMap<string, NodeFixSpec>
assetDeleteVerb?: 'Delete' | 'Archive'
onEdgeAction?: (a: {
action: 'listen' | 'stop'
targets: Array<{ script: string; asset: { kind: AssetKind; path: string }; reads: boolean }>
}) => void
onAddAssetTrigger?: (a: {
scripts: Array<{ script: string; reads: boolean }>
asset: { kind: AssetKind; path: string }
}) => void
onRunProducer?: (producer: RunProducer) => Promise<string | undefined>
idlePane?: Snippet
onRequestEdit?: () => void
@@ -175,8 +216,16 @@
// callback to persist them as they change — see the page's dataUploadArgs.
runFormInitialArgs?: Record<string, any>
onRunFormArgsChange?: (path: string, args: Record<string, any>, isValid: boolean) => void
/** See AssetGraphDetailsPane's `inputsOnly`. */
inputsOnly?: boolean
onRunInputs?: (
path: string,
args: Record<string, any>,
live?: { content: string; language: ScriptLang }
) => Promise<string | undefined>
onShowScript?: () => void
// Data-upload entry scripts whose staged upload is ready (green node).
readyDataUploadPaths?: Set<string>
readyDataUploadPaths?: ReadonlySet<string>
/** Local-dev (`/pipeline_dev`): resolve a node to its working-tree content
* so the details pane skips the (nonexistent) deployed-script fetch. May
* be async (to infer the args schema for the run form). */
@@ -247,18 +296,81 @@
? (editor.selection != undefined || editor.activeDraftPath != undefined) && !panelHidden
: !panelHidden
)
// Docked beside the graph, or in a modal over it (modal mode). In modal mode the
// details only show once asked for: a double-click on a node, a click on the
// node already selected, or a script's open button — never on a selection the
// editor makes itself (an AI-built node), which would pop a modal unasked.
const panelController = useFlowPanelMode({ enabled: () => modalPanel })
$effect(() => panelController.measure(containerWidth))
const panelMode = $derived(panelController.mode)
let panelModalOpen = $state(false)
const dockedPaneOpen = $derived(detailsPaneOpen && panelMode === 'docked')
$effect(() => {
if (untrack(() => panelModalOpen) && (panelMode === 'docked' || !detailsPaneOpen)) {
panelModalOpen = false
}
})
let panelDisposable: Disposable | undefined = $state(undefined)
// Disposable joins the overlay stack through its methods, not by watching `open`.
$effect(() => {
panelModalOpen
untrack(() => {
panelModalOpen ? panelDisposable?.openDrawer() : panelDisposable?.closeDrawer()
})
})
const overlayHost = getOverlayHost()
const modalHost = $derived(overlayHost?.el())
function selectableNodeAt(e: MouseEvent): HTMLElement | null {
return (e.target as HTMLElement | null)?.closest('.svelte-flow__node.selectable') ?? null
}
// Read in the capture phase: once the click bubbles, the canvas has applied
// its selection and a first click looks like a click on the selected node.
let clickStartedOnSelected = false
function noteSelectionBeforeClick(e: MouseEvent) {
clickStartedOnSelected = !!selectableNodeAt(e)?.classList.contains('selected')
}
function openPanelModalIfReselected(e: MouseEvent) {
if (clickStartedOnSelected && selectableNodeAt(e)) panelModalOpen = true
}
function openPanelModalFromGraph(e: MouseEvent) {
if (selectableNodeAt(e)) panelModalOpen = true
}
// A button in the pane that ends it closes the modal in the same update: left to
// the effect above, the modal would first render the pane without its selection.
function closePanelModal() {
if (panelMode === 'modal') panelModalOpen = false
}
function handleCanvasSelect(s: AssetGraphSelection | undefined, opts?: { open?: boolean }) {
onSelect(s)
if (opts?.open && s && panelMode === 'modal') panelModalOpen = true
}
// A data-upload node's click asks for its run form; in modal mode nothing shows
// it unless the modal opens too.
$effect(() => {
if (focusUploadSignal > 0 && untrack(() => panelMode) === 'modal') {
untrack(() => (panelModalOpen = true))
}
})
const showNodeHint = $derived(panelMode === 'modal' && !panelModalOpen)
const nodeHintText = $derived(
effectiveSelection
? 'Click the selected node to open it'
: 'Double click a node to open it'
)
let idleView = $derived(
mode !== 'edit' && editor.selection == undefined && editor.activeDraftPath == undefined
)
// Wrap the writes (and the rightPaneSize read in the else branch) in untrack so
// the effect tracks only `detailsPaneOpen` / `storedRightPaneSize` — without it,
// the effect tracks only `dockedPaneOpen` / `storedRightPaneSize` — without it,
// the Pane `bind:size` feedback loops the effect and pegs the main thread.
$effect(() => {
// Stacked (vertical) splits give the details pane more room — the run
// form + result need height more than the graph needs it.
const fallback = stacked ? 55 : 40
if (detailsPaneOpen) {
if (dockedPaneOpen) {
const restore = storedRightPaneSize
untrack(() => {
rightPaneSize = restore > 0 ? restore : fallback
@@ -272,6 +384,67 @@
}
})
// The selected asset's producing scripts, named and iconed from the graph, for
// the pane's "Edit script".
let producerScripts = $derived(
selectionProducers
.filter((p) => p.kind === 'script')
.map((p) => {
const r = displayGraph.runnables.find((x) => x.usage_kind === 'script' && x.path === p.path)
return { path: p.path, summary: r?.summary, language: r?.language }
})
)
// The selected asset's upstream triggers that can be opened from here, each
// with the same action its trigger node has: edit (edit mode), create a
// missing one (edit mode), or the webhook / data-upload drawers.
let producerTriggers = $derived.by((): UpstreamTriggerAction[] => {
const scripts = new Set(producerScripts.map((p) => p.path))
const seen = new Set<string>()
const out: UpstreamTriggerAction[] = []
for (const t of displayGraph.triggers ?? []) {
if (t.trigger_kind === 'asset' || !scripts.has(t.runnable_path)) continue
const kind = t.trigger_kind
const rowless = kind === 'webhook' || kind === 'data_upload'
const key = `${kind}:${rowless || t.missing ? t.runnable_path : t.path}`
if (seen.has(key)) continue
seen.add(key)
const label = TRIGGER_NODE_STYLE[kind]?.label ?? kind
const script = t.runnable_path
let action: UpstreamTriggerAction | undefined
if (kind === 'webhook') {
if (onOpenWebhook)
action = { label: 'Open webhook', detail: script, onOpen: () => onOpenWebhook(script) }
} else if (kind === 'data_upload') {
if (onOpenDataUpload)
action = { label: 'Upload data', detail: script, onOpen: () => onOpenDataUpload(script) }
} else if (t.missing && !rowless) {
if (onCreateMissingTrigger)
action = {
label: `Create ${label} trigger`,
detail: script,
onOpen: () => onCreateMissingTrigger(kind, script)
}
} else if (t.path && onEditTrigger) {
const path = t.path
action = {
label: kind === 'schedule' ? 'Edit schedule' : `Edit ${label} trigger`,
detail:
kind === 'schedule' && t.schedule
? (t.summary ?? describeSchedule(t.schedule, t.timezone) ?? path)
: path,
onOpen: () => onEditTrigger(kind, path, script)
}
}
if (action) out.push(action)
}
return out
})
function editProducerScript(path: string) {
if (mode !== 'edit') onRequestEdit?.()
onSelect({ kind: 'runnable', runnable_kind: 'script', path })
}
// ===================== Draft autosave (persistDrafts only) =====================
// All of this folder's in-flight drafts live in ONE per-user DB draft (typ
// `data_pipeline`) keyed at the folder, syncing across devices + surfacing in
@@ -279,12 +452,21 @@
// for the one-time migration below; the DB is the source of truth on load.
// FlowBuilder's autosave analogue — gated by `persistDrafts`.
let pipelineDraftPath = $derived(pipelineBundlePath(folder))
let storageKey = $derived(`pipeline-${folder}`)
type PipelineDraftBundle = { drafts: Array<[string, PipelineDraft]>; activeDraftPath?: string }
let storageKey = $derived(pipelineLocalMirrorKey(folder))
type PipelineDraftBundle = {
drafts: Array<[string, PipelineDraft]>
activeDraftPath?: string
triggerDrafts?: PipelineTriggerDraft[]
}
// Hydration is tracked on the editor instance (`editor.hydratedFromDb`), not a
// component flag: the in-session preview reuses one instance across editor
// hide/show, and it must hydrate ONCE per instance, not on every remount.
let lastPersistedBundle: string | undefined = undefined
// What decides whether the bundle needs saving: its drafts, not which one is
// open. Opening a draft is not an edit, so it rides along with the next real
// change instead of saving (and spinning the sync indicator) on a click.
const persistKey = (b: PipelineDraftBundle | undefined) =>
b ? JSON.stringify({ drafts: b.drafts, triggerDrafts: b.triggerDrafts }) : undefined
function restoreBundle(bundle: PipelineDraftBundle) {
if (Array.isArray(bundle.drafts)) {
@@ -300,6 +482,18 @@
if (loaded.size > 0) editor.drafts = loaded
}
if (typeof bundle.activeDraftPath === 'string') editor.activeDraftPath = bundle.activeDraftPath
if (Array.isArray(bundle.triggerDrafts)) {
editor.triggerDrafts = new Map(
bundle.triggerDrafts
.filter(
(d) =>
isDraftableTriggerKind(d?.kind) &&
typeof d.config?.path === 'string' &&
typeof d.config?.script_path === 'string'
)
.map((d) => [triggerDraftKey(d.kind, d.config.path), d])
)
}
}
function readLocalBundle(): PipelineDraftBundle | undefined {
@@ -311,7 +505,8 @@
if (s && (Array.isArray(s.drafts) || typeof s.activeDraftPath === 'string')) {
return {
drafts: Array.isArray(s.drafts) ? s.drafts : [],
activeDraftPath: typeof s.activeDraftPath === 'string' ? s.activeDraftPath : undefined
activeDraftPath: typeof s.activeDraftPath === 'string' ? s.activeDraftPath : undefined,
triggerDrafts: Array.isArray(s.triggerDrafts) ? s.triggerDrafts : undefined
}
}
} catch (e) {
@@ -339,8 +534,8 @@
fromServer = true
}
}
// A folder retarget during the await (the route-page header switcher, or a
// session retarget) changed the target and reset hydratedFromDb. Don't
// A folder switch during the await (the route page's header switcher)
// changed the target and reset hydratedFromDb. Don't
// apply this now-stale folder's bundle or mark the new folder hydrated —
// otherwise the stale result blocks the new folder's hydrate and its
// drafts bleed across folders.
@@ -359,7 +554,7 @@
{ workspace: ws ?? '', itemKind: PIPELINE_DRAFT_KIND, path },
migratedFromLocal ? undefined : serverSavedAt
)
if (!migratedFromLocal) lastPersistedBundle = bundle ? JSON.stringify(bundle) : undefined
if (!migratedFromLocal) lastPersistedBundle = persistKey(bundle)
} catch (e) {
console.warn('failed to load pipeline drafts', e)
} finally {
@@ -412,30 +607,52 @@
return [p, d] as [string, PipelineDraft]
return [p, { ...d, script, outputAssets, inputAssets }] as [string, PipelineDraft]
})
// A deployed script being edited is only promoted to a draft when its pane
// closes; until then its buffer is saved as the draft it will become.
const { scriptPath: livePath, content: liveBody, base } = editor.liveContent
if (
livePath != undefined &&
base &&
!editor.drafts.has(livePath) &&
liveBody !== (base.content ?? '')
) {
const live = editor.liveBodyAssets.scriptPath === livePath
const writes = live ? extractWrites(editor.liveBodyAssets.assets) : []
serialized.push([
livePath,
{
localId: editor.promotedDraftLocalId(livePath),
script: { ...base, content: liveBody },
outputAssets: writes.length > 0 ? writes : undefined,
inputAssets: live ? extractReads(editor.liveBodyAssets.assets) : undefined
}
])
}
const activePath = editor.activeDraftPath
const triggerDrafts = [...editor.triggerDrafts.values()]
const key = storageKey
const ws = workspace
const path = pipelineDraftPath
const hydrated = editor.hydratedFromDb
untrack(() => {
if (!hydrated) return
const isEmpty = serialized.length === 0 && !activePath
const isEmpty = serialized.length === 0 && !activePath && triggerDrafts.length === 0
const bundle: PipelineDraftBundle | undefined = isEmpty
? undefined
: { drafts: serialized, activeDraftPath: activePath }
: {
drafts: serialized,
activeDraftPath: activePath,
...(triggerDrafts.length > 0 ? { triggerDrafts } : {})
}
try {
if (typeof localStorage !== 'undefined') {
if (isEmpty) localStorage.removeItem(key)
else
localStorage.setItem(
key,
encodeState({ drafts: serialized, activeDraftPath: activePath })
)
else localStorage.setItem(key, encodeState(bundle))
}
} catch (e) {
console.warn('failed to mirror pipeline state', e)
}
const serializedBundle = bundle ? JSON.stringify(bundle) : undefined
const serializedBundle = persistKey(bundle)
if (serializedBundle === lastPersistedBundle) return
lastPersistedBundle = serializedBundle
if (!ws) return
@@ -450,10 +667,90 @@
})
</script>
<div class="h-full w-full" bind:clientWidth={containerWidth}>
{#snippet detailsBody(ws: string)}
{#if idleView && idlePane}
{@render idlePane()}
{:else}
<AssetGraphDetailsPane
{mode}
{onRequestEdit}
{canRunByPath}
{onRunByPath}
{onRunCascadeByPath}
{runFormInitialArgs}
{onRunFormArgsChange}
{inputsOnly}
{onRunInputs}
{onShowScript}
{resolveLocalScript}
{localScriptsVersion}
selection={activeDraft ? undefined : editor.selection}
selectionProducers={activeDraft ? [] : selectionProducers}
{producerScripts}
onEditScript={editProducerScript}
{producerTriggers}
{selectionColumnGraph}
{selectionColumnLoading}
{selectionColumnTruncated}
{selectionColumnFailed}
{selectionDbt}
{schemaCanEvolve}
{selectionForkMaterialization}
{schemaContractContext}
{runsRefreshKey}
{runsPendingJobId}
{activeRunnable}
{downstreamSubscribers}
onStartBoundedRun={canBoundedRunOpenScript &&
editor.openScriptPath &&
onStartBoundedRunForOpen
? () => onStartBoundedRunForOpen?.(editor.openScriptPath!)
: undefined}
{onRunCompleted}
{onTestStateChange}
{requestRemoveSignal}
{requestRunSignal}
{requestRunCascadeSignal}
{focusUploadSignal}
draftScript={activeDraft?.script}
draftOutputAssets={activeDraft?.outputAssets}
draftInputAssets={activeDraft?.inputAssets}
{onDraftPathChange}
onDraftMetaChange={editor.setDraftMeta}
{pathPrefix}
workspace={ws}
onAnnotationsChange={editor.handleAnnotationsChange}
onAssetsChange={editor.handleAssetsChange}
onContentChange={editor.handleContentChange}
onDraftPersist={editor.handleDraftPersist}
onclose={() => {
closePanelModal()
onClose()
}}
onHide={onTogglePanelHidden &&
(() => {
closePanelModal()
onTogglePanelHidden()
})}
{onDiscard}
{onDraftSaved}
{onPersistedSaved}
{onScriptRenamed}
{onScriptRemoved}
/>
{/if}
{/snippet}
<div class="relative h-full w-full" bind:clientWidth={containerWidth}>
<Splitpanes class="!h-full" horizontal={stacked}>
<Pane bind:size={leftPaneSize}>
<div class="relative h-full">
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div
class="relative h-full"
ondblclick={panelMode === 'modal' ? openPanelModalFromGraph : undefined}
onpointerdowncapture={panelMode === 'modal' ? noteSelectionBeforeClick : undefined}
onclick={panelMode === 'modal' ? openPanelModalIfReselected : undefined}
>
<AssetGraphCanvas
graph={displayGraph}
selection={effectiveSelection}
@@ -470,10 +767,15 @@
{onOpenWebhook}
{onOpenDataUpload}
{readyDataUploadPaths}
onselect={onSelect}
onselect={handleCanvasSelect}
{onAddScriptForAsset}
{onAddPipelineScript}
{onRunnableMenuRemove}
{onDeleteAssetUpstream}
{nodeFixes}
{assetDeleteVerb}
{onEdgeAction}
{onAddAssetTrigger}
{onRunProducer}
{validStartPaths}
{onStartBoundedRun}
@@ -482,6 +784,8 @@
{panToNodeId}
showMinimap={!stacked}
viewportFitKey={viewportFitKey ?? folder}
assetsOnlyToggle
assetsOnlyFolder={workspace ? `${workspace}/${folder}` : undefined}
/>
{#if boundBar}{@render boundBar()}{/if}
{#if mode === 'edit'}
@@ -509,66 +813,47 @@
{/if}
</div></Pane
>
{#if detailsPaneOpen && workspace}
{#if dockedPaneOpen && workspace}
<Pane bind:size={rightPaneSize} minSize={25}>
{#if idleView && idlePane}
{@render idlePane()}
{:else}
<AssetGraphDetailsPane
{mode}
{onRequestEdit}
{canRunByPath}
{onRunByPath}
{onRunCascadeByPath}
{runFormInitialArgs}
{onRunFormArgsChange}
{resolveLocalScript}
{localScriptsVersion}
selection={activeDraft ? undefined : editor.selection}
selectionProducers={activeDraft ? [] : selectionProducers}
{selectionColumnGraph}
{selectionColumnLoading}
{selectionColumnTruncated}
{selectionColumnFailed}
{selectionDbt}
{schemaCanEvolve}
{selectionForkMaterialization}
{schemaContractContext}
{runsRefreshKey}
{runsPendingJobId}
{activeRunnable}
{downstreamSubscribers}
onStartBoundedRun={canBoundedRunOpenScript &&
editor.openScriptPath &&
onStartBoundedRunForOpen
? () => onStartBoundedRunForOpen?.(editor.openScriptPath!)
: undefined}
{onRunCompleted}
{onTestStateChange}
{requestRemoveSignal}
{requestRunSignal}
{requestRunCascadeSignal}
{focusUploadSignal}
draftScript={activeDraft?.script}
draftOutputAssets={activeDraft?.outputAssets}
draftInputAssets={activeDraft?.inputAssets}
{pathPrefix}
{onDraftPathChange}
{workspace}
onAnnotationsChange={editor.handleAnnotationsChange}
onAssetsChange={editor.handleAssetsChange}
onContentChange={editor.handleContentChange}
onDraftPersist={editor.handleDraftPersist}
onclose={onClose}
onHide={onTogglePanelHidden}
{onDiscard}
{onDraftSaved}
{onPersistedSaved}
{onScriptRenamed}
{onScriptRemoved}
/>
{/if}
{@render detailsBody(workspace)}
</Pane>
{/if}
</Splitpanes>
{#if showNodeHint}
<!-- Above the event log's bar, which owns the canvas's bottom edge. -->
<div
class="pointer-events-none absolute bottom-10 left-3 z-30 flex items-center gap-1.5 text-xs text-hint"
>
<MousePointerClick size={13} />
{nodeHintText}
</div>
{/if}
</div>
<!-- Portalled to the host's overlay anchor (a session preview tab) so the modal covers
that tab rather than the whole page; Disposable owns its place in the overlay stack
and its Escape. -->
<Disposable bind:open={panelModalOpen} bind:this={panelDisposable}>
{#snippet children({ zIndex })}
{#if panelMode === 'modal' && panelModalOpen && workspace}
<Portal target={modalHost ?? 'body'} class="contents">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="{modalHost ? 'absolute' : 'fixed'} inset-0 flex justify-center px-2 py-6"
style="z-index: {zIndex}"
role="dialog"
>
<div class="absolute inset-0 bg-black/20" onclick={() => (panelModalOpen = false)}></div>
<div
class="relative flex w-full max-w-4xl flex-col overflow-hidden rounded-md border bg-surface shadow-xl"
>
<div class="min-h-0 flex-1 overflow-auto">
{@render detailsBody(workspace)}
</div>
</div>
</div>
</Portal>
{/if}
{/snippet}
</Disposable>
@@ -1,6 +1,7 @@
<script lang="ts" module>
import type { ComponentType } from 'svelte'
import type { SupportedLanguage } from '$lib/common'
import type { AssetKind, NewSchedule } from '$lib/gen'
export type PipelineInsertKind = {
// Machine-readable id; drives which right-column panel renders.
@@ -8,6 +9,9 @@
label: string
description?: string
icon?: ComponentType
// Its trigger is configured in its own editor right after the script is
// created, so the wizard's last button reads "Configure".
configuredAfterCreate?: boolean
}
export type PipelineInsertPick = {
@@ -22,7 +26,18 @@
// from this prompt via AI (using language + outputKind + the
// upstream input as context) instead of using the seeded template.
aiPrompt?: string
// Set by the schedule step when the picked kind is `schedule`.
schedule?: PipelineInsertSchedule
// Set by the asset step for the table output kinds, replacing the
// generated name.
outputAsset?: { kind: AssetKind; path: string }
}
export type PipelineInsertOptions = Pick<PipelineInsertPick, 'schedule' | 'outputAsset'>
// Everything but the target, which is the script being created. An empty
// `path` means the default `<script>_schedule`.
export type PipelineInsertSchedule = Omit<NewSchedule, 'script_path'>
</script>
<script lang="ts">
@@ -36,10 +51,28 @@
PIPELINE_OUTPUT_KINDS,
type PipelineOutputKind
} from './pipelineTemplates'
import CronInput from '$lib/components/CronInput.svelte'
import type { ScriptLang } from '$lib/gen'
import Label from '$lib/components/Label.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { CornerDownLeft, Sparkles } from 'lucide-svelte'
import TextInput, {
inputBaseClass,
inputBorderClass,
inputSizeClasses
} from '$lib/components/text_input/TextInput.svelte'
import DucklakePicker from '$lib/components/DucklakePicker.svelte'
import DatatablePicker from '$lib/components/DatatablePicker.svelte'
import Path from '$lib/components/Path.svelte'
import Section from '$lib/components/Section.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import DateTimeInput from '$lib/components/DateTimeInput.svelte'
import ScheduleAdvancedOptions, {
emptyScheduleAdvanced,
loadDefaultScheduleAdvanced,
scheduleAdvancedCfg
} from '$lib/components/triggers/schedules/ScheduleAdvancedOptions.svelte'
import { workspaceStore } from '$lib/stores'
import { ArrowLeft, ChevronDown, CornerDownLeft, Loader2, Sparkles } from 'lucide-svelte'
import { tick, untrack } from 'svelte'
import { arrowTabNav } from '$lib/attachments/arrowTabNav'
import { selectAndAdvanceTo } from '$lib/attachments/selectAndAdvanceTo'
@@ -49,8 +82,15 @@
pathPrefix?: string
defaultPathSuffix?: string
onPick: (pick: PipelineInsertPick) => void
trigger: import('svelte').Snippet
/** Receives whether the menu is open, e.g. to keep a hover state while it is. */
trigger: import('svelte').Snippet<[{ open: boolean }]>
placement?: 'bottom' | 'top' | 'left' | 'right'
/** Opens the menu each time it changes, for an entry point other than the trigger. */
openSignal?: number
/** With `openSignal`: only the schedule step, for this existing script; its
* confirm hands the schedule to `onSchedule` instead of creating a script. */
scheduleFor?: string
onSchedule?: (schedule: PipelineInsertSchedule) => void
}
let {
@@ -60,7 +100,10 @@
trigger: triggerSnippet,
placement = 'bottom',
defaultPathSuffix,
onPick
onPick,
openSignal,
scheduleFor,
onSchedule
}: Props = $props()
// When there's only one trigger kind, hide the Trigger column entirely
@@ -75,6 +118,107 @@
aiPrompt: undefined as undefined | string
})
let selected = $state(buildEmptySelected())
let menuOpen = $state(false)
$effect(() => {
if (!openSignal) return
untrack(() => {
if (scheduleFor) {
resetWizard()
selected.triggerId = 'schedule'
step = 1
config.schedulePath = `${scheduleFor}_schedule`
loadAdvancedDefaults()
void focusCurrentStep()
}
menuOpen = true
})
})
let selectedKind = $derived(kinds.find((k) => k.id === selected.triggerId))
// Steps after the first, each configuring something the picks call for.
type ConfigStep = 'schedule' | 'asset'
const NAMED_OUTPUT_KINDS: PipelineOutputKind[] = ['materialize', 'datatable', 'ducklake']
let configSteps = $derived.by<ConfigStep[]>(() => {
const steps: ConfigStep[] = []
if (selected.triggerId === 'schedule') steps.push('schedule')
if (selected.outputId && NAMED_OUTPUT_KINDS.includes(selected.outputId)) steps.push('asset')
return steps
})
// 0 is the trigger/language/output/path step; `n` is `configSteps[n - 1]`.
let step = $state(0)
let currentConfigStep = $derived(step > 0 ? configSteps[step - 1] : undefined)
let isLastStep = $derived(step >= configSteps.length)
const buildEmptyConfig = () => ({
schedule: '0 0 12 * *',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
cronVersion: 'v2',
validCron: true,
// Follows the script path until the Path field is edited.
schedulePath: '',
schedulePathDirty: false,
schedulePathError: '',
summary: '',
description: '',
pauseUntil: false,
pausedUntil: undefined as string | undefined,
advanced: emptyScheduleAdvanced(),
// The workspace's default handlers, loaded when the schedule step opens.
advancedDefaults: 'unloaded' as 'unloaded' | 'loading' | 'loaded',
// `store` is the data table or DuckLake catalog name. The table name
// follows the script name until the user types in it.
asset: undefined as
| undefined
| { kind: 'ducklake' | 'datatable'; store: string | undefined; table: string },
tableEdited: false
})
let config = $state(buildEmptyConfig())
// A new schedule starts from the workspace's default handlers, as in the schedule
// drawer. The step cannot be confirmed until they are in.
function loadAdvancedDefaults() {
const ws = $workspaceStore
if (!ws || config.advancedDefaults !== 'unloaded') return
const target = config
target.advancedDefaults = 'loading'
loadDefaultScheduleAdvanced(ws)
.then((d) => (target.advanced = d))
.catch(() => {})
.finally(() => (target.advancedDefaults = 'loaded'))
}
function resetWizard() {
selected = buildEmptySelected()
config = buildEmptyConfig()
step = 0
}
const TABLE_NAME_RE = /^[^/\s]+$/
// The path grammar of `Path.svelte`: word segments, no empty or trailing ones.
const SCRIPT_NAME_RE = /^[\w-]+(\/[\w-]+)*$/
let scriptNameValid = $derived(SCRIPT_NAME_RE.test(selected.scriptPath.trim()))
let assetValid = $derived(!!config.asset?.store && TABLE_NAME_RE.test(config.asset.table.trim()))
let stepValid = $derived(
currentConfigStep === 'schedule'
? config.validCron &&
!!config.schedule.trim() &&
!config.schedulePathError &&
config.advancedDefaults === 'loaded'
: currentConfigStep === 'asset'
? assetValid
: scriptNameValid && !!selected.triggerId && !!selected.language && !!selected.outputId
)
function enterAssetStep() {
const kind = selected.outputId === 'datatable' ? 'datatable' : 'ducklake'
if (config.asset?.kind !== kind) {
config.asset = { kind, store: 'main', table: '' }
config.tableEdited = false
}
if (!config.tableEdited && config.asset) {
config.asset.table = selected.scriptPath.trim().split('/').pop() ?? ''
}
}
// Refs to the column containers so Enter inside one column can hand
// focus off to the first tabbable in the next column.
@@ -104,16 +248,73 @@
return out
}
// A step change re-renders the popover body, dropping focus to <body>, where
// keystrokes (Enter, Cmd+Enter) no longer reach the step's key handlers.
let contentEl: HTMLElement | undefined = $state()
async function focusCurrentStep() {
await tick()
const target =
step === 0
? pathEl?.querySelector('input')
: contentEl?.querySelector<HTMLElement>('[data-autofocus] input')
target?.focus()
}
function goBack() {
step -= 1
void focusCurrentStep()
}
function scheduleConfig(): PipelineInsertSchedule {
return {
path: config.schedulePath,
schedule: config.schedule.trim(),
timezone: config.timezone,
cron_version: config.cronVersion,
summary: config.summary.trim() || undefined,
description: config.description,
paused_until: config.pauseUntil ? config.pausedUntil : undefined,
is_flow: false,
args: {},
...scheduleAdvancedCfg($state.snapshot(config.advanced))
}
}
function confirm(close: () => void) {
if (scheduleFor) {
if (!stepValid) return
onSchedule?.(scheduleConfig())
close()
return
}
const suffix = selected.scriptPath.trim()
if (!suffix || !selected.triggerId || !selected.language || !selected.outputId) return
if (!stepValid) return
if (!isLastStep) {
step += 1
if (configSteps[step - 1] === 'asset') enterAssetStep()
if (configSteps[step - 1] === 'schedule') {
loadAdvancedDefaults()
if (!config.schedulePathDirty) config.schedulePath = `${pathPrefix}${suffix}_schedule`
}
void focusCurrentStep()
return
}
const trimmedPrompt = selected.aiPrompt?.trim()
onPick({
kindId: selected.triggerId,
language: selected.language,
path: pathPrefix + suffix,
outputKind: selected.outputId,
aiPrompt: trimmedPrompt && trimmedPrompt.length > 0 ? trimmedPrompt : undefined
aiPrompt: trimmedPrompt && trimmedPrompt.length > 0 ? trimmedPrompt : undefined,
schedule: configSteps.includes('schedule') ? scheduleConfig() : undefined,
outputAsset:
configSteps.includes('asset') && config.asset
? {
kind: config.asset.kind as AssetKind,
path: `${config.asset.store}/${config.asset.table.trim()}`
}
: undefined
})
close()
}
@@ -123,9 +324,18 @@
enableFlyTransition
contentClasses={twMerge(
'p-0 bg-surface overflow-hidden relative transition-height',
showBottomPanel ? 'h-[26rem]' : 'h-[22rem]'
// The first step's columns (w-56 + w-48 + w-80), held across steps.
singleKind ? 'w-[32rem]' : 'w-[46rem]',
currentConfigStep === 'schedule'
? 'h-[27rem]'
: currentConfigStep === 'asset'
? 'h-[14rem]'
: showBottomPanel
? 'h-[26rem]'
: 'h-[22rem]'
)}
class="inline-block"
bind:isOpen={menuOpen}
usePointerDownOutside
floatingConfig={{
placement,
@@ -136,24 +346,30 @@
fitViewport: true,
overlap: false
}}
onClose={() => (selected = buildEmptySelected())}
onClose={resetWizard}
>
{#snippet trigger()}
{@render triggerSnippet?.()}
{@render triggerSnippet?.({ open: menuOpen })}
{/snippet}
{#snippet content({ close })}
<div class="flex flex-col h-full">
<div class={'flex flex-row transition-height divide-x overflow-y-scroll'}>
{@render topSection()}
</div>
<div
class={twMerge(
'flex flex-col gap-5 grow transition-height px-4 border-t',
showBottomPanel ? 'h-[14rem] py-4' : 'h-0'
)}
>
{@render bottomSection(close)}
</div>
<div class="h-full" bind:this={contentEl}>
{#if currentConfigStep}
{@render configStepSection(currentConfigStep, close)}
{:else}
<div class="flex flex-col h-full">
<div class={'flex flex-row transition-height divide-x overflow-y-scroll'}>
{@render topSection()}
</div>
<div
class={twMerge(
'flex flex-col gap-5 grow transition-height px-4 border-t',
showBottomPanel ? 'h-[14rem] py-4' : 'h-0'
)}
>
{@render bottomSection(close)}
</div>
</div>
{/if}
</div>
{/snippet}
</Popover>
@@ -170,7 +386,10 @@
<Button
variant="subtle"
btnClasses={'text-left'}
onClick={() => (selected.triggerId = k.id)}
onClick={() => {
selected.triggerId = k.id
if (k.id === 'schedule') loadAdvancedDefaults()
}}
selected={isSelected}
>
{#if k.icon}
@@ -264,14 +483,30 @@
{#snippet bottomSection(close: () => void)}
<Label label="Path">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div bind:this={pathEl} class="flex" onkeydown={selectAndAdvanceTo(() => aiPromptEl ?? saveEl)}>
<div
bind:this={pathEl}
class="flex"
onkeydown={(e) =>
(e.metaKey || e.ctrlKey) && e.key === 'Enter'
? (e.preventDefault(), e.stopPropagation(), confirm(close))
: selectAndAdvanceTo(() => aiPromptEl ?? saveEl)(e)}
>
<div
class="border rounded-md rounded-r-none border-r-0 text-xs w-fit shrink-0 whitespace-nowrap flex items-center px-2 text-secondary bg-surface-input"
>
{pathPrefix}
</div>
<TextInput bind:value={selected.scriptPath} class="rounded-l-none" />
<TextInput
bind:value={selected.scriptPath}
class="rounded-l-none"
error={!scriptNameValid && !!selected.scriptPath.trim()}
/>
</div>
{#if !scriptNameValid && selected.scriptPath.trim()}
<span class="text-2xs text-red-600 dark:text-red-400">
Letters, digits, <code>_</code> and <code>-</code>, in <code>/</code>-separated segments
</span>
{/if}
</Label>
{#if !$copilotInfo.workspaceDisabled}
<Label label="AI Prompt (optional)">
@@ -292,15 +527,217 @@
</div>
</Label>
{/if}
{@const hasAiPrompt = !!selected.aiPrompt?.trim()}
<div class="ml-auto" bind:this={saveEl}>
<Button
variant="accent"
btnClasses="w-fit"
disabled={!selected.scriptPath.trim()}
onClick={() => confirm(close)}
startIcon={hasAiPrompt ? { icon: Sparkles } : undefined}
shortCut={{ Icon: CornerDownLeft }}>{hasAiPrompt ? 'Generate' : 'Create'}</Button
>
{@render confirmButton(close)}
</div>
{/snippet}
{#snippet confirmButton(close: () => void)}
{@const hasAiPrompt = !!selected.aiPrompt?.trim()}
<Button
variant="accent"
btnClasses="w-fit"
disabled={!stepValid}
onClick={() => confirm(close)}
startIcon={isLastStep && hasAiPrompt ? { icon: Sparkles } : undefined}
shortCut={{ Icon: CornerDownLeft }}
>{scheduleFor
? 'Create schedule'
: !isLastStep
? step === 0
? 'Configure'
: 'Next'
: hasAiPrompt
? 'Generate'
: selectedKind?.configuredAfterCreate
? 'Configure'
: 'Create'}</Button
>
{/snippet}
{#snippet configStepSection(configStep: ConfigStep, close: () => void)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex flex-col h-full"
onkeydown={(e) => {
if (e.key !== 'Enter') return
const target = e.target as HTMLElement | null
// Cmd/Ctrl+Enter confirms from anywhere; bare Enter only from the step's own
// inputs, not from the Advanced fields (a textarea, pickers with their own Enter).
const bareEnterSubmits = target?.tagName === 'INPUT' && !target.closest('[data-advanced]')
if (e.metaKey || e.ctrlKey || bareEnterSubmits) {
e.preventDefault()
e.stopPropagation()
confirm(close)
}
}}
>
<div class="flex items-center gap-2 px-2 py-2 border-b">
{#if !scheduleFor}
<Button
variant="subtle"
unifiedSize="sm"
iconOnly
startIcon={{ icon: ArrowLeft }}
onClick={goBack}
title="Back"
/>
{/if}
<span class="text-xs font-semibold text-emphasis {scheduleFor ? 'pl-2' : ''}">
{scheduleFor
? `Schedule for ${scheduleFor}`
: configStep === 'schedule'
? 'Schedule'
: 'Output asset'}
</span>
{#if !scheduleFor}
<span class="text-2xs text-hint ml-auto pr-2"
>Step {step + 1} of {configSteps.length + 1}</span
>
{/if}
</div>
<div class="flex flex-col gap-2 grow overflow-auto p-4">
{#if configStep === 'schedule'}
<span class="text-2xs text-secondary">
Saved as a draft schedule and deployed along with the pipeline.
</span>
<div data-autofocus class="contents">
<CronInput
bind:schedule={config.schedule}
bind:timezone={config.timezone}
bind:validCRON={config.validCron}
bind:cronVersion={config.cronVersion}
/>
</div>
<div data-advanced class="mt-4">
<Section label="Advanced" collapsable initiallyCollapsed>
<div class="flex flex-col gap-6">
<Label label="Summary">
<TextInput
bind:value={config.summary}
inputProps={{ placeholder: 'Short summary to be displayed when listed' }}
/>
</Label>
<Label label="Path">
<Path
bind:path={config.schedulePath}
bind:dirty={config.schedulePathDirty}
bind:error={config.schedulePathError}
initialPath=""
checkInitialPathExistence
namePlaceholder="schedule"
kind="schedule"
/>
</Label>
<Label label="Description">
<TextInput
underlyingInputEl="textarea"
bind:value={config.description}
inputProps={{ placeholder: 'What this schedule does and how to use it' }}
/>
</Label>
<div class="flex flex-col gap-1">
<Toggle
options={{
right: 'Pause schedule until...',
rightTooltip:
'Pausing the schedule will program the next job to run as if the schedule starts at the time the pause is lifted, instead of now.'
}}
bind:checked={config.pauseUntil}
/>
{#if config.pauseUntil}
<DateTimeInput bind:value={config.pausedUntil} />
{/if}
</div>
<!-- Rendered once the workspace defaults are in, so they never
overwrite a handler picked while they load. -->
{#if config.advancedDefaults === 'loaded'}
<ScheduleAdvancedOptions
wsId={$workspaceStore}
itemKind="script"
canWrite
bind:errorHandlerSelected={config.advanced.errorHandlerSelected}
bind:errorHandlerPath={config.advanced.errorHandlerPath}
bind:errorHandleritemKind={config.advanced.errorHandleritemKind}
bind:errorHandlerExtraArgs={config.advanced.errorHandlerExtraArgs}
bind:wsErrorHandlerMuted={config.advanced.wsErrorHandlerMuted}
bind:failedTimes={config.advanced.failedTimes}
bind:failedExact={config.advanced.failedExact}
bind:recoveryHandlerSelected={config.advanced.recoveryHandlerSelected}
bind:recoveryHandlerPath={config.advanced.recoveryHandlerPath}
bind:recoveryHandlerItemKind={config.advanced.recoveryHandlerItemKind}
bind:recoveryHandlerExtraArgs={config.advanced.recoveryHandlerExtraArgs}
bind:recoveredTimes={config.advanced.recoveredTimes}
bind:successHandlerSelected={config.advanced.successHandlerSelected}
bind:successHandlerPath={config.advanced.successHandlerPath}
bind:successHandlerItemKind={config.advanced.successHandlerItemKind}
bind:successHandlerExtraArgs={config.advanced.successHandlerExtraArgs}
bind:retry={config.advanced.retry}
bind:dynamicSkipPath={config.advanced.dynamicSkipPath}
bind:tag={config.advanced.tag}
/>
{:else}
<div class="flex items-center gap-1.5 text-xs text-secondary">
<Loader2 size={12} class="animate-spin" />
Loading the workspace's default handlers…
</div>
{/if}
</div>
</Section>
</div>
{:else if configStep === 'asset' && config.asset}
<Label label="Name">
<div
class={twMerge(
inputBaseClass,
inputBorderClass({ error: !assetValid }),
inputSizeClasses.md,
'flex flex-row items-center gap-0 py-0 pl-2 pr-0'
)}
>
<span class="text-xs text-secondary shrink-0">{config.asset.kind}://</span>
{#if config.asset.kind === 'datatable'}
<DatatablePicker
bind:value={config.asset.store}
class="shrink-0"
selectInputClass="!border-none"
placeholder="data table"
useContentEditable
RightIcon={ChevronDown}
/>
{:else}
<DucklakePicker
bind:value={config.asset.store}
class="shrink-0"
selectInputClass="!border-none"
placeholder="catalog"
useContentEditable
RightIcon={ChevronDown}
/>
{/if}
<span class="text-sm text-secondary">/</span>
<div data-autofocus class="grow flex">
<TextInput
bind:value={config.asset.table}
class="!border-none grow"
inputProps={{
placeholder: 'table',
oninput: () => (config.tableEdited = true)
}}
/>
</div>
</div>
</Label>
<span class="text-2xs text-hint">
{config.asset.kind === 'datatable' ? 'Data table' : 'DuckLake catalog'}, then the table
name
</span>
{/if}
</div>
<div class="flex px-4 py-3 border-t">
<div class="ml-auto">
{@render confirmButton(close)}
</div>
</div>
</div>
{/snippet}
@@ -0,0 +1,174 @@
<!--
@component
The body every pipeline graph node shares: icon, a kind line ("Script · draft", "Data table"),
a title, and trailing chips. Node components add only what is theirs — the icon, the chips,
and the controls hung around the card.
-->
<script lang="ts">
import { NODE } from '$lib/components/graph/util'
import {
PIPELINE_NODE_EXTRA_ROW,
PIPELINE_NODE_HEADER,
PIPELINE_NODE_HEADER_CARD_PAD,
PIPELINE_NODE_HEIGHT
} from './assetGraphLayout'
import { twMerge } from 'tailwind-merge'
import type { Snippet } from 'svelte'
interface Props {
icon: Snippet
kindLabel: string
title: string
/** Hover text; the path, when the title is a summary. */
tooltip?: string
/** Scripts sit on the main surface, assets and triggers a step down; in the
* assets-only view, which has no scripts, assets take the main surface. */
surface?: 'primary' | 'secondary'
selected?: boolean
/** Not deployed yet: dashed edge. */
draft?: boolean
/** Feedback states, which recolor the whole card. `error` is a misconfigured
* node, styled like a failed flow step. */
tone?: 'danger' | 'error' | 'success' | 'running'
/** Makes the whole card a button. */
onclick?: (e: MouseEvent) => void
/** Between the icon and the text: a live status the eye should find first. */
leading?: Snippet
trailing?: Snippet
/** Defaults to `NODE.width`. */
width?: number
/** A line under the title, in the text column. */
subtitle?: Snippet
/** A tab on the card's top edge. Only on a card without `onclick`, since it
* may hold buttons of its own. */
header?: Snippet
/** The header's script is selected: its edge takes the selected colour. */
headerSelected?: boolean
/** The header's script has an error: its edge goes red. */
headerTone?: 'error'
}
let {
icon,
kindLabel,
title,
tooltip,
surface = 'secondary',
selected = false,
draft = false,
tone,
onclick,
leading,
trailing,
subtitle,
header,
headerSelected = false,
headerTone,
width = NODE.width
}: Props = $props()
const cardClass = $derived(
twMerge(
'flex items-center w-full text-left font-normal rounded-md drop-shadow-sm overflow-hidden border transition-colors',
surface === 'primary' ? 'bg-surface' : 'bg-surface-secondary',
'border-gray-400 dark:border-gray-600 hover:border-gray-500 dark:hover:border-gray-500',
// Like a flow step: a card with a background needs no edge, only its shadow,
// until one of the states below draws one.
surface === 'primary' &&
!draft &&
!selected &&
!tone &&
'border-transparent dark:border-transparent hover:border-transparent dark:hover:border-transparent',
draft && 'border-dashed border-gray-400 dark:border-gray-500',
// Like a selected flow step: the fill and the edge colour change, the edge's
// width and dashes stay.
// Each dark: variant too: the base edge sets its own dark colour, which a plain
// border class does not override.
selected &&
'bg-surface-accent-selected border-border-selected dark:border-border-selected hover:border-border-selected dark:hover:border-border-selected',
tone === 'danger' &&
'bg-red-50 dark:bg-red-900/30 border-dashed border-red-400 dark:border-red-500 hover:bg-red-100 dark:hover:bg-red-900/40',
tone === 'error' &&
(selected
? 'bg-red-200 dark:bg-red-600 border-red-500 dark:border-red-500 hover:border-red-500 dark:hover:border-red-500'
: 'bg-red-100 dark:bg-red-700 border-transparent dark:border-transparent hover:border-transparent dark:hover:border-transparent'),
tone === 'success' &&
'bg-green-50 dark:bg-green-900/30 border-green-500 dark:border-green-600',
tone === 'running' &&
'bg-amber-50 dark:bg-amber-900/30 border-amber-400 dark:border-amber-600 animate-pulse'
)
)
const minHeight = $derived(PIPELINE_NODE_HEIGHT + (subtitle ? PIPELINE_NODE_EXTRA_ROW : 0))
const toneText = $derived(
tone === 'danger'
? 'text-red-700 dark:text-red-400'
: tone === 'error'
? 'text-red-700 dark:text-red-200'
: tone === 'success'
? 'text-green-700 dark:text-green-400'
: undefined
)
</script>
{#snippet body()}
<span class="shrink-0 ml-3 mr-2.5 flex items-center">{@render icon()}</span>
{@render leading?.()}
<span class="flex flex-col min-w-0 flex-1 pr-1 py-1 leading-tight">
<span class={twMerge('text-2xs truncate text-secondary', toneText)}>{kindLabel}</span>
<span class={twMerge('text-xs truncate text-emphasis', selected && 'text-accent', toneText)}
>{title}</span
>
{#if subtitle}
<span class="flex items-center min-w-0 mt-1">{@render subtitle()}</span>
{/if}
</span>
{#if trailing}
<span class="flex items-center shrink-0 pr-2">{@render trailing()}</span>
{/if}
{/snippet}
{#if onclick}
<button
type="button"
{onclick}
class={cardClass}
style="width: {width}px; min-height: {minHeight}px;"
title={tooltip}
>
{@render body()}
</button>
{:else if header}
<!-- The header is a tab on the card's top edge, inset from its sides. -->
<div class="flex flex-col" style="width: {width}px;">
<div
class={twMerge(
'mx-2 flex items-stretch min-w-0 rounded-t-md overflow-hidden bg-surface border border-b-0 border-gray-200 dark:border-gray-700',
// Like an errored card: red, and a stronger red when selected.
headerTone === 'error'
? headerSelected
? 'border-red-500 dark:border-red-500'
: 'border-red-300 dark:border-red-600'
: headerSelected && 'border-border-selected dark:border-border-selected'
)}
style="height: {PIPELINE_NODE_HEADER}px;"
>
{@render header()}
</div>
<div
class={twMerge(cardClass, !selected && !tone && 'bg-surface-tertiary')}
style="min-height: {minHeight + PIPELINE_NODE_HEADER_CARD_PAD}px; padding-block: {PIPELINE_NODE_HEADER_CARD_PAD /
2}px;"
title={tooltip}
>
{@render body()}
</div>
</div>
{:else}
<div
class={cardClass}
style="width: {width}px; min-height: {minHeight}px;"
title={tooltip}
>
{@render body()}
</div>
{/if}
@@ -1,6 +1,6 @@
<script lang="ts">
import type { AssetKind, Script } from '$lib/gen'
import { CalendarClock, Loader2, Play, Upload, Zap } from 'lucide-svelte'
import { CalendarClock, Code2, Loader2, Play, Upload, Zap } from 'lucide-svelte'
import Button from '$lib/components/common/button/Button.svelte'
import HighlightCode from '$lib/components/HighlightCode.svelte'
import PipelineRunForm from './PipelineRunForm.svelte'
@@ -44,6 +44,12 @@
// "green / ready" state and seeds the whole-pipeline run. `isValid` is the
// full-schema validity (all required fields, not just the S3 file).
onArgsChange?: (path: string, args: Record<string, any>, isValid: boolean) => void
// Just the run form and the runs, without the source: what a data-upload
// click asks for. `onShowScript` switches to the script itself.
inputsOnly?: boolean
onShowScript?: () => void
// `onRun` previews a draft's own content, so a draft can run too.
runsDrafts?: boolean
}
let {
@@ -57,8 +63,12 @@
runsPendingJobId,
onRunCompleted,
initialArgs,
onArgsChange
onArgsChange,
inputsOnly = false,
onShowScript,
runsDrafts = false
}: Props = $props()
let runBlockedByDraft = $derived(isDraft && !runsDrafts)
// Seed once from the persisted args (see initialArgs). Cloned so the run
// form mutates its own copy, not the page's stored snapshot.
@@ -139,7 +149,7 @@
</script>
<div class="flex flex-col h-full">
{#if isDraft}
{#if runBlockedByDraft}
<div
class="shrink-0 flex items-center gap-2 px-3 py-1.5 text-2xs bg-amber-50 dark:bg-amber-900/30 border-b border-amber-200 dark:border-amber-900/60 text-amber-700 dark:text-amber-400"
>
@@ -168,13 +178,24 @@
{/if}
</span>
<div class="flex items-center gap-1.5">
{#if inputsOnly && onShowScript}
<Button
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: Code2 }}
onclick={onShowScript}
title="Show the script instead of its inputs"
>
Open script
</Button>
{/if}
{#if hasCascade}
<Button
variant="accent-secondary"
unifiedSize="sm"
startIcon={{ icon: running ? Loader2 : Zap }}
onclick={() => run(true)}
disabled={isDraft || running || !runValid}
disabled={runBlockedByDraft || running || !runValid}
title={`Run this script with these inputs, then run its ${downstreamCount} downstream pipeline script${downstreamCount === 1 ? '' : 's'} in order`}
>
Run + downstream
@@ -185,8 +206,8 @@
unifiedSize="sm"
startIcon={{ icon: running ? Loader2 : Play }}
onclick={() => run(false)}
disabled={isDraft || running || !runValid || !onRun}
title={isDraft
disabled={runBlockedByDraft || running || !runValid || !onRun}
title={runBlockedByDraft
? 'Deploy this draft to run it for real'
: hasCascade
? 'Run just this step — downstream scripts are not triggered'
@@ -210,9 +231,11 @@
{/key}
{/if}
</div>
<div class="flex-1 min-h-0 overflow-auto text-xs p-3">
<HighlightCode code={script.content ?? ''} language={script.language} />
</div>
{#if !inputsOnly}
<div class="flex-1 min-h-0 overflow-auto text-xs p-3">
<HighlightCode code={script.content ?? ''} language={script.language} />
</div>
{/if}
</div>
</Pane>
<Pane size={45} minSize={20}>
@@ -3,6 +3,8 @@
import { WorkspaceService } from '$lib/gen'
import { base } from '$lib/base'
import { CircleCheck, CircleAlert, Database, HardDrive, ArrowRight } from 'lucide-svelte'
import Alert from '$lib/components/common/alert/Alert.svelte'
import { Button } from '$lib/components/common'
interface Props {
workspace: string
@@ -73,48 +75,43 @@
</script>
{#if show}
<div
class="flex flex-col gap-3 rounded-lg border border-amber-300 dark:border-amber-900/60 bg-amber-50 dark:bg-amber-950/30 p-4"
>
<div class="flex items-start gap-2">
<CircleAlert size={18} class="text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<div class="flex flex-col gap-0.5">
<h3 class="text-sm font-semibold text-emphasis">Finish setting up pipelines</h3>
<p class="text-xs text-tertiary">
Pipelines materialize data into DuckLake tables backed by object storage. Configure the
following before your first pipeline can run.
</p>
</div>
<Alert type="warning" title="Finish setting up pipelines" hideIcon>
<div class="flex flex-col gap-3">
<p>
Pipelines materialize data into DuckLake tables backed by object storage. Configure the
following before your first pipeline can run.
</p>
<ul class="flex flex-col gap-2">
{#each steps as step (step.title)}
{@const Icon = step.icon}
<li class="flex items-center gap-3 rounded-md border bg-surface-tertiary px-3 py-2">
{#if step.done === true}
<CircleCheck size={16} class="text-green-600 dark:text-green-400 shrink-0" />
{:else if step.done === false}
<CircleAlert size={16} class="text-yellow-600 dark:text-yellow-400 shrink-0" />
{:else}
<Icon size={16} class="text-secondary shrink-0" />
{/if}
<div class="flex flex-col min-w-0 flex-1">
<span class="text-xs font-semibold text-emphasis">{step.title}</span>
<span class="text-2xs text-secondary">{step.description}</span>
</div>
{#if step.done !== true}
<Button
variant="default"
unifiedSize="sm"
href={step.href}
endIcon={{ icon: ArrowRight }}
wrapperClasses="shrink-0"
>
{step.cta}
</Button>
{:else}
<span class="shrink-0 text-2xs text-green-700 dark:text-green-400">Configured</span>
{/if}
</li>
{/each}
</ul>
</div>
<ul class="flex flex-col gap-2">
{#each steps as step (step.title)}
{@const Icon = step.icon}
<li class="flex items-center gap-3 rounded-md border bg-surface px-3 py-2">
{#if step.done === true}
<CircleCheck size={16} class="text-green-600 dark:text-green-400 shrink-0" />
{:else if step.done === false}
<CircleAlert size={16} class="text-amber-600 dark:text-amber-400 shrink-0" />
{:else}
<Icon size={16} class="text-tertiary shrink-0" />
{/if}
<div class="flex flex-col min-w-0 flex-1">
<span class="text-xs font-semibold text-primary">{step.title}</span>
<span class="text-2xs text-tertiary">{step.description}</span>
</div>
{#if step.done !== true}
<a
href={step.href}
class="shrink-0 inline-flex items-center gap-1 text-xs text-blue-500 hover:underline whitespace-nowrap"
>
{step.cta}
<ArrowRight size={12} />
</a>
{:else}
<span class="shrink-0 text-2xs text-green-700 dark:text-green-400">Configured</span>
{/if}
</li>
{/each}
</ul>
</div>
</Alert>
{/if}
@@ -1,18 +1,10 @@
<script lang="ts">
import { sendUserToast } from '$lib/utils'
import { workspaceStore } from '$lib/stores'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import type { NativeTriggerKind } from './types'
import {
EmailTriggerService,
GcpTriggerService,
KafkaTriggerService,
MqttTriggerService,
AmqpTriggerService,
NatsTriggerService,
PostgresTriggerService,
ScheduleService,
SqsTriggerService
} from '$lib/gen'
import type { NativeTriggerKind, PipelineTriggerDraftKind } from './types'
import type { Schedule } from '$lib/gen'
import { deleteTriggerRow } from './pipelineTriggerDraftDeploy'
import KafkaTriggerEditor from '$lib/components/triggers/kafka/KafkaTriggerEditor.svelte'
import MqttTriggerEditor from '$lib/components/triggers/mqtt/MqttTriggerEditor.svelte'
import AmqpTriggerEditor from '$lib/components/triggers/amqp/AmqpTriggerEditor.svelte'
@@ -22,6 +14,10 @@
import GcpTriggerEditor from '$lib/components/triggers/gcp/GcpTriggerEditor.svelte'
import EmailTriggerEditor from '$lib/components/triggers/email/EmailTriggerEditor.svelte'
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
import {
loadDefaultScheduleAdvanced,
scheduleAdvancedCfg
} from '$lib/components/triggers/schedules/ScheduleAdvancedOptions.svelte'
import WebhookEditor from '$lib/components/triggers/webhook/WebhookEditor.svelte'
import { setOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
@@ -110,6 +106,56 @@
}
}
/** The kind's full editor over a pipeline-local draft; Save hands the config back,
* and the drawer stays open when `onSave` refuses it.
* `saved` is the draft being edited (absent for a new one), applied over `defaults`. */
export function openTriggerDraft(
kind: PipelineTriggerDraftKind,
scriptPath: string,
defaults: Record<string, any>,
saved: Record<string, any> | undefined,
onSave: (cfg: Record<string, any>) => boolean
) {
switch (kind) {
case 'schedule': {
// A new draft starts from the workspace's default handlers, like a new
// schedule does; the drawer only applies them when it opens without one.
const open = (advanced: Record<string, any>) =>
scheduleEditor?.openDraft(
{
is_flow: false,
args: {},
...advanced,
...defaults,
...saved,
script_path: scriptPath
} as Schedule,
onSave
)
if (saved || !$workspaceStore) return open({})
return loadDefaultScheduleAdvanced($workspaceStore)
.then((a) => open(scheduleAdvancedCfg(a)))
.catch(() => open({}))
}
case 'kafka':
return kafkaEditor?.openDraft(scriptPath, defaults, saved, onSave)
case 'mqtt':
return mqttEditor?.openDraft(scriptPath, defaults, saved, onSave)
case 'amqp':
return amqpEditor?.openDraft(scriptPath, defaults, saved, onSave)
case 'nats':
return natsEditor?.openDraft(scriptPath, defaults, saved, onSave)
case 'postgres':
return postgresEditor?.openDraft(scriptPath, defaults, saved, onSave)
case 'sqs':
return sqsEditor?.openDraft(scriptPath, defaults, saved, onSave)
case 'gcp':
return gcpEditor?.openDraft(scriptPath, defaults, saved, onSave)
case 'email':
return emailEditor?.openDraft(scriptPath, defaults, saved, onSave)
}
}
// Open the webhook drawer (endpoint URLs + token flow). Caller owns the
// draft guard.
export function openWebhook(scriptPath: string) {
@@ -133,37 +179,7 @@
const { kind, path: triggerPath } = triggerDeleteTarget
triggerDeleteLoading = true
try {
switch (kind) {
case 'schedule':
await ScheduleService.deleteSchedule({ workspace, path: triggerPath })
break
case 'kafka':
await KafkaTriggerService.deleteKafkaTrigger({ workspace, path: triggerPath })
break
case 'mqtt':
await MqttTriggerService.deleteMqttTrigger({ workspace, path: triggerPath })
break
case 'amqp':
await AmqpTriggerService.deleteAmqpTrigger({ workspace, path: triggerPath })
break
case 'nats':
await NatsTriggerService.deleteNatsTrigger({ workspace, path: triggerPath })
break
case 'postgres':
await PostgresTriggerService.deletePostgresTrigger({ workspace, path: triggerPath })
break
case 'sqs':
await SqsTriggerService.deleteSqsTrigger({ workspace, path: triggerPath })
break
case 'gcp':
await GcpTriggerService.deleteGcpTrigger({ workspace, path: triggerPath })
break
case 'email':
await EmailTriggerService.deleteEmailTrigger({ workspace, path: triggerPath })
break
default:
return
}
if (!(await deleteTriggerRow(kind, triggerPath, workspace))) return
sendUserToast(`Deleted ${kind} trigger "${triggerPath}"`)
triggerDeleteTarget = undefined
onUpdate()
@@ -0,0 +1,37 @@
<script lang="ts">
import { twMerge } from 'tailwind-merge'
import { CheckCircle2, Loader2, XCircle } from 'lucide-svelte'
import type { RunnableRunState } from './activeRunnables.svelte'
let { runState: rs, class: className }: { runState: RunnableRunState; class?: string } = $props()
</script>
<div
class={twMerge(
'shrink-0 flex items-center gap-0.5 px-1 py-0.5 rounded-sm border',
rs.status === 'running'
? 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700'
: rs.status === 'success'
? 'bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300 border-emerald-300 dark:border-emerald-700'
: 'bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300 border-red-300 dark:border-red-700',
className
)}
title={`${
rs.status === 'running'
? 'Running now'
: rs.status === 'success'
? 'Last run succeeded'
: 'Last run failed'
}${rs.runs > 0 ? ` — ran ${rs.runs}× this session` : ''}`}
>
{#if rs.status === 'running'}
<Loader2 size={10} class="animate-spin" />
{:else if rs.status === 'success'}
<CheckCircle2 size={10} />
{:else}
<XCircle size={10} />
{/if}
{#if rs.runs > 0}
<span class="text-3xs leading-none tabular-nums">×{rs.runs}</span>
{/if}
</div>
@@ -1,10 +1,8 @@
<script lang="ts">
import { Handle, Position } from '@xyflow/svelte'
import {
CheckCircle2,
ChevronDown,
Code2,
EllipsisVertical,
GitBranch,
Layers,
Loader2,
@@ -15,17 +13,20 @@
Target,
Timer,
Trash2,
XCircle,
Zap
} from 'lucide-svelte'
import DbtIcon from '$lib/components/icons/DbtIcon.svelte'
import { twMerge } from 'tailwind-merge'
import { preventDefault, stopPropagation } from 'svelte/legacy'
import type { GraphUsageKind } from './types'
import type { RunnableRunState } from './activeRunnables.svelte'
import { parseDurationSecs } from './parsePipelineAnnotations'
import { NODE } from '$lib/components/graph/util'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import LanguageIcon from '$lib/components/common/languageIcons/LanguageIcon.svelte'
import PipelineNodeCard from './PipelineNodeCard.svelte'
import NodeActionsMenu from './NodeActionsMenu.svelte'
import NodeFixButton, { type NodeFix } from './NodeFixButton.svelte'
import NodeOnDot from './NodeOnDot.svelte'
import RunStateChip from './RunStateChip.svelte'
import type { ScriptLang } from '$lib/gen'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import type { Item } from '$lib/utils'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
@@ -35,8 +36,16 @@
interface Props {
data: {
/** What is wrong with the node, and how to fix it: shows the Fix pill. */
fix?: NodeFix
/** Starts dragging the top dot onto an asset to subscribe the script to it. */
onStartOnDrag?: (e: PointerEvent) => void
runnable_kind: GraphUsageKind
path: string
summary?: string
language?: ScriptLang
// Why the script is misconfigured, when it is: renders it red.
error?: string
in_pipeline?: boolean
partition_kind?: 'daily' | 'hourly' | 'weekly' | 'monthly' | 'dynamic'
freshness?: string
@@ -101,10 +110,11 @@
}
let { data, selected = false }: Props = $props()
// The icon alone conveys "script" vs "flow"; the uppercase kind label was
// visually noisy and redundant. Tooltip on hover surfaces the path in
// full when truncated.
let Icon = $derived(data.runnable_kind === 'flow' ? GitBranch : Code2)
let kindLabel = $derived(
data.runnable_kind === 'flow' ? 'Flow' : data.dbt ? 'dbt project' : 'Script'
)
// The title shows the summary when there is one, so the tooltip keeps the path.
let nodeTooltip = $derived(
data.unsaved
? `${data.path} (unsaved draft)`
@@ -114,7 +124,6 @@
)
let hover = $state(false)
let menuOpen = $state(false)
let running = $state(false)
// Amber "computing now" surface, gated so only the replay player lights it up.
@@ -214,131 +223,106 @@
<!--
Mirrors the flow editor's step styling (getNodeColorClasses): muted
surface-tertiary fill with a quiet gray border, accent only for the
selected state, dashes for unsaved drafts. The 2px dashed stroke is
necessary for the dash gaps to be readable; 1px disappears into the
fill.
selected state, dashes for unsaved drafts — 1px, like the trigger
nodes' dashed outline, so every draft node reads the same.
-->
<div
class={twMerge(
'flex items-center rounded-md drop-shadow-sm overflow-hidden border transition-colors',
'bg-surface border-gray-400 dark:border-gray-600 hover:border-gray-500 dark:hover:border-gray-500',
selected && 'bg-surface-accent-selected border-border-selected',
data.unsaved && 'border-2 border-dashed border-gray-400 dark:border-gray-500',
computingNow &&
'bg-amber-50 dark:bg-amber-900/30 border-amber-400 dark:border-amber-600 animate-pulse'
)}
style="width: {NODE.width}px; min-height: {NODE.height}px;"
title={nodeTooltip}
>
<Icon size={14} class={`shrink-0 ml-2 mr-2 ${selected ? 'text-accent' : 'text-secondary'}`} />
<span class="flex-1 min-w-0 pr-1 py-0.5 text-2xs font-mono text-emphasis truncate">
{data.path}
</span>
<!-- Annotation chips share one neutral treatment — the icon carries
the meaning (colors are reserved for feedback, per the brand
guidelines). Only the freshness chip (when it has a verdict)
and the run-state chip below use semantic colors. -->
{#if data.partition_kind}
<div
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary"
title={`// partitioned ${data.partition_kind}`}
>
<Layers size={10} />
<span class="text-3xs leading-none">{data.partition_kind}</span>
</div>
{/if}
<!-- Freshness is the one annotation chip that carries feedback (a
fresh/stale verdict against real run history), so like the
run-state chip it uses semantic colors: emerald = within window,
amber = stale. Neutral when there's no verdict (drafts, bad
window value). -->
{#if data.freshness}
<div
class={twMerge(
'shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm',
freshnessState === 'fresh'
? 'bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300'
: freshnessState === 'stale'
? 'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300'
: 'bg-surface-secondary text-secondary'
)}
title={freshnessTooltip}
>
<Timer size={10} />
<span class="text-3xs leading-none">{data.freshness}</span>
</div>
{/if}
{#if data.tag}
<div
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary"
title={`// tag ${data.tag}`}
>
<Tag size={10} />
<span class="text-3xs leading-none">{data.tag}</span>
</div>
{/if}
{#if data.retry}
{@const r = data.retry}
<div
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary"
title={`// retry ${r.count}${r.delay ? ` ${r.delay}` : ''}`}
>
<RotateCw size={10} />
<span class="text-3xs leading-none">×{r.count}</span>
</div>
{/if}
{#if data.macros && data.macros.length > 0}
<div
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary"
title={`// macros — defines ${data.macros.length} macro${data.macros.length > 1 ? 's' : ''}:\n${data.macros
.map((m) => `• ${m.name}(${m.params})${m.is_table ? ' → table' : ''}`)
.join('\n')}`}
>
<SquareFunction size={10} />
<span class="text-3xs leading-none">×{data.macros.length}</span>
</div>
{/if}
{#if data.dbt}
<div
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300"
title={`dbt project — materializes ${data.dbt.model_count} model${data.dbt.model_count === 1 ? '' : 's'}`}
>
<DbtIcon width={10} height={10} />
<span class="text-3xs leading-none">×{data.dbt.model_count}</span>
</div>
{/if}
{#if data.runState}
{@const rs = data.runState}
<div
class={twMerge(
'shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm',
rs.status === 'running'
? 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300'
: rs.status === 'success'
? 'bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300'
: 'bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300'
)}
title={`${
rs.status === 'running'
? 'Running now'
: rs.status === 'success'
? 'Last run succeeded'
: 'Last run failed'
}${rs.runs > 0 ? ` — ran ${rs.runs}× this session` : ''}`}
>
{#if rs.status === 'running'}
<Loader2 size={10} class="animate-spin" />
{:else if rs.status === 'success'}
<CheckCircle2 size={10} />
<NodeActionsMenu items={menuItems} {hover}>
<PipelineNodeCard
surface="primary"
kindLabel="{kindLabel}{data.unsaved ? ' · draft' : ''}"
title={data.summary || data.path}
tooltip={data.error ? `${data.path}: ${data.error}` : nodeTooltip}
{selected}
draft={data.unsaved}
tone={computingNow ? 'running' : data.error ? 'error' : undefined}
>
{#snippet icon()}
{#if data.language && data.runnable_kind === 'script'}
<LanguageIcon lang={data.language} width={14} height={14} />
{:else}
<XCircle size={10} />
<Icon size={14} class={selected ? 'text-accent' : 'text-secondary'} />
{/if}
{#if rs.runs > 0}
<span class="text-3xs leading-none tabular-nums">×{rs.runs}</span>
{/snippet}
{#snippet trailing()}
<!-- Annotation chips share one neutral treatment — the icon carries
the meaning (colors are reserved for feedback, per the brand
guidelines). Only the freshness chip (when it has a verdict)
and the run-state chip below use semantic colors. -->
{#if data.partition_kind}
<div
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary"
title={`// partitioned ${data.partition_kind}`}
>
<Layers size={10} />
<span class="text-3xs leading-none">{data.partition_kind}</span>
</div>
{/if}
</div>
{/if}
</div>
<!-- Freshness is the one annotation chip that carries feedback (a
fresh/stale verdict against real run history), so like the
run-state chip it uses semantic colors: emerald = within window,
amber = stale. Neutral when there's no verdict (drafts, bad
window value). -->
{#if data.freshness}
<div
class={twMerge(
'shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm',
freshnessState === 'fresh'
? 'bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300'
: freshnessState === 'stale'
? 'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300'
: 'bg-surface-secondary text-secondary'
)}
title={freshnessTooltip}
>
<Timer size={10} />
<span class="text-3xs leading-none">{data.freshness}</span>
</div>
{/if}
{#if data.tag}
<div
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary"
title={`// tag ${data.tag}`}
>
<Tag size={10} />
<span class="text-3xs leading-none">{data.tag}</span>
</div>
{/if}
{#if data.retry}
{@const r = data.retry}
<div
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary"
title={`// retry ${r.count}${r.delay ? ` ${r.delay}` : ''}`}
>
<RotateCw size={10} />
<span class="text-3xs leading-none">×{r.count}</span>
</div>
{/if}
{#if data.macros && data.macros.length > 0}
<div
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-surface-secondary text-secondary"
title={`// macros — defines ${data.macros.length} macro${data.macros.length > 1 ? 's' : ''}:\n${data.macros
.map((m) => `• ${m.name}(${m.params})${m.is_table ? ' → table' : ''}`)
.join('\n')}`}
>
<SquareFunction size={10} />
<span class="text-3xs leading-none">×{data.macros.length}</span>
</div>
{/if}
{#if data.dbt}
<div
class="shrink-0 flex items-center gap-0.5 px-1 py-0.5 mr-1 rounded-sm bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300"
title={`dbt project — materializes ${data.dbt.model_count} model${data.dbt.model_count === 1 ? '' : 's'}`}
>
<DbtIcon width={10} height={10} />
<span class="text-3xs leading-none">×{data.dbt.model_count}</span>
</div>
{/if}
{#if data.runState}
<RunStateChip runState={data.runState} class="mr-1" />
{/if}
{/snippet}
</PipelineNodeCard>
</NodeActionsMenu>
{#if showRun}
<!-- Run button revealed on hover/select. Matches the placement, size,
and behaviour of AssetNode's run button so both nodes feel
@@ -463,38 +447,12 @@
{/if}
</div>
{/if}
{#if menuItems.length > 0}
<!--
Hover-revealed action menu, top-right of the node. Mirrors the
FlowModuleSchemaItem pattern: position the trigger button just
outside the node frame, only render it on hover (or while the
menu is open) so the canvas isn't visually cluttered when idle.
The pointerdown stop+preventDefault keeps xyflow from selecting
the node when the user is reaching for the menu.
-->
<div class="absolute -top-2 -right-2 h-7 p-1 min-w-7" style="will-change: transform;">
<DropdownV2
items={menuItems}
placement="bottom-end"
bind:open={menuOpen}
fixedHeight={false}
usePointerDownOutside
>
{#snippet buttonReplacement()}
<button
class={twMerge(
'center-center p-1 text-secondary shadow-sm bg-surface duration-0 hover:bg-surface-tertiary',
hover || menuOpen ? 'block' : '!hidden',
'shadow-md rounded-md'
)}
onpointerdown={stopPropagation(preventDefault(() => {}))}
title="Actions"
>
<EllipsisVertical size={12} />
</button>
{/snippet}
</DropdownV2>
{#if data.onStartOnDrag}
<NodeOnDot onStart={data.onStartOnDrag} visible={hover} />
{/if}
{#if data.fix}
<div class="absolute left-1/2 -bottom-3 -translate-x-1/2 z-10">
<NodeFixButton fix={data.fix} />
</div>
{/if}
</div>
@@ -24,46 +24,36 @@
type Presentation = {
icon: ComponentType
label: string
// Tailwind class fragments for bg + border + text accent.
bg: string
border: string
borderUnsaved: string
iconText: string
}
// One muted treatment for every source kind — colors are meaningful, not
// One muted icon color for every source kind — colors are meaningful, not
// decorative (brand guidelines), so the kind is carried by the icon and
// label while bg/border stay on the surface/gray scale like the flow
// editor's nodes. Red stays reserved for the missing-trigger state.
const MUTED = {
bg: 'bg-surface-secondary',
border: 'outline-gray-400 dark:outline-gray-600',
borderUnsaved: 'outline-dashed outline-gray-400 dark:outline-gray-500',
iconText: 'text-secondary'
}
// label. Red stays reserved for the missing-trigger state.
const MUTED = { iconText: 'text-secondary' }
export const TRIGGER_NODE_STYLE: Record<TriggerNodeKind, Presentation> = {
schedule: { icon: Clock, label: 'schedule', ...MUTED },
webhook: { icon: Webhook, label: 'webhook', ...MUTED },
email: { icon: Mail, label: 'email', ...MUTED },
kafka: { icon: Zap, label: 'kafka', ...MUTED },
mqtt: { icon: Radio, label: 'mqtt', ...MUTED },
amqp: { icon: Radio, label: 'amqp', ...MUTED },
nats: { icon: MessageSquare, label: 'nats', ...MUTED },
postgres: { icon: Database, label: 'postgres', ...MUTED },
sqs: { icon: Send, label: 'sqs', ...MUTED },
gcp: { icon: CloudCog, label: 'gcp', ...MUTED },
data_upload: { icon: Upload, label: 'data upload', ...MUTED }
schedule: { icon: Clock, label: 'Schedule', ...MUTED },
webhook: { icon: Webhook, label: 'Webhook', ...MUTED },
email: { icon: Mail, label: 'Email', ...MUTED },
kafka: { icon: Zap, label: 'Kafka', ...MUTED },
mqtt: { icon: Radio, label: 'MQTT', ...MUTED },
amqp: { icon: Radio, label: 'AMQP', ...MUTED },
nats: { icon: MessageSquare, label: 'NATS', ...MUTED },
postgres: { icon: Database, label: 'Postgres', ...MUTED },
sqs: { icon: Send, label: 'SQS', ...MUTED },
gcp: { icon: CloudCog, label: 'GCP Pub/Sub', ...MUTED },
data_upload: { icon: Upload, label: 'Data upload', ...MUTED }
}
</script>
<script lang="ts">
import { Handle, Position } from '@xyflow/svelte'
import { NODE } from '$lib/components/graph/util'
import { twMerge } from 'tailwind-merge'
import { AlertTriangle, CheckCircle2, EllipsisVertical, Target, Trash2 } from 'lucide-svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { stopPropagation, preventDefault } from 'svelte/legacy'
import PipelineNodeCard from './PipelineNodeCard.svelte'
import NodeActionsMenu from './NodeActionsMenu.svelte'
import NodeFixButton, { type NodeFix } from './NodeFixButton.svelte'
import { describeSchedule } from '$lib/utils/describeCron'
import { AlertTriangle, CheckCircle2, Target, Trash2 } from 'lucide-svelte'
import type { Item } from '$lib/utils'
interface Props {
@@ -74,16 +64,22 @@
// owning script is in `runnable_path` (used by the title and the
// "+ Create trigger" drawer hook passed by the page).
data: {
/** What is wrong with the node, and how to fix it: shows the Fix pill. */
fix?: NodeFix
kind: TriggerNodeKind
ref: string
unsaved?: boolean
missing?: boolean
// A pipeline-local schedule draft: `ref` is the path it deploys to.
// Edit/delete go through the same callbacks, which the page routes
// to the draft instead of a trigger row.
draft?: boolean
// The cron, for a schedule: the node names its cadence instead of "Schedule".
schedule?: string
timezone?: string
// Shown as the title over the trigger's path, when the trigger has one.
summary?: string
runnable_path?: string
// True iff the target script is still a draft (no DB row yet).
// Drives the "(after draft save)" hint on the missing
// placeholder, since the page-level handler refuses to open the
// create drawer until the script is deployed.
runnable_unsaved?: boolean
// Page-supplied dispatcher that opens the matching native
// trigger drawer with `script_path` pre-filled. When absent
// (e.g. schedule, or a kind without an editor) the placeholder is
@@ -128,7 +124,6 @@
let { data }: Props = $props()
let hover = $state(false)
let menuOpen = $state(false)
let style = $derived(TRIGGER_NODE_STYLE[data.kind])
// Webhooks are never genuinely "missing" — every deployed runnable has an
@@ -143,7 +138,7 @@
let Icon = $derived(displayMissing ? AlertTriangle : style.icon)
let missingTitle = $derived(
displayMissing
? `Missing ${style.label} trigger: ${data.runnable_path ?? ''} declares \`// on ${style.label}\` but no ${style.label} trigger targets it. Click to create one, or remove the annotation.`
? `Missing ${style.label} trigger: ${data.runnable_path ?? ''} declares \`// on ${data.kind}\` but no ${style.label} trigger targets it. Click to create one, or remove the annotation.`
: undefined
)
// Webhook gets a drawer (URLs + webhook-specific token creation) rather
@@ -200,7 +195,7 @@
...(canDelete
? [
{
displayName: 'Delete…',
displayName: data.draft ? 'Discard draft' : 'Delete…',
icon: Trash2,
type: 'delete' as const,
action: () => {
@@ -212,6 +207,75 @@
: [])
])
// A schedule names its cadence ("Every day at 4:00", or the cron itself
// when it has no plain reading); every other kind names its kind.
let kindName = $derived(
data.kind === 'schedule' && data.schedule
? (describeSchedule(data.schedule, data.timezone) ?? `Schedule (${data.schedule})`)
: style.label
)
// One card for every state: which label, title and click the node gets.
let card = $derived.by(
(): {
kindLabel: string
title: string
tooltip?: string
draft: boolean
tone?: 'danger' | 'success'
onclick?: () => void
} => {
const unsaved = !!data.unsaved
if (canCreate)
return {
kindLabel: `${style.label} · missing`,
title: 'Click to create',
tooltip: missingTitle,
draft: false,
tone: 'danger',
onclick: handleMissingClick
}
if (canEdit)
return {
kindLabel: `${kindName}${data.draft ? ' · draft' : unsaved ? ' · unsaved' : ''}`,
title: data.summary || data.ref,
tooltip: data.draft
? `Draft ${style.label}: ${data.ref} — created when you save the pipeline. Click to edit.`
: `Edit ${style.label} trigger: ${data.ref}`,
draft: unsaved,
onclick: handleEditClick
}
if (canOpenWebhook)
return {
kindLabel: style.label,
title: 'URLs & token',
tooltip: `Webhook endpoint for ${data.runnable_path ?? ''} — click to view URLs and create a token`,
draft: unsaved,
onclick: handleWebhookClick
}
if (canOpenDataUpload)
return {
kindLabel: `${style.label}${dataUploadReady ? ' · ready' : ''}`,
title: dataUploadReady ? 'File staged' : 'Upload & run',
tooltip: dataUploadReady
? `Data upload for ${data.runnable_path ?? ''} — a file is staged; the pipeline is ready to run. Click to change it.`
: `Data upload for ${data.runnable_path ?? ''} — click to open the run form and upload a file`,
draft: !dataUploadReady && unsaved,
tone: dataUploadReady ? 'success' : undefined,
onclick: handleDataUploadClick
}
return {
kindLabel: `${displayMissing ? style.label : kindName}${displayMissing ? ' · missing' : unsaved ? ' · unsaved' : ''}`,
title: displayMissing ? 'no trigger row' : data.summary || data.ref,
tooltip:
missingTitle ??
(unsaved ? `Unsaved ${style.label}: ${data.ref}` : `${style.label}: ${data.ref}`),
draft: unsaved && !displayMissing,
tone: displayMissing ? 'danger' : undefined
}
}
)
function handleMissingClick() {
if (!canCreate || !data.runnable_path || !data.onCreateMissingTrigger) return
data.onCreateMissingTrigger(data.kind as NativeTriggerKind, data.runnable_path)
@@ -235,195 +299,33 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="relative" onmouseenter={() => (hover = true)} onmouseleave={() => (hover = false)}>
{#if canCreate}
<!-- Same affordance as the asset's downstream + button: render the
whole node as a button so the cursor + hover + click is obvious
without depending on svelte-flow's wrapper-level handlers. -->
<button
type="button"
onclick={handleMissingClick}
class={twMerge(
'flex items-center rounded-md drop-shadow-sm overflow-hidden outline outline-1 w-full text-left',
'bg-red-50 dark:bg-red-900/30 outline-dashed outline-red-400 dark:outline-red-500',
'hover:bg-red-100 dark:hover:bg-red-900/40 transition-colors'
)}
style="width: {NODE.width}px; min-height: {NODE.height}px;"
title={missingTitle}
<NodeActionsMenu items={menuItems} {hover}>
<PipelineNodeCard
kindLabel={card.kindLabel}
title={card.title}
tooltip={card.tooltip}
draft={card.draft}
tone={card.tone}
onclick={card.onclick}
>
<Icon size={14} class="shrink-0 ml-2 mr-2 text-red-600 dark:text-red-400" />
<div class="flex flex-col min-w-0 flex-1 pr-2 py-0.5 leading-tight">
<span class="text-3xs uppercase tracking-wide truncate text-red-700 dark:text-red-400">
{style.label} · missing
</span>
<span class="text-2xs font-mono truncate text-red-700 dark:text-red-400">
{data.runnable_unsaved ? 'Click to create (after draft save)' : 'Click to create'}
</span>
</div>
</button>
{:else if canEdit}
<!-- Mirrors the missing-trigger pattern: render the whole node as a
button so clicks open the editor drawer reliably (don't rely on
svelte-flow's onnodeclick wiring). -->
<button
type="button"
onclick={handleEditClick}
class={twMerge(
'flex items-center rounded-md drop-shadow-sm overflow-hidden outline outline-1 w-full text-left',
style.bg,
data.unsaved ? `opacity-80 ${style.borderUnsaved}` : style.border,
'hover:brightness-95 dark:hover:brightness-110 transition-[filter]'
)}
style="width: {NODE.width}px; min-height: {NODE.height}px;"
title={`Edit ${style.label} trigger: ${data.ref}`}
>
<Icon size={14} class={`shrink-0 ml-2 mr-2 ${style.iconText}`} />
<div class="flex flex-col min-w-0 flex-1 pr-2 py-0.5 leading-tight">
<span class="text-3xs uppercase tracking-wide truncate text-tertiary">
{style.label}{data.unsaved ? ' · unsaved' : ''}
</span>
<span class="text-2xs font-mono truncate text-emphasis">
{data.ref}
</span>
</div>
</button>
{:else if canOpenWebhook}
<!-- Webhook endpoint: implicit (no trigger row), so the node opens a
drawer with the URLs + webhook-specific token creation instead of
an editor. Styled like an attached trigger, never the red
"missing" state. -->
<button
type="button"
onclick={handleWebhookClick}
class={twMerge(
'flex items-center rounded-md drop-shadow-sm overflow-hidden outline outline-1 w-full text-left',
style.bg,
data.unsaved ? `opacity-80 ${style.borderUnsaved}` : style.border,
'hover:brightness-95 dark:hover:brightness-110 transition-[filter]'
)}
style="width: {NODE.width}px; min-height: {NODE.height}px;"
title={`Webhook endpoint for ${data.runnable_path ?? ''} — click to view URLs and create a token`}
>
<Icon size={14} class={`shrink-0 ml-2 mr-2 ${style.iconText}`} />
<div class="flex flex-col min-w-0 flex-1 pr-2 py-0.5 leading-tight">
<span class="text-3xs uppercase tracking-wide truncate text-tertiary">
{style.label}
</span>
<span class="text-2xs font-mono truncate text-emphasis"> URLs & token </span>
</div>
</button>
{:else if canOpenDataUpload}
<!-- Data upload: UI-first entry point (no trigger row). Clicking the
node opens the target script's run form, where the auto-generated
S3 picker lets the user upload a file and run the pipeline. Goes
green once a file is staged (ready), so "Run pipeline" can proceed;
until then it stays neutral with an "upload a file" prompt. Never
the red "missing" state. -->
<button
type="button"
onclick={handleDataUploadClick}
class={twMerge(
'flex items-center rounded-md drop-shadow-sm overflow-hidden outline outline-1 w-full text-left',
dataUploadReady
? 'bg-green-50 dark:bg-green-900/30 outline-green-500 dark:outline-green-600'
: style.bg,
dataUploadReady ? '' : data.unsaved ? `opacity-80 ${style.borderUnsaved}` : style.border,
'hover:brightness-95 dark:hover:brightness-110 transition-[filter]'
)}
style="width: {NODE.width}px; min-height: {NODE.height}px;"
title={dataUploadReady
? `Data upload for ${data.runnable_path ?? ''} — a file is staged; the pipeline is ready to run. Click to change it.`
: `Data upload for ${data.runnable_path ?? ''} — click to open the run form and upload a file`}
>
<DataUploadIcon
size={14}
class={`shrink-0 ml-2 mr-2 ${dataUploadReady ? 'text-green-600 dark:text-green-400' : style.iconText}`}
/>
<div class="flex flex-col min-w-0 flex-1 pr-2 py-0.5 leading-tight">
<span
class={twMerge(
'text-3xs uppercase tracking-wide truncate',
dataUploadReady ? 'text-green-700 dark:text-green-400' : 'text-tertiary'
)}
>
{style.label}{dataUploadReady ? ' · ready' : ''}
</span>
<span
class={twMerge(
'text-2xs font-mono truncate',
dataUploadReady ? 'text-green-700 dark:text-green-400' : 'text-emphasis'
)}
>
{dataUploadReady ? 'File staged' : 'Upload & run'}
</span>
</div>
</button>
{:else}
<div
class={twMerge(
'flex items-center rounded-md drop-shadow-sm overflow-hidden outline outline-1',
displayMissing
? 'bg-red-50 dark:bg-red-900/30 outline-dashed outline-red-400 dark:outline-red-500'
: style.bg,
displayMissing ? '' : data.unsaved ? `opacity-80 ${style.borderUnsaved}` : style.border
)}
style="width: {NODE.width}px; min-height: {NODE.height}px;"
title={missingTitle ??
(data.unsaved ? `Unsaved ${style.label}: ${data.ref}` : `${style.label}: ${data.ref}`)}
>
<Icon
size={14}
class={`shrink-0 ml-2 mr-2 ${displayMissing ? 'text-red-600 dark:text-red-400' : style.iconText}`}
/>
<div class="flex flex-col min-w-0 flex-1 pr-2 py-0.5 leading-tight">
<span
class={twMerge(
'text-3xs uppercase tracking-wide truncate',
displayMissing ? 'text-red-700 dark:text-red-400' : 'text-tertiary'
)}
>
{style.label}{displayMissing ? ' · missing' : data.unsaved ? ' · unsaved' : ''}
</span>
<span
class={twMerge(
'text-2xs font-mono truncate',
displayMissing ? 'text-red-700 dark:text-red-400' : 'text-emphasis'
)}
>
{displayMissing ? 'no trigger row' : data.ref}
</span>
</div>
</div>
{/if}
{#if menuItems.length > 0}
<!-- Hover-revealed kebab menu (Delete only for now). Mirrors the
RunnableNode pattern: positioned just outside the top-right of
the node, rendered only on hover or while the menu is open so
the canvas stays clean at rest. `pointerdown` is stopped so
svelte-flow doesn't kick off node selection / drag when the
user reaches for the menu. -->
<div class="absolute -top-2 -right-2 h-7 p-1 min-w-7" style="will-change: transform;">
<DropdownV2
items={menuItems}
placement="bottom-end"
bind:open={menuOpen}
fixedHeight={false}
usePointerDownOutside
>
{#snippet buttonReplacement()}
<button
class={twMerge(
'center-center p-1 text-secondary shadow-sm bg-surface duration-0 hover:bg-surface-tertiary',
hover || menuOpen ? 'block' : '!hidden',
'shadow-md rounded-md'
)}
onpointerdown={stopPropagation(preventDefault(() => {}))}
title="Actions"
>
<EllipsisVertical size={12} />
</button>
{/snippet}
</DropdownV2>
{#snippet icon()}
{#if canOpenDataUpload}
<DataUploadIcon
size={14}
class={dataUploadReady ? 'text-green-600 dark:text-green-400' : style.iconText}
/>
{:else}
<Icon
size={14}
class={displayMissing || canCreate ? 'text-red-600 dark:text-red-400' : style.iconText}
/>
{/if}
{/snippet}
</PipelineNodeCard>
</NodeActionsMenu>
{#if data.fix}
<div class="absolute left-1/2 -bottom-3 -translate-x-1/2 z-10">
<NodeFixButton fix={data.fix} />
</div>
{/if}
</div>
@@ -147,4 +147,18 @@ describe('layoutAssetGraph (tidy-tree with join breaks)', () => {
expect(pos.get(id)!.y).toBeGreaterThan(anchor.y)
}
})
it('packs narrow nodes by their own width', () => {
const pos = layoutAssetGraph({
nodes: [
{ ...n('p'), width: 100 },
{ ...n('l'), width: 100 },
{ ...n('r'), width: 180 }
],
edges: [e('p', 'l'), e('p', 'r')]
})
const gap = pos.get('r')!.x - 90 - (pos.get('l')!.x + 50)
expect(gap).toBe(NODE.gap.horizontal)
expect(pos.get('l')!.x).toBe(50)
})
})
@@ -7,7 +7,15 @@ import { NODE } from '$lib/components/graph/util'
// breathing room.
const NODE_WIDTH = NODE.width
const NODE_HEIGHT = NODE.height + 30
const LAYER_GAP = NODE.gap.vertical
/** Rendered height of a pipeline node: a kind label over a title. Fits inside
* the `NODE_HEIGHT` row the layout reserves. */
export const PIPELINE_NODE_HEIGHT = 48
// A step more than the flow editor's gap: the + on a node's bottom edge sits in it.
const LAYER_GAP = NODE.gap.vertical + 16
/** Height of the "Add data source" pill the anchor draws, and its gap to the
* graph: tighter than LAYER_GAP, since no edge runs through it. */
const ANCHOR_HEIGHT = 32
const ANCHOR_GAP = 40
const SIBLING_GAP = NODE.gap.horizontal
// Horizontal gutter between two disjoint subgraphs. Wider than the
// within-subgraph sibling gap so visually-unrelated components (e.g. two
@@ -15,10 +23,16 @@ const SIBLING_GAP = NODE.gap.horizontal
// into each other's columns — but only ~2×, so they don't drift far apart.
const COMPONENT_GAP = SIBLING_GAP * 2
const LAYER_H = NODE_HEIGHT + LAYER_GAP
/** Extra height of an assets-only node: the trigger chip under its title. */
export const PIPELINE_NODE_EXTRA_ROW = 20
/** Height of the header strip naming an assets-only node's producing script. */
export const PIPELINE_NODE_HEADER = 22
/** Extra vertical padding of a card under a header, top plus bottom. */
export const PIPELINE_NODE_HEADER_CARD_PAD = 12
interface GraphInput {
nodes: Array<{ id: string; data: AssetGraphNodeData }>
/** `width` defaults to `NODE.width`. */
nodes: Array<{ id: string; data: AssetGraphNodeData; width?: number }>
edges: Array<{ source: string; target: string }>
}
@@ -52,15 +66,18 @@ interface Band {
// intersects theirs.
//
// y comes from longest-path layering (same top-down orientation as before:
// producers above, assets in the middle, consumers below). Returns positions
// (band centers) normalized so the component's min x,y = 0. Cyclic input is
// handled by dropping feedback edges (see the Kahn step below).
// producers above, assets in the middle, consumers below). Returns node
// centers, normalized so the component's leftmost edge and top are at 0.
// Cyclic input is handled by dropping feedback edges (see the Kahn step below).
function layoutComponent(
nodes: GraphInput['nodes'],
edges: GraphInput['edges']
edges: GraphInput['edges'],
rowH: number
): Map<string, Positioned> {
const layerH = rowH + LAYER_GAP
const out = new Map<string, Positioned>()
const ids = new Set(nodes.map((n) => n.id))
const nodeW = new Map(nodes.map((n) => [n.id, n.width ?? NODE_WIDTH]))
const parents = new Map<string, string[]>()
const children = new Map<string, string[]>()
@@ -137,7 +154,7 @@ function layoutComponent(
const kids = treeChildren.get(id)!
const kidsW =
kids.reduce((acc, k) => acc + W.get(k)!, 0) + SIBLING_GAP * Math.max(0, kids.length - 1)
W.set(id, Math.max(NODE_WIDTH, kidsW))
W.set(id, Math.max(nodeW.get(id)!, kidsW))
}
// Vertical (pixel) extent of a subtree, for band collision checks.
@@ -152,14 +169,14 @@ function layoutComponent(
if (l > hi) hi = l
for (const k of treeChildren.get(cur)!) stack.push(k)
}
return { top: lo * LAYER_H, bottom: hi * LAYER_H + NODE_HEIGHT }
return { top: lo * layerH, bottom: hi * layerH + rowH }
}
// Recursive placement: node centered over its band, children packed
// side-by-side and centered within it.
function placeTree(id: string, left: number) {
const w = W.get(id)!
out.set(id, { x: left + w / 2, y: layer.get(id)! * LAYER_H })
out.set(id, { x: left + w / 2, y: layer.get(id)! * layerH })
const kids = treeChildren.get(id)!
if (kids.length === 0) return
const kidsW = kids.reduce((acc, k) => acc + W.get(k)!, 0) + SIBLING_GAP * (kids.length - 1)
@@ -211,11 +228,11 @@ function layoutComponent(
placeAndRecord(id, center - w / 2)
}
// Normalize so the component's min x,y = 0.
// Normalize so the component's leftmost edge and top are at 0.
let minX = Infinity
let minY = Infinity
for (const p of out.values()) {
if (p.x < minX) minX = p.x
for (const [id, p] of out) {
if (p.x - nodeW.get(id)! / 2 < minX) minX = p.x - nodeW.get(id)! / 2
if (p.y < minY) minY = p.y
}
if (isFinite(minX) && isFinite(minY)) {
@@ -243,7 +260,13 @@ function layoutComponent(
//
// Falls back to a stable grid if the component layout throws (defensive —
// cycles are already absorbed by feedback-edge dropping in layoutComponent).
export function layoutAssetGraph(graph: GraphInput, anchorId?: string): Map<string, Positioned> {
export function layoutAssetGraph(
graph: GraphInput,
anchorId?: string,
extraRowHeight = 0
): Map<string, Positioned> {
const rowH = NODE_HEIGHT + extraRowHeight
const layerH = rowH + LAYER_GAP
const byId = new Map<string, Positioned>()
if (graph.nodes.length === 0) return byId
@@ -297,19 +320,20 @@ export function layoutAssetGraph(graph: GraphInput, anchorId?: string): Map<stri
// 3. Lay out each component and pack side-by-side. xOffset advances by
// the laid-out width of each component (max node center + a node width,
// since positions are node centers) plus the gutter.
const widthOf = new Map(nodes.map((n) => [n.id, n.width ?? NODE_WIDTH]))
let xOffset = 0
for (let c = 0; c < nComp; c++) {
const positions = layoutComponent(compNodes[c], compEdges[c])
let maxX = 0
for (const p of positions.values()) if (p.x > maxX) maxX = p.x
const positions = layoutComponent(compNodes[c], compEdges[c], rowH)
let maxRight = 0
for (const [id, p] of positions) {
maxRight = Math.max(maxRight, p.x + widthOf.get(id)! / 2)
byId.set(id, { x: p.x + xOffset, y: p.y })
}
xOffset += maxX + NODE_WIDTH + COMPONENT_GAP
xOffset += maxRight + COMPONENT_GAP
}
// 4. Re-place the anchor centered horizontally over the whole packed
// graph, one layer above it; then renormalize so the anchor sits at the
// graph, just above it; then renormalize so the anchor sits at the
// top (y = 0), pushing the components down a layer to make room (mirrors
// the previous "anchor is the parent of every root" behaviour).
if (anchorId && graph.nodes.some((n) => n.id === anchorId)) {
@@ -324,7 +348,9 @@ export function layoutAssetGraph(graph: GraphInput, anchorId?: string): Map<stri
if (p.x > maxX) maxX = p.x
if (p.y < minY) minY = p.y
}
byId.set(anchorId, { x: (minX + maxX) / 2, y: minY - LAYER_H })
// One gap above the graph, sized to the pill rather than a full node row:
// a row tall enough for an assets-only card leaves it floating high.
byId.set(anchorId, { x: (minX + maxX) / 2, y: minY - ANCHOR_HEIGHT - ANCHOR_GAP })
let nMinY = Infinity
for (const p of byId.values()) if (p.y < nMinY) nMinY = p.y
for (const p of byId.values()) p.y -= nMinY
@@ -336,7 +362,7 @@ export function layoutAssetGraph(graph: GraphInput, anchorId?: string): Map<stri
graph.nodes.forEach((n, i) => {
byId.set(n.id, {
x: (i % cols) * (NODE_WIDTH + SIBLING_GAP),
y: Math.floor(i / cols) * LAYER_H
y: Math.floor(i / cols) * layerH
})
})
return byId
@@ -0,0 +1,66 @@
import { NODE } from '$lib/components/graph/util'
const MIN_WIDTH = 160
let ctx: CanvasRenderingContext2D | null | undefined
/** Rendered width of `text` at `rem` in the page font, or a rough guess without a DOM. */
function textWidth(text: string, rem: number): number {
if (typeof document === 'undefined') return text.length * rem * 16 * 0.6
ctx ??= document.createElement('canvas').getContext('2d')
const root = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16
if (!ctx) return text.length * rem * root * 0.6
const body = getComputedStyle(document.body)
ctx.font = `${rem * root}px ${body.fontFamily}`
const spacing = parseFloat(body.letterSpacing) || 0
return ctx.measureText(text).width + spacing * text.length
}
/**
* Width of an assets-only asset card, from the pieces `PipelineNodeCard` and
* `AssetNode` lay out: icon and the widest of kind / title / trigger chip, or the
* header naming the producing script, if wider. The numbers mirror their Tailwind spacing; change one
* and the other drifts. Clamped to the regular node width, past which the title
* truncates as usual.
*/
export function assetsOnlyNodeWidth(n: {
kind: string
title: string
chip: string
/** The chip leads with an icon (the folded-errors Fix chip). */
chipIcon?: boolean
/** The chip ends in the edit icon (an editable trigger). */
chipEdit?: boolean
/** The header tab: its text (a script's summary or path, "N scripts"), icon
* count, and whether it ends in the menu chevron (else the edit icon) or has a "draft" mark;
* undefined without a header. */
header?: { label: string; icons: number; chevron?: boolean; draft?: boolean }
}): number {
const icon = 12 + 14 + 10
const text = Math.max(
textWidth(n.kind, 0.7),
textWidth(n.title, 0.75),
textWidth(n.chip, 0.65) + 12 + 2 + (n.chipIcon ? 14 : 0) + (n.chipEdit ? 4 + 9 : 0)
)
const body = Math.ceil(icon + text + 4 + 8 + 2 + 8)
// Header tab: mx-2 and a 1px border around px-2, then gap-1.5 between its
// items: the icons, the label, a "draft" mark, and the chevron or edit icon.
// The run chip is left out: it comes and goes with runs, and a width that
// followed it would re-lay the graph on a click; the label truncates instead.
const h = n.header
const header = h
? Math.ceil(
16 +
2 +
16 +
h.icons * (11 + 6) +
textWidth(h.label, 0.65) +
(h.draft ? 6 + textWidth('draft', 0.65) : 0) +
// The menu chevron, or the edit icon of a single script.
6 +
(h.chevron ? 11 : 10) +
4
)
: 0
return Math.min(NODE.width, Math.max(MIN_WIDTH, body, header))
}
@@ -0,0 +1,25 @@
// Pipelines open in the assets-only view. This remembers the ones last left
// showing their scripts and triggers, as `<workspace>/<folder>`, so reopening one
// shows it the same way. Browser-local: a missing or unreadable list reads as
// "none", and storage that throws (private mode, blocked site data) is ignored.
const KEY = 'pipeline-full-view-folders'
function read(): string[] {
try {
const parsed = JSON.parse(localStorage.getItem(KEY) ?? '[]')
return Array.isArray(parsed) ? parsed.filter((f) => typeof f === 'string') : []
} catch {
return []
}
}
export function isAssetsOnlyFolder(folder: string): boolean {
return !read().includes(folder)
}
export function setAssetsOnlyFolder(folder: string, on: boolean): void {
const rest = read().filter((f) => f !== folder)
try {
localStorage.setItem(KEY, JSON.stringify(on ? rest : [...rest, folder]))
} catch {}
}

Some files were not shown because too many files have changed in this diff Show More