mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
{
|
||||
".": "1.811.1"
|
||||
".": "1.812.0"
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
+41
@@ -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"
|
||||
}
|
||||
+18
@@ -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"
|
||||
}
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
-26
@@ -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"
|
||||
}
|
||||
+20
@@ -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"
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
-16
@@ -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"
|
||||
}
|
||||
+12
-4
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+28
@@ -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"
|
||||
}
|
||||
+9
-3
@@ -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"
|
||||
}
|
||||
+4
-2
@@ -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"
|
||||
}
|
||||
+9
-3
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+28
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+9
-3
@@ -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"
|
||||
}
|
||||
+34
@@ -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"
|
||||
}
|
||||
+9
-3
@@ -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"
|
||||
}
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
+47
@@ -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"
|
||||
}
|
||||
+66
@@ -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"
|
||||
}
|
||||
-18
@@ -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"
|
||||
}
|
||||
+34
@@ -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"
|
||||
}
|
||||
-14
@@ -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"
|
||||
}
|
||||
+24
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
-22
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
-24
@@ -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"
|
||||
}
|
||||
+55
@@ -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"
|
||||
}
|
||||
+4
-3
@@ -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"
|
||||
}
|
||||
+25
@@ -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"
|
||||
}
|
||||
Generated
+101
-108
@@ -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
@@ -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 @@
|
||||
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
@@ -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
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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');
|
||||
@@ -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}]}');
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -29,4 +29,5 @@ serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sql-builder.workspace = true
|
||||
sqlx.workspace = true
|
||||
tracing.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}"))
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user