Merge branch 'main' into datatable-roles-redesign

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb
This commit is contained in:
Diego Imbert
2026-09-15 14:00:55 +02:00
co-authored by Claude Opus 5
250 changed files with 15463 additions and 1433 deletions
+1
View File
@@ -12,6 +12,7 @@ sed -i '' -e "/^export const VERSION =/s/= .*/= \"v$VERSION\";/" ${root_dirpath}
sed -i '' -e "/version: /s/: .*/: $VERSION/" ${root_dirpath}/backend/windmill-api/openapi.yaml
sed -i '' -e "/version: /s/: .*/: $VERSION/" ${root_dirpath}/openflow.openapi.yaml
sed -i '' -e "/\"version\": /s/: .*,/: \"$VERSION\",/" ${root_dirpath}/typescript-client/package.json
sed -i '' -e "/\"version\": /s/: .*,/: \"$VERSION\",/" ${root_dirpath}/chat-sdk/package.json
sed -i '' -e "/\"version\": /s/: .*,/: \"$VERSION\",/" ${root_dirpath}/frontend/package.json
sed -i '' -e "/^version =/s/= .*/= \"$VERSION\"/" ${root_dirpath}/python-client/wmill/pyproject.toml
sed -i '' -e "/^windmill-api =/s/= .*/= \"\\^$VERSION\"/" ${root_dirpath}/python-client/wmill/pyproject.toml
+3
View File
@@ -13,6 +13,7 @@ sed -i -e "/version: /s/: .*/: $VERSION/" ${root_dirpath}/backend/windmill-api/o
sed -i -e "/version: /s/: .*/: $VERSION/" ${root_dirpath}/openflow.openapi.yaml
sed -i -e "/\"version\": /s/: .*,/: \"$VERSION\",/" ${root_dirpath}/typescript-client/package.json
sed -i -e "/\"version\": /s/: .*,/: \"$VERSION\",/" ${root_dirpath}/typescript-client/jsr.json
sed -i -e "/\"version\": /s/: .*,/: \"$VERSION\",/" ${root_dirpath}/chat-sdk/package.json
sed -i -e "/\"version\": /s/: .*,/: \"$VERSION\",/" ${root_dirpath}/frontend/package.json
sed -i -e "/\"version\": /s/: .*,/: \"$VERSION\",/" ${root_dirpath}/windmill-yaml-validator/package.json
sed -i -e "/^version =/s/= .*/= \"$VERSION\"/" ${root_dirpath}/python-client/wmill/pyproject.toml
@@ -33,3 +34,5 @@ cd ${root_dirpath}/frontend && npm i --package-lock-only --ignore-scripts
# The CLI installs this package on every `bun install`, which would otherwise rewrite the
# lockfile's version and leave a dirty tree.
cd ${root_dirpath}/windmill-yaml-validator && npm i --package-lock-only --ignore-scripts
cd ${root_dirpath}/chat-sdk && npm i --package-lock-only --ignore-scripts
+11
View File
@@ -17,6 +17,17 @@ jobs:
- run: cd typescript-client && ./publish.sh --access public && cd ..
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
publish_chat_sdk:
runs-on: ubicloud-standard-8
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v3
with:
node-version: "20.x"
registry-url: "https://registry.npmjs.org"
- run: cd chat-sdk && npm ci && npm run build && npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
publish_cli:
runs-on: ubicloud-standard-8
steps:
+19
View File
@@ -32,6 +32,25 @@ jobs:
working-directory: ./typescript-client
run: bun test --timeout 120000 tests/
chat-sdk:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- uses: actions/setup-node@v4
with:
node-version: "20.x"
- name: Run tests
working-directory: ./chat-sdk
run: npm ci && npm run check && bun test
python-client:
runs-on: ubuntu-latest
steps:
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.811.1"
".": "1.812.0"
}
+3
View File
@@ -30,6 +30,9 @@ Open-source platform for internal tools, workflows, API integrations, background
reaches the DB only through the API, so `Connection::Http` paths are never taken by a plain
`cargo run`; a normal build cannot start one at all.
- **Enterprise**: `docs/enterprise.md` — EE file conventions and PR workflow
- **Auth surface**: `docs/auth-surface.md` — credential precedence, session/cache invalidation
scope, how OAuth login matches `login_type`, and that every superadmin route refuses `$WM_TOKEN`.
Read before designing anything that creates users, tokens or sessions.
- **Product telemetry**: `docs/feature-telemetry.md` — when to instrument a new feature with
`feature_usage`, and the four-step recipe. An unregistered `(feature, kind)` pair is dropped
silently, so frontend-only instrumentation records nothing.
+30
View File
@@ -1,5 +1,35 @@
# Changelog
## [1.812.0](https://github.com/windmill-labs/windmill/compare/v1.811.1...v1.812.0) (2026-09-15)
### Features
* add per-route CORS origin allowlist for HTTP triggers ([#10833](https://github.com/windmill-labs/windmill/issues/10833)) ([d8d7332](https://github.com/windmill-labs/windmill/commit/d8d7332eb6d92f7a55b82890de5de4196039b40d))
* **ai-sessions:** share session artifacts with the workspace by link ([#11115](https://github.com/windmill-labs/windmill/issues/11115)) ([57a134e](https://github.com/windmill-labs/windmill/commit/57a134e2de18e27b3c1d3066a60b892af1089b30))
* **cli:** list, get and restore trashed items with wmill trash ([#11125](https://github.com/windmill-labs/windmill/issues/11125)) ([a95e950](https://github.com/windmill-labs/windmill/commit/a95e950529f6f01a220e09d322d5a4fb507ea694))
* dynamic AI agent toolsets ([#11050](https://github.com/windmill-labs/windmill/issues/11050)) ([a78beff](https://github.com/windmill-labs/windmill/commit/a78beff743f6bb289805c263a8d32515bea9688f))
* **git-sync:** gate GitHub PRs on Windmill CI test results (WIN-2051) ([#10096](https://github.com/windmill-labs/windmill/issues/10096)) ([80eba80](https://github.com/windmill-labs/windmill/commit/80eba80d6ed51753cfaa67310f1a0f5dd5ce0484))
* pre-approved cloud accounts: login links, OAuth adoption, setup, and the trial bridge ([#10875](https://github.com/windmill-labs/windmill/issues/10875)) ([91e6dc3](https://github.com/windmill-labs/windmill/commit/91e6dc39ce795fafc2bed0d799b62c9880fd6430))
* run a flow step test through the chat's argument form ([#11114](https://github.com/windmill-labs/windmill/issues/11114)) ([5d32b61](https://github.com/windmill-labs/windmill/commit/5d32b6106788b665e4752fcac63ff1ef457d604e))
* store resource type display names and label hub integrations ([#11113](https://github.com/windmill-labs/windmill/issues/11113)) ([42f4896](https://github.com/windmill-labs/windmill/commit/42f489685bc87a8479818175c87b5a21b0c17998))
* windmill-chat sdk for chat-mode flows in external frontends and raw apps ([#11117](https://github.com/windmill-labs/windmill/issues/11117)) ([e8c02c0](https://github.com/windmill-labs/windmill/commit/e8c02c04cdb1a3f199f3f0a8b53a839a53d4a1d9))
### Bug Fixes
* **ai-chat:** hide other users' MCP servers from the chat unless shared ([#11112](https://github.com/windmill-labs/windmill/issues/11112)) ([244ec13](https://github.com/windmill-labs/windmill/commit/244ec132914a6e689d7d79da9cf2cb39f920aaef))
* **cli:** say where a sync push deleted variable or resource went ([#10851](https://github.com/windmill-labs/windmill/issues/10851)) ([d54a66f](https://github.com/windmill-labs/windmill/commit/d54a66f15c09c34f7a2b45c2e6e9649807a19265))
* **cli:** stage a rewritten shared lockfile on git-sync deploy push ([#11126](https://github.com/windmill-labs/windmill/issues/11126)) ([75ee497](https://github.com/windmill-labs/windmill/commit/75ee497011dca076de0923ded9da8e23e08bfb84))
* **flows:** stop re-evaluating skip_if once a loop is in progress ([#11008](https://github.com/windmill-labs/windmill/issues/11008)) ([56e21bc](https://github.com/windmill-labs/windmill/commit/56e21bce832182528562688acd13ec416e01ebfc))
* **git-sync:** run auto-pull as the admin who enabled it ([#11121](https://github.com/windmill-labs/windmill/issues/11121)) ([69e6efd](https://github.com/windmill-labs/windmill/commit/69e6efd875e020779ea115c096331da8eae2ffe7))
* keep a script draft's password marking through the chat's run form ([#11110](https://github.com/windmill-labs/windmill/issues/11110)) ([56dd940](https://github.com/windmill-labs/windmill/commit/56dd940e34b146c3ca25de7959b4fb618308eb86))
* **python:** parse wheel RECORD paths as RFC 4180 csv fields ([#11133](https://github.com/windmill-labs/windmill/issues/11133)) ([9696308](https://github.com/windmill-labs/windmill/commit/96963080f1711192fe1d9bc4142c1710511504d4))
* re-attach flow chat to the same job on SSE timeout instead of re-running it ([#11122](https://github.com/windmill-labs/windmill/issues/11122)) ([94c548c](https://github.com/windmill-labs/windmill/commit/94c548cd5dc43131e0471b0edabe496dff245862))
* set the enclosing span's trace context on exported log records ([#11123](https://github.com/windmill-labs/windmill/issues/11123)) ([d0cac08](https://github.com/windmill-labs/windmill/commit/d0cac0807f1b6f4fc76d6e5f7e27e03d30abec8d))
* skip instance group members that are not email addresses ([#11128](https://github.com/windmill-labs/windmill/issues/11128)) ([e3e638f](https://github.com/windmill-labs/windmill/commit/e3e638f7f587f0ee090d06436575b65a0860a855))
* wake a WAC parent from every path that completes its child ([#11119](https://github.com/windmill-labs/windmill/issues/11119)) ([0b1e9c0](https://github.com/windmill-labs/windmill/commit/0b1e9c0dda2ae55c56b1e0de5c0419b4511b973f))
## [1.811.1](https://github.com/windmill-labs/windmill/compare/v1.811.0...v1.811.1) (2026-09-13)
@@ -0,0 +1,41 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO ai_shared_artifact\n (workspace_id, artifact_id, email, created_by, name, kind, version, content)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n ON CONFLICT (workspace_id, email, artifact_id) DO UPDATE\n SET created_by = EXCLUDED.created_by,\n name = EXCLUDED.name,\n kind = EXCLUDED.kind,\n version = EXCLUDED.version,\n content = EXCLUDED.content,\n shared_at = now()\n RETURNING id, shared_at, (xmax = 0) AS \"inserted!\"",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "shared_at",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"name": "inserted!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Varchar",
"Varchar",
"Varchar",
"Int4",
"Text"
]
},
"nullable": [
false,
false,
null
]
},
"hash": "0589cb0f96e17ecadae4923be70a6cf0148a9e10c1c4ad0f99312b8e99ec1b8a"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO login_link (token_hash, email, rd, expiration, created_by)\n VALUES ($1, $2, $3, $4, $5)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Bpchar",
"Varchar",
"Text",
"Timestamptz",
"Varchar"
]
},
"nullable": []
},
"hash": "071de805623be166dddd2655f099bed4ebbf6a03ec5988acf072c83818d57a02"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT username, email FROM usr WHERE workspace_id = $1 AND is_admin = true AND operator = false AND disabled = false ORDER BY username LIMIT 1",
"query": "SELECT u.username, u.email FROM usr u WHERE u.workspace_id = $1 AND u.is_admin AND NOT u.operator AND NOT u.disabled AND NOT EXISTS (SELECT 1 FROM password p WHERE p.email = u.email AND p.disabled) ORDER BY u.username LIMIT 1",
"describe": {
"columns": [
{
@@ -24,5 +24,5 @@
false
]
},
"hash": "3202bed875693ae923f496272cd8ad89b2f17a9d3ef4659c2d2284415177b32c"
"hash": "0c4dc0e9dc159fac7e41492c78a4e4e0b12b105d4475d7eba2d3a9573b93e388"
}
@@ -1,26 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COALESCE(username, split_part(email, '@', 1)) AS \"username!\", email FROM password WHERE super_admin = true AND disabled = false ORDER BY email LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username!",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "email",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
false
]
},
"hash": "17cdf02b4912078459526205849246fd2bdf9e3fce89852120aa4ad4ad6abfc3"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO resource_type (workspace_id, name, schema, description, format_extension, display_name, edited_at)\n VALUES ('admins', $1, $2, $3, $4, $6, now())\n ON CONFLICT (workspace_id, name) DO UPDATE\n SET schema = EXCLUDED.schema, description = EXCLUDED.description,\n -- A fileset is a set of files, so it cannot also be one file.\n -- Create and update reject the pair; this writer bypasses both, so\n -- it declines the extension rather than persisting the forbidden\n -- combination onto a same-named local fileset.\n format_extension = CASE\n WHEN resource_type.is_fileset THEN NULL\n WHEN $5 THEN EXCLUDED.format_extension\n ELSE resource_type.format_extension END,\n display_name = CASE WHEN $7 THEN EXCLUDED.display_name ELSE resource_type.display_name END,\n edited_at = now()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Jsonb",
"Text",
"Varchar",
"Bool",
"Varchar",
"Bool"
]
},
"nullable": []
},
"hash": "212bf5b32de102a9c537907aaff04befc3a6596c258086805dd42f513ddb3ead"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE cloud_trial_offer SET consumed_at = now() WHERE email = $1 AND consumed_at IS NULL",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "25f27dba5c0ea81d9412bdf1986c2a38b2dcf3976fbd67522c4644ee9bddc330"
}
@@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_completed SET\n workflow_as_code_status = jsonb_set(\n jsonb_set(\n workflow_as_code_status,\n array[$1],\n COALESCE(workflow_as_code_status->$1, '{}'::jsonb)\n ),\n array[$1, 'duration_ms'],\n to_jsonb($2::bigint)\n )\n WHERE id = $3 AND workflow_as_code_status IS NOT NULL",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int8",
"Uuid"
]
},
"nullable": []
},
"hash": "29935e89475f637d765c516f1aa2be2f0f31fb50d519b42a056d0d73417599a3"
}
@@ -1,15 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO v2_job_completed\n (workspace_id, id, started_at, duration_ms, result, memory_peak, status, worker)\n SELECT q.workspace_id, q.id, q.started_at,\n COALESCE((EXTRACT('epoch' FROM now()) - EXTRACT('epoch' FROM COALESCE(q.started_at, now()))) * 1000, 0)::bigint,\n $2::jsonb, r.memory_peak, 'failure'::job_status, q.worker\n FROM v2_job_queue q\n LEFT JOIN v2_job_runtime r ON r.id = q.id\n WHERE q.id = $1\n ON CONFLICT (id) DO UPDATE SET status = 'failure', result = $2::jsonb",
"query": "INSERT INTO v2_job_completed\n (workspace_id, id, started_at, duration_ms, result, memory_peak, status, worker)\n SELECT q.workspace_id, q.id, q.started_at,\n COALESCE((EXTRACT('epoch' FROM now()) - EXTRACT('epoch' FROM COALESCE(q.started_at, now()))) * 1000, 0)::bigint,\n $2::jsonb, r.memory_peak, 'failure'::job_status, q.worker\n FROM v2_job_queue q\n LEFT JOIN v2_job_runtime r ON r.id = q.id\n WHERE q.id = $1\n ON CONFLICT (id) DO UPDATE SET status = 'failure', result = $2::jsonb\n RETURNING duration_ms AS \"duration_ms!\"",
"describe": {
"columns": [],
"columns": [
{
"ordinal": 0,
"name": "duration_ms!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Uuid",
"Jsonb"
]
},
"nullable": []
"nullable": [
false
]
},
"hash": "beecb176df512e4a94771d0d73c4c597e07e53d499131b57e4d6441fd0af09cb"
"hash": "2abc2a5830130b2b4b32983407abeea41923ba4122fa666c6e8d8b06dcb5a71f"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE password SET password_hash = $1, login_type = 'password'\n WHERE email = $2 AND login_type = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "310d91848c7a032846aa8be8c5e5f42477fc5ab17fac70864d1b2f7f91ac7f9d"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT parent_job, flow_step_id FROM v2_job WHERE id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "parent_job",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "flow_step_id",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
true,
true
]
},
"hash": "32ca7941db013dacd2479962fa9ed5c8c64daec45ba820a6c8f7d7ab76cc40c9"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset from resource_type WHERE name = $1 AND (workspace_id = $2 OR workspace_id = 'admins')",
"query": "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset, display_name from resource_type WHERE name = $1 AND (workspace_id = $2 OR workspace_id = 'admins')",
"describe": {
"columns": [
{
@@ -42,6 +42,11 @@
"ordinal": 7,
"name": "is_fileset",
"type_info": "Bool"
},
{
"ordinal": 8,
"name": "display_name",
"type_info": "Varchar"
}
],
"parameters": {
@@ -58,8 +63,9 @@
true,
true,
true,
false
false,
true
]
},
"hash": "623b061ccaa6bb883e95771fde8c911a165c9c430b7db389370361ca74d737f4"
"hash": "36ddecbdad3cce7a2593171ff10a6b07cdb70994be5724f08f864d29517f4907"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM resource_type WHERE workspace_id = 'admins' AND name = $1 AND schema IS NOT DISTINCT FROM $2 AND description IS NOT DISTINCT FROM $3 AND ($5 IS NOT TRUE OR format_extension IS NOT DISTINCT FROM $4))",
"query": "SELECT EXISTS(SELECT 1 FROM resource_type WHERE workspace_id = 'admins' AND name = $1 AND schema IS NOT DISTINCT FROM $2 AND description IS NOT DISTINCT FROM $3 AND ($5 IS NOT TRUE OR format_extension IS NOT DISTINCT FROM $4) AND ($7 IS NOT TRUE OR display_name IS NOT DISTINCT FROM $6))",
"describe": {
"columns": [
{
@@ -15,6 +15,8 @@
"Jsonb",
"Text",
"Text",
"Bool",
"Text",
"Bool"
]
},
@@ -22,5 +24,5 @@
null
]
},
"hash": "8ad79b80033b38ebddf6c8cd4d8cb160d41bac4c45a0fc74d9c9e96d3ef4486a"
"hash": "386e14cf7572027f2c4ef313cd7cc5dd6c7b0da4f76131313d3e6d97f36c5c34"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset from resource_type WHERE (workspace_id = $1 OR workspace_id = 'admins') ORDER BY name",
"query": "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset, display_name from resource_type WHERE (workspace_id = $1 OR workspace_id = 'admins') ORDER BY name",
"describe": {
"columns": [
{
@@ -42,6 +42,11 @@
"ordinal": 7,
"name": "is_fileset",
"type_info": "Bool"
},
{
"ordinal": 8,
"name": "display_name",
"type_info": "Varchar"
}
],
"parameters": {
@@ -57,8 +62,9 @@
true,
true,
true,
false
false,
true
]
},
"hash": "d0a95698b9a2c5e2543e94276d854d7e509c7db2c2ac7d395b7b53ad5dbc25e6"
"hash": "38b6c6cb91d3ba38838a7a015c59ecdad014ce319066eabec0f0d7f53338698a"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT super_admin AS \"super_admin!\" FROM password WHERE email = $1 AND disabled = false",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "super_admin!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "3fe41e2a72d02613a2b1c1c44fb0a7b681d101d286adfc6ff4548d1b9fdbab8c"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT profile FROM cloud_onboarding_profile WHERE email = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "profile",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "42783d94ee41c5b17ec16b480dd55af2a0ba2e827ae8add59d5e5465dc1d5743"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO password(email, verified, password_hash, login_type, super_admin, name, company, username, first_time_user)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Bool",
"Varchar",
"Varchar",
"Bool",
"Varchar",
"Varchar",
"Varchar",
"Bool"
]
},
"nullable": []
},
"hash": "4ed69ae9e2a0d045ec63e327bc40c73aba9b34302e2f25cd6928a29d974bbb2c"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT super_admin, devops FROM password WHERE email = $1 AND disabled = false FOR UPDATE",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "super_admin",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "devops",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "5bd410d777a7a6d48129e9fee8402455082e0a172948be6441e5383552331c3f"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM cloud_trial_offer WHERE email = $1 AND consumed_at IS NULL)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "5ca0afc5a7b0437de221c8cc7b31e015ec604eb92a40b8649d054cabef1d8060"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO cloud_onboarding_profile (email, profile, created_by) VALUES ($1, $2, $3)\n ON CONFLICT (email) DO UPDATE SET profile = EXCLUDED.profile",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Jsonb",
"Varchar"
]
},
"nullable": []
},
"hash": "64bc01a5d88680febabd794b6472b6d22720d71e555501011e1d6b3418064ed0"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM ai_shared_artifact\n WHERE shared_at <= now() - ($1::bigint::text || ' s')::interval",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "6774bc0ec8ca8c6c48e8e111ab074b8c5beb1c3992f6413d7bcabc8159c0f9bb"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT r->'auto_pull'->>'enabled_by' AS \"enabled_by\"\n FROM workspace_settings, jsonb_array_elements(git_sync->'repositories') r\n WHERE workspace_id = $1 AND r->>'git_repo_resource_path' = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "enabled_by",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "6dc8032100a28c4a6e843370038dfec43dc8a042f9d95e63bf84b2dc15a72165"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset from resource_type ORDER BY name",
"query": "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset, display_name from resource_type ORDER BY name",
"describe": {
"columns": [
{
@@ -42,6 +42,11 @@
"ordinal": 7,
"name": "is_fileset",
"type_info": "Bool"
},
{
"ordinal": 8,
"name": "display_name",
"type_info": "Varchar"
}
],
"parameters": {
@@ -55,8 +60,9 @@
true,
true,
true,
false
false,
true
]
},
"hash": "e253b9e7e6450652589d6ee7ffa86d600e449cd399ac781af8b40c1c444972c3"
"hash": "6f993567336a2f5ff642ed54e3aaf4d070f803b6192739e9ebaad7427ce59251"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE login_link SET consumed_at = now()\n WHERE token_hash = $1 AND consumed_at IS NULL AND expiration > now()\n RETURNING email, rd, created_by",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "rd",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "created_by",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Bpchar"
]
},
"nullable": [
false,
true,
false
]
},
"hash": "754598696e57a8c3ee6477d4f55f62e12019aa9582f06b9a47f7d04715eee24c"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset FROM resource_type WHERE workspace_id = $1",
"query": "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset, display_name FROM resource_type WHERE workspace_id = $1",
"describe": {
"columns": [
{
@@ -42,6 +42,11 @@
"ordinal": 7,
"name": "is_fileset",
"type_info": "Bool"
},
{
"ordinal": 8,
"name": "display_name",
"type_info": "Varchar"
}
],
"parameters": {
@@ -57,8 +62,9 @@
true,
true,
true,
false
false,
true
]
},
"hash": "45d5e9ead8193a04fd00c44a488590fdd2f7c4de45117a18360651655d153545"
"hash": "82350027cf9722a993f27808e570e795ff9fb6b863dfe9c0bcad382c3b73a25b"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO resource_type (workspace_id, name, schema, description, edited_at, created_by, format_extension, is_fileset)\n SELECT $2, name, schema, description, edited_at, created_by, format_extension, is_fileset\n FROM resource_type\n WHERE workspace_id = $1",
"query": "INSERT INTO resource_type (workspace_id, name, schema, description, edited_at, created_by, format_extension, is_fileset, display_name)\n SELECT $2, name, schema, description, edited_at, created_by, format_extension, is_fileset, display_name\n FROM resource_type\n WHERE workspace_id = $1",
"describe": {
"columns": [],
"parameters": {
@@ -11,5 +11,5 @@
},
"nullable": []
},
"hash": "1c2157ce14e90f0751d7f0a9f2dbb3c5a5789a32423e75260098a5300a4af986"
"hash": "86ad1e7ebe659f97877cc142c09676488dc6c80428e13e5db3c016e37072cfe4"
}
@@ -0,0 +1,47 @@
{
"db_name": "PostgreSQL",
"query": "SELECT u.username, u.is_admin, u.operator, u.disabled, EXISTS (SELECT 1 FROM password p WHERE p.email = u.email AND p.disabled) AS \"instance_disabled!\" FROM usr u WHERE u.workspace_id = $1 AND u.email = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "is_admin",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "operator",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "disabled",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "instance_disabled!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false,
false,
null
]
},
"hash": "8bdfc02e7be54c2b610fed11cce75f8d2f1665d329b8adea424b3a6f1e5c7013"
}
@@ -0,0 +1,66 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, email, name, kind, version, created_by, content, shared_at\n FROM ai_shared_artifact\n WHERE workspace_id = $1 AND id = $2\n AND shared_at > now() - ($3::bigint::text || ' s')::interval",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "kind",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "version",
"type_info": "Int4"
},
{
"ordinal": 5,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "content",
"type_info": "Text"
},
{
"ordinal": 7,
"name": "shared_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Uuid",
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "945230149990abda67fdf4779529207306fb19756ab9c3a3d7824a15542a5b42"
}
@@ -1,18 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO resource_type (workspace_id, name, schema, description, format_extension, edited_at)\n VALUES ('admins', $1, $2, $3, $4, now())\n ON CONFLICT (workspace_id, name) DO UPDATE\n SET schema = EXCLUDED.schema, description = EXCLUDED.description,\n -- A fileset is a set of files, so it cannot also be one file.\n -- Create and update reject the pair; this writer bypasses both, so\n -- it declines the extension rather than persisting the forbidden\n -- combination onto a same-named local fileset.\n format_extension = CASE\n WHEN resource_type.is_fileset THEN NULL\n WHEN $5 THEN EXCLUDED.format_extension\n ELSE resource_type.format_extension END,\n edited_at = now()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Jsonb",
"Text",
"Varchar",
"Bool"
]
},
"nullable": []
},
"hash": "972df41db505fbbd20a558b200a2e2e8bc43633707d8365f73130c5bca3923b9"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "SELECT super_admin, devops, login_type FROM password WHERE email = $1 AND disabled = false",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "super_admin",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "devops",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "login_type",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "a2be5aeb7e663b0fe403726b4a41a7760b21c7edb651452c80b46b54ec964901"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_queue SET suspend = 0, suspend_until = NULL WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "a72081cb042f09034338dcb49381e91093233cf16af0dae666b4743f3878b22e"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO usr (workspace_id, username, email, is_admin, operator)\n SELECT $1::varchar, u.username, u.email, true, false FROM usr u\n WHERE u.workspace_id = $2 AND u.email = $3 AND u.is_admin AND NOT u.operator AND NOT u.disabled\n AND NOT EXISTS (SELECT 1 FROM password p WHERE p.email = u.email AND p.disabled)\n AND NOT EXISTS (SELECT 1 FROM usr f WHERE f.workspace_id = $1::varchar AND f.email = $3)\n ON CONFLICT DO NOTHING\n RETURNING username",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Varchar",
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "a80a18774baf36d09b07da1e4e30baab26b8160929aea6d8f5f226e6ec4f8bd8"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT consumed_at IS NOT NULL AS \"used!\" FROM login_link WHERE token_hash = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "used!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Bpchar"
]
},
"nullable": [
null
]
},
"hash": "ab16363a5225b022c7262f3caf5cd21ed1fbcc989fcc020c8246d5e2a313b72c"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO cloud_trial_offer (email, created_by) VALUES ($1, $2)\n ON CONFLICT (email) DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar"
]
},
"nullable": []
},
"hash": "b2855a7bf20ec5a405d8c059e7b3b635507f545a514ff0d9142c663b779dd961"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) WHERE id = $1 RETURNING suspend",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "suspend",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false
]
},
"hash": "b663e6baf2f8da00c6d94e5b8e35be9d9f51071978c0e48df4284d8b90000a4a"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS (SELECT 1 FROM usr WHERE workspace_id = $1 AND username = $2) AS \"claimed!\"",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "claimed!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "b675c20bb7a15bec5e9a34d7ddf347e22196e46727487f35634af079c71c2bef"
}
@@ -1,24 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_status SET\n workflow_as_code_status = jsonb_set(\n jsonb_set(\n workflow_as_code_status,\n array[$1],\n COALESCE(workflow_as_code_status->$1, '{}'::jsonb)\n ),\n array[$1, 'duration_ms'],\n to_jsonb($2::bigint)\n )\n WHERE id = $3 AND workflow_as_code_status IS NOT NULL\n RETURNING workflow_as_code_status->'_checkpoint'->'pending_steps'->'job_ids' AS \"job_ids: serde_json::Value\"",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "job_ids: serde_json::Value",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
"Int8",
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "c331609952e0b98d36f605bd5d2933aa54523bf44d63e304440e53b9eadd5340"
}
@@ -0,0 +1,55 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, name, kind, version, created_by, shared_at FROM ai_shared_artifact\n WHERE workspace_id = $1 AND email = $2 AND artifact_id = $3\n AND shared_at > now() - ($4::bigint::text || ' s')::interval",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "kind",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "version",
"type_info": "Int4"
},
{
"ordinal": 4,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "shared_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false,
false
]
},
"hash": "d2861932a739887785658cdf89a804306fe2083928e57458ba936d6afde53b57"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO resource_type\n (workspace_id, name, schema, description, created_by, format_extension, is_fileset, edited_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, now())",
"query": "INSERT INTO resource_type\n (workspace_id, name, schema, description, created_by, format_extension, is_fileset, display_name, edited_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())",
"describe": {
"columns": [],
"parameters": {
@@ -11,10 +11,11 @@
"Text",
"Varchar",
"Varchar",
"Bool"
"Bool",
"Varchar"
]
},
"nullable": []
},
"hash": "5899c7614f195fdd23e38389e52b004f957aafa2201b80638b5f87a625373f00"
"hash": "dd6f4b505f4c1e2c734c5d04528c95bd6b3fb6ebf3ba115160573487cee4a606"
}
@@ -0,0 +1,25 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM ai_shared_artifact\n WHERE workspace_id = $1 AND id = $2 AND (email = $3 OR $4::bool)\n RETURNING name",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Uuid",
"Text",
"Bool"
]
},
"nullable": [
false
]
},
"hash": "e50660f58274e9c135ace356ea8107739baa9ee276a99f481335fec8099f4d51"
}
+101 -108
View File
@@ -2425,9 +2425,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.6.6"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
dependencies = [
"clap_builder",
"clap_derive",
@@ -2435,9 +2435,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.6.6"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
dependencies = [
"anstream",
"anstyle",
@@ -2447,9 +2447,9 @@ dependencies = [
[[package]]
name = "clap_derive"
version = "4.6.4"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
dependencies = [
"heck",
"proc-macro2",
@@ -2459,9 +2459,9 @@ dependencies = [
[[package]]
name = "clap_lex"
version = "1.1.0"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
[[package]]
name = "cmake"
@@ -5177,9 +5177,9 @@ dependencies = [
[[package]]
name = "frostem"
version = "1.20260821.5"
version = "1.20260821.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36a80a7406da302e04bfd2ca987907590d3a1f3c69958947c43890abd7426b2f"
checksum = "d7d51501290db793146d5005edc29d3feac221da5702a4c63436344841ea88d4"
[[package]]
name = "fs3"
@@ -7442,9 +7442,9 @@ dependencies = [
[[package]]
name = "lru-slab"
version = "0.1.2"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
[[package]]
name = "lscolors"
@@ -9851,9 +9851,9 @@ dependencies = [
[[package]]
name = "quinn"
version = "0.11.11"
version = "0.11.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
dependencies = [
"bytes",
"cfg_aliases",
@@ -9871,9 +9871,9 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.17"
version = "0.11.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc"
dependencies = [
"aws-lc-rs",
"bytes",
@@ -13059,9 +13059,9 @@ dependencies = [
[[package]]
name = "textwrap"
version = "0.16.3"
version = "0.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b81c0cb5fce14f53e49c1d4da0c508334ff12040221bb8ab01b2dabd91d04b6e"
checksum = "6ecfad6c3abc80a577f2b91c1e412ee57e7a060d430b553c1b0c940974ebcd49"
dependencies = [
"icu_segmenter",
"unicode-width 0.2.2",
@@ -13251,18 +13251,9 @@ dependencies = [
[[package]]
name = "tinyvec"
version = "1.13.2"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tinyvector"
@@ -14792,7 +14783,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"async-nats",
@@ -14880,7 +14871,7 @@ dependencies = [
[[package]]
name = "windmill-ai"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"async-stream",
"async-trait",
@@ -14913,7 +14904,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14926,7 +14917,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"argon2",
@@ -15066,7 +15057,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15089,7 +15080,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15106,7 +15097,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15132,7 +15123,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -15142,7 +15133,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15159,7 +15150,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"axum 0.8.9",
"base64 0.22.1",
@@ -15181,7 +15172,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15204,7 +15195,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15220,7 +15211,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15242,7 +15233,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15253,6 +15244,7 @@ dependencies = [
"serde_json",
"sql-builder",
"sqlx",
"tracing",
"uuid",
"windmill-api-auth",
"windmill-api-workspaces",
@@ -15263,7 +15255,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15277,7 +15269,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"async-nats",
@@ -15312,7 +15304,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15337,7 +15329,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15365,7 +15357,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15387,7 +15379,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15407,7 +15399,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15445,7 +15437,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15474,7 +15466,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"lazy_static",
"serde",
@@ -15486,7 +15478,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"argon2",
"axum 0.8.9",
@@ -15510,7 +15502,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15524,7 +15516,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15559,7 +15551,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"chrono",
"lazy_static",
@@ -15573,7 +15565,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15592,7 +15584,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -15698,7 +15690,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"chrono",
"futures",
@@ -15718,7 +15710,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"regex",
"serde",
@@ -15727,6 +15719,7 @@ dependencies = [
"tokio",
"tracing",
"uuid",
"windmill-audit",
"windmill-common",
"windmill-dep-map",
"windmill-queue",
@@ -15734,7 +15727,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -15761,7 +15754,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"futures",
@@ -15778,7 +15771,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -15794,7 +15787,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15815,7 +15808,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15846,7 +15839,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -15871,7 +15864,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"async-stream",
@@ -15906,7 +15899,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"futures",
@@ -15924,7 +15917,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -15933,7 +15926,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15945,7 +15938,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"serde_json",
@@ -15957,7 +15950,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"gosyn",
@@ -15969,7 +15962,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15981,7 +15974,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"serde_json",
@@ -15993,7 +15986,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -16004,7 +15997,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16015,7 +16008,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16027,7 +16020,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -16038,7 +16031,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16060,7 +16053,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"serde_json",
@@ -16072,7 +16065,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16086,7 +16079,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -16103,7 +16096,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16116,7 +16109,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"serde",
@@ -16128,7 +16121,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16146,7 +16139,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -16162,7 +16155,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -16178,7 +16171,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16192,7 +16185,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16231,7 +16224,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"const_format",
@@ -16271,7 +16264,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -16282,7 +16275,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16317,7 +16310,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16341,7 +16334,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16374,7 +16367,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-amqp"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16401,7 +16394,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-azure"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16434,7 +16427,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16454,7 +16447,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16488,7 +16481,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16524,7 +16517,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16547,7 +16540,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16571,7 +16564,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"async-nats",
@@ -16595,7 +16588,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16630,7 +16623,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16658,7 +16651,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16683,7 +16676,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"bitflags 2.13.2",
@@ -16702,7 +16695,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"async-once-cell",
@@ -16820,7 +16813,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"bytes",
"futures",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.811.1"
version = "1.812.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.811.1"
version = "1.812.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
e6e5dc7d20bb7143231a2bede7f53c430aeaa2c1
5c853e2c20eca6b748415fc0d6862a6ebfb5fec4
@@ -0,0 +1 @@
DROP TABLE IF EXISTS login_link;
@@ -0,0 +1,13 @@
-- Single-use login links minted by a superadmin for one account. Consumed by an
-- unauthenticated GET that mints a session; the row is never a bearer credential itself.
CREATE TABLE login_link (
token_hash CHAR(64) PRIMARY KEY,
email VARCHAR(255) NOT NULL REFERENCES password(email) ON DELETE CASCADE ON UPDATE CASCADE,
rd TEXT,
expiration TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ,
created_by VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX login_link_email_idx ON login_link (email);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS cloud_trial_offer;
@@ -0,0 +1,11 @@
-- A pre-approved self-hosted Enterprise trial offered to an account created through a
-- pre-approved invite. No expiry: the offer lasts until a trial or subscription exists.
-- The cascade follows the account out on deletion and rename. A superadmin users-import
-- replaces every account by deleting and reinserting it, which takes these rows with it:
-- the offers, like the onboarding profiles, are recorded by the portal that minted them.
CREATE TABLE cloud_trial_offer (
email VARCHAR(255) PRIMARY KEY REFERENCES password(email) ON DELETE CASCADE ON UPDATE CASCADE,
consumed_at TIMESTAMPTZ,
created_by VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS cloud_onboarding_profile;
@@ -0,0 +1,9 @@
-- Context an invite carried about the account's owner, written at provisioning and read by
-- onboarding to tailor itself (skip the source question it knows the answer to, later
-- template picks and starter prompts). Free-form JSON so new fields need no migration.
CREATE TABLE cloud_onboarding_profile (
email VARCHAR(255) PRIMARY KEY REFERENCES password(email) ON DELETE CASCADE ON UPDATE CASCADE,
profile JSONB NOT NULL,
created_by VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
@@ -0,0 +1 @@
ALTER TABLE resource_type DROP COLUMN display_name;
@@ -0,0 +1,24 @@
-- The name a product goes by, beside the identifier a resource references: `gsheets` is
-- "Google Sheets". Null where nobody named the type; readers derive a label from the name.
ALTER TABLE resource_type ADD COLUMN display_name VARCHAR(100);
-- The names the hub carries today, so existing instances show them before any sync. Only in
-- admins, where hub resource types live and every workspace reads them from.
UPDATE resource_type SET display_name = v.display_name
FROM (VALUES
('bamboo_hr', 'BambooHR'),
('cacertificate', 'CA certificate'),
('deep_infra', 'DeepInfra'),
('gcal', 'Google Calendar'),
('gdocs', 'Google Docs'),
('gdrive', 'Google Drive'),
('gforms', 'Google Forms'),
('gsheets', 'Google Sheets'),
('gworkspace', 'Google Workspace'),
('sensortower', 'Sensor Tower'),
('snowflake_oauth', 'Snowflake (OAuth)'),
('their_stack', 'TheirStack')
) AS v(name, display_name)
WHERE resource_type.workspace_id = 'admins'
AND resource_type.name = v.name
AND resource_type.display_name IS NULL;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS ai_shared_artifact;
@@ -0,0 +1,32 @@
-- A copy of an AI session artifact that its author explicitly shared with the workspace.
-- Artifacts otherwise live only in the author's browser; this row exists only while the
-- share does, and the monitor deletes it once `shared_at` falls outside
-- AI_SHARED_ARTIFACT_RETENTION_SECS.
CREATE TABLE ai_shared_artifact (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
-- The browser-side artifact id. Unique per author so sharing the same artifact again
-- moves its one link forward rather than minting a second one.
artifact_id VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
created_by VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
kind VARCHAR(10) NOT NULL CHECK (kind IN ('md', 'html')),
version INTEGER NOT NULL,
content TEXT NOT NULL,
-- Reset on every re-share: retention counts from the last time the author shared it.
shared_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (workspace_id, email, artifact_id)
);
CREATE INDEX idx_ai_shared_artifact_shared_at ON ai_shared_artifact (shared_at);
GRANT ALL ON ai_shared_artifact TO windmill_admin;
GRANT ALL ON ai_shared_artifact TO windmill_user;
-- The handlers go through the raw pool and scope every query to the workspace themselves.
-- An admin-only policy is the backstop for a future query that reaches this table through
-- UserDB.
ALTER TABLE ai_shared_artifact ENABLE ROW LEVEL SECURITY;
CREATE POLICY admin_policy ON ai_shared_artifact FOR ALL TO windmill_admin USING (true);
+24 -24
View File
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6274,7 +6274,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"proc-macro2",
"quote",
@@ -6286,7 +6286,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"convert_case",
"serde",
@@ -6295,7 +6295,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6307,7 +6307,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6319,7 +6319,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"gosyn",
@@ -6331,7 +6331,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6343,7 +6343,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6355,7 +6355,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -6366,7 +6366,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6377,7 +6377,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6389,7 +6389,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6400,7 +6400,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -6422,7 +6422,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6434,7 +6434,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6448,7 +6448,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"convert_case",
@@ -6465,7 +6465,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6478,7 +6478,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"serde",
@@ -6490,7 +6490,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6508,7 +6508,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6524,7 +6524,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6540,7 +6540,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6572,7 +6572,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6586,7 +6586,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.811.1"
version = "1.812.0"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.811.1"
version = "1.812.0"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+56 -11
View File
@@ -411,6 +411,13 @@ struct HubResourceTypeRaw {
/// Absent from hubs predating the column, and from caches written before it.
#[serde(default)]
pub format_extension: Option<String>,
/// Doubly optional, so a hub predating the field (no key) is told apart from a type the
/// hub leaves unnamed (null).
#[serde(
default,
deserialize_with = "windmill_common::more_serde::double_option"
)]
pub display_name: Option<Option<String>>,
}
@@ -434,6 +441,14 @@ pub struct HubResourceType {
skip_serializing_if = "Option::is_none"
)]
pub format_extension: Option<Option<String>>,
/// Doubly optional like `format_extension`: a cache written before the field leaves the
/// stored name alone, while a null from the hub clears it.
#[serde(
default,
deserialize_with = "windmill_common::more_serde::double_option",
skip_serializing_if = "Option::is_none"
)]
pub display_name: Option<Option<String>>,
}
const HUB_RT_CACHE_FILE: &str = "resource_types.json";
@@ -481,6 +496,7 @@ async fn cache_hub_resource_types() -> anyhow::Result<()> {
app: rt.app,
description: rt.description,
format_extension: Some(rt.format_extension),
display_name: rt.display_name,
})
})
.collect();
@@ -531,8 +547,9 @@ pub async fn sync_cached_resource_types(db: &sqlx::Pool<sqlx::Postgres>) -> anyh
Option<String>,
Option<String>,
bool,
Option<String>,
)> = sqlx::query_as(
"SELECT name, schema, description, format_extension, is_fileset FROM resource_type WHERE workspace_id = 'admins'",
"SELECT name, schema, description, format_extension, is_fileset, display_name FROM resource_type WHERE workspace_id = 'admins'",
)
.fetch_all(db)
.await
@@ -540,12 +557,23 @@ pub async fn sync_cached_resource_types(db: &sqlx::Pool<sqlx::Postgres>) -> anyh
let existing_map: std::collections::HashMap<
String,
(Option<serde_json::Value>, Option<String>, Option<String>, bool),
(
Option<serde_json::Value>,
Option<String>,
Option<String>,
bool,
Option<String>,
),
> = existing_types
.into_iter()
.map(|(name, schema, desc, format_extension, is_fileset)| {
(name, (schema, desc, format_extension, is_fileset))
})
.map(
|(name, schema, desc, format_extension, is_fileset, display_name)| {
(
name,
(schema, desc, format_extension, is_fileset, display_name),
)
},
)
.collect();
let mut synced_count = 0;
@@ -553,8 +581,9 @@ pub async fn sync_cached_resource_types(db: &sqlx::Pool<sqlx::Postgres>) -> anyh
for rt in cached_types {
let existing = existing_map.get(&rt.name);
let is_fileset = existing.map(|(_, _, _, f)| *f).unwrap_or(false);
let stored_extension = existing.and_then(|(_, _, e, _)| e.clone());
let is_fileset = existing.map(|(_, _, _, f, _)| *f).unwrap_or(false);
let stored_extension = existing.and_then(|(_, _, e, _, _)| e.clone());
let stored_display_name = existing.and_then(|(_, _, _, _, n)| n.clone());
// A fileset is a set of files, so it cannot also be one file. Create, update
// and the manual sync all reject the pair; this writer would otherwise
// persist it onto a same-named local fileset.
@@ -570,11 +599,25 @@ pub async fn sync_cached_resource_types(db: &sqlx::Pool<sqlx::Postgres>) -> anyh
None => stored_extension.clone(),
}
};
// No key in the cache leaves the stored name alone, as for the extension. So does a name
// too long for the column: one bad entry must not fail the upsert and end the sync.
let display_name = match &rt.display_name {
Some(Some(name)) if name.chars().count() > 100 => {
tracing::warn!(
"Ignoring the display_name of resource type {}: longer than 100 characters",
rt.name
);
stored_display_name.clone()
}
Some(from_cache) => from_cache.clone(),
None => stored_display_name.clone(),
};
if let Some((existing_schema, existing_desc, _, _)) = existing {
if let Some((existing_schema, existing_desc, _, _, _)) = existing {
if existing_schema == &rt.schema
&& existing_desc == &rt.description
&& stored_extension == format_extension
&& stored_display_name == display_name
{
skipped_count += 1;
continue;
@@ -586,16 +629,18 @@ pub async fn sync_cached_resource_types(db: &sqlx::Pool<sqlx::Postgres>) -> anyh
// `format_extension` is resolved above rather than coalesced here: a
// COALESCE could never clear one, so a hub that dropped an extension
// would leave the stale value behind forever.
"INSERT INTO resource_type (workspace_id, name, schema, description, format_extension, edited_at)
VALUES ('admins', $1, $2, $3, $4, now())
"INSERT INTO resource_type (workspace_id, name, schema, description, format_extension, display_name, edited_at)
VALUES ('admins', $1, $2, $3, $4, $5, now())
ON CONFLICT (workspace_id, name) DO UPDATE
SET schema = EXCLUDED.schema, description = EXCLUDED.description,
format_extension = EXCLUDED.format_extension, edited_at = now()",
format_extension = EXCLUDED.format_extension,
display_name = EXCLUDED.display_name, edited_at = now()",
)
.bind(&rt.name)
.bind(&rt.schema)
.bind(&rt.description)
.bind(&format_extension)
.bind(&display_name)
.execute(db)
.await
.with_context(|| format!("Failed to upsert resource type {}", rt.name))?;
+72 -6
View File
@@ -1784,6 +1784,23 @@ pub async fn delete_expired_items(db: &DB) -> () {
Err(e) => tracing::error!("Error deleting token: {}", e.to_string()),
}
let expired_login_links_r: std::result::Result<Vec<String>, _> =
// Expired rows stay a day so an open still reports "expired" rather than "invalid".
sqlx::query_scalar(
"DELETE FROM login_link WHERE expiration <= now() - interval '1 day' RETURNING token_hash",
)
.fetch_all(db)
.await;
match expired_login_links_r {
Ok(hashes) => {
if !hashes.is_empty() {
tracing::info!("deleted {} expired login links", hashes.len())
}
}
Err(e) => tracing::error!("Error deleting login links: {}", e.to_string()),
}
let pip_resolution_r = sqlx::query_scalar!(
"DELETE FROM pip_resolution_cache WHERE expiration <= now() RETURNING hash",
)
@@ -1895,6 +1912,17 @@ pub async fn delete_expired_items(db: &DB) -> () {
tracing::info!("deleted {} expired otel trace spans", deleted_spans);
}
if let Err(e) = sqlx::query!(
"DELETE FROM ai_shared_artifact
WHERE shared_at <= now() - ($1::bigint::text || ' s')::interval",
windmill_common::ai_shared_artifact_retention_secs(),
)
.execute(db)
.await
{
tracing::error!("Error deleting expired shared AI artifacts: {:?}", e);
}
let audit_retention_days = audit_log_retention_days().await;
let audit_retention_secs: i64 = audit_retention_days * 60 * 60 * 24;
@@ -6151,7 +6179,10 @@ async fn handle_zombie_jobs(db: &Pool<Postgres>, base_internal_url: &str, node_n
/// Force-complete a zombie job that handle_job_error failed to complete.
/// This is a minimal fallback: it inserts a failed completed job and deletes
/// from the queue in a single transaction, without schedule pushing or
/// error handler logic that could cause the completion to fail.
/// error handler logic. The one thing it keeps is the WAC parent notification,
/// deliberately inside the transaction: if that fails, the whole completion
/// rolls back and the job waits for the next sweep, which is cheaper than a
/// parent parked for its full suspend window and a task run twice.
async fn force_complete_zombie_job(
db: &Pool<Postgres>,
job_id: &Uuid,
@@ -6173,14 +6204,18 @@ async fn force_complete_zombie_job(
"Zombie job {job_id} was not completed by handle_job_error, force-completing it"
);
// Same `{"error": ...}` shape as every other failed job's result, so a WAC
// parent's failure record reads the name and message like any task failure.
let error_value = serde_json::json!({
"message": error_message,
"name": "ExecutionErr",
"error": {
"message": error_message,
"name": "ExecutionErr",
}
});
let mut tx = db.begin().await?;
sqlx::query!(
let duration_ms = sqlx::query_scalar!(
"INSERT INTO v2_job_completed
(workspace_id, id, started_at, duration_ms, result, memory_peak, status, worker)
SELECT q.workspace_id, q.id, q.started_at,
@@ -6189,19 +6224,50 @@ async fn force_complete_zombie_job(
FROM v2_job_queue q
LEFT JOIN v2_job_runtime r ON r.id = q.id
WHERE q.id = $1
ON CONFLICT (id) DO UPDATE SET status = 'failure', result = $2::jsonb",
ON CONFLICT (id) DO UPDATE SET status = 'failure', result = $2::jsonb
RETURNING duration_ms AS \"duration_ms!\"",
job_id,
error_value,
)
.execute(&mut *tx)
.fetch_optional(&mut *tx)
.await?;
// A WAC parent parked on this job must learn of the failure here too, or it
// waits out its whole suspend window and runs the task again.
let mut wac_parent_ready = false;
if let Some(duration_ms) = duration_ms {
let parent = sqlx::query!(
"SELECT parent_job, flow_step_id FROM v2_job WHERE id = $1",
job_id
)
.fetch_optional(&mut *tx)
.await?;
if let Some(parent_job) = parent
.filter(|j| j.flow_step_id.is_none())
.and_then(|j| j.parent_job)
{
wac_parent_ready = windmill_common::wac::record_child_completion(
&mut tx,
&parent_job,
job_id,
false,
duration_ms,
&error_value.to_string(),
)
.await?;
}
}
sqlx::query!("DELETE FROM v2_job_queue WHERE id = $1", job_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
if wac_parent_ready {
windmill_common::wac::WAC_SUSPEND_READY.store(true, Ordering::Relaxed);
}
tracing::info!("Force-completed zombie job {job_id}");
Ok(())
}
+3 -1
View File
@@ -40,6 +40,8 @@ agent_token_blacklist: token(char), expires_at(ts), blacklisted_at(ts), blacklis
ai_agent_memory: workspace_id(char), conversation_id(uuid), step_id(char), messages(jsonb), created_at(ts), updated_at(ts)
ai_free_token_daily_usage: day(date), cost_nanos(bigint), updated_at(ts)
ai_free_token_usage: email(char), cost_nanos(bigint), updated_at(ts)
ai_shared_artifact: id(uuid), workspace_id(char), artifact_id(char), email(char), created_by(char), name(char), kind(char), version(int), content(text), shared_at(ts)
FK: (workspace_id) -> workspace(id)
ai_token_usage: workspace_id(char), day(date), email(char), provider(char), model(char), session_id(char), input_tokens(bigint), cache_read_tokens(bigint), cache_write_tokens(bigint), output_tokens(bigint), reported_cost_nano_usd(bigint), requests(bigint), updated_at(ts)
FK: (workspace_id) -> workspace(id)
alerts: id(int), alert_type(char), message(text), created_at(ts), acknowledged(bool), workspace_id(text), acknowledged_workspace(bool), resource(text)
@@ -171,7 +173,7 @@ raw_app: path(char), version(int), workspace_id(char), summary(char), edited_at(
FK: (workspace_id) -> workspace(id)
resource: workspace_id(char), path(char), value(jsonb), description(text), resource_type(char), extra_perms(jsonb), edited_at(ts), created_by(char), labels(text[])
FK: (workspace_id) -> workspace(id)
resource_type: workspace_id(char), name(char), schema(jsonb), description(text), edited_at(ts), created_by(char), format_extension(char), is_fileset(bool)
resource_type: workspace_id(char), name(char), schema(jsonb), description(text), edited_at(ts), created_by(char), format_extension(char), is_fileset(bool), display_name(char)
FK: (workspace_id) -> workspace(id)
resume_job: id(uuid), job(uuid), flow(uuid), created_at(ts), value(jsonb), approver(char), resume_id(int), approved(bool)
FK: (flow) -> v2_job_queue(id)
+184
View File
@@ -0,0 +1,184 @@
//! Shared AI session artifacts: one link per author and artifact, readable by any workspace
//! member until its retention window passes, and removable only by its author or an admin.
//!
//! Expiry is enforced on read as well as by the monitor's sweep, so a share past its window must
//! not be served in the gap before the sweep reaches it.
use serde_json::{json, Value};
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
const ADMIN: &str = "Bearer SECRET_TOKEN";
const MEMBER: &str = "Bearer SECRET_TOKEN_2";
async fn share(
client: &reqwest::Client,
base: &str,
token: &str,
content: &str,
) -> anyhow::Result<Value> {
let resp = client
.post(format!("{base}/share"))
.header("Authorization", token)
.json(&json!({
"artifact_id": "plan:session-1",
"name": "Plan",
"kind": "md",
"version": 1,
"content": content,
}))
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
Ok(resp.json().await?)
}
#[sqlx::test(fixtures("base", "ai_shared_artifacts"))]
async fn shared_artifact_is_served_to_members_until_it_expires(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base = format!(
"http://localhost:{}/api/w/test-workspace/ai/shared_artifacts",
server.addr.port()
);
let client = reqwest::Client::new();
let first = share(&client, &base, MEMBER, "draft").await?;
let second = share(&client, &base, MEMBER, "final").await?;
assert_eq!(first["id"], second["id"], "re-sharing minted a second link");
let id = second["id"].as_str().unwrap();
let resp = client
.get(format!("{base}/get/{id}"))
.header("Authorization", ADMIN)
.send()
.await?;
assert_eq!(resp.status(), 200);
let body: Value = resp.json().await?;
assert_eq!(body["content"], "final");
// The handlers read through the raw pool, so the workspace in the URL is the only thing
// scoping a share: a member of another workspace must not reach it by id through theirs.
let resp = client
.get(format!(
"http://localhost:{}/api/w/test-workspace-2/ai/shared_artifacts/get/{id}",
server.addr.port()
))
.header("Authorization", ADMIN)
.send()
.await?;
assert_eq!(
resp.status(),
404,
"a share was served through another workspace's path"
);
sqlx::query(
"UPDATE ai_shared_artifact SET shared_at = now() - ($1::bigint + 60) * interval '1 second'",
)
.bind(windmill_common::ai_shared_artifact_retention_secs())
.execute(&db)
.await?;
let resp = client
.get(format!("{base}/get/{id}"))
.header("Authorization", ADMIN)
.send()
.await?;
assert_eq!(resp.status(), 404, "an expired share was served");
let status: Value = client
.get(format!("{base}/status?artifact_id=plan:session-1"))
.header("Authorization", MEMBER)
.send()
.await?
.json()
.await?;
assert!(
status.get("share").is_none(),
"an expired share was reported live: {status}"
);
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn only_the_author_or_an_admin_can_unshare(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base = format!(
"http://localhost:{}/api/w/test-workspace/ai/shared_artifacts",
server.addr.port()
);
let client = reqwest::Client::new();
let shared = share(&client, &base, ADMIN, "admin's plan").await?;
let id = shared["id"].as_str().unwrap();
let resp = client
.delete(format!("{base}/delete/{id}"))
.header("Authorization", MEMBER)
.send()
.await?;
assert_eq!(resp.status(), 404);
let remaining: i64 = sqlx::query_scalar("SELECT count(*) FROM ai_shared_artifact")
.fetch_one(&db)
.await?;
assert_eq!(remaining, 1, "a member deleted someone else's share");
let member_share = share(&client, &base, MEMBER, "member's plan").await?;
let resp = client
.delete(format!(
"{base}/delete/{}",
member_share["id"].as_str().unwrap()
))
.header("Authorization", ADMIN)
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
Ok(())
}
/// The id is compared against a `VARCHAR(255)` column on every route that takes one, and a
/// NUL in it would otherwise reach Postgres and come back as a 500.
#[sqlx::test(fixtures("base"))]
async fn a_malformed_artifact_id_is_refused_on_every_route(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base = format!(
"http://localhost:{}/api/w/test-workspace/ai/shared_artifacts",
server.addr.port()
);
let client = reqwest::Client::new();
for bad_id in ["", "a\0b", &"x".repeat(256)] {
let resp = client
.get(format!("{base}/status"))
.query(&[("artifact_id", bad_id)])
.header("Authorization", MEMBER)
.send()
.await?;
assert_eq!(resp.status(), 400, "status accepted {bad_id:?}");
let resp = client
.post(format!("{base}/share"))
.header("Authorization", MEMBER)
.json(&json!({
"artifact_id": bad_id,
"name": "Plan",
"kind": "md",
"version": 1,
"content": "x",
}))
.send()
.await?;
assert_eq!(resp.status(), 400, "share accepted {bad_id:?}");
}
Ok(())
}
+17
View File
@@ -0,0 +1,17 @@
-- Layers on `base`: a second workspace the superadmin `test-user` is also a member of, so a
-- share can be requested through the wrong workspace's path by a caller the route accepts.
INSERT INTO workspace (id, name, owner) VALUES
('test-workspace-2', 'test-workspace-2', 'test-user');
INSERT INTO workspace_key(workspace_id, kind, key) VALUES
('test-workspace-2', 'cloud', 'test-key-2');
INSERT INTO workspace_settings (workspace_id) VALUES
('test-workspace-2');
INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES
('test-workspace-2', 'all', 'All users', '{}');
INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES
('test-workspace-2', 'test@windmill.dev', 'test-user', true, 'Admin');
+46
View File
@@ -0,0 +1,46 @@
-- A parent workspace whose auto-pulled repository was last saved by alice, and a fork of
-- it holding only carol, the non-admin who created it. aaron is an admin who sorts before
-- alice; bob is no longer an admin; dora is a workspace admin deactivated on the instance.
-- sam and sue are instance superadmins who are not members: sam's instance username is
-- carol's, sue's is unclaimed.
INSERT INTO workspace (id, name, owner) VALUES ('ap-parent', 'ap-parent', 'alice@windmill.dev');
INSERT INTO workspace (id, name, owner, parent_workspace_id)
VALUES ('wm-fork-feat', 'feat', 'carol@windmill.dev', 'ap-parent');
INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES
('ap-parent', 'all', 'All users', '{}'),
('wm-fork-feat', 'all', 'All users', '{}');
INSERT INTO password (email, password_hash, login_type, super_admin, verified, name, disabled) VALUES
('aaron@windmill.dev', 'not-a-real-hash', 'password', false, true, 'aaron', false),
('alice@windmill.dev', 'not-a-real-hash', 'password', false, true, 'alice', false),
('bob@windmill.dev', 'not-a-real-hash', 'password', false, true, 'bob', false),
('carol@windmill.dev', 'not-a-real-hash', 'password', false, true, 'carol', false),
('dora@windmill.dev', 'not-a-real-hash', 'password', false, true, 'dora', true);
INSERT INTO password (email, password_hash, login_type, super_admin, verified, name, disabled, username) VALUES
('sam@windmill.dev', 'not-a-real-hash', 'password', true, true, 'sam', false, 'carol'),
('sue@windmill.dev', 'not-a-real-hash', 'password', true, true, 'sue', false, 'sue');
INSERT INTO usr (workspace_id, email, username, is_admin) VALUES
('ap-parent', 'aaron@windmill.dev', 'aaron', true),
('ap-parent', 'alice@windmill.dev', 'alice', true),
('ap-parent', 'bob@windmill.dev', 'bob', false),
('ap-parent', 'carol@windmill.dev', 'carol', false),
('ap-parent', 'dora@windmill.dev', 'dora', true),
('wm-fork-feat', 'carol@windmill.dev', 'carol', false);
INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by) VALUES
('ap-parent', 'u/alice/repo', '{"url": "https://github.com/test/repo.git", "branch": "main"}',
'git_repository', '{}', 'alice'),
('wm-fork-feat', 'u/alice/repo', '{"url": "https://github.com/test/repo.git", "branch": "main"}',
'git_repository', '{}', 'alice');
INSERT INTO workspace_settings (workspace_id, git_sync) VALUES
('ap-parent', '{"repositories":[{"git_repo_resource_path":"$res:u/alice/repo",
"use_individual_branch":false,"group_by_folder":false,
"auto_pull":{"enabled":true,"mode":"polling","sync_forks":true,
"enabled_by":"alice@windmill.dev"}}]}'),
('wm-fork-feat', '{"repositories":[{"git_repo_resource_path":"$res:u/alice/repo",
"use_individual_branch":false,"group_by_folder":false}]}');
+194
View File
@@ -0,0 +1,194 @@
//! An automatic pull runs as the admin stamped on the repository's settings, never as
//! someone picked from the workspace, and stops once that admin is revoked. A fork's
//! pull runs as the parent's pull identity, added to the fork first.
#![cfg(all(feature = "enterprise", feature = "private"))]
use sqlx::{Pool, Postgres};
use windmill_common::workspaces::GitRepositorySettings;
use windmill_git_sync::{reconcile_and_enqueue_pull, reconcile_fork_branch_pull};
const PARENT: &str = "ap-parent";
const FORK: &str = "wm-fork-feat";
const REPO: &str = "$res:u/alice/repo";
fn repo_enabled_by(email: &str) -> GitRepositorySettings {
serde_json::from_value(serde_json::json!({
"git_repo_resource_path": REPO,
"use_individual_branch": false,
"group_by_folder": false,
"auto_pull": { "enabled": true, "enabled_by": email }
}))
.expect("repository settings")
}
/// `(created_by, permissioned_as, permissioned_as_email)` of every pull job in `w_id`.
async fn pull_identities(
db: &Pool<Postgres>,
w_id: &str,
) -> anyhow::Result<Vec<(String, String, Option<String>)>> {
Ok(sqlx::query_as(
"SELECT created_by, permissioned_as, permissioned_as_email FROM v2_job \
WHERE workspace_id = $1 AND kind = 'deploymentcallback'",
)
.bind(w_id)
.fetch_all(db)
.await?)
}
fn identity(username: &str) -> (String, String, Option<String>) {
(
username.to_string(),
format!("u/{username}"),
Some(format!("{username}@windmill.dev")),
)
}
async fn recorded_pull_error(db: &Pool<Postgres>, w_id: &str) -> anyhow::Result<String> {
let git_sync: serde_json::Value =
sqlx::query_scalar("SELECT git_sync FROM workspace_settings WHERE workspace_id = $1")
.bind(w_id)
.fetch_one(db)
.await?;
Ok(
git_sync["repositories"][0]["auto_pull"]["last_pull_status"]["error"]
.as_str()
.unwrap_or_default()
.to_string(),
)
}
#[sqlx::test(fixtures("git_sync_autopull_identity"))]
async fn pull_runs_as_the_admin_who_enabled_it(db: Pool<Postgres>) -> anyhow::Result<()> {
let job = reconcile_and_enqueue_pull(
&db,
PARENT,
&repo_enabled_by("alice@windmill.dev"),
"main",
"abc123",
None,
)
.await?;
assert!(job.is_some());
assert_eq!(pull_identities(&db, PARENT).await?, vec![identity("alice")]);
Ok(())
}
/// bob was demoted in the workspace; dora is still a workspace admin but deactivated on
/// the instance. Neither may run the pull, and the failure lands on the status rather
/// than as an error, which a webhook delivery would turn into a failed response.
#[sqlx::test(fixtures("git_sync_autopull_identity"))]
async fn pull_fails_on_the_status_once_the_enabling_admin_is_revoked(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
for email in ["bob@windmill.dev", "dora@windmill.dev"] {
let job = reconcile_and_enqueue_pull(
&db,
PARENT,
&repo_enabled_by(email),
"main",
"abc123",
None,
)
.await?;
assert!(job.is_none(), "{email} must not run the pull");
let error = recorded_pull_error(&db, PARENT).await?;
assert!(error.contains(email), "{error}");
}
assert!(pull_identities(&db, PARENT).await?.is_empty());
Ok(())
}
#[sqlx::test(fixtures("git_sync_autopull_identity"))]
async fn fork_pull_runs_as_the_parent_admin_added_to_the_fork(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
let job = reconcile_fork_branch_pull(&db, PARENT, REPO, "wm-fork/main/feat", "main", "abc123")
.await?;
assert!(
job.is_some(),
"the fork branch must route to the fork and enqueue"
);
let (is_admin, in_all): (bool, bool) = sqlx::query_as(
"SELECT u.is_admin, EXISTS (SELECT 1 FROM usr_to_group g \
WHERE g.workspace_id = u.workspace_id AND g.usr = u.username AND g.group_ = 'all') \
FROM usr u WHERE u.workspace_id = $1 AND u.email = 'alice@windmill.dev'",
)
.bind(FORK)
.fetch_one(&db)
.await?;
assert!(
is_admin && in_all,
"alice must be an admin member of the fork"
);
assert_eq!(pull_identities(&db, FORK).await?, vec![identity("alice")]);
let grants: i64 = sqlx::query_scalar(
"SELECT count(*) FROM audit_partitioned WHERE workspace_id = $1 \
AND operation = 'users.git_sync_fork_add' AND resource = 'alice@windmill.dev'",
)
.bind(FORK)
.fetch_one(&db)
.await?;
assert_eq!(grants, 1, "adding alice to the fork must be audited");
Ok(())
}
/// A superadmin who is not a member runs the pull under their instance username, and
/// `u/<username>` resolves through the workspace's members first. sam's instance username
/// is carol's, so sam's stamp must not run the pull as carol; sue's is unclaimed.
#[sqlx::test(fixtures("git_sync_autopull_identity"))]
async fn a_non_member_superadmin_runs_the_pull_only_under_an_unclaimed_username(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
let job = reconcile_and_enqueue_pull(
&db,
PARENT,
&repo_enabled_by("sam@windmill.dev"),
"main",
"abc123",
None,
)
.await?;
assert!(job.is_none(), "sam's username belongs to carol");
assert!(pull_identities(&db, PARENT).await?.is_empty());
let job = reconcile_and_enqueue_pull(
&db,
PARENT,
&repo_enabled_by("sue@windmill.dev"),
"main",
"abc123",
None,
)
.await?;
assert!(job.is_some());
assert_eq!(pull_identities(&db, PARENT).await?, vec![identity("sue")]);
Ok(())
}
/// With no stamp on the parent, the fork pull still runs as the parent's first active
/// admin: the fork holds only its non-admin creator, so no identity resolved in the fork
/// could run it.
#[sqlx::test(fixtures("git_sync_autopull_identity"))]
async fn unstamped_fork_pull_runs_as_the_parents_first_admin(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
sqlx::query(
"UPDATE workspace_settings SET git_sync = git_sync #- '{repositories,0,auto_pull,enabled_by}' \
WHERE workspace_id = $1",
)
.bind(PARENT)
.execute(&db)
.await?;
let job = reconcile_fork_branch_pull(&db, PARENT, REPO, "wm-fork/main/feat", "main", "abc123")
.await?;
assert!(
job.is_some(),
"an unstamped parent must still sync its forks"
);
assert_eq!(pull_identities(&db, FORK).await?, vec![identity("aaron")]);
Ok(())
}
@@ -0,0 +1,189 @@
//! A WAC v2 parent parks on its dispatched children and is woken by their
//! completions. A child does not always complete through the worker that ran it:
//! the zombie monitor and a force cancel both go straight to
//! `add_completed_job_error`. The parent must be woken from there too, or it sits
//! out its whole suspend window and then runs the task a second time.
use serde_json::{json, Value};
use sqlx::{types::Json, Pool, Postgres};
use uuid::Uuid;
use windmill_queue::{add_completed_job, add_completed_job_error, get_mini_completed_job};
const W_ID: &str = "test-workspace";
async fn insert_job(db: &Pool<Postgres>, id: Uuid, parent: Option<Uuid>) -> anyhow::Result<()> {
sqlx::query(
"INSERT INTO v2_job (id, workspace_id, created_by, created_at, permissioned_as, \
permissioned_as_email, kind, script_lang, runnable_path, tag, visible_to_owner, parent_job) \
VALUES ($1, $2, 'test-user', now(), 'u/test-user', 'test@windmill.dev', \
'script', 'bun', 'u/test-user/wac', 'bun', true, $3)",
)
.bind(id)
.bind(W_ID)
.bind(parent)
.execute(db)
.await?;
sqlx::query(
"INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, tag) \
VALUES ($1, $2, now(), true, 'bun')",
)
.bind(id)
.bind(W_ID)
.execute(db)
.await?;
Ok(())
}
/// A parent parked on `steps` (step key → child job), the shape
/// `handle_wac_v2_output` leaves behind once the children are pushed.
async fn plant_parked_parent(db: &Pool<Postgres>, steps: &[(&str, Uuid)]) -> anyhow::Result<Uuid> {
let parent = Uuid::new_v4();
insert_job(db, parent, None).await?;
sqlx::query(
"UPDATE v2_job_queue SET suspend = $2, suspend_until = now() + interval '14 days' \
WHERE id = $1",
)
.bind(parent)
.bind(steps.len() as i32)
.execute(db)
.await?;
let job_ids: serde_json::Map<String, Value> = steps
.iter()
.map(|(k, id)| (k.to_string(), json!(id.to_string())))
.collect();
let keys: Vec<&str> = steps.iter().map(|(k, _)| *k).collect();
sqlx::query("INSERT INTO v2_job_status (id, workflow_as_code_status) VALUES ($1, $2)")
.bind(parent)
.bind(json!({
"_checkpoint": {
"completed_steps": {},
"pending_steps": { "mode": "dispatch", "keys": keys, "job_ids": job_ids },
"job_ids": job_ids,
}
}))
.execute(db)
.await?;
for (_, child) in steps {
insert_job(db, *child, Some(parent)).await?;
}
Ok(parent)
}
async fn parent_state(db: &Pool<Postgres>, parent: Uuid) -> anyhow::Result<(i32, bool, Value)> {
let (suspend, parked, status): (i32, bool, Value) = sqlx::query_as(
"SELECT q.suspend, q.suspend_until IS NOT NULL, s.workflow_as_code_status \
FROM v2_job_queue q JOIN v2_job_status s USING (id) WHERE q.id = $1",
)
.bind(parent)
.fetch_one(db)
.await?;
Ok((suspend, parked, status))
}
/// The zombie monitor's path: `handle_job_error` → `add_completed_job_error`, never
/// the worker's result processor. The parent must come out of it pullable, with the
/// failure recorded under the step so the workflow's `try/catch` sees a task error.
#[sqlx::test(fixtures("base"))]
async fn a_child_failed_outside_the_worker_wakes_its_parent(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
let child = Uuid::new_v4();
let parent = plant_parked_parent(&db, &[("slowTask", child)]).await?;
let child_job = get_mini_completed_job(&child, W_ID, &db).await?.unwrap();
add_completed_job_error(
&db,
&child_job,
0,
None,
json!({"name": "ExecutionErr", "message": "Job timed out after no ping"}),
"monitor",
false,
None,
)
.await?;
let (suspend, parked, status) = parent_state(&db, parent).await?;
assert_eq!(suspend, 0, "the parent must be released");
assert!(
parked,
"suspend_until stays set: the suspended pull query keys on it"
);
let step = &status["_checkpoint"]["completed_steps"]["slowTask"];
assert_eq!(step["__wmill_error"], json!(true), "{status}");
assert_eq!(step["child_job_id"], json!(child.to_string()));
assert_eq!(
step["result"]["error"]["message"],
json!("Job timed out after no ping")
);
assert!(
status["_checkpoint"].get("pending_steps").is_none(),
"nothing left to wait on: {status}"
);
assert!(
windmill_common::wac::WAC_SUSPEND_READY.swap(false, std::sync::atomic::Ordering::Relaxed)
);
Ok(())
}
/// Only a child the parent is waiting on moves the counter. A child the body
/// launched itself, or a completion arriving after the key was re-dispatched to
/// another job, records its timeline entry and nothing else.
#[sqlx::test(fixtures("base"))]
async fn a_child_the_parent_is_not_waiting_on_leaves_it_parked(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
let awaited = Uuid::new_v4();
let parent = plant_parked_parent(&db, &[("task", awaited)]).await?;
let stray = Uuid::new_v4();
insert_job(&db, stray, Some(parent)).await?;
let stray_job = get_mini_completed_job(&stray, W_ID, &db).await?.unwrap();
add_completed_job_error(
&db,
&stray_job,
0,
None,
json!({"message": "boom"}),
"w",
false,
None,
)
.await?;
let (suspend, _, status) = parent_state(&db, parent).await?;
assert_eq!(
suspend, 1,
"a stray child must not release the parent: {status}"
);
assert_eq!(status["_checkpoint"]["completed_steps"], json!({}));
assert!(
status[stray.to_string()]["duration_ms"].is_number(),
"the timeline entry is still stamped: {status}"
);
let awaited_job = get_mini_completed_job(&awaited, W_ID, &db).await?.unwrap();
let result = serde_json::value::to_raw_value(&json!("done"))?;
add_completed_job(
&db,
&awaited_job,
true,
false,
Json(&result),
None,
0,
None,
false,
None,
false,
)
.await?;
let (suspend, _, status) = parent_state(&db, parent).await?;
assert_eq!(suspend, 0);
assert_eq!(
status["_checkpoint"]["completed_steps"]["task"],
json!("done")
);
Ok(())
}
+77
View File
@@ -5649,6 +5649,83 @@ async fn test_whileloop_propagates_inner_iterator_eval_failure(
Ok(())
}
#[cfg(all(feature = "quickjs", feature = "python"))]
#[sqlx::test(fixtures("base"))]
async fn test_whileloop_skip_if_evaluated_once_at_entry(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
// Regression test for #11007: `skip_if` on a while-loop module must be
// evaluated once, at loop entry, using the preceding step's result.
// Re-evaluating it on every iteration aliases `results.first` to the
// previous iteration's own result instead, which here lacks `.ok` and
// makes `skip_if` incorrectly turn true after the first iteration.
let port = 123;
let flow: FlowValue = serde_json::from_value(serde_json::json!({
"modules": [
{
"id": "first",
"value": {
"type": "rawscript",
"language": "python3",
"content": "def main(): return {\"ok\": True}",
},
},
{
"id": "outer",
"value": {
"type": "whileloopflow",
"skip_failures": false,
"modules": [
{
"id": "inner",
"value": {
"input_transforms": {
"i": {
"type": "javascript",
"expr": "flow_input.iter.index",
},
},
"type": "rawscript",
"language": "python3",
"content": "def main(i): return i",
},
},
],
},
"skip_if": { "expr": "!results.first.ok" },
"stop_after_if": {
"expr": "result >= 2",
"skip_if_stopped": false,
},
},
],
}))
.unwrap();
let job = JobPayload::RawFlow { value: flow, path: None, restarted_from: None };
let cjob = RunJob::from(job).run_until_complete(&db, false, port).await;
assert!(cjob.success, "flow should succeed");
let outer_module = get_module(&cjob, "outer").expect("outer module status");
match outer_module {
windmill_common::flow_status::FlowStatusModule::Success { skipped, flow_jobs, .. } => {
assert!(
!skipped,
"while-loop must not be skipped: skip_if should only run once, at entry"
);
assert_eq!(
flow_jobs.map(|v| v.len()),
Some(3),
"while-loop should run 3 iterations before stop_after_if halts it"
);
}
other => panic!("expected outer module to be Success, got {other:?}"),
}
Ok(())
}
#[cfg(all(feature = "quickjs", feature = "python"))]
#[sqlx::test(fixtures("base"))]
async fn test_stop_after_all_iters_if_bad_expr_parallel_branchall(
+5
View File
@@ -103,6 +103,7 @@ struct AIAgentArgsRaw {
streaming: Option<bool>,
max_iterations: Option<usize>,
memory: Option<Memory>,
enabled_tools: Option<Vec<String>>,
// Legacy field for backward compatibility
messages_context_length: Option<usize>,
#[serde(default)]
@@ -123,6 +124,9 @@ pub struct AIAgentArgs {
pub streaming: Option<bool>,
pub max_iterations: Option<usize>,
pub memory: Option<Memory>,
/// Which of the agent's tools this run may call; `narrow_roster` holds what the names are and
/// what `None` means.
pub enabled_tools: Option<Vec<String>>,
pub credentials_check: bool,
}
@@ -155,6 +159,7 @@ impl From<AIAgentArgsRaw> for AIAgentArgs {
streaming: raw.streaming,
max_iterations: raw.max_iterations,
memory,
enabled_tools: raw.enabled_tools,
credentials_check: raw.credentials_check.unwrap_or(false),
}
}
+1 -1
View File
@@ -418,7 +418,7 @@ impl EmbeddingsDb {
let hub_resource_types = response.json::<Vec<HubResourceType>>().await?;
let resource_types: Vec<ResourceType> =
sqlx::query_as!(ResourceType, "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset from resource_type ORDER BY name",)
sqlx::query_as!(ResourceType, "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset, display_name from resource_type ORDER BY name",)
.fetch_all(pg_db)
.await?;
+1
View File
@@ -29,4 +29,5 @@ serde.workspace = true
serde_json.workspace = true
sql-builder.workspace = true
sqlx.workspace = true
tracing.workspace = true
uuid.workspace = true
+23 -1
View File
@@ -22,7 +22,10 @@ use windmill_common::{
error::{Error, JsonResult, Result},
utils::{not_found_if_none, paginate, Pagination},
};
use windmill_common::{db::UserDB, users::username_to_permissioned_as};
use windmill_common::{
db::UserDB,
users::{username_to_permissioned_as, usr_accepts_email},
};
use serde::{Deserialize, Serialize};
use sqlx::{query_scalar, FromRow, Postgres, Transaction};
@@ -981,6 +984,15 @@ async fn add_user_igroup(
) -> Result<String> {
require_super_admin(&db, &authed).await?;
// `email_to_igroup` has no shape constraint of its own; `usr`, which the member is
// promoted into on reconcile, has `proper_email`, and a value failing it there would
// roll back every member of the group.
if !usr_accepts_email(&db, &email).await? {
return Err(Error::BadRequest(format!(
"'{email}' is not a valid email address"
)));
}
let mut tx: Transaction<'_, Postgres> = db.begin().await?;
// FOR UPDATE: the group row is the group-level mutex, taken before the workspace
@@ -1433,6 +1445,16 @@ async fn overwrite_igroups(
if let Some(emails) = &igroup.emails {
for email in emails.iter() {
// An export can carry a member the source instance stored before ingest
// validated member values; it is dropped rather than failing the import.
if !usr_accepts_email(&mut *tx, email).await? {
tracing::warn!(
"Skipping member '{}' of imported instance group '{}': not an email address",
email,
igroup.name
);
continue;
}
sqlx::query!(
"INSERT INTO email_to_igroup (email, igroup) VALUES ($1, $2)",
email,
@@ -913,3 +913,140 @@ async fn test_preserve_orphaned_members_migration(db: Pool<Postgres>) -> anyhow:
Ok(())
}
/// A membership row whose value is not an email (an IdP object id a SCIM sync stored before
/// member values were validated) must not break the workspace's instance-group save: the
/// reconciler skips it and still provisions the valid members. The admin endpoint refuses to
/// add such a value in the first place.
#[cfg(feature = "private")]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_instance_group_member_that_is_not_an_email_is_skipped(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let global_base = format!("http://localhost:{port}/api/groups");
let ws_base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
const ENTRA_OBJECT_ID: &str = "ef40ea04-1a9e-4a84-9e65-cb1baa81dfed";
let resp = authed(client().post(format!("{global_base}/create")))
.json(&json!({ "name": "entra_grp" }))
.send()
.await?;
assert_eq!(resp.status(), 200, "create");
let resp = authed(client().post(format!("{global_base}/adduser/entra_grp")))
.json(&json!({ "email": "kept@example.com" }))
.send()
.await?;
assert_eq!(resp.status(), 200, "adduser");
let resp = authed(client().post(format!("{global_base}/adduser/entra_grp")))
.json(&json!({ "email": ENTRA_OBJECT_ID }))
.send()
.await?;
assert_eq!(
resp.status(),
400,
"adduser must refuse a value that is not an email"
);
let too_wide = format!("{}@example.com", "a".repeat(244));
let resp = authed(client().post(format!("{global_base}/adduser/entra_grp")))
.json(&json!({ "email": too_wide }))
.send()
.await?;
assert_eq!(
resp.status(),
400,
"adduser must refuse a value wider than the email columns"
);
// A valid address whose local part is wider than the username columns: the derived
// username is cut to fit rather than failing the promotion.
let long_local_part = format!("{}@example.com", "a".repeat(60));
let resp = authed(client().post(format!("{global_base}/adduser/entra_grp")))
.json(&json!({ "email": long_local_part }))
.send()
.await?;
assert_eq!(resp.status(), 200, "adduser long local part");
sqlx::query("INSERT INTO email_to_igroup (email, igroup) VALUES ($1, 'entra_grp')")
.bind(ENTRA_OBJECT_ID)
.execute(&db)
.await?;
// A member whose address only the wider `proper_email` of `usr` accepts, already
// provisioned through the group: reconciliation must keep and re-role them, since
// removal destroys their drafts, inputs and permissions.
sqlx::raw_sql(
r#"
INSERT INTO email_to_igroup (email, igroup) VALUES ('"quoted"@example.com', 'entra_grp');
INSERT INTO usr (workspace_id, username, email, is_admin, operator, added_via)
VALUES ('test-workspace', 'quoted', '"quoted"@example.com', false, true,
'{"source": "instance_group", "group": "entra_grp"}'::jsonb);
"#,
)
.execute(&db)
.await?;
let resp = authed(client().post(format!("{ws_base}/edit_instance_groups")))
.json(&json!({
"groups": ["entra_grp"],
"roles": { "entra_grp": "developer" }
}))
.send()
.await?;
assert_eq!(resp.status(), 200, "edit: {}", resp.text().await?);
let mut members: Vec<(String, bool)> = sqlx::query_as(
"SELECT email, operator FROM usr WHERE workspace_id = 'test-workspace'
AND added_via->>'source' = 'instance_group'",
)
.fetch_all(&db)
.await?;
members.sort();
assert_eq!(
members,
vec![
("\"quoted\"@example.com".to_string(), false),
(long_local_part.clone(), false),
("kept@example.com".to_string(), false),
],
"valid members provisioned and existing member kept, all as developers; non-email one skipped"
);
// A full import carrying the same rows: the object id is dropped, the address only
// `proper_email` accepts is kept, and neither member loses their workspace row.
let resp = authed(client().post(format!("{global_base}/overwrite")))
.json(&json!([{
"name": "entra_grp",
"emails": ["kept@example.com", "\"quoted\"@example.com", long_local_part, ENTRA_OBJECT_ID]
}]))
.send()
.await?;
assert_eq!(resp.status(), 200, "overwrite: {}", resp.text().await?);
let mut stored: Vec<String> =
sqlx::query_scalar("SELECT email FROM email_to_igroup WHERE igroup = 'entra_grp'")
.fetch_all(&db)
.await?;
stored.sort();
assert_eq!(
stored,
vec![
"\"quoted\"@example.com".to_string(),
long_local_part.clone(),
"kept@example.com".to_string(),
],
"import drops the object id and keeps the rest"
);
let mut after_import: Vec<(String, bool)> = sqlx::query_as(
"SELECT email, operator FROM usr WHERE workspace_id = 'test-workspace'
AND added_via->>'source' = 'instance_group'",
)
.fetch_all(&db)
.await?;
after_import.sort();
assert_eq!(after_import, members, "import must not evict either member");
Ok(())
}
@@ -0,0 +1,192 @@
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap()
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn login_link_is_single_use_and_same_origin(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");
let mint = |token: &'static str, body: serde_json::Value| {
client()
.post(format!("{base}/users/login_links"))
.header("Authorization", format!("Bearer {token}"))
.json(&body)
.send()
};
// Only a superadmin mints.
let resp = mint("SECRET_TOKEN_2", json!({"email": "test2@windmill.dev"})).await?;
assert_eq!(resp.status(), 401);
// A superadmin account is never a valid target: the minting credential must not
// become an instance-wide role.
let resp = mint("SECRET_TOKEN", json!({"email": "test@windmill.dev"})).await?;
assert_eq!(resp.status(), 400);
// An off-origin destination is refused before anything is minted.
let resp = mint(
"SECRET_TOKEN",
json!({"email": "test2@windmill.dev", "rd": "https://evil.example/"}),
)
.await?;
assert_eq!(resp.status(), 400);
let resp = mint(
"SECRET_TOKEN",
json!({"email": "test2@windmill.dev", "rd": "/user/workspaces?x=1"}),
)
.await?;
assert_eq!(resp.status(), 201);
let link = resp.json::<serde_json::Value>().await?;
let path = link["url"]
.as_str()
.unwrap()
.split_once("/api")
.unwrap()
.1
.to_string();
let consume_url = format!("{base}{path}");
// A promotion inside the link's window is re-checked at open time: no session,
// and the link is not spent while the account is privileged.
sqlx::query("UPDATE password SET super_admin = true WHERE email = 'test2@windmill.dev'")
.execute(&db)
.await?;
let resp = client().get(&consume_url).send().await?;
assert_eq!(resp.status(), 302);
assert_eq!(
resp.headers()["location"],
"/user/login_link_expired?reason=invalid"
);
assert!(resp.headers().get("set-cookie").is_none());
sqlx::query("UPDATE password SET super_admin = false WHERE email = 'test2@windmill.dev'")
.execute(&db)
.await?;
// First open: session cookie for the target account, redirected to the stored rd.
let resp = client().get(&consume_url).send().await?;
assert_eq!(resp.status(), 302);
assert_eq!(resp.headers()["location"], "/user/workspaces?x=1");
assert_eq!(resp.headers()["referrer-policy"], "no-referrer");
let cookie = resp
.headers()
.get_all("set-cookie")
.iter()
.map(|c| c.to_str().unwrap().to_string())
.find(|c| c.starts_with("token="))
.expect("session cookie");
assert!(cookie.contains("HttpOnly"));
let session = cookie
.split(';')
.next()
.unwrap()
.trim_start_matches("token=")
.to_string();
let resp = client()
.get(format!("{base}/users/whoami"))
.header("Authorization", format!("Bearer {session}"))
.send()
.await?;
assert_eq!(resp.status(), 200);
assert_eq!(
resp.json::<serde_json::Value>().await?["email"],
"test2@windmill.dev"
);
// Second open: burned, no cookie, bounced to the explanation page.
let resp = client().get(&consume_url).send().await?;
assert_eq!(resp.status(), 302);
assert_eq!(
resp.headers()["location"],
"/user/login_link_expired?reason=used"
);
assert!(resp.headers().get("set-cookie").is_none());
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn login_link_mint_can_require_a_login_type(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");
let mint = || {
client()
.post(format!("{base}/users/login_links"))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({"email": "test2@windmill.dev", "require_login_type": "pending_oauth"}))
.send()
};
// A password account is not the account the caller created: no link.
let resp = mint().await?;
assert_eq!(resp.status(), 409);
assert!(resp.text().await?.contains("login_type_mismatch"));
sqlx::query(
"UPDATE password SET login_type = 'pending_oauth', password_hash = NULL WHERE email = 'test2@windmill.dev'",
)
.execute(&db)
.await?;
let resp = mint().await?;
assert_eq!(resp.status(), 201);
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn cloud_trial_offer_go_refuses_a_job_token(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
set_jwt_secret().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api");
// The answer is a signed-in portal login for the account, so the job-token check
// must come before every other gate: a script holding `$WM_TOKEN` is refused outright,
// where a browser session reaches the next check (off cloud, "no offer").
let job_id = uuid::Uuid::new_v4();
sqlx::query(
"INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, kind, tag, args)
VALUES ($1, 'test-workspace', 'test-user-2', 'u/test-user-2', 'script', 'deno', '{}'::jsonb)",
)
.bind(job_id)
.execute(&db)
.await?;
let job_token = windmill_common::auth::create_token_for_owner(
&db,
"test-workspace",
"u/test-user-2",
"job",
600,
"test2@windmill.dev",
&job_id,
None,
None,
)
.await?;
let go = |token: String| {
client()
.post(format!("{base}/users/cloud_trial_offer/go"))
.header("Authorization", format!("Bearer {token}"))
.send()
};
let resp = go(job_token).await?;
assert_eq!(resp.status(), 403);
assert!(resp.text().await?.contains("job token"));
let resp = go("SECRET_TOKEN_2".to_string()).await?;
assert_eq!(resp.status(), 404);
Ok(())
}
@@ -443,6 +443,30 @@ async fn test_resource_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
let body = resp.json::<serde_json::Value>().await?;
assert_eq!(body["description"], "Updated type desc");
// display_name: an update that omits it, as a push from a CLI predating the field does,
// keeps it; an explicit null clears it.
for (update, expected) in [
(
json!({"display_name": "New Test Type"}),
json!("New Test Type"),
),
(
json!({"description": "Updated type desc"}),
json!("New Test Type"),
),
(json!({"display_name": null}), serde_json::Value::Null),
] {
let resp = authed(client().post(resource_url(port, "type/update", "new_test_type")))
.json(&update)
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let resp = authed_get(port, "type/get", "new_test_type").await;
let body = resp.json::<serde_json::Value>().await?;
assert_eq!(body["display_name"], expected);
}
// type/delete
let resp = authed(client().delete(resource_url(port, "type/delete", "new_test_type")))
.send()
@@ -923,6 +923,7 @@ async fn test_pull_stays_on_the_workspace_lane(db: Pool<Postgres>) -> anyhow::Re
&db,
"test-workspace",
&repo,
("test-user", "test@windmill.dev"),
None,
false,
None,
+27 -3
View File
@@ -2087,6 +2087,13 @@ struct CachedResourceType {
deserialize_with = "windmill_common::more_serde::double_option"
)]
format_extension: Option<Option<String>>,
/// Doubly optional like `format_extension`: no key leaves the stored name alone, an explicit
/// null (the hub naming nothing) clears it.
#[serde(
default,
deserialize_with = "windmill_common::more_serde::double_option"
)]
display_name: Option<Option<String>>,
}
#[derive(serde::Deserialize)]
@@ -2098,6 +2105,11 @@ struct HubResourceTypeRaw {
description: Option<String>,
#[serde(default)]
format_extension: Option<String>,
#[serde(
default,
deserialize_with = "windmill_common::more_serde::double_option"
)]
display_name: Option<Option<String>>,
}
async fn fetch_resource_types_from_hub() -> error::Result<Vec<CachedResourceType>> {
@@ -2140,6 +2152,7 @@ async fn fetch_resource_types_from_hub() -> error::Result<Vec<CachedResourceType
app: rt.app,
description: rt.description,
format_extension: Some(rt.format_extension),
display_name: rt.display_name,
})
})
.collect())
@@ -2192,13 +2205,21 @@ async fn sync_cached_resource_types(
let mut synced_count = 0;
for rt in &resource_types {
// A name too long for the column counts as absent, leaving the stored one alone: one bad
// entry must not fail the upsert and end the rest of the sync.
let display_name = match &rt.display_name {
Some(Some(name)) if name.chars().count() > 100 => None,
other => other.clone(),
};
let exists: Option<bool> = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM resource_type WHERE workspace_id = 'admins' AND name = $1 AND schema IS NOT DISTINCT FROM $2 AND description IS NOT DISTINCT FROM $3 AND ($5 IS NOT TRUE OR format_extension IS NOT DISTINCT FROM $4))",
"SELECT EXISTS(SELECT 1 FROM resource_type WHERE workspace_id = 'admins' AND name = $1 AND schema IS NOT DISTINCT FROM $2 AND description IS NOT DISTINCT FROM $3 AND ($5 IS NOT TRUE OR format_extension IS NOT DISTINCT FROM $4) AND ($7 IS NOT TRUE OR display_name IS NOT DISTINCT FROM $6))",
&rt.name,
rt.schema.as_ref(),
rt.description.as_deref(),
rt.format_extension.clone().flatten(),
rt.format_extension.is_some(),
display_name.clone().flatten(),
display_name.is_some(),
)
.fetch_one(&db)
.await?;
@@ -2211,8 +2232,8 @@ async fn sync_cached_resource_types(
// Whether the payload carried the key at all is what decides: present
// (even as null) is authoritative and may clear, absent means a cache
// written before the column and must leave the stored value alone.
"INSERT INTO resource_type (workspace_id, name, schema, description, format_extension, edited_at)
VALUES ('admins', $1, $2, $3, $4, now())
"INSERT INTO resource_type (workspace_id, name, schema, description, format_extension, display_name, edited_at)
VALUES ('admins', $1, $2, $3, $4, $6, now())
ON CONFLICT (workspace_id, name) DO UPDATE
SET schema = EXCLUDED.schema, description = EXCLUDED.description,
-- A fileset is a set of files, so it cannot also be one file.
@@ -2223,12 +2244,15 @@ async fn sync_cached_resource_types(
WHEN resource_type.is_fileset THEN NULL
WHEN $5 THEN EXCLUDED.format_extension
ELSE resource_type.format_extension END,
display_name = CASE WHEN $7 THEN EXCLUDED.display_name ELSE resource_type.display_name END,
edited_at = now()",
&rt.name,
rt.schema.as_ref(),
rt.description.as_deref(),
rt.format_extension.clone().flatten(),
rt.format_extension.is_some(),
display_name.clone().flatten(),
display_name.is_some(),
)
.execute(&db)
.await?;
+569 -5
View File
@@ -46,15 +46,15 @@ use tracing::Instrument;
use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
use windmill_common::audit::AuditAuthor;
use windmill_common::auth::{safe_token_prefix, TOKEN_PREFIX_LEN};
use windmill_common::auth::{hash_token, safe_token_prefix, TOKEN_PREFIX_LEN};
use windmill_common::global_settings::AUTOMATE_USERNAME_CREATION_SETTING;
use windmill_common::oauth2::InstanceEvent;
use windmill_common::per_minute_counter::PerMinuteCounter;
use windmill_common::users::truncate_token;
use windmill_common::users::COOKIE_NAME;
use windmill_common::users::{
username_to_permissioned_as, PERMISSIONED_AS_MAX_LEN, SUPERADMIN_NOTIFICATION_EMAIL,
SUPERADMIN_SECRET_EMAIL, SUPERADMIN_SYNC_EMAIL, VALID_EMAIL,
username_to_permissioned_as, EMAIL_COLUMN_MAX_LEN, PERMISSIONED_AS_MAX_LEN,
SUPERADMIN_NOTIFICATION_EMAIL, SUPERADMIN_SECRET_EMAIL, SUPERADMIN_SYNC_EMAIL, VALID_EMAIL,
};
use windmill_common::utils::paginate;
use windmill_common::worker::CLOUD_HOSTED;
@@ -140,6 +140,16 @@ pub fn global_service() -> Router {
)
.route("/tokens/list", get(list_tokens))
.route("/tokens/impersonate", post(impersonate))
.route("/login_links", post(create_login_link))
.route(
"/cloud_trial_offer",
post(set_cloud_trial_offer).get(get_cloud_trial_offer),
)
.route("/cloud_trial_offer/go", post(go_cloud_trial_offer))
.route(
"/onboarding_profile",
post(set_onboarding_profile).get(get_onboarding_profile),
)
.route("/usage", get(get_usage))
.route("/all_runnables", get(get_all_runnables))
.route("/refresh_token", get(refresh_token))
@@ -158,6 +168,7 @@ pub fn make_unauthed_service() -> Router {
.route("/logout", post(logout).get(logout))
.route("/is_first_time_setup", get(is_first_time_setup))
.route("/request_password_reset", post(request_password_reset))
.route("/login_link/{token}", get(consume_login_link))
.route("/is_smtp_configured", get(is_smtp_configured))
.route(
"/is_password_login_disabled",
@@ -255,11 +266,14 @@ pub struct WorkspaceInvite {
#[derive(Deserialize)]
pub struct NewUser {
pub email: String,
pub password: String,
/// Required when `login_type` is `password` (the default), ignored otherwise.
pub password: Option<String>,
pub super_admin: bool,
pub name: Option<String>,
pub company: Option<String>,
pub skip_email: Option<bool>,
/// `password`, `pending_oauth`, or a configured OAuth login client key.
pub login_type: Option<String>,
}
#[derive(Deserialize)]
@@ -1755,7 +1769,6 @@ struct ChangeUserEmail {
/// `varchar(50)`, and `v2_job.permissioned_as` in a `varchar(55)`; every other email column is
/// `varchar(255)`. The strictest of the two bounds is used for all of them.
const SHORT_EMAIL_COLUMN_MAX_LEN: usize = 50;
const EMAIL_COLUMN_MAX_LEN: usize = 255;
/// Move an account to a new email address, in place: the `password` row (and with it the
/// instance-wide username, the role and the login type) is kept and every email-keyed row is
@@ -3201,6 +3214,506 @@ async fn impersonate(
Ok((StatusCode::CREATED, token))
}
const LOGIN_LINK_DEFAULT_TTL_S: u32 = 600;
const LOGIN_LINK_MAX_TTL_S: u32 = 900;
const LOGIN_LINK_DEFAULT_RD: &str = "/user/workspaces";
const LOGIN_LINK_EXPIRED_PAGE: &str = "/user/login_link_expired";
#[derive(Deserialize)]
pub struct NewLoginLink {
pub email: String,
pub expires_in_s: Option<u32>,
pub rd: Option<String>,
/// Refuse to mint unless the account still has this login type: a caller re-entering an
/// account it created can require `pending_oauth`, so the link stops working once the
/// owner has set a password or signed in with a provider.
pub require_login_type: Option<String>,
}
#[derive(Serialize)]
pub struct LoginLink {
pub url: String,
pub expires_at: chrono::DateTime<chrono::Utc>,
}
/// A post-login destination is only ever a same-origin path: anything else would hand the
/// fresh session's first navigation to another host. Control characters are refused because
/// browsers strip tab/newline from a `Location` before parsing it, so `/\t/host` reads as
/// the protocol-relative `//host`.
fn same_origin_rd(rd: Option<String>) -> Option<String> {
rd.filter(|r| {
r.starts_with('/')
&& !r.starts_with("//")
&& !r.contains('\\')
&& !r.chars().any(|c| c.is_ascii_control())
})
}
#[cfg(test)]
mod same_origin_rd_tests {
use super::same_origin_rd;
fn accepts(rd: &str) -> bool {
same_origin_rd(Some(rd.to_string())).is_some()
}
#[test]
fn only_plain_same_origin_paths_pass() {
assert!(accepts("/"));
assert!(accepts("/user/workspaces?rd=%2Fx"));
assert!(!accepts("https://evil.example/"));
assert!(!accepts("//evil.example/"));
assert!(!accepts("/\\evil.example/"));
assert!(!accepts("/\t/evil.example/"));
assert!(!accepts("/x\r\nSet-Cookie: a=b"));
assert!(!accepts("user/workspaces"));
}
}
/// Both provisioning writes reference `password(email)`; a typo'd address from the
/// provisioning script should read as "no such account", not as a foreign-key error.
async fn require_account(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
email: &str,
) -> Result<()> {
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM password WHERE email = $1)",
email
)
.fetch_one(&mut **tx)
.await?
.unwrap_or(false);
if !exists {
return Err(Error::NotFound(format!("no account for {email}")));
}
Ok(())
}
fn login_link_redirect(location: String) -> Response {
(
StatusCode::FOUND,
[
("location", location),
("referrer-policy", "no-referrer".to_string()),
],
)
.into_response()
}
/// Mint a single-use link that signs `email` in when opened. The row is not a `token`:
/// it can only ever become a session, and burning it needs no cache invalidation.
async fn create_login_link(
Extension(db): Extension<DB>,
authed: ApiAuthed,
OptJobAuthed { job_id, .. }: OptJobAuthed,
Json(nl): Json<NewLoginLink>,
) -> Result<(StatusCode, Json<LoginLink>)> {
require_super_admin(&db, &authed).await?;
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
let email = nl.email.to_lowercase();
let rd = match nl.rd {
Some(rd) => Some(same_origin_rd(Some(rd)).ok_or_else(|| {
Error::BadRequest("rd must be a same-origin path starting with /".to_string())
})?),
None => None,
};
let ttl = nl
.expires_in_s
.unwrap_or(LOGIN_LINK_DEFAULT_TTL_S)
.clamp(1, LOGIN_LINK_MAX_TTL_S);
let mut tx = db.begin().await?;
let target = sqlx::query!(
"SELECT super_admin, devops, login_type FROM password WHERE email = $1 AND disabled = false",
&email
)
.fetch_optional(&mut *tx)
.await?;
let Some(target) = target else {
return Err(Error::NotFound(format!("no active account for {email}")));
};
// A link is a full session for its account; whoever holds the minting credential
// must not be able to turn it into an instance-wide role.
if target.super_admin || target.devops {
return Err(Error::BadRequest(
"login links cannot target superadmin or devops accounts".to_string(),
));
}
if let Some(required) = nl.require_login_type.as_deref() {
if target.login_type != required {
return Err(Error::Generic(
StatusCode::CONFLICT,
format!(
"login_type_mismatch: {email} signs in with {}, not {required}",
target.login_type
),
));
}
}
let token = rd_string(32);
let expires_at = chrono::Utc::now() + chrono::Duration::seconds(ttl as i64);
sqlx::query!(
"INSERT INTO login_link (token_hash, email, rd, expiration, created_by)
VALUES ($1, $2, $3, $4, $5)",
hash_token(&token),
&email,
rd,
expires_at,
&authed.email,
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
"users.login_link.create",
ActionKind::Create,
"global",
Some(&email),
Some([("expires_in_s", &ttl.to_string()[..])].into()),
)
.await?;
tx.commit().await?;
let url = format!(
"{}/api/auth/login_link/{}",
(**BASE_URL.load()).clone(),
token
);
Ok((StatusCode::CREATED, Json(LoginLink { url, expires_at })))
}
#[derive(Deserialize)]
pub struct CloudTrialOfferUpdate {
pub email: String,
#[serde(default)]
pub consumed: bool,
}
#[derive(Deserialize)]
pub struct OnboardingProfileUpdate {
pub email: String,
pub profile: serde_json::Value,
}
#[derive(Serialize)]
pub struct OnboardingProfile {
pub profile: Option<serde_json::Value>,
}
/// Context the invite carried about this account's owner, written at provisioning.
/// Onboarding tailors itself from it (today: `touch_point` answers the source question
/// so it is never asked); everything degrades to the plain flow when absent.
async fn set_onboarding_profile(
Extension(db): Extension<DB>,
authed: ApiAuthed,
OptJobAuthed { job_id, .. }: OptJobAuthed,
Json(body): Json<OnboardingProfileUpdate>,
) -> Result<String> {
if !*CLOUD_HOSTED {
return Err(Error::NotFound("cloud only".to_string()));
}
require_super_admin(&db, &authed).await?;
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
if !body.profile.is_object() {
return Err(Error::BadRequest(
"profile must be a JSON object".to_string(),
));
}
let email = body.email.to_lowercase();
let mut tx = db.begin().await?;
require_account(&mut tx, &email).await?;
sqlx::query!(
"INSERT INTO cloud_onboarding_profile (email, profile, created_by) VALUES ($1, $2, $3)
ON CONFLICT (email) DO UPDATE SET profile = EXCLUDED.profile",
&email,
body.profile,
&authed.email
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
"users.onboarding_profile.set",
ActionKind::Update,
"global",
Some(&email),
None,
)
.await?;
tx.commit().await?;
Ok(format!("onboarding profile for {email} recorded"))
}
async fn get_onboarding_profile(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> JsonResult<OnboardingProfile> {
if !*CLOUD_HOSTED {
return Ok(Json(OnboardingProfile { profile: None }));
}
let profile = sqlx::query_scalar!(
"SELECT profile FROM cloud_onboarding_profile WHERE email = $1",
&authed.email
)
.fetch_optional(&db)
.await?;
Ok(Json(OnboardingProfile { profile }))
}
#[derive(Serialize)]
pub struct CloudTrialOffer {
pub offered: bool,
}
/// What the customer portal answers when asked to sign a cloud account in and start its
/// pre-approved trial.
pub enum PortalTrialLogin {
/// Send the browser here: a short-lived portal login that starts the trial on landing.
LoginUrl(String),
/// The portal will not start one (a subscription exists, or it knows no offer); the
/// offer is spent and the browser goes to the portal home instead.
Unavailable { reason: String, portal_url: String },
}
async fn set_cloud_trial_offer(
Extension(db): Extension<DB>,
authed: ApiAuthed,
OptJobAuthed { job_id, .. }: OptJobAuthed,
Json(body): Json<CloudTrialOfferUpdate>,
) -> Result<String> {
if !*CLOUD_HOSTED {
return Err(Error::NotFound("cloud only".to_string()));
}
require_super_admin(&db, &authed).await?;
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
let email = body.email.to_lowercase();
let mut tx = db.begin().await?;
require_account(&mut tx, &email).await?;
if body.consumed {
sqlx::query!(
"UPDATE cloud_trial_offer SET consumed_at = now() WHERE email = $1 AND consumed_at IS NULL",
&email
)
.execute(&mut *tx)
.await?;
} else {
// A consumed offer stays consumed: a trial or subscription already exists for it.
sqlx::query!(
"INSERT INTO cloud_trial_offer (email, created_by) VALUES ($1, $2)
ON CONFLICT (email) DO NOTHING",
&email,
&authed.email
)
.execute(&mut *tx)
.await?;
}
audit_log(
&mut *tx,
&authed,
"users.cloud_trial_offer.set",
ActionKind::Update,
"global",
Some(&email),
Some([("consumed", if body.consumed { "true" } else { "false" })].into()),
)
.await?;
tx.commit().await?;
Ok(format!(
"cloud trial offer for {email} {}",
if body.consumed {
"consumed"
} else {
"recorded"
}
))
}
async fn offered(db: &DB, email: &str) -> Result<bool> {
Ok(sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM cloud_trial_offer WHERE email = $1 AND consumed_at IS NULL)",
email
)
.fetch_one(db)
.await?
.unwrap_or(false))
}
async fn get_cloud_trial_offer(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> JsonResult<CloudTrialOffer> {
if !*CLOUD_HOSTED {
return Ok(Json(CloudTrialOffer { offered: false }));
}
Ok(Json(CloudTrialOffer {
offered: offered(&db, &authed.email).await?,
}))
}
/// Where the browser goes to start the pre-approved trial: a signed-in portal login, or
/// the portal's front page with the reason it could not start one.
#[derive(Serialize)]
pub struct CloudTrialOfferGo {
pub location: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
/// The one click that turns a cloud account's pre-approved offer into a trial: the portal
/// is asked for a login that starts it, and the browser is handed over. The portal is the
/// authority on whether the offer still stands; its refusal spends the offer here so the
/// sidebar stops advertising it.
async fn go_cloud_trial_offer(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> JsonResult<CloudTrialOfferGo> {
// The answer carries a signed-in portal login for this account: a credential for
// another system, and asking for it starts the trial. A script running as the offered
// user holds their identity through `$WM_TOKEN`, so a job token must not be able to
// fetch it and hand it to whoever wrote the script. It is a POST answered as JSON, not
// a redirecting GET, so a cross-site top-level navigation cannot start the trial with
// the SameSite=Lax session cookie either; the frontend navigates to `location` itself.
if authed.job_id.is_some() {
return Err(Error::NotAuthorized(
"This endpoint cannot be called with a job token ($WM_TOKEN).".to_string(),
));
}
if !*CLOUD_HOSTED || !offered(&db, &authed.email).await? {
return Err(Error::NotFound(
"no pre-approved trial offer for this account".to_string(),
));
}
let outcome = crate::users_oss::portal_cloud_trial_login(&authed.email).await?;
let (location, reason) = match outcome {
PortalTrialLogin::LoginUrl(url) => (url, None),
PortalTrialLogin::Unavailable { reason, portal_url } => (portal_url, Some(reason)),
};
let mut tx = db.begin().await?;
if reason.is_some() {
sqlx::query!(
"UPDATE cloud_trial_offer SET consumed_at = now() WHERE email = $1 AND consumed_at IS NULL",
&authed.email
)
.execute(&mut *tx)
.await?;
}
audit_log(
&mut *tx,
&authed,
"users.cloud_trial_offer.go",
ActionKind::Execute,
"global",
Some(&authed.email),
Some([("outcome", reason.as_deref().unwrap_or("login"))].into()),
)
.await?;
tx.commit().await?;
Ok(Json(CloudTrialOfferGo { location, reason }))
}
#[derive(Deserialize)]
struct LoginLinkQuery {
rd: Option<String>,
}
async fn consume_login_link(
headers: axum::http::HeaderMap,
cookies: Cookies,
Extension(db): Extension<DB>,
Path(token): Path<String>,
Query(query): Query<LoginLinkQuery>,
) -> Result<Response> {
let bounce = |reason: &str| {
Ok(login_link_redirect(format!(
"{LOGIN_LINK_EXPIRED_PAGE}?reason={reason}"
)))
};
if token.len() != 32 {
return bounce("invalid");
}
let t_hash = hash_token(&token);
// The account is unknown until the row is read, so only the global and per-IP tiers
// apply here; a 32-char random token leaves nothing for the per-account tier to guard.
windmill_common::login_rate_limit::check_and_increment_login_attempt(
&headers,
&t_hash[..TOKEN_PREFIX_LEN],
)?;
let mut tx = db.begin().await?;
let link = sqlx::query!(
"UPDATE login_link SET consumed_at = now()
WHERE token_hash = $1 AND consumed_at IS NULL AND expiration > now()
RETURNING email, rd, created_by",
&t_hash
)
.fetch_optional(&mut *tx)
.await?;
let Some(link) = link else {
let used = sqlx::query_scalar!(
"SELECT consumed_at IS NOT NULL AS \"used!\" FROM login_link WHERE token_hash = $1",
&t_hash
)
.fetch_optional(&mut *tx)
.await?;
return bounce(match used {
Some(true) => "used",
Some(false) => "expired",
None => "invalid",
});
};
// Re-checked at open time and locked through session creation: a promotion inside
// the link's window must not turn a link minted for an ordinary account into a
// privileged session. The bounce drops the transaction, so the link is not spent.
let target = sqlx::query!(
"SELECT super_admin, devops FROM password WHERE email = $1 AND disabled = false FOR UPDATE",
&link.email
)
.fetch_optional(&mut *tx)
.await?;
let Some(target) = target else {
return bounce("invalid");
};
if target.super_admin || target.devops {
return bounce("invalid");
}
let session = create_session_token(&link.email, false, None, false, &mut tx, cookies).await?;
audit_log(
&mut *tx,
&AuditAuthor {
email: link.email.clone(),
username: link.email.clone(),
username_override: None,
token_prefix: Some(safe_token_prefix(&session)),
},
"users.login",
ActionKind::Create,
"global",
Some(&truncate_token(&session)),
Some(
[
("method", "login_link"),
("minted_by", link.created_by.as_str()),
]
.into(),
),
)
.await?;
tx.commit().await?;
let rd = link
.rd
.or_else(|| same_origin_rd(query.rd))
.unwrap_or_else(|| LOGIN_LINK_DEFAULT_RD.to_string());
Ok(login_link_redirect(rd))
}
#[derive(Deserialize)]
pub struct ImpersonateServiceAccountRequest {
pub username: String,
@@ -3597,12 +4110,63 @@ async fn get_all_runnables(
#[derive(Deserialize, Debug, Clone)]
pub struct LoginUserInfo {
pub email: Option<String>,
/// OIDC `email_verified` claim where the provider sends one.
#[serde(default, deserialize_with = "deserialize_lenient_bool")]
pub email_verified: Option<bool>,
pub name: Option<String>,
pub company: Option<String>,
pub preferred_username: Option<String>,
pub displayName: Option<String>,
}
/// Some providers (Cognito among them) send `email_verified` as the strings "true"/"false";
/// a strict bool would reject their whole userinfo document and break login.
fn deserialize_lenient_bool<'de, D: serde::Deserializer<'de>>(
d: D,
) -> std::result::Result<Option<bool>, D::Error> {
Ok(match Option::<serde_json::Value>::deserialize(d)? {
Some(serde_json::Value::Bool(b)) => Some(b),
Some(serde_json::Value::String(s)) => match s.trim().to_ascii_lowercase().as_str() {
"true" => Some(true),
"false" => Some(false),
_ => None,
},
_ => None,
})
}
#[cfg(test)]
mod login_user_info_tests {
use super::LoginUserInfo;
fn email_verified(json: &str) -> Option<bool> {
serde_json::from_str::<LoginUserInfo>(json)
.unwrap()
.email_verified
}
#[test]
fn email_verified_accepts_bool_and_stringified_bool() {
assert_eq!(
email_verified(r#"{"email":"a@b","email_verified":true}"#),
Some(true)
);
assert_eq!(
email_verified(r#"{"email":"a@b","email_verified":"true"}"#),
Some(true)
);
assert_eq!(
email_verified(r#"{"email":"a@b","email_verified":"false"}"#),
Some(false)
);
assert_eq!(
email_verified(r#"{"email":"a@b","email_verified":"maybe"}"#),
None
);
assert_eq!(email_verified(r#"{"email":"a@b"}"#), None);
}
}
#[derive(Serialize)]
struct InstanceUsernameInfo {
username: String,
@@ -26,3 +26,13 @@ pub async fn impersonate_service_account(
"Service accounts require Windmill Enterprise Edition".to_string(),
))
}
#[cfg(not(feature = "private"))]
pub(crate) async fn portal_cloud_trial_login(
_email: &str,
) -> windmill_common::error::Result<crate::users::PortalTrialLogin> {
Err(windmill_common::error::Error::FeatureUnavailable(
"Starting a pre-approved trial from Windmill Cloud requires Windmill Enterprise Edition"
.to_string(),
))
}
@@ -913,12 +913,15 @@ fn redact_git_sync_webhook_secrets(git_sync: &mut serde_json::Value) {
}
/// Zero the server-owned auto-pull fields (webhook id/secret/url/error, synced
/// sha, last pull status) on a client-supplied `AutoPullSettings`. The client only
/// controls `enabled` / `mode` / `poll_interval_s`; the rest is written by the
/// server (webhook creation, poller) and must never be trusted from the request —
/// otherwise a caller could inject a webhook id/secret or fake sync state.
fn clear_client_supplied_auto_pull_state(
/// sha, last pull status) on a client-supplied `AutoPullSettings`, and stamp who
/// pulls run as: `saver_email` while auto pull is on. The client only controls
/// `enabled` / `mode` / `poll_interval_s`; the rest is written by the server (webhook
/// creation, poller, this save) and must never be trusted from the request —
/// otherwise a caller could inject a webhook id/secret, fake sync state, or pick who
/// pulls run as.
fn sanitize_client_auto_pull(
auto_pull: &mut windmill_common::workspaces::AutoPullSettings,
saver_email: &str,
) {
auto_pull.webhook_id = None;
auto_pull.webhook_secret = None;
@@ -926,6 +929,28 @@ fn clear_client_supplied_auto_pull_state(
auto_pull.webhook_error = None;
auto_pull.last_synced_sha = std::collections::HashMap::new();
auto_pull.last_pull_status = None;
auto_pull.enabled_by = auto_pull.enabled.then(|| saver_email.to_string());
}
#[cfg(test)]
mod sanitize_client_auto_pull_tests {
use windmill_common::workspaces::AutoPullSettings;
#[test]
fn a_save_stamps_the_saver_over_any_client_supplied_stamp() {
let mut ap = AutoPullSettings {
enabled: true,
enabled_by: Some("forged@example.com".to_string()),
..Default::default()
};
super::sanitize_client_auto_pull(&mut ap, "saver@example.com");
assert_eq!(ap.enabled_by.as_deref(), Some("saver@example.com"));
ap.enabled = false;
ap.enabled_by = Some("forged@example.com".to_string());
super::sanitize_client_auto_pull(&mut ap, "saver@example.com");
assert_eq!(ap.enabled_by, None, "auto pull off carries no stamp");
}
}
/// Whether a git-sync repository tracking `tracked` rules out `label_branch` as a dev workspace's
@@ -4330,7 +4355,7 @@ async fn edit_git_sync_config(
// stay clean.
for repo in git_sync_settings.repositories.iter_mut() {
if let Some(ap) = repo.auto_pull.as_mut() {
clear_client_supplied_auto_pull_state(ap);
sanitize_client_auto_pull(ap, &authed.email);
}
repo.open_pr_error = None;
repo.credential = None;
@@ -4577,7 +4602,7 @@ async fn edit_git_sync_repository(
// existing repo re-derives it from the DB (carried over below) and a new one
// starts clean.
if let Some(ap) = new_config.repository.auto_pull.as_mut() {
clear_client_supplied_auto_pull_state(ap);
sanitize_client_auto_pull(ap, &authed.email);
}
new_config.repository.open_pr_error = None;
new_config.repository.credential = None;
@@ -6813,8 +6838,8 @@ async fn clone_resource_types(
target_workspace_id: &str,
) -> Result<()> {
sqlx::query!(
"INSERT INTO resource_type (workspace_id, name, schema, description, edited_at, created_by, format_extension, is_fileset)
SELECT $2, name, schema, description, edited_at, created_by, format_extension, is_fileset
"INSERT INTO resource_type (workspace_id, name, schema, description, edited_at, created_by, format_extension, is_fileset, display_name)
SELECT $2, name, schema, description, edited_at, created_by, format_extension, is_fileset, display_name
FROM resource_type
WHERE workspace_id = $1",
source_workspace_id,
+521 -4
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.811.1
version: 1.812.0
title: Windmill API
contact:
@@ -293,6 +293,108 @@ paths:
items:
$ref: "#/components/schemas/AuditLog"
/w/{workspace}/trash/list:
get:
summary: list the workspace trashbin (requires admin privilege)
operationId: listTrash
tags:
- trash
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: item_kind
in: query
description: >
only return items of this kind: script, flow, app, schedule, variable,
resource, or a trigger kind such as http_trigger
schema:
type: string
- name: page
in: query
description: which page to return (starts at 0, default 0)
schema:
type: integer
- name: per_page
in: query
description: number of items to return for a given page (default 100, max 1000)
schema:
type: integer
responses:
"200":
description: the trashed items, most recently deleted first
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/TrashItem"
/w/{workspace}/trash/get/{id}:
get:
summary: get a trashed item with the data it was deleted with (requires admin privilege)
operationId: getTrashItem
tags:
- trash
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/PathId"
responses:
"200":
description: the trashed item
content:
application/json:
schema:
$ref: "#/components/schemas/TrashItemWithData"
/w/{workspace}/trash/restore/{id}:
post:
summary: restore a trashed item to its path (requires admin privilege)
operationId: restoreTrashItem
tags:
- trash
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/PathId"
responses:
"200":
description: item restored
content:
text/plain:
schema:
type: string
/w/{workspace}/trash/delete/{id}:
delete:
summary: permanently delete a trashed item (requires admin privilege)
operationId: permanentlyDeleteTrashItem
tags:
- trash
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/PathId"
responses:
"200":
description: item permanently deleted
content:
text/plain:
schema:
type: string
/w/{workspace}/trash/empty:
post:
summary: permanently delete every item in the workspace trashbin (requires admin privilege)
operationId: emptyTrash
tags:
- trash
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: trashbin emptied
content:
text/plain:
schema:
type: string
/auth/login:
post:
security: []
@@ -406,6 +508,28 @@ paths:
"400":
description: SMTP not configured
/auth/login_link/{token}:
get:
security: []
summary: consume a single-use login link, set the session cookie and redirect
operationId: consumeLoginLink
tags:
- user
parameters:
- name: token
in: path
required: true
schema:
type: string
- name: rd
in: query
required: false
schema:
type: string
responses:
"302":
description: redirected to the post-login destination, or to /user/login_link_expired when the link is used, expired or unknown
/auth/reset_password:
post:
security: []
@@ -623,9 +747,14 @@ paths:
skip_email:
type: boolean
description: Skip sending email notifications to the user
login_type:
type: string
description: >-
password (default, requires `password`), pending_oauth (no credential
until the first OAuth login proving the address adopts the account), or
a configured OAuth login client key
required:
- email
- password
- super_admin
responses:
"201":
@@ -6573,6 +6702,172 @@ paths:
schema:
type: string
/users/login_links:
post:
summary: mint a single-use login link for an account (require superadmin)
operationId: createLoginLink
tags:
- user
requestBody:
description: target account and link options
required: true
content:
application/json:
schema:
type: object
required:
- email
properties:
email:
type: string
expires_in_s:
type: integer
description: link lifetime in seconds, at most 900 (default 600)
rd:
type: string
description: same-origin path the browser lands on after login (default /user/workspaces)
require_login_type:
type: string
description: >-
mint only while the account still has this login type (for example
pending_oauth), so a link stops working once the owner has set a password
or signed in with a provider
responses:
"201":
description: login link minted
content:
application/json:
schema:
type: object
required:
- url
- expires_at
properties:
url:
type: string
expires_at:
type: string
format: date-time
"409":
description: the account does not have the required login type
/users/cloud_trial_offer:
post:
summary: record or consume a pre-approved self-hosted trial offer for a cloud account (require superadmin, cloud only)
operationId: setCloudTrialOffer
tags:
- user
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- email
properties:
email:
type: string
consumed:
type: boolean
description: mark the offer used (a trial or subscription now exists) instead of recording it
responses:
"200":
description: offer recorded or consumed
content:
text/plain:
schema:
type: string
get:
summary: whether the signed-in account holds an unconsumed pre-approved self-hosted trial offer (cloud only)
operationId: getCloudTrialOffer
tags:
- user
responses:
"200":
description: offer state
content:
application/json:
schema:
type: object
required:
- offered
properties:
offered:
type: boolean
/users/cloud_trial_offer/go:
post:
summary: start the signed-in account's pre-approved self-hosted trial on the customer portal (cloud only). A POST answered as JSON rather than a redirecting GET, so a cross-site navigation cannot start it; the browser navigates to `location` itself
operationId: goCloudTrialOffer
tags:
- user
responses:
"200":
description: where to go — the customer portal signed in with the trial being started, or the portal home with the reason the offer could not be used
content:
application/json:
schema:
type: object
required:
- location
properties:
location:
type: string
reason:
type: string
description: present when the portal refused (e.g. the account already has a subscription); the offer is then spent
"403":
description: called with a job token
"404":
description: no offer for this account
/users/onboarding_profile:
post:
summary: record the invite context an account's onboarding tailors itself from (require superadmin, cloud only)
operationId: setOnboardingProfile
tags:
- user
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- email
- profile
properties:
email:
type: string
profile:
type: object
description: free-form context from the invite, every key optional. The frontend reads `touch_point` (answers onboarding's source question), `company` and `workspace_name` (prefill the first workspace's name), `hub_projects` (slugs surfaced first on an empty workspace), `tools` (integrations, used to pick hub projects when none are named) and `starter_prompts` (`[{label, prompt}]`, replacing the home page's example prompts); unknown keys are kept and ignored
responses:
"200":
description: profile recorded
content:
text/plain:
schema:
type: string
get:
summary: the invite context recorded for the signed-in account, if any (cloud only)
operationId: getOnboardingProfile
tags:
- user
responses:
"200":
description: the profile, or null when none was recorded
content:
application/json:
schema:
type: object
properties:
profile:
type: object
nullable: true
additionalProperties: true
/users/tokens/delete/{token_prefix}:
delete:
summary: delete token
@@ -9174,6 +9469,10 @@ paths:
picks:
description: how often the integration has been picked, absent on a hub that does not count picks
type: integer
display_name:
description: the label the hub curates for the integration, null or absent where it names none
type: string
nullable: true
required:
- name
@@ -13085,6 +13384,134 @@ paths:
type: boolean
description: more buckets matched than were returned, so summing them under-reports
/w/{workspace}/ai/shared_artifacts/share:
post:
summary: share an AI session artifact with the workspace
description: >
Stores a read-only copy that any member of the workspace can open by id. Sharing the
same artifact again updates that copy, keeps its id, and restarts its retention window.
operationId: shareAiArtifact
tags:
- ai
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- artifact_id
- name
- kind
- version
- content
properties:
artifact_id:
type: string
description: the artifact's id in the author's session
name:
type: string
kind:
type: string
enum: [md, html]
version:
type: integer
minimum: 1
content:
type: string
responses:
"200":
description: the shared copy
content:
application/json:
schema:
$ref: "#/components/schemas/SharedAiArtifactInfo"
/w/{workspace}/ai/shared_artifacts/status:
get:
summary: get the calling user's share of one of their AI session artifacts
operationId: getAiArtifactShareStatus
tags:
- ai
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: artifact_id
in: query
required: true
schema:
type: string
responses:
"200":
description: the live share, if any, and how long shares last
content:
application/json:
schema:
type: object
required:
- retention_secs
properties:
retention_secs:
type: integer
share:
$ref: "#/components/schemas/SharedAiArtifactInfo"
/w/{workspace}/ai/shared_artifacts/get/{id}:
get:
summary: get a shared AI session artifact
operationId: getSharedAiArtifact
tags:
- ai
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
"200":
description: the shared artifact
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/SharedAiArtifactInfo"
- type: object
required:
- content
- can_unshare
properties:
content:
type: string
can_unshare:
type: boolean
description: whether the caller authored the share or is a workspace admin
/w/{workspace}/ai/shared_artifacts/delete/{id}:
delete:
summary: stop sharing an AI session artifact
operationId: unshareAiArtifact
tags:
- ai
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
"200":
description: share deleted
content:
text/plain:
schema:
type: string
/w/{workspace}/apps/get_data/v/{secretWithExtension}:
get:
summary: get raw app data by
@@ -28150,6 +28577,36 @@ components:
- output_tokens
- requests
SharedAiArtifactInfo:
type: object
properties:
id:
type: string
format: uuid
name:
type: string
kind:
type: string
enum: [md, html]
version:
type: integer
created_by:
type: string
shared_at:
type: string
format: date-time
expires_at:
type: string
format: date-time
required:
- id
- name
- kind
- version
- created_by
- shared_at
- expires_at
InstanceAIProviderSummary:
type: object
properties:
@@ -30011,6 +30468,51 @@ components:
- operation
- action_kind
TrashItem:
type: object
properties:
id:
type: integer
format: int64
workspace_id:
type: string
item_kind:
type: string
description: script, flow, app, schedule, variable, resource, or a trigger kind such as http_trigger
item_path:
type: string
deleted_by:
type: string
deleted_at:
type: string
format: date-time
expires_at:
type: string
format: date-time
description: when the item is permanently deleted unless restored first
required:
- id
- workspace_id
- item_kind
- item_path
- deleted_by
- deleted_at
- expires_at
TrashItemWithData:
allOf:
- $ref: "#/components/schemas/TrashItem"
- type: object
properties:
item_data:
type: object
additionalProperties: true
description: >
the deleted rows as they were stored; the shape depends on the kind, and a
secret variable's value stays encrypted
required:
- item_data
MainArgSignature:
type: object
properties:
@@ -30468,6 +30970,11 @@ components:
type: string
is_fileset:
type: boolean
display_name:
type: string
description: >-
The name the product goes by, e.g. "Google Sheets" for gsheets.
Absent where nobody named the type.
required:
- name
@@ -30485,6 +30992,12 @@ components:
description: >-
File extension for a type whose value is one file rather than a set
of fields. Omit to leave it unchanged; send null to clear it.
display_name:
type: string
nullable: true
description: >-
The name the product goes by. Omit to leave it unchanged; send null
to clear it.
TriggerHistoryEntry:
type: object
@@ -33951,7 +34464,7 @@ components:
type: string
login_type:
type: string
enum: ["password", "github", "service_account"]
enum: ["password", "github", "service_account", "pending_oauth"]
super_admin:
type: boolean
devops:
@@ -34243,7 +34756,8 @@ components:
carrying their own identity restricted to these scopes, handed to
the app bundle so `windmill-client` calls run as the viewer. Must
be a subset of the server's curated allowlist (jobs:run, jobs:read,
users:read, resources:read, variables:read).
users:read, resources:read, variables:read, flow_conversations:read,
flow_conversations:write).
ListableApp:
type: object
@@ -35181,6 +35695,9 @@ components:
type: string
last_pull_status:
$ref: "#/components/schemas/AutoPullStatus"
enabled_by:
type: string
description: Email of the admin automatic pulls apply changes as. Set by the server when the settings are saved.
required:
- enabled
+4
View File
@@ -518,6 +518,10 @@ pub fn workspaced_service() -> Router {
// could make the server allocate and parse an arbitrarily large one.
// Sized well above a full batch of the shape below.
.layer(DefaultBodyLimit::max(AI_USAGE_BODY_LIMIT)),
)
.nest(
"/shared_artifacts",
crate::ai_shared_artifacts::workspaced_service(),
);
#[cfg(feature = "bedrock")]
@@ -0,0 +1,322 @@
/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2026
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
//! Read-only copies of AI session artifacts, shared with the workspace by their author.
//!
//! Artifacts live in the author's browser; a row here exists only because the author asked
//! for a link. Every read filters on the retention window as well as the monitor sweeping
//! it, so a share past its window is never served in the gap before the sweep reaches it.
use crate::db::{ApiAuthed, DB};
use axum::{
extract::{DefaultBodyLimit, Extension, Json, Path, Query},
routing::{delete, get, post},
Router,
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use windmill_audit::{audit_oss::audit_log, ActionKind};
use windmill_common::{
ai_shared_artifact_retention_secs,
error::{Error, JsonResult, Result},
};
/// The frontend's `MAX_ARTIFACT_BYTES`: no artifact the viewer holds is larger.
const MAX_CONTENT_BYTES: usize = 256 * 1024;
const MAX_NAME_CHARS: usize = 255;
const MAX_ARTIFACT_ID_CHARS: usize = 255;
/// JSON escapes a control character into six bytes, so a full-size artifact made entirely of
/// them still fits.
const SHARE_BODY_LIMIT: usize = MAX_CONTENT_BYTES * 6 + 64 * 1024;
pub fn workspaced_service() -> Router {
Router::new()
.route(
"/share",
post(share_artifact).layer(DefaultBodyLimit::max(SHARE_BODY_LIMIT)),
)
.route("/status", get(get_share_status))
.route("/get/{id}", get(get_shared_artifact))
.route("/delete/{id}", delete(unshare_artifact))
}
#[derive(Deserialize, Clone, Copy)]
#[serde(rename_all = "lowercase")]
enum ArtifactKind {
Md,
Html,
}
impl ArtifactKind {
fn as_str(self) -> &'static str {
match self {
ArtifactKind::Md => "md",
ArtifactKind::Html => "html",
}
}
}
#[derive(Deserialize)]
struct ShareArtifact {
artifact_id: String,
name: String,
kind: ArtifactKind,
version: i32,
content: String,
}
#[derive(Serialize)]
struct SharedArtifactInfo {
id: Uuid,
name: String,
kind: String,
version: i32,
created_by: String,
shared_at: DateTime<Utc>,
expires_at: DateTime<Utc>,
}
#[derive(Serialize)]
struct SharedArtifact {
#[serde(flatten)]
info: SharedArtifactInfo,
content: String,
/// Whether the caller may stop sharing it: its author, or a workspace admin.
can_unshare: bool,
}
#[derive(Serialize)]
struct ShareStatus {
retention_secs: i64,
#[serde(skip_serializing_if = "Option::is_none")]
share: Option<SharedArtifactInfo>,
}
#[derive(Deserialize)]
struct ShareStatusQuery {
artifact_id: String,
}
fn expires_at(shared_at: DateTime<Utc>, retention_secs: i64) -> DateTime<Utc> {
shared_at + chrono::Duration::seconds(retention_secs)
}
/// The browser-side artifact id, as every handler that takes one must check it: it is compared
/// against a `VARCHAR(255)` column, and Postgres answers a NUL in a text parameter with an
/// opaque 500.
fn check_artifact_id(artifact_id: &str) -> Result<()> {
if artifact_id.is_empty() || artifact_id.chars().count() > MAX_ARTIFACT_ID_CHARS {
return Err(Error::BadRequest(format!(
"Artifact id must be between 1 and {MAX_ARTIFACT_ID_CHARS} characters"
)));
}
if artifact_id.contains('\0') {
return Err(Error::BadRequest(
"Artifact id cannot contain NUL characters".to_string(),
));
}
Ok(())
}
/// Share an artifact, or move the caller's existing link for it to this content. Re-sharing
/// keeps the link and restarts its retention window.
async fn share_artifact(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(payload): Json<ShareArtifact>,
) -> JsonResult<SharedArtifactInfo> {
let name = payload.name.trim();
if name.is_empty() || name.chars().count() > MAX_NAME_CHARS {
return Err(Error::BadRequest(format!(
"Artifact name must be between 1 and {MAX_NAME_CHARS} characters"
)));
}
check_artifact_id(&payload.artifact_id)?;
if payload.content.len() > MAX_CONTENT_BYTES {
return Err(Error::BadRequest(format!(
"Artifact content is {} bytes, above the {MAX_CONTENT_BYTES} byte limit",
payload.content.len()
)));
}
if payload.version < 1 {
return Err(Error::BadRequest(
"Artifact version must be at least 1".to_string(),
));
}
// Postgres rejects NUL in text columns with an opaque 500.
if name.contains('\0') || payload.content.contains('\0') {
return Err(Error::BadRequest(
"Artifact name and content cannot contain NUL characters".to_string(),
));
}
let mut tx = db.begin().await?;
let row = sqlx::query!(
r#"INSERT INTO ai_shared_artifact
(workspace_id, artifact_id, email, created_by, name, kind, version, content)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (workspace_id, email, artifact_id) DO UPDATE
SET created_by = EXCLUDED.created_by,
name = EXCLUDED.name,
kind = EXCLUDED.kind,
version = EXCLUDED.version,
content = EXCLUDED.content,
shared_at = now()
RETURNING id, shared_at, (xmax = 0) AS "inserted!""#,
&w_id,
&payload.artifact_id,
&authed.email,
&authed.username,
name,
payload.kind.as_str(),
payload.version,
&payload.content,
)
.fetch_one(&mut *tx)
.await?;
let id = row.id.to_string();
audit_log(
&mut *tx,
&authed,
"ai.shared_artifacts.share",
if row.inserted {
ActionKind::Create
} else {
ActionKind::Update
},
&w_id,
Some(&id),
Some([("name", name)].into()),
)
.await?;
tx.commit().await?;
Ok(Json(SharedArtifactInfo {
id: row.id,
name: name.to_string(),
kind: payload.kind.as_str().to_string(),
version: payload.version,
created_by: authed.username.clone(),
shared_at: row.shared_at,
expires_at: expires_at(row.shared_at, ai_shared_artifact_retention_secs()),
}))
}
/// The caller's own live share of one of their artifacts, if any, and how long a share lasts.
async fn get_share_status(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Query(query): Query<ShareStatusQuery>,
) -> JsonResult<ShareStatus> {
check_artifact_id(&query.artifact_id)?;
let retention_secs = ai_shared_artifact_retention_secs();
let share = sqlx::query!(
"SELECT id, name, kind, version, created_by, shared_at FROM ai_shared_artifact
WHERE workspace_id = $1 AND email = $2 AND artifact_id = $3
AND shared_at > now() - ($4::bigint::text || ' s')::interval",
&w_id,
&authed.email,
&query.artifact_id,
retention_secs,
)
.fetch_optional(&db)
.await?
.map(|r| SharedArtifactInfo {
id: r.id,
name: r.name,
kind: r.kind,
version: r.version,
created_by: r.created_by,
shared_at: r.shared_at,
expires_at: expires_at(r.shared_at, retention_secs),
});
Ok(Json(ShareStatus { retention_secs, share }))
}
/// Any member of the workspace may read a live share: that is what sharing it granted.
async fn get_shared_artifact(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> JsonResult<SharedArtifact> {
let retention_secs = ai_shared_artifact_retention_secs();
let row = sqlx::query!(
"SELECT id, email, name, kind, version, created_by, content, shared_at
FROM ai_shared_artifact
WHERE workspace_id = $1 AND id = $2
AND shared_at > now() - ($3::bigint::text || ' s')::interval",
&w_id,
id,
retention_secs,
)
.fetch_optional(&db)
.await?
.ok_or_else(|| {
Error::NotFound(format!(
"Shared artifact {id} not found: it may have expired or been unshared"
))
})?;
Ok(Json(SharedArtifact {
can_unshare: row.email == authed.email || authed.is_admin,
content: row.content,
info: SharedArtifactInfo {
id: row.id,
name: row.name,
kind: row.kind,
version: row.version,
created_by: row.created_by,
shared_at: row.shared_at,
expires_at: expires_at(row.shared_at, retention_secs),
},
}))
}
async fn unshare_artifact(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> Result<String> {
let mut tx = db.begin().await?;
let name = sqlx::query_scalar!(
"DELETE FROM ai_shared_artifact
WHERE workspace_id = $1 AND id = $2 AND (email = $3 OR $4::bool)
RETURNING name",
&w_id,
id,
&authed.email,
authed.is_admin,
)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| {
Error::NotFound(format!(
"Shared artifact {id} not found, or not shared by you"
))
})?;
let id_str = id.to_string();
audit_log(
&mut *tx,
&authed,
"ai.shared_artifacts.unshare",
ActionKind::Delete,
&w_id,
Some(&id_str),
Some([("name", name.as_str())].into()),
)
.await?;
tx.commit().await?;
Ok(format!("Stopped sharing {name}"))
}
+7 -1
View File
@@ -1440,12 +1440,14 @@ const APP_EMBED_TOKEN_VALIDITY_HOURS: i64 = 12;
/// Scopes an app author may declare in `Policy::frontend_sdk_scopes`. No `apps:*`
/// scope, so the token cannot reach the mint endpoints and renew itself; the
/// `raw_app_sdk` sentinel narrows the rest (see `scopes.rs`).
pub const FRONTEND_SDK_ALLOWED_SCOPES: [&str; 5] = [
pub const FRONTEND_SDK_ALLOWED_SCOPES: [&str; 7] = [
"jobs:run",
"jobs:read",
"users:read",
"resources:read",
"variables:read",
"flow_conversations:read",
"flow_conversations:write",
];
/// Reject a policy declaring frontend SDK scopes outside the curated list.
@@ -6014,6 +6016,10 @@ mod embed_token_tests {
// the author declared it and the viewer consented.
("/api/w/test/resources/get_value/u/admin/r", "GET"),
("/api/w/test/variables/get_value/u/admin/v", "GET"),
// A chat UI's history for a chat-mode flow; RLS keeps it to the viewer's own.
("/api/w/test/flow_conversations/list", "GET"),
("/api/w/test/flow_conversations/some-uuid/messages", "GET"),
("/api/w/test/flow_conversations/delete/some-uuid", "DELETE"),
];
for (path, method) in allowed {
assert!(
+6 -1
View File
@@ -69,6 +69,7 @@ mod ai;
#[cfg(feature = "private")]
mod ai_free_tier_ee;
mod ai_free_tier_oss;
mod ai_shared_artifacts;
mod apps;
mod apps_raw_bundle;
pub use apps::invalidate_app_policy_cache;
@@ -659,9 +660,13 @@ pub async fn run_server(
"/workspace_dependencies",
workspace_dependencies::workspaced_service(),
)
// CORS so a chat UI on another origin (an external site, or
// a sandboxed raw app with its frontend SDK token) can read
// its conversation history. Bearer-only, like variables.
.nest(
"/flow_conversations",
windmill_api_flow_conversations::workspaced_service(),
windmill_api_flow_conversations::workspaced_service()
.layer(cors.clone()),
)
// CORS so an opaque-origin app iframe (WIN-2006 embed,
// no separate domain) can read folders/listnames with a
@@ -1268,10 +1268,12 @@ is, a different one moves it there and archives the old path"),
"description": "Who may open the app, and who its runnables execute as. Optional, and what omitting it means depends on the operation: creating an app defaults it to `publisher` (runs on behalf of the app's publisher and requires an authenticated viewer), while updating one keeps the mode the app is already deployed under. Neither `anonymous`, which makes the app publicly executable, nor `guest`, which opens it to anyone the identity provider authenticates, is ever assumed. A guest is only admitted where the workspace also has `guest_access_enabled`, which is checked when the session is minted and again on every guest request. Possible values: viewer, publisher, guest, anonymous"
},
"on_behalf_of": {
"type": "string"
"type": "string",
"description": "The user or group the app runs as in anonymous or publisher mode (e.g. 'u/admin' or 'g/mygroup'). The authority for the app's identity."
},
"on_behalf_of_email": {
"type": "string"
"type": "string",
"description": "Address of `on_behalf_of`, written through from it on every save and returned as stored. Optional; when absent it is derived from `on_behalf_of`. Sending it is optional too; it must name the same account as `on_behalf_of`, and a pair that disagrees is rejected."
},
"sandbox": {
"type": "boolean",
@@ -1282,7 +1284,7 @@ is, a different one moves it there and archives the old path"),
"items": {
"type": "string"
},
"description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true — an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers mint (after consenting) a short-lived token carrying their own identity restricted to these scopes, handed to the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read).\n"
"description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true — an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers mint (after consenting) a short-lived token carrying their own identity restricted to these scopes, handed to the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read, flow_conversations:read, flow_conversations:write).\n"
}
}
}
@@ -1383,10 +1385,12 @@ is, a different one moves it there and archives the old path"),
"description": "Who may open the app, and who its runnables execute as. Optional, and what omitting it means depends on the operation: creating an app defaults it to `publisher` (runs on behalf of the app's publisher and requires an authenticated viewer), while updating one keeps the mode the app is already deployed under. Neither `anonymous`, which makes the app publicly executable, nor `guest`, which opens it to anyone the identity provider authenticates, is ever assumed. A guest is only admitted where the workspace also has `guest_access_enabled`, which is checked when the session is minted and again on every guest request. Possible values: viewer, publisher, guest, anonymous"
},
"on_behalf_of": {
"type": "string"
"type": "string",
"description": "The user or group the app runs as in anonymous or publisher mode (e.g. 'u/admin' or 'g/mygroup'). The authority for the app's identity."
},
"on_behalf_of_email": {
"type": "string"
"type": "string",
"description": "Address of `on_behalf_of`, written through from it on every save and returned as stored. Optional; when absent it is derived from `on_behalf_of`. Sending it is optional too; it must name the same account as `on_behalf_of`, and a pair that disagrees is rejected."
},
"sandbox": {
"type": "boolean",
@@ -1397,7 +1401,7 @@ is, a different one moves it there and archives the old path"),
"items": {
"type": "string"
},
"description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true — an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers mint (after consenting) a short-lived token carrying their own identity restricted to these scopes, handed to the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read).\n"
"description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true — an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers mint (after consenting) a short-lived token carrying their own identity restricted to these scopes, handed to the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read, flow_conversations:read, flow_conversations:write).\n"
}
}
},
@@ -924,7 +924,7 @@ pub(crate) async fn tarball_workspace(
if !skip_resource_types.unwrap_or(false) {
let resource_types = sqlx::query_as!(
ResourceType,
"SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset FROM resource_type WHERE workspace_id = $1",
"SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset, display_name FROM resource_type WHERE workspace_id = $1",
&w_id
)
.fetch_all(&mut *tx)
@@ -1587,10 +1587,10 @@ pub(crate) async fn tarball_workspace(
// Use v2 format only if explicitly requested, otherwise use v1 (legacy) for backward compatibility
// Server-owned state (the HMAC webhook secret + hook id/error, the
// synced-sha / last-pull status, and what the credential check observed)
// must never leave the server: keep it out of export archives and synced
// repos, and don't let a re-imported workspace inherit another install's
// hook/sync state. Mirrors the GET-settings redaction.
// synced-sha / last-pull status, the admin automatic pulls run as, and what
// the credential check observed) must never leave the server: keep it out of
// export archives and synced repos, and don't let a re-imported workspace
// inherit another install's hook/sync state or pull identity.
fn redact_git_sync_for_export(git_sync: Option<Value>) -> Option<Value> {
let mut git_sync = git_sync?;
if let Some(repos) = git_sync
@@ -1607,6 +1607,7 @@ pub(crate) async fn tarball_workspace(
"webhook_error",
"last_synced_sha",
"last_pull_status",
"enabled_by",
] {
auto_pull.remove(field);
}
@@ -577,6 +577,7 @@ pub const ENV_SETTINGS: &[&str] = &[
"OTEL_RESOURCE_ATTRIBUTES",
"OTEL_JOB_LOGS",
"OTEL_TRACES_RETENTION_SECS",
"AI_SHARED_ARTIFACT_RETENTION_SECS",
"DISABLE_S3_STORE",
"PG_SCHEMA",
"PG_LISTENER_REFRESH_PERIOD_SECS",
+17
View File
@@ -155,6 +155,7 @@ pub const DEFAULT_HUB_BASE_URL: &str = "https://hub.windmill.dev";
pub const PRIVATE_HUB_MIN_VERSION: i32 = 10_000_000;
pub const DEFAULT_SERVICE_LOG_RETENTION_SECS: i64 = 60 * 60 * 24 * 14; // 2 weeks retention period for logs
pub const DEFAULT_OTEL_TRACES_RETENTION_SECS: i64 = 60 * 60 * 24 * 7; // 1 week retention period for HTTP request spans
pub const DEFAULT_AI_SHARED_ARTIFACT_RETENTION_SECS: i64 = 60 * 60 * 24 * 30;
pub const WM_DEPLOYERS_GROUP: &str = "wm_deployers";
/// A century. Every consumer has to survive `now - retention`, and the ceilings are much lower
@@ -233,6 +234,13 @@ pub fn service_log_retention_secs() -> i64 {
SERVICE_LOG_RETENTION_SECS.load(std::sync::atomic::Ordering::Relaxed)
}
/// How long a shared AI session artifact stays viewable, in seconds, counted from the last time
/// its author shared it. Read by both the API, which stops serving an expired share, and the
/// monitor, which deletes it — so both must agree, which is why they share this one reader.
pub fn ai_shared_artifact_retention_secs() -> i64 {
*AI_SHARED_ARTIFACT_RETENTION_SECS
}
/// Canonical form of a base URL, used as one of the inputs to the offline-license
/// instance hash (`compute_instance_hash`).
///
@@ -480,6 +488,15 @@ lazy_static::lazy_static! {
/// [`set_otel_traces_retention_secs`] is the only writer, [`otel_traces_retention_secs`] the
/// only reader.
static ref OTEL_TRACES_RETENTION_SECS: AtomicI64 = AtomicI64::new(DEFAULT_OTEL_TRACES_RETENTION_SECS);
/// Read it with [`ai_shared_artifact_retention_secs`].
static ref AI_SHARED_ARTIFACT_RETENTION_SECS: i64 = clamp_retention_secs(
std::env::var("AI_SHARED_ARTIFACT_RETENTION_SECS")
.ok()
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(DEFAULT_AI_SHARED_ARTIFACT_RETENTION_SECS),
DEFAULT_AI_SHARED_ARTIFACT_RETENTION_SECS,
"AI shared artifact",
);
pub static ref MONITOR_LOGS_ON_OBJECT_STORE: AtomicBool = AtomicBool::new(false);
+34 -3
View File
@@ -17,6 +17,23 @@ lazy_static::lazy_static! {
pub static ref VALID_USERNAME: Regex = Regex::new(r#"^[a-zA-Z][a-zA-Z_0-9]*$"#).unwrap();
}
/// Width of the `username` columns of `usr`, `password` and `pending_user`.
pub const USERNAME_MAX_LEN: usize = 50;
/// `base` with the collision suffix of `attempt` appended (none for the first attempt), cut
/// to `USERNAME_MAX_LEN`. A local part longer than the column is a valid email, and an
/// insert that fails on the derived username rolls back everything around it.
pub fn fit_username(base: &str, attempt: u32) -> String {
let suffix = if attempt > 1 {
attempt.to_string()
} else {
String::new()
};
let mut username: String = base.chars().take(USERNAME_MAX_LEN - suffix.len()).collect();
username.push_str(&suffix);
username
}
pub async fn generate_instance_wide_unique_username<'c>(
tx: &mut Transaction<'c, Postgres>,
email: &str,
@@ -41,9 +58,7 @@ pub async fn generate_instance_wide_unique_username<'c>(
email
)));
}
if i > 1 {
username = format!("{}{}", base_username, i)
}
username = fit_username(&base_username, i);
username_conflict = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM usr WHERE username = $1 and email != $2 UNION SELECT 1 FROM password WHERE username = $1 UNION SELECT 1 FROM pending_user WHERE username = $1)",
&username,
@@ -164,3 +179,19 @@ pub async fn get_instance_username_or_create_pending<'c>(
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fit_username_keeps_the_column_width() {
assert_eq!(fit_username("alice", 1), "alice");
assert_eq!(fit_username("alice", 2), "alice2");
let base = "a".repeat(60);
assert_eq!(fit_username(&base, 1), "a".repeat(USERNAME_MAX_LEN));
let with_suffix = fit_username(&base, 1000);
assert_eq!(with_suffix.len(), USERNAME_MAX_LEN);
assert!(with_suffix.ends_with("1000"));
}
}
+36
View File
@@ -13,6 +13,35 @@ lazy_static::lazy_static! {
pub static ref VALID_EMAIL: regex::Regex = regex::Regex::new(
r"^[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*@([A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?\.)+[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?$"
).unwrap();
}
/// Width of the `email` columns of `usr`, `workspace_invite` and `email_to_igroup`.
pub const EMAIL_COLUMN_MAX_LEN: usize = 255;
/// The regex of the `proper_email` CHECK constraint on `usr` and `workspace_invite`
/// (`20220620210708_regex_fix`), verbatim, for [`usr_accepts_email`]. Evaluated by the
/// database and never by a Rust engine: `~*` folds case under the database collation, so a
/// fixed mirror accepts addresses the constraint rejects, or rejects ones it holds, on some
/// locale. `windmill-common/tests/usr_accepts_email.rs` pins the text to the constraint.
pub const PROPER_EMAIL_PATTERN: &str = r#"^(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])$"#;
/// Whether `usr` (and `workspace_invite`) will store `email`: the `proper_email` regex as the
/// database evaluates it, plus the column width. Unlike [`VALID_EMAIL`] this admits every
/// address those tables already hold, which matters wherever an existing member is judged.
pub async fn usr_accepts_email<'c, E>(db: E, email: &str) -> crate::error::Result<bool>
where
E: sqlx::Executor<'c, Database = sqlx::Postgres>,
{
if email.contains('\0') || email.chars().count() > EMAIL_COLUMN_MAX_LEN {
return Ok(false);
}
let accepted: bool = sqlx::query_scalar("SELECT $1::text ~* $2::text")
.bind(email)
.bind(PROPER_EMAIL_PATTERN)
.fetch_one(db)
.await?;
Ok(accepted)
}
pub const SUPERADMIN_SECRET_EMAIL: &str = "superadmin_secret@windmill.dev";
@@ -21,6 +50,13 @@ pub const SUPERADMIN_SYNC_EMAIL: &str = "superadmin_sync@windmill.dev";
pub const COOKIE_NAME: &str = "token";
/// `password.login_type` of an account created for someone before they have signed in:
/// no credential of its own (password login and reset require `'password'`), reachable
/// only through a superadmin-minted login link until either `set_password` turns it into
/// a password account or the first OAuth login proving the same address adopts it and
/// rewrites `login_type` to the provider.
pub const PENDING_OAUTH_LOGIN_TYPE: &str = "pending_oauth";
/// Prefix for user-based permissioned_as values: "u/"
pub const PERMISSIONED_AS_USER_PREFIX: &str = "u/";
/// Prefix for group-based permissioned_as values: "g/"
+49 -17
View File
@@ -1125,19 +1125,35 @@ use tokio::time::{self, Duration, Sleep};
use pin_project_lite::pin_project;
/// What a [`WarnAfterFuture`] is timing, which decides how its warning reads.
pub enum WarnSubject {
/// A database query, with the SQL when the caller has it.
Query(Option<String>),
/// Anything else (a child process, a cache transfer), named for the log line.
Step(String),
}
pub trait WarnAfterExt: Future + Sized {
/// Warns if the future takes longer than the specified number of seconds to complete.
#[track_caller]
fn warn_after_seconds(self, seconds: u8) -> WarnAfterFuture<Self> {
let caller = Location::caller();
self.build_from_caller(seconds, caller, None)
self.build_from_caller(seconds, caller, WarnSubject::Query(None))
}
/// Same, for a step that is not a database query (a child process, a cache transfer):
/// the warning names `step` instead of reporting a slow query.
#[track_caller]
fn warn_after_seconds_for(self, seconds: u8, step: &str) -> WarnAfterFuture<Self> {
let caller = Location::caller();
self.build_from_caller(seconds, caller, WarnSubject::Step(step.to_string()))
}
fn build_from_caller(
self,
seconds: u8,
caller: &Location,
sql: Option<String>,
subject: WarnSubject,
) -> WarnAfterFuture<Self> {
let location = format!("{}:{}", caller.file(), caller.line());
WarnAfterFuture {
@@ -1147,13 +1163,13 @@ pub trait WarnAfterExt: Future + Sized {
start_time: std::time::Instant::now(),
location,
seconds,
sql,
subject,
}
}
#[track_caller]
fn warn_after_seconds_with_sql(self, seconds: u8, sql: String) -> WarnAfterFuture<Self> {
let caller = Location::caller();
self.build_from_caller(seconds, caller, Some(sql))
self.build_from_caller(seconds, caller, WarnSubject::Query(Some(sql)))
}
}
@@ -1171,7 +1187,7 @@ pin_project! {
location: String,
start_time: std::time::Instant,
seconds: u8,
sql: Option<String>,
subject: WarnSubject,
}
}
@@ -1191,12 +1207,20 @@ impl<F: Future> Future for WarnAfterFuture<F> {
// Poll the timeout future to check if it has elapsed.
if !*this.warned {
if this.timeout.poll(cx).is_ready() {
tracing::warn!(
location = this.location,
"SLOW_QUERY: query {} to db taking longer than expected (> {} seconds)",
build_query_string(&this.location, this.sql.as_deref()),
this.seconds,
);
match &*this.subject {
WarnSubject::Step(step) => tracing::warn!(
location = this.location,
"SLOW_STEP: {step} at {} taking longer than expected (> {} seconds)",
this.location,
this.seconds,
),
WarnSubject::Query(sql) => tracing::warn!(
location = this.location,
"SLOW_QUERY: query {} to db taking longer than expected (> {} seconds)",
build_query_string(&this.location, sql.as_deref()),
this.seconds,
),
}
*this.warned = true;
}
}
@@ -1206,12 +1230,20 @@ impl<F: Future> Future for WarnAfterFuture<F> {
Poll::Ready(output) => {
if *this.warned {
let elapsed = this.start_time.elapsed();
tracing::warn!(
location = this.location,
"SLOW_QUERY: completed query {} with total duration: {:.2?}",
build_query_string(&this.location, this.sql.as_deref()),
elapsed
);
match &*this.subject {
WarnSubject::Step(step) => tracing::warn!(
location = this.location,
"SLOW_STEP: {step} at {} completed with total duration: {:.2?}",
this.location,
elapsed
),
WarnSubject::Query(sql) => tracing::warn!(
location = this.location,
"SLOW_QUERY: completed query {} with total duration: {:.2?}",
build_query_string(&this.location, sql.as_deref()),
elapsed
),
}
}
Poll::Ready(output)
}
+202
View File
@@ -8,11 +8,17 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sqlx::{Postgres, Transaction};
use std::sync::atomic::AtomicBool;
use uuid::Uuid;
use crate::error::{self, Error};
use crate::DB;
/// Set when a child completion brought a parked WAC parent's `suspend` counter to
/// zero, so a worker's pull loop tries the suspended-jobs query first instead of
/// waiting for its next periodic attempt.
pub static WAC_SUSPEND_READY: AtomicBool = AtomicBool::new(false);
/// Checkpoint state persisted across workflow invocations.
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
pub struct WacCheckpoint {
@@ -672,3 +678,199 @@ pub async fn persist_inline_checkpoint_delta(
Ok(failure)
}
/// The step key a WAC v2 parent is waiting on `child_job` for, if any.
///
/// `job_ids` is the parent's `pending_steps.job_ids` (step key → child job id).
/// A child absent from it is not a step this round is waiting on: a completion
/// arriving after the parent re-dispatched the key to a new job, or a child the
/// body launched directly (`runScript` and friends).
fn pending_step_key(job_ids: &Value, child_job: &Uuid) -> Option<String> {
let child = child_job.to_string();
job_ids
.as_object()?
.iter()
.find_map(|(key, id)| (id.as_str() == Some(child.as_str())).then(|| key.clone()))
}
/// Record a completed child on its WAC parent, in the child's completion transaction.
///
/// Every path that brings a child to a terminal state — a worker's result, the
/// zombie monitor, a cancel — completes it through `add_completed_job`, and this is
/// where the parent learns of it. For a child the parent is parked on, the step's
/// result (or failure record) is merged into the checkpoint's `completed_steps` and
/// the parent's `suspend` counter drops by one, atomically with the child's own
/// completion: there is no window in which the child is completed but the parent
/// still waits for it. For any other child, only the timeline entry is stamped.
///
/// Returns whether the counter reached zero, i.e. the parent is ready to be pulled.
///
/// Exactly once: the merge is refused when `completed_steps` already holds the key
/// or `job_ids` no longer maps it to this child, and the decrement follows only a
/// merge that happened. Two completions of one child (a worker and the monitor
/// racing) therefore decrement once, and a stale completion never touches a
/// counter that belongs to a later round.
///
/// Lock order: the parent's queue row, then its status row, then (by the caller)
/// the child's queue row. A cancel walks parent then children, the parent's own
/// completion deletes its queue row and cascades to its status row, and the park
/// (`suspend_wac_parent`) locks the queue row before writing the checkpoint, so
/// any other order can deadlock against one of them.
///
/// Authorization: none is checked here. `child_job` is the job the caller is
/// completing, which it already holds, and `parent_job` must be that job's
/// persisted `v2_job.parent_job` (both callers read it from the child's row).
/// The read that gates the step merge and the counter decrement joins on that
/// relationship, so a mismatched pair changes no parent's `completed_steps` or
/// `suspend`; only the timeline stamp at the end runs unconditionally. Job ids
/// are global, so no workspace scoping is needed on top.
pub async fn record_child_completion(
tx: &mut Transaction<'_, Postgres>,
parent_job: &Uuid,
child_job: &Uuid,
success: bool,
duration_ms: i64,
result: &str,
) -> error::Result<bool> {
let job_ids: Option<Option<Value>> = sqlx::query_scalar(
"SELECT s.workflow_as_code_status->'_checkpoint'->'pending_steps'->'job_ids' \
FROM v2_job_status s JOIN v2_job c ON c.parent_job = s.id \
WHERE s.id = $1 AND c.id = $2",
)
.bind(parent_job)
.bind(child_job)
.fetch_optional(&mut **tx)
.await
.map_err(|e| Error::internal_err(format!("Failed to read WAC parent {parent_job}: {e}")))?;
let step_key = job_ids
.flatten()
.and_then(|ids| pending_step_key(&ids, child_job));
let mut parent_ready = false;
if let Some(step_key) = step_key {
let parked: Option<i32> =
sqlx::query_scalar("SELECT suspend FROM v2_job_queue WHERE id = $1 FOR UPDATE")
.bind(parent_job)
.fetch_optional(&mut **tx)
.await
.map_err(|e| {
Error::internal_err(format!("Failed to lock WAC parent {parent_job}: {e}"))
})?;
if parked.is_some() {
let step_value = if success {
result.to_string()
} else {
let raw: Value = serde_json::from_str(result).unwrap_or(Value::Null);
wac_failure_record(&step_key, Some(&child_job.to_string()), &raw).to_string()
};
let merged: Option<i32> = sqlx::query_scalar(
"UPDATE v2_job_status SET workflow_as_code_status = jsonb_set(
workflow_as_code_status,
'{_checkpoint,completed_steps}',
COALESCE(workflow_as_code_status->'_checkpoint'->'completed_steps', '{}'::jsonb)
|| jsonb_build_object($2::text, $3::text::jsonb)
) WHERE id = $1
AND workflow_as_code_status->'_checkpoint'->'pending_steps'->'job_ids'->>$2 = $4
AND NOT COALESCE(workflow_as_code_status->'_checkpoint'->'completed_steps' ? $2, false)
RETURNING 1",
)
.bind(parent_job)
.bind(&step_key)
.bind(&step_value)
.bind(child_job.to_string())
.fetch_optional(&mut **tx)
.await
.map_err(|e| {
Error::internal_err(format!("Failed to add WAC completed step: {e}"))
})?;
if merged.is_some() {
// `suspend_until` stays set: the suspended pull query is what takes a
// parked parent back, and it selects on `suspend_until IS NOT NULL`.
let suspend: Option<i32> = sqlx::query_scalar(
"UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) \
WHERE id = $1 RETURNING suspend",
)
.bind(parent_job)
.fetch_optional(&mut **tx)
.await
.map_err(|e| Error::internal_err(format!("Failed to unsuspend WAC parent: {e}")))?;
parent_ready = suspend == Some(0);
if parent_ready {
sqlx::query(
"UPDATE v2_job_status SET workflow_as_code_status = \
workflow_as_code_status #- '{_checkpoint,pending_steps}' WHERE id = $1",
)
.bind(parent_job)
.execute(&mut **tx)
.await
.map_err(|e| {
Error::internal_err(format!("Failed to clear WAC pending steps: {e}"))
})?;
}
}
tracing::info!(
parent_job = %parent_job,
child_job = %child_job,
step_key = %step_key,
success,
recorded = merged.is_some(),
parent_ready,
"WAC v2 child job completed"
);
}
}
// The child's entry in the parent's timeline, keyed by child id. The parent may
// already be completed (cancelled with its children still running), in which
// case the entry lives on its completed row instead. Errors propagate: a failed
// statement has already aborted the transaction, so there is nothing to continue with.
let stamped: Option<i32> = sqlx::query_scalar(
"UPDATE v2_job_status SET workflow_as_code_status = jsonb_set(
jsonb_set(
workflow_as_code_status,
ARRAY[$1],
COALESCE(workflow_as_code_status->$1, '{}'::jsonb)
),
ARRAY[$1, 'duration_ms'],
to_jsonb($2::bigint)
) WHERE id = $3 AND workflow_as_code_status IS NOT NULL RETURNING 1",
)
.bind(child_job.to_string())
.bind(duration_ms)
.bind(parent_job)
.fetch_optional(&mut **tx)
.await
.map_err(|e| {
Error::internal_err(format!(
"Could not update parent job `duration_ms` in workflow as code status: {e}"
))
})?;
if stamped.is_none() {
sqlx::query(
"UPDATE v2_job_completed SET workflow_as_code_status = jsonb_set(
jsonb_set(
workflow_as_code_status,
ARRAY[$1],
COALESCE(workflow_as_code_status->$1, '{}'::jsonb)
),
ARRAY[$1, 'duration_ms'],
to_jsonb($2::bigint)
) WHERE id = $3 AND workflow_as_code_status IS NOT NULL",
)
.bind(child_job.to_string())
.bind(duration_ms)
.bind(parent_job)
.execute(&mut **tx)
.await
.map_err(|e| {
Error::internal_err(format!(
"Could not update completed parent job `duration_ms` in workflow as code status: {e}"
))
})?;
}
Ok(parent_ready)
}
+8 -1
View File
@@ -184,7 +184,7 @@ pub enum ObjectType {
DatatableMigration,
}
pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28958/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
@@ -515,6 +515,11 @@ pub struct AutoPullSettings {
pub last_synced_sha: std::collections::HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_pull_status: Option<AutoPullStatus>,
/// Email of the admin this repository's automatic pulls (its own and its forks')
/// apply changes as: whoever last saved the settings with auto pull on. Stamped
/// server-side, never taken from the client.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled_by: Option<String>,
}
// Manual Debug so the HMAC `webhook_secret` (even encrypted) never lands in logs.
@@ -525,6 +530,7 @@ impl std::fmt::Debug for AutoPullSettings {
.field("mode", &self.mode)
.field("poll_interval_s", &self.poll_interval_s)
.field("sync_forks", &self.sync_forks)
.field("enabled_by", &self.enabled_by)
.field("webhook_id", &self.webhook_id)
.field(
"webhook_secret",
@@ -3339,6 +3345,7 @@ mod tests {
webhook_secret: None,
webhook_url: None,
webhook_error: None,
enabled_by: None,
last_synced_sha: synced
.iter()
.map(|(r, s)| (r.to_string(), s.to_string()))
@@ -0,0 +1,59 @@
//! `usr_accepts_email` predicts whether `usr` will store an address by evaluating
//! `PROPER_EMAIL_PATTERN` in the database. It only stays right while that text matches the
//! `proper_email` constraint and the width matches the column: each sample below must be
//! stored by `usr` exactly when the check accepts it, and everything `VALID_EMAIL` accepts
//! within the width must be stored too.
use sqlx::{Pool, Postgres};
use windmill_common::users::{usr_accepts_email, EMAIL_COLUMN_MAX_LEN, VALID_EMAIL};
#[sqlx::test(migrations = "../migrations")]
async fn usr_accepts_email_agrees_with_the_constraint(db: Pool<Postgres>) -> anyhow::Result<()> {
let domain = "@example.com";
let widest = format!(
"{}{domain}",
"a".repeat(EMAIL_COLUMN_MAX_LEN - domain.len())
);
let too_wide = format!("a{widest}");
for email in [
"alice@example.com",
"Alice@Example.COM",
"alice.bob+tag@sub.example.co.uk",
"\"quoted\"@example.com",
"\"quoted local\"@example.com",
"alice@[192.168.0.1]",
widest.as_str(),
too_wide.as_str(),
"ef40ea04-1a9e-4a84-9e65-cb1baa81dfed",
// Unicode case folding would map the long s and the Kelvin sign into `[a-z]`.
"u\u{17f}er@example.com",
"alice@example\u{212a}.com",
"alice",
"alice@example",
"alice@@example.com",
"alice @example.com",
"alice@example.com\nbob@example.com",
"",
] {
let mut tx = db.begin().await?;
let stored = sqlx::query(
"INSERT INTO usr (workspace_id, username, email, is_admin, operator)
VALUES ('admins', 'probe', $1, false, false)",
)
.bind(email)
.execute(&mut *tx)
.await
.is_ok();
tx.rollback().await?;
assert_eq!(
stored,
usr_accepts_email(&db, email).await?,
"{email:?}: `usr` and usr_accepts_email disagree"
);
if VALID_EMAIL.is_match(email) && email.len() <= EMAIL_COLUMN_MAX_LEN {
assert!(stored, "{email:?}: VALID_EMAIL accepts what `usr` rejects");
}
}
Ok(())
}
+3 -2
View File
@@ -9,8 +9,8 @@ name = "windmill_git_sync"
path = "./src/lib.rs"
[features]
private = ["windmill-common/private", "windmill-dep-map/private"]
enterprise = ["windmill-queue/enterprise", "windmill-common/enterprise"]
private = ["windmill-common/private", "windmill-dep-map/private", "windmill-audit/private"]
enterprise = ["windmill-queue/enterprise", "windmill-common/enterprise", "windmill-audit/enterprise"]
all_sqlx_features = ["enterprise"]
default = []
@@ -23,5 +23,6 @@ tracing.workspace = true
windmill-common = { workspace = true, default-features = false }
windmill-queue.workspace = true
windmill-dep-map.workspace = true
windmill-audit.workspace = true
regex = "1.10.3"
tokio = { workspace = true, features = ["full"] }
+26 -68
View File
@@ -983,7 +983,7 @@ pub async fn add_completed_job<T: Serialize + Send + Sync + ValidableJson>(
flow_is_done: bool,
duration: Option<i64>,
from_cache: bool,
) -> Result<(Uuid, i64, Option<serde_json::Value>), Error> {
) -> Result<(Uuid, i64), Error> {
// tracing::error!("Start");
// let start = tokio::time::Instant::now();
@@ -1017,7 +1017,7 @@ pub async fn add_completed_job<T: Serialize + Send + Sync + ValidableJson>(
};
let result_columns = result_columns.as_ref();
let (opt_uuid, duration, _skip_downstream_error_handlers, wac_job_ids) = (|| {
let (opt_uuid, duration, _skip_downstream_error_handlers, wac_parent_ready) = (|| {
commit_completed_job(
db,
completed_job,
@@ -1052,9 +1052,13 @@ pub async fn add_completed_job<T: Serialize + Send + Sync + ValidableJson>(
.sleep(tokio::time::sleep)
.await?;
if wac_parent_ready {
windmill_common::wac::WAC_SUSPEND_READY.store(true, std::sync::atomic::Ordering::Relaxed);
}
// if scheduling next job failed, return the job_id early to ensure the job get retried after a timeout
if let Some(job_id) = opt_uuid {
return Ok((job_id, duration, None));
return Ok((job_id, duration));
}
// Auto-resolve a retry chain that ultimately worked, from whichever of the two
@@ -1101,7 +1105,7 @@ pub async fn add_completed_job<T: Serialize + Send + Sync + ValidableJson>(
// tracing::error!("4 {:?}", start.elapsed());
Ok((completed_job.id, duration, wac_job_ids))
Ok((completed_job.id, duration))
}
async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
@@ -1119,7 +1123,7 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
// True when a native script retry was enqueued for this failed attempt, i.e.
// this is not the terminal attempt — schedule completion handlers must wait.
retry_pending: bool,
) -> windmill_common::error::Result<(Option<Uuid>, i64, bool, Option<serde_json::Value>)> {
) -> windmill_common::error::Result<(Option<Uuid>, i64, bool, bool)> {
// let start = std::time::Instant::now();
let job_id = completed_job.id;
@@ -1249,74 +1253,23 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
.map_err(|e| Error::InternalErr(format!("Could not update job labels: {e:#}")))?;
}
let mut wac_job_ids: Option<serde_json::Value> = None;
// Before `delete_job`: the parent's rows are locked ahead of the child's own
// queue row (see `record_child_completion` for the order this must keep).
let mut wac_parent_ready = false;
if !completed_job.is_flow_step() {
if let Some(parent_job) = completed_job.parent_job {
// Only update WAC parents (v1 or v2). The WHERE condition skips
// non-WAC parents entirely (error handlers, run_script children, etc.).
// Also returns pending_steps.job_ids so WAC v2 child completion
// doesn't need a separate read.
let row = sqlx::query_scalar!(
r#"UPDATE v2_job_status SET
workflow_as_code_status = jsonb_set(
jsonb_set(
workflow_as_code_status,
array[$1],
COALESCE(workflow_as_code_status->$1, '{}'::jsonb)
),
array[$1, 'duration_ms'],
to_jsonb($2::bigint)
)
WHERE id = $3 AND workflow_as_code_status IS NOT NULL
RETURNING workflow_as_code_status->'_checkpoint'->'pending_steps'->'job_ids' AS "job_ids: serde_json::Value""#,
&completed_job.id.to_string(),
wac_parent_ready = windmill_common::wac::record_child_completion(
&mut tx,
&parent_job,
&completed_job.id,
success,
duration,
parent_job
sanitized_result.as_ref(),
)
.fetch_optional(&mut *tx)
.warn_after_seconds(10)
.await
.inspect_err(|e| {
tracing::error!(
"Could not update parent job `duration_ms` in workflow as code status: {}",
e,
)
})
.ok()
.flatten();
wac_job_ids = row.flatten();
// If parent was already completed (e.g. cancelled), update v2_job_completed instead
if wac_job_ids.is_none() {
let _ = sqlx::query!(
r#"UPDATE v2_job_completed SET
workflow_as_code_status = jsonb_set(
jsonb_set(
workflow_as_code_status,
array[$1],
COALESCE(workflow_as_code_status->$1, '{}'::jsonb)
),
array[$1, 'duration_ms'],
to_jsonb($2::bigint)
)
WHERE id = $3 AND workflow_as_code_status IS NOT NULL"#,
&completed_job.id.to_string(),
duration,
parent_job
)
.execute(&mut *tx)
.warn_after_seconds(10)
.await
.inspect_err(|e| {
tracing::error!(
"Could not update completed parent job `duration_ms` in workflow as code status: {}",
e,
)
});
}
.await?;
}
}
// tracing::error!("Added completed job {:#?}", queued_job);
let mut _skip_downstream_error_handlers = false;
tx = delete_job(tx, &job_id).warn_after_seconds(10).await?;
@@ -1544,14 +1497,19 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
completed_job.id
);
// tracing::info!("completed job: {:?}", start.elapsed().as_micros());
Ok((None, duration, _skip_downstream_error_handlers, wac_job_ids))
Ok((
None,
duration,
_skip_downstream_error_handlers,
wac_parent_ready,
))
}
async fn check_result_size<T: ValidableJson>(
db: &Pool<Postgres>,
queued_job: &MiniCompletedJob,
result: Json<&T>,
) -> Option<Result<(Option<Uuid>, i64, bool, Option<serde_json::Value>), Error>> {
) -> Option<Result<(Option<Uuid>, i64, bool, bool), Error>> {
let result_size = result.size() / 1024 / 1024;
if result_size > 2 {
if result_size > *MAX_RESULT_SIZE_MB {
+38 -4
View File
@@ -118,6 +118,10 @@ pub struct ResourceType {
pub edited_at: Option<chrono::DateTime<chrono::Utc>>,
pub format_extension: Option<String>,
pub is_fileset: bool,
/// The name the product goes by (`gsheets` is "Google Sheets"), null where nobody named it.
/// Skipped when absent, so the type files of a synced repo gain nothing until one is set.
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
}
#[derive(Deserialize)]
@@ -127,6 +131,7 @@ pub struct CreateResourceType {
pub description: Option<String>,
pub format_extension: Option<String>,
pub is_fileset: Option<bool>,
pub display_name: Option<String>,
}
#[derive(Deserialize)]
@@ -143,6 +148,13 @@ pub struct EditResourceType {
deserialize_with = "windmill_common::more_serde::double_option"
)]
pub format_extension: Option<Option<String>>,
/// Doubly optional for the same reason. A push from a CLI that predates the field omits it,
/// and must not clear a name the hub set.
#[serde(
default,
deserialize_with = "windmill_common::more_serde::double_option"
)]
pub display_name: Option<Option<String>>,
}
#[derive(FromRow, Serialize, Deserialize)]
@@ -2729,7 +2741,7 @@ async fn list_resource_types(
) -> JsonResult<Vec<ResourceType>> {
let rows = sqlx::query_as!(
ResourceType,
"SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset from resource_type WHERE (workspace_id = $1 OR workspace_id = 'admins') ORDER \
"SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset, display_name from resource_type WHERE (workspace_id = $1 OR workspace_id = 'admins') ORDER \
BY name",
&w_id
)
@@ -3109,7 +3121,7 @@ async fn get_resource_type(
let resource_type_o = sqlx::query_as!(
ResourceType,
"SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset from resource_type WHERE name = $1 AND (workspace_id = $2 OR workspace_id = 'admins')",
"SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset, display_name from resource_type WHERE name = $1 AND (workspace_id = $2 OR workspace_id = 'admins')",
&name,
&w_id
)
@@ -3137,6 +3149,20 @@ async fn exists_resource_type(
Ok(Json(exists))
}
/// Trimmed, blank as none, and held to the column's 100 characters, so an over-long name is
/// refused with a message rather than a database error.
fn normalize_display_name(name: Option<&str>) -> Result<Option<String>> {
let Some(name) = name.map(str::trim).filter(|n| !n.is_empty()) else {
return Ok(None);
};
if name.chars().count() > 100 {
return Err(Error::BadRequest(
"display_name must be at most 100 characters".to_string(),
));
}
Ok(Some(name.to_string()))
}
async fn create_resource_type(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -3170,11 +3196,12 @@ async fn create_resource_type(
"A fileset resource type cannot have a format_extension".to_string(),
));
}
let display_name = normalize_display_name(resource_type.display_name.as_deref())?;
sqlx::query!(
"INSERT INTO resource_type
(workspace_id, name, schema, description, created_by, format_extension, is_fileset, edited_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, now())",
(workspace_id, name, schema, description, created_by, format_extension, is_fileset, display_name, edited_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())",
w_id,
resource_type.name,
resource_type.schema,
@@ -3182,6 +3209,7 @@ async fn create_resource_type(
authed.username,
resource_type.format_extension,
is_fileset,
display_name,
)
.execute(&mut *tx)
.await?;
@@ -3349,6 +3377,12 @@ async fn update_resource_type(
None => sqlb.set("format_extension", "NULL"),
};
}
if let Some(display_name) = &ns.display_name {
match normalize_display_name(display_name.as_deref())? {
Some(name) => sqlb.set_str("display_name", name),
None => sqlb.set("display_name", "NULL"),
};
}
sqlb.set_str("edited_at", "now()");
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
let mut tx = user_db.begin(&authed).await?;

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