mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-19 16:02:31 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79f61f49b5 | ||
|
|
f00b2fcb1e | ||
|
|
974b0265f8 | ||
|
|
8f9a94328c | ||
|
|
3ce158ad58 |
+43
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at, s.created_by as created_by\n FROM script s LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash\n WHERE s.workspace_id = $1 AND s.path = $2\n ORDER by s.created_at DESC\n LIMIT $3 OFFSET $4",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "hash",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "deployment_msg",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "created_by",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "044310c0e7745867a24a9d3653ff29c87ce48ffd89fac070c8314556f884ce55"
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE draft AS d\n SET path = $3::text,\n value = (\n SELECT CASE\n WHEN s.clean -> 'path' = to_jsonb($2::text)\n OR s.clean -> 'draft_path' = to_jsonb($2::text)\n THEN to_json(\n s.clean\n || CASE WHEN s.clean -> 'path' = to_jsonb($2::text)\n THEN jsonb_build_object('path', $3::text)\n ELSE '{}'::jsonb END\n || CASE WHEN s.clean -> 'draft_path' = to_jsonb($2::text)\n THEN jsonb_build_object('draft_path', $3::text)\n ELSE '{}'::jsonb END\n )\n ELSE d.value\n END\n FROM (SELECT replace(replace(replace(d.value::text, chr(92) || chr(92), chr(1)),\n chr(92) || 'u0000', ''), chr(1), chr(92) || chr(92))::jsonb AS clean) s\n )\n WHERE d.workspace_id = $1\n AND d.path = $2::text\n AND d.typ::text = ANY($4::text[])",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "09e3f8fa8a9e64048971983584bfa00ab137c70b73f381452cadfae6e1cda934"
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH RECURSIVE descendants AS (\n SELECT id FROM v2_job WHERE parent_job = $1 AND workspace_id = $2\n UNION ALL\n SELECT j.id FROM v2_job j JOIN descendants d ON j.parent_job = d.id\n WHERE j.workspace_id = $2\n ) SELECT d.id AS \"id!\" FROM descendants d JOIN v2_job_queue q ON q.id = d.id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id!",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "0c2bf6925de6dd4d9f8d47d7fdd9e7801129570106b5acd49c0f708398606771"
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT value as \"value!: sqlx::types::Json<Box<serde_json::value::RawValue>>\",\n created_at, base\n FROM draft\n WHERE workspace_id = $1\n AND (email = $2 OR email IS NULL)\n AND path = $3\n AND typ = $4\n ORDER BY email NULLS LAST\n LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
|
||||
"type_info": "Json"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "base",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "draft_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"app",
|
||||
"raw_app",
|
||||
"resource",
|
||||
"variable",
|
||||
"trigger_schedule",
|
||||
"trigger_webhook",
|
||||
"trigger_default_email",
|
||||
"trigger_email",
|
||||
"trigger_http",
|
||||
"trigger_websocket",
|
||||
"trigger_postgres",
|
||||
"trigger_kafka",
|
||||
"trigger_nats",
|
||||
"trigger_mqtt",
|
||||
"trigger_sqs",
|
||||
"trigger_gcp",
|
||||
"trigger_azure",
|
||||
"trigger_poll",
|
||||
"trigger_cli",
|
||||
"trigger_nextcloud",
|
||||
"trigger_google",
|
||||
"trigger_github",
|
||||
"data_pipeline",
|
||||
"trigger_amqp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "0cc6770a81ecaecafe0d9b7100f94b329c4ec7978b016af4c964415e0c38396c"
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE draft\n SET path = $3,\n -- Both path keys, not just the typed one: the editors mirror the\n -- typed path into the other while it differs from the row's path,\n -- and the loaders prefer the mirror — left naming the old location\n -- it un-does this move on the next save. `create_missing = false`\n -- on both, so a draft carrying only one keeps only one.\n value = to_json(\n jsonb_set(\n jsonb_set(\n CASE WHEN $7::text IS NULL THEN to_jsonb(value)\n ELSE jsonb_set(to_jsonb(value), ARRAY['summary'], to_jsonb($7::text))\n END,\n ARRAY[$5::text], to_jsonb($3::text), false\n ),\n ARRAY[$8::text], to_jsonb($3::text), false\n )\n )\n WHERE workspace_id = $1\n AND path = $2\n AND typ = $4\n AND email = $6\n -- A pre-sanitizer NUL escape makes `to_jsonb` raise 22P05. Excluded\n -- here so the statement can't 500; reported below instead. Unlike the\n -- passive carry, rewriting the value IS this operation, so skipping it\n -- silently would move the row and leave its typed path stale.\n AND position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) = 0\n -- Skipped on a summary-only edit, where the \"target\" row is this\n -- row and the guard would refuse the update against itself.\n AND ($2 = $3 OR NOT EXISTS (\n SELECT 1 FROM draft o\n WHERE o.workspace_id = $1 AND o.path = $3 AND o.typ::text = ANY($9::text[])\n -- Of this kind only the caller's own row and the legacy one collide:\n -- teammates' drafts of one item share its path by design, but a deploy\n -- there wipes those two together, so a second would discard edits the\n -- caller never saw. The other app kind is a different item on the same\n -- deployed path, so it collides whoever owns it.\n AND (o.typ <> $4 OR o.email = $6 OR o.email IS NULL)\n ))\n RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "draft_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"app",
|
||||
"raw_app",
|
||||
"resource",
|
||||
"variable",
|
||||
"trigger_schedule",
|
||||
"trigger_webhook",
|
||||
"trigger_default_email",
|
||||
"trigger_email",
|
||||
"trigger_http",
|
||||
"trigger_websocket",
|
||||
"trigger_postgres",
|
||||
"trigger_kafka",
|
||||
"trigger_nats",
|
||||
"trigger_mqtt",
|
||||
"trigger_sqs",
|
||||
"trigger_gcp",
|
||||
"trigger_azure",
|
||||
"trigger_poll",
|
||||
"trigger_cli",
|
||||
"trigger_nextcloud",
|
||||
"trigger_google",
|
||||
"trigger_github",
|
||||
"data_pipeline",
|
||||
"trigger_amqp"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0d9a83d77632bc0de19a48eddb7e6f80b2a41db4f1d352920d0c295ea3a018b1"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM draft_move WHERE email = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "11af34e5fe5b8ed59453d58628ed5fb3b2120835158f22d715b73460f6a55108"
|
||||
}
|
||||
+8
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at\n FROM script s LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash\n WHERE s.workspace_id = $1 AND s.path = $2\n ORDER by s.created_at DESC",
|
||||
"query": "SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at, s.created_by as created_by\n FROM script s LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash\n WHERE s.workspace_id = $1 AND s.path = $2\n ORDER by s.created_at DESC LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -17,6 +17,11 @@
|
||||
"ordinal": 2,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "created_by",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -28,8 +33,9 @@
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "9a1483a81f5b086e0765d3d69483e29b09f66090e1f9d394564c16d921d2e66c"
|
||||
"hash": "148fbee57c6abbb6f361818c5b6bfc8a1db451d31dfddbb5be945b3551c8becb"
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg,\n av.created_by as created_by, av.created_at as created_at\n FROM app a\n JOIN LATERAL (\n SELECT av2.id, COALESCE(v.ord, 0) AS ord\n FROM app_version av2\n LEFT JOIN unnest(a.versions) WITH ORDINALITY AS v(id, ord) ON v.id = av2.id\n WHERE av2.app_id = a.id\n ORDER BY ord DESC, av2.id DESC\n LIMIT $3 OFFSET $4\n ) page ON TRUE\n JOIN app_version av ON av.id = page.id\n LEFT JOIN deployment_metadata dm ON av.id = dm.app_version\n WHERE a.workspace_id = $1 AND a.path = $2\n ORDER BY page.ord DESC",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "app_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "version_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "deployment_msg",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "created_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "18b38efcb141101192b372e7d7e7e47481877f3ab717682f3b84e665c95a5214"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH legacy AS (\n DELETE FROM draft\n WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email IS NULL\n RETURNING value\n )\n INSERT INTO draft (workspace_id, email, path, typ, value, created_at)\n SELECT $1, $4, $2, $3, value, now() FROM legacy\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = EXCLUDED.value, created_at = now()\n RETURNING 1 as \"one!\"",
|
||||
"query": "WITH legacy AS (\n DELETE FROM draft\n WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email IS NULL\n RETURNING value, base\n )\n INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base)\n SELECT $1, $4, $2, $3, value, now(), base FROM legacy\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = EXCLUDED.value, created_at = now(), base = EXCLUDED.base\n RETURNING 1 as \"one!\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -55,5 +55,5 @@
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "46f00a75b2e7e4ac70758a9687070f68bc0421f1aa228f80157adda63191d33b"
|
||||
"hash": "1c6859291963a9ca38d73f500213a575bfcd4469524f0dc8812470737759f02b"
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO draft_move (workspace_id, typ, old_path, new_path, email)\n SELECT m.workspace_id, m.typ, m.old_path, $4, $5::text\n FROM draft_move m\n WHERE m.workspace_id = $1 AND m.typ::text = ANY($2::text[])\n AND m.new_path = $3 AND m.email IS NULL\n AND NOT EXISTS (\n SELECT 1 FROM draft_move o\n WHERE o.workspace_id = m.workspace_id AND o.typ = m.typ\n AND o.old_path = m.old_path AND o.email = $5::text\n )",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"TextArray",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "232ff36c2d522383061cafbf9639698dbc44a18c0f016aae94a1705f06fd1e48"
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO draft_move (workspace_id, typ, old_path, new_path, email)\n SELECT $1, t::draft_kind, $3, $4, $5 FROM unnest($2::text[]) t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"TextArray",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3a015ab279785a15f89c89d8c4d155e85370d4c2b7ed14fbb510919e7624ca1c"
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE draft_move SET new_path = $4\n WHERE workspace_id = $1 AND typ::text = ANY($2::text[])\n AND new_path = $3 AND ($5::text IS NULL OR email = $5)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"TextArray",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3db5f1d8ec1f8d867cf01c066876122e78f0c76a93a1d9860e8dff05d9a02c09"
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE v2_job_queue SET started_at = now() WHERE id = $1 RETURNING started_at",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "started_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "451f303f4a24d848ba4ccdc2441ca7cb85bdfc66110b6534260f8642f91f006a"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM draft_move dest\n WHERE dest.email = $1\n AND EXISTS (SELECT 1 FROM draft_move src\n WHERE src.email = $2\n AND src.workspace_id = dest.workspace_id\n AND src.old_path = dest.old_path\n AND src.typ = dest.typ)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "5959c60da91f1e62080a4455e2a4627cb1fd3d8c70210bf7f38cb222dd15d88f"
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base)\n VALUES ($1, $2, $3::text, $4,\n CASE WHEN $10::bool\n THEN to_json($5::text::jsonb || jsonb_strip_nulls(jsonb_build_object(\n 'path', CASE WHEN $5::text::jsonb -> 'path' = to_jsonb($11::text)\n THEN to_jsonb($3::text) END,\n 'draft_path', CASE WHEN $5::text::jsonb -> 'draft_path' = to_jsonb($11::text)\n THEN to_jsonb($3::text) END)))\n ELSE $5::text::json\n END,\n COALESCE($8::timestamptz, now()), $9)\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = CASE\n WHEN NOT $10::bool\n OR position(chr(92) || 'u0000' in replace(draft.value::text, chr(92) || chr(92), '')) > 0\n THEN EXCLUDED.value\n ELSE to_json((to_jsonb(EXCLUDED.value) - 'path' - 'draft_path')\n || jsonb_strip_nulls(jsonb_build_object(\n 'path', to_jsonb(draft.value) -> 'path',\n 'draft_path', to_jsonb(draft.value) -> 'draft_path')))\n END,\n created_at = EXCLUDED.created_at,\n base = EXCLUDED.base\n WHERE $7::bool = true\n OR $6::timestamptz IS NULL\n OR draft.created_at <= $6::timestamptz\n RETURNING path, created_at",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "draft_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"app",
|
||||
"raw_app",
|
||||
"resource",
|
||||
"variable",
|
||||
"trigger_schedule",
|
||||
"trigger_webhook",
|
||||
"trigger_default_email",
|
||||
"trigger_email",
|
||||
"trigger_http",
|
||||
"trigger_websocket",
|
||||
"trigger_postgres",
|
||||
"trigger_kafka",
|
||||
"trigger_nats",
|
||||
"trigger_mqtt",
|
||||
"trigger_sqs",
|
||||
"trigger_gcp",
|
||||
"trigger_azure",
|
||||
"trigger_poll",
|
||||
"trigger_cli",
|
||||
"trigger_nextcloud",
|
||||
"trigger_google",
|
||||
"trigger_github",
|
||||
"data_pipeline",
|
||||
"trigger_amqp"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Text",
|
||||
"Timestamptz",
|
||||
"Bool",
|
||||
"Timestamptz",
|
||||
"Text",
|
||||
"Bool",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "7761959d05fff8c314d04c1bc9fb4e84a61008dbec0995ae192f1b3a1c261579"
|
||||
}
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, status = 'success' AS \"success!\", result AS \"result: Json<Box<RawValue>>\"\n FROM v2_job_completed WHERE workspace_id = $1 AND id = ANY($2)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "success!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "result: Json<Box<RawValue>>",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"UuidArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "789c3f6d29f46fcb17a22fe97405a1754ccb8ddeb08c23795dfe14e9f332e9dc"
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM draft_move\n WHERE workspace_id = $1 AND typ::text = ANY($2::text[])\n AND old_path IN ($3, $4) AND ($5::text IS NULL OR email = $5)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"TextArray",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "797896e0152046844dbfee9d0e57e0055d742e44119bfb31ae7ff37a89b34d14"
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT flow_version.id, flow_version.created_at, flow_version.created_by, deployment_metadata.deployment_msg FROM flow_version\n LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version\n WHERE flow_version.path = $1 AND flow_version.workspace_id = $2\n ORDER BY flow_version.created_at DESC\n LIMIT $3 OFFSET $4",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "created_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "deployment_msg",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "7ff60d155510303e307c47dcb6153ccaac72871eb68822d8e296c0d747e55f35"
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO draft (workspace_id, path, typ, value, created_at, email)\n SELECT $2, path, typ,\n CASE WHEN typ IN ('script', 'flow')\n THEN to_json(to_jsonb(value) - 'on_behalf_of')\n ELSE value END,\n created_at, email\n FROM draft\n WHERE workspace_id = $1 AND (email = $3 OR email IS NULL)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "87afd5289e54b6accda8a399ec3c66df3d19217059f62903be3c74eadb9c15d5"
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM draft_move WHERE workspace_id = $1 AND typ::text = ANY($2::text[])\n AND (old_path = $3 OR (new_path = $3 AND $4::text IS NULL))",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"TextArray",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "9723edfd6de38cf0bf21123a46d88fa7d0b650b5cfd80c099f785f5e411062e5"
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM draft_move\n WHERE workspace_id = $1 AND typ::text = ANY($2::text[])\n AND new_path = $4 AND old_path <> $3 AND ($5::text IS NULL OR email = $5)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"TextArray",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "9d1b202844e6935e636d1570a92d46af4419dda9410ffc46f19bc38e56efe609"
|
||||
}
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT flow_version.id, flow_version.created_at, deployment_metadata.deployment_msg FROM flow_version \n LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version\n WHERE flow_version.path = $1 AND flow_version.workspace_id = $2 \n ORDER BY flow_version.created_at DESC",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "deployment_msg",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "a0f1c0df6bc2f1fbca50edee90e42c94445536e201b322eda6f7a90bdf38f36a"
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n -- The guard's own predicate, ordered own row, then legacy, then another\n -- user's other-kind row: each needs a different remedy, and a bare\n -- LIMIT 1 would name an arbitrary one.\n (SELECT typ::text FROM draft WHERE workspace_id = $1 AND path = $3\n AND typ::text = ANY($6::text[])\n AND (typ <> $2 OR email = $4 OR email IS NULL)\n ORDER BY CASE WHEN email = $4 THEN 0 WHEN email IS NULL THEN 1 ELSE 2 END\n LIMIT 1) as \"at_target\",\n EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $3\n AND typ::text = ANY($6::text[]) AND email = $4) as \"at_target_own!\",\n EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $3\n AND typ::text = ANY($6::text[]) AND email IS NULL) as \"at_target_legacy!\",\n EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $5\n AND typ = $2 AND email = $4\n AND position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) > 0\n ) as \"poisoned!\",\n EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $5\n AND typ = $2 AND email IS NULL) as \"legacy!\" ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "at_target",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "at_target_own!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "at_target_legacy!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "poisoned!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "legacy!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "draft_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"app",
|
||||
"raw_app",
|
||||
"resource",
|
||||
"variable",
|
||||
"trigger_schedule",
|
||||
"trigger_webhook",
|
||||
"trigger_default_email",
|
||||
"trigger_email",
|
||||
"trigger_http",
|
||||
"trigger_websocket",
|
||||
"trigger_postgres",
|
||||
"trigger_kafka",
|
||||
"trigger_nats",
|
||||
"trigger_mqtt",
|
||||
"trigger_sqs",
|
||||
"trigger_gcp",
|
||||
"trigger_azure",
|
||||
"trigger_poll",
|
||||
"trigger_cli",
|
||||
"trigger_nextcloud",
|
||||
"trigger_google",
|
||||
"trigger_github",
|
||||
"data_pipeline",
|
||||
"trigger_amqp"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "a1ed94ef0a6d4f7bc89ac4a2341bc06dc95d60ec92469523935f5a18a4d3f907"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE draft_move SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a725d5b99b65a1637406179b058a1ff36620099c6b87fd82a59eae1e35303c07"
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg,\n av.created_by as created_by, av.created_at as created_at\n FROM app a JOIN app_version av\n ON av.id = a.versions[array_upper(a.versions, 1)] AND av.app_id = a.id\n LEFT JOIN deployment_metadata dm ON av.id = dm.app_version\n WHERE a.workspace_id = $1 AND a.path = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "app_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "version_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "deployment_msg",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "created_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "bd8ac169ad15346f28f561233fe340efd453512c1464a413af2393ab8bfea9ee"
|
||||
}
|
||||
+3
-3
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE v2_job_queue SET worker = $1 WHERE id = $2",
|
||||
"query": "UPDATE draft_move SET email = $1 WHERE email = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Uuid"
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e459c277c0bc27293d71972d12d26d32b476a43ed391c09d7678215195c4283a"
|
||||
"hash": "e428f0cdb9077bffe2b7271d9b5f30ed81e8c26a9f1e65f423078947153ff9dd"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO draft (workspace_id, path, typ, value, created_at, email, base)\n SELECT $2, path, typ,\n to_json(\n CASE WHEN typ IN ('script', 'flow') THEN clean - 'on_behalf_of' ELSE clean END\n - CASE WHEN typ = 'flow' THEN 'version_id'\n WHEN typ IN ('app', 'raw_app') THEN 'parent_version'\n ELSE '' END\n ),\n created_at, email,\n CASE WHEN typ = 'script' THEN base END\n FROM (\n SELECT d.path, d.typ, d.created_at, d.email, d.base,\n replace(replace(replace(d.value::text, chr(92) || chr(92), chr(1)), chr(92) || 'u0000', ''), chr(1), chr(92) || chr(92))::jsonb AS clean\n FROM draft d\n WHERE d.workspace_id = $1 AND (d.email = $3 OR d.email IS NULL)\n ) s",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e6140682ebc65083f48714ab3299fdb239ef507e23003db03aff7d35b02c540b"
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT m.new_path FROM draft_move m\n WHERE m.workspace_id = $1 AND m.typ = $2 AND m.old_path = $3\n AND (m.email IS NULL OR m.email = $4)\n AND NOT EXISTS (\n SELECT 1 FROM draft d\n WHERE d.workspace_id = $1 AND d.typ = $2 AND d.path = $3\n AND d.email IS NOT DISTINCT FROM $4\n )\n ORDER BY m.email IS NULL\n LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "new_path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "draft_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"app",
|
||||
"raw_app",
|
||||
"resource",
|
||||
"variable",
|
||||
"trigger_schedule",
|
||||
"trigger_webhook",
|
||||
"trigger_default_email",
|
||||
"trigger_email",
|
||||
"trigger_http",
|
||||
"trigger_websocket",
|
||||
"trigger_postgres",
|
||||
"trigger_kafka",
|
||||
"trigger_nats",
|
||||
"trigger_mqtt",
|
||||
"trigger_sqs",
|
||||
"trigger_gcp",
|
||||
"trigger_azure",
|
||||
"trigger_poll",
|
||||
"trigger_cli",
|
||||
"trigger_nextcloud",
|
||||
"trigger_google",
|
||||
"trigger_github",
|
||||
"data_pipeline",
|
||||
"trigger_amqp"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e63da92247045f2aaaa62b9d6ee17510fd05d0b7c5311b61e7257ccd07b16da2"
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT flow_version.id, flow_version.created_at, flow_version.created_by, deployment_metadata.deployment_msg FROM flow_version \n LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version\n WHERE flow_version.path = $1 AND flow_version.workspace_id = $2 \n ORDER BY flow_version.created_at DESC",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "created_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "deployment_msg",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "ef98b0fab16d3b19997fd639b74a895cce1e8ae010307431e6787d20c7b25780"
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT d.email IS NULL as \"legacy!\", COALESCE(u.username, p.username) as username\n FROM draft d\n LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email\n LEFT JOIN password p ON p.email = d.email AND p.super_admin = true\n WHERE d.workspace_id = $1 AND d.path = $2 AND d.typ::text = ANY($3::text[])\n -- A row a move already brought here from the very path being renamed is this\n -- rename's own draft, not an item in its way: the record says which owners\n -- it moved (all of them for an item move, one for a draft-only move). Any\n -- other row is a second item and still collides.\n AND NOT EXISTS (\n SELECT 1 FROM draft_move m\n WHERE m.workspace_id = $1 AND m.typ::text = ANY($3::text[])\n AND m.old_path = $4 AND m.new_path = $2\n AND (m.email IS NULL OR m.email IS NOT DISTINCT FROM d.email)\n )\n ORDER BY 2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "legacy!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "username",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"TextArray",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "fc2081d9077c743bc3978ae1905db558549b2356fc6ac8f0557aa40e1345ff46"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE draft DROP COLUMN base;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- The version a draft forked from, as one opaque text id whatever the kind: a
|
||||
-- script hash (hex), a flow_version.id, an app_version.id. NULL for a draft that
|
||||
-- was never forked from a deploy and for kinds that keep no lineage.
|
||||
ALTER TABLE draft ADD COLUMN base TEXT;
|
||||
|
||||
-- A U+0000 inside a `json` value makes `->>` raise 22P05; such rows keep NULL and
|
||||
-- get their base on their next save.
|
||||
UPDATE draft SET base = CASE typ::text
|
||||
WHEN 'script' THEN value ->> 'parent_hash'
|
||||
WHEN 'flow' THEN value ->> 'version_id'
|
||||
ELSE value ->> 'parent_version'
|
||||
END
|
||||
WHERE typ::text IN ('script', 'flow', 'app', 'raw_app')
|
||||
AND position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) = 0;
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE draft_move;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Where an item's drafts went when it moved, so a draft save still addressed to the
|
||||
-- old path (an editor left open across the move, a chat, the CLI) lands on the moved
|
||||
-- draft instead of starting a new item there. `email` NULL records a deployed item's
|
||||
-- move and applies to every user; set, it records that user's draft-only move.
|
||||
CREATE TABLE draft_move (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
typ DRAFT_KIND NOT NULL,
|
||||
old_path VARCHAR(255) NOT NULL,
|
||||
new_path VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255)
|
||||
);
|
||||
|
||||
CREATE INDEX draft_move_old_path_idx ON draft_move (workspace_id, typ, old_path);
|
||||
|
||||
GRANT ALL ON draft_move TO windmill_user;
|
||||
GRANT ALL ON draft_move TO windmill_admin;
|
||||
@@ -1,181 +0,0 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::{Pool, Postgres};
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use tokio::sync::Notify;
|
||||
use windmill_common::{flows::FlowValue, jobs::JobPayload};
|
||||
use windmill_test_utils::{
|
||||
completed_job, in_test_worker, listen_for_completed_jobs, ApiServer, RunJob, StreamFind,
|
||||
};
|
||||
|
||||
async fn model(Json(body): Json<Value>) -> ([(&'static str, &'static str); 1], String) {
|
||||
let finished = body["messages"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|m| m["role"] == "tool");
|
||||
let delta = if finished {
|
||||
json!({"role":"assistant", "content":"done"})
|
||||
} else {
|
||||
json!({"role":"assistant", "tool_calls": (0..3).map(|i| json!({
|
||||
"index":i, "id":format!("call_{i}"), "type":"function",
|
||||
"function":{"name":format!("tool_{i}"), "arguments":"{}"}
|
||||
})).collect::<Vec<_>>()})
|
||||
};
|
||||
let event = json!({"choices":[{"index":0, "delta":delta, "finish_reason":null}]});
|
||||
let end = json!({"choices":[{"index":0, "delta":{}, "finish_reason":if finished {"stop"} else {"tool_calls"}}]});
|
||||
(
|
||||
[("content-type", "text/event-stream")],
|
||||
format!("data: {event}\n\ndata: {end}\n\ndata: [DONE]\n\n"),
|
||||
)
|
||||
}
|
||||
|
||||
async fn run_batch(db: Pool<Postgres>, parallel: bool, limited: bool) -> anyhow::Result<()> {
|
||||
std::env::set_var("ALLOW_PRIVATE_AI_BASE_URLS", "true");
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
|
||||
let base = format!("http://{}", listener.local_addr()?);
|
||||
let gate = Arc::new(Notify::new());
|
||||
let router = Router::new()
|
||||
.route("/v1/chat/completions", post(model))
|
||||
.route(
|
||||
"/first",
|
||||
get(|State(gate): State<Arc<Notify>>| async move {
|
||||
gate.notified().await;
|
||||
"ok"
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/second",
|
||||
get(|State(gate): State<Arc<Notify>>| async move {
|
||||
gate.notify_one();
|
||||
"ok"
|
||||
}),
|
||||
)
|
||||
.with_state(gate);
|
||||
let stub = tokio::spawn(async move {
|
||||
axum::serve(listener, router).await.unwrap();
|
||||
});
|
||||
let tools: Vec<Value> = (0..3).map(|i| {
|
||||
let wait = if parallel && !limited && i < 2 {
|
||||
format!("await fetch('{base}/{}');", if i == 0 {"first"} else {"second"})
|
||||
} else { String::new() };
|
||||
let finish = if i == 2 { "throw new Error('expected tool failure');".to_string() }
|
||||
else { format!("return {i};") };
|
||||
json!({"id":format!("t{i}"),"summary":format!("tool_{i}"),"value":{
|
||||
"type":"rawscript", "language":"bun", "input_transforms":{},
|
||||
"tag": if parallel { "bun" } else { "unserved-tool-tag" },
|
||||
"concurrent_limit": if limited { Some(1) } else { None },
|
||||
"custom_concurrency_key": if limited { Some("agent-tool-test") } else { None },
|
||||
"content":format!("export async function main() {{ {wait} await Bun.sleep({}); {finish} }}", if i == 0 {200} else {0})
|
||||
}})
|
||||
}).collect();
|
||||
let flow: FlowValue = serde_json::from_value(json!({"modules":[{"id":"agent","value":{
|
||||
"type":"aiagent", "tools":tools, "input_transforms":{
|
||||
"provider":{"type":"static","value":{"kind":"customai","model":"queue-test","resource":{"base_url":format!("{base}/v1")}}},
|
||||
"user_message":{"type":"static","value":"run the tools"},
|
||||
"max_iterations":{"type":"static","value":3}
|
||||
}
|
||||
}}]}))?;
|
||||
let id = RunJob::from(JobPayload::RawFlow {
|
||||
value: flow,
|
||||
path: Some("u/test/agent_queue".into()),
|
||||
restarted_from: None,
|
||||
})
|
||||
.push(&db)
|
||||
.await;
|
||||
let notifications = listen_for_completed_jobs(&db).await;
|
||||
let wait = async {
|
||||
if parallel {
|
||||
in_test_worker(&db, notifications.find(&id), server.addr.port()).await;
|
||||
} else {
|
||||
notifications.find(&id).await;
|
||||
}
|
||||
};
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(45),
|
||||
in_test_worker(&db, wait, server.addr.port()),
|
||||
)
|
||||
.await?;
|
||||
let result = completed_job(id, &db).await;
|
||||
assert!(result.success, "{:?}", result.result);
|
||||
let result = result.json_result().expect("agent result");
|
||||
let messages: Vec<_> = result["messages"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|m| m["role"] == "tool")
|
||||
.collect();
|
||||
assert_eq!(messages.len(), 3);
|
||||
for (index, message) in messages.iter().enumerate() {
|
||||
assert_eq!(message["tool_call_id"], format!("call_{index}"));
|
||||
}
|
||||
assert_eq!(messages[0]["content"], "0");
|
||||
assert_eq!(messages[1]["content"], "1");
|
||||
assert!(messages[2]["content"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("expected tool failure"));
|
||||
let (parent_id, parent_worker): (uuid::Uuid, String) = sqlx::query_as(
|
||||
"SELECT j.id, c.worker FROM v2_job j JOIN v2_job_completed c USING(id) WHERE j.parent_job = $1"
|
||||
).bind(id).fetch_one(&db).await?;
|
||||
let children: Vec<(String, String, bool)> = sqlx::query_as(
|
||||
"SELECT j.runnable_path, c.worker, c.status = 'success' FROM v2_job j JOIN v2_job_completed c USING(id) WHERE j.parent_job = $1 ORDER BY j.runnable_path"
|
||||
).bind(parent_id).fetch_all(&db).await?;
|
||||
assert_eq!(children.len(), 3);
|
||||
assert_eq!(
|
||||
children[0].1, parent_worker,
|
||||
"first tool must stay on the parent worker"
|
||||
);
|
||||
assert_eq!(
|
||||
children.iter().map(|c| c.2).collect::<Vec<_>>(),
|
||||
vec![true, true, false]
|
||||
);
|
||||
if limited {
|
||||
let overlapping: i64 = sqlx::query_scalar(
|
||||
"SELECT count(*) FROM v2_job j1 JOIN v2_job_completed c1 ON c1.id = j1.id
|
||||
JOIN v2_job j2 ON j2.parent_job = j1.parent_job AND j2.id > j1.id
|
||||
JOIN v2_job_completed c2 ON c2.id = j2.id
|
||||
WHERE j1.parent_job = $1 AND c1.started_at < c2.completed_at AND c2.started_at < c1.completed_at"
|
||||
).bind(parent_id).fetch_one(&db).await?;
|
||||
assert_eq!(
|
||||
overlapping, 0,
|
||||
"the reserved first job must count toward the shared limit"
|
||||
);
|
||||
} else if parallel {
|
||||
assert_ne!(
|
||||
children[1].1, parent_worker,
|
||||
"second tool must unblock the first from another worker"
|
||||
);
|
||||
} else {
|
||||
assert!(children.iter().all(|child| child.1 == parent_worker));
|
||||
}
|
||||
stub.abort();
|
||||
server.close().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
#[serial_test::serial]
|
||||
async fn parent_drains_tools_without_another_worker(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
run_batch(db, false, false).await
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
#[serial_test::serial]
|
||||
async fn parent_keeps_first_tool_while_another_worker_runs_siblings(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
run_batch(db, true, false).await
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
#[serial_test::serial]
|
||||
async fn reserved_tool_obeys_shared_concurrency_limit(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
run_batch(db, true, true).await
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
//! The deployed order of an app's versions is the order they were appended to
|
||||
//! `app.versions`, not the order of their `created_at`.
|
||||
//!
|
||||
//! `app_version.created_at` defaults to `now()`, which in Postgres is the
|
||||
//! transaction's start time, while the append happens under the app row's lock.
|
||||
//! Two deploys that overlap therefore land in one order and carry timestamps in
|
||||
//! the other. The head the editor guards against, and the sequence the diff
|
||||
//! picker numbers, both have to follow the array.
|
||||
//!
|
||||
//! Users from the `base` fixture: test-user (admin, token SECRET_TOKEN).
|
||||
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
const TOKEN: &str = "SECRET_TOKEN";
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_app_head_follows_the_append_order_not_the_timestamps(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let ws = format!(
|
||||
"http://localhost:{}/api/w/test-workspace",
|
||||
server.addr.port()
|
||||
);
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let res = client
|
||||
.post(format!("{ws}/apps/create"))
|
||||
.header("Authorization", format!("Bearer {TOKEN}"))
|
||||
.json(&json!({
|
||||
"path": "u/test-user/order_app",
|
||||
"summary": "ordered",
|
||||
"value": {},
|
||||
"policy": { "execution_mode": "publisher", "triggerables": {} }
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(res.status().is_success(), "{}", res.text().await?);
|
||||
|
||||
let first: i64 = sqlx::query_scalar(
|
||||
"SELECT versions[array_upper(versions, 1)] FROM app
|
||||
WHERE workspace_id = 'test-workspace' AND path = 'u/test-user/order_app'",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
|
||||
// The overlapping deploy: appended after `first`, so it is the version that
|
||||
// landed, but stamped before it, so a timestamp sort puts it underneath.
|
||||
let second: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO app_version (app_id, value, created_by, created_at, raw_app)
|
||||
SELECT app_id, value, 'racer', created_at - interval '1 hour', raw_app
|
||||
FROM app_version WHERE id = $1
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(first)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"UPDATE app SET versions = array_append(versions, $1::bigint)
|
||||
WHERE workspace_id = 'test-workspace' AND path = 'u/test-user/order_app'",
|
||||
)
|
||||
.bind(second)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let head: serde_json::Value = client
|
||||
.get(format!(
|
||||
"{ws}/apps/get_latest_version/u/test-user/order_app"
|
||||
))
|
||||
.header("Authorization", format!("Bearer {TOKEN}"))
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
head["version"], second,
|
||||
"the head is the version appended last, not the newest timestamp: {head}"
|
||||
);
|
||||
|
||||
let history: Vec<serde_json::Value> = client
|
||||
.get(format!("{ws}/apps/history/p/u/test-user/order_app"))
|
||||
.header("Authorization", format!("Bearer {TOKEN}"))
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
let listed: Vec<i64> = history
|
||||
.iter()
|
||||
.map(|v| v["version"].as_i64().unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
listed,
|
||||
vec![second, first],
|
||||
"the history lists in deployed order, so it leads with the head"
|
||||
);
|
||||
|
||||
// Enough versions that a page cannot hold them, so "asked for nothing" and "asked for
|
||||
// a page" are visibly different answers.
|
||||
let mut appended = vec![second, first];
|
||||
for _ in 0..24 {
|
||||
let extra: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO app_version (app_id, value, created_by, created_at, raw_app)
|
||||
SELECT app_id, value, 'bulk', created_at, raw_app FROM app_version WHERE id = $1
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(first)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"UPDATE app SET versions = array_append(versions, $1::bigint)
|
||||
WHERE workspace_id = 'test-workspace' AND path = 'u/test-user/order_app'",
|
||||
)
|
||||
.bind(extra)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
appended.insert(0, extra);
|
||||
}
|
||||
|
||||
let versions_at = |query: &str| {
|
||||
let url = format!("{ws}/apps/history/p/u/test-user/order_app{query}");
|
||||
let client = client.clone();
|
||||
async move {
|
||||
let rows: Vec<serde_json::Value> = client
|
||||
.get(url)
|
||||
.header("Authorization", format!("Bearer {TOKEN}"))
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
Ok::<_, anyhow::Error>(
|
||||
rows.iter()
|
||||
.map(|v| v["version"].as_i64().unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// The deployment-history panel and the CLI read this endpoint without paging, so
|
||||
// asking for no page has to keep answering with the whole history.
|
||||
assert_eq!(
|
||||
versions_at("").await?,
|
||||
appended,
|
||||
"an unpaginated request still answers whole"
|
||||
);
|
||||
assert_eq!(
|
||||
versions_at("?per_page=10").await?,
|
||||
appended[..10],
|
||||
"a page holds what was asked for, newest first"
|
||||
);
|
||||
assert_eq!(
|
||||
versions_at("?per_page=10&page=2").await?,
|
||||
appended[10..20],
|
||||
"the next page carries on where the first left off, skipping nothing"
|
||||
);
|
||||
// A page past the end runs off it rather than overflowing into one. (The clamp on an
|
||||
// asked-for size is pinned where it lives, in `paginate_optional`'s own test.)
|
||||
assert!(
|
||||
versions_at("?per_page=10&page=99999999")
|
||||
.await?
|
||||
.is_empty(),
|
||||
"a page past the end is empty"
|
||||
);
|
||||
// Naming only the page still asks for a page, rather than an offset into a listing
|
||||
// sized to hold everything — which would answer nothing at all.
|
||||
assert_eq!(
|
||||
versions_at("?page=1").await?,
|
||||
appended,
|
||||
"a first page with no size named holds the history"
|
||||
);
|
||||
|
||||
// A version that never entered the deployed sequence — one a restore or a fork copy
|
||||
// could leave behind — still has to be reachable, after the ones that did.
|
||||
let stray: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO app_version (app_id, value, created_by, created_at, raw_app)
|
||||
SELECT app_id, value, 'stray', created_at, raw_app FROM app_version WHERE id = $1
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(first)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
let mut with_stray = appended.clone();
|
||||
with_stray.push(stray);
|
||||
assert_eq!(
|
||||
versions_at("").await?,
|
||||
with_stray,
|
||||
"a version outside app.versions is listed, and after the deployed sequence"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -521,10 +521,8 @@ def main():
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
res.text().await.unwrap(),
|
||||
"app f/rel/root_app updated (npath: \"f/rel/root_app_renamed\")"
|
||||
);
|
||||
let deployed: serde_json::Value = res.json().await.unwrap();
|
||||
assert_eq!(deployed["path"], "f/rel/root_app_renamed", "{deployed}");
|
||||
|
||||
let mut completed = listen_for_completed_jobs(&db).await;
|
||||
in_test_worker(&db, completed.next(), port).await;
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Moving a draft must carry both of its path keys.
|
||||
//!
|
||||
//! A draft value holds a typed path and a mirror the editors keep beside it while
|
||||
//! it differs from the row's path (`path`/`draft_path`; which is which depends on
|
||||
//! the kind). The loaders prefer the mirror, so a move that rewrote only the typed
|
||||
//! key left the mirror naming the old location: reopening the item restored the
|
||||
//! old path, and the next autosave wrote it back — undoing the move silently.
|
||||
//!
|
||||
//! This pins `move_draft`, including that a draft with no mirror never gains one.
|
||||
//! A deploy's carry follows the keys only where they still name the old path
|
||||
//! (`drafts_save_follows_move` pins that).
|
||||
|
||||
use serde_json::Value;
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
use windmill_test_utils::*;
|
||||
|
||||
async fn move_to(port: u16, from: &str, to: &str) -> anyhow::Result<String> {
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/{from}"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&serde_json::json!({ "new_path": to }))
|
||||
.send()
|
||||
.await?;
|
||||
// Asserted here so a rejected move fails as itself, rather than as a JSON
|
||||
// parse error in the read-back below.
|
||||
let status = resp.status();
|
||||
let body = resp.text().await?;
|
||||
assert!(status.is_success(), "move {from} -> {to} failed: {body}");
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// The stored draft value at `path`, read back through the API so this test needs
|
||||
/// no `sqlx::query!` (which would want an offline cache entry of its own).
|
||||
async fn value_at(port: u16, path: &str) -> anyhow::Result<Value> {
|
||||
let body: Value = reqwest::Client::new()
|
||||
.get(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/get_own/script/{path}"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
Ok(body
|
||||
.get("value")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| panic!("no draft at {path}: {body}")))
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base", "drafts_move_mirror"))]
|
||||
async fn test_move_carries_both_path_keys(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
move_to(port, "u/test-user/draft_mirror", "u/test-user/renamed").await?;
|
||||
move_to(port, "u/test-user/draft_plain", "u/test-user/plain2").await?;
|
||||
|
||||
// The mirror follows: left at `u/test-user/friendly` it would win at load and
|
||||
// walk the item back there.
|
||||
let moved = value_at(port, "u/test-user/renamed").await?;
|
||||
assert_eq!(moved["path"], "u/test-user/renamed");
|
||||
assert_eq!(moved["draft_path"], "u/test-user/renamed");
|
||||
|
||||
// A draft that never had a mirror must not be given one.
|
||||
let plain = value_at(port, "u/test-user/plain2").await?;
|
||||
assert_eq!(plain["path"], "u/test-user/plain2");
|
||||
assert_eq!(plain.get("draft_path"), None, "mirror injected: {plain}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
//! A rename onto a path that already holds a draft is refused.
|
||||
//!
|
||||
//! Nothing deployed can sit at a rename's destination (the deploy conflicts on
|
||||
//! that), but a draft can: a never-deployed item, or a draft left on an archived
|
||||
//! script. Moving onto it would merge two items or strand a row, so the rename
|
||||
//! itself fails, in its own transaction, and the source stays deployed. The
|
||||
//! destination draft here is the deployer's own, which is the same collision.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
use windmill_test_utils::*;
|
||||
|
||||
/// Hex form of script hash 7010, the way the API takes a parent hash.
|
||||
const HEAD_HASH: &str = "0000000000001b62";
|
||||
|
||||
#[sqlx::test(fixtures("base", "drafts_move_taken"))]
|
||||
async fn test_rename_onto_a_draft_is_refused(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let resp = client
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/scripts/create"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({
|
||||
"path": "u/test-user/mvtaken_b",
|
||||
"parent_hash": HEAD_HASH,
|
||||
"summary": "A",
|
||||
"description": "",
|
||||
"content": "export function main() { return 1 }",
|
||||
"language": "deno",
|
||||
"schema": {}
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
let body = resp.text().await?;
|
||||
assert_eq!(status, 400, "rename onto a draft was not refused: {body}");
|
||||
assert!(
|
||||
body.contains("already has a draft"),
|
||||
"unexpected refusal: {body}"
|
||||
);
|
||||
|
||||
// The whole deploy rolled back: the source is still the live head, and the
|
||||
// draft at the destination is untouched.
|
||||
let head: Value = client
|
||||
.get(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/scripts/get/p/u/test-user/mvtaken_a"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(head["hash"], HEAD_HASH, "source was replaced: {head}");
|
||||
assert_eq!(head["archived"], false, "source was archived: {head}");
|
||||
|
||||
let draft: Value = client
|
||||
.get(format!("http://localhost:{port}/api/w/test-workspace/drafts/get_own/script/u/test-user/mvtaken_b"))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
draft["value"]["summary"], "B",
|
||||
"destination draft changed: {draft}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A legacy (ownerless) draft occupies its path too: a deploy there deletes it together
|
||||
/// with the caller's own row, so a move that parks a second draft beside it would discard
|
||||
/// edits the caller never saw. Only an admin can clear it, so the refusal says so.
|
||||
#[sqlx::test(fixtures("base", "drafts_move_taken"))]
|
||||
async fn test_draft_move_refuses_a_legacy_destination(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/mvtaken_b"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({ "new_path": "u/test-user/mvtaken_legacy" }))
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
let body = resp.text().await?;
|
||||
assert_eq!(status, 400, "move onto a legacy draft was allowed: {body}");
|
||||
assert!(
|
||||
body.contains("legacy workspace draft") && body.contains("workspace admin"),
|
||||
"the refusal did not point at the one remedy: {body}"
|
||||
);
|
||||
|
||||
// Both rows stayed where they were: the caller's own, and the legacy one the list
|
||||
// synthesizes under the caller's name.
|
||||
let list: Vec<Value> = reqwest::Client::new()
|
||||
.get(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/list"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
let mut at = list
|
||||
.iter()
|
||||
.filter_map(|d| Some((d["kind"].as_str()?, d["path"].as_str()?)))
|
||||
.filter(|(_, p)| p.starts_with("u/test-user/mvtaken_b") || p.ends_with("mvtaken_legacy"))
|
||||
.collect::<Vec<_>>();
|
||||
at.sort();
|
||||
assert_eq!(
|
||||
at,
|
||||
vec![
|
||||
("script", "u/test-user/mvtaken_b"),
|
||||
("script", "u/test-user/mvtaken_legacy")
|
||||
],
|
||||
"{list:?}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A classic app and a raw app deploy into the same table, so a draft-only move onto
|
||||
/// the other kind's draft must be refused: deploying either path afterwards deletes
|
||||
/// the caller's drafts of both kinds, taking the loser's item with it.
|
||||
#[sqlx::test(fixtures("base", "drafts_move_taken"))]
|
||||
async fn test_draft_move_refuses_the_other_app_kind(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/move/raw_app/u/test-user/mvtaken_raw"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({ "new_path": "u/test-user/mvtaken_app" }))
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
let body = resp.text().await?;
|
||||
assert_eq!(
|
||||
status, 400,
|
||||
"move onto a classic app draft was allowed: {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains("already have a draft at 'u/test-user/mvtaken_app' (app)"),
|
||||
"the refusal did not name the occupying kind: {body}"
|
||||
);
|
||||
|
||||
// Both drafts are untouched.
|
||||
let list: Vec<Value> = reqwest::Client::new()
|
||||
.get(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/list"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
let mut at = list
|
||||
.iter()
|
||||
.filter(|d| matches!(d["kind"].as_str(), Some("app") | Some("raw_app")))
|
||||
.filter_map(|d| Some((d["kind"].as_str()?, d["path"].as_str()?)))
|
||||
.collect::<Vec<_>>();
|
||||
at.sort();
|
||||
assert_eq!(
|
||||
at,
|
||||
vec![
|
||||
("app", "u/test-user/mvtaken_app"),
|
||||
("raw_app", "u/test-user/mvtaken_raw")
|
||||
],
|
||||
"{list:?}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Teammates' drafts of one item share its path by design, so another user's row is no
|
||||
/// obstacle — except across the app pair, where the two kinds are different items on one
|
||||
/// deployed path: deploying either strands the other, and deleting the app takes both.
|
||||
#[sqlx::test(fixtures("base", "drafts_move_taken"))]
|
||||
async fn test_draft_move_refuses_another_users_other_app_kind(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let move_to = |kind: &'static str, from: &'static str, to: &'static str| async move {
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/move/{kind}/{from}"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({ "new_path": to }))
|
||||
.send()
|
||||
.await?;
|
||||
Ok::<_, anyhow::Error>((resp.status(), resp.text().await?))
|
||||
};
|
||||
|
||||
let (status, body) = move_to(
|
||||
"app",
|
||||
"u/test-user/mvtaken_app",
|
||||
"u/test-user/mvtaken_theirs",
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
status, 400,
|
||||
"a classic app was moved onto another user's raw app: {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains("holds another user's raw app draft"),
|
||||
"the refusal did not name the occupant: {body}"
|
||||
);
|
||||
|
||||
// And the other direction, where the occupant reads as the classic kind.
|
||||
let (status, body) = move_to(
|
||||
"raw_app",
|
||||
"u/test-user/mvtaken_raw",
|
||||
"u/test-user/mvtaken_app_theirs",
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
status, 400,
|
||||
"a raw app was moved onto another user's classic app: {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains("holds another user's app draft"),
|
||||
"the refusal did not name the occupant: {body}"
|
||||
);
|
||||
|
||||
// The same-kind case is the ordinary one: two users' drafts of one raw app.
|
||||
let (status, body) = move_to(
|
||||
"raw_app",
|
||||
"u/test-user/mvtaken_raw",
|
||||
"u/test-user/mvtaken_theirs",
|
||||
)
|
||||
.await?;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"a raw app was refused beside another user's raw-app draft: {body}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -78,3 +78,46 @@ async fn test_draft_write_strips_nul(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The lineage the server derives for `draft.base` comes from the same value: read before
|
||||
/// the sanitizer, a NUL in it costs the draft its base, which then reads as up to date
|
||||
/// whatever the head is.
|
||||
#[sqlx::test(fixtures("drafts_nul"))]
|
||||
async fn test_draft_base_is_derived_from_sanitized_value(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/dnul-ws");
|
||||
|
||||
let resp = authed(client().post(format!(
|
||||
"{base}/drafts/update/script/u/dnul-admin/poisoned_base"
|
||||
)))
|
||||
.json(&json!({
|
||||
"value": {
|
||||
"summary": "s",
|
||||
"path": "u/dnul-admin/poisoned_base",
|
||||
"content": "x",
|
||||
"parent_hash": "0000000000001b62\u{0}"
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"save refused: {}",
|
||||
resp.text().await.unwrap_or_default()
|
||||
);
|
||||
|
||||
let stored: Option<String> = sqlx::query_scalar(
|
||||
"SELECT base FROM draft WHERE workspace_id = 'dnul-ws' AND path = 'u/dnul-admin/poisoned_base'",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
stored.as_deref(),
|
||||
Some("0000000000001b62"),
|
||||
"the NUL cost the draft its base: {stored:?}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,587 @@
|
||||
//! A draft save addressed to a path its item moved away from lands on the moved draft.
|
||||
//!
|
||||
//! A move carries every draft on the item to the new path and records where they
|
||||
//! went. An editor left open across it still saves to the old path; the server puts
|
||||
//! the save on the moved draft, keeps the path keys the move gave it, and names the
|
||||
//! new path so the editor can follow. Without the record the save would plant a
|
||||
//! phantom draft-only item at the old location.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
use windmill_test_utils::*;
|
||||
|
||||
/// Hex form of script hash 7030, the way the API takes a parent hash.
|
||||
const HEAD_HASH: &str = "0000000000001b76";
|
||||
|
||||
async fn own_draft_value(port: u16, path: &str) -> anyhow::Result<Value> {
|
||||
let draft: Value = reqwest::Client::new()
|
||||
.get(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/get_own/script/{path}"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
Ok(draft["value"].clone())
|
||||
}
|
||||
|
||||
async fn own_draft_paths(port: u16) -> anyhow::Result<Vec<String>> {
|
||||
let list: Vec<Value> = reqwest::Client::new()
|
||||
.get(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/list"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
Ok(list
|
||||
.iter()
|
||||
.filter(|d| d["kind"] == "script")
|
||||
.filter_map(|d| d["path"].as_str().map(String::from))
|
||||
.filter(|p| p.starts_with("u/test-user/follow_"))
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
|
||||
async fn test_save_follows_a_rename(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Rename from Home: a redeploy of the deployed content at the new path that
|
||||
// keeps the deployer's own draft, so the draft is carried rather than consumed.
|
||||
let resp = client
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/scripts/create"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({
|
||||
"path": "u/test-user/follow_b",
|
||||
"parent_hash": HEAD_HASH,
|
||||
"summary": "A",
|
||||
"description": "",
|
||||
"content": "export function main() { return 1 }",
|
||||
"language": "deno",
|
||||
"schema": {},
|
||||
"skip_draft_deletion": true
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 201, "rename failed: {}", resp.text().await?);
|
||||
assert_eq!(own_draft_paths(port).await?, vec!["u/test-user/follow_b"]);
|
||||
// A script draft's `path` is where deploying it lands, so it moves with the row.
|
||||
assert_eq!(
|
||||
own_draft_value(port, "u/test-user/follow_b").await?["path"],
|
||||
"u/test-user/follow_b"
|
||||
);
|
||||
|
||||
// The editor is still on the old path and writes that path back into the value.
|
||||
let saved: Value = client
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/update/script/u/test-user/follow_a"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({
|
||||
"value": {
|
||||
"path": "u/test-user/follow_a",
|
||||
"parent_hash": HEAD_HASH,
|
||||
"summary": "A",
|
||||
"content": "edited after the move"
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(saved["status"], "saved", "save refused: {saved}");
|
||||
assert_eq!(
|
||||
saved["path"], "u/test-user/follow_b",
|
||||
"save did not follow the row: {saved}"
|
||||
);
|
||||
|
||||
// The write landed on the carried row; nothing reappeared at the old path.
|
||||
assert_eq!(own_draft_paths(port).await?, vec!["u/test-user/follow_b"]);
|
||||
let draft = own_draft_value(port, "u/test-user/follow_b").await?;
|
||||
assert_eq!(draft["content"], "edited after the move", "{draft}");
|
||||
assert_eq!(draft["path"], "u/test-user/follow_b", "{draft}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A draft-only move rewrites both path keys. The owner's open editor still carries
|
||||
/// the typed path it had, which names neither the old nor the new path; the moved
|
||||
/// draft's own keys have to win, or the save walks the item back.
|
||||
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
|
||||
async fn test_save_follows_a_draft_only_move(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let resp = client
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/draft_store"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({ "new_path": "u/test-user/moved" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"move failed: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
let saved: Value = client
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/update/script/u/test-user/draft_store"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({
|
||||
"value": {
|
||||
"path": "u/test-user/friendly",
|
||||
"draft_path": "u/test-user/friendly",
|
||||
"summary": "D",
|
||||
"content": "edited after the move"
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(saved["path"], "u/test-user/moved", "{saved}");
|
||||
|
||||
let draft = own_draft_value(port, "u/test-user/moved").await?;
|
||||
assert_eq!(draft["content"], "edited after the move", "{draft}");
|
||||
assert_eq!(draft["path"], "u/test-user/moved", "{draft}");
|
||||
assert_eq!(draft["draft_path"], "u/test-user/moved", "{draft}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rename `from` to `to` the way Home does: redeploy the deployed content at the new
|
||||
/// path, keeping the deployer's own draft so it is carried rather than consumed.
|
||||
/// Returns the new head's hash.
|
||||
async fn rename(port: u16, from_hash: &str, to: &str) -> anyhow::Result<String> {
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/scripts/create"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({
|
||||
"path": to,
|
||||
"parent_hash": from_hash,
|
||||
"summary": "A",
|
||||
"description": "",
|
||||
"content": "export function main() { return 1 }",
|
||||
"language": "deno",
|
||||
"schema": {},
|
||||
"skip_draft_deletion": true
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
let hash = resp.text().await?;
|
||||
assert_eq!(status, 201, "rename to {to} failed: {hash}");
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
/// Save the draft as an editor still bound to `url_path` would. Returns the path the
|
||||
/// save landed at.
|
||||
async fn save_at(port: u16, url_path: &str, content: &str) -> anyhow::Result<String> {
|
||||
let saved: Value = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/update/script/{url_path}"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({
|
||||
"value": { "path": url_path, "summary": "A", "content": content, "language": "deno" }
|
||||
}))
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(saved["status"], "saved", "save refused: {saved}");
|
||||
Ok(saved["path"].as_str().unwrap_or_default().to_string())
|
||||
}
|
||||
|
||||
/// A record is kept to one hop, and a move back to the path it left ends it: both are
|
||||
/// three statements whose order decides the answer.
|
||||
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
|
||||
async fn test_move_records_stay_one_hop(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let b = rename(port, HEAD_HASH, "u/test-user/follow_b").await?;
|
||||
let _c = rename(port, &b, "u/test-user/follow_c").await?;
|
||||
assert_eq!(
|
||||
save_at(port, "u/test-user/follow_a", "after two moves").await?,
|
||||
"u/test-user/follow_c",
|
||||
"a save at the first path did not reach the last"
|
||||
);
|
||||
assert_eq!(own_draft_paths(port).await?, vec!["u/test-user/follow_c"]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
|
||||
async fn test_move_back_ends_the_record(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let b = rename(port, HEAD_HASH, "u/test-user/follow_b").await?;
|
||||
let _a = rename(port, &b, "u/test-user/follow_a").await?;
|
||||
assert_eq!(
|
||||
save_at(port, "u/test-user/follow_a", "after moving back").await?,
|
||||
"u/test-user/follow_a",
|
||||
"a save was routed off the path the item moved back to"
|
||||
);
|
||||
assert_eq!(own_draft_paths(port).await?, vec!["u/test-user/follow_a"]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A rename carries a teammate's row too: both its path keys follow, and the version
|
||||
/// it forked from does not move. A restamp there would clear their out-of-date prompt
|
||||
/// and let them deploy over the mover's version believing they were current.
|
||||
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
|
||||
async fn test_a_teammates_draft_follows_with_its_base(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
rename(port, HEAD_HASH, "u/test-user/follow_b").await?;
|
||||
|
||||
// Read from the pool: the teammate's row is another user's, and this asserts on
|
||||
// `base`, which no endpoint exposes for someone else's draft.
|
||||
let row: (String, String, Option<String>) = sqlx::query_as(
|
||||
"SELECT value::jsonb ->> 'path', value::jsonb ->> 'draft_path', base
|
||||
FROM draft WHERE workspace_id = 'test-workspace' AND typ = 'script'
|
||||
AND email = 'test2@windmill.dev'",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(row.0, "u/test-user/follow_b", "typed path did not follow");
|
||||
assert_eq!(row.1, "u/test-user/follow_b", "mirror did not follow");
|
||||
assert_eq!(
|
||||
row.2.as_deref(),
|
||||
Some(HEAD_HASH),
|
||||
"the teammate's base was restamped by someone else's rename"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An item move and then the owner's own move of what is left: the two records have
|
||||
/// different scopes, so the owner's move has to extend the chain in its own scope or
|
||||
/// a save addressed to the first path stops at the abandoned middle one.
|
||||
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
|
||||
async fn test_an_owner_move_extends_an_item_move(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
rename(port, HEAD_HASH, "u/test-user/follow_b").await?;
|
||||
// Archiving the script at the new path leaves the carried draft as a draft-only
|
||||
// item, which its owner can move through `/drafts/move`.
|
||||
sqlx::query("UPDATE script SET archived = true WHERE path = 'u/test-user/follow_b'")
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/follow_b"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({ "new_path": "u/test-user/follow_c" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"move failed: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
save_at(port, "u/test-user/follow_a", "after both moves").await?,
|
||||
"u/test-user/follow_c",
|
||||
"a save at the first path stopped at the path the owner's move left"
|
||||
);
|
||||
assert_eq!(own_draft_paths(port).await?, vec!["u/test-user/follow_c"]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Redeploying at a path an owner's move routed away from ends that route: the live item
|
||||
/// owns its path again, and its saves must not follow the draft that left.
|
||||
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
|
||||
async fn test_redeploy_at_a_routed_path_ends_the_route(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
// `move_draft` ignores archived rows, so an archived script's draft can be moved away.
|
||||
sqlx::query("UPDATE script SET archived = true WHERE path = 'u/test-user/follow_a'")
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/follow_a"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({ "new_path": "u/test-user/follow_b" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"move failed: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
// Unarchiving redeploys at the same path, with the archived version as parent.
|
||||
rename(port, HEAD_HASH, "u/test-user/follow_a").await?;
|
||||
|
||||
assert_eq!(
|
||||
save_at(port, "u/test-user/follow_a", "for the live script").await?,
|
||||
"u/test-user/follow_a",
|
||||
"a save for the redeployed script followed the draft that moved away"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A draft written before the NUL sanitizer still has to follow a move: its path keys are
|
||||
/// what a deploy of it would land on, so the carry rewrites them, sanitizing the value it
|
||||
/// could not otherwise parse.
|
||||
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
|
||||
async fn test_a_poisoned_draft_follows_a_rename(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
// The teammate's row, rewritten the way a pre-sanitizer client left one: a real NUL
|
||||
// escape in the content, both path keys naming the path the item is about to leave.
|
||||
sqlx::query(
|
||||
r#"UPDATE draft SET value = '{"path": "u/test-user/follow_a", "draft_path": "u/test-user/follow_a",
|
||||
"parent_hash": "0000000000001b76", "summary": "A", "content": "a\u0000b"}'
|
||||
WHERE email = 'test2@windmill.dev'"#,
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
rename(port, HEAD_HASH, "u/test-user/follow_b").await?;
|
||||
|
||||
let row: (String, String, String) = sqlx::query_as(
|
||||
"SELECT value::jsonb ->> 'path', value::jsonb ->> 'draft_path', value::jsonb ->> 'content'
|
||||
FROM draft WHERE email = 'test2@windmill.dev'",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(row.0, "u/test-user/follow_b", "typed path did not follow");
|
||||
assert_eq!(row.1, "u/test-user/follow_b", "mirror did not follow");
|
||||
assert_eq!(row.2, "ab", "the NUL survived the rewrite");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A discard whose answer is lost is retried, and the retry finds nothing to delete. It
|
||||
/// still has to name where the item went: the editor asking is on the path it left, and a
|
||||
/// reload there lands on nothing.
|
||||
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
|
||||
async fn test_a_retried_discard_still_names_the_destination(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
rename(port, HEAD_HASH, "u/test-user/follow_b").await?;
|
||||
|
||||
let discard = |token: &'static str| {
|
||||
let client = client.clone();
|
||||
async move {
|
||||
client
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/update/script/u/test-user/follow_a"
|
||||
))
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&json!({ "value": null }))
|
||||
.send()
|
||||
.await?
|
||||
.json::<Value>()
|
||||
.await
|
||||
}
|
||||
};
|
||||
|
||||
let first = discard("SECRET_TOKEN").await?;
|
||||
assert_eq!(first["status"], "saved", "{first}");
|
||||
assert_eq!(first["path"], "u/test-user/follow_b", "{first}");
|
||||
|
||||
let retry = discard("SECRET_TOKEN").await?;
|
||||
assert_eq!(
|
||||
retry["status"], "saved",
|
||||
"the retry was not idempotent: {retry}"
|
||||
);
|
||||
assert_eq!(retry["path"], "u/test-user/follow_b", "{retry}");
|
||||
|
||||
// A third user has no draft on this item, so their discard deletes nothing and the
|
||||
// destination is a move record and nothing else: it is answered only to a caller who
|
||||
// can read that path.
|
||||
let other = discard("SECRET_TOKEN_3").await?;
|
||||
assert_eq!(other["status"], "saved", "{other}");
|
||||
assert_eq!(
|
||||
other["path"],
|
||||
Value::Null,
|
||||
"a user who cannot read the destination was told where the item went: {other}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The legacy workspace-level row is carried by a rename like any other draft, and the
|
||||
/// record that routes saves to it covers every caller — so discarding it from a page that
|
||||
/// still names the old path has to reach it where it went.
|
||||
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
|
||||
async fn test_a_legacy_discard_follows_a_rename(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
sqlx::query(
|
||||
r#"INSERT INTO draft (workspace_id, path, typ, value, email)
|
||||
VALUES ('test-workspace', 'u/test-user/follow_a', 'script',
|
||||
'{"path": "u/test-user/follow_a", "summary": "legacy", "content": "x"}', NULL)"#,
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
rename(port, HEAD_HASH, "u/test-user/follow_b").await?;
|
||||
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/update/script/u/test-user/follow_a"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({ "value": null, "legacy": true }))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"discard failed: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
let left: i64 = sqlx::query_scalar(
|
||||
"SELECT count(*) FROM draft WHERE workspace_id = 'test-workspace' AND email IS NULL",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
left, 0,
|
||||
"the legacy draft survived a discard aimed at its old path"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Deploying a draft that a move carried off an archived script: its parent is still the
|
||||
/// version at the old path, so the deploy renames from there and carries what is left over
|
||||
/// — onto the very draft being deployed. That row is this deploy's own, not an item in its
|
||||
/// way, or the deploy is refused and every retry refuses again.
|
||||
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
|
||||
async fn test_a_moved_draft_deploys_at_its_new_path(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
sqlx::query("UPDATE script SET archived = true WHERE path = 'u/test-user/follow_a'")
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/follow_a"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({ "new_path": "u/test-user/follow_b" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"move failed: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
// Deploy it where it now lives, still parented on the archived version it forked from.
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/scripts/create"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({
|
||||
"path": "u/test-user/follow_b",
|
||||
"parent_hash": HEAD_HASH,
|
||||
"summary": "A",
|
||||
"description": "",
|
||||
"content": "export function main() { return 2 }",
|
||||
"language": "deno",
|
||||
"schema": {}
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
let body = resp.text().await?;
|
||||
assert_eq!(status, 201, "the moved draft could not be deployed: {body}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A route is only as good as the item it points at: when an unrelated item claims the
|
||||
/// destination, a save still addressed to the old path must stay where it is rather than
|
||||
/// land on that item's draft.
|
||||
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
|
||||
async fn test_a_reused_destination_ends_the_route(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
sqlx::query("UPDATE script SET archived = true WHERE path = 'u/test-user/follow_a'")
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/follow_a"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({ "new_path": "u/test-user/follow_b" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"move failed: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
// Someone else's item takes the destination, and the moved draft goes with the
|
||||
// deploy that consumes it.
|
||||
sqlx::query("DELETE FROM draft WHERE workspace_id = 'test-workspace' AND path = 'u/test-user/follow_b'")
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/scripts/create"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({
|
||||
"path": "u/test-user/follow_b",
|
||||
"summary": "unrelated",
|
||||
"description": "",
|
||||
"content": "export function main() { return 3 }",
|
||||
"language": "deno",
|
||||
"schema": {}
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 201, "create failed: {}", resp.text().await?);
|
||||
|
||||
assert_eq!(
|
||||
save_at(port, "u/test-user/follow_a", "after the destination was reused").await?,
|
||||
"u/test-user/follow_a",
|
||||
"a save was routed onto the item that now owns the destination"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
-- Fixture for the two-path-key move test.
|
||||
--
|
||||
-- Two draft-only scripts owned by test-user, both parked at a generated storage
|
||||
-- key the way a session-created draft is. One carries the `draft_path` mirror the
|
||||
-- session editor writes while the typed path differs from that key; the other
|
||||
-- carries no mirror at all, which is what a plain draft looks like.
|
||||
|
||||
INSERT INTO draft (workspace_id, path, typ, value, email) VALUES
|
||||
('test-workspace', 'u/test-user/draft_mirror', 'script',
|
||||
'{"path": "u/test-user/friendly", "draft_path": "u/test-user/friendly",
|
||||
"content": "x", "language": "bun", "summary": "S"}',
|
||||
'test@windmill.dev'),
|
||||
('test-workspace', 'u/test-user/draft_plain', 'script',
|
||||
'{"path": "u/test-user/draft_plain",
|
||||
"content": "x", "language": "bun", "summary": "S"}',
|
||||
'test@windmill.dev');
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
-- Fixture for refusing a rename onto a path a draft already occupies.
|
||||
--
|
||||
-- A deployed script at `u/test-user/mvtaken_a` (hash 7010 = 0x1b62), and a
|
||||
-- never-deployed draft of test-user's own at `u/test-user/mvtaken_b`, the path
|
||||
-- the rename will target. Nothing deployed lives at the target, so only the
|
||||
-- draft can refuse the move.
|
||||
|
||||
INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by,
|
||||
schema, summary, description, lock, extra_perms)
|
||||
VALUES ('test-workspace', 7010, 'u/test-user/mvtaken_a',
|
||||
'export function main() { return 1 }',
|
||||
'deno', 'script', 'test-user', '{}', 'A', '', '', '{}');
|
||||
|
||||
INSERT INTO draft (workspace_id, path, typ, value, email)
|
||||
VALUES ('test-workspace', 'u/test-user/mvtaken_b', 'script',
|
||||
'{"path": "u/test-user/mvtaken_b", "summary": "B", "content": ""}',
|
||||
'test@windmill.dev');
|
||||
|
||||
-- An ownerless (pre per-user drafts) row at a third path: a deploy there deletes it
|
||||
-- together with the caller's, so it occupies the path just as an own draft does.
|
||||
INSERT INTO draft (workspace_id, path, typ, value, email)
|
||||
VALUES ('test-workspace', 'u/test-user/mvtaken_legacy', 'script',
|
||||
'{"path": "u/test-user/mvtaken_legacy", "summary": "legacy", "content": ""}',
|
||||
NULL);
|
||||
|
||||
-- A draft-only classic app and a draft-only raw app of the same owner. They share
|
||||
-- the `app` table, so one occupies the other's path. The third is another user's raw
|
||||
-- app: a different item at its own path, not a second copy of anyone's.
|
||||
INSERT INTO draft (workspace_id, path, typ, value, email) VALUES
|
||||
('test-workspace', 'u/test-user/mvtaken_app', 'app',
|
||||
'{"summary": "classic", "value": {}}', 'test@windmill.dev'),
|
||||
('test-workspace', 'u/test-user/mvtaken_raw', 'raw_app',
|
||||
'{"summary": "raw", "files": {}}', 'test@windmill.dev'),
|
||||
('test-workspace', 'u/test-user/mvtaken_theirs', 'raw_app',
|
||||
'{"summary": "theirs", "files": {}}', 'test2@windmill.dev'),
|
||||
('test-workspace', 'u/test-user/mvtaken_app_theirs', 'app',
|
||||
'{"summary": "theirs", "value": {}}', 'test2@windmill.dev');
|
||||
@@ -0,0 +1,29 @@
|
||||
-- Fixture for saving a draft to the path its item moved away from.
|
||||
--
|
||||
-- A deployed script at `u/test-user/follow_a` (hash 7030 = 0x1b76) with the
|
||||
-- deployer's own draft on it. The test renames the script and then saves the
|
||||
-- draft from an editor still bound to the old path.
|
||||
|
||||
INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by,
|
||||
schema, summary, description, lock, extra_perms)
|
||||
VALUES ('test-workspace', 7030, 'u/test-user/follow_a',
|
||||
'export function main() { return 1 }',
|
||||
'deno', 'script', 'test-user', '{}', 'A', '', '', '{}');
|
||||
|
||||
INSERT INTO draft (workspace_id, path, typ, value, email, base)
|
||||
VALUES ('test-workspace', 'u/test-user/follow_a', 'script',
|
||||
'{"path": "u/test-user/follow_a", "parent_hash": "0000000000001b76", "summary": "A", "content": "draft"}',
|
||||
'test@windmill.dev', '0000000000001b76');
|
||||
|
||||
-- A draft-only script parked at a generated storage key, its typed path elsewhere.
|
||||
INSERT INTO draft (workspace_id, path, typ, value, email)
|
||||
VALUES ('test-workspace', 'u/test-user/draft_store', 'script',
|
||||
'{"path": "u/test-user/friendly", "draft_path": "u/test-user/friendly", "summary": "D", "content": "draft"}',
|
||||
'test@windmill.dev');
|
||||
|
||||
-- A teammate's draft on the same deployed script, forked from the same head. The
|
||||
-- rename must carry it too, without touching the version it forked from.
|
||||
INSERT INTO draft (workspace_id, path, typ, value, email, base)
|
||||
VALUES ('test-workspace', 'u/test-user/follow_a', 'script',
|
||||
'{"path": "u/test-user/follow_a", "draft_path": "u/test-user/follow_a", "parent_hash": "0000000000001b76", "summary": "A", "content": "teammate draft"}',
|
||||
'test2@windmill.dev', '0000000000001b76');
|
||||
@@ -51,7 +51,10 @@ use windmill_common::{
|
||||
jobs::JobPayload,
|
||||
schedule::Schedule,
|
||||
triggers::MovedNativeTrigger,
|
||||
utils::{http_get_from_hub, not_found_if_none, paginate, Pagination, RunnableKind, StripPath},
|
||||
utils::{
|
||||
http_get_from_hub, not_found_if_none, paginate, paginate_optional, Pagination,
|
||||
RunnableKind, StripPath,
|
||||
},
|
||||
};
|
||||
use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap;
|
||||
use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
|
||||
@@ -747,6 +750,14 @@ async fn create_flow(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
windmill_common::user_drafts::clear_draft_moves_from(
|
||||
&mut tx,
|
||||
&w_id,
|
||||
&[UserDraftItemKind::Flow],
|
||||
&nf.path,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
@@ -915,25 +926,36 @@ pub struct FlowVersion {
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deployment_msg: Option<String>,
|
||||
/// Who deployed this version — the diff's version picker names them so a reader
|
||||
/// can tell their own deploys from a teammate's.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub created_by: Option<String>,
|
||||
}
|
||||
|
||||
async fn get_flow_history(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
Query(pagination): Query<Pagination>,
|
||||
) -> JsonResult<Vec<FlowVersion>> {
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("flows:read:{}", path))?;
|
||||
// Unasked-for, this listing stays whole: the history panels, the restart picker and
|
||||
// the CLI all read it without paging. The diff picker asks for a page.
|
||||
let (per_page, offset) = paginate_optional(pagination);
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let flows = sqlx::query_as!(
|
||||
FlowVersion,
|
||||
"SELECT flow_version.id, flow_version.created_at, deployment_metadata.deployment_msg FROM flow_version
|
||||
"SELECT flow_version.id, flow_version.created_at, flow_version.created_by, deployment_metadata.deployment_msg FROM flow_version
|
||||
LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version
|
||||
WHERE flow_version.path = $1 AND flow_version.workspace_id = $2
|
||||
ORDER BY flow_version.created_at DESC",
|
||||
WHERE flow_version.path = $1 AND flow_version.workspace_id = $2
|
||||
ORDER BY flow_version.created_at DESC
|
||||
LIMIT $3 OFFSET $4",
|
||||
path,
|
||||
w_id
|
||||
w_id,
|
||||
per_page,
|
||||
offset,
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
@@ -953,7 +975,7 @@ async fn get_latest_version(
|
||||
|
||||
let version = sqlx::query_as!(
|
||||
FlowVersion,
|
||||
"SELECT flow_version.id, flow_version.created_at, deployment_metadata.deployment_msg FROM flow_version
|
||||
"SELECT flow_version.id, flow_version.created_at, flow_version.created_by, deployment_metadata.deployment_msg FROM flow_version
|
||||
LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version
|
||||
WHERE flow_version.path = $1 AND flow_version.workspace_id = $2
|
||||
ORDER BY flow_version.created_at DESC",
|
||||
@@ -1438,6 +1460,20 @@ async fn update_flow(
|
||||
.await?;
|
||||
}
|
||||
|
||||
if is_new_path {
|
||||
// Everything left at the old path is a draft this deploy didn't consume
|
||||
// — teammates' rows, and the deployer's own when the caller asked us to
|
||||
// keep it. Carry them rather than strand them.
|
||||
windmill_common::user_drafts::move_drafts_for_path(
|
||||
&mut tx,
|
||||
&w_id,
|
||||
&[UserDraftItemKind::Flow],
|
||||
flow_path,
|
||||
&nf.path,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
|
||||
@@ -407,9 +407,18 @@ async fn test_fork_repoints_unresolvable_identities(db: Pool<Postgres>) -> anyho
|
||||
text("SELECT value->'policy'->>'on_behalf_of' FROM draft WHERE workspace_id = 'wm-fork-repoint' AND path = 'u/test-user/stranger' AND typ = 'raw_app'").await?,
|
||||
"u/test-user"
|
||||
);
|
||||
// `clone_drafts` strips a NUL escape as it copies, so the row reaches the fork
|
||||
// parseable and the repoint below reaches it like any other draft's. The rule this
|
||||
// guards is that the fork completes and no identity naming nobody survives it; the
|
||||
// skip only ever existed because `to_jsonb` raises on a value still holding one.
|
||||
assert_eq!(
|
||||
text("SELECT CASE WHEN strpos(value::text, 'u/test-user-2') > 0 THEN 'kept' ELSE 'rewritten' END FROM draft WHERE workspace_id = 'wm-fork-repoint' AND path = 'u/test-user/nul'").await?,
|
||||
"kept"
|
||||
"rewritten"
|
||||
);
|
||||
// And it arrives without the poison that made it a special case.
|
||||
assert_eq!(
|
||||
text("SELECT CASE WHEN position(chr(92) || 'u0000' in value::text) > 0 THEN 'poisoned' ELSE 'clean' END FROM draft WHERE workspace_id = 'wm-fork-repoint' AND path = 'u/test-user/nul'").await?,
|
||||
"clean"
|
||||
);
|
||||
assert_eq!(
|
||||
text("SELECT value->>'permissioned_as' FROM draft WHERE workspace_id = 'wm-fork-repoint' AND typ = 'trigger_websocket'").await?,
|
||||
|
||||
@@ -55,7 +55,7 @@ use windmill_common::{
|
||||
min_version_supports_runnable_settings_v0, RunnableSettings, RunnableSettingsTrait,
|
||||
},
|
||||
scripts::{hash_script, ScriptRunnableSettingsHandle, ScriptRunnableSettingsInline},
|
||||
utils::{paginate_without_limits, WarnAfterExt},
|
||||
utils::{paginate_optional, paginate_without_limits, WarnAfterExt},
|
||||
worker::CLOUD_HOSTED,
|
||||
};
|
||||
use windmill_object_store::upload_artifact_to_store;
|
||||
@@ -2312,6 +2312,20 @@ async fn create_script_internal<'c>(
|
||||
.await?;
|
||||
}
|
||||
|
||||
if p_path != &ns.path {
|
||||
// Everything left at the old path is a draft this deploy didn't
|
||||
// consume — teammates' rows, and the deployer's own when the caller
|
||||
// asked us to keep it. Carry them rather than strand them.
|
||||
windmill_common::user_drafts::move_drafts_for_path(
|
||||
&mut tx,
|
||||
&w_id,
|
||||
&[UserDraftItemKind::Script],
|
||||
p_path,
|
||||
&ns.path,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE capture_config SET path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS FALSE",
|
||||
ns.path,
|
||||
@@ -2389,19 +2403,32 @@ async fn create_script_internal<'c>(
|
||||
tx = push_scheduled_job(&db, tx, &schedule, None, None).await?;
|
||||
}
|
||||
}
|
||||
} else if !skip_draft_deletion {
|
||||
// See the matching branch above — only wipe the deployer's own
|
||||
// draft (plus the legacy NULL-email row).
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script' \
|
||||
AND (email = $3 OR email IS NULL)",
|
||||
ns.path,
|
||||
&w_id,
|
||||
&authed.email,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
} else {
|
||||
if !skip_draft_deletion {
|
||||
// See the matching branch above — only wipe the deployer's own
|
||||
// draft (plus the legacy NULL-email row).
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script' \
|
||||
AND (email = $3 OR email IS NULL)",
|
||||
ns.path,
|
||||
&w_id,
|
||||
&authed.email,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
// Every deploy, not only a new script: an archived script's draft can be moved away
|
||||
// (`move_draft` ignores archived rows), and unarchiving redeploys at the same path,
|
||||
// where a route left behind would send the live script's saves to the moved draft.
|
||||
windmill_common::user_drafts::clear_draft_moves_from(
|
||||
&mut tx,
|
||||
&w_id,
|
||||
&[UserDraftItemKind::Script],
|
||||
&ns.path,
|
||||
p_path_opt.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
if p_hashes.is_some() && !p_hashes.unwrap().is_empty() {
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
@@ -3061,17 +3088,24 @@ async fn get_script_history(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
Query(pagination): Query<Pagination>,
|
||||
) -> JsonResult<Vec<ScriptHistory>> {
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("scripts:read:{}", path))?;
|
||||
// Unasked-for, this listing stays whole: the deployment-history panels, the restart
|
||||
// picker and the CLI all read it without paging. The diff picker asks for a page.
|
||||
let (per_page, offset) = paginate_optional(pagination);
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let query_result = sqlx::query!(
|
||||
"SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at
|
||||
"SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at, s.created_by as created_by
|
||||
FROM script s LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash
|
||||
WHERE s.workspace_id = $1 AND s.path = $2
|
||||
ORDER by s.created_at DESC",
|
||||
ORDER by s.created_at DESC
|
||||
LIMIT $3 OFFSET $4",
|
||||
w_id,
|
||||
path,
|
||||
per_page,
|
||||
offset,
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
@@ -3083,6 +3117,7 @@ async fn get_script_history(
|
||||
script_hash: ScriptHash(row.hash),
|
||||
deployment_msg: row.deployment_msg,
|
||||
created_at: Some(row.created_at),
|
||||
created_by: Some(row.created_by),
|
||||
})
|
||||
.collect();
|
||||
return Ok(Json(result));
|
||||
@@ -3097,7 +3132,7 @@ async fn get_latest_version(
|
||||
check_scopes(&authed, || format!("scripts:read:{}", path))?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let row_o = sqlx::query!(
|
||||
"SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at
|
||||
"SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at, s.created_by as created_by
|
||||
FROM script s LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash
|
||||
WHERE s.workspace_id = $1 AND s.path = $2
|
||||
ORDER by s.created_at DESC LIMIT 1",
|
||||
@@ -3113,6 +3148,7 @@ async fn get_latest_version(
|
||||
script_hash: ScriptHash(row.hash),
|
||||
deployment_msg: row.deployment_msg,
|
||||
created_at: Some(row.created_at),
|
||||
created_by: Some(row.created_by),
|
||||
};
|
||||
return Ok(Json(Some(result)));
|
||||
} else {
|
||||
|
||||
@@ -7961,14 +7961,32 @@ async fn clone_drafts(
|
||||
// filtered like `clone_scripts`: the address the draft still carries re-derives the
|
||||
// clone's own principal at deploy time, which is the more accurate answer of the two.
|
||||
sqlx::query!(
|
||||
"INSERT INTO draft (workspace_id, path, typ, value, created_at, email)
|
||||
// A script hash is content-addressed and copied as-is, so a script draft's base
|
||||
// still names a version the clone has. `clone_flows` / `clone_apps` mint new ids,
|
||||
// so those drafts arrive with no base (staleness falls back to the timestamps),
|
||||
// lineage field included, or the next autosave would re-derive the source id.
|
||||
//
|
||||
// `clean` is `strip_json_nul`'s parity rule in SQL, so a pre-sanitizer U+0000
|
||||
// escape cannot abort the clone on `to_jsonb` or arrive with its principal
|
||||
// unstripped: escaped backslashes park on chr(1) (lossless, a `json` value's text
|
||||
// cannot hold a raw control byte) so only a real NUL is removed, and chr(92)
|
||||
// spells the backslash so no escape sequence reaches this source file.
|
||||
r#"INSERT INTO draft (workspace_id, path, typ, value, created_at, email, base)
|
||||
SELECT $2, path, typ,
|
||||
CASE WHEN typ IN ('script', 'flow')
|
||||
THEN to_json(to_jsonb(value) - 'on_behalf_of')
|
||||
ELSE value END,
|
||||
created_at, email
|
||||
FROM draft
|
||||
WHERE workspace_id = $1 AND (email = $3 OR email IS NULL)",
|
||||
to_json(
|
||||
CASE WHEN typ IN ('script', 'flow') THEN clean - 'on_behalf_of' ELSE clean END
|
||||
- CASE WHEN typ = 'flow' THEN 'version_id'
|
||||
WHEN typ IN ('app', 'raw_app') THEN 'parent_version'
|
||||
ELSE '' END
|
||||
),
|
||||
created_at, email,
|
||||
CASE WHEN typ = 'script' THEN base END
|
||||
FROM (
|
||||
SELECT d.path, d.typ, d.created_at, d.email, d.base,
|
||||
replace(replace(replace(d.value::text, chr(92) || chr(92), chr(1)), chr(92) || 'u0000', ''), chr(1), chr(92) || chr(92))::jsonb AS clean
|
||||
FROM draft d
|
||||
WHERE d.workspace_id = $1 AND (d.email = $3 OR d.email IS NULL)
|
||||
) s"#,
|
||||
source_workspace_id,
|
||||
target_workspace_id,
|
||||
authed_email,
|
||||
|
||||
@@ -382,6 +382,13 @@ pub(crate) async fn change_workspace_id(
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
"UPDATE draft_move SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
info!("Updating favorite table");
|
||||
sqlx::query!(
|
||||
|
||||
@@ -10392,8 +10392,49 @@ paths:
|
||||
current_timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
path:
|
||||
type: string
|
||||
description: "`saved` only, upsert or delete: where the write landed. Differs from the URL path when the item had moved away from it; the editor follows it there. Absent when a delete found nothing to remove and the caller cannot read the path it moved to."
|
||||
required: [status, current_timestamp]
|
||||
|
||||
/w/{workspace}/drafts/move/{kind}/{path}:
|
||||
post:
|
||||
summary: move the current user's draft-only item to another path
|
||||
description: Relocates the authed user's own draft row, along with both path keys inside its value (the typed path and the mirror the editors keep beside it). Only for draft-only items — a deployed item must be moved through its own deploy endpoint, which carries every draft with it. Restricted to script, flow, app and raw_app; any other kind is rejected with 400, because only these keep their deploy target where this endpoint rewrites it.
|
||||
operationId: moveDraft
|
||||
tags:
|
||||
- draft
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: kind
|
||||
in: path
|
||||
required: true
|
||||
description: script, flow, app or raw_app only.
|
||||
schema:
|
||||
type: string
|
||||
enum: [script, flow, app, raw_app]
|
||||
- $ref: "#/components/parameters/ScriptPath"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
new_path:
|
||||
type: string
|
||||
summary:
|
||||
type: string
|
||||
description: Also restate the draft's summary.
|
||||
required: [new_path]
|
||||
responses:
|
||||
"200":
|
||||
description: move result
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/drafts/migrate_legacy/{kind}/{path}:
|
||||
post:
|
||||
summary: resolve a legacy (workspace-level) draft (admin only)
|
||||
@@ -10904,6 +10945,8 @@ paths:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/ScriptPath"
|
||||
- $ref: "#/components/parameters/Page"
|
||||
- $ref: "#/components/parameters/PerPage"
|
||||
responses:
|
||||
"200":
|
||||
description: script history
|
||||
@@ -12309,6 +12352,8 @@ paths:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/ScriptPath"
|
||||
- $ref: "#/components/parameters/Page"
|
||||
- $ref: "#/components/parameters/PerPage"
|
||||
tags:
|
||||
- flow
|
||||
responses:
|
||||
@@ -14187,6 +14232,8 @@ paths:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/ScriptPath"
|
||||
- $ref: "#/components/parameters/Page"
|
||||
- $ref: "#/components/parameters/PerPage"
|
||||
responses:
|
||||
"200":
|
||||
description: app history
|
||||
@@ -14454,9 +14501,9 @@ paths:
|
||||
"200":
|
||||
description: app updated
|
||||
content:
|
||||
text/plain:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
$ref: "#/components/schemas/AppDeployed"
|
||||
|
||||
/w/{workspace}/apps/create_raw_source:
|
||||
post:
|
||||
@@ -14596,9 +14643,9 @@ paths:
|
||||
"200":
|
||||
description: app updated
|
||||
content:
|
||||
text/plain:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
$ref: "#/components/schemas/AppDeployed"
|
||||
|
||||
/w/{workspace}/apps/update_raw/{path}:
|
||||
post:
|
||||
@@ -14652,9 +14699,9 @@ paths:
|
||||
"200":
|
||||
description: app updated
|
||||
content:
|
||||
text/plain:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
$ref: "#/components/schemas/AppDeployed"
|
||||
|
||||
/w/{workspace}/apps/custom_path_exists/{custom_path}:
|
||||
get:
|
||||
@@ -27702,6 +27749,13 @@ components:
|
||||
draft_saved_at:
|
||||
type: string
|
||||
format: date-time
|
||||
draft_base:
|
||||
type: string
|
||||
description: |
|
||||
The deployed version the draft forked from, as text whatever the
|
||||
kind (script hash, flow version id, app version id). Compare to the
|
||||
deployed head to tell a draft that is behind. Absent when there is
|
||||
no draft or it was never forked from a deploy.
|
||||
no_deployed:
|
||||
type: boolean
|
||||
draft:
|
||||
@@ -29597,6 +29651,11 @@ components:
|
||||
type: string
|
||||
deployment_msg:
|
||||
type: string
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
created_by:
|
||||
type: string
|
||||
required:
|
||||
- script_hash
|
||||
|
||||
@@ -35493,6 +35552,20 @@ components:
|
||||
- raw_app
|
||||
|
||||
|
||||
AppDeployed:
|
||||
type: object
|
||||
description: "What a deploy of an existing app answers with. `version` is the one this call wrote, which is what an editor pins as the fork base of the draft it starts next: reading the head back afterwards cannot tell it from a deploy that landed beside it. A metadata-only update writes none and reports the head it kept."
|
||||
properties:
|
||||
path:
|
||||
type: string
|
||||
description: Where the app now lives, which differs from the request path on a rename.
|
||||
version:
|
||||
type: integer
|
||||
format: int64
|
||||
required:
|
||||
- path
|
||||
- version
|
||||
|
||||
AppHistory:
|
||||
type: object
|
||||
properties:
|
||||
@@ -35500,6 +35573,11 @@ components:
|
||||
type: integer
|
||||
deployment_msg:
|
||||
type: string
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
created_by:
|
||||
type: string
|
||||
required:
|
||||
- version
|
||||
|
||||
@@ -35569,6 +35647,8 @@ components:
|
||||
format: date-time
|
||||
deployment_msg:
|
||||
type: string
|
||||
created_by:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- created_at
|
||||
|
||||
@@ -69,8 +69,8 @@ use windmill_common::{
|
||||
user_drafts::{overlay_or_draft_only, DraftUserRef, UserDraftItemKind, WithDraftOverlay},
|
||||
users::username_to_permissioned_as,
|
||||
utils::{
|
||||
http_get_from_hub, not_found_if_none, paginate, query_elems_from_hub, require_admin,
|
||||
strip_json_nul, Pagination, RunnableKind, StripPath,
|
||||
http_get_from_hub, not_found_if_none, paginate, paginate_optional,
|
||||
query_elems_from_hub, require_admin, strip_json_nul, Pagination, RunnableKind, StripPath,
|
||||
},
|
||||
variables::{build_crypt, build_crypt_with_key_suffix, encrypt},
|
||||
worker::{to_raw_value, CLOUD_HOSTED},
|
||||
@@ -274,6 +274,12 @@ pub struct AppHistory {
|
||||
pub version: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deployment_msg: Option<String>,
|
||||
/// Who deployed this version, and when — the diff's version picker names them so
|
||||
/// a reader can tell their own deploys from a teammate's.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub created_by: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -567,6 +573,17 @@ pub struct CreateApp {
|
||||
pub skip_draft_deletion: Option<bool>,
|
||||
}
|
||||
|
||||
/// What a deploy of an existing app answers with. `version` is the one this call wrote,
|
||||
/// which is what an editor pins as the fork base of the draft it starts next: reading the
|
||||
/// head back afterwards cannot tell it from a deploy that landed beside it. A
|
||||
/// metadata-only update writes none and reports the head it kept.
|
||||
#[derive(Serialize)]
|
||||
pub struct AppDeployed {
|
||||
/// Where the app now lives, which differs from the request path on a rename.
|
||||
pub path: String,
|
||||
pub version: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct EditApp {
|
||||
pub path: Option<String>,
|
||||
@@ -1220,18 +1237,42 @@ async fn get_app_history(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
Query(pagination): Query<Pagination>,
|
||||
) -> JsonResult<Vec<AppHistory>> {
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("apps:read:{}", &path))?;
|
||||
// Unasked-for, this listing stays whole: the deployment-history panel reads it
|
||||
// without paging. The diff picker asks for a page.
|
||||
let (per_page, offset) = paginate_optional(pagination);
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
// Newest first in deployed order, which is a version's position in `app.versions` and
|
||||
// not its `created_at`: the latter is the deploying transaction's start time, so two
|
||||
// that overlap can carry it in the opposite order from the one they landed in. A row
|
||||
// outside the array never sat in that sequence, so it sorts after the ones that did.
|
||||
// Paging happens before the metadata joins, so a page costs its own rows.
|
||||
let query_result = sqlx::query!(
|
||||
"SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg
|
||||
FROM app a LEFT JOIN app_version av ON a.id = av.app_id LEFT JOIN deployment_metadata dm ON av.id = dm.app_version
|
||||
"SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg,
|
||||
av.created_by as created_by, av.created_at as created_at
|
||||
FROM app a
|
||||
JOIN LATERAL (
|
||||
SELECT av2.id, COALESCE(v.ord, 0) AS ord
|
||||
FROM app_version av2
|
||||
LEFT JOIN unnest(a.versions) WITH ORDINALITY AS v(id, ord) ON v.id = av2.id
|
||||
WHERE av2.app_id = a.id
|
||||
ORDER BY ord DESC, av2.id DESC
|
||||
LIMIT $3 OFFSET $4
|
||||
) page ON TRUE
|
||||
JOIN app_version av ON av.id = page.id
|
||||
LEFT JOIN deployment_metadata dm ON av.id = dm.app_version
|
||||
WHERE a.workspace_id = $1 AND a.path = $2
|
||||
ORDER BY created_at DESC",
|
||||
ORDER BY page.ord DESC",
|
||||
w_id,
|
||||
path,
|
||||
).fetch_all(&mut *tx).await?;
|
||||
per_page,
|
||||
offset,
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
let result: Vec<AppHistory> = query_result
|
||||
@@ -1240,6 +1281,8 @@ async fn get_app_history(
|
||||
app_id: row.app_id,
|
||||
version: row.version_id,
|
||||
deployment_msg: row.deployment_msg,
|
||||
created_by: Some(row.created_by),
|
||||
created_at: Some(row.created_at),
|
||||
})
|
||||
.collect();
|
||||
return Ok(Json(result));
|
||||
@@ -1253,14 +1296,22 @@ async fn get_latest_version(
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("apps:read:{}", path))?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
// The head is the tail of `app.versions` — the version the runtime serves. Deploys
|
||||
// append to it under the app row's lock, whereas `app_version.created_at` is the
|
||||
// deploying transaction's start time, so two that overlap can carry it in either
|
||||
// order and the newest timestamp is then not the one that landed last.
|
||||
let row = sqlx::query!(
|
||||
"SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg
|
||||
FROM app a LEFT JOIN app_version av ON a.id = av.app_id LEFT JOIN deployment_metadata dm ON av.id = dm.app_version
|
||||
WHERE a.workspace_id = $1 AND a.path = $2
|
||||
ORDER BY created_at DESC",
|
||||
"SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg,
|
||||
av.created_by as created_by, av.created_at as created_at
|
||||
FROM app a JOIN app_version av
|
||||
ON av.id = a.versions[array_upper(a.versions, 1)] AND av.app_id = a.id
|
||||
LEFT JOIN deployment_metadata dm ON av.id = dm.app_version
|
||||
WHERE a.workspace_id = $1 AND a.path = $2",
|
||||
w_id,
|
||||
path,
|
||||
).fetch_optional(&mut *tx).await?;
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
if let Some(row) = row {
|
||||
@@ -1268,6 +1319,8 @@ async fn get_latest_version(
|
||||
app_id: row.app_id,
|
||||
version: row.version_id,
|
||||
deployment_msg: row.deployment_msg,
|
||||
created_by: Some(row.created_by),
|
||||
created_at: Some(row.created_at),
|
||||
};
|
||||
|
||||
return Ok(Json(Some(result)));
|
||||
@@ -2591,6 +2644,14 @@ async fn create_app_internal<'a>(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
windmill_common::user_drafts::clear_draft_moves_from(
|
||||
&mut tx,
|
||||
&w_id,
|
||||
&[UserDraftItemKind::App, UserDraftItemKind::RawApp],
|
||||
&app.path,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let id = sqlx::query_scalar!(
|
||||
"INSERT INTO app
|
||||
(workspace_id, path, summary, policy, versions, custom_path, labels)
|
||||
@@ -2947,7 +3008,7 @@ async fn update_app(
|
||||
Extension(webhook): Extension<WebhookShared>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
Json(ns): Json<EditApp>,
|
||||
) -> Result<String> {
|
||||
) -> JsonResult<AppDeployed> {
|
||||
if authed.is_operator {
|
||||
return Err(Error::NotAuthorized(
|
||||
"Operators cannot update apps for security reasons".to_string(),
|
||||
@@ -2986,7 +3047,7 @@ async fn update_app(
|
||||
},
|
||||
);
|
||||
|
||||
Ok(format!("app {} updated (npath: {:?})", opath, npath))
|
||||
Ok(Json(AppDeployed { path: npath, version: v_id }))
|
||||
}
|
||||
|
||||
/// Deploy a raw app from its sources, compiling them on a worker. `update_raw`
|
||||
@@ -3000,7 +3061,7 @@ async fn update_app_raw_source(
|
||||
Extension(webhook): Extension<WebhookShared>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
Json(ns): Json<EditApp>,
|
||||
) -> Result<String> {
|
||||
) -> JsonResult<AppDeployed> {
|
||||
if authed.is_operator {
|
||||
return Err(Error::NotAuthorized(
|
||||
"Operators cannot update apps for security reasons".to_string(),
|
||||
@@ -3098,7 +3159,7 @@ async fn update_app_raw_source(
|
||||
},
|
||||
);
|
||||
|
||||
Ok(format!("app {} updated (npath: {:?})", opath, npath))
|
||||
Ok(Json(AppDeployed { path: npath, version: v_id }))
|
||||
}
|
||||
|
||||
/// Whether the caller may create an app at `path` — asked of the database rather
|
||||
@@ -3324,7 +3385,7 @@ async fn update_app_raw<'a>(
|
||||
Extension(webhook): Extension<WebhookShared>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
multipart: Multipart,
|
||||
) -> Result<String> {
|
||||
) -> JsonResult<AppDeployed> {
|
||||
if authed.is_operator {
|
||||
return Err(Error::NotAuthorized(
|
||||
"Operators cannot update apps for security reasons".to_string(),
|
||||
@@ -3372,7 +3433,7 @@ async fn update_app_raw<'a>(
|
||||
},
|
||||
);
|
||||
|
||||
Ok(format!("app {} updated (npath: {:?})", opath, npath))
|
||||
Ok(Json(AppDeployed { path: npath, version: v_id }))
|
||||
}
|
||||
// async fn create_app_internal<'a>(
|
||||
// authed: ApiAuthed,
|
||||
@@ -3749,6 +3810,19 @@ async fn update_app_internal<'a>(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
if npath != path {
|
||||
// Everything left at the old path is a draft this deploy didn't consume
|
||||
// — teammates' rows, and the deployer's own when the caller asked us to
|
||||
// keep it. Carry them rather than strand them.
|
||||
windmill_common::user_drafts::move_drafts_for_path(
|
||||
&mut tx,
|
||||
&w_id,
|
||||
&[UserDraftItemKind::App, UserDraftItemKind::RawApp],
|
||||
path,
|
||||
&npath,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
|
||||
@@ -19,7 +19,7 @@ use windmill_common::{
|
||||
error::{Error, Result},
|
||||
user_drafts::{DraftUserRef, UserDraftItemKind, ENCRYPTED_DRAFT_PREFIX},
|
||||
users::resolve_username_to_email,
|
||||
utils::strip_json_nul,
|
||||
utils::{check_proper_path, strip_json_nul},
|
||||
variables::{build_crypt, encrypt},
|
||||
};
|
||||
|
||||
@@ -29,6 +29,7 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/get/{kind}/{*path}", get(get_draft_for_user))
|
||||
.route("/get_own/{kind}/{*path}", get(get_own_draft))
|
||||
.route("/update/{kind}/{*path}", post(update_draft))
|
||||
.route("/move/{kind}/{*path}", post(move_draft))
|
||||
.route("/migrate_legacy/{kind}/{*path}", post(migrate_legacy_draft))
|
||||
}
|
||||
|
||||
@@ -320,12 +321,71 @@ pub struct SaveDraftResponse {
|
||||
/// On `saved`: when the change was applied (client remembers it as the
|
||||
/// next `last_sync`). On `conflict`: the existing row's `created_at`.
|
||||
pub current_timestamp: chrono::DateTime<chrono::Utc>,
|
||||
/// `saved` only: where the write landed. Differs from the URL path when the item
|
||||
/// had moved away from it; the editor follows it there. Absent when a delete found
|
||||
/// nothing to remove and the caller cannot read the path it moved to.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
/// Apply the current user's draft at (workspace, kind, path): non-null `value`
|
||||
/// upserts, `null` (or omitted) deletes. Either way, when the existing row is
|
||||
/// newer than `last_sync` (and `force` is false) the op is skipped and the
|
||||
/// response is `status = conflict` + the server's current timestamp.
|
||||
/// The version a draft forked from, as the editors write it into `draft.value`.
|
||||
/// Each kind names it differently and only one is ever set.
|
||||
#[derive(Deserialize)]
|
||||
struct DraftBaseVersion {
|
||||
/// Scripts: hex-encoded script hash.
|
||||
#[serde(default)]
|
||||
parent_hash: Option<String>,
|
||||
/// Flows: `flow_version.id`.
|
||||
#[serde(default)]
|
||||
version_id: Option<i64>,
|
||||
/// Apps / raw apps: `app_version.id`.
|
||||
#[serde(default)]
|
||||
parent_version: Option<i64>,
|
||||
}
|
||||
|
||||
impl DraftBaseVersion {
|
||||
/// The base as the `draft.base` column stores it: one opaque text id whatever
|
||||
/// the kind, so a reader compares it to the head without knowing the kind's
|
||||
/// own field name or type.
|
||||
fn as_text(&self, kind: UserDraftItemKind) -> Option<String> {
|
||||
use UserDraftItemKind::*;
|
||||
match kind {
|
||||
Script => self.parent_hash.clone(),
|
||||
Flow => self.version_id.map(|v| v.to_string()),
|
||||
_ => self.parent_version.map(|v| v.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The version this draft forked from, or `None` when it has none — a kind
|
||||
/// that keeps no lineage, a malformed payload, or a draft that was never forked
|
||||
/// from a deploy. Pure: no queries.
|
||||
fn draft_lineage(kind: UserDraftItemKind, value: &str) -> Option<DraftBaseVersion> {
|
||||
use UserDraftItemKind::*;
|
||||
if !matches!(kind, Script | Flow | App | RawApp) {
|
||||
return None;
|
||||
}
|
||||
let base = serde_json::from_str::<DraftBaseVersion>(value).ok()?;
|
||||
let has_base = match kind {
|
||||
Script => base
|
||||
.parent_hash
|
||||
.as_deref()
|
||||
.and_then(|h| windmill_common::scripts::to_i64(h).ok())
|
||||
.is_some(),
|
||||
Flow => base.version_id.is_some(),
|
||||
_ => base.parent_version.is_some(),
|
||||
};
|
||||
has_base.then_some(base)
|
||||
}
|
||||
|
||||
/// Apply the current user's draft: non-null `value` upserts, `null` (or
|
||||
/// omitted) deletes. Either way, when the existing row is newer than
|
||||
/// `last_sync` (and `force` is false) the op is skipped and the response is
|
||||
/// `status = conflict` + the server's current timestamp.
|
||||
///
|
||||
/// A save addressed to a path its item moved away from lands where the move took
|
||||
/// the drafts (`draft_move`), unless the caller still has a draft of their own at
|
||||
/// that path. The response names where it landed.
|
||||
async fn update_draft(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
@@ -334,7 +394,7 @@ async fn update_draft(
|
||||
Json(req): Json<SaveDraftRequest>,
|
||||
) -> Result<Json<SaveDraftResponse>> {
|
||||
let email = &authed.email;
|
||||
let path = path.to_path();
|
||||
let url_path = path.to_path();
|
||||
// Saving a draft requires write permission on the underlying path. Deleting
|
||||
// (discarding) one's OWN draft does not: the email-scoped row belongs to the
|
||||
// authed user, so they can always discard it even after losing write access
|
||||
@@ -343,11 +403,68 @@ async fn update_draft(
|
||||
// touch the caller's own row. Legacy (NULL-email) rows aren't owned by anyone
|
||||
// — they keep the write gate.
|
||||
let is_own_discard = req.value.is_none() && !req.legacy;
|
||||
// `legacy` targets the workspace-level row and is delete-only: an upsert writes the
|
||||
// caller's own row whatever it says. Every read of that rule goes through this.
|
||||
let legacy_delete = req.value.is_none() && req.legacy;
|
||||
|
||||
// Whose row this write is for: the caller's, or the workspace-level one on a legacy
|
||||
// DELETE (`legacy` is delete-only, so an upsert is the caller's own row either way).
|
||||
// It picks both the record that applies — an item's move (`email IS NULL`) covers the
|
||||
// legacy row too, since the same rename carried it — and the draft whose presence
|
||||
// means this path is still the write's own.
|
||||
let owner: Option<&str> = (!legacy_delete).then_some(email.as_str());
|
||||
// The caller's own draft-only move outranks the move of the deployed item. Only the
|
||||
// kinds whose value carries a deploy target are ever recorded as moved, so for the
|
||||
// rest this would be a guaranteed-empty query on the autosave hot path.
|
||||
let moved_to = match kind.typed_path_field() {
|
||||
None => None,
|
||||
Some(_) => {
|
||||
sqlx::query_scalar!(
|
||||
r#"SELECT m.new_path FROM draft_move m
|
||||
WHERE m.workspace_id = $1 AND m.typ = $2 AND m.old_path = $3
|
||||
AND (m.email IS NULL OR m.email = $4)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM draft d
|
||||
WHERE d.workspace_id = $1 AND d.typ = $2 AND d.path = $3
|
||||
AND d.email IS NOT DISTINCT FROM $4
|
||||
)
|
||||
ORDER BY m.email IS NULL
|
||||
LIMIT 1"#,
|
||||
&w_id,
|
||||
kind as UserDraftItemKind,
|
||||
url_path,
|
||||
owner,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
let path: &str = moved_to.as_deref().unwrap_or(url_path);
|
||||
|
||||
// Everything past here writes, so the gate applies from here on. Answered
|
||||
// without the path when the item moved: it may have gone somewhere the caller
|
||||
// cannot see.
|
||||
if !is_own_discard {
|
||||
require_can_write_path(&authed, &db, &user_db, &w_id, kind, path).await?;
|
||||
match require_can_write_path(&authed, &db, &user_db, &w_id, kind, path).await {
|
||||
// Naming the move is for whoever was editing the item: it tells them why a
|
||||
// save they were already making stopped landing. Someone who cannot read the
|
||||
// path they addressed gets the plain denial, or the wording itself would
|
||||
// answer whether an item was moved away from a path they only guessed at.
|
||||
Err(Error::NotAuthorized(e)) if moved_to.is_some() => {
|
||||
return Err(Error::NotAuthorized(
|
||||
match require_can_read_path(&authed, &user_db, &w_id, kind, url_path).await {
|
||||
Ok(()) => {
|
||||
"this draft's item was moved to a path you cannot write".to_string()
|
||||
}
|
||||
Err(_) => e,
|
||||
},
|
||||
));
|
||||
}
|
||||
other => other?,
|
||||
}
|
||||
}
|
||||
|
||||
let applied_at = if let Some(value) = &req.value {
|
||||
let applied = if let Some(value) = &req.value {
|
||||
// Secret variable values must never sit in `draft.value` in plaintext
|
||||
// (see `encrypt_secret_variable_value`).
|
||||
let serialized = if kind == UserDraftItemKind::Variable {
|
||||
@@ -359,19 +476,49 @@ async fn update_draft(
|
||||
// escape and later make any `->>`/`to_jsonb` extraction raise `22P05`.
|
||||
// Strip it here so a NUL never reaches the column.
|
||||
let serialized = strip_json_nul(&serialized);
|
||||
// `base` is derived here from the value's per-kind field rather than sent
|
||||
// by the client, so every writer (editors, chat, CLI) fills it the same way.
|
||||
// Read from the sanitized text, which is what the value column gets: a NUL in the
|
||||
// lineage field otherwise costs the draft its base (the hash no longer parses),
|
||||
// leaving a draft that reads as up to date whatever the head is.
|
||||
let base = draft_lineage(kind, serialized.as_ref()).and_then(|l| l.as_text(kind));
|
||||
// Upsert. The conflict check rides on the DO UPDATE WHERE clause —
|
||||
// when the row is newer than `last_sync`, RETURNING yields nothing.
|
||||
// `created_at` defaults to `now()` but the migration overrides it ($8)
|
||||
// so a migrated draft keeps its original age instead of jumping to top.
|
||||
sqlx::query_scalar!(
|
||||
r#"INSERT INTO draft (workspace_id, email, path, typ, value, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5::text::json, COALESCE($8::timestamptz, now()))
|
||||
//
|
||||
// A moved save ($10) carries the path keys its editor had before the move.
|
||||
// One naming the path it addressed ($11) follows to where it landed; a draft
|
||||
// already there keeps the keys the move gave it. A pre-sanitizer NUL escape
|
||||
// in that draft makes `to_jsonb` raise, so it takes the incoming keys.
|
||||
sqlx::query!(
|
||||
r#"INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base)
|
||||
VALUES ($1, $2, $3::text, $4,
|
||||
CASE WHEN $10::bool
|
||||
THEN to_json($5::text::jsonb || jsonb_strip_nulls(jsonb_build_object(
|
||||
'path', CASE WHEN $5::text::jsonb -> 'path' = to_jsonb($11::text)
|
||||
THEN to_jsonb($3::text) END,
|
||||
'draft_path', CASE WHEN $5::text::jsonb -> 'draft_path' = to_jsonb($11::text)
|
||||
THEN to_jsonb($3::text) END)))
|
||||
ELSE $5::text::json
|
||||
END,
|
||||
COALESCE($8::timestamptz, now()), $9)
|
||||
ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL
|
||||
DO UPDATE SET value = EXCLUDED.value, created_at = EXCLUDED.created_at
|
||||
DO UPDATE SET value = CASE
|
||||
WHEN NOT $10::bool
|
||||
OR position(chr(92) || 'u0000' in replace(draft.value::text, chr(92) || chr(92), '')) > 0
|
||||
THEN EXCLUDED.value
|
||||
ELSE to_json((to_jsonb(EXCLUDED.value) - 'path' - 'draft_path')
|
||||
|| jsonb_strip_nulls(jsonb_build_object(
|
||||
'path', to_jsonb(draft.value) -> 'path',
|
||||
'draft_path', to_jsonb(draft.value) -> 'draft_path')))
|
||||
END,
|
||||
created_at = EXCLUDED.created_at,
|
||||
base = EXCLUDED.base
|
||||
WHERE $7::bool = true
|
||||
OR $6::timestamptz IS NULL
|
||||
OR draft.created_at <= $6::timestamptz
|
||||
RETURNING created_at"#,
|
||||
RETURNING path, created_at"#,
|
||||
&w_id,
|
||||
email,
|
||||
path,
|
||||
@@ -380,9 +527,13 @@ async fn update_draft(
|
||||
req.last_sync,
|
||||
req.force,
|
||||
req.created_at,
|
||||
base.as_deref(),
|
||||
moved_to.is_some(),
|
||||
url_path,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.map(|r| (r.created_at, Some(r.path)))
|
||||
} else {
|
||||
// Delete, same conflict rule in the WHERE clause. Returns NULL when
|
||||
// the row was too new (conflict) OR already absent (idempotent) —
|
||||
@@ -403,16 +554,20 @@ async fn update_draft(
|
||||
kind as UserDraftItemKind,
|
||||
req.last_sync,
|
||||
req.force,
|
||||
req.legacy,
|
||||
legacy_delete,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
// Named for the same reason an upsert is: the editor that discarded is still on
|
||||
// the path the item left, and reloading there would land on nothing.
|
||||
.map(|ts| (ts, moved_to.clone()))
|
||||
};
|
||||
|
||||
if let Some(ts) = applied_at {
|
||||
if let Some((ts, path)) = applied {
|
||||
return Ok(Json(SaveDraftResponse {
|
||||
status: SaveDraftStatus::Saved,
|
||||
current_timestamp: ts,
|
||||
path,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -428,7 +583,7 @@ async fn update_draft(
|
||||
email,
|
||||
path,
|
||||
kind as UserDraftItemKind,
|
||||
req.legacy,
|
||||
legacy_delete,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
@@ -437,20 +592,307 @@ async fn update_draft(
|
||||
Some(ts) => Ok(Json(SaveDraftResponse {
|
||||
status: SaveDraftStatus::Conflict,
|
||||
current_timestamp: ts,
|
||||
path: None,
|
||||
})),
|
||||
// Delete + nothing-was-there ⇒ report success with server's NOW().
|
||||
None => {
|
||||
let now = sqlx::query_scalar!(r#"SELECT now() as "now!""#)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
// A retry of a routed discard whose answer was lost lands here: the row is
|
||||
// gone but the editor is still on the path the item left, so it needs the
|
||||
// destination as much as the first attempt did. Unlike the arm above there is
|
||||
// no deleted row proving the caller ever held that draft, and an own discard
|
||||
// is not gated, so this names a path to someone who may have none of it: the
|
||||
// read gate, which is what keeps a path from being disclosed elsewhere in this
|
||||
// module. Without it, discarding at a guessed path reads `draft_move`.
|
||||
let disclosed = match moved_to {
|
||||
Some(dest) => {
|
||||
match require_can_read_path(&authed, &user_db, &w_id, kind, &dest).await {
|
||||
Ok(()) => Some(dest),
|
||||
Err(Error::NotFound(_))
|
||||
| Err(Error::NotAuthorized(_))
|
||||
| Err(Error::BadRequest(_)) => None,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
Ok(Json(SaveDraftResponse {
|
||||
status: SaveDraftStatus::Saved,
|
||||
current_timestamp: now,
|
||||
path: disclosed,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct MoveDraftRequest {
|
||||
pub new_path: String,
|
||||
/// Also restate the draft's summary, so the same drawer that renames a
|
||||
/// deployed item can retitle a draft-only one.
|
||||
#[serde(default)]
|
||||
pub summary: Option<String>,
|
||||
}
|
||||
|
||||
/// Relocate the authed user's own DRAFT-ONLY item. Such an item is nothing but
|
||||
/// its draft row, so moving it is a rewrite of that row's path plus both path
|
||||
/// keys inside its value — there is no deployed row, schedule or trigger to
|
||||
/// cascade to.
|
||||
///
|
||||
/// The owner's own open editor follows: its next save, still addressed to the old
|
||||
/// path, lands at the new one through the move record, and it is told where.
|
||||
///
|
||||
/// Scoped to the caller's own row on purpose: two users can each have a draft
|
||||
/// at the same never-deployed path, and those are two separate items.
|
||||
///
|
||||
/// A DEPLOYED item must move through its own deploy endpoint instead, which
|
||||
/// cascades everything that references the path and carries every draft along.
|
||||
async fn move_draft(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, kind, path)): Path<(String, UserDraftItemKind, windmill_common::utils::StripPath)>,
|
||||
Json(req): Json<MoveDraftRequest>,
|
||||
) -> Result<String> {
|
||||
let path = path.to_path();
|
||||
let new_path = req.new_path.as_str();
|
||||
// Only the full-page editor kinds, which is exactly the set that has a typed
|
||||
// path to rewrite. Reading the movable set off the same mapping the rewrite
|
||||
// uses keeps them from drifting apart: a resource, a variable or a trigger
|
||||
// keeps its deploy target in `value.path` with no editor to stage a rename,
|
||||
// so moving one would leave the real target naming the old location and the
|
||||
// next deploy would recreate it where it came from.
|
||||
let (Some(typed_field), Some(mirror_field)) =
|
||||
(kind.typed_path_field(), kind.mirror_path_field())
|
||||
else {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"moving a {kind:?} draft is not supported — only scripts, flows and apps"
|
||||
)));
|
||||
};
|
||||
// Validate before authorizing: `require_can_write_path` is not a format check
|
||||
// (an admin returns immediately, and a user returns early inside their own
|
||||
// namespace), so without this a malformed path is stored as-is, and an over-long
|
||||
// or NUL-bearing one reaches Postgres as a raw server error. The source is a URL
|
||||
// segment and arrives decoded, so it needs the same check as the destination.
|
||||
check_proper_path(path)?;
|
||||
check_proper_path(new_path)?;
|
||||
// A summary-only edit is a legitimate use of this endpoint: the drawer edits
|
||||
// both fields, and for a draft-only script the path it posts back is the row
|
||||
// path unchanged (`list_scripts` only reports `draft_path` when it differs).
|
||||
// Returning early on the path alone would drop the new summary silently.
|
||||
if new_path == path && req.summary.is_none() {
|
||||
return Ok("unchanged".to_string());
|
||||
}
|
||||
require_can_write_path(&authed, &db, &user_db, &w_id, kind, path).await?;
|
||||
if new_path != path {
|
||||
require_can_write_path(&authed, &db, &user_db, &w_id, kind, new_path).await?;
|
||||
}
|
||||
|
||||
if let Some(table) = kind.deployed_table() {
|
||||
// `table` is from the closed `deployed_table()` enum, never user input.
|
||||
// Archived and soft-deleted rows keep sitting at their path — a script
|
||||
// move archives its parent in place — so an existence check that counted
|
||||
// them would refuse a move away from, or into, a path nothing occupies.
|
||||
// `create_script_internal` resolves its own path clashes the same way.
|
||||
let archived_filter = if table == "script" {
|
||||
" AND NOT archived AND NOT deleted"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let query = format!(
|
||||
"SELECT 1 FROM {table} WHERE path = $1 AND workspace_id = $2{archived_filter} LIMIT 1"
|
||||
);
|
||||
let mut tx = user_db.clone().begin(&authed).await?;
|
||||
let deployed_at_old = sqlx::query_scalar::<_, i32>(&query)
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let deployed_at_new = sqlx::query_scalar::<_, i32>(&query)
|
||||
.bind(new_path)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
if deployed_at_old.is_some() {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"'{path}' is deployed — move it from its editor so schedules and triggers follow"
|
||||
)));
|
||||
}
|
||||
if deployed_at_new.is_some() {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"'{new_path}' already has a deployed item — moving there would turn this into a draft on top of it"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// A NUL in the summary reaches Postgres as a raw byte and fails the statement with
|
||||
// an encoding error, so it is dropped here the way `strip_json_nul` drops one from a
|
||||
// draft value. `json` cannot store it either.
|
||||
let summary = req.summary.as_ref().map(|s| s.replace('\0', ""));
|
||||
|
||||
// A classic app and a raw app share the `app` table, so a draft of either kind
|
||||
// occupies the destination for both: deploying there deletes the caller's drafts
|
||||
// of both kinds, taking the item that lost the collision with it.
|
||||
let collision_typs: Vec<&str> = match kind {
|
||||
UserDraftItemKind::App | UserDraftItemKind::RawApp => vec![
|
||||
UserDraftItemKind::App.as_str(),
|
||||
UserDraftItemKind::RawApp.as_str(),
|
||||
],
|
||||
_ => vec![kind.as_str()],
|
||||
};
|
||||
|
||||
// One transaction with the move record, so a save addressed to the old path
|
||||
// never sees the row gone without knowing where it went.
|
||||
let mut tx = db.begin().await?;
|
||||
let moved = sqlx::query_scalar!(
|
||||
r#"UPDATE draft
|
||||
SET path = $3,
|
||||
-- Both path keys, not just the typed one: the editors mirror the
|
||||
-- typed path into the other while it differs from the row's path,
|
||||
-- and the loaders prefer the mirror — left naming the old location
|
||||
-- it un-does this move on the next save. `create_missing = false`
|
||||
-- on both, so a draft carrying only one keeps only one.
|
||||
value = to_json(
|
||||
jsonb_set(
|
||||
jsonb_set(
|
||||
CASE WHEN $7::text IS NULL THEN to_jsonb(value)
|
||||
ELSE jsonb_set(to_jsonb(value), ARRAY['summary'], to_jsonb($7::text))
|
||||
END,
|
||||
ARRAY[$5::text], to_jsonb($3::text), false
|
||||
),
|
||||
ARRAY[$8::text], to_jsonb($3::text), false
|
||||
)
|
||||
)
|
||||
WHERE workspace_id = $1
|
||||
AND path = $2
|
||||
AND typ = $4
|
||||
AND email = $6
|
||||
-- A pre-sanitizer NUL escape makes `to_jsonb` raise 22P05. Excluded
|
||||
-- here so the statement can't 500; reported below instead. Unlike the
|
||||
-- passive carry, rewriting the value IS this operation, so skipping it
|
||||
-- silently would move the row and leave its typed path stale.
|
||||
AND position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) = 0
|
||||
-- Skipped on a summary-only edit, where the "target" row is this
|
||||
-- row and the guard would refuse the update against itself.
|
||||
AND ($2 = $3 OR NOT EXISTS (
|
||||
SELECT 1 FROM draft o
|
||||
WHERE o.workspace_id = $1 AND o.path = $3 AND o.typ::text = ANY($9::text[])
|
||||
-- Of this kind only the caller's own row and the legacy one collide:
|
||||
-- teammates' drafts of one item share its path by design, but a deploy
|
||||
-- there wipes those two together, so a second would discard edits the
|
||||
-- caller never saw. The other app kind is a different item on the same
|
||||
-- deployed path, so it collides whoever owns it.
|
||||
AND (o.typ <> $4 OR o.email = $6 OR o.email IS NULL)
|
||||
))
|
||||
RETURNING id"#,
|
||||
&w_id,
|
||||
path,
|
||||
new_path,
|
||||
kind as UserDraftItemKind,
|
||||
typed_field,
|
||||
&authed.email,
|
||||
summary,
|
||||
mirror_field,
|
||||
&collision_typs as &[&str],
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if moved.is_some() && new_path != path {
|
||||
windmill_common::user_drafts::record_draft_move(
|
||||
&mut tx,
|
||||
&w_id,
|
||||
&[kind],
|
||||
path,
|
||||
new_path,
|
||||
Some(&authed.email),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
|
||||
if moved.is_none() {
|
||||
let row = sqlx::query!(
|
||||
r#"SELECT
|
||||
-- The guard's own predicate, ordered own row, then legacy, then another
|
||||
-- user's other-kind row: each needs a different remedy, and a bare
|
||||
-- LIMIT 1 would name an arbitrary one.
|
||||
(SELECT typ::text FROM draft WHERE workspace_id = $1 AND path = $3
|
||||
AND typ::text = ANY($6::text[])
|
||||
AND (typ <> $2 OR email = $4 OR email IS NULL)
|
||||
ORDER BY CASE WHEN email = $4 THEN 0 WHEN email IS NULL THEN 1 ELSE 2 END
|
||||
LIMIT 1) as "at_target",
|
||||
EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $3
|
||||
AND typ::text = ANY($6::text[]) AND email = $4) as "at_target_own!",
|
||||
EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $3
|
||||
AND typ::text = ANY($6::text[]) AND email IS NULL) as "at_target_legacy!",
|
||||
EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $5
|
||||
AND typ = $2 AND email = $4
|
||||
AND position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) > 0
|
||||
) as "poisoned!",
|
||||
EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $5
|
||||
AND typ = $2 AND email IS NULL) as "legacy!" "#,
|
||||
&w_id,
|
||||
kind as UserDraftItemKind,
|
||||
new_path,
|
||||
&authed.email,
|
||||
path,
|
||||
&collision_typs as &[&str],
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
return Err(Error::BadRequest(if row.poisoned {
|
||||
// This endpoint also serves a summary-only edit, so name the operation
|
||||
// the caller actually asked for rather than always saying "moved".
|
||||
let attempted = if new_path == path { "updated" } else { "moved" };
|
||||
format!(
|
||||
"'{path}' contains a NUL character and predates the sanitizer, so it cannot be \
|
||||
{attempted}. Reopen it, re-save to rewrite it cleanly, then retry."
|
||||
)
|
||||
} else if row.legacy {
|
||||
// The home list synthesizes a draft-only row for the legacy draft with the
|
||||
// caller's own name on it, so this is reachable from the row menu. Only an
|
||||
// admin can claim or discard that row, and only from the drafts page.
|
||||
format!(
|
||||
"'{path}' is a legacy workspace draft with no owner, so it cannot be moved. \
|
||||
A workspace admin can claim or discard it on the Review & deploy drafts page."
|
||||
)
|
||||
} else if let Some(occupant) = row.at_target.filter(|_| new_path != path) {
|
||||
// Naming the kind matters for the app pair: a classic-app draft refusing a
|
||||
// raw-app move is invisible in the raw-app list the caller is looking at.
|
||||
let occupant = occupant.replace('_', " ");
|
||||
if row.at_target_own {
|
||||
format!("You already have a draft at '{new_path}' ({occupant})")
|
||||
} else if row.at_target_legacy {
|
||||
// An ownerless row the caller cannot clear themselves, so send them to
|
||||
// the one place it can be resolved rather than to "discard your draft".
|
||||
format!(
|
||||
"A legacy workspace draft with no owner is already at '{new_path}' \
|
||||
({occupant}). A workspace admin can claim or discard it on the Review & \
|
||||
deploy drafts page."
|
||||
)
|
||||
} else {
|
||||
// The other app kind, owned by someone else: one deployed path cannot hold
|
||||
// both, so this is the other item's path, not a teammate's copy of this one.
|
||||
format!(
|
||||
"'{new_path}' holds another user's {occupant} draft, and an app and a raw \
|
||||
app cannot share a path. Pick another path, or ask them to move or \
|
||||
discard theirs."
|
||||
)
|
||||
}
|
||||
} else {
|
||||
format!("You have no draft at '{path}'")
|
||||
}));
|
||||
}
|
||||
|
||||
if new_path == path {
|
||||
return Ok(format!("updated draft {path}"));
|
||||
}
|
||||
Ok(format!("moved draft {path} to {new_path}"))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MigrateLegacyDraftAction {
|
||||
@@ -504,12 +946,12 @@ async fn migrate_legacy_draft(
|
||||
r#"WITH legacy AS (
|
||||
DELETE FROM draft
|
||||
WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email IS NULL
|
||||
RETURNING value
|
||||
RETURNING value, base
|
||||
)
|
||||
INSERT INTO draft (workspace_id, email, path, typ, value, created_at)
|
||||
SELECT $1, $4, $2, $3, value, now() FROM legacy
|
||||
INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base)
|
||||
SELECT $1, $4, $2, $3, value, now(), base FROM legacy
|
||||
ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL
|
||||
DO UPDATE SET value = EXCLUDED.value, created_at = now()
|
||||
DO UPDATE SET value = EXCLUDED.value, created_at = now(), base = EXCLUDED.base
|
||||
RETURNING 1 as "one!""#,
|
||||
&w_id,
|
||||
path,
|
||||
@@ -767,8 +1209,8 @@ async fn require_can_write_path(
|
||||
|
||||
/// Resolves to `Ok(())` if `authed` can read at `path`. Three layers:
|
||||
/// 1. admin → always.
|
||||
/// 2. Path-prefix match against own `u/{username}` or any folder in
|
||||
/// `authed.folders` (the precomputed read set, with groups + direct
|
||||
/// 2. Path-prefix match against own `u/{username}`, a group in `authed.groups`, or any
|
||||
/// folder in `authed.folders` (the precomputed read set, with groups + direct
|
||||
/// grants already factored in).
|
||||
/// 3. RLS-aware `SELECT 1` against the backing table — covers item-level
|
||||
/// extra_perms grants that bypass folder/owner checks.
|
||||
@@ -793,6 +1235,10 @@ async fn require_can_read_path(
|
||||
if parts.len() >= 2 {
|
||||
match parts[0] {
|
||||
"u" if parts[1] == authed.username => return Ok(()),
|
||||
// As `require_can_write_path` and the `see_member` RLS policy read it: a
|
||||
// draft-only `g/` path has no row for the probe below to fall back on, so
|
||||
// without this a member cannot see a draft their group owns.
|
||||
"g" if authed.groups.iter().any(|g| g == parts[1]) => return Ok(()),
|
||||
"f" => {
|
||||
let folder = parts[1];
|
||||
if authed.folders.iter().any(|(name, _, _)| name == folder) {
|
||||
|
||||
@@ -160,6 +160,34 @@ impl UserDraftItemKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `draft.value` key holding the user-typed target path — where a
|
||||
/// deploy of this draft would land when the user staged a rename. A script
|
||||
/// draft round-trips its own `path`; flows and apps write a separate
|
||||
/// `draft_path`, and only when it differs from the row's path.
|
||||
///
|
||||
/// `None` for every kind with no editor to stage a rename in — a resource,
|
||||
/// a variable, a trigger. Their drafts carry no such key, so `Some` is also
|
||||
/// the test for whether a kind can be moved at all.
|
||||
pub fn typed_path_field(&self) -> Option<&'static str> {
|
||||
use UserDraftItemKind::*;
|
||||
match self {
|
||||
Script => Some("path"),
|
||||
Flow | App | RawApp => Some("draft_path"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The other of the two path keys a draft value can carry. The editors mirror
|
||||
/// the typed path into it while it differs from the row's path, so a move that
|
||||
/// rewrote only `typed_path_field` would leave the mirror naming the old
|
||||
/// location — and the loaders prefer the mirror, which un-does the move.
|
||||
pub fn mirror_path_field(&self) -> Option<&'static str> {
|
||||
match self.typed_path_field()? {
|
||||
"path" => Some("draft_path"),
|
||||
_ => Some("path"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether OTHER users' drafts at a path are visible to a viewer (the
|
||||
/// "others are editing" list, owner circles, and the `get_draft_for_user`
|
||||
/// View JSON / Fork endpoint). Enabled only for the full-page editor items
|
||||
@@ -212,6 +240,12 @@ pub struct WithDraftOverlay {
|
||||
pub is_draft: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft_saved_at: Option<DateTime<Utc>>,
|
||||
/// The deployed version the draft forked from (`draft.base`), as text
|
||||
/// whatever the kind. The editor compares it to the head it loaded to tell
|
||||
/// a draft that is behind. Absent when there is no draft or it was never
|
||||
/// forked from a deploy.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft_base: Option<String>,
|
||||
/// True when no deployed row exists at this path: `inner` is only a
|
||||
/// best-effort stand-in synthesized from the draft and only `draft` is
|
||||
/// canonical. Frontend uses this to disable "diff/reset vs deployed" and
|
||||
@@ -297,6 +331,7 @@ where
|
||||
inner: Box::new(deployed),
|
||||
is_draft: false,
|
||||
draft_saved_at: None,
|
||||
draft_base: None,
|
||||
no_deployed: false,
|
||||
draft: None,
|
||||
other_drafts_users: Vec::new(),
|
||||
@@ -317,7 +352,7 @@ where
|
||||
// row when an owned one exists.
|
||||
let row = sqlx::query!(
|
||||
r#"SELECT value as "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
|
||||
created_at
|
||||
created_at, base
|
||||
FROM draft
|
||||
WHERE workspace_id = $1
|
||||
AND (email = $2 OR email IS NULL)
|
||||
@@ -338,6 +373,7 @@ where
|
||||
inner: Box::new(deployed),
|
||||
is_draft: false,
|
||||
draft_saved_at: None,
|
||||
draft_base: None,
|
||||
no_deployed: false,
|
||||
draft: None,
|
||||
other_drafts_users,
|
||||
@@ -350,6 +386,7 @@ where
|
||||
inner: Box::new(deployed),
|
||||
is_draft: true,
|
||||
draft_saved_at: Some(row.created_at),
|
||||
draft_base: row.base,
|
||||
no_deployed: false,
|
||||
draft: Some(draft_json),
|
||||
other_drafts_users,
|
||||
@@ -468,7 +505,8 @@ pub async fn overlay_or_draft_only<T: serde::Serialize + Send + 'static>(
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the drafts an address owns, across every workspace.
|
||||
/// Delete the drafts an address owns, and the move records routing saves to them,
|
||||
/// across every workspace.
|
||||
///
|
||||
/// `draft.email` carries no foreign key to `password`: a draft's owner is any principal the
|
||||
/// instance authenticates, and an external JWT's subject never has a `password` row. Deleting an
|
||||
@@ -479,12 +517,14 @@ pub async fn overlay_or_draft_only<T: serde::Serialize + Send + 'static>(
|
||||
/// No authorization of its own: it acts instance-wide on whatever address it is handed, so the
|
||||
/// caller must already have authorized removing that account (superadmin, the account's own
|
||||
/// holder, or SCIM).
|
||||
pub async fn delete_drafts_of_email<'c>(
|
||||
executor: impl sqlx::PgExecutor<'c>,
|
||||
email: &str,
|
||||
) -> Result<()> {
|
||||
pub async fn delete_drafts_of_email(conn: &mut sqlx::PgConnection, email: &str) -> Result<()> {
|
||||
sqlx::query!("DELETE FROM draft WHERE email = $1", email)
|
||||
.execute(executor)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
// The records routing saves to those drafts go with them: one left behind would send
|
||||
// a later holder of the address to a draft that no longer exists.
|
||||
sqlx::query!("DELETE FROM draft_move WHERE email = $1", email)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -526,6 +566,29 @@ pub async fn rename_drafts_of_email(
|
||||
)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
// The records that route saves to those drafts follow the same address, or a save
|
||||
// still addressed to a path the account moved away from would start a second draft
|
||||
// there. The moving account's record for a path wins, as its draft just did.
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft_move dest
|
||||
WHERE dest.email = $1
|
||||
AND EXISTS (SELECT 1 FROM draft_move src
|
||||
WHERE src.email = $2
|
||||
AND src.workspace_id = dest.workspace_id
|
||||
AND src.old_path = dest.old_path
|
||||
AND src.typ = dest.typ)",
|
||||
new_email,
|
||||
old_email
|
||||
)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
"UPDATE draft_move SET email = $1 WHERE email = $2",
|
||||
new_email,
|
||||
old_email
|
||||
)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -582,6 +645,247 @@ pub async fn delete_own_draft_for_path(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Carry every draft at `old_path` over to `new_path` when an item MOVES
|
||||
/// (rename or relocation). A draft is bound to its item by nothing but the path
|
||||
/// string, so without this a move detaches every draft on the item. No owner
|
||||
/// filter: teammates' rows and the legacy NULL-email row follow too.
|
||||
///
|
||||
/// **The caller must have authorized the underlying item move first.** This rewrites
|
||||
/// rows the caller has no permission on and enforces nothing itself, taking both paths
|
||||
/// on trust: it is safe only inside a deploy that has already cleared them, and is a
|
||||
/// cross-user write with no gate anywhere else.
|
||||
///
|
||||
/// The value keeps its base version, so every carried draft reads as behind the head the
|
||||
/// move minted. Of its two path keys, one still naming `old_path` follows the row (the
|
||||
/// editors write the item's own path there on every save, so it is the item's path and
|
||||
/// not a staged rename); any other value is a rename the user staged, and is kept.
|
||||
///
|
||||
/// A draft already at `new_path` occupies it the way a deployed item does, so the move is
|
||||
/// refused with `BadRequest` inside the deploy's transaction, refusing the rename itself:
|
||||
/// moving onto it would merge two items or strand the row that lost. A row a `draft_move`
|
||||
/// record already brought there from `old_path` is this rename's own draft and does not
|
||||
/// count: deploying it would otherwise be refused against itself, with no way out.
|
||||
pub async fn move_drafts_for_path(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
w_id: &str,
|
||||
kinds: &[UserDraftItemKind],
|
||||
old_path: &str,
|
||||
new_path: &str,
|
||||
) -> Result<()> {
|
||||
let typs = kinds.iter().map(|k| k.as_str()).collect::<Vec<_>>();
|
||||
// Named by workspace username, as the editors name other users' drafts: the
|
||||
// caller is often not the owner, and cannot clear a draft they cannot find.
|
||||
let owners = sqlx::query!(
|
||||
r#"SELECT d.email IS NULL as "legacy!", COALESCE(u.username, p.username) as username
|
||||
FROM draft d
|
||||
LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email
|
||||
LEFT JOIN password p ON p.email = d.email AND p.super_admin = true
|
||||
WHERE d.workspace_id = $1 AND d.path = $2 AND d.typ::text = ANY($3::text[])
|
||||
-- A row a move already brought here from the very path being renamed is this
|
||||
-- rename's own draft, not an item in its way: the record says which owners
|
||||
-- it moved (all of them for an item move, one for a draft-only move). Any
|
||||
-- other row is a second item and still collides.
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM draft_move m
|
||||
WHERE m.workspace_id = $1 AND m.typ::text = ANY($3::text[])
|
||||
AND m.old_path = $4 AND m.new_path = $2
|
||||
AND (m.email IS NULL OR m.email IS NOT DISTINCT FROM d.email)
|
||||
)
|
||||
ORDER BY 2"#,
|
||||
w_id,
|
||||
new_path,
|
||||
&typs as &[&str],
|
||||
old_path,
|
||||
)
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
if !owners.is_empty() {
|
||||
let names = owners
|
||||
.into_iter()
|
||||
.map(|o| match (o.username, o.legacy) {
|
||||
(Some(name), _) => name,
|
||||
(None, true) => "a legacy workspace draft".to_string(),
|
||||
(None, false) => "another user".to_string(),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
return Err(crate::error::Error::BadRequest(format!(
|
||||
"'{new_path}' already has a draft on it ({names}) — it must be moved or discarded first"
|
||||
)));
|
||||
}
|
||||
// `draft.value` is `json`, so a NUL escape left by a pre-sanitizer write makes
|
||||
// `to_jsonb` raise 22P05. `clean` is `strip_json_nul`'s parity rule in SQL (escaped
|
||||
// backslashes parked on chr(1), which a `json` value's text cannot hold, so nothing
|
||||
// collides with it; chr(92) spells the backslash so no escape sequence reaches this
|
||||
// file). A row whose keys need re-pointing is therefore rewritten clean rather than
|
||||
// left naming the old path, and one that needs nothing keeps its value byte for byte.
|
||||
sqlx::query!(
|
||||
r#"UPDATE draft AS d
|
||||
SET path = $3::text,
|
||||
value = (
|
||||
SELECT CASE
|
||||
WHEN s.clean -> 'path' = to_jsonb($2::text)
|
||||
OR s.clean -> 'draft_path' = to_jsonb($2::text)
|
||||
THEN to_json(
|
||||
s.clean
|
||||
|| CASE WHEN s.clean -> 'path' = to_jsonb($2::text)
|
||||
THEN jsonb_build_object('path', $3::text)
|
||||
ELSE '{}'::jsonb END
|
||||
|| CASE WHEN s.clean -> 'draft_path' = to_jsonb($2::text)
|
||||
THEN jsonb_build_object('draft_path', $3::text)
|
||||
ELSE '{}'::jsonb END
|
||||
)
|
||||
ELSE d.value
|
||||
END
|
||||
FROM (SELECT replace(replace(replace(d.value::text, chr(92) || chr(92), chr(1)),
|
||||
chr(92) || 'u0000', ''), chr(1), chr(92) || chr(92))::jsonb AS clean) s
|
||||
)
|
||||
WHERE d.workspace_id = $1
|
||||
AND d.path = $2::text
|
||||
AND d.typ::text = ANY($4::text[])"#,
|
||||
w_id,
|
||||
old_path,
|
||||
new_path,
|
||||
&typs as &[&str],
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
record_draft_move(tx, w_id, kinds, old_path, new_path, None).await
|
||||
}
|
||||
|
||||
/// Record that the drafts at `old_path` now live at `new_path`, so a draft save still
|
||||
/// addressed to `old_path` lands on them (see `update_draft`). `email` scopes the
|
||||
/// record to one user's draft-only move; `None` is a deployed item's move, for everyone.
|
||||
///
|
||||
/// Kept to one hop: records pointing at `old_path` are re-pointed (an owner's move
|
||||
/// leaves the item's own record alone and copies it into its own scope instead), and
|
||||
/// records leaving either path are replaced, since `new_path` now holds the item.
|
||||
///
|
||||
/// **The caller must have authorized the move first.** A record routes every later
|
||||
/// draft write at `old_path` (any owner's, for an item move), and enforces nothing
|
||||
/// itself.
|
||||
///
|
||||
/// A record outlives the editors that need it: it ends when a later move touches either
|
||||
/// path or an item is deployed at `old_path`, so a save meaning to start a NEW draft at a
|
||||
/// vacated path would be routed instead. Nothing does that today: every surface parks a
|
||||
/// new item at a minted `u/<user>/draft_<uuid>` key (`mintDraftPath.ts`).
|
||||
pub async fn record_draft_move(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
w_id: &str,
|
||||
kinds: &[UserDraftItemKind],
|
||||
old_path: &str,
|
||||
new_path: &str,
|
||||
email: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let typs = kinds.iter().map(|k| k.as_str()).collect::<Vec<_>>();
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft_move
|
||||
WHERE workspace_id = $1 AND typ::text = ANY($2::text[])
|
||||
AND old_path IN ($3, $4) AND ($5::text IS NULL OR email = $5)",
|
||||
w_id,
|
||||
&typs as &[&str],
|
||||
old_path,
|
||||
new_path,
|
||||
email,
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
// Routes that ended at the destination before this move describe drafts that were
|
||||
// carried there for an item this one is replacing: left alive, a save addressed to
|
||||
// the start of that chain would land on this move's draft instead.
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft_move
|
||||
WHERE workspace_id = $1 AND typ::text = ANY($2::text[])
|
||||
AND new_path = $4 AND old_path <> $3 AND ($5::text IS NULL OR email = $5)",
|
||||
w_id,
|
||||
&typs as &[&str],
|
||||
old_path,
|
||||
new_path,
|
||||
email,
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
"UPDATE draft_move SET new_path = $4
|
||||
WHERE workspace_id = $1 AND typ::text = ANY($2::text[])
|
||||
AND new_path = $3 AND ($5::text IS NULL OR email = $5)",
|
||||
w_id,
|
||||
&typs as &[&str],
|
||||
old_path,
|
||||
new_path,
|
||||
email,
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
// An owner's move must not re-point what everyone else follows, so the item's own
|
||||
// records ending at `old_path` are copied into this scope: a save addressed to the
|
||||
// start of that chain still reaches this destination in one hop. Only those: another
|
||||
// user's owner-scoped record routes their own draft-only item, which this move has
|
||||
// nothing to do with.
|
||||
if email.is_some() {
|
||||
sqlx::query!(
|
||||
"INSERT INTO draft_move (workspace_id, typ, old_path, new_path, email)
|
||||
SELECT m.workspace_id, m.typ, m.old_path, $4, $5::text
|
||||
FROM draft_move m
|
||||
WHERE m.workspace_id = $1 AND m.typ::text = ANY($2::text[])
|
||||
AND m.new_path = $3 AND m.email IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM draft_move o
|
||||
WHERE o.workspace_id = m.workspace_id AND o.typ = m.typ
|
||||
AND o.old_path = m.old_path AND o.email = $5::text
|
||||
)",
|
||||
w_id,
|
||||
&typs as &[&str],
|
||||
old_path,
|
||||
new_path,
|
||||
email,
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
sqlx::query!(
|
||||
"INSERT INTO draft_move (workspace_id, typ, old_path, new_path, email)
|
||||
SELECT $1, t::draft_kind, $3, $4, $5 FROM unnest($2::text[]) t",
|
||||
w_id,
|
||||
&typs as &[&str],
|
||||
old_path,
|
||||
new_path,
|
||||
email,
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop the move records at `path`: an item was just created there, so saves addressed to
|
||||
/// it are its own (records leaving `path`). A deploy that is not a rename also drops the
|
||||
/// records arriving, which point at an item that no longer owns the path; `keep_from`,
|
||||
/// the path a rename came from, suppresses that, since the chain ending here is the one
|
||||
/// this very deploy just wrote.
|
||||
///
|
||||
/// **The caller must have authorized the deploy that created the item first.**
|
||||
/// Dropping a record sends later draft writes at `path` back to `path`.
|
||||
pub async fn clear_draft_moves_from(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
w_id: &str,
|
||||
kinds: &[UserDraftItemKind],
|
||||
path: &str,
|
||||
keep_from: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let typs = kinds.iter().map(|k| k.as_str()).collect::<Vec<_>>();
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft_move WHERE workspace_id = $1 AND typ::text = ANY($2::text[])
|
||||
AND (old_path = $3 OR (new_path = $3 AND $4::text IS NULL))",
|
||||
w_id,
|
||||
&typs as &[&str],
|
||||
path,
|
||||
keep_from,
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch the authed user's draft as a standalone payload, for "get by path"
|
||||
/// routes when no deployed row exists but a draft might. Returns it as a
|
||||
/// `WithDraftOverlay` with `inner` and `draft` both set to the same JSON and
|
||||
@@ -600,7 +904,7 @@ pub async fn fetch_draft_only(
|
||||
// Own draft first, legacy NULL-email row as fallback (see `maybe_overlay_draft`).
|
||||
let row = sqlx::query!(
|
||||
r#"SELECT value as "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
|
||||
created_at
|
||||
created_at, base
|
||||
FROM draft
|
||||
WHERE workspace_id = $1
|
||||
AND (email = $2 OR email IS NULL)
|
||||
@@ -631,6 +935,7 @@ pub async fn fetch_draft_only(
|
||||
inner: Box::new(draft_json.clone()),
|
||||
is_draft: true,
|
||||
draft_saved_at: Some(row.created_at),
|
||||
draft_base: row.base,
|
||||
no_deployed: true,
|
||||
draft: Some(draft_json),
|
||||
other_drafts_users,
|
||||
|
||||
@@ -479,6 +479,28 @@ pub fn paginate(pagination: Pagination) -> (usize, usize) {
|
||||
(per_page, offset)
|
||||
}
|
||||
|
||||
/// [`paginate`] for a listing that answers whole unless a size is asked for: the deploy
|
||||
/// histories, which the history panels and the CLI read unpaged while the diff picker takes
|
||||
/// a page at a time. An asked-for size is still clamped, and the offset saturates rather
|
||||
/// than wrapping, so no caller can turn this into an unbounded scan or a negative bind.
|
||||
pub fn paginate_optional(pagination: Pagination) -> (i64, i64) {
|
||||
// Naming neither parameter asks for the whole listing, the contract these endpoints
|
||||
// have always answered on. Naming either makes it a page like any other listing's.
|
||||
if pagination.page.is_none() && pagination.per_page.is_none() {
|
||||
return (i64::MAX, 0);
|
||||
}
|
||||
let per_page = pagination
|
||||
.per_page
|
||||
.unwrap_or(DEFAULT_PER_PAGE)
|
||||
.clamp(1, MAX_PER_PAGE) as i64;
|
||||
// Bound before Postgres sees it: an unchecked cast of a caller-controlled page becomes
|
||||
// a negative OFFSET, which is an error rather than an empty page.
|
||||
let offset = i64::try_from(pagination.page.unwrap_or(1).max(1) - 1)
|
||||
.unwrap_or(i64::MAX)
|
||||
.saturating_mul(per_page);
|
||||
(per_page, offset)
|
||||
}
|
||||
|
||||
pub fn paginate_without_limits(pagination: Pagination) -> (usize, usize) {
|
||||
let per_page = pagination.per_page.unwrap_or(MAX_PER_PAGE);
|
||||
let offset = (pagination.page.unwrap_or(1).max(1) - 1) * per_page;
|
||||
@@ -1672,6 +1694,31 @@ pub fn truncate_with_ellipsis(s: &str, max_chars: usize) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_paginate_optional_answers_whole_but_bounds_what_is_asked_for() {
|
||||
// Nothing asked for: every row there can be, which is what the history panels and
|
||||
// the CLI read.
|
||||
assert_eq!(
|
||||
paginate_optional(Pagination { page: None, per_page: None }),
|
||||
(i64::MAX, 0)
|
||||
);
|
||||
assert_eq!(
|
||||
paginate_optional(Pagination { page: Some(3), per_page: Some(20) }),
|
||||
(20, 40)
|
||||
);
|
||||
// An asked-for size is still capped, so no caller turns this into an unbounded scan.
|
||||
assert_eq!(
|
||||
paginate_optional(Pagination { page: None, per_page: Some(usize::MAX) }),
|
||||
(MAX_PER_PAGE as i64, 0)
|
||||
);
|
||||
// A page nobody could mean lands past the end rather than going negative, which
|
||||
// Postgres would reject outright.
|
||||
let (per_page, offset) =
|
||||
paginate_optional(Pagination { page: Some(usize::MAX), per_page: Some(20) });
|
||||
assert_eq!(per_page, 20);
|
||||
assert_eq!(offset, i64::MAX);
|
||||
}
|
||||
|
||||
/// A 5-field crontab line is the most common way to get a schedule rejected, and both
|
||||
/// parsers report it in terms a crontab user cannot act on, so the seconds field and the
|
||||
/// equivalent expression must reach the caller for v1 and v2 alike.
|
||||
|
||||
@@ -775,19 +775,6 @@ pub fn make_pull_query(tags: &[String]) -> String {
|
||||
query
|
||||
}
|
||||
|
||||
/// Claim a parent's tool jobs without tag filtering. The caller must supply only child IDs
|
||||
/// it owns; this query is an internal scheduling primitive and does not authorize job access.
|
||||
pub fn make_tool_job_pull_query(job_ids: &[uuid::Uuid]) -> String {
|
||||
// pull() binds only the worker name. These literals come from typed UUIDs, never input SQL.
|
||||
let ids = job_ids.iter().map(|id| format!("'{id}'::uuid")).join(", ");
|
||||
format_pull_query(format!(
|
||||
"SELECT id FROM v2_job_queue
|
||||
WHERE running = false AND id = ANY(ARRAY[{ids}]::uuid[]) AND scheduled_for <= now()
|
||||
ORDER BY priority DESC NULLS LAST, scheduled_for
|
||||
FOR UPDATE SKIP LOCKED LIMIT 1"
|
||||
))
|
||||
}
|
||||
|
||||
// Variant of `make_pull_query` that additionally excludes jobs whose workspace_id is in the
|
||||
// overloaded-list bind parameter ($2::text[]). Built as a separate string (rather than reusing
|
||||
// `make_pull_query` with an always-bound array) so the planner can keep using the same indexes
|
||||
|
||||
@@ -4399,45 +4399,6 @@ pub fn has_active_concurrency_limit(concurrent_limit: Option<i32>) -> bool {
|
||||
concurrent_limit.is_some_and(|n| n > 0)
|
||||
}
|
||||
|
||||
/// Admit a job already owned by a worker without releasing its queue reservation.
|
||||
/// The caller must maintain its heartbeat while waiting and complete it on failure.
|
||||
pub async fn try_admit_owned_job(db: &DB, job: &MiniPulledJob) -> error::Result<bool> {
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
{
|
||||
let settings = windmill_common::runnable_settings::prefetch_cached_from_handle(
|
||||
job.runnable_settings_handle,
|
||||
db,
|
||||
)
|
||||
.await?
|
||||
.1
|
||||
.maybe_fallback(None, job.concurrent_limit, job.concurrency_time_window_s);
|
||||
if has_active_concurrency_limit(settings.concurrent_limit)
|
||||
&& !*DISABLE_CONCURRENCY_LIMIT
|
||||
&& job.canceled_by.is_none()
|
||||
{
|
||||
let key = concurrency_key(db, &job.id).await?.ok_or_else(|| {
|
||||
Error::internal_err(format!("No concurrency key found for job {}", job.id))
|
||||
})?;
|
||||
if !key.is_empty() {
|
||||
return Ok(crate::jobs_ee::update_concurrency_counter(
|
||||
db,
|
||||
&job.id,
|
||||
key,
|
||||
serde_json::json!({ job.id.to_string(): {} }),
|
||||
job.id.to_string(),
|
||||
settings.concurrency_time_window_s.unwrap_or(0),
|
||||
settings.concurrent_limit.unwrap_or_default(),
|
||||
)
|
||||
.await?
|
||||
.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
let _ = (db, job);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn custom_concurrency_key(
|
||||
db: &Pool<Postgres>,
|
||||
job_id: &Uuid,
|
||||
|
||||
@@ -6457,7 +6457,7 @@ var AppService = class {
|
||||
* @param data.workspace
|
||||
* @param data.path
|
||||
* @param data.requestBody update app
|
||||
* @returns string app updated
|
||||
* @returns AppDeployed the path the app now lives at and the version this deploy wrote
|
||||
* @throws ApiError
|
||||
*/
|
||||
static updateApp(data) {
|
||||
|
||||
@@ -507,6 +507,10 @@ pub struct ScriptHistory {
|
||||
pub deployment_msg: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
/// Who deployed this version — the version picker names it, so a reader can
|
||||
/// tell their own deploys from a teammate's.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub created_by: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
||||
@@ -9,12 +9,14 @@ use crate::result_processor::handle_non_flow_job_error;
|
||||
use crate::worker_flow::{
|
||||
evaluate_input_transform, raw_script_to_payload, script_to_payload, JobPayloadWithTag,
|
||||
};
|
||||
use crate::{create_job_dir, handle_queued_job, JobCompletedSender};
|
||||
use crate::{
|
||||
create_job_dir, handle_queued_job, JobCompletedReceiver, JobCompletedSender, SendResult,
|
||||
SendResultPayload,
|
||||
};
|
||||
use anyhow::Context;
|
||||
use mappable_rc::Marc;
|
||||
use serde_json::value::RawValue;
|
||||
use sqlx::types::Json;
|
||||
use std::{collections::HashMap, sync::Arc, time::Duration};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use uuid::Uuid;
|
||||
use windmill_ai::{ai_types::OpenAIToolCall, query_builder::StreamEventSink, types::*};
|
||||
use windmill_common::jobs::JobPayload;
|
||||
@@ -34,11 +36,11 @@ use windmill_common::{
|
||||
flow_conversations::{MessageExtras, MessageType},
|
||||
flow_status::AgentAction,
|
||||
flows::FlowModuleValue,
|
||||
worker::{make_tool_job_pull_query, to_raw_value, Connection},
|
||||
worker::{to_raw_value, Connection},
|
||||
};
|
||||
use windmill_queue::{
|
||||
get_mini_pulled_job, pull, push, try_admit_owned_job, MiniCompletedJob, MiniPulledJob,
|
||||
PushArgs, PushIsolationLevel,
|
||||
add_completed_job, add_completed_job_error, get_mini_pulled_job, push, MiniCompletedJob,
|
||||
MiniPulledJob, PushArgs, PushIsolationLevel,
|
||||
};
|
||||
|
||||
/// Shared collection of abort handles for spawned tool tasks.
|
||||
@@ -80,7 +82,6 @@ pub struct ToolExecutionContext<'a> {
|
||||
|
||||
// Abort handles for spawned tool tasks (used for force-cancel cleanup)
|
||||
pub tool_abort_handles: ToolAbortHandles,
|
||||
pub job_completed_tx: JobCompletedSender,
|
||||
}
|
||||
|
||||
/// Execute all tool calls from an AI response
|
||||
@@ -97,8 +98,7 @@ pub async fn execute_tool_calls(
|
||||
let mut used_structured_output_tool = false;
|
||||
let mut final_content = None;
|
||||
|
||||
let mut calls = tool_calls.iter().peekable();
|
||||
while let Some(tool_call) = calls.next() {
|
||||
for tool_call in tool_calls.iter() {
|
||||
// Stream tool call progress
|
||||
if let Some(stream_event_processor) = ctx.stream_event_processor {
|
||||
let event = StreamingEvent::ToolExecution {
|
||||
@@ -150,24 +150,15 @@ pub async fn execute_tool_calls(
|
||||
)
|
||||
.await?;
|
||||
} else if tool.module.is_some() {
|
||||
let mut batch = vec![(tool_call, tool)];
|
||||
while let Some(next_call) = calls.peek() {
|
||||
if structured_output_tool_name.as_deref()
|
||||
== Some(next_call.function.name.as_str())
|
||||
{
|
||||
break;
|
||||
}
|
||||
let Some(next_tool) = tools.iter().find(|t| {
|
||||
t.def.function.name == next_call.function.name
|
||||
&& t.mcp_source.is_none()
|
||||
&& t.module.is_some()
|
||||
}) else {
|
||||
break;
|
||||
};
|
||||
batch.push((calls.next().unwrap(), next_tool));
|
||||
}
|
||||
execute_windmill_tools(&mut ctx, &batch, actions, &mut messages, final_events_str)
|
||||
.await?;
|
||||
execute_windmill_tool(
|
||||
&mut ctx,
|
||||
tool_call,
|
||||
tool,
|
||||
actions,
|
||||
&mut messages,
|
||||
final_events_str,
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
return Err(Error::internal_err(format!(
|
||||
"Tool type not supported: {}",
|
||||
@@ -322,13 +313,14 @@ async fn execute_mcp_tool_call(
|
||||
}
|
||||
|
||||
/// Execute a Windmill tool (script or flow)
|
||||
async fn enqueue_windmill_tool(
|
||||
ctx: &ToolExecutionContext<'_>,
|
||||
async fn execute_windmill_tool(
|
||||
ctx: &mut ToolExecutionContext<'_>,
|
||||
tool_call: &OpenAIToolCall,
|
||||
tool: &Tool,
|
||||
actions: &mut Vec<AgentAction>,
|
||||
reserved: bool,
|
||||
) -> Result<Uuid, Error> {
|
||||
messages: &mut Vec<OpenAIMessage>,
|
||||
final_events_str: &mut String,
|
||||
) -> Result<(), Error> {
|
||||
// Regular Windmill tools must have a module
|
||||
let tool_module = tool.module.as_ref().ok_or_else(|| {
|
||||
Error::internal_err(format!("Tool {} has no module", tool_call.function.name))
|
||||
@@ -410,6 +402,8 @@ async fn enqueue_windmill_tool(
|
||||
tool_call_args.insert(key.clone(), result);
|
||||
}
|
||||
|
||||
let is_ai_agent_tool = matches!(tool_value, FlowModuleValue::AIAgent { .. });
|
||||
|
||||
let job_payload = match tool_value {
|
||||
FlowModuleValue::Script { path: script_path, hash: script_hash, tag_override, .. } => {
|
||||
script_to_payload(
|
||||
@@ -479,6 +473,8 @@ async fn enqueue_windmill_tool(
|
||||
));
|
||||
}
|
||||
let path = format!("{}/tools/{}", ctx.job.runnable_path(), tool_module.id);
|
||||
// tool jobs are pushed with the parent agent job's tag and executed inline on the
|
||||
// same worker, so a tag override on a nested agent tool does not apply here
|
||||
JobPayloadWithTag {
|
||||
payload: JobPayload::AIAgent { path },
|
||||
tag: None,
|
||||
@@ -536,52 +532,44 @@ async fn enqueue_windmill_tool(
|
||||
false,
|
||||
None,
|
||||
ctx.job.visible_to_owner,
|
||||
job_payload.tag,
|
||||
Some(ctx.job.tag.clone()),
|
||||
job_payload.timeout,
|
||||
None,
|
||||
job_priority,
|
||||
job_perms.as_ref(),
|
||||
reserved,
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut tx = tx;
|
||||
if reserved {
|
||||
// Running ownership reserves the first child; its normal tag allows zombie recovery.
|
||||
sqlx::query!(
|
||||
"UPDATE v2_job_queue SET worker = $1 WHERE id = $2",
|
||||
ctx.worker_name,
|
||||
uuid,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query!("UPDATE v2_job_runtime SET ping = now() WHERE id = $1", uuid)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(uuid)
|
||||
}
|
||||
|
||||
fn spawn_local_tool(
|
||||
ctx: &ToolExecutionContext<'_>,
|
||||
tool_job: MiniPulledJob,
|
||||
reserved: bool,
|
||||
) -> tokio::task::JoinHandle<Result<OccupancyMetrics, Error>> {
|
||||
let mut tool_job = Arc::new(tool_job);
|
||||
let tool_job = get_mini_pulled_job(ctx.db, &uuid).await?;
|
||||
|
||||
let Some(tool_job) = tool_job else {
|
||||
return Err(Error::internal_err("Tool job not found".to_string()));
|
||||
};
|
||||
|
||||
let tool_job = Arc::new(tool_job);
|
||||
|
||||
let (inner_job_completed_tx, inner_job_completed_rx) = JobCompletedSender::new(ctx.conn, 1);
|
||||
|
||||
let inner_job_completed_rx = inner_job_completed_rx.expect(
|
||||
"inner_job_completed_tx should be set as agent jobs are not supported on agent workers",
|
||||
);
|
||||
|
||||
// Spawn handle_queued_job on separate task to prevent tokio stack overflow
|
||||
// Clone everything needed for the spawned task
|
||||
let db = ctx.db.clone();
|
||||
let tool_job_spawn = tool_job.clone();
|
||||
let conn_spawn = ctx.conn.clone();
|
||||
let client_spawn = ctx.client.clone();
|
||||
let hostname_spawn = ctx.hostname.to_string();
|
||||
let worker_name_spawn = ctx.worker_name.to_string();
|
||||
let worker_dir_spawn = ctx.worker_dir.to_string();
|
||||
let base_internal_url_spawn = ctx.base_internal_url.to_string();
|
||||
let job_completed_tx = ctx.job_completed_tx.clone();
|
||||
let inner_job_completed_tx_spawn = inner_job_completed_tx.clone();
|
||||
let mut occupancy_metrics_spawn = ctx.occupancy_metrics.clone();
|
||||
let mut killpill_rx_spawn = ctx.killpill_rx.resubscribe();
|
||||
|
||||
@@ -590,264 +578,145 @@ fn spawn_local_tool(
|
||||
#[cfg(feature = "benchmark")]
|
||||
let mut bench_spawn = windmill_common::bench::BenchmarkIter::new();
|
||||
|
||||
let result = async {
|
||||
if reserved {
|
||||
loop {
|
||||
let queued = get_mini_pulled_job(&db, &tool_job.id)
|
||||
.await?
|
||||
.ok_or_else(|| Error::AlreadyCompleted("Tool job already completed".to_string()))?;
|
||||
tool_job = Arc::new(queued);
|
||||
sqlx::query!(
|
||||
"UPDATE v2_job_runtime SET ping = now() WHERE id = $1",
|
||||
tool_job.id,
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
if try_admit_owned_job(&db, &tool_job).await? {
|
||||
let started_at = sqlx::query_scalar!(
|
||||
"UPDATE v2_job_queue SET started_at = now() WHERE id = $1 RETURNING started_at",
|
||||
tool_job.id,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.flatten();
|
||||
Arc::make_mut(&mut tool_job).started_at = started_at;
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
}
|
||||
let perms =
|
||||
windmill_common::auth::get_job_perms(&db, &tool_job.id, &tool_job.workspace_id).await?;
|
||||
let token = windmill_queue::create_token(&db, &tool_job, perms).await;
|
||||
let client_spawn = AuthedClient::new(
|
||||
base_internal_url_spawn.clone(),
|
||||
tool_job.workspace_id.clone(),
|
||||
token,
|
||||
None,
|
||||
);
|
||||
let job_dir = create_job_dir(&worker_dir_spawn, tool_job.id).await;
|
||||
let job_dir = create_job_dir(&worker_dir_spawn, tool_job_spawn.id).await;
|
||||
|
||||
handle_queued_job(
|
||||
tool_job.clone(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&conn_spawn,
|
||||
&client_spawn,
|
||||
&hostname_spawn,
|
||||
&worker_name_spawn,
|
||||
&worker_dir_spawn,
|
||||
&job_dir,
|
||||
None,
|
||||
&base_internal_url_spawn,
|
||||
job_completed_tx,
|
||||
&mut occupancy_metrics_spawn,
|
||||
&mut killpill_rx_spawn,
|
||||
None,
|
||||
None,
|
||||
#[cfg(feature = "benchmark")]
|
||||
&mut bench_spawn,
|
||||
)
|
||||
.await
|
||||
}
|
||||
let result = handle_queued_job(
|
||||
tool_job_spawn,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&conn_spawn,
|
||||
&client_spawn,
|
||||
&hostname_spawn,
|
||||
&worker_name_spawn,
|
||||
&worker_dir_spawn,
|
||||
&job_dir,
|
||||
None,
|
||||
&base_internal_url_spawn,
|
||||
inner_job_completed_tx_spawn,
|
||||
&mut occupancy_metrics_spawn,
|
||||
&mut killpill_rx_spawn,
|
||||
None,
|
||||
None,
|
||||
#[cfg(feature = "benchmark")]
|
||||
&mut bench_spawn,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Err(err) => {
|
||||
let err_string = format!("{}: {}", err.name(), err);
|
||||
handle_non_flow_job_error(
|
||||
&db,
|
||||
&MiniCompletedJob::from(tool_job),
|
||||
0,
|
||||
None,
|
||||
err_string,
|
||||
windmill_common::worker::error_to_value(&err),
|
||||
&worker_name_spawn,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(_) => {}
|
||||
}
|
||||
Ok(occupancy_metrics_spawn)
|
||||
// Return both result and updated metrics
|
||||
(result, occupancy_metrics_spawn)
|
||||
});
|
||||
|
||||
// Register abort handle so the task can be killed on force-cancel
|
||||
let abort_handle = join_handle.abort_handle();
|
||||
// unwrap safe: lock is only held briefly for push/drain, no panic possible inside
|
||||
ctx.tool_abort_handles.lock().unwrap().push(abort_handle);
|
||||
join_handle
|
||||
}
|
||||
|
||||
async fn execute_windmill_tools(
|
||||
ctx: &mut ToolExecutionContext<'_>,
|
||||
batch: &[(&OpenAIToolCall, &Tool)],
|
||||
actions: &mut Vec<AgentAction>,
|
||||
messages: &mut Vec<OpenAIMessage>,
|
||||
final_events_str: &mut String,
|
||||
) -> Result<(), Error> {
|
||||
let mut job_ids = Vec::with_capacity(batch.len());
|
||||
let mut local = None;
|
||||
for (index, (call, tool)) in batch.iter().enumerate() {
|
||||
if index > 0 {
|
||||
if let Some(processor) = ctx.stream_event_processor {
|
||||
processor
|
||||
.send(
|
||||
StreamingEvent::ToolExecution {
|
||||
call_id: call.id.clone(),
|
||||
function_name: call.function.name.clone(),
|
||||
},
|
||||
final_events_str,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
// Await the spawned task
|
||||
let (handle_result, updated_occupancy) = join_handle.await.map_err(|e| {
|
||||
if e.is_cancelled() {
|
||||
Error::ExecutionErr("Tool execution task was cancelled".to_string())
|
||||
} else {
|
||||
Error::internal_err(format!("Tool execution task failed: {}", e))
|
||||
}
|
||||
job_ids.push(enqueue_windmill_tool(ctx, call, tool, actions, index == 0).await?);
|
||||
if index == 0 {
|
||||
let job = get_mini_pulled_job(ctx.db, &job_ids[0])
|
||||
.await?
|
||||
.ok_or_else(|| Error::internal_err("Reserved tool job not found".to_string()))?;
|
||||
local = Some(spawn_local_tool(ctx, job, true));
|
||||
})?;
|
||||
|
||||
// Merge occupancy metrics back
|
||||
ctx.occupancy_metrics.total_duration_of_running_jobs =
|
||||
updated_occupancy.total_duration_of_running_jobs;
|
||||
|
||||
match handle_result {
|
||||
Err(err) => {
|
||||
handle_tool_execution_error(
|
||||
ctx,
|
||||
tool_call,
|
||||
tool_module,
|
||||
&MiniCompletedJob::from(tool_job),
|
||||
job_id,
|
||||
err,
|
||||
messages,
|
||||
final_events_str,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(outcome) => {
|
||||
handle_tool_execution_success(
|
||||
ctx,
|
||||
tool_call,
|
||||
tool_module,
|
||||
job_id,
|
||||
outcome.is_success(),
|
||||
is_ai_agent_tool,
|
||||
inner_job_completed_rx,
|
||||
messages,
|
||||
final_events_str,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut results: HashMap<Uuid, (bool, String)> = HashMap::new();
|
||||
let mut next_result = 0;
|
||||
let mut poll = tokio::time::interval(Duration::from_millis(100));
|
||||
poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
while next_result < batch.len() || local.is_some() {
|
||||
tokio::select! {
|
||||
result = async { local.as_mut().unwrap().await }, if local.is_some() => {
|
||||
local = None;
|
||||
let metrics = result.map_err(|e| Error::internal_err(format!("Tool task failed: {e}")))??;
|
||||
ctx.occupancy_metrics.total_duration_of_running_jobs = metrics.total_duration_of_running_jobs;
|
||||
ctx.tool_abort_handles.lock().unwrap().retain(|handle| !handle.is_finished());
|
||||
}
|
||||
_ = poll.tick() => {}
|
||||
}
|
||||
|
||||
let pending: Vec<Uuid> = job_ids[next_result..]
|
||||
.iter()
|
||||
.filter(|id| !results.contains_key(id))
|
||||
.copied()
|
||||
.collect();
|
||||
if !pending.is_empty() {
|
||||
let completed = sqlx::query!(
|
||||
"SELECT id, status = 'success' AS \"success!\", result AS \"result: Json<Box<RawValue>>\"
|
||||
FROM v2_job_completed WHERE workspace_id = $1 AND id = ANY($2)",
|
||||
ctx.job.workspace_id,
|
||||
&pending,
|
||||
).fetch_all(ctx.db).await?;
|
||||
for completed in completed {
|
||||
let index = job_ids
|
||||
.iter()
|
||||
.position(|id| *id == completed.id)
|
||||
.ok_or_else(|| Error::internal_err("Unexpected tool completion".to_string()))?;
|
||||
let (call, tool) = batch[index];
|
||||
let result = completed
|
||||
.result
|
||||
.map(|value| value.0)
|
||||
.unwrap_or_else(|| to_raw_value(&serde_json::Value::Null));
|
||||
let is_agent = tool.module.as_ref().is_some_and(|module| {
|
||||
matches!(module.get_value(), Ok(FlowModuleValue::AIAgent { .. }))
|
||||
});
|
||||
let content = if is_agent && completed.success {
|
||||
extract_ai_agent_output(&result).unwrap_or_else(|| result.get().to_string())
|
||||
} else {
|
||||
result.get().to_string()
|
||||
};
|
||||
if let Some(processor) = ctx.stream_event_processor {
|
||||
processor
|
||||
.send(
|
||||
StreamingEvent::ToolResult {
|
||||
call_id: call.id.clone(),
|
||||
function_name: call.function.name.clone(),
|
||||
result: content.clone(),
|
||||
success: completed.success,
|
||||
},
|
||||
final_events_str,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
results.insert(completed.id, (completed.success, content));
|
||||
}
|
||||
}
|
||||
|
||||
// Transcript rows, model messages, and positional action statuses share call order.
|
||||
while next_result < batch.len() {
|
||||
let job_id = job_ids[next_result];
|
||||
let Some((success, content)) = results.remove(&job_id) else {
|
||||
break;
|
||||
};
|
||||
let (call, tool) = batch[next_result];
|
||||
let module = tool
|
||||
.module
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::internal_err("Windmill tool has no module".to_string()))?;
|
||||
messages.push(OpenAIMessage {
|
||||
role: "tool".to_string(),
|
||||
content: Some(OpenAIContent::Text(content.clone())),
|
||||
tool_call_id: Some(call.id.clone()),
|
||||
agent_action: Some(AgentAction::ToolCall {
|
||||
job_id,
|
||||
function_name: call.function.name.clone(),
|
||||
module_id: module.id.clone(),
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
if let Some(parent) = ctx.parent_job {
|
||||
update_flow_status_module_with_actions_success(ctx.db, parent, success).await?;
|
||||
}
|
||||
let (content, extras) = windmill_tool_row(call, success, &content);
|
||||
add_tool_message_to_chat(ctx, Some(job_id), &content, success, Some(extras)).await;
|
||||
next_result += 1;
|
||||
}
|
||||
|
||||
if local.is_none() && next_result < batch.len() {
|
||||
let pending: Vec<Uuid> = job_ids[next_result..]
|
||||
.iter()
|
||||
.filter(|id| !results.contains_key(id))
|
||||
.copied()
|
||||
.collect();
|
||||
if !pending.is_empty() {
|
||||
local = claim_local_tool(ctx, &pending).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn claim_local_tool(
|
||||
ctx: &ToolExecutionContext<'_>,
|
||||
pending: &[Uuid],
|
||||
) -> Result<Option<tokio::task::JoinHandle<Result<OccupancyMetrics, Error>>>, Error> {
|
||||
let query = (String::new(), make_tool_job_pull_query(pending));
|
||||
#[cfg(feature = "benchmark")]
|
||||
let mut bench = windmill_common::bench::BenchmarkIter::new();
|
||||
let mut pulled = pull(
|
||||
/// Handle tool execution error
|
||||
async fn handle_tool_execution_error(
|
||||
ctx: &mut ToolExecutionContext<'_>,
|
||||
tool_call: &OpenAIToolCall,
|
||||
tool_module: &windmill_common::flows::FlowModule,
|
||||
tool_job: &MiniCompletedJob,
|
||||
job_id: Uuid,
|
||||
err: Error,
|
||||
messages: &mut Vec<OpenAIMessage>,
|
||||
final_events_str: &mut String,
|
||||
) -> Result<(), Error> {
|
||||
let err_string = format!("{}: {}", err.name(), err.to_string());
|
||||
let err_json = windmill_common::worker::error_to_value(&err);
|
||||
let _ = handle_non_flow_job_error(
|
||||
ctx.db,
|
||||
false,
|
||||
tool_job,
|
||||
0,
|
||||
None,
|
||||
err_string.clone(),
|
||||
err_json,
|
||||
ctx.worker_name,
|
||||
Some(&query),
|
||||
#[cfg(feature = "benchmark")]
|
||||
&mut bench,
|
||||
)
|
||||
.await?;
|
||||
if let Err(err) = pulled.maybe_apply_debouncing(ctx.db).await {
|
||||
pulled.error_while_preprocessing = Some(err.to_string());
|
||||
.await;
|
||||
|
||||
let error_message = format!("Error running tool: {}", err_string);
|
||||
messages.push(OpenAIMessage {
|
||||
role: "tool".to_string(),
|
||||
content: Some(OpenAIContent::Text(error_message.clone())),
|
||||
tool_call_id: Some(tool_call.id.clone()),
|
||||
agent_action: Some(AgentAction::ToolCall {
|
||||
job_id,
|
||||
function_name: tool_call.function.name.clone(),
|
||||
module_id: tool_module.id.clone(),
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Stream tool result (error case)
|
||||
if let Some(stream_event_processor) = ctx.stream_event_processor {
|
||||
let tool_result_event = StreamingEvent::ToolResult {
|
||||
call_id: tool_call.id.clone(),
|
||||
function_name: tool_call.function.name.clone(),
|
||||
result: error_message.clone(),
|
||||
success: false,
|
||||
};
|
||||
stream_event_processor
|
||||
.send(tool_result_event, final_events_str)
|
||||
.await?;
|
||||
}
|
||||
match pulled.to_pulled_job() {
|
||||
Ok(job) => Ok(job.map(|job| spawn_local_tool(ctx, job.job, false))),
|
||||
Err(
|
||||
windmill_queue::PulledJobResultToJobErr::MissingConcurrencyKey(job)
|
||||
| windmill_queue::PulledJobResultToJobErr::ErrorWhilePreprocessing(job),
|
||||
) => {
|
||||
ctx.job_completed_tx.send_job(job, true).await?;
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
if let Some(parent_job) = ctx.parent_job {
|
||||
update_flow_status_module_with_actions_success(ctx.db, parent_job, false).await?;
|
||||
}
|
||||
|
||||
let (content, extras) = windmill_tool_row(tool_call, false, &error_message);
|
||||
add_tool_message_to_chat(ctx, Some(job_id), &content, false, Some(extras)).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract the `output` field of an `AIAgentResult` envelope, serialized back to JSON.
|
||||
@@ -859,6 +728,119 @@ fn extract_ai_agent_output(result: &RawValue) -> Option<String> {
|
||||
.map(|output| output.get().to_string())
|
||||
}
|
||||
|
||||
/// Handle tool execution success
|
||||
async fn handle_tool_execution_success(
|
||||
ctx: &mut ToolExecutionContext<'_>,
|
||||
tool_call: &OpenAIToolCall,
|
||||
tool_module: &windmill_common::flows::FlowModule,
|
||||
job_id: Uuid,
|
||||
success: bool,
|
||||
is_ai_agent_tool: bool,
|
||||
inner_job_completed_rx: JobCompletedReceiver,
|
||||
messages: &mut Vec<OpenAIMessage>,
|
||||
final_events_str: &mut String,
|
||||
) -> Result<(), Error> {
|
||||
let send_result = inner_job_completed_rx.bounded_rx.try_recv().ok();
|
||||
|
||||
let (result, job_success) = if let Some(SendResult {
|
||||
result: SendResultPayload::JobCompleted(ref jc),
|
||||
..
|
||||
}) = send_result
|
||||
{
|
||||
let result = jc.result.clone();
|
||||
// Write tool completion to the DB inline instead of forwarding through
|
||||
// the parent channel. Forwarding would deadlock for nested agents: the
|
||||
// sub-tool result would fill the parent's bounded(1) channel, leaving
|
||||
// no room for the agent's own completion from process_result.
|
||||
if jc.success {
|
||||
add_completed_job(
|
||||
ctx.db,
|
||||
&jc.job,
|
||||
true,
|
||||
false,
|
||||
sqlx::types::Json(&*jc.result),
|
||||
jc.result_columns.clone(),
|
||||
jc.mem_peak,
|
||||
jc.canceled_by.clone(),
|
||||
false,
|
||||
jc.duration,
|
||||
jc.from_cache.unwrap_or(false),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("Failed to add completed job: {e}")))?;
|
||||
} else {
|
||||
let error_value: serde_json::Value =
|
||||
serde_json::from_str(jc.result.get()).unwrap_or_else(|_| {
|
||||
serde_json::json!({ "message": format!("Non serializable error: {}", jc.result.get()) })
|
||||
});
|
||||
add_completed_job_error(
|
||||
ctx.db,
|
||||
&jc.job,
|
||||
jc.mem_peak,
|
||||
jc.canceled_by.clone(),
|
||||
error_value,
|
||||
ctx.worker_name,
|
||||
false,
|
||||
jc.duration,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("Failed to add completed job error: {e}")))?;
|
||||
}
|
||||
(result, jc.success)
|
||||
} else {
|
||||
return Err(Error::internal_err(
|
||||
"Tool job completed but no result".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
// A nested agent returns the whole `AIAgentResult` envelope: on top of `output` it carries
|
||||
// the child's entire message history, stream log and token usage. Feeding that back would
|
||||
// grow the caller's context by the child's full transcript on every call, so the caller only
|
||||
// sees `output`. The envelope stays intact in the tool job's completed row.
|
||||
let tool_result = if is_ai_agent_tool && job_success {
|
||||
extract_ai_agent_output(&result).unwrap_or_else(|| result.get().to_string())
|
||||
} else {
|
||||
result.get().to_string()
|
||||
};
|
||||
|
||||
messages.push(OpenAIMessage {
|
||||
role: "tool".to_string(),
|
||||
content: Some(OpenAIContent::Text(tool_result.clone())),
|
||||
tool_call_id: Some(tool_call.id.clone()),
|
||||
agent_action: Some(AgentAction::ToolCall {
|
||||
job_id,
|
||||
function_name: tool_call.function.name.clone(),
|
||||
module_id: tool_module.id.clone(),
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let (content, extras) = windmill_tool_row(tool_call, success, &tool_result);
|
||||
|
||||
// The job ran; whether it ran successfully is `success`, and the row stored below is
|
||||
// worded from it. The stream has to carry the same value, or the card the reader watches
|
||||
// and the row that replaces it describe the same call differently.
|
||||
if let Some(stream_event_processor) = ctx.stream_event_processor {
|
||||
let tool_result_event = StreamingEvent::ToolResult {
|
||||
call_id: tool_call.id.clone(),
|
||||
function_name: tool_call.function.name.clone(),
|
||||
result: tool_result,
|
||||
success,
|
||||
};
|
||||
stream_event_processor
|
||||
.send(tool_result_event, final_events_str)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(parent_job) = ctx.parent_job {
|
||||
update_flow_status_module_with_actions_success(ctx.db, parent_job, success).await?;
|
||||
}
|
||||
|
||||
add_tool_message_to_chat(ctx, Some(job_id), &content, success, Some(extras)).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A Windmill tool's conversation row: worded from the tool, carrying the model's call and
|
||||
/// the exact text the model got back, the same text agent memory keeps for that tool
|
||||
/// message, so a card needs no job fetch. The call is the model's arguments, not the job's
|
||||
@@ -923,7 +905,9 @@ async fn add_tool_message_to_chat(
|
||||
.or(ctx.job.flow_step_id.as_deref());
|
||||
let step_name = get_step_name_from_flow(ctx.summary.as_deref(), effective_step_id);
|
||||
|
||||
// created_seq defines transcript order, so these writes must stay sequential.
|
||||
// Awaited, not spawned: `created_seq` is the transcript's order, so a round's rows
|
||||
// must commit in the order of its calls. Calls run one after another; running them
|
||||
// in parallel would need their rows written in call order all the same.
|
||||
if let Err(e) = add_message_to_conversation(
|
||||
ctx.db,
|
||||
&memory_id,
|
||||
|
||||
@@ -504,7 +504,6 @@ pub async fn handle_ai_agent_job(
|
||||
hostname: &str,
|
||||
killpill_rx: &mut tokio::sync::broadcast::Receiver<()>,
|
||||
has_stream: &mut bool,
|
||||
job_completed_tx: crate::JobCompletedSender,
|
||||
) -> Result<Box<RawValue>, Error> {
|
||||
// build_args_map returns None if no $res:/$var: transforms needed, in which case use original args
|
||||
let local_args = match build_args_map(job, client, conn).await? {
|
||||
@@ -1011,7 +1010,6 @@ pub async fn handle_ai_agent_job(
|
||||
omit_output_from_conversation,
|
||||
cancel_rx,
|
||||
tool_abort_handles.clone(),
|
||||
job_completed_tx,
|
||||
);
|
||||
|
||||
let mut occupancy_opt = Some(occupancy_metrics);
|
||||
@@ -1030,10 +1028,13 @@ pub async fn handle_ai_agent_job(
|
||||
cancel_tx,
|
||||
CANCEL_GRACE_PERIOD,
|
||||
)
|
||||
.await
|
||||
.await?
|
||||
};
|
||||
// agent_fut and update_job are now dropped — borrows on mcp_clients and canceled_by released
|
||||
|
||||
// Cleanup MCP clients
|
||||
cleanup_mcp_clients(mcp_clients).await;
|
||||
|
||||
let format_cancel_info = |cb: &Option<CanceledBy>| {
|
||||
cb.as_ref()
|
||||
.map_or(("unknown".to_string(), "unknown".to_string()), |x| {
|
||||
@@ -1044,42 +1045,38 @@ pub async fn handle_ai_agent_job(
|
||||
})
|
||||
};
|
||||
|
||||
let result = match outcome {
|
||||
Err(error) => Err(error),
|
||||
Ok(outcome) => match outcome {
|
||||
GracefulPollOutcome::Ok(result) => Ok(result),
|
||||
GracefulPollOutcome::Timeout(ms) => {
|
||||
tracing::error!("AI agent timeout after {}s", ms / 1000);
|
||||
Err(Error::ExecutionErr(format!(
|
||||
"AI agent timeout after (>{}s)",
|
||||
ms / 1000
|
||||
)))
|
||||
match outcome {
|
||||
GracefulPollOutcome::Ok(result) => Ok(result),
|
||||
GracefulPollOutcome::Timeout(ms) => {
|
||||
tracing::error!("AI agent timeout after {}s", ms / 1000);
|
||||
Err(Error::ExecutionErr(format!(
|
||||
"AI agent timeout after (>{}s)",
|
||||
ms / 1000
|
||||
)))
|
||||
}
|
||||
GracefulPollOutcome::Cancelled { canceled_by: cb } => {
|
||||
let (by, reason) = format_cancel_info(&cb);
|
||||
Err(Error::ExecutionErr(format!(
|
||||
"Job cancelled by {by} (reason: {reason})"
|
||||
)))
|
||||
}
|
||||
GracefulPollOutcome::CancelledTimeout { canceled_by: cb } => {
|
||||
let (by, reason) = format_cancel_info(&cb);
|
||||
// Abort any still-running spawned tool tasks
|
||||
// unwrap safe: lock is only held briefly for push/drain, no panic possible inside
|
||||
for handle in tool_abort_handles.lock().unwrap().drain(..) {
|
||||
handle.abort();
|
||||
}
|
||||
GracefulPollOutcome::Cancelled { canceled_by: cb } => {
|
||||
let (by, reason) = format_cancel_info(&cb);
|
||||
Err(Error::ExecutionErr(format!(
|
||||
"Job cancelled by {by} (reason: {reason})"
|
||||
)))
|
||||
}
|
||||
GracefulPollOutcome::CancelledTimeout { canceled_by: cb } => {
|
||||
let (by, reason) = format_cancel_info(&cb);
|
||||
Err(Error::ExecutionErr(format!(
|
||||
"Job cancelled by {by} (reason: {reason}, timed out waiting for tool calls)"
|
||||
)))
|
||||
}
|
||||
GracefulPollOutcome::AlreadyCompleted => {
|
||||
Err(Error::AlreadyCompleted("Job already completed".to_string()))
|
||||
}
|
||||
},
|
||||
};
|
||||
if result.is_err() {
|
||||
for handle in tool_abort_handles.lock().unwrap().drain(..) {
|
||||
handle.abort();
|
||||
// Hard timeout: clean up orphaned jobs still stuck in v2_job_queue
|
||||
cleanup_orphaned_tool_jobs(db, &job.id, &job.workspace_id, cb).await;
|
||||
Err(Error::ExecutionErr(format!(
|
||||
"Job cancelled by {by} (reason: {reason}, timed out waiting for tool calls)"
|
||||
)))
|
||||
}
|
||||
GracefulPollOutcome::AlreadyCompleted => {
|
||||
Err(Error::AlreadyCompleted("Job already completed".to_string()))
|
||||
}
|
||||
cleanup_orphaned_tool_jobs(db, &job.id, &job.workspace_id, canceled_by.clone()).await;
|
||||
}
|
||||
cleanup_mcp_clients(mcp_clients).await;
|
||||
result
|
||||
}
|
||||
|
||||
/// OpenAI rejects a `prompt_cache_key` over 64 characters
|
||||
@@ -1132,7 +1129,6 @@ pub async fn run_agent(
|
||||
|
||||
// abort handles for spawned tool tasks
|
||||
tool_abort_handles: ToolAbortHandles,
|
||||
job_completed_tx: crate::JobCompletedSender,
|
||||
) -> error::Result<Box<RawValue>> {
|
||||
let output_type = args.output_type.as_ref().unwrap_or(&OutputType::Text);
|
||||
let credentials = args.provider.to_provider_credentials(db).await?;
|
||||
@@ -1863,7 +1859,6 @@ pub async fn run_agent(
|
||||
previous_result: &previous_result,
|
||||
id_context: &id_context,
|
||||
tool_abort_handles: tool_abort_handles.clone(),
|
||||
job_completed_tx: job_completed_tx.clone(),
|
||||
};
|
||||
|
||||
let (tool_messages, tool_content, tool_used_structured_output) =
|
||||
@@ -2763,13 +2758,11 @@ async fn cleanup_orphaned_tool_jobs(
|
||||
)
|
||||
});
|
||||
|
||||
// Find direct child jobs still in v2_job_queue (agent tool jobs are always direct children)
|
||||
let orphaned_ids: Vec<Uuid> = match sqlx::query_scalar!(
|
||||
r#"WITH RECURSIVE descendants AS (
|
||||
SELECT id FROM v2_job WHERE parent_job = $1 AND workspace_id = $2
|
||||
UNION ALL
|
||||
SELECT j.id FROM v2_job j JOIN descendants d ON j.parent_job = d.id
|
||||
WHERE j.workspace_id = $2
|
||||
) SELECT d.id AS "id!" FROM descendants d JOIN v2_job_queue q ON q.id = d.id"#,
|
||||
r#"SELECT j.id FROM v2_job j
|
||||
JOIN v2_job_queue q ON q.id = j.id
|
||||
WHERE j.parent_job = $1 AND j.workspace_id = $2"#,
|
||||
parent_job_id,
|
||||
w_id,
|
||||
)
|
||||
|
||||
@@ -4973,7 +4973,6 @@ pub async fn handle_queued_job(
|
||||
hostname,
|
||||
killpill_rx,
|
||||
&mut has_stream,
|
||||
job_completed_tx.clone(),
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -3594,12 +3594,15 @@ export async function pull(
|
||||
) {
|
||||
if ((opts as any).jsonOutput) log.setSilent(true);
|
||||
const originalCliOpts = { ...opts };
|
||||
opts = await mergeConfigWithConfigFile(opts);
|
||||
|
||||
// --include-secrets overrides skipSecrets from wmill.yaml
|
||||
if ((originalCliOpts as any).includeSecrets) {
|
||||
opts.skipSecrets = false;
|
||||
}
|
||||
const withConfigFile = async () => {
|
||||
const merged = await mergeConfigWithConfigFile({ ...originalCliOpts });
|
||||
// --include-secrets overrides skipSecrets from wmill.yaml
|
||||
if ((originalCliOpts as any).includeSecrets) {
|
||||
merged.skipSecrets = false;
|
||||
}
|
||||
return merged;
|
||||
};
|
||||
opts = await withConfigFile();
|
||||
|
||||
// Resolve workspace name for config lookups.
|
||||
// --branch resolves git branch → workspace name (deprecated but still supported).
|
||||
@@ -3634,10 +3637,6 @@ export async function pull(
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (opts.stateful) {
|
||||
await mkdir(path.join(process.cwd(), ".wmill"), { recursive: true });
|
||||
}
|
||||
|
||||
const workspace = await resolveWorkspace(opts, wsNameForConfig);
|
||||
await requireLogin(opts);
|
||||
|
||||
@@ -3729,6 +3728,14 @@ export async function pull(
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// The pull writes into the branch now checked out, so its wmill.yaml
|
||||
// applies, not the cloned branch's: a fork branch that turned on
|
||||
// `dedupeLockfiles` would otherwise get one lockfile per script back.
|
||||
if (getCurrentGitBranch() !== clonedBranchName) {
|
||||
opts = await withConfigFile();
|
||||
wsNameForConfig = resolveWsNameForConfigFromFlags(opts);
|
||||
}
|
||||
}
|
||||
|
||||
// If wsNameForConfig wasn't set from flags, infer from the resolved profile
|
||||
@@ -3763,6 +3770,10 @@ export async function pull(
|
||||
// Merge CLI flags with resolved settings (CLI flags take precedence only for explicit overrides)
|
||||
opts = mergeCliWithEffectiveOptions(originalCliOpts, effectiveOpts);
|
||||
|
||||
if (opts.stateful) {
|
||||
await mkdir(path.join(process.cwd(), ".wmill"), { recursive: true });
|
||||
}
|
||||
|
||||
const codebases = await listSyncCodebases(opts);
|
||||
|
||||
log.info(
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createRemoteWorkspaceDeps, withTestBackend } from "./test_backend.ts";
|
||||
|
||||
function git(cwd: string, ...args: string[]): string {
|
||||
return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
||||
}
|
||||
|
||||
// The git-sync deploy callback starts in a clone of the tracked branch and
|
||||
// switches to the fork's branch before pulling. What it writes there must follow
|
||||
// that branch's wmill.yaml: a fork branch that turned on `dedupeLockfiles` keeps
|
||||
// its shared lockfile instead of getting a `.script.lock` per script back.
|
||||
test("git-sync fork deploy follows the fork branch's wmill.yaml", async () => {
|
||||
await withTestBackend(async (backend) => {
|
||||
const post = (path: string, body: unknown) =>
|
||||
backend.apiRequest!(`/api/w/${backend.workspace}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
await createRemoteWorkspaceDeps(backend, "python3", "wmill\n");
|
||||
await post("/folders/create", { name: "dedupe" });
|
||||
const script = await post("/scripts/create", {
|
||||
path: "f/dedupe/a",
|
||||
summary: "",
|
||||
description: "",
|
||||
content: "def main():\n pass\n",
|
||||
language: "python3",
|
||||
lock: "wmill==1.0.0\n",
|
||||
});
|
||||
expect(script.ok).toBe(true);
|
||||
|
||||
const bare = await mkdtemp(join(tmpdir(), "wmill_deploy_cfg_bare_"));
|
||||
const seed = await mkdtemp(join(tmpdir(), "wmill_deploy_cfg_seed_"));
|
||||
const work = await mkdtemp(join(tmpdir(), "wmill_deploy_cfg_work_"));
|
||||
try {
|
||||
execFileSync("git", ["init", "--bare", "--initial-branch=main", bare]);
|
||||
git(seed, "init", "--initial-branch=main");
|
||||
git(seed, "config", "user.email", "seed@windmill.dev");
|
||||
git(seed, "config", "user.name", "seed");
|
||||
git(seed, "remote", "add", "origin", `file://${bare}`);
|
||||
const wmillYaml = "defaultTs: bun\nincludes:\n - f/dedupe/**\nexcludes: []\n";
|
||||
await writeFile(join(seed, "wmill.yaml"), wmillYaml);
|
||||
git(seed, "add", "-A");
|
||||
git(seed, "commit", "-m", "main");
|
||||
git(seed, "push", "origin", "main");
|
||||
|
||||
// Any parent id makes the callback deploy as a fork, to this branch.
|
||||
const forkBranch = `wm-fork/main/${backend.workspace}`;
|
||||
git(seed, "checkout", "-b", forkBranch);
|
||||
await writeFile(
|
||||
join(seed, "wmill.yaml"),
|
||||
wmillYaml + "dedupeLockfiles: true\n",
|
||||
);
|
||||
const pulled = await backend.runCLICommand(["sync", "pull", "--yes"], seed);
|
||||
expect(pulled.code).toBe(0);
|
||||
expect(existsSync(join(seed, "locks/requirements.in.lock"))).toBe(true);
|
||||
git(seed, "add", "-A");
|
||||
git(seed, "commit", "-m", "dedupe");
|
||||
git(seed, "push", "origin", forkBranch);
|
||||
|
||||
git(work, "clone", `file://${bare}`, ".");
|
||||
const deployed = await backend.runCLICommand(
|
||||
[
|
||||
"sync",
|
||||
"git-deploy",
|
||||
"--repository",
|
||||
"u/test/unused",
|
||||
"--git-deploy-items",
|
||||
JSON.stringify([
|
||||
{ path_type: "script", path: "f/dedupe/a", commit_msg: "deploy" },
|
||||
]),
|
||||
"--parent-workspace-id",
|
||||
"parent",
|
||||
],
|
||||
work,
|
||||
);
|
||||
expect(deployed.code).toBe(0);
|
||||
expect(git(work, "rev-parse", "--abbrev-ref", "HEAD")).toBe(forkBranch);
|
||||
expect(git(work, "status", "--porcelain")).toBe("");
|
||||
} finally {
|
||||
await rm(bare, { recursive: true, force: true });
|
||||
await rm(seed, { recursive: true, force: true });
|
||||
await rm(work, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
Generated
+41
-246
@@ -67,7 +67,7 @@
|
||||
"panzoom": "^9.4.3",
|
||||
"pdfjs-dist": "4.8.69",
|
||||
"quicktype-core": "^23.2.6",
|
||||
"quill": "^1.3.7",
|
||||
"quill": "^2.0.3",
|
||||
"rehype-github-alerts": "^3.0.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
@@ -3746,28 +3746,11 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
|
||||
"integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.0",
|
||||
"es-define-property": "^1.0.0",
|
||||
"get-intrinsic": "^1.2.4",
|
||||
"set-function-length": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -3781,6 +3764,7 @@
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
@@ -5222,26 +5206,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-equal": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz",
|
||||
"integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-arguments": "^1.1.1",
|
||||
"is-date-object": "^1.0.5",
|
||||
"is-regex": "^1.1.4",
|
||||
"object-is": "^1.1.5",
|
||||
"object-keys": "^1.1.1",
|
||||
"regexp.prototype.flags": "^1.5.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-extend": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||
@@ -5284,40 +5248,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/define-data-property": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/define-properties": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
|
||||
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"define-data-property": "^1.0.1",
|
||||
"has-property-descriptors": "^1.0.0",
|
||||
"object-keys": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/defu": {
|
||||
"version": "6.1.7",
|
||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
|
||||
@@ -5570,6 +5500,7 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
@@ -5684,6 +5615,7 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -5693,6 +5625,7 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -5709,6 +5642,7 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
@@ -6176,9 +6110,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz",
|
||||
"integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==",
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/events": {
|
||||
@@ -6233,9 +6167,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-diff": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz",
|
||||
"integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==",
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
|
||||
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/fast-equals": {
|
||||
@@ -6522,15 +6456,7 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/functions-have-names": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
|
||||
"integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
@@ -6569,6 +6495,7 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
@@ -6593,6 +6520,7 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
@@ -6776,6 +6704,7 @@
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -6868,22 +6797,11 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/has-property-descriptors": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
||||
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -6896,6 +6814,7 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
@@ -6917,6 +6836,7 @@
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -7315,22 +7235,6 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-arguments": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
|
||||
"integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"has-tostringtag": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-arrayish": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
||||
@@ -7368,22 +7272,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-date-object": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
|
||||
"integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"has-tostringtag": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
@@ -7474,24 +7362,6 @@
|
||||
"@types/estree": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/is-regex": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
||||
"integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"gopd": "^1.2.0",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-url": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
|
||||
@@ -8686,6 +8556,7 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -10139,31 +10010,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/object-is": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
|
||||
"integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.7",
|
||||
"define-properties": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/object-keys": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
||||
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
@@ -10356,9 +10202,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/parchment": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz",
|
||||
"integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
|
||||
"integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
@@ -11677,31 +11523,32 @@
|
||||
}
|
||||
},
|
||||
"node_modules/quill": {
|
||||
"version": "1.3.7",
|
||||
"resolved": "https://registry.npmjs.org/quill/-/quill-1.3.7.tgz",
|
||||
"integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==",
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
|
||||
"integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"clone": "^2.1.1",
|
||||
"deep-equal": "^1.0.1",
|
||||
"eventemitter3": "^2.0.3",
|
||||
"extend": "^3.0.2",
|
||||
"parchment": "^1.1.4",
|
||||
"quill-delta": "^3.6.2"
|
||||
"eventemitter3": "^5.0.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"parchment": "^3.0.0",
|
||||
"quill-delta": "^5.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"npm": ">=8.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/quill-delta": {
|
||||
"version": "3.6.3",
|
||||
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz",
|
||||
"integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==",
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
|
||||
"integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"deep-equal": "^1.0.1",
|
||||
"extend": "^3.0.2",
|
||||
"fast-diff": "1.1.2"
|
||||
"fast-diff": "^1.3.0",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"lodash.isequal": "^4.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
"node": ">= 12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rbush": {
|
||||
@@ -11874,26 +11721,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/regexp.prototype.flags": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
||||
"integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.8",
|
||||
"define-properties": "^1.2.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"set-function-name": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/rehype-github-alerts": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/rehype-github-alerts/-/rehype-github-alerts-3.0.0.tgz",
|
||||
@@ -12290,38 +12117,6 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"define-data-property": "^1.1.4",
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-intrinsic": "^1.2.4",
|
||||
"gopd": "^1.0.1",
|
||||
"has-property-descriptors": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/set-function-name": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
|
||||
"integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"define-data-property": "^1.1.4",
|
||||
"es-errors": "^1.3.0",
|
||||
"functions-have-names": "^1.2.3",
|
||||
"has-property-descriptors": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
"panzoom": "^9.4.3",
|
||||
"pdfjs-dist": "4.8.69",
|
||||
"quicktype-core": "^23.2.6",
|
||||
"quill": "^1.3.7",
|
||||
"quill": "^2.0.3",
|
||||
"rehype-github-alerts": "^3.0.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Alert, Button, Drawer, DrawerContent } from './common'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import { ArrowRight, Loader2 } from 'lucide-svelte'
|
||||
import { scriptLangToEditorLang } from '$lib/scripts'
|
||||
import Tabs from './common/tabs/Tabs.svelte'
|
||||
import Tab from './common/tabs/Tab.svelte'
|
||||
@@ -11,7 +11,10 @@
|
||||
type Value
|
||||
} from '$lib/utils'
|
||||
import { orderedYamlStringify } from '$lib/utils/orderedYaml'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import type { Script } from '$lib/gen'
|
||||
import Select from './select/Select.svelte'
|
||||
import type { DiffVersionOption } from './diff_drawer'
|
||||
|
||||
type DiffData = {
|
||||
lang?: string
|
||||
@@ -44,6 +47,13 @@
|
||||
| {
|
||||
mode: 'normal'
|
||||
deployed: DiffData | undefined
|
||||
/** Which deployed version the left side is — the reader otherwise has no
|
||||
* way to tell what their draft is being compared against. */
|
||||
deployedLabel?: string
|
||||
versions?: DiffVersionOption[]
|
||||
onTakeLatest?: (head?: string) => void | Promise<void>
|
||||
draftBase?: string
|
||||
deployedHead?: string
|
||||
draft: DiffData | undefined
|
||||
current: DiffData
|
||||
path?: string
|
||||
@@ -58,7 +68,13 @@
|
||||
}
|
||||
| undefined = $state(undefined)
|
||||
|
||||
export function openDrawer() {
|
||||
export function openDrawer(token?: number) {
|
||||
// No token means the caller is taking the drawer for itself, so claim one here:
|
||||
// every reuse (a deploy-override's "Show diff", a draft badge, a workspace
|
||||
// comparison) then invalidates an editor opening that is still fetching, instead
|
||||
// of being replaced by it when it lands.
|
||||
if (token != null && token !== openingToken) return
|
||||
if (token == null) openingToken++
|
||||
data = undefined
|
||||
diffType = undefined
|
||||
diffViewer?.openDrawer()
|
||||
@@ -68,6 +84,36 @@
|
||||
diffViewer?.closeDrawer()
|
||||
}
|
||||
|
||||
/** Counted per opening, and counted here rather than in the editor that opens one: a
|
||||
* path change remounts the editor while this drawer stays, so a counter local to it is
|
||||
* one an outlived request still matches, and that request would fill the drawer with
|
||||
* the item the user just left. Every write an opening makes checks this first. */
|
||||
let openingToken = 0
|
||||
|
||||
export function beginOpening(): number {
|
||||
return ++openingToken
|
||||
}
|
||||
|
||||
export function ownsOpening(token: number): boolean {
|
||||
return token === openingToken
|
||||
}
|
||||
|
||||
/** Drop an opening and everything it put on screen: its editor is going away, so a diff
|
||||
* it filled acts on an item that is gone (Take latest would move the base of whatever
|
||||
* loaded in its place) and one still in flight would leave the spinner behind. A no-op
|
||||
* once another opening owns the drawer: what it shows is then someone else's. */
|
||||
export function abandonOpening(token: number) {
|
||||
if (token !== openingToken) return
|
||||
openingToken++
|
||||
// The version load and the page in flight, if any, belong to the diff being dropped.
|
||||
versionLoadGeneration++
|
||||
versionListGeneration++
|
||||
loadingVersion = false
|
||||
data = undefined
|
||||
diffType = undefined
|
||||
diffViewer?.closeDrawer()
|
||||
}
|
||||
|
||||
function prepareDiff(data: Value) {
|
||||
const metadata = structuredClone(cleanValueProperties(replaceFalseWithUndefined(data)))
|
||||
const content = metadata['content']
|
||||
@@ -81,11 +127,145 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Which deployed version the left pane shows. The drawer keeps the id opaque and
|
||||
// hands it back to the editor, which knows how to fetch it for its own kind.
|
||||
let selectedVersion: string | undefined = $state(undefined)
|
||||
let versionLoader: ((id: string) => Promise<Value | undefined>) | undefined = $state(undefined)
|
||||
let headLabel: string | undefined = $state(undefined)
|
||||
/** The deployed head, prepared for diffing as `data.deployed` is: `data.deployed`
|
||||
* follows the picker while Restore always restores the head, so its enabled state
|
||||
* compares against this. Must hold `prepareDiff`'s output, not the raw value, or the
|
||||
* comparison never matches and Restore is always enabled. */
|
||||
let headDeployed: ReturnType<typeof prepareDiff> | undefined = $state(undefined)
|
||||
let loadingVersion = $state(false)
|
||||
/** Which version load the spinner belongs to, counted rather than keyed on the id so
|
||||
* re-picking the same version is still generation-safe. A response from any other
|
||||
* generation is stale — a slower earlier pick, or one outlived by a drawer reset —
|
||||
* and neither replaces the diff nor clears the spinner, which `disabled` rides on. */
|
||||
let versionLoadGeneration = 0
|
||||
/** Counted separately from `versionLoadGeneration`: only a diff replacing this one
|
||||
* invalidates a page in flight. Picking a version while one loads is not a reason to
|
||||
* drop it — the editor's cursor has already moved past that page, so discarding it
|
||||
* would skip it until the drawer is reopened. */
|
||||
let versionListGeneration = 0
|
||||
/** Pages of `versions` fetched after the first. Kept beside `data` so a diff swapped in
|
||||
* by a newer opening drops them along with the list they extended. */
|
||||
let extraVersions: DiffVersionOption[] = $state([])
|
||||
let moreLoader: (() => Promise<DiffVersionOption[] | undefined>) | undefined = $state(undefined)
|
||||
let loadingMore = $state(false)
|
||||
/** What the picker offers: the page the editor handed over plus whatever the reader has
|
||||
* asked for since. Every read of the version list goes through this. */
|
||||
const pickerVersions = $derived.by(() =>
|
||||
data?.mode === 'normal' ? [...(data.versions ?? []), ...extraVersions] : []
|
||||
)
|
||||
|
||||
/** The editors hand over one page, so a path a pipeline has deployed thousands of times
|
||||
* does not hold the drawer shut while its whole history arrives. */
|
||||
async function fetchMoreVersions() {
|
||||
if (!moreLoader || loadingMore) return
|
||||
loadingMore = true
|
||||
const generation = versionListGeneration
|
||||
try {
|
||||
const more = await moreLoader()
|
||||
// A diff swapped in while this ran owns the picker now; appending would splice
|
||||
// one item's history onto another's.
|
||||
if (generation !== versionListGeneration) return
|
||||
if (more?.length) {
|
||||
extraVersions = [...extraVersions, ...more]
|
||||
} else {
|
||||
// Nothing came back, so there is nothing further to ask for.
|
||||
moreLoader = undefined
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (generation === versionListGeneration) {
|
||||
sendUserToast(`Could not load older versions: ${e?.body ?? e?.message ?? e}`, true)
|
||||
}
|
||||
} finally {
|
||||
// Guarded like the writes above: an outlived request clearing this would hand
|
||||
// the drawer that replaced it a second concurrent page.
|
||||
if (generation === versionListGeneration) loadingMore = false
|
||||
}
|
||||
}
|
||||
|
||||
async function selectVersion(id: string | undefined) {
|
||||
if (!id || !versionLoader || !data || data.mode !== 'normal') return
|
||||
const shown = selectedVersion
|
||||
selectedVersion = id
|
||||
const generation = ++versionLoadGeneration
|
||||
loadingVersion = true
|
||||
try {
|
||||
const value = await versionLoader(id)
|
||||
if (generation !== versionLoadGeneration) return
|
||||
if (!value || !data || data.mode !== 'normal') {
|
||||
// Nothing loaded: the diff still shows the previous version, so the picker
|
||||
// has to say so rather than name one the reader is not looking at.
|
||||
selectedVersion = shown
|
||||
if (!value) sendUserToast(`Could not load version ${id}`, true)
|
||||
return
|
||||
}
|
||||
const opt = pickerVersions.find((v) => v.id === id)
|
||||
data = {
|
||||
...data,
|
||||
deployed: prepareDiff(value),
|
||||
deployedLabel: opt?.isHead ? headLabel : opt?.label
|
||||
}
|
||||
} catch (e: any) {
|
||||
// The snap-back would otherwise be the only sign the version never loaded.
|
||||
if (generation === versionLoadGeneration) {
|
||||
selectedVersion = shown
|
||||
sendUserToast(`Could not load version ${id}: ${e?.body ?? e?.message ?? e}`, true)
|
||||
}
|
||||
} finally {
|
||||
if (generation === versionLoadGeneration) loadingVersion = false
|
||||
}
|
||||
}
|
||||
|
||||
/** The version this drawer presents as the deployed head: the one its list marks, else
|
||||
* the head the editor knows. Both the action's gate and the base it adopts hang off
|
||||
* it, so "take latest" means the version the reader is looking at. */
|
||||
const headShown = $derived.by(() =>
|
||||
data?.mode === 'normal'
|
||||
? (pickerVersions.find((v) => v.isHead)?.id ?? data.deployedHead)
|
||||
: undefined
|
||||
)
|
||||
/** Behind as the drawer can see it. Unknown counts as not behind: offering to adopt a
|
||||
* head nobody could name would move the base to a version never shown. */
|
||||
const behindShown = $derived.by(
|
||||
() =>
|
||||
data?.mode === 'normal' &&
|
||||
data.draftBase != null &&
|
||||
headShown != null &&
|
||||
data.draftBase !== headShown
|
||||
)
|
||||
|
||||
let takingLatest = $state(false)
|
||||
async function takeLatest() {
|
||||
if (!data || data.mode !== 'normal' || !data.onTakeLatest || takingLatest) return
|
||||
takingLatest = true
|
||||
// Persisting the base is awaited, and this drawer outlives the editor that filled
|
||||
// it: close only the opening this action belongs to, or it takes down whichever
|
||||
// diff was opened meanwhile.
|
||||
const opening = openingToken
|
||||
try {
|
||||
await data.onTakeLatest(headShown)
|
||||
if (opening === openingToken) diffViewer?.closeDrawer()
|
||||
} finally {
|
||||
takingLatest = false
|
||||
}
|
||||
}
|
||||
|
||||
export function setDiff(
|
||||
diff:
|
||||
| {
|
||||
mode: 'normal'
|
||||
deployed: Value
|
||||
deployedLabel?: string
|
||||
versions?: DiffVersionOption[]
|
||||
loadVersion?: (id: string) => Promise<Value | undefined>
|
||||
loadMoreVersions?: () => Promise<DiffVersionOption[] | undefined>
|
||||
onTakeLatest?: (head?: string) => void | Promise<void>
|
||||
draftBase?: string
|
||||
deployedHead?: string
|
||||
draft?: Value | undefined
|
||||
current: Value
|
||||
defaultDiffType?: 'deployed' | 'draft'
|
||||
@@ -97,13 +277,46 @@
|
||||
current: Value
|
||||
title: string
|
||||
button?: { text: string; onClick: () => void }
|
||||
}
|
||||
},
|
||||
token?: number
|
||||
) {
|
||||
// Same rule as `openDrawer`: an opening's own token has to still be current, and a
|
||||
// caller with none is taking the drawer, so it claims one.
|
||||
if (token != null && token !== openingToken) return
|
||||
if (token == null) openingToken++
|
||||
if (diff.mode === 'normal') {
|
||||
const { deployed, draft, current, button } = diff
|
||||
const {
|
||||
deployed,
|
||||
deployedLabel,
|
||||
versions,
|
||||
loadVersion,
|
||||
loadMoreVersions,
|
||||
onTakeLatest,
|
||||
draftBase,
|
||||
deployedHead,
|
||||
draft,
|
||||
current,
|
||||
button
|
||||
} = diff
|
||||
versionLoader = loadVersion
|
||||
moreLoader = loadMoreVersions
|
||||
headLabel = deployedLabel
|
||||
headDeployed = !deployed.draft_only ? prepareDiff(deployed) : undefined
|
||||
// A load or page still in flight belongs to the diff being replaced.
|
||||
versionLoadGeneration++
|
||||
versionListGeneration++
|
||||
loadingVersion = false
|
||||
extraVersions = []
|
||||
loadingMore = false
|
||||
selectedVersion = versions?.find((v) => v.isHead)?.id
|
||||
data = {
|
||||
mode: 'normal',
|
||||
deployed: !deployed.draft_only ? prepareDiff(deployed) : undefined,
|
||||
deployedLabel,
|
||||
versions,
|
||||
onTakeLatest,
|
||||
draftBase,
|
||||
deployedHead,
|
||||
draft: draft ? prepareDiff(draft) : undefined,
|
||||
current: prepareDiff(current),
|
||||
path: draft?.path || deployed?.path,
|
||||
@@ -130,19 +343,46 @@
|
||||
<Drawer bind:this={diffViewer} size="1200px" on:close>
|
||||
<DrawerContent title="Diff" on:close={diffViewer.closeDrawer}>
|
||||
<div class="flex flex-col gap-4 h-full">
|
||||
{#if data?.mode === 'normal'}
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="default"
|
||||
wrapperClasses="self-start"
|
||||
onClick={restoreDeployed}
|
||||
disabled={!data.draft &&
|
||||
orderedJsonStringify(data.deployed) === orderedJsonStringify(data.current)}
|
||||
>
|
||||
Restore to deployed{data.draft ? ' and discard draft' : ''}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if data}
|
||||
<!-- Outside the `contentType` check on purpose: with no differences against
|
||||
the head, picking an older version is exactly how the reader finds one,
|
||||
so the picker has to outlive the "no changes" state. -->
|
||||
{#if data.mode === 'normal' && (data.deployedLabel || pickerVersions.length)}
|
||||
<div class="flex gap-2 items-center text-xs text-secondary">
|
||||
{#if pickerVersions.length && versionLoader}
|
||||
<div class="w-72">
|
||||
<Select
|
||||
items={pickerVersions.map((v) => ({
|
||||
value: v.id,
|
||||
label: v.label,
|
||||
subtitle: v.subtitle
|
||||
}))}
|
||||
bind:value={() => selectedVersion, (v) => selectVersion(v as string)}
|
||||
disabled={loadingVersion}
|
||||
clearable={false}
|
||||
/>
|
||||
</div>
|
||||
{#if loadingVersion}
|
||||
<Loader2 size={12} class="animate-spin shrink-0" />
|
||||
{/if}
|
||||
{#if moreLoader}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="2xs"
|
||||
disabled={loadingMore}
|
||||
on:click={fetchMoreVersions}>Load older</Button
|
||||
>
|
||||
{/if}
|
||||
{#if loadingMore}
|
||||
<Loader2 size={12} class="animate-spin shrink-0" />
|
||||
{/if}
|
||||
{:else if data.deployedLabel}
|
||||
<span class="font-medium text-primary">{data.deployedLabel}</span>
|
||||
{/if}
|
||||
<ArrowRight size={12} class="shrink-0" />
|
||||
<span>your draft</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if contentType}
|
||||
{@const content =
|
||||
data.mode === 'normal' ? data.deployed?.content : data.original?.content}
|
||||
@@ -224,6 +464,23 @@
|
||||
{/if}
|
||||
</div>
|
||||
{#snippet actions()}
|
||||
{#if data?.mode === 'normal' && data.onTakeLatest && behindShown}
|
||||
<Button unifiedSize="sm" variant="default" loading={takingLatest} onClick={takeLatest}>
|
||||
Take latest, keep my edits
|
||||
</Button>
|
||||
{/if}
|
||||
{#if data?.mode === 'normal'}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
onClick={restoreDeployed}
|
||||
disabled={!data.draft &&
|
||||
orderedJsonStringify(headDeployed ?? data.deployed) ===
|
||||
orderedJsonStringify(data.current)}
|
||||
>
|
||||
Restore to deployed{data.draft ? ' and discard draft' : ''}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if data?.button}
|
||||
<Button
|
||||
variant="subtle"
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { Alert, Button } from '$lib/components/common'
|
||||
|
||||
interface Props {
|
||||
/** Take the draft the server holds, replacing what is on screen. */
|
||||
onReload: () => void
|
||||
/** Write what is on screen over the server's draft. */
|
||||
onOverwrite: () => void
|
||||
}
|
||||
|
||||
let { onReload, onOverwrite }: Props = $props()
|
||||
</script>
|
||||
|
||||
<Alert type="warning" title="Your draft changed elsewhere">
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<div>
|
||||
It was saved from another tab or session since this one read it, so your changes here are no
|
||||
longer being saved.
|
||||
</div>
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button unifiedSize="sm" variant="default" onClick={onReload}>Load the other version</Button>
|
||||
<Button unifiedSize="sm" variant="default" onClick={onOverwrite}>Keep mine</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
@@ -10,6 +10,7 @@
|
||||
type Job
|
||||
} from '$lib/gen'
|
||||
import { initHistory, redo, undo } from '$lib/history.svelte'
|
||||
import { VERSION_PAGE_SIZE } from '$lib/components/diff_drawer'
|
||||
import {
|
||||
clearLinkedAgentTools,
|
||||
linkedAgentToolsForScope,
|
||||
@@ -41,7 +42,7 @@
|
||||
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
|
||||
import AIChangesWarningModal from '$lib/components/copilot/chat/flow/AIChangesWarningModal.svelte'
|
||||
|
||||
import { getContext, setContext, untrack } from 'svelte'
|
||||
import { getContext, onDestroy, setContext, untrack } from 'svelte'
|
||||
import { writable } from 'svelte/store'
|
||||
import CenteredPage from './CenteredPage.svelte'
|
||||
import { Button } from './common'
|
||||
@@ -114,6 +115,11 @@
|
||||
|
||||
let {
|
||||
initialPath = $bindable(''),
|
||||
/** The draft row's own path (the URL path). Unlike `initialPath`, which the
|
||||
* route re-seeds from `draft_path` so the topbar shows the pending name,
|
||||
* this stays where the item actually is. */
|
||||
userDraftPath = '',
|
||||
onTakeLatest = undefined,
|
||||
pathStoreInit = undefined,
|
||||
newFlow,
|
||||
selectedId,
|
||||
@@ -183,6 +189,10 @@
|
||||
|
||||
// Used by multiplayer deploy collision warning
|
||||
let deployedValue: Value | undefined = $state(undefined) // Value to diff against
|
||||
let deployedLabel: string | undefined = $state(undefined) // Names it in the diff
|
||||
/** The flow_version the payload in `deployedValue` came from, so the picker marks that
|
||||
* one as head rather than trusting the history's first row. */
|
||||
let deployedVersionShown: number | undefined = $state(undefined)
|
||||
let deployedBy: string | undefined = $state(undefined) // Author
|
||||
let confirmCallback: () => void = $state(() => {}) // What happens when user clicks `override` in warning
|
||||
let open: boolean = $state(false) // Is confirmation modal open
|
||||
@@ -336,7 +346,7 @@
|
||||
// a draft, else the load-time head. This catches both a concurrent deploy
|
||||
// (head moved since open) AND a stale draft reopened after a deploy (head ==
|
||||
// load-time head, but the draft was forked from an older version).
|
||||
const base = draftBaseVersion ?? version
|
||||
const base = draftBaseVersion ?? (version != null ? String(version) : undefined)
|
||||
if (base === undefined) {
|
||||
return
|
||||
}
|
||||
@@ -347,7 +357,7 @@
|
||||
path: initialPath
|
||||
})
|
||||
|
||||
onLatest = base === flowVersion?.id
|
||||
onLatest = flowVersion != null && base === String(flowVersion.id)
|
||||
} else {
|
||||
onLatest = true
|
||||
}
|
||||
@@ -463,12 +473,19 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
async function syncWithDeployed() {
|
||||
async function syncWithDeployed(opening?: number) {
|
||||
const flow = await FlowService.getFlowByPath({
|
||||
workspace: opWorkspace!,
|
||||
path: initialPath,
|
||||
// The draft row's own path, not `initialPath` — the route re-seeds that from
|
||||
// the draft's `draft_path` so the topbar shows the pending name, which after
|
||||
// someone renames the item still names the old location. Comparing against
|
||||
// that fetches the row left behind there instead of the live one.
|
||||
path: userDraftPath || initialPath,
|
||||
withStarredInfo: true
|
||||
})
|
||||
// A superseded opening must not write these: the current one would then render
|
||||
// and offer Take latest against the older head.
|
||||
if (opening != null && !diffDrawer?.ownsOpening(opening)) return
|
||||
deployedValue = replaceFalseWithUndefined({
|
||||
...flow,
|
||||
edited_at: undefined,
|
||||
@@ -476,6 +493,10 @@
|
||||
workspace_id: undefined
|
||||
})
|
||||
deployedBy = flow.edited_by
|
||||
deployedVersionShown = flow.version_id
|
||||
// Names the deployed side of the diff. Without it the reader is shown two panes
|
||||
// and told nothing about what the left one is.
|
||||
deployedLabel = `Deployed${flow.version_id != null ? ` ${flow.version_id}` : ''}${flow.edited_by ? ` by ${flow.edited_by}` : ''} · latest`
|
||||
}
|
||||
|
||||
async function saveFlow(deploymentMsg?: string, toDeploy?: DraftChangesToDeploy): Promise<void> {
|
||||
@@ -1115,21 +1136,117 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function openDiffDrawer() {
|
||||
/** Deployed versions for the diff picker, newest first. Best-effort: losing the
|
||||
* list costs the picker, not the diff. */
|
||||
/** Throws: the drawer says so and lets the reader ask for the same page again. */
|
||||
async function fetchVersionPage(page: number) {
|
||||
const path = userDraftPath || initialPath
|
||||
if (!opWorkspace || !path) return undefined
|
||||
{
|
||||
const history = await FlowService.getFlowHistory({
|
||||
workspace: opWorkspace,
|
||||
path,
|
||||
page,
|
||||
perPage: VERSION_PAGE_SIZE
|
||||
})
|
||||
// Head is the version the payload beside this list came from, not whatever the
|
||||
// history now leads with: a deploy landing between the two fetches would
|
||||
// otherwise label the shown (older) value as the latest.
|
||||
const head = deployedVersionShown ?? history[0]?.id
|
||||
// No ordinal: the list arrives a page at a time, so a number counted within one
|
||||
// would rename versions as more load.
|
||||
return history.map((h) => {
|
||||
const detail = [
|
||||
h.created_by,
|
||||
h.created_at ? new Date(h.created_at).toLocaleString() : undefined,
|
||||
h.deployment_msg
|
||||
].filter(Boolean)
|
||||
const isHead = h.id === head
|
||||
return {
|
||||
id: String(h.id),
|
||||
label: `${h.id}${isHead ? ' · latest' : ''}`,
|
||||
subtitle: detail.length ? detail.join(' · ') : undefined,
|
||||
isHead
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** The first page, best-effort: losing it costs the picker, not the diff. */
|
||||
async function deployedVersionOptions() {
|
||||
try {
|
||||
return await fetchVersionPage(1)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Hands the drawer the next page each time the reader asks for one. The page number
|
||||
* belongs to this item's history, so it lives here — and only moves once a page has
|
||||
* actually arrived, or a failed request would skip it. */
|
||||
function moreVersionsLoader() {
|
||||
let loaded = 1
|
||||
return async () => {
|
||||
const page = await fetchVersionPage(loaded + 1)
|
||||
loaded += 1
|
||||
return page
|
||||
}
|
||||
}
|
||||
|
||||
/** The opening this editor claimed last. A path change remounts this editor while the
|
||||
* drawer stays mounted, so its teardown hands that opening back rather than leaving
|
||||
* the drawer on the item the user left. */
|
||||
let lastOpening: number | undefined = undefined
|
||||
onDestroy(() => {
|
||||
if (lastOpening != null) diffDrawer?.abandonOpening(lastOpening)
|
||||
})
|
||||
|
||||
export async function openDiffDrawer() {
|
||||
if (!savedFlow) return
|
||||
await syncWithDeployed()
|
||||
// The fetches below are awaited, so a reopen (or a path change, which remounts
|
||||
// this editor but not the drawer) while they run must not have the older one
|
||||
// land last. The drawer counts the openings for that reason.
|
||||
const opening = diffDrawer?.beginOpening()
|
||||
lastOpening = opening
|
||||
if (opening == null) return
|
||||
await syncWithDeployed(opening)
|
||||
const currentDraftTriggers = structuredClone(triggersState.getDraftTriggersSnapshot())
|
||||
diffDrawer?.openDrawer()
|
||||
// Blanking the drawer belongs to the opening that will fill it.
|
||||
if (!diffDrawer?.ownsOpening(opening)) return
|
||||
diffDrawer.openDrawer(opening)
|
||||
const currentFlow = flowStore.val
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'normal',
|
||||
deployed: deployedValue ?? savedFlow,
|
||||
current: {
|
||||
...currentFlow,
|
||||
path: $pathStore,
|
||||
draft_triggers: currentDraftTriggers
|
||||
}
|
||||
})
|
||||
const versions = await deployedVersionOptions()
|
||||
if (!diffDrawer?.ownsOpening(opening)) return
|
||||
diffDrawer.setDiff(
|
||||
{
|
||||
mode: 'normal',
|
||||
deployed: deployedValue ?? savedFlow,
|
||||
deployedLabel,
|
||||
versions,
|
||||
loadMoreVersions: moreVersionsLoader(),
|
||||
onTakeLatest,
|
||||
draftBase: draftBaseVersion,
|
||||
deployedHead: deployedVersionShown != null ? String(deployedVersionShown) : undefined,
|
||||
loadVersion: async (id) => {
|
||||
const v = await FlowService.getFlowVersion({
|
||||
workspace: opWorkspace!,
|
||||
version: Number(id)
|
||||
})
|
||||
return replaceFalseWithUndefined({
|
||||
...v,
|
||||
edited_at: undefined,
|
||||
edited_by: undefined,
|
||||
workspace_id: undefined
|
||||
})
|
||||
},
|
||||
current: {
|
||||
...currentFlow,
|
||||
path: $pathStore,
|
||||
draft_triggers: currentDraftTriggers
|
||||
}
|
||||
},
|
||||
opening
|
||||
)
|
||||
}
|
||||
|
||||
let flowCopilotContext: FlowCopilotContext = $state({
|
||||
@@ -1374,6 +1491,7 @@
|
||||
{confirmCallback}
|
||||
bind:open
|
||||
{diffDrawer}
|
||||
claimOpening={() => (lastOpening = diffDrawer?.beginOpening())}
|
||||
bind:deployedValue
|
||||
currentValue={flowStore.val}
|
||||
/>
|
||||
|
||||
@@ -8,12 +8,19 @@
|
||||
import { updateItemPathAndSummary, checkFlowOnBehalfOf } from './moveRenameManager'
|
||||
import Label from './Label.svelte'
|
||||
import TextInput from './text_input/TextInput.svelte'
|
||||
import { FlowService, ScriptService, type TriggersCount } from '$lib/gen'
|
||||
import { DraftService, FlowService, ScriptService, type TriggersCount } from '$lib/gen'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
type Kind = 'script' | 'resource' | 'schedule' | 'variable' | 'flow' | 'app'
|
||||
|
||||
/** The address the move endpoint takes: where a draft-only item's row lives, which
|
||||
* `initialPath` (what the user sees and edits) need not equal. Empty for a deployed
|
||||
* item, which is addressed by `initialPath` instead. */
|
||||
let storagePath = $state('')
|
||||
let rawApp = $state(false)
|
||||
let draftOnly = $derived(storagePath !== '')
|
||||
|
||||
let kind = $state<Kind>('flow')
|
||||
let initialPath = $state('')
|
||||
let initialSummary = $state('')
|
||||
@@ -66,21 +73,31 @@
|
||||
})
|
||||
let attachedTotal = $derived(attachedSummary.reduce((s, { count }) => s + count, 0))
|
||||
|
||||
/** `draft` marks an item that exists only as the caller's draft: pass the
|
||||
* generated path its draft row sits at, and `initialPath_l` is then the name
|
||||
* the user sees. Nothing is deployed, so there are no triggers to cascade
|
||||
* and no on-behalf-of identity to warn about. */
|
||||
export async function openDrawer(
|
||||
initialPath_l: string,
|
||||
summary_l: string | undefined,
|
||||
kind_l: Kind
|
||||
kind_l: Kind,
|
||||
draft?: { storagePath: string; rawApp?: boolean }
|
||||
) {
|
||||
kind = kind_l
|
||||
path = undefined
|
||||
dirtyPath = false
|
||||
onBehalfOfEmail = undefined
|
||||
attachedTriggers = undefined
|
||||
storagePath = draft?.storagePath ?? ''
|
||||
rawApp = draft?.rawApp ?? false
|
||||
initialPath = initialPath_l
|
||||
initialSummary = summary_l ?? ''
|
||||
summary = summary_l
|
||||
loadOwner()
|
||||
drawer.openDrawer()
|
||||
if (draftOnly) {
|
||||
return
|
||||
}
|
||||
if (kind === 'flow') {
|
||||
onBehalfOfEmail = await checkFlowOnBehalfOf($workspaceStore!, initialPath_l)
|
||||
}
|
||||
@@ -103,11 +120,18 @@
|
||||
}
|
||||
|
||||
function loadOwner() {
|
||||
own = isOwner(initialPath, $userStore!, $workspaceStore!)
|
||||
own = isOwner(draftOnly ? storagePath : initialPath, $userStore!, $workspaceStore!)
|
||||
}
|
||||
|
||||
async function updatePath() {
|
||||
if (kind === 'flow' || kind === 'script' || kind === 'app') {
|
||||
if (draftOnly && (kind === 'flow' || kind === 'script' || kind === 'app')) {
|
||||
await DraftService.moveDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: kind === 'app' && rawApp ? 'raw_app' : kind,
|
||||
path: storagePath,
|
||||
requestBody: { new_path: path ?? '', summary: summary ?? '' }
|
||||
})
|
||||
} else if (kind === 'flow' || kind === 'script' || kind === 'app') {
|
||||
await updateItemPathAndSummary({
|
||||
workspace: $workspaceStore!,
|
||||
kind,
|
||||
@@ -142,9 +166,8 @@
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-1 mt-1">
|
||||
{#each attachedSummary as { label, count } (label)}
|
||||
<span class="text-xs"
|
||||
><span class="font-mono font-semibold">{count}</span> {label}{count === 1
|
||||
? ''
|
||||
: 's'}</span
|
||||
><span class="font-mono font-semibold">{count}</span>
|
||||
{label}{count === 1 ? '' : 's'}</span
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { useActingUser } from '$lib/actingUser.svelte'
|
||||
import { UserDraft, draftValuesEqual, type UserDraftHandle } from '$lib/userDraft.svelte'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import DraftConflictAlert from './DraftConflictAlert.svelte'
|
||||
import { setLocalDraftHint } from '$lib/localDraftHints.svelte'
|
||||
import { onUserInput } from '$lib/userDraftEditGate'
|
||||
|
||||
@@ -261,6 +262,54 @@
|
||||
)
|
||||
const anyDirty = $derived(dirtyWorkspaces.length > 0)
|
||||
|
||||
/** The server refused this tab's autosave because the row moved under it: another tab, or the
|
||||
* AI chat, which writes these drafts too. Nothing typed here reaches the server until the user
|
||||
* picks a version, and the unsaved-changes banner says the opposite — that the edits are held
|
||||
* as a draft — so without this they are told their work is safe while it is being dropped. */
|
||||
const draftConflict = $derived(
|
||||
selected && initialPath
|
||||
? UserDraftDbSyncer.getConflict({
|
||||
workspace: selected,
|
||||
itemKind: 'resource',
|
||||
path: initialPath
|
||||
}).conflict
|
||||
: undefined
|
||||
)
|
||||
|
||||
async function resolveDraftConflict(keepMine: boolean): Promise<void> {
|
||||
const ws = selected
|
||||
const p = initialPath
|
||||
if (!ws || !p) return
|
||||
const query = { workspace: ws, itemKind: 'resource' as const, path: p }
|
||||
if (keepMine) {
|
||||
// Forced, so it goes over the row that refused us, and its response reseeds
|
||||
// `last_sync` so the next ordinary save is conditional again.
|
||||
const mine = states[ws]?.draft
|
||||
if (mine) await UserDraftDbSyncer.overwrite({ ...query, value: $state.snapshot(mine) })
|
||||
return
|
||||
}
|
||||
// Taking theirs: drop the refused payload first so no later flush can send it, then read
|
||||
// what the server holds and seed that in, which is also what gives this tab a baseline.
|
||||
UserDraftDbSyncer.dropPending(query)
|
||||
UserDraftDbSyncer.clearConflict(query)
|
||||
const r = await ResourceService.getResource({ workspace: ws, path: p, getDraft: true })
|
||||
const deployedState: ResourceState = {
|
||||
path: r.path,
|
||||
args: (r.value ?? {}) as Record<string, any>,
|
||||
description: r.description ?? '',
|
||||
labels: r.labels ?? undefined,
|
||||
wsSpecific: r.ws_specific ?? false
|
||||
}
|
||||
initialStates[ws] = structuredClone(deployedState)
|
||||
UserDraftDbSyncer.recordRemoteSync(query, (r as any).draft_saved_at)
|
||||
UserDraft.seed(
|
||||
'resource',
|
||||
p,
|
||||
((r as any).draft as ResourceState | undefined) ?? deployedState,
|
||||
{ workspace: ws }
|
||||
)
|
||||
}
|
||||
|
||||
// The syncer owns the list-page `*` hint; the editor only CLEARS it when a
|
||||
// workspace is at the deployed baseline (so a draft discarded elsewhere
|
||||
// vanishes on reopen). Never SET here. See VariableEditor for the full note.
|
||||
@@ -531,6 +580,13 @@
|
||||
|
||||
<div>
|
||||
<div class="flex flex-col gap-6 pb-2">
|
||||
{#if draftConflict}
|
||||
<DraftConflictAlert
|
||||
onReload={() => resolveDraftConflict(false)}
|
||||
onOverwrite={() => resolveDraftConflict(true)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if otherDirty.length > 0}
|
||||
<Alert type="warning" title="Editing multiple workspaces">
|
||||
You are going to edit the value in: {otherDirty.join(', ')}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
WorkerService
|
||||
} from '$lib/gen'
|
||||
import { inferArgs } from '$lib/infer'
|
||||
import { VERSION_PAGE_SIZE } from '$lib/components/diff_drawer'
|
||||
import {
|
||||
initialCode,
|
||||
canHavePreprocessor,
|
||||
@@ -87,7 +88,7 @@
|
||||
import { writable } from 'svelte/store'
|
||||
import { defaultScriptLanguages, processLangs } from '$lib/scripts'
|
||||
import DefaultScripts from './DefaultScripts.svelte'
|
||||
import { getContext, onMount, setContext, tick, untrack } from 'svelte'
|
||||
import { getContext, onDestroy, onMount, setContext, tick, untrack } from 'svelte'
|
||||
import EditorHeader from './EditorHeader.svelte'
|
||||
import ScriptSettingsBadges from './ScriptSettingsBadges.svelte'
|
||||
import Badge from './common/badge/Badge.svelte'
|
||||
@@ -119,6 +120,8 @@
|
||||
fullyLoaded = true,
|
||||
initialPath = $bindable(''),
|
||||
userDraftPath = '',
|
||||
onTakeLatest = undefined,
|
||||
draftBaseHash = undefined,
|
||||
autosaveWorkspace = undefined,
|
||||
autosavePath = undefined,
|
||||
template = $bindable('script'),
|
||||
@@ -558,10 +561,13 @@
|
||||
|
||||
try {
|
||||
if (initialPath && initialPath != '') {
|
||||
// The row's path, not `initialPath`: that is the typed path, where a
|
||||
// staged rename has nothing deployed, and a missing head would deploy
|
||||
// on a base that already has a child.
|
||||
actual_parent_hash = (
|
||||
await ScriptService.getScriptLatestVersion({
|
||||
workspace: opWorkspace!,
|
||||
path: initialPath
|
||||
path: userDraftPath || initialPath
|
||||
})
|
||||
)?.script_hash
|
||||
}
|
||||
@@ -573,9 +579,15 @@
|
||||
// But if we specify parent_hash that is already used, than we get error
|
||||
// In order to fix it we make sure that client's understanding of parent_hash
|
||||
// is aligns with understanding of backend.
|
||||
if (actual_parent_hash == undefined || script.parent_hash == actual_parent_hash) {
|
||||
//
|
||||
// A draft with no base (one predating `draft.base`) is compared on the head this
|
||||
// editor loaded, as the flow and raw-app guards are: comparing `undefined` would
|
||||
// open the confirmation on every deploy, telling the user a version landed while
|
||||
// they were editing when none had.
|
||||
const baseHash = script.parent_hash ?? deployedScriptHash
|
||||
if (actual_parent_hash == undefined || baseHash == actual_parent_hash) {
|
||||
// Handle directly
|
||||
await editScript(stay, script.parent_hash!, deployMsg)
|
||||
await editScript(stay, (script.parent_hash ?? actual_parent_hash)!, deployMsg)
|
||||
} else {
|
||||
// Fetch entire script, since we need it to show Diff
|
||||
await syncWithDeployed()
|
||||
@@ -605,13 +617,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function syncWithDeployed() {
|
||||
async function syncWithDeployed(opening?: number) {
|
||||
const latestScript = await ScriptService.getScriptByPath({
|
||||
workspace: opWorkspace!,
|
||||
path: initialPath,
|
||||
// The draft row's own path, not `initialPath` — that one tracks the path
|
||||
// the user has typed, so after someone renames the item it still names
|
||||
// the old location and this would compare against the archived row left
|
||||
// there. Resolving by the row is what lets the diff show the rename.
|
||||
path: userDraftPath || initialPath,
|
||||
withStarredInfo: true
|
||||
})
|
||||
|
||||
// A superseded opening must not write these: the current one would then render
|
||||
// and offer Take latest against the older head.
|
||||
if (opening != null && !diffDrawer?.ownsOpening(opening)) return
|
||||
deployedValue = replaceFalseWithUndefined({
|
||||
...latestScript,
|
||||
workspace_id: undefined,
|
||||
@@ -782,7 +801,7 @@
|
||||
// + `tick()` first so the last keystrokes reach the bindable before the
|
||||
// syncer flushes. No toast — the AutosaveIndicator narrates the result, and
|
||||
// `flush` never rejects (postSave routes errors to the failures map).
|
||||
async function saveDraft(): Promise<void> {
|
||||
export async function saveDraft(): Promise<void> {
|
||||
if (!opWorkspace || !userDraftPath) return
|
||||
editor?.flushPendingChanges()
|
||||
await tick()
|
||||
@@ -811,11 +830,90 @@
|
||||
// top-bar button (rendered independently of the session pane), not here.
|
||||
const inSessionPane = !!getContext('aiChatManager')
|
||||
|
||||
async function openDiffDrawer() {
|
||||
/** Names the version on the deployed side of the diff. Without it the reader is
|
||||
* shown two panes and told nothing about what the left one is — which matters
|
||||
* most in the stale-draft case, where the whole question is "whose version am I
|
||||
* about to overwrite". */
|
||||
function deployedVersionLabel(deployed: any): string | undefined {
|
||||
if (!deployed?.hash) return undefined
|
||||
// `deployedBy` is captured in `syncWithDeployed` before it strips `created_by`.
|
||||
// Only used when the version list is unavailable; otherwise the picker labels it.
|
||||
return `Deployed${deployedBy ? ` by ${deployedBy}` : ''} · latest`
|
||||
}
|
||||
|
||||
/** Deployed versions to offer in the diff picker, newest first. Best-effort: a
|
||||
* failure here costs the picker, not the diff, so the drawer still opens on the
|
||||
* head. `deployment_msg` is all the history endpoint carries besides the hash. */
|
||||
/** Throws: the drawer says so and lets the reader ask for the same page again. */
|
||||
async function fetchVersionPage(headHash: string | undefined, page: number) {
|
||||
if (!opWorkspace || !userDraftPath) return undefined
|
||||
{
|
||||
const history = await ScriptService.getScriptHistoryByPath({
|
||||
workspace: opWorkspace,
|
||||
path: userDraftPath,
|
||||
page,
|
||||
perPage: VERSION_PAGE_SIZE
|
||||
})
|
||||
// The hash identifies the version — it is what the API and the CLI speak — and
|
||||
// who deployed it drops to the subtitle. No ordinal: the list arrives a page at
|
||||
// a time, so a number counted within one would rename versions as more load.
|
||||
return history.map((h) => {
|
||||
const isHead = h.script_hash === headHash
|
||||
const detail = [
|
||||
h.created_by,
|
||||
h.created_at ? new Date(h.created_at).toLocaleString() : undefined,
|
||||
h.deployment_msg
|
||||
].filter(Boolean)
|
||||
return {
|
||||
id: h.script_hash,
|
||||
label: `${h.script_hash.slice(0, 8)}${isHead ? ' · latest' : ''}`,
|
||||
subtitle: detail.length ? detail.join(' · ') : undefined,
|
||||
isHead
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** The first page, best-effort: losing it costs the picker, not the diff. */
|
||||
async function deployedVersionOptions(headHash: string | undefined) {
|
||||
try {
|
||||
return await fetchVersionPage(headHash, 1)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Hands the drawer the next page each time the reader asks for one. The page number
|
||||
* belongs to this item's history, so it lives here — and only moves once a page has
|
||||
* actually arrived, or a failed request would skip it. */
|
||||
function moreVersionsLoader(headHash: string | undefined) {
|
||||
let loaded = 1
|
||||
return async () => {
|
||||
const page = await fetchVersionPage(headHash, loaded + 1)
|
||||
loaded += 1
|
||||
return page
|
||||
}
|
||||
}
|
||||
|
||||
/** The opening this editor claimed last. A path change remounts this editor while the
|
||||
* drawer stays mounted, so its teardown hands that opening back rather than leaving
|
||||
* the drawer on the item the user left. */
|
||||
let lastOpening: number | undefined = undefined
|
||||
onDestroy(() => {
|
||||
if (lastOpening != null) diffDrawer?.abandonOpening(lastOpening)
|
||||
})
|
||||
|
||||
export async function openDiffDrawer() {
|
||||
if (!savedScript) {
|
||||
return
|
||||
}
|
||||
await syncWithDeployed()
|
||||
// The fetches below are awaited, so a reopen (or a path change, which remounts
|
||||
// this editor but not the drawer) while they run must not have the older one
|
||||
// land last. The drawer counts the openings for that reason.
|
||||
const opening = diffDrawer?.beginOpening()
|
||||
lastOpening = opening
|
||||
if (opening == null) return
|
||||
await syncWithDeployed(opening)
|
||||
|
||||
const currentDraftTriggers = structuredClone(triggersState.getDraftTriggersSnapshot())
|
||||
|
||||
@@ -832,13 +930,40 @@
|
||||
}
|
||||
if (current.assets && !current.assets.length) delete current.assets
|
||||
|
||||
diffDrawer?.openDrawer()
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'normal',
|
||||
deployed,
|
||||
draft: savedScript['draft'],
|
||||
current
|
||||
})
|
||||
// Blanking the drawer belongs to the opening that will fill it.
|
||||
if (!diffDrawer?.ownsOpening(opening)) return
|
||||
diffDrawer.openDrawer(opening)
|
||||
const headHash = (deployed as { hash?: string } | undefined)?.hash
|
||||
const versions = await deployedVersionOptions(headHash)
|
||||
if (!diffDrawer?.ownsOpening(opening)) return
|
||||
diffDrawer.setDiff(
|
||||
{
|
||||
mode: 'normal',
|
||||
deployed,
|
||||
deployedLabel: deployedVersionLabel(deployed),
|
||||
versions,
|
||||
loadMoreVersions: moreVersionsLoader(headHash),
|
||||
onTakeLatest,
|
||||
draftBase: draftBaseHash,
|
||||
deployedHead: headHash,
|
||||
loadVersion: async (hash) => {
|
||||
const v = await ScriptService.getScriptByHash({ workspace: opWorkspace!, hash })
|
||||
return replaceFalseWithUndefined({
|
||||
...v,
|
||||
workspace_id: undefined,
|
||||
created_at: undefined,
|
||||
created_by: undefined,
|
||||
extra_perms: undefined,
|
||||
lock: undefined,
|
||||
lock_error_logs: undefined,
|
||||
parent_hashes: undefined
|
||||
})
|
||||
},
|
||||
draft: savedScript['draft'],
|
||||
current
|
||||
},
|
||||
opening
|
||||
)
|
||||
}
|
||||
|
||||
function computeDropdownItems(
|
||||
@@ -1145,6 +1270,7 @@
|
||||
{confirmCallback}
|
||||
bind:open
|
||||
{diffDrawer}
|
||||
claimOpening={() => (lastOpening = diffDrawer?.beginOpening())}
|
||||
bind:deployedValue
|
||||
currentValue={script}
|
||||
/>
|
||||
@@ -2041,7 +2167,7 @@
|
||||
args={hasPreprocessor && selectedInputTab !== 'preprocessor' ? {} : args}
|
||||
isDeployed={savedScript && savedScript?.no_deployed !== true}
|
||||
schema={script.schema}
|
||||
runnableVersion={script.parent_hash}
|
||||
runnableVersion={deployedScriptHash}
|
||||
onDeployTrigger={handleDeployTrigger}
|
||||
/>
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
import { useActingUser } from '$lib/actingUser.svelte'
|
||||
import { UserDraft, draftValuesEqual, type UserDraftHandle } from '$lib/userDraft.svelte'
|
||||
import LocalDraftBanner from './LocalDraftBanner.svelte'
|
||||
import DraftConflictAlert from './DraftConflictAlert.svelte'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import { isEncryptedDraftValue } from '$lib/encryptedDraft'
|
||||
import { setLocalDraftHint } from '$lib/localDraftHints.svelte'
|
||||
|
||||
@@ -123,6 +125,62 @@
|
||||
Object.keys(states).filter((ws) => !draftValuesEqual(states[ws].draft, initialStates[ws]))
|
||||
)
|
||||
|
||||
/** The server refused this tab's autosave because the row moved under it: another tab, or the
|
||||
* AI chat, which writes these drafts too. Nothing typed here reaches the server until the user
|
||||
* picks a version, and the unsaved-changes banner says the opposite — that the edits are held
|
||||
* as a draft — so without this they are told their work is safe while it is being dropped. */
|
||||
const draftConflict = $derived(
|
||||
edit && selected && editPath
|
||||
? UserDraftDbSyncer.getConflict({
|
||||
workspace: selected,
|
||||
itemKind: 'variable',
|
||||
path: editPath
|
||||
}).conflict
|
||||
: undefined
|
||||
)
|
||||
|
||||
async function resolveDraftConflict(keepMine: boolean): Promise<void> {
|
||||
const ws = selected
|
||||
const p = editPath
|
||||
if (!ws || !p) return
|
||||
const query = { workspace: ws, itemKind: 'variable' as const, path: p }
|
||||
if (keepMine) {
|
||||
// Forced, so it goes over the row that refused us, and its response reseeds
|
||||
// `last_sync` so the next ordinary save is conditional again.
|
||||
const mine = states[ws]?.draft
|
||||
if (mine) await UserDraftDbSyncer.overwrite({ ...query, value: $state.snapshot(mine) })
|
||||
return
|
||||
}
|
||||
// Taking theirs: drop the refused payload first so no later flush can send it, then read
|
||||
// what the server holds and seed that in, which is also what gives this tab a baseline.
|
||||
UserDraftDbSyncer.dropPending(query)
|
||||
UserDraftDbSyncer.clearConflict(query)
|
||||
const v = await VariableService.getVariable({
|
||||
workspace: ws,
|
||||
path: p,
|
||||
decryptSecret: false,
|
||||
getDraft: true
|
||||
})
|
||||
const deployedState: VariableState = {
|
||||
path: v.path,
|
||||
variable: {
|
||||
value: v.value ?? '',
|
||||
is_secret: v.is_secret,
|
||||
description: v.description ?? ''
|
||||
},
|
||||
labels: v.labels ?? undefined,
|
||||
wsSpecific: v.ws_specific ?? false
|
||||
}
|
||||
initialStates[ws] = structuredClone(deployedState)
|
||||
UserDraftDbSyncer.recordRemoteSync(query, (v as any).draft_saved_at)
|
||||
UserDraft.seed(
|
||||
'variable',
|
||||
p,
|
||||
((v as any).draft as VariableState | undefined) ?? deployedState,
|
||||
{ workspace: ws }
|
||||
)
|
||||
}
|
||||
|
||||
// The list-page `*` hint is owned by UserDraftDbSyncer (set on save, cleared
|
||||
// on delete). The editor only CLEARS it — a workspace at the deployed
|
||||
// baseline has no draft, so drop any stale hint (this is how a draft
|
||||
@@ -317,6 +375,12 @@
|
||||
on:close={drawer?.closeDrawer}
|
||||
>
|
||||
{#snippet banner()}
|
||||
{#if draftConflict}
|
||||
<DraftConflictAlert
|
||||
onReload={() => resolveDraftConflict(false)}
|
||||
onOverwrite={() => resolveDraftConflict(true)}
|
||||
/>
|
||||
{/if}
|
||||
<LocalDraftBanner
|
||||
show={edit && selectedDirty}
|
||||
reserveSpace={edit}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import InputValue from '../helpers/InputValue.svelte'
|
||||
import InitializeComponent from '../helpers/InitializeComponent.svelte'
|
||||
|
||||
let editor = $state()
|
||||
let editor: HTMLElement | undefined = $state()
|
||||
let quill: any = $state()
|
||||
interface Props {
|
||||
id: string
|
||||
@@ -24,7 +24,7 @@
|
||||
toolbarOptions = [
|
||||
[{ header: 1 }, { header: 2 }, 'blockquote', 'link', 'image', 'video'],
|
||||
['bold', 'italic', 'underline', 'strike'],
|
||||
[{ list: 'ordered' }, { list: 'ordered' }],
|
||||
[{ list: 'ordered' }, { list: 'bullet' }],
|
||||
[{ align: [] }],
|
||||
['clean']
|
||||
]
|
||||
@@ -42,6 +42,7 @@
|
||||
|
||||
async function loadQuill() {
|
||||
const { default: Quill } = await import('quill')
|
||||
if (!editor) return
|
||||
|
||||
quill = new Quill(editor, {
|
||||
modules: {
|
||||
@@ -51,33 +52,73 @@
|
||||
placeholder: placeholder
|
||||
})
|
||||
|
||||
if (defaultValue) {
|
||||
quill.root.innerHTML = defaultValue
|
||||
}
|
||||
|
||||
quill.on('text-change', function (delta, oldDelta, source) {
|
||||
setOutput()
|
||||
})
|
||||
|
||||
if (defaultValue) {
|
||||
setHtml(defaultValue)
|
||||
}
|
||||
}
|
||||
|
||||
// Load HTML through Quill's clipboard converter rather than assigning `root.innerHTML`:
|
||||
// Quill only keeps markup it has a blot for, and writes to the DOM bypass that mapping.
|
||||
// Quill 2 renders every list as `<ol>`, so a `<ul>` from stored content would be dropped
|
||||
// outright instead of becoming a bullet list.
|
||||
function setHtml(html: string | undefined) {
|
||||
quill.setContents(quill.clipboard.convert({ html: html ?? '', text: '' }))
|
||||
}
|
||||
|
||||
$componentControl[untrack(() => id)] = {
|
||||
setValue(nvalue: string) {
|
||||
if (quill) {
|
||||
quill.root.innerHTML = nvalue
|
||||
setHtml(nvalue)
|
||||
setOutput()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Quill 2 keeps every list in an `<ol>` and marks the kind with `data-list` on each
|
||||
// item (`bullet`, `ordered`, `checked`, `unchecked`), plus a `span.ql-ui` that only its
|
||||
// stylesheet renders. Consumers of the output (an HTML component, stored content) render
|
||||
// it outside that stylesheet, where that markup reads as a numbered list. Emit the
|
||||
// standard shape instead: `<ul>`/`<ol>` runs, checklists as `<ul data-checked>`, which
|
||||
// is what the clipboard converter reads back on load (`matchList`).
|
||||
function toStandardHtml(root: HTMLElement): string {
|
||||
const clone = root.cloneNode(true) as HTMLElement
|
||||
clone.querySelectorAll('span.ql-ui').forEach((s) => s.remove())
|
||||
clone.querySelectorAll('ol').forEach((ol) => {
|
||||
const lists: HTMLElement[] = []
|
||||
for (const li of Array.from(ol.children)) {
|
||||
const kind = li.getAttribute('data-list')
|
||||
li.removeAttribute('data-list')
|
||||
const tag = kind === 'ordered' ? 'ol' : 'ul'
|
||||
const checked = kind === 'checked' ? 'true' : kind === 'unchecked' ? 'false' : null
|
||||
const last = lists.at(-1)
|
||||
if (
|
||||
last?.tagName.toLowerCase() !== tag ||
|
||||
last.getAttribute('data-checked') !== checked
|
||||
) {
|
||||
const list = document.createElement(tag)
|
||||
if (checked !== null) list.setAttribute('data-checked', checked)
|
||||
lists.push(list)
|
||||
}
|
||||
lists.at(-1)!.appendChild(li)
|
||||
}
|
||||
ol.replaceWith(...lists)
|
||||
})
|
||||
return clone.innerHTML
|
||||
}
|
||||
|
||||
function setOutput() {
|
||||
if (quill) {
|
||||
outputs?.result.set(quill.root.innerHTML)
|
||||
outputs?.result.set(toStandardHtml(quill.root))
|
||||
}
|
||||
}
|
||||
|
||||
function handleDefault(defaultValue: string | undefined) {
|
||||
if (quill) {
|
||||
quill.root.innerHTML = defaultValue
|
||||
setHtml(defaultValue)
|
||||
setOutput()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,8 @@
|
||||
loadedFromDraft = false,
|
||||
othersDraftsCount = 0,
|
||||
onOpenOthersDrafts,
|
||||
onRestore
|
||||
onRestore,
|
||||
onDeploy
|
||||
}: AppEditorProps = $props()
|
||||
|
||||
migrateApp(untrack(() => app))
|
||||
@@ -893,6 +894,7 @@
|
||||
{othersDraftsCount}
|
||||
{onOpenOthersDrafts}
|
||||
{onRestore}
|
||||
{onDeploy}
|
||||
{policy}
|
||||
{fromHub}
|
||||
bind:this={appEditorHeader}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
Zap,
|
||||
Globe
|
||||
} from 'lucide-svelte'
|
||||
import { getContext, untrack } from 'svelte'
|
||||
import { getContext, onDestroy, untrack } from 'svelte'
|
||||
import { orderedJsonStringify, type Value, replaceFalseWithUndefined } from '../../../utils'
|
||||
import type { App, AppEditorContext, AppViewerContext } from '../types'
|
||||
import { toStatic } from '../utils'
|
||||
@@ -110,6 +110,10 @@
|
||||
// (not `on:restore` forwarding): forwarding a `createEventDispatcher`
|
||||
// event up through these runes-mode components silently drops it.
|
||||
onRestore?: (restoredApp: any) => void
|
||||
// Fired after a successful deploy, which keeps this editor open: `version` is what
|
||||
// the deploy wrote, for the next draft's fork base, and `head` what is deployed
|
||||
// now, with its author and time.
|
||||
onDeploy?: (e: { version?: number; head?: number; headBy?: string; headAt?: string }) => void
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -137,7 +141,8 @@
|
||||
loadedFromDraft = false,
|
||||
othersDraftsCount = 0,
|
||||
onOpenOthersDrafts,
|
||||
onRestore
|
||||
onRestore,
|
||||
onDeploy
|
||||
}: Props = $props()
|
||||
|
||||
/** Mirror of the path the user is editing in the pen popover. Initialized
|
||||
@@ -329,13 +334,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function syncWithDeployed() {
|
||||
/** The opening this editor claimed last. A path change remounts this editor while the
|
||||
* drawer stays mounted, so its teardown hands that opening back rather than leaving
|
||||
* the drawer on the app the user left. */
|
||||
let lastOpening: number | undefined = undefined
|
||||
onDestroy(() => {
|
||||
if (lastOpening != null) diffDrawer?.abandonOpening(lastOpening)
|
||||
})
|
||||
|
||||
async function syncWithDeployed(opening?: number) {
|
||||
const deployedApp = await AppService.getAppByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path: $appPath!,
|
||||
withStarredInfo: true
|
||||
})
|
||||
|
||||
// A superseded opening must not write these: the current one would then render
|
||||
// against the older deployed value.
|
||||
if (opening != null && !diffDrawer?.ownsOpening(opening)) return
|
||||
deployedBy = deployedApp.created_by
|
||||
|
||||
// Strip off extra information
|
||||
@@ -351,7 +367,7 @@
|
||||
|
||||
async function updateApp(npath: string) {
|
||||
policy = await updatePolicy($app, policy)
|
||||
await AppService.updateApp({
|
||||
const deployed = await AppService.updateApp({
|
||||
workspace: $workspaceStore!,
|
||||
path: $appPath!,
|
||||
requestBody: {
|
||||
@@ -377,16 +393,26 @@
|
||||
custom_path: customPath,
|
||||
labels: $state.snapshot(labels)
|
||||
}
|
||||
const appHistory = await AppService.getAppHistoryByPath({
|
||||
const head = await AppService.getAppLatestVersion({
|
||||
workspace: $workspaceStore!,
|
||||
path: npath
|
||||
})
|
||||
version = appHistory[0]?.version
|
||||
// Re-pin the fork base to the just-deployed head: the editor stays open, so a
|
||||
// `version` is what is deployed now, which is the deploy guard's fallback head; the
|
||||
// deploy's own answer is the base, and the two differ when another landed beside it.
|
||||
version = head?.version
|
||||
// Re-pin the fork base to the version just written: the editor stays open, so a
|
||||
// follow-up deploy (or a new edit) would otherwise compare against the now-
|
||||
// superseded base and falsely warn. parent_version is in
|
||||
// DRAFT_COMPARE_IGNORED_FIELDS, so this write can't spawn a spurious draft.
|
||||
if ($app) $app.parent_version = version
|
||||
if ($app) $app.parent_version = deployed.version
|
||||
// The route owns the pair the out-of-date prompt reads, and this editor stays open
|
||||
// across the deploy, so hand both over rather than leaving it on the old ones.
|
||||
onDeploy?.({
|
||||
version: deployed.version,
|
||||
head: version,
|
||||
headBy: head?.created_by,
|
||||
headAt: head?.created_at
|
||||
})
|
||||
|
||||
closeSaveDrawer()
|
||||
sendUserToast('App deployed successfully')
|
||||
@@ -430,6 +456,9 @@
|
||||
}
|
||||
|
||||
let onLatest = $state(true)
|
||||
/** The last comparison could not read the head, so the confirmation it raises is
|
||||
* caution and not an observed deploy. Cleared by the next reading comparison. */
|
||||
let headUnknown = $state(false)
|
||||
async function compareVersions() {
|
||||
// Compare the draft's pinned fork base (`$app.parent_version`) against the
|
||||
// current head when editing a draft, else the load-time head. Catches both a
|
||||
@@ -445,9 +474,14 @@
|
||||
path: $appPath
|
||||
})
|
||||
onLatest = appVersion?.version === undefined || base === appVersion?.version
|
||||
headUnknown = false
|
||||
} catch (e) {
|
||||
console.error('Error comparing versions', e)
|
||||
onLatest = true
|
||||
// The head is what this compares against, so an unanswered read is not
|
||||
// evidence of being current: confirm, as an unclaimable deploy does, and say
|
||||
// that is why rather than claiming a version that was never seen.
|
||||
onLatest = false
|
||||
headUnknown = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -624,23 +658,33 @@
|
||||
if (!savedApp || newApp) {
|
||||
return
|
||||
}
|
||||
// The fetch below is awaited, so a reopen (or a path change, which remounts
|
||||
// this editor but not the drawer) while it runs must not have the older one
|
||||
// land last. The drawer counts the openings for that reason.
|
||||
const opening = diffDrawer?.beginOpening()
|
||||
lastOpening = opening
|
||||
if (opening == null) return
|
||||
|
||||
// deployedValue should be syncronized when we open Diff
|
||||
await syncWithDeployed()
|
||||
await syncWithDeployed(opening)
|
||||
|
||||
diffDrawer?.openDrawer()
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'normal',
|
||||
deployed: deployedValue ?? savedApp,
|
||||
current: {
|
||||
summary: $summary,
|
||||
value: $app,
|
||||
path: newEditedPath || savedApp.path,
|
||||
policy,
|
||||
custom_path: customPath,
|
||||
labels
|
||||
}
|
||||
})
|
||||
if (!diffDrawer?.ownsOpening(opening)) return
|
||||
diffDrawer.openDrawer(opening)
|
||||
diffDrawer.setDiff(
|
||||
{
|
||||
mode: 'normal',
|
||||
deployed: deployedValue ?? savedApp,
|
||||
current: {
|
||||
summary: $summary,
|
||||
value: $app,
|
||||
path: newEditedPath || savedApp.path,
|
||||
policy,
|
||||
custom_path: customPath,
|
||||
labels
|
||||
}
|
||||
},
|
||||
opening
|
||||
)
|
||||
},
|
||||
disabled: !savedApp || newApp
|
||||
},
|
||||
@@ -721,6 +765,8 @@
|
||||
{confirmCallback}
|
||||
bind:open
|
||||
{diffDrawer}
|
||||
claimOpening={() => (lastOpening = diffDrawer?.beginOpening())}
|
||||
{headUnknown}
|
||||
bind:deployedValue
|
||||
currentValue={{
|
||||
summary: $summary,
|
||||
@@ -759,33 +805,41 @@
|
||||
if (!savedApp || newApp) {
|
||||
return
|
||||
}
|
||||
// The other entry point into the same drawer, so it takes an opening too.
|
||||
const opening = diffDrawer?.beginOpening()
|
||||
lastOpening = opening
|
||||
if (opening == null) return
|
||||
// deployedValue should be syncronized when we open Diff
|
||||
await syncWithDeployed()
|
||||
await syncWithDeployed(opening)
|
||||
|
||||
if (!diffDrawer?.ownsOpening(opening)) return
|
||||
saveDrawerOpen = false
|
||||
diffDrawer?.openDrawer()
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'normal',
|
||||
deployed: deployedValue ?? savedApp,
|
||||
current: {
|
||||
summary: $summary,
|
||||
value: $app,
|
||||
path: newEditedPath || savedApp.path,
|
||||
policy,
|
||||
custom_path: customPath,
|
||||
labels
|
||||
},
|
||||
button: {
|
||||
text: 'Looks good, deploy',
|
||||
onClick: () => {
|
||||
if (newApp) {
|
||||
createApp(newEditedPath)
|
||||
} else {
|
||||
handleUpdateApp(newEditedPath)
|
||||
diffDrawer.openDrawer(opening)
|
||||
diffDrawer.setDiff(
|
||||
{
|
||||
mode: 'normal',
|
||||
deployed: deployedValue ?? savedApp,
|
||||
current: {
|
||||
summary: $summary,
|
||||
value: $app,
|
||||
path: newEditedPath || savedApp.path,
|
||||
policy,
|
||||
custom_path: customPath,
|
||||
labels
|
||||
},
|
||||
button: {
|
||||
text: 'Looks good, deploy',
|
||||
onClick: () => {
|
||||
if (newApp) {
|
||||
createApp(newEditedPath)
|
||||
} else {
|
||||
handleUpdateApp(newEditedPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
opening
|
||||
)
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-row gap-2 items-center">
|
||||
@@ -818,6 +872,7 @@
|
||||
{setPublishState}
|
||||
appPath={$appPath}
|
||||
{onLatest}
|
||||
{headUnknown}
|
||||
{savedApp}
|
||||
bind:summary={$summary}
|
||||
bind:customPath
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
appPath,
|
||||
customPath = $bindable(),
|
||||
onLatest,
|
||||
headUnknown = false,
|
||||
savedApp,
|
||||
summary = $bindable(),
|
||||
deploymentMsg = $bindable(),
|
||||
@@ -54,6 +55,9 @@
|
||||
appPath: string
|
||||
customPath: string | undefined
|
||||
onLatest: boolean
|
||||
/** The comparison behind `onLatest` could not read the deployed head, so the warning
|
||||
* is caution rather than an observed newer version. */
|
||||
headUnknown?: boolean
|
||||
savedApp: any
|
||||
summary: string
|
||||
deploymentMsg: string | undefined
|
||||
@@ -239,8 +243,15 @@
|
||||
</script>
|
||||
|
||||
{#if !onLatest}
|
||||
<Alert title="You're not on the latest app version. " type="warning">
|
||||
By deploying, you may overwrite changes made by other users. Press 'Deploy' to see diff.
|
||||
<Alert
|
||||
title={headUnknown
|
||||
? 'Could not check the deployed version. '
|
||||
: "You're not on the latest app version. "}
|
||||
type="warning"
|
||||
>
|
||||
{headUnknown
|
||||
? "Deploying may overwrite changes made by other users. Press 'Deploy' to see diff."
|
||||
: "By deploying, you may overwrite changes made by other users. Press 'Deploy' to see diff."}
|
||||
</Alert>
|
||||
<div class="py-2"></div>
|
||||
{/if}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { base } from "$lib/base"
|
||||
import { workspaceStore } from "$lib/stores"
|
||||
import { get } from "svelte/store"
|
||||
import { base } from '$lib/base'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { get } from 'svelte/store'
|
||||
|
||||
export function computeSecretUrl(secretUrl: string) {
|
||||
return `${window.location.origin}${base}/public/${get(workspaceStore)}/${secretUrl}`
|
||||
return `${window.location.origin}${base}/public/${get(workspaceStore)}/${secretUrl}`
|
||||
}
|
||||
|
||||
@@ -183,6 +183,11 @@ export interface AppEditorProps {
|
||||
// AppEditorHeader as a callback prop rather than `on:restore` forwarding,
|
||||
// which does not propagate through these runes-mode components.
|
||||
onRestore?: (restoredApp: any) => void
|
||||
// Fired after a successful deploy, which keeps this editor open: `version` is what
|
||||
// the deploy wrote, for the next draft's fork base, and `head` what is deployed now,
|
||||
// with its author and time. `version` and `head` differ when another deploy landed
|
||||
// beside this one, which is when the out-of-date prompt has something to say.
|
||||
onDeploy?: (e: { version?: number; head?: number; headBy?: string; headAt?: string }) => void
|
||||
}
|
||||
|
||||
export type App = {
|
||||
|
||||
@@ -71,7 +71,9 @@
|
||||
style={bgStyle}
|
||||
>
|
||||
<div class="flex flex-row items-center">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-full">
|
||||
<!-- shrink-0: the badge's min-content width is the 16px icon, so without it a
|
||||
narrow container squeezes the circle into an oval. -->
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full">
|
||||
<SvelteComponent
|
||||
class={twMerge(classes[type].iconClass, iconClass)}
|
||||
style={iconStyle}
|
||||
|
||||
@@ -172,7 +172,11 @@
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div
|
||||
class={classNames(
|
||||
'relative transform overflow-hidden rounded-lg bg-surface px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6',
|
||||
// `w-full max-w-lg` unprefixed, not `sm:`-only: below 640px the panel would
|
||||
// otherwise be sized by its content's min-content width, and since the
|
||||
// wrapper centres it without scrolling, anything wider than the viewport
|
||||
// gets clipped on both sides with the left half unreachable.
|
||||
'relative transform overflow-hidden rounded-lg bg-surface px-4 pt-5 pb-4 text-left shadow-xl transition-all w-full max-w-lg sm:my-8 sm:p-6',
|
||||
open
|
||||
? 'ease-out duration-300 opacity-100 translate-y-0 sm:scale-100'
|
||||
: 'ease-in duration-200 opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95'
|
||||
@@ -180,13 +184,20 @@
|
||||
>
|
||||
<div class="flex">
|
||||
{#if showIcon}
|
||||
<!-- shrink-0: the badge is a flex item whose min-content width is the
|
||||
24px icon, so without it a narrow dialog squeezes the circle into a
|
||||
24x48 oval instead of wrapping the text beside it. -->
|
||||
<div
|
||||
class={`flex h-12 w-12 items-center justify-center rounded-full ${theme[type].classes.iconWrapper}`}
|
||||
class={`flex h-12 w-12 shrink-0 items-center justify-center rounded-full ${theme[type].classes.iconWrapper}`}
|
||||
>
|
||||
<Icon class={theme[type].classes.icon} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class={twMerge('ml-0 text-left flex-1 ', showIcon ? 'ml-4' : '')}>
|
||||
<!-- min-w-0: `flex-1` alone keeps `min-width: auto`, so a body containing
|
||||
one long unbreakable string (a path list, a URL) sizes this column by
|
||||
that string and pushes it out of the panel — and any `truncate` inside
|
||||
never engages. -->
|
||||
<div class={twMerge('ml-0 text-left flex-1 min-w-0', showIcon ? 'ml-4' : '')}>
|
||||
<h3 class="text-lg font-medium text-primary">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
+33
-14
@@ -11,6 +11,15 @@
|
||||
confirmCallback: () => void
|
||||
deployedBy?: string | undefined
|
||||
open?: boolean
|
||||
/** Takes a drawer opening on the host editor's behalf, so the diff below is one the
|
||||
* editor can take back down. Override anyway deploys that editor's content: without
|
||||
* this the drawer would outlive it (a relocation remounts the editor) and deploy it
|
||||
* at a path it has left. Omit where the host cannot be remounted under the drawer. */
|
||||
claimOpening?: () => number | undefined
|
||||
/** The host could not read the deployed head, so this confirmation is caution rather
|
||||
* than an observed newer version: nobody may have deployed over the user, and
|
||||
* `deployedBy` is then whoever wrote the head, possibly themselves. */
|
||||
headUnknown?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -19,13 +28,15 @@
|
||||
diffDrawer = undefined,
|
||||
confirmCallback,
|
||||
deployedBy = undefined,
|
||||
open = $bindable(false)
|
||||
open = $bindable(false),
|
||||
claimOpening = undefined,
|
||||
headUnknown = false
|
||||
}: Props = $props()
|
||||
</script>
|
||||
|
||||
<ConfirmationModal
|
||||
{open}
|
||||
title={'New version deployed by ' + deployedBy}
|
||||
title={headUnknown ? 'Deploy anyway?' : 'New version deployed by ' + deployedBy}
|
||||
confirmationText="Override"
|
||||
on:canceled={() => {
|
||||
open = false
|
||||
@@ -33,7 +44,11 @@
|
||||
on:confirmed={() => confirmCallback()}
|
||||
>
|
||||
<div class="flex flex-col w-full space-y-4">
|
||||
<span>A new version was deployed while you were editing this one.</span>
|
||||
<span>
|
||||
{headUnknown
|
||||
? 'This editor could not check whether a newer version is deployed, so it cannot tell whether this overwrites newer work.'
|
||||
: 'A new version was deployed while you were editing this one.'}
|
||||
</span>
|
||||
{#if diffDrawer}
|
||||
<Button
|
||||
wrapperClasses="self-start"
|
||||
@@ -44,17 +59,21 @@
|
||||
return
|
||||
}
|
||||
open = false
|
||||
diffDrawer?.openDrawer()
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'simple',
|
||||
original: deployedValue,
|
||||
current: currentValue,
|
||||
title: 'Deployed <> Current',
|
||||
button: {
|
||||
text: 'Override anyway',
|
||||
onClick: () => confirmCallback()
|
||||
}
|
||||
})
|
||||
const opening = claimOpening?.()
|
||||
diffDrawer?.openDrawer(opening)
|
||||
diffDrawer?.setDiff(
|
||||
{
|
||||
mode: 'simple',
|
||||
original: deployedValue,
|
||||
current: currentValue,
|
||||
title: 'Deployed <> Current',
|
||||
button: {
|
||||
text: 'Override anyway',
|
||||
onClick: () => confirmCallback()
|
||||
}
|
||||
},
|
||||
opening
|
||||
)
|
||||
}}
|
||||
>Show diff
|
||||
</Button>
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
import type { UserDraftItemKind } from '$lib/gen'
|
||||
import DraftSyncConflictModal from './DraftSyncConflictModal.svelte'
|
||||
import OtherUsersDraftsModal, { type OtherDraftUser } from './OtherUsersDraftsModal.svelte'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { base } from '$app/paths'
|
||||
import { goto } from '$app/navigation'
|
||||
import StaleDraftModal from './StaleDraftModal.svelte'
|
||||
import ConfirmationModal from './ConfirmationModal.svelte'
|
||||
import { OtherUserDraftLoad } from '$lib/components/otherUserDraftLoad.svelte'
|
||||
@@ -38,15 +42,27 @@
|
||||
draftSavedAt?: string | undefined
|
||||
/** ISO timestamp of the latest deploy at this path. */
|
||||
deployedAt?: string | undefined
|
||||
/** Precise staleness inputs (flows/apps): the deployed version the draft was
|
||||
* forked from, and the current deployed head. When both are set they drive
|
||||
* `isStale` and the dedup key instead of the timestamps — exact, and stable
|
||||
* across autosaves (the timestamp drifts past `deployedAt` as you keep
|
||||
* editing). Absent (pre-feature drafts, scripts) ⇒ timestamp fallback. */
|
||||
draftBaseVersion?: number | undefined
|
||||
deployedHeadVersion?: number | undefined
|
||||
/** Precise staleness inputs: the deployed version the draft forked from
|
||||
* (`draft_base` on the get-by-path response) and the current deployed head,
|
||||
* both as text whatever the kind. When both are set they drive `isStale` and
|
||||
* the dedup key instead of the timestamps, which drift past `deployedAt` as
|
||||
* you keep editing. Absent (a draft never forked from a deploy) ⇒ timestamp
|
||||
* fallback. */
|
||||
draftBaseVersion?: string | undefined
|
||||
deployedHeadVersion?: string | undefined
|
||||
/** Who deployed the head, named in the stale prompt. */
|
||||
deployedBy?: string | undefined
|
||||
/** Discard the draft and reload deployed (same as "Reset to deployed"). */
|
||||
onLoadLatestDeploy?: () => void | Promise<void>
|
||||
/** Opens the editor's Deployed↔Current diff from the stale prompt, so the
|
||||
* choice between keeping and discarding is informed. Omit where the editor
|
||||
* has no diff drawer; the action is then not rendered. */
|
||||
onViewDiff?: () => void | Promise<void>
|
||||
/** Runs before this editor follows its draft to the item's new path: the
|
||||
* editor's own draft save, which materializes text the code editor still
|
||||
* holds. Without it, keystrokes typed since the relocating save are lost
|
||||
* to the navigation. */
|
||||
onBeforeRelocate?: () => void | Promise<void>
|
||||
/** Defaults to true; set to false to suppress all modals. */
|
||||
enabled?: boolean
|
||||
}
|
||||
@@ -65,7 +81,10 @@
|
||||
deployedAt = undefined,
|
||||
draftBaseVersion = undefined,
|
||||
deployedHeadVersion = undefined,
|
||||
deployedBy = undefined,
|
||||
onLoadLatestDeploy,
|
||||
onViewDiff,
|
||||
onBeforeRelocate,
|
||||
enabled = true
|
||||
}: Props = $props()
|
||||
|
||||
@@ -74,9 +93,9 @@
|
||||
let staleAlertKey = $state<string | undefined>(undefined)
|
||||
let staleModalOpen = $state(false)
|
||||
|
||||
// Prefer the exact version comparison (flows/apps) over the timestamp: the
|
||||
// draft's pinned fork base never drifts, whereas `draftSavedAt` advances past
|
||||
// `deployedAt` once you keep editing a stale draft, hiding the staleness.
|
||||
// Prefer the version comparison over the timestamp for every kind that supplies
|
||||
// one: `draftSavedAt` advances past `deployedAt` as you keep editing, hiding the
|
||||
// staleness outright.
|
||||
const useVersion = $derived(draftBaseVersion != null && deployedHeadVersion != null)
|
||||
const isStale = $derived(
|
||||
!!onLoadLatestDeploy &&
|
||||
@@ -105,6 +124,48 @@
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const EDITOR_SEGMENT: Partial<Record<UserDraftItemKind, string>> = {
|
||||
script: 'scripts/edit',
|
||||
flow: 'flows/edit',
|
||||
app: 'apps/edit',
|
||||
raw_app: 'apps_raw/edit'
|
||||
}
|
||||
|
||||
// The item was moved while this editor was open: the draft row followed it
|
||||
// and the save just landed there. Follow it too — the route reloads the item
|
||||
// at its new path, and the stale prompt above then says what changed. Edits
|
||||
// typed since that save are flushed first, so leaving this path drops none.
|
||||
$effect(() => {
|
||||
if (!enabled || !workspace || !path) return
|
||||
const seg = EDITOR_SEGMENT[itemKind]
|
||||
if (!seg) return
|
||||
const query = { workspace, itemKind, path }
|
||||
// The flush below saves again and can land here a second time, and a second move
|
||||
// can land while it runs: the last destination reported is the one to follow.
|
||||
let relocating = false
|
||||
let destination: string | undefined = undefined
|
||||
return UserDraftDbSyncer.onRelocated(query, async (newPath) => {
|
||||
destination = newPath
|
||||
if (relocating) return
|
||||
relocating = true
|
||||
await onBeforeRelocate?.()
|
||||
await UserDraftDbSyncer.flush(query)
|
||||
// `flush` resolves on a failed or rejected save as well, and leaving the
|
||||
// route drops what it was carrying: stay, so the editor keeps the edits
|
||||
// and its own failure indicator.
|
||||
if (
|
||||
UserDraftDbSyncer.getState(query).failureMessage ||
|
||||
UserDraftDbSyncer.getConflict(query).conflict
|
||||
) {
|
||||
relocating = false
|
||||
return
|
||||
}
|
||||
const target = destination ?? newPath
|
||||
sendUserToast(`This item was moved to ${target}. You are now editing it there.`)
|
||||
await goto(`${base}/${seg}/${target}`)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if enabled && workspace && path}
|
||||
@@ -130,9 +191,14 @@
|
||||
{#if onLoadLatestDeploy}
|
||||
<StaleDraftModal
|
||||
bind:isOpen={staleModalOpen}
|
||||
{itemKind}
|
||||
{draftSavedAt}
|
||||
{deployedAt}
|
||||
{draftBaseVersion}
|
||||
{deployedHeadVersion}
|
||||
{deployedBy}
|
||||
{onLoadLatestDeploy}
|
||||
{onViewDiff}
|
||||
/>
|
||||
{/if}
|
||||
<ConfirmationModal
|
||||
|
||||
@@ -1,41 +1,66 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Modal opened on editor mount when the authed user's per-user draft
|
||||
* is older than the latest deployed version at the same path — i.e.
|
||||
* someone else deployed a new version while the draft was sitting
|
||||
* around. The user is asked to either pick up the latest deploy
|
||||
* (discards the stale draft) or keep editing what they had.
|
||||
*
|
||||
* The parent threads `draftSavedAt` and `deployedAt` raw and the
|
||||
* modal computes staleness internally; this keeps each route from
|
||||
* re-implementing the comparison and the threshold (we treat a
|
||||
* draft as stale only when it's strictly older — a deploy at the
|
||||
* exact same instant is treated as not stale).
|
||||
* The prompt for a draft that is behind: someone deployed a newer version
|
||||
* of the item after the draft forked from it. Opened on every load while
|
||||
* that holds (the parent computes it; see DraftEditorModals), it names the
|
||||
* two versions and offers two ways out: look at the diff, or drop the draft
|
||||
* for the latest deploy. Closing it is "keep editing". Taking the latest as
|
||||
* the new base while keeping the edits lives in the diff drawer, where the
|
||||
* user can see what they are taking.
|
||||
*
|
||||
* Open-state is bindable so the parent can dismiss programmatically
|
||||
* (e.g. after the load-latest-deploy callback completes).
|
||||
*/
|
||||
import Modal2 from '$lib/components/common/modal/Modal2.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { AlertTriangle } from 'lucide-svelte'
|
||||
import { AlertTriangle, GitCompare } from 'lucide-svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import type { UserDraftItemKind } from '$lib/gen'
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean
|
||||
/** ISO timestamp the authed user's draft was saved. */
|
||||
itemKind: UserDraftItemKind
|
||||
/** ISO timestamp the authed user's draft was saved. Shown only when the
|
||||
* versions below are unknown (a draft that predates the base). */
|
||||
draftSavedAt: string | undefined
|
||||
/** ISO timestamp the latest deploy at this path landed. */
|
||||
deployedAt: string | undefined
|
||||
/** The version the draft forked from and the deployed head, as text. */
|
||||
draftBaseVersion?: string | undefined
|
||||
deployedHeadVersion?: string | undefined
|
||||
/** Who deployed the head. */
|
||||
deployedBy?: string | undefined
|
||||
/** Discards the draft and reloads the deployed payload — the route
|
||||
* already has this callback for the AutosaveIndicator's "Reset to
|
||||
* deployed" button; pass the same function in. */
|
||||
onLoadLatestDeploy: () => void | Promise<void>
|
||||
/** Opens the editor's own Deployed↔Current diff. Without it the user is
|
||||
* asked to choose between keeping and discarding their draft with no way
|
||||
* to see what actually differs — and after a rename the difference is
|
||||
* often only the path. Omitted where the editor has no diff drawer. */
|
||||
onViewDiff?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
let { isOpen = $bindable(), draftSavedAt, deployedAt, onLoadLatestDeploy }: Props = $props()
|
||||
let {
|
||||
isOpen = $bindable(),
|
||||
itemKind,
|
||||
draftSavedAt,
|
||||
deployedAt,
|
||||
draftBaseVersion = undefined,
|
||||
deployedHeadVersion = undefined,
|
||||
deployedBy = undefined,
|
||||
onLoadLatestDeploy,
|
||||
onViewDiff
|
||||
}: Props = $props()
|
||||
|
||||
let loading = $state(false)
|
||||
|
||||
// Scripts are versioned by hash, the other kinds by a numeric version id;
|
||||
// the diff picker renders them the same way.
|
||||
function formatVersion(v: string): string {
|
||||
return itemKind === 'script' ? v.slice(0, 8) : v
|
||||
}
|
||||
|
||||
async function loadLatestDeploy() {
|
||||
if (loading) return
|
||||
loading = true
|
||||
@@ -49,6 +74,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Dismisses on the way out: the diff drawer opens behind this modal, so
|
||||
// leaving it up would cover the thing the user asked to see.
|
||||
async function viewDiff() {
|
||||
isOpen = false
|
||||
await onViewDiff?.()
|
||||
}
|
||||
|
||||
function formatTs(ts: string | undefined): string {
|
||||
if (!ts) return ''
|
||||
try {
|
||||
@@ -64,22 +96,38 @@
|
||||
<div class="flex gap-3 items-start">
|
||||
<AlertTriangle size={20} class="text-amber-500 shrink-0 mt-0.5" />
|
||||
<div class="flex flex-col gap-1 text-sm text-primary">
|
||||
<p>
|
||||
A newer version was deployed after you started editing. Your draft is based on the older
|
||||
deploy.
|
||||
</p>
|
||||
<p class="text-xs text-secondary">
|
||||
Draft saved {formatTs(draftSavedAt)} · Deployed {formatTs(deployedAt)}
|
||||
</p>
|
||||
<p>A newer version was deployed after you started editing.</p>
|
||||
{#if draftBaseVersion && deployedHeadVersion}
|
||||
<p class="text-xs text-secondary">
|
||||
Your draft is based on <span class="font-mono">{formatVersion(draftBaseVersion)}</span>
|
||||
· latest is <span class="font-mono">{formatVersion(deployedHeadVersion)}</span>
|
||||
{#if deployedBy}by {deployedBy}{/if}{#if deployedAt}, {formatTs(deployedAt)}{/if}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-xs text-secondary">
|
||||
Draft saved {formatTs(draftSavedAt)} · Deployed {formatTs(deployedAt)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-2">
|
||||
<Button variant="default" size="sm" on:click={() => (isOpen = false)}>
|
||||
Keep editing my draft
|
||||
</Button>
|
||||
<Button variant="contained" color="dark" size="sm" {loading} on:click={loadLatestDeploy}>
|
||||
Load latest deploy
|
||||
<div class="flex justify-between items-center gap-2 mt-2">
|
||||
{#if onViewDiff}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: GitCompare }}
|
||||
on:click={viewDiff}
|
||||
>
|
||||
See what changed
|
||||
</Button>
|
||||
{:else}
|
||||
<div></div>
|
||||
{/if}
|
||||
<!-- Red: this replaces the draft with the latest deploy. Closing the modal
|
||||
is "keep editing", so it needs no button of its own. -->
|
||||
<Button variant="accent" destructive unifiedSize="sm" {loading} on:click={loadLatestDeploy}>
|
||||
Use latest
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import Button from '../button/Button.svelte'
|
||||
import Row from './Row.svelte'
|
||||
import type { RowSelection } from './rowSelection'
|
||||
import { selectMenuItems, type RowSelection } from './rowSelection'
|
||||
import InheritedLabels from '$lib/components/InheritedLabels.svelte'
|
||||
import Badge from '../badge/Badge.svelte'
|
||||
import {
|
||||
@@ -188,6 +188,21 @@
|
||||
const canEdit = canWrite && showEditButton
|
||||
if (draft_only) {
|
||||
return [
|
||||
...selectMenuItems(rowSelection),
|
||||
{
|
||||
displayName: 'Move/Rename',
|
||||
icon: FolderOpen,
|
||||
action: () => {
|
||||
// Addressed by the generated path its draft row sits at, but
|
||||
// named by the path typed in the editor.
|
||||
moveDrawer.openDrawer((app as any).draft_path ?? path, summary, 'app', {
|
||||
storagePath: path,
|
||||
rawApp: !!app.raw_app
|
||||
})
|
||||
},
|
||||
disabled: !showEditButton,
|
||||
hide: $userStore?.operator
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash,
|
||||
@@ -221,6 +236,7 @@
|
||||
]
|
||||
}
|
||||
return [
|
||||
...selectMenuItems(rowSelection),
|
||||
{
|
||||
displayName: 'Duplicate/Fork',
|
||||
icon: GitFork,
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import Badge from '../badge/Badge.svelte'
|
||||
import Button from '../button/Button.svelte'
|
||||
import Row from './Row.svelte'
|
||||
import type { RowSelection } from './rowSelection'
|
||||
import { selectMenuItems, type RowSelection } from './rowSelection'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { copyToClipboard, isOwner } from '$lib/utils'
|
||||
import { isDeployable } from '$lib/utils_deployable'
|
||||
@@ -219,6 +219,20 @@
|
||||
const canEdit = flow.canWrite && showEditButton
|
||||
if (draft_only) {
|
||||
return [
|
||||
...selectMenuItems(rowSelection),
|
||||
{
|
||||
displayName: 'Move/Rename',
|
||||
icon: FolderOpen,
|
||||
action: () => {
|
||||
// Addressed by the generated path its draft row sits at, but
|
||||
// named by the path typed in the editor.
|
||||
moveDrawer.openDrawer((flow as any).draft_path ?? path, flow.summary, 'flow', {
|
||||
storagePath: path
|
||||
})
|
||||
},
|
||||
disabled: !showEditButton,
|
||||
hide: $userStore?.operator
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash,
|
||||
@@ -242,6 +256,7 @@
|
||||
]
|
||||
}
|
||||
return [
|
||||
...selectMenuItems(rowSelection),
|
||||
{
|
||||
displayName: 'View runs',
|
||||
icon: List,
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
import Badge from '../badge/Badge.svelte'
|
||||
import Button from '../button/Button.svelte'
|
||||
import Row from './Row.svelte'
|
||||
import type { RowSelection } from './rowSelection'
|
||||
import { selectMenuItems, type RowSelection } from './rowSelection'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { capitalize, copyToClipboard, isOwner } from '$lib/utils'
|
||||
import { isDeployable } from '$lib/utils_deployable'
|
||||
@@ -281,6 +281,7 @@
|
||||
const canEdit = script.canWrite && showEditButton
|
||||
if (script.draft_only) {
|
||||
return [
|
||||
...selectMenuItems(rowSelection),
|
||||
{
|
||||
displayName: 'View code',
|
||||
icon: Code,
|
||||
@@ -288,6 +289,19 @@
|
||||
showCode(script.path, script.summary)
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Move/Rename',
|
||||
icon: FolderOpen,
|
||||
action: () => {
|
||||
// Addressed by the generated path its draft row sits at, but
|
||||
// named by the path typed in the editor.
|
||||
moveDrawer.openDrawer(script.draft_path ?? script.path, script.summary, 'script', {
|
||||
storagePath: script.path
|
||||
})
|
||||
},
|
||||
disabled: !showEditButton,
|
||||
hide: $userStore?.operator
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash,
|
||||
@@ -311,6 +325,7 @@
|
||||
]
|
||||
}
|
||||
return [
|
||||
...selectMenuItems(rowSelection),
|
||||
{
|
||||
displayName: 'View code',
|
||||
icon: Code,
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { SquareCheckBig } from 'lucide-svelte'
|
||||
import type { Item } from '$lib/utils'
|
||||
|
||||
/**
|
||||
* Wiring for a row whose kind icon doubles as a selection control: the icon
|
||||
* swaps to a checkbox on hover, and stays one while a selection is active.
|
||||
@@ -14,3 +17,19 @@ export type RowSelection = {
|
||||
active: boolean
|
||||
onToggle: (e: MouseEvent | KeyboardEvent) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The row menu's way into a selection, for the rows that offer one. The icon
|
||||
* checkbox is the fast path but only appears on hover; this is the one a user
|
||||
* can find by looking.
|
||||
*/
|
||||
export function selectMenuItems(rowSelection: RowSelection | undefined): Item[] {
|
||||
if (!rowSelection) return []
|
||||
return [
|
||||
{
|
||||
displayName: rowSelection.selected ? 'Deselect' : 'Select',
|
||||
icon: SquareCheckBig,
|
||||
action: (e) => rowSelection.onToggle(e)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,9 +1,53 @@
|
||||
import type { Value } from '$lib/utils'
|
||||
|
||||
export type DiffVersionOption = {
|
||||
/** Opaque to the drawer — a script hash, a flow version id, an app version id. */
|
||||
id: string
|
||||
/** Identifies the version: its number and hash. Carries the weight in the list. */
|
||||
label: string
|
||||
/** Who deployed it, and when — rendered under the label in secondary text, so the
|
||||
* version reads first and the attribution second. */
|
||||
subtitle?: string
|
||||
/** The version currently deployed. */
|
||||
isHead?: boolean
|
||||
}
|
||||
|
||||
/** How many deployed versions the diff picker asks for at a time. The history endpoints
|
||||
* still answer whole when nobody asks — the panels and the CLI read them that way — so
|
||||
* this is the picker's own appetite, not their default. */
|
||||
export const VERSION_PAGE_SIZE = 20
|
||||
|
||||
export type DiffDrawerDiff =
|
||||
| {
|
||||
mode: 'normal'
|
||||
deployed: Value
|
||||
/** Names the deployed side, e.g. `Deployed d2154d55 by bob`. The version
|
||||
* identity is stripped from the displayed metadata, so without this the
|
||||
* reader cannot tell what their draft is being compared against. */
|
||||
deployedLabel?: string
|
||||
/** Deployed versions the reader can compare against, newest first. The
|
||||
* drawer only renders the picker; each editor supplies its own list and
|
||||
* fetcher because scripts, flows and apps identify versions differently. */
|
||||
versions?: DiffVersionOption[]
|
||||
/** Loads one version's payload. Returning `undefined` leaves the current
|
||||
* comparison in place rather than blanking the diff. */
|
||||
loadVersion?: (id: string) => Promise<Value | undefined>
|
||||
/** Fetches the next, older page of `versions`. `versions` holds one page so the
|
||||
* drawer opens without waiting on a path a pipeline has deployed thousands of
|
||||
* times; the reader asks for the rest. Returning nothing ends the list. */
|
||||
loadMoreVersions?: () => Promise<DiffVersionOption[] | undefined>
|
||||
/** Moves the draft's base to the head and keeps its content, rendered as a
|
||||
* header action so the user takes the latest with the diff in front of them.
|
||||
* Called with the version the drawer is showing as head (from `versions`), so
|
||||
* the base adopted is the one the reader just looked at. */
|
||||
onTakeLatest?: (head?: string) => void | Promise<void>
|
||||
/** The version the draft forked from. The drawer offers `onTakeLatest` only
|
||||
* while it differs from the head, and passes that head to it. */
|
||||
draftBase?: string
|
||||
/** The version the deployed payload passed here came from, used as the head when
|
||||
* no `versions` list loaded. Without either, the drawer cannot tell whether the
|
||||
* draft is behind and offers nothing. */
|
||||
deployedHead?: string
|
||||
draft?: Value | undefined
|
||||
current: Value
|
||||
defaultDiffType?: 'deployed' | 'draft'
|
||||
@@ -18,7 +62,19 @@ export type DiffDrawerDiff =
|
||||
}
|
||||
|
||||
export interface DiffDrawerI {
|
||||
openDrawer: () => void
|
||||
/** Pass the token from `beginOpening` to continue that opening; called without one,
|
||||
* the drawer claims a fresh opening, so any reuse invalidates a fetch still in
|
||||
* flight rather than being overwritten by it. */
|
||||
openDrawer: (token?: number) => void
|
||||
closeDrawer: () => void
|
||||
setDiff: (diff: DiffDrawerDiff) => void
|
||||
/** Takes the opening's token on the same terms as `openDrawer`. */
|
||||
setDiff: (diff: DiffDrawerDiff, token?: number) => void
|
||||
/** Claim the drawer for one opening. Filling it takes awaited fetches, and a path
|
||||
* change remounts the editor while this drawer stays, so the token lives here: an
|
||||
* editor checks `ownsOpening` before every write and drops the opening it started
|
||||
* when it goes away. */
|
||||
beginOpening: () => number
|
||||
ownsOpening: (token: number) => boolean
|
||||
/** Drop that opening and, when it still owns the drawer, what it put on screen. */
|
||||
abandonOpening: (token: number) => void
|
||||
}
|
||||
|
||||
@@ -10,6 +10,14 @@ import type { WorkspaceItem } from './workspacePicker'
|
||||
|
||||
export type FlowBuilderProps = {
|
||||
initialPath?: string
|
||||
/** The draft row's own path (the URL path). `initialPath` is re-seeded from the
|
||||
* draft's `draft_path` so the topbar shows the pending name, so it can't be used
|
||||
* to resolve what is actually deployed. */
|
||||
userDraftPath?: string
|
||||
/** Moves the draft's base to the head and keeps its content; offered in the diff
|
||||
* drawer while the draft is behind, and called with the version the drawer shows
|
||||
* as head. */
|
||||
onTakeLatest?: (head?: string) => void | Promise<void>
|
||||
pathStoreInit?: string | undefined
|
||||
newFlow: boolean
|
||||
selectedId: string | undefined
|
||||
@@ -24,9 +32,10 @@ export type FlowBuilderProps = {
|
||||
disabledFlowInputs?: boolean
|
||||
savedPrimarySchedule?: ScheduleTrigger | undefined // used to set the primary schedule in the legacy primaryScheduleStore
|
||||
version?: number | undefined
|
||||
/** flow_version the draft was forked from; when set, the deploy-time staleness
|
||||
* check compares it (not the load-time head `version`) against the latest. */
|
||||
draftBaseVersion?: number | undefined
|
||||
/** flow_version the draft was forked from (as text, like `draft_base`); when
|
||||
* set, the deploy-time staleness check compares it (not the load-time head
|
||||
* `version`) against the latest. */
|
||||
draftBaseVersion?: string | undefined
|
||||
draftTriggersFromUrl?: Trigger[] | undefined
|
||||
selectedTriggerIndexFromUrl?: number | undefined
|
||||
children?: import('svelte').Snippet
|
||||
|
||||
@@ -10,13 +10,16 @@
|
||||
import Select from '$lib/components/select/Select.svelte'
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { useReducedMotion } from '$lib/svelte5Utils.svelte'
|
||||
import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
|
||||
import { Archive, ArchiveRestore, FolderInput, MoreHorizontal, Trash, X } from 'lucide-svelte'
|
||||
import { fly } from 'svelte/transition'
|
||||
import {
|
||||
blockedReason,
|
||||
eligible,
|
||||
movedPath,
|
||||
runBulk,
|
||||
sourcePath,
|
||||
type BulkAction,
|
||||
type BulkContext,
|
||||
type BulkOutcome
|
||||
@@ -36,7 +39,17 @@
|
||||
let { selection, workspace, isAdmin, moveTargets, onDone }: Props = $props()
|
||||
|
||||
let ctx: BulkContext = $derived({ workspace, isAdmin })
|
||||
let items = $derived(selection.items)
|
||||
// The bar is still on screen while it flies out, and unticking the last row is
|
||||
// what starts that — by then the live selection is empty. Rendering the last
|
||||
// non-empty one keeps the count and the action states from blinking on the way
|
||||
// out. `keepOnly` only ever removes keys, so acting on the held copy is inert.
|
||||
let lastNonEmpty = $state<BulkItem[]>([])
|
||||
$effect(() => {
|
||||
if (selection.items.length > 0) lastNonEmpty = selection.items
|
||||
})
|
||||
let items = $derived(selection.items.length > 0 ? selection.items : lastNonEmpty)
|
||||
|
||||
let reducedMotion = useReducedMotion()
|
||||
|
||||
const plural = (n: number) => (n === 1 ? '' : 's')
|
||||
const ACTION_LABEL: Record<BulkAction, string> = {
|
||||
@@ -71,9 +84,6 @@
|
||||
|
||||
function actionTitle(action: BulkAction): string {
|
||||
const n = targets(action).length
|
||||
// Selection mode is entered from the toolbar with nothing picked yet, so this
|
||||
// is the state the primary entry point lands on — it has no blocked reason.
|
||||
if (items.length === 0) return `Select items to ${ACTION_LABEL[action].toLowerCase()}`
|
||||
if (n === 0) return `Cannot ${ACTION_LABEL[action].toLowerCase()}: ${blockedSummary(action)}`
|
||||
if (n < items.length) return `${ACTION_LABEL[action]} ${n} of the ${items.length} selected`
|
||||
return `${ACTION_LABEL[action]} ${n} item${plural(n)}`
|
||||
@@ -98,6 +108,11 @@
|
||||
let discardReverts = $derived(pendingItems.filter((i) => !i.draftOnly))
|
||||
|
||||
function open(action: BulkAction) {
|
||||
// Gate on the LIVE selection, not `items`. That holds the last non-empty
|
||||
// copy so the bar doesn't blink to zero while it flies out, and the bar stays
|
||||
// clickable for the whole 200ms outro — so without this a click landing in
|
||||
// that window would open a dialog acting on rows the user just deselected.
|
||||
if (selection.items.length === 0) return
|
||||
if (targets(action).length === 0) return
|
||||
pending = action
|
||||
outcomes = undefined
|
||||
@@ -156,92 +171,99 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 flex items-center gap-2 rounded-md border bg-surface px-3 py-2 shadow-lg"
|
||||
>
|
||||
<span class="text-xs font-semibold text-emphasis whitespace-nowrap">
|
||||
{selection.size} selected
|
||||
</span>
|
||||
<div class="h-4 border-l"></div>
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: FolderInput }}
|
||||
disabled={targets('move').length === 0}
|
||||
title={actionTitle('move')}
|
||||
on:click={() => open('move')}
|
||||
<!-- Centred by the wrapper rather than by a translate on the bar itself: `fly`
|
||||
composes its slide onto whatever transform the element already has, and a
|
||||
captured `-translate-x-1/2` would be stale the moment the bar's width
|
||||
changed mid-animation. -->
|
||||
<div class="fixed bottom-6 inset-x-0 z-40 flex justify-center pointer-events-none">
|
||||
<div
|
||||
transition:fly={{ y: 32, duration: reducedMotion.val ? 0 : 200 }}
|
||||
class="pointer-events-auto flex items-center gap-2 rounded-md border bg-surface px-3 py-2 shadow-lg"
|
||||
>
|
||||
Move{countSuffix('move')}
|
||||
</Button>
|
||||
<!-- Both render when both have targets: a selection mixing archived and active
|
||||
rows would otherwise be a dead end for whichever action lost the toss. With
|
||||
no targets either way, Archive stands alone and disabled, explaining why. -->
|
||||
{#if targets('archive').length > 0 || targets('unarchive').length === 0}
|
||||
<span class="text-xs font-semibold text-emphasis whitespace-nowrap">
|
||||
{items.length} selected
|
||||
</span>
|
||||
<div class="h-4 border-l"></div>
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: Archive }}
|
||||
disabled={targets('archive').length === 0}
|
||||
title={actionTitle('archive')}
|
||||
on:click={() => open('archive')}
|
||||
startIcon={{ icon: FolderInput }}
|
||||
disabled={targets('move').length === 0}
|
||||
title={actionTitle('move')}
|
||||
on:click={() => open('move')}
|
||||
>
|
||||
Archive{countSuffix('archive')}
|
||||
Move{countSuffix('move')}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if targets('unarchive').length > 0}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: ArchiveRestore }}
|
||||
title={actionTitle('unarchive')}
|
||||
on:click={() => open('unarchive')}
|
||||
>
|
||||
Unarchive{countSuffix('unarchive')}
|
||||
</Button>
|
||||
{/if}
|
||||
<DropdownV2
|
||||
placement="top-end"
|
||||
items={[
|
||||
{
|
||||
displayName: `Discard drafts${countSuffix('discard')}`,
|
||||
icon: Trash,
|
||||
action: () => open('discard'),
|
||||
disabled: targets('discard').length === 0,
|
||||
// Only while blocked: a disabled entry can't open its modal, so the reason
|
||||
// has to live here — and an enabled one would render a pointless ⓘ.
|
||||
tooltip: targets('discard').length === 0 ? actionTitle('discard') : undefined
|
||||
},
|
||||
{
|
||||
displayName: `Delete${countSuffix('delete')}`,
|
||||
icon: Trash,
|
||||
type: 'delete' as const,
|
||||
action: () => open('delete'),
|
||||
disabled: targets('delete').length === 0,
|
||||
tooltip: targets('delete').length === 0 ? actionTitle('delete') : undefined
|
||||
}
|
||||
]}
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<!-- Both render when both have targets: a selection mixing archived and active
|
||||
rows would otherwise be a dead end for whichever action lost the toss. With
|
||||
no targets either way, Archive stands alone and disabled, explaining why. -->
|
||||
{#if targets('archive').length > 0 || targets('unarchive').length === 0}
|
||||
<Button
|
||||
nonCaptureEvent
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: MoreHorizontal }}
|
||||
title="More actions"
|
||||
startIcon={{ icon: Archive }}
|
||||
disabled={targets('archive').length === 0}
|
||||
title={actionTitle('archive')}
|
||||
on:click={() => open('archive')}
|
||||
>
|
||||
More
|
||||
Archive{countSuffix('archive')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
<div class="h-4 border-l"></div>
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
iconOnly
|
||||
startIcon={{ icon: X }}
|
||||
title="Cancel selection (Esc)"
|
||||
on:click={() => selection.exit()}
|
||||
/>
|
||||
{/if}
|
||||
{#if targets('unarchive').length > 0}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: ArchiveRestore }}
|
||||
title={actionTitle('unarchive')}
|
||||
on:click={() => open('unarchive')}
|
||||
>
|
||||
Unarchive{countSuffix('unarchive')}
|
||||
</Button>
|
||||
{/if}
|
||||
<DropdownV2
|
||||
placement="top-end"
|
||||
items={[
|
||||
{
|
||||
displayName: `Discard drafts${countSuffix('discard')}`,
|
||||
icon: Trash,
|
||||
action: () => open('discard'),
|
||||
disabled: targets('discard').length === 0,
|
||||
// Only while blocked: a disabled entry can't open its modal, so the reason
|
||||
// has to live here — and an enabled one would render a pointless ⓘ.
|
||||
tooltip: targets('discard').length === 0 ? actionTitle('discard') : undefined
|
||||
},
|
||||
{
|
||||
displayName: `Delete${countSuffix('delete')}`,
|
||||
icon: Trash,
|
||||
type: 'delete' as const,
|
||||
action: () => open('delete'),
|
||||
disabled: targets('delete').length === 0,
|
||||
tooltip: targets('delete').length === 0 ? actionTitle('delete') : undefined
|
||||
}
|
||||
]}
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<Button
|
||||
nonCaptureEvent
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: MoreHorizontal }}
|
||||
title="More actions"
|
||||
>
|
||||
More
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
<div class="h-4 border-l"></div>
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
iconOnly
|
||||
startIcon={{ icon: X }}
|
||||
title="Cancel selection (Esc)"
|
||||
on:click={() => selection.exit()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmationModal
|
||||
@@ -266,7 +288,7 @@
|
||||
{@const target = moveTarget}
|
||||
{@render pathList(
|
||||
'Will be moved to',
|
||||
pendingItems.map((i) => `${i.path} → ${movedPath(i, target)}`)
|
||||
pendingItems.map((i) => `${sourcePath(i)} → ${movedPath(i, target)}`)
|
||||
)}
|
||||
{/if}
|
||||
{:else if pending === 'discard'}
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
import type uFuzzy from '@leeoniya/ufuzzy'
|
||||
import {
|
||||
ArrowDownUp,
|
||||
CheckSquare,
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
Code2,
|
||||
@@ -1805,20 +1804,10 @@
|
||||
{/if}
|
||||
|
||||
{#if !loading && !contentActive && !workspaceEmpty}
|
||||
<!-- List controls, between the kind toggle and the searchbar: select mode, tree
|
||||
view, expand/collapse (tree only), sort. Nothing to select, group or order on
|
||||
an empty workspace, so the whole row goes. -->
|
||||
<!-- List controls, between the kind toggle and the searchbar: tree view,
|
||||
expand/collapse (tree only), sort. Nothing to group or order on an empty
|
||||
workspace, so the whole row goes. -->
|
||||
<div class="flex items-center gap-2">
|
||||
{#if homeSelection.available && !homeSelection.active}
|
||||
<Button
|
||||
startIcon={{ icon: CheckSquare }}
|
||||
iconOnly
|
||||
unifiedSize="xs"
|
||||
variant="default"
|
||||
title="Select items — move, archive, delete or discard several at once"
|
||||
on:click={() => homeSelection.enter()}
|
||||
/>
|
||||
{/if}
|
||||
<Toggle size="xs" bind:checked={treeView} options={{ right: 'Tree view' }} />
|
||||
{#if treeView}
|
||||
<Button
|
||||
|
||||
@@ -56,10 +56,12 @@ describe('blockedReason', () => {
|
||||
expect(blockedReason('unarchive', item({ archived: false }), admin)).toBeDefined()
|
||||
})
|
||||
|
||||
it('routes a draft-only row to discard, never to move/archive/delete', () => {
|
||||
it('lets a draft-only row move or discard, never archive/delete', () => {
|
||||
const draftOnly = item({ draftOnly: true, isDraft: true })
|
||||
expect(blockedReason('discard', draftOnly, admin)).toBeUndefined()
|
||||
expect(blockedReason('move', draftOnly, admin)).toBeDefined()
|
||||
// Moving one rewrites its own draft row (DraftService.moveDraft) — there is
|
||||
// no deployed path, but there is somewhere for it to go.
|
||||
expect(blockedReason('move', draftOnly, admin)).toBeUndefined()
|
||||
expect(blockedReason('archive', draftOnly, admin)).toBeDefined()
|
||||
expect(blockedReason('delete', draftOnly, admin)).toBeDefined()
|
||||
})
|
||||
@@ -75,4 +77,13 @@ describe('movedPath', () => {
|
||||
expect(movedPath(item({ path: 'f/alpha/sub/x' }), 'f/beta')).toBe('f/beta/sub/x')
|
||||
expect(movedPath(item({ path: 'u/ana/x' }), 'f/beta')).toBe('f/beta/x')
|
||||
})
|
||||
|
||||
it('names a draft-only row by what it displays, not the path it is parked at', () => {
|
||||
const parked = item({
|
||||
draftOnly: true,
|
||||
path: 'u/ana/draft_9f3c',
|
||||
displayPath: 'u/ana/my_script'
|
||||
})
|
||||
expect(movedPath(parked, 'f/beta')).toBe('f/beta/my_script')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,11 +8,21 @@
|
||||
* addresses the deployed row. A draft-only item is therefore not deletable —
|
||||
* there is nothing deployed at its path.
|
||||
*/
|
||||
import { AppService, FlowService, ScriptService } from '$lib/gen'
|
||||
import { AppService, DraftService, FlowService, ScriptService } from '$lib/gen'
|
||||
import type { UserDraftItemKind } from '$lib/gen'
|
||||
import { updateItemPathAndSummary } from '$lib/components/moveRenameManager'
|
||||
import { discardDraft } from '$lib/utils_draft_deploy'
|
||||
import type { BulkItem } from './homeSelection.svelte'
|
||||
|
||||
/** The draft overlay is the one place a raw app is its own kind. Narrowed to the
|
||||
* four kinds `moveDraft` accepts — a `BulkItem` is never anything else, and
|
||||
* saying so lets the compiler check that rather than trusting it. */
|
||||
function draftKind(
|
||||
item: BulkItem
|
||||
): Extract<UserDraftItemKind, 'script' | 'flow' | 'app' | 'raw_app'> {
|
||||
return item.kind === 'app' && item.rawApp ? 'raw_app' : item.kind
|
||||
}
|
||||
|
||||
export type BulkAction = 'move' | 'archive' | 'unarchive' | 'delete' | 'discard'
|
||||
|
||||
export type BulkContext = {
|
||||
@@ -32,7 +42,6 @@ export function blockedReason(
|
||||
const notOwner = 'you are not an owner of this path'
|
||||
switch (action) {
|
||||
case 'move':
|
||||
if (item.draftOnly) return 'a draft-only item has no deployed path to move'
|
||||
if (item.archived) return 'archived items cannot be moved'
|
||||
if (!item.owner) return notOwner
|
||||
if (!item.canWrite) return 'you do not have write permission on this path'
|
||||
@@ -64,17 +73,35 @@ export function eligible(action: BulkAction, items: BulkItem[], ctx: BulkContext
|
||||
return items.filter((i) => blockedReason(action, i, ctx) == undefined)
|
||||
}
|
||||
|
||||
/** The path a move reads from. A draft-only item is parked at a generated
|
||||
* storage path but named by what was typed in the editor, and it is that name
|
||||
* the user expects to find under the target. */
|
||||
export function sourcePath(item: BulkItem): string {
|
||||
return item.draftOnly ? item.displayPath : item.path
|
||||
}
|
||||
|
||||
/** Where an item lands under `target` (`f/<folder>` or `u/<user>`): everything
|
||||
* below its own owner prefix is preserved, so nested paths keep their shape. */
|
||||
export function movedPath(item: BulkItem, target: string): string {
|
||||
const rest = item.path.split('/').slice(2).join('/')
|
||||
const rest = sourcePath(item).split('/').slice(2).join('/')
|
||||
return `${target}/${rest}`
|
||||
}
|
||||
|
||||
async function moveItem(ctx: BulkContext, item: BulkItem, target: string): Promise<void> {
|
||||
const newPath = movedPath(item, target)
|
||||
// Re-saving a script at its current path would mint a pointless new version.
|
||||
if (newPath === item.path) return
|
||||
if (newPath === sourcePath(item)) return
|
||||
if (item.draftOnly) {
|
||||
// Nothing is deployed at this path, so there is no deploy to re-run: the
|
||||
// item IS its draft row, and moving it rewrites that row.
|
||||
await DraftService.moveDraft({
|
||||
workspace: ctx.workspace,
|
||||
kind: draftKind(item),
|
||||
path: item.path,
|
||||
requestBody: { new_path: newPath }
|
||||
})
|
||||
return
|
||||
}
|
||||
await updateItemPathAndSummary({
|
||||
workspace: ctx.workspace,
|
||||
kind: item.kind,
|
||||
@@ -117,10 +144,15 @@ async function deleteItem(ctx: BulkContext, item: BulkItem): Promise<void> {
|
||||
}
|
||||
|
||||
async function discardItemDraft(ctx: BulkContext, item: BulkItem): Promise<void> {
|
||||
// The draft overlay is the one place a raw app is its own kind.
|
||||
const kind = item.kind === 'app' && item.rawApp ? 'raw_app' : item.kind
|
||||
// invalidate=false: the caller refreshes the draft list once for the batch.
|
||||
const res = await discardDraft(kind, item.path, ctx.workspace, item.draftOnly, false, false)
|
||||
const res = await discardDraft(
|
||||
draftKind(item),
|
||||
item.path,
|
||||
ctx.workspace,
|
||||
item.draftOnly,
|
||||
false,
|
||||
false
|
||||
)
|
||||
if (!res.success) throw new Error(res.error ?? 'discard failed')
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { HomeSelection, type BulkItem } from './homeSelection.svelte'
|
||||
|
||||
// A shift-click range resolves its ends through the rendered rows, so these need a
|
||||
// document: the keys come back from `[data-row-selection-key]` in visual order.
|
||||
function row(key: string): BulkItem {
|
||||
return {
|
||||
key,
|
||||
kind: 'script',
|
||||
path: key.slice('script/'.length),
|
||||
displayPath: key.slice('script/'.length),
|
||||
summary: '',
|
||||
canWrite: true,
|
||||
owner: true,
|
||||
archived: false,
|
||||
draftOnly: false,
|
||||
isDraft: false,
|
||||
rawApp: false
|
||||
}
|
||||
}
|
||||
|
||||
const a = row('script/f/a/one')
|
||||
const b = row('script/f/a/two')
|
||||
const c = row('script/f/a/three')
|
||||
|
||||
function rendered(...items: BulkItem[]): HomeSelection {
|
||||
document.body.innerHTML = items
|
||||
.map((i) => `<div data-row-selection-key="${i.key}"></div>`)
|
||||
.join('')
|
||||
const s = new HomeSelection()
|
||||
s.available = true
|
||||
for (const i of items) s.register(i)
|
||||
return s
|
||||
}
|
||||
|
||||
describe('HomeSelection shift-range', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('selects the span between the anchor and the clicked row', () => {
|
||||
const s = rendered(a, b, c)
|
||||
s.toggle(a)
|
||||
|
||||
s.toggle(c, true)
|
||||
|
||||
expect(s.items.map((i) => i.key)).toEqual([a.key, b.key, c.key])
|
||||
})
|
||||
|
||||
it('does not reach back into a selection the user already emptied', () => {
|
||||
const s = rendered(a, b, c)
|
||||
s.toggle(a)
|
||||
s.toggle(a)
|
||||
|
||||
s.toggle(c, true)
|
||||
|
||||
expect(s.items.map((i) => i.key)).toEqual([c.key])
|
||||
})
|
||||
})
|
||||
@@ -77,21 +77,16 @@ export class HomeSelection {
|
||||
/** The page offers multi-selection at all (never to an operator, and not on
|
||||
* the embedded read-only variants of the list). */
|
||||
available = $state(false)
|
||||
/** Selection mode is on even with nothing selected yet — entered from the
|
||||
* toolbar, so every row reveals its checkbox before the first pick. */
|
||||
private explicit = $state(false)
|
||||
private selected = new SvelteMap<string, BulkItem>()
|
||||
/** Every rendered selectable row, so a shift-click range can resolve the keys
|
||||
* between the anchor and the clicked row back to items. */
|
||||
private registry = new SvelteMap<string, BulkItem>()
|
||||
private anchor: string | undefined = undefined
|
||||
|
||||
/** Selection mode lasts exactly as long as something is selected: unticking
|
||||
* the last row drops back to the normal list. */
|
||||
get active(): boolean {
|
||||
return this.available && (this.explicit || this.selected.size > 0)
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.selected.size
|
||||
return this.available && this.selected.size > 0
|
||||
}
|
||||
|
||||
get items(): BulkItem[] {
|
||||
@@ -133,12 +128,7 @@ export class HomeSelection {
|
||||
this.registry.delete(key)
|
||||
}
|
||||
|
||||
enter(): void {
|
||||
this.explicit = true
|
||||
}
|
||||
|
||||
exit(): void {
|
||||
this.explicit = false
|
||||
this.selected.clear()
|
||||
this.anchor = undefined
|
||||
}
|
||||
@@ -155,13 +145,15 @@ export class HomeSelection {
|
||||
}
|
||||
|
||||
toggle(item: BulkItem, range = false): void {
|
||||
this.explicit = true
|
||||
if (range && this.anchor != undefined && this.anchor !== item.key) {
|
||||
if (this.selectRange(this.anchor, item.key)) return
|
||||
}
|
||||
if (this.selected.has(item.key)) this.selected.delete(item.key)
|
||||
else this.selected.set(item.key, item)
|
||||
this.anchor = item.key
|
||||
// Unticking the last row ends selection mode, so the row it was on is not an
|
||||
// anchor any more: the next shift-click would otherwise reach back and select
|
||||
// everything between it and the row the user just started from.
|
||||
this.anchor = this.selected.size === 0 ? undefined : item.key
|
||||
}
|
||||
|
||||
/** Visual order is read back from the DOM: the tree nests rows and pages them
|
||||
|
||||
@@ -21,6 +21,11 @@ export async function checkFlowOnBehalfOf(
|
||||
*
|
||||
* Note: on_behalf_of_email is intentionally omitted from flow updates for security
|
||||
* reasons — the backend will redeploy the flow on behalf of the current user.
|
||||
*
|
||||
* `skip_draft_deletion` on every call: this re-deploys the DEPLOYED content at a
|
||||
* new path, so the caller's draft is unrelated work, not the thing being
|
||||
* deployed. Without the flag the backend would delete it. The backend carries
|
||||
* every remaining draft at the old path over to the new one.
|
||||
*/
|
||||
export async function updateItemPathAndSummary(opts: {
|
||||
workspace: string
|
||||
@@ -47,7 +52,8 @@ export async function updateItemPathAndSummary(opts: {
|
||||
dedicated_worker: flow.dedicated_worker,
|
||||
ws_error_handler_muted: flow.ws_error_handler_muted,
|
||||
visible_to_runner_only: flow.visible_to_runner_only,
|
||||
labels
|
||||
labels,
|
||||
skip_draft_deletion: true
|
||||
}
|
||||
})
|
||||
} else if (kind === 'script') {
|
||||
@@ -61,7 +67,8 @@ export async function updateItemPathAndSummary(opts: {
|
||||
lock: script.lock,
|
||||
parent_hash: script.hash,
|
||||
path: newPath,
|
||||
labels
|
||||
labels,
|
||||
skip_draft_deletion: true
|
||||
}
|
||||
})
|
||||
} else if (kind === 'app') {
|
||||
@@ -71,7 +78,8 @@ export async function updateItemPathAndSummary(opts: {
|
||||
requestBody: {
|
||||
path: newPath !== initialPath ? newPath : undefined,
|
||||
summary: newSummary,
|
||||
labels
|
||||
labels,
|
||||
skip_draft_deletion: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -103,8 +103,16 @@
|
||||
| undefined
|
||||
diffDrawer?: DiffDrawer | undefined
|
||||
onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void
|
||||
/** Fired after a successful deploy; the session preview reloads on it. */
|
||||
onDeploy?: (e: { path: string }) => void
|
||||
/** Fired after a successful deploy; the session preview reloads on it and the route
|
||||
* re-pins the draft's fork base. `version` is what this deploy wrote and `head`
|
||||
* what is deployed now: the two differ when another deploy landed beside it. */
|
||||
onDeploy?: (e: {
|
||||
path: string
|
||||
version?: number
|
||||
head?: number
|
||||
headBy?: string
|
||||
headAt?: string
|
||||
}) => void
|
||||
/** Initial collapsed state for the file/runnable sidebar. The user's
|
||||
* toggled preference is persisted under `sidebarStorageKey`; this prop
|
||||
* only seeds the very first open. */
|
||||
@@ -132,6 +140,16 @@
|
||||
pendingDraftPath?: string | undefined
|
||||
// Threaded to the AutosaveIndicator's "Reset to deployed" button.
|
||||
onResetToDeployed?: () => void | Promise<void>
|
||||
/** The app_version the draft forked from, for the deploy-time "new version
|
||||
* deployed" guard: deploying is refused with a confirmation while it is not
|
||||
* the head. The head at load when the draft's base is unknown; undefined for
|
||||
* a draft-only app. */
|
||||
version?: number | undefined
|
||||
/** Moves the draft's base to the deployed head and keeps its content;
|
||||
* offered in the diff drawer while the draft is behind. */
|
||||
onTakeLatest?: (head?: string) => void | Promise<void>
|
||||
/** The app_version the draft forked from, threaded to the topbar's diff drawer. */
|
||||
draftBaseVersion?: string | undefined
|
||||
// See ScriptBuilderProps — same indicator semantics.
|
||||
loadedFromDraft?: boolean
|
||||
othersDraftsCount?: number
|
||||
@@ -203,9 +221,11 @@
|
||||
onScreenshotRequester = undefined,
|
||||
onRestore,
|
||||
onSavedNewAppPath,
|
||||
condensedHeader = false
|
||||
condensedHeader = false,
|
||||
version = undefined,
|
||||
onTakeLatest = undefined,
|
||||
draftBaseVersion = undefined
|
||||
}: Props = $props()
|
||||
export const version: number | undefined = undefined
|
||||
|
||||
// Workspace this editor operates on: the session's acting workspace when
|
||||
// embedded in a session preview (autosaveWorkspace), else the navigation
|
||||
@@ -249,6 +269,12 @@
|
||||
// so the preview just opens the app.
|
||||
setOpenInSessionHandoff({ source: () => sessionOpen })
|
||||
|
||||
let header: RawAppEditorHeader | undefined = $state(undefined)
|
||||
/** The Deployed↔Current diff, for the route's stale-draft prompt. */
|
||||
export function openDiffDrawer() {
|
||||
return header?.openDiffDrawer()
|
||||
}
|
||||
|
||||
/** Hand this app off to a fresh AI session, seeding `seedPrompt` and sending
|
||||
* it on arrival. Exposed for the template picker's "Start in AI session": the
|
||||
* route owns the prompt, but the draft persistence the preview depends on
|
||||
@@ -2272,11 +2298,15 @@
|
||||
/>
|
||||
<div bind:clientWidth={rootWidth} class="max-h-full overflow-hidden h-full min-h-0 flex flex-col">
|
||||
<RawAppEditorHeader
|
||||
bind:this={header}
|
||||
bind:jobs
|
||||
bind:jobsById
|
||||
bind:savedApp
|
||||
bind:summary
|
||||
bind:pendingDraftPath
|
||||
{version}
|
||||
{onTakeLatest}
|
||||
{draftBaseVersion}
|
||||
{onRestore}
|
||||
{onSavedNewAppPath}
|
||||
{policy}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
Undo,
|
||||
WandSparkles
|
||||
} from 'lucide-svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import { onDestroy, untrack } from 'svelte'
|
||||
import { orderedJsonStringify, type Value, replaceFalseWithUndefined } from '../../utils'
|
||||
import { random_adj } from '$lib/components/random_positive_adjetive'
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
import DeploymentHistory from '../apps/editor/DeploymentHistory.svelte'
|
||||
import Awareness from '$lib/components/Awareness.svelte'
|
||||
import type DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
||||
import { VERSION_PAGE_SIZE } from '$lib/components/diff_drawer'
|
||||
|
||||
import EditorHeader from '$lib/components/EditorHeader.svelte'
|
||||
import AutosaveIndicator from '$lib/components/AutosaveIndicator.svelte'
|
||||
@@ -109,6 +110,12 @@
|
||||
}
|
||||
| undefined
|
||||
version?: number | undefined
|
||||
/** Moves the draft's base to the deployed head and keeps its content;
|
||||
* offered in the diff drawer while the draft is behind. */
|
||||
onTakeLatest?: (head?: string) => void | Promise<void>
|
||||
/** The app_version the draft forked from; the drawer offers `onTakeLatest` only
|
||||
* while it differs from the head on display. */
|
||||
draftBaseVersion?: string | undefined
|
||||
newApp: boolean
|
||||
newPath?: string
|
||||
/** Initial labels for the app, threaded from the loaded app data. */
|
||||
@@ -146,8 +153,17 @@
|
||||
* falls back to `$workspaceStore`/`liveEditorDraftStoragePath`. */
|
||||
autosaveWorkspace?: string
|
||||
autosavePath?: string
|
||||
// Fired after a successful deploy; lets the session preview reload.
|
||||
onDeploy?: (e: { path: string }) => void
|
||||
// Fired after a successful deploy; lets the session preview reload. `version` is
|
||||
// what this deploy wrote, for the next draft's fork base; `head` is what is
|
||||
// deployed now, with its author and time, and the two differ when another deploy
|
||||
// landed beside this one.
|
||||
onDeploy?: (e: {
|
||||
path: string
|
||||
version?: number
|
||||
head?: number
|
||||
headBy?: string
|
||||
headAt?: string
|
||||
}) => void
|
||||
/** Surfaces the user-typed path (`newEditedPath`) up to the route
|
||||
* when (and only when) it differs from the deployed/seeded
|
||||
* `savedApp.path`. The route writes it into the autosaved raw-app
|
||||
@@ -178,6 +194,8 @@
|
||||
diffDrawer = undefined,
|
||||
savedApp = $bindable(undefined),
|
||||
version = $bindable(undefined),
|
||||
onTakeLatest = undefined,
|
||||
draftBaseVersion = undefined,
|
||||
newApp,
|
||||
newPath = '',
|
||||
labels: initialLabels = undefined,
|
||||
@@ -388,34 +406,139 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function syncWithDeployed() {
|
||||
async function syncWithDeployed(opening?: number) {
|
||||
const deployedApp = await AppService.getAppByPath({
|
||||
workspace: opWorkspace!,
|
||||
path: appPath!,
|
||||
withStarredInfo: true
|
||||
})
|
||||
|
||||
// A superseded opening must not write these: the current one would then render
|
||||
// and offer Take latest against the older head.
|
||||
if (opening != null && !diffDrawer?.ownsOpening(opening)) return
|
||||
deployedBy = deployedApp.created_by
|
||||
const shownVersions = (deployedApp as { versions?: number[] }).versions
|
||||
deployedVersionShown = Array.isArray(shownVersions)
|
||||
? shownVersions[shownVersions.length - 1]
|
||||
: undefined
|
||||
|
||||
// Normalize away post-deploy noise (see stripRawAppDiffNoise) so the
|
||||
// diff/comparison only reflects what the editor actually changed.
|
||||
deployedValue = replaceFalseWithUndefined(stripRawAppDiffNoise(deployedApp))
|
||||
}
|
||||
|
||||
async function openDiffDrawer() {
|
||||
/** The app_version the payload in `deployedValue` came from, so the picker marks that
|
||||
* one as head rather than trusting the history's first row. */
|
||||
let deployedVersionShown: number | undefined = $state(undefined)
|
||||
|
||||
/** Deployed versions for the diff picker, newest first. Best-effort: losing the
|
||||
* list costs the picker, not the diff. */
|
||||
/** Throws: the drawer says so and lets the reader ask for the same page again. */
|
||||
async function fetchVersionPage(page: number) {
|
||||
if (!opWorkspace || !appPath) return undefined
|
||||
{
|
||||
const history = await AppService.getAppHistoryByPath({
|
||||
workspace: opWorkspace,
|
||||
path: appPath,
|
||||
page,
|
||||
perPage: VERSION_PAGE_SIZE
|
||||
})
|
||||
// Head is the version the payload beside this list came from; see FlowBuilder.
|
||||
const head = deployedVersionShown ?? history[0]?.version
|
||||
// No ordinal: the list arrives a page at a time, so a number counted within one
|
||||
// would rename versions as more load.
|
||||
return history.map((h) => {
|
||||
const detail = [
|
||||
h.created_by,
|
||||
h.created_at ? new Date(h.created_at).toLocaleString() : undefined,
|
||||
h.deployment_msg
|
||||
].filter(Boolean)
|
||||
const isHead = h.version === head
|
||||
return {
|
||||
id: String(h.version),
|
||||
label: `${h.version}${isHead ? ' · latest' : ''}`,
|
||||
subtitle: detail.length ? detail.join(' · ') : undefined,
|
||||
isHead
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Names the deployed side when the picker has no list to label it with — both are
|
||||
* read in `syncWithDeployed`, which succeeds independently of the history. */
|
||||
function deployedVersionLabel(): string | undefined {
|
||||
if (deployedVersionShown == null) return undefined
|
||||
return `Deployed ${deployedVersionShown}${deployedBy ? ` by ${deployedBy}` : ''} · latest`
|
||||
}
|
||||
|
||||
/** The first page, best-effort: losing it costs the picker, not the diff. */
|
||||
async function deployedVersionOptions() {
|
||||
try {
|
||||
return await fetchVersionPage(1)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Hands the drawer the next page each time the reader asks for one. The page number
|
||||
* belongs to this item's history, so it lives here — and only moves once a page has
|
||||
* actually arrived, or a failed request would skip it. */
|
||||
function moreVersionsLoader() {
|
||||
let loaded = 1
|
||||
return async () => {
|
||||
const page = await fetchVersionPage(loaded + 1)
|
||||
loaded += 1
|
||||
return page
|
||||
}
|
||||
}
|
||||
|
||||
/** The opening this editor claimed last. A path change remounts this editor while the
|
||||
* drawer stays mounted, so its teardown hands that opening back rather than leaving
|
||||
* the drawer on the item the user left. */
|
||||
let lastOpening: number | undefined = undefined
|
||||
onDestroy(() => {
|
||||
if (lastOpening != null) diffDrawer?.abandonOpening(lastOpening)
|
||||
})
|
||||
|
||||
export async function openDiffDrawer() {
|
||||
if (!savedApp) {
|
||||
return
|
||||
}
|
||||
// The fetches below are awaited, so a reopen (or a path change, which remounts
|
||||
// this editor but not the drawer) while they run must not have the older one
|
||||
// land last. The drawer counts the openings for that reason.
|
||||
const opening = diffDrawer?.beginOpening()
|
||||
lastOpening = opening
|
||||
if (opening == null) return
|
||||
|
||||
// deployedValue should be syncronized when we open Diff
|
||||
await syncWithDeployed()
|
||||
await syncWithDeployed(opening)
|
||||
|
||||
diffDrawer?.openDrawer()
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'normal',
|
||||
deployed: deployedValue ?? stripRawAppDiffNoise(savedApp),
|
||||
current: currentDiffValue
|
||||
})
|
||||
// Blanking the drawer belongs to the opening that will fill it.
|
||||
if (!diffDrawer?.ownsOpening(opening)) return
|
||||
diffDrawer.openDrawer(opening)
|
||||
const versions = await deployedVersionOptions()
|
||||
if (!diffDrawer?.ownsOpening(opening)) return
|
||||
diffDrawer.setDiff(
|
||||
{
|
||||
mode: 'normal',
|
||||
deployed: deployedValue ?? stripRawAppDiffNoise(savedApp),
|
||||
deployedLabel: deployedVersionLabel(),
|
||||
versions,
|
||||
loadMoreVersions: moreVersionsLoader(),
|
||||
onTakeLatest,
|
||||
draftBase: draftBaseVersion,
|
||||
deployedHead: deployedVersionShown != null ? String(deployedVersionShown) : undefined,
|
||||
loadVersion: async (id) => {
|
||||
const v = await AppService.getAppByVersion({ workspace: opWorkspace!, id: Number(id) })
|
||||
// Same normalization as `syncWithDeployed`, so switching versions doesn't
|
||||
// reintroduce the post-deploy noise the head side already strips.
|
||||
return replaceFalseWithUndefined(stripRawAppDiffNoise(v as any))
|
||||
},
|
||||
current: currentDiffValue
|
||||
},
|
||||
opening
|
||||
)
|
||||
}
|
||||
|
||||
async function updateApp(npath: string) {
|
||||
@@ -428,7 +551,7 @@
|
||||
if (!policy.execution_mode) {
|
||||
policy.execution_mode = 'publisher'
|
||||
}
|
||||
await AppService.updateAppRaw({
|
||||
const deployed = await AppService.updateAppRaw({
|
||||
workspace: opWorkspace!,
|
||||
path: appPath!,
|
||||
formData: {
|
||||
@@ -458,11 +581,14 @@
|
||||
custom_path: customPath,
|
||||
labels: $state.snapshot(labels)
|
||||
}
|
||||
const appHistory = await AppService.getAppHistoryByPath({
|
||||
const head = await AppService.getAppLatestVersion({
|
||||
workspace: opWorkspace!,
|
||||
path: npath
|
||||
})
|
||||
version = appHistory[0]?.version
|
||||
// The deploy's own answer is the next draft's base; `version` is what is deployed
|
||||
// now, and the two differ when another deploy landed beside this one. The route
|
||||
// owns this prop and re-pushes `parentVersion ?? head` as soon as `onDeploy` returns.
|
||||
version = head?.version
|
||||
|
||||
closeSaveDrawer()
|
||||
sendUserToast('App deployed successfully')
|
||||
@@ -477,7 +603,13 @@
|
||||
if (appPath !== npath) {
|
||||
onSavedNewAppPath?.(npath)
|
||||
}
|
||||
onDeploy?.({ path: npath })
|
||||
onDeploy?.({
|
||||
path: npath,
|
||||
version: deployed.version,
|
||||
head: version,
|
||||
headBy: head?.created_by,
|
||||
headAt: head?.created_at
|
||||
})
|
||||
}
|
||||
|
||||
async function setPublishState(message?: string) {
|
||||
@@ -502,6 +634,9 @@
|
||||
}
|
||||
|
||||
let onLatest = $state(true)
|
||||
/** The last comparison could not read the head, so the confirmation it raises is
|
||||
* caution and not an observed deploy. Cleared by the next reading comparison. */
|
||||
let headUnknown = $state(false)
|
||||
async function compareVersions() {
|
||||
if (version === undefined) {
|
||||
return
|
||||
@@ -512,9 +647,14 @@
|
||||
path: appPath
|
||||
})
|
||||
onLatest = appVersion?.version === undefined || version === appVersion?.version
|
||||
headUnknown = false
|
||||
} catch (e) {
|
||||
console.error('Error comparing versions', e)
|
||||
onLatest = true
|
||||
// The head is what this compares against, so an unanswered read is not
|
||||
// evidence of being current: confirm, as an unclaimable deploy does, and say
|
||||
// that is why rather than claiming a version that was never seen.
|
||||
onLatest = false
|
||||
headUnknown = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -607,6 +747,8 @@
|
||||
{confirmCallback}
|
||||
bind:open
|
||||
{diffDrawer}
|
||||
claimOpening={() => (lastOpening = diffDrawer?.beginOpening())}
|
||||
{headUnknown}
|
||||
bind:deployedValue
|
||||
currentValue={currentDiffValue}
|
||||
/>
|
||||
@@ -622,26 +764,34 @@
|
||||
if (!savedApp || newApp) {
|
||||
return
|
||||
}
|
||||
// The other entry point into the same drawer, so it takes an opening too.
|
||||
const opening = diffDrawer?.beginOpening()
|
||||
lastOpening = opening
|
||||
if (opening == null) return
|
||||
// deployedValue should be syncronized when we open Diff
|
||||
await syncWithDeployed()
|
||||
await syncWithDeployed(opening)
|
||||
|
||||
if (!diffDrawer?.ownsOpening(opening)) return
|
||||
saveDrawerOpen = false
|
||||
diffDrawer?.openDrawer()
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'normal',
|
||||
deployed: deployedValue ?? stripRawAppDiffNoise(savedApp),
|
||||
current: currentDiffValue,
|
||||
button: {
|
||||
text: 'Looks good, deploy',
|
||||
onClick: () => {
|
||||
if (newApp || appPath == '') {
|
||||
createApp(newEditedPath)
|
||||
} else {
|
||||
handleUpdateApp(newEditedPath)
|
||||
diffDrawer.openDrawer(opening)
|
||||
diffDrawer.setDiff(
|
||||
{
|
||||
mode: 'normal',
|
||||
deployed: deployedValue ?? stripRawAppDiffNoise(savedApp),
|
||||
current: currentDiffValue,
|
||||
button: {
|
||||
text: 'Looks good, deploy',
|
||||
onClick: () => {
|
||||
if (newApp || appPath == '') {
|
||||
createApp(newEditedPath)
|
||||
} else {
|
||||
handleUpdateApp(newEditedPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
opening
|
||||
)
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-row gap-2 items-center">
|
||||
@@ -674,6 +824,7 @@
|
||||
{setPublishState}
|
||||
{appPath}
|
||||
{onLatest}
|
||||
{headUnknown}
|
||||
{savedApp}
|
||||
rawApp
|
||||
operatingWorkspace={opWorkspace}
|
||||
|
||||
@@ -36,6 +36,13 @@ export interface ScriptBuilderProps {
|
||||
* stop/restart pair is a no-op on a non-live entry.
|
||||
*/
|
||||
userDraftPath?: string
|
||||
/** Moves the draft's base to the head and keeps its content; offered in the diff
|
||||
* drawer while the draft is behind. Called with the version the drawer shows as
|
||||
* head. The route owns it because the base lives in a per-kind field of the value. */
|
||||
onTakeLatest?: (head?: string) => void | Promise<void>
|
||||
/** The hash the draft forked from, so the drawer offers `onTakeLatest` only while
|
||||
* it differs from the head on display. */
|
||||
draftBaseHash?: string
|
||||
/**
|
||||
* Workspace + path the AutosaveIndicator watches for sync state. Default
|
||||
* (undefined) falls back to `$workspaceStore` / `userDraftPath` — the
|
||||
|
||||
@@ -104,6 +104,9 @@
|
||||
cell.store), reactive state owned by the SessionRuntime class (via
|
||||
flowCell), not by a component ancestor — so Svelte's ownership check
|
||||
flags a false positive here. -->
|
||||
<!-- draftBaseVersion / version: the base the session's draft carries and the head at
|
||||
load time, so the deploy guard compares the same pair the full-page editor does;
|
||||
without them every deploy from a session tab reads as up to date. -->
|
||||
<!-- svelte-ignore ownership_invalid_binding -->
|
||||
<FlowBuilder
|
||||
flowStore={cell.store}
|
||||
@@ -115,6 +118,10 @@
|
||||
{selectedId}
|
||||
loading={false}
|
||||
bind:savedFlow={cell.saved.val}
|
||||
draftBaseVersion={cell.store.val?.version_id != null
|
||||
? String(cell.store.val.version_id)
|
||||
: undefined}
|
||||
version={cell.saved.val?.version_id}
|
||||
{diffDrawer}
|
||||
{onNavigate}
|
||||
condensedHeader={true}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user