Compare commits

..
Author SHA1 Message Date
Diego ImbertandClaude Opus 5 b2596c6adc fix: default Path's owner controls to the user acting in its workspace
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 16:08:43 +02:00
Diego ImbertandClaude Opus 5 43ed8c6d56 fix: keep session permissions reactive to the acting role and restore tabs as last seen
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 15:37:25 +02:00
Diego ImbertandClaude Opus 5 f4e8fe4838 fix: judge every session-editor permission by the user acting in that workspace
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 15:21:56 +02:00
Diego Imbert dbe0b49aca Merge remote-tracking branch 'origin/main' into remove-iframe-editors
# Conflicts:
#	frontend/src/lib/components/AIAgentLogViewer.svelte
#	frontend/src/lib/components/ResourceForm.svelte
#	frontend/src/lib/components/raw_apps/RawAppEditor.svelte
#	frontend/src/lib/components/settings/CreateToken.svelte
2026-09-18 15:06:51 +02:00
Diego ImbertandClaude Opus 5 f5d4fdab7d fix: judge trigger permissions by the user acting in the session workspace
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 15:03:02 +02:00
Diego Imbert fffd8b5217 Merge remote-tracking branch 'origin/main' into remove-iframe-editors
# Conflicts:
#	frontend/src/lib/components/flows/conversations/FlowChat.svelte
2026-09-18 14:10:40 +02:00
Diego ImbertandClaude Opus 5 83778f571f feat: close a page item tab from its editor header
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 18:06:57 +02:00
Diego ImbertandClaude Opus 5 6fb48c64df fix: follow a saved page item in the tab's own workspace and restore in-frame rows
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 17:49:39 +02:00
Diego ImbertandClaude Opus 5 09ab9b8e6a fix: show a page item tab whose item is gone instead of mounting its editor
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 17:33:44 +02:00
Diego Imbert ca44af5f5a Merge remote-tracking branch 'origin/main' into remove-iframe-editors 2026-09-17 17:05:16 +02:00
Diego ImbertandClaude Opus 5 a4eb80bd8d fix: guard every component under a session editor against the navigation workspace
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 17:05:15 +02:00
Diego ImbertandClaude Opus 5 34aecb0d29 fix: resolve open in workspace for every preview tab kind in one place
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:49:41 +02:00
Diego ImbertandClaude Opus 5 cc84d07484 fix: reload page item tabs only in sessions acting on the tool's workspace
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:49:41 +02:00
Diego ImbertandClaude Opus 5 acbdb285c9 refactor: one operating-workspace context for editors acting on a session's workspace
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:45:19 +02:00
Diego ImbertandClaude Opus 5 29abd63de6 fix: take a trigger's flow kind from its config and name the written trigger
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:50:04 +02:00
Diego ImbertandClaude Opus 5 9d029c0d44 feat: edit variables, resources and triggers in their own session tab
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:39:22 +02:00
405 changed files with 4628 additions and 8579 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.815.0"
".": "1.814.0"
}
-34
View File
@@ -1,39 +1,5 @@
# Changelog
## [1.815.0](https://github.com/windmill-labs/windmill/compare/v1.814.0...v1.815.0) (2026-09-18)
### Features
* add a workspace toggle that adds its admins and developers to new forks ([#11215](https://github.com/windmill-labs/windmill/issues/11215)) ([9d335de](https://github.com/windmill-labs/windmill/commit/9d335de87a4dbaa51038d55afe8d980761dcdfaf))
* add an instance setting to refuse a token in MCP URLs ([#11162](https://github.com/windmill-labs/windmill/issues/11162)) ([37e493a](https://github.com/windmill-labs/windmill/commit/37e493ae66ed5c000ecac492d60fc0fdf4bda71f))
* **ai-chat:** render get_run through the run tools' card ([#11204](https://github.com/windmill-labs/windmill/issues/11204)) ([6f9c4dc](https://github.com/windmill-labs/windmill/commit/6f9c4dc29455d13b0e64af05c2d6aa8bd5ff4fd6))
* **ai-chat:** tell the chat which kind of app it is looking at ([#11208](https://github.com/windmill-labs/windmill/issues/11208)) ([c8c06d8](https://github.com/windmill-labs/windmill/commit/c8c06d8f79774abf109192e71a8b6fc37c7937ba))
* attach files to a flow chat message ([#11185](https://github.com/windmill-labs/windmill/issues/11185)) ([c4c9677](https://github.com/windmill-labs/windmill/commit/c4c9677982b75c63d98ebf85b1904e0c341ba957))
* **auth:** 2 h login links and a click-to-sign-in page for emailed ones ([#11203](https://github.com/windmill-labs/windmill/issues/11203)) ([5639187](https://github.com/windmill-labs/windmill/commit/5639187fec6d517a72e82df49d63d7438301127c))
* cap user token expiration with an instance setting ([#11159](https://github.com/windmill-labs/windmill/issues/11159)) ([9320312](https://github.com/windmill-labs/windmill/commit/9320312eac56f944c4d31504601293ab4e816ccc))
* flow chat job-backed detail, smooth streaming and answer chrome ([#11186](https://github.com/windmill-labs/windmill/issues/11186)) ([e2a91ca](https://github.com/windmill-labs/windmill/commit/e2a91ca2b141a0bee64ddfafe3cbf3ff86b6fb35))
* put a data table's connection under Postgres roles ([#11020](https://github.com/windmill-labs/windmill/issues/11020)) ([0e807fb](https://github.com/windmill-labs/windmill/commit/0e807fb1dd80d7536ec144cd49445abc7961e504))
* render an AI agent result as its answer, not as raw JSON ([#11051](https://github.com/windmill-labs/windmill/issues/11051)) ([a089928](https://github.com/windmill-labs/windmill/commit/a08992834d45d0211336f4fc32c3421646ca47c5))
* support $flow_expr[...] dynamic tags on flow steps ([#11170](https://github.com/windmill-labs/windmill/issues/11170)) ([48f0025](https://github.com/windmill-labs/windmill/commit/48f00259c5e7361d3553dbcb809461e1cde96f8f))
### Bug Fixes
* **frontend:** inline only the package version, not the whole package.json ([#11191](https://github.com/windmill-labs/windmill/issues/11191)) ([813e486](https://github.com/windmill-labs/windmill/commit/813e486e166ac6215364817a7732b66f8dc1d463))
* keep instance groups when editing auto-invite ([#11217](https://github.com/windmill-labs/windmill/issues/11217)) ([df61dea](https://github.com/windmill-labs/windmill/commit/df61dea5fa8b18d1e0044dc0db6702b053d5119f))
* key the large root font size on screen width, not window width ([#11216](https://github.com/windmill-labs/windmill/issues/11216)) ([3b4e13d](https://github.com/windmill-labs/windmill/commit/3b4e13d1c564c6195e30b55b0671f7533e3ce408))
* re-encrypt git sync secrets on workspace key rotation ([#11218](https://github.com/windmill-labs/windmill/issues/11218)) ([f0d66a4](https://github.com/windmill-labs/windmill/commit/f0d66a42eba27502767423af0c08b2ca1f16e8df))
* re-point cloned fork identities that name nobody in the fork ([#11161](https://github.com/windmill-labs/windmill/issues/11161)) ([9690c44](https://github.com/windmill-labs/windmill/commit/9690c4462cf264a5577b87d07d465b5442b4e09d))
* register the job token with the sensitive log masking system ([#10943](https://github.com/windmill-labs/windmill/issues/10943)) ([53afecd](https://github.com/windmill-labs/windmill/commit/53afecd4588247bc1812d3e68a30db1f3c3b2724))
* show the New menu's description panel only on hover ([#11199](https://github.com/windmill-labs/windmill/issues/11199)) ([ecd0a6c](https://github.com/windmill-labs/windmill/commit/ecd0a6c77bc3a057b8072dbb0aca731e8bd3d882))
* stop picker listing a draft twice when drafts share a friendly path ([#11214](https://github.com/windmill-labs/windmill/issues/11214)) ([72507d5](https://github.com/windmill-labs/windmill/commit/72507d52a021de8101794ee8f4a3392be1ba20c7))
### Performance Improvements
* count completed jobs on the v2_job_completed index with a timeout ([#11211](https://github.com/windmill-labs/windmill/issues/11211)) ([d1a2536](https://github.com/windmill-labs/windmill/commit/d1a25360b070994a31fb033707dbfde1fb553939))
## [1.814.0](https://github.com/windmill-labs/windmill/compare/v1.813.0...v1.814.0) (2026-09-17)
@@ -1,43 +0,0 @@
{
"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"
}
@@ -1,17 +0,0 @@
{
"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"
}
@@ -1,71 +0,0 @@
{
"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"
}
@@ -1,64 +0,0 @@
{
"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"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM draft_move WHERE email = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "11af34e5fe5b8ed59453d58628ed5fb3b2120835158f22d715b73460f6a55108"
}
@@ -1,49 +0,0 @@
{
"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"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url FROM workspace_settings WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "1d8ccd32266637d7f7915f92a8483dce0c9986f3847227c2f66daa84d4109d7d"
}
@@ -1,18 +0,0 @@
{
"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"
}
@@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via)\n SELECT $1, username, email, is_admin, created_at, operator, disabled, role, is_service_account,\n CASE WHEN $3 THEN NULL ELSE added_via END\n FROM usr WHERE workspace_id = $2\n AND (NOT $3 OR (NOT operator AND NOT disabled AND NOT is_service_account))\n ON CONFLICT DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text",
"Bool"
]
},
"nullable": []
},
"hash": "2d6b117324eaf076a0ed06d2cb0ce73279957d6a39fdc6b1ecb6e0a1e02f921f"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT add_admins_and_developers_to_forks FROM workspace_settings WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "add_admins_and_developers_to_forks",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "2f39fce0ee700117f3e4c066e0b56ee979e4c392970278304e80368b770bb7b4"
}
@@ -1,18 +0,0 @@
{
"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"
}
@@ -1,18 +0,0 @@
{
"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"
}
@@ -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, 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!\"",
"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!\"",
"describe": {
"columns": [
{
@@ -55,5 +55,5 @@
null
]
},
"hash": "1c6859291963a9ca38d73f500213a575bfcd4469524f0dc8812470737759f02b"
"hash": "46f00a75b2e7e4ac70758a9687070f68bc0421f1aa228f80157adda63191d33b"
}
@@ -1,15 +0,0 @@
{
"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"
}
@@ -1,72 +0,0 @@
{
"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"
}
@@ -1,18 +0,0 @@
{
"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"
}
@@ -1,43 +0,0 @@
{
"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"
}
@@ -0,0 +1,16 @@
{
"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"
}
@@ -1,17 +0,0 @@
{
"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"
}
@@ -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, 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",
"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",
"describe": {
"columns": [
{
@@ -17,11 +17,6 @@
"ordinal": 2,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "created_by",
"type_info": "Varchar"
}
],
"parameters": {
@@ -33,9 +28,8 @@
"nullable": [
false,
true,
false,
false
]
},
"hash": "148fbee57c6abbb6f361818c5b6bfc8a1db451d31dfddbb5be945b3551c8becb"
"hash": "9a1483a81f5b086e0765d3d69483e29b09f66090e1f9d394564c16d921d2e66c"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET add_admins_and_developers_to_forks = $1 WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Bool",
"Text"
]
},
"nullable": []
},
"hash": "9bd1995747f0073b3a866d1238e78f7e6dfa9185056f7731547ff05b8176b271"
}
@@ -1,18 +0,0 @@
{
"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"
}
@@ -0,0 +1,35 @@
{
"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"
}
@@ -1,85 +0,0 @@
{
"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"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE workspace_settings\n SET\n ai_config = source_ws.ai_config,\n large_file_storage = source_ws.large_file_storage,\n ducklake = source_ws.ducklake,\n dbt_warehouses = source_ws.dbt_warehouses,\n datatable = source_ws.datatable,\n git_app_installations = source_ws.git_app_installations,\n add_admins_and_developers_to_forks = source_ws.add_admins_and_developers_to_forks\n FROM workspace_settings source_ws\n WHERE source_ws.workspace_id = $1\n AND workspace_settings.workspace_id = $2\n ",
"query": "\n UPDATE workspace_settings\n SET\n ai_config = source_ws.ai_config,\n large_file_storage = source_ws.large_file_storage,\n ducklake = source_ws.ducklake,\n dbt_warehouses = source_ws.dbt_warehouses,\n datatable = source_ws.datatable,\n git_app_installations = source_ws.git_app_installations\n FROM workspace_settings source_ws\n WHERE source_ws.workspace_id = $1\n AND workspace_settings.workspace_id = $2\n ",
"describe": {
"columns": [],
"parameters": {
@@ -11,5 +11,5 @@
},
"nullable": []
},
"hash": "5ccfbd0f345b9b86ca356008def6cc1011a49fc8d8ad046ac572e81cf8938995"
"hash": "a6decdebcd9750691f20f874b66a9c6f2ede08c33605c6a3c3dfc213a3eda76a"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE draft_move SET workspace_id = $1 WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "a725d5b99b65a1637406179b058a1ff36620099c6b87fd82a59eae1e35303c07"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via)\n SELECT $1, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via\n FROM usr WHERE workspace_id = $2\n ON CONFLICT DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "b98844926ff127c528ed3e7bc63bf1ebed0192be267983e8fbd18f79997e6142"
}
@@ -1,47 +0,0 @@
{
"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"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n ai_config,\n dbt_warehouses,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n public_app_execution_limit_per_minute,\n error_handler_fallback_to_instance_alerts,\n guest_access_enabled,\n guest_jwt_public_key,\n guest_jwt_jwks_url,\n add_admins_and_developers_to_forks\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n ai_config,\n dbt_warehouses,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n public_app_execution_limit_per_minute,\n error_handler_fallback_to_instance_alerts,\n guest_access_enabled,\n guest_jwt_public_key,\n guest_jwt_jwks_url\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
"describe": {
"columns": [
{
@@ -177,11 +177,6 @@
"ordinal": 34,
"name": "guest_jwt_jwks_url",
"type_info": "Text"
},
{
"ordinal": 35,
"name": "add_admins_and_developers_to_forks",
"type_info": "Bool"
}
],
"parameters": {
@@ -224,9 +219,8 @@
false,
false,
true,
true,
false
true
]
},
"hash": "8ebe054b41793f1a7b85f1f8d29cd21d12fa1207b58bfc46249c250cdcdb5363"
"hash": "dc4a57df3becc610f631ef22c116450390addbfae85fecc61c991d94167e6e99"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE draft_move SET email = $1 WHERE email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "e428f0cdb9077bffe2b7271d9b5f30ed81e8c26a9f1e65f423078947153ff9dd"
}
@@ -1,16 +0,0 @@
{
"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"
}
@@ -1,59 +0,0 @@
{
"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"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n slack_name,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n mute_critical_alerts,\n guest_access_enabled,\n add_admins_and_developers_to_forks,\n deploy_ui,\n large_file_storage,\n datatable\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n slack_name,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n mute_critical_alerts,\n guest_access_enabled,\n deploy_ui,\n large_file_storage,\n datatable\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
"describe": {
"columns": [
{
@@ -45,21 +45,16 @@
},
{
"ordinal": 8,
"name": "add_admins_and_developers_to_forks",
"type_info": "Bool"
},
{
"ordinal": 9,
"name": "deploy_ui",
"type_info": "Jsonb"
},
{
"ordinal": 10,
"ordinal": 9,
"name": "large_file_storage",
"type_info": "Jsonb"
},
{
"ordinal": 11,
"ordinal": 10,
"name": "datatable",
"type_info": "Jsonb"
}
@@ -78,11 +73,10 @@
true,
true,
false,
false,
true,
true,
true
]
},
"hash": "e6e31fdf705896c81f9a0f45d47c1b93db0406aaf278bccba07be00e9f937e2a"
"hash": "ede15bff96152f209aff756830cbc76b5afa1af6ed324376989117b1054c3447"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks FROM workspace_settings WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "eefa0588a6a927fd9b3f65e1df652fb2b4cf7983049d2c490940df360c7e2b30"
}
@@ -1,41 +0,0 @@
{
"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"
}
@@ -1,31 +0,0 @@
{
"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"
}
+85 -85
View File
@@ -2311,9 +2311,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.4.7"
version = "1.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313"
checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -5055,9 +5055,9 @@ dependencies = [
[[package]]
name = "find-msvc-tools"
version = "0.1.13"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b"
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
[[package]]
name = "fixedbitset"
@@ -5497,9 +5497,9 @@ dependencies = [
[[package]]
name = "generator"
version = "0.8.10"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54ade96dc9003043bce7c035c85a9df5a858bfb2039c5a2e6fdf00f324f6c551"
checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae"
dependencies = [
"cc",
"cfg-if",
@@ -14793,7 +14793,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-nats",
@@ -14881,7 +14881,7 @@ dependencies = [
[[package]]
name = "windmill-ai"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"async-stream",
"async-trait",
@@ -14915,7 +14915,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14928,7 +14928,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"argon2",
@@ -15068,7 +15068,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15091,7 +15091,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15108,7 +15108,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15134,7 +15134,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -15144,7 +15144,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15161,7 +15161,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"base64 0.22.1",
@@ -15183,7 +15183,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15206,7 +15206,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15222,7 +15222,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15244,7 +15244,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15266,7 +15266,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15280,7 +15280,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-nats",
@@ -15315,7 +15315,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15340,7 +15340,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15368,7 +15368,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15390,7 +15390,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15410,7 +15410,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15448,7 +15448,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15477,7 +15477,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"lazy_static",
"serde",
@@ -15489,7 +15489,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"argon2",
"axum 0.8.9",
@@ -15513,7 +15513,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15527,7 +15527,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15562,7 +15562,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"chrono",
"lazy_static",
@@ -15576,7 +15576,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15595,7 +15595,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -15702,7 +15702,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"chrono",
"futures",
@@ -15722,7 +15722,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"regex",
"serde",
@@ -15739,7 +15739,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -15766,7 +15766,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"futures",
@@ -15783,7 +15783,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -15799,7 +15799,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15820,7 +15820,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15851,7 +15851,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -15876,7 +15876,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-stream",
@@ -15911,7 +15911,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"futures",
@@ -15929,7 +15929,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -15938,7 +15938,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15950,7 +15950,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"serde_json",
@@ -15962,7 +15962,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"gosyn",
@@ -15974,7 +15974,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15986,7 +15986,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"serde_json",
@@ -15998,7 +15998,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -16009,7 +16009,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16020,7 +16020,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16032,7 +16032,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -16043,7 +16043,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16065,7 +16065,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"serde_json",
@@ -16077,7 +16077,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16091,7 +16091,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -16108,7 +16108,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16121,7 +16121,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"serde",
@@ -16133,7 +16133,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16151,7 +16151,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -16167,7 +16167,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -16183,7 +16183,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16197,7 +16197,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16236,7 +16236,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"const_format",
@@ -16276,7 +16276,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -16287,7 +16287,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16322,7 +16322,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16346,7 +16346,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16379,7 +16379,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-amqp"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16406,7 +16406,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-azure"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16439,7 +16439,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16459,7 +16459,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16493,7 +16493,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16529,7 +16529,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16552,7 +16552,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16576,7 +16576,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-nats",
@@ -16600,7 +16600,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16635,7 +16635,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16663,7 +16663,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16688,7 +16688,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"bitflags 2.13.2",
@@ -16707,7 +16707,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-once-cell",
@@ -16825,7 +16825,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"bytes",
"futures",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.815.0"
version = "1.814.0"
authors.workspace = true
edition.workspace = true
@@ -88,7 +88,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
[workspace.package]
version = "1.815.0"
version = "1.814.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
bc3ef08c8e4233508c023e6ee847a3cd0b8be43b
f2fced19fcae81de7f6dac545010ce404c052e1b
@@ -1 +0,0 @@
ALTER TABLE draft DROP COLUMN base;
@@ -1,14 +0,0 @@
-- 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;
@@ -1 +0,0 @@
DROP TABLE draft_move;
@@ -1,17 +0,0 @@
-- 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 +0,0 @@
ALTER TABLE workspace_settings DROP COLUMN add_admins_and_developers_to_forks;
@@ -1 +0,0 @@
ALTER TABLE workspace_settings ADD COLUMN add_admins_and_developers_to_forks BOOLEAN NOT NULL DEFAULT false;
+24 -24
View File
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6274,7 +6274,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"proc-macro2",
"quote",
@@ -6286,7 +6286,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"convert_case",
"serde",
@@ -6295,7 +6295,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6307,7 +6307,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6319,7 +6319,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"gosyn",
@@ -6331,7 +6331,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6343,7 +6343,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6355,7 +6355,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -6366,7 +6366,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6377,7 +6377,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6389,7 +6389,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6400,7 +6400,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -6422,7 +6422,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6434,7 +6434,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6448,7 +6448,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"convert_case",
@@ -6465,7 +6465,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6478,7 +6478,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"serde",
@@ -6490,7 +6490,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6508,7 +6508,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6524,7 +6524,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6540,7 +6540,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6572,7 +6572,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6586,7 +6586,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.815.0"
version = "1.814.0"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+2 -7
View File
@@ -52,8 +52,7 @@ use windmill_common::{
INSTANCE_EVENTS_WEBHOOK_SETTING, INSTANCE_PYTHON_VERSION_SETTING,
JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING,
KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MAVEN_REPOS_SETTING, MAVEN_SETTINGS_XML_SETTING,
MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING,
NO_DEFAULT_MAVEN_SETTING,
MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NO_DEFAULT_MAVEN_SETTING,
NPM_CONFIG_REGISTRY_SETTING, NSJAIL_TMPFS_SIZE_MB_SETTING, NSJAIL_TMP_BACKING_SETTING,
NUGET_CONFIG_SETTING, OAUTH_SETTING, OTEL_SETTING, OTEL_TRACES_RETENTION_SECS_SETTING,
OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING,
@@ -127,8 +126,7 @@ use windmill_worker::{
use crate::monitor::{
initial_load, load_concurrency_key_max_queued, load_disable_password_login,
load_fork_workspace_tag_append_fork_suffix, load_keep_job_dir,
load_mcp_disable_token_query_param, load_metrics_debug_enabled,
load_fork_workspace_tag_append_fork_suffix, load_keep_job_dir, load_metrics_debug_enabled,
load_preview_tags_override, load_require_preexisting_user, load_retention_period_overrides,
load_tag_per_workspace_enabled, load_tag_per_workspace_workspaces,
load_workspace_fairness_duration_secs, load_workspace_fairness_enabled,
@@ -2166,9 +2164,6 @@ async fn process_notify_event(
DISABLE_PASSWORD_LOGIN_SETTING => {
load_disable_password_login(db).await;
}
MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING => {
load_mcp_disable_token_query_param(db).await;
}
EXPOSE_METRICS_SETTING => {
tracing::info!("Metrics setting changed, restarting");
spawn_graceful_killpill(tx, db, 30, "metrics setting change", server_mode)
-27
View File
@@ -65,7 +65,6 @@ use windmill_common::{
FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING, HUB_API_SECRET_SETTING,
HUB_BASE_URL_SETTING, INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING,
JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING,
MCP_DISABLE_TOKEN_QUERY_PARAM, MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING,
MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NPMRC_SETTING, NPM_CONFIG_REGISTRY_SETTING,
NSJAIL_TMPFS_SIZE_MB_SETTING, NSJAIL_TMP_BACKING_SETTING, NUGET_CONFIG_SETTING,
OTEL_SETTING, OTEL_TRACES_RETENTION_SECS_SETTING, OTEL_TRACING_PROXY_SETTING,
@@ -289,15 +288,6 @@ pub async fn initial_load(
);
if let Some(db) = conn.as_sql() {
// Outside the `server_mode` block below: a `MODE=mcp` process serves the MCP routes
// with `server_mode` false and would otherwise never read this at all. That mode
// joins no monitor loop, so there — as for every global setting, `base_url`
// included — this pass is the only read, and a change lands on restart.
pass.setting(
MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING,
false,
|v| async move { apply_mcp_disable_token_query_param(v) },
);
pass.setting(DEFAULT_TAGS_PER_WORKSPACE_SETTING, false, |v| async move {
apply_tag_per_workspace_enabled(v)
});
@@ -1627,23 +1617,6 @@ pub fn apply_disable_password_login(value: Option<serde_json::Value>) {
};
}
pub async fn load_mcp_disable_token_query_param(db: &DB) {
match load_value_from_global_settings(db, MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING).await {
Ok(v) => apply_mcp_disable_token_query_param(v),
Err(e) => tracing::error!("Error loading mcp_disable_token_query_param setting: {e:#}"),
};
}
pub fn apply_mcp_disable_token_query_param(value: Option<serde_json::Value>) {
match value {
Some(serde_json::Value::Bool(t)) => {
MCP_DISABLE_TOKEN_QUERY_PARAM.store(t, Ordering::Relaxed)
}
None => MCP_DISABLE_TOKEN_QUERY_PARAM.store(false, Ordering::Relaxed),
_ => (),
};
}
struct LogFile {
file_path: String,
hostname: String,
+1 -1
View File
@@ -234,7 +234,7 @@ workspace_protection_rule: workspace_id(char), name(char), rules(int), bypass_gr
FK: (workspace_id) -> workspace(id)
workspace_runnable_dependencies: flow_path(char), runnable_path(char), script_hash(bigint), runnable_is_flow(bool), workspace_id(char), app_path(char), id(bigint), runnable_is_agent(bool)
FK: (app_path, workspace_id) -> app(path, workspace_id) | (flow_path, workspace_id) -> flow(path, workspace_id)
workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb), guest_access_enabled(bool), guest_jwt_public_key(text), guest_jwt_jwks_url(text), ai_sessions_backup_generation(int), add_admins_and_developers_to_forks(bool)
workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb), guest_access_enabled(bool), guest_jwt_public_key(text), guest_jwt_jwks_url(text), ai_sessions_backup_generation(int)
FK: (workspace_id) -> workspace(id)
zombie_job_counter: job_id(uuid), counter(int)
FK: (job_id) -> v2_job(id)
-193
View File
@@ -1,193 +0,0 @@
//! 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(())
}
+4 -2
View File
@@ -521,8 +521,10 @@ def main():
.await
.unwrap();
let deployed: serde_json::Value = res.json().await.unwrap();
assert_eq!(deployed["path"], "f/rel/root_app_renamed", "{deployed}");
assert_eq!(
res.text().await.unwrap(),
"app f/rel/root_app updated (npath: \"f/rel/root_app_renamed\")"
);
let mut completed = listen_for_completed_jobs(&db).await;
in_test_worker(&db, completed.next(), port).await;
-74
View File
@@ -1,74 +0,0 @@
//! 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(())
}
-250
View File
@@ -1,250 +0,0 @@
//! 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(())
}
-43
View File
@@ -78,46 +78,3 @@ 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(())
}
-587
View File
@@ -1,587 +0,0 @@
//! 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
View File
@@ -1,16 +0,0 @@
-- 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
View File
@@ -1,37 +0,0 @@
-- 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');
-29
View File
@@ -1,29 +0,0 @@
-- 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');
-108
View File
@@ -1,108 +0,0 @@
/*
* The job's own token (`$WM_TOKEN`) stays valid well past the job it was minted
* for, and job logs are persisted to `job_logs` and optionally to object storage,
* so a script that echoes the token would otherwise park a live credential in
* durable storage. `run_worker` registers the token with `sensitive_log_masks`
* for the job it pulled; this pins that the persisted log carries the masked form.
*/
use sqlx::{Pool, Postgres};
use windmill_common::{
jobs::{JobPayload, RawCode},
scripts::ScriptLang,
};
use windmill_test_utils::*;
/// Prefix of a serialized job token: `jwt_` plus the base64 of a JWT header.
/// The masked form keeps only `jwt` + the last three characters, so it never matches.
const RAW_TOKEN_PREFIX: &str = "jwt_ey";
#[sqlx::test(fixtures("base"))]
async fn test_job_token_masked_in_persisted_logs(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let job = RunJob::from(JobPayload::Code(RawCode {
hash: None,
content: "echo \"running with --token $WM_TOKEN\"".to_string(),
path: None,
lock: None,
language: ScriptLang::Bash,
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
modules: None,
tag: None,
}))
.run_until_complete(&db, false, port)
.await;
assert!(job.success, "job should have succeeded");
let logs =
sqlx::query_scalar::<_, Option<String>>("SELECT logs FROM job_logs WHERE job_id = $1")
.bind(job.id)
.fetch_one(&db)
.await?
.unwrap_or_default();
assert!(
!logs.contains(RAW_TOKEN_PREFIX),
"an unmasked job token reached the persisted logs: {logs}"
);
assert!(
logs.contains("secret value was masked"),
"expected the masking notice in logs: {logs}"
);
Ok(())
}
/// nativets runs V8 in-process and persists `console.log` output through its own
/// channel, so it is masked by a different mechanism than the bash case above and
/// needs its own guard.
#[cfg(feature = "deno_core")]
#[sqlx::test(fixtures("base"))]
async fn test_job_token_masked_in_nativets_logs(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let job = RunJob::from(JobPayload::Code(RawCode {
hash: None,
content: "export async function main() {\n console.log('running with --token ' + process.env.WM_TOKEN);\n return 'ok';\n}".to_string(),
path: None,
lock: None,
language: ScriptLang::Nativets,
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
modules: None,
tag: None,
}))
.run_until_complete(&db, false, port)
.await;
assert!(job.success, "job should have succeeded");
let logs =
sqlx::query_scalar::<_, Option<String>>("SELECT logs FROM job_logs WHERE job_id = $1")
.bind(job.id)
.fetch_one(&db)
.await?
.unwrap_or_default();
assert!(
!logs.contains(RAW_TOKEN_PREFIX),
"an unmasked job token reached the persisted logs: {logs}"
);
assert!(
logs.contains("secret value was masked"),
"expected the masking notice in logs: {logs}"
);
Ok(())
}
+6 -42
View File
@@ -51,10 +51,7 @@ use windmill_common::{
jobs::JobPayload,
schedule::Schedule,
triggers::MovedNativeTrigger,
utils::{
http_get_from_hub, not_found_if_none, paginate, paginate_optional, Pagination,
RunnableKind, StripPath,
},
utils::{http_get_from_hub, not_found_if_none, paginate, Pagination, RunnableKind, StripPath},
};
use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap;
use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
@@ -750,14 +747,6 @@ 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,
@@ -926,36 +915,25 @@ 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, flow_version.created_by, deployment_metadata.deployment_msg FROM flow_version
"SELECT flow_version.id, flow_version.created_at, 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
LIMIT $3 OFFSET $4",
WHERE flow_version.path = $1 AND flow_version.workspace_id = $2
ORDER BY flow_version.created_at DESC",
path,
w_id,
per_page,
offset,
w_id
)
.fetch_all(&mut *tx)
.await?;
@@ -975,7 +953,7 @@ async fn get_latest_version(
let version = sqlx::query_as!(
FlowVersion,
"SELECT flow_version.id, flow_version.created_at, flow_version.created_by, deployment_metadata.deployment_msg FROM flow_version
"SELECT flow_version.id, flow_version.created_at, 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",
@@ -1460,20 +1438,6 @@ 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,18 +407,9 @@ 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?,
"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"
"kept"
);
assert_eq!(
text("SELECT value->>'permissioned_as' FROM draft WHERE workspace_id = 'wm-fork-repoint' AND typ = 'trigger_websocket'").await?,
@@ -1,75 +0,0 @@
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
/// With `add_admins_and_developers_to_forks` on, a fork starts with the parent's admins and
/// developers at their parent role, even when a developer forks it; operators are left out. The
/// copies are manual members: a parent membership that came from an instance group must not carry
/// that provenance into a fork that does not configure the group.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_fork_adds_parent_admins_and_developers(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base_url = format!(
"http://localhost:{}/api/w/test-workspace/workspaces",
server.addr.port()
);
let client = reqwest::Client::new();
sqlx::query(
"UPDATE usr SET operator = true WHERE workspace_id = 'test-workspace' AND username = 'test-user-3'",
)
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO usr (workspace_id, email, username, is_admin, added_via)
VALUES ('test-workspace', 'test4@windmill.dev', 'test-user-4', false,
'{\"source\": \"instance_group\", \"group\": \"devs\"}')",
)
.execute(&db)
.await?;
let resp = client
.post(format!(
"{base_url}/edit_add_admins_and_developers_to_forks"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "add_admins_and_developers_to_forks": true }))
.send()
.await?;
assert!(
resp.status().is_success(),
"enabling the setting: {}",
resp.text().await?
);
let resp = client
.post(format!("{base_url}/create_fork"))
.header("Authorization", "Bearer SECRET_TOKEN_2")
.json(&json!({ "id": "wm-fork-team", "name": "Team fork" }))
.send()
.await?;
assert!(
resp.status().is_success(),
"creating the fork: {}",
resp.text().await?
);
let members: Vec<(String, bool, bool)> = sqlx::query_as(
"SELECT username, is_admin, added_via IS NULL FROM usr
WHERE workspace_id = 'wm-fork-team' ORDER BY username",
)
.fetch_all(&db)
.await?;
assert_eq!(
members,
vec![
("test-user".to_string(), true, true),
("test-user-2".to_string(), false, true),
("test-user-4".to_string(), false, true),
]
);
Ok(())
}
@@ -1,102 +0,0 @@
//! The `mcp_disable_token_query_param` switch closes the URL-borne credential path.
//!
//! The rejection is a middleware layered between the `WWW-Authenticate` decorator and
//! everything that reads a token, on both the workspaced and the gateway mount. Each half of
//! that sandwich is pinned: the `WWW-Authenticate` header on the refusal catches the layer
//! being moved outward (a client would lose the pointer that starts OAuth discovery), and
//! refusing a token that was never valid catches it being moved inward past authentication
//! (the URL-borne token would be hashed and looked up before anything refused it).
#![cfg(feature = "mcp")]
use std::sync::atomic::Ordering;
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_common::global_settings::MCP_DISABLE_TOKEN_QUERY_PARAM;
use windmill_test_utils::*;
/// Workspace-less with an `mcp:` scope, which is what the gateway mount requires; the
/// workspaced mount takes its workspace from the path, so one token reaches both.
async fn insert_mcp_token(db: &Pool<Postgres>) -> anyhow::Result<()> {
sqlx::query(
"INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes)
VALUES (encode(sha256('MCP_TOKEN'::bytea), 'hex'), 'MCP_TOK', 'MCP_TOKEN', 'test@windmill.dev', 'mcp token', true, ARRAY['mcp:all'])",
)
.execute(db)
.await?;
Ok(())
}
/// A token that is not in `token` at all. Authentication would refuse it on its own, so a
/// refusal carrying the middleware's own wording is evidence nothing looked it up first.
const BOGUS_TOKEN: &str = "NOT_A_REAL_TOKEN";
async fn tools_list(url: &str) -> anyhow::Result<reqwest::Response> {
Ok(reqwest::Client::new()
.post(url)
.header("Accept", "application/json, text/event-stream")
.json(&json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }))
.send()
.await?)
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_mcp_token_query_param_switch(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
set_jwt_secret().await;
insert_mcp_token(&db).await?;
let server = ApiServer::start_mcp(db.clone()).await?;
let port = server.addr.port();
let workspaced =
format!("http://localhost:{port}/api/mcp/w/test-workspace/mcp?token=MCP_TOKEN");
let gateway = format!("http://localhost:{port}/api/mcp/gateway?token=MCP_TOKEN");
assert_eq!(
tools_list(&workspaced).await?.status(),
200,
"a URL-borne token is the documented default and must keep working while the switch is off"
);
assert_eq!(tools_list(&gateway).await?.status(), 200);
MCP_DISABLE_TOKEN_QUERY_PARAM.store(true, Ordering::Relaxed);
for url in [&workspaced, &gateway] {
let resp = tools_list(url).await?;
assert_eq!(
resp.status(),
401,
"{url} still admitted a token in the URL"
);
// What sends the client into the OAuth flow rather than leaving it stuck on a 401.
assert!(
resp.headers().contains_key("www-authenticate"),
"{url} rejected without pointing at the authorization server"
);
}
// Refused before authentication, not after: an invalid token gets the middleware's own
// message rather than the generic 401 that looking it up would produce.
let resp = tools_list(&format!(
"http://localhost:{port}/api/mcp/w/test-workspace/mcp?token={BOGUS_TOKEN}"
))
.await?;
assert_eq!(resp.status(), 401);
assert!(
resp.text().await?.contains("does not accept a token in the MCP URL"),
"an invalid URL token was answered by authentication, so the token was read before \
the switch refused it"
);
// The header stays open: it is the channel the OAuth flow itself hands tokens over on.
let resp = reqwest::Client::new()
.post(format!("http://localhost:{port}/api/mcp/gateway"))
.header("Accept", "application/json, text/event-stream")
.header("Authorization", "Bearer MCP_TOKEN")
.json(&json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }))
.send()
.await?;
assert_eq!(resp.status(), 200);
Ok(())
}
@@ -275,67 +275,6 @@ async fn test_encryption_key_rotation_dispatches_batched_git_sync(
Ok(())
}
/// Stored repository tokens and webhook secrets are encrypted under the
/// workspace key but never synced, so a rotation has to carry them over even
/// when the caller skips re-encrypting variables.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_encryption_key_rotation_reencrypts_git_sync_secrets(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
use windmill_common::variables::{build_crypt, crypt_from_key_with_suffix, decrypt, encrypt};
initialize_tracing().await;
create_folder(&db, "28103").await?;
create_git_repo_resource(&db).await?;
let sync_script_path = "f/28103/test_sync_script_git_secrets";
create_sync_script(&db, sync_script_path).await?;
setup_git_sync_config(&db, sync_script_path).await?;
let mc = build_crypt(&db, "test-workspace").await?;
sqlx::query(
r#"
UPDATE workspace_settings SET
git_credentials = jsonb_build_array(jsonb_build_object(
'token', $1::text, 'repo_identity', 'https://gitlab.example.com/grp/proj')),
git_sync = jsonb_set(git_sync, '{repositories,0,auto_pull}', jsonb_build_object(
'enabled', true, 'mode', 'webhook', 'webhook_id', 1, 'webhook_secret', $2::text))
WHERE workspace_id = 'test-workspace'
"#,
)
.bind(encrypt(&mc, "stored-token"))
.bind(encrypt(&mc, "hook-secret"))
.execute(&db)
.await?;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
let new_key = "c".repeat(64);
let resp = authed(client().post(format!("{base}/encryption_key")))
.json(&json!({"new_key": new_key, "skip_reencrypt": true}))
.send()
.await?;
assert_eq!(
resp.status(),
200,
"set_encryption_key failed: {}",
resp.text().await?
);
let (token, secret): (String, String) = sqlx::query_as(
"SELECT git_credentials->0->>'token', git_sync#>>'{repositories,0,auto_pull,webhook_secret}'
FROM workspace_settings WHERE workspace_id = 'test-workspace'",
)
.fetch_one(&db)
.await?;
let new_mc = crypt_from_key_with_suffix(&new_key, "");
assert_eq!(decrypt(&new_mc, token)?, "stored-token");
assert_eq!(decrypt(&new_mc, secret)?, "hook-secret");
Ok(())
}
/// Regression test for the non-debouncing fallback: a workspace whose sync
/// script predates hub version 28103 must still receive git-sync jobs for the
/// encryption_key entry and every re-encrypted secret. Before the fallback was
+16 -52
View File
@@ -55,7 +55,7 @@ use windmill_common::{
min_version_supports_runnable_settings_v0, RunnableSettings, RunnableSettingsTrait,
},
scripts::{hash_script, ScriptRunnableSettingsHandle, ScriptRunnableSettingsInline},
utils::{paginate_optional, paginate_without_limits, WarnAfterExt},
utils::{paginate_without_limits, WarnAfterExt},
worker::CLOUD_HOSTED,
};
use windmill_object_store::upload_artifact_to_store;
@@ -2312,20 +2312,6 @@ 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,
@@ -2403,32 +2389,19 @@ 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,
@@ -3088,24 +3061,17 @@ 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, s.created_by as created_by
"SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at
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 $3 OFFSET $4",
ORDER by s.created_at DESC",
w_id,
path,
per_page,
offset,
)
.fetch_all(&mut *tx)
.await?;
@@ -3117,7 +3083,6 @@ 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));
@@ -3132,7 +3097,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, s.created_by as created_by
"SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at
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",
@@ -3148,7 +3113,6 @@ 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 {
+4 -9
View File
@@ -64,11 +64,10 @@ use windmill_common::{
GITHUB_APP_WEBHOOK_BASE_URL_SETTING, HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING,
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING,
INSTANCE_BANNER_SETTING, MAX_RETENTION_OVERRIDE_WORKSPACES,
MAX_TOKEN_EXPIRATION_DAYS_SETTING, MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING,
RETENTION_PERIOD_SECS_OVERRIDES_SETTING, RUFF_CONFIG_SETTING, UNIQUE_ID_SETTING,
WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, WORKSPACE_FAIRNESS_ENABLED_SETTING,
WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING,
WS_BASE_URL_SETTING,
MAX_TOKEN_EXPIRATION_DAYS_SETTING, RETENTION_PERIOD_SECS_OVERRIDES_SETTING,
RUFF_CONFIG_SETTING, UNIQUE_ID_SETTING, WORKSPACE_FAIRNESS_DURATION_SECS_SETTING,
WORKSPACE_FAIRNESS_ENABLED_SETTING, WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING,
WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING, WS_BASE_URL_SETTING,
},
instance_config::{self, ApplyMode, InstanceConfig},
server::Smtp,
@@ -1365,10 +1364,6 @@ pub async fn get_global_setting(
&& key != INSTANCE_BANNER_SETTING
// The token form reads it to stop offering expirations the server would shorten.
&& key != MAX_TOKEN_EXPIRATION_DAYS_SETTING
// Whoever is wiring up an MCP client reads it to know whether a URL-borne token
// would be refused, and they are usually not a superadmin. Not a secret: pointing
// any MCP client at the instance discovers the same answer.
&& key != MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING
{
require_super_admin(&db, &authed).await?;
}
+24 -171
View File
@@ -38,7 +38,7 @@ use windmill_common::global_settings::HTTP_ROUTE_WORKSPACED_ROUTE;
use windmill_common::query_builders::{render_db_quoted_identifier, DbType};
use windmill_common::users::username_to_permissioned_as;
use windmill_common::variables::{
crypt_from_key_with_suffix, decrypt, encrypt, WORKSPACE_CRYPT_CACHE,
build_crypt, decrypt, encrypt, SECRET_SALT, WORKSPACE_CRYPT_CACHE,
};
use windmill_common::worker::{to_raw_value, CLOUD_HOSTED};
use windmill_common::workspaces::GitRepositorySettings;
@@ -155,10 +155,6 @@ pub fn workspaced_service() -> Router {
.route("/edit_deploy_ui_config", post(edit_deploy_ui_config))
.route("/edit_default_app", post(edit_default_app))
.route("/edit_guest_access", post(edit_guest_access))
.route(
"/edit_add_admins_and_developers_to_forks",
post(edit_add_admins_and_developers_to_forks),
)
.route("/edit_guest_jwt_key", post(edit_guest_jwt_key))
.route("/guest_usage", get(get_guest_usage))
.route("/default_app", get(get_default_app))
@@ -342,7 +338,6 @@ pub struct WorkspaceSettings {
pub guest_jwt_public_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guest_jwt_jwks_url: Option<String>,
pub add_admins_and_developers_to_forks: bool,
}
/// Subset of `WorkspaceSettings` that is safe to return to any workspace
@@ -368,8 +363,6 @@ pub struct WorkspacePublicSettings {
/// Not sensitive, and the app editor needs it to say whether the guest rung is
/// live -- an app can be set to `guest` while the workspace has guests off.
pub guest_access_enabled: bool,
/// Read by the fork dialog, which tells the forker who else the fork will include.
pub add_admins_and_developers_to_forks: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub deploy_ui: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -1133,8 +1126,7 @@ async fn get_settings(
error_handler_fallback_to_instance_alerts,
guest_access_enabled,
guest_jwt_public_key,
guest_jwt_jwks_url,
add_admins_and_developers_to_forks
guest_jwt_jwks_url
FROM
workspace_settings
WHERE
@@ -1176,7 +1168,6 @@ async fn get_public_settings(
teams_team_guid,
mute_critical_alerts,
guest_access_enabled,
add_admins_and_developers_to_forks,
deploy_ui,
large_file_storage,
datatable
@@ -5061,47 +5052,6 @@ async fn edit_guest_access(
))
}
#[derive(Deserialize)]
struct EditAddAdminsAndDevelopersToForks {
add_admins_and_developers_to_forks: bool,
}
async fn edit_add_admins_and_developers_to_forks(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(EditAddAdminsAndDevelopersToForks { add_admins_and_developers_to_forks }): Json<
EditAddAdminsAndDevelopersToForks,
>,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
let mut tx = db.begin().await?;
sqlx::query!(
"UPDATE workspace_settings SET add_admins_and_developers_to_forks = $1 WHERE workspace_id = $2",
add_admins_and_developers_to_forks,
&w_id
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
"workspaces.edit_add_admins_and_developers_to_forks",
ActionKind::Update,
&w_id,
Some(&add_admins_and_developers_to_forks.to_string()),
None,
)
.await?;
tx.commit().await?;
Ok(format!(
"Adding admins and developers to new forks set to {add_admins_and_developers_to_forks} for workspace {w_id}"
))
}
#[derive(Deserialize)]
struct EditGuestJwtKey {
/// A PEM public key (RS or ES family), or a JWKS URL, at most one. Both empty clears the
@@ -5690,6 +5640,9 @@ async fn set_encryption_key(
));
}
// Build the previous cipher before the transaction (reads from cache/pool)
let previous_encryption_key = build_crypt(&db, w_id.as_str()).await?;
let mut tx = db.begin().await?;
// Under the row's lock, so two rotations racing serialize and each sees the key the
@@ -5723,14 +5676,17 @@ async fn set_encryption_key(
None
};
// From the keys read and written under the lock, never from `build_crypt`: its
// cache can still hold a key an earlier rotation replaced, and the git-sync
// secrets below are skipped rather than failed when they do not decrypt.
let previous_encryption_key = crypt_from_key_with_suffix(&previous_key, "");
let new_encryption_key = crypt_from_key_with_suffix(&request.new_key, "");
let mut reencrypted_secret_paths: Vec<String> = Vec::new();
if !request.skip_reencrypt.unwrap_or(false) {
// Build the new cipher directly from the key string, since the transaction
// hasn't committed yet and build_crypt() would read the old key from the pool.
let crypt_key = if let Some(ref salt) = SECRET_SALT.as_ref() {
format!("{}{}", request.new_key, salt)
} else {
request.new_key.clone()
};
let new_encryption_key = magic_crypt::new_magic_crypt!(crypt_key, 256);
let mut truncated_new_key = request.new_key.clone();
truncated_new_key.truncate(8);
tracing::warn!(
@@ -5770,14 +5726,6 @@ async fn set_encryption_key(
}
}
reencrypt_git_sync_secrets(
&mut tx,
&w_id,
&previous_encryption_key,
&new_encryption_key,
)
.await?;
tx.commit().await?;
// Invalidate the cache only after the transaction has committed
@@ -5815,64 +5763,6 @@ async fn set_encryption_key(
return Ok(());
}
/// Move the git-sync secrets the server keeps under the workspace key (stored
/// repository tokens, webhook secrets) to the new key. They are never synced, so
/// unlike variables they are still under the old key when the caller skips
/// re-encryption.
async fn reencrypt_git_sync_secrets(
conn: &mut sqlx::PgConnection,
w_id: &str,
old: &magic_crypt::MagicCrypt256,
new: &magic_crypt::MagicCrypt256,
) -> Result<()> {
let Some((mut credentials, mut git_sync)) =
sqlx::query_as::<_, (serde_json::Value, Option<serde_json::Value>)>(
"SELECT git_credentials, git_sync FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE",
)
.bind(w_id)
.fetch_optional(&mut *conn)
.await?
else {
return Ok(());
};
let reencrypt = |value: &mut serde_json::Value| {
let Some(ciphertext) = value.as_str() else {
return;
};
match decrypt(old, ciphertext.to_string()) {
Ok(plain) => *value = serde_json::Value::String(encrypt(new, &plain)),
// Left by an earlier rotation and unrecoverable either way; failing here
// would block every later rotation of the workspace.
Err(e) => tracing::warn!(
"a git-sync secret of workspace {w_id} does not decrypt under its current key, leaving it as is: {e}"
),
}
};
for entry in credentials.as_array_mut().into_iter().flatten() {
if let Some(token) = entry.get_mut("token") {
reencrypt(token);
}
}
let repositories = git_sync
.as_mut()
.and_then(|g| g.get_mut("repositories"))
.and_then(|r| r.as_array_mut());
for repo in repositories.into_iter().flatten() {
if let Some(secret) = repo.pointer_mut("/auto_pull/webhook_secret") {
reencrypt(secret);
}
}
sqlx::query(
"UPDATE workspace_settings SET git_credentials = $2, git_sync = $3 WHERE workspace_id = $1",
)
.bind(w_id)
.bind(credentials)
.bind(git_sync)
.execute(&mut *conn)
.await?;
Ok(())
}
#[derive(Serialize)]
struct UsedTriggers {
pub websocket_used: bool,
@@ -6822,8 +6712,7 @@ async fn update_workspace_settings(
ducklake = source_ws.ducklake,
dbt_warehouses = source_ws.dbt_warehouses,
datatable = source_ws.datatable,
git_app_installations = source_ws.git_app_installations,
add_admins_and_developers_to_forks = source_ws.add_admins_and_developers_to_forks
git_app_installations = source_ws.git_app_installations
FROM workspace_settings source_ws
WHERE source_ws.workspace_id = $1
AND workspace_settings.workspace_id = $2
@@ -6964,21 +6853,14 @@ async fn copy_workspace_members(
tx: &mut Transaction<'_, Postgres>,
source_workspace_id: &str,
target_workspace_id: &str,
admins_and_developers_only: bool,
) -> Result<()> {
// Admins and developers join as manual members: the fork does not inherit the source's
// instance-group config, so a copied `instance_group` provenance would let the fork's
// reconciliation delete them and their data.
sqlx::query!(
"INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via)
SELECT $1, username, email, is_admin, created_at, operator, disabled, role, is_service_account,
CASE WHEN $3 THEN NULL ELSE added_via END
SELECT $1, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via
FROM usr WHERE workspace_id = $2
AND (NOT $3 OR (NOT operator AND NOT disabled AND NOT is_service_account))
ON CONFLICT DO NOTHING",
target_workspace_id,
source_workspace_id,
admins_and_developers_only,
)
.execute(&mut **tx)
.await?;
@@ -7961,32 +7843,14 @@ 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!(
// 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)
"INSERT INTO draft (workspace_id, path, typ, value, created_at, email)
SELECT $2, path, typ,
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"#,
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)",
source_workspace_id,
target_workspace_id,
authed_email,
@@ -8787,19 +8651,8 @@ async fn create_workspace_fork(
// intended. Dev creation is already admin-gated, so this is transitively admin-only too. Done before
// the explicit creator insert below so the creator (a parent member) is copied with full metadata
// (operator/role/is_service_account/added_via), not the bare row the insert alone would leave.
// Independently, the parent's admins can have every fork of it start with its admins and
// developers; the forker cannot opt out, since the point is that those admins can review it.
if nw.copy_members && nw.is_dev_workspace {
copy_workspace_members(&mut tx, &parent_workspace_id, &forked_id, false).await?;
} else if sqlx::query_scalar!(
"SELECT add_admins_and_developers_to_forks FROM workspace_settings WHERE workspace_id = $1",
parent_workspace_id
)
.fetch_optional(&mut *tx)
.await?
.unwrap_or(false)
{
copy_workspace_members(&mut tx, &parent_workspace_id, &forked_id, true).await?;
copy_workspace_members(&mut tx, &parent_workspace_id, &forked_id).await?;
}
// Ensure the creator is a member of the fork even without copy_members (or if they aren't a parent
@@ -113,7 +113,7 @@ pub(crate) async fn change_workspace_id(
// Duplicate workspace settings (keep copy in old workspace for reference)
info!("Duplicating workspace_settings table");
sqlx::query!(
"INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks FROM workspace_settings WHERE workspace_id = $2",
"INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url FROM workspace_settings WHERE workspace_id = $2",
&rw.new_id,
&old_id
)
@@ -382,13 +382,6 @@ 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!(
+7 -127
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.815.0
version: 1.814.0
title: Windmill API
contact:
@@ -4090,13 +4090,9 @@ paths:
guest_access_enabled:
type: boolean
description: Whether this workspace admits guest sessions. An app's own `guest` execution mode is inert while this is false.
add_admins_and_developers_to_forks:
type: boolean
description: Whether every new fork of this workspace starts with its admins and developers as members, keeping their role.
required:
- workspace_id
- guest_access_enabled
- add_admins_and_developers_to_forks
/w/{workspace}/workspaces/get_settings:
get:
@@ -4187,9 +4183,6 @@ paths:
guest_jwt_jwks_url:
type: string
description: JWKS URL a guest JWT (`jwt_guest_`) is verified against for this workspace. Mutually exclusive with `guest_jwt_public_key`.
add_admins_and_developers_to_forks:
type: boolean
description: Whether every new fork of this workspace starts with its admins and developers as members, keeping their role.
/w/{workspace}/workspaces/get_deploy_to:
get:
@@ -6320,39 +6313,6 @@ paths:
schema:
type: string
/w/{workspace}/workspaces/edit_add_admins_and_developers_to_forks:
post:
summary: choose whether new forks of this workspace start with its admins and developers
description: >-
When on, every fork created from this workspace gets the workspace's admins and
developers as members, with the role they hold here; operators, disabled users and
service accounts are left out. The setting is copied into each fork, so forks of a
fork follow it too. Off by default. Workspace-admin gated.
operationId: editAddAdminsAndDevelopersToForks
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description: Whether new forks start with this workspace's admins and developers
required: true
content:
application/json:
schema:
type: object
properties:
add_admins_and_developers_to_forks:
type: boolean
required:
- add_admins_and_developers_to_forks
responses:
"200":
description: status
content:
text/plain:
schema:
type: string
/w/{workspace}/workspaces/edit_guest_jwt_key:
post:
summary: set the key guest JWTs are verified against for this workspace
@@ -10392,49 +10352,8 @@ 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)
@@ -10945,8 +10864,6 @@ paths:
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/ScriptPath"
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PerPage"
responses:
"200":
description: script history
@@ -12352,8 +12269,6 @@ paths:
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/ScriptPath"
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PerPage"
tags:
- flow
responses:
@@ -14232,8 +14147,6 @@ paths:
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/ScriptPath"
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PerPage"
responses:
"200":
description: app history
@@ -14501,9 +14414,9 @@ paths:
"200":
description: app updated
content:
application/json:
text/plain:
schema:
$ref: "#/components/schemas/AppDeployed"
type: string
/w/{workspace}/apps/create_raw_source:
post:
@@ -14643,9 +14556,9 @@ paths:
"200":
description: app updated
content:
application/json:
text/plain:
schema:
$ref: "#/components/schemas/AppDeployed"
type: string
/w/{workspace}/apps/update_raw/{path}:
post:
@@ -14699,9 +14612,9 @@ paths:
"200":
description: app updated
content:
application/json:
text/plain:
schema:
$ref: "#/components/schemas/AppDeployed"
type: string
/w/{workspace}/apps/custom_path_exists/{custom_path}:
get:
@@ -27749,13 +27662,6 @@ 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:
@@ -29651,11 +29557,6 @@ components:
type: string
deployment_msg:
type: string
created_at:
type: string
format: date-time
created_by:
type: string
required:
- script_hash
@@ -35552,20 +35453,6 @@ 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:
@@ -35573,11 +35460,6 @@ components:
type: integer
deployment_msg:
type: string
created_at:
type: string
format: date-time
created_by:
type: string
required:
- version
@@ -35647,8 +35529,6 @@ components:
format: date-time
deployment_msg:
type: string
created_by:
type: string
required:
- id
- created_at
+17 -91
View File
@@ -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, paginate_optional,
query_elems_from_hub, require_admin, strip_json_nul, Pagination, RunnableKind, StripPath,
http_get_from_hub, not_found_if_none, paginate, 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,12 +274,6 @@ 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)]
@@ -573,17 +567,6 @@ 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>,
@@ -1237,42 +1220,18 @@ 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,
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
"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 page.ord DESC",
ORDER BY created_at DESC",
w_id,
path,
per_page,
offset,
)
.fetch_all(&mut *tx)
.await?;
).fetch_all(&mut *tx).await?;
tx.commit().await?;
let result: Vec<AppHistory> = query_result
@@ -1281,8 +1240,6 @@ 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));
@@ -1296,22 +1253,14 @@ 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,
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",
"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",
w_id,
path,
)
.fetch_optional(&mut *tx)
.await?;
).fetch_optional(&mut *tx).await?;
tx.commit().await?;
if let Some(row) = row {
@@ -1319,8 +1268,6 @@ 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)));
@@ -2644,14 +2591,6 @@ 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)
@@ -3008,7 +2947,7 @@ async fn update_app(
Extension(webhook): Extension<WebhookShared>,
Path((w_id, path)): Path<(String, StripPath)>,
Json(ns): Json<EditApp>,
) -> JsonResult<AppDeployed> {
) -> Result<String> {
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot update apps for security reasons".to_string(),
@@ -3047,7 +2986,7 @@ async fn update_app(
},
);
Ok(Json(AppDeployed { path: npath, version: v_id }))
Ok(format!("app {} updated (npath: {:?})", opath, npath))
}
/// Deploy a raw app from its sources, compiling them on a worker. `update_raw`
@@ -3061,7 +3000,7 @@ async fn update_app_raw_source(
Extension(webhook): Extension<WebhookShared>,
Path((w_id, path)): Path<(String, StripPath)>,
Json(ns): Json<EditApp>,
) -> JsonResult<AppDeployed> {
) -> Result<String> {
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot update apps for security reasons".to_string(),
@@ -3159,7 +3098,7 @@ async fn update_app_raw_source(
},
);
Ok(Json(AppDeployed { path: npath, version: v_id }))
Ok(format!("app {} updated (npath: {:?})", opath, npath))
}
/// Whether the caller may create an app at `path` — asked of the database rather
@@ -3385,7 +3324,7 @@ async fn update_app_raw<'a>(
Extension(webhook): Extension<WebhookShared>,
Path((w_id, path)): Path<(String, StripPath)>,
multipart: Multipart,
) -> JsonResult<AppDeployed> {
) -> Result<String> {
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot update apps for security reasons".to_string(),
@@ -3433,7 +3372,7 @@ async fn update_app_raw<'a>(
},
);
Ok(Json(AppDeployed { path: npath, version: v_id }))
Ok(format!("app {} updated (npath: {:?})", opath, npath))
}
// async fn create_app_internal<'a>(
// authed: ApiAuthed,
@@ -3810,19 +3749,6 @@ 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,
+22 -468
View File
@@ -19,7 +19,7 @@ use windmill_common::{
error::{Error, Result},
user_drafts::{DraftUserRef, UserDraftItemKind, ENCRYPTED_DRAFT_PREFIX},
users::resolve_username_to_email,
utils::{check_proper_path, strip_json_nul},
utils::strip_json_nul,
variables::{build_crypt, encrypt},
};
@@ -29,7 +29,6 @@ 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))
}
@@ -321,71 +320,12 @@ 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>,
}
/// 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.
/// 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.
async fn update_draft(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -394,7 +334,7 @@ async fn update_draft(
Json(req): Json<SaveDraftRequest>,
) -> Result<Json<SaveDraftResponse>> {
let email = &authed.email;
let url_path = path.to_path();
let 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
@@ -403,68 +343,11 @@ 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 {
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?,
}
require_can_write_path(&authed, &db, &user_db, &w_id, kind, path).await?;
}
let applied = if let Some(value) = &req.value {
let applied_at = 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 {
@@ -476,49 +359,19 @@ 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.
//
// 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)
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()))
ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL
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
DO UPDATE SET value = EXCLUDED.value, created_at = EXCLUDED.created_at
WHERE $7::bool = true
OR $6::timestamptz IS NULL
OR draft.created_at <= $6::timestamptz
RETURNING path, created_at"#,
RETURNING created_at"#,
&w_id,
email,
path,
@@ -527,13 +380,9 @@ 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) —
@@ -554,20 +403,16 @@ async fn update_draft(
kind as UserDraftItemKind,
req.last_sync,
req.force,
legacy_delete,
req.legacy,
)
.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, path)) = applied {
if let Some(ts) = applied_at {
return Ok(Json(SaveDraftResponse {
status: SaveDraftStatus::Saved,
current_timestamp: ts,
path,
}));
}
@@ -583,7 +428,7 @@ async fn update_draft(
email,
path,
kind as UserDraftItemKind,
legacy_delete,
req.legacy,
)
.fetch_optional(&db)
.await?;
@@ -592,307 +437,20 @@ 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 {
@@ -946,12 +504,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, base
RETURNING value
)
INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base)
SELECT $1, $4, $2, $3, value, now(), base FROM legacy
INSERT INTO draft (workspace_id, email, path, typ, value, created_at)
SELECT $1, $4, $2, $3, value, now() FROM legacy
ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL
DO UPDATE SET value = EXCLUDED.value, created_at = now(), base = EXCLUDED.base
DO UPDATE SET value = EXCLUDED.value, created_at = now()
RETURNING 1 as "one!""#,
&w_id,
path,
@@ -1209,8 +767,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}`, a group in `authed.groups`, or any
/// folder in `authed.folders` (the precomputed read set, with groups + direct
/// 2. Path-prefix match against own `u/{username}` 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.
@@ -1235,10 +793,6 @@ 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) {
+3 -5
View File
@@ -560,7 +560,7 @@ pub async fn run_server(
if server_mode || mcp_mode {
use mcp::{
add_www_authenticate_header, add_www_authenticate_header_gateway,
extract_workspace_from_token, reject_token_query_param,
extract_workspace_from_token,
};
let (mcp_router, mcp_cancellation_token) = setup_mcp_server(
db.clone(),
@@ -573,17 +573,15 @@ pub async fn run_server(
let workspaced_mcp_router = mcp_router
.clone()
.route_layer(from_extractor::<ApiAuthed>())
.layer(axum::middleware::from_fn(reject_token_query_param))
.layer(axum::middleware::from_fn(add_www_authenticate_header))
.layer(axum::middleware::from_fn(extract_and_store_workspace_id));
// Gateway MCP router — resolves workspace from token
let gateway_mcp_router = mcp_router
.route_layer(from_extractor::<ApiAuthed>())
.layer(axum::middleware::from_fn(extract_workspace_from_token))
.layer(axum::middleware::from_fn(reject_token_query_param))
.layer(axum::middleware::from_fn(
add_www_authenticate_header_gateway,
));
))
.layer(axum::middleware::from_fn(extract_workspace_from_token));
(
workspaced_mcp_router,
gateway_mcp_router,
+2 -28
View File
@@ -43,14 +43,11 @@ use axum::{
extract::{Extension, Path},
http::Request,
middleware::Next,
response::{IntoResponse, Response},
response::Response,
routing::get,
Json, Router,
};
use windmill_common::{
auth::hash_token, db::GatewayWorkspaceId, error::JsonResult,
global_settings::MCP_DISABLE_TOKEN_QUERY_PARAM,
};
use windmill_common::{auth::hash_token, db::GatewayWorkspaceId, error::JsonResult};
// McpAuth impl for ApiAuthed is in windmill-api-auth (same crate as the type)
@@ -449,29 +446,6 @@ pub async fn add_www_authenticate_header(
}
}
/// Middleware refusing a credential carried in the MCP URL once the instance sets
/// `mcp_disable_token_query_param`. Sits outside everything that reads the token, so neither
/// the gateway lookup nor `ApiAuthed` ever sees it, and inside the `WWW-Authenticate` layer,
/// whose header is what sends the client into the OAuth flow instead. Refused rather than
/// ignored: the URL leaked the token whether or not the request used it.
pub async fn reject_token_query_param(request: Request<axum::body::Body>, next: Next) -> Response {
let carries_token = MCP_DISABLE_TOKEN_QUERY_PARAM.load(std::sync::atomic::Ordering::Relaxed)
&& request
.uri()
.query()
.is_some_and(|q| url::form_urlencoded::parse(q.as_bytes()).any(|(k, _)| k == "token"));
if carries_token {
return (
axum::http::StatusCode::UNAUTHORIZED,
"This instance does not accept a token in the MCP URL. Remove the token query \
parameter and let your client sign in through OAuth, or send the token in an \
Authorization header.",
)
.into_response();
}
next.run(request).await
}
/// Extract the bearer token from either the `Authorization` header or the
/// `?token=` query parameter (MCP clients commonly pass it in the URL).
fn extract_gateway_token(request: &Request<axum::body::Body>) -> Option<String> {
+1 -1
View File
@@ -12,5 +12,5 @@ pub mod oauth_server;
pub use core::{
add_www_authenticate_header, add_www_authenticate_header_gateway,
extract_and_store_workspace_id, extract_workspace_from_token, list_tools_service,
reject_token_query_param, setup_mcp_server,
setup_mcp_server,
};
@@ -102,10 +102,6 @@ pub const HUB_API_SECRET_SETTING: &str = "hub_api_secret";
pub const AUTOMATE_USERNAME_CREATION_SETTING: &str = "automate_username_creation";
pub const DISABLE_WORKSPACE_INVITE_EMAILS_SETTING: &str = "disable_workspace_invite_emails";
pub const DISABLE_PASSWORD_LOGIN_SETTING: &str = "disable_password_login";
/// Refuse `?token=` on the MCP endpoints, leaving the `Authorization` header as the only way
/// in. A URL-borne credential ends up in browser history, proxy logs and referrers, so an
/// instance that cares sends MCP clients through the OAuth flow instead.
pub const MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING: &str = "mcp_disable_token_query_param";
/// Ceiling, in days, on how far ahead a token minted through `POST /users/tokens/create` or
/// `POST /users/tokens/impersonate` may expire; a request asking for more, or for no
/// expiration at all, is shortened to it rather than refused. On those routes only: server-side
@@ -411,7 +407,6 @@ use std::sync::atomic::AtomicBool;
lazy_static::lazy_static! {
pub static ref HTTP_ROUTE_WORKSPACED_ROUTE: AtomicBool = AtomicBool::new(false);
pub static ref DISABLE_PASSWORD_LOGIN: AtomicBool = AtomicBool::new(false);
pub static ref MCP_DISABLE_TOKEN_QUERY_PARAM: AtomicBool = AtomicBool::new(false);
/// Origins HTTP routes allow cross-origin when they configure none of their
/// own. Empty means unset, which keeps the historical `*`.
pub static ref HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS: arc_swap::ArcSwap<Vec<String>> =
@@ -10,7 +10,7 @@
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, RwLock};
use std::sync::RwLock;
use uuid::Uuid;
/// Minimum length for a secret to be registered for masking.
@@ -20,27 +20,9 @@ const MIN_SECRET_LENGTH: usize = 8;
const MASKED_NOTICE: &str =
"[windmill] secret value was masked for security reasons, use string transformations to display full value";
/// The secrets registered for one job, plus the automaton compiled from them.
#[derive(Default)]
struct JobMasks {
secrets: HashSet<String>,
/// Built on the first `snapshot` after a change and shared by every later
/// snapshot. Every job registers at least its own token, so without this
/// cache each log batch of each job would rebuild the automaton.
compiled: Option<Arc<CompiledMasks>>,
}
/// Aho-Corasick automaton for O(m) multi-pattern matching in a single pass,
/// regardless of the number of secrets registered, with the replacement
/// strings indexed to match the automaton's pattern order.
struct CompiledMasks {
ac: aho_corasick::AhoCorasick,
replacements: Vec<String>,
}
lazy_static::lazy_static! {
/// Map of job_id -> secret values that should be masked in that job's logs.
static ref SENSITIVE_MASKS: RwLock<HashMap<Uuid, JobMasks>> =
/// Map of job_id -> set of secret values that should be masked in that job's logs.
static ref SENSITIVE_MASKS: RwLock<HashMap<Uuid, HashSet<String>>> =
RwLock::new(HashMap::new());
/// Set of currently running job IDs on this worker process.
@@ -50,8 +32,13 @@ lazy_static::lazy_static! {
}
/// A lock-free snapshot of secrets for a job, taken once per log batch.
/// Uses Aho-Corasick for O(m) multi-pattern matching in a single pass,
/// regardless of the number of secrets registered.
pub struct MaskSnapshot {
compiled: Arc<CompiledMasks>,
/// Aho-Corasick automaton for fast matching.
ac: aho_corasick::AhoCorasick,
/// Replacement strings, indexed to match the automaton's pattern order.
replacements: Vec<String>,
/// Whether the security notice has already been appended for this snapshot.
/// Tracked locally to avoid a global write lock on every masked line.
notice_shown: std::cell::Cell<bool>,
@@ -66,104 +53,34 @@ impl MaskSnapshot {
}
// Single-pass check + replace using the pre-built automaton
if !self.compiled.ac.is_match(text) {
if !self.ac.is_match(text) {
return Cow::Borrowed(text);
}
let mut result = self
.compiled
.ac
.replace_all(text, &self.compiled.replacements);
let mut result = self.ac.replace_all(text, &self.replacements);
// Append the notice only once per snapshot (i.e. per batch), as its own line.
// Callers pass either a bare line (`handle_child`) or a chunk that already ends
// in a newline (nativets), and the sinks concatenate what they get verbatim:
// assuming either shape welds the notice onto a neighbouring line.
// Append the notice only once per snapshot (i.e. per batch)
if !self.notice_shown.get() {
self.notice_shown.set(true);
if result.ends_with('\n') {
result.push_str(MASKED_NOTICE);
result.push('\n');
} else {
result.push('\n');
result.push_str(MASKED_NOTICE);
}
result.push('\n');
result.push_str(MASKED_NOTICE);
}
Cow::Owned(result)
}
}
/// A masker for sinks that mask line by line rather than in batches, like nativets
/// masking each `console.log` chunk as V8 produces it. `snapshot` per line would
/// re-arm the security notice on every one; this keeps it to once per distinct set
/// of secrets while still picking up secrets registered mid-run.
///
/// Masks by job id alone — the caller is the one that knows the text it passes
/// belongs to that job.
pub struct JobMasker {
job_id: Uuid,
snapshot: Option<MaskSnapshot>,
}
impl JobMasker {
pub fn new(job_id: Uuid) -> Self {
JobMasker { job_id, snapshot: snapshot(&job_id) }
}
/// Mask every secret registered for the job. Returns `Cow::Borrowed` when no match.
/// Falls back to the masks it last saw once the job is unregistered, so a sink
/// still draining past the end of a run does not start emitting secrets.
pub fn mask<'a>(&mut self, text: &'a str) -> Cow<'a, str> {
if let Some(fresh) = snapshot(&self.job_id) {
// Replacing an equivalent snapshot would re-arm the notice, so only take
// one built from a secret set we have not seen.
let unchanged = self
.snapshot
.as_ref()
.is_some_and(|cur| Arc::ptr_eq(&cur.compiled, &fresh.compiled));
if !unchanged {
self.snapshot = Some(fresh);
}
}
match self.snapshot.as_ref() {
Some(snapshot) => snapshot.mask(text),
None => Cow::Borrowed(text),
}
}
}
/// Take a snapshot of the current secrets for a job. Returns `None` if no secrets
/// are registered (the caller can then skip masking entirely for the whole batch).
///
/// Call this once per log batch in `write_lines`, not per line.
pub fn snapshot(job_id: &Uuid) -> Option<MaskSnapshot> {
{
let masks = SENSITIVE_MASKS.read().unwrap_or_else(|e| e.into_inner());
let job = masks.get(job_id)?;
if job.secrets.is_empty() {
return None;
}
if let Some(compiled) = job.compiled.as_ref() {
return Some(MaskSnapshot {
compiled: compiled.clone(),
notice_shown: std::cell::Cell::new(false),
});
}
}
let mut masks = SENSITIVE_MASKS.write().unwrap_or_else(|e| e.into_inner());
let job = masks.get_mut(job_id)?;
if job.secrets.is_empty() {
let masks = SENSITIVE_MASKS.read().unwrap_or_else(|e| e.into_inner());
let secrets = masks.get(job_id)?;
if secrets.is_empty() {
return None;
}
let compiled = job
.compiled
.get_or_insert_with(|| Arc::new(compile(&job.secrets)));
Some(MaskSnapshot { compiled: compiled.clone(), notice_shown: std::cell::Cell::new(false) })
}
fn compile(secrets: &HashSet<String>) -> CompiledMasks {
// Sort longest-first so longer secrets are matched before shorter substrings
let mut sorted: Vec<&String> = secrets.iter().collect();
sorted.sort_by(|a, b| b.len().cmp(&a.len()));
@@ -189,7 +106,7 @@ fn compile(secrets: &HashSet<String>) -> CompiledMasks {
.build(sorted.iter().map(|s| s.as_str()))
.expect("failed to build aho-corasick automaton");
CompiledMasks { ac, replacements }
Some(MaskSnapshot { ac, replacements, notice_shown: std::cell::Cell::new(false) })
}
/// Register a job as currently running. Call this before `handle_queued_job`.
@@ -231,110 +148,20 @@ pub fn register_secret_for_all_running_jobs(secret: &str) {
let mut masks = SENSITIVE_MASKS.write().unwrap_or_else(|e| e.into_inner());
for job_id in job_ids {
if let Some(job) = masks.get_mut(&job_id) {
if job.secrets.insert(secret.to_string()) {
job.compiled = None;
}
if let Some(set) = masks.get_mut(&job_id) {
set.insert(secret.to_string());
}
}
}
/// Register a secret value for a specific job.
/// Used for the job's own token and for `$encrypted:` args, where we know the job ID.
/// Used for `$encrypted:` args where we know the job ID.
pub fn register_secret_for_job(job_id: Uuid, secret: &str) {
if secret.len() < MIN_SECRET_LENGTH {
return;
}
let mut masks = SENSITIVE_MASKS.write().unwrap_or_else(|e| e.into_inner());
if let Some(job) = masks.get_mut(&job_id) {
if job.secrets.insert(secret.to_string()) {
job.compiled = None;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The compiled automaton is cached per job, so a secret registered after the
/// first snapshot only gets masked if the cache is invalidated.
#[test]
fn snapshot_rebuilds_after_a_new_secret_is_registered() {
let job_id = Uuid::new_v4();
register_running_job(job_id);
register_secret_for_job(job_id, "firstsecretvalue");
let _ = snapshot(&job_id)
.expect("secret registered")
.mask("firstsecretvalue");
register_secret_for_job(job_id, "secondsecretvalue");
let snap = snapshot(&job_id).expect("secrets registered");
let masked = snap.mask("firstsecretvalue then secondsecretvalue");
assert!(!masked.contains("firstsecretvalue"), "{masked}");
assert!(!masked.contains("secondsecretvalue"), "{masked}");
unregister_running_job(job_id);
}
/// A line-by-line sink must not repeat the notice on every line, and must still
/// pick up a secret registered after the masker was built.
#[test]
fn job_masker_notices_once_per_secret_set() {
let job_id = Uuid::new_v4();
register_running_job(job_id);
register_secret_for_job(job_id, "firstsecretvalue");
let mut masker = JobMasker::new(job_id);
let first = masker.mask("saw firstsecretvalue").into_owned();
assert!(!first.contains("firstsecretvalue"), "{first}");
assert!(first.contains(MASKED_NOTICE), "{first}");
let second = masker.mask("saw firstsecretvalue again").into_owned();
assert!(!second.contains("firstsecretvalue"), "{second}");
assert!(!second.contains(MASKED_NOTICE), "{second}");
register_secret_for_job(job_id, "secondsecretvalue");
let third = masker.mask("saw secondsecretvalue").into_owned();
assert!(!third.contains("secondsecretvalue"), "{third}");
unregister_running_job(job_id);
}
/// Unregistration must not turn masking off under a sink that is still emitting:
/// the masker keeps working off the masks it last saw rather than going quiet.
#[test]
fn job_masker_masks_after_the_job_is_unregistered() {
let job_id = Uuid::new_v4();
register_running_job(job_id);
register_secret_for_job(job_id, "supersecretvalue");
let mut masker = JobMasker::new(job_id);
unregister_running_job(job_id);
let masked = masker.mask("logged supersecretvalue here");
assert!(!masked.contains("supersecretvalue"), "{masked}");
}
/// The notice has to end up on a line of its own for both shapes callers pass:
/// a bare line (`handle_child`) and a newline-terminated chunk (nativets). The
/// sinks concatenate what they are given verbatim, so getting this wrong welds
/// the notice onto whichever line follows it.
#[test]
fn notice_lands_on_its_own_line_for_both_caller_shapes() {
let job_id = Uuid::new_v4();
register_running_job(job_id);
register_secret_for_job(job_id, "supersecretvalue");
let line = snapshot(&job_id)
.expect("secret registered")
.mask("tok supersecretvalue");
assert_eq!(line, format!("tok s*****e\n{MASKED_NOTICE}"));
let chunk = snapshot(&job_id)
.expect("secret registered")
.mask("tok supersecretvalue\n");
assert_eq!(chunk, format!("tok s*****e\n{MASKED_NOTICE}\n"));
unregister_running_job(job_id);
if let Some(set) = masks.get_mut(&job_id) {
set.insert(secret.to_string());
}
}
+8 -313
View File
@@ -160,34 +160,6 @@ 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
@@ -240,12 +212,6 @@ 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
@@ -331,7 +297,6 @@ 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(),
@@ -352,7 +317,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, base
created_at
FROM draft
WHERE workspace_id = $1
AND (email = $2 OR email IS NULL)
@@ -373,7 +338,6 @@ where
inner: Box::new(deployed),
is_draft: false,
draft_saved_at: None,
draft_base: None,
no_deployed: false,
draft: None,
other_drafts_users,
@@ -386,7 +350,6 @@ 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,
@@ -505,8 +468,7 @@ pub async fn overlay_or_draft_only<T: serde::Serialize + Send + 'static>(
}
}
/// Delete the drafts an address owns, and the move records routing saves to them,
/// across every workspace.
/// Delete the drafts an address owns, 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
@@ -517,14 +479,12 @@ 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(conn: &mut sqlx::PgConnection, email: &str) -> Result<()> {
pub async fn delete_drafts_of_email<'c>(
executor: impl sqlx::PgExecutor<'c>,
email: &str,
) -> Result<()> {
sqlx::query!("DELETE FROM draft WHERE email = $1", email)
.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)
.execute(executor)
.await?;
Ok(())
}
@@ -566,29 +526,6 @@ 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(())
}
@@ -645,247 +582,6 @@ 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
@@ -904,7 +600,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, base
created_at
FROM draft
WHERE workspace_id = $1
AND (email = $2 OR email IS NULL)
@@ -935,7 +631,6 @@ 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,
-47
View File
@@ -479,28 +479,6 @@ 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;
@@ -1694,31 +1672,6 @@ 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.
+2 -2
View File
@@ -184,7 +184,7 @@ pub enum ObjectType {
DatatableMigration,
}
pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28971/sync-script-to-git-repo-windmill";
pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28969/sync-script-to-git-repo-windmill";
/// Hub script that applies a repository's state back into a workspace
/// (the repo → Windmill / "pull" direction). Same script the UI runs from
@@ -192,7 +192,7 @@ pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28971/sync-script-to-git-repo
/// ignores the slug, so the slug is kept free of characters that would be
/// percent-encoded into the run URL (a `:` becomes `%3A`, which some hardened
/// reverse proxies reject as double-encoding when the client re-encodes it).
pub const GIT_SYNC_PULL_SCRIPT_PATH: &str = "hub/28970/git-sync-init-repository-windmill";
pub const GIT_SYNC_PULL_SCRIPT_PATH: &str = "hub/28957/git-sync-init-repository-windmill";
/// Prefix used to identify fork workspaces. A workspace whose id starts with this string is a
/// fork of another workspace.
+4 -19
View File
@@ -838,11 +838,6 @@ pub async fn eval_fetch_timeout(
}
}
let w_id_for_tracing = w_id_for_tracing;
// nativets delivers logs in-process, so they never reach the masking in
// `handle_child::write_lines` and a `console.log` of `$WM_TOKEN` would be
// persisted verbatim. Mask here rather than in the detached task draining into
// `append_logs`: this loop normally runs while the job is still registered.
let mut masker = windmill_common::sensitive_log_masks::JobMasker::new(job_id);
let handle = tokio::spawn(async move {
let mut result_stream = String::new();
let mut is_stream = false;
@@ -850,20 +845,10 @@ pub async fn eval_fetch_timeout(
use windmill_common::result_stream::extract_stream_from_logs;
use windmill_common::tracing_init::{OTEL_JOB_LOGS, OTEL_PREFIX};
let stream = extract_stream_from_logs(&log.trim_end_matches("\n"));
// A stream chunk is result data, not a log line — it never reaches
// `job_logs`, and `merge_result_stream` can make it the job's result —
// so it stays raw wherever it goes, here and in the mirror below.
// Deliberately unlike `handle_child`, which streams the masked text.
// Routed before masking because the notice is one-shot: spent on a chunk
// no sink persists, a later redaction in `job_logs` would go unexplained.
let logged = stream.is_none().then(|| masker.mask(&log).into_owned());
// Mirror `process_streaming_log_lines` (EE) + the OTEL_JOB_LOGS
// hook from handle_child.rs, neither of which runs for nativets
// since nativets delivers logs in-process via the log channel.
for line in logged.as_deref().unwrap_or(&log).lines() {
for line in log.lines() {
tracing::info!(
target: "windmill:job_log",
job_id = ?job_id,
@@ -877,7 +862,7 @@ pub async fn eval_fetch_timeout(
}
}
if let Some(stream) = stream {
if let Some(stream) = extract_stream_from_logs(&log.trim_end_matches("\n")) {
if !is_stream {
is_stream = true;
if let Some(ref f) = stream_notifier_update {
@@ -889,8 +874,8 @@ pub async fn eval_fetch_timeout(
if let Err(e) = result_stream_sender.send(stream) {
tracing::error!("failed to send result stream: {e}");
}
} else if let Some(logged) = logged {
if let Err(e) = append_logs_sender.send(logged) {
} else {
if let Err(e) = append_logs_sender.send(log) {
tracing::error!("failed to send log: {e}");
}
}
@@ -6457,7 +6457,7 @@ var AppService = class {
* @param data.workspace
* @param data.path
* @param data.requestBody update app
* @returns AppDeployed the path the app now lives at and the version this deploy wrote
* @returns string app updated
* @throws ApiError
*/
static updateApp(data) {
-4
View File
@@ -507,10 +507,6 @@ 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)]
+4 -26
View File
@@ -3859,6 +3859,8 @@ pub async fn run_worker(
let arc_job = Arc::new(job);
windmill_common::sensitive_log_masks::register_running_job(arc_job.id);
let span = create_span_with_name(&arc_job, &worker_name, Some(hostname), "job");
let log_ctx = log_context_for_job(&arc_job, &worker_name, Some(hostname));
@@ -3974,6 +3976,8 @@ pub async fn run_worker(
_ => {}
}
windmill_common::sensitive_log_masks::unregister_running_job(job_id);
#[cfg(feature = "prometheus")]
if let Some(duration) = _timer.map(|x| x.stop_and_record()) {
register_metric(
@@ -4508,30 +4512,6 @@ async fn detect_and_store_runtime_assets_from_job_args(
}
}
/// Holds a job's entry in the log-masking registry for as long as it executes, so
/// that secrets it fetches can be registered against it, and masks the job's own
/// token from the start: `$WM_TOKEN` stays valid well past the run, and a script
/// that echoes it would otherwise leave a live credential in the persisted logs.
///
/// Lives here rather than at the call sites so that every way of running a job —
/// the poller, the interactive worker shell, an inline AI agent tool — is covered
/// by construction.
struct RunningJobMasks(Uuid);
impl RunningJobMasks {
fn register(job_id: Uuid, token: &str) -> Self {
windmill_common::sensitive_log_masks::register_running_job(job_id);
windmill_common::sensitive_log_masks::register_secret_for_job(job_id, token);
RunningJobMasks(job_id)
}
}
impl Drop for RunningJobMasks {
fn drop(&mut self) {
windmill_common::sensitive_log_masks::unregister_running_job(self.0);
}
}
pub async fn handle_queued_job(
job: Arc<MiniPulledJob>,
raw_code: Option<String>,
@@ -4553,8 +4533,6 @@ pub async fn handle_queued_job(
flow_runners: Option<Arc<FlowRunners>>,
#[cfg(feature = "benchmark")] _bench: &mut BenchmarkIter,
) -> windmill_common::error::Result<JobOutcome> {
let _masks = RunningJobMasks::register(job.id, &client.token);
if job.canceled_by.is_some() {
return Err(Error::JsonErr(canceled_job_to_result(&job)));
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.815.0";
export const VERSION = "v1.814.0";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "windmill-chat",
"version": "1.815.0",
"version": "1.814.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-chat",
"version": "1.815.0",
"version": "1.814.0",
"license": "Apache-2.0",
"devDependencies": {
"@ai-sdk/react": "^4.0.102",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "windmill-chat",
"description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app",
"version": "1.815.0",
"version": "1.814.0",
"author": "Ruben Fiszel",
"license": "Apache-2.0",
"homepage": "https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme",
+10 -21
View File
@@ -3594,15 +3594,12 @@ export async function pull(
) {
if ((opts as any).jsonOutput) log.setSilent(true);
const originalCliOpts = { ...opts };
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();
opts = await mergeConfigWithConfigFile(opts);
// --include-secrets overrides skipSecrets from wmill.yaml
if ((originalCliOpts as any).includeSecrets) {
opts.skipSecrets = false;
}
// Resolve workspace name for config lookups.
// --branch resolves git branch → workspace name (deprecated but still supported).
@@ -3637,6 +3634,10 @@ 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);
@@ -3728,14 +3729,6 @@ 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
@@ -3770,10 +3763,6 @@ 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(
+1 -1
View File
@@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork";
// (e.g. utils.ts) can read it without importing main.ts and creating a circular
// dependency (main → workspace → utils → main) that triggers a TDZ.
// Re-exported from main.ts for backwards compatibility.
export const VERSION = "1.815.0";
export const VERSION = "1.814.0";
@@ -1,91 +0,0 @@
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 });
}
});
});
+250 -100
View File
@@ -1,12 +1,12 @@
{
"name": "@windmill-labs/components",
"version": "1.815.0",
"version": "1.814.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@windmill-labs/components",
"version": "1.815.0",
"version": "1.814.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
@@ -67,7 +67,7 @@
"panzoom": "^9.4.3",
"pdfjs-dist": "4.8.69",
"quicktype-core": "^23.2.6",
"quill": "^2.0.3",
"quill": "^1.3.7",
"rehype-github-alerts": "^3.0.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
@@ -1733,7 +1733,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1750,7 +1749,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1767,7 +1765,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1784,7 +1781,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1801,7 +1797,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1818,7 +1813,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1835,7 +1829,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1852,7 +1845,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1869,7 +1861,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1886,7 +1877,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1903,7 +1893,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1920,7 +1909,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1937,7 +1925,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1954,7 +1941,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3746,11 +3732,28 @@
"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",
@@ -3764,7 +3767,6 @@
"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",
@@ -5206,6 +5208,26 @@
"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",
@@ -5248,6 +5270,40 @@
"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",
@@ -5500,7 +5556,6 @@
"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",
@@ -5615,7 +5670,6 @@
"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"
@@ -5625,7 +5679,6 @@
"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"
@@ -5642,7 +5695,6 @@
"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"
@@ -6110,9 +6162,9 @@
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz",
"integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==",
"license": "MIT"
},
"node_modules/events": {
@@ -6167,9 +6219,9 @@
"license": "MIT"
},
"node_modules/fast-diff": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz",
"integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==",
"license": "Apache-2.0"
},
"node_modules/fast-equals": {
@@ -6456,7 +6508,15 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"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==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -6495,7 +6555,6 @@
"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",
@@ -6520,7 +6579,6 @@
"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",
@@ -6704,7 +6762,6 @@
"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"
@@ -6797,11 +6854,22 @@
"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"
@@ -6814,7 +6882,6 @@
"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"
@@ -6836,7 +6903,6 @@
"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"
@@ -7235,6 +7301,22 @@
"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",
@@ -7272,6 +7354,22 @@
"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",
@@ -7362,6 +7460,24 @@
"@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",
@@ -7418,7 +7534,7 @@
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"devOptional": true,
"license": "MIT",
"bin": {
"jiti": "bin/jiti.js"
@@ -8124,7 +8240,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8145,7 +8260,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8166,7 +8280,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8187,7 +8300,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8208,7 +8320,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8229,7 +8340,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8250,7 +8360,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8271,7 +8380,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8292,7 +8400,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8313,7 +8420,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8334,7 +8440,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8556,7 +8661,6 @@
"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"
@@ -10010,6 +10114,31 @@
"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",
@@ -10202,9 +10331,9 @@
}
},
"node_modules/parchment": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
"integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz",
"integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
"license": "BSD-3-Clause"
},
"node_modules/parent-module": {
@@ -11523,32 +11652,31 @@
}
},
"node_modules/quill": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
"integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/quill/-/quill-1.3.7.tgz",
"integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==",
"license": "BSD-3-Clause",
"dependencies": {
"eventemitter3": "^5.0.1",
"lodash-es": "^4.17.21",
"parchment": "^3.0.0",
"quill-delta": "^5.1.0"
},
"engines": {
"npm": ">=8.2.3"
"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"
}
},
"node_modules/quill-delta": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
"integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
"version": "3.6.3",
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz",
"integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==",
"license": "MIT",
"dependencies": {
"fast-diff": "^1.3.0",
"lodash.clonedeep": "^4.5.0",
"lodash.isequal": "^4.5.0"
"deep-equal": "^1.0.1",
"extend": "^3.0.2",
"fast-diff": "1.1.2"
},
"engines": {
"node": ">= 12.0.0"
"node": ">=0.10"
}
},
"node_modules/rbush": {
@@ -11721,6 +11849,26 @@
"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",
@@ -12117,6 +12265,38 @@
"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",
@@ -13007,21 +13187,6 @@
}
}
},
"node_modules/svelte-check/node_modules/picomatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/svelte-eslint-parser": {
"version": "0.43.0",
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz",
@@ -13232,21 +13397,6 @@
"svelte": "^4.2.19 || ^5.1.0"
}
},
"node_modules/svelte/node_modules/@typescript-eslint/types": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.0.tgz",
"integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/svelte/node_modules/esrap": {
"version": "2.3.7",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.7.tgz",
@@ -13833,7 +13983,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill-labs/components",
"version": "1.815.0",
"version": "1.814.0",
"scripts": {
"dev": "vite dev",
"dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev",
@@ -145,7 +145,7 @@
"panzoom": "^9.4.3",
"pdfjs-dist": "4.8.69",
"quicktype-core": "^23.2.6",
"quill": "^2.0.3",
"quill": "^1.3.7",
"rehype-github-alerts": "^3.0.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
@@ -3,11 +3,13 @@
import Select from './select/Select.svelte'
import { fetchAvailableModels, AI_PROVIDERS } from './copilot/lib'
import type { AIProvider, ProviderConfig } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import ResourcePicker from './ResourcePicker.svelte'
import Toggle from './Toggle.svelte'
import { saveConfig, removeConfig, isSameAsStoredConfig } from './aiProviderStorage'
import AIReasoningEffortPicker from './AIReasoningEffortPicker.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
value: ProviderConfig | undefined
@@ -26,7 +28,7 @@
workspace = undefined
}: Props = $props()
let effectiveWorkspace = $derived(workspace ?? $workspaceStore ?? '')
let effectiveWorkspace = $derived(workspace ?? $operatingWorkspace ?? '')
let value = $derived.by(() => {
if (!_uncheckedValue || typeof _uncheckedValue !== 'object') return undefined
@@ -2,7 +2,6 @@
import { ExternalLink, Globe } from 'lucide-svelte'
import { JobService, type Job } from '$lib/gen'
import { base } from '$lib/base'
import { workspaceStore } from '$lib/stores'
import { msToReadableTimeShort } from '$lib/utils'
import ChatCollapsibleCard from './copilot/chat/ChatCollapsibleCard.svelte'
import ToolContentDisplay from './copilot/chat/ToolContentDisplay.svelte'
@@ -10,6 +9,9 @@
import GfmMarkdown from './GfmMarkdown.svelte'
import type { AgentTraceEntry } from './agentTrace'
import { SvelteMap, SvelteSet } from 'svelte/reactivity'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
entries: AgentTraceEntry[]
@@ -40,7 +42,7 @@
try {
jobs.set(
jobId,
await JobService.getJob({ id: jobId, workspace: workspaceId ?? $workspaceStore! })
await JobService.getJob({ id: jobId, workspace: workspaceId ?? $operatingWorkspace! })
)
} catch {
// A tool job can be gone (retention) or unreadable. The row still has its
@@ -121,7 +123,7 @@
{:else if entry.jobId}
<a
class="text-2xs text-accent inline-flex items-center gap-1 w-fit hover:underline"
href="{base}/run/{entry.jobId}?workspace={workspaceId ?? $workspaceStore}"
href="{base}/run/{entry.jobId}?workspace={workspaceId ?? $operatingWorkspace}"
target="_blank"
rel="noreferrer"
>
@@ -1,7 +1,6 @@
<script lang="ts">
import { OauthService, type ResourceType } from '$lib/gen'
import FilesetEditor from './FilesetEditor.svelte'
import { workspaceStore } from '$lib/stores'
import { emptySchema, emptyString } from '$lib/utils'
import SchemaForm from './SchemaForm.svelte'
import Toggle from './Toggle.svelte'
@@ -20,6 +19,9 @@
import { base } from '$lib/base'
import { isDataTableWizardEnabled } from './workspaceSettings/utils.svelte'
import { parsePostgresConnectionString } from '$lib/utils/postgresConnectionString'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
resourceType: string
@@ -152,7 +154,7 @@
}
}
$effect(() => {
$workspaceStore && untrack(() => loadSchema())
$operatingWorkspace && untrack(() => loadSchema())
})
$effect(() => {
notFound && rawCode && untrack(() => parseJson())
@@ -1,7 +1,7 @@
<script lang="ts">
import { run } from 'svelte/legacy'
import { userStore, workspaceStore } from '$lib/stores'
import { userStore } from '$lib/stores'
import LabelsInput from './LabelsInput.svelte'
import IconedResourceType from './IconedResourceType.svelte'
import {
@@ -53,6 +53,9 @@
import Label from './Label.svelte'
import ResourcePathHint from './ResourcePathHint.svelte'
import SchemaForm from './SchemaForm.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
step?: number
@@ -84,7 +87,7 @@
fillPath = undefined
}: Props = $props()
let effectiveWorkspace = $derived(workspace ?? $workspaceStore!)
let effectiveWorkspace = $derived(workspace ?? $operatingWorkspace!)
let isValid = $state(true)
+5 -3
View File
@@ -1,12 +1,14 @@
<script lang="ts">
import { ResourceService, VariableService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { copyToClipboard, truncate } from '$lib/utils'
import { ClipboardCopy, Expand } from 'lucide-svelte'
import Drawer from './common/drawer/Drawer.svelte'
import ObjectViewer from './propertyPicker/ObjectViewer.svelte'
import Tooltip from './Tooltip.svelte'
import { Button, DrawerContent } from './common'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
value: any
@@ -22,14 +24,14 @@
async function getResource(path: string) {
jsonViewerContent = await ResourceService.getResourceValue({
workspace: $workspaceStore!,
workspace: $operatingWorkspace!,
path
})
}
async function getVariable(path: string) {
jsonViewerContent = await VariableService.getVariableValue({
workspace: $workspaceStore!,
workspace: $operatingWorkspace!,
path
})
}
+4 -2
View File
@@ -41,12 +41,14 @@
import { safeSelectItems } from './select/utils.svelte'
import S3ArgInput from './common/fileUpload/S3ArgInput.svelte'
import { base } from '$lib/base'
import { workspaceStore } from '$lib/stores'
import { getJsonSchemaFromResource } from './schema/jsonSchemaResource.svelte'
import AIProviderPicker from './AIProviderPicker.svelte'
import TextInput from './text_input/TextInput.svelte'
import FileInput from './common/fileInput/FileInput.svelte'
import { randomUUID } from '$lib/utils/uuid'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
label?: string
@@ -823,7 +825,7 @@
/>
{/await}
{:else if inputCat == 'object' && format?.startsWith('jsonschema-')}
{#await getJsonSchemaFromResource(format.substring('jsonschema-'.length), workspace ?? $workspaceStore ?? '')}
{#await getJsonSchemaFromResource(format.substring('jsonschema-'.length), workspace ?? $operatingWorkspace ?? '')}
<Loader2 class="animate-spin" />
{:then schema}
{#if !schema || !schema.properties}

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